Rails là một framework lớn, và nó lớn hơn mỗi năm. Điều này khiến một số tính năng hữu ích bị lọt mất mà không bị chú ý. Trong series này, chúng ta sẽ xem xét một số chức năng ít được biết đến được xây dựng sẵn trong Rails cho các tác vụ cụ thể.
Trong bài viết đầu tiên trong series này, chúng ta sẽ xem cách gọi Rails.env.test? thực sự hoạt động bên dưới bằng cách sử dụng lớp StringInquirer ít được biết đến từ ActiveSupport. Đi một bước xa hơn, chúng ta cũng sẽ đi qua mã nguồn của StringInquirer để xem nó hoạt động như thế nào, mà (spoiler alert) là một ví dụ đơn giản về metaprogramming của Ruby qua phương thức đặc biệt method_missing.
Trợ Giúp Rails.env
Bạn có thể đã thấy code kiểm tra môi trường Rails:
if Rails.env.test?
# return hard-coded value...
else
# contact external API...
end
Tuy nhiên, test? thực sự làm gì, và phương thức này đến từ đâu?
Nếu chúng ta kiểm tra Rails.env trong console, nó hoạt động như một string:
=> Rails.env
"development"
String ở đây là bất cứ thứ gì biến môi trường RAILS*ENV được đặt thành; nó không phải _chỉ* là một String, tuy nhiên. Khi đọc class trong console, chúng ta thấy:
=> Rails.env.class
ActiveSupport::StringInquirer
Rails.env thực sự là một StringInquirer.
StringInquirer
Mã cho StringInquirer vừa ngắn gọn vừa được tài liệu hóa tốt. Cách nó hoạt động, tuy nhiên, có thể không rõ ràng trừ khi bạn quen với khả năng metaprogramming của Ruby. Chúng ta sẽ đi qua những gì đang xảy ra ở đây.
class StringInquirer < String
...
end
Trước tiên, chúng ta thấy StringInquirer là subclass của String. Đây là lý do Rails.env hoạt động như một String khi chúng ta gọi nó. Bằng cách kế thừa từ String, chúng ta tự động nhận được tất cả chức năng giống String, vì vậy chúng ta có thể đối xử với nó như một String. Rails.env.upcase hoạt động, như ActiveRecordModel.find_by(string_column: Rails.env).
Trong khi Rails.env là một ví dụ tiện lợi, tích hợp sẵn của StringInquirer, chúng ta cũng tự do tạo ra của riêng mình:
def type
result = "old"
result = "new" if @new
ActiveSupport::StringInquirer.new(result)
end
Sau đó, chúng ta nhận các phương thức kết thúc bằng dấu hỏi trên giá trị trả về:
=> @new = false
=> type.old?
true
=> type.new?
false
=> type.testvalue?
false
=> @new = true
=> type.new?
true
=> type.old?
false
method_missing
method_missing là bí mật thực sự ở đây. Trong Ruby, bất cứ khi nào chúng ta gọi một phương thức trên một đối tượng, Ruby bắt đầu bằng cách nhìn vào tất cả tổ tiên của đối tượng (tức là base class hoặc included module) để tìm phương thức. Nếu không tìm thấy, Ruby sẽ gọi method_missing, truyền vào tên phương thức nó đang tìm, cùng với bất kỳ đối số nào.
Mặc định, phương thức này chỉ ném ra exception. Chúng ta có thể đều quen thuộc với thông báo lỗi NoMethodError: undefined method 'test' for nil:NilClass. Chúng ta có thể triển khai method_missing riêng không ném exception, chính xác những gì StringInquirer làm:
def method_missing(method_name, *arguments)
if method_name.end_with?("?")
self == method_name[0..-2]
else
super
end
end
Với bất kỳ tên phương thức kết thúc bằng ?, chúng ta kiểm tra giá trị của self (tức là String) so với tên phương thức không có ?. Nói cách khác, nếu chúng ta gọi StringInquirer.new("test").long_test_method_name?, giá trị trả về là "test" == "long_test_method_name".
Nếu tên phương thức không kết thúc bằng dấu hỏi, chúng ta fallback về method_missing gốc (cái sẽ ném exception).
respond_to_missing?
Có một phương thức nữa trong tệp: respond_to_missing?. Chúng ta có thể nói đây là phương thức đồng hành của method_missing. Mặc dù method_missing cho chúng ta chức năng, chúng ta cũng cần một cách để nói với Ruby rằng chúng ta chấp nhận các phương thức kết thúc bằng dấu hỏi.
def respond_to_missing?(method_name, include_private = false)
method_name.end_with?("?") || super
end
Điều này phát huy tác dụng nếu chúng ta gọi respond_to? trên đối tượng này. Nếu không có điều này, nếu chúng ta gọi StringInquirer.new("test").respond_to?(:test?), kết quả sẽ là false vì chúng ta không có phương thức rõ ràng tên test?. Điều này rõ ràng gây hiểu lầm vì tôi mong đợi nó trả về true nếu tôi gọi Rails.env.respond_to?(:test?).
respond_to_missing? là thứ cho phép chúng ta nói "có, tôi có thể xử lý phương thức đó" cho Ruby. Nếu tên phương thức không kết thúc bằng dấu hỏi, chúng ta fallback về phương thức superclass.
Các Trường Hợp Sử Dụng Thực Tế
Bây giờ chúng ta biết cách StringInquirer hoạt động, hãy xem các trường hợp nó có thể hữu ích:
1. Biến Môi Trường (Environment Variables)
Biến môi trường đáp ứng hai tiêu chí làm cho chúng trở thành lựa chọn tuyệt vời cho StringInquirer. Trước hết, chúng thường có tập giá trị có thể có giới hạn, đã biết (như một enum), và thứ hai, chúng ta thường có logic điều kiện phụ thuộc vào giá trị của chúng.
Giả sử app của chúng ta kết nối với payment API, và chúng ta lưu credentials trong biến môi trường. Trong hệ thống production, này rõ ràng là API thật, nhưng trong staging hoặc development, chúng ta muốn dùng sandbox API của họ:
# ENV["PAYMENT_API_MODE"] = sandbox/production
class PaymentGateway
def api_mode
# Chúng ta dùng ENV.fetch vì chúng ta muốn raise nếu giá trị bị thiếu
@api_mode ||= ENV.fetch("PAYMENT_API_MODE").inquiry
end
def api_url
# Pro-tip: Chúng ta *chỉ* dùng production nếu MODE == 'production', và mặc định
# về sandbox nếu giá trị là bất cứ thứ gì khác, điều này ngăn chặn việc dùng giá trị
# production nếu giá trị bị gõ sai hoặc không đúng
if api_mode.production?
PRODUCTION_URL
else
SANDBOX_URL
end
end
end
Lưu ý rằng ở trên, chúng ta đang dùng phương thức String#inquiry của ActiveSupport, tiện lợi chuyển đổi String thành StringInquirer cho chúng ta.
2. API Response (Phản Hồi API)
Tiếp tục ví dụ payment API từ trên, API sẽ gửi cho chúng ta phản hồi bao gồm một số trạng thái success/failure. Lại nữa, điều này đáp ứng hai tiêu chí làm cho nó trở thành ứng cử viên cho StringInquirer: tập giá trị có thể có giới hạn và logic điều kiện sẽ kiểm tra các giá trị này.
class PaymentGateway
def create_charge
response = JSON.parse(api_call(...))
result = response["result"].inquiry
if result.success?
...
else
...
end
# result vẫn hoạt động như string
Rails.logger.info("Payment result was: #{result}")
end
end
Kết Luận
StringInquirer là một công cụ thú vị để có trong túi áo, nhưng cá nhân tôi không sẽ lấy nó thường xuyên. Nó có một vài dùng, nhưng hầu hết thời gian, một phương thức rõ ràng trên đối tượng cho bạn cùng kết quả. Một phương thức được đặt tên rõ ràng cũng có một vài lợi ích; nếu giá trị bao giờ cần thay đổi, bạn chỉ phải cập nhật một nơi duy nhất, và nó làm cho codebase dễ tìm kiếm hơn nếu nhà phát triển đang cố gắng định vị phương thức.
Mặc dù nó tập trung vào StringInquirer, bài viết này dự định là một giới thiệu nhẹ nhàng vào một số khả năng metaprogramming của Ruby sử dụng method_missing. Tôi sẽ nói rằng bạn có thể không muốn dùng method_missing trong ứng dụng của mình. Tuy nhiên, nó thường được dùng trong framework, như Rails hoặc domain-specific-languages do gem cung cấp, vì vậy nó có thể rất hữu ích để biết "cách xúc xích được làm" khi bạn gặp vấn đề.