Obsługa błędów

Propozycja I - EitherT

Standardowo błedy w programach funkcyjnych chcemy reprezentować jako typy. Stąd nieadekwatne jest np rzucanie wyjątków gdyż te nie są dobrze zdefiniowane w systemie typów Scali. Typem, który się do tego dobrze nadaje jest Either.

Musimy jednak wystrzegać się "drabinek śmierci" związanych z obsługą Either w kontekście monadycznym. Dla bardziej złożonych operacji potrzebuemy transformera EitherT

Obsługa błędów

trait Request {
  def computeCondition: Condition
  def computeAnotherCondition: Condition
  def computeNewValue: Option[String]
}

trait Condition {
  def updatedField: Option[String]
}

trait DTO {
  def aField: Option[String]
  def isValid: Boolean
  def update(newField: Option[String]): DTO
}

sealed trait Result
object Result {
  case class Ok(dto: DTO)                             extends Result
  case class EvenBetter(dto: DTO, additional: String) extends Result
}

sealed trait Error
object Error {
  case object NotFound         extends Error
  case object NotValid         extends Error
  case object StarsAreNotRight extends Error
}

Obsługa błędów

trait Repository[M[_]] {
  def findSomeData(condition: Condition): M[Option[DTO]]
  def findSomeRelatedData(dto: DTO): M[Option[String]]
  def filterSomething(dto: DTO, condition: Condition): M[List[String]]
  def update(dto: DTO): M[Int]
}

trait Service[M[_]] {
  import Error._
  import Result._
  import cats.syntax.all._

  def repo: Repository[M]

  def doSth(request: Request)(implicit ev: Monad[M]): M[Either[Error, Result]] = ???

}

Chcemy użyc wszystkiego :-) Kooperacji z M[Option], robienia kilku operacji, zapewniania poprawności ...

Obsługa błędów

trait Service[M[_]] {
  import Error._
  import Result._
  import cats.syntax.all._

  def repo: Repository[M]

  def doSth(request: Request)(implicit ev: Monad[M]): M[Either[Error, Result]] =
    EitherT
      .fromOptionF(repo.findSomeData(request.computeCondition), ifNone = NotFound: Error)
      .ensure(onFailure = NotValid)(_.isValid)
      .map(_.update(request.computeNewValue))
      .flatMapF(updatedDto => repo.update(updatedDto)
                                  .map(updated => 
                                    Either.cond(updated == 1, 
                                                updatedDto, 
                                                StarsAreNotRight: Error)))
      .semiflatMap(dto => (repo.findSomeRelatedData(dto), 
                           repo.filterSomething(dto, request.computeAnotherCondition))
                          .tupled
                          .tupleLeft(dto))
      .map {
        case (dto, (Some(x), xs)) if xs.contains(x) => EvenBetter(dto, x)
        case (dto, _)                               => Ok(dto)
      }
      .value

}

Obsługa błędów

trait Service[M[_]] {
  import Error._
  import Result._
  import cats.syntax.all._

  def repo: Repository[M]

  def doSth(request: Request)(implicit ev: Monad[M]): M[Either[Error, Result]] =
    EitherT
      .fromOptionF(repo.findSomeData(request.computeCondition), ifNone = NotFound: Error)
      .ensure(onFailure = NotValid)(_.isValid)
      .map(_.update(request.computeNewValue))
      .flatMapF(updatedDto => repo.update(updatedDto)
                                  .map(updated => 
                                    Either.cond(updated == 1, 
                                                updatedDto, 
                                                StarsAreNotRight: Error)))
      .semiflatMap(dto => (repo.findSomeRelatedData(dto), 
                           repo.filterSomething(dto, request.computeAnotherCondition))
                          .tupled
                          .tupleLeft(dto))
      .map {
        case (dto, (Some(x), xs)) if xs.contains(x) => EvenBetter(dto, x)
        case (dto, _)                               => Ok(dto)
      }
      .value

}

Używamy kombinatorów. Code smell - tylko flatMap i map

Obsługa błędów

Podstawowe kombinatory EitherT z przykładowymi przypadkami użycia:

  • flatMapF - integracja z zewnętrznym kodem, który może zwrócić błąd (funkcyjnie - czyli Either)
  • semiflatMap - integracja z kodem, który nie zwraca błędu
  • subflatMap /ensure /ensureOr - do walidacji wartości
  • recover / transform - żeby część błędów zamienić na poprawne wartości
  • mapK - zmiana monady

