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.

ElasticSearch 6 - na dobrej drodze

Wersja 6

Wersja 6.0 ElasticSearch jest dla mnie szczególna - twórcy wprowadzają zmiany ułatwiające zarządzanie, ale też rezygnują z dawnych błędów, które krytykowałem na łamach tego bloga.

Każdemu polecam migrację do wersji 6.x. Służę pomocą w migracji z wersji 2.x oraz 5.x.

Aktualizacje ElasticSearch - jak wykonać?

Nie można zaprzeczyć, że ElasticSearch jest rozwijany dynamicznie. Tak szybki rozwój produktu nie zawsze jest oczekiwany, bo albo wdrożenie zostaje (z przyczyn obiektywnych) oparte o zamrożoną (i nie wspieraną) wersję, albo konieczne staje się przeprowadzanie migracji do nowszych wersji. Takie operacje trzeba zaplanować, zabudżetować, a na dodatek nie obejdzie się bez downtime i sukcesem jest, gdy przerwa techniczna jest relatywnie krótka.

Przypomnę w tym miejscu daty końca wsparcia poszczególnych wersji ElasticSearch:

Elasticsearch EOL Date Maintained Until
2.0.x 2017-04-28 2.1.0
2.1.x 2017-05-24 2.2.0
2.2.x 2017-08-02 2.3.0
2.3.x 2017-09-30 2.4.0
2.4.x 2018-02-28 6.0.0
5.0.x 2018-04-26 5.1.0
5.1.x 2018-06-08 5.2.0
5.2.x 2018-07-31 5.3.0
5.3.x 2018-09-28 5.4.0
5.4.x 2018-11-04 5.5.0
5.5.x 2019-01-06 5.6.0
5.6.x 2019-03-11 7.0.0
6.0.x 2019-05-14 6.1.0
6.1.x 2019-06-13 6.2.0
6.2.x 2019-08-06 6.3.0
6.3.x 2019-12-13 6.4.0
6.4.x 2020-02-23 6.5.0
6.5.x 2020-05-14 6.6.0
6.6.x 2020-07-29 6.7.0
6.7.x 2020-09-26 8.0.0
7.0.x 2020-10-10 7.1.0

Przygotowując się do jakichkolwiek operacji powinno się opracować procedurę, z którą należy zapoznać wszystkich zainteresowanych, opracować procedurę odwrotną, tj. wycofującą zmiany w razie nieprzewidzianych problemów, albo oznaczyć jasno punkty po realizacji których nie ma już odwrotu.

W przypadku ElasticSearch procedura jest o tyle bezpieczna, że ryzyko utraty danych jest stosunkowo niewielkie i ogranicza się do przypadku utraty danych z kartoteki wtórnej (zazwyczaj bazy danych RDBMS systemu OLTP), co ma miejsce niezwykle rzadko ale jest możliwe (a jak coś jest możliwe, to zazwyczaj dzieje się w najgorszym momencie).

Czy można śmiało aktualizować ElasticSearch? Tak, ale pod warunkiem, że został zapewniony backup danych lub środowiska. Największym problemem jest downtime, czyli czas braku usługi, który będzie skutkował wyłączeniem bądź dysfunkcją aplikacji web.

A czy warto aktualizować? Tak!

Aktualizacja od 5.x

Producent zapewnia, że aktualizacja od wersji 5.6.3 nie wymaga downtime. Zachowanie kompatybilności wstecznej na poziomie fizycznym jest ogromnym plusem tego wydania.

Wyjątkiem jest użycie X-Pack Security bez SSL/TLS, co wymaga przekonfigurowania węzłów (włączenia SSL/TLS) i restartu całego klastra. Pomijam przypadki, gdzie w stacku używana jest np. Kibana i najlepiej użyć jest asystenta migracji.

Etapy migracji warto zatem podzielić na: - migrację 2.x do 5 - migrację 5.x do 5.6.3 - migrację 5.6.3 do 6.x

Koniec z mappingami

Nie mogę powiedzieć, że to mój osobisty sukces, ale wersja 6.0 wycofuje tzw. mappingi, czyli grupowanie atrybutów w bliżej nieokreślonym celu. O bezsensie mappingów pisałem szerzej we wpisie z 2016 roku.

Dzięki temu posunięciu indeksy są w końcu takie, jakie powinny być. Tworzenie bibliotek klienckich też staje się o prostsze (dokładnie o jedną warstwę), gdyż struktura indeksu nie musi być opisywana wieloma mappingami. Wydajność takich indeksów staje się lepsza, a zarządzanie nimi - prostsze.

