7 lỗi chết người của Ruby Metaprogramming

Là một developer, bạn thường dành khoảng 90% thời gian cho việc đọc và bảo trì code có sẵn. Với một khoảng thời gian dài như vậy, việc quan trọng nhất là phải đảm bảo tất cả mọi thứ bạn làm phải thật chính xác và hiệu quả. Meta programming với Ruby thực sự rất mạnh, tuy nhiên việc sử dụng meta programming có thể làm code của chúng ta khó đọc và làm tăng chi phí bảo trì. Vì vậy, bài viết này muốn chia sẻ những sai lầm bạn có thể mắc phải trong project của mình.

Sai lầm 1: Sử dụng method_missing như một lựa chọn đầu tiên

Paolo Perrotta, tác giả của cuốn Metaprogramming Ruby nói:

The method_missing() is a chainsaw: it’s powerful, but it’s also potentially dangerous.

Tạm dịch: **method_missing() ** là một cưa máy, nó rất mạnh mẽ nhưng cũng tiềm tàng nguy hiểm

Vậy làm thế nào để sử dụng chúng một cách tốt nhất ? Chúng ta sẽ sử dụng method_missing để cài đặt Null_object_pattern , mà class gồm 1 method 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

Chúng ta có 2 lựa chọn, dùng define_method hoặc method_missing, để đánh giá, ta sẽ sử dụng benchmark

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

Kết quả

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

Với kết quả này, chúng ta có thể thấy define_method nhanh hơn 10 lần so với method_missing Trong trường hợp này giữa việc linh hoạt trong việc xử lý tiền tệ và hiệu năng của code thì chúng ta nên chọn define_method sẽ tốt hơn.

Sai lầm 2: Không overriding respond_to_missing

Bạn phải override method respond_to_missing? mỗi khi bạn override method_missing. Nếu bạn làm việc với Rails, bạn sẽ quen với việc kiểu tra biến môi trường bằng cách sử dụng: Rails.new.production? thay cho Rails.env == ‘production’

Hãy cùng xem cách cài đặt 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

Bạn có thể thấy điều kiện để sử dụng method_missing giống như trong respond_to_missing?. Đó chính là vấn đề. Nếu bạn không override respond_to_missing?, object sẽ không đáp ứng với bất kỳ method tạo động nào.

Sai lầm 3: Quên xử lý các trường hợp unknown

Trong ví dụ trước, bạn thấy Rails sử dụng super để call các method mà không biết phải xử lý như thế nào. Trong class StringInquirer, nếu method không kết thúc bằng ?, nó cho phép call lên super. Nếu bạn không dự phòng để super, nó có thể gây ra lỗi mà rất khó để traking. Hãy nhớ method_missing là nơi bug bị ẩn đi.

Sai lầm 4: Sử dụng define_method khi không cần thiết

Ta cùng xem ví dụ từ Restclient gem . Trong file bin/restclient ta 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

Đây là một cách viết sai lầm. Bởi bạn đang phải hy sinh khả năng đọc và hiểu mã mà không đạt được bất kỳ điều gì. Danh sách các HTTP verbs là cố định (bởi nó hầu như không thay đổi). Nhưng khi sử dụng meta programming, bạn đã tăng độ phức tạp bằng các phương pháp tự động xác định HTTP verbs => Tôi cho rằng ở đây không cần sử dụng meta programming.

Sai lầm 5: Thay đổi ngữ nghĩa khi open class

Bạn nên kiểm tra trước, nếu method đã tồn tại trước khi bạn open class có sẵn và thêm method mới. Nếu bạn không kiểm tra, bạn sẽ thay đổi ngữ nghĩa của method đã tồn tại => nó sẽ gây ra lỗi Vì vậy việc kiểm tra là cần thiết để giảm bớt ảnh hưởng xấu tới namspace global. Một ví dụ cho việc này là gem JSON, nó tích hợp vào các lớp Range, Rational, Symbol ... method to_json.

Sai lầm 6: Phụ thuộc sai hướng

Trong một kiến trúc theo layered , layer 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, các thư viện layer trên phải không được tái sử dụng lại nó được. Nếu thư viện layer trên có thể tái sử dụng được layer dưới thì ta gọi đó là phụ thuộc sai hướng.

Phụ thuộc sai hướng này sẽ dễ gặp phải nếu chúng ta sử dụng meta programming ở thư viện ở tầng thấp.

Sai lầm 7: Quá nhiều cấp nesting

Sử dụng method programming trong code buộc client sử dụng quá nhiều block nested. Ví dụ khi ta sử dụng Rspec

# 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

Có nhiều cấp nested làm tăng bối cảnh và khiến đoạn code khó hiểu hơn. Cách viết tốt là khiến code đơn giản và dễ hiểu hơn, một ví dụ tốt cho việc này đó là cách validation của ActiveModel

class Person
  include ActiveModel::Validations

  attr_accessor :name
  validates_presence_of :name
end

Hoặc các viết 1 cấp nested như trong routes.rb

resources :articles do
  resources :comments
end

Kết luận

Meta programming vô cùng giá trị, nó có thể giải quyết những vấn đề phức tạp dễ dàng hơn. Tuy nhiên nó chỉ có giá trị khi có được sự cân bằng giữa việc viết ít code và viết dễ đọc.

Tham khảo

https://www.codeschool.com/blog/2015/04/24/7-deadly-sins-of-ruby-metaprogramming/


All Rights Reserved