最近、fluent-plugin-droongaという分散データストリームエンジンを書いています。その中で、RubyでDSLを実現するときに工夫していることに気づきました。それは、値を設定するときは代入する字面にするということです。代入する字面にするために、グループ化用のオブジェクトを作っていました。
これだけだとどういうことかわからないので、具体例を示しながら説明します。
Rubyを使っているとRubyで実現されたDSLに触れることが多くあります。RubyのMake実装であるRakeの設定ファイルもそうですし、ライブラリー管理ツールのBundlerの設定ファイルもそうです。
Rakeの場合:
1 2 3 |
task :test do ruby("test/run-test.rb") end |
Bundlerの場合:
1 2 |
source "https://rubygems.org/" gem "rake" |
設定ファイルではなく、Rubyのコードの中で使われているDSLもあります。WebアプリケーションのSinatraはWebアプリケーション作成のためのDSLと自称しているくらいです。
Sinatraの場合:
1 2 3 4 5 6 |
require "sinatra" get "/" do cache_control :public, :must_revalidate, :max_age => 60 "Hello world!" end |
いろんなDSLを見るといくつかの種類に分類できることに気づきます。例えば次のように分類できます。
task :test
:タスクを定義get "/"
:「GET / HTTP/1.1」されたときの動作を定義attr_reader
とかの特化版)
source "https://rubygems.org/"
:RubyGemsの取得元を宣言gem "rake"
:使うRubyGemsを宣言ruby("test/run-test.rb")
:Rubyでスクリプトを実行cache_control
:Cache-Controlヘッダーの値を設定今回注目するのは設定系です。設定系のDSLを作るときに工夫していることです。
設定系は値を変えるのでまさに代入という動作です。そのため、「代入する字面」になるようにします。
Sinatraのcache_control
は、次のように宣言系の字面になっています。
1 |
cache_control :public, :must_revalidate, :max_age => 60 |
そうではなく、「代入する字面」にするということです。「代入する字面」とは、例えば次のようにするということです。
1 |
self.cache_control = [:public, :must_revalidate, {:max_age => 60}] |
ここで気になるのが「self.
」です。Pythonと違いRubyでは明示的に「self
」を書くことがほとんどありません。明示的に書くときは次のようなケースです。
class << self
)「代入する字面」にすると2番目の「自分の代入メソッドを呼び出すとき」というケースに当てはまるので、明示的に「self
」と書く必要があります。これは、ローカル変数への代入と代入メソッドの呼び出しを区別するためのRubyの制限です。
1 2 3 4 5 6 |
def xxx=(value) @xxx = value end xxx = "local" # <- ローカル変数への代入 self.xxx = "method" # <- xxx=メソッドの呼び出し |
self
はRubyでは不自然な字面*1です。self
を使わず、自然な字面にするためにオブジェクトを作ります。例えば次のようにします。
1 |
response.cache_control = [:public, :must_revalidate, {:max_age => 60}] |
「レスポンスのCache-Controlを設定する」と読めるコードで妙なところはありません。このようにself
ではなく何かオブジェクトを使うように工夫していました。
この例ではレシーバーとしてself
ではなくresponse
を導入しました。このレスポンスをどうやって見つけるか。それを見つけるために、「グループ化」して考えていました。
Cache-Controlの例で言うと、「Cache-Controlは何関連の設定だろう」と考えます。レスポンスのヘッダーの設定なのでresponse
を導入しました。「レスポンス関連の設定」と考えたということです。
レシーバーをつけると記述が長くなります。
1 2 |
cache_control :public, :must_revalidate, :max_age => 60 response.cache_control = [:public, :must_revalidate, {:max_age => 60}] |
これをデメリットと考えることもできますが、次のメリットはそのデメリットを上回ります。
このことから、設定系のDSLはレシーバー付き代入式にすることをオススメします。このとき、レシーバーには設定対象のグループを表す名前をつけます。
いくつか実例を示します。
まずは、この工夫をしていることを自覚したfluent-plugin-droongaのケースです。
fluent-plugin-droongaは入力メッセージや出力メッセージの処理をプラグインでカスタマイズできます。プラグインは特定のメッセージを処理します。どのメッセージを処理するかをメタデータとして設定するAPIになっています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
module Droonga module Plugins module CRUD class Adapter < Droonga::Adapter message.input_pattern = ["type", :equal, "add"] message.output_pattern = ["body.success", :exist?] def adapt_input(input_message) end def adapt_output(output_message) end end end end end |
message.input_pattern=
とmessage.output_pattern=
が設定系のDSL*2です。メッセージ関連の設定なのでmessage
をレシーバーにしています。input_message.pattern=
とoutput_message.pattern=
にしてもよいでしょう。
これを次のようにすることもできます。
1 2 3 4 |
class Adapter < Droonga::Adapter input_pattern ["type", :equal, "add"] output_pattern ["body.success", :exist?] end |
宣言系のDSLのような字面です。DSLが好きな人はこちらを好むかもしれません。しかし、ここで設定した値を使うときにself
とは違った違和感があります。
設定した値を取得するときは引数なしで同じメソッドを呼びます。
1 2 |
Adapter.input_pattern # -> ["type", :equal, "add"] Adapter.output_pattern # -> ["body.success", :exist?] |
これらのメソッドの実装はこうなります。
1 2 3 4 5 6 7 8 9 10 11 12 |
class Droonga::Adapter class << self PATTERN_NIL = Object.new def input_pattern(pattern=PATTERN_NIL) if PATTERN_NIL == pattern @input_pattern else @input_pattern = pattern end end end end |
1つのメソッドで2つのことをしているので理解するまでにワンクッション必要なコードになっています。
message.input_pattern=
の実装はこうなります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class Droonga::Adapter class MessageConfiguration attr_accessor :input_pattern attr_accessor :output_pattern def initialize @input_pattern = nil @output_pattern = nil end end class << self def message @message ||= MessageConfiguration.new end end end |
オブジェクトが1つ増えていますが、やっていることは明快なのですぐに理解できます。
別の例を紹介します。
設定ファイルで値を設定するケースです。milter managerというソフトウェアの設定ファイルはRubyスクリプトで次のようになっています。
1 2 3 4 5 6 7 |
security.privilege_mode = false security.effective_user = nil security.effective_group = nil manager.connection_spec = "inet:10025@[127.0.0.1]" manager.unix_socket_mode = 0660 manager.unix_socket_group = nil |
security
とmanager
をグループとしています。
milter managerもグループごとにオブジェクトを作る実装になっています。
Rubyで自然なDSLを作るコツとして、値を設定するときは、レシーバーに設定対象のグループを示す名前をつけ、代入式にするとよいということを紹介しました。実例と実装例もつけたので、よさそうだと思ったらマネしてみてください。
*1 Rubyist Magazine - Ruby コードの感想戦 【第 2 回】 WikiR - set_srcのこと参照。
*2 fluent-plugin-droongaに特化したシンタックス。DSLではなく、単なるAPIだよね、といってもいいくらい薄いDSL。