皆さんこんにちは。しうへいと申します。
今日はYAMLでDSLを作ってみたいと思います。
YAMLとは
構造化データを扱うフォーマットの一種で、JSONと(ほぼ)相互変換できます。
JSONのようにYAMLをプログラム中で読み込んで、構造化データオブジェクトを復元することができます。
YAMLのフォーマットは、インデントが強制されたJSONのようなもの、と考えるとイメージしやすいと思います。
JSONとの機能上の一番の違いは、コメントが使用可能なことです。
DSLとは
Domain Specific Language(ドメイン固有言語)の略です。
プログラミング言語の文法は一般に、あらゆる処理を記述可能なように、柔軟に設計されています。
これに対し、柔軟性が失われてもいいから、ある特定の範囲の命令を簡潔に書きたい。
そんな場合は、DSLを作成するモチベーションになると思います。
さっそくDSLを書いてみる
DSL自体に決まりはないので、好きなように作ったらいいのですが、標準出力を行うDSLとして、一旦以下のように書いてみました。
func: print
args:
# argsは配列として記述
- Hello, World!
- Goodbye!
これをDSLとして解釈するPythonプログラム(便宜上、DSLランナーと呼びます)を書きます。
PythonでYAMLを扱うために、PyYamlというライブラリをあらかじめインストールして、使用します。
import yaml
# DSLから呼び出し可能な関数を列挙
func_dict = {'print': print}
with open('hello1.yml') as yaml_file:
# YAMLのロード
dsl = yaml.load(yaml_file)
# ロードされたDSLを、確認のために標準出力
print(dsl)
# 実際の解釈処理
if 'func' in dsl and 'args' in dsl:
func_name = dsl['func']
if func_name in func_dict:
func = func_dict[func_name]
func(*dsl['args'])
DSLによって渡された関数名が、事前に関数の辞書func_dictに存在していれば、そこから関数の実体を取得し、
それに引数を可変長引数として渡して実行する、という実装です。
処理自体には、関数名のprintも、引数も入っていないところがミソです。
実行結果は以下のようになります。
{'func': 'print', 'args': ['Hello, World!', 'Goodbye!']}
Hello, World! Goodbye!
世界で一番単純なDSLの実装
さて、何をもってDSLが実装されたとするかは難しいところなのですが、
私が考える、世界で一番単純なDSLの実装は、以下のようなものです。
- DSLには、ある一定のルールに基づいて、関数名と引数を列挙する。
- DSLランナーは、ある一定のルールに基づいて、関数名と引数を受け取る。
- DSLランナーが、関数を実際に呼び出すことで、処理が実行される。
「ある一定のルールに基づいて」の部分を具体化させるのが、実際のデザインおよび実装タスクだと考えられます。
もう少し書いてみる
Hello Worldに名前を含められるようにしましょう。
先にYAMLからデザインします。
# 複数の命令を実行できるように、トップレベルをリストに変更
# setは変数をセットする命令
- func: set
args:
# 第一引数は変数名
- your_name
# 第二引数は値
- cuhey3
- func: print
args:
- Hello, World!
# セット済みの変数を引数に含める
- your_name
- Goodbye!
先にYAMLから書きはじめて、使いやすそうか?使いにくそうか?を考えるのも醍醐味だと思いますが、
それはさておき、変更後のDSLを解釈できるよう、DSLランナーに対して以下のように修正を加える必要があります。
- トップレベルは辞書型でなく、リスト
- 変数をセットする命令を追加
- 命令実行時に渡す引数に変数が含まれる場合、評価してから渡す
これらの修正を加えたDSLランナーは、以下のようになります。
import yaml
variables = {}
# 変数セット命令
def _set(variable_name, value):
variables[variable_name] = value;
# 引数を評価する関数
def evaluate_args(args):
result = []
for arg in args:
if arg in variables:
result.append(variables[arg])
else:
result.append(arg)
return result
# DSLから呼び出し可能な関数を列挙
func_dict = {'print': print, 'set': _set}
with open('hello2.yml') as yaml_file:
# YAMLのロード
dsl = yaml.load(yaml_file)
# ロードされたDSLを標準出力
print(dsl)
# 実際の解釈処理
# トップレベルをリストに変更。instはinstructionの意味
for inst in dsl:
if 'func' in inst and 'args' in inst:
func_name = inst['func']
if func_name in func_dict:
func = func_dict[func_name]
# 引数を評価
args = evaluate_args(inst['args'])
func(*args)
実行すると、以下のような結果が得られます。
変数名をセットし、評価して使用できていることが確認できます。
[{'func': 'set', 'args': ['your_name', 'cuhey3']}, {'func': 'print', 'args': ['Hello, World!', 'your_name', 'Goodbye!']}]
Hello, World! cuhey3 Goodbye!
…実装がしょぼいぞ!
聡明な読者の方なら、上記DSLランナー側の実装のしょぼさに気づかれたかと思います。
- 引数を評価するって言っても、辞書と引き当ててるだけ。
- 常にevaluate_argsを呼んでいると、変数の上書きができない。それどころか、意図しない変数の汚染が起きる。
しかし、ここにも重要な示唆があります。
DSLは先にも書いたとおり、特定用途を満たせていればよいのです。
- 変数の評価として、辞書と引き当てる以上のことは行わない。
- 変数の再セットは行わない。
という要件の元では、今の実装で十分です。
「特定用途」の範囲が拡がった時に、あらためて実装するとしましょう。
DSLランナーを他の言語でも実装してみる
DSLとDSLランナーを実装することで、DSLの機能が実現できることを見てきましたが、
一度定義したDSLを、Pythonではない他の言語から、再利用することはできないでしょうか。
この場合、言語に固有のDSLランナーを書けばよいことになります。
次の例では、ブラウザ上のJavaScript実行環境をターゲットとし、開発者コンソール上でデモできるよう、三回に分けてスクリプトを実行します。
まずJavaScriptからYAMLを読めるようにします。
// CDNからjs-yamlライブラリをロード
var script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/js-yaml/3.10.0/js-yaml.js';
document.body.appendChild(script);
次にYAMLをロードします。YAML文字列はhello2.ymlと同じものです。
var dsl = jsyaml.load(`
- func: set
args:
- your_name
- cuhey3
- func: print
args:
- Hello, World!
- your_name
- Goodbye!
`);
最後にDSLランナーを実行します。
var variables = {};
function _set(variableName, value) {
variables[variableName] = value;
}
var funcDict = {
print: console.log,
set: _set,
};
function evaluateArgs(args) {
var result = [];
args.forEach(function(arg) {
if (arg in variables) {
result.push(variables[arg]);
}
else {
result.push(arg);
}
});
return result;
}
dsl.forEach(function(inst) {
if ('func' in inst && 'args' in inst) {
var funcName = inst['func'];
if (funcName in funcDict) {
var func = funcDict[funcName];
var args = evaluateArgs(inst['args']);
func.apply(this, args);
}
}
});
実行結果
Hello, World! cuhey3 Goodbye!
undefined
Pythonの時と同じように、DSLを解釈して、結果を得ることができました。
以上でDSLの説明は終わりです。
DSLは実際どこで、何に対して書く?メリットは?
私の場合ですが、フロントエンド開発において、テンプレートエンジンを使ってHTMLをレンダリングする際に使用しています。
- フォームの属性をYAMLで記述する。
- input type / name / value / option / validation / データに応じたdisable, hiddenなど
- 連動して動作するバリデータもあります。
- 一旦サーバーサイドでデフォルト情報などを補う。(DSLランナーその1)
- フォームオブジェクトをテンプレートエンジンのカスタムフィルタやマクロに渡す。(DSLランナーその2)
以上の手順で、実際のHTMLとして展開させることをしています。
メリットですが、まず自分が書いてて楽しいことと、
そしてチーム開発をする際にも、メンバーに既存のDSLを見せた上で、
「こうやって書いたら画面上こう動くからよろしくね、動きがわかんなかったら聞いてね」で通じるケースが多いです。
DSLがシンプルな反面、DSLランナーはどうしてもごちゃごちゃするのですが、
興味を持ってくれるメンバーは、DSLランナーの中身を読んで、拡張してくれたりもします。
いまは結構いい感じで回っているかも?
というわけで、ここまでお読みいただきありがとうございました。