ここ数年で『関数型ドメインモデリング』という書籍や、『Functional and Reactive Domain Modeling』といった書籍を読んだ経験から、今業務で取り組んでいるKotlinではどう表現できるのかに興味がありました。年末年始に少しまとまった時間が取れたので、実際に実装してみました。今回は、その過程でどのような知見を得られたかを、主には自分の理解のためにまとめておきたいと思います。
先に書いておきますが、長いです。目次をご覧になって、興味のある場所をかいつまんでお読みください。
免責事項
筆者はKotlinを書き始めて半年くらいです。可能な限り調査をして一次情報を当たるなどし、情報の正しさの担保には努めるようにしましたが、事実誤認が少なからず含まれる可能性があります。また、説明は網羅的でない可能性はあり、遺漏が多々含まれることもあろうかと思います。あらかじめご了承ください。
参考にした書籍や資料などは、参考文献として掲載するようにしてあります。そちらも併せてご覧ください。
また、今回実装したアプリケーションは比較的小規模なものになります。筆者はKotlinで実務で大規模開発を行なっており、そこから予想されるシナリオなどを一応把握はしています。しかし、今回の成果物は仮想的なものに過ぎません。今回紹介する手法が業務上有用かどうかは現場でよく議論してから導入されることをおすすめします。
お題
mattnさんの記事にインスパイアされてTodoアプリを実装しました。
データベースはPostgreSQLを使用しています。
技術スタック
最初に、使用した技術について説明しておきます。
- Ktor: Kotlinでのバックエンド開発で用いられるフレームワークです。KotlinではSpring Bootがまず候補に上がるようですが、Spring系はもともとJavaのフレームワークなのもあり、個人的にはPure Kotlinの方が好きなので、Ktorを選定することのほうが多いです。
- kotlinx-serialization: JSONなどのデータ構造へのシリアライズ・デシリアライズに用いられるライブラリです。JSONに対して今回は使用しました。Protobufなどにも対応しているようです。
- Exposed: いわゆるORMです。書き心地はScalaのSlickにとても近く、実はSlickを使ってプロダクトをリリースしたことがあるので、懐かしさすら覚えました。
- Koin: DIコンテナを提供するライブラリです。
Module
と名のつく関数なりクラスなりを用意しておき、そこに依存関係の定義を記述します。 - kotlin-result: Kotlinで
Result<T, E>
型を提供するライブラリです。後述するように例外に型付けをしたいので、意図的に使用しています。 - Kotest: Kotlinで使用できるテスティングフレームワークです。JUnitより記法が多くおすすめです。
個人的なライブラリに対する感想を記しておきます。
Ktorは、私の中では正直評価は微妙です。ExpressなどのRequest
-> Response
の型遷移をさせるハンドラを用意させるだけのライブラリとは異なり、かなりDSLでカスタマイズされたハンドラを書くことになります。このDSLがとっつきやすいかとっつきにくいかは、正直評価のわかれるところだと思います。私は個人的にはこのデザインは嫌い寄りで、その関数が実はsuspended
であったり、引数や返り値などの型情報をDSLが完全に隠蔽している関係で、結果的に何をやっているかわかりにくくなっていると感じています。DSLで生産性が上がる場面があるのかもしれませんが、このDSLのデザインには懐疑的です。[*1]あと、何をするにもプラグインを導入することを求められますが、そもそもJSONくらいはデフォルトで何の苦労もなく返せてほしい。
Ktorのバージョンですが、3系ではなく2系を使用しています。この点についてですが、Ktor3系を使用していると、現時点で最新のKoinではNoClassDefFoundException
が発生してしまいます。こちらのIssueにも上がっていてすでにクローズはされているので、そのうち対応されるかと思いますが、噛み合わせが悪いようです。3系の機能でとくに積極的に使いたいものがあるわけでもないので、意図的に古いバージョンのものを使用しています。
kotlinx-serializationは、私の中では評価は微妙です。Kotlinにはマクロがないので仕方がない節は多そうですが、実行時に解決される話が多すぎるように見えました。RustのSerdeに飼い慣らされてしまっているので、まったく物足りなかったです。ただ、アノテーションをつけるだけでしっかり欲しい機能に対応できるのはいい点だと思います。業務でJacksonを用いていますが、さすがにいろいろ冗長だと感じることが多いためです。
Exposedは、Slickに慣れていれば軽くドキュメントを読むだけで使いこなせました。また、トランザクションの切り出し方がなかなかいい感じになっており、このあと説明するように、DDDというかオニオンアーキテクチャを実装する際にレイヤーの関係性を壊しにくく非常に有用でした。一方で、suspend
への対応が甘く(ORMなのだからちゃんとやってほしい)、たとえば例外が親のcoroutineにちゃんと伝播してくれないなどのバグが潜んでいそうに見えています。詳しく検証していないので、この点については導入時に現場で検証されることをおすすめします。
Koinはあまり言うことはないです。ハマりどころも少なかったように思いました。簡単なアプリケーションを実装する程度であれば、このライブラリで十分そうでした。Ktorとの噛み合わせも悪くなかったです。Ktor3系への対応がまだ足りておらず、3系と組み合わせると例外を吐いてますが…。
kotlin-resultは、個人的にはほしい機能は揃っているように見えました。binding
で処理をフラットに書いていけるのはいい点ですね。コルーチンにも対応していて、coroutineBinding
と切り替えるだけで利用できます。Arrow-KtのEither
と比較されることも多いでしょうが、ほとんど好みの差だと思います。Arrow-Ktを入れるかkotlin-resultを入れるかは、Arrow-Ktの他の機能が欲しいかどうかで決まるでしょう。モナモナした純粋関数型プログラミングをKotlinで行いたいというケースを除いて大半のケースでは不要だと思うので[*2]、モジュールサイズなどを考えるとkotlin-resultをおとなしく利用しておくのが吉かもしれません。型パズル対策のzipOrAccumulate
なども揃っています。
アプリケーション以外の周辺ツールでは、マイグレーション部分にsqldefを使用しています。このツールはRubyのRidgepoleのような機能を提供しています。CREATE TABLE
文を書き続けることでALTER TABLE
を生成してくれます。そもそも私がFlywayよりRidgepoleが好きでRubyでないプロジェクトでも入れていたレベルなんですが、どうせならバイナリポンで動いてくれるこちらを使うことにしました。便利なので使ってください。
設計
さて、長くなってしまいましたが本題に入りましょう。設計面でいくつか言及しておきたいと思います。
全体的な設計
DDDを標榜するプロジェクトでよく使用される型をそのまま再現しています。下記の図のようなレイヤー関係になっています。
QueryServiceはデータベースへの読み取り処理を主に担っています。たとえばToDoリストを全部取得したいというようなユースケースがあると思いますが、そうした取得系はクエリサービスで完結させています。クエリサービスはクエリの発行を直接行います。
ApplicationServiceはデータベースへの書き込み処理が発生する場合、呼び出されます。たとえばToDoリストへの新しいタスクの登録、更新、ToDoリストからのタスクの削除などがこれに該当します。アプリケーションサービスでは、一度ドメインモデルを構築しておき、そのモデルを使ってリポジトリを経由してデータベースへの書き込み処理が走るようになっています。
Kotlinの使用に関するもの
データ型の定義にはdata classを使う
便利なので使いましょう。equalsやhashCodeなどの実装は不要です。また、copyメソッドなどが生えます。
data class ValidatedTodo private constructor( val id: TodoId, val title: String1024, val description: String2048?, val due: TodoDue?, val status: TodoStatus, val createdAt: OffsetDateTime, val updatedAt: OffsetDateTime )
一点注意ですが、現状のKotlinにはprivate constructorを使用する場合、copyの可視性周りにバグがある点に注意です。Kotlin 2.0.20あたりから徐々にcopyの使用に制限がついていく形にはなるようです。私はそもそも使用しなければいいと思っているので、コンパイラオプションでまったく使用できなくしました。[*3]
// Shut up calling `copy` function against data classes that have private constructors. tasks { named("compileKotlin", KotlinCompilationTask::class.java) { compilerOptions { freeCompilerArgs.add("-Xconsistent-data-class-copy-visibility") } } }
フィールドひとつの値オブジェクトの表現にはvalue classを使う
値オブジェクトは多くのケースではフィールドをひとつしか持たないと思います。こうしたケースではvalue class
を積極的に利用したいところです。value class
はオーバーヘッドなしで、別の型名を与えることができる機能です。型エイリアスとは異なり、エイリアスではないのでコンパイル時には別の型としてみなされます。data class
でもやれなくはないのですが、data classでラップした分のオーバーヘッドが乗ることになります。value class
を使用すると、このオーバーヘッドなくして別の型を定義できます。
下記では、TodoId
という型はコンパイル時にはUUID
型とまったく同じと解釈されます。
@JvmInline value class TodoId(val value: UUID)
スコープ関数は正しく利用する
Kotlinにはlet
やrun
などの「スコープ関数」と呼ばれる便利関数群があります。これが用意されているのは、Kotlinでは{}
を() -> T
の関数ブロックとして定義している手前、ブロックスコープを利用できないからでしょう。ブロックスコープを利用したい場合、これらの利用を検討するとよいと思っています。
ブロックスコープをはじめとするこうしたスコープを細かく切る機能を利用したい理由は、バグの低減です。これは経験則になりますが、昔上司が「バグりやすいコードはだいたい変数のスコープが長すぎる」と言っていたことを思い出します。変数のスコープを短く切ったり、余計な情報をスコープ外に漏らさないことは、読みやすかったりメンテナンスしやすかったりするコードの要素のひとつになりえるでしょう。パフォーマンスの面では、変数のスコープが正しく管理されていたり短かったりすると変数の寿命が早めに確定するので、コンパイラやリソース節約にもいい影響を与えます。
そういうわけで、スコープ関数は使える場面では使うようにしました。解説記事を読んでいるとnullableハンドリングのためのlet
以外非推奨としている記事も見かけましたが、それはもったいないかなと思いました。
たとえば、run
は次のような箇所で使用しています。ここでは、読み取り専用と書き込み可能なデータベース接続のふたつを生成している箇所です。似たような処理が連続していることがわかります。
internal fun Application.establishDatabaseConnection(cfg: ApplicationConfig): Pair<DatabaseConn<Permission.ReadOnly>, DatabaseConn<Permission.Writable>> { val read = cfg.run { val jdbcUrl = property("db.read.jdbcURL").getString() val user = property("db.read.user").getString() val password = property("db.read.password").getString() val driverName = property("db.read.driverClassName").getString() DatabaseConn.establishRead(Database.connect(jdbcUrl, driver = driverName, user = user, password = password)) } val write = cfg.run { val jdbcUrl = property("db.write.jdbcURL").getString() val user = property("db.write.user").getString() val password = property("db.write.password").getString() val driverName = property("db.write.driverClassName").getString() DatabaseConn.establishWrite(Database.connect(jdbcUrl, driver = driverName, user = user, password = password)) } return Pair(read, write) }
これを仮にスコープ関数なしで書いたとすると、次のように変数名をずらす必要が出てきます。が、たとえばread
側の情報はwrite
側では一切不要なので、スコープで切ってそもそも参照できないようにしたくなります。他のプログラミング言語であればブロックスコープの出番ですが、Kotlinにはないのでrun
関数を利用しているというわけです。
internal fun Application.establishDatabaseConnection(cfg: ApplicationConfig): Pair<DatabaseConn<Permission.ReadOnly>, DatabaseConn<Permission.Writable>> { val readJdbcUrl = cfg.property("db.read.jdbcURL").getString() val readUser = cfg.property("db.read.user").getString() val readPassword = cfg.property("db.read.password").getString() val readDriverName = cfg.property("db.read.driverClassName").getString() val read = DatabaseConn.establishRead(Database.connect(readJdbcUrl, driver = readDriverName, user = readUser, password = readPassword)) val writeJdbcUrl = cfg.property("db.write.jdbcURL").getString() val writeUser = cfg.property("db.write.user").getString() val writePassword = cfg.property("db.write.password").getString() val writeDriverName = cfg.property("db.write.driverClassName").getString() val write = DatabaseConn.establishWrite(Database.connect(writeJdbcUrl, driver = writeDriverName, user = writeUser, password = writePassword) return Pair(read, write) }
エラー型の表現はsealed classで行う
エラー型を値として扱いたいと言うのもありますが、そもそもKtorについている例外ハンドリング機構をいい形で利用するためには、 when
を用いたエラーハンドリングを前提とする必要があります。これを有効活用するための方法として、sealed class
を利用する手が考えられます。sealed interface
でないのは、このあと説明するようにThrowable
との噛み合わせの問題です。
今回実装した方法では、まずsharedと呼ばれる場所(どのレイヤーでも共通して使用するようなものを定義する)に下記のようにルートとなるエラー型を定義しています。これがThrowable
を継承する形になっています。Throwable
の制約が必要なのはThrowable
を継承しておくと、後述するkotlin-resultのResult型でいくつか有用な関数を利用できるようになるためです。
open class AppErrors(message: String?, cause: Throwable?) : Throwable(message, cause)
次に、各レイヤーのエラー型を定義します。たとえばでdomainレイヤーのエラー定義を示します。sealed class
で名前空間を切った後、data class
で個別のデータ型を定義しています。
package com.github.yuk1ty.todoAppKt.domain.error import com.github.yuk1ty.todoAppKt.shared.AppErrors sealed class DomainErrors(why: String) : AppErrors(message = why, cause = null) { data class ValidationError(val why: String) : DomainErrors(why) data class ValidationErrors(val errors: List<ValidationError>) : DomainErrors(errors.joinToString(", ")) }
今回のエラー型は最終的にはKtorのエラーハンドラの機構で、対応するステータスコードを返すようハンドリングされます。このときsealed class
を使って定義しておくのが生きてきます。when
でマッチ状況を確認しながらハンドリングします。こうすることで、仮に新しいエラー型が追加されたとしても、when
のもつ網羅性チェックによりコンパイルエラーとなるため追加忘れに気づけるようになります。
package com.github.yuk1ty.todoAppKt.shared.modules import com.github.yuk1ty.todoAppKt.adapter.error.AdapterErrors import com.github.yuk1ty.todoAppKt.api.error.HandlerErrors import com.github.yuk1ty.todoAppKt.application.error.ApplicationServiceErrors import com.github.yuk1ty.todoAppKt.domain.error.DomainErrors import com.github.yuk1ty.todoAppKt.shared.AppErrors import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.plugins.statuspages.* import io.ktor.server.response.* internal fun Application.registerExceptionHandlers() { install(StatusPages) { exception<AppErrors> { call, cause -> when (cause) { is HandlerErrors.InvalidPathParameter -> call.respond(HttpStatusCode.BadRequest) is ApplicationServiceErrors.EntityNotFound -> call.respond(HttpStatusCode.NotFound) is DomainErrors.ValidationError -> call.respondText( status = HttpStatusCode.BadRequest, text = cause.why ) is DomainErrors.ValidationErrors -> call.respondText( status = HttpStatusCode.BadRequest, text = cause.errors.joinToString(", ") ) is AdapterErrors.DatabaseError -> { call.application.environment.log.error("Database-related error happened", cause.cause) call.respond(HttpStatusCode.InternalServerError) } } } } }
enum classで定数チックに扱う手もありそうですが、Throwable
を継承できません。Result型の利用を前提とするなら単に不便なので、Throwable
を継承できるsealed class
を利用したほうがいいと思います。
アプリケーション全体の非同期化
国内外問わずネット上の情報を見ると、Webアプリケーションのサンプル実装であってもブロッキング処理を行っているものが散見されます。Rustだとasync/awaitならびにtokioを使用するノンブロッキング処理がほぼ前提になっているのであまり気にすることはありませんでしたが、アプリケーションの非同期化は、ユーザー数の多いアプリケーションを構築するのであればわりと最初にやる話なので、その辺りがコミュニティ全体ではどう考慮されているのかは気になっています。
さすがに非同期化は前提として扱ったほうがよさそうに思ったので、今回構築したアプリケーションでは、必要な箇所はほぼすべてsuspend関数を使用しています。I/Oが発生する箇所にはほぼ確実に必要になるため導入されていますが、逆に言うと、たとえばドメインモデルのようにI/Oを行わない箇所には不要なのでつけてはいません。
顕著なのはアプリケーションサービスなので例を示しておきます。アプリケーションサービスのほぼすべての関数には、次のように先頭にsuspend
キーワードがついています。これは、中のトランザクション発行処理がやはりsuspend
なことに引っ張られています。なお後述しますが、使用しているライブラリ側のバグと思われる事象により、本来トランザクションの発行やリポジトリの関数はやはりsuspend
になるべきなのですが、現状は同期処理としています。ブロッキングI/O専用のスレッドに逃しています。
class TodoApplicationService( private val conn: DatabaseConn<Permission.Writable>, private val repository: TodoRepository ) { // ... suspend fun updateTodo(command: TodoCommands.Update): Result<Unit, AppErrors> = conn.tryBeginWriteTransaction { binding { val existingTodo = repository.getById(TodoId(command.id)) .toErrorIfNull { ApplicationServiceErrors.EntityNotFound(command.id) } .bind() val toBeUpdated = UnvalidatedTodo( id = existingTodo.id.value, title = command.title ?: existingTodo.title.value, description = command.description ?: existingTodo.description?.value, due = command.due ?: existingTodo.due?.value?.toLocalDateTime(), status = command.status ?: existingTodo.status.asString(), createdAt = existingTodo.createdAt.toLocalDateTime(), updatedAt = LocalDateTime.now() ).let { ValidatedTodo(it) }.bind() repository.update(toBeUpdated) } } // ...
余談: suspendとasync/await
ところでsuspend関数周りを調査していると、普通にsuspend関数を呼び出す例と、async
やawait
を使った例のふたつに出会います。たとえばKotlin in Action Second Editionではsuspend関数の説明の後にasyncとawaitが解説されています。違いを押さえておく必要があると思うので(実際どちらをどう使うか迷った)、簡単に私の理解を述べておきます。
両者は並行処理にまつわる機能ですが、評価戦略に違いがあります。suspend関数に何も加工しない状態では即時評価が行われます。一方で、suspend関数をasyncで囲むと遅延評価が行われます。この場合、後述するようにawait()
関数を呼び出すまで評価が走りません。
suspend関数単体では即時評価が行われるようです。つまり、その関数が呼び出されたタイミングです。await
が呼び出されると関数の評価が走り、計算が終われば値が確定します。[*4]たとえば次のコードを確認すると、myFirst
とmySecond
の二つの変数に値が代入された時点で、200msの待ちが入りslowlyAddNumbers
の結果が確定しています。これは内部に仕込んであるログの流れを見ても顕著です。
import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlin.time.Duration.Companion.milliseconds suspend fun slowlyAddNumbers(a: Int, b: Int): Int { println("Waiting a bit before calculating $a + $b") delay(100.milliseconds * a) return a + b } fun main() = runBlocking { println("Starting the suspend computation") val myFirst = slowlyAddNumbers(2, 2) val mySecond = slowlyAddNumbers(4, 4) println("Waiting for suspended value to be available") println("The first ${myFirst}") println("The second ${mySecond}") } // === // 下記は出力結果 // === // Starting the suspend computation // Waiting a bit before calculating 2 + 2 // Waiting a bit before calculating 4 + 4 // Waiting for suspended value to be available // The first 4 // The second 8
一方で、async {}
とawait()
を使うと遅延評価が行われます。async
で囲まれた関数が2つの変数に代入されたタイミングでは評価は走らず、await()
を行ったタイミングで評価が走ります。「Waiting for suspended value to be available」というメッセージの後ろに、関数内の出力内容が出ていることを確認できるでしょう。
import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.async import kotlin.time.Duration.Companion.milliseconds suspend fun slowlyAddNumbers(a: Int, b: Int): Int { println("Waiting a bit before calculating $a + $b") delay(100.milliseconds * a) return a + b } fun main() = runBlocking { println("Starting the async computation") val myFirst = async { slowlyAddNumbers(2, 2) } val mySecond = async { slowlyAddNumbers(4, 4) } println("Waiting for suspended value to be available") println("The first ${myFirst.await()}") println("The second ${mySecond.await()}") } // === // 下記は出力結果 // === // Starting the async computation // Waiting for suspended value to be available // Waiting a bit before calculating 2 + 2 // Waiting a bit before calculating 4 + 4 // The first 4 // The second 8
async {}
はDeferred<T>
という型を返すわけですが、これがミソになります。この型は他のプログラミング言語ではPromise
やFuture
などという型で表現されていることが多いでしょう。こうした型は、「将来のある時点で値を確定させる」意味合いを含んでいます。将来のある時点とはつまりawait
が走るタイミングで、ここまではブロック内部の評価を走らせないよう遅延させるということが起こります。[*5]
両者の使い分けについてです。まず間違いなくasync/awaitを使う例から行くと、タスク(つまりひとつひとつのsuspend fun)を同時に呼び出したい場合に使用するといいようです。たとえば、最終的に.awaitAll()
という関数を呼び出したいパターンではこちらを使用せざるを得ないです。これは複数のDeferred<T>
を同時に走らせ、すべての値が出揃うまでの合流待ちをさせる機能を持つ関数です。このように複数タスクを一度に実行させたいケースではasync
を使うといいという旨の記述が『Kotlin in Action』にはありました[*6]。それ以外のケースでは素直にsuspend関数をそのまま呼び出しておく、で基本的には問題ないようです。
このあたりは、あまり調べても体系的な解説が出てこなかった関係で、自分の思考の整理も兼ねて後日記事を書こうと思っています。
ライブラリの使用に関するもの
全部Result型で通す
さまざまな記事や事例を拝見しましたが、レイヤー単位で例外とResult型によるハンドリングとを切り替えている実装が多いようです。私は例外はどうしても必要でない限り不要だと思っているので、全部Result型で通したほうが方針としてはすっきりすると思いました。また、関数の純粋性の担保の観点からも、可能な限り例外は避けたほうがいいと思っています。例外は代表的な副作用です。論理的に正しい型遷移を十分に示すためにも、どのレイヤーであっても全部Result型で通すほうがいいと思います。
これが意味するところは、init
ブロックやrequire
を使用しないということです。使用しないことによりどれくらいのコストが追加でかかるようになるのかが、実は今回の裏テーマでした。やってみた感想としては、使用しないことそれ自体のコストは単に例外を投げなくするだけなのでほとんど感じないものの、呼び出し側のResultのハンドリングに慣れが必要だと思いました。少なくとも、private constructor
とinvoke
を組み合わせつつ実装すれば、init
やrequire
は利用せずとも困ることはないとも思いました。
今回の実装では、最終的なドメインモデルとしてのTodo
はValidatedTodo
という型で表現されます。このValidatedTodo
は、UnvalidatedTodo
を受け取り、その値をすべて検査して、検査をすべて通過すれば生成することができます。型の遷移としては、UnvalidatedTodo -> Result<ValidatedTodo, DomainErrors>
となります。下記はValidatedTodo
の実装です。
data class ValidatedTodo private constructor( val id: TodoId, val title: String1024, val description: String2048?, val due: TodoDue?, val status: TodoStatus, val createdAt: OffsetDateTime, val updatedAt: OffsetDateTime ) { companion object { operator fun invoke( unvalidatedTodo: UnvalidatedTodo ): Result<ValidatedTodo, DomainErrors.ValidationErrors> = unvalidatedTodo.run { zipOrAccumulate( { String1024(title) }, { description?.let { String2048(it) } ?: Ok(null) }, { TodoStatus.fromString(status) }, ) { validatedTitle, validatedDescription, validatedStatus -> ValidatedTodo( id = TodoId(unvalidatedTodo.id ?: UUID.randomUUID()), title = validatedTitle, description = validatedDescription, due = due?.let { TodoDue(it.atOffset(ZoneOffset.UTC)) }, status = validatedStatus, createdAt = createdAt.atOffset(ZoneOffset.UTC), updatedAt = updatedAt.atOffset(ZoneOffset.UTC) ) }.mapError { DomainErrors.ValidationErrors(it) } } } }
Railway-Oriented Programming
Result型は、内部に持つ値を「エラーになるかもしれない」という文脈で包んだ型です。この文脈というのがやっかいで、中身を取り出すためにはwhen
を使ってパターンマッチングもどきをかけるか、map
やflatMap
などの専属の関数を使って中身の値に対する操作をかける必要があります。
map
やflatMap
などは、成功の場合は引数として渡された関数をそのまま実行し、次も成功であれば同様に引数の関数を実行し、という操作をパイプラインのようにつなげて実行させます。このパイプライン内で一度でもエラーが発生すると、発生箇所以降では全部そのエラーをパイプラインの最後まで伝播させます。これをRailway-Oriented Programmingというようです。参考になりそうな記事。
ただ、flatMap
の多重使用は多重ネストを生みやすい問題があります。そこで回避策として上がるのが、kotlin-resultの持つbinding
という機能です。これを利用すると、flatMap
の多重ネスト問題を回避できます。下記にbind
の利用例を示します。
suspend fun updateTodo(command: TodoCommands.Update): Result<Unit, AppErrors> = conn.tryBeginWriteTransaction { binding { val existingTodo = repository.getById(TodoId(command.id)) .toErrorIfNull { ApplicationServiceErrors.EntityNotFound(command.id) } .bind() val toBeUpdated = UnvalidatedTodo( id = existingTodo.id.value, title = command.title ?: existingTodo.title.value, description = command.description ?: existingTodo.description?.value, due = command.due ?: existingTodo.due?.value?.toLocalDateTime(), status = command.status ?: existingTodo.status.asString(), createdAt = existingTodo.createdAt.toLocalDateTime(), updatedAt = LocalDateTime.now() ).let { ValidatedTodo(it) }.bind() repository.update(toBeUpdated) } }
bind
を使うと、Scalaのfor-yieldのようにflatMapを宣言的に記述できるようになります。処理の見通しがよくなったり、余計な型を作ってflatMapのパイプラインをがんばって伝播させる必要がなくなるなどのメリットがあると思います。デメリットは、多少慣れが必要なことです。
例外的に例外を投げるとき
さて、「例外がどうしても必要でない限り」と先ほど書きました。実はそうしたケースがいくつかあります。今回のアプリケーションでは、Ktorのハンドラ内と、Exposedのトランザクション内です。Ktorは先ほども説明したように最終的に500 Internal Server Errorとしたり、設定したステータスでハンドリングさせたい場合に、どうしても例外を投げる必要があります。Exposedのトランザクション発行機能は、トランザクション内部の処理で例外が投げられるとロールバックが走るようになっています。これらはきちんと利用しないと逆に不具合を生むので、意図的に例外を投げるようにしています。
下記はKtorのハンドラの例です。getOrThrow
関数を呼び出している箇所が見られると思いますが、ここで投げた例外はKtorのエラーハンドリング機構を経由して適切なステータスコードに変換されます。
get<Todos.GetAll> { coroutineBinding { val allTodos = todoQueryService.getTodos().bind() val res = allTodos.map { TodoResponse.fromTodo(it) } call.respond(HttpStatusCode.OK, res) }.getOrThrow() }
また、下記はExposedのトランザクション発行周りです。statement
には実際のクエリ発行部分が入ります。Exposedのクエリ発行部分は、いくつか例外を投げる可能性があるため、transaction関数のスコープ関数内では一旦例外を投げています。投げられた例外はtransaction関数本体でロールバック処理を行った後再度伝播して投げられてくるので、投げられてきた例外をkotlin-resultのrunCatching
で拾ってResult型に再び変換しています。これにより、beginReadTransaction
関数を呼び出す側では例外が投げられたかどうかを気にすることはありません。
余談ですが、statement
はブロッキング処理になっているので、Dispatchers.IO
でブロッキング処理を逃しています。これによりこの関数はsuspend
になっています。ちなみに後述しますが、この対応は不十分ではないかと考えています。
suspend fun <T> DatabaseConn<Permission.ReadOnly>.beginReadTransaction(statement: Transaction.() -> T): Result<T, AppErrors> { val conn = this.inner return withContext(Dispatchers.IO) { runCatching { transaction( db = conn, ) { statement() } }.mapError { AdapterErrors.DatabaseError(it) } }
suspend関数との組み合わせ
アプリケーション全体を非同期処理で設計していると、binding
では対応できません。というのも、binding
の定義は次のようになっており、suspend
関数を受け付け可能ではないからです。
public inline fun <V, E> binding(crossinline block: BindingScope<E>.() -> V): Result<V, E>
そこで利用できるのがcoroutineBinding
です。この関数はsuspend
関数を受け付けできるようになっています。.bind()
の呼び出しは変えることなく、外側のみcoroutineBinding
とするだけで移行は完了します。今回の実装では、ハンドラの部分でこのcoroutineBinding
がよく利用されています。
private fun Route.todoHandler() { // ... get<Todos.GetAll> { coroutineBinding { val allTodos = todoQueryService.getTodos().bind() val res = allTodos.map { TodoResponse.fromTodo(it) } call.respond(HttpStatusCode.OK, res) }.getOrThrow() } }
コンストラクタインジェクションを利用する
Koinを使用している関係で、コンストラクタインジェクションを使用しています。関数単体で見ると確かに純粋性が…という話になってしまう気はしますが、ここは気にしないことにしました。このほうがKotlinにとっては読みやすいかなと思っているためです。
関数型プログラミングを利用していると、DIをするためにカリー化や高階関数、Readerモナドを利用することがあります。また、書籍などを読んでもそう紹介されていることが多そうです。しかし、Kotlinではこうした機能をネスト少なく読みやすく扱える機構がないように思っており、素直な実装からは程遠い超絶技巧が必要になることがあります。やってみてもおもしろかったのですが、実用性を鑑みるとコンストラクタインジェクションに軍配があがりそうだったので、今回は採用しませんでした。
class TodoApplicationService( private val conn: DatabaseConn<Permission.Writable>, private val repository: TodoRepository ) { ... }
幽霊型で権限を表現する
データベースの接続を読み取りと書き込みでわけているケースはままあると思います。一応その辺りを意識しておくと実装例がより実用的になるかと思い、このようにしました。
この場合、データベースの接続は「読み取り専用」と「書き込み可能」のふたつの属性にわかれることがわかります。この属性情報は型付けしておくと、データベースの接続先の取り違えをコンパイル時に防ぐことができるようになります。読み取りと書き込みの権限情報を型付けするということです。
ここで役に立つのは幽霊型という型付け方法です。幽霊型はジェネリクスの位置に型を入れてはおくものの、そのジェネリクスの型は実際には利用されないので、コンパイル時には型情報が消去されます。この消去されるさまが、幽霊が消えるかのようになくなってしまうことから「幽霊型」と名付けられているようです。
Kotlinではやはり幽霊型を利用できるため、データベース接続情報もこれを利用して権限ごとに型付けしました。
sealed interface Permission { data object ReadOnly : Permission data object Writable : Permission } @JvmInline value class DatabaseConn<K : Permission>(val inner: Database) { companion object { fun establishRead(conn: Database): DatabaseConn<Permission.ReadOnly> = DatabaseConn(conn) fun establishWrite(conn: Database): DatabaseConn<Permission.Writable> = DatabaseConn(conn) } }
クエリサービスでは読み取り、アプリケーションサービスでは書き込みが想定されます。この想定を反映した実装は下記のようになります。
// TodoQueryService class TodoQueryService(private val conn: DatabaseConn<Permission.ReadOnly>)
// TodoApplicationService class TodoApplicationService( private val conn: DatabaseConn<Permission.Writable>, private val repository: TodoRepository )
幽霊型はわざわざ用いる必要はないかもしれません。Kotlinの場合、interface
を用意しておきつつ、個別のdata class
を用意するだけで十分ではあります。この方針では、たとえば次のように実装できるでしょう。実用上はこれでもいいとは思います。
sealed interface DatabaseConn { val conn: Database } data class ReadOnlyDatabaseConn(override val conn: Database) : DatabaseConn data class WritableDatabaseConn(override val conn: Database) : DatabaseConn
幽霊型を用いるメリットは、型情報の節約でしょうか。先に示した実装例だとReadDatabaseConnection
とWritaDatabaseConnection
のふたつの型が必要でした。幽霊型を用いると、DatabaseConnection
だけ用意しておけばよいことになります。今回の実装ではそこまで恩恵を感じられませんが、フィールドの増減がままあるデータに対して幽霊型を用いると、実装コストの削減につながるかもしれません。
また、特定の型になったタイミングで特定の関数を呼び出せるようにしたいケースでも有用です。よく紹介される例としてはビルダーパターンを幽霊型で実装する手があります。ビルダーパターンではすべてのメソッドを呼び出したかや、ビルド後の型を生成する関数をどのタイミングで呼び出すかは、基本的にユーザーに委ねられることになります。しかし幽霊型を組み合わせると、そうしたミスの発生しそうな箇所をコンパイル時に検査させることができます。
今回のケースでは、両者の用例にとくに当てはまらないので、普通にinterface
を用意するパターンで実装したとしても同じような結果を得られたのかもしれません。一応トランザクションの発行部分で拡張関数を用いているので、完全に無駄になっているわけではないのですが。
suspend fun <T> DatabaseConn<Permission.ReadOnly>.beginReadTransaction(statement: Transaction.() -> T): Result<T, AppErrors> { val conn = this.inner return withContext(Dispatchers.IO) { runCatching { transaction( db = conn, ) { statement() } }.mapError { AdapterErrors.DatabaseError(it) } } } suspend fun <T> DatabaseConn<Permission.Writable>.tryBeginWriteTransaction(statement: Transaction.() -> Result<T, AppErrors>): Result<T, AppErrors> { val conn = this.inner return withContext(Dispatchers.IO) { runCatching { transaction( transactionIsolation = Connection.TRANSACTION_READ_COMMITTED, db = conn, ) { statement().getOrThrow() } }.mapError { AdapterErrors.DatabaseError(it) } } }
トランザクションの切り出しどころ
トランザクションはどこで扱われるべきか?問題
データベースアクセスするにあたり、トランザクションをどのレイヤーで切り出すかは悩みどころのようです。よくある実装ではRepositoryのインターフェースを切っておき、それをドメインレイヤーに置いておきます。実装はインフラレイヤーに置いておき、ドメインにインフラの情報が漏れ出すことを防ぐものが見られます。このとき、トランザクションはどう扱えばいいでしょうか?
よくやる手のひとつには、インフラレイヤーでトランザクションを切るというパターンがあり得そうです。この場合、何個もテーブルに対して更新をかけるようなケースでリポジトリをどう切り分けるかが問題になるわけですが、そもそも集約がトランザクションの単位であったことを思い出し、集約を一つ切り出して、集約に対応するリポジトリ内に各テーブルに対する更新をすべて書き切ってしまうという手がありそうです。ただこれは集約が大きくなりがち問題が発生する傾向にはあります。濫用防止のための努力が求められるでしょう。
もうひとつよくやる手として、いわゆるアプリケーションサービスでトランザクションを発行し、複数リポジトリにまたがるトランザクションを管理するというものがあります。今回私が行った実装ではこの手法をとっています。この手法のいいところは、集約などの大きめのデータにすべてをまとめておかずとも、細かい単位のままでデータベースへの書き込み処理をかけられる点です。
ですがここで問題になるのが、トランザクションの情報をどう扱うかというものです。たとえばトランザクションがTransaction
という型で表現され、それをインフラレイヤーで使われるであろうデータベース操作のライブラリにまで引き渡す必要があったとします。すると、ドメインレイヤーのinterface
なリポジトリのシグネチャを次のように書き換える必要が出てくるでしょう。
interface TodoRepository { fun create(validatedTodo: ValidatedTodo, tx: Transaction): Result<Unit, DomainErrors.ValidationErrors> }
ただしこれはドメインレイヤーにインフラレイヤーの都合が漏れ出していることになります。これを許容するかどうかは現場によりそうです。事例を調べてみると、この辺りは諦めている現場が多そうに見えてきます。たとえばこの記事では、ドメインレイヤーのリポジトリのインタフェースにトランザクションの情報が入り込むことを許容しているようです。
私の昔の同僚が以前つぶやいていたのですが、アプリケーションレイヤーにリポジトリのインタフェースを設置する手があります。アプリケーションレイヤーにインタフェースを置いておき、インフラレイヤーで具体的な実装を行うという方法です。こうすると、ドメインレイヤーにインフラの情報が一切漏れ出すことはなくなります。割と理想系かもしれません。最近読んだ下記の本でも、アプリケーションレイヤーにインタフェースを置いていそうでした。[*7]今回はこの手法を採用しています。
アプリケーションレイヤーにリポジトリのinterface
を置くのは、ドメインモデルをさまざまな副作用から解放する意味でも合理的だと考えています。たとえばドメインレイヤーにリポジトリのinterface
を置くと、ドメインレイヤーにsuspend
やDeferred
などの副作用を含む情報が置かれることになります。これはドメインを純粋に保つ観点からは避けたいでしょう。
また、そもそもDIPが本当に必要かは検討しなおしてもよいかもしれません。Kotlinのモックライブラリではそもそもobject
に対してもモックをさせたりしますし、テストでモックを利用しないのであれば、インタフェースをわざわざ切り出す必要すらないかもしれません。インタフェースに切り出すことで、データベースバックエンドの切り替えによる影響を吸収させたいという目的を達成できるかもしれませんが、そもそもそうした切り替えはそこまで大きな頻度で起こらないかもしれません。
Exposedのトランザクションの発行の仕方がいい話
話は少し逸れてしまいましたが、Exposedの場合はこの点を考慮する必要はまったくありませんでした。リポジトリのインタフェースを置いておいて、それで終わりでした。というのもtransaction
関数がよくできており、アプリケーションレイヤーで発行後、transaction
関数のブロック内にあるすべての処理はひとつのトランザクションとしてまとめられるからです。つまり関数のシグネチャにトランザクションの情報が飛び出てくることはありません。
まず、今回実装したアプリケーションレイヤーのリポジトリは次のようになっています。トランザクションに関連するシグネチャは一切現れていません。[*8]最悪、ドメインレイヤーに置いても問題ないかもしれません。
package com.github.yuk1ty.todoAppKt.application.repository import com.github.michaelbull.result.Result import com.github.yuk1ty.todoAppKt.domain.model.TodoId import com.github.yuk1ty.todoAppKt.domain.model.ValidatedTodo import com.github.yuk1ty.todoAppKt.shared.AppErrors interface TodoRepository { fun getById(id: TodoId): Result<ValidatedTodo?, AppErrors> fun create(validatedTodo: ValidatedTodo): Result<Unit, AppErrors> fun update(validatedTodo: ValidatedTodo): Result<Unit, AppErrors> fun delete(id: TodoId): Result<Unit, AppErrors> }
次にアプリケーションレイヤーでのトランザクションの発行部分です。タスクの更新では、現在のタスクの情報をデータベースから読み取り、ドメインオブジェクトを編集後データベースに更新処理をかけています。読み取りと書き込みが同時に起こる例です。ここは、tryBeginWritableTransaction
関数で囲っておしまいです。
class TodoApplicationService( private val conn: DatabaseConn<Permission.Writable>, private val repository: TodoRepository ) { // ... suspend fun updateTodo(command: TodoCommands.Update): Result<Unit, AppErrors> = conn.tryBeginWriteTransaction { binding { val existingTodo = repository.getById(TodoId(command.id)) .toErrorIfNull { ApplicationServiceErrors.EntityNotFound(command.id) } .bind() val toBeUpdated = UnvalidatedTodo( id = existingTodo.id.value, title = command.title ?: existingTodo.title.value, description = command.description ?: existingTodo.description?.value, due = command.due ?: existingTodo.due?.value?.toLocalDateTime(), status = command.status ?: existingTodo.status.asString(), createdAt = existingTodo.createdAt.toLocalDateTime(), updatedAt = LocalDateTime.now() ).let { ValidatedTodo(it) }.bind() repository.update(toBeUpdated) } } }
選ぶライブラリによってはこの辺りを真剣に考慮する必要があります。知る限りではJava系では@Transactional
のようなアノテーションをつければ済むものが多く、ドメインレイヤーにリポジトリを置いて終了です。一方で、GoやRustのライブラリでは、トランザクションがスコープではなく型として表現されるものがあり、トランザクション型がドメイン側に流出するのを許容するなどの何かしらの追加の対応が必要になる印象を持っています。
まとめると、
となりそうです。
ExposedのnewSuspendedTransaction
周りの例外ハンドリングについて
ところでトランザクションの発行は、今のサンプルコードではブロッキング処理になっています。Exposedはノンブロッキング対応版のnewSuspendedTransaction
という関数を用意しており、これを使うとsuspend
で実装を通し切ることができます。が、coroutineの例外ハンドリングを上手に制御しきれず、うまく実装することができませんでした。というのも、変に例外をキャッチしてしまうと、newSuspendTransaction
が内部で走らせるロールバックをうまく発火させられないからです。この辺りは悩んでいる人がいるようで、Issueとしても上がっていました。
下記は、newSuspendTransaction
を導入してみたものの、リポジトリ内で発生したエラーを上位レイヤーに上手に伝播できず、結果廃止した実装例です。
(本格的に調査しきれたらこの部分を更新していると思います。)
現状の実装では、一旦妥協策としてブロッキング側のtransaction
を利用しつつ、それをwithContext(Dispatchers.IO)
で逃しておくという実装にとどめています。この実装は確かに手軽に行えるのですが、実運用上では問題が起こるかもしれません。Dispatchers.IO
のスレッド数を適切に制御する必要が出てくるためです。
まとめ
ここまで長々と書いてきましたが、実はまだテストに関する話を書いていません。つまり、道半ばというわけです。この手の情報は一度Zennの本にしてまとめるなどした方が、チームメイトにも伝えやすくなりますし、他の方の役にも立ちそうな気がしてきました。今年1年くらいかけて書いてみてもいいかもと思い始めています。
書いていたら思ったよりレイヤーの話が多くなってしまいました。物をどこに置くか議論は楽しくはあるんですが、物事があまり前に進んでいる感じはしないですね…。Ktorのような設計のライブラリを利用しているからこうなる説もあり、Ruby on Railsのようなレイヤーがはっきり決まったフレームワークを採用した方が、実は考えることも少なくて開発が早まるのでは…と思わなくもなかったです。このあたりの議論はROIが低そうなので、参照する本を一冊決めてそれにみんなで従うとかした方がいいのかなと思っています。
たかがToDoアプリでここまでやるかという話ではあるんですが、自分の頭の整理によかったかなと思います。これはあくまで事例のひとつかつ、私の好みを存分に反映しました。仕事で採用している言語やライブラリを使って一度自分ならどう設計するかを考えてみると、たとえばまったく新規のプロダクトを立ち上げることになったとしてもよりよい設計でスタートできますし、日々の業務にもフィードバックできます。RustとAxumで2021年にやってみたこともあるんですが、これを元に書籍を書くことになったりもしました。
「What if」を考えるのは常に楽しいですね。今後もプロダクトが変わるごとにやっていきたいです。そして、レイヤリングやモデリングなどの議論では、常に手を動かしてみてどうなるかを実験し続け、思考し、検証し続けたいものです。
*1:そもそもレスポンスを返すのにcall.respond関数などを使用しますが、これはUnit型を返す、つまり副作用を生んでいます。これがまず微妙です。
*2:この場合、よほどの事情がない限りScalaを選んだほうがいいと思いますが…。
*3:ところでcopyっていります?単にオブジェクトを作り直せばいいような気がしました。生成AIでコード補完できる昨今であれば、ほぼ手間に感じることもなさそう。
*4:正確を期すために言及しておくと、どのタイミングで値が確定するかはコルーチン機構ののスケジューラ次第となりそうです。
*5:ちなみに普通のsuspend関数はどう型として表現されるのか調べてみたのですが、Continuationという型で表現されているようです。おそらくですが、suspendはいわゆるシンタックスシュガーなのでしょう。
*6:この部分を切り出して、「asyncは並列処理用である」という解説をする記事をいくつか見ました。しかしそれは厳密には微妙に誤りだと思っていて、あくまでasync/awaitは並行処理というか非同期処理のためのツールだと考えています。なので、並列処理という言い方を意図的にしていません。
*7:この本ではヘキサゴナルアーキテクチャが採用されています。語やレイヤー定義の違いには注意が必要です。ただしこの本では、トランザクションの扱いは完全には答えを出しておらず、要議論だねで済ませています。書いてくれ〜〜〜
*8:suspendがついてないじゃないかと思われるかもしれませんが、後述するようにExposedを利用する際に調査中の事項があり、調査が完了してトランザクションの発行をノンブロッキングにできると、suspendを頭につけられるようになります。それを想定してアプリケーションレイヤーに置いています。