Computer >> คอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

ActiveSupports วิธี #descendants:A Deep Dive

Rails เพิ่มหลายสิ่งหลายอย่างให้กับวัตถุในตัวของ Ruby นี่คือสิ่งที่บางคนเรียกว่า "ภาษาถิ่น" ของ Ruby และเป็นสิ่งที่ช่วยให้นักพัฒนา Rails สามารถเขียนบรรทัดต่างๆ เช่น 1.day.ago .

วิธีการพิเศษเหล่านี้ส่วนใหญ่อยู่ใน ActiveSupport วันนี้ เราจะมาดูวิธีที่อาจไม่ค่อยมีใครรู้จักซึ่ง ActiveSupport เพิ่มลงใน Class โดยตรง:descendants . เมธอดนี้ส่งคืนคลาสย่อยทั้งหมดของคลาสที่เรียก ตัวอย่างเช่น ApplicationRecord.descendants จะส่งคืนคลาสในแอปของคุณที่สืบทอดมาจากมัน (เช่น ทุกรุ่นในแอปพลิเคชันของคุณ) ในบทความนี้ เราจะมาดูวิธีการทำงาน เหตุผลที่คุณอาจต้องการใช้ และวิธีเพิ่มวิธีการที่เกี่ยวข้องกับการสืบทอดในตัวของ Ruby

การสืบทอดในภาษาเชิงวัตถุ

