Trong bài này, chúng ta sẽ khám phá cách tạo Ruby module có thể cấu hình bởi user của code chúng ta - một pattern cho phép gem author thêm sự linh hoạt cho thư viện.
Hầu hết Ruby developer quen dùng module để share behavior. Theo docs, đây là một trong hai mục đích chính:
Modules serve two purposes in Ruby, namespacing and mixin functionality.
Rails thêm syntactic sugar dạng ActiveSupport::Concern, nhưng nguyên tắc chung vẫn vậy.
Vấn đề
Dùng module cung cấp mixin functionality thường đơn giản. Chỉ cần gom method và include module ailleurs:
module HelloWorld
def hello
"Hello, world!"
end
end
class Test
include HelloWorld
end
Test.new.hello #=> "Hello, world!"
Cơ chế này khá static, tuy hook method inherited và extended của Ruby cho phép behavior thay đổi dựa trên class including:
module HelloWorld
def self.included(base)
define_method :hello do
"Hello, world from #{base}!"
end
end
end
class Test
include HelloWorld
end
Test.new.hello #=> "Hello, world from Test!"
Đây dynamic hơn nhưng vẫn không cho user code rename method hello tại thời điểm include module.
Giải pháp: Configurable Ruby Modules
Vài năm qua, pattern mới nổi lên giải quyết vấn đề này, đôi khi gọi là "module builder pattern". Kỹ thuật này dựa trên hai đặc tính chính của Ruby:
Module giống như object bất kỳ - có thể tạo on the fly, gán biến, dynamic modify, pass to/return from method.
def make_module mod = Module.new mod.module_eval do def hello "Hello, AppSignal world!" end end mod endArgument của
includehoặcextendkhông nhất thiết phải là module, có thể là expression trả về module, ví dụ method call.class Test include make_module end Test.new.hello #=> "Hello, AppSignal world!"
Module Builder Hành động
Chúng ta sẽ dùng kiến thức xây dựng module đơn giản Wrapper implement behavior sau:
- Class include
Wrapperchỉ wrap object của type cụ thể. Constructor verify argument type, raise error nếu type không match expected. - Wrapped object доступ qua instance method tên
original_<class>, ví dụoriginal_integerhoặcoriginal_string. - Cho phép consumer chỉ định tên alternative cho accessor method, ví dụ
the_string.
Xem cách code hoạt động mong muốn:
# 1
class IntWrapper
# 2
include Wrapper.for(Integer)
end
# 3
i = IntWrapper.new(42)
i.original_integer #=> 42
# 4
i = IntWrapper.new("42") #=> TypeError (not a Integer)
# 5
class StringWrapper
include Wrapper.for(String, accessor_name: :the_string)
end
s = StringWrapper.new("Hello, World!")
# 6
s.the_string #=> "Hello, World!"
Bước 1: define class IntWrapper.
Bước 2: class không просто include module by name mà mixin kết quả gọi Wrapper.for(Integer).
Bước 3: instantiate object, gán cho i. Object có method original_integer, thỏa mãn yêu cầu.
Bước 4: nếu pass argument sai type (string), raise TypeError hữu ích. Cuối cùng verify user có thể chỉ định custom accessor name.
Bước 5: define class StringWrapper, pass the_string làm keyword argument accessor_name, thấy hoạt động bước 6.
Mặc dù ví dụ hơi constructed, nhưng đủ varying behavior để demo module builder pattern và cách dùng.
Lần thử đầu tiên
Dựa trên yêu cầu và usage example, bắt đầu implement. Chúng ta biết cần module Wrapper với module-level method for, nhận class và optional keyword argument:
module Wrapper
def self.for(klass, accessor_name: nil)
end
end
Vì return value của method này trở thành argument của include, nó phải là module. Tạo anonymous module với Module.new.
Module.new do
end
Theo yêu cầu, cần define constructor verify type của object passed, và appropriately named accessor method. Bắt đầu với constructor:
define_method :initialize do |object|
raise TypeError, "not a #{klass}" unless object.is_a?(klass)
@object = object
end
Dùng define_method dynamic add instance method cho receiver. Block đóng vai closure, dùng object klass từ outer scope để type check.
Thêm accessor method tên phù hợp không khó hơn:
# 1
method_name = accessor_name || begin
klass_name = klass.to_s.gsub(/(.)([A-Z])/, '\1_\2').downcase
"original_#{klass_name}"
end
# 2
define_method(method_name) { @object }
Bước 1: check caller có pass accessor_name không. Nếu có, gán cho method_name xong. Nếu không, lấy class convert to underscored string, ví dụ Integer → integer, OpenStruct → open_struct. Biến klass_name prefix original_ tạo final accessor name. Bước 2: dùng define_method thêm vào module.
Code hoàn chỉnh đến đây. Dưới 20 dòng cho module Ruby flexible, configurable; không tệ.
module Wrapper
def self.for(klass, accessor_name: nil)
Module.new do
define_method :initialize do |object|
raise TypeError, "not a #{klass}" unless object.is_a?(klass)
@object = object
end
method_name = accessor_name || begin
klass_name = klass.to_s.gsub(/(.)([A-Z])/, '\1_\2').downcase
"original_#{klass_name}"
end
define_method(method_name) { @object }
end
end
end
Độc giả observant có thể nhớ Wrapper.for trả về anonymous module. Không phải vấn đề, nhưng hơi confusing khi examine inheritance chain của object:
StringWrapper.ancestors
#=> [StringWrapper, #, Object, Kernel, BasicObject]
Ở đây # (tên khác nếu bạn follow along) refer đến anonymous module của chúng ta.
Phiên bản cải tiến
Làm cho user dễ dàng hơn bằng cách trả về named module thay vì anonymous. Code tương tự trước, với minor changes:
module Wrapper
def self.for(klass, accessor_name: nil)
# 1
mod = const_set("#{klass}InstanceMethods", Module.new)
# 2
mod.module_eval do
define_method :initialize do |object|
raise TypeError, "not a #{klass}" unless object.is_a?(klass)
@object = object
end
method_name = accessor_name || begin
klass_name = klass.to_s.gsub(/(.)([A-Z])/, '\1_\2').downcase
"original_#{klass_name}"
end
define_method(method_name) { @object }
end
# 3
mod
end
end
Bước 1: tạo nested module tên "#{klass}InstanceMethods" (ví dụ IntegerInstanceMethods), là module "empty".
Bước 2: dùng module_eval trong method for, evaluate block code trong context của module nó được gọi. Cách này thêm behavior vào module trước khi return bước 3.
Bây giờ examine ancestors của class include Wrapper, output sẽ include properly named module, meaningful và dễ debug hơn anonymous module trước.
StringWrapper.ancestors
#=> [StringWrapper, Wrapper::StringInstanceMethods, Object, Kernel, BasicObject]
Module Builder Pattern Trong Thực Tế
Ngoài bài này, pattern module builder hoặc kỹ thuật tương tự còn tìm thấy đâu?
Ví dụ: family gem dry-rb, nơi dry-effects dùng module builder pass configuration options cho các effect handler:
# This adds a `counter` effect provider. It will handle (eliminate) effects
include Dry::Effects::Handler.State(:counter)
# Providing scope is required
# All cache values will be scoped with this key
include Dry::Effects::Handler.Cache(:blog)
Tương tự trong gem Shrine tuyệt vời, cung cấp file upload toolkit cho Ruby app:
class Photo < Sequel::Model
include Shrine::Attachment(:image)
end
Pattern này còn tương đối mới, nhưng tôi kỳ vọng thấy nhiều hơn trong tương lai, đặc biệt trong gem focus pure Ruby app hơn Rails.
Tóm tắt
Bài này khám phá implement configurable module trong Ruby, kỹ thuật đôi khi gọi module builder pattern. Giống metaprogramming techniques khác, đi kèm cost tăng complexity, không nên dùng nếu không có lý do tốt. Tuy nhiên, trong rare case cần flexibility, object model Ruby lại cho phép giải pháp elegant, concise. Module builder pattern không phải thứ hầu hết Ruby developer cần thường xuyên, nhưng tool tuyệt vời trong toolkit, đặc biệt cho library author.
P.S. Nếu muốn đọc Ruby Magic posts ngay khi xuất bản, đăng ký newsletter Ruby Magic để không bỏ lỡ bài nào!