38 linii kodu ułatwiających walidację danych w Scali

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 na Medium.


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).

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, aleFuturejest 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:

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:

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:

  1. Jeśli nazwa użytkownika jest poprawna, upewnij się, że użytkownik o takiej nazwie jeszcze nie istnieje.
  2. Jeśli użytkownik nie istnieje, sprawdź, czy wiek jest poprawny.
  3. 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:

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

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.

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:

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:

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.

EitherT

ValidationResult jest głównie aliasem na monad transformer EitherT. Są jednak dwie główne zalety, dlaczego lepiej korzystać z tego aliasu, a nie z samegoEitherT:

  • Język jest bardziej konkretny — sama nazwa typu informuje o tym, że służy do obsługi walidacji.
  • Ukryty jest typ zagnieżdżonej monady (np. Future).EitherTprzyjmuje trzy parametry typów: monadę, lewą stronę i prawą stronę. Typ monady dla ValidationResult jest podany w obiekcie rozszerzającymValidationResultLib i nie trzeba go nigdzie podawać ponownie.

Biblioteka Cats dostarcza wiele użytecznych funkcji dlaEitherT, obsługującychEither zagnieżdżone w innej monadzie (czyli M[Either], np. Future[Either},EDBIO[Either]itp.). Większość z nich wymaga domniemanego parametru typu Functor[M]Applicative[M], czy Monad[M], w zależności od tego, jakie operacje mają być przeprowadzane na M. W implementacji ValidationResultLib należy podać instancję monady, gdyż ona wyczerpuje możliwości także pozostałych dwóch typów.

RozszerzenieMonad[M]wymaga implementacji trzech funkcji:pure,flatMap itailRecM. Pierwsze dwie są wystarczające, by zdefiniować monadę. Z kolei tailRecM jest wymagane przezCats, co wynika z budowy biblioteki, a nie z samej definicji monady (opisano to w dokumentacji). Istnieją także inne zestawy funkcji wystarczających do zdefiniowania monady, ale nie chciałbym tu wchodzić w szczegóły. Zachęcam do zapoznania się z rozdziałem 11 z książki Functional Programming in Scala Paula Chiusano, albo np. artykułu dostępnego tutaj.

Warto dodać, że przekazywanie domniemanych parametrów, które gwarantują możliwość wykonania określonych operacji na danym obiekcie to znany wzorzec projektowy w programowaniu funkcyjnym — type classes.

Type classes

Type classes to sposób implementacji polimorfizmu ad-hoc — dodawanie zachowania różnym obiektom, które pasują do określonego interfejsu. Type classes składają się z trzech elementów (por. Functional Programming, Simplified, Alvina Alexandra):

  1. Odpowiedniej klasy, albo cechy (trait), przyjmującej przynajmniej jeden parametr generyczny, pełniącej funkcję type class.
  2. Instancji tej klasy (cechy), której zachowanie będzie rozszerzane.
  3. Metod dostępu do nowego API, wykorzystującego tę klasę.

Na poniższym listingu zaznaczone są te elementy dla opisywanego w ramach artykułu przykładu:

Type classes w znacznym stopniu wykorzystywane są w bibliotece Cats. Kiedy zdefiniowana jest instancja typuMonad[F], można wykonywać poprzez Cats określone operacje naF, ponieważ dostępny jest określony interfejs, który pozwala traktowaćF jak monadę.

Podobne podejście wykorzystane jest w ValidationResultLib. W implementacji 38 linijek kodu nie jest istotne, jaki rodzaj typu opakowujeEither. Istotne jest to, że można przeprowadzać na nim takie operacje, jak na monadzie.

Tego rodzaju sparametryzowanie typu monady jest jednocześnie reifikacją innego wzorca, znanego z programowania funkcyjnego: tagless final.

Tagless final

Głównym celem tagless final jest sparametryzowanie typu monady (jako typu generycznego klas i cech). Ten wzorzec również składa się z trzech elementów (por. źródła 1, 2 i 3):

  1. Początkowego zestawu instrukcji (znanego też jako algebra, język, albo DSL), który określa, jakie operacje można wykonać na parametryzowanym typie.
  2. Opisu rozwiązania, gdzie zdefiniowane wcześniej instrukcje wykorzystywane są w implementacji.
  3. Interpretera, który zawiera implementacje instrukcji dla określonego typu monady.

