Posty oznaczone etykietą ethos

Kij w mrowisko: Django Sprint 2014, Kraków

15 i 16 lutego 2014 odbył się Django Sprint w Krakowie, na którym najlepsi programiści Python poprawiali błędy Django. A ja zapytuję - po co poprawiać coś, co jest w wielu miejscach źle zrealizowane "by design"?

Oczywiście przesadzam, ale nie mogę oprzeć się krytyce, skoro na prawdę istotne wady Django nie są korygowane a istotne feature requesty są odrzucane za "niezgodność z Django ethos". Kilka najbardziej irytujących błędów i braków na mojej liście:

Brak model.reload()

Odświeżenie properties obiektu jest czasem konieczne, bo DjangoORM nie ma sensownego persistence managera (błąd projektowy polega na własnej i przeciętnej implementacji ORM, zamiast zastosowania sprawdzonych wzorców lub gotowych rozwiązań).

Obecnie trzeba obchodzić przez sekwencję

obj = Model.objects.get(pk=obj.pk)

Skopany transaction management

Do v1.6 mamy do dyspozycji dekoratory commit_on_success oraz commit_manually, a także low-level API z funkcjami commit, rollback, savepoint. Problemy są następujące:

  • obsługa commit_manually sprowadza się do zabawy w dostawianie rollbacków i commitów w odpowiednich miejscach, np. przed/po response, w przeciwnym razie otrzymujemy TransactionManagementError z poziomu template/middleware.
  • przy commit_manually każdy wyjątek opakowywany jest w TransactionManagementError, więc trzeba używać debuggera lub usuwać wsparcie dla transakcji, aby dowiedzieć się o szczegółach błędu (sic!)
  • od wersji 1.6 mamy atomic, który zastępuje powyższe funkcje i upraszcza obsługę transakcji, ale od v1.8 nie będzie już żadnej możliwości niskopoziomowego kontrolowania transakcji (będzie prościej i jednoznacznie, ale ze związanymi rękoma)
  • wsparcie multidb jest skopane, więc szereg funkcji i metod ma dodatkowy parametr using, który domyślnie oznacza bazędefault. W przypadku dekoratorów i funkcji związanych z transakcjami ma to fatalny skutek w postaci zarządzania wyłącznie transakcjami dla bazy domyślnej, ale niekoniecznie używanej w danym widoku (!!!), chyba że jawnie użyje się N dekoratorów dla wszystkich używanych baz (to nie jest czytelne i takie oczywiste, szczególnie przy class-based views).

Formularze spaghetti

Związek formularzy z HTML (widgetami) to fatalny błąd projektowy. Formularze służą walidacji i czyszczeniu danych i czasem zawierają w sobie jakieś konkretne operacje (np. form.save). Niestety każdy field jest ściśle związany ze swoim widgetem HTML, co widać wyraźnie przy próbie odczytania błędów formularza, gdzie jako reprezentację ciągu otrzymujemy HTML! Implementując RESTful service albo jakąkolwiek inną usługę nie związaną z HTML borykamy się z bagażem zależności, które trzeba sprytnie omijać.

Widgety zaimplementowane są w stylu "spaghetti" - w kodzie pythona odnajdujemy rzeź generującą koślawe HTML-e (nienależyta separacja prezentacji od kontrolera). Aby dostosować prezentację trzeba uciekać się do pakietów rozszerzających możliwości parametryzowania prezentacji (Uniform, Crispy forms), ale one też ostatecznie nie są elastycznym i poprawnym rozwiązaniem. Problemy wynikają właściwie z nieco ułomnego systemu szablonów (zbyt wiele działań oddelegowanych jest do helperów).

Wadliwa jest obsługa pól, gdzie definicja Meta.fields jest "magicznie" uzupełniana o wszystkie pola dodane w klasie formularza. Dziedziczenie po takim formularzu uniemożliwia proste ograniczanie używanych pól i trzeba usuwać je w __init__ (!).

Wadliwa jest obsługa formularzy bez pól, w przypadku których Django zakłada zawsze brak błędów, mimo zdefiniowania metody clean ze ścisłymi regułami!

Ograniczony system szablonów

Django ethos nie pozwala na dostarczenie tak podstawowego template taga czy rozwiązania, jakim jest dostęp do danych przez klucz/indeks określony zmienną. Za każdym razem trzeba pisać swój template tag lookup czy get_key

Ze względu na strukturę ograniczone są możliwości łatwej wizualizacji bardziej skomplikowanych obiektów, jak np. formularzy.

Co robić, wuju?

Jak radzić sobie z Django w prawdziwych projektach podpowiada Christophe Pettus z PostgreSQL Experts, Inc w PDF Unbreaking Django.

Django i None/null w BooleanField (forms)

Dostałem kolejnym issue związany z "nietypowym" zastosowaniem Django, a typowym w prawdziwym świecie.

Powszechnie wiadomo, że formularze w Django służą do walidacji danych. Niestety błąd projektowy Django polega na tym, że formularze są ściśle związane z danymi wejściowymi pochodzącymi z HTML, a konkretnie związane są widgetami HTML.

Formularze można wykorzystać do walidacji jakichkolwiek wejściowych danych (np. przy imporcie z CSV), przełykając niepotrzebne widgety HTML oraz godząc się na komunikaty o błędach walidacji formatowane do HTML (co jest także swoistym nonsensem, ale od v1.7 będzie już możliwość zwrócenia jako dict lub JSON). Jednak kiedy spróbujecie zrobić API i formularz walidujący dane wejściowe z requestu z użyciem NullBooleanField, to się niemiło zaskoczycie.

