新しくプロダクト開発を始める時、あなたはどの言語でコードを書くことを選ぶでしょうか?
シンプルに、スピーディーに開発が行える言語?メモリ効率や実行速度に優れた言語?はたまた一生使い続けると愛を誓った言語でしょうか。
今回は、そんな言語選定における選択肢としてのDSL(ドメイン固有言語)とその作り方について、Rubyを用いたコードを通して紹介していきます。
株式会社メンバーズ メンバーズエッジカンパニー Webエンジニア
2018年中途入社。SNS分野でのフィード広告運用システム・キャンペーン管理システムの開発に従事。
最近のブームは、リモートワーク定着を口実にしたホームオフィス環境の整備やスマートホーム化。お寿司とドーナツが好き。
GPLとDSL
言語選定は、プロダクトに求められる要件(保守性やパフォーマンス、セキュリティなど)を基準に、言語自体の性質、業界での開発実績、扱えるエンジニアの数など、非常に多くの物事を考慮しなければならない大変な作業です。
そうして苦労して選んだ言語は、しかし、今後解決していくべき課題に100%マッチするものではありません。どこかで多少なりとも扱いにくさを覚えるタイミングがあります。
なぜなら、RubyやJava、Pythonなど私たちがよく知る言語は、それぞれ得意分野こそありはするものの、あくまでも汎用的なプログラミングのための言語(GPL=General Purpose Language)だからです。
例えば図書管理を行うシステムの開発にPythonを使うことになったとして、本の貸し出し・返却を担うオブジェクトやメソッドは開発者自身が実装しなければなりません。
対して、ある特定の課題解決のために設計された言語をドメイン固有(特化)言語(DSL=Domain Specific Language)と呼びます。
身近なところでは、UNIXシェルやExcel VBA、SQLなどがDSLであると言えるでしょう。
これらの言語は汎用言語とは別の構文を持っており、独自のパーサーやジェネレータが用意されています。
DSLは、ソフトウェア開発者というよりも、その対象となる分野、業務に精通した人に向けた言語です。
分野特化の言語であることから、専門家にとって読みやすく、理解しやすいコードを、開発者でなくとも記述できるというメリットがあります。
対象となる分野とうまくマッチすれば、DSLは非常に高い生産性をもたらしてくれることでしょう。
また、Rubyプログラマにはお馴染みのRailsのルーティング設定の記法や、テスティングフレームワークのRSpecもDSLにあたります。
ただこれらのDSLは、(そうは見えずとも)実はRubyのルールに従った構文で成り立っています。Rubyの持つフォーマットの自由度の高さ、記法の柔軟さを利用して、違う言語のように見せているのです。
このようなGPLの中に潜んだDSLを「内部DSL」と言います。(感の良いあなたの想像する通り、先程例に挙げたSQLなど独自のパーサーを持つ言語は対照的に「外部DSL」と呼ばれます)
例えば以下のコードは、CLI上で動作するアドベンチャーゲームの設定を記述するためのDSLです。Rubyベースの内部DSLとして、今回書いてみたものになります。
シーン(‘始まり’) {
テキスト表示 ‘あなたは見知らぬ部屋で目が覚めた。ここは一体…?’
テキスト表示 ‘あなたは混乱した頭をなんとか落ち着かせ、部屋の中を調べることにした。’
移動 ‘リビング’
シーン終了
}シーン(‘リビング’) {
もし(初めてのシーン?) {
テキスト表示 “自分が寝ていた場所はどうやらリビングらしい。床に文房具や雑貨が散らかっている”
}
もし(2回目以降?) {
テキスト表示 ‘酷く荒れ果てたリビングだ。’
}
アクション({
‘テーブルを調べる’ => 結果 {
テキスト表示 ‘木製のダイニングテーブルだ。傷みが酷く、ところどころに赤黒いシミがついている。’
テキスト表示 ‘上には、空のマグカップと電源の入っていないノートパソコンが置かれている’
アクション({
‘ノートパソコンを調べる’ => 結果 {
テキスト表示 ‘電源は繋がっているが起動しない。故障しているようだ。’
},
‘離れる’ => 結果 {
一つ前のアクションに戻る
}
})
},
‘タンスを調べる’ => 結果 {
テキスト表示 “古びたタンスだ。取手には鎖と錠前がかけられている。簡単には開けられそうにない。”
もし(持っている? ‘錆だらけの鍵’) {
アクション({
‘錆だらけの鍵を使う’ => 結果 {
アイテム使用 ‘錆だらけの鍵’
テキスト表示 “鍵を差し込み回すと、錠前は意外にもあっさりと外れた。次に絡み合っていたチェーンを外し、タンスの取手に手を掛ける”
テキスト表示 “軋む音をたてながらを開かれた、タンスの内側には…”
移動 ‘結末’
シーン終了
},
‘何もしない’ => 結果 {
テキスト表示 ‘何もしないでおこう’
一つ前のアクションに戻る
}
})
}
アクション({
‘離れる’ => 結果 {
テキスト表示 ‘今できることはない。ここから離れよう。’
一つ前のアクションに戻る
}
})
},
‘風呂場に移動する’ => 結果 {
移動 ‘風呂場’
シーン終了
}
})
}
シーン(‘風呂場’) {
もし(初めてのシーン?) {
テキスト表示 ‘リビングの奥に、風呂場につながる廊下を見つけた。’
テキスト表示 ‘長らく使用されていないユニットバスがある’
}
もし(2回目以降?) {
テキスト表示 ‘汚れの目立つバスルームだ’
}
アクション({
‘洗面台を調べる’ => 結果 {
テキスト表示 ‘水垢が酷く、とても役目を果たせる状態ではない。’
},
‘浴槽を調べる’ => 結果 {
もし(持っている? ‘錆だらけの鍵’) {
テキスト表示 ‘こびりついた汚れ以外、他に目に留まる物はない。’
}
もし(持っていない? ‘錆だらけの鍵’) {
テキスト表示 ‘バスタブの中には、錆だらけの鍵が落ちていた。’
アイテム取得 ‘錆だらけの鍵’
}
},
‘リビングに戻る’ => 結果 {
移動 ‘リビング’
シーン終了
}
})
}
シーン(‘結末’) {
テキスト表示 ‘変わり果てたあなた自身が詰め込まれていた’
テキスト表示 ‘to be continued…’
移動 ‘END’
シーン終了
}
end
見慣れない記法やひらがなによる記述など到底Rubyのコードには見えないと思いますが、このコードは実際にあなたをミステリーアドベンチャーの冒頭部分へと導いてくれます。
そのための裏側の実装については、ライブラリなどが既に存在する場合はともかく、ほとんどの場合自分で実装しなければなりません。
今回、上記内部DSLを機能させるためのコードを書いてみましたので、それを元に内部DSLがどのように実装されるのかをお伝えできたらと思います。
Rubyで作るDSL
先程のDSLの全体を観察してみると、概ね次のような仕様になっていることが見て取れると思います。
- Adventureクラスのクラスメソッド、`start`のブロックの中にゲームの全ての設定が記述されている
- 設定は日本語で記述することができる
- いくつかの「シーン」のまとまりが並んでおり、シーン間の移動によってゲームが進む
- 「シーン」ブロックの中で、各シーンに合わせたテキストの表示やアクションの設定が記述できる
- 所持アイテムやシーンを訪れた回数などのプレイヤーの状態管理と、それに応じたシーンの分岐進行が行える
- プレイヤーの選択肢に応じてさらに選択肢が表示されるなど、ネストされたアクションの設定が行える
最初に、上記仕様を満たすように書いたコードの全体を示します。
# ゲームの設定に必要なメソッドを特異クラスに定義
class << self
def start(first_scene:, &block)
@player = Player.new
@scene_list = []
@current_scene = ‘ ‘
@current_processes = []# シーンの登録
parse_config(&block)
# 最初のシーンに移動
change_scene(first_scene)
move_player(first_scene)
# @current_sceneが’END’になるまでシーンを実行
start_scene until @current_scene == ‘END’
end
def parse_config(&block)
# Adventureクラスのコンテキストでブロックを実行する
self.class_eval(&block)
end
def move_player(scene)
# プレイヤーを新しいシーンに移動
@player.visit(scene)
end
def change_scene(next_scene)
# 現在のシーンを変更
@current_scene = next_scene
end
def start_scene
# 現在のシーンに登録された処理をプロセスとして順に実行する
@current_processes.clear
target_scene = @scene_list.find { |scene| scene[:name] == @current_scene }
target_scene[:process].call
# :scene_endの終了のタグがthrowされた場合、そこで処理を終了する
catch(:scene_end) do
@current_processes.each(&:call)
end
end
def display_text(&block)
# プレイヤーの操作に応じてテキストをゲームっぽく表示する
puts ‘-+-‘ * 20
puts “<- #{@current_scene} ->”
block.call
print “< Enter >”.rjust(60)
gets
end
def シーン(name, &block)
# 各シーンのプロセスをハッシュ形式でリストに登録する
@scene_list.push({ name: name, process: block })
end
def アクション(config)
process = Proc.new {
# シーンが終了するか一つ前のアクションに戻るタグがthrowされるまで選択肢の表示を繰り返す
catch(:action_back) do
loop do
# アクションブロックに記述された選択肢をリストに一時保存
action_list = []
config.each do |command, result|
action_list.push({ command: command, result: result })
end
# プレイヤーに、実行させるアクションを選択させる
puts ‘-+-‘ * 20
puts “<- #{@current_scene} ->”
puts ‘どうする?(コマンド番号を選択)’
action_list.each.with_index(1) do |action, index|
puts “#{index}: #{action[:command]}”
end
print “\nコマンド: ”
command_number = gets.chomp.to_i
# 選択肢にないコマンド番号を選択した場合を考慮
next if command_number <= 0 || command_number > action_list.count
# 選択したアクションに設定されたコマンドを実行し
# current_processesに実行するプロセスを登録・実行する
@current_processes.clear
selected_action = action_list[command_number – 1]
selected_action[:result].call
@current_processes.each(&:call)
end
end
}
@current_processes.push(process)
end
def 結果(&block)
# 与えられたブロックをProcオブジェクトに変換して返す
block
end
def シーン終了
# シーンを終了させるタグをthrowする
process = Proc.new { throw(:scene_end) }
@current_processes.push(process)
end
def 一つ前のアクションに戻る
# ネストしたアクションを一つ前に戻すためのタグをthrowする
process = Proc.new { throw(:action_back) }
@current_processes.push(process)
end
def 移動(next_scene)
# シーンの遷移とプレイヤーの行動履歴の追加を行う
process = Proc.new {
change_scene(next_scene)
move_player(next_scene)
}
@current_processes.push(process)
end
def テキスト表示(text)
# コマンドラインにテキストを30文字折り返しで表示する
process = Proc.new {
display_text do
text.each_char.each_slice(30) { |line| puts line.join }
end
}
@current_processes.push(process)
end
def アイテム取得(item)
# プレイヤーの所持品にアイテムを追加する
process = Proc.new {
@player.get_item(item)
display_text { puts “\”#{item}\” を手に入れた” }
}
@current_processes.push(process)
end
def アイテム使用(item)
process = Proc.new {
@player.lost_item(item)
display_text { puts “\”#{item}\” を使用した” }
}
@current_processes.push(process)
end
# 条件分岐設定用
def もし(condition, &block)
block.call if condition
end
# 以下はプレイヤーの状態に応じた真偽地を返すメソッド
# 上記`もし`のメソッドの条件部分に指定する
def 初めてのシーン?
@player.visited_count[@current_scene] == 1
end
def 2回目以降?
@player.visited_count[@current_scene] > 1
end
def 持っている?(item)
@player.items.include?(item)
end
def 持っていない?(item)
@player.items.none?(item)
end
end
end
# ゲーム進行におけるプレイヤーの状態管理を行うクラス
class Player
attr_reader :visited_count, :items
def initialize
@items = []
@visited_count = {}
end
def visit(scene)
visited_count[scene] ? @visited_count[scene] += 1 : @visited_count[scene] = 1
end
def get_item(item)
@items.push(item)
end
def lost_item(item)
@items.delete(item)
end
end
このコードの下に先程のゲーム設定用DSLを追記し、`dsl.rb`などの名前で保存してください。
その後、コマンドラインからファイルを保存したディレクトリに移動し、以下のコマンドを実行することで実際にゲームをプレイすることができます。
(Ruby2.7以上での実行を推奨します)
余裕があれば、既存コードを真似てシーンを追加したり、アクションを拡張したりしてみてください。
実際に動くコードとして、DSLが機能していることを確認できるかと思います。
class_execメソッド
ここからは、DSL実装コードの重要な部分について見ていきます。
まずゲームの進行は、Adventureクラスのクラスメソッドであるstartメソッドから始まります。
このメソッドは、プレイヤーの状態を管理するPlayerクラスのインスタンスの生成、シーンやその中で実行されるプロセスの登録などを行った後、引数`first_scene`で受け取ったシーンに移動・プロセスを実行します。
その後は、インスタンス変数`current_scene`が”END”になるまで、`current_scene`に指定されているシーンを繰り返し実行します。
class << self
def start(first_scene:, &block)
@player = Player.new
@scene_list = []
@current_scene = ‘ ‘
@current_processes = []# シーンの登録
parse_config(&block)
# 最初のシーンに移動
change_scene(first_scene)
move_player(first_scene)
start_scene until @current_scene == ‘END’
end
…
end
ここでのポイントは、シーンの登録に用いているparse_configメソッドです。
# Adventureクラスのコンテキストでブロックを実行する
self.class_eval(&block)
end
メソッド内で実行されている`class_eval`メソッドは、レシーバのコンテキストで与えられたブロックを実行するメソッドです。
`parse_config`はAdventureクラスの特異クラスの中で定義されているため、ここでのレシーバである`self`はAdventureクラスそのものを指します。
なぜこのような手順を踏んでいるか、ですが。
DSLに記述されている`シーン`や`テキスト表示`といったキーワードは、裏側ではRubyのメソッドとして実行されます。
それらはstartメソッドのブロックの中で評価されますが、そのコンテキストはブロックの外側、つまりトップレベルと同じものになります。
つまり、`シーン`や`テキスト表示`というメソッドを機能させるためにはトップレベルにそれらを実装しなければなりません。
そのようにしてもよかったのですが、今回はトップレベルに余計なメソッドを追加したくなかったため、DSLで記述できる全てのメソッドをAdventureクラスのコンテキストで評価・実行するようにしています。
Procオブジェクト
DSLで記述されているキーワードの内、`移動`や`テキスト表示`などゲームの進行を担うメソッドは「現在のプロセスリストに、キーワードごとの処理をまとめたProcオブジェクトを追加する」という構成になっています。
# シーンの遷移とプレイヤーの行動履歴の追加を行う
process = Proc.new {
change_scene(next_scene)
move_player(next_scene)
}
@current_processes.push(process)
end
Procオブジェクトは、生成時に渡されたブロックに記述された一連の処理を、コンテキストごとまるっとまとめてオブジェクトにしたものです。
このように生成・登録したProcオブジェクトを、シーンやアクションごとに遅延評価することでゲームを進行しています。
また、`もし`という条件判定を行うためのメソッドの実装では、引数に与えられた条件部分が真の場合にのみ、与えられたブロックを実行するようにしています。
block.call if condition
end
このProcオブジェクトによる遅延評価によって、プレイヤーの状況や選択に応じて実行するアクション/プロセスを変化させるなど、ゲーム特有のコントロールが可能になっています。
DSLの設計について
今回作成してみたコードのように、内部DSLは元の言語の要素やAPIを覆い隠すように実装され、元の言語の制限はあるものの、まるで全く別の言語のように表現することができます。
そうすることで、元の言語や関連する技術的な知識がなくとも、専門家自身が課題解決に最適な処理を書くことができます。
ただし、その分DSLの開発は慎重に、綿密に行わなければなりません。
解決すべき分野、ドメインの深い理解はもちろんのこと、利用者にとって分かりやすく扱いやすい記法を定義していく難しさがあります。
さらに言えば、利用にあたってのドキュメントや、誤った記述をしてしまった場合のエラー表示なども用意してあると親切でしょう。
今回例として書いたDSLの実装コードは、冒頭のゲーム設定通りに「とりあえず動く」ことを目的とした非常に拙いものです。
将来の拡張性や保守性を考慮し、より適切なクラス定義、メソッド分割などを行う必要があるでしょう。もちろん、テストも書かかねばなりません。
また今回使用した`class_eval`のような、与えられたブロックを評価するメソッドを扱う場合には注意が必要です。
内部DSLはあくまでも元となる言語をベースに作成されているため、通常任意のRubyコードをその中で実行できてしまうからです。(冒頭に示したアドベンチャーゲームの設定コードの中に、`puts`など適当なメソッドを書いてみてください)
利用者の範囲が限定的な場合は問題にならないかもしれませんが、実行環境をWeb上で不特定多数に公開するような場合には、コードインジェクションへの対策が必須になります。
このような問題に注意しなければならないのは、DSLの作成にメタプログラミングの要素が絡んでくるためです。メタプログラミングはとても強大な力を秘めていますが、その分使用する際に大きな責任が伴うのです。
このように、外部DSLまでとは言わないものの、内部DSLの作成も非常に難易度が高いです。
その実装に当たっては、非常に多くのハードルを知識とアイデアで乗り越えなければならないでしょう。
最後に
例として上げたアドベンチャーゲーム向けDSLは今回の記事執筆のために作成したものなのですが、その実装作業はなかなかにハードなものでした。
Rubyのオブジェクトモデルやメタプログラミングなど、Rubyを構成する要素の深い理解が必要になりましたし、記法の設計には丸一日頭を悩ませました。
ただ、Rubyを使用した内部DSLはそれ自体非常に面白いトピックだと感じましたし、また、Rubyに関して新しく学べたこともとても数多くありました。
もしRubyの内部DSLに興味が沸いたら、あなたもぜひ挑戦してみてください。
それは非常に過酷な挑戦になるかもしれません。しかしそれを乗り越えた先では、Rubyの更なる可能性と面白さがあなたを待っているはずです。