paint-brush
Kotlin で DSL を開発する方法@yaf
1,945 測定値
1,945 測定値

Kotlin で DSL を開発する方法

Fedor Yaremenko11m2023/12/11
Read on Terminal Reader

長すぎる; 読むには

ドメイン固有言語は、主題領域のタスクを記述するのに、より簡単、便利、そしてより表現力豊かです。 Kotlin は、多くの機能と糖衣構文を備えた最新のプログラミング言語であるため、内部 DSL のホスト言語として最適です。この記事では、Kotlin のさまざまな機能を使用して、ビジネス プロセスを定義するための DSL を作成する方法について説明します。
featured image - Kotlin で DSL を開発する方法
Fedor Yaremenko HackerNoon profile picture


プログラマーたちは、どの言語が最適であるかについて常に議論しています。一度CとPascalを比較しましたが、時間が経ちました。 Python / RubyJava /C# の戦いはすでに終わっています。各言語には長所と短所があるため、比較します。理想的には、私たち自身のニーズに合わせて言語を拡張したいと考えています。プログラマーには長い間この機会が与えられてきました。私たちはメタプログラミング、つまりプログラムを作成するためのプログラムを作成するさまざまな方法を知っています。 C の簡単なマクロでも、小さな記述から大きなコードの塊を生成できます。ただし、これらのマクロは信頼性が低く、制限があり、表現力もあまり高くありません。現代言語には、より表現力豊かな拡張手段があります。これらの言語の 1 つが Kotlin です。


ドメイン固有言語の定義

ドメイン固有言語 (DSL) は、Java、C#、C++ などの汎用言語とは異なり、特定の主題分野に特化して開発された言語です。これは、対象領域のタスクを説明する方が簡単、便利、表現力豊かであるが、同時に不便であり、日常的なタスクを解決するのには実用的ではない、つまり世界共通言語ではないことを意味します。 DSL では、正規表現言語を使用できます。正規表現の主題は文字列の形式です。


文字列が形式に準拠しているかどうかを確認するには、正規表現のサポートを実装するライブラリを使用するだけで十分です。

 private boolean isIdentifierOrInteger(String s) { return s.matches("^\\s*(\\w+\\d*|\\d+)$"); }


Java などの汎用言語で指定された形式に文字列が準拠しているかどうかをチェックすると、次のコードが得られます。

 private boolean isIdentifierOrInteger(String s) { int index = 0; while (index < s.length() && isSpaceChar(s.charAt(index))) { index++; } if (index == s.length()) { return false; } if (isLetter(s.charAt(index))) { index++; while (index < s.length() && isLetter(s.charAt(index))) index++; while (index < s.length() && isDigit(s.charAt(index))) index++; } else if (Character.isDigit(s.charAt(index))) { while (index < s.length() && isDigit(s.charAt(index))) index++; } return index == s.length(); }


上記のコードは正規表現よりも読みにくく、間違いが発生しやすく、変更が難しくなります。


DSL の他の一般的な例としては、HTML、CSS、SQL、UML、BPMN があります (後者の 2 つはグラフィック表記を使用します)。 DSL は開発者だけでなく、テスト担当者や IT 専門家以外の人も使用します。


DSLの種類

DSL は、外部と内部の 2 つのタイプに分類されます。外部 DSL言語には独自の構文があり、そのサポートが実装されているユニバーサル プログラミング言語には依存しません。


外部 DSL の長所と短所:

🟢 さまざまな言語でのコード生成 / 既製のライブラリ

🟢 構文を設定するためのその他のオプション

🔴 特殊なツールの使用: ANTLR、yacc、lex

🔴 文法を説明するのが難しい場合があります

🔴 IDE サポートがないため、プラグインを作成する必要があります


内部 DSL は、特定のユニバーサル プログラミング言語 (ホスト言語) に基づいています。つまり、ホスト言語の標準ツールを利用して、よりコンパクトに記述できるライブラリが作成されます。例として、Fluent API アプローチを考えてみましょう。


内部 DSL の長所と短所:

🟢 ホスト言語の表現を基礎として使用します

🟢 ホスト言語のコードに DSL を埋め込むことは簡単で、その逆も同様です

🟢 コード生成は必要ありません

🟢 ホスト言語のサブルーチンとしてデバッグ可能

🔴 構文設定の制限された可能性


実際の例

最近、私たち会社は DSL を作成する必要性に直面しました。弊社製品は購入受付機能を実装しております。このモジュールは、BPM (Business Process Management) のミニエンジンです。ビジネス プロセスは多くの場合、グラフィカルに表現されます。たとえば、以下の BPMN 表記は、タスク 1 の実行、次にタスク 2 とタスク 3 の並列実行で構成されるプロセスを示しています。


私たちにとって、ルートの動的な構築、承認ステージの実行者の設定、ステージ実行の期限の設定など、ビジネス プロセスをプログラムで作成できることが重要でした。これを行うために、最初に Fluent API を使用してこの問題を解決しようとしました。アプローチ。