Praktyka, czyli rozbicie się o mur

Potrzebujemy filtrowania zasobu restful API i parametry przkazywane przez GET mają charakter opcjonalny. W przypadku parametrów typu bool potrzebujemy rozróżnienia trzech stanów:

  • nie użyto filtra
  • filtr dla wartości True - cleaning z [1,'1','true','True'], jak w to_python()
  • filtr dla wartości False

Dobrym zwyczajem walidację filtra przepuszczamy przez formularz. Podczas definicji formularza mamy dwie możliwości:

  • forms.BooleanField, który mimo required=False defaultowo zwraca zawsze False, zatem odpada
  • forms.NullBooleanField, który zezwala na None

Niestety żadne z powyższych nie działa dla wartości wejściowych ze zbioru [1,'1','True','true']. Pierwsze wiadomo dlaczego: z definicji nie przyjmuje None, więc mamy False by default. Ale dlaczego nie działa NullBooleanField? Bo NullBoolenField w cleaningu odwołuje się do parsowania danych za pomocą... widgetu NullBooleanSelect, którego implementacja wygląda tak:

def value_from_datadict(self, data, files, name):
    value = data.get(name, None)
    return {u'2': True,
            True: True,
            'True': True,
            u'3': False,
            'False': False,
            False: False}.get(value, None)

Niech was gęś kopnie, cudaczni developerzy Django! Nie wszędzie używa się wejścia z waszych wbudowanych widgetów HTML! Dlaczego widget HTML jest domyślny i używany nawet przy wejściu nie pochodzącym z tego widgetu? Nie wiedzieć... Może to Django ethos.

Obejście problemu

class MyNullBooleanSelectWidget(forms.NullBooleanSelect):
    def __init__(self, *args, **kw):
        super(MyNullBooleanSelectWidget, self).__init__(*args, **kw)
        self.choices = ((u'', _('Unknown')),
                   (u'1', _('Yes')),
                   (u'0', _('No')))

    def render(self, *args, **kw):
        """
        tu mozna sobie zaimplementowac rendering w razie potrzeby,
        bo Django developers zlamali DRY i nie uzywaja choices 
        w renderingu; (kolejny klops na kolejny wpis)
        """
        raise NotImplementedError("Not used")

    def value_from_datadict(self, data, files, name):
        value = data.get(name, None)
        if value is None or value=='':
            return None
        if value in ('True', 'true', '1', 1):
            return True
        return False


class MyNullBooleanField(forms.NullBooleanField):
    widget = MyNullBooleanSelectWidget

W formsach API używamy MyNullBooleanField zamiast forms.NullBooleanField. A dla profesjonalnych zastosowań w kontekście budowania API polecam Colander i oczywiście Pyramid.

Konkluzja

Nie pchajcie się ludzie w Django, jeśli nie robicie kolejnego bloga albo frontendu dla jakiegoś portalu.

Opisane zachowanie oparłem o Django v1.4, ale w źródłach master widzę ten sam gnój.

Django handler500, request.is_ajax() i AngularJS

Kolejny wypadek Django przy pracy, który powinien przekonać niedowiarków i wyznawców Django, że ten framework nadaje się do stawiania blogasków, ale niespecjalnie jako server side dla aplikacji.

handler500 i DEBUG=True

Przy włączonym DEBUG handlery 403,404 i 500 nie są używane. Do akcji wkraczają tzw. technical responses, które generują specjalny output dla poszczególnych błędów. Z reguły są one bardzo przydatne, ale pisząc server pod klienta w AngularJS w response otrzymujemy... piękny html ze stylami i css. Przeczytanie błędu graniczy z cudem, a zdevelopowanie poprawnej obsługi błędu w kliencie jest niemożliwe w trybie DEBUG. Niemożliwe, bo nie da się podmienić technical responses. Są one wołane w core handlerze, więc nie ma na to szans.

Możemy w konfiguracji podmienić exception filter, ale on jest używany przez exeption reporter a to na jego wymianie lub rozszerzeniu nam zależy, jeśli chcemy cokolwiek sensownego zrobić z obsługą błędów 403,404,500 w trybie DEBUG!

AngularJS i request.is_ajax()

Implementacja is_ajax() opiera się na jakiejś konwencji. Po pierwsze - skoro to konwencja, to powinno dać się ją zmienić na potrzeby projektu. Oczywiście nie da się, podobnie jak nie da się prosto wymienić klasy HttpRequest, w której podmieniłbym is_ajax (trzeba grzebać low-level na poziomie handlerów wsgi). Monkey patching mnie nie interesuje - readability counts.

Natomiast można skonfigurować AngularJS do współpracy ze zmaszczonym niespecjalnie rozsądnymi rozwiązaniami Django, wystarczy skonfigurować nagłówek:

angular.module('myApp').config(function($httpProvider) {
    $httpProvider.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
});

A co zrobili Django developers?

Ano dodali kiedyś takiego paszkwila w odpowiedzi na zgłoszenie:

if request.is_ajax():
    text = reporter.get_traceback_text()
    return HttpResponseServerError(text, content_type='text/plain')

Świetnie, ale to nie rozwiązuje problemu, bo:

  • HTTP_ACCEPT mam application/json i chcę mieć w response JSON z błędem (!!!)
  • niewiadomo jaki tekstowy response jest słabo parsowalny przez jakąkolwiek implementację JSON-a

Django developers są szczęśliwi i dumni ze swojego podejścia znanego jako "Django ethos". Sukcesów życzę i omijania Django w dużych projektach.