ElasticSearch 6.x zachowuje kompatybilność pozostawiając obsługę mappingów w starych indeksach, sam ogranicza tworzenie dokładnie jednego mappingu do indeksu, aż w wersji 7.0 mappingi zostaną kompletnie usunięte (z wyjątkami w 9.0).

Twórcy ElasticSearch argumentują zmiany takimi słowami:

In the early days of Elasticsearch, we spoke about an “index” being similar to a “database” in an SQL database, and a “type” being equivalent to a “table”. This was a bad analogy that led to incorrect assumptions. In an SQL database, tables are independent of each other. The columns in one table have no bearing on columns with the same name in another table. This is not the case for fields in a mapping type.

Tak. Ta analogia była po prostu głupia.

In an Elasticsearch index, fields that have the same name in different mapping types are backed by the same Lucene field internally. In other words, using the example above, the user_name field in the user type is stored in exactly the same field as the user_name field in the tweet type, and both user_name fields must have the same mapping (definition) in both types.

I dokładnie to krytykowałem w moim poprzednim wpisie.

Cieszę się, że twórcy ElasticSearch w końcu to dostrzegli. Czekam, aż przyjmą do wiadomości, że ich API HTTP/JSON nie jest RESTful :)

Co nowego w ElasticSearch 6.0?

Wersja 6.0, oprócz pierwszych kroków do usunięcia naprawdę głupich mappingów, poprawia wydajność wyszukiwania, szczególnie skracając czasy wyszukiwania posortowanych zbiorów, ale też usprawnia w wyszukiwanie rozproszone oraz bezpieczeństwo.

To pierwsza wersja ElasticSearch, do której gorąco namawiam obecnych i potencjalnych klientów. Zainteresowanych proszę o kontakt przez stronę firmową lub telefonicznie +48326100016.

Prawa Murphy'ego jako narzędzie warsztatowe

Prawa Murphy'ego znane sią chyba każdemu, także osobom nietechnicznym, od dobrych dwóch dekad. W tym krótkim wpisie przytoczę kilka "praw", które zazwyczaj traktowane z przymrużeniem oka, są podstawą rzemiosła profesjonalisty, a szczególnie administratora lub wdrożeniowca.

Stosując metodykę DevOps, a nawet ograniczając się tylko do samego continuous delivery lub continuous deployment, istotnym czynnikiem jest zapewnienie jakości. Przeprowadzając wszelakie operacje w środowiskach produkcyjnych istnieją dwie naczelne zasady: możliwie najkrótszy downtime (lub zero downtime) oraz niedopuszczanie do wprowadzania wadliwych wersji.

Mówiąc ogólnie - naczelną zasadą jest nie szkodzić użytkownikowi eksploatowanego systemu. A jak powszechnie wiadomo człowiek jest z zasady istotą omylną, najsłabszym ogniwem każdego łańcucha, dlatego do zapewnienia jakości każdemu profesjonaliście potrzebne są odpowiednie procedury postępowania.

Ciekawą pomocą w ich opracowywaniu, prawdopodobnie stosowaną powszechnie nieświadomie, są moim zdaniem Prawa Murphy'ego. Każdy odpowiedzialny profesjonalista powinien być w jakiejś części sceptykiem i pesymistą, choć niekoniecznie musi to okazywać ;)

Jeżeli coś może się nie udać, to się nie uda.
Jeśli coś może pójść źle, to pójdzie.

Powinny być podstawowymi zasadami każdego inżyniera operującego w środowiskach produkcyjnych. Błąd operatora jest jedną z najczęstszych przyczyn awarii. Przykłady można mnożyć - od przypadkowych usunięć danych, długich dysfunkcji usług sieciowych (co jest krytyczne w modelu SaaS), aż po spektakularne wycieki danych (spowodowane np. pozostawieniem "otwartego na świat" narzędzia). Nie, chmury nie rozwiązują "magicznie" takich problemów ;)

Opracowując procedury postępowania, czy to tworząc playbooki Ansible lub innego narzędzia orkiestracji, czy też wykonując operacje typowo manualne, powinno się mieć na uwadze że każdy krok może się nie powieść. Powinno się też przewidzieć w jakim stanie będzie system i jaki będzie sposób przywrócenia do stanu pierwotnego.

Jeśli wiesz, że coś może pójść źle i podejmiesz stosowne środki zapobiegawcze, to źle pójdzie coś innego

