Kotlin Confがはじまった!!!
キーノートではKotlin 2.0に追加される言語仕様が発表された。
個人的には好きな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が活きてくるのではなかろうか。