ภาษาเฉพาะโดเมน (DSL) เป็นเครื่องมือที่ทรงพลังอย่างไม่น่าเชื่อสำหรับการตั้งโปรแกรมหรือกำหนดค่าระบบที่ซับซ้อนได้ง่ายขึ้น นอกจากนี้ยังมีอยู่ทุกหนทุกแห่งในฐานะวิศวกรซอฟต์แวร์คุณมักจะใช้ DSL ต่างๆเป็นประจำทุกวัน
ในบทความนี้คุณจะได้เรียนรู้ว่าภาษาเฉพาะโดเมนคืออะไรควรใช้เมื่อใดและสุดท้ายจะสร้าง DSL ของคุณเองใน Ruby ได้อย่างไรโดยใช้เทคนิคการเขียนโปรแกรมเมตาขั้นสูง
บทความนี้สร้างจาก Nikola Todorovic’s บทนำ ไปยัง Ruby metaprogramming ซึ่งเผยแพร่บน ApeeScape Blog ดังนั้นหากคุณยังใหม่กับ metaprogramming อย่าลืมอ่านก่อน
คำจำกัดความทั่วไปของ DSL คือเป็นภาษาเฉพาะสำหรับโดเมนแอปพลิเคชันเฉพาะหรือกรณีการใช้งาน ซึ่งหมายความว่าคุณสามารถใช้สิ่งเหล่านี้สำหรับบางสิ่งเท่านั้นซึ่งไม่เหมาะสำหรับการพัฒนาซอฟต์แวร์ที่ใช้งานทั่วไป หากฟังดูกว้างนั่นก็เป็นเพราะ 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 พร้อมกับความสามารถในการเขียนโปรแกรม metaprogram ทำให้เหมาะอย่างยิ่งสำหรับการสร้างภาษาเฉพาะโดเมน 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 ภายใน คือพวกเขาไม่ต้องการตัวแยกวิเคราะห์แยกต่างหากซึ่งอาจเป็นเรื่องยากที่จะนำไปใช้อย่างถูกต้อง และเนื่องจากใช้ไวยากรณ์ของภาษาที่นำมาใช้จึงรวมเข้ากับส่วนที่เหลือของโค้ดเบสได้อย่างราบรื่น
สิ่งที่เราต้องยอมแพ้ในทางกลับกันคือความอิสระทางวากยสัมพันธ์ - DSL ภายในจะต้องมีความถูกต้องทางวากยสัมพันธ์ในภาษาการนำไปใช้งาน คุณต้องประนีประนอมมากน้อยเพียงใดในเรื่องนี้ขึ้นอยู่กับภาษาที่เลือกเป็นส่วนใหญ่โดยใช้ verbose ภาษาที่พิมพ์แบบคงที่เช่น Java และ VB.NET อยู่ที่ปลายด้านหนึ่งของสเปกตรัมและภาษาไดนามิกที่มีความสามารถในการเขียนโปรแกรม metaprogram อย่างกว้างขวางเช่น Ruby บนอีก จบ.
วิธีแก้ไขหน่วยความจำรั่วใน java
DSL ตัวอย่างที่เรากำลังจะสร้างใน Ruby เป็นเครื่องมือกำหนดค่าที่ใช้ซ้ำได้สำหรับการระบุแอตทริบิวต์การกำหนดค่าของคลาส Ruby โดยใช้ไวยากรณ์ที่เรียบง่ายมาก การเพิ่มความสามารถในการกำหนดค่าให้กับคลาสเป็นข้อกำหนดทั่วไปในโลกของ Ruby โดยเฉพาะอย่างยิ่งเมื่อต้องกำหนดค่าอัญมณีภายนอกและไคลเอ็นต์ API วิธีแก้ปัญหาปกติคืออินเทอร์เฟซเช่นนี้:
MyApp.configure do |config| config.app_id = 'my_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.config.title => 'My App' MyApp.config.app_id = 'not_my_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
เมธอดคลาสจะไม่ถูกเพิ่มลงในคลาสโฮสต์ตามค่าเริ่มต้น อย่างไรก็ตามหากเรากำหนดวิธีการพิเศษที่เรียกว่า included
บนโมดูล Ruby จะเรียกมันเมื่อใดก็ตามที่โมดูลนั้นรวมอยู่ในคลาส เราสามารถขยายคลาสโฮสต์ได้ด้วยตนเองด้วยวิธีการใน ClassMethods
:
รับเวลาเป็นมิลลิวินาที javascript
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 สามารถเข้าถึงตัวแปรภายนอกได้ก็เป็นสาเหตุที่บางครั้งเรียกว่าบล็อกใน Ruby การปิด ตามที่รวมไว้หรือ 'ปิดทับ' สภาพแวดล้อมภายนอกที่กำหนดไว้โปรดทราบว่าฉันใช้วลี 'กำหนดใน' ไม่ใช่ 'ดำเนินการใน' ถูกต้อง - ไม่ว่าจะ 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
ด้วยบล็อกอีกครั้ง แต่คราวนี้ในคลาสซิงเกิลตันของโมดูลขณะที่เรากำลังกำหนดวิธีการในอินสแตนซ์ของโมดูลเอง โอ้และตั้งแต่ 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
ในบริบทของออบเจ็กต์คอนฟิกูเรชันเพื่อให้เมธอดเรียกภายในบล็อกไปที่อ็อบเจ็กต์นั้น ประการที่สองเราต้องเปลี่ยนวิธีการเข้าถึงเพื่อให้พวกเขาเขียนค่าหากมีการให้อาร์กิวเมนต์แก่พวกเขาและอ่านกลับเมื่อถูกเรียกโดยไม่มีอาร์กิวเมนต์ การใช้งานที่เป็นไปได้มีลักษณะดังนี้:
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
บล็อกในบริบทของวัตถุการกำหนดค่า เรียก Ruby's instance_eval
วิธีการบนวัตถุช่วยให้คุณสามารถเรียกใช้บล็อกโค้ดโดยพลการราวกับว่ามันกำลังทำงานอยู่ภายในออบเจ็กต์นั้นซึ่งหมายความว่าเมื่อบล็อกการกำหนดค่าเรียกใช้ 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_id = 'not_my_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
ที่นี่เราใช้ Ruby’s instance_variable_get
วิธีการอ่านตัวแปรอินสแตนซ์ด้วยชื่อที่กำหนดเองและ instance_variable_set
เพื่อกำหนดค่าใหม่ให้กับมัน น่าเสียดายที่ชื่อตัวแปรต้องขึ้นต้นด้วยเครื่องหมาย“ @” ในทั้งสองกรณีด้วยเหตุนี้การแก้ไขสตริง
คุณอาจสงสัยว่าทำไมเราต้องใช้วัตถุเปล่าเป็นค่าเริ่มต้นสำหรับ“ not provided” และทำไมเราไม่สามารถใช้ 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
นิพจน์จะบันทึกบล็อกหากมีการส่งผ่านหรือมิฉะนั้นจะบันทึกค่า จากนั้นเมื่ออ่านแอตทริบิวต์ในภายหลังเราจะตรวจสอบว่าเป็นบล็อกหรือไม่และประเมินโดยใช้ 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
มองทั้งหมดนี้ ทับทิม เวทมนตร์ในโค้ดที่แทบจะอ่านไม่ออกและดังนั้นจึงยากที่จะดูแลรักษาคุณอาจสงสัยว่าความพยายามทั้งหมดนี้คุ้มค่าเพียงแค่ทำให้ภาษาเฉพาะโดเมนของเราดีขึ้นเล็กน้อย คำตอบสั้น ๆ คือขึ้นอยู่กับว่า - ซึ่งจะนำเราไปสู่หัวข้อสุดท้ายของบทความนี้
คุณอาจสังเกตเห็นในขณะที่อ่านขั้นตอนการใช้งาน DSL ของเราว่าเนื่องจากเราทำให้ไวยากรณ์ของภาษาภายนอกดูสะอาดขึ้นและใช้งานง่ายขึ้นเราจึงต้องใช้จำนวนมากขึ้นเรื่อย ๆ เทคนิค metaprogramming ภายใต้ประทุนเพื่อให้มันเกิดขึ้น ส่งผลให้เกิดการนำไปใช้งานที่จะเข้าใจและแก้ไขได้ยากอย่างเหลือเชื่อในอนาคต เช่นเดียวกับสิ่งอื่น ๆ อีกมากมายในการพัฒนาซอฟต์แวร์นี่เป็นข้อแลกเปลี่ยนที่ต้องตรวจสอบอย่างรอบคอบ
เพื่อให้ภาษาเฉพาะของโดเมนคุ้มค่ากับค่าใช้จ่ายในการใช้งานและค่าบำรุงรักษานั้นจะต้องนำผลประโยชน์มาสู่ตารางมากยิ่งขึ้น โดยปกติจะทำได้โดยการทำให้ภาษาใช้ซ้ำได้ในสถานการณ์ต่างๆให้มากที่สุดเท่าที่จะเป็นไปได้ดังนั้นการตัดจำหน่ายต้นทุนรวมระหว่างกรณีการใช้งานต่างๆ เฟรมเวิร์กและไลบรารีมีแนวโน้มที่จะมี DSL ของตัวเองอย่างแน่นอนเนื่องจากมีการใช้งานโดยนักพัฒนาจำนวนมากซึ่งแต่ละคนสามารถใช้ประโยชน์จากภาษาฝังตัวเหล่านั้นได้
ดังนั้นตามหลักการทั่วไปแล้วให้สร้าง DSL ก็ต่อเมื่อคุณผู้พัฒนารายอื่นหรือผู้ใช้ปลายทางของแอปพลิเคชันของคุณจะได้รับประโยชน์อย่างมากจากพวกเขา หากคุณสร้าง DSL อย่าลืมรวมชุดทดสอบที่ครอบคลุมไว้ด้วยรวมทั้งจัดทำเอกสารไวยากรณ์อย่างถูกต้องเนื่องจากอาจเป็นเรื่องยากที่จะเข้าใจจากการนำไปใช้งานเพียงอย่างเดียว อนาคตคุณและเพื่อนนักพัฒนาจะขอบคุณสำหรับสิ่งนี้