その後、Fluent API を使用して受け入れルートを設定するのは依然として面倒であることが判明し、私たちのチームは独自の DSL を作成するオプションを検討しました。私たちは、外部 DSL と Kotlin に基づく内部 DSL で受け入れルートがどのようになるかを調査しました (製品コードは Java とKotlinで書かれているため)。


外部DSL:

 acceptance addStep executor: HEAD_OF_DEPARTMENT duration: 7 days protocol should be formed parallel addStep executor: FINANCE_DEPARTMENT or CTO or CEO condition: ${!request.isInternal} duration: 7 work days after start date addStep executor: CTO dueDate: 2022-12-08 08:00 PST can change addStep executor: SECRETARY protocol should be signed


内部 DSL:

 acceptance { addStep { executor = HEAD_OF_DEPARTMENT duration = days(7) protocol shouldBe formed } parallel { addStep { executor = FINANCE_DEPARTMENT or CTO or CEO condition = !request.isInternal duration = startDate() + workDays(7) } addStep { executor = CTO dueDate = "2022-12-08 08:00" timezone PST +canChange } } addStep { executor = SECRETARY protocol shouldBe signed } }


中括弧を除けば、どちらのオプションもほぼ同じです。したがって、外部 DSL の開発に時間と労力を無駄にするのではなく、内部 DSL を作成することが決定されました。


DSLの基本構造の実装

オブジェクトモデルの開発を始めましょう

interface AcceptanceElement class StepContext : AcceptanceElement { lateinit var executor: ExecutorCondition var duration: Duration? = null var dueDate: ZonedDateTime? = null val protocol = Protocol() var condition = true var canChange = ChangePermission() } class AcceptanceContext : AcceptanceElement { val elements = mutableListOf<AcceptanceElement>() fun addStep(init: StepContext.() -> Unit) { elements += StepContext().apply(init) } fun parallel(init: AcceptanceContext.() -> Unit) { elements += AcceptanceContext().apply(init) } } object acceptance { operator fun invoke(init: AcceptanceContext.() -> Unit): AcceptanceContext { val acceptanceContext = AcceptanceContext() acceptanceContext.init() return acceptanceContext } }


ラムダ

まず、 AcceptanceContextクラスを見てみましょう。これは、ルート要素のコレクションを保存するように設計されており、ダイアグラム全体とparallelブロックを表すために使用されます。

addStepメソッドとparallelメソッドは、パラメーターとしてレシーバーを持つラムダを受け取ります。


レシーバーを備えたラムダは、特定のレシーバー オブジェクトにアクセスできるラムダ式を定義する方法です。関数リテラルの本体内では、呼び出しに渡されるレシーバー オブジェクトは暗黙的なthisになるため、追加の修飾子なしでそのレシーバー オブジェクトのメンバーにアクセスしたり、 this式を使用してレシーバー オブジェクトにアクセスしたりできます。


また、メソッド呼び出しの最後の引数がラムダの場合、ラムダを括弧の外側に置くことができます。そのため、DSL では次のようなコードを書くことができます。

 parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } }


これは、構文シュガーのないコードと同等です。

 parallel({ this.addStep({ this.executor = FINANCE_DEPARTMENT ... }) this.addStep({ this.executor = CTO ... }) })


レシーバーを含む Lambda と括弧の外側の Lambda は、DSL を操作する場合に特に便利な Kotlin の機能です。


オブジェクト宣言

次に、エンティティのacceptanceを見てみましょう。 acceptanceは目的です。 Kotlin では、オブジェクト宣言はシングルトン (インスタンスが 1 つだけあるクラス) を定義する方法です。したがって、オブジェクト宣言では、クラスとその単一インスタンスの両方を同時に定義します。


「invoke」演算子のオーバーロード

さらに、 invoke演算子はaccreditationオブジェクトに対してオーバーロードされます。 invoke演算子は、クラスで定義できる特別な関数です。クラスのインスタンスを関数であるかのように呼び出すと、 invoke演算子関数が呼び出されます。これにより、オブジェクトを関数として扱い、関数のような方法で呼び出すことができます。


invokeメソッドのパラメーターもレシーバーを備えたラムダであることに注意してください。これで受け入れルートを定義できるようになりました…

 val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT ... } parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } } addStep { executor = SECRETARY ... } }


...そしてその中を歩いてみましょう

val headOfDepartmentStep = acceptanceRoute.elements[0] as StepContext val parallelBlock = acceptanceRoute.elements[1] as AcceptanceContext val ctoStep = parallelBlock.elements[1] as StepContext


詳細の追加

中置関数

このコードを見てください

addStep { executor = FINANCE_DEPARTMENT or CTO or CEO ... }


