LoginSignup
11
7

More than 3 years have passed since last update.

ZIOのエラー・モデルとエラー処理

Last updated at Posted at 2019-06-30

はじめに

この記事ではZIOのエラー・モデルとエラー処理について紹介します。

エラー・モデル

ZIOは実行の失敗(=エラー)をCause[E]という代数的データ型で表現します。Cause[E]の型パラメータEZIO[R, E, A]の2番目の型パラメータEと同じ型でアプリケーション・ロジックに関する失敗を表現する型です。Cause[E]は概念的にはアプリケーション内の失敗Eとアプリケーション外の失敗Throwableの直和型です。

例えばユーザ管理を行っているサービスがあります。指定したユーザuserIdのアイコンを更新するロジックupdateUserIconは以下のように記述できます。指定されたユーザが見つからずアイコンの更新が失敗する可能性をロジックのシグネチャでZIO[R, E, A]EUserNotFound.type型を指定して表現します。対応するCause[E]Cause[UserNotFound.type]になります。”ユーザが見つからない”といったアプリケーション上の制約以外にもOut of Memory、スレッドの中断などが原因でupdateUserIconは失敗する可能性があります。

final case class UserId()
final case class Icon()

object UserNotFound

object UserService {
  def updateUserIcon(userId: UserId, icon: Icon): scalaz.zio.ZIO[Any, UserNotFound.type, Unit] = ???
}

エラーへのアクセス&エラー処理

エラーにアクセスして処理する方法を紹介します。

アプリケーション・エラー(E)

ZIO#foldメソッドを使用するとアプリケーション・ロジックの結果(ZIO[R, E, A]R)とアプリケーション・ロジックの失敗(ZIO[R, E, A]E)に同時にアクセスすることができます。

先ほどのUserServiceを利用してREST APIのエンドポイントを提供するUserControllerサービスを例とします。UserControllerUserServiceの結果、またはエラーをレスポンスに変換してクライアントに返します。UserController#endpointForUpdatingUserIconのシグネチャZIO[Any, Nothing, Response]でエラー情報の部分がNothingになりました。これは失敗UserNotFound.typeをアプリケーション上処理して、結果Responseに変換したためです。UserControllerのクライアントは存在しないユーザのアイコンを更新しようとしても処理は失敗せず、処理の結果Responseを受け取ることができます。

final case class UserId()
final case class Icon()

object UserNotFound

object UserService {
  def updateUserIcon(userId: UserId, icon: Icon): scalaz.zio.ZIO[Any, UserNotFound.type, Unit] = ???
}

final case class Request(userId: UserId, icon: Icon)
final case class Response()

object UserController {
  def errorToResponse(notFound: UserNotFound.type): Response = ???
  def successToResponse(unit: Unit): Response = ???

  def endpointForUpdatingUserIcon(request: Request): scalaz.zio.ZIO[Any, Nothing, Response] = for {
    response <- UserService.updateUserIcon(request.userId, request.icon)
      .fold(
        errorToResponse,
        successToResponse
      )
  } yield response
}

ZIO.mapErrorを使用するとエラーにのみアクセスすることができます。Effective Javaで紹介されているerror translationなどのイディオムを実装するときに便利です。

前回までの例と同じ機能をClean Architectureで実装する例を考えます。Clean Architectureではインフラ側の失敗(Throwable)をラップしてアプリケーションの失敗として扱います。ユースケースのロジックUpdateUserIconUseCase#executeZIO[IUserRepository, UseCaseError, Unit]を返しています。アプリケーション・レベルの失敗(UserNotFound)とインフラの失敗(Throwable)をerror translation(mapError(InfraError))によって同じアプリケーション・レベルの失敗(UseCaseError)として扱います。

final case class UserId()
final case class Icon()
final case class User() {
  def update(icon: Icon): User = ???
}

trait IUserRepository {
  def find(id: UserId): scalaz.zio.ZIO[Any, Throwable, Option[User]]
  def store(user: User): scalaz.zio.ZIO[Any, Throwable, Unit]
}

sealed trait UseCaseError
final case class InfraError(cause: Throwable) extends UseCaseError
case object UserNotFound extends UseCaseError

