5 niezbędnych bibliotek języka Scala

nofluffjobs.com 1 rok temu

Zaczynasz nowy projekt w Scali i nie chcesz wynajdywać koła na nowo? Z tego artykułu dowiesz się, jakie biblioteki ułatwią Ci życie przy rozwiązywaniu typowych problemów.
Z przyczyn technicznych kod w treści artykułu jest w postaci zrzutów ekranu. Na końcu artykułu znajdziesz link do repozytorium, w którym kompletne przykłady dostępne są w postaci kodu źródłowego.

Spis treści
Problem #1 – konfiguracja
Problem #2 – prawie identyczne case classes
Problem #3 – HTTP
Problem #4 – zagnieżdżone struktury danych
Problem #5 – niepoprawne dane
Podsumowanie

Problem #1 – konfiguracja

Być może kojarzysz bibliotekę Lightbend Config (wcześniej Typesafe Config), która pozwala na wczytywanie konfiguracji z plików w formacie HOCON. Spełnia ona swoją funkcję dopóty, dopóki wczytujemy z konfiguracji pojedyncze wartości dzięki metod typu:

getString, getInt

itp.
Rozważmy poniższą konfigurację pewnego serwera w pliku

src/main/resources/application.conf:


Aby wczytać ją z użyciem Lightbend Config, użyjemy następującego kodu:

Co jednak w przypadku, kiedy modelujemy konfigurację aplikacji dzięki case classes, np.:

Możemy oczywiście “ręcznie” tworzyć case classes z pojedynczych wartości, ale wtedy prędzej czy później nasunie się pytanie czy nie dałoby się tego robić automatycznie. Z pomocą przychodzi biblioteka pureconfig, która pozwala wczytać konfigurację bezpośrednio do

ServerConfig

w następujący sposób:

Pod spodem pureconfig przez cały czas korzysta z Lightbend Config i wszystkich jej dobrodziejstw. Aby powyższy przykład działał, nazwy pól w case class muszą odpowiadać nazwom kluczy w konfiguracji – jeżeli tak nie jest, to przez cały czas mamy możliwość zdefiniowania własnego mapowania nazw na poziomie pojedynczych pól.

Metoda

loadOrThrow

spowoduje rzucenie wyjątku, gdy z jakiegoś powodu nie uda się wczytać konfiguracji. Alternatywnie możemy użyć metody load, która zwraca wartość typu

Either[ConfigReaderFailures, A]

, dzięki której możemy obsłużyć błędy w bardziej funkcyjny sposób.

Po więcej szczegółów odsyłam do dokumentacji pureconfig.

Problem #2 – prawie identyczne case classes

Wyobraźmy sobie, iż w naszej aplikacji mamy reprezentację użytkowników w postaci klasy:

Z kolei na poziomie zewnętrznego API aplikacji użytkownik jest reprezentowany w bardzo podobny, ale jednak nieco inny sposób:

W kodzie, który łączy te dwie warstwy, będziemy dokonywać konwersji pomiędzy tymi dwoma typami:

Taka konwersja oczywiście zadziała, natomiast ma ona pewną wadę: większość pól po prostu przepisujemy – co byłoby jeszcze mocniej odczuwalne w przypadku bardziej rozbudowanych obiektów. I znów nasuwa się pytanie czy nie dałoby się tego procesu jakoś zautomatyzować.

Tym razem pomoże nam biblioteka chimney, która umożliwia zapisanie powyższej konwersji następująco:

Dzięki temu pola o tych samych nazwach i typach są przepisywane automatycznie a my skupiamy się jedynie na zdefiniowaniu konwersji dla pól, które różnią się pomiędzy konwertowanymi typami.

Ponieważ chimney opiera się na makrach, już na etapie kompilacji dostaniemy informację o błędnie zdefiniowanej transformacji – np. gdybyśmy w powyższym przykładzie zapomnieli o zmianie nazwy pola

age

na

howOld

.

Chimney potrafi out of the box konwertować wiele wbudowanych typów, w tym algebraiczne typy danych (ADT) typu product (tj. case classes) oraz union/coproduct, tj. hierarchie złożone z

sealed trait

i jego implementacji. jeżeli chcemy dokonywać konwersji pomiędzy niestandardowymi typami

A

i

B

, wystarczy dostarczyć instancję type classy

