Rails bổ sung nhiều thứ cho các đối tượng tích hợp sẵn của Ruby. Đây là cái mà một số người gọi là "phương ngữ" của Ruby và là thứ cho phép các nhà phát triển Rails viết các dòng như 1.day.ago
.
Hầu hết các phương thức bổ sung này đều có trong ActiveSupport. Hôm nay, chúng ta sẽ xem xét một phương pháp có lẽ ít được biết đến hơn mà ActiveSupport thêm trực tiếp vào Lớp:descendants
. Phương thức này trả về tất cả các lớp con của lớp được gọi. Ví dụ:ApplicationRecord.descendants
sẽ trả về các lớp trong ứng dụng của bạn kế thừa từ nó (ví dụ:tất cả các mô hình trong ứng dụng của bạn). Trong bài viết này, chúng ta sẽ xem xét cách thức hoạt động của nó, tại sao bạn có thể muốn sử dụng nó và cách nó tăng cường các phương thức liên quan đến kế thừa có sẵn của Ruby.
Kế thừa trong Ngôn ngữ Hướng đối tượng
Đầu tiên, chúng tôi sẽ cung cấp một bản cập nhật nhanh về mô hình kế thừa của Ruby. Giống như các ngôn ngữ hướng đối tượng (OO) khác, Ruby sử dụng các đối tượng nằm trong một hệ thống phân cấp. Bạn có thể tạo một lớp, sau đó là một lớp con của lớp đó, rồi một lớp con của lớp con đó, v.v. Khi đi lên hệ thống phân cấp này, chúng tôi nhận được danh sách các tổ tiên. Ruby cũng có một tính năng tuyệt vời là tất cả các thực thể là bản thân các đối tượng (bao gồm các lớp, số nguyên và thậm chí là nil), trong khi một số ngôn ngữ khác thường sử dụng "nguyên thủy" không phải là đối tượng thực, thường là vì lợi ích của hiệu suất (chẳng hạn như số nguyên, đôi, boolean, v.v.; I ' tôi đang nhìn bạn, Java).
Ruby và thực sự là tất cả các ngôn ngữ OO, phải theo dõi các tổ tiên để nó biết nơi tra cứu các phương thức và phương thức nào được ưu tiên hơn.
class BaseClass
def base
"base"
end
def overridden
"Base"
end
end
class SubClass < BaseClass
def overridden
"Subclass"
end
end
Tại đây, đang gọi SubClass.new.overridden
cung cấp cho chúng tôi "SubClass"
. Tuy nhiên, SubClass.new.base
không có trong định nghĩa SubClass của chúng ta, vì vậy Ruby sẽ đi qua từng tổ tiên để xem phương thức nào triển khai phương thức (nếu có). Chúng ta có thể xem danh sách tổ tiên bằng cách gọi SubClass.ancestors
. Trong Rails, kết quả sẽ như thế này:
[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 tôi sẽ không mổ xẻ toàn bộ danh sách này ở đây; cho mục đích của chúng tôi, cần lưu ý rằng SubClass
ở trên cùng, với BaseClass
bên dưới nó. Ngoài ra, hãy lưu ý rằng BasicObject
là ở dưới cùng; đây là Đối tượng cấp cao nhất trong Ruby, vì vậy nó sẽ luôn ở dưới cùng của ngăn xếp.
Mô-đun (còn gọi là 'Mixins')
Mọi thứ trở nên phức tạp hơn một chút khi chúng tôi thêm các mô-đun vào hỗn hợp. Một mô-đun không phải là tổ tiên trong hệ thống phân cấp lớp, nhưng chúng ta có thể "bao gồm" nó vào lớp của mình để Ruby phải biết khi nào cần kiểm tra mô-đun để tìm một phương thức, hoặc thậm chí mô-đun nào cần kiểm tra đầu tiên trong trường hợp có nhiều mô-đun được đưa vào. .
Một số ngôn ngữ không cho phép loại "đa kế thừa" này, nhưng Ruby thậm chí còn tiến xa hơn một bước bằng cách cho phép chúng tôi chọn nơi mô-đun được chèn vào hệ thống phân cấp bằng cách chúng tôi bao gồm hay thêm mô-đun.
Mô-đun ưu tiên
Các mô-đun prepended, như tên gọi của chúng phần nào gợi ý, được chèn vào tổ tiên danh sách trước lớp, về cơ bản ghi đè lên bất kỳ phương thức nào của lớp. Điều này cũng có nghĩa là bạn có thể gọi "super" trong phương thức của mô-đun được bổ sung trước để gọi phương thức của lớp ban đầu.
module PrependedModule
def test
"module"
end
def super_test
super
end
end
# Re-using `BaseClass` from earlier
class SubClass < BaseClass
prepend PrependedModule
def test
"Subclass"
end
def super_test
"Super calls SubClass"
end
end
Tổ tiên của SubClass bây giờ trông giống như sau:
[PrependedModule,
SubClass,
BaseClass,
ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency,
...
]
Với danh sách tổ tiên mới này, PrependedModule
của chúng tôi bây giờ là đầu tiên trong dòng, có nghĩa là Ruby sẽ xem xét ở đó đầu tiên cho bất kỳ phương thức nào chúng ta gọi trên SubClass
. Cái này cũng có nghĩa là nếu chúng ta gọi super
trong PrependedModule
, chúng tôi sẽ gọi phương thức trên SubClass
:
> SubClass.new.test
=> "module"
> SubClass.new.super_test
=> "Super calls SubClass"
Bao gồm các Mô-đun
Mặt khác, các mô-đun bao gồm được chèn vào tổ tiên sau lớp. Điều này làm cho chúng trở nên lý tưởng cho các phương thức chặn mà nếu không sẽ được xử lý bởi lớp cơ sở.
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 sự sắp xếp này, tổ tiên của SubClass bây giờ trông giống như sau:
[SubClass,
IncludedModule,
BaseClass,
ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency,
...
]
Bây giờ, SubClass là điểm gọi đầu tiên, vì vậy Ruby sẽ chỉ thực thi các phương thức trong IncludedModule
nếu chúng không có trong SubClass
. Đối với super
, mọi cuộc gọi đến super
trong SubClass
sẽ chuyển đến IncludedModule
đầu tiên, trong khi bất kỳ cuộc gọi nào đến super
trong vòng IncludedModule
sẽ chuyển đến BaseClass
.
Nói một cách khác, một mô-đun bao gồm nằm giữa một lớp con và lớp cơ sở của nó trong hệ thống phân cấp tổ tiên. Điều này hiệu quả có nghĩa là chúng có thể được sử dụng để 'chặn' các phương thức mà lớp cơ sở sẽ xử lý theo cách khác:
> SubClass.new.test
=> "Subclass"
> SubClass.new.super_test
=> "Super calls BaseClass"
Vì "chuỗi lệnh" này, Ruby phải theo dõi tổ tiên của các lớp. Tuy nhiên, điều ngược lại là không đúng. Với một lớp cụ thể, Ruby không cần theo dõi các lớp con hoặc "con cháu" của nó, vì nó sẽ không bao giờ cần thông tin này để thực thi một phương thức.
Thứ tự Tổ tiên
Những độc giả tinh ý có thể nhận ra rằng nếu chúng ta đang sử dụng nhiều mô-đun trong một lớp, thì thứ tự chúng ta đưa vào (hoặc thêm trước) chúng có thể tạo ra các kết quả khác nhau. Ví dụ, tùy thuộc vào các phương thức, lớp này:
class SubClass < BaseClass
include IncludedModule
include IncludedOtherModule
end
và lớp này:
class SubClass < BaseClass
include IncludedOtherModule
include IncludedModule
end
Có thể đã cư xử hoàn toàn khác. Nếu hai mô-đun này có các phương thức có cùng tên, thì thứ tự ở đây sẽ xác định phương thức nào được ưu tiên và nơi các cuộc gọi đến super
sẽ được giải quyết. Theo cá nhân tôi, tôi sẽ tránh tối đa việc có các phương thức trùng lặp nhau như thế này, đặc biệt là để tránh phải lo lắng về những thứ như thứ tự các mô-đun được bao gồm.
Cách sử dụng trong Thế giới thực
Mặc dù thật tốt khi biết sự khác biệt giữa include
và prepend
đối với mô-đun, tôi nghĩ rằng một ví dụ trong thế giới thực hơn sẽ giúp hiển thị khi nào bạn có thể chọn cái này hơn cái kia. Trường hợp sử dụng chính của tôi cho các mô-đun như thế này là với các công cụ Rails.
Có lẽ một trong những công cụ Rails phổ biến nhất được phát minh ra. Giả sử chúng tôi muốn thay đổi thuật toán thông báo mật khẩu đang được sử dụng, nhưng trước tiên, một tuyên bố từ chối trách nhiệm nhanh:
Việc sử dụng các mô-đun hàng ngày của tôi là để tùy chỉnh hành vi của một công cụ Rails nắm giữ logic nghiệp vụ mặc định của chúng tôi. Chúng tôi đang ghi đè hành vi của mã chúng tôi kiểm soát . Tất nhiên, bạn có thể áp dụng cùng một phương pháp cho bất kỳ phần nào của Ruby, nhưng Tôi không khuyên bạn nên ghi đè mã mà bạn không kiểm soát (ví dụ:từ đá quý do người khác duy trì), vì bất kỳ thay đổi nào đối với mã bên ngoài đó có thể không tương thích với các thay đổi của bạn.
Thông báo mật khẩu của Devise xảy ra tại đây trong mô-đun Devise ::Models ::DatabaseAuthenticatable:
def password_digest(password)
Devise::Encryptor.digest(self.class, password)
end
# and also in the password check:
def valid_password?(password)
Devise::Encryptor.compare(self.class, encrypted_password, password)
end
Devise cho phép bạn tùy chỉnh thuật toán đang được sử dụng ở đây bằng cách tạo Devise::Encryptable::Encryptors
của riêng bạn , đó là cách chính xác để làm điều đó. Tuy nhiên, vì mục đích trình diễn, chúng tôi sẽ sử dụng một mô-đun.
# app/models/password_digest_module
module PasswordDigestModule
def password_digest(password)
# Devise's default bcrypt is better for passwords,
# using sha1 here just for demonstration
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 - because we are calling User here, ActiveRecord will
# try to read from the database when this class is loaded.
# This can cause commands like `rails db:create` to fail.
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
end
Để tải mô-đun này, bạn cần gọi Rails.application.eager_load!
trong quá trình phát triển hoặc thêm bộ khởi tạo Rails để tải tệp. Bằng cách thử nghiệm nó, chúng tôi có thể thấy nó hoạt động như mong đợi:
> User.create!(email: "[email protected]", name: "Test", password: "TestPassword")
=> #<User id: 1, name: "Test", created_at: "2021-05-01 02:08:29", updated_at: "2021-05-01 02:08:29", posts_count: nil, email: "[email protected]">
> User.first.valid_password?("TestPassword")
=> true
> User.first.encrypted_password
=> "4203189099774a965101b90b74f1d842fc80bf91"
Trong trường hợp của chúng tôi ở đây, cả include
và prepend
sẽ có kết quả tương tự, nhưng hãy thêm một sự phức tạp. Điều gì sẽ xảy ra nếu mô hình Người dùng của chúng tôi triển khai password_salt
của riêng nó nhưng chúng tôi muốn ghi đè nó trong các phương thức mô-đun của chúng tôi:
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 đó, chúng tôi cập nhật mô-đun của mình để sử dụng password_salt
của riêng nó khi tạo thông báo mật khẩu:
def password_digest(password)
# Devise's default bcrypt is better for passwords,
# using sha1 here just for demonstration
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
sẽ hoạt động khác vì cái nào chúng tôi sử dụng sẽ xác định password_salt
nào phương thức Ruby thực thi. Với prepend
, mô-đun sẽ được ưu tiên và chúng tôi nhận được điều này:
> User.last.password_digest("test")
=> "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.salt"
Thay đổi mô-đun để sử dụng include
thay vào đó sẽ có nghĩa là việc triển khai lớp Người dùng được ưu tiên:
> User.last.password_digest("test")
=> "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.dHdvQHRlc3QuY2"
Nói chung, tôi đạt được prepend
đầu tiên bởi vì, khi viết một mô-đun, tôi thấy dễ coi nó giống như một lớp con hơn và cho rằng bất kỳ phương thức nào trong mô-đun sẽ ghi đè phiên bản của lớp đó. Rõ ràng, điều này không phải lúc nào cũng mong muốn, đó là lý do tại sao Ruby cũng cung cấp cho chúng ta include
tùy chọn.
Con cháu
Chúng ta đã thấy cách Ruby theo dõi tổ tiên của lớp để biết thứ tự ưu tiên khi thực thi các phương thức, cũng như cách chúng ta chèn các mục nhập vào danh sách này thông qua các mô-đun. Tuy nhiên, với tư cách là lập trình viên, có thể hữu ích khi lặp lại qua tất cả các lớp ' con cháu , cũng vậy. Đây là nơi #descendants
của ActiveSupport phương thức đi kèm. Phương thức này khá ngắn và dễ dàng sao chép bên 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à một phần rất thú vị của Ruby lưu trữ thông tin về mọi Ruby Object hiện có trong bộ nhớ. Chúng tôi sẽ không đi sâu vào nó ở đây, nhưng nếu bạn có một lớp được xác định trong ứng dụng của mình (và nó đã được tải), nó sẽ có mặt trong ObjectSpace. ObjectSpace#each_object
, khi được chuyển qua một mô-đun, chỉ trả về các đối tượng phù hợp hoặc là các lớp con của mô-đun; khối ở đây cũng từ chối cấp cao nhất (ví dụ:nếu chúng ta gọi Numeric.descendants
, chúng tôi không mong đợi Numeric
để có trong kết quả).
Đừng lo lắng nếu bạn không hiểu rõ những gì đang xảy ra ở đây, vì có lẽ cần phải đọc nhiều hơn trên ObjectSpace để thực sự hiểu được nó. Đối với mục đích của chúng tôi, đủ để biết rằng phương thức này sống trên Class
và trả về danh sách các lớp con cháu hoặc bạn có thể coi đó là "cây gia đình" của con, cháu của lớp đó, v.v.
Sử dụng #descendants trong thế giới thực
Trong RailsConf 2018, Ryan Laughlin đã có một bài nói chuyện về 'kiểm tra sức khỏe'. Video rất đáng xem, nhưng chúng tôi sẽ chỉ trích xuất một ý tưởng, đó là chạy định kỳ qua tất cả các hàng trong cơ sở dữ liệu của bạn và kiểm tra xem chúng có vượt qua kiểm tra tính hợp lệ của mô hình của bạn hay không. Bạn có thể ngạc nhiên khi có bao nhiêu hàng trong cơ sở dữ liệu của mình không vượt qua #valid?
kiểm tra.
Sau đó, câu hỏi đặt ra là làm cách nào để chúng ta thực hiện việc kiểm tra này mà không cần phải duy trì danh sách các mô hình theo cách thủ công? #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
Tại đây, ApplicationRecord.descendants
cung cấp cho chúng tôi danh sách mọi mô hình trong một ứng dụng Rails tiêu chuẩn. Trong vòng lặp của chúng tôi, sau đó, model
là lớp (ví dụ:User
hoặc Product
). Việc triển khai ở đây khá cơ bản, nhưng kết quả là điều này sẽ lặp lại qua mọi mô hình (hoặc chính xác hơn là mọi lớp con của ApplicationRecord) và gọi .valid?
cho mọi hàng.
Kết luận
Đối với hầu hết các nhà phát triển Rails, các mô-đun không được sử dụng phổ biến. Đây là một lý do chính đáng; nếu bạn sở hữu mã, thường có nhiều cách dễ dàng hơn để tùy chỉnh hành vi của mã và nếu bạn không sở hữu mã, có những rủi ro trong việc thay đổi hành vi của nó với các mô-đun. Tuy nhiên, chúng có các trường hợp sử dụng và đó là minh chứng cho tính linh hoạt của Ruby rằng chúng ta không chỉ có thể thay đổi một lớp từ tệp khác mà chúng ta còn có tùy chọn để chọn ở đâu trong chuỗi tổ tiên, mô-đun của chúng tôi xuất hiện.
ActiveSupport sau đó đi vào để cung cấp nghịch đảo của #ancestors
với #descendants
. Phương pháp này hiếm khi được sử dụng nhiều như tôi đã thấy, nhưng một khi bạn biết nó ở đó, bạn có thể sẽ tìm thấy ngày càng nhiều cách sử dụng cho nó. Cá nhân tôi đã sử dụng nó không chỉ để kiểm tra tính hợp lệ của mô hình, mà còn với các thông số kỹ thuật để xác nhận rằng chúng tôi đang thêm attribute_alias
một cách chính xác cho tất cả các mô hình của chúng tôi.