Rubyで自然なDSLを作るコツ:値を設定するときはグループ化して代入 - 2014-02-13 - ククログ

ククログ

株式会社クリアコード > ククログ > Rubyで自然なDSLを作るコツ:値を設定するときはグループ化して代入

Rubyで自然なDSLを作るコツ:値を設定するときはグループ化して代入

最近、fluent-plugin-droongaという分散データストリームエンジンを書いています。その中で、RubyでDSLを実現するときに工夫していることに気づきました。それは、値を設定するときは代入する字面にするということです。代入する字面にするために、グループ化用のオブジェクトを作っていました。

これだけだとどういうことかわからないので、具体例を示しながら説明します。

RubyとDSL

Rubyを使っているとRubyで実現されたDSLに触れることが多くあります。RubyのMake実装であるRakeの設定ファイルもそうですし、ライブラリー管理ツールのBundlerの設定ファイルもそうです。

Rakeの場合:

task :test do
  ruby("test/run-test.rb")
end

Bundlerの場合:

source "https://rubygems.org/"
gem "rake"

設定ファイルではなく、Rubyのコードの中で使われているDSLもあります。WebアプリケーションのSinatraはWebアプリケーション作成のためのDSLと自称しているくらいです。

Sinatraの場合:

require "sinatra"

get "/" do
  cache_control :public, :must_revalidate, :max_age => 60
  "Hello world!"
end

いろんなDSLを見るといくつかの種類に分類できることに気づきます。例えば次のように分類できます。

  • 定義系:動作に名前をつける。(メソッド定義とかの特化版)
    • task :test:タスクを定義
    • get "/":「GET / HTTP/1.1」されたときの動作を定義
  • 宣言系:登録する。N回実行するとN個登録できる。(attr_readerとかの特化版)
    • source "https://rubygems.org/":RubyGemsの取得元を宣言
    • gem "rake":使うRubyGemsを宣言
  • 操作系:実行する。(メソッド呼び出しの特化版)
    • ruby("test/run-test.rb"):Rubyでスクリプトを実行
  • 設定系:値を変える。N回実行すると最後の値が有効になる。(代入)
    • cache_control:Cache-Controlヘッダーの値を設定

今回注目するのは設定系です。設定系のDSLを作るときに工夫していることです。

設定系のDSLは代入する字面にする

設定系は値を変えるのでまさに代入という動作です。そのため、「代入する字面」になるようにします。

Sinatraのcache_controlは、次のように宣言系の字面になっています。

cache_control :public, :must_revalidate, :max_age => 60

そうではなく、「代入する字面」にするということです。「代入する字面」とは、例えば次のようにするということです。

self.cache_control = [:public, :must_revalidate, {:max_age => 60}]

ここで気になるのが「self.」です。Pythonと違いRubyでは明示的に「self」を書くことがほとんどありません。明示的に書くときは次のようなケースです。

  • クラスメソッドを定義するとき(class << self
  • 自分の代入メソッドを呼び出すとき

「代入する字面」にすると2番目の「自分の代入メソッドを呼び出すとき」というケースに当てはまるので、明示的に「self」と書く必要があります。これは、ローカル変数への代入と代入メソッドの呼び出しを区別するためのRubyの制限です。

def xxx=(value)
  @xxx = value
end

xxx      = "local"  # <- ローカル変数への代入
self.xxx = "method" # <- xxx=メソッドの呼び出し

selfはRubyでは不自然な字面1です。selfを使わず、自然な字面にするためにオブジェクトを作ります。例えば次のようにします。

response.cache_control = [:public, :must_revalidate, {:max_age => 60}]

「レスポンスのCache-Controlを設定する」と読めるコードで妙なところはありません。このようにselfではなく何かオブジェクトを使うように工夫していました。

レシーバーをだれにするか

この例ではレシーバーとしてselfではなくresponseを導入しました。このレスポンスをどうやって見つけるか。それを見つけるために、「グループ化」して考えていました。

Cache-Controlの例で言うと、「Cache-Controlは何関連の設定だろう」と考えます。レスポンスのヘッダーの設定なのでresponseを導入しました。「レスポンス関連の設定」と考えたということです。

グループ化して考えたレシーバーをつけるメリット

レシーバーをつけると記述が長くなります。

cache_control :public, :must_revalidate, :max_age => 60
response.cache_control = [:public, :must_revalidate, {:max_age => 60}]

これをデメリットと考えることもできますが、次のメリットはそのデメリットを上回ります。

  • 「代入」の字面なので値を上書きすることが明確になる。
  • レシーバーが付加情報になるため、より自己記述的になり、コードの意図が明確になる。
    • ただし、適切なグループをレシーバー名にした場合。適当につけるとかえってわかりにくくなることもある。

このことから、設定系のDSLはレシーバー付き代入式にすることをオススメします。このとき、レシーバーには設定対象のグループを表す名前をつけます。

実例:fluent-plugin-droonga

いくつか実例を示します。

まずは、この工夫をしていることを自覚したfluent-plugin-droongaのケースです。

fluent-plugin-droongaは入力メッセージや出力メッセージの処理をプラグインでカスタマイズできます。プラグインは特定のメッセージを処理します。どのメッセージを処理するかをメタデータとして設定するAPIになっています。

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=が設定系のDSL2です。メッセージ関連の設定なのでmessageをレシーバーにしています。input_message.pattern=output_message.pattern=にしてもよいでしょう。

これを次のようにすることもできます。

class Adapter < Droonga::Adapter
  input_pattern  ["type", :equal, "add"]
  output_pattern ["body.success", :exist?]
end

宣言系のDSLのような字面です。DSLが好きな人はこちらを好むかもしれません。しかし、ここで設定した値を使うときにselfとは違った違和感があります。

設定した値を取得するときは引数なしで同じメソッドを呼びます。

Adapter.input_pattern  # -> ["type", :equal, "add"]
Adapter.output_pattern # -> ["body.success", :exist?]

これらのメソッドの実装はこうなります。

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=の実装はこうなります。

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

別の例を紹介します。

設定ファイルで値を設定するケースです。milter managerというソフトウェアの設定ファイルはRubyスクリプトで次のようになっています。

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

securitymanagerをグループとしています。

milter managerもグループごとにオブジェクトを作る実装になっています。

まとめ

Rubyで自然なDSLを作るコツとして、値を設定するときは、レシーバーに設定対象のグループを示す名前をつけ、代入式にするとよいということを紹介しました。実例と実装例もつけたので、よさそうだと思ったらマネしてみてください。

  1. Rubyist Magazine - Ruby コードの感想戦 【第 2 回】 WikiR - set_srcのこと参照。

  2. fluent-plugin-droongaに特化したシンタックス。DSLではなく、単なるAPIだよね、といってもいいくらい薄いDSL。