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 artykułów publikowanych 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 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.

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.

Zapraszamy do dyskusji

Patronujemy

 
 
More Stories
fizyka gry
Fizyka gry ma ładnie wyglądać i tylko symulować prawdziwą