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:
- inferencer, który nie potrafi wydedukować dobrego typu (np. jaka ma być typ wychodzący z flatMap)
- 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)
- ... 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łędujest explicite w MonadError -
NAJWAŻNIEJSZE: TEN PRZYKŁAD SIĘ NIE KOMPILUJEBO 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,148