12/08/2018, 16:47

7 sai lầm chết người của Ruby developer với metaprogramming

Đối với 1 Ruby developer hay với các ngôn ngữ khác, bạn dành khoảng 90% thời gian cho các hoạt động liên quan tới đọc code và maintain. Với một khoảng thời gian dài dành cho những tác vụ này, điều quan trọng là đảm bảo rằng mọi thứ bạn làm (và code) đều hiệu quả. Trong suốt quá trình đó có lẽ ít ...

Đối với 1 Ruby developer hay với các ngôn ngữ khác, bạn dành khoảng 90% thời gian cho các hoạt động liên quan tới đọc code và maintain. Với một khoảng thời gian dài dành cho những tác vụ này, điều quan trọng là đảm bảo rằng mọi thứ bạn làm (và code) đều hiệu quả. Trong suốt quá trình đó có lẽ ít nhiều các bạn cũng đã nghe về lập trình meta trong Ruby. Lập trình meta với Ruby cực kỳ mạnh mẽ, tuy nhiên sử dụng lập trình meta cũng tiềm ẩn rất nhiều những rủi ro. Nếu bạn xử lí không tốt sẽ làm tăng chi phí bảo trì trong dài hạn. Vì vậy, hôm nay tôi muốn chia sẻ những sai lầm cơ bản mà bạn (đương nhiên tôi cũng đã trải qua và có khá nhiều rắc rối với nó) có thể gặp phải trong quá trình lập trình meta.

Paolo Perrotta, tác giá cuốn sách Metaprogramming Ruby book, nói rằng "method_missing () là một cưa xích: nó mạnh mẽ, nhưng nó cũng có khả năng nguy hiểm." Vậy đâu là cách tốt nhất để sử dụng nó? Câu trả lời là hạn chế sử dụng và chỉ khi nào bạn có đủ khả năng để kiểm soát nó. Hãy để tôi chỉ cho bạn một ví dụ bằng cách sử dụng method_missing để thực hiện Null object pattern cho một order, mà class của bạn sẽ có một phương thức để xử lý các loại tiền tệ khác nhau.

class NullOrder
  def price_euro
    0.0
  end

  def price_usd
    0.0
  end

  # ...
  # more methods needed to handle all other currencies
end

Với những ai đã đọc qua hay biết về meta chúng ta có hai lựa chọn - sử dụng define_method hoặc method_missing, để đỡ phải khai báo nhiều method hơn. Bây giờ hãy thử so sánh sử dụng define_method và method_missing

require 'benchmark'

iterations = 100_000

Benchmark.bm do |bm|
  bm.report('define_method') do
    class NullOrder

      ['usd', 'euro', 'yen'].each do |method|
       define_method "price_#{method}".to_sym do
         0.0
       end
      end

    end
    iterations.times do
      o = NullOrder.new
      o.price_euro
      o.price_usd
    end
  end

  bm.report('method_missing') do

    class NullOrder2
      def method_missing(m, *args, &block)
        m.to_s =~ /price_/ ? 0.0 : super
      end
    end

    iterations.times do
      o2 = NullOrder2.new
      o2.price_euro
      o2.price_usd
    end
  end
end

Outputs:

user system total real
define_method 0.050000 0.000000 0.050000 (0.062126)
method_missing 0.460000 0.000000 0.460000 (0.582257)

Bạn có thể thấy rằng với kết quả trên define_method nhanh gấp 10 lần so với method_missing. Chúng ta cũng không cần nhiều sự linh hoạt để xử lý bất kỳ loại tiền tệ nào. Miễn là chúng ta có thể liệt kê tiền tệ sẽ được xử lý trong code, chúng ta có thể sử dụng define_method để giảm sự trùng lặp phương pháp và đạt được mã hiệu suất tốt hơn.

Có một điều mà các bạn hay bản thân tôi cũng rất hay quên đó là: Bạn nhất thiết phải ghi đè phương thức respond_to_missing? mỗi khi ghi đè phương thức method_missing. Điều này thực sự gây ra một số phiền phức nhất định.

Nếu bạn làm trong 1 Rails project, bạn sẽ làm quen với việc kiểm tra môi trường hiện tại bằng cách sử dụng:

Rails.env.production? thay cho việc sử dụng Rails.env == ‘production’

Vì vậy, chúng ta hãy xem cách thực hiện điều này trong ActiveSupport string_inquirer.rb trong Rails 4.2:

module ActiveSupport
  # Wrapping a string in this class gives you a prettier way to test
  # for equality. The value returned by Rails.env is wrapped
  # in a StringInquirer object so instead of calling this:
  #
  #   Rails.env == 'production'
  #
  # you can call this:
  #
  #   Rails.env.production?
  class StringInquirer < String
    private

      def respond_to_missing?(method_name, include_private = false)
        method_name[-1] == '?'
      end

      def method_missing(method_name, *arguments)
        if method_name[-1] == '?'
          self == method_name[0..-2]
        else
          super
        end
      end
  end
end

Việc kiểm tra việc thực hiện method_missing kiểm tra để đảm bảo phương thức kết thúc bằng một dấu chấm hỏi. Nếu có, nó sẽ chèn dấu chấm hỏi đó từ tên phương thức và so sánh nó với đối tượng hiện tại (giá trị của self). Và nếu chúng giống nhau, nó sẽ trả về đúng và nếu không trả về sai.

Bạn có thể thấy các điều kiện được sử dụng để bẫy một số cuộc gọi (call) là giống như trong respond_to_missing? thực hiện, và đó là chính xác cách chúng ta muốn nó. Nếu bạn không ghi đè answer_to_missing?, đối tượng sẽ không đáp ứng với bất kỳ phương pháp tự động tạo ra. Điều này sẽ gây ngạc nhiên khi các nhà phát triển thử nghiệm với thư viện của bạn trong irb console, và một thư viện tốt hoạt động như mong đợi với rất ít bất ngờ, nếu có.

Trong ví dụ trước, bạn có thể thấy cách Rails sử dụng super để truyền một cuộc gọi mà phương thức hiện tại không biết làm thế nào để xử lý. Trong lớp StringInquirer ở trên, nếu phương thức không kết thúc với một dấu chấm hỏi, thì nó cho phép cuộc gọi được truyền đi lên bằng cách gọi cho super.

Nếu bạn không trở lại super, sau đó nó có thể dẫn bạn đến lỗi mà thực sự khó có thể theo dõi. Hãy nhớ rằng, method_missing là nơi mà các lỗi đi để ẩn. Vì vậy, không quên fallback trên BasicObject # method_missing khi bạn không biết làm thế nào để xử lý một cuộc gọi.

Đây là một ví dụ từ Restclient gem (version 2.0.0.alpha). Trong file bin/restclient, bạn sẽ thấy:

POSSIBLE_VERBS = ['get', 'put', 'post', 'delete']

POSSIBLE_VERBS.each do |m|
  define_method(m.to_sym) do |path, *args, &b|
    r[path].public_send(m.to_sym, *args, &b)
  end
end

def method_missing(s, * args, & b)
  if POSSIBLE_VERBS.include? s
    begin
      r.send(s, *args, & b)
    rescue RestClient::RequestFailed => e
      print STDERR, e.response.body
      raise e
    end
  else
    super
  end
end

Tại sao lại là tội lỗi? Bởi vì bạn đang hy sinh khả năng đọc và hiểu code mà không có bất kì lời ích nào mang lại. Danh sách các verbs HTTP ổn định - hầu như không bao giờ thay đổi. Nhưng bằng cách sử dụng lập trình meta, bạn đã tăng tính phức tạp bằng cách tự động định nghĩa các phương thức cho verbs HTTP. Chúng ta không có bất kì sự thay đổi nào về các vấn đề phương thức ở đây, do đó, không cần cho bất kỳ lập trình meta. Các bạn có thể xem thêm ở đây để hiểu được lí do vì sao chúng ta không nên lạm dụng lập trình meta. Về cơ bản sẽ làm giảm tốc độ của ứng dụng, gây ra sự khó đọc code và khó tìm kiếm hơn.

Bạn nên kiểm tra xem liệu phương thức đã tồn tại trước khi bạn mở một class hiện có và thêm một phương thức. Nếu không, bạn sẽ thay đổi ngữ nghĩa của một phương pháp hiện có do nhầm lẫn (monkey path). Điều này sẽ gây bất ngờ cho người dùng thư viện của bạn. Vì vậy, hãy xem xét một cách kĩ càng các class trên toàn ứng dụng để tránh những lỗi không đáng có. Điều này không chỉ riêng với lập trình meta mà với tất cả mọi thứ.

Một ví dụ tốt cho điều này là đá quý JSON. Nó mở ra các lớp được xây dựng trong Ruby như Range, Rational, Symbol, và như vậy để xác định phương pháp to_json.

