Ruby 如何打造出自己的 DSL

written | in ruby | comments

前言

當 Ruby 的 gem 使用得夠多,不時會發現有許多既方便又神奇的語法,例如 RSpec 裡面提供了許多方法,如下方的語法範例:

1
2
3
4
5
6
7
8
9
10
RSpec.describe Jedi::FightService do
  describe '#initialize' do
    context 'with valid args provided' do
       let(:args) { { foo: 'bar' } }
       it 'does not raise any error' do
          expect { descibed_class.new(args) }.not_to raise_error
       end
    end
  end
end

其中有 contextit,而在 it 裡面又有像是 expect 等特殊的方法都是 RSpec 所預先提供著,而使用者可以在 RSpec 所提供的 範圍 裡,遵照 RSpe 的設計風格與規範,使用了 特別的工具 方法,寫出愉快、精美又有效地能達成某些需求或目的的 script/code ,這就 Domain-specific language ( DSL ) 的一種實現。

其他常見的工具像是 Prawn也是一種很好的 DSL 範例,在 Prawn::Document.generatecodeblock 裡,我們可以使用存取特定的參數 pdf、以及使用許多預先提供的方法像是 draw_text, image, start_new_page 等等

1
2
3
4
Prawn::Document.generate("tmp/order_report.pdf", page_size: 'A4') do |pdf|
  pdf.draw_text order.order_no, at: [123, 321]
  pdf.image order.receive.file.path, at: [0, pdf.bounds.height], width: pdf.bounds.width
end

但有沒有好奇過,這到底是如何實現的呢?為何 Ruby 這麼適合用來打造 DSL ? 接下來的章節我將會為大家做粗略的解說以及分享案例。

何謂 DSL

DSL 是 Domain-Specific Language 的縮寫,中文譯為「特定領域語言」,顧名思義就是說:

開發人員為了解決特定領域的問題所定義的專用語言。

任何人都可以開發自己的 DSL ,爽的話當然自己也可以立刻打造一個,但前提是它真的能滿足某些需求,以及方便令人使用,故我們可以在進一步做些定義:

  1. 滿足特定的需求 –> 存在的目的
  2. 提供有效的處理方式 –> 與其他可選方案的差異性
  3. 易於使用 –> 吸引他人使用的價值
  4. 有一定的規範或架構 –> 讓人有跡可尋、並遵守或拓展

Why Ruby?

因為 Ruby 寫起來很爽 通常具備動態能力的語言較容易實現 DSL ,而且如果還要能產生更讓人上手、並且自然、直覺地去使用的 DSL,那無疑 Ruby 兩者都能滿足。

但相較於其他動態語言 (暫不考慮靜態語言),我個人覺得 Ruby 最關鍵的地方於:

1. 呼叫方法時可省略 self

在 Ruby 裡,當不指定方法(訊息)的傳送對象,都會是默認當做傳送給 self;所以我們不需要寫出像這樣的 code :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Foo
  def name
    @name ||= 'SuperFoo'
  end

  def say_hi
     puts "Hi, I'm #{self.name}.""
  end

  def introduce
     self.say_hi
     self.do_something
  end
end

而在 JavaScript 裡則必須地明確指定 this ,不然一般都會傳送給最頂層的物件 (像是 window),Python 也有類似的狀況。

因此 Ruby 此時的優勢是 帶來更簡潔的寫法、可讀性上也更佳、更直覺

2. 潔淨室的實現相當容易

而在講解潔淨室之前,我想先簡單介紹一下 Proc, codeblock。

Proc 與 codeblock

Ruby 裡的函式(方法)似乎不能當做第一級成員,至少它不能直接地被拿來傳遞給其他方法 (JavaScript, Python 都可以),但是取而代之的是,Proc 與 codeblock

Ruby 中同時存在著 Proclambda ,兩者的特性很像,除了對參數的接受容忍度、以及對 return 的處理行為不太一樣以外,我們大致可以把它視為相同的東西。

