Krótko o jakości bibliotek Python do ElasticSearch

Do Pythona istnieją dwie podstawowe biblioteki do komunikacji z ElasticSearch: elasticsearch-py oraz elasticsearch-dsl. Są one typowymi interfejsami do komunikacji z serwerem ElasticSearch. Śledzę ich rozwój od pierwszych wersji i niestety, mimo już siódmej odsłony, autorzy powielają te same błędy projektowe, czyli de facto błędy programistyczne. Utrudnia to korzystanie z bibliotek, ponieważ tworzone są sytuacje niejasne, co może mieć negatywny wpływ na stabilność i jakość rozwiązań o nie opartych.

elasticsearch-py

Pierwsza uwaga dotyczy biblioteki elasticsearch-py, czyli stosunkowo niskopoziomowego interfejsu programistycznego. Już pierwszy przykład z dokumentacji uwidacznia problem:

es = ElasticSearch()
es.indices.create(index='my-index', ignore=400)

Co robi powyższe wywołanie? Tworzy indeks my-index, a w przypadku gdy takowy już istnieje, ma za zadanie zignorować błąd, pozwalając aby program wykonał się dalej bezbłędnie.

Problem tego podejścia jest w sposobie obsługi tej sytuacji. Otóż przez parametr ignore=400 powoduje, że szczegóły implementacji, tu konkretnie warstwy transportowej HTTP, przechodzą do warstwy wyższej. Program korzystający z biblioteki musi obsługiwać kody specyficzne dla warstwy niższej, co powoduje bardzo problematyczną zależność. Dlaczego to takie ważne?

Status 400 HTTP oznacza BadRequest, czyli wskazuje na problem żądania wygenerowanego przez klienta HTTP. Może to być zła składnia żądania HTTP, może to być błąd walidacji (nieprawidłowe dane w prawidłowo składniowo sformułowanym żądaniu), albo tak jak w omawianym przypadku - istnienie konkretnego zasobu. Zatem status 400 może mieć wiele znaczeń, i nawet jeśli teraz ma tylko jedno, to nie ma żadnej gwarancji, iż w kolejnych odsłonach HTTP API nie nabierze dodatkowych. Status o kodzie 400 nie oznacza, że indeks istnieje. Oznacza, że serwer zwrócić odpowiedź o kodzie 400, czyli uznaje że problem leży w żądaniu klienta, gdzie klient może powtórzyć odpowiednio zmodyfikowane żądanie, a przyczyny mogą być różne. Ale jak klient zmodyfikuje żądanie, skoro dokładnie nie określono przyczyny?

Kolejnym problemem jest sam flow programu, w którym zawiera się wywołanie es.indices.create(index='my-index', ignore=400). W przypadkach błędu utworzenia indeksu, niekoniecznie związanego z jego istnieniem po stronie serwera, program będzie wykonywał się dalej. To oznacza, że błąd programu wystąpi w innym miejscu, a konkretnie podczas próby wykonania operacji na indeksie, który nie istnieje.

Złamanie zasad hermetyzacji powoduje, że zależność z niższą warstwą transportową tworzy kod aplikacji, który jest trudniejszy do utrzymania. Kod 400 nie jest jednoznaczny, nie jest dostatecznie czytelny, a w przypadku zmian w warstwie transportowej nie będzie kompatybilny wstecznie.

Interfejs biblioteki jest niespójny, ponieważ programista nie ma wpływu na szczegóły żądania HTTP wysyłanego do serwera, ale musi operować na częściowych informacjach z odpowiedzi HTTP. To nie tylko nie czyni programowania łatwym ani elastycznym, ale przede wszystkim forsuje programowanie w błędnym stylu, wskutek czego powstające aplikacje w oparciu o biblioteki tej jakości będą o wiele bardziej błędne.

Poprawne podejście do zagadnienia powinno być oparte o wyjątki. Powyższy przykład, w bibliotece o dobrze zaprojektowanym interfejsie, powinien wyglądać tak:

es = ElasticSearch()

try:
    es.indices.create(index='my-index')
except IndexAlreadyExists:
    # obsługa sytuacji, w której indeks już istnieje
except IndexCreationError:
    # obsługa błędu przy próbie tworzenia indeksu (złe parametry, inne)

Kod jest niewiele dłuższy, ale za to czytelny oraz niezależny od szczegółów implementacji warstwy transportowej.

Jeśli intencją autorów było ulokowanie biblioteki klienckiej w warstwie transportowej, to sens istnienia tej biblioteki w tej formie jest właściwie żaden. Czym bowiem różni się zapytanie es.indices.create('my-index') od requests.put('http://elasticsearch-instance/my-index')? Czyż nie wystarczyłoby utworzyć niewielki adapter, który zawierałby adresy węzłów klastra, implementację sniffera czy strategii wysyłania żądań, tworząc z niego klienta stricte transportowego, ułatwiającego komunikację HTTP z usługą ElasticSearch?

elasticsearch-dsl

Biblioteka elasticsearch-dsl jest adapterem wysokiego poziomu. Dostarcza interfejs bardziej obiektowy i przyjazny programowaniu aplikacji. Podstawową klasą, z którą programiści aplikacji mają do czynienia, jest klasa Search. Swoją konstrukcją przypomina klasę QuerySet z frameworka Django, i zapewne była ona wzorem dla autorów biblioteki. Nie ustrzegli się jednak błędów projektowych.

