Metaprogramming Safely
Metaprogramming Safely Metaprogramming cung cấp cho bạn sức mạnh để có thể viết code một cách ngắn gọn và đẹp. Theo các bài viết mà mình viết trước đây về metaprogramming thì bạn có thể nhìn thấy một vài các bẫy. các tính năng dễ gây nhầm lẫn, lỗi khó hiểu trong mô hình đối tượng của Ruby. Nó ...
Metaprogramming Safely
Metaprogramming cung cấp cho bạn sức mạnh để có thể viết code một cách ngắn gọn và đẹp. Theo các bài viết mà mình viết trước đây về metaprogramming thì bạn có thể nhìn thấy một vài các bẫy. các tính năng dễ gây nhầm lẫn, lỗi khó hiểu trong mô hình đối tượng của Ruby. Nó đủ để làm cho ngay cả các nhà phát triển lo lắng như họ đang viết những dòng đầu tiên của metaprogramming code. Nhưng dù sao đi nũa thì bạn hãy tự tin lên, một khi bạn tìm hiểu những vấn đề lớn, những cạm bẫy của metaprogramming bạn có thể dễ dàng tránh được chúng. Thậm chí bạn có thể sử dụng metaprogramming để làm cho code của bạn đáng tin cậy hơn. Và trong bài viết này chúng ta sẽ cùng nhau xem xét một vài kĩ thuật để giúp bạn có thể đạt được điều đó.
Testing Metaprogramming
Trong một số posts trước mình tập trung vào Rails's ActiveRecord, thư viện dùng để thực thi một phần của mô hình Model-View-Controller. Trong phần này chúng ta sẽ xem xét tới ActionPack, các thứ viện mà sẽ quan tâm tới Views và Controllers. Controllers của Rails là những thành phần xử lý các yêu cầu từ phía client thông qua giao thức HTTP. Chúng đồng thời cũng gọi tới các đối tượng model để chạy các logic nghiệp vụ, và sau đó chúng trả về các response, thường là bằng cách renđẻ một template HTML (view). Tất cả các controllers là lớp con của ActionController::Base.
Dưới đây là một controller
class GreetController < ActionController::Base def hello render :text => "Hello, world" end def goodbye render :text => "Goodbye, world" end end
Các phương thức ở trong một controller cũng được gọi là các hành động, một người dùng chạy hành động hello() bằng cách gõ URL http://my_server/my_rails_app/hello vào trình duyệt và nó sẽ trả lại một page chứa một string là “Hello, world”. Tương tự như thế người dùng nhập vào trình duyệt là http://my_server/my_rails_app/goodbye , lúc đó sẽ nhận được string “Goodbye, world”. Thỉnh thoảng bạn có code những đoạn code dùng chung cho tất cả các actions trong một controller, như là code đăng nhập, code để validate. Bạn có thể sử dụng một filter để khởi chạy các đoạn code dùng chung này trước hay sau action.
Controller Filters
Bạn có thể tạo một before filter như sau :
class GreetController < ActionController::Base before_filter :check_password def hello render :text => "Hello, world" end def goodbye render :text => "Goodbye, world" end private def check_password raise 'No password' unless 'my_password' == params[:pwd] end end
Phương thức check_password( ) sẽ đưa ra một error, trừ khi phía client thêm vào URL một password như sau http://my_server/my_rails_app/hello?pwd=my_password . Nó là một phương thức private, nó không phải là một action, bạn không thể truy cập nó thông qua http://my_server/my_rails_app/check_password . Thay vào đó chính bản thân controller sẽ thực hiện phương thức này trước khi chạy phuonwg thức hello() hay goodbye(). Vì vậy, đó là cách mà bạn sử dụng các fillter cho controller. Bạn chỉ cần gọi before_filter() hoặc after_filter() , với tên của một phương thức. Trong phần tiếp theo mình sẽ chỉ ra mã nguồn của các before filter.
The Source Behind Controller Filters
Phương thức before_filter được định nghĩa trong module ActionController::Filters. Module này được tóm lại bên trong ActionController::Base với một Class Extension Mixin, cũng giống như các phương thức mà bạn đã thấy ở một số bài post trước.
module ActionController module Filters def self.included(base) base.class_eval do extend ClassMethods include ActionController::Filters::InstanceMethods end end module ClassMethods def append_before_filter(*filters, &block) filter_chain.append_filter_to_chain(filters, :before, &block) end alias :before_filter :append_before_filter # ... end module InstanceMethods private def run_before_filters(chain, index, nesting) while chain[index] filter = chain[index] break unless filter # end of call chain reached filter.call(self) # ... end end # ... end end end
Nếu bạn tìm hiểu mã nguồn và nhìn vào phương thức append_filter_to_chain(), bạn sẽ thấy rằng nó tạo ra các filters (các đối tượng của lớp ActionController::Filters:::Filter) và chèn chúng vào một “filter chain”. Trước mỗi action, controller sẽ thực hiện tất cả các before filters trong chuỗi bằng phương thức call() và truyền qua chính nó.
Nếu bạn nhìn vào mã nguồn bạn sẽ thấy rằng tất cả các filters đều kế thừa từ ActionRecord::Callbacks::Callback, một lớp tiện ích trong thư viện ActiveSupport của Rails. Dưới đây là một vài dòng code được lấy ra từ chính lớp này.
module ActiveSupport module Callbacks class Callback attr_reader :kind, :method, :identifier, :options def initialize(kind, method, options = {}) @method = method # ... end def call(*args, &block) evaluate_method(method, *args, &block) if should_run_callback?(*args) # ... end # ... private def evaluate_method(method, *args, &block) case method when Symbol object = args.shift object.send(method, *args, &block) when String eval(method, args.first.instance_eval { binding }) when Proc, Method method.call(*args, &block) else if method.respond_to?(kind) method.send(kind, *args, &block) else raise ArgumentError, "Callbacks must be a symbol denoting the method to call, " + "a string to be evaluated, a block to be invoked, " + "or an object responding to the callback method." end end end end end end
Một ActiveSupport::Callbacks::Callback có thể bao một tên phương thức, một đối tượng có thể gọi được hoặc một string.
Chú ý rằng mặc dù các proc và các phương thức được thực hiện trong các bối cảnh riêng. và bạn cũng cần một bối cảnh để đánh giá chúng. Trong trường hợp của symbols và strings, Rails ' callbacks sử dụng phương thức call() là đối số đâu tiên như một context. Vì dụ, nhìn vào dòng code để đánh giá Strins của Code, nó sử dụng một Context Probe để trích xuất các liên kết từ argument đầu tiên và sau đó nó sử chúng để đánh giá các chuỗi.
Testing Controller Filters
Đây là một phần của ActionController test cho filters :
class FilterTest < Test::Unit::TestCase class TestController < ActionController::Base before_filter :ensure_login def show render :inline => "ran action" end private def ensure_login @ran_filter ||= [] @ran_filter << "ensure_login" end end class PrependingController < TestController prepend_before_filter :wonderful_life private def wonderful_life @ran_filter ||= [] @ran_filter << "wonderful_life" end end def test_prepending_filter assert_equal [ :wonderful_life, :ensure_login ], PrependingController.before_filters end def test_running_filters assert_equal %w( wonderful_life ensure_login ), test_process(PrependingController).template.assigns["ran_filter" ] end end
Các TestController chứa một single before filter. Đơn giản chì cần bằng cách định nghĩa lớp này, test sẽ bảo đảm rằng before_filter() được định nghĩa chính xác như một Class Macro trong ActionController::Base.
Test cũng định nghĩa một lớp con của TestController gọi là PrependingController, sử dụng prepend_before_filter(), nó tương tự như before_filter() nhưng nó chèn bộ lọc ở đầu filter chain, chứ không phải ở cuối. Vì vậy mặc dù wonderful_life được định nghĩa sau ensure_login filter, nhưng nó phải được thực hiện đầu tiên. Cả hai bộ lọc nói thêm tên riêng, khởi tạo với Nil Guard bằng các bộ lọc đầu tiên được thực thi.
Bây giờ tôi sẽ sang các lớp helper và tests chúng. Trong số nhiều unit test ở FilterTest, tôi chọn 2 test feature ở PrependingController. Test đầu tiên là test_prepending_filter(), xác thực rằng Class Macros thêm các bộ lọc cho chain theo thứ tự đúng. Test thứ 2 là test_running_filters(), mô phỏng một client gọi tới một controller action. Nó thực hiện bằng cách gọi một phương thức helper tên là test_process(). Phương thức này sau đó sẽ copy các biến thực thể của controller trong một respone, vì thế có thể test bằng cách nhìn vào respone để xem xem filter nào đã được thực thi.
Ngay cả nếu code trong các controller filter sử dụng metaprogramming, các khối test trông giống như các test mà bạn viết cho bất cứ đoạn code thông thường nào. Bạn tự hỏi test sẽ có gì với metaprogramming, bạn hãy nhìn vào phương thức helpter test_process() sau :
def test_process(controller, action = "show" ) ActionController::Base.class_eval { include ActionController::ProcessWithTest } unless ActionController::Base < ActionController::ProcessWithTest request = ActionController::TestRequest.new request.action = action controller = controller.new if controller.is_a?(Class) controller.process_with_test(request, ActionController::TestResponse.new) end
FilterTest#test_process( ) mở lại ActionController::Base để thêm vào một module helper tên là ActionController::ProcessWithTest. Sau đó nó tạo ra một kết nối HTTP giả lập, kết nói nó đến một action, tạo ra một controller mới, và yêu cầu controller xử lí yêu cầu đó. Nhìn vào ActionController::ProcessWithTest bạn sẽ hiểu action metaprogramming dễ dàng hơn.
module ActionController module ProcessWithTest def self.included(base) base.class_eval { attr_reader :assigns } end def process_with_test(*args) process(*args).tap { set_test_assigns } end private def set_test_assigns @assigns = {} (instance_variable_names - self.class.protected_instance_variables).each do |var| name, value = var[1..-1], instance_variable_get(var) @assigns[name] = value response.template.assigns[name] = value if response end end end end
ActionController::ProcessWithTest là một đoạn code metaprogramming đơn giản. Nó sử dụng một lớp Extension Mixin và một Open Class để định nghĩa một assigns attribute. Nó đồng thời cũng định nghĩa một phương thức process_with_test(). Tuy nhiên, process_with_test() đồng thời cũng taps set_test_assigns( ) trong kết quả trước khi trả lại. Nếu bạn sử dụng Ruby 1.9 hoặc cao hơn thì tap() là một trong nhưng phương thức chuẩn trong Object. Nếu bạn đang sử dụng bạn cũ thì Rails sẽ định nghĩa tab() cho bạn.
def tap yield self self end unless Object.respond_to?(:tap)
Bây giờ nhìn vào set_test_assigns(). Dòng thứ 2 sử dụng 2 phương thức instance_variable_names( ) dùng để trả lại tất cả các biến thực thể trong controller và protected_instance_variables( ) để trả về những biến được xác định bới ActionController::Base . Và bằng cách trừ 2 mảng cho nhau, thì đoạn code này trả về chỉ những biến thực thể được định nghĩa ở trong class controller, ngoại trừ những cái được định nghĩa ở supperclass của controller.
Sau đó set_test_assigns() lặp qua tất cả các biến thực thể. Nó lấy ra giá trị với instance_variable_get() và lưu lại cả tên và giá trị của biến. Nó đồng thời cũng luuw tên và giá trị trong một biến @assigns để cho những trường hợp HTTP respone là nil. Và cuối cùng, đoạn code này cho phép mọt test class như FilterTest gọi một controller action và sau đó thực hiện xác nhận dựa vào các biến thực thể của controller.
Như bạn thấy đó, bạn có thể sử dụng metaprogramming trong các đơn vị test để có thể kiểm thử chắc chắn hơn ở các phần code của bạn và cũng đảm bào rằng nó hoạt động như mong đợi. Tuy nhiên bạn cũng nên xử lí cẩn thận ngay cả khi bạn code những test tốt.
Defusing Monkeypatches
Trong phần nói về Object Model mà mình đã viết trước đây, bạn đã biết về các class và module, bao gồm cả các class và module của thư viện lõi của Ruby, có thể mở lại như Open Class.
"abc".capitalize # => "Abc" class String def capitalize upcase end end "abc".capitalize # => "ABC"
Open Class là rất hữu ích những cũng rất nguy hiểm. Bởi vì mở lại một class, bạn có thể thay đổi các chức năng hiện tại của nó, giống như phương thức String#capitalize() ở treeb. Kỹ thuật này (hay còn gọi là một Monkeypatch) chỉ ra một số vấn đề mà bạn cần phải biết.
Đầu tiên, một Monkeypatch là toàn cục. Nếu bạn thay đổi một phương thức của String, tất cả các string trong hệ thống của bạn sẽ thấy phương thức đó. Thứ hai, một Monkeypatch là vô hình. Một khi bạn đã định nghĩa lại String#capitalize(), thật khó để nhận thấy rằng phương thức này đã thay đổi. Nếu code của bạn, hoặc một thư viện mà bạn đang sử dụng, dựa trên hành vi ban đầu của capitalize(), code đó sẽ bị vỡ, bởi vì Monkeypatch là toàn cục, bạn code thể sẽ gặp khó khăn để tìm ra đoạn code nào đã bị thay đổi trong class.
Với tất cả những lý do bên trên, bạn có thể nghĩ rằng Monkeypatch rắc rối, và có thể không nên dùng. Tuy nhiên bạn có thể áp dụng một số kỹ thuật để khiến cho Monkeypatch an toàn hơn.
Making Monkeypatches Explicit
Một lý do tại sao mà Monkeypatch lại nguy hiểm đó là rất khó phát hiển ra lỗi. Nếu bạn khiến cho chúng rõ ràng hơn, bạn sẽ có thời gian theo dõi và xác định được. Ví dụ thay vì bạn định nghĩa các phương thức ở trong Open Class, bạn có thể định nghĩa phương thức trong một module và sau đó include module ấy vào trong Open Class. Theo cách này bạn có thể nhìn thấy các module trong ancestors của Open Class.
Thư viện ActiveSupport của Rails sử dụng các module để mở rộng thư viện chuẩn của các class như String. Đầu tiên nó định nghĩa một phương thức cần bổ sung trong một module như ActiveSupport::CoreExtensions::String
module ActiveSupport module CoreExtensions module String module Filters def squish # ... def squish! # ... end end end end
Sau đó ActiveSupport include tất cả các module mở rộng trong String.
class String include ActiveSupport::CoreExtensions::String::Access include ActiveSupport::CoreExtensions::String::Conversions include ActiveSupport::CoreExtensions::String::Filters include ActiveSupport::CoreExtensions::String::Inflections include ActiveSupport::CoreExtensions::String::StartsEndsWith include ActiveSupport::CoreExtensions::String::Iterators include ActiveSupport::CoreExtensions::String::Behavior include ActiveSupport::CoreExtensions::String::Multibyte end
Bây giờ tưởng tượng rằng bạn đang viết code cho ứng dụng Rails của bạn, và bạn muốn theo dõi tất cả các module đã định nghĩa phương thức mới cho String. Bạn có thể lấy ra một danh sách đấy đủ các module ấy bằng cách gọi String.ancestors( )
[String, ActiveSupport::CoreExtensions::String::Multibyte, ActiveSupport::CoreExtensions::String::Behavior, ActiveSupport::CoreExtensions::String::Filters, ActiveSupport::CoreExtensions::String::Conversions, ActiveSupport::CoreExtensions::String::Access, ActiveSupport::CoreExtensions::String::Inflections, Enumerable, Comparable, Object, ActiveSupport::Dependencies::Loadable, Base64::Deprecated, Base64, Kernel, BasicObject]
Mặc dù các module không thực sự giải quyết hết các vấn đề với Mokeypatches, nhưng chúng đã giúp cho bạn có thể theo dõi, kiếm tra Monkeypatch dễ hơn.