我們通常把它當作可傳遞可做方法呼叫的物件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 當做可呼叫物件
p = Proc.new { |obj| puts "Hello, #{obj}." }
p.call('World')
## 印出 Hello, World.
p['world']
## 印出 Hello, World.

# 傳遞給其他方法
def bar(proc_obj)
  puts 'in bar method...'
  proc_obj.call(__method__)
end
bar(p)
 ## 印出 in bar method...
 ## 印出 Hello, bar.

以及最重要的特性:

proc 會產生閉包 ( closure ),會記住(帶著)創見時的語意環境 ( context )

1
2
3
4
5
6
7
8
9
10
  def create_proc
     name = "in method #{__method__}"
     Proc.new { puts name }
  end

  p = create_proc
  puts name
  => NameError: undefined local variable or method `name' for main:Object
  p.call
  ## 印出 in method create_proc

在這個例子裡,create_proc 方法裡的 name 是地方變數,無法被外部存取,但是創見的 proc 確把它偷渡出來了

codeblock 也是一種 Ruby 用來傳遞一段程式碼的東西,但他不能直接被使用、存取,僅能透過 yield來使用、以及 block_given? 判斷是否存在。 兩者關係:

就是可以藉由 & 來將 codeblock 與 proc 相互轉換。

所以我們通常使用兩者的時機就是,把一些彈性預留給外部去做定義,需要的時候在把這個來自外部的彈性帶進來執行。

就像 context, it 都會傳遞一組 codeblock ,因為這就是整個方法的重點、要執行的主角,但在定義方法的時後還不知道,也不可能知道,但可以把這段執行主體交給外部決定( 如果常寫 JS 就把它想成是一個 callback )。

潔淨室的實現

我們已經知道 proc 、codeblock 會產生 closure ,就是會記住外部的語意環境,所以就是可以存取、使用存在於那個語意環境裡的變數、方法。

而潔淨室是一種我們專門用來執行 proc 或 codeblock 的物件

這樣看可能還是有些不懂,但其中一個關鍵字是 物件,那麼我就用這個物件再搭配 instance_eval方法一起看就會更懂了。

我們都知道 eval 家族得方法都很強大又有些邪惡,所以一般是少用的,但 Ruby 的超強大 meta programming 能力又很多仰賴於那些 eval 家族的方法像是 instance_evalclass_eval,由於這兩個方法可以接受 code_block 所以相對地沒只能接受字串的 eval 方法這麼危險。

而潔淨室的關鍵正是要搭配 instance_eval 一起服用

一句化簡單介紹 instance_eval,就是把給該方法的參數(通常是 codeblock)丟給該方法的接受物件去執行

1
2
3
4
5
6
p = Proc.new do
  introduce
end
p.call
=> NameError: undefined local variable or method `introduce' for main:Object
# 因為該 closure 裡的 introduce 方法還未定義,所以其實帶走了,也是無法執行的

因為該 closure 裡的 introduce 方法還未定義,所以其實帶走了,也是無法執行的;但我們可以利用 instance_eval 將該 closure 給某個有提供 introduce的物件去執行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Foo
  def initialize
    @name = 'SuperFoo'
  end

  def introduce
    puts "Hi, I'm #{@name}, and nice to meet you."
  end

  def show_myself
    yield(self) if block_given?
    puts 'I swear.'
  end
end
Foo.new.instance_eval(&p)
# 引出 Hi, I'm SuperFoo, and nice to meet you.

Foo 是一個具有 introduce方法的類別,我們根據 Foo 建立出一個物件,並且對該物件呼叫 instance_eval 方法,再將透過 & 將 proc 物件轉換成 codeblock 當做 instance_eval 的參數去執行了

所以此時 Foo 建立的物件就是那個專門用來執行 > codeblock 的環境,也就是潔淨室

而使用 instance_eval 的另一個目的就是,讓 codeblock 內隱含的 self 全都指向作為潔淨室的物件,不然照慣例 codeblock 是把當時環境的 self 給帶走的。