W oryginale, tj. QuerySet, metody zwracają referencję na zmodyfikowane kopie instancji. Dzięki temu podejściu można stosować łańcuchowanie zapytań do baz danych, używać fragmentów łańcuchów, tworzyć warunkowe konstrukty, itd. Programista ma pewność, że każdy obiekt zwrócony przez te metody jest nowym obiektem, a stan oryginalnej instancji klasy QuerySet nie jest modyfikowany.

Programiści elasticsearch-dsl stoją jednak w koncepcyjnym rozkroku. Podstawowe metody kalsy Search istotnie zwracają zmodyfikowane kopie, co widać na poniższym przykładzie:

>>> s1 = Search()
>>> s2 = Search().filter(foo='bar')

>>> id(s1) == id(s2)
False

>>> s1.to_dict()
{}

>>> s2.to_dict()
{'query': {'bool': {'filter': [{'match_all': {'foo': 'bar'}}]}}}

Jednak już przy agregacjach wpadli w sidła niekonsekwencji, i obiekt klasy Search zmienia swój stan in place:

>>> s1.aggs.bucket('per_tag', 'terms', field='tags')
>>> s1.to_dict()
{'aggs': {'per_tag': {'terms': {'field': 'tags'}}}}

Oczywiście można uzasadnić, że skoro metoda .bucket() jest wywoływana na właściwości aggs, to zmodyfikowana zostanie instancja klasy opisującej agregacje (tu: AggsProxy), co pośrednio wpływa na stan instancji klasy Search. Jednak nie jest do końca czytelne, co dzieje się ze stanem Search. Nie wiadomo czy oraz jak zmiany aggs wpływają na stan instancji nadrzędnej klasy Search, a znając pierwotną konwencję intuicyjnie spodziewamy się kopii.

Zastosowana tutaj asocjacja między klasami, jest niepotrzebnie wyeksponowana do interfejsu publicznego nadrzędnej klasy Search, co wpływa na niespójne zachowanie interfejsu wysokiego poziomu. Jest to pogwałcenie nie tylko zasad hermetyzacji, ale także dobrych i prostych zasad PEP20 - The Zen of Python.

Prawidłowy publiczny interfejs do modyfikacji agregacji powinien eksponować odpowiednie metody.

Przykład:

>>> s1.agg_bucket('per_tag', 'terms', field='tags')

gdzie metoda agg_bucket zwracałaby kopię s1z kopią zmodyfikowanego obiektu opisującego agregacje, lub interfejs uogólniony działający na podobnej zasadzie:

>>> s1.aggregate(A(...))

Siedem wersji architektonicznego bólu i jeden plus

Obydwie biblioteki są już w siódmej odsłonie (7.x). Przez wiele lat nie zrobiono nic ku wyeliminowaniu błędów projektowych. Wręcz przeciwnie - autorzy brną dalej w tym samym kierunku.

Chyba tylko z powodu utopijnej wizji oraz braku mocy przerobowych, biblioteka niższego poziomu jest w większości generowana automatycznie z API HTTP, co tylko dowodzi braku sensu jej istnienia w takiej formie. Wygenerowany kod musi pokrywać całe API HTTP - wszystkie operacje. Ale po co, skoro i tak narzuca interakcję z częścią specyficzną dla warstwy transportowej? Po co, skoro kod biblioteki jest w części generowany z API HTTP, przez co i tak nie zapewni kompatybilności wstecznej, gdyby interfejs HTTP się zmienił?

W praktyce wraz z aktualizacją serwera ElasticSearch i tak trzeba aktualizować obydwie biblioteki, co dowodzi braku spójnego i dobrego konceptu na te interfejsy. Dla kontrprzykładu Django nie musi być aktualizowane, żeby pracować z różnymi wersjami PostgreSQL, np. od wersji 9 do 12. Dopiero gdy chcemy z poziomu interfejsu obiektowego dostać się do nowych funkcji bazy danych, może okazać się konieczne uaktualnienie wersji frameworka. Ale nie powstaje Django 12 dla dwunastej odsłony bazy danych.

Opisywane tu biblioteki są, bo po prostu muszą istnieć. Ze względu na wsparcie dla istniejących projektów oraz przez to, że są "standardem" komunikacji z ElasticSearch z poziomu Pythona. Nie są jednak interfejsem dobrym. Ich zaletą jest to, że w ogóle są. Można z nich korzystać, a ze względu na specyfikę komunikacji z serwerem nawet trzeba, ale najlepiej ze świadomością istniejących pułapek. Podane we wpisie przykłady są "pierwszymi z brzegu", a problemów tej kategorii jest więcej. Niniejszy wpis ma za zadanie wyczulić programistów na problem oraz pokazać na tych przykładach, jak nie powinno się projektować interfejsów.

Krótko o mnie

Jestem programistą i architektem systemów IT. Specjalizuję się w aplikacjach webowych, szczególnie w Python oraz Django, PostgreSQL oraz systemach wyszukiwana ElasticSearch.

Zajmuję się wsparciem istniejących systemów oraz projektowaniem i produkcją. W branży działam od 2001 roku. Oferuję doświadczenie, profesjonalizm oraz indywidualne podejście do zleceń.

Zainteresowanych moimi usługami zapraszam do wysłania zapytania.

Javascript logo PostgreSQL logo Cassandra logo Redis logo ElasticSearch logo Ansible logo HTML5 logo CSS3 logo NGINX logo Docker logo

Komentarze

Brak komentarzy