/var/log/jsoizo

メモ帳 技術とか趣味とか

Context Receiverを利用した簡易的なDI

Kotlin Confがはじまった!!!

キーノートではKotlin 2.0に追加される言語仕様が発表された。

blog.jetbrains.com

個人的には好きなKEEP段階の機能であるContext Receiverがついに正式なリリースを迎えることになりそうで嬉しい一方で、Kotlin 2.0まで待たないといけないのか。。。という悲しみもあった。

とはいえ、現行の最新版であるKotlin 1.8でもすでに利用可能ではあり大幅に仕様変更をすることもなさそうで既にプロダクションで使えるレベルにあるので、実践的にContext Receiverを使うにはこういう用法が良いだろうということを示すための簡易的なDIのコードと、実際に使ってみてのContext ReceiverでDIすることのメリットを書き記す。

余談だが、この機能を初めて見たとき個人的にはScalaのZIOにおける環境型みたいなものだと思った。
https://zio.dev/overview/summary#zio

Context Receiverの仕様サマリ

Context Receiverの仕様を要約すると以下の通りとなる。

  • 任意の関数宣言の上部に context(Foo, Bar) と記載することで、関数が実行されるコンテキストを明示することができる
  • contextで与えた型のフィールドは関数内部から参照, 呼び出し可能である
  • 関数呼び出しを with(Foo){ } でラップすることで、関数に対して暗黙的にコンテキストを渡すことができる

Context Receiverを利用する上で

現時点では正式な機能ではないため、build.gradleにおけるKotlinコンパイラのオプションに -Xcontext-receivers を追加する必要がある。Kotlin 2.0がリリースされるとこれが不要となる。

tasks.withType<KotlinCompile> {
    kotlinOptions.jvmTarget = "1.8"
    kotlinOptions.freeCompilerArgs = listOf("-Xcontext-receivers")
}

DIが必要な想定ユースケース

たとえば以下のようなUserドメインクラスがあり、インスタンスの生成ロジック User.create を利用,テストしたいとする

  • ID、名前、作成日時をもつ
  • Userの作成には名前のみが必要でIDはランダムに払い出し作成日時は現在時刻を利用する
  • 名前は少なくとも1文字以上である
class User private constructor(val userId: UUID, val name: String, val createdAt: OffsetDateTime) {

    companion object {
        fun create(name: String): User = run {
            if (name.isEmpty()) throw IllegalArgumentException("User.name is empty!!")
            User(UUID.randomUUID(), name, OffsetDateTime.now())
        }
    }

}

ここで、

IDはランダムに払い出し作成日時は現在時刻を利用する

という仕様があるが、この状態だと id, createdAt フィールドは値の生成時に副作用を伴い実行時点の乱数や時計に依存するためにテスト時のassertが難しい(※)。ということでClockやUUIDをDIすることで User.create から副作用を切り離して差し替えられるようにし、モックによるによるテストが出来るようにしたくなる。

※ あくまでサンプルとして単純化しただけで、特にロジックのない id, createdAt フィールドのassertは要らんだろ的なことは承知している

これを単純に解こうとすると、User.create を以下のように直すことになるが、 、、

fun User.create(name: String, generateUUID: () -> UUID, clock: Clock): User = run {
    if (name.isEmpty()) throw IllegalArgumentException("User.name is empty!!")
    User(generateUUID(), name, OffsetDateTime.now(clock))
}

fun main(args: Array<String>) {
    val generateUUID = UUID::randomUUID
    val clock = Clock.systemDefaultZone()
    val user = User.create("John", generateUUID, clock)
}

これだとたしかに副作用を伴うUUIDとClockをを関数に対して渡せるようになったため、テストしやすさの点では良くなった。

val dummyDateTime = OffsetDateTime.parse("2023-01-30T12:34:56Z")
val fixedClock = Clock.fixed(dummyDateTime.toInstant(), ZoneId.of("UTC"))
val dummyUUID = UUID.fromString("5ac47e1a-19a9-408c-96d1-6741e8701433")

@Test
fun userCreateTest() {
    val user = User.create("John", { dummyUUID }, fixedClock)
    assert(user.userId == dummyUUID)
    assert(user.name == "John")
    assert(user.createdAt == dummyDateTime)
}

一方で、引数を増やしたことによって

Userの作成には名前のみが必要

という要件がメソッドシグネチャからわかりづらくなり、可読性を落とすことになってしまっている。

Context Receiverを利用してDIしてみる

まずはUUID, Clockそれぞれのコンテキスト型を定義して User.create にレシーバを追加する。

interface UUIDContext {
    fun generateUUID(): UUID
}

interface ClockContext {
    val clock: Clock
}

context(UUIDContext, ClockContext)
fun User.create(name: String): User = run {
    if (name.isEmpty()) throw IllegalArgumentException("User.name is empty!!")
    User(generateUUID(), name, OffsetDateTime.now(clock))
}

呼び出し側ではそれぞれのコンテキスト型を具象化したオブジェクトを with により関数呼び出しのコンテキストとして宣言。 User.create に対して暗黙的に ApplicationContext がそれぞれのコンテキスト型の値として渡ることとなる。

object ApplicationContext : UUIDContext, ClockContext {
    override fun generateUUID(): UUID = UUID.randomUUID()
    override val clock: Clock = Clock.systemDefaultZone()
}

fun main(args: Array<String>) {
    with(ApplicationContext) {
        val user = User.create("John")
    }
}

テストのときは同じようにモックを使ったContextを作れば良い。

@Test
fun userCreateTest() {
    val user = with(testContext) {
        User.create("John")
    }
    assert(user.userId == dummyUUID)
    assert(user.name == "John")
    assert(user.createdAt == dummyDateTime)
}

これにより User.createシグネチャから仕様がわかるし、依存がcontext receiverに明示されているので、Userは見通しの良いコードとなった。

Context Receiverを利用したDIのメリット

個人的にはDIのツールとしてContext Receiverを利用することの最大のメリットは、 依存関係と引数を分離することができる 点に尽きると考えている。

単純なDIとしての利便性だけでいうとおそらくGuiceやSpring、AndroidならHiltのようなDIコンテナのほうが高いと思われるが、これらはあくまで引数として宣言したものに対して動的にオブジェクトを注入するので、 ドメインオブジェクトのように メソッドのシグネチャでドメインの要件を表現したい 場合の見通しを良くしようと思うとこのContext Receiverを用いたDIが活きてくるのではなかろうか。