ในบทความนี้ เราจะสำรวจวิธีสร้างโมดูล Ruby ที่ผู้ใช้โค้ดของเราสามารถกำหนดค่าได้—รูปแบบที่ช่วยให้ผู้เขียน gem เพิ่มความยืดหยุ่นให้กับไลบรารีของพวกเขา
นักพัฒนา Ruby ส่วนใหญ่คุ้นเคยกับการใช้โมดูลเพื่อแชร์พฤติกรรม เพราะนี่เป็นหนึ่งในกรณีใช้งานหลัก ตามเอกสาร:
โมดูลใน Ruby มีสองวัตถุประสงค์: namespace และ mixin functionality
Rails เพิ่ม syntactic sugar ในรูปของ ActiveSupport::Concern แต่หลักการทั่วไปยังคงเหมือนเดิม
ปัญหา
การใช้โมดูลเพื่อให้ mixin functionality ปกติจะตรงไปตรงมา เราเพียงรวมเมธอดบางส่วนและ include โมดูลที่อื่น:
module HelloWorld def hello "Hello, world!" end end class Test include HelloWorld end Test.new.hello #=> "Hello, world!"นี่เป็นกลไกค่อนข้าง static แม้ว่า hook methods inherited และ extended ของ Ruby จะอนุญาตให้พฤติกรรมเปลี่ยนแปลงได้บ้างขึ้นอยู่กับคลาสที่ include:
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!"นี่ไดนามิกกว่านิดหน่อย แต่ยังไม่ให้ผู้ใช้โค้ดของเราสามารถเปลี่ยนชื่อเมธอด hello ในขณะ include โมดูลได้
วิธีแก้: โมดูล Ruby ที่กำหนดค่าได้ (Configurable Ruby Modules)
ในไม่กี่ปีที่ผ่านมา รูปแบบใหม่ได้ปรากฏขึ้นซึ่งแก้ปัญหานี้ ซึ่งบางคนเรียกว่า "module builder pattern" เทคนิคนี้อาศัยคุณสมบัติหลักสองอย่างของ Ruby:
โมดูลเหมือนวัตถุอื่นๆ—สามารถสร้างบน the fly กำหนดให้ตัวแปร ปรับเปลี่ยนแบบไดนามิก ส่งต่อหรือคืนค่าจากเมธอดได้
def make_module # สร้างโมดูลบน the fly และกำหนดให้ตัวแปร mod = Module.new # ปรับเปลี่ยนโมดูล mod.module_eval do def hello "Hello, AppSignal world!" end end # คืนค่าโดยตรง mod endอาร์กิวเมนต์ของ
includeหรือextendไม่จำเป็นต้องเป็นโมดูล แต่อาจเป็นนิพจน์ที่คืนค่าโมดูล เช่น เรียกเมธอดclass Test # include โมดูลที่คืนค่าจาก make_module include make_module end Test.new.hello #=> "Hello, AppSignal world!"
Module Builder ในการทำงาน
เราจะใช้ความรู้นี้สร้างโมดูลชื่อ Wrapper ที่ implement พฤติกรรมดังนี้:
- คลาสที่ include
Wrapperสามารถ wrap オブเจ็กต์ของประเภทเฉพาะได้เท่านั้น Constructor จะตรวจสอบประเภทอาร์กิวเมนต์และ raise error หากประเภทไม่ตรงตามที่คาดหวัง - อ็อบเจ็กต์ที่ถูก wrap จะเข้าถึงได้ผ่าน instance method ชื่อ
original_<class>เช่นoriginal_integerหรือoriginal_string - อนุญาตให้ผู้ใช้โค้ดระบุชื่อทางเลือกสำหรับ accessor method นี้ เช่น
the_string
ดูตัวอย่างว่าเราต้องการให้โค้ดทำงานอย่างไร:
# 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!"ขั้นตอน 1 เรากำหนดคลาสใหม่ชื่อ IntWrapper
ขั้นตอน 2 เรา ensure ว่าคลาสนี้ไม่ได้แค่ include โมดูลโดยชื่อ แต่ mix in ผลลัพธ์ของการเรียก Wrapper.for(Integer)
ขั้นตอน 3 เราสร้างอ็อบเจ็กต์ของคลาสใหม่และกำหนดให้ i ตามที่ระบุ อ็อบเจ็กต์นี้มีเมธอดชื่อ original_integer ตอบสนองข้อกำหนดหนึ่ง
ขั้นตอน 4 หากเราพยายามส่งอาร์กิวเมนต์ประเภทผิด เช่น string จะมี TypeError ที่ช่วยเหลือถูก raise ขึ้น สุดท้าย ให้ตรวจสอบว่าผู้ใช้สามารถระบุ custom accessor names ได้
สำหรับนี้ เรากำหนดคลาสใหม่ชื่อ StringWrapper ในขั้นตอน 5 และส่ง the_string เป็น keyword argument accessor_name ซึ่งเราเห็นในขั้นตอน 6
แม้ตัวอย่างนี้จะค่อนข้าง contrived แต่ก็มีพฤติกรรมที่แตกต่างกันพอที่จะแสดง module builder pattern และวิธีการใช้งาน
การลองครั้งแรก
จากข้อกำหนดและตัวอย่างการใช้งาน เราสามารถเริ่ม implement ได้ เราทราบแล้วว่าต้องการโมดูลชื่อ Wrapper พร้อมเมธอดระดับโมดูลชื่อ for ซึ่งรับคลาสและ keyword argument ทางเลือก:
module Wrapper def self.for(klass, accessor_name: nil) end endเนื่องจากค่าคืนของเมธอดนี้จะกลายเป็นอาร์กิวเมนต์ของ include จึงต้องเป็นโมดูล ดังนั้นเราสร้าง anonymous module ใหม่ด้วย Module.new
Module.new do endตามข้อกำหนด ต้องกำหนด constructor ที่ตรวจสอบประเภทของอ็อบเจ็กต์ที่ส่งมา และ accessor method ที่มีชื่อเหมาะสม เริ่มจาก constructor:
define_method :initialize do |object| raise TypeError, "not a #{klass}" unless object.is_a?(klass) @object = object endโค้ดชิ้นนี้ใช้ define_method เพื่อเพิ่ม instance method แบบไดนามิกให้ receiver เนื่องจาก block ทำงานเป็น closure จึงสามารถใช้ object klass จาก outer scope เพื่อตรวจสอบประเภทที่ต้องการ
การเพิ่ม accessor method ที่มีชื่อเหมาะสมไม่ได้ยากกว่านี้มาก:
# 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 }ขั้นแรก ตรวจสอบว่าผู้เรียกโค้ดส่ง accessor_name มาหรือไม่ หากส่งมา ก็แค่กำหนดให้ method_name และเสร็จแล้ว ไม่เช่นนั้น เราจะนำคลาสมาแปลงเป็น underscore string เช่น Integer กลายเป็น integer หรือ OpenStruct กลายเป็น open_struct ตัวแปร klass_name นี้จะถูก prefix ด้วย original_ เพื่อสร้างชื่อ accessor สุดท้าย เมื่อรู้ชื่อเมธอดแล้ว เราใช้ define_method อีกครั้งเพื่อเพิ่มเข้าโมดูล ตามขั้นตอน 2
นี่คือโค้ดทั้งหมดจนถึงตอนนี้ น้อยกว่า 20 บรรทัดสำหรับโมดูล Ruby ที่ยืดหยุ่นและกำหนดค่าได้ ไม่แย่เลย
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ผู้สังเกตดีอาจจำได้ว่า Wrapper.for คืนค่า anonymous module นี่ไม่ใช่ปัญหา แต่อาจสับสนเมื่อตรวจสอบ inheritance chain ของอ็อบเจ็กต์:
StringWrapper.ancestors #=> [StringWrapper, #<Module:0x0000000107283680>, Object, Kernel, BasicObject]ที่นี่ #<Module:0x0000000107283680> (ชื่อจะแตกต่างถ้าคุณลองทำตาม) หมายถึง anonymous module ของเรา
รูปแบบที่ปรับปรุง
มาเพิ่มความสะดวกให้ผู้ใช้โดยการคืนค่า named module แทน anonymous module โค้ดคล้ายกับที่ผ่านมา แต่มีการเปลี่ยนแปลงเล็กน้อย:
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ในขั้นตอนแรก เราสร้าง nested module ชื่อ "#{klass}InstanceMethods" (เช่น IntegerInstanceMethods) ซึ่งเป็นโมดูล "ว่าง"
ตามขั้นตอน 2 เราใช้ module_eval ในเมธอด for ซึ่งประเมิน block โค้ดในบริบทของโมดูลที่ถูกเรียก วิธีนี้ช่วยให้เราเพิ่มพฤติกรรมให้โมดูลก่อนคืนค่าในขั้นตอน 3
หากเราตรวจสอบ ancestors ของคลาสที่ include Wrapper ตอนนี้ output จะรวม named module ที่มีความหมายและ debug ได้ง่ายกว่า anonymous module ก่อนหน้า
StringWrapper.ancestors #=> [StringWrapper, Wrapper::StringInstanceMethods, Object, Kernel, BasicObject]Module Builder Pattern ในโลกจริง
นอกจากบทความนี้ รูปแบบ module builder หรือเทคนิคคล้ายๆ กันยังพบได้ที่ไหน?
ตัวอย่างหนึ่งคือ họ gem dry-rb โดย dry-effects ใช้ module builders เพื่อส่งตัวเลือกการกำหนดค่าให้กับ effect handlers ต่างๆ:
# 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)เราพบการใช้งานคล้ายกันใน gem Shrine ที่ยอดเยี่ยม ซึ่ง提供 file upload toolkit สำหรับแอปพลิเคชัน Ruby:
class Photo < Sequel::Model include Shrine::Attachment(:image) endรูปแบบนี้ยังค่อนข้างใหม่ แต่คาดว่าเราจะเห็นมากขึ้นในอนาคต โดยเฉพาะใน gems ที่เน้นแอปพลิเคชัน Ruby มurn ไปกว่า Rails
สรุป
ในบทความนี้ เราสำรวจวิธี implement โมดูลที่กำหนดค่าได้ใน Ruby เทคนิคที่บางครั้งเรียกว่า module builder pattern ในทำนองเดียวกับเทคนิค metaprogramming อื่นๆ นี่มีค่าใช้จ่ายในความซับซ้อนที่เพิ่มขึ้น และไม่ควรใช้โดยไม่มีเหตุผลที่ดี อย่างไรก็ตาม ในกรณีที่นادرةที่ต้องการความยืดหยุ่นแบบนี้ โมเดลวัตถุของ Ruby อีกครั้งก็อนุญาตให้มีวิธีแก้ที่สง่างามและกระชับ Module builder pattern ไม่ใช่สิ่งที่นักพัฒนา Ruby ส่วนใหญ่จะต้องใช้บ่อย แต่เป็นเครื่องมือที่ยอดเยี่ยมในชุดเครื่องมือ โดยเฉพาะสำหรับผู้เขียนไลบรารี
P.S. หากคุณต้องการอ่านบทความ Ruby Magic ทันทีที่เผยแพร่ ให้สมัครสมาชิก newsletter Ruby Magic และจะไม่พลาดบทความใดๆ!