Jak praktycznie wykorzystałem Clojure w moim projekcie
Często wykład o Clojure kończy się dosyć charakterystycznie. Słuchacze pytają czy autor korzysta z języka na co dzień, jakie ma doświadczenia z tym związane i do czego właściwie ”ten” język się nadaje. Prelegent zwykle odpowiada, że niestety projekty komercyjne pisze na co dzień w Javie/C#/Pythonie/PHP. Nie może odpowiedzieć do czego Clojure się nadaje, bo nie ma doświadczenia. Clojure staje się wtedy ciekawostką.
Konrad Mrożek. Programista Clojure w firmie Shareablee, zajmującej się przetwarzaniem i analizą danych z serwisów społecznościowych. Od kilkunastu lat związany z platformą Java. Interesuje się programowaniem funkcyjnym, językami programowania oraz historią informatyki. W wolnym czasie “gra w grę” i kolekcjonuje komputery 8- i 16-bitowe (Atari, Commodore, Spectrum/Timex), na których rozpoczynał swoją przygodę z programowaniem.
Niby język funkcyjny, z interesującymi rozwiązaniami (stałe struktury danych, wsparcie dla wielowątkowości, REPL), “dziwną” zwięzłą składnią, ale pewnie “akademicki” i nikt z niego tak naprawdę nie korzysta. Prawda jest jednak inna. Clojure to całkiem dojrzały język (pierwsza wersja pochodzi z 2009 roku), ze sporą rzeszą fanów, wykorzystywany w wielu firmach, takich jak Walmart, Nubank, Thoughtworks, Atlassian, Puppet czy Apple. Niestety, w naszym kraju jest on nadal postrzegany jako język mocno niszowy. Za to np. Walmart wykorzystuje go w systemach bilingowych i do tworzenia swojego GraphQL API. Jest to nie tylko ciekawy język z dziwną składnią, ale także język ogólnego zastosowania, z pomocą którego można budować mikroserwisy, usługi RESTowe czy serwisy webowe.
W świecie Javy, Pythona czy Ruby często kojarzymy tworzenie aplikacji z konkretnymi frameworkami. W przypadku Javy jest to Spring, Pythona Django/Flusk a Ruby to oczywiście Ruby on Rails. W świecie Clojure nie ma jednego frameworka. Programiści korzystają z poszczególnych bibliotek, dających im różne funkcjonalności (serwer HTTP, routing, dostęp do bazy danych albo kolejek), a frameworkiem jest sam język, który dostarcza narzędzi do łączenia ze sobą komponentów. Clojure daje programowanie funkcyjne, aspektowe, instrumentację, zarządzanie stanem w aplikacji, memoizację (cache’owanie wyniku funkcji), itd. Makra pozwalają dodatkowo rozszerzać język w razie potrzeby, o kolejne funkcjonalności.
Sztandarowym przykładem jest biblioteka core.async, która wbudowuje w Clojure (i jego odmianę ClojureScript działającą na platformie Javascript) CSP (Communicating Sequential Processing) znane z Go.
Tak więc, jak wspomniałem powyżej, projekty napisane w Clojure są zlepkiem różnych bibliotek. Nie korzystamy z jednego frameworka, który jest klejem scalającym naszą aplikację. Na przykład w przypadku mikroserwisu HTTP, musimy wybrać serwer, który będzie serwował nasze API. Nie jest to trudne, ponieważ większość serwerów implementuje standard Ring, który jest abstrakcją na zapytania i odpowiedzi HTTP. Serwerowi dostarczamy funkcję (handler), która jako argument przyjmuje zapytanie i zwraca odpowiedź. Zapytanie i odpowiedź są mapami, z podstawowymi kluczami, takimi jak :headers, :status czy :body. Oto przykład takiej funkcji:
(require '[ring.util.response :refer [response content-type status not-found]]) (defn todos-list [req] (-> (response “Hello world”) (status 200) (content-type “text/plain”)))
Clojure jest językiem funkcyjnym i bardzo łatwo jest dodać kolejne funkcjonalności do takiego prostego handlera poprzez funkcje wyższego rzędu. Można go rozszerzyć o generowanie w locie JSONa z odpowiedzi (biblioteka ring-json) albo obsługę autoryzacji (biblioteka buddy) wrapując funkcję handlera za pomocą tzw. middleware. Przykład z generowaniem w locie JSONa wygląd tak:
(require '[ring.middleware.json :only [wrap-json-body]]) (def app (wrap-json-body todos-list))
Definiujemy tutaj nową funkcję app, która będzie złożeniem naszego todos-list z wrap-json-body.
Często potrzebujemy zwracać różne dane w zależności od ścieżki. Na przykład w Springu stosujemy wzorzec MVC, a odpowiednia metoda kontrolera ma adnotację ze ścieżką. Tutaj można wszystko zrobić za pomocą samego Clojure albo użyć zewnętrznej biblioteki do routingu (np. Compojure albo Bidi), która także będzie tylko kolejną funkcją. Porównajmy Compojure i czyste Clojure:
(defmulti router (juxt :request-method :uri)) (defmethod router [:get “/todos”] [req] (todos-list req)) (defmethod router :default [req] (not-found “Not-found”)) (def app (wrap-json-body router))
Albo Compojure:
(require ‘[compojure.core :refer [defroute GET]]) (def app (wrap-json-body (routes (GET “/todos” [req] (todos-list req)))))
Kolejną rzeczą, jaką dają nam frameworki, jest uproszczenie obsługi bazy danych. Praktycznie musimy tylko podać adres serwera i hasło, a framework magicznie da nam dostęp do bazy, zmapuje obiekty na tabele i wygeneruje metody do zapisu/wyszukiwania. Jeśli chodzi o sam dostęp to korzystamy głównie ze standardu JDBC i wrappera na niego, czyli clojure.java.jdbc.
Jeśli chodzi o mapowanie obiektowo-relacyjne to w środowisku Clojure nie ma jego odpowiednika. Nikt nie uznał tego za coś ważnego i potrzebnego. Pewnie ciężko byłoby stworzyć skomplikowaną bibliotekę, która magicznie zmapuje zagnieżdżone mapy na tabele. Tak wygląda przykładowy kod zapisu do tabeli kontakty prostej mapy:
(require ‘[clojure.java.jdbc :as jdbc) (jdbc/insert! db-spec :kontakty {:imie “Jan” :nazwisko “Kowalski” :nr_telefonu “+48123456789”})
Jak widać, nie wygląda to tak strasznie. W bardziej skomplikowanych przypadkach, możemy użyć bibliotek, które ułatwią nam tworzenie zapytań SQL, ale może to nie być rozwiązanie satysfakcjonujące wszystkich. Biblioteka HoneySQL pozwala na zapisanie zapytań jako struktury danych Clojure, które można prosto tworzyć, składać i przetwarzać za pomocą wbudowanych funkcji takich jak concat, map, reduce, filter, itd. Są też biblioteki, które zamieniają gotowe zapytania SQLowe w funkcje Clojure, wykorzystując w tym celu makra (np. YeSQL). Można też użyć baz danych NoSQL zamiast klasycznej bazy relacyjnej.
Mając warstwę wejścia/wyjścia (serwer) i warstwę persystencji (bazę SQL), brakuje nam tylko logiki biznesowej. Wewnętrzne struktury będziemy najczęściej zapisywać jako mapy oraz wektory, a przetwarzać zwykłymi funkcjami (bez efektów ubocznych). W frameworkach korzystających z mapowania obiektowo-relacyjnego (Hibernate/JPA/Spring albo Django) często logika biznesowa działa na encjach. Jest to z jednej strony wygodne, ale może nas ograniczać i wiązać ze sobą różne funkcjonalności (np. persystencję i serializację). W Clojure zwykle jest to mocno rozgraniczone, ponieważ używa się czystych danych, niezwiązanych z żadną z warstw. Na przykład stwórzmy logikę biznesową dla koszyka zakupowego
(defn make-basket [owner] {:basket/owner owner :basket/items []}) (defn make-item [name price] {:basket-item/name name :basket-item/price price}) (defn add-item [basket item] (update basket :basket/items conj item)) (defn count-basket-price [basket] (->> basket :basket/items (map :basket-item/price) (reduce + 0)))
Teraz połączmy ją z bazą danych:
# ! w nazwie oznacza, że funkcja modyfikuje stan (defn store-basket! [conn basket] (let [basket-id (:id (jdbc/insert! conn :basket {:owner (:basket/owner basket)})] (jdbc/insert-multi! conn :basket-items (map (fn [item] {:name (:basket-item/name item) :price (:basket-item/price item) :basket-id basket-id}) (:basket/items basket)))))
Zapisujemy koszyk w bazie danych w formacie bazy, a gdy go odczytujemy przepisujemy go z powrotem z formatu bazy na koszyk. Oczywiście słusznie kojarzy się to z DTO (Data Transfer Objects), ale w tym przypadku nie tworzymy kolejnych obiektów, a tylko zapisujemy bezpośrednio dane z naszego wewnętrznego formatu i vice versa.
Clojure nie jest statycznie typowanym językiem, więc musimy pisać więcej testów, które w np. w Javie zastąpiłyby typy. Z językiem dostarczana jest prosta, ale elastyczna biblioteka do testów: clojure.test. Zwykle wystarcza do pisania zarówno testów jednostkowych, jak i testów integracyjnych, wymagających określania środowiska, w którym wykonywane będą testy. Przykładowo, chcemy żeby testy wykonały się przy podłączonej bazie danych:
(deftest kontakt-db-test (try (connect-db!) (pupulate-database-with-testdata) (testing “search for data” (is (= [{:imie “Zenon” :nazwisko “Woo” :nr_telefony “123456”}] (get-all-kontakt-by-surname “Woo”)) (finally (cleanup-db!) (disconnect-db!)))
Oczywiście możemy ten blok try opakować w jakieś makro, które sprawi, że kod zestawiający środowisko testowe nie będzie widoczny w kodzie testu.
Przy testowaniu naszej aplikacji możemy dodatkowo wesprzeć się testami generowanymi, które często łączy się z tworzeniem tzw. specyfikacji danych. Jest to opis struktury danych (mapy) i jej poszczególnych elementów za pomocą predykatów (czyli funkcji, które zwrócą true/false w zależności od tego czy wartość jest prawidłowa). Specyfikacja jest przydatna również do określania argumentów wejściowych i wyjścia z funkcji. Istnieją biblioteki (np. github.com/clojure/test.check/) generatorów danych testowych, które przetestują nasze funkcje znajdując często takie przypadki brzegowe, o których nawet nie pomyślelibyśmy. Niestety tworzenie takich danych i wykonywanie wszystkich testów jest dużo bardziej czasochłonne niż w przypadku konwencjonalnych testów.
Podsumowując, projekty w Clojure często wymagają od programisty dokonywania większej ilości wyborów. Ma to swoje wady, ponieważ musimy bardziej zgłębić dany temat (np. serwera HTTP), ale w przyszłości procentuje to większą wiedzą i świadomością jak działa aplikacja i gdzie mogą pojawiać się błędy. Frameworki często robiąc dla nas piękną robotę, zaciemniają obraz i sprawiają, że wierzymy w ich magię. A magia czasem może się przeistoczyć w koszmar.