Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Kotlinを用いたDSL的な設計手法と使用上の注意

kohii
March 08, 2024

 Kotlinを用いたDSL的な設計手法と使用上の注意

サーバーサイドKotlin LT大会 vol.11 の発表資料です。
https://server-side-kotlin-meetup.connpass.com/event/309183/

kohii

March 08, 2024
Tweet

More Decks by kohii

Other Decks in Programming

Transcript

  1. Copyrights(c) Henry, Inc. All rights reserved. About Me Kohei Ishikawa

    / @kohii • 2021.08〜 Henry Inc. ◦ Developer ◦ CTO室 Lead Architect • Indie Hacker ◦ Moyuk - ブラウザ上でTypeScriptでツールを作るプラットフォーム ◦ MediXplorer - 医科診療行為マスタービューアー ◦ SmoothCSV - CSVエディター ◦ など • X: @kohii00
  2. Copyrights(c) Henry, Inc. All rights reserved. ヘンリーが作っているもの • レセコン一体型電子カルテ「Henry」 ◦

    電子カルテ: 医療情報を記録・管理するソフトウェア ◦ レセコン: 診療報酬制度に基づいた会計情報を管理するソ フトウェア
  3. Copyrights(c) Henry, Inc. All rights reserved. 今日話すこと 1. コードは表現 2.

    DSLについて 3. KotlinでDSL的なことをやる 4. ヘンリーでやったこと 5. 用法・容量
  4. Copyrights(c) Henry, Inc. All rights reserved. 今日話すこと 1. コードは表現 2.

    DSLについて 3. KotlinでDSL的なことをやる 4. ヘンリーでやったこと 5. 用法・容量
  5. Copyrights(c) Henry, Inc. All rights reserved. なぜなら... • 作って終わりではない ◦

    作る時間より、その後保守運用される時間の方が圧倒的に長い ◦ コードは様々な理由で修正される • チームで開発する ◦ 自分が作っていないコードの面倒を見る ◦ 昨日の自分は他人 「そもそも、他人の書いたコードを理解すること自体が既に十分に難しい作業なのです。」 (Vladimir Khorikov「単体テストの考え方/使い方」p.79)
  6. Copyrights(c) Henry, Inc. All rights reserved. コードが意図の表現に見えるか、文字の連なりに見えるか • 前提知識の問題 ◦

    コードを理解するために学習しないといけないことがどれくらいあるか ◦ ドメイン知識、設計指針、過去の経緯、 etc… • 設計・実装の問題 ◦ コードがどれくらい意図をダイレクトに伝えるものになっているか ◦ いろいろな要素がある ▪ 適切な命名 ▪ 適切な構成 ▪ 適切な責務 ▪ 手続き的 ←→ 宣言的(グラデーション) ▪ etc.
  7. Copyrights(c) Henry, Inc. All rights reserved. 今日話すこと 1. コードは表現 2.

    DSLについて 3. KotlinでDSL的なことをやる 4. ヘンリーでやったこと 5. 用法・容量
  8. Copyrights(c) Henry, Inc. All rights reserved. DSL(ドメイン固有言語) • DSL =

    特定のドメインやタスクに特化した構文やAPI ◦ そのドメインにおける操作や設定を直感的に表現できる ◦ 手続きではなく欲しい結果や意図を宣言的に記述する
  9. Copyrights(c) Henry, Inc. All rights reserved. • 内部DSL ◦ 特定の言語内で機能する

    DSL ◦ 例: ▪ Kotestのアサーション ▪ KoinのDI設定 • 外部DSL ◦ 完全に独立した構文 ◦ 例: ▪ SQL ▪ HTML ここでは内部DSLについて話します 内部DSL と 外部DSL
  10. Copyrights(c) Henry, Inc. All rights reserved. 今日話すこと 1. コードは表現 2.

    DSLについて 3. KotlinでDSL的なことをやる 4. ヘンリーでやったこと 5. 用法・容量
  11. Copyrights(c) Henry, Inc. All rights reserved. 愚直に書く • `ExecutorService` で並列処理を管理

    ◦ これはこれで程よく抽象化されていて良い APIだと思う • Workflow中の各タスクは一旦 `println` で表現している
  12. Copyrights(c) Henry, Inc. All rights reserved. 欠点 • ミスや見落としが発生しやすい ◦

    例えば並列処理の待ち合わせを忘れたりとか、 shutdownする対象のExecutorServiceを間違えた りとか... • 構造が見えにくい ◦ 処理を順に追わないとワークフローの構造が見えてこない • 本質的なことと副次的なことがごちゃまぜ ◦ ワークフロー制御のためのコード と ワークフロー内で実行されるタスク本体のコード が混ざってい て読みにくい
  13. Copyrights(c) Henry, Inc. All rights reserved. 宣言的に書けるようにしてみる • 方針: 仕組みとそれを使う側に分ける

    ◦ 仕組み側: ワークフローの構造を表現するための APIと実行機構を提供 ◦ 使う側: 仕組みを使ってワークフローを記述する
  14. Copyrights(c) Henry, Inc. All rights reserved. ①ワークフローをモデリング ワークフローはファイルシステム(ファイル/ フォルダ)のような木構造で表現できる •

    Task ◦ 実行対象の処理を持つモデル ◦ 木構造の葉となるノード • Sequential ◦ 複数の子ノードを持つ ◦ 子ノードは順次実行される • Parallel ◦ 複数の子ノードを持つ ◦ 子ノードは並列実行される ◦ (オプショナルなパラメータとして並列度も 持つ)
  15. Copyrights(c) Henry, Inc. All rights reserved. ①ワークフローをモデリング ワークフローはファイルシステム(ファイル/ フォルダ)のような木構造で表現できる •

    Task ◦ 実行対象の処理を持つモデル ◦ 木構造の葉となるノード • Sequential ◦ 複数の子ノードを持つ ◦ 子ノードは順次実行される • Parallel ◦ 複数の子ノードを持つ ◦ 子ノードは並列実行される ◦ (オプショナルなパラメータとして並列度も 持つ)
  16. Copyrights(c) Henry, Inc. All rights reserved. モデルを使ってワークフローを記述 • コンストラクタでオブジェクトを組み立てる ◦

    単にインスタンスを作っているだけだが、ワークフ ローの構造を宣言的に表現できている ◦ この手法を Poor man’s DSL と呼ぶことにする (https://github.com/zsmb13/VillageDSL から借用) • 細かいところ: ◦ Task: コンストラクタの引数がラムダ式だけなの で `()` を省略して呼び出せる ◦ Parallel / Sequential: コンストラクタ引数を `vararg`(可変長引数)として定義しているので、 `listOf` とかを使わなくても複数の子要素を直接 渡せる ◦ 組み立てたワークフローは `run` でそのまま実行 可能 ②ワークフローを記述
  17. Copyrights(c) Henry, Inc. All rights reserved. Poor man’s DSL •

    シンプル。他のプログラミング言語でもできる • Kotlinだとさらに ◦ newを書かなくていいのでスッキリ記述できる ◦ 名前付き引数でパラメータの意味が明示的になる • これがDSLの基本 ◦ 他のアプローチでも結局このオブジェクト構造を組み立てるのはやる ◦ これを如何にスタイリッシュに書けるようにするかで、より “DSL” らしくなっていく
  18. Copyrights(c) Henry, Inc. All rights reserved. • 意図や構造が見えやすい ◦ ワークフローの構造がダイレクトに表現される

    ◦ 実行制御等の詳細は仕組み側に隠蔽されるため、本質的なことだけに集中したコードになる • ミスが発生しにくい ◦ DSL利用側は間違える余地が少ない ◦ コードを見渡しやすい • 関心の分離 ◦ 業務上の関心事の表現 vs 表現を処理するための内部の仕組み ◦ 関心が分離されているとそれぞれ進化させやすい ▪ 例1: ExecutorServiceをCoroutineに置き換える ▪ 例2: 各タスクの前後にログ出力を仕込む ▪ 例3: 失敗したタスクを自動リトライする ▪ これらの変更はDSL利用側には手を入れずにでき、しかもすべての利用箇所が恩恵を受け る DSL的なやり方の利点
  19. Copyrights(c) Henry, Inc. All rights reserved. テクニック1. レシーバー付き関数リテラル • KotlinのDSLでよく使われるテクニック

    • 例1: Gradle • 例2: HTMLビルダー (Kotlin公式サイト https://kotlinlang.org/docs/type-safe-builders.html )
  20. Copyrights(c) Henry, Inc. All rights reserved. コードのしくみ Gradleの `dependencies` ブロックも

    どきを実現する簡易的なコードを書い てみる `dependencies` ブロックは依存 (Dependency)のリストを組み立てるも のとしてデザインする →
  21. Copyrights(c) Henry, Inc. All rights reserved. 1⃣ 2⃣ 1⃣ Dependency

    • 依存先を表すモデル 2⃣ DependenciesBuilder • `List<Dependency>` を作るため のビルダー • `api` / `implementation` メソッド を呼び出すと`dependencies` フィールドに `Dependency` のイ ンスタンスが追加される
  22. Copyrights(c) Henry, Inc. All rights reserved. 3⃣ dependencies 関数 •

    ユーザーに公開する DSL関数本体 • 引数として関数を受け取る ◦ この関数リテラルにはレシーバー ( `DependenciesBuilder.`) が付いて いるので、関数内からビルダーの インスタンス直接にアクセスできる • やってること: ◦ ビルダーのインスタンスを作って ◦ `block` に渡して実行して ◦ 実行後のビルダーから `dependencies` を取り出す 3⃣
  23. Copyrights(c) Henry, Inc. All rights reserved. さっきのワークフローの例 • 構造をダイレクトに表現するという 意味では

    Poor man’s DSL と同じ ◦ このDSLは先ほどのモデルを組み立て るビルダーとして機能する • Pros: ◦ ちょっとカッコよくなった ◦ オプショナルなパラメータである `maxConcurrency` の指定が読みやす くなった • Cons: ◦ 使う側が気持ちよく書ける分、仕組み 側の負担が増える ▪ モデルの実装に加えてビルダー やDSL関数の実装が増える DSLのコードはこちら: https://github.com/kohii/kotlin-dsl-example
  24. Copyrights(c) Henry, Inc. All rights reserved. テクニック2. Infix notation •

    スペース区切りでメソッド呼び出せるやつ • 例: Kotestのアサーション • 例2: Gradleでも見かける
  25. Copyrights(c) Henry, Inc. All rights reserved. しくみ • `infix` をつけた関数は

    `.` と `()` を省略 して呼び出せる • Pros: ◦ より英文らしく書ける /読める ◦ 普通のメソッド呼び出しより左右が対等ぽく 書ける • Cons: ◦ 乱用すると明瞭性が損なわれる
  26. Copyrights(c) Henry, Inc. All rights reserved. さっきのワークフローの例 • ワークフローの構造を宣言する代わりに、 タスクとタスク間の依存を宣言する

    DSL ◦ `task2 dependsOn task1` =「task2は task1に依存する」=「task2はtask1が終 わってから実行する」 ◦ 実行順序は依存関係から導き出せる • 利用者の関心がワークフローの構造では なく、タスク間の依存関係の場合はベター なDSLと言える ◦ (`infix` がこの違いをもたらすわけでは ないが...) DSLのコードはこちら: https://github.com/kohii/kotlin-dsl-example
  27. Copyrights(c) Henry, Inc. All rights reserved. テクニック3. Operator overloading •

    オペレーター(`+` とか `-` とか)をオーバーロードできる • 例1: Algoliaの検索クエリDSL(取得するプロパティを指定) • 例2: Coroutine(coroutine context の設定)
  28. Copyrights(c) Henry, Inc. All rights reserved. • `operator fun` で定義できる

    ◦ `a.unaryPlus()` → `+a` ◦ `a.unaryMinus()` → `-a` ◦ `a.not()` → `!a` ◦ `a.plus(b)` → `a+b` ◦ `a.minus(b)` → `a-b` ◦ `a.times(b)` → `a*b` ◦ その他いっぱいある • Pros: ◦ 通常のメソッドより短く書ける ◦ ふるまいが演算子の意味と合致する場 合に直感的に読める • Cons: ◦ 演算子を一般的な意味と異なる意味で 用いた場合に可読性が低下する しくみ
  29. Copyrights(c) Henry, Inc. All rights reserved. 今日話すこと 1. コードは表現 2.

    DSLについて 3. KotlinでDSL的なことをやる 4. ヘンリーでやったこと 5. 用法・容量
  30. Copyrights(c) Henry, Inc. All rights reserved. ヘンリーでやったことの紹介 前提: ヘンリーには診療報酬制度に基づく医療費の会計機能がある •

    診療報酬制度 = 医療費の計算・請求のルール ◦ 提供した医療行為ごとに定められた点数に基づき医療費を計算し、患者や保険組合に請求 • こんな感じの会計データを作成する ◦ ↓は先日耳鼻科にかかったときの明細 ◦ 1点10円で、通常そのうちの 3割を会計時に支払う
  31. Copyrights(c) Henry, Inc. All rights reserved. 診療報酬制度に基づく会計内容のチェック機能 • 「ある条件を満たしたらこっちも追加で算定できる」みたいなのがある ◦

    例: 初診料(288点)算定時に、6歳未満の場合は乳幼児加算(75点)を算定できる。ただし 時間外 加算、休日加算、深夜加算を加算する場合は算定できない。 • ヘンリーに求められる機能: ◦ ① 条件を満たしていたら自動で算定する( or 算定するよう提案する) ◦ ② 逆に条件を満たしていないのに算定していたら警告する ◦ 元々はそれぞれ手続き的に実装していた
  32. Copyrights(c) Henry, Inc. All rights reserved. 算定ルールを定義するDSL • ※コードは雰囲気 •

    ここから次のような挙動を導き出せる ◦ ① `baseCost` (初診料) を算定時に、 `requirements` をすべて満たしていた 場合、`target` (乳幼児加算) を自動で算 定する ◦ ② `target` (乳幼児加算) を算定してい るが `baseCost` (初診料) が存在しな い、もしくは `requirements` を満たさ ない場合は警告する
  33. Copyrights(c) Henry, Inc. All rights reserved. やってみての所管 • 成果: ◦

    処理や手続きではなく仕様を直接表すコードになった ◦ コード量が約7〜9割減(DSL利用側) • トレードオフ: ◦ 利用側が楽になる分、仕組み側に難しさを押し付ける ▪ 制度が複雑かつバリエーションが無限にあり設計が大変 ◦ それなりにキャッチアップコストがある
  34. Copyrights(c) Henry, Inc. All rights reserved. 今日話すこと 1. コードは表現 2.

    DSLについて 3. KotlinでDSL的なことをやる 4. ヘンリーでやったこと 5. 用法・容量
  35. Copyrights(c) Henry, Inc. All rights reserved. 社内で使う用ならあまり凝ったことやらない方がいいかも • カッコいいのやりたくなるけど... ◦

    コード量増える ◦ それをメンテナンスするのはチーム • だいたいの場合は Poor man’s DSL で十分
  36. Copyrights(c) Henry, Inc. All rights reserved. 利用する側が多いほど仕組みを凝るコストが見合う DSLを利用する側の数 アプローチ メモ

    少 DSL化しない 利用箇所が少ないなら、 DSLの仕組みをメンテナ ンスするコストが見合わない場合が多い 中 Poor man's DSL コスパ重視 - 作成者と利用者が同じ人達の場合 - ターゲットが社内のみの場合 多 リッチなDSL 利用数が多ければクールで気持ちいい書き心地を 提供するための労力が報われる - OSSの場合 - 大企業における社内向けライブラリの場合
  37. Copyrights(c) Henry, Inc. All rights reserved. 様々な視点を行き来する • 使う側の視点、満たしたい挙動の視点、内部構造の視点を行き来して設計 ◦

    全部を同時に満たさないとだめ • 使う側のコードがどうあってほしいかから始めるのがおすすめ
  38. Copyrights(c) Henry, Inc. All rights reserved. 責任を持つ • 仕組みを作るというのは「制約と誓約」 ◦

    縛りを設けることでより力を発揮 • 制約の中で扱えない概念が現れたときに、土台となる設計をちゃんと見直していけ るか ◦ 既存の枠組みの中で無理やり対応しようとして余計に複雑になるあるある • 一定程度安定するまでは関わり続ける(自戒) • 手離れすることにも責任を持つ(マジ自戒)
  39. Copyrights(c) Henry, Inc. All rights reserved. Thank you We are

    hiring! 𝕏: @kohii00 https://jobs.henry-app.jp/
  40. Copyrights(c) Henry, Inc. All rights reserved. (おまけ)ネストした関数リテラルでのスコープ制御 • DSL関数をネストする場合、祖先のレシーバーにアクセスできてしまう •

    `@DslMarker` を使うことで、直近のレシーバーに対してのみアクセスできるように 制御するできる ◦ ↑のようなコードをコンパイルエラーにできる ◦ 詳しくは https://kotlinlang.org/docs/type-safe-builders.html#scope-control-dslmarker