ในโพสต์นี้ เราจะสำรวจวิธีสร้างโมดูล Ruby ที่ผู้ใช้โค้ดของเรากำหนดค่าได้ ซึ่งเป็นรูปแบบที่ช่วยให้ผู้เขียน gem สามารถเพิ่มความยืดหยุ่นให้กับไลบรารีได้
นักพัฒนา Ruby ส่วนใหญ่คุ้นเคยกับการใช้โมดูลเพื่อแชร์พฤติกรรม ท้ายที่สุด นี่เป็นหนึ่งในกรณีการใช้งานหลัก ตามเอกสาร:
โมดูลให้บริการสองวัตถุประสงค์ในฟังก์ชัน Ruby, เนมสเปซและมิกซ์อิน
Rails ได้เพิ่มน้ำตาลซินแทคติคในรูปแบบของ ActiveSupport::Concern
แต่หลักการทั่วไปยังคงเดิม
ปัญหา
การใช้โมดูลเพื่อให้ฟังก์ชันมิกซ์อินมักจะตรงไปตรงมา สิ่งที่เราต้องทำคือรวมวิธีการบางอย่างและรวมโมดูลของเราไว้ที่อื่น:
module HelloWorld
def hello
"Hello, world!"
end
end
class Test
include HelloWorld
end
Test.new.hello
#=> "Hello, world!"
นี่เป็นกลไกที่ค่อนข้างคงที่ แม้ว่า inherited
. ของ Ruby และ extended
วิธีการของ hook อนุญาตให้มีพฤติกรรมที่แตกต่างกันไปตามคลาสรวมถึง:
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
วิธีการ ณ เวลารวมโมดูล
วิธีแก้ปัญหา:โมดูล Ruby ที่กำหนดค่าได้
ในช่วงไม่กี่ปีที่ผ่านมา มีรูปแบบใหม่ที่แก้ปัญหานี้ ซึ่งบางครั้งผู้คนเรียกว่า "รูปแบบการสร้างโมดูล" เทคนิคนี้อาศัยคุณสมบัติหลักสองประการของ Ruby:
-
โมดูลก็เหมือนกับอ็อบเจ็กต์อื่นๆ ที่สามารถสร้างได้ทันที กำหนดให้กับตัวแปร แก้ไขแบบไดนามิก และส่งผ่านไปยังหรือส่งคืนจากเมธอด
def make_module # create a module on the fly and assign it to variable mod = Module.new # modify module mod.module_eval do def hello "Hello, AppSignal world!" end end # explicitly return it mod end
-
อาร์กิวเมนต์
include
หรือextended
การเรียกไม่จำเป็นต้องเป็นโมดูล แต่ก็สามารถเป็นนิพจน์ที่ส่งคืนได้ เช่น การเรียกเมธอดclass Test # include the module returned by make_module include make_module end Test.new.hello #=> "Hello, AppSignal world!"
การทำงานของตัวสร้างโมดูล
ตอนนี้เราจะใช้ความรู้นี้เพื่อสร้างโมดูลอย่างง่ายที่เรียกว่า Wrapper
ซึ่งใช้พฤติกรรมต่อไปนี้:
- คลาสที่มี
Wrapper
สามารถห่อเฉพาะวัตถุบางประเภทเท่านั้น ตัวสร้างจะตรวจสอบประเภทอาร์กิวเมนต์และทำให้เกิดข้อผิดพลาดหากประเภทไม่ตรงกับที่คาดไว้ - อ็อบเจ็กต์ที่ห่อหุ้มจะพร้อมใช้งานผ่านเมธอดของอินสแตนซ์ที่เรียกว่า
original_<class>
, เช่น.original_integer
หรือoriginal_string
. - จะช่วยให้ผู้บริโภคโค้ดของเราสามารถระบุชื่ออื่นสำหรับวิธีการเข้าถึงนี้ได้ เช่น
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 เรามั่นใจว่าคลาสนี้ไม่เพียงแค่รวมโมดูลตามชื่อ แต่รวมผลลัพธ์ของการเรียกไปยัง Wrapper.for(Integer)
แทน .
ในขั้นตอนที่ 3 เรายกตัวอย่างวัตถุของคลาสใหม่ของเราและกำหนดให้กับ i
. ตามที่ระบุไว้ วัตถุนี้มีวิธีการที่เรียกว่า original_integer
ที่ตรงตามความต้องการของเรา
ในขั้นตอนที่ 4 หากเราพยายามส่งผ่านอาร์กิวเมนต์ประเภทที่ไม่ถูกต้อง เช่น สตริง TypeError
ที่เป็นประโยชน์ จะถูกยกขึ้น สุดท้าย มาตรวจสอบว่าผู้ใช้สามารถระบุชื่อตัวเข้าถึงที่กำหนดเองได้
สำหรับสิ่งนี้ เรากำหนดคลาสใหม่ที่เรียกว่า StringWrapper
ในขั้นตอนที่ 5 และส่ง the_string
เป็นอาร์กิวเมนต์คำหลัก accessor_name
ซึ่งเราเห็นในการดำเนินการในขั้นตอนที่ 6
แม้ว่านี่จะเป็นที่ยอมรับว่าเป็นตัวอย่างที่ค่อนข้างประดิษฐ์ แต่ก็มีพฤติกรรมที่แตกต่างกันมากพอที่จะอวดรูปแบบตัวสร้างโมดูลและวิธีใช้งาน
ความพยายามครั้งแรก
จากข้อกำหนดและตัวอย่างการใช้งาน ตอนนี้เราสามารถเริ่มการใช้งานได้แล้ว เรารู้แล้วว่าเราต้องการโมดูลชื่อ Wrapper
ด้วยวิธีการระดับโมดูลที่เรียกว่า for
ซึ่งรับคลาสเป็นอาร์กิวเมนต์คีย์เวิร์ดเสริม:
module Wrapper
def self.for(klass, accessor_name: nil)
end
end
เนื่องจากค่าส่งคืนของวิธีนี้กลายเป็นอาร์กิวเมนต์ของ include
จะต้องเป็นโมดูล ดังนั้น เราสามารถสร้างอันที่ไม่ระบุตัวตนใหม่ด้วย Module.new
.
Module.new do
end
ตามข้อกำหนดของเรา สิ่งนี้จำเป็นต้องกำหนดคอนสตรัคเตอร์ซึ่งตรวจสอบประเภทของอ็อบเจ็กต์ที่ส่งผ่าน รวมถึงวิธีการระบุชื่อที่เหมาะสม มาเริ่มกันที่ตัวสร้าง:
define_method :initialize do |object|
raise TypeError, "not a #{klass}" unless object.is_a?(klass)
@object = object
end
โค้ดชิ้นนี้ใช้ define_method
เพื่อเพิ่มวิธีการอินสแตนซ์ให้กับผู้รับแบบไดนามิก เนื่องจากบล็อกทำหน้าที่เป็นตัวปิด จึงสามารถใช้ klass
วัตถุจากขอบเขตภายนอกเพื่อทำการตรวจสอบประเภทที่ต้องการ
การเพิ่มเมธอด accessor ที่มีชื่อเหมาะสมนั้นไม่ยากนัก:
# 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
และเสร็จแล้ว มิฉะนั้น เราจะนำคลาสและแปลงเป็นสตริงที่ขีดเส้นใต้ เช่น Integer
เปลี่ยนเป็น integer
หรือ OpenStruct
ลงใน open_struct
. klass_name
นี้ ตัวแปรจะถูกนำหน้าด้วย 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
ส่งคืนโมดูลที่ไม่ระบุชื่อ นี่ไม่ใช่ปัญหา แต่อาจทำให้สับสนเล็กน้อยเมื่อตรวจสอบสายการสืบทอดของวัตถุ:
StringWrapper.ancestors
#=> [StringWrapper, #<Module:0x0000000107283680>, Object, Kernel, BasicObject]
ที่นี่ #<Module:0x0000000107283680>
(ชื่อจะแตกต่างกันไปหากคุณติดตาม) หมายถึงโมดูลที่ไม่ระบุตัวตนของเรา
เวอร์ชั่นปรับปรุง
มาทำให้ชีวิตผู้ใช้ของเราง่ายขึ้นด้วยการส่งคืนโมดูลที่มีชื่อแทนโมดูลที่ไม่ระบุตัวตน โค้ดสำหรับสิ่งนี้คล้ายกับที่เราเคยมี โดยมีการเปลี่ยนแปลงเล็กน้อย:
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
ในขั้นตอนแรก เราสร้างโมดูลที่ซ้อนกันที่เรียกว่า "#{klass}InstanceMethods" (เช่น IntegerInstanceMethods
) นั่นเป็นเพียงโมดูล "ว่างเปล่า"
ดังที่แสดงในขั้นตอนที่ 2 เราใช้ module_eval
ใน for
เมธอด ซึ่งประเมินกลุ่มโค้ดในบริบทของโมดูลที่เรียกใช้ ด้วยวิธีนี้ เราสามารถเพิ่มพฤติกรรมให้กับโมดูลก่อนที่จะส่งคืนในขั้นตอนที่ 3
หากตอนนี้เราตรวจสอบบรรพบุรุษของคลาสรวมถึง Wrapper
ผลลัพธ์จะรวมโมดูลที่มีชื่อถูกต้อง ซึ่งมีความหมายมากกว่าและง่ายต่อการแก้ไขจุดบกพร่องมากกว่าโมดูลที่ไม่ระบุชื่อก่อนหน้า
StringWrapper.ancestors
#=> [StringWrapper, Wrapper::StringInstanceMethods, Object, Kernel, BasicObject]
รูปแบบตัวสร้างโมดูลในป่า
นอกจากโพสต์นี้ เราจะหารูปแบบตัวสร้างโมดูลหรือเทคนิคที่คล้ายกันได้จากที่ไหนอีกบ้าง
ตัวอย่างหนึ่งคือ dry-rb
ตระกูลอัญมณี เช่น dry-effects
ใช้ตัวสร้างโมดูลเพื่อส่งตัวเลือกการกำหนดค่าไปยังตัวจัดการเอฟเฟกต์ต่างๆ:
# 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)
เราสามารถพบการใช้งานที่คล้ายกันใน Shrine gem ที่ยอดเยี่ยม ซึ่งมีชุดเครื่องมืออัปโหลดไฟล์สำหรับแอปพลิเคชัน Ruby:
class Photo < Sequel::Model
include Shrine::Attachment(:image)
end
รูปแบบนี้ยังค่อนข้างใหม่ แต่ฉันคาดว่าเราจะเห็นรูปแบบนี้มากขึ้นในอนาคต โดยเฉพาะอย่างยิ่งในอัญมณีที่เน้นไปที่แอปพลิเคชัน Ruby ล้วนๆ มากกว่า Rails
สรุป
ในโพสต์นี้ เราได้สำรวจวิธีการใช้โมดูลที่กำหนดค่าได้ใน Ruby ซึ่งบางครั้งเทคนิคเรียกว่ารูปแบบตัวสร้างโมดูล เช่นเดียวกับเทคนิค metaprogramming อื่น ๆ สิ่งนี้มาพร้อมกับความซับซ้อนที่เพิ่มขึ้น ดังนั้นจึงไม่ควรใช้โดยไม่มีเหตุผลที่ดี อย่างไรก็ตาม ในกรณีที่ไม่ค่อยพบที่ต้องการความยืดหยุ่นดังกล่าว โมเดลวัตถุของ Ruby ช่วยให้ได้โซลูชันที่หรูหราและรัดกุมอีกครั้ง รูปแบบตัวสร้างโมดูลไม่ใช่สิ่งที่นักพัฒนา Ruby ส่วนใหญ่ต้องการบ่อยๆ แต่เป็นเครื่องมือที่ยอดเยี่ยมที่ควรมีในชุดเครื่องมือของตัวเอง โดยเฉพาะอย่างยิ่งสำหรับผู้เขียนห้องสมุด
ป.ล. หากคุณต้องการอ่านโพสต์ Ruby Magic ทันทีที่ออกจากสื่อ สมัครรับจดหมายข่าว Ruby Magic ของเราและไม่พลาดแม้แต่โพสต์เดียว!