อันดับแรก เราจะให้การทบทวนอย่างรวดเร็วเกี่ยวกับรูปแบบการสืบทอดของ Ruby เช่นเดียวกับภาษาเชิงวัตถุ (OO) อื่นๆ Ruby ใช้วัตถุที่อยู่ในลำดับชั้น คุณสามารถสร้างคลาส จากนั้นเป็นคลาสย่อยของคลาสนั้น จากนั้นเป็นคลาสย่อยของคลาสย่อยนั้น และอื่นๆ เมื่อเดินขึ้นลำดับนี้ เราก็ได้รายชื่อบรรพบุรุษ Ruby ยังมีคุณสมบัติที่ดีที่ ทั้งหมด เอนทิตีเป็นวัตถุเอง (รวมถึงคลาส จำนวนเต็ม และแม้แต่ศูนย์) ในขณะที่ภาษาอื่นบางภาษามักใช้ "พื้นฐาน" ที่ไม่ใช่วัตถุจริง โดยปกติแล้วเพื่อประสิทธิภาพ (เช่น จำนวนเต็ม คู่ บูลีน ฯลฯ ฉัน' มองมาที่คุณ Java)

Ruby และภาษา OO ทั้งหมดต้องติดตามบรรพบุรุษเพื่อที่จะรู้ว่าจะหาวิธีการได้ที่ไหนและภาษาใดมีความสำคัญกว่า

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 ของเรา ดังนั้น Ruby จะตรวจสอบบรรพบุรุษแต่ละคนเพื่อดูว่าวิธีใดนำวิธีการไปใช้ (ถ้ามี) เราสามารถดูรายชื่อบรรพบุรุษได้โดยเพียงแค่เรียก 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 อยู่ที่ด้านล่าง นี่คือออบเจ็กต์ระดับบนสุดใน Ruby ดังนั้นจึงจะอยู่ที่ด้านล่างของสแต็กเสมอ

โมดูล (a.k.a. 'Mixins')

สิ่งต่างๆ จะซับซ้อนขึ้นเล็กน้อยเมื่อเราเพิ่มโมดูลลงในมิกซ์ โมดูลไม่ใช่บรรพบุรุษในลำดับชั้นของคลาส แต่เราสามารถ "รวม" ไว้ในคลาสของเราได้ ดังนั้น Ruby จึงต้องรู้ว่าเมื่อใดควรตรวจสอบโมดูลสำหรับเมธอด หรือแม้แต่โมดูลใดที่จะตรวจสอบก่อนในกรณีที่มีหลายโมดูลรวมอยู่ด้วย .

บางภาษาไม่อนุญาตให้มี "การสืบทอดหลายรายการ" ประเภทนี้ แต่ Ruby ยังก้าวไปอีกขั้นโดยให้เราเลือกตำแหน่งที่จะแทรกโมดูลลงในลำดับชั้น ว่าเรารวมหรือเพิ่มโมดูลไว้ข้างหน้าหรือไม่

กำลังเตรียมโมดูล

โมดูลที่เติมหน้า ตามชื่อที่แนะนำ จะถูกแทรกลงในรายการบรรพบุรุษก่อนคลาส โดยทั่วไปจะแทนที่เมธอดใดๆ ของคลาส นอกจากนี้ยังหมายความว่าคุณสามารถเรียก "super" ในเมธอดของโมดูลที่นำหน้าเพื่อเรียกเมธอดของคลาสดั้งเดิมได้

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

บรรพบุรุษสำหรับ SubClass ตอนนี้มีลักษณะดังนี้:

[PrependedModule,
 SubClass,
 BaseClass,
 ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency,
 ...
]

ด้วยรายชื่อบรรพบุรุษใหม่นี้ PrependedModule . ของเรา ตอนนี้อยู่ในบรรทัดแรก หมายความว่า Ruby จะมองหาวิธีการใดๆ ที่เราเรียกใช้ใน SubClass ที่นั่นก่อน . นี้ด้วย หมายความว่าถ้าเราเรียก super ภายใน PrependedModule เราจะเรียกเมธอดใน SubClass :

> SubClass.new.test
=> "module"
> SubClass.new.super_test
=> "Super calls SubClass"

รวมโมดูล

โมดูลที่รวมไว้จะถูกแทรกลงในบรรพบุรุษ หลังจาก ห้องเรียน. วิธีนี้เหมาะอย่างยิ่งสำหรับวิธีการสกัดกั้นที่คลาสพื้นฐานจะจัดการ

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

ด้วยการจัดเรียงนี้ บรรพบุรุษของ SubClass จะมีลักษณะดังนี้:

[SubClass,
 IncludedModule,
 BaseClass,
 ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency,
 ...
]

ตอนนี้ SubClass เป็นจุดแรกของการโทร ดังนั้น Ruby จะดำเนินการเฉพาะเมธอดใน IncludedModule หากไม่มีอยู่ใน SubClass . ส่วน super , การเรียกใด ๆ ไปยัง super ใน SubClass จะไปที่ IncludedModule อย่างแรกในขณะที่เรียกใช้ super ภายใน IncludedModule จะไปที่ BaseClass .

ในอีกทางหนึ่ง โมดูลที่รวมไว้จะอยู่ระหว่างคลาสย่อยและคลาสพื้นฐานในลำดับชั้นของบรรพบุรุษ นี่หมายความว่าสามารถใช้เพื่อ 'สกัดกั้น' วิธีที่อาจได้รับการจัดการโดยคลาสพื้นฐาน:

> SubClass.new.test
=> "Subclass"
> SubClass.new.super_test
=> "Super calls BaseClass"

ด้วยเหตุนี้ "สายการบังคับบัญชา" นี้ Ruby จึงต้องติดตามบรรพบุรุษของคลาส กลับไม่เป็นความจริงแม้ว่า สำหรับคลาสใดคลาสหนึ่ง Ruby ไม่จำเป็นต้องติดตามลูกๆ หรือ "ผู้สืบทอด" เพราะจะไม่ต้องการข้อมูลนี้เพื่อดำเนินการเมธอด

การจัดลำดับบรรพบุรุษ

ผู้อ่านที่ชาญฉลาดอาจรู้ว่าถ้าเราใช้หลายโมดูลในชั้นเรียน ลำดับที่เรารวม (หรือเติมหน้า) โมดูลเหล่านี้อาจให้ผลลัพธ์ที่แตกต่างกัน ตัวอย่างเช่น ขึ้นอยู่กับเมธอด คลาสนี้:

class SubClass < BaseClass
  include IncludedModule
  include IncludedOtherModule
end

และคลาสนี้:

class SubClass < BaseClass
  include IncludedOtherModule
  include IncludedModule
end

อาจมีพฤติกรรมแตกต่างไปจากเดิมอย่างสิ้นเชิง หากทั้งสองโมดูลมีเมธอดที่มีชื่อเหมือนกัน ลำดับที่นี่จะเป็นตัวกำหนดว่าโมดูลใดมีความสำคัญมากกว่า และ ที่เรียก super จะได้รับการแก้ไขเพื่อ โดยส่วนตัวแล้ว ฉันจะหลีกเลี่ยงการมีวิธีการที่ทับซ้อนกันเช่นนี้ให้มากที่สุด โดยเฉพาะอย่างยิ่งเพื่อหลีกเลี่ยงความกังวลเกี่ยวกับสิ่งต่าง ๆ เช่น ลำดับที่รวมโมดูลไว้

การใช้งานจริง

แม้ว่าจะเป็นการดีที่จะทราบความแตกต่างระหว่าง include และ prepend สำหรับโมดูล ฉันคิดว่าตัวอย่างในโลกแห่งความเป็นจริงจะช่วยแสดงให้เห็นว่าเมื่อใดที่คุณอาจเลือกอันใดอันหนึ่งมากกว่าอีกอันหนึ่ง กรณีการใช้งานหลักของฉันสำหรับโมดูลเช่นนี้คือเครื่องยนต์ Rails

อาจเป็นหนึ่งในเครื่องยนต์ Rails ที่ได้รับความนิยมมากที่สุด สมมติว่าเราต้องการเปลี่ยนอัลกอริทึมไดเจสต์รหัสผ่านที่กำลังใช้อยู่ แต่ก่อนอื่น ขอปฏิเสธความรับผิดชอบอย่างรวดเร็ว:

การใช้โมดูลในแต่ละวันของฉันคือการปรับแต่งการทำงานของกลไก Rails ที่มีตรรกะทางธุรกิจเริ่มต้นของเรา เรากำลังเอาชนะพฤติกรรมของโค้ด เราควบคุม . แน่นอน คุณสามารถใช้วิธีเดียวกันกับ Ruby ชิ้นใดก็ได้ แต่ ฉันจะไม่แนะนำให้แทนที่โค้ดที่คุณไม่ได้ควบคุม (เช่น จากอัญมณีที่ดูแลโดยผู้อื่น) เนื่องจากการเปลี่ยนแปลงใดๆ ในโค้ดภายนอกนั้นอาจเข้ากันไม่ได้กับการเปลี่ยนแปลงของคุณ

สรุปรหัสผ่านของ Devise เกิดขึ้นที่นี่ในโมดูล 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 ให้คุณปรับแต่งอัลกอริทึมที่ใช้ที่นี่โดยสร้าง Devise::Encryptable::Encryptors ของคุณเอง ซึ่งเป็นวิธีที่ถูกต้อง อย่างไรก็ตาม เพื่อจุดประสงค์ในการสาธิต เราจะใช้โมดูล

# 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

หากต้องการโหลดโมดูลนี้ คุณจะต้องเรียก Rails.application.eager_load! ในการพัฒนาหรือเพิ่มตัวเริ่มต้น Rails เพื่อโหลดไฟล์ เมื่อทดสอบแล้ว เราพบว่าใช้งานได้ตามที่คาดไว้:

> 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"

ในกรณีของเราที่นี่ ทั้ง include และ prepend จะได้ผลลัพธ์เหมือนกัน แต่ขอเพิ่มความซับซ้อน จะเกิดอะไรขึ้นหากโมเดลผู้ใช้ของเราใช้ password_salt . ของตัวเอง เมธอด แต่เราต้องการแทนที่ในเมธอดของโมดูลของเรา:

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

จากนั้น เราอัปเดตโมดูลของเราเพื่อใช้ password_salt . ของตัวเอง เมธอดเมื่อสร้างไดเจสต์รหัสผ่าน:

  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 จะมีพฤติกรรมแตกต่างออกไปเพราะว่าอันไหนที่เราใช้เป็นตัวกำหนด password_salt วิธีการ Ruby ดำเนินการ ด้วย prepend , โมดูลจะมีความสำคัญ และเราได้รับสิ่งนี้:

> User.last.password_digest("test")
=> "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.salt"

เปลี่ยนโมดูลเพื่อใช้ include จะหมายถึงการนำคลาส User ไปใช้แทน:

> User.last.password_digest("test")
=> "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.dHdvQHRlc3QuY2"

โดยทั่วไป ฉันไปถึงสำหรับ prepend อย่างแรกเลย เพราะเมื่อเขียนโมดูล ฉันพบว่ามันง่ายกว่าที่จะปฏิบัติต่อมันเหมือนคลาสย่อยมากกว่า และถือว่าวิธีการใดๆ ในโมดูลจะแทนที่เวอร์ชันของคลาส เห็นได้ชัดว่านี่ไม่ใช่สิ่งที่ต้องการเสมอไป ซึ่งเป็นเหตุผลที่ Ruby ให้ include . แก่เรา ตัวเลือก

ทายาท

เราได้เห็นวิธีที่ Ruby ติดตามบรรพบุรุษของคลาสเพื่อทราบลำดับความสำคัญเมื่อดำเนินการเมธอด ตลอดจนวิธีที่เราแทรกรายการลงในรายการนี้ผ่านโมดูล อย่างไรก็ตาม ในฐานะโปรแกรมเมอร์ การวนซ้ำใน ผู้สืบทอด ของคลาสทั้งหมดอาจเป็นประโยชน์ , ด้วย. นี่คือที่ที่ #descendants . ของ ActiveSupport เมธอดเข้ามา วิธีการนี้ค่อนข้างสั้นและทำซ้ำได้ง่ายนอก 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 ทุกตัวที่อยู่ในหน่วยความจำ เราจะไม่ลงรายละเอียดที่นี่ แต่ถ้าคุณมีคลาสที่กำหนดไว้ในแอปพลิเคชันของคุณ (และมันถูกโหลดแล้ว) คลาสนั้นจะปรากฏใน ObjectSpace ObjectSpace#each_object เมื่อส่งผ่านโมดูล ส่งคืนเฉพาะอ็อบเจ็กต์ที่ตรงกันหรือเป็นคลาสย่อยของโมดูล การบล็อกที่นี่ยังปฏิเสธระดับบนสุดด้วย (เช่น ถ้าเราเรียก Numeric.descendants เราไม่คาดหวัง Numeric ให้อยู่ในผลลัพธ์)

อย่ากังวลหากคุณไม่เข้าใจสิ่งที่เกิดขึ้นที่นี่ เนื่องจากอาจจำเป็นต้องอ่านเพิ่มเติมเกี่ยวกับ ObjectSpace จึงจะเข้าใจได้ สำหรับจุดประสงค์ของเรา ก็เพียงพอแล้วที่จะรู้ว่าวิธีนี้ใช้กับ Class และส่งคืนรายการของคลาสที่สืบทอด หรือคุณอาจคิดว่ามันเป็น "แผนภูมิต้นไม้" ของลูก หลาน ฯลฯ ในชั้นเรียนนั้น

การใช้งาน #descendants ในโลกแห่งความเป็นจริง

ใน RailsConf ปี 2018 Ryan Laughlin ได้บรรยายเรื่อง "การตรวจสุขภาพ" วิดีโอควรค่าแก่การชม แต่เราจะแยกแนวคิดหนึ่งออกมา ซึ่งก็คือการเรียกใช้แถวทั้งหมดในฐานข้อมูลของคุณเป็นระยะๆ และตรวจสอบว่าผ่านการตรวจสอบความถูกต้องของแบบจำลองของคุณหรือไม่ คุณอาจแปลกใจว่าฐานข้อมูลของคุณมีกี่แถวที่ไม่ผ่าน #valid? ทดสอบ

คำถามคือ เราจะนำการตรวจสอบนี้ไปใช้ได้อย่างไรโดยไม่ต้องดูแลรายการแบบจำลองด้วยตนเอง #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 แสดงรายการของทุกรุ่นในแอปพลิเคชัน Rails มาตรฐาน ในลูปของเราแล้ว model เป็นคลาส (เช่น User หรือ Product ). การใช้งานที่นี่ค่อนข้างเรียบง่าย แต่ผลที่ได้คือสิ่งนี้จะทำซ้ำในทุกโมเดล (หรือแม่นยำกว่าทุกคลาสย่อยของ ApplicationRecord) และเรียก .valid? สำหรับทุกแถว

บทสรุป

สำหรับนักพัฒนา Rails ส่วนใหญ่ มักไม่ใช้โมดูล นี่เป็นเหตุผลที่ดี หากคุณเป็นเจ้าของรหัส มักจะมีวิธีที่ง่ายกว่าในการปรับแต่งลักษณะการทำงาน และหากคุณไม่ เป็นเจ้าของรหัส มีความเสี่ยงในการเปลี่ยนพฤติกรรมด้วยโมดูล อย่างไรก็ตาม พวกเขามีกรณีการใช้งานและเป็นข้อพิสูจน์ถึงความยืดหยุ่นของ Ruby ที่ไม่เพียงแต่เราสามารถเปลี่ยนคลาสจากไฟล์อื่นได้ เรายังมีตัวเลือกให้เลือกที่ ในสายบรรพบุรุษ โมดูลของเราจะปรากฏขึ้น

ActiveSupport เข้ามาเพื่อให้ตรงกันข้ามของ #ancestors กับ #descendants . วิธีนี้ไม่ค่อยได้ใช้เท่าที่ฉันเคยเห็นมา แต่เมื่อคุณรู้ว่ามีวิธีนี้แล้ว คุณก็จะพบว่าวิธีนี้มีประโยชน์มากขึ้นเรื่อยๆ โดยส่วนตัวแล้ว ฉันไม่ได้ใช้มันเพื่อตรวจสอบความถูกต้องของโมเดลเท่านั้น แต่ยังใช้สเปกเพื่อตรวจสอบว่าเรากำลังเพิ่ม attribute_alias อย่างถูกต้องหรือไม่ วิธีการสำหรับทุกรุ่นของเรา