Trong một kiến trúc theo class, class dưới cùng có thể phụ thuộc vào nhiều thư viện khác, nằm trên nó. Vì vậy, nó phải là bất khả thi (agnostic) cho bất kỳ class nào ở trên có thể dùng lại được. Có hướng phụ thuộc hướng lên phía trên là sai, và một trong những tội lỗi kinh khủng mà một lập trình viên có thể thực hiện. Mặc dù nó liên quan đến khía cạnhbên trong của Ruby hơn lập trình meta, tôi nghĩ rằng tác động là rất lớn và đáng nói đến.

Tôi đã nhìn thấy lỗi này được thực hiện trên các dự án tôi đã làm việc với khách hàng - các thư viện ở lớp thấp nhất không nên sử dụng được defined? some_constant để xem bối cảnh thực thi trong đó nó đang chạy để thay đổi hành vi. Các thư viện ở lớp thấp nhất phải được độc lập với ngữ cảnh thực thi của chúng. Tuy nhiên, thư viện có thể cung cấp API cho việc sử dụng tùy biến trong một ngữ cảnh cụ thể. Một tùy chọn khác là sử dụng các tệp cấu hình để tùy chỉnh hành vi. Sự phụ thuộc phải theo một hướng, và luôn luôn phải hướng tới sự trừu tượng ổn định.

Sử dụng lập trình meta trong mã của bạn buộc client phải sử dụng quá nhiều khối lồng nhau, và thật không may, bạn có thể thấy nhiều dự án nguồn mở sử dụng RSpec phạm lỗi này. Dưới đây là một ví dụ từ Spree gem mà làm cho nó khó hiểu mã. Đoạn mã sau đây là một phần của backend/spec/controllers/spree/admin/payments_controller_spec.rb

require 'spec_helper'

module Spree
  module Admin
    describe PaymentsController, :type => :controller do
      stub_authorization!

      let(:order) { create(:order) }

      context "order has billing address" do
        before do
          order.bill_address = create(:address)
          order.save!
        end

        context "order does not have payments" do
          it "redirect to new payments page" do
            spree_get :index, { amount: 100, order_id: order.number }
            expect(response).to redirect_to(spree.new_admin_order_payment_path(order))
          end
        end

        context "order has payments" do
          before do
            order.payments << create(:payment, amount: order.total, order: order, state: 'completed')
          end

          it "shows the payments page" do
            spree_get :index, { amount: 100, order_id: order.number }
            expect(response.code).to eq "200"
          end
        end

      end

    end
  end
end

Đây là một tội lỗi vì nó làm tăng bối cảnh bạn cần phải giải thích về một đoạn mã cụ thể. API đơn giản hơn, thì thanh lịch và dễ sử dụng hơn. Ví dụ điển hình là các phương pháp xác nhận của ActiveModel - đây là một trong các tài liệu Rails cho một lớp người:

class Person
  include ActiveModel::Validations

  attr_accessor :name
  validates_presence_of :name
end

Và đây là một ví dụ khác - một cấp lồng trong một tệp routes.rb trong Rails.

resources :articles do
  resources :comments
end

Lập trình meta là vô cùng có giá trị, và có thể giải quyết các vấn đề phức tạp dễ dàng hơn. Nhưng hãy nhớ, nó chỉ có giá trị sử dụng khi có một sự dễ đọc và sự hiểu biết giống nhau để đổi lấy việc giải quyết các vấn đề phức tạp bằng mã ít hơn. Miễn là bạn giữ những lời khuyên trong tâm trí, bạn sẽ thấy mình trở thành một nhà phát triển tốt hơn và hiệu quả hơn trong thời gian không. Hãy cho tôi biết suy nghĩ của bạn về lập trình meta, cùng với bất kỳ lời khuyên nào bạn có trong phần ý kiến dưới đây!

Bài viết trên mình có tham khảo tài đây. Mình cũng đã gặp phỉa hầu hết các lỗi như đã nêu ở trên và cũng đang sửa dần.

Tuy nhiên sử dụng meta đòi hỏi khá nhiều ở vào khả năng đọc code và đưa ra giải pháp hợp lí. Mình có tham khảo một số người pro hơn và có một lời khuyên cho mọi người. "Chúng ta chỉ nên lập trình meta khi viết các thư viện để nó linh động hơn trong các trường hợp. Còn đối với làm các project cụ thể thì việc này nên hạn chế vì nó có thể gây ra chi phí bảo trì rất lớn".

Cảm ơn các bạn đã đọc bài viết của mình!

0