これは次のように実装できます。

 enum class ExecutorConditionType { EQUALS, OR } data class ExecutorCondition( private val name: String, private val values: Set<ExecutorCondition>, private val type: ExecutorConditionType, ) { infix fun or(another: ExecutorCondition) = ExecutorCondition("or", setOf(this, another), ExecutorConditionType.OR) } val HEAD_OF_DEPARTMENT = ExecutorCondition("HEAD_OF_DEPARTMENT", setOf(), ExecutorConditionType.EQUALS) val FINANCE_DEPARTMENT = ExecutorCondition("FINANCE_DEPARTMENT", setOf(), ExecutorConditionType.EQUALS) val CHIEF = ExecutorCondition("CHIEF", setOf(), ExecutorConditionType.EQUALS) val CTO = ExecutorCondition("CTO", setOf(), ExecutorConditionType.EQUALS) val SECRETARY = ExecutorCondition("SECRETARY", setOf(), ExecutorConditionType.EQUALS)


ExecutorConditionクラスを使用すると、いくつかのタスク実行プログラムを設定できます。 ExecutorConditionでは、中置関数or定義します。 infix 関数は、より自然な infix 記法を使用して呼び出すことができる特別な種類の関数です。


言語のこの機能を使用しない場合は、次のように記述する必要があります。

 addStep { executor = FINANCE_DEPARTMENT.or(CTO).or(CEO) ... }


Infix 関数は、プロトコルの必要な状態とタイムゾーンを使用した時刻を設定するためにも使用されます。

 enum class ProtocolState { formed, signed } class Protocol { var state: ProtocolState? = null infix fun shouldBe(state: ProtocolState) { this.state = state } } enum class TimeZone { ... PST, ... } infix fun String.timezone(tz: TimeZone): ZonedDateTime { val format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z") return ZonedDateTime.parse("$this $tz", format) }


拡張機能

String.timezoneは拡張関数です。 Kotlin では、拡張関数を使用すると、ソース コードを変更せずに、既存のクラスに新しい関数を追加できます。この機能は、標準ライブラリや外部ライブラリのクラスなど、制御できないクラスの機能を拡張したい場合に特に便利です。


DSL での使用法:

 addStep { ... protocol shouldBe formed dueDate = "2022-12-08 08:00" timezone PST ... }


ここで、 "2022-12-08 08:00"は拡張関数timezoneが呼び出される受信者オブジェクトであり、 PSTがパラメータです。受信側オブジェクトには、 thisキーワードを使用してアクセスします。

演算子のオーバーロード

DSL で使用する次の Kotlin 機能は、演算子のオーバーロードです。 invoke演算子のオーバーロードについてはすでに検討しました。 Kotlin では、算術演算子を含む他の演算子をオーバーロードできます。

 addStep { ... +canChange }


ここでは、単項演算子+がオーバーロードされています。これを実装するコードは次のとおりです。

 class StepContext : AcceptanceElement { ... var canChange = ChangePermission() } data class ChangePermission( var canChange: Boolean = true, ) { operator fun unaryPlus() { canChange = true } operator fun unaryMinus() { canChange = false } }


仕上げタッチ

これで、DSL に受け入れルートを記述できるようになりました。ただし、DSL ユーザーは起こり得るエラーから保護される必要があります。たとえば、現在のバージョンでは、次のコードを使用できます。

 val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT duration = days(7) protocol shouldBe signed addStep { executor = FINANCE_DEPARTMENT } } }


addStep内のaddStep奇妙に見えますね。このコードがエラーなしで正常にコンパイルされる理由を考えてみましょう。上で述べたように、 acceptance#invokeメソッドとAcceptanceContext#addStepメソッドは、パラメーターとしてレシーバーを持つラムダを受け取り、レシーバー オブジェクトはthisキーワードによってアクセスできます。したがって、前のコードを次のように書き換えることができます。

 val acceptanceRoute = acceptance { [email protected] { [email protected] = HEAD_OF_DEPARTMENT [email protected] = days(7) [email protected] shouldBe signed [email protected] { executor = FINANCE_DEPARTMENT } } }


これで、 [email protected]が両方の回で呼び出されることがわかります。特にそのような場合のために、Kotlin にはDslMarkerアノテーションがあります。 @DslMarkerを使用してカスタム アノテーションを定義できます。同じようなアノテーションが付けられた受信者は、相互に内部でアクセスできません。

 @DslMarker annotation class AcceptanceDslMarker @AcceptanceDslMarker class AcceptanceContext : AcceptanceElement { ... } @AcceptanceDslMarker class StepContext : AcceptanceElement { ... }


今コード

val acceptanceRoute = acceptance { addStep { ... addStep { ... } } }


エラーのためコンパイルされません'fun addStep(init: StepContext.() -> Unit): Unit' can't be called in this context by implicit receiver. Use the explicit one if necessary

リンク

以下は、この記事で検討した言語機能に関する Kotlin の公式ドキュメントへのリンクです。



結論

ドメイン固有言語は、特定のドメイン内の問題をモデル化して解決するための専門的かつ表現力豊かな方法を提供することで、生産性を向上させ、エラーを削減し、コラボレーションを向上させる強力な手段を提供します。 Kotlin は、多くの機能と糖衣構文を備えた最新のプログラミング言語であるため、内部 DSL のホスト言語として最適です。