Obsługa błędów

trait Service[M[_]] {
  import Error._
  import Result._
  import cats.syntax.all._

  def repo: Repository[M]

  def doSth(request: Request)(implicit ev: Monad[M]): M[Either[Error, Result]] =
    EitherT
      .fromOptionF(repo.findSomeData(request.computeCondition), ifNone = NotFound: Error)
      .ensure(onFailure = NotValid)(_.isValid)
      .map(_.update(request.computeNewValue))
      .flatMapF(updatedDto => repo.update(updatedDto)
                                  .map(updated => 
                                    Either.cond(updated == 1, 
                                                updatedDto, 
                                                StarsAreNotRight: Error)))
      .semiflatMap(dto => (repo.findSomeRelatedData(dto), 
                           repo.filterSomething(dto, request.computeAnotherCondition))
                          .tupled
                          .tupleLeft(dto))
      .map {
        case (dto, (Some(x), xs)) if xs.contains(x) => EvenBetter(dto, x)
        case (dto, _)                               => Ok(dto)
      }
      .value

}

Pamiętamy, że nasze M ma pewne właściwości. Jest monadą, a co za tym idzie: funktorem, aplikatywnym funktorem itd.

Obsługa błędów

Co my tam mamy? (mniej znane rzeczy)

  • mproduct (FlatMap) - jak a => M[B], ale zwracający (A, B)
  • flatTap (FlatMap) - do side effectu (wykonaj i zapomnij)
  • fproduct / tupleLeft / tupleRight (Functor) - do tupli :-)
  • mapN / tupled (Applicative) - wykonaj niezależnie (Ma, Mb, ..., Mn) i zrób coś z wynikami (potencjalnie równolegle)
  • >* / <* (Applicative) - fire'n'forget

Obsługa błędów

trait Service[M[_]] {
  import Error._
  import Result._
  import cats.syntax.all._

  def repo: Repository[M]

  def doSth(request: Request)(implicit ev: Monad[M]): M[Either[Error, Result]] =
    EitherT
      .fromOptionF(repo.findSomeData(request.computeCondition), ifNone = NotFound: Error)
      .ensure(onFailure = NotValid)(_.isValid)
      .flatMapF(dto => updateDto(dto.update(request.computeNewValue)))
      .semiflatMap(dto => inspectData(request, dto).tupleLeft(dto))
      .map {
        case (dto, (Some(x), xs)) if xs.contains(x) => EvenBetter(dto, x)
        case (dto, _)                               => Ok(dto)
      }
      .value

  private def inspectData(request: Request, dto: DTO)(implicit ev: Applicative[M]): 
    M[(Option[String], List[String])] =
    (repo.findSomeRelatedData(dto), 
     repo.filterSomething(dto, request.computeAnotherCondition)).tupled
  private def updateDto(dto: DTO)(implicit ev: Functor[M]): M[Either[Error, DTO]] =
    repo.update(dto).map(updated => Either.cond(updated == 1, dto, StarsAreNotRight: Error))
}

Obsługa błędów

Wady

  • nie pasuje do stylu for-comprehension (większość przydatnych rzeczy dzieje się poza flatMap)
  • rozdzielenie błędów od sukcesów (?)
  • trzeba annotować często i gęsto typ błędu
  • NAJWAŻNIEJSZE: TEN PRZYKŁAD SIĘ NIE KOMPILUJE BO SCALA MA FATALNY FLAW - SUBTYPING:

type mismatch;
found   : M[Either[A$A4.this.Error,Product with Serializable with A$A4.this.Result]]
required: M[Either[A$A4.this.Error,A$A4.this.Result]]
Note: Either[A$A4.this.Error,Product with Serializable with A$A4.this.Result] <: Either[A$A4.this.Error,A$A4.this.Result], but type M is invariant in type _.
You may wish to define _ as +_ instead. (SLS 4.5)

Obsługa błędów

Wady

  • komponowalność to semi-smutny joke (jak każda komponowalność oparta o subtyping w Scali). Zobaczmy!

Obsługa błędów

Komponowalność

sealed trait ComposedErrors

sealed trait Module1Errors extends ComposedErrors
sealed trait Module2Errors extends ComposedErrors

trait Service1[M[_]] {
  def doSth: M[Either[Module1Errors, Result]]
}

