Posty oznaczone etykietą rest

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.

Colander i Django: ModelChoice

Budując REST API mogą przydać się walidatory danych wejściowych bardziej elastyczne niż formularze Django. Do realizacji tego celu polecam i używam Colander.

Colander jest świetnym pakietem do (de)serializacji danych. Jest to odpowiednik formularzy Django dla Pyramida, lecz zdecydowanie bardziej elastyczny oraz bez związku HTML. Formularze Django są źle zaprojektowane - zbyt ściśle związane z HTML (widgety), których przecież nie używamy budując REST API, oraz są ograniczone do płaskich struktur. Jedynie jest są dyspozycji formsety, co pozwala zwalidować i zdeserializować listę obiektów jednego typu.

Colander wolny jest od tych wad, a ponieważ nie ma zależności od Pyramid (jest pakietem samodzielnym) to można go użyć z Django bez żadnego problemu. Jednak w niektórych sytuacjach brakuje mi pola typu ModelChoice, co utrudnia fabrykowanie instancji modeli. Z tego powodu zrobiłem poniższy snippet definiujący walidator ModelOneOf oraz klasę węzła ModelChoice. Jest na tyle przydatny, że postanowiłem się z nim podzielić jak najszybciej:

import colander
import types


class ModelOneOf(object):
    def __init__(self, qs):
        self._qs = qs

    def __call__(self, node, value):
        if not self._qs.filter(pk=value).exists():
            raise colander.Invalid(node, '%r is not valid choice' % value)


class ModelChoice(colander.SchemaType):
    def __init__(self, qs, *args, **kw):
        self._qs = qs
        self._model = qs.model
        self._validate = ModelOneOf(self._qs)

        super(ModelChoice, self).__init__(*args, **kw)

    def serialize(self, node, appstruct):
        if appstruct is colander.null:
            return colander.null
        if not isinstance(appstruct, self._model):
            raise colander.Invalid(
                    node, '%r is not a %s' % (appstruct, self._model))
        return appstruct.pk

    def deserialize(self, node, cstruct):
        if cstruct is colander.null:
            return colander.null
        if not isinstance(cstruct, (types.StringType, int)):
            raise colander.Invalid(
                    node, '%r is not a string nor int' % cstruct)

        self._validate(node, cstruct)
        return self._qs.get(pk=cstruct)

Przykładowe użycie (forms.py):

import colander
from .models import MyModel


class MySchema(colander.MappingSchema):
    model = colander.SchemaNode(ModelChoice(MyModel.objects.all()))
In [1]: s = MySchema()
In [2]: s.deserialize({'model': '1'})  # gdzie '1' to wartość PK
Out[2]: {'model': <MyModel pk=1>} 

Teraz zdeserializowane dane wejściowe da się wprost przekazać do fabryk lub konstruktora modeli, np:

instance = MyModel(**s.deserialize(...))

W ten sposób można całkowicie pominąć formularze django.

RESTful JavaScript client w kwadrans

Próbowałem restful.js, próbowałem jQuery REST client, aż ostatecznie dałem sobie spokój. Dla mnie były jakieś trudne/ciężkie w użyciu. A czym powinien być REST client? Cienkim wrapperem na dowolnego klienta HTTP, który gada z dowolnym url-em.

Na własne potrzeby założyłem, że:

  • interfejs ma być banalnie prosty w użyciu
  • wystarczy obsługa tylko content type application/json
  • ma być obsługa nagłówków, także defaultowych (żeby się nie powtarzać)
  • dozwolona jest zależność od jQuery (można się tego względnie łatwo wyzbyć)

Powstał prototyp:

var RestAPI = function(url, params) {
    var absoluteUrlPattern = /^https?:\/\//i,
        self=this,
        params = params || {};

    this.defaultHeaders = params.headers || {};
    this.url = url;

    function isAbsoluteUrl(url) {
        return absoluteUrlPattern.test(url);
    }

    function resourceToUrl(resource) {
        resource = resource || '';
        if(isAbsoluteUrl(resource)) {
            return resource;
        } else {
            return self.url+'/'+resource;
        }
    }

    function makeResponseObject(jqXhr, data) {
        var headers = {},
            headersList = jqXhr.getAllResponseHeaders().split('\r\n');
        $.each(headersList, function(i, headerStr) {
            var headerTuple = headerStr.split(': ');
            if(headerTuple[0]) {
                headers[headerTuple[0].toLowerCase()] = headerTuple[1];
            }
        });
        return {
            status: jqXhr.status,
            statusText: jqXhr.statusText,
            headers: headers,
            data: data 
        };
    }

    function doMethod(type, resource, data, headers) {
        var dfr = $.Deferred(),
            completeHeaders = $.extend({}, self.defaultHeaders, headers);

        $.ajax({
            url: resourceToUrl(resource),
            type: type,
            dataType: 'json',
            contentType: 'application/json',
            headers: completeHeaders,
            data: typeof(data)!=='undefined' && data!==null ? JSON.stringify(data) : null
        }).then(function(respData,respStatus,jqXhr) {
            dfr.resolve(respData, makeResponseObject(jqXhr, respData));
        }, function(jqXhr) {
            dfr.reject(makeResponseObject(jqXhr));
        });
        return dfr.promise();
    };


    this.get = function(resource, params, headers) {
        return doMethod('GET', resource, params, headers);
    }

    this.post = function(resource, payload, headers) {
        return doMethod('POST', resource, payload, headers);
    }

    this.put = function(resource, payload, headers) {
        return doMethod('PUT', resource, payload, headers);
    }

    this.patch = function(resource, payload, headers) {
        return doMethod('PATCH', resource, payload, headers);
    }

    this['delete'] = function(resource, headers) {
        return doMethod('DELETE', resource, null, headers);
    }

}

