Rails thêm nhiều thứ vào built-in objects của Ruby. Đây là cái một số người gọi là "dialect" của Ruby và là thứ cho phép Rails developers viết dòng như 1.day.ago.
Hầu hết extra methods này sống trong ActiveSupport. Hôm nay, chúng ta sẽ xem một method ít người biết hơn mà ActiveSupport thêm trực tiếp vào Class: descendants. Method này trả về tất cả subclasses của class được gọi. Ví dụ, ApplicationRecord.descendants trả về các classes trong app inherit từ nó (ví dụ: tất cả models trong ứng dụng). Trong bài này, chúng ta xem nó hoạt động như thế nào, tại sao bạn muốn dùng, và nó augment thế nào built-in inheritance-related methods của Ruby.
Kế thừa trong ngôn ngữ Object-Oriented
Đầu tiên, quick refresher về inheritance model của Ruby. Giống các OO languages khác, Ruby dùng objects nằm trong hierarchy. Bạn tạo class, rồi subclass của class đó, rồi subclass của subclass đó, và cứ thế. Khi đi lên hierarchy, ta được list ancestors. Ruby cũng có feature hay: tất cả entities đều là objects (bao gồm classes, integers, thậm chí nil), trong khi một số language khác thường dùng "primitives" không phải true objects, thường cho performance (như integers, doubles, booleans, v.v.; nhìn bạn đấy, Java).
Ruby và tất cả OO languages phải track ancestors để biết lookup methods ở đâu và cái nào ưu tiên.
class BaseClass
def base
"base"
end
def overridden
"Base"
end
end
class SubClass < BaseClass
def overridden
"Subclass"
end
end
Ở đây, gọi SubClass.new.overridden cho "SubClass". Tuy nhiên, SubClass.new.base không có trong SubClass definition, nên Ruby sẽ duyệt ancestors xem cái nào implement method (nếu có). Ta thấy list ancestors bằng cách gọi SubClass.ancestors. Trong Rails, kết quả như sau:
[SubClass, BaseClass, ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency, ActiveSupport::ToJsonWithActiveSupportEncoder, Object, PP::ObjectMixin, JSON::Ext::Generator::GeneratorMethods::Object, ActiveSupport::Tryable, ActiveSupport::Dependencies::Loadable, Kernel, BasicObject]
Chúng ta không dissect toàn bộ list; cho mục đích của chúng ta, đủ lưu ý SubClass ở top, BaseClass dưới nó. Cũng lưu ý BasicObject ở bottom; đây là top-level Object trong Ruby, luôn ở bottom của stack.
Modules (a.k.a. 'Mixins')
Việc phức tạp hơn khi thêm modules. Module không phải ancestor trong class hierarchy, nhưng ta có thể "include" vào class nên Ruby phải biết khi nào check module cho method, hoặc thậm chí check module nào trước khi multiple modules included.
Một số language không cho "multiple inheritance" kiểu này, nhưng Ruby còn đi xa hơn bằng cách cho chọn nơi module inserted vào hierarchy bằng include hoặc prepend.
Prepending Modules
Prepended modules, như tên gợi ý, inserted vào list ancestors trước class, cơ bản override methods của class. Điều này cũng nghĩa là bạn có thể gọi "super" trong method của prepended module để gọi method gốc của class.
module PrependedModule
def test
"module"
end
def super_test
super
end
end
# Re-using `BaseClass` từ trước
class SubClass < BaseClass
prepend PrependedModule
def test
"Subclass"
end
def super_test
"Super calls SubClass"
end
end
Ancestors cho SubClass bây giờ:
[PrependedModule, SubClass, BaseClass, ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency, ... ]
Với ancestor list mới, PrependedModule first-in-line, nghĩa là Ruby sẽ look there first cho methods gọi trên SubClass. Điều này cũng nghĩa là nếu gọi super trong PrependedModule, ta gọi method trên SubClass:
> SubClass.new.test
=> "module"
> SubClass.new.super_test
=> "Super calls SubClass"
Including Modules
Included modules, ngược lại, inserted vào ancestors sau class. Điều này làm chúng ideal cho intercepting methods mà nếu không sẽ được handle bởi base class.
class BaseClass
def super_test
"Super calls base class"
end
end
module IncludedModule
def test
"module"
end
def super_test
super
end
end
class SubClass < BaseClass
include IncludedModule
def test
"Subclass"
end
end
Với arrangement này, ancestors cho SubClass:
[SubClass, IncludedModule, BaseClass, ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency, ... ]
Bây giờ, SubClass là first point of call, nên Ruby chỉ execute methods trong IncludedModule nếu không có trong SubClass. Về super, calls to super trong SubClass đi đến IncludedModule trước, trong khi calls to super bên trong IncludedModule đi đến BaseClass.
Nói cách khác, included module nằm giữa subclass và base class trong ancestor hierarchy. Điều này effectively nghĩa là chúng có thể dùng để 'intercept' methods mà nếu không sẽ được handle bởi base class:
> SubClass.new.test
=> "Subclass"
> SubClass.new.super_test
=> "Super calls BaseClass"
Vì "chain of command" này, Ruby phải track ancestors của class. Ngược lại thì không. Cho một class cụ thể, Ruby không cần track children, hay "descendants", vì không bao giờ cần info này để execute method.
Ancestor Ordering
Độc giả tinh ý có thể nhận ra nếu dùng multiple modules trong class, thứ tự include (hoặc prepend) có thể tạo kết quả khác. Ví dụ, tùy methods, class này:
class SubClass < BaseClass
include IncludedModule
include IncludedOtherModule
end
và class này:
class SubClass < BaseClass
include IncludedOtherModule
include IncludedModule
end
Có thể behave khác nhau. Nếu hai modules có methods trùng tên, thứ tự ở đây quyết định cái nào precedence và calls to super resolved ở đâu. Cá nhân tôi tránh overlapping methods nhất có thể để tránh lo lắng về thứ tự include modules.
Real-World Usage
Mặc dù biết difference giữa include và prepend cho modules tốt, tôi nghĩ real-world example giúp show khi nào chọn cái nào. Main use-case của tôi cho modules như này là với Rails engines.
Có lẽ Rails engine phổ biến nhất là devise. Giả sử muốn thay password digest algorithm, nhưng trước, disclaimer nhanh:
Day-to-day use của tôi cho modules là customize behavior của Rails engine hold default business logic. Chúng ta override behavior của code chúng ta control. Bạn có thể áp dụng cùng method cho bất kỳ Ruby piece nào, nhưng tôi không khuyên override code bạn không control (ví dụ: từ gems maintained bởi người khác), vì bất kỳ change nào external code có thể incompatible với changes của bạn.
Devise's password digest xảy ra ở Devise::Models::DatabaseAuthenticatable module:
def password_digest(password)
Devise::Encryptor.digest(self.class, password)
end
# và cũng trong password check:
def valid_password?(password)
Devise::Encryptor.compare(self.class, encrypted_password, password)
end
Devise cho phép customize algorithm dùng ở đây bằng cách tạo Devise::Encryptable::Encryptors riêng, đây là cách đúng. Cho demo, chúng ta dùng module.
# app/models/password_digest_module
module PasswordDigestModule
def password_digest(password)
# Devise's default bcrypt tốt hơn cho passwords,
# dùng sha1 ở đây chỉ demo
Digest::SHA1.hexdigest(password)
end
def valid_password?(password)
Devise.secure_compare(password_digest(password), self.encrypted_password)
end
end
begin
User.include(PasswordDigestModule)
# Pro-tip - vì gọi User ở đây, ActiveRecord sẽ
# try read từ database khi class load.
# Điều này có thể cause commands như `rails db:create` fail.
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
end
Để load module này, cần gọi Rails.application.eager_load! trong development hoặc thêm Rails initializer load file. Test ra thấy hoạt động như mong đợi:
> User.create!(email: "one@test.com", name: "Test", password: "TestPassword")
=> #
> User.first.valid_password?("TestPassword")
=> true
> User.first.encrypted_password
=> "4203189099774a965101b90b74f1d842fc80bf91"
Trong case này, cả include và prepend cho cùng kết quả, nhưng hãy thêm complication. Giả sử User model implement password_salt riêng, nhưng chúng ta muốn override trong module methods:
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable
has_many :posts
def password_salt
# Terrible way to create a password salt,
# purely for demonstration purposes
Base64.encode64(email)[0..-4]
end
end
Sau đó, update module dùng password_salt riêng khi tạo password digest:
def password_digest(password)
# Devise's default bcrypt tốt hơn cho passwords,
# dùng sha1 ở đây chỉ demo
Digest::SHA1.hexdigest(password + "." + password_salt)
end
def password_salt
# an even worse way of generating a password salt
"salt"
end
Bây giờ, include và prepend behave khác vì cái nào dùng quyết định password_salt method nào Ruby execute. Với prepend, module take precedence, ta được:
> User.last.password_digest("test")
=> "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.salt"
Đổi module dùng include sẽ thay vào đó User class implementation take precedence:
> User.last.password_digest("test")
=> "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.dHdvQHRlc3QuY2"
Thường tôi reach for prepend trước vì, khi viết module, tôi thấy dễ treat nó như subclass và assume method nào trong module sẽ override version của class. Rõ ràng không luôn desired, đó là lý do Ruby cũng cho include option.
Descendants
Chúng ta đã thấy Ruby track class ancestors để biết order-of-precedence khi execute methods, cũng như insert entries vào list này via modules. Tuy nhiên, as programmers, useful để iterate qua tất cả descendants của class. Đây là nơi ActiveSupport's #descendants method xuất hiện. Method khá ngắn và dễ duplicate ngoài Rails nếu cần:
class Class
def descendants
ObjectSpace.each_object(singleton_class).reject do |k|
k.singleton_class? || k == self
end
end
end
ObjectSpace là phần thú vị của Ruby lưu info về mọi Ruby Object hiện tại trong memory. Chúng ta không deep dive ở đây, nhưng nếu class defined trong app (và đã loaded), nó present trong ObjectSpace. ObjectSpace#each_object, khi passed module, trả về chỉ objects match hoặc là subclasses của module; block ở đây cũng reject top level (ví dụ: gọi Numeric.descendants, không expect Numeric trong results).
Đừng lo nếu chưa hiểu hoàn toàn, đọc thêm về ObjectSpace cần thiết để really get it. Cho mục đích của chúng ta, đủ biết method này sống trên Class và trả về list descendant classes, hoặc bạn nghĩ là "family tree" của children, grandchildren, v.v. của class đó.
Real-World Use của #descendants
Trong 2018 RailsConf, Ryan Laughlin talk về 'checkups'. Video đáng xem, nhưng trích một ý: định kỳ run qua tất cả rows trong database check nếu pass models' validity checks. Bạn có thể ngạc nhiên bao nhiêu rows không pass test #valid?.
Câu hỏi: làm sao implement check này mà không manually maintain list models? #descendants là câu trả lời:
# Ensure all models are loaded (should not be necessary in production)
Rails.application.load! if Rails.env.development?
ApplicationRecord.descendants.each do |model_class|
# in the real world you'd want to send this off to background job(s)
model_class.all.each do |record|
if !record.valid?
HoneyBadger.notify("Invalid #{model.name} found with ID: #{record.id}")
end
end
end
Ở đây, ApplicationRecord.descendants cho list mọi model trong standard Rails application. Trong loop, model là class (ví dụ: User hoặc Product). Implementation cơ bản, nhưng kết quả là iterate qua mọi model (hoặc chính xác hơn, mọi subclass của ApplicationRecord) và gọi .valid? cho mọi row.
Kết luận
Đối với hầu hết Rails developers, modules không commonly used. Có lý do tốt; nếu own code, thường có easier ways customize behavior, và nếu không own code, có risks thay đổi behavior với modules. Tuy nhiên, chúng có use-cases, và là testament đến flexibility của Ruby không chỉ cho phép change class từ file khác, mà còn có option chọn nơi trong ancestor chain module xuất hiện.
ActiveSupport sau đó cung cấp inverse của #ancestors với #descendants. Method này ít dùng theo tôi thấy, nhưng một khi biết nó ở đó, bạn sẽ tìm thấy 점점 nhiều use cases. Cá nhân tôi dùng không chỉ check model validity, mà thậm chí với specs validate chúng ta correctly adding attribute_alias methods cho tất cả models.