Transformer[A, B]

dla tych dwóch typów.

Więcej szczegółów w dokumentacji chimney.

Problem #3 – HTTP

Zaczynasz nowy projekt i jest duża szansa, iż zabierasz się za (kolejną) implementację serwera HTTP. Wybierasz coś spośród bibliotek

http4s

,

akka-http

,

zio-http

a może choćby Play Framework. Ostatecznie każdy serwer HTTP to funkcja z requesta na response, tylko zapisana dzięki DSL charakterystycznego dla danej implementacji serwera.

Wyobraźmy sobie taką (nie do końca hipotetyczną) sytuację: opiekun biblioteki, której używasz, postanawia zmienić licencję z open-source na płatną. Nie chcesz jednak płacić, więc pozostaje Ci wybrać inną bibliotekę i przepisać definicje endpointów na nowy DSL.

Ale czy na pewno? Skoro i tak zawsze musimy w jakiś sposób zapisać funkcję z requesta na response, to może dałoby się to zrobić w sposób niezależny od konkretnej implementacji serwera HTTP? Np. tak:

Powyższy kod – zapisany dzięki tapira – definiuje opis endpointa, który obsłuży requesty w stylu:

GET /books?year=1984

zwracając listę książek w postaci JSONa.

Istotne jest to, iż opis endpointów nie jest powiązany z żadną konkretną implementacją serwera HTTP. Jest to ponadto jedynie opis wejścia i wyjścia, nie zawiera natomiast adekwatnej logiki serwera – tę też możemy zdefiniować niezależnie od implementacji:

a następnie podłączyć do zdefiniowanego wcześniej endpointa:

Mamy w projekcie akka-http? Wystarczy teraz jedna linijka:

i mamy gotową integrację z istniejącym serwerem akka-http. Chcemy z jakiegoś powodu zmienić serwer na Play? Wystarczy jedynie użyć innego interpretera:

nic poza tym się nie zmienia!
Powiecie, iż w praktyce rzadko zmieniamy implementację serwera HTTP. Jest to słuszna uwaga, jednak definiowanie endpointów dzięki tapira ma też inne zalety.
Używając innego interpretera, możemy np. z opisu endpointów wygenerować dokumentację w formacie OpenAPI, razem z interfejsem graficznym Swagger UI lub Redoc:

Teraz wystarczy zamienić swaggerEndpoints na opis zrozumiały dla konkretnego serwera HTTP – dzięki odpowiedniego interpretera, podobnie jak w przypadku serverEndpoint.
Jeszcze inny interpreter pozwoli nam wygenerować klienta HTTP do naszego endpointa, używającego np. sttp (biblioteki umożliwiającej definiowanie komunikacji po HTTP niezależnie od używanego pod spodem klienta):

Wszystkie powyższe elementy (definicję serwera, dokumentację oraz klienta) wygenerowaliśmy z raz utworzonego opisu endpointa – dzięki czemu unikamy duplikacji w naszym kodzie.
Nieco bardziej rozbudowany i możliwy do uruchomienia przykład użycia tapira znajdziesz na GitHubie a więcej szczegółów – w dokumentacji.

Problem #4 – zagnieżdżone struktury danych

Wyobraźmy sobie następujący model danych:

Tworzymy teraz instancję klasy Person:

po czym okazuje się, iż musimy zmienić nazwę ulicy. Korzystamy więc z wbudowanej w case classes metody copy:

Już przy tak niewielkim zagnieżdżeniu kod robi się mało czytelny. Gdyby poziomów zagnieżdżenia było więcej, sytuacja byłaby jeszcze gorsza. Wygodniej byłoby po prostu podać “ścieżkę” do pola, które chcemy zaktualizować, tu: person.address.street, i podać nową wartość.
Do rozwiązania tego problemu posłuży nam koncepcja lenses, zaimplementowana np. w bibliotece quicklens. Dzięki niej możemy zaktualizować adres w następujący sposób:

Taką ścieżkę do zagnieżdżonego pola – czyli lens – możemy zapisać jako wartość i użyć jej wielokrotnie:

Quicklens umożliwia również:

  • modyfikację dzięki funkcji – przy użyciu using,
  • modyfikację warunkową – przy użyciu setToIf i usingIf,
  • modyfikację elementów kolekcji – przy użyciu each i eachWhere
  • składanie lenses dzięki andThenModify

