創建 Ruby DSL:高級元編程指南

已發表: 2022-03-11

領域特定語言 (DSL) 是一種非常強大的工具,可以更輕鬆地對複雜系統進行編程或配置。 它們也無處不在——作為一名軟件工程師,您很可能每天都使用幾種不同的 DSL。

在本文中,您將了解什麼是特定領域的語言,何時應該使用它們,以及如何使用高級元編程技術在 Ruby 中創建自己的 DSL。

本文基於 Nikola Todorvic 對 Ruby 元編程的介紹,該介紹也發表在 Toptal 博客上。 因此,如果您不熟悉元編程,請務必先閱讀該內容。

什麼是領域特定語言?

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 塊實現的,並且使用getresources等方法調用來定義這種迷你語言的關鍵字。

元編程在 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

這段代碼還包含fluent interfaces的示例,它允許將聲明作為簡單的英語句子大聲朗讀,從而更容易理解代碼在做什麼:

 # 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 也存在於其他語言中。 下面是一個使用 Jasmine 框架的 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 在其實現語言中必須在語法上有效。 在這方面你必須妥協多少很大程度上取決於所選的語言,其中一個是冗長的靜態類型語言,如 Java 和 VB.NET,另一個是具有廣泛元編程功能的動態語言,如 Ruby結尾。

構建我們自己的——用於類配置的 Ruby DSL

我們將在 Ruby 中構建的示例 DSL 是一個可重用的配置引擎,用於使用非常簡單的語法指定 Ruby 類的配置屬性。 向類添加配置功能是 Ruby 世界中非常常見的需求,尤其是在配置外部 gems 和 API 客戶端時。 通常的解決方案是這樣的界面:

 MyApp.configure do |config| config.app_ config.title = "My App" config.cookie_name = "my_app_session" end

讓我們先實現這個接口——然後,以它為起點,我們可以通過添加更多特性、清理語法和使我們的工作可重用來逐步改進它。

我們需要什麼才能使這個界面工作? MyApp類應該有一個configure類方法,它接受一個塊,然後通過屈服於它來執行該塊,傳入一個配置對象,該對象具有用於讀取和寫入配置值的訪問器方法:

 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方法外,這裡沒有太大變化。 我們需要這個方法是因為包含一個模塊只混入了它的實例方法,所以我們的configconfigure類方法默認不會添加到宿主類中。 但是,如果我們在一個模塊上定義了一個名為 include 的特殊方法,那麼只要該模塊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的表達式,它就會愉快地包含它。 因此,我們需要一個名稱為with的方法,而不是直接包含Configurable ,它會生成一個使用指定屬性定制的新模塊:

 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 中的塊可以訪問外部變量的事實也是它們有時被稱為閉包的原因,因為它們包含或“關閉”它們定義的外部環境。請注意,我使用了“定義在”這個短語而不是“執行”。 這是正確的——無論我們的define_method塊最終在何時何地執行,它們總是能夠訪問變量config_classclass_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

唷,那已經是一些非常核心的元編程了。 但是增加的複雜性值得嗎? 看看它是多麼容易使用並自己決定:

 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 的instance_eval方法可以讓您執行任意代碼塊,就好像它在該對像中運行一樣,這意味著當配置塊在第一行調用app_id方法時,該調用將轉到我們的配置類實例。

config_class中屬性訪問器方法的更改有點複雜。 要理解它,我們首先需要了解attr_accessor在幕後究竟做了什麼。 以下面的attr_accessor調用為例:

 class SomeClass attr_accessor :foo, :bar end

這相當於為每個指定的屬性定義一個 reader 和 writer 方法:

 class SomeClass def foo @foo end def foo=(value) @foo = value end # and the same with `bar` end

因此,當我們在原始代碼中編寫attr_accessor *attrs時,Ruby 為attrs中的每個屬性定義了屬性讀取器和寫入器方法——也就是說,我們得到了以下標準訪問器方法: app_idapp_id=titletitle=等等在。 在我們的新版本中,我們希望保留標準的編寫器方法,以便這樣的分配仍然可以正常工作:

 MyApp.config.app_ => "not_my_app"

我們可以通過調用attr_writer *attrs繼續自動生成 writer 方法。 但是,我們不能再使用標準的讀取器方法,因為它們還必須能夠編寫屬性來支持這種新語法:

 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 的instance_variable_get方法讀取具有任意名稱的實例變量,並使用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_nameapp_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 指定這些配置值,該 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 的實現步驟時,您可能已經註意到,由於我們使語言的面向外部的語法更簡潔且更易於使用,因此我們不得不在後台使用越來越多的元編程技巧來實現它。 這導致了未來將難以理解和修改的實現。 就像軟件開發中的許多其他事情一樣,這也是一個必須仔細檢查的權衡。

對於特定領域的語言來說,它的實施和維護成本是值得的,它必須帶來更多的好處。 這通常是通過使語言在盡可能多的不同場景中可重用來實現的,從而在許多不同的用例之間分攤總成本。 框架和庫更有可能包含自己的 DSL,因為它們被許多開發人員使用,每個開發人員都可以享受這些嵌入式語言的生產力優勢。

因此,作為一般原則,僅當您、其他開發人員或您的應用程序的最終用戶將大量使用 DSL 時才構建 DSL。 如果您確實創建了 DSL,請確保包含一個全面的測試套件,並正確記錄其語法,因為僅從實現中很難弄清楚。 未來您和您的開發人員會為此感謝您。


進一步閱讀 Toptal 工程博客:

  • 如何從頭開始編寫解釋器