trait Service2[M[_]] {
  def doSth: M[Either[Module2Errors, Result]]
}


def composition[M[_]: Monad](implicit service1: Service1[M], service2: Service2[M]) =
  for {
    r1 <- EitherT(service1.doSth)
    r2 <- EitherT(service2.doSth)
  } yield (r1, r2)

Obsługa błędów

Komponowalność

def composition[M[_]: Monad](implicit service1: Service1[M], service2: Service2[M]) =
  for {
    r1 <- EitherT[M, ComposedErrors, Result](service1.doSth)
    r2 <- EitherT[M, ComposedErrors, Result](service2.doSth)
  } yield (r1, r2)

Obsługa błędów

Komponowalność

def composition[M[_]: Monad](implicit service1: Service1[M], service2: Service2[M]) =
  for {
    r1 <- EitherT(service1.doSth).leftMap(identity[ComposedErrors])
    r2 <- EitherT(service2.doSth)
  } yield (r1, r2)

Obsługa błędów

Komponowalność

def composition[M[_]: Monad](implicit service1: Service1[M], service2: Service2[M]) =
  for {
    r1 <- EitherT(service1.doSth)
    r2 <- EitherT(service2.doSth).leftMap(identity[ComposedErrors])
  } yield (r1, r2)

Obsługa błędów

Wady

  • komponowalność to semi-smutny joke - cała ta zabawa z hierarchiami typów rozbija się zawsze o jedno z poniższych:
  1. inferencer, który nie potrafi wydedukować dobrego typu (np. jaka ma być typ wychodzący z flatMap)
  2. system typów (wariancja), który nie potrafi tego objąć (M[_] jest inwariantne - można by je zrobić ko- ale wtedy tracimy na ogólności bo nie zamodelujemy np. Free)
  3. ... i tak musimy a priori zbudować wspólny nadtyp dla każdej interesującej nas kompozycji

Obsługa błędów

CAŁY TEN PROBLEM Z HIERARCHIAMI OBIEKTÓW OBESZLIŚMY STOSUJĄC FUNKCJĘ IDENTYCZNOŚCIOWĄ I LEFT MAP ERGO ZAAWANSOWANE PROBLEMY Z PROGRAMOWANIEM OBIEKTOWYM ROZWIĄZUJE PROŚCIUTKIE PROGRAMOWANIE FUNKCYJNE :-)

 

Są też inne sposoby np. koprodukty, Liskov, type-classy błędów

Obsługa błędów

Propozycja II - MonadError

MonadError generalizuje monady, które potrafią obsługiwać błędy np. instancja MonadError[M, E] istnieje dla <M = {Future, DBIO}, E = Throwable> (bo Future / DBIO potrafią obsługiwać błędy typu Throwable), <M = M', E = Either[E', ?]> (bo dowolna monada potrafi obsługiwać Either), <M = EitherT[M', E', ?], E = E'>  (patrz  poprzedni przykładu).

MonadError rozszerza Monad dokładając parę kombinatorów do pracy z błędami: recover, ensure, adapt, raiseError (ergo zmienia to styl pisania, zobaczmy przykład)

Obsługa błędów

trait Service[M[_]] {
//...

  def doSth(request: Request)(implicit ME: MonadError[M, Error]): M[Result] =
    for {
      maybeDto <- repo.findSomeData(request.computeCondition)
      dto      <- ME.fromOption(maybeDto, ifEmpty = NotFound)
                    .ensure(error = NotValid)(_.isValid)
      updatedDto = dto.update(request.computeNewValue)
      _   <- updateDto(updatedDto).rethrow
      res <- inspectData(request, updatedDto)
    } yield
      res match {
        case (Some(x), xs) if xs.contains(x) => EvenBetter(updatedDto, x)
        case _                               => Ok(updatedDto)
      }

//...
}

Obsługa błędów

trait Service[M[_]] {
//...

  def doSth(request: Request)(implicit ME: MonadError[M, Error]): M[Result] =
    for {
      maybeDto <- repo.findSomeData(request.computeCondition)
      dto      <- ME.fromOption(maybeDto, ifEmpty = NotFound)
                    .ensure(error = NotValid)(_.isValid)
      updatedDto = dto.update(request.computeNewValue)
      _   <- updateDto(updatedDto).rethrow
      res <- inspectData(request, updatedDto)
    } yield
      res match {
        case (Some(x), xs) if xs.contains(x) => EvenBetter(updatedDto, x)
        case _                               => Ok(updatedDto)
      }

//...
}