1
2
3
4
5
6
7
Foo.new.show_myself do |foo_obj|
  puts self.inspect
  puts foo_obj.inspect
end
## 印出 
## main => 當時帶走環境背後的 self
## #<Foo:0x007fdebc858f70 @name="SuperFoo"> => 真正的 foo 

這就是為什麼我們可以在 RSpec 裡使用諸多預先提供好的方法像是 expect 等等,這都是某個 RSpec 的潔淨室物件所提供的。

而 Prawn 的 codeblock 又可以在明確地存取 pdf或是隱含地把 self 當做 pdf 物件去使用,這一切全看你所想定義的風格

1
2
3
4
5
6
7
8
# 明確定透過參數 `pdf` 去掉用潔淨室的方法
Prawn::Document.generate("tmp/#{order_no}.pdf", page_size: 'A4') do |pdf|
  pdf.text 'lala land'
end
# 不透過參數呼叫,使用隱式的 self 當做潔淨室物件 pdf
Prawn::Document.generate("tmp/#{order_no}.pdf", page_size: 'A4') do
  text 'lala land'
end

以上兩個特性就是為何 Ruby 適合用來開發 DSL ,以及如何實現 DSL 的奧祕了(極度精簡版),剩下的就請大家去自行實踐或嘗試看看了。

範例

需求

有時候我們在呼叫一個方法時,會有一些前置方法去判斷是否符合條件,全部都通過才會執行方法本體,像是以下

1
2
3
4
5
6
def do_your_job
  return unless for_match_condtion_a?
  return unless for_match_condtion_b?
  return unless escape_from_evil?
  do_the_right_thing
end

for_match_condtion_afor_match_condtion_b? 這些方法其實只是作為某些規則去判斷而已,其他地方幾乎不會使用到,此時是否作為一個物件的其他方法就不是那麼必要的了,因此我們可以把這些抽象的規則邏輯給抽取出來成為一些 codeblock

使用起來很像是 Rails Model 裡常見的 callback 定義,而且我們期望在定義規則的 codeblock 可以直接存取實體方法

1
2
3
4
5
6
7
8
9
10
add_rule :for_match_condtion_b do
  your_logic_here
  and_here_the_scope_comes_the_instance_level
  expect_to_return_true_false_or_nil
end

add_rule :for_match_condtion_b, priority: 999 do
  expect_priority_to_be_a_integer
  with_higer_priority_would_be_check_first
end

然後我們就可以藉由以下實體方法來做一些事:

  1. rules_all_passed? => 判斷是否條件全部都通過
  2. failed_rule => 找出哪個條件失敗,如果都通過就回傳 nil
  3. match_rules => 回傳我們藉由 add_rule 建立的那些規則

以上就是使用的需求以及情境,也可以當做一種很微小的 DSL 或是客製化類宏

實現範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
module RulesValidatable
  extend ActiveSupport::Concern

  included do
    delegate :rules, to: :class
    alias_method :match_rules, :rules
    remove_method :rules
  end

  module ClassMethods
    def rules
      @rules ||= []
    end

    def add_rule(key, priority: nil, &block)
      rules.push(MatchRule.create(key, priority.to_i, &block))
    end
  end

  MatchRule = Struct.new(:key, :priority, :pass_proc) do
    def self.create(key, priority, &block)
      new(key, priority, block)
    end
  end

  def rules_all_passed?
    return true if match_rules.empty?
    catch(:rules_result) do
      match_rules.sort_by(&:priority).reverse.each do |rule|
        unless instance_eval(&rule.pass_proc)
          @failed_rule = rule.key
          throw(:rules_result, false)
        end
      end
      true
    end
  end

  def failed_rule
    @failed_rule ||= nil
  end
end

class Foo < ActiveRecord::Base
  include RulesValidatable

  add_rule :foo do
    # anything_u_like
    true
  end

  def bar
    return unless rules_all_passed?
    puts 'FooBar'
  end
end

結果可以自行試玩看看,或是有錯誤請幫忙 debug


Comments

blog comments powered by Disqus