Share
Embed
EitherT_with_Future
Naoki Aoyama - @aoiroaoino
September 06, 2024
Transcript
57 slides
-
❖ Naoki Aoyama ❖ Twitter/GitHub: @aoiroaoino ❖ 実践Scala入門 共著者 ❖
Working: $ whoami -
Agenda ❖ Introduction ➢ アプリケーションサービスあるある話 ❖ ちょうどいい、 Future[Either[E, A]] ➢
非同期処理、正常系、準正常系、異常系 … 表現力が丁度いい ➢ でも、for 式でフラットに合成はできない ... ❖ EitherT で包んじゃえ ➢ EitherT とは? cats の実装をみてみよう ➢ なぜ for 式でフラットに合成できる? map, flatMap を詳しく ❖ Tips ➢ 実用上の細かい話、注意点など ❖ まとめ -
語らないこと ❖ Monad, MonadTransformer の詳解(そもそもほぼ触れない) ❖ アプリケーションサービスの実装 (ベスト)プラクティス ❖ IO
モナドや Eff など、他の解決策について -
どんな場面で使ってるの?(ざっくりイメージ) アダプタA アダプタB アダプタC ドメイン層 アプリケーション層 アプリケーションサービス内で さまざまな処理の合成に使用 様々な処理を組み合わせて ユースケースを実装していく
❖ データを読み込んだり ❖ 外部連携サービスと同期したり ❖ イベント送信したり ❖ などなど -
アプリケーションサービス実装例: 理想 // ユーザーの表示名を変更するユースケース(理想) def execute(userId: UserId, newName: User.Name): Future[Result]
= for { user <- userRepository.findById(userId) updated = user.rename(newName) _ <- userRepository.store(updated) _ <- notificationService.notify(...) } yield { Result.Succes(...) } -
アプリケーションサービス実装例: 理想 // ユーザーの表示名を変更するユースケース(理想) def execute(userId: UserId, newName: User.Name): Future[Result]
= for { user <- userRepository.findById(userId) updated = user.rename(newName) _ <- userRepository.store(updated) _ <- notificationService.notify(...) } yield { Result.Succes(...) } 現実は... ❖ DB からの取得 … def findById(...): Future[Option[A]] ❖ ドメインロジック実行 … def rename(...): Either[E, A] ❖ 外部サービス API Call … def notify(...): Future[Either[E, A]] -
アプリケーションサービス実装例: 理想 // ユーザーの表示名を変更するユースケース(理想) def execute(userId: UserId, newName: User.Name): Future[Result]
= for { user <- userRepository.findById(userId) updated = user.rename(newName) _ <- userRepository.store(updated) _ <- notificationService.notify(...) } yield { Result.Succes(...) } 現実は... ❖ DB からの取得 … def findById(...): Future[Option[A]] ❖ ドメインロジック実行 … def rename(...): Either[E, A] ❖ 外部サービス API Call … def notify(...): Future[Either[E, A]] => 結果を適切にハンドリングしていく必要がある -
アプリケーションサービス実装例: 現実 // ユーザーの表示名を変更するユースケース(現実) def execute(userId: UserId, newName: User.Name): Future[Result]
= for { userOpt <- userRepository.findById(userId) result <- userOpt match { case Some(user) => user.rename(newName) match { case Right(updated) => userRepository.store(updated) case Left(error) => Future.successful(Result.Error(error)) } case None => Future.successful(Result.UserNotFound) } _ <- result match { case Result.Success => notificationService.run(...) case _ => Future.unit } } yield result -
アプリケーションサービス実装例: 現実 // ユーザーの表示名を変更するユースケース(現実) def execute(userId: UserId, newName: User.Name): Future[Result]
= for { userOpt <- userRepository.findById(userId) result <- userOpt match { case Some(user) => user.rename(newName) match { // ユーザーが存在し、リネームに成功した場合のみ永続化 case Right(updated) => userRepository.store(updated) case Left(error) => Future.successful(Result.Error(error)) } case None => Future.successful(Result.UserNotFound) } _ <- result match { case Result.Success => notificationService.run(...) case _ => Future.unit } } yield result -
アプリケーションサービス実装例: 現実 // ユーザーの表示名を変更するユースケース(現実) def execute(userId: UserId, newName: User.Name): Future[Result]
= for { userOpt <- userRepository.findById(userId) result <- userOpt match { case Some(user) => user.rename(newName) match { // ユーザーが存在し、リネームに成功した場合のみ永続化 case Right(updated) => userRepository.store(updated) case Left(error) => Future.successful(Result.Error(error)) } case None => // ユーザーが見つからないなら NotFound Future.successful(Result.UserNotFound) } _ <- result match { case Result.Success => notificationService.run(...) case _ => Future.unit } } yield result -
アプリケーションサービス実装例: 現実 // ユーザーの表示名を変更するユースケース(現実) def execute(userId: UserId, newName: User.Name): Future[Result]
= for { userOpt <- userRepository.findById(userId) result <- userOpt match { case Some(user) => user.rename(newName) match { // ユーザーが存在し、リネームに成功した場合のみ永続化 case Right(updated) => userRepository.store(updated) case Left(error) => Future.successful(Result.Error(error)) } case None => // ユーザーが見つからないなら NotFound Future.successful(Result.UserNotFound) } _ <- result match { case Result.Success => notificationService.run(...) case _ => Future.unit // 成功していないなら何もしない } } yield result -
アプリケーションサービス実装例: 現実 // ユーザーの表示名を変更するユースケース(現実) def execute(userId: UserId, newName: User.Name): Future[Result]
= for { userOpt <- userRepository.findById(userId) result <- userOpt match { case Some(user) => user.rename(newName) match { // ユーザーが存在し、リネームに成功した場合のみ永続化 case Right(updated) => userRepository.store(updated) case Left(error) => Future.successful(Result.Error(error)) } case None => // ユーザーが見つからないなら NotFound Future.successful(Result.UserNotFound) } _ <- result match { case Result.Success => notificationService.run(...) case _ => Future.unit // 成功していないなら何もしない } } yield result for 式を ”フラット ”に 書けない -
アプリケーションサービス実装例: 現実 // ユーザーの表示名を変更するユースケース(現実) def execute(userId: UserId, newName: User.Name): Future[Result]
= for { userOpt <- userRepository.findById(userId) result <- userOpt match { case Some(user) => user.rename(newName) match { // ユーザーが存在し、リネームに成功した場合のみ永続化 case Right(updated) => userRepository.store(updated) case Left(error) => Future.successful(Result.Error(error)) } case None => // ユーザーが見つからないなら NotFound Future.successful(Result.UserNotFound) } _ <- result match { case Result.Success => notificationService.run(...) case _ => Future.unit // 成功していないなら何もしない } } yield result Explicit な 分岐に 都度対処 -
アプリケーションサービス実装例: 現実 ❖ for 式の中(特に Generator まわり)がごちゃごちゃしてつらい ❖ 処理が失敗した場合にどのような値が返るのか、ぱっと見わかりにくい ❖
一度きりの実装なら...しかし、実際は大量のボイラープレートになる ❖ などなど -
もう少し、型を整理してみる よくある処理の共通(表現力の高い)型、ありませんか? ❖ x: A … ❖ x: Either[E, A]
… ❖ x: Future[Option[A]] … ❖ x: Future[A] … ❖ x: Nothing (throw Exception) … -
もう少し、型を整理してみる よくある処理の共通(表現力の高い)型、ありませんか? ❖ x: A … Future.successful(Right(x)) ❖ x: Either[E,
A] … Future.successful(x) ❖ x: Future[Option[A]] … x.map(_.toRight(leftValue)) ❖ x: Future[A] … x.map(a => Right(a)) ❖ x: Nothing (throw Exception) … Future.failed(x) -
もう少し、型を整理してみる よくある処理の共通(表現力の高い)型、ありませんか? ❖ x: A … ❖ x: Either[E, A]
… ❖ x: Future[Option[A]] … ❖ x: Future[A] … ❖ x: Nothing (throw Exception) … Future[Either[E, A]] にしておけば よくあるパターンは表現できそう! -
もう少し、型を整理してみる よくある処理の共通(表現力の高い)型の持つ意味を考える ❖ x: A … 成功系(成功のみ) ❖ x: Either[E,
A] … 準正常系 or 正常系(異常系なし) ❖ x: Future[Option[A]] … 異常系 or 正常系(Opt な値とみなす場合) ❖ x: Future[A] … 異常系 or 正常系 ❖ x: Nothing (throw Exception) … 異常系 -
ちょうどいい、Future[Either[E, A]] ❖ 非同期処理を前提に、正常系/準正常系/異常系をシンプルに表現 ❖ 標準ライブラリのみで構成される ➢ 3rd party ライブラリが不要
➢ Scala のバージョンが変わっても互換性がまず保たれる ❖ 学習コストの低さ、チーム開発における認知負荷の低さ ➢ Scala 入門の書籍、ドキュメントにもほぼ間違いなく解説がある ➢ 他の言語でも同様の実装があり、 (近年割と)一般的な概念でもある -
でも for 式でフラットに合成できない... // ユーザーの表示名を変更するユースケース(現実) def execute(userId: UserId, newName: User.Name):
Future[Result] = for { userE <- userRepository.findById(userId) resultE <- userE match { case Right(user) => for { updatedE <- user.rename(newName) result <- updatedE match { case Right(updated) => userRepository.store(updated).map(_ => Result.Success) case Left(error) => Future.successful(Left(Result.OpsError(error))) } } yield result case Left(_) => Future.successful(Left(Result.UserNotFound)) } _ <- resultE match { case Right(Result.Success) => notificationService.run(...) case Left(Result.OpsError(_)) => Future.successful(()) case Left(Result.UserNotFound) => Future.successful } } yield result -
でも for 式でフラットに合成できない... // ユーザーの表示名を変更するユースケース(現実) def execute(userId: UserId, newName: User.Name):
Future[Result] = for { userE <- userRepository.findById(userId) resultE <- userE match { case Right(user) => for { updatedE <- user.rename(newName) result <- updatedE match { case Right(updated) => userRepository.store(updated).map(_ => Result.Success) case Left(error) => Future.successful(Left(Result.OpsError(error))) } } yield result case Left(_) => Future.successful(Left(Result.UserNotFound)) } _ <- resultE match { case Right(Result.Success) => notificationService.run(...) case Left(Result.OpsError(_)) => Future.successful(()) case Left(Result.UserNotFound) => Future.successful } } yield result -
flatMap で書き直したらパターンが見える...? userRepository.findById(userId).flatMap { case Right(user) => user.rename(newName).flatMap { case
Right(updated) => userRepository.store(updated).map(_ => Result.Success) case Left(error) => Future.successful(Left(Result.OpsError(error))) } case Left(_) => Future.successful(Left(Result.UserNotFound)) } Future#flatMap して、Either をパターンマッチ、 Future#flatMap して、Either をパターンマッチ、 Future#map して... -
Tips: Scala の for 式は map, flatMap に展開される fa.flatMap {
a => fb.flatMap { b => fc.map { c => (a, b, c) } } } for { a <- fa b <- fb c <- fc } yield { (a, b, c) } -
for 式を”フラット”に合成するためのソリューション ❖ Free Monad や Eff など、eDSL でプログラムを組み立てる ❖
Tagless-final で F[_]: Monad の制約をかける ❖ cats.effect.IO や zio.ZIO など、いわゆる高機能な IO 型に揃える ❖ cats.data.ContT のような継続モナドに揃える ❖ 合成したい時だけ EitherT[Future, A, B] に包む -
for 式を”フラット”に合成するためのソリューション ❖ Free Monad や Eff など、eDSL でプログラムを組み立てる ❖
Tagless-final で F[_]: Monad の制約をかける ❖ cats.effect.IO や zio.ZIO など、いわゆる高機能な IO 型に揃える ❖ cats.data.ContT のような継続モナドに揃える ❖ 合成したい時だけ EitherT[Future, A, B] に包む -
EitherT とは ❖ 任意の型コンストラクタ F の作用と Either の失敗する可能性のある 計算効果を組み合わせられる MonadTransformer
➢ 型で表現するなら F[Either[A, B]] ➢ Scala、cats 特有の概念ではないことに注意 ❖ cats 版は cats.data.EitherT ➢ F[Either[A, B]] のデータ型(ラッパークラス)として実装されている -
EitherT とは ※ https://github.com/typelevel/cats/blob/v2.12.0/core/src/main/scala/cats/data/EitherT.scala より final case class EitherT[F[_], A,
B](value: F[Either[A, B]]) { // ... def map[D](f: B => D)(implicit F: Functor[F]): EitherT[F, A, D] = ... def flatMap[AA >: A, D](f: B => EitherT[F, AA, D])(implicit F: Monad[F]): EitherT[F, AA, D] = ... // ... } object EitherT extends EitherTInstances { // ... final def pure[F[_], A]: PurePartiallyApplied[F, A] = ... final def fromEither[F[_]]: FromEitherPartiallyApplied[F] = ... // ... } -
EitherT の map, flatMap をみてみる def map[D](f: B => D)(implicit
F: Functor[F]): EitherT[F, A, D] = bimap(identity, f) def bimap[C, D](fa: A => C, fb: B => D)(implicit F: Functor[F]): EitherT[F, C, D] = EitherT( F.map(value) { case Right(b) => Right(fb(b)) case Left(a) => Left(fa(a)) } ) -
EitherT の map, flatMap をみてみる def map[D](f: B => D)(implicit
F: Functor[F]): EitherT[F, A, D] = bimap(identity, f) def bimap[C, D](fa: A => C, fb: B => D)(implicit F: Functor[F]): EitherT[F, C, D] = EitherT( F.map(value) { case Right(b) => Right(fb(b)) case Left(a) => Left(fa(a)) } ) // F を Future に固定するなら def map[D](f: B => D): EitherT[Future, A, D] = EitherT( value.map { case Right(b) => Right(f(b)) case Left(a) => Left(a) // Left なら何もしない } ) -
EitherT の map, flatMap をみてみる def flatMap[AA >: A, D](f:
B => EitherT[F, AA, D])(implicit F: Monad[F]): EitherT[F, AA, D] = EitherT(F.flatMap(value) { case l @ Left(_) => F.pure(EitherUtil.rightCast(l)) case Right(b) => f(b).value }) -
EitherT の map, flatMap をみてみる def flatMap[AA >: A, D](f:
B => EitherT[F, AA, D])(implicit F: Monad[F]): EitherT[F, AA, D] = EitherT(F.flatMap(value) { case l @ Left(_) => F.pure(EitherUtil.rightCast(l)) case Right(b) => f(b).value }) // F を Future に固定するなら def flatMap[AA >: A, D](f: B => EitherT[Future, AA, D]): EitherT[Future, AA, D] = EitherT(value.flatMap { case l @ Left(_) => Future.successful(EitherUtil.rightCast(l)) case Right(b) => f(b).value }) -
EitherT の map, flatMap をみてみる def flatMap[AA >: A, D](f:
B => EitherT[F, AA, D])(implicit F: Monad[F]): EitherT[F, AA, D] = EitherT(F.flatMap(value) { case l @ Left(_) => F.pure(EitherUtil.rightCast(l)) case Right(b) => f(b).value }) // F を Future に固定するなら def flatMap[AA >: A, D](f: B => EitherT[Future, AA, D]): EitherT[Future, AA, D] = EitherT(value.flatMap { case l @ Left(_) => Future.successful(EitherUtil.rightCast(l)) case Right(b) => f(b).value }) Future#flatMap 内で例外が投げられたら catch されて Failure になる ≒ 異常系で処理が短絡 -
EitherT の map, flatMap をみてみる def flatMap[AA >: A, D](f:
B => EitherT[F, AA, D])(implicit F: Monad[F]): EitherT[F, AA, D] = EitherT(F.flatMap(value) { case l @ Left(_) => F.pure(EitherUtil.rightCast(l)) case Right(b) => f(b).value }) // F を Future に固定するなら def flatMap[AA >: A, D](f: B => EitherT[Future, AA, D]): EitherT[Future, AA, D] = EitherT(value.flatMap { case l @ Left(_) => Future.successful(EitherUtil.rightCast(l)) case Right(b) => f(b).value }) Left になったら f を評価せず 常に Left の値を返し続ける ≒ 準正常系でも処理が短絡 -
先ほどの実例っぽいコードを EitherT w/ Future で // ユーザーの表示名を変更するユースケース( EitherT with Future)
def execute(userId: UserId, newName: User.Name): Future[Result] = (for { user <- EitherT.fromOptionF( userRepository.findById(userId), Result.UserNotFound: Result ) updated <- Either.fromEither[Future]( user.rename(newName).left.map(e => Result.OpsError(e)) ) _ <- EitherT.right[Result]( userRepository.store(updated) ) _ <- EitherT.right[Result]( notificationService.run(...) ) } yield { Result.Succes(...) }).merge -
先ほどの実例っぽいコードを EitherT w/ Future で // ユーザーの表示名を変更するユースケース( EitherT with Future)
def execute(userId: UserId, newName: User.Name): Future[Result] = (for { user <- EitherT.fromOptionF( userRepository.findById(userId), Result.UserNotFound: Result ) updated <- Either.fromEither[Future]( user.rename(newName).left.map(e => Result.OpsError(e)) ) _ <- EitherT.right[Result]( userRepository.store(updated) ) _ <- EitherT.right[Result]( notificationService.run(...) ) } yield { Result.Succes(...) }).merge findById が None を返したら Result.UserNotFound (準正常系) で終了 -
先ほどの実例っぽいコードを EitherT w/ Future で // ユーザーの表示名を変更するユースケース( EitherT with Future)
def execute(userId: UserId, newName: User.Name): Future[Result] = (for { user <- EitherT.fromOptionF( userRepository.findById(userId), Result.UserNotFound: Result ) updated <- Either.fromEither[Future]( user.rename(newName).left.map(e => Result.OpsError(e)) ) _ <- EitherT.right[Result]( userRepository.store(updated) ) _ <- EitherT.right[Result]( notificationService.run(...) ) } yield { Result.Succes(...) }).merge rename が Left を返したら Result.OpsError (準正常系) で終了 -
先ほどの実例っぽいコードを EitherT w/ Future で // ユーザーの表示名を変更するユースケース( EitherT with Future)
def execute(userId: UserId, newName: User.Name): Future[Result] = (for { user <- EitherT.fromOptionF( userRepository.findById(userId), Result.UserNotFound: Result ) updated <- Either.fromEither[Future]( user.rename(newName).left.map(e => Result.OpsError(e)) ) _ <- EitherT.right[Result]( userRepository.store(updated) ) _ <- EitherT.right[Result]( notificationService.run(...) ) } yield { Result.Succes(...) }).merge 準異常系は発生しないが、 例外が投げられたら異常系で終了 -
先ほどの実例っぽいコードを EitherT w/ Future で // ユーザーの表示名を変更するユースケース( EitherT with Future)
def execute(userId: UserId, newName: User.Name): Future[Result] = (for { user <- EitherT.fromOptionF( userRepository.findById(userId), Result.UserNotFound: Result ) updated <- Either.fromEither[Future]( user.rename(newName).left.map(e => Result.OpsError(e)) ) _ <- EitherT.right[Result]( userRepository.store(updated) ) _ <- EitherT.right[Result]( notificationService.run(...) ) } yield { Result.Succes(...) }).merge 全ての計算が正常系だった場合は EitherT[Future, Result, Result] になるので merge メソッドで Future[Result] を得る -
先ほどの実例っぽいコードを EitherT w/ Future で // ユーザーの表示名を変更するユースケース( EitherT with Future)
def execute(userId: UserId, newName: User.Name): Future[Result] = (for { user <- EitherT.fromOptionF( userRepository.findById(userId), Result.UserNotFound: Result ) updated <- Either.fromEither[Future]( user.rename(newName).left.map(e => Result.OpsError(e)) ) _ <- EitherT.right[Result]( userRepository.store(updated) ) _ <- EitherT.right[Result]( notificationService.run(...) ) } yield { Result.Succes(...) }).merge \\ EitherT に包んで、 for 式でフラットに書けた! // -
型アノテーション/型パラメータの明示が必要な場面がある ❖ 型パラメータを明示する必要がある ➢ Partially-Applied Type パターンが採用さ れてるので、指定しやすくはある ❖ 型推論がうまくいかず、型アノテーションを
付与することも ➢ ケースバイケースではある for { // ... newFoo <- EitherT.fromEither[Future]( fooFactory .create(...) .toEither .left .map(Result.Error(_): Result) ) // ... } yield { ... } -
メソッドのシグネチャや返り値型に登場させない // cats への依存が発生してしまう // 合成しないなら都度 value で unwrap する必要も
def findById(userId: UserId): EitherT[Future, Result, User] = ... // 合成前提としても常に Future[Either[E, A]] は型が冗長(場合にはよる) def findById(userId: UserId): Future[Either[Result, User]] = ... // あくまで処理の結果をミニマムに表現し、必要な時だけ EitherT に包む def findById(userId: UserId): Future[Option[User]] = ... -
まとめ 👉 「Scalebase ペイメント」を Scala で開発しています 👉 Future[Either[E, A]] の表現力と利便性を共有した
👉 EitherT を用いて for 式でフラットに合成できる 👉 EitherT w/ Future はちょうどいい、よね?