การสร้าง Ruby DSL: คำแนะนำเกี่ยวกับ Metaprogramming ขั้นสูง
เผยแพร่แล้ว: 2022-03-11ภาษาเฉพาะของโดเมน (DSL) เป็นเครื่องมือที่ทรงพลังอย่างเหลือเชื่อสำหรับการตั้งโปรแกรมหรือกำหนดค่าระบบที่ซับซ้อนได้ง่ายขึ้น สิ่งเหล่านี้มีอยู่ทุกที่—ในฐานะวิศวกรซอฟต์แวร์ คุณมักจะใช้ DSL ที่แตกต่างกันหลายตัวในแต่ละวัน
ในบทความนี้ คุณจะได้เรียนรู้ว่าภาษาเฉพาะโดเมนคืออะไร เมื่อใดจึงควรใช้ และสุดท้ายวิธีสร้าง DSL ของคุณเองใน Ruby โดยใช้เทคนิคเมตาโปรแกรมมิ่งขั้นสูง
บทความนี้สร้างขึ้นจากบทนำของ Nikola Todorovic เกี่ยวกับ Ruby metaprogramming ซึ่งเผยแพร่ใน Toptal Blog ด้วย ดังนั้น หากคุณยังใหม่ต่อ metaprogramming โปรดอ่านก่อน
ภาษาเฉพาะของโดเมนคืออะไร?
คำจำกัดความทั่วไปของ DSL คือภาษาที่เชี่ยวชาญสำหรับโดเมนแอปพลิเคชันเฉพาะหรือกรณีการใช้งาน ซึ่งหมายความว่าคุณสามารถใช้ได้เฉพาะกับบางสิ่งเท่านั้น ไม่เหมาะสำหรับการพัฒนาซอฟต์แวร์ทั่วไป หากฟังดูกว้าง นั่นก็เพราะว่า DSL มีรูปร่างและขนาดต่างกันมากมาย ต่อไปนี้คือหมวดหมู่ที่สำคัญบางประการ:
- ภาษามาร์กอัป เช่น HTML และ CSS ได้รับการออกแบบมาเพื่ออธิบายสิ่งต่างๆ เช่น โครงสร้าง เนื้อหา และรูปแบบของหน้าเว็บ เป็นไปไม่ได้ที่จะเขียนอัลกอริธึมตามอำเภอใจกับพวกเขา ดังนั้นจึงเหมาะสมกับคำอธิบายของ DSL
- ภาษามาโครและคิวรี (เช่น SQL) จะอยู่เหนือระบบใดระบบหนึ่งหรือภาษาโปรแกรมอื่น และมักจะจำกัดในสิ่งที่สามารถทำได้ ดังนั้นพวกเขาจึงมีคุณสมบัติเป็นภาษาเฉพาะของโดเมนอย่างชัดเจน
- DSL จำนวนมากไม่มีไวยากรณ์ของตัวเอง—แต่จะใช้ไวยากรณ์ของภาษาการเขียนโปรแกรมที่จัดตั้งขึ้นในลักษณะที่ชาญฉลาดซึ่งให้ความรู้สึกเหมือนใช้ภาษาย่อยแยกต่างหาก
หมวดหมู่สุดท้ายนี้เรียกว่า DSL ภายใน และเป็นหนึ่งในหมวดหมู่นี้ที่เราจะสร้างเป็นตัวอย่างเร็วๆ นี้ แต่ก่อนที่เราจะพูดถึงเรื่องนี้ เรามาดูตัวอย่างที่เป็นที่รู้จักของ DSL ภายในกันก่อน ไวยากรณ์การกำหนดเส้นทางใน Rails เป็นหนึ่งในนั้น:
Rails.application.routes.draw do root to: "pages#main" resources :posts do get :preview resources :comments, only: [:new, :create, :destroy] end end
นี่คือรหัส Ruby แต่ให้ความรู้สึกเหมือนเป็นภาษากำหนดเส้นทางแบบกำหนดเอง ต้องขอบคุณเทคนิคเมตาโปรแกรมมิงที่หลากหลายที่ทำให้อินเทอร์เฟซที่สะอาดและใช้งานง่ายเป็นไปได้ โปรดสังเกตว่าโครงสร้างของ DSL นั้นใช้งานโดยใช้บล็อก Ruby และการเรียกใช้เมธอด เช่น get
และ resources
ใช้สำหรับกำหนดคีย์เวิร์ดของภาษาขนาดเล็กนี้
Metaprogramming ถูกใช้ในไลบรารีการทดสอบ RSpec มากขึ้น:
describe UsersController, type: :controller do before do allow(controller).to receive(:current_user).and_return(nil) end describe "GET #new" do subject { get :new } it "returns success" do expect(subject).to be_success end end end
โค้ดชิ้นนี้ยังมีตัวอย่างสำหรับ อินเทอร์เฟซที่คล่องแคล่ว ซึ่งช่วยให้การประกาศสามารถอ่านออกเสียงเป็นประโยคภาษาอังกฤษธรรมดาได้ ทำให้เข้าใจได้ง่ายขึ้นมากว่าโค้ดกำลังทำอะไรอยู่:
# Stubs the `current_user` method on `controller` to always return `nil` allow(controller).to receive(:current_user).and_return(nil) # Asserts that `subject.success?` is truthy expect(subject).to be_success
อีกตัวอย่างหนึ่งของอินเทอร์เฟซที่คล่องแคล่วคืออินเทอร์เฟซการสืบค้นของ ActiveRecord และ Arel ซึ่งใช้แผนผังไวยากรณ์นามธรรมภายในสำหรับการสร้างการสืบค้น SQL ที่ซับซ้อน:
Post. # => select([ # SELECT Post[Arel.star], # `posts`.*, Comment[:id].count. # COUNT(`comments`.`id`) as("num_comments"), # AS num_comments ]). # FROM `posts` joins(:comments). # INNER JOIN `comments` # ON `comments`.`post_id` = `posts`.`id` where.not(status: :draft). # WHERE `posts`.`status` <> 'draft' where( # AND Post[:created_at].lte(Time.now) # `posts`.`created_at` <= ). # '2017-07-01 14:52:30' group(Post[:id]) # GROUP BY `posts`.`id`
แม้ว่ารูปแบบที่ชัดเจนและชัดเจนของ Ruby ควบคู่ไปกับความสามารถในการเขียนโปรแกรมเมตาทำให้มันเหมาะสมเป็นพิเศษสำหรับการสร้างภาษาเฉพาะโดเมน DSL ก็มีอยู่ในภาษาอื่นๆ เช่นกัน นี่คือตัวอย่างการทดสอบ JavaScript โดยใช้กรอบงานจัสมิน:
describe("Helper functions", function() { beforeEach(function() { this.helpers = window.helpers; }); describe("log error", function() { it("logs error message to console", function() { spyOn(console, "log").and.returnValue(true); this.helpers.log_error("oops!"); expect(console.log).toHaveBeenCalledWith("ERROR: oops!"); }); }); });
ไวยากรณ์นี้อาจไม่สะอาดเท่าตัวอย่าง Ruby แต่แสดงให้เห็นว่าด้วยการตั้งชื่ออย่างชาญฉลาดและการใช้ไวยากรณ์อย่างสร้างสรรค์ DSL ภายในสามารถสร้างขึ้นได้โดยใช้เกือบทุกภาษา
ประโยชน์ของ DSL ภายในคือไม่ต้องมี parser แยกต่างหาก ซึ่งอาจเป็นเรื่องยากที่จะนำไปใช้อย่างเหมาะสม และเนื่องจากพวกเขาใช้ไวยากรณ์ของภาษาที่นำมาใช้ พวกเขาจึงรวมเข้ากับฐานโค้ดที่เหลือได้อย่างราบรื่น
สิ่งที่เราต้องละทิ้งเป็นการตอบแทนคือเสรีภาพทางวากยสัมพันธ์—DSL ภายในต้องมีความถูกต้องทางวากยสัมพันธ์ในภาษาการนำไปใช้ จำนวนเงินที่คุณต้องประนีประนอมในเรื่องนี้ขึ้นอยู่กับภาษาที่เลือกเป็นส่วนใหญ่โดยที่ verbose, ภาษาที่พิมพ์แบบสแตติกเช่น Java และ VB.NET อยู่ที่ปลายด้านหนึ่งของสเปกตรัม และภาษาแบบไดนามิกที่มีความสามารถในการ metaprogramming ที่กว้างขวางเช่น Ruby ในอีกด้านหนึ่ง จบ.
การสร้างของเราเอง—A Ruby DSL สำหรับการกำหนดค่าคลาส
ตัวอย่าง DSL ที่เราจะสร้างใน Ruby คือเอ็นจิ้นการกำหนดค่าที่ใช้ซ้ำได้สำหรับการระบุแอตทริบิวต์การกำหนดค่าของคลาส Ruby โดยใช้ไวยากรณ์ที่ง่ายมาก การเพิ่มความสามารถในการกำหนดค่าให้กับคลาสเป็นข้อกำหนดทั่วไปในโลก Ruby โดยเฉพาะอย่างยิ่งเมื่อต้องกำหนดการกำหนดค่า gem ภายนอกและไคลเอ็นต์ API วิธีแก้ปัญหาปกติคืออินเทอร์เฟซดังนี้:
MyApp.configure do |config| config.app_ config.title = "My App" config.cookie_name = "my_app_session" end
มาเริ่มใช้งานอินเทอร์เฟซนี้กันก่อน จากนั้น ใช้เป็นจุดเริ่มต้น เราสามารถปรับปรุงทีละขั้นตอนโดยเพิ่มคุณสมบัติเพิ่มเติม ล้างไวยากรณ์ และทำให้งานของเรานำกลับมาใช้ใหม่ได้
เราต้องการอะไรเพื่อให้อินเทอร์เฟซนี้ทำงาน คลาส MyApp
ควรมีวิธี configure
คลาสที่รับบล็อกแล้วรันบล็อกนั้นโดยยอมจำนน ส่งผ่านอ็อบเจ็กต์การกำหนดค่าที่มีเมธอด accessor สำหรับการอ่านและเขียนค่าคอนฟิกูเรชัน:
class MyApp # ... class << self def config @config ||= Configuration.new end def configure yield config end end class Configuration attr_accessor :app_id, :title, :cookie_name end end
เมื่อบล็อกการกำหนดค่าทำงานแล้ว เราสามารถเข้าถึงและแก้ไขค่าได้อย่างง่ายดาย:
MyApp.config => #<MyApp::Configuration:0x2c6c5e0 @app_, @title="My App", @cookie_name="my_app_session"> MyApp.config.title => "My App" MyApp.config.app_ => "not_my_app"
จนถึงตอนนี้ การใช้งานนี้ไม่รู้สึกเหมือนเป็นภาษาที่กำหนดเองมากพอที่จะถือว่าเป็น DSL แต่มาทำกันทีละขั้นตอน ต่อไป เราจะแยกฟังก์ชันการกำหนดค่าออกจากคลาส MyApp
และทำให้เป็นแบบทั่วไปเพียงพอที่จะใช้งานได้ในกรณีการใช้งานต่างๆ มากมาย
ทำให้สามารถนำกลับมาใช้ใหม่ได้
ตอนนี้ หากเราต้องการเพิ่มความสามารถในการกำหนดค่าที่คล้ายกันให้กับคลาสอื่น เราจะต้องคัดลอกทั้งคลาส Configuration
และวิธีการตั้งค่าที่เกี่ยวข้องไปยังคลาสอื่นนั้น รวมทั้งแก้ไขรายการ attr_accessor
เพื่อเปลี่ยนแอตทริบิวต์การกำหนดค่าที่ยอมรับ เพื่อหลีกเลี่ยงไม่ให้ต้องทำเช่นนี้ ให้ย้ายคุณลักษณะการกำหนดค่าไปยังโมดูลแยกต่างหากที่เรียกว่า Configurable
ด้วยเหตุนี้ คลาส MyApp
ของเราจะมีลักษณะดังนี้:
class MyApp #BOLD include Configurable #BOLDEND # ... end
ทุกอย่างที่เกี่ยวข้องกับการกำหนดค่าถูกย้ายไปยังโมดูลที่ Configurable
ได้:
#BOLD module Configurable def self.included(host_class) host_class.extend ClassMethods end module ClassMethods #BOLDEND def config @config ||= Configuration.new end def configure yield config end #BOLD end #BOLDEND class Configuration attr_accessor :app_id, :title, :cookie_name end #BOLD end #BOLDEND
มีการเปลี่ยนแปลงไม่มากนักที่นี่ ยกเว้นวิธีการรวมตัว self.included
แบบใหม่ เราต้องการวิธีนี้เพราะการรวมโมดูลจะผสมกันในเมธอดของอินสแตนซ์เท่านั้น ดังนั้น config
และ configure
method ของเราจะไม่ถูกเพิ่มในคลาสโฮสต์โดยค่าเริ่มต้น อย่างไรก็ตาม หากเรากำหนดวิธีการพิเศษที่เรียกว่า included
ในโมดูล Ruby จะเรียกมันทุกครั้งที่โมดูลนั้นรวมอยู่ในคลาส ที่นั่น เราสามารถขยายโฮสต์คลาสด้วยตนเองด้วยเมธอดใน ClassMethods
:
def self.included(host_class) # called when we include the module in `MyApp` host_class.extend ClassMethods # adds our class methods to `MyApp` end
เรายังทำไม่เสร็จ—ขั้นตอนต่อไปของเราคือทำให้สามารถระบุแอตทริบิวต์ที่รองรับในคลาสโฮสต์ที่มีโมดูลที่ Configurable
ได้ วิธีแก้ปัญหาเช่นนี้จะดูดี:
class MyApp #BOLD include Configurable.with(:app_id, :title, :cookie_name) #BOLDEND # ... end
โค้ดด้านบนนี้อาจ include
ความถูกต้องทางวากยสัมพันธ์ ซึ่งอาจจะไม่ใช่คีย์เวิร์ด แต่เป็นวิธีการปกติที่คาดหวังให้อ็อบเจกต์ Module
เป็นพารามิเตอร์ ตราบใดที่เราส่งนิพจน์ที่ส่งคืน Module
มันก็จะรวมไว้อย่างมีความสุข ดังนั้น แทนที่จะรวม Configurable
โดยตรง เราต้องการวิธีการที่ with
ชื่อซึ่งสร้างโมดูลใหม่ที่ปรับแต่งด้วยแอตทริบิวต์ที่ระบุ:
module Configurable #BOLD def self.with(*attrs) #BOLDEND # Define anonymous class with the configuration attributes #BOLD config_class = Class.new do attr_accessor *attrs end #BOLDEND # Define anonymous module for the class methods to be "mixed in" #BOLD class_methods = Module.new do define_method :config do @config ||= config_class.new end #BOLDEND def configure yield config end #BOLD end #BOLDEND # Create and return new module #BOLD Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods end end end #BOLDEND end
มีจำนวนมากที่จะแกะที่นี่ โมดูลที่ Configurable
ได้ทั้งหมดในขณะนี้ประกอบด้วยเมธอดเดียว with
ทุกอย่างเกิดขึ้นภายในเมธอดนั้น อันดับแรก เราสร้างคลาสที่ไม่ระบุชื่อใหม่ด้วย Class.new
เพื่อเก็บวิธีการเข้าถึงแอตทริบิวต์ของเรา เนื่องจาก Class.new
ใช้คำจำกัดความของคลาสเป็นบล็อกและบล็อกมีการเข้าถึงตัวแปรภายนอก เราจึงสามารถส่งตัวแปร attrs
ไปยัง attr_accessor
ได้โดยไม่มีปัญหา
def self.with(*attrs) # `attrs` is created here # ... config_class = Class.new do # class definition passed in as a block attr_accessor *attrs # we have access to `attrs` here end
ความจริงที่ว่าบล็อกใน Ruby สามารถเข้าถึงตัวแปรภายนอกได้ยังเป็นสาเหตุที่บางครั้งเรียกว่า closures เนื่องจากมีหรือ "ปิด" สภาพแวดล้อมภายนอกที่กำหนดไว้ โปรดทราบว่าฉันใช้วลี "กำหนดใน" และไม่ "ดำเนินการใน" ถูกต้อง – ไม่ว่าเมื่อไรและที่ไหนที่บล็อก define_method
ของเราจะถูกดำเนินการในที่สุด พวกเขาจะสามารถเข้าถึงตัวแปร config_class
และ class_methods
ได้เสมอ แม้ว่า เมธอด with
จะทำงานและส่งคืนเสร็จแล้ว ตัวอย่างต่อไปนี้แสดงให้เห็นถึงลักษณะการทำงานนี้:
def create_block foo = "hello" # define local variable return Proc.new { foo } # return a new block that returns `foo` end block = create_block # call `create_block` to retrieve the block block.call # even though `create_block` has already returned, => "hello" # the block can still return `foo` to us
ตอนนี้เรารู้เกี่ยวกับพฤติกรรมที่เรียบร้อยของบล็อกแล้ว เราสามารถดำเนินการต่อและกำหนดโมดูลที่ไม่ระบุตัวตนใน class_methods
สำหรับวิธีการของคลาสที่จะเพิ่มไปยังคลาสโฮสต์เมื่อรวมโมดูลที่เราสร้างขึ้น ในที่นี้ เราต้องใช้ define_method
เพื่อกำหนดวิธี config
เนื่องจากเราต้องการเข้าถึงตัวแปร config_class
ภายนอกจากภายในเมธอด การกำหนดเมธอดโดยใช้คีย์เวิร์ด def
จะไม่ทำให้เราเข้าถึงได้ เนื่องจากนิยามเมธอดปกติที่มี def
ไม่ใช่การปิด – อย่างไรก็ตาม define_method
บล็อก ดังนั้นสิ่งนี้จะได้ผล:

