チェインソーマンの最終回の前日の記事です。私の予想ですが、コベニちゃんと吉田くんがまだ生き残っているので死ぬと思います。
去年は初心者向けにプログラム初心者向けー知って得するベストプラクティス・原則・パターン集を書いたので、少しアドバンスドな内容について触れます。
メタプログラミングという、構造や実装自体を抽象化し、DSLと呼ばれるミニ言語やマクロやジェネリックなども使う「コードを書くコード」を作るテクニックです。
単純なコード作業を長時間行うより、その構造を書くコードを作ることで、時間も短縮、保守性も上がるとよいことが多いです。(なんでもかんでもそうするのではなく、単純な繰り返し作業のようなものを、最低限の文法を持ったミニ言語(自身のプログラミング言語でも他言語でも外部ファイルでも)で記述して処理するのに向いています。
例としては、マクロのある言語(C/C++など)、C++のテンプレート(コンパイル時にヘッダーから実装を生成する)やJavaのGeneric、OR MapのJQL(HQL Hibernate Query Language)、C#のLinQ、TerraformのHCLなどもそうです。
// Specify the data source.
int[] scores = new int[] { 97, 92, 81, 60 };
// Define the query expression.
IEnumerable<int> scoreQuery =
from score in scores
where score > 80
select score;
コードを書くコードやロジックを書き出すロジックを書けば、余暇ができるしその分、先に進めたり他の事を覚えられて便利だよね、というテクニックです。
ちなみに、このテクニックを教えることや記事にすることで、この説明をする必要を減らすというメタのメタも含まれます。
少しだけ難しいので新人さんには存在だけ教えたり、中級の入り口の方には、自分が知った年代のちょっと前か同じくらいのタイミングに説明していますが、皆さんは早めに取得し、展開しましょう。
メタプログラミングとは
日本語だと狭い意味でのRubyやPhtyonのいわゆるリフレクション関連の記事も多いですが、本来はもう少し広い意味で、「ロジックを生成するロジック」を意味します。
英語版のWikipediaの記事「Metaprogramming - Wikipedia」によれば、以下のように、プログラムのデータや言語を扱えるようにするテクニックと書かれています。
Metaprogramming is a programming technique in which computer programs have the ability to treat other programs as their data.
用途としては以下のようなものがあります。
- コード生成
- コード埋め込み
- 動作の差し替え
マクロやJavaのGeneric(C++のTemplate)などもその一種です。
本記事では、そのうち、上記記事に書かれている、
One style of generative approach is to employ domain-specific languages (DSLs).
特に、DSLを使ったコード生成の部分を取り上げます。
ドメイン特化言語(DSL - Domain Specific Language)
「Domain-Specific Language - Wikipedia」からの抜粋と注釈ですが、
A domain-specific language (DSL) is a computer language specialized to a particular application domain.
DSLとは、コンピューター言語で使う狭い領域に専用の言語です。
定義はとても曖昧で、SQLのように文法と解析して処理を行うような本格言語であったり、1アプリ内の内部DSLや外部DSL(定義ファイル程度の扱い)などであったり、ただのAPIであったりします。
Simpler DSLs, particularly ones used by a single application, are sometimes informally called mini-languages.
「特定目的を簡単に記述するためだけの簡易なミニ言語」くらいの意味です。
Martin Fowlerさんの記事があるので詳しくはそちらを参照ください
- Domain Sepcific Language https://martinfowler.com/bliki/DomainSpecificLanguage.html
- 日本語訳 https://bliki-ja.github.io/DomainSpecificLanguage/
実践例
はじめの例は、同じプログラミング言語で使える、内部DSLを構成するAPIを使うもので十分だと思います。
流れとしては、以下のようになります。
- 特定の単純なルールの処理からルールを出します。
- ルールを定義する構造を作ります。なるべくFluentなAPI(「流れるようなAPI」と呼ばれるような形)にします
- 定義された言語をもとに、「頑張って」処理する
汎用的な言語を作るのが目的ではなく、行いたい処理をする最小限のシンプルな言語を作ります。
例外処理が1つあったとしたら、それをカバーするための汎用的な言語を拡張するのではなく、例えばNotNodeMove=trueのような場当たりなフラグを持つような言語でよいのです。
また3番目の処理は、設計上が対象的でなくとも、要件を満たせればよいです。
例の内容:XMLからJSONファイルを抽出する
幸い、OSSになっているので、コードとファイルを加工して抜粋をしますが、処理の概要は、XMLファイルからJSONを抽出するというものです。
入力ファイルは、jobの下に開始ノード、ステップノード、終了ノードがあるネストした構造になっています。
stepのstepTypeという値に応じて変化するほかのAttribute属性をJSONの構造にして出力するのが処理内容です。
<job id="job-1" name="">
<start .../>
<step id="step-1" stepType="file2db" ... >
(図形情報)
</step>
<job id="Job_1" name="db to db step test" isExecutable="false">
<start id="Start_1">
(図形情報)
</start>
<step id="Step_0b6tvz9" name="transform1" stepType="db2db"
bean="TransformedBean" fileName="INPUT.csv" sqlId="SQLID">
(図形情報)
</step>
<step id="Step_00356t4" name="transform2" stepType="db2db"
bean="TransformedBean" fileName="INPUT.csv" sqlId="SQLID"
updateSize="1000" extractBean="InputBean"
mergeOnColumns="mergeColumn1,mergeColumn2">
(図形情報)
</step>
(図形情報)
<end id="End_10h0atd">
(図形情報)
</end>
(図形情報)
</job>
<step id="step-3" stepType="db2file" ... >
(図形情報)
</step>
<end .../>
</job>
出力するJSONの仕様は以下のようになっています。
- stepノードの、stepTypeという値に応じて処理が変わります。
- stepType別に出力するAttribute(タグ内の属性
<step ... bean="TransformedBean">
の値、この例だとTransformedBean
)が変わります。 - あるstepTypeだけ子供のノードのAttributeを取得します。
- 取得したAttributeからJSONを出力します。
- JSONには子供の構造を持つ場合もあります。(下のmergeOnColumns, updateSize)
"step-1": {
"type": "db2db",
"bean": "TransformedBean",
"sqlId": "SQLID"
},
"step-2": {
"type": "db2db",
"bean": "TransformedBean",
"sqlId": "SQLID",
"mergeOnColumns": [
"mergeColumn1",
"mergeColumn2"
],
"updateSize": {
"size": 1000,
"bean": "InputBean"
}
}
べた書きによる実装
この例は極端に悪い例を書いていますが、共通部分を関数化したとしても、stepType別のAttributeを取得する処理、mergeOnColumns, updateSizeといった特別な構造の処理を関数化するだけで、本質的な行数は変わらないでしょう。
また、メンテナンス面でも、stepTypeの種類が増える、取得するAttributeが変わったり、特殊処理が増えたときに実装を、仕様のパターンに応じて比例してコード量が増えます。
扱う処理が増えると、caseが増え、Attributeも増えるます。
var parser = new DOMParser();
var bpmnDom = parser.parseFromString(xmlString, 'text/xml');
var jobElements = bpmnDom.getElementsByTagName("job");
var jsonObj = {};
for (var i = 0; i < jobElements.length; i++) {
var jobEelement = jobElements[i];
var stepType =
switch(stepType) {
case "validate"
// validateの場合のAttribute取得処理
break;
case "truncate"
// validateの場合のAttribute取得処理
break;
case "db2db":
// db2dbの場合のAttribute取得処理
// mergeOnColumns の特別処理
// updateSizeの特別処理
var updateSizeElement = ...
var updateSize = updateSizeElement.getAttribute("updateSize");
...
break;
case "file2db":
// file2dbの場合のAttribute取得処理
break;
case "db2file":
// db2fileの場合のAttribute取得処理
break;
...
}
}
ルールを構造化し、コードを生成するコードを書く
必要な処理は「stepタグの属性をstepTypeの属性値に応じて、JSONの構造にする」というものです。
ルールは非常に簡単です。
- stepTypeと、Attributeの組み合わせが固定化されています。
- JSONに出す際に、Attributeは文字列型、数値型、文字列配列型の3種になります
- 一部例外処理があり、JSON上の子ノードを持つ場合があります。
処理方針としては、先に構造化、そのあとその構造をコードに展開させます。
- Nodeというクラスを作ります。
- NodeはXMLのタグのタイプと、JSON上の名前と属性、子のNode配列を持ちます。
- NodeにはAttrというクラスを配列で持ちます。
- AttrはXMLのAttribute属性名と、JSON上のキーと、値の種類:文字列、数値、文字列配列を持ちます
厳密にはクラスではないですが構造体のようなものです。
実装すると以下のようになります。
function node(parent, tagName, matchAttr, matchVal, fixedName, nameAttrs, useParentElement, attrs) {
// XML→JSONの変換ルールを定義するオブジェクト
var node = {
'tagName': tagName, //XMLのタグ名
'matchAttr': matchAttr, //処理分岐に使う属性名=「stepType」
'matchVal': matchVal, //処理分岐に使う属性値=「stepTypeの値」
'fixedName': fixedName, // 特殊処理フラグ1
'nameAttrs': nameAttrs, // 特殊処理フラグ2
'useParentElement': useParentElement, // 特殊処理フラグ3
'attrs': attrs, // JSONに出力するAttrオブジェクト配列
'childNodes': []
};
if (parent !== null) {
parent.childNodes.push(node);
}
return node;
}
function attr(attrName, jsonProp, attrType) {
// XML→JSONへ変換するXML属性名とJSON上のキー名と型
var attr = {
'name': attrName,
'jsonProp': jsonProp,
'type': attrType
};
return attr;
}
ルールを内部DSLとして記述する
上で作成した、ルールを表す、再帰構造を持つNodeオブジェクトと、XMLのAttributeをJSONに変換する定義オブジェクトで、仕様を記述します。
以下のようにルールを全種類定義しています。特殊処理を持つものも扱っています。
1. root のルールのノード作成(XMLのJobタグに該当)
2. truncateNode のルールのノード作成(XMLのStepタグ stepType="truncate"処理用)
3. validationNode のルールのノード作成(XMLのStepタグ stepType="truncate"処理用)
4. file2dbNodeのルールのノード作成(XMLのStepタグ stepType="fill2db"処理用)
5. db2dbNodeのルールのノード作成(XMLのStepタグ stepType="db2db"処理用)
...
実際のコードは以下のようになります。
もともとがcase文で書かれていた5種類のJSON構造と特殊処理が、ルールオブジェクトにされているだけです。
したがって、種類が増えたり、Attribute属性が変わったり追加削除があれば、このルールを記述したブロックを編集するだけでよくなります。
これを別のファイルに定義したり、文法チェック等を足したりすると外部DSLと呼ばれるものに発展します。
case "db2db"
// Attributeの取得処理
// 特別な処理
function getRootNode() {
var root =
node(null, 'job', '', '', '', [], false, []);
var truncateNode =
node(root, 'step', 'stepType', 'truncate', '', ['name', 'id'], false,
[attr('stepType', 'type', 'string'),
attr('entities', 'entities', 'stringArray')]
);
var validationNode =
node(root, 'step', 'stepType', 'validation', '', ['name', 'id'], false,
[attr('stepType', 'type', 'string'),
attr('bean', 'bean', 'string'),
attr('errorEntity', 'errorEntity', 'string'),
attr('mode', 'mode', 'string'),
attr('errorLimit', 'errorLimit', 'integer')]
);
var file2dbNode =
node(root, 'step', 'stepType', 'file2db', '', ['name', 'id'], false,
[attr('stepType', 'type', 'string'),
attr('bean', 'bean', 'string'),
attr('fileName', 'fileName', 'string'),
attr('sqlId', 'sqlId', 'string')]
);
var db2dbNode =
node(root, 'step', 'stepType', 'db2db', '', ['name', 'id'], true,
[attr('stepType', 'type', 'string'),
attr('bean', 'bean', 'string'),
attr('sqlId', 'sqlId', 'string'),
attr('mergeOnColumns', 'mergeOnColumns', 'stringArray'),
attr('insertMode', 'insertMode', 'string')]
);
var updateSizeNode =
node(db2dbNode, 'updateSize', '', '', 'updateSize', [], false,
[attr('updateSize', 'size', 'integer'),
attr('extractBean', 'bean', 'string')]
);
var db2fileNode =
node(root, 'step', 'stepType', 'db2file', '', ['name', 'id'], false,
[attr('stepType', 'type', 'string'),
attr('bean', 'bean', 'string'),
attr('fileName', 'fileName', 'string'),
attr('sqlId', 'sqlId', 'string')]
);
return root;
}
コードを生成するコードを実装する
泥臭く頑張るだけです。再帰処理やたくさんのケースを持つ処理になるので汚くなるかもしれません。
しかし、例えばですが100種類のコードと10種類の例外処理をべた書きもしくは関数を別にして分散して書くよりはるかに楽に作れるでしょう。3-5種類くらいからはこうしたDSLとコード生成コードを書く方が楽になると個人的には考えます。
この例でのコードは、以下のURLにあります。
この先は、自分の目で確かめてほしいです。
その他の例
そのほかの例では、CLIインストーラーのスクリプト言語などを作成したことがあります。
対話形式のCLIインストーラーを作成することがあり、同梱製品のコードが3万行のべた書きで書かれていました。すべてがハードコードでした。
一行1コマンドの言語で、
- 画面に文言表示する
- 画面から文字を受け取りパラメータとする
- 画面から数値を受け取りパラメータとする
- 画面からリスト形式の選択肢を受け取りパラメータとする
- パラメータに応じてコマンドを実行、非実行する
- ./archiveフォルダのファイルを指定パラメータのパスにZipで展開する
- パラメータをテンプレートファイルの文字内に置き換える
- ./templateフォルダ内のファイルを指定パラメータにコピーする
というようなコマンドをDSL言語として作り処理させることで、4千行と200行程度の定義ファイルに書き換わった汎用インストーラーになりました。
Elasticsearchをインストールして、Wildflyに製品のJarを展開し、画面入力された値でDeployファイルやパラメーターファイルを書き換えるなどができました。
残念ながら公開コードはないです。コード量が大幅に減ること、メンテナンスの集約度と保守性は上がります。ただ生成コードやDSLの解析処理の初期コストは少し難易度が高くなる傾向があります。
おわり
最後に。本記事には、「チェインソーマン」のタグをつけたかったのですが、Qiitaにはまだそのタグはありませんでした。ドラゴンボールと鬼滅の刃はありました。残念でありません。おすすめの回は、57話の突然です。