DSL 設計

このページの目次

DSL(ドメイン固有言語)は、特定のドメインを対象とした言語です。その価値は、通常の API をより短いチェーン呼び出しに変更するだけでなく、ドメインの概念、制約、組み合わせ方を表現の中に直接現れさせる点にあります。

言語を設計する前に、典型的なタスクとエラーを収集します。ユーザーは何を表現したいのか、どの組み合わせを拒否すべきか、ルールはどのくらいの頻度で変化するか、誰がそれを保守するのか、失敗をどのように特定するのか。構文はこれらのニーズに奉仕すべきです。

セマンティクスを先に設計する:価格ルールを例として

ルール order.total >= 100 USD を考えます。中括弧かインデントかを選ぶ前に、total が税込み金額か税抜き金額か、欠落している場合はエラーかゼロか、通貨が異なる場合に比較可能か、数値が整数のセント単位か10進固定小数点数かという説明が必要です。

これらが定義されていない場合、パーサーが入力を正しく受け付けていても、システムは安定した回答を提供できません。DSL の「ドメイン固有」という性質は、まず型、制約、実行ルールに現れます。

ルール DSL: 構文解析後にドメインチェックが始まる 入力ルール order.total >= 100 USD 1 構文構造を解析し、ソースコードの位置を記録 比較式: フィールド、演算子、金額リテラル 2 名前を解析し、ドメインの型をチェック total が存在するか、金額の通貨が互換性があるか 3 安定したルール表現を生成 欠落値、丸め、エラーを明示的に処理 4 解釈またはコンパイルして実行 予算、診断、結果追跡の一貫性を保つ 5 構文が合法かどうかと、ルールにドメイン的な意味があるかどうかは、独立した2つのチェックです。 同じルールでも、偽、真、入力エラーの3つの結果がある order.total >= 100.00 USD 閾値 10000 セント、通貨 USD にコンパイル 9999 セント USD → false 正常な条件不充足 10000 セント USD → true 境界値が ≥ を満たす 欠落 / EUR → error 暗黙的にゼロに変更したり、自動為替交換したりしない ドメイン契約は、パーサー、ビジュアルエディタ、実行バックエンドのすべてで同一でなければならず、構文の外観によって決まるものではありません。

このルールを最小限の実行可能言語として実装する

この例では「フィールド >= 非負の金額 通貨」のみをサポートします。ドメインモデルは、order.total がセント単位で確定された注文合計額であることを規定しています。USD のみを受け入れ、為替レート変換は行わず、合計額が欠落している場合はエラーを報告します。金額は最大9桁の整数と2桁の小数までとし、任意の長さの数字やあいまいな丸めルールを実行エンジンに委ねないようにします。

from dataclasses import dataclass
import re

@dataclass(frozen=True)
class Rule:
    field: str
    cents: int
    currency: str
    span: tuple[int, int]

class RuleError(ValueError):
    pass

PATTERN = re.compile(
    r"(?P<field>[a-z][a-z0-9_.]*)\s*>=\s*"
    r"(?P<whole>[0-9]{1,9})(?:\.(?P<fraction>[0-9]{1,2}))?"
    r"\s+(?P<currency>[A-Z]{3})"
)

def compile_rule(source):
    if len(source) > 256:
        raise RuleError("rule too long")
    match = PATTERN.fullmatch(source)
    if match is None:
        raise RuleError("expected FIELD >= AMOUNT CURRENCY")
    field = match["field"]
    if field != "order.total":
        raise RuleError(f"unknown field at {match.span('field')}: {field}")
    if match["currency"] != "USD":
        raise RuleError(f"unsupported currency at {match.span('currency')}")
    fraction = (match["fraction"] or "").ljust(2, "0")
    cents = int(match["whole"]) * 100 + int(fraction)
    return Rule(field, cents, "USD", match.span())

def evaluate(rule, order):
    if "total_cents" not in order or order["total_cents"] is None:
        raise RuleError("missing order.total")
    amount = order["total_cents"]
    if type(amount) is not int or amount < 0:
        raise RuleError("total must be nonnegative integer cents")
    if order.get("currency") != rule.currency:
        raise RuleError("currency mismatch")
    return amount >= rule.cents

rule = compile_rule("order.total >= 100.00 USD")
assert rule.cents == 10000
assert not evaluate(rule, {"total_cents": 9999, "currency": "USD"})
assert evaluate(rule, {"total_cents": 10000, "currency": "USD"})
assert compile_rule("order.total >= 0.1 USD").cents == 10
for source in ["order.totla >= 100 USD", "order.total >= 1 EUR",
               "order.total >= 0.001 USD", "order.total >= 1 USD trailing"]:
    try:
        compile_rule(source)
    except RuleError:
        pass
    else:
        raise AssertionError("invalid rule accepted")
for order in [{}, {"total_cents": None},
              {"total_cents": True, "currency": "USD"},
              {"total_cents": 10000, "currency": "EUR"}]:
    try:
        evaluate(rule, order)
    except RuleError:
        pass
    else:
        raise AssertionError("invalid input accepted")