object UpdateUserIconUseCase {
  def execute(id: UserId, icon: Icon): scalaz.zio.ZIO[IUserRepository, UseCaseError, Unit] = for {
    repository <- scalaz.zio.ZIO.environment[IUserRepository]
    maybeUser <- repository.find(id).mapError(InfraError)
    _ <- maybeUser match {
      case Some(user) =>
        repository.store(user).mapError(InfraError)
      case None =>
        scalaz.zio.ZIO.fail(UserNotFound)
    }
  } yield ()
}

アプリケーション外のエラー(Throwable)

アプリケーション外の失敗はロジックの型情報(ZIO[R, E, A])には現れません。アクセスするにはをアプリケーション・ロジックの結果(ZIO[R, E, A]A)やアプリケーション・ロジックの失敗(ZIO[R, E, A]E)として取り出す必要があります。

ZIO#sandboxメソッドを利用するとアプリケーション外の失敗情報へアクセスできるようになります。型ZIO[R, E, A]のロジックに対してsandboxを呼び出すとロジックの型はZIO[R, Cause[E], A]になります。前述のとおりCause[E]がアプリケーションの失敗(Failure[E])とアプリケーション外の失敗Throwableを含む代数的データ型です。

最初のUserServiceの例でSandbox化の前後で型を比べてみます。ZIO[Any, UserNotFound.type, Unit]のロジックをSandbox化するとZIO[Any, Exit.Cause[UserNotFound.type], Unit]になります。

import scalaz.zio.{Exit, ZIO}

final case class UserId()
final case class Icon()

object UserNotFound

object UserService {
  def updateUserIcon(userId: UserId, icon: Icon)         : scalaz.zio.ZIO[Any, UserNotFound.type                       , Unit] = ???
  def updateUserIconSandboxed(userId: UserId, icon: Icon): scalaz.zio.ZIO[Any, scalaz.zio.Exit.Cause[UserNotFound.type], Unit] 
    = updateUserIcon(userId, icon)
      .sandbox
}

Sandbox化でCause[E]を取り出した後は前述のfoldmapErrorで扱うことができます。先ほどと同じControllerの例でアプリケーション外の失敗もResponseで返すように修正します。Cause[E]#failureOrCauseメソッドでEither型に変換することができます。Leftがアプリケーション内の失敗ERightがアプリケーション外の失敗Cause[Nothing]です。

RightThrowableではなくCause[Nothing]であるのは、Cause[E]は複数の失敗を保持できるように設計されているためです。複数の失敗情報のうち"最も重要な失敗"をsquashで取得します。squashの実装では"アプリケーション内の失敗E > InterruptedException > その他のThrowable"の順に重要度が定義されています。例えば、EInterruptedExceptionの両方が発生した場合、squashの結果はEになります。

final case class UserId()
final case class Icon()

object UserNotFound

object UserService {
  def updateUserIcon(userId: UserId, icon: Icon): scalaz.zio.ZIO[Any, UserNotFound.type, Unit] = ???
}

final case class Request(userId: UserId, icon: Icon)
final case class Response()

object UserController {
  def throwableToResponse(th: Throwable): Response = ???
  def errorToResponse(notFound: UserNotFound.type): Response = ???
  def successToResponse(unit: Unit): Response = ???

  def endpointForUpdatingUserIcon(request: Request): scalaz.zio.ZIO[Any, Nothing, Response] = for {
    response <- UserService.updateUserIcon(request.userId, request.icon)
      .sandbox
      .fold(c =>
        c.failureOrCause match {
          case Left(value)  => // value: UserNotFound.type
            errorToResponse(value)
          case Right(value) => // value: Exit.Cause[Nothing]
            throwableToResponse(value.squash)
        },
        successToResponse
      )
  } yield response
}

ベストプラクティス(エラー型の定義方法)

Error Management: Future vs ZIO:スライドで紹介されているZIOのエラー処理のベストプラクティスのうちの1つ紹介します。

エラー型を定義するときはExceptionを継承したseald traitを使用するようにしましょう。ZIO[R, E, A]E型に対してcovariantで設計されているため複数のエラーを共通の型へ自動的に拡張してくれます。

以下サービスごとにエラーを2系統(UserServiceErrorNotificationServiceError)定義したケースです。2つのサービスを利用したlogicでは共通のApplicationErrorに拡張されます。このエラー拡張ではNothingも意図通りに動作します。ログはアプリケーションのロジックに影響を与えるべきではないためLoggineService#logは"アプリケーションレベルでは失敗しません"(E = Nothing)。ロジック中にログを取得しても失敗の型はApplicationErrorです。