Zawsze trzeba mieć plan "B". Nawet zwykły backup. Często robiąc wdrożenia mocno modyfikujące dane, skracam RPO do absolutnego minimum tworząc kopie danych tuż po przejściu w tryb przerwy technicznej (aby wyeliminować napływ nowych danych). Czasem robię hotcopy zakładając, że częściowe odzyskanie danych będzie możliwe i nie będzie wiązać się z nadpisaniem nowszych informacji. Ostatecznie zawsze pod ręką powinien być backup całej maszyny lub okresowe dumpy danych.

Moje plany "B" zazwyczaj są wielowarstwowe, przez co na wiele niespodziewanych przypadków jestem odporny z zachowaniem RPO (Recovery Point Objective) oraz RTO (Recovery Time Objective) wynikającego z przyjętej polityki bezpieczeństwa.

Powinieneś być przerażony, gdy dziecko jest zbyt ciche.

Ta "prawda życiowa" ma swoją analogię w świecie IT. System wykazujący zbyt niskie obciążenie w stosunku do typowego dla danego dnia i pory dnia, powinien być poddany natychmiastowej inspekcji. Oznacza to, że konieczne są narzędzia monitorujące pracę systemu, ale nie tylko on-line ale również gromadzące dane historyczne. Nie ma lepszego sposobu jak ogląd krytycznych parametrów systemu w formie wykresów.

Tu warto zauważyć, że skuteczny monitoring wymaga personelu, który w niego patrzy i reaguje na jego zgłoszenia. Bez tego każdy system monitorowania jest niestety niemym głosem tonącego.

Nigdy nie mów „ups”, gdy pacjent jest przytomny

Profesjonalista nigdy nie poddaje się, gdy obsługuje awarię. Nie można wycofać się nawet wtedy, gdy za awarię w 100% odpowiedzialny jest ktoś inny. W opanowaniu sytuacji pomogą ci procedury i twoje plany "B".

Z tego wynika jeszcze coś, co nie jest oczywiste. Musi istnieć dokumentacja techniczna systemu, z której pomocą inżynier będzie mógł podejmować odpowiednie działania. Alternatywą jest posiadanie pełnej wiedzy o funkcjonowaniu całego systemu i sposobie jego obsługi, ale to podejście ma istotne wady, gdyż...

Wiedza i doświadczenie przychodzą z wiekiem. Najczęściej jest to wieko od trumny.

... każdy jest śmiertelny. Jeśli z nim zniknie wiedza, to biznes będzie miał niemały kłopot. Tworzenie dokumentacji technicznych oraz instrukcji obsługi systemu/podsystemów jest częścią pracy profesjonalisty. Zawsze trzeba na to mieć czas.

Ten, który się waha, ma prawdopodobnie rację

Trzeba być wyczulonym na takie sygnały. Wynikają one zazwyczaj z umiejętności przewidywania oraz z doświadczenia. Znowu zabezpieczeniem są procedury, plany "B".

Zawodowcy są przewidywalni – strzeż się amatorów

Ta zasada dotyczy doboru odpowiednich ludzi. Współpracuję z profesjonalistami z branży. Na tej samej zasadzie zapraszam do skorzystania z moich usług.

1000zł/m-c

Utrzymanie aplikacji web Python/Django

obejmuje monitoring, obsługę awarii i pakiet roboczogodzin na rozwój bądź korekty

TypeScript w praktyce

Drodzy webdeveloperzy!

Proszę, przestańcie tworzyć oprogramowanie webowe w TypeScript!

Natywnym językiem programowania dla webdevelopmentu jest implementacja EcmaScriptu w postaci Javascriptu. Kod generowany przez kompilator TypeScriptu jest zły, nieoptymalny, przypomina sieczkę generowaną przez GWT (tam: Java->JavaScript). Sam proces kompilacji jest czymś zupełnie zbędnym (TS -> kompilacja -> JS -> interpretacja -> wykonanie). To generuje koszt, jest bezzasadnym utrudnieniem. Źródła są niemodyfikowalne bez znajomości TypeScriptu, który jest zbędny przy tworzeniu bibliotek i produktów javascriptowych o przeznaczeniu webowym.

Używajcie TypeScript tam, gdzie jest on zasadny - we własnych projektach, w swoim Visual Studio, w rozszerzeniach do swoich projektów lub ogólnie projektów "niewebowych". Tam, gdzie efekt prac jest kodem wykonywanym przez przeglądarki webowe, stosujcie JavaScript.

Programując rozwiązania webowe w TypeScript popełniacie ogromny błąd.