この制限された言語にはネストされた式が含まれていないため、ルール全体に一致する正規表現で現在の構文を表現するのに十分です。括弧、and/or、優先順位を追加する場合は、正規表現を貼り付けて構造を隠蔽するのではなく、明確な構文とパーサーを設計する必要があります。fullmatch は、ルールの後に無視される末尾テキストがないことを保証します。

金額の解析は、10進文字列から直接整数のセントに変換され、0.1 は 10 セントとして解釈され、0.001 は拒否され、こっそりと丸められません。実行時には type(amount) is int を使用します。これは、Python の bool が int のサブクラスであり、このドメインでは true を1セントとして受け付けないためです。ここでルールセマンティクスは例によって明確に定義されており、ホスト言語の暗黙的な型変換は借用しません。

診断精度と言語能力を同時に拡張する

現在のフィールドまたは通貨のエラーは一致範囲を保持していますが、構文エラーは全体としてのヒントしかありません。これは最小限の実行エンジンであり、完全なエディタ診断ではありません。対話型編集を対象とする場合、トークンとノードの両方に span を保持し、「>= の後に金額がない」「通貨がサポートされていない」「ルールが未完成」などを区別し、診断をソースコードにマッピングできます。

入力どの層で処理すべきかなぜすべて false を返してはいけないか
合計額 99.99 USD実行して false を取得有効なデータだが、ルールに一致しない
order.totla名前チェックで拒否綴りミスによりルールが永遠に発動しない
0.001 USD現在の金額構文で拒否精度ポリシーではこの入力をまだ許可していない
合計額が欠落実行時の入力エラーデータ不足と閾値未満は意味が異なる
合計額の通貨が EURドメインの互換性エラー暗黙的な為替変換ルールは存在しない

ビジネスで欠落値を unknown に変え、and/or で三値論理を使用したい場合は、それを別の明示的な言語セマンティクスとして扱うべきです。単にバックエンドですべての例外をキャッチして false を返すだけでは、2つのバックエンドが同じルールで異なる回答を生成することになります。

パースは成功したのに、なぜすべての失敗を false として扱えないのか?

編集可能なルールは、4つの「成功/失敗」ボタンよりも、言語の境界をよりよく検証できます。以下の入力は、完全な一致、フィールドチェック、通貨チェック、実行時入力チェックを順に通過し、制限された CompareAmount 表現を生成します。綴りを修正し、3桁の小数を書き、合計額を欠落させると、異なるエラーカテゴリに属するはずです。合法な金額が閾値を下回る場合にのみ false を返します。

図を準備しています
パースは成功したのに、なぜすべての失敗を false として扱えないのか?

構文、フィールド、単位、ランタイムデータは異なる契約です。99.99 は閾値に達していないため false だが、未知のフィールドや欠落データはエラーです。金額は整数セントに直接変換され、浮動小数点の丸めを経ません。

内部 DSL: ホスト構文とツールを借用する

内部または埋め込み DSL は、関数、演算子、型、クロージャなどのホストメカニズムを利用してドメイン操作を表現します。例えば、クエリビルダーはフィールド比較を式木に組み合わせ、最終的にバックエンドがパラメータ化された SQL を生成します。

ホスト言語の型チェックは基本的な機能ですが、ドメインチェックは無料ではありません。フィールドの型、操作の制約、状態遷移を API にエンコードして初めて、コンパイラは相應の誤用を拒否するのに役立ちます。任意の文字列を受け取る where(string) だけでは、フィールド名チェックや SQL の安全性は自動的に得られません。

ビルダーは即時実行することも、中間表現を保存して解釈またはコンパイルすることもできます。メソッドチェーンは表面的な構文に過ぎず、チェーン書きから AST を構築していると推測することはできません。

式テンプレート: 遅延評価にも正しいコストモデルが必要

行列ライブラリは式テンプレートを使用して B + C * D の構造を保持し、ループ融合や評価順序の最適化を選択できます。しかし、行列乗算、エイリアシング、再利用により、一時結果がより正確または効率的になる可能性があります。すべての式が一時行列を割り当てない、または自動的に最適なコードを取得すると主張することはできません。Eigen の遅延評価とエイリアシング

マクロはまた、ホスト言語内に個別に解析が必要なトークン構文を埋め込むこともできます。したがって、「内部 DSL には必ずしも独自のパーサーがない」というのも一般則ではありません。そのセマンティクスチェックがマクロフェーズで行われるのか、後続の型チェックフェーズで行われるのかは、インターフェース設計によります。

外部 DSL: 独自の入力言語を持つ

独立した構文は、ホスト言語を跨いで使用したい場合、ドメインユーザー向け、または表現能力を厳密に制限したい場合に適しています。独自の字句解析、構文解析、位置情報、診断、バージョン処理が必要ですが、マシンコードを生成する必要はありません。

一般的なバックエンドには、AST の直接解釈、ルール IR への lowered、SQL やホストソースコードの生成、バイトコードの生成、マシンコードへのコンパイルなどがあります。選択は、実行頻度、デプロイ、デバッグ、移植性、制限能力によって決まり、「速くしたいからコンパイル、デバッグしたいからトランスパイル」という単純な話ではありません。

