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

実践Context Receiver 〜現場で使えるかもしれないパターン3選〜

実践Context Receiver 〜現場で使えるかもしれないパターン3選〜

More Decks by コドモン開発チーム

Transcript

  1. 8 CONFIDENTIAL - © 2022 CoDMON Inc. 8 Context Receiver機能について

    • 2023年6月現在、Preview機能 ◦ 詳細はKEEPを参照してください • 2022年2月のリリースで、Previewとはいえ既に1年経過 ◦ 標準搭載待ち状態といって良さそう ◦ 機能的に大幅な変更が加わることもおそらくない • 先日のKotlin Conf ‘23にて、Kotlin 2.0より正式な機能に昇格と発表 🎉
  2. 9 CONFIDENTIAL - © 2022 CoDMON Inc. 9 Context Receiver機能について

    引き受けたコンテキスト型の内部で 関数を実行していることになる。 = 複数の型に対し拡張できるイメージ class UserUseCase { context(Logger, Storage<User>) fun userInfo(name: String): Storage<User>.Info { log("Retrieving User info about $name") return info(name) } } Logger Storage<User> fun info(name: String): Info fun log(message: String): Unit 関数定義の例 UserUseCase fun userInfo(name: String): Storage.Info
  3. 10 CONFIDENTIAL - © 2022 CoDMON Inc. 10 Context Receiver機能について

    withやrunなどのスコープ関数  の引数 block: T.() -> R における コンテキスト型Tが、呼び出し先の関数において暗黙的に受け渡される ※ スコープ関数は元々ある機能で、渡されるところの機能なのでReceiverなのだと理解 -/ コンテキストを定義 class LoggerAndStorageContext <T>(loggerName: String) : Logger, Storage<T> -/ コンテキストのインスタンスを生成 val userStorageContext = LoggerAndStorageContext<User>("user storage") -/ コンテキスト内でuserInfoを実行 -/ = 暗黙的にuserInfoにコンテキストが渡される userStorageContext.run { UserUseCase.userInfo("Alice") } 呼び出し元の例
  4. 13 CONFIDENTIAL - © 2022 CoDMON Inc. 13 ① 簡易DI

    • ユーザの新規登録のドメインを実装するようなケースを考える • ユニットテストにより期待通りにnewできるか検証したい   DIでClockを注入することを検討するが、この程度のことのために   DIコンテナを使うのはtoo muchな気もする... class User(val id: UUID, val name: String, val created: LocalDateTime) { constructor(name: String): this(UUID.randomUUID(), name, LocalDateTime.now()) init { require(name.isNotEmpty()) { "name is empty" } } } DIしたいクラスの実装
  5. 14 CONFIDENTIAL - © 2022 CoDMON Inc. 14 ① 簡易DI

    companion objectからコンストラクトするようにしつつ、 ClockContextを引き受けられるようにすると... interface ClockContext { val clock: Clock } class User (val id: UUID, val name: String, val created: LocalDateTime) { companion object { context(ClockContext) fun of(name: String): User { require(name.isNotEmpty()) { "name is empty" } return User(UUID.randomUUID(), name, LocalDateTime.now(clock)) } } } 引数をそのままに依存しているコンテキストを宣言できる - (仕様が分かりづらくなるのでclockは引数には入れたくない) - User生成関数の宣言 Clockのインスタンスを持つコンテキスト型を定義
  6. 15 CONFIDENTIAL - © 2022 CoDMON Inc. 15 ① 簡易DI

    mainでは呼び出しはこのようにシステムの時計を利用し、 本来の挙動通りシステムコールを行う時計となる。 object UTCClockContext: ClockContext { override val clock: Clock = Clock.systemUTC() } val user1 = UTCClockContext.run { User.of("Alice") } val user2 = with(UTCClockContext) { User.of("Bob") } 呼び出し元
  7. 16 CONFIDENTIAL - © 2022 CoDMON Inc. 16 ① 簡易DI

    testでは時計の時刻を固定してnowが特定の値になるようにすることで、 期待通りの初期化が出来ているかassertできる。 ※ そもそもモックすべきかについては様々意見や好みがあるので触れない class FixedClockContext(time: String): ClockContext { override val clock: Clock = Clock.fixed(Instant.parse(time), ZoneId.of("UTC")) } val fixedTime = "2023-12-31T12:34:56.123Z" val user = with(FixedClockContext(fixedTime)) { User("Alice") } assert(user.created -= LocalDateTime.parse(fixedTime)) テストコード
  8. 18 CONFIDENTIAL - © 2022 CoDMON Inc. 18 ② セッションやトランザクションの引き回し

    例としてこのようなユースケースを考える。 • マイクロサービスアーキテクチャでECサイトを開発している • 商品APIと商品レコメンドAPIが存在している • 商品APIのふるまい ◦ 任意の商品とユーザ&商品に対するレコメンド商品を返す ◦ ユーザ情報を商品レコメンドAPIに対して送る必要あり
  9. 19 CONFIDENTIAL - © 2022 CoDMON Inc. 19 User情報の引き回しを行う例 //

    Controller fun getItems(req: Request): Response { val result = usecase.getItems(req.user, req.itemId) return ItemsResponse.from(result) } // Usecase fun getItems(user: User): Result { val item = repository.getItem(itemId) val recommended = getRecommendedItems(user, itemId) return Result(item, recommended) } // API Client fun getRecommendedItems(user: User, itemId: String): List<Item> レイヤーを跨いでuserのバケツリレーをしている ② セッションやトランザクションの引き回し
  10. 20 CONFIDENTIAL - © 2022 CoDMON Inc. 20 ② セッションやトランザクションの引き回し

    User情報の引き回しにContext Receiverを利用した例 -/ Controller fun someEndpoint(req: Request): Response { UserContext(req.user).run { usecase.getItems(req.itemId) } } -/ Usecase context(UserContext) fun getItems(user: User): Result { val item = repository.getItem(itemId) val recommended = getRecommendedItems(itemId) return Result(item, recommended) } -/ API Client context(UserContext) fun getRecommendedItems(itemId: String): List<Item> getItemsはUserContext内にあるため、 apiClient.getItemsに対して暗黙的にUserContextを渡している - 以降は暗黙的にコンテキストが引き回されるので、 user情報を扱うコンテキストの宣言を一度だけ行えば良い - getRecommendedItemsは Controllerで宣言したUserContextを利用できる
  11. 22 CONFIDENTIAL - © 2022 CoDMON Inc. 22 ③ チェック例外(Arrow-kt必須)

    Javaにはあるチェック例外の機構がKotlinでは意図的に省かれている。 一方で、エラー型を定義して返却するのは良いプラクティスとされている   ゆえに極力エラー時の型はシグネチャから分かるようにしておきたい。 class PositiveInt { private int value; public PositiveInt(int value) throws InvalidNumberException { if (value < 0) { throw new InvalidNumberException("Number cannot be less than 0"); } this.value = value; } } チェック例外を使うJavaコード例
  12. 23 CONFIDENTIAL - © 2022 CoDMON Inc. 23 ③ チェック例外(Arrow-kt必須)

    Resut<T>を利用することもできるが、 Result.failureは引数がThrowableなので型情報が失われてしまう(惜しい)。 companion object { fun of(value: Int): Result<PositiveInt> = if (value < 0) { Result.failure( InvalidNumberException("Number cannot be less than 0") ) } else { Result.success(PositiveInt(value)) } } Result<T>を返すコンストラクタ
  13. 24 CONFIDENTIAL - © 2022 CoDMON Inc. 24 ③ チェック例外(Arrow-kt必須)

    Arrow-ktのRaise<T>をコンテキストとして受け取るパターン Raise<T>のコンテキスト内でensureやraise等Raise<T>の関数を呼び出すと、 その時点で処理が中断する(≒ throwに近い)かたちとなる。 context(Raise<InvalidNumberException>) fun of(value: Int): PositiveInt { if(value -= 0) { raise(InvalidNumberException("Number cannot be less than 0")) } return PositiveInt(value) } raiseがthrow相当で、コンテキストの宣言を行った箇所まで飛ぶ - コンテキストがRaise<T>なコンストラクタ
  14. 25 CONFIDENTIAL - © 2022 CoDMON Inc. 25 ③ チェック例外(Arrow-kt必須)

    Raise<T>をコンテキストとして関数を呼ぶために、 Arrow-ktが提供するRaise<T>用fold関数を利用する。 fold( { -/ 第一引数: Raise<T>をコンテキストとした処理 PositiveInt.of(-1) }, { -/ 第二引数: エラー<T>がraiseされたときの処理 it -> println(“Failed… ${it.message}“) }, { -/ 第三引数: 正常時の処理 it -> println(“Success!!!”) } ) PositiveIntに対してマイナス値を与えているため、 raiseされて第二引数のブロックが呼び出される。 Raise<T>の利用とエラーハンドリング
  15. 26 CONFIDENTIAL - © 2022 CoDMON Inc. 26 main() ③

    チェック例外(Arrow-kt必須) Raise<Error> fun: raise<Error>(r: Error): Nothing PositiveInt fun: of(int: Int): PositiveInt { if ( int < 0 ) raise(Error()) PositiveInt(int) } fold({ }, { e: Error => T }, { pi: PositiveInt => T } ) エラー時 正常時
  16. 28 CONFIDENTIAL - © 2022 CoDMON Inc. 28 コドモンでのContext Receiver

    ① 簡易DIのパターン => 利用中 🥰 • ドメインロジックにおける副作用を含む部分を外部から注入したい ◦ [背景] webアプリ(Spring WebFlux)をクリーンアーキテクチャにより実装 ◦ ドメインロジックを可能な限りピュアに保ちFWへの依存をなくしたくて、 このパターンでClockをDIすることにした 採用してみての社内からのフィードバック - テスタビリティが上がることを実感している。 関数のシグネチャ + Context宣言でニコイチである ことを覚えておく必要があるのは慣れなければ。
  17. 29 CONFIDENTIAL - © 2022 CoDMON Inc. 29 コドモンでのContext Receiver

    ② セッションやトランザクションの引き回し = 継続検討 🤔 1. 見送 DBトランザクション情報をクラスを跨いで受け渡す ◦ 現行のクラス設計上かならずしも必要ではないという話になった ◦ やるにしてもSpring Frameworkを利用しているのでその流儀に則る 2. 将来 ログインユーザー情報の引き回し ◦ マイクロサービスアーキテクチャを採用しており、publicなapiで処理した    認証ユーザ情報をprivateなapiに対して渡す必要性がいずれ出てくる
  18. 30 CONFIDENTIAL - © 2022 CoDMON Inc. 30 ③ チェック例外(Arrow)

    => 未検討だが見送りの可能性高し 😇 • Arrow-kt自体はEither等のデータ型のために導入している • それでもRaise<T>へ置き換えるにはハードルがやや高いと感じる ◦ Kotlin + Eitherに加えてRaiseまで覚えるのはさすがに学習コスト高い ◦ && 他言語(PHP)を書いていたチームから異動したメンバーもいる ※ もしかしたらEitherを捨ててRaiseだけでいくぞ!と判断できれば良いかもしれない。   Eitherは社内で他チーム含めある程度浸透しており今更Either使わないとは言いづらい コドモンでのContext Receiver
  19. 31 CONFIDENTIAL - © 2022 CoDMON Inc. 31 さいごに: Context

    Receiver自体への感想 • 便利ではあるが、現実的に環境で利用するかは悩ましい ◦ 😁 テスト容易性やコードの型安全性UP、ボイラープレート削減 ◦ 🥺 単純に機能自体への理解ハードルがありeasyさを欠く ▪ 不用意に難易度をあげて保守性がむしろ下がるリスク ▪ “暗黙的に” というところがそう感じさせる • 検討結果としては ”良し悪しあるので使い方の理解が必要” という形に ◦ 用法用量を守って使うためにはガイドラインがあると良さそう = どういう時に使えてどういう時に避けるほうが良いか