Przetransformujemy go teraz w minimalną działajacą aplikację. Będzie to fragment systemu obsługującego księgarnię. Celem jest napisanie REST-owego interfesju do zarządzania bazą książek.
Pozwoli on pobranie zbioru książek, a także na jego modyfikację – dodanie nowej, usunięcie i modyfikację istniejących. Nic skomplikowanego, ale od czegoś trzeba przecież zacząć.
Każda książka będzie miała autora, tytuł i rok wydania. Na razie tyle nam wystarczy.
Przykładowy JSON:
REST?
O pełną definicję się niestety nie pokuszę. Nie wiem czy w ogóle takowa istnieje.
Powiem jednak, że jest to implementacja jednego ze stylów architektury integracyjnej - RPC, czyli zdalnego wywołania procedury1. Oparta na protokole HTTP.
Mówimy z angielskiego, że serwis jest RESTful. No i może on być RESTful w różnym stopniu. Możemy np. wystawić tylko jeden endpoint i obsługiwać zapytania inaczej w zależności od przesłanej treści.
Można wyodrębnić oddzielne zasoby, posiadające odzielne URL-e, a także obsługiwać poprawnie HTTP verbs jak GET, POST, PUT, etc w celu wykorzystania cache’owania.
Przypomnijmy, że nasz handler wygląda do tej pory tak:
Pierwsza rzecz jaką zrobimy jest dodanie obsługi jsona. Mam tutaj na myśli transformację z tekstu np. ”{ "author":"Hickey" }” na odpowiadajacą mu strukturę Clojure, np. mapę { :author “Hickey” }. I na odwrót dla response.
Istnieje wiele bibliotek, które mogą to dla nas załatwić jak np. chessire, data.json. W naszym przypadku użyjemy tego, co oferuje nam Ring, na którym oparty jest Compojure, czyli funkcji:
(wrap-json-response), (wrap-json-body)
Zmienimy także handler/site and handler/api, który zgodnie z dokumentacją zdaje się lepiej odpowiadać naszym potrzebom: “Create a handler suitable for a web API(…)”.
Funkcja app wyglada teraz tak:
Poslużyliśmy się tutaj wygodnym niekiedy makrem ->, które pozwala na bardziej czytelne wyrażenie złożenia funkcji. Kod zwraca (wrap-json-response (wrap-json-body (handler/api app-routes))).
W tym przypadku najpierw tworzymy handlera naszych zadań http aplikując funkcję handler/api do instancji definicji routingu. Nastepnie ,,wzbogacamy’’ go o obsługę jsona.
Dla większego zrozumienia warto porównać implementację wrap-json-body z wrap-json-response.
Routes
Definicja routingu tworzy szkielet naszej aplikacji. Tutaj określamy jakie mamy mieć endpointy i jakie funkcje je obsługują.
GET /books
Zwraca zbiór wszystkich książek. W Compojure wyrażony przez (GET "/books" [] (read-books), gdzie read-books odpowiada za operację czytania. Zobaczymy jej implementację niebawem.
GET /books/[id]
Zwraca książkę o podanym id. Definicja tej ruty to (GET "/books/:id" [id] (read-book-by-id id))
POST /books
Dodanie nowej książki – (POST "/books" {body :body} (insert-new-book body))
Możemy zauważyć, że wszystkie te operacje odnoszą się do tego samego zasobu /books. Compojure pozwoli nam zgrupować je używając makra context.
Trzeba podkreślić ponadto, że starałem się oddzielić logikę operacji od definicji routingu, przy użyciu pomocniczych funkcji. Jako zaawansowany początkujący w Clojure, nie znam wszystkich konwencji, ale podowiadał mi to zdrowy rozsądek.
Podsumujmy zatem czego udało nam się dokonać do tej pory. Poniżej kod routingu wraz z dodatkowymi operacjami usuwania i modyfikacji.
,,Logika biznesowa’’
Każda aplikacja musi posiadać warstwę logiki biznesowej2. Nasza nie będzie gorsza. Ta logika, to implementacja funkcji takich jak read-books.
W naszym przypadku operacje te będą głównie bazować na odwołaniach do bazy danych. Być może z dodakiem pewnych dodatkowych detali.
No i właśnie ta pierwsza funkcja, podobnie jak ta odpowiadajaca za usuwanie książek będą mieć trywialną implementację:
Zwyczajnie odwołują się one do funkcji bazodanowych. Trochę ciekawiej to wygląda w przypadku dostępu do konkretnego zasobu książkowego:
Funkcja db/read-book jedynie wykonuje select zwracając listę wierszy z książkami. A zatem pozostaje nam wyłuskac wynikowa książkę oraz obsłużyć przypadek braku wyników. Zwracamy wtedy naturalnie kod 404, czyli not found.
Baza danych
Książki musimy gdzieś trzymać. Potrzebujemy zatem bazy danych. Na potrzeby naszego przykładu będzie to H2, istniejąca tylko w pamięci - tzw. in-memory database. Będziemy mogli w dowolnym momencie to zmienić, nie modyfikując kodu naszej aplikacji.
Dla lepszego uporządkowania cały kod związany z bazą znajdzie się w oddzielnym namespace – bookstore-rest.db.
Połączenie
Po pierwsze zdefiniujmy parametry połączenia do naszej bazy. Mogą one wygladać np. tak:
Widzimy powyżej mapę z nastepującymi parametrami: klasę sterownika połączenia do bazy, protokół oraz dodatkowe parametry bazy H2.
Wskazówka
Dla uproszczenia przykładu z bazą danych będziemy sie łaczyć na nowo przy każdym zapytaniu. Prawidłową techniką byłoby wykorzytywanie puli istniejących połączeń. Rozwinięcie tematu niebawem. Na teraz mogę wspomnieć, że mamy do dyspozycji np. ComboPooledDataSource z biblioteki C3P0 (java). Stąd też "DB_CLOSE_DELAY=-1" – chcemy żeby baza dalej była dostępna, mimo braku aktywnych z nią połączeń.
Możemy więc już łączyć się z bazą. No ale najpierw musimy ją odpowiednio zainicjalizować.
Tworzenie tabeli
Ring pozwala na zdefiniowanie funkcji wywoływanej przy starcie serwera. To idealne miejsce na umieszczenie funkcji tworzącej tabele w bazie, która w końcu będzie żyła tak długo jak i serwer.
Funkcja ta jest podpięta do ringa z poziomu project.clj. Alternatywnie możemy dodać w razie potrzeby funkcję sprzątająca, wywoływaną podczas zatrzymania serwera.
Operacje bazodanowe
Stworzyliśmy już szkielet routingu. Teraz pokażę implementacje funkcji odpowiadających za poszczególne operacje. Nie będzie tutaj zbyt dużo magii. Ot, zwykłe wywołania zapytań SQL za pośrednictwem pakietu clojure.java.jdbc:
Na początek wystarczy, ale…
O tym jak dodać obsługę poolingu połączeń przeczytamy więcej na clojure-doc.org.
Jeżeli wiążemy jakieś większe nadzieje z projektem, to powinniśmy pomyśleć o obsłudze migracji. Być może przy użyciu jednej z bibliotek wymienionych na dole tej samej strony.
Możliwe wariacje API
Możemy się spotkać z nieznacznymi różnicami czy też usprawnieniami w realizacji tego typu serwisów. Takimi jak:
brak respose body dla operacji POST, zamiast tego Location header wskazujący skąd pobrać nowo utworzony zasób
PUT - obsługa statusów 404, czy też 409
DELETE – odpowiedź 404 w przypadku braku obiektu
można mieć dylemat czy id powinno być częścią jsona i zwracane w GET czy może nie, szczególnie dziwnie może może wyglądać PUT z różnymi id w URL i body
wykorzystanie ETAG do weryfikacji najświeższej wersji zasobu
Przetestujmy!
Sprawdźmy zatem jak działa nasz serwis w akcji. Użyjemy do tego zwyczajnie programu curl.
Oczywiście nie znaczy to, że czekamy z testowaniem, aż skończymy pisać cały kod aplikacji. Testy – w mojej opinii – powinny się przynajmniej implementacją zazębiać, tak aby mieć pewność z kod działa jak należy z jak najmniejszym opóźnieniem.
Zwykle nie jest dla mnie aż tak istotne czy najpierw piszemy testy (TDD) czy też potwierdzamy poprawne działanie już napisanego kodu. Coraz częściej – świadom wad i zalet – skłaniam się ku temu drugiemu. Ale to temat na zupełnie inną okazję.