ある設定は、読み込み時にチェックされた後、繰り返し解釈されるだけでパフォーマンス要件を満たすことができます。計算集約型の DSL では、IR 最適化と専用バックエンドへの投資が価値があるかもしれません。両方とも完全で合理的な設計です。

フロントエンドチェックを階層化する

階層例題適切な診断
字句金額リテラルに不正な文字が含まれている具体的な文字と許可フォーマットを指摘
構文比較演算に右辺値がない欠落項目と現在位置を指摘
名前解決order.totla が存在しない未知のフィールドを指摘し、候補の綴りを提示
型と単位金額と文字列の比較両側の型と許可された操作を説明
ドメイン制約現在のルールでは個人機密フィールドへのアクセスが許可されていない制限とルールコンテキストを説明
実行データの欠落または予算の枯渇ソースコードに関連付け可能な制御されたエラーを返す

ノードはソース位置を保持し、IR に lowered する際も必要なマッピングを保持すべきです。これにより、生成されたコードやランタイムエラーが、内部の一時変数を表示するのではなく、ユーザーが書いたルールに戻ることができます。

型、効果、リソースの境界

言語の表現能力を制限することで、検証が容易になります。例えば、任意のネットワークやファイル呼び出しを禁止し、明確に定義された純粋関数のみを提供する。ループを制限するか、有界集合操作を使用する。実行にステップ数、時間、メモリ予算を設定する。

しかし、「DSL である」ことは、すでに安全に隔離されていることを意味しません。ホスト実行への埋め込み、マクロ展開、外部関数の呼び出し時には、その権限と副作用を明確にする必要があります。ルールソースが信頼できない場合、文字列を連結してホストの eval に渡すだけではサンドボックスと呼ぶことはできません。

SQL を生成する DSL の場合、値はバインドパラメータを通じて渡すべきです。識別子は、構文、引用、許可された集合に応じて処理されます。パラメータ化された値が任意のテーブル名や列名を直接置き換えることはできません。

中間表現により複数のエントリがセマンティクスを共有する

ビジュアルルールエディタ、ホスト API、外部テキストパーサーのすべてが、同じドメイン IR を生成できるようにできます。型チェック、最適化、実行はこの表現を共有し、3つのエントリが異なるセマンティクスを生成しないようにします。

例えば、IR ノード CompareAmount(field, amount, currency) は、未解析の文字列断片よりもチェック、フォーマット、シリアライズが容易です。IR をエクスポートする場合は、バージョンを定義すべきです。内部メモリ構造を長期互換プロトコルとして直接使用しないでください。

バージョンの進化: ルールも長期的なデータ

言語がリリースされた後、フィールドの削除、デフォルト値の変更、丸めルールの変更、キーワードの追加などが、古いルールの意味を変更する可能性があります。ルールに言語バージョンを記録し、解析とマイグレーション戦略を提供し、正規化された表現と再現可能なテストケースを保持します。

後方互換性は、「古いテキストが依然として解析できる」かどうかだけでなくチェックすべきではありません。古いルールを解析した後に異なる結果が得られる場合も、セマンティクスの破壊です。回帰テストケースは、境界値、欠落値、エラー入力をカバーし、インタープリタとコンパイルバックエンドの結果を比較する必要があります。

マイグレーション前に結果とエラーカテゴリを比較する

旧バージョンではデフォルトで欠落した金額がゼロだったが、新バージョンではエラーを報告するようになったと仮定します。古いテキストは依然として解析できますが、動作が変更されています。マイグレーションケースは、AST が同じように見えるかどうかだけでなく、入力データ、ルールバージョン、期待される結果またはエラーカテゴリを同時に保存すべきです。

同じケースセットを、インタープリタ、SQL を生成するバックエンド、その他のコンパイルバックエンドに渡すことができます。正常な結果は一致し、欠落、通貨不一致、精度の境界の処理も一致する必要があります。正規化フォーマットはスペースや金額の表示を変更できますが、0.1 の精度ポリシーや >= の境界をこっそりと変更することはできません。

選択のクイックリファレンス

要件検討すべき起点
開発者が同じ言語内で使用し、通常の API で表現しやすい関数、型安全なビルダー、または演算子インターフェース
ホストコードの繰り返し生成が必要で、構文構造を読む必要があるマクロまたはコードジェネレーター
言語を跨いで配布、ドメインユーザーが編集、独立したバージョン管理外部構文と安定したセマンティクスモデル
複数のエントリがルール能力を共有する統一されたドメイン IR に、異なるフロントエンドを接続

内部 DSL が最終的に外部言語に進化する必要はありません。外部言語も「より高度」な終点ではありません。まず最小限の表現力で実際のタスクをカバーし、その後、明確なニーズに基づいて拡張します。

参考

Keywords: DSL, 埋め込み言語、フローエント API、式テンプレート、ドメインセマンティクス、インタープリタ、コード生成、言語進化