Pracując ze skomplikowaną domeną biznesową często koniecznością staje się przeprowadzenie złożonego procesu walidacji danych. W tym artykule pokażę, jak rozwiązać problem z walidacją danych na zagnieżdżonych monadach, kiedy ilość kodu nadmiernie rośnie i trudno wyizolować odpowiednie poziomy abstrakcji. Proponowane rozwiązanie – 38 linijek kodu, które równie dobrze mogą być mikro-biblioteką, jest jednocześnie dobrym przykładem działania różnych wzorców programowania funkcyjnego: monad transformers, type classes i tagless final.
Jakub Dzikowski. Full stack developer w SoftwareMill, z ponad dziesięcioletnim doświadczeniem. Obecnie pracuje głównie w językach Scala i JavaScript. Dużą wagę przywiązuje do miękkich aspektów procesu wytwarzania oprogramowania. Autor blogów na dzikowski.github.io, oraz artykułów publikowanych na Medium.
Spis treści
Problem
Załóżmy, że pracujemy z kodem aplikacji zbudowanej na podstawie standardowej architektury, z repozytoriami (repositories) i usługami (services). Chcemy zapisać użytkownika (obiekt klasyUser
).
1 2 |
def saveUser(name: String, age: Int, country: String): Future[User] = repository.putUser(User(name, age, country)) |
Na razie implementacja jest stosunkowo prosta. Na potrzeby przykładu użyłem scalowego typuFuture
. Są oczywiście typy monadyczne, które mogą się sprawdzić lepiej, aleFuture
jest prawdopodobnie znane przez wszystkich, którzy programują w Scali i świetnie spełnia potrzeby przykładu.
Nagle pojawia się nowe wymaganie biznesowe. Powinniśmy mieć możliwość zapisania użytkownika tylko wtedy, gdy nie ma jeszcze użytkownika o takiej samej nazwie. Musimy wprowadzić walidację i obsługę błędów. Kod ewoluuje:
1 2 3 4 5 |
def saveUser(name: String, age: Int, country: String): Future[Either[ValidationError, User]] = repository.findUser(name).flatMap { case Some(_) => Future.successful(Left(UserAlreadyExists(name))) case None => repository.putUser(User(name, age, country)).map(u => Right(u)) } |
Dobrą praktyką jest, by wyjątki zarezerwowane były rzeczywiście dla wyjątkowych sytuacji. Dlatego w Scali raczej nie wyrzuca się wyjątków, tylko korzysta z monadyEither
lubTry
(ja preferuję tę pierwszą). Dodatkowo, na potrzeby przykładu stworzony został jeszcze sealed trait ValidationError
, a także odpowiednie klasy, reprezentujące poszczególne rodzaje błędów walidacji (na przykład UserAlreadyExists
).
Okazuje się jednak, że to ciągle nie wszystkie wymagania biznesowe. Aplikacja musi przeprowadzać walidację na podstawie wieku użytkownika. Klient ma oddzielną usługę, która sprawdza, czy wiek użytkownika jest odpowiedni dla danego państwa. Dodatkowo, nazwa użytkownika musi składać się przynajmniej z dwóch znaków.
Poniżej podaję przykład zmodyfikowanej metodysaveUser
,uwzględniającej wszystkie te wymagania:
1 2 3 4 5 6 7 8 9 10 11 12 |
def saveUser(name: String, age: Int, country: String): Future[Either[ValidationError, User]] = if (name.length >= 2) repository.findUser(name).flatMap { case Some(_) => Future.successful(Left(UserAlreadyExists(name))) case None => ageService.isAgeValid(age, country).flatMap { case false => Future.successful(Left(InvalidAge(age, country))) case true => repository.putUser(User(name, age, country)).map(u => Right(u)) } } else Future.successful(Left(InvalidName(name))) |
Takiego rodzaju kod bardzo trudno się analizuje, rozszerza i testuje. Tak naprawdę wymieszane są tutaj różne warstwy abstrakcji: reguły walidacji, odwołania do innych usług i bazy danych. Brakuje odpowiedniego rozdzielenia odpowiedzialności.
Możemy próbować jakoś ten kod zrefaktoryzować. Na przykład zaimplementować metody pomocnicze, które ukryją część niskopoziomowej logiki. Nawet jeśli po tym kod będzie wyglądać nieco lepiej, nie będzie wcale prostszy. Nie uda się uniknąć zagnieżdżania logiki walidacji.
- Najpierw konieczne jest zwalidowanie długości nazwy użytkownika. Nie ma potrzeby, by odwoływać się do bazy danych, jeśli ta nazwa jest za krótka. Tym samym nie uda się uniknąć jakiejś formy instrukcji warunkowej widocznej na przykładzie.
- Nie ma potrzeby odwoływać się do usługi weryfikującej wiek, jeśli użytkownik o takiej samej nazwie już istnieje. I tak już wystąpił błąd walidacji.
Kiedy pojawią się kolejne wymagania biznesowe, trzeba będzie poświęcić mnóstwo czasu na analizę aktualnej logiki walidacji, a kod, który powstanie, będzie najprawdopodobniej jeszcze bardziej skomplikowany. Aby uprościć ten kod, nie wystarczy standardowy refaktoring — należy dokonać przeskoku i sięgnąć do odpowiednich wzorców projektowych programowania funkcyjnego.
Zagnieżdżone monady
Walidacja w przedstawionym przykładzie przeprowadzana jest sekwencyjnie. Jest tylko jedna ścieżka:
- Jeśli nazwa użytkownika jest poprawna, upewnij się, że użytkownik o takiej nazwie jeszcze nie istnieje.
- Jeśli użytkownik nie istnieje, sprawdź, czy wiek jest poprawny.
- Jeśli wiek jest poprawny, zapisz użytkownika w bazie.
Czy dałoby się tak wykorzystać monadę Either
, żeby przeprowadzić walidację jako sekwencję kroków?Either
jako monada umożliwia wykonywanie akcji wewnątrzRight
, będącego odpowiednikiem poprawnego rezultatu.
Niestety, nie jest to możliwe. Konieczne jest uwzględnienie kolejnego poziomu monad, ponieważ w rzeczywistości mamy do czynienia z typemFuture[Either[F, S]]
. (Future
tak naprawdę jest tylko monadą w pewnych okolicznościach — w przypadku leniwej ewaluacji – jednak w wielu przypadkach zachowuje się jak monada).
Wykorzystanie zagnieżdżonych monad (w tym wypadku Future[Either[F, S]]
) jako zwracanego typu dla każdego kroku walidacji to krok w dobrym kierunku. Co jednak jest problematyczne, aby przeprowadzić walidację, należy każdorazowo przeprowadzić operację wewnątrz obu monad, na najniższym poziomie. Aby to zrobić, potrzeba sporo dodatkowego kodu i funkcji pomocniczych. Najlepsze, co udało mi się wypracować, można znaleźć w plikuUserServiceBetterLegacy.scala
w repozytorium z przykładami dla tego artykułu. Takiemu rozwiązaniu jednak daleko do tego, co można osiągnąć przez wprowadzenie nowej abstrakcji.
W tym miejscu dobrze by było sformalizować problem, który się tutaj pojawia. Konieczne jest przeprowadzenie operacji na zagnieżdżonych monadach jednocześnie, co wymaga znacznej ilości mało czytelnego kodu. Ponieważ jest to dość ogólny problem, więc:
- wydaje się dobrym pomysłem, by wydzielić nową abstrakcję, by go rozwiązać (funkcję, obiekt, bibliotekę itp.),
- prawdopodobnie społeczność skoncentrowana wokół Scali już sobie z tym w jakiś sposób poradziła i istnieje rozwiązanie (tak, istnieje, ale o tym za chwilę).
Najlepiej by było, gdyby zagnieżdżone monady zachowywały się tak samo jak pojedyncza monada.
Rozwiązanie
Zanim przejdę do wspomnianych w tytule 38 linijek kodu, chciałbym pokazać, jak wygląda metodasaveUser
po ich implementacji:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
def saveUser(name: String, age: Int, country: String): Future[Either[ValidationError, User]] = { val validationResult = for { _ <- validateIfUserDoesNotExist(name) _ <- validateName(name) _ <- validateAge(age, country) } yield () validationResult.onSuccess(repository.putUser(User(name, age, country))) } private def validateIfUserDoesNotExist(name: String): ValidationResult[ValidationError, Unit] = { val userDoesNotExist = repository.findUser(name).map(_.isEmpty) ValidationResult.ensureF(userDoesNotExist, onFailure = UserAlreadyExists(name)) } private def validateName(name: String): ValidationResult[ValidationError, Unit] = ValidationResult.ensure(name.length > 2, onFailure = InvalidName(name)) private def validateAge(age: Int, country: String): ValidationResult[ValidationError, Unit] = { val isAgeValid = ageService.isAgeValid(age, country) ValidationResult.ensureF(isAgeValid, onFailure = InvalidAge(age, country)) } |
Dzięki zastosowanemu rozwiązaniu:
- Mamy jasne rozdzielenie odpowiedzialności pomiędzy poszczególnymi krokami walidacji.
- Walidacja jest przeprowadzana jako sekwencja kroków (łatwo analizować kod).
- Każdy krok walidacji znajduje się w osobnej funkcji (zasada jednej odpowiedzialności).
Metody pomocnicze zwracają typ ValidationResult
, który zawiera zagnieżdżone monady:Either
iFuture
. Sam typValidationResult
również zachowuje się jak monada, dlatego w celu stworzenia finalnego rezultatu walidacji, możemy go wykorzystać bezpośrednio w blokufor
. Tego rodzaju typ, który reprezentuje zagnieżdżenie dwóch typów monadycznych, to tzw. monad transformer.
Na obiekcie typuValidationResult
można wywołać metodęonSuccess
, która odpowiada za przeprowadzenie pożądanej operacji, jeśli walidacja się powiodła. W tym wypadku jest to zapis użytkownika.
38 linijek kodu w Scali
To trochę dużo jak na listing, ale zależało mi, żeby wszystko pokazać w jednym miejscu. Poniżej znajduje się całość zaproponowanej przeze mnie mikro-biblioteki, ułatwiającej walidację danych w Scali. Wykorzystuje onaEitherT
, monad transformer z biblioteki Cats i nie jest związana z konkretnym typem monady (w przykładzie został użytyFuture
, ale wykorzystywałem teżDBIOAction
pochodzące z biblioteki Slick).
Zachęcam do użycia tego kodu we własnych projektach i zaadoptowania go do własnych potrzeb.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
package com.softwaremill.monadvalidation.lib import cats.Monad import cats.data.EitherT import scala.language.higherKinds trait ValidationResultLib[M[_]] { type ValidationResult[F, S] = EitherT[M, F, S] object ValidationResult { def successful[F, S](s: S)(implicit m: Monad[M]): ValidationResult[F, S] = EitherT.rightT(s) def failed[F, S](f: F)(implicit m: Monad[M]): ValidationResult[F, S] = EitherT.leftT(f) def ensure[F](c: => Boolean, onFailure: => F)(implicit m: Monad[M]): ValidationResult[F, Unit] = EitherT.cond[M](c, Unit, onFailure) def ensureM[F](c: => M[Boolean], onFailure: => F)(implicit m: Monad[M]): ValidationResult[F, Unit] = EitherT.right(c).ensure(onFailure)(b => b).map(_ => Unit) def fromOptionM[F, S](opt: M[Option[S]], ifNone: => F)(implicit m: Monad[M]): ValidationResult[F, S] = EitherT.fromOptionF(opt, ifNone) } implicit class ValidationResultOps[F, S](vr: ValidationResult[F, S]) { def onSuccess[S2](s2: => M[S2])(implicit m: Monad[M]): M[Either[F, S2]] = vr.onSuccess(_ => s2) def onSuccess[S2](fn: S => M[S2])(implicit m: Monad[M]): M[Either[F, S2]] = vr.semiflatMap(fn).value } } |
Taki kod stosowałem całkiem intensywnie w ostatnim projekcie, składającym się z ponad 60 tys. linijek kodu scalowego. Różniły się trochę sygnatury, mieliśmy kilka dodatkowych funkcji, jednak tak naprawdę sposób działania był taki sam. Po prostu kod powyżej został okrojony dla potrzeb przykładowej metody saveUser.
W przypadku wykorzystania zaproponowanych 38 linijek kodu, prędzej czy później pojawi się konieczność dostosowania ich do konkretnych potrzeb projektu. Dlatego nie warto publikować ich jako osobnej biblioteki.
Aby wykorzystać ValidationResultLib
, wystarczy stworzyć obiekt go rozszerzający (np. o nazwie Validation
), który dostarczy implementację domniemanego parametru typu Monad[M]
, dla pożądanego typu monady. Następnie należy zaimportowaćValidation._
i odpowiednie funkcjonalności staną się dostępne.
Na potrzeby przykładu, przygotowałem obiekt FuturesValidation
:
1 2 3 4 5 6 |
object FuturesValidation extends ValidationResultLib[Future] { import cats.Monad import cats.instances.future.catsStdInstancesForFuture implicit def monad(implicit ec: ExecutionContext): Monad[Future] = catsStdInstancesForFuture } |
Jak widać w przykładzie, typ Monad
pochodzi z biblioteki Cats. Co więcej, Cats dostarcza także domyślną instancję Monad[Future]
, dlatego implementacje tego przypadku jest bardzo prosta.
W projekcie, o którym wspominałem, walidacja przeprowadzana była na monadach pochodzących ze Slicka:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
package object validation extends ValidationResultLib[DBRead] { implicit def monad(implicit ec: ExecutionContext): Monad[DBRead] = new Monad[DBRead] { override def pure[A](x: A): DBRead[A] = DBIOAction.successful(x) override def flatMap[A, B](fa: DBRead[A])(f: A => DBRead[B]): DBRead[B] = fa.flatMap(f) override def tailRecM[A, B](a: A)(f: A => DBRead[Either[A, B]]): DBRead[B] = f(a).flatMap { case Right(b) => pure(b) case Left(a1) => tailRecM(a1)(f) } } } |
W tym wypadku DBRead
to alias na slickowe DBIOAction[_
, NoStream
,Read]
. Dzięki temu same typy gwarantują, że walidacja operuje jedynie na odczytach z bazy danych.
W tym wypadku również konieczne jest zaimplementowanie własnego domniemanego parametru na Monad[M, ponieważ użyty został inny niż obsługiwany przez Cats typ monady. Jest on konieczny, by przeprowadzać operacja na monadach ValidationResult
iEitherT
. Tak zresztą zbudowana jest biblioteka Cats — wymaga domniemanych parametrów, gwarantujących, że określone operacje są możliwe do przeprowadzenia na danym typie.