12/08/2018, 16:58

7 Deadly Sins of Ruby Metaprogramming

Với tư cách là một nhà phát triển ruby on rails, bạn dành 90% thời gian cho các hoạt động liên quan tới code như đọc và duy trì code hiện tại. Với một khoảng thời gian dài dành cho những tác vụ này, và đ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 khi metaprogramming ...

Với tư cách là một nhà phát triển ruby on rails, bạn dành 90% thời gian cho các hoạt động liên quan tới code như đọc và duy trì code hiện tại. Với một khoảng thời gian dài dành cho những tác vụ này, và đ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 khi metaprogramming với Ruby có thể cực kỳ mạnh mẽ, nhưng cũng có thể khiến cho việc đọc trở nên khó khăn hay tạo ra sự cân bằng không tốt, cuối cùng 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ó thể bạn đã từng gặp phải trong quá trình làm dự án của mình.

1. Sử dụng method_missing như sự lựa chọn đầu tiên Paolo Perrotta, tác giả của cuốn Metaprogramming Ruby có nói: "Method_missing () là một mắt xích: nó mạnh mẽ, nhưng cũng rất nguy hiểm." Cách tốt nhất để sử dụng nó là gì? Hãy để tôi chỉ cho bạn một ví dụ sử dụng method_missing để thực thi Null Object pattern cho một order, nó sẽ có 1 method để xử lý các 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

Bây giờ để tránh viết nhiều method, chúng ta có 2 lựa chọn: Chúng ta hoặc có thể sử dụng define_method hoặc method_missing. So sánh giữa 2 method này như sau:

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
user system total real
define_method 0.050000 0.000000 0.050000 (0.062126)
method_missing 0.460000 0.000000 0.460000 (0.582257)

Thống kê này chỉ ra rằng define_method nhanh gấp 10 lần sử dụng method_missing. Chúng ta 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 của chúng ta, chúng ta có thể sử dụng define_method để giảm sự trùng lặp và đạt được mã hiệu suất tốt hơn.

2. Không ghi đè respond_to_missing Bạn phải ghi đè lên respond_to_missing? mỗi khi bạn ghi đè method_missing. Nếu bạn đã làm việc trong một dự án Rails, 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 vì 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 thực hiện method_missing kiểm tra để đảm bảo phương pháp kết thúc bằng một dấu 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 bản thân nó). Và nếu chúng giống nhau, nó sẽ trả về true và ngược lại. 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 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 trả lời bất kỳ phương pháp nào được tạo ra tự động. Điều này sẽ gây ngạc nhiên khi các developer thử nghiệm với thư viện của bạn trong console irb, 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ó.

3. Quên để xử lý các trường hợp không rõ Trong ví dụ trước, bạn có thể thấy cách Rails sử dụng super để truyền bá một cuộc gọi mà phương pháp 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 pháp 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 bị ẩn đi. 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.

4. Sử dụng define_method khi nó không cần Đây là một ví dụ từ Restclient gem (phiên bản 2.0.0.alpha). Trong bin / restclient, bạn sẽ tìm 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à lỗi? Bởi vì bạn đang hy sinh khả năng đọc và hiểu của mã mà không trả về cái gì. Danh sách các hoạt động HTTP ổn định - hầu như không bao giờ thay đổi. Nhưng bằng cách sử dụng metaprogramming, 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 HTTP. Chúng ta không có một vụ nổ các vấn đề methods ở đây, do đó, không cần bất kỳ metaprogramming nào ở đây.

5. Thay đổi ngữ nghĩa khi mở các class Bạn nên kiểm tra xem liệu phương pháp đã tồn tại trước khi bạn mở một lớp hiện có và thêm một phương pháp. Nếu bạn 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. Đ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, sàng lọc các class global để giảm bớt ô nhiễm trên global namespace. Một ví dụ tốt cho điều này là JSON gem. Nó mở ra các lớp được xây dựng trong Ruby như Range, Rational, Symbol, và nhiều nữa để xác định phương pháp to_json.

6. Phụ thuộc sai địa chỉ Trong một kiến trúc theo lớp, lớp 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à agnostic cho bất kỳ lớp 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 lỗi kinh khủng mà một lập trình viên có thể gặp. Mặc dù nó liên quan đến khía cạnh của Ruby hơn metaprogramming, 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 define? some_constant để xem bối cảnh thực hiện 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.

7. Có quá nhiều mức lồng nhau Sử dụng metaprogramming trong code 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 mắc lỗi này. Dưới đây là một ví dụ từ Spree gem mà làm cho nó khó hiểu code. Đ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 lỗi vì nó làm tăng ngữ cảnh mà 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 validations của ActiveModel - đây là một trong các tài liệu Rails cho một class person:

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 nhau trong một tệp routes.rb trong Rails.

resources :articles do
  resources :comments
end

Metaprogramming 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ự cân bằng - như khả năng đọc và hiểu để đổi lấy giải quyết các vấn đề phức tạp với ít code hơn. Miễn là bạn giữ những tips 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 xa.

0