catsのEitherTを使ってみる
普段Scalaを使っていると、Futureがたくさん出てくる。Futureは並行処理を簡単にしてくれる型だが、Eitherなどを使って例外をハンドリングしようと思うと結構面倒がある。普段PlayFrameworkを使っていると、DBとやりとりする層でFutureを生成したら、そのままControllerで返すまでずっとFutureのまま処理するのだが、色々やろうと思うと、mapがネストされたり、どんどん読みづらくなっていく。
何とかFutureを使いつつも、 Eitherのように柔軟にエラーハンドリングできないかと調べていたら、cats の EitherT に行き着いた。
先に参考記事をあげておく。
やりたいこと
UseCase層の実装である、Interactorで、DBでデータを取得したり、その他必要な処理をまとめ、最終的にControllerに結果として返す訳だが、失敗時のErrorを細かくハンドリングしたい。Errorを独自に細かくハンドリングしたい場合、ScalaではEitherを使うのが一般的な選択だが、DBとのやりとりの部分はFutureなので、for式やmapによる合成がごちゃつきがちだ。
Interactorではできる限りfor式一発で返せたら綺麗だ。
EitherTを使って書いてみる
EitherTを確認する。EitherTの実装を見に行ってみると、下記のようになっている。
final case class EitherT[F[_], A, B](value: F[Either[A, B]]) {
EitherTの F[_], A, B
は不変だ。※私はこれに気づかず1週間sealed traitを使った場合分け用のクラスをどうやってEitherTにコンパイルエラーにせず渡すかで悩むという無駄な時間を過ごした。それと、F[Eihter[A, B]]
を取ることも分かる。なので、EitherT
を生成するときの方法は下記だ。
val futureEitherValue: Future[Either[Error, Output]] = ... val eithertValue: EitherT[Future, Error, Output] = EitherT(futureEitherValue)
上記のように、Future[Either[A, B]]
型の値を EitherT
で囲ってあげれば良い。
次にシチュエーションを考える。多言語対応されたアプリを作成しており、言語別に能力値の詳細説明データがDBに保管されている。
- リクエストされた言語コードがDBのマスタに存在しなければ、言語コードが無いことをレスポンスとして返す(Fail)
- リクエストされた言語コードはあるが、リクエストされたキーの能力値は存在しない場合、その旨をレスポンスとして返す(Fail)
- リクエストされた言語コードと、能力値キーがともに存在した場合、能力値キーに対応するデータを返す(Success)
みたいな感じで実装する。実装の中身は置いておいて、DBと実際にやりとりする部分のInteraceを見ていく。
LanguageCodeRepository
import cats.data.EitherT import scala.concurrent.Future trait LanguageCodeRepository { def find(languageCode: LanguageCode): EitherT[Future, LanguageCodeRepositoryError, LanguageCode] } case class LanguageCodeRepositoryError(error: LanguageCodeRepositoryErrors) sealed trait LanguageCodeRepositoryErrors case object LanguageCodeNotFound extends LanguageCodeRepositoryErrors
AbilityQueryService
import cats.data.EitherT import coc.domain.models.abilities.{Ability, AbilityKey} import coc.domain.models.languages.LanguageCode import scala.concurrent.Future trait AbilityQueryService { def findAbility( abilityKey: AbilityKey, languageCode: LanguageCode ): EitherT[Future, AbilityQueryServiceError, Ability] } case class AbilityQueryServiceError(error: AbilityQueryServiceErrors) sealed trait AbilityQueryServiceErrors case object AbilityNotFoundError extends AbilityQueryServiceErrors
想定しているエラーがそれぞれ一個しか無いのであんまり良さが無いけど、とりあえず進めていく。
case class LanguageCodeRepositoryError
と sealed trait LanguageCodeRepositoryErrors
に分けて、LanguageCodeRepositoryError
の値に、エラーを格納しているのは、EitherT
の A
が不変で sealed trait
をそのまま使うと合成が上手くいかなかったから。
これを合成する。UseCaseとしては下記を定義した。
import coc.domain.models.abilities.{Ability, AbilityKey} import coc.domain.models.languages.LanguageCode import coc.domain.usecases.core.{AsyncUseCase, Input, Output} abstract class FindAbilityUseCase extends AsyncUseCase[FindAbilityInput, FindAbilityOutput, ErrorFindAbility] case class FindAbilityOutput(ability: Ability) extends Output case class ErrorFindAbility(error: FindAbilityError) sealed trait FindAbilityError case object NotFoundLanguageCode extends FindAbilityError case object NotFoundAbility extends FindAbilityError case class FindAbilityInput(languageCode: LanguageCode, abilityKey: AbilityKey) extends Input[FindAbilityOutput] object FindAbilityInput { def apply(abilityKeyStr: String, maybeLangCode: Option[String]): FindAbilityInput = { FindAbilityInput(LanguageCode(maybeLangCode), AbilityKey(abilityKeyStr)) } }
つまりは、FindAbilityError
として、NotFoundLanguageCode
と、 NotFoundAbility
が存在する。
Interactorで、上手く、DB層のエラーをハンドリングしていく。
FindAbilityInteractor
import cats.implicits.catsStdInstancesForFuture import coc.domain.application.queryServices.{AbilityNotFoundError, AbilityQueryService} import coc.domain.models.languages.{LanguageCodeNotFound, LanguageCodeRepository} import coc.domain.usecases.abilities.find._ import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class FindAbilityInteractor @Inject() ( abilityQueryService: AbilityQueryService, languageCodeRepository: LanguageCodeRepository )(implicit ec: ExecutionContext) extends FindAbilityUseCase { override def handle(input: FindAbilityInput): Future[Either[ErrorFindAbility, FindAbilityOutput]] = { (for { languageCode <- languageCodeRepository .find(input.languageCode) .leftMap { error => error.error match { case LanguageCodeNotFound => ErrorFindAbility(NotFoundLanguageCode) } } ability <- abilityQueryService.findAbility(input.abilityKey, languageCode).leftMap { error => error.error match { case AbilityNotFoundError => ErrorFindAbility(NotFoundAbility) } } } yield FindAbilityOutput(ability)).value } }
leftMap
のところとかは少し縦長で綺麗さにかけるが、やってることはパターンマッチでハンドリングのパターンが増えても修正は容易だろう。当初の目的通り、Interactorで for
文一発で書けた。