Działa? Działa, i to ciekawie.

Przykład - kto mnie śledzi na GitHub?

var github = new RestAPI('https://api.github.com');

github.get('users/marcinn').then(function(user) {
    github.get(user.followers_url).then(function(followers) {
        console.log(followers);
    });
});

Koniec, kropka. Prosto i na temat. Pobawta się sami na plunkr.

Jak kogo interesuje - publikuję na licencji "Brać! Mać..." oczywiście bez żadnej gwarancji. Wspomnijta w creditsach, że był taki Nowak, któremu to się klepać kodu nie chciało.

I jeszcze jedno - skoro się niektórzy chwalą filesize, to ja też podam:

  • Original: 2370 bytes
  • Gzipped: 764 bytes

Niech przebijają.


A AngularJS i jego ngResource albo nawet Restangular? Głupotą jest mapowanie REST na CRUD (albo stosowanie active record). W REST mamy dobrze określone VERBS. Nie ma wśród nich save. I tyle w temacie.

REST po ludzku

Znalazłem ostatnio dwa ciekawe wpisy traktujące o architekturze REST. Pierwszy z nich jest autorstwa niezastąpionego Martina Fowlera, który ciekawie przedstawił drogę od prostych usług typu RPC aż po podstawy RESTful (ale nie samego REST-a). Zdecydowanie ułatwia przejście od jednej do drugiej koncepcji: http://martinfowler.com/articles/richardsonMaturityModel.html

Z kolei drugą perełką jest wpis Ryana Tomayko, programisty GitHuba i Heroku, który wytłumaczył idee RESTful (wirtualnej?) żonie. Niestety środowisko gender zaatakowało Ryana i autor usunął artykuł-wywiad, ale na szczęście (w tym wypadku) Internet zachował jego kopie. Znajdziecie takową m.in. na archive.org: http://web.archive.org/web/20130116005443/http://tomayko.com/writings/rest-to-my-wife

Miłej lektury!

Kiedy Django zawodzi

Słoneczny dzień

Django jest świetnym rozwiązaniem należącym do kategorii full stack framework. Spójna budowa, setki aplikacji, rozwiązania generyczne (content types framework, comments framework) - batteries included. Świetne narzędzie do realizacji małych i średnich projektów, pozwala zaoszczędzić wiele czasu, nawet gdy istnieje potrzeba napisania od podstaw własnych rozszerzeń. Ostatnio jednak natrafiłem na mur.

Nadciągają cumulusy, czy rozumiesz co to znaczy?

Realizuję zupełnie nową funkcjonalność do portalu górskiego , która generuje sporo żądań XHR. Z rozpędu dodałem widoki w Django i podpiąłem je do szablonów. Rezultat powalił mnie... powolnością odpowiedzi. I to nie chodzi już o benchmarki, tylko o zwykłe odczucia użytkownika.

Szukając rozwiązania udałem się w kierunku Twisted i Tornado. Twisted jest dojrzałym frameworkiem sieciowym sterowanym zdarzeniami, a Tornado młodym web serverem napisanym specjalnie na potrzeby FriendFeed. Obydwa produkty cechuje wysoka wydajność, lecz z racji prostoty Tornado zająłem się nim na pierwszym miejscu. Niestety polubiłem go i mam nadzieję, że kiedyś wrócę do Twisted przy realizacji jakiegoś projektu. Do rzeczy.

Tornado

Tornado wieje z siłą urywającą nie łeb, ale setki głów. Niech poniższy kod (zacytowany ze strony Tornado) posłuży za prosty benchmark i przykład jednocześnie:

import tornado.httpserver
import tornado.ioloop
import tornado.web

class MainHandler(tornado.web.RequestHandler):
    def get(self):
        self.write("Hello, world")

application = tornado.web.Application([
    (r"/", MainHandler),
])

if __name__ == "__main__":
    http_server = tornado.httpserver.HTTPServer(application)
    http_server.listen(8888)
    tornado.ioloop.IOLoop.instance().start()

Wyniki prostego pomiaru na mojej niespecjalnej maszynie:

$ ab -c 1000 -n 10000 http://127.0.0.1:8888/
>> 3208.29 [#/sec]

Zainteresowanych dokładniejszymi testami odsyłam do porównania serwerów asynchronicznych.

Oczywiście docelowo nie będzie tak różowo. Zapytania bazodanowe i kosztowne algorytmy osłabią ten wynik. Krótkie podsumowanie wydajności tej samej funkcji REST API:

  • Django/ORM: 2.47 rq/sec
  • Django/plain db queries (podsystem jako osobny pakiet): 66 rq/sec
  • Tornado/plain db queries (dokladnie to samo, co wyżej): 215 rq/sec (-c100 -n10000, 0 failed)
  • Tornado/plain db queries/in memory cache: 2183 rq/sec (-c100 -n10000, 0 failed)

Django server zapychał się już przy 400 requestach przy concurrency 10.

Jak przetrwać burzę

Jeśli projekt oparłeś w całości na Django, to skazany będziesz albo na refaktoryzację, albo na kombinatorykę pozwalającą importować moduły z aplikacji Django. Ja popełniłem ten błąd i umieściłem API oraz algorytmy w pakiecie zależnym od Django. Na szczęście był to dopiero prototyp i przeniosłem całość do odrębnego pakietu.

Prawidłowe rozwarstwienie podsystemu uczyni go łatwym do integracji. Dobrze jest wydzielić core do oddzielnego pakietu. Nie stosować w nim rozwiązań opierających się na elementach frameworków typu full-stack, szczególnie nie przywiązywać się do warstwy dostępu do danych. Zastosować wzorzec proxy, który ułatwi również opracowanie unit testów. Nie ulegać magii ani kucom na każdym kroku.