i wiele innych operacji, których przykłady znajdziesz na stronie projektu na GitHubie.

Problem #5 – niepoprawne dane

Walidacja danych to dla Ciebie prawdopodobnie nic nowego. Powiedzmy, iż w naszej domenie modelujemy użytkownika w postaci jego nazwy i wieku, ale dopuszczamy jedynie użytkowników pełnoletnich. Jak możemy podejść do tego zagadnienia?
W najprostszym przypadku w naszej case class użyjemy wbudowanej funkcji require, która w przypadku niepoprawnych danych rzuci IllegalArgumentException:

Możemy też zdecydować się na bardziej usystematyzowane podejście i wykorzystać do walidacji gotowy model danych, np. Cats Validated. Ciągle jednak będzie to walidacja w runtime, tj. podczas działania aplikacji.
A gdybyśmy mogli wykorzystać fakt, iż piszemy w silnie typowanym języku, i przynajmniej część walidacji wykonać już na etapie kompilacji? Z pomocą przyjdzie nam biblioteka refined, która umożliwia zapisywanie ograniczeń już na poziomie typów.
Za pomocą refined możemy zdefiniować nowy typ:

Zapis ten to nic innego jak infiksowa notacja typu Refined[Int, Greater[18]]. Z kolei od Scali 2.13 dostępne są literal types, co oznacza, iż w powyższym zapisie 18 to też typ (a nie wartość).
Jeśli stworzymy teraz alternatywną wersję naszej case class:

To następujący kod się skompiluje:

ale taki już nie:

Nie skompiluje się również taki kod:

Co więc zrobić, jeżeli nasz model danych wymaga typu zawężonego przez refined, ale dostajemy jedynie wartość typu niezawężonego (tu: Int)? Możemy wykorzystać wbudowaną metodę applyRef, żeby dokonać odpowiedniej konwersji, której wynik będzie opakowany w Either, dzięki czemu możemy obsłużyć ew. błędy:

Za pomocą refined przenieśliśmy więc sporą część informacji o nieprawidłowych danych na etap kompilacji, dzięki czemu znacznie szybciej dostajemy informację, iż próbujemy zrobić coś niedozwolonego. Wartości, które nie są znane w czasie kompilacji, możemy próbować konwertować do zawężonych typów już w czasie działania aplikacji.
Ponieważ w praktyce mamy najczęściej do czynienia z wartościami dynamicznymi, których nie znamy w czasie kompilacji, prawdziwa siła refined tkwi w integracji z innymi popularnymi bibliotekami, np.

  • pureconfig – pozwalająca na walidację konfiguracji,
  • circe – pozwalająca dokonywać walidacji w trakcie parsowania JSONa,
  • doobie – pozwalająca zapisywać/odczytywać typy zawężone przez refined do/z relacyjnej bazy danych,
  • i wielu innych.

Po więcej szczegółów odsyłam do dokumentacji refined.

Podsumowanie

Mam nadzieję, iż opisane biblioteki przydadzą Ci się w codziennej pracy Scala dewelopera. Zapraszam tutaj do repozytorium na GitHubie, gdzie masz łatwy dostęp do kompletnych przykładów kodu z tego artykułu.
Jeśli pracujesz lub lubisz pisać w Scali, to zapraszam do dyskusji na podobne tematy na żywo. Już 23 i 24 marca masz okazję poznać światowe community Scali, dołączając do konferencji Scalar w Warszawie. Scalar to największe wydarzenie skupiające praktyków programowania funkcyjnego w Europie. W trakcie ósmej edycji konferencji nie zabraknie znamienitych prelegentów, takich jak Daniel z RockTheJVM, Gabriel Volpe, czy sam Martin Odersky – twórca języka Scala – tu pełna agenda.
Scalar to aż dwa dni ogromnej dawki wiedzy, konferencja odbywa się w trybie single-track, by każdy uczestnik miał pewność, iż może uczestniczyć w każdej prelekcji. No Fluff Jobs zostało partnerem Scalara, więc dla wszystkich czytelników bloga mamy niespodziankę.
Na hasło: NFJportal udostępniliśmy 10 biletów z 10% zniżką. Kto pierwszy ten lepszy, kod należy podać w momencie rejestracji. Dołączam do Scalara >>

Idź do oryginalnego materiału