catsのEitherTを使ってみる

普段Scalaを使っていると、Futureがたくさん出てくる。Futureは並行処理を簡単にしてくれる型だが、Eitherなどを使って例外をハンドリングしようと思うと結構面倒がある。普段PlayFrameworkを使っていると、DBとやりとりする層でFutureを生成したら、そのままControllerで返すまでずっとFutureのまま処理するのだが、色々やろうと思うと、mapがネストされたり、どんどん読みづらくなっていく。

何とかFutureを使いつつも、 Eitherのように柔軟にエラーハンドリングできないかと調べていたら、catsEitherT に行き着いた。

先に参考記事をあげておく。

やりたいこと

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に保管されている。

  1. リクエストされた言語コードがDBのマスタに存在しなければ、言語コードが無いことをレスポンスとして返す(Fail)
  2. リクエストされた言語コードはあるが、リクエストされたキーの能力値は存在しない場合、その旨をレスポンスとして返す(Fail)
  3. リクエストされた言語コードと、能力値キーがともに存在した場合、能力値キーに対応するデータを返す(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 LanguageCodeRepositoryErrorsealed trait LanguageCodeRepositoryErrors に分けて、LanguageCodeRepositoryError の値に、エラーを格納しているのは、EitherTA が不変で 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 文一発で書けた。