以 Rspec 為例,創造一個自己的 DSL 工具吧!

什麼是 DSL? DSL 的存在意義是什麼?

DSL 為 Domain Specific Language 的縮寫,中文譯為「特定領域語言」。而 DSL 的開發通常是為了迎合某些特殊的需求,例如針對系統、平台、程式工具、軟體問題、商業邏輯等等領域,當人們發現現有的程式語言並沒有辦法好好地嵌合使用需求,DSL 就有其存在的價值。目前為人所知的 DSL 包括 CSS 以及 SQL,而這種易於人類閱讀的 DSL 也能夠讓程式設計師乃至於一般使用者都能享受 DSL 帶來的好處,甚至不懂程式設計的使用者都能夠針對自己的需求調整參數。

而 Ruby 本身由於程式設計面本身帶有易於閱讀的特質,因此非常多的 Gem 都是開發為 DSL 的形式,例如 AASMwhenever、以及最為廣為人知,最多人使用的測試框架 RSpec 等等。

這篇文章透過設計一個簡單的 RSpec 框架,來告訴大家一個 DSL 是如何開發而成的。

測試範例

我們會用下面的程式來舉例。

class CatFactory
  def produce
    "meow"
  end
end

describe CatFactory
  describe "#produce" do 
    it "produce a cat and meowing" do
      expect(CatFactory.new.produce).to eq("meow")
    end

    it "does not produce a dog" do
      expect(CatFactory.new.produce).not_to eq("woof")
    end
  end
end

這是一個相當簡單的範例,我們有個貓咪工廠,每當我們產出一隻貓就會喵一聲。下面的 Test Case 則有兩個,在 DSL 的形式之下,我們可以很明顯地讀懂這個測試想表達的意思:我們希望測試這個 #produce 函式可以產生出一隻貓,並且「喵」一聲,而不是產生出一隻狗。

實作 RSpec

接著我們會慢慢地實作出小型的測試框架,先從 describe 開始吧!

class Describe
  def initialize(context, &block)
    @context = context
    instance_eval(&block)
  end

  # 為了讓我們可以使用 describe 來做成巢狀結構
  def describe(context, &block)
    Describe.new(context, &block)
  end
end

由於我們希望 DSL 的形式是 describe CatFactory 而不是Describe.new(CatFactory),我們可以在 Describe 這個 class 外面新增一個 helper method:

# helper method

def describe(context, &block)
  Describe.new(context, &block)
end

如此以來我們就可以利用 describe 來創造一個清楚明瞭的巢狀結構了。

接著我們希望把每個測試的 Case 都化為一個物件稱為 Example,因此我們要在創建一個新的 class。

class Example
  def initialize(context, &block)
    @context = context
    instance_eval(&block)
  end

  def expect(result)
  end

  def to(expectation)
  end

  def not_to(expectation)
  end

  def eq(expectation)
  end
end

讓我們來一個一個實作這些函式吧。

#expect 這個函式單純只是保存測試的結果,因此是最為容易的一個。

  def expect(result)
    @result = result

    # 為了讓 #expect 之後可以繼續串 Example 裡頭的其他函數,所以我們這裡希望回傳 self。

    self
  end

#eq 這個函式很單純只是做出像 == 一樣的運算,不過我們必須將它傳進 #to,並且和真正的結果對比。這裡我們會用 Proc 來處理。

  def to(expectation)
    @test_result = expectation.call(@result)
  end

  def eq(expectation)
    Proc.new { |result| result.eql?(expectation) }
  end

別忘了,我們還有一個 #not_to,不過 #not_to 其實就是 #to 的相反,因此就很容易了。

  def not_to(expectation)
    @test_result = !(expectation.call(@result))
  end

顯示測試的結果

我們已經實作了大致的邏輯,現在我們希望可以在終端機中顯示我們的測試結果。這裡我們希望顯示出 describe 的巢狀結構,並且也列出所有 #it 表示的測試內容描述。為了表現出測試的結果是否成功與失敗,我們要運用 colorize 這個 Gem。

為了達到上述的功能,我們希望給 Example 一個 test_result 的 Reader,讓 Describe 可以得知測試的結果。

  class Example
    attr_reader :context, :test_result

    # other code below
    # ...
  end

接著我們讓 Describe 可以用陣列儲存我們的 context 和 example:

  class Describe
    attr_reader :context, :examples, :describes
     def initialize(context, &block)
      @context = context
      @describes = []
      @examples = []
      instance_eval &block
    end

    def describe(context, &block)
      describes << Describe.new(context, &block)
    end

    def it(context, &block)
      @examples << Example.new(context, &block)
    end
  end

最後,我們在 Describe 裡面實作一個 test 函式 來啟動整個測試:

    def test
      puts context
      describes.each do |describe_node|
        puts "  " + describe_node.context
        describe_node.examples.each do |example_node|
          color = example_node.test_result ? :green : :red
          puts "    " + example_node.context.colorize(color)
        end
      end
    end

在這個函式裡面我們可以看見,我們先印出最上位的 context,之後我們再印出每個巢狀結構下面的 describe 裡面的 context,最後則是印出 Example 的結果。而 color 的部分則是我們的 test_result 是 true 的情況下印出綠色,而 false 的情況下則印出紅色。

接著讓我賦予 test case 一個變數,然後啟動測試。

rspec = describe CatFactory
  describe "#produce" do 
    it "produce a cat and meowing" do
      expect(CatFactory.new.produce).to eq("meow")
    end

    it "does not produce a dog" do
      expect(CatFactory.new.produce).not_to eq("woof")
    end
  end
end

rspec.test

當你執行這個 Ruby 檔案時,你應該會看到一個巢狀結構的顯示,代表你順利創造出了這個小型的 DSL 測試框架了!

CatFactory
  #produce
    produce cat and meowing
    does not produce dog

總結

由於 Ruby 的種種特性(適合 Metaprogramming,簡潔的寫法,易讀的語句),讓 DSL 的實現變得更加容易,也使得很多的 Gem 成為非常洗鍊的 DSL 工具。未來在製作自己的工具的時候,不仿利用這些特性,也可以讓你的工具更加易於應用及推廣。