このようにエラー型の拡張は自動で行われるためエラーは不必要に抽象的な型を返さないようにしましょう。例えばUserServiceApplicationErrorを返したり、LoggingServiceApplicationErrorを返すことはやめましょう。

sealed trait ApplicationError extends Exception
sealed trait UserServiceError extends ApplicationError
case object UserNotFound extends UserServiceError
sealed trait NotificationServiceError extends ApplicationError
case object TemporaryUnavailable extends NotificationServiceError

final case class UserId()
final case class User(email: Email)
final case class Email()

object UserService {
  def getUserInfo(userId: UserId): scalaz.zio.ZIO[Any, UserNotFound.type, User] = ???
}

object NotificationService {
  def sendEmail(email: Email): scalaz.zio.ZIO[Any, TemporaryUnavailable.type, Unit] = ???
}

object LoggingService {
  def log(msg: String): scalaz.zio.ZIO[Any, Nothing, Unit] = ???
}

object Application{
  val logic: scalaz.zio.ZIO[Any, ApplicationError, Unit] = for {
    u <- UserService.getUserInfo(UserId())
    _ <- NotificationService.sendEmail(u.email)
    _ <- LoggingService.log("successful!")
  } yield ()
}

Clean Architecture再訪

最後にZIOのエラー・モデルを利用してClean Architectureのエラー・モデルを単純化する方法を見てみたいと思います。前述のとおりCause[E]は実質的にはアプリケーション内の失敗Eとアプリケーション外の失敗Throwableの直和でした。アプリケーション外の失敗をEでラップすることなくCause[E]で表現することができます。

先ほどのClean Architectureのコードからインフラ側の失敗をラップするInfraErrorを削除します。error translation(mapError)をしていた箇所でZIO#orDieを呼び出します。ZIO#orDieはアプリケーション内の失敗からアプリケーション外の失敗へと変換します。ZIO[R, E, A]に対してorDieを呼び出すとZIO[R, Nothing, A]という型になります。重要なことはエラー情報を伝えるチャネルがEからCause[E]に変わるだけでエラー情報は失われないということです。InfraErrorを削除する前のコードと削除した後のコードは等価です。さらに等価の変換を推し進めてIUserRepository#findIUserRepository#storeの型をそれぞれZIO[Any, Nothing, Option[User]]ZIO[Any, Nothing, Unit]に変更すればorDieの呼び出しも不要になります。

final case class UserId()
final case class Icon()
final case class User() {
  def update(icon: Icon): User = ???
}

trait IUserRepository {
  def find(id: UserId): scalaz.zio.ZIO[Any, Throwable, Option[User]]
  def store(user: User): scalaz.zio.ZIO[Any, Throwable, Unit]
}

sealed trait UseCaseError
case object UserNotFound extends UseCaseError

object UpdateUserIconUseCase {
  def execute(id: UserId, icon: Icon): scalaz.zio.ZIO[IUserRepository, UseCaseError, Unit] = for {
    repository <- scalaz.zio.ZIO.environment[IUserRepository]
    maybeUser <- repository.find(id).orDie
    _ <- maybeUser match {
      case Some(user) =>
        repository.store(user).orDie
      case None =>
        scalaz.zio.ZIO.fail(UserNotFound)
    }
  } yield ()
}

最後に

この記事ではZIOではエラー・モデル、エラー情報へのアクセス方法、ベストプラクティスを紹介しました。

ZIOでは失敗を大きく2つに分類します。アプリケーション内の失敗とアプリケーション外の失敗です。アプリケーション内の失敗は副作用ZIO[R, E, A]Eという型で表現されます。アプリケーション外の失敗はThrowableで表現され副作用の型にはでてきません。またCause[E]という型でアプリケーション内と外の失敗の直和を表現します。

アプリケーション内の失敗EにアクセスするにはfoldmapErrorを使用します。

アプリケーション外の失敗ThrowableにアクセスするにはsandboxCause[E]を取得する必要があります。

ZIOのエラーの仕組みを最大限に活用するには、エラーをExceptionから派生させたsealed trait(直和型)で表現しましょう。ロジックで必要な最低限のエラーを返すようにするとcovariantを活かして合成が楽になります。

またこの記事では紹介しませんでしたが、ZIOには強力なトレース機能が備わっています。興味のある人にはError Management: Future vs ZIO:動画をお勧めします。

参考

11
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
7