ここ数年で『関数型ドメイン モデリング 』という書籍や、『Functional and Reactive Domain Modeling』といった書籍を読んだ経験から、今業務で取り組んでいるKotlinではどう表現できるのかに興味がありました。年末年始に少しまとまった時間が取れたので、実際に実装してみました。今回は、その過程でどのような知見を得られたかを、主には自分の理解のためにまとめておきたいと思います。
github.com
先に書いておきますが、長いです。目次をご覧になって、興味のある場所をかいつまんでお読みください。
免責事項
筆者はKotlinを書き始めて半年くらいです。可能な限り調査をして一次情報を当たるなどし、情報の正しさの担保には努めるようにしましたが、事実誤認が少なからず含まれる可能性があります。また、説明は網羅的でない可能性はあり、遺漏が多々含まれることもあろうかと思います。あらかじめご了承ください。
参考にした書籍や資料などは、参考文献として掲載するようにしてあります。そちらも併せてご覧ください。
また、今回実装したアプリケーションは比較的小規模なものになります。筆者はKotlinで実務で大規模開発を行なっており、そこから予想されるシナリオなどを一応把握はしています。しかし、今回の成果物は仮想的なものに過ぎません。今回紹介する手法が業務上有用かどうかは現場でよく議論してから導入されることをおすすめします。
お題
mattnさんの記事にインスパイアされてTodoアプリを実装しました。
levtech.jp
データベースは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
への対応で一部きちんと動いているのか怪しい箇所がありそうです。詳しく検証していないので、この点については導入時に現場で検証されることをおすすめします。
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} " )
}
一方で、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()} " )
}
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)
}
}
クエリサービスでは読み取り、アプリケーションサービスでは書き込みが想定されます。この想定を反映した実装は下記のようになります。
class TodoQueryService(private val conn: DatabaseConn<Permission.ReadOnly>)
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のライブラリでは、トランザクション がスコープではなく型として表現されるものがあり、トランザクション 型がドメイン 側に流出するのを許容するなどの何かしらの追加の対応が必要になる印象を持っています。
まとめると、
DIP する前提の場合
インフラレイヤーで発行して大きい集約を許容する。
アプリケーションレイヤーで発行してドメイン レイヤーにリポジトリ のインタフェースを置く。ドメイン にインフラの情報が流れ込むのを許容する。
アプリケーションレイヤーで発行して、アプリケーションレイヤーにリポジトリ のインタフェースを置く。
DIP しない前提の場合
インフラレイヤーにリポジトリ の実装をおきつつ、トランザクション もインフラレイヤーで発行して大きい集約を許容する。
インフラレイヤーにリポジトリ の実装をおきつつ、トランザクション 自体はアプリケーションレイヤーで発行する。
となりそうです。
ExposedのnewSuspendedTransaction
周りの例外ハンドリングについて
ところでトランザクション の発行は、今のサンプルコードではブロッキング 処理になっています。Exposedはノンブロッキング 対応版のnewSuspendedTransaction
という関数を用意しており、これを使うとsuspend
で実装を通し切ることができます。が、coroutineの例外ハンドリングを上手に制御しきれず、うまく実装することができませんでした。というのも、変に例外をキャッチしてしまうと、newSuspendTransaction
が内部で走らせるロールバック をうまく発火させられないからです。この辺りは悩んでいる人がいるようで、Issueとしても上がっていました 。
下記は、newSuspendTransaction
を導入してみたものの、リポジトリ 内で発生したエラーを上位レイヤーに上手に伝播できず、結果廃止した実装例です。
github.com
現状の実装では、一旦妥協策としてブロッキング 側のtransaction
を利用しつつ、それをwithContext(Dispatchers.IO)
で逃しておくという実装にとどめています。この実装は確かに手軽に行えるのですが、実運用上では問題が起こるかもしれません。Dispatchers.IO
のスレッド数を適切に制御する必要が出てくるためです。
[追記] 例外を投げずにResult型を判定させてエラーだったらrollback
関数を呼び出せばよさそうでした。しかし、ORMにはノンブロッキング IOが期待されるはずなのにsuspendの機能がexperimentalなのは、未成熟感を感じますね。[*9 ]
suspend fun <T> DatabaseConn<Permission.Writable>.tryBeginWriteTransaction(statement: suspend Transaction.() -> Result <T, AppErrors>): Result <T, AppErrors> {
return newSuspendedTransaction(
transactionIsolation = Connection.TRANSACTION_READ_COMMITTED,
db = this .inner ,
) {
statement().orElse {
rollback()
Err(it)
}
この実装を行うと、無事にRepositoryもsuspend化でき、それをとりまくbinding
もcoroutineBinding
化できます。Dispatchers.IO
について回りそうだったスレッドの枯渇問題も一旦気にしなくて良くなるかもしれません。
github.com
まとめ
ここまで長々と書いてきましたが、実はまだテストに関する話を書いていません。つまり、道半ばというわけです。この手の情報は一度Zennの本にしてまとめるなどした方が、チームメイトにも伝えやすくなりますし、他の方の役にも立ちそうな気がしてきました。今年1年くらいかけて書いてみてもいいかもと思い始めています。
書いていたら思ったよりレイヤーの話が多くなってしまいました。物をどこに置くか議論は楽しくはあるんですが、物事があまり前に進んでいる感じはしないですね…。Ktorのような設計のライブラリを利用しているからこうなる説もあり、Ruby on Rails のようなレイヤーがはっきり決まったフレームワーク を採用した方が、実は考えることも少なくて開発が早まるのでは…と思わなくもなかったです。このあたりの議論はROIが低そうなので、参照する本を一冊決めてそれにみんなで従うとかした方がいいのかなと思っています。
たかがToDoアプリでここまでやるかという話ではあるんですが、自分の頭の整理によかったかなと思います。これはあくまで事例のひとつかつ、私の好みを存分に反映しました。仕事で採用している言語やライブラリを使って一度自分ならどう設計するかを考えてみると、たとえばまったく新規のプロダクトを立ち上げることになったとしてもよりよい設計でスタートできますし、日々の業務にもフィードバックできます。RustとAxumで2021年にやってみたこともあるんですが 、これを元に書籍を書くことになったりもしました。
「What if」を考えるのは常に楽しいですね。今後もプロダクトが変わるごとにやっていきたいです。そして、レイヤリングやモデリング などの議論では、常に手を動かしてみてどうなるかを実験し続け、思考し、検証し続けたいものです。