Klasyczne przykłady reifikacji wzorca tagless final najczęściej wykorzystują repozytoria i usługi. Jest na przykład zaimplementowana klasaUserRepository[M[_]] i metoda  Udef findUser(id: String): M[Option[User]]. Typ monady nie jest znany zarówno dla samej klasy, jak i dla innych klas, które wywołuję metodę findUser.

W przypadku ValidationResultLib[M[_]] sytuacja jest trochę inna, jednak parametrM[_] sugeruje, że także i tutaj może być wykorzystany wzorzec tagless final. Najlepiej sprawdzić to na przykładzie:

Wydaje się, że i w tym przypadku można mówić o tagless final. Początkowy zestaw operacji to funkcje: pure,flatMap i tailRecM. Cały kod mikro-biblioteki, wewnątrz ValidationResultLib to opis rozwiązania. Wreszcie, obiekt FuturesValidation jest interpreterem, gdzie początkowy zestaw operacji jest zaimplementowany dla typu Future.

Są dwie główne zalety wykorzystania takiego podejścia w ValidationResultLib. Wspominałem już o nich wcześniej, ale teraz można sformułować je w kontekście tagless final:

  • Można skopiować 38 linijek kodu i zacząć ich używać po napisaniu interpretera dla określonego typu monady.
  • Ponieważ typ monady zapisany jest na poziomie cechy (trait), nie ma konieczności parametryzowania metod typem monady (kiedy kompilator nie jest w stanie wywnioskować tego automatycznie).

Wykorzystanie tagless final ma swoje zalety, głównie dzięki lepszej izolacji abstrakcji. Korzystanie jednak z niego przekrojowo, w całej aplikacji, może być moim zdaniem problematyczne. W proponowanym przykładzie wzorzec ten jest wykorzystany wewnątrz kodu mikro-biblioteki, a nie na poziomie repozytoriów i usług.

Podsumowanie i wnioski

W tym artykule pokazałem 38 linii kodu ułatwiających walidację danych w Scali. Takie podejście bardzo mi pomogło w poprzednim projekcie, eliminując sporo niepotrzebnego, zagmatwanego kodu. Ma ono niewątpliwe zalety, jednak może być problematyczne.

Po pierwsze, nie zawsze walidacja oparta na Either to najlepsze rozwiązanie. W tym wypadku, jeśli pierwsza reguła walidacji zwróci błąd, proces walidacji się kończy. Zwracana jest jedynie informacja o pierwszym napotkanym błędzie. Jest to użyteczne, jeśli chcemy na przykład uniknąć niepotrzebnych zapytań do bazy danych, albo zewnętrznych usług. Są jednak sytuacje, kiedy pożądana jest informacja o wszystkich błędach walidacji, a nie tylko o pierwszym. Wówczas, zamiastEither, użyteczne może być wykorzystanie cechyValidated z biblioteki Cats i rozbudowanie walidacji na podstawie tego typu.

Pojawia się także problem wynikający z wykorzystania ExecutionContext w Scali. Domniemane parametry tego typu często są konieczne przy wykonywaniu wielu operacji na monadach (i podobnych typach), takich jak Future (więcej np. w tej dyskusji na Reddit). Dlatego w prezentowanych przykładach parametr typu Monad[Future] był tworzony w locie: Eimplicit def monad(implicit ec: ExecutionContext). Nie mamy dostępnej jednej instancji tego parametru, tylko za każdym razem tworzona jest nowa, w zależności od danego ExecutionContext. Narzut jest pewnie niewielki i akceptowalny, jednak mogą wystąpić takie przypadki użycia, gdy doprowadzi do problemów z wydajnością.

Oprócz tego pojawia się szerszy problem związany z wydajnością, który może czasami wystąpić przy wykorzystaniu monad transformers. To dlatego, że tak naprawdę wirtualna maszyna Javy nie jest dostosowana do niektórych konstrukcji programowania funkcyjnego. Można o tym poczytać np. w tym artykule Johna A De Goesa.

Innymi słowy, mamy do czynienia z pewnym kompromisem. Znacząca poprawa czytelności kodu, ale związana z niewielkimi problemami z wydajnością, które mogą się pojawić w pewnych okolicznościach. W większości przypadków wybór jest prosty.

Wszystkie przykłady zawarte w artykule można znaleźć w tym repozytorium na GitHubie.


Artykuł został pierwotnie opublikowany na blog.softwaremill.com. Zdjęcie główne artykułu pochodzi z unsplash.com.

Patronujemy

 
 
Polecamy
35 milionów linii kodu Microsoftu. Historia Kacpra Rzepeckiego