config_class = # ... # `config_class` is defined here # ... class_methods = Module.new do # define new module using a block define_method :config do # method definition with a block @config ||= config_class.new # even two blocks deep, we can still end # access `config_class`
สุดท้าย เราเรียก Module.new
เพื่อสร้างโมดูลที่เราจะส่งคืน ที่นี่เราต้องกำหนดวิธีการ self.included
ของเรา แต่น่าเสียดายที่เราไม่สามารถทำเช่นนั้นด้วยคำหลัก def
เนื่องจากวิธีการนั้นต้องการการเข้าถึงตัวแปร class_methods
ภายนอก ดังนั้น เราจึงต้องใช้ define_method
กับ block อีกครั้ง แต่คราวนี้เป็น singleton class ของโมดูล เนื่องจากเรากำลังกำหนด method บนตัวโมดูลเอง โอ้ และเนื่องจาก define_method
เป็นเมธอดส่วนตัวของคลาสซิงเกิลตัน เราจึงต้องใช้ send
เพื่อเรียกมันแทนที่จะเรียกโดยตรง:
class_methods = # ... # ... Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods # the block has access to `class_methods` end end
วุ้ยนั่นเป็น metaprogramming ที่ค่อนข้างไม่ยอมใครง่ายๆอยู่แล้ว แต่ความซับซ้อนที่เพิ่มขึ้นนั้นคุ้มค่าหรือไม่ ดูว่ามันใช้งานง่ายเพียงใดและตัดสินใจด้วยตัวเอง:
class SomeClass include Configurable.with(:foo, :bar) # ... end SomeClass.configure do |config| config.foo = "wat" config.bar = "huh" end SomeClass.config.foo => "wat"
แต่เราสามารถทำได้ดียิ่งขึ้นไปอีก ในขั้นตอนต่อไป เราจะล้างไวยากรณ์ของบล็อก configure
เล็กน้อย เพื่อให้โมดูลของเราใช้งานได้สะดวกยิ่งขึ้น
ทำความสะอาดไวยากรณ์
มีสิ่งสุดท้ายที่ยังคงรบกวนฉันด้วยการใช้งานปัจจุบันของเรา—เราต้องกำหนด config
ซ้ำในทุกบรรทัดในบล็อกการกำหนดค่า DSL ที่เหมาะสมจะรู้ว่าทุกอย่างภายในบล็อก configure
ควรถูกดำเนินการในบริบทของอ็อบเจ็กต์การกำหนดค่าของเรา และทำให้เราสามารถบรรลุสิ่งเดียวกันได้ด้วยสิ่งนี้:
MyApp.configure do app_id "my_app" title "My App" cookie_name "my_app_session" end
เรามาปฏิบัติกันดีไหม? ดูจากลักษณะแล้วเราต้องการสองสิ่ง อันดับแรก เราต้องการวิธีดำเนินการบล็อกที่ส่งผ่านเพื่อ configure
ในบริบทของออบเจ็กต์การกำหนดค่าเพื่อให้เมธอดภายในบล็อกไปที่อ็อบเจ็กต์นั้น ประการที่สอง เราต้องเปลี่ยนเมธอด accessor เพื่อให้พวกเขาเขียนค่าหากมีการจัดเตรียมอาร์กิวเมนต์ไว้และอ่านกลับเมื่อถูกเรียกโดยไม่มีอาร์กิวเมนต์ การใช้งานที่เป็นไปได้มีลักษณะดังนี้:
module Configurable def self.with(*attrs) #BOLD not_provided = Object.new #BOLDEND config_class = Class.new do #BOLD attrs.each do |attr| define_method attr do |value = not_provided| if value === not_provided instance_variable_get("@#{attr}") else instance_variable_set("@#{attr}", value) end end end attr_writer *attrs #BOLDEND end class_methods = Module.new do # ... def configure(&block) #BOLD config.instance_eval(&block) #BOLDEND end end # Create and return new module # ... end end
การเปลี่ยนแปลงที่ง่ายกว่านี้คือการเรียกใช้บล็อก configure
ในบริบทของวัตถุการกำหนดค่า การเรียกใช้เมธอด instance_eval
ของ Ruby บนอ็อบเจ็กต์ทำให้คุณสามารถรันบล็อกโค้ดได้ตามอำเภอใจ ราวกับว่ามันทำงานอยู่ภายในอ็อบเจกต์นั้น ซึ่งหมายความว่าเมื่อบล็อกการกำหนดค่าเรียกใช้เมธอด app_id
ในบรรทัดแรก การเรียกนั้นจะไปที่อินสแตนซ์คลาสการกำหนดค่าของเรา
การเปลี่ยนแปลงวิธีการเข้าถึงแอตทริบิวต์ใน config_class
นั้นซับซ้อนกว่าเล็กน้อย เพื่อให้เข้าใจ เราต้องเข้าใจก่อนว่า attr_accessor
ทำอะไรอยู่เบื้องหลัง ยกตัวอย่างการโทร attr_accessor
ต่อไปนี้:
class SomeClass attr_accessor :foo, :bar end
ซึ่งเทียบเท่ากับการกำหนดวิธีการอ่านและเขียนสำหรับแต่ละแอตทริบิวต์ที่ระบุ:
class SomeClass def foo @foo end def foo=(value) @foo = value end # and the same with `bar` end
ดังนั้นเมื่อเราเขียน attr_accessor *attrs
ในโค้ดต้นฉบับ Ruby ได้กำหนดวิธีการอ่านและเขียนแอตทริบิวต์สำหรับเราสำหรับแอตทริบิวต์ทุกรายการใน attrs
นั่นคือเราได้รับวิธีการเข้าถึงมาตรฐานดังต่อไปนี้: app_id
, app_id=
, title
, title=
เป็นต้น บน. ในเวอร์ชันใหม่นี้ เราต้องการเก็บวิธีการเขียนมาตรฐานไว้เพื่อให้งานในลักษณะนี้ยังคงทำงานได้อย่างถูกต้อง:
MyApp.config.app_ => "not_my_app"
เราสามารถสร้างวิธีเขียนอัตโนมัติต่อไปได้โดยเรียก attr_writer *attrs
อย่างไรก็ตาม เราไม่สามารถใช้วิธีอ่านมาตรฐานได้อีกต่อไป เนื่องจากจะต้องสามารถเขียนแอตทริบิวต์เพื่อรองรับรูปแบบใหม่นี้ได้:
MyApp.configure do app_id "my_app" # assigns a new value app_id # reads the stored value end
ในการสร้างวิธีการอ่านด้วยตัวเราเอง เราวนรอบอาร์เรย์ attrs
และกำหนดวิธีการสำหรับแต่ละแอตทริบิวต์ที่คืนค่าปัจจุบันของตัวแปรอินสแตนซ์ที่ตรงกัน หากไม่มีการระบุค่าใหม่และเขียนค่าใหม่หากมีการระบุไว้:
not_provided = Object.new # ... attrs.each do |attr| define_method attr do |value = not_provided| if value === not_provided instance_variable_get("@#{attr}") else instance_variable_set("@#{attr}", value) end end end
ที่นี่เราใช้เมธอด instance_variable_get
ของ Ruby เพื่ออ่านตัวแปรอินสแตนซ์ด้วยชื่อที่กำหนดเอง และ instance_variable_set
เพื่อกำหนดค่าใหม่ให้กับตัวแปร ขออภัย ชื่อตัวแปรต้องขึ้นต้นด้วยเครื่องหมาย “@” ในทั้งสองกรณี ดังนั้นจึงเป็นการสอดแทรกสตริง
คุณอาจสงสัยว่าเหตุใดเราจึงต้องใช้วัตถุว่างเป็นค่าเริ่มต้นสำหรับ "ไม่ได้ระบุ" และเหตุใดเราจึงใช้ nil
เพื่อจุดประสงค์นั้นไม่ได้ เหตุผลนั้นง่ายมาก nil
เป็นค่าที่ถูกต้องที่บางคนอาจต้องการตั้งค่าสำหรับแอตทริบิวต์การกำหนดค่า หากเราทดสอบหา nil
เราจะไม่สามารถแยกสองสถานการณ์นี้ออกจากกัน:
MyApp.configure do app_id nil # expectation: assigns nil app_id # expectation: returns current value end
ออบเจ็กต์เปล่าที่เก็บไว้ใน not_provided
จะเท่ากับตัวมันเองเท่านั้น ดังนั้นด้วยวิธีนี้ เราจึงมั่นใจได้ว่าจะไม่มีใครส่งผ่านเข้าไปในวิธีการของเรา และทำให้อ่านโดยไม่ได้ตั้งใจแทนการเขียน
เพิ่มการสนับสนุนสำหรับการอ้างอิง
มีอีกหนึ่งคุณลักษณะที่เราสามารถเพิ่มเพื่อทำให้โมดูลของเราใช้งานได้หลากหลายมากขึ้น นั่นคือความสามารถในการอ้างอิงแอตทริบิวต์การกำหนดค่าจากคุณลักษณะอื่น:
MyApp.configure do app_id "my_app" title "My App" cookie_name { "#{app_id}_session" } End MyApp.config.cookie_name => "my_app_session"
ที่นี่เราได้เพิ่มข้อมูลอ้างอิงจาก cookie_name
ไปยังแอตทริบิวต์ app_id
โปรดทราบว่านิพจน์ที่มีการอ้างอิงจะถูกส่งต่อในรูปแบบบล็อก ซึ่งจำเป็นเพื่อรองรับการประเมินค่าแอตทริบิวต์ที่ล่าช้า แนวคิดคือการประเมินบล็อกในภายหลังเมื่อแอตทริบิวต์ถูกอ่านเท่านั้น ไม่ใช่เมื่อมีการกำหนด มิฉะนั้น เรื่องตลกจะเกิดขึ้นหากเรากำหนดแอตทริบิวต์ในลำดับ "ผิด":
SomeClass.configure do foo "#{bar}_baz" # expression evaluated here bar "hello" end SomeClass.config.foo => "_baz" # not actually funny
ถ้านิพจน์ถูกห่อในบล็อก จะเป็นการป้องกันไม่ให้มีการประเมินทันที เราสามารถบันทึกบล็อกเพื่อดำเนินการในภายหลังเมื่อดึงค่าแอตทริบิวต์แทน:
SomeClass.configure do foo { "#{bar}_baz" } # stores block, does not evaluate it yet bar "hello" end SomeClass.config.foo # `foo` evaluated here => "hello_baz" # correct!
เราไม่ต้องทำการเปลี่ยนแปลงครั้งใหญ่กับโมดูลที่ Configurable
ได้เพื่อเพิ่มการรองรับสำหรับการประเมินที่ล่าช้าโดยใช้บล็อก อันที่จริง เราต้องเปลี่ยนนิยามเมธอดของแอททริบิวเท่านั้น:
define_method attr do |value = not_provided, &block| if value === not_provided && block.nil? result = instance_variable_get("@#{attr}") result.is_a?(Proc) ? instance_eval(&result) : result else instance_variable_set("@#{attr}", block || value) end end
เมื่อตั้งค่าแอตทริบิวต์ block || value
นิพจน์ block || value
จะบันทึกบล็อกหากมีการส่งผ่านหรือมิฉะนั้นจะบันทึกค่า จากนั้น เมื่อแอตทริบิวต์ถูกอ่านในภายหลัง เราจะตรวจสอบว่าเป็นบล็อกหรือไม่ และประเมินโดยใช้ instance_eval
หากเป็น หรือหากไม่ใช่การบล็อก เราจะคืนค่ากลับมาเหมือนที่เคยทำ
เอกสารอ้างอิงที่รองรับมาพร้อมกับคำเตือนและกรณีขอบของมันเอง ตัวอย่างเช่น คุณอาจทราบได้ว่าเกิดอะไรขึ้นหากคุณอ่านแอตทริบิวต์ใดๆ ในการกำหนดค่านี้:
SomeClass.configure do foo { bar } bar { foo } end
โมดูลสำเร็จรูป
ในที่สุด เราก็ได้โมดูลที่ค่อนข้างเรียบร้อยสำหรับการกำหนดค่าคลาสที่กำหนดเองได้ จากนั้นจึงระบุค่าการกำหนดค่าเหล่านั้นโดยใช้ DSL ที่เรียบง่ายและสะอาดตา ซึ่งช่วยให้เราอ้างอิงแอตทริบิวต์การกำหนดค่าหนึ่งจากอีกแอตทริบิวต์หนึ่งได้:
class MyApp include Configurable.with(:app_id, :title, :cookie_name) # ... end SomeClass.configure do app_id "my_app" title "My App" cookie_name { "#{app_id}_session" } end
ต่อไปนี้คือเวอร์ชันสุดท้ายของโมดูลที่ใช้ DSL ของเรา ซึ่งเป็นโค้ดทั้งหมด 36 บรรทัด:
module Configurable def self.with(*attrs) not_provided = Object.new config_class = Class.new do attrs.each do |attr| define_method attr do |value = not_provided, &block| if value === not_provided && block.nil? result = instance_variable_get("@#{attr}") result.is_a?(Proc) ? instance_eval(&result) : result else instance_variable_set("@#{attr}", block || value) end end end attr_writer *attrs end class_methods = Module.new do define_method :config do @config ||= config_class.new end def configure(&block) config.instance_eval(&block) end end Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods end end end end
เมื่อพิจารณาถึงความมหัศจรรย์ของ Ruby ทั้งหมดในโค้ดที่แทบจะอ่านไม่ออกและดูแลรักษายากมาก คุณอาจสงสัยว่าความพยายามทั้งหมดนี้คุ้มค่าหรือไม่เพียงแค่ทำให้ภาษาเฉพาะโดเมนของเราดีขึ้นเล็กน้อย คำตอบสั้น ๆ คือมันขึ้นอยู่กับ—ซึ่งนำเราไปสู่หัวข้อสุดท้ายของบทความนี้
Ruby DSL—เมื่อใดควรใช้และเมื่อไม่ใช้งาน
คุณอาจสังเกตเห็นในขณะที่อ่านขั้นตอนการใช้งาน DSL ของเราว่าในขณะที่เราสร้างรูปแบบการเผชิญหน้าภายนอกของภาษาที่สะอาดขึ้นและใช้งานง่ายขึ้น เราต้องใช้เทคนิค metaprogramming เพิ่มมากขึ้นเรื่อย ๆ ภายใต้ประทุนเพื่อให้เกิดขึ้น ส่งผลให้มีการใช้งานที่จะเข้าใจและปรับเปลี่ยนได้ยากอย่างไม่น่าเชื่อในอนาคต เช่นเดียวกับสิ่งอื่น ๆ อีกมากมายในการพัฒนาซอฟต์แวร์ นี่เป็นข้อแลกเปลี่ยนที่ต้องตรวจสอบอย่างรอบคอบ
เพื่อให้ภาษาเฉพาะของโดเมนคุ้มค่ากับการใช้งานและค่าบำรุงรักษา ภาษานั้นต้องนำประโยชน์มากมายมาสู่ตาราง ซึ่งมักจะทำได้โดยการทำให้ภาษาสามารถนำมาใช้ซ้ำได้ในสถานการณ์ต่างๆ ให้มากที่สุดเท่าที่จะมากได้ ซึ่งจะเป็นการตัดจำหน่ายต้นทุนทั้งหมดระหว่างกรณีการใช้งานต่างๆ กรอบงานและไลบรารีมีแนวโน้มที่จะมี DSL ของตัวเองมากกว่า เนื่องจากมีการใช้โดยนักพัฒนาจำนวนมาก ซึ่งแต่ละคนสามารถเพลิดเพลินกับประโยชน์ด้านประสิทธิภาพการทำงานของภาษาที่ฝังตัวเหล่านั้น
ดังนั้น ตามหลักการทั่วไปแล้ว ให้สร้าง DSL ก็ต่อเมื่อคุณ นักพัฒนารายอื่นๆ หรือผู้ใช้แอปพลิเคชันของคุณจะได้รับประโยชน์อย่างมากจากสิ่งเหล่านี้ หากคุณสร้าง DSL ตรวจสอบให้แน่ใจว่าได้รวมชุดทดสอบที่ครอบคลุมไว้ด้วย รวมทั้งจัดทำเอกสารไวยากรณ์อย่างถูกต้อง เนื่องจากอาจเป็นเรื่องยากมากที่จะเข้าใจจากการใช้งานเพียงอย่างเดียว ในอนาคตคุณและนักพัฒนาเพื่อนของคุณจะขอบคุณสำหรับมัน
อ่านเพิ่มเติมในบล็อก Toptal Engineering:
- วิธีการเขียนล่ามตั้งแต่เริ่มต้น