Colander i Django: ColanderSchemaField

Tym razem przedstawię rozwiązanie odwrotne do poprzedniego - zastosuję walidację schematem Colandera w formularzu Django.

Budując web API bardzo często istnieje potrzeba zwalidowania i odczytania nieco bardziej skomplikowanej struktury. W przypadku gdy po stronie serwera modelem jest obiekt klasy django.db.Model, to zapewne stosowany jest formularz klasy django.forms.ModelForm. To częsta i dosyć wygodna praktyka. Jednak jeśli wejście jest bardziej złożone, a dzieje się tak często podczas enkapsulacji interfejsu za pomocą RESTful API, to istnieje tendencja do tworzenia wielu formularzy, adapterów typu complex/composite forms, albo do zmiany modelu w celu odzwierciedlenia go w interfejsie 1:1.

Żadne z tych rozwiązań nie jest wystarczająco dobre, ponieważ wiele formularzy to bardziej złożona logika walidacji i brak spójności tejże (także w kontekście komunikatów o błędach), composite/complex forms narzucają tworzenie zlepek z wielu modeli (co też nie zawsze jest konieczne), a zmiany modelu mogą być zbędne i kosztowne.

Przypuśćmy, że bardziej złożona struktura to dodatkowy poziom zagnieżdżenia danych wejściowych. Naturalnym krokiem, szczególnie przy budowaniu JSON API, jest dodanie pola typu JSONField, ktore zdeserializuje część JSON-a do słownika. Byłoby to rozwiązanie dobre gdyby nie fakt, że walidacji podlega tylko syntaktyka przekazanej wartości. Nazwy kluczy, czyli ich prawidłowość oraz ich typy i wartości, pozostają nieweryfikowalne. I nie jest to winą JSON-a, tylko jego użycia (może wynikać wprost z zastosowania prostego pola typu JSONField).

Problem można rozwiązać używając pakietu Colander, za pomocą którego definiuje się dowolne schematy. Schematy Colander mogą być komponowane w złożone struktury, ale mogą być też używane pojedynczo. Colander nie narzuca tutaj żadnej konkretnej struktury oraz nie wprowadza zbędnych zależności, zatem użycie go jest bardzo wygodne.

Rozważmy bardzo prosty schemat opisujący współrzędne geograficzne:

class LocationSchema(colander.MappingSchema):
    lat = colander.SchemaNode(colander.Float())
    lng = colander.SchemaNode(colander.Float())

Powyższy schemat przyjmie słownik z kluczami lat oraz lng, których wartości muszą być liczbami zmiennoprzecinkowymi. Krótko mówiąc przyjmie on dane postaci: {"lat": 50.0, "lng": 50.0}.

Taki schemat można podpiąć bezpośrednio pod formularz Django, tak jak każde inne pole:

class LocationForm(forms.ModelForm):
    destination = ColanderSchemaField(LocationSchema, required=True)

Oczywiście potrzebny jest adapter - dedykowane pole dla formularzy Django, za pomocą którego proces walidacji zainstancjonuje schemat LocationSchema, zweryfikuje oraz oczyści dane wejściowe.

Ciało tego adaptera jest bardzo proste:

class ColanderSchemaField(forms.Field):
    def __init__(self, schema_factory, *args, **kwargs):
        self.schema_factory = schema_factory
        super(ColanderSchemaField, self).__init__(*args, **kwargs)

    def clean(self, value):
        if value is None:
            return

        schema = self.schema_factory()
        try:
            return schema.deserialize(value)
        except colander.Invalid as ex:
            raise forms.ValidationError(ex)

Od tej chwili formularz będzie oczekiwał klucza destination o strukturze określonej klasą LocationSchema.

Jednak zapis tej informacji oczywiście nie nastąpi, ponieważ ColanderSchemaField nie jest częścią modelu. Zapis trzeba przeprowadzić jawnie:

class LocationForm(forms.ModelForm):
    # ...

    def save(self, commit=True):
        instance = super(LocationForm, self).save(commit=False)
        destination = self.cleaned_data['destination']

        instance.destination_lat = destination['lat']
        instance.destination_lng = destination['lng']

        if commit:
            instance.save()
        return instance

Jeśli model miałby zdefiniowane pole destination klasy django.contrib.postgres.fields.JSONField lub analogicznej, to aktualizacja za pomocą ColanderSchemaField powinna przebiec automatycznie bez konieczności nadpisywania metody save(). Niestety jeszcze nie testowałem tego wariantu, do czego gorąco zachęcam.