+1

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ó đủ để 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.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí