Rails เพิ่มหลายสิ่งเข้าไปใน built-in objects ของ Ruby นี่คือสิ่งที่บางคนเรียกว่า "dialect" ของ Ruby และเป็นสิ่งที่ทำให้นักพัฒนา Rails เขียนบรรทัดแบบ 1.day.ago ได้
ส่วนใหญ่ของเมธอดเพิ่มเติมเหล่านี้อยู่ใน ActiveSupport วันนี้เราจะดูเมธอดที่อาจรู้จักน้อยกว่า ที่ ActiveSupport เพิ่มเข้าไปใน Class โดยตรง: descendants เมธอดนี้คืน subclasses ทั้งหมดของ class ที่เรียก ตัวอย่าง ApplicationRecord.descendants จะคืน classes ในแอปที่ inherit จากมัน (เช่น models ทั้งหมดในแอปพลิเคชัน) ในบทความนี้เราจะดูว่ามันทำงานยังไง ทำไมถึงอยากใช้ และมัน augment built-in inheritance-related methods ของ Ruby อย่างไร
Inheritance ใน Object-Oriented Languages
ก่อนอื่น เราจะรีเฟรชเร็วๆ เกี่ยวกับ inheritance model ของ Ruby เหมือน OO languages อื่นๆ Ruby ใช้ objects ที่อยู่ใน hierarchy สร้าง class แล้ว subclass ของ class นั้น แล้ว subclass ของ subclass นั้น ต่อไป เมื่อเดินขึ้น hierarchy จะได้ list ของ ancestors Ruby มี feature ดีที่ ทั้งหมด เป็น objects เอง (รวม classes, integers, และแม้แต่ nil) ในขณะที่บางภาษาใช้ "primitives" ที่ไม่ใช่ true objects มักเพื่อ performance (เช่น integers, doubles, booleans ฯลฯ มองที่คุณ Java)
Ruby และ OO languages ทั้งหมด ต้อง track ancestors เพื่อรู้ว่าจะ lookup methods ที่ไหน และอันไหนมี precedence
class BaseClass
def base
"base"
end
def overridden
"Base"
end
end
class SubClass < BaseClass
def overridden
"Subclass"
end
end
ที่นี่ การเรียก SubClass.new.overridden ให้ "SubClass" แต่ SubClass.new.base ไม่อยู่ใน SubClass definition ดังนั้น Ruby จะเดินผ่าน ancestors แต่ละตัวดูว่าตัวไหน implement method (ถ้ามี) เราสามารถดู list of ancestors ด้วย SubClass.ancestors ใน Rails ผลลัพธ์จะประมาณนี้:
[SubClass, BaseClass, ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency, ActiveSupport::ToJsonWithActiveSupportEncoder, Object, PP::ObjectMixin, JSON::Ext::Generator::GeneratorMethods::Object, ActiveSupport::Tryable, ActiveSupport::Dependencies::Loadable, Kernel, BasicObject]
เราจะไม่แยกวิเคราะห์ทั้งลิสต์ สำหรับเราเพียงพอที่จะเห็น SubClass อยู่บนสุด พร้อม BaseClass ใต้ และ BasicObject อยู่ล่างสุด นี่คือ top-level Object ใน Ruby จะอยู่ล่างสุดของ stack เสมอ
Modules (a.k.a. 'Mixins')
เรื่องซับซ้อนขึ้นเมื่อเพิ่ม modules เข้าไป Module ไม่ใช่ ancestor ใน class hierarchy แต่เราสามารถ "include" เข้า class ได้ ทำให้ Ruby ต้องรู้ว่าจะเช็ค module สำหรับ method เมื่อไหร่ หรือว่าจะเช็ค module ไหนก่อนกรณี include หลาย modules
บางภาษาไม่อนุญาต "multiple inheritance" แบบนี้ แต่ Ruby ไปไกลกว่าด้วยการให้เลือกว่า module จะถูก insert ใน hierarchy ที่ไหน ผ่าน include หรือ prepend
Prepending Modules
Prepended modules ตามชื่อ บ่งชี้ว่า insert เข้า ancestors list ก่อน class โดย基本上 override methods ของ class ใดๆ นี่ยังหมายถึงคุณสามารถเรียก "super" ในเมธอดของ prepended module เพื่อเรียกเมธอดเดิมของ class
module PrependedModule
def test
"module"
end
def super_test
super
end
end
# Re-using `BaseClass` จากก่อนหน้า
class SubClass < BaseClass
prepend PrependedModule
def test
"Subclass"
end
def super_test
"Super calls SubClass"
end
end
Ancestors สำหรับ SubClass ตอนนี้เป็น:
[PrependedModule, SubClass, BaseClass, ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency, ... ]
ด้วย ancestors list ใหม่ PrependedModule เป็น first-in-line Ruby จะมองที่นั่นก่อนสำหรับ methods ใดๆ ที่เรียกบน SubClass นี่ ยัง 意味著 หากเราเรียก super ภายใน PrependedModule เราจะเรียกเมธอดบน SubClass:
> SubClass.new.test
=> "module"
> SubClass.new.super_test
=> "Super calls SubClass"
Including Modules
Included modules 反面 插入 ancestors หลัง class ทำให้เหมาะสำหรับ intercept methods ที่ 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
ด้วยการจัดเรียงนี้ ancestors สำหรับ SubClass กลายเป็น:
[SubClass, IncludedModule, BaseClass, ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency, ... ]
ตอนนี้ SubClass เป็น first point of call Ruby จะ execute methods ใน IncludedModule ก็ต่อเมื่อไม่มีใน SubClass สำหรับ super เรียก super ใน SubClass จะไป IncludedModule ก่อน ในขณะที่ super ภายใน IncludedModule จะไป BaseClass
อีกนัยหนึ่ง included module นั่งระหว่าง subclass และ base class ใน ancestor hierarchy นี่ effectively หมายถึงพวกมันสามารถ用來 'intercept' methods ที่ base class จะจัดการ:
> SubClass.new.test
=> "Subclass"
> SubClass.new.super_test
=> "Super calls BaseClass"
เนื่องจาก "chain of command" นี้ Ruby ต้อง track ancestors ของ class แต่กลับกันไม่จริง ด้วย class เฉพาะ Ruby ไม่ต้อง track children หรือ "descendants" เพราะไม่ต้องใช้ข้อมูลนี้ execute method
Ancestor Ordering
ผู้อ่าน敏銳อาจรู้สึกว่า ถ้าใช้ multiple modules ใน class ลำดับ include (หรือ prepend) อาจให้ผลต่างกัน ตัวอย่าง ขึ้นอยู่กับ methods class นี้:
class SubClass < BaseClass
include IncludedModule
include IncludedOtherModule
end
และ class นี้:
class SubClass < BaseClass
include IncludedOtherModule
include IncludedModule
end
อาจ behave แตกต่างมาก หากสอง modules มี methods ชื่อเดียวกัน ลำดับที่นี่จะ determine ว่าอันไหน precedence และ เรียก super จะ resolve ไปที่ไหน โดยส่วนตัวผมหลีกเลี่ยง methods ที่ overlap แบบนี้ มากที่สุด เพื่อไม่ต้องกังวลเรื่อง order ของ modules included
Real-World Usage
แม้รู้ความแตกต่าง include และ prepend สำหรับ modules แล้ว แต่ตัวอย่าง real-world ช่วยแสดงเมื่อไหร่ควรเลือกอันไหน Use-case หลักของผมสำหรับ modules แบบนี้คือ Rails engines
อาจจะเป็น Rails engine ยอดนิยมที่สุดคือ devise สมมติเราอยากเปลี่ยน password digest algorithm ที่ใช้ แต่ก่อนอื่น disclaimer:
การใช้ modules ประจำวันของผมคือ customize behavior ของ Rails engine ที่เก็บ default business logic ของเรา เรา override behavior ของโค้ด ที่เราควบคุม คุณสามารถ apply วิธีเดียวกันกับ Ruby ใดๆ แต่ ไม่แนะนำ override โค้ดที่คุณไม่ควบคุม (เช่น gems ที่ maintained โดยคนอื่น) เพราะการเปลี่ยนแปลง external code อาจ incompatible กับ changes ของคุณ
Password digest ของ Devise เกิดขึ้นใน module Devise::Models::DatabaseAuthenticatable:
def password_digest(password)
Devise::Encryptor.digest(self.class, password)
end
# และใน password check:
def valid_password?(password)
Devise::Encryptor.compare(self.class, encrypted_password, password)
end
Devise อนุญาต customize algorithm โดยสร้าง Devise::Encryptable::Encryptors ของตัวเอง ซึ่งเป็นวิธีที่ถูกต้อง แต่เพื่อ demonstration เราจะใช้ module
# 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 - เนื่องจากเราเรียก User ที่นี่ ActiveRecord จะ
# พยายามอ่านจาก database เมื่อ class โหลด
# สาเหตุ commands เช่น `rails db:create` fail
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
end
เพื่อโหลด module ต้องเรียก Rails.application.eager_load! ใน development หรือเพิ่ม Rails initializer โหลดไฟล์ ทดสอบจะเห็นทำงานแล้ว:
> User.create!(email: "one@test.com", name: "Test", password: "TestPassword")
=> #
> User.first.valid_password?("TestPassword")
=> true
> User.first.encrypted_password
=> "4203189099774a965101b90b74f1d842fc80bf91"
ในกรณีเรา include และ prepend ให้ผลลัพธ์เหมือนกัน แต่ลองเพิ่ม complication ถ้า User model implement password_salt เอง แต่เราอยาก override ใน module methods:
class User < ApplicationRecord
# Include default devise modules...
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
อัปเดต module ใช้ password_salt ของตัวเองเมื่อสร้าง password digest:
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
ตอนนี้ include และ prepend จะ behave ต่างกัน เพราะอันไหนเราใช้ จะ determine ว่า password_salt method ไหน Ruby executes ด้วย prepend module จะ precedence และได้:
> User.last.password_digest("test")
=> "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.salt"
เปลี่ยน module ใช้ include แทน จะหมายถึง User class implementation precedence:
> User.last.password_digest("test")
=> "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.dHdvQHRlc3QuY2"
โดยทั่วไป ผมใช้ prepend ก่อน เพราะตอนเขียน module ผมรู้สึกง่ายกว่า ถ้า treat มันเหมือน subclass และ assume ว่า method ใดใน module จะ override class version แน่นอนไม่ได้ต้องการเสมอไป ซึ่งเป็นเหตุผลที่ Ruby ให้ include option ด้วย
Descendants
เราเห็นว่า Ruby track class ancestors เพื่อรู้ order-of-precedence execute methods และเรา insert entries ลง list นี้ผ่าน modules แต่ในฐานะนักเขียนโปรแกรม อาจมีประโยชน์ที่จะ iterate ผ่านทั้งหมดของ class descendants ด้วย นี่คือที่มาของ ActiveSupport #descendants method เมธอดนี้สั้นและ duplicate ได้ง่ายนอก Rails หากต้องการ:
class Class
def descendants
ObjectSpace.each_object(singleton_class).reject do |k|
k.singleton_class? || k == self
end
end
end
ObjectSpace เป็นส่วนน่าสนใจของ Ruby ที่เก็บข้อมูลเกี่ยวกับทุก Ruby Object ใน memory ปัจจุบัน เราจะไม่ลงลึก แต่ถ้ามี class defined ในแอป (และโหลดแล้ว) จะอยู่ใน ObjectSpace ObjectSpace#each_object เมื่อส่ง module จะคืน objects ที่ match หรือเป็น subclasses ของ module บล็อกที่นี่ reject top level (เช่น ถ้าเรียก Numeric.descendants ไม่คาดหวัง Numeric ใน results)
ไม่ต้องกังวลถ้าไม่เข้าใจสนิท อ่านเพิ่มเติม ObjectSpace คงต้องการเพื่อเข้าใจจริง สำหรับเรา เพียงพอที่รู้ว่าเมธอดนี้อยู่บน Class คืน list of descendant classes หรือคิดว่าเป็น "family tree" ของลูก หลาน ฯลฯ ของ class นั้น
Real-World Use of #descendants
ใน RailsConf 2018 Ryan Laughlin ให้ talk เรื่อง 'checkups' วิดีโอดูคุ้มค่า แต่เราจะดึง idea หนึ่ง คือ รันทุก rows ใน database เป็นระยะ ตรวจสอบว่าผ่าน validity checks ของ models หรือไม่ อาจประหลาดใจว่ามี rows 多少 ไม่ผ่าน test #valid?
คำถามคือ implement check นี้ยังไงโดยไม่ต้อง maintain list of models ด้วยมือ? #descendants คือคำตอบ:
# 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
ที่นี่ ApplicationRecord.descendants ให้ list ของทุก model ใน Rails app มาตรฐาน ใน loop model คือ class (เช่น User หรือ Product) Implementation ที่นี่ basic แต่ผลลัพธ์คือจะ iterate ทุก model (หรือ subclass ของ ApplicationRecord) เรียก .valid? สำหรับทุก row
สรุป
สำหรับนักพัฒนา Rails ส่วนใหญ่ modules ไม่ค่อยใช้ มากับเหตุผลที่ดี หากคุณเป็นเจ้าของโค้ด มักมีวิธีง่ายกว่าปรับ behavior และหากคุณ ไม่ เป็นเจ้าของโค้ด มีความเสี่ยงเปลี่ยน behavior ด้วย modules อย่างไรก็ตาม พวกมันมี use-cases และเป็นหลักฐานของความยืดหยุ่นของ Ruby ที่ไม่ได้แค่เปลี่ยน class จากไฟล์อื่น เรายังมี option เลือก ที่ไหน ใน ancestor chain module ของเราจะปรากฏ
ActiveSupport จึงมา provide inverse ของ #ancestors ด้วย #descendants เมธอดนี้ใช้บ่อยน้อยตามที่เห็น แต่เมื่อรู้ว่ามันอยู่ คุณน่าจะหา uses มากขึ้น ผมใช้ไม่ได้แค่ check model validity แต่แม้แต่ specs validate ว่าเราเพิ่ม attribute_alias methods ให้ models ทั้งหมดถูกต้อง