Typ błędu występuje już explicite w MonadError. Tutaj jest implicite

Podobne kombinatory

Obsługa błędów

Wady

  • nie pasuje do stylu for-comprehension (większość przydatnych rzeczy dzieje się poza flatMap)
  • rozdzielenie błędów od sukcesów (?)
  • trzeba annotować często i gęsto typ błędu jest explicite w MonadError
  • NAJWAŻNIEJSZE: TEN PRZYKŁAD SIĘ NIE KOMPILUJE BO SCALA MA FATALNY FLAW - SUBTYPING: Teraz mamy nowy problem tj. MonadError[M, E1 | E2] nie implikuje istnienia MonadError[M, E{1,2}] tzn. nie możemy komponować ME ze względu na typ błędu

 

Obsługa błędów

Nowe wady

  • zasadna krytyka MonadError przez Lukę Jakobowica w artykule "Rethinking MonadError" - warto się zapoznać

  • inwazyjna technika (wszystkie metody zwracające M[X] nagle zaczynają zwracać np. EitherT[M, E, X])

  • problemy z bindingiem (zazwyczaj musimy stosować jakieś higher-order funktory typu mapK)

Obsługa błędów

Propozycja III - XD

Założenia, spostrzeżenia, oczekiwania

  • Metody serwisu tworzą zamkniętą całość
  • Typ zwracanej wartości precyzyjnie określa to co może się stać (nie ma współdzielenia hierarchii) - ADT
  • Nie ma (sztucznego) rozróżnienia na sukces i błąd
  • Obliczenia wewnątrz metod tworzą drzewiastą strukturę (drzewo decyzyjne)
  • for-comprehension jest git :)
  • Obsługa błędów jest lokalna dla metody (refaktoryzacja, optymalizacja)

Obsługa błędów

object dto {
  case class EmailPasswordLoginRequest(email: String, password: String)

  sealed trait LoginResponse
  object LoginResponse {
    final case class LoggedIn(token: String)                     extends LoginResponse
    final case class AccountNeedsConfirmation(weakToken: String) extends LoginResponse
    case object UserNotFound                                     extends LoginResponse
    case object AuthMethodFailure                                extends LoginResponse
    case object InvalidCredentials                               extends LoginResponse
    case object Deleted                                          extends LoginResponse
  }
}

class AuthService[M[_]: Monad](userRepository: UserRepository[M],
                               authMethodRepository: AuthMethodRepository[M],
                               jwtService: JwtService) {
  def login(request: EmailPasswordLoginRequest): M[LoginResponse] = ???
}

DYSKUSJA - dobrze czy źle?

Obsługa błędów

Obsługa błędów

def login(request: EmailPasswordLoginRequest): M[LoginResponse] = {
  (for {
    user <- userRepository.find(request.email)
      .valueOr(LoginResponse.UserNotFound: LoginResponse)
      .ensure(_.archived, LoginResponse.Deleted)
      .inspect {
        case Left(LoginResponse.UserNotFound) => println(s"DEBUG: User ${request.email} not found")
        case Left(LoginResponse.Deleted) => println(s"WARNING: User ${request.email} is deleted")
      }
    authMethod <- authMethodRepository.find(user.id, Provider.EmailPass)
      .valueOr(LoginResponse.AuthMethodFailure: LoginResponse)
      .ensureOr(_.confirmed, _ => notifyAccountNeedsConfirming(user))
  } yield {
    if (verifyPassword(authMethod, request.password))
      LoginResponse.LoggedIn(jwtService.issueToken(user))
    else
      LoginResponse.InvalidCredentials
  }).run
}

Obsługa błędów

XD ~ EitherT[F, A, ADT]; DSL dla EitherT

run ~ merge  EitherT[F, ADT, ADT]

Zalety

  • możliwe do zaimplementowania ad-hoc w metodzie (tagless, Future) - zobowiązanie "krótkoterminowe" :)
  • praktycznie dowolna hierarchia błędów/sukcesów
  • zwięzłe, czytelne (for-comprehension)

Wady

  • annotacje typów
  • wydajność (?)
  • komponowalność
  • NIH (?)                                                                                     

Obsługa błędów: EitherT, MonadError czy XD?

By Marcin Rzeźnicki

Obsługa błędów: EitherT, MonadError czy XD?

  • 1,044