Wprowadzenie do tworzenia aplikacji w ember.js z Django

emberjs to nowy framework JavaScriptowy do tworzenia interaktywnych aplikacji internetowych. Przez interaktywność można rozumieć wiele różnych zachowań. W odróżnieniu od aplikacji napisanej bardziej klasycznie nie musimy sami pisać warstwy wysyłającej żądania AJAX w celu pobrania, modyfikacji danych. Nie zakopujemy się także w operacjach na DOM-nodach, czy przechowywaniu danych pomiędzy akcjami użytkownika. Ember zdejmuje z programisty wiele upierdliwych aspektów tworzenia aplikacji internetowych z rozbudowaną interaktywną warstwą JavaScriptowych widgetów.

emberj.js to dość młody framework i na chwilę obecną trzeba pogodzić się z nieco ubogą dokumentacją. Większość bazy wiedzy znajdziemy na StackOverflow czy na kanale IRC. Konkurentami w tej samej kategorii są frameworki takie jak Backboje.js, czy Angular.js. Każdy ma jakieś dobre i słabe strony.

W tym artykule przedstawię aplikację ukazującą framework w działaniu i sposób w jaki programuje się z nim aplikacje. Przyda się także znajomość django-tastypie. Gotowy projekt można też znaleźć na githubie.

Instalacja i konfiguracja

Zaczynamy od Django i backendu. Potrzebować będziemy Django w wersji 1.5 (lub nowszej) lub gdy nie jest to możliwe - starsze z dodanym do szablonów tagiem "verbatim". Na początek tworzymy projekt i przykładową aplikację. Do tego katalog na szablony i pliki statyczne.
django-admin.py startproject ember_showcaser
cd ember_showcaser/
django-admin.py startapp quotes
mkdir quotes/static
mkdir quotes/templates
Następnie instalujemy (pip) django-tastypie oraz south. Tastypie posłuży do generowania RESTowego API dla djangowskich modeli, natomiast south używany jest w migracjach zmian w modelach (w tym też modeli tastypie). Do INSTALLED_APPS dodaj:
'tastypie',
'south',
Oraz dostosuj konfigurację settings.py do twoich potrzeb. Ja ustawiłem bazę na SQLite, odblokowałem panel admina (w settingsach i urls.py) po czym wykonałem:
manage.py syncdb
manage.py migrate
Powinniśmy mieć teraz działający projekt Django.

Instalacja ember.js

Cała instalacja ember.js polega na pobraniu i wrzuceniu paru plików do statyki naszej aplikacji. Na chwilę obecną nie jest to takie szybkie jakby mogło się wydawać.

Ze strony emberjs.com/builds/ pobierz plik ember.js oraz ember-data.js. Ze strony handlebarsjs.com pobierz plik handlebars.js. Z repozytorium ember-data-tastypie-adapter pobierz (packages/ember-data-tastypie-adapter/lib) tastypie_adapter.js i tastypie_serializer.js.

Gdy mamy już te wszystkie pliki statyczne gotowe (ważne by mieć jak najnowsze wersje) możemy przystąpić do stworzenia widoku Django wyświetlającego prosty szablon z tymi że plikami JavaScript.

Podstawowy widok

W views.py naszej przykładowej aplikacji tworzymy prosty widok wyświetlający szablon:
from django.views import generic


class MainPageView(generic.TemplateView):
    template_name = 'index.html'

main_page = MainPageView.as_view()
W urls.py podpinamy go np. jako główną stronę:
url(r'^$', 'quotes.views.main_page'),
Najważniejszy w tym wszystkim szablon HTML wygląda tak:
<!doctype html>
<html>
<head>
    <title>Ember.js showcase</title>
    <meta charset="utf-8"/>
    <script src="{{ STATIC_URL }}js/vendor/jquery-1.11.0.min.js" type="text/javascript"></script>
    <script src="{{ STATIC_URL }}js/vendor/handlebars.js" type="text/javascript"></script>
    <script src="{{ STATIC_URL }}js/vendor/ember.js" type="text/javascript"></script>
    <script src="{{ STATIC_URL }}js/vendor/ember-data.js" type="text/javascript"></script>
    <script src="{{ STATIC_URL }}js/vendor/tastypie_serializer.js" type="text/javascript"></script>
    <script src="{{ STATIC_URL }}js/vendor/tastypie_adapter.js" type="text/javascript"></script>
    <script src="{{ STATIC_URL }}js/quotes.js" type="text/javascript"></script>
</head>
<body>
<h1>Ember.js Showcase Quotes App</h1>
</body>
</html>
Wszystkie plik JavaScript "osób trzecich" umieściłem w katalogu quites/static/js/vendor/. Do tego dodałem pusty plik quotes.js na kod naszej emberowej aplikacji. W tym momencie powinniśmy mieć wyświetlający się szablon ze wszystkimi plikami (i brak błędów w konsoli JavaScript przeglądarki). Kolejność plików ma znaczenie.

Aplikacja w Ember.js

Struktura aplikacji

Aplikacja napisana w ember składa się z kilku różnych warstw. Mamy szablony, kontrolery, widoki, modele, a także routing. Koncept tego framework różni się np. od Django i żeby dobrze zrozumieć embera trzeba dobrze poznać te warstwy i ich rolę.

Szablony obsługiwane są przez bibliotekę handlebars. Oferuje ona nieco podobne do djangowskich tagi i raczej dość szybko do nich przywykniemy. W odróżnieniu od systemu szablonów Django, czy niektórych innych JavaScriptowych Handlebars nie obsługują załączania szablonów z zewnętrznych plików więc zazwyczaj wszystkie szablony są w jednym pliku HTML (jak to wygląda w praktyce za chwilę).

Widoki (Views) są wbudowane w szablony i służą do przetwarzania "prymitywnych" zdarzeń jak np. kliknięcie w akcje bardziej znaczące dla kontrolera (np. zamiast zdarzenia "click" będziemy mieć np. "deleteItem" przekazanym z widoku do kontrolera). W najprostszych przypadkach nie będziemy nawet definiować żadnych widoków, ale jeżeli pojawią się jakieś formularze, widżety to wtedy widok będzie warstwą pośredniczącą między szablonem a kontrolerem.

Kontrolery (Controllers) to obiekt, który przechowuje stan aplikacji - dane jakie zostały pobrane, stworzone, zmodyfikowane, ustawione flagi i inne tego typu dane. Na podstawie stanu kontrolera renderowany jest szablon mu przypisany. Emberowski kontroler przypomina djangowski widok, lecz jest od niego bardziej rozbudowany/złożony. Kontroler ciągle żyje gdy wyświetlana jest strona HTML i w każdej chwili może np. zostać dodany rekord, czy może zmienić się jakaś flaga pod wpływem działań użytkownika.

Modele to obiekty Ember, które reprezentują dane dostarczane (w naszym przypadku) z modeli Django poprzez JSONowe API tastypie. Warstwa modeli ember pozwala na pobieranie, tworzenie i modyfikowanie danych. Ważną różnicą jest asynchroniczność. Żądanie pobrania danych z modelu nie zwróci od razu wyników, a jedynie obiekt, którego stan ulegnie zmianie po pobraniu rekordów. Ta asynchroniczność wpływa na kontrolery - na to jak są zbudowane i jak z nich będziemy korzystać. Ta asynchroniczność jest chyba największą różnicą do przełknięcia.

Routing, czy też obiekty-routery mapują adres URL na obiekt modelu i mówią kontrolerowi by reprezentował tenże obiekt i wyrenderował dla niego właściwy szablon. W uproszczeniu odpowiednik urls.py z Django lecz z dużym ale - bo samo mapowanie URLi to mały fragment routerów. Obiekty te można rozszerzać o własne funkcjonalności - np. ustawiać wiele obiektów modeli, czy podejmować akcje po zrenderowaniu szablonu itd. W funkcjonalności te wchodzi się stopniowo tworząc coraz bardziej złożone aplikacje.

Wszystko to opisane jest w dokumentacji.

Przy większych aplikacjach warto rozbić poszczególne warstwy na oddzielne pliki - modeli, kontrolerów, widoków oraz jeden lub dwa na routing (jeden na mapę, a drugi na ew. rozszerzenia do poszczególnych routerów). Ja w tym przykładzie użyję po prostu jednego pliku na cały kod aplikacji.

Tworzymy aplikację Ember

Plik quotes.js zawierać będzie kod JavaScript naszej aplikacji, natomiast index.html szablony handlebars. Zaczynamy od szablonu, w którym pojawia się główny szablon aplikacji:

<body>
{% verbatim %}
    <script type="text/x-handlebars">
        <h1>Ember.js Showcase Quotes App</h1>
        {{outlet}}
    </script>
{% endverbatim %}
</body>

Główny szablon renderowany jest przez aplikację. W miejsce taga "outlet" zostanie wstawiony kod HTML zwrócony przez routing dla danego URLa. W tym głównym szablonie możemy dodać elementy takie jak nagłówek, stopkę i cały szkielet dla outleta jeżeli potrzebujemy. Djangowski tag "verbatime" (dodany w Django 1.5) wyłącza wewnątrz tego bloku renderowanie djangowskich znaczników (a jak widać tagi handlebars byłyby rozpoznane przez Django).

Od strony JavaScriptu aplikację tworzymy jedną linijką:

(function($, undefined ) {
    Quotes = Ember.Application.create();

}(jQuery));
Teraz po przeładowaniu strony w konsoli JavaScript przeglądarki powinniśmy zobaczyć dane debuggerskie z Embera - wersje embera, handlebars i jQuery.

Modele Django i zasoby Tastypie

tastypie to aplikacja Django generująca RESTowe API dla modeli Django. Ember lubi takie dane i dlatego podstawą w aplikacjach ember są zasoby Tastypie dla modeli. W przypadku aplikacji Emberowych na API Tastypie spada sporo odpowiedzialności. API musi zwracać tylko te dane, które użytkownik powinien móc zobaczyć (walidacja). Przy zapisie lub modyfikacji API musi być bardzo nieufne i wszystko walidować tak by użytkownik nie mógł stworzyć niepoprawnego wpisu (np. przypisując innego użytkownika jako autora). Zacznijmy jednak od modelu Django:

from django.db import models


class Quote(models.Model):
    quote = models.CharField(max_length=300, unique=True)
    poster = models.ForeignKey('auth.user')
    posted_date = models.DateTimeField(auto_now_add=True)

    def __unicode__(self):
        return self.quote
Mamy prosty model cytatów. Dla tego modelu możemy stworzyć zasób Tastypie (w quotes/resources.py):
from django.contrib.auth.models import User
from tastypie.resources import fields
from tastypie.resources import ModelResource

from quotes import models


class UserResource(ModelResource):
    class Meta:
        queryset = User.objects.all()
        allowed_methods = ['get']
        always_return_data = True
        fields = ['username']


class QuoteResource(ModelResource):
    poster_id = fields.ForeignKey(UserResource, 'poster')

    class Meta:
        queryset = models.Quote.objects.all()
        allowed_methods = ['get']
        always_return_data = True
Jak widzimy trzeba było też stworzyć zasób dla modelu User. Wszystkie użyte modele muszą mieć swoje zasoby (no chyba że ich w aplikacji zupełnie nie używamy). Żeby zasoby działały dodajemy je do urls.py (includowanie v1_api.urls) oraz rejestracja dwóch zasobów):
from django.conf.urls import patterns, include, url
from django.contrib import admin
from tastypie.api import Api

from quotes import resources
admin.autodiscover()

v1_api = Api(api_name='v1')
v1_api.register(resources.UserResource())
v1_api.register(resources.QuoteResource())

urlpatterns = patterns(
    '',
    url(r'^$', 'quotes.views.main_page'),
    url(r'^admin/', include(admin.site.urls)),
    (r'^api/', include(v1_api.urls)),
)
Teraz zasoby Tastypie powinny działać. Np. http://127.0.0.1:8000/api/v1/user/?format=json powinien wylistować użytkowników. W panelu admina można dodać parę cytatów. Pojawią się one wtedy pod zasobem http://127.0.0.1:8000/api/v1/quote/?format=json.

Kolejny etap to stworzenie modeli ember.js, które to podepną się pod te zasoby Tastypie. Cała wymiana danych pomiędzy aplikacją Ember.js a Django odbywa się poprzez takie zasoby.

Modele Ember.js

Mając gotowe zasoby możemy stworzyć modele w emberze. Za modele odpowiada ember-data. Oto gotowy model dla naszej aplikacji:

(function($, undefined ) {
    Quotes = Ember.Application.create();
    Quotes.ApplicationAdapter = DS.DjangoTastypieAdapter.extend({});
    Quotes.ApplicationSerializer = DS.DjangoTastypieSerializer.extend({});

    var attr = DS.attr;

    Quotes.Quote = DS.Model.extend({
        quote: attr('string'),
        poster: DS.belongsTo('user'),
        posted_date: attr('date')
    });
    Quotes.User = DS.Model.extend({
        username: attr('string')
    });

}(jQuery));

Na początku definiujemy adapter i serializer danych jaki ma być używany przez ember-data. Gdy mamy już to z głowy możemy definiować modele. Nazwa modelu jest dopasowywana do nazwy zasobu (Quote na QuoteResource itd.). Przy wieloczłonowych nazwach ember może zmapować je nieco inaczej (np. model FooBar może przełożyć się na FooBarResource, ale reprezentowane w API jako foo_bar). Wtedy wystarczy w zasobie ustawić "resource_name" na tą żądaną przez ember.

Tak jak w zasobach tak i tutaj wszystkie użyte relacje trzeba opisać modelami. Każde pole ma swój typ - np. string, number, boolean, czy date. Relacje to DS.belongsTo, czy DS.hasMany dla wiele-do-wielu (w tym przypadku drugi model musi mieć wsteczną relację DS.belongsTo).

W tej chwili modele są gotowe do działania. Wykonanie this.store.find('quote') pobierze listę cytatów z API i zwróci ją do kodu w aplikacji emberjs. Nie będzie to jednak takie zachowanie jak w Django, gdzie wszystko dzieje się synchronicznie. Wykonanie tego kodu zwróci nam obiekt obietnicy że po jakimś czasie będą tam dane pobrane z API. Takie zachowanie aplikacji wymusza inne podejście do tworzenia aplikacji, ale najpierw coś prostszego, czyli szablon listujący cytaty:

Routing i szablony

Powiedzmy że chcemy teraz wyświetlić listę cytatów. Zacznijmy więc od stworzenia szablonu handlebars:
{% verbatim %}
    <script type="text/x-handlebars">
        <h1>Ember.js Showcase Quotes App</h1>
        {{outlet}}
    </script>
    <script type="text/x-handlebars" data-template-name="quotes-list">
        {{#each quote in content}}
            <article>
                {{quote.quote}}
                <p>Added by: {{ quote.poster.username }}</p>
            </article>
        {{/each}}
    </script>
{% endverbatim %}

Pojawił się nam nowy szablon o nazwie "quotes-list". Wewnątrz iterujemy listę content zawierającą cytaty i wyświetlamy je. Poza nieco odmiennymi tagami przypomina to szablon Django. Na podstawie nazwy szablonu generowane są linki. Ten szablon będzie pod adresem "/#/quotes-list" (jeżeli nie skonfigurujemy generowania URLi inaczej).

Druga część zadania to przypisanie listy cytatów pod zmienną "quotes". Dobrze by było gdyby działo się na samym początku - czyli w routingu. Warto też wyświetlać ten szablon po wejściu na główny adres (/):

    Quotes.Router.map(function() {
        this.route('quotes-list');
    });

    Quotes.IndexRoute = Ember.Route.extend({
        redirect: function() {
            this.transitionTo('quotes-list');
        }
    });
    Quotes.QuotesListRoute = Ember.Route.extend({
        model: function() {
            return this.store.find('quote');
        }
    });

Na początku mamy Router.map - w mapie umieszczamy po prostu wszystkie używane szablony (w uproszczeniu). Następnie mamy dwa własne routingi dla głównego widoku (Quotes.IndexRoute) i widoku naszej listy (Quotes.QuotesListRoute - nazwa powiązana z nazwą szablonu). Po wejściu na główny adres każemy w routingu przekierować na szablon/adres "quotes-list". Natomiast w routingu listy cytatów (QuotesListRoute) ustawiamy model, co spowoduje że po wejściu na jego stronę pobrana zostanie lista cytatów.

Jak to teraz działa? Po wejściu na http://127.0.0.1:8000/ zostajemy przekierowani na http://127.0.0.1:8000/#/quotes-list. Następnie routing odpala kontroler, który renderuje szablon. Do kontrolera przypisywana jest lista komentarzy pod zmienną "content" (ember zawsze używa takiej nazwy). Szablon wykorzystuje ją i obserwuje czy zawartość ulega zmianie - jeżeli tak przerenderowuje elementy, które uległy zmianie. Jak się dobrze przyjrzysz to zauważysz że np. nazwa użytkownika pojawia się nieco później niż sam cytat. Dzieje się tak gdyż wczytywana jest lista cytatów i dopiero w próbie wyświetlenia czegoś z zależnego obiektu tworzone jest żądanie pobrania go z zasobu (co widać w konsoli przeglądarki). Szablony zdejmują z nas całą implementację renderowania danych. Same obserwują i renderują.

Lista cytatów wyświetlana przez ember

Lista cytatów wyświetlana przez ember

Przy pierwszym zetknięciu z ember wiele rzeczy może wydawać się magicznych, lub dziwnych. Skąd można wiedzieć jaką "metodę" w routingu zdefiniować? Sporo opisano w dokumentacji ember (choć nie jest to dokumentacja klasy Django). Pomoc można znaleźć także na IRCu na kanale #emberjs, czy na StackOverflow. To co teraz przedstawiam w tym artykule to coś w rodzaju szkieletu, szybkiego startu z ember.js.

Nawigacja

Teraz coś prostego, aczkolwiek całkiem fajne - nawigacja i linkowanie szablonów. Dorzucamy dwa nowe szablony:
    <script type="text/x-handlebars" data-template-name="about">
        <h2>About the application</h2>
        <p>This application rocks!</p>
    </script>
    <script type="text/x-handlebars" data-template-name="contact">
        <h2>Contact</h2>
        <p>Call Houston in case of troubles.</p>
    </script>
Dodajemy je do mapy:
    Quotes.Router.map(function() {
        this.route('quotes-list');
        this.route('about');
        this.route('contact');
    });
I teraz zróbmy proste menu nawigacyjne w głównym szablonie. By stworzyć link do szablony trzeba użyć taga "linkTo":
    <script type="text/x-handlebars">
        <h1>Ember.js Showcase Quotes App</h1>
        <nav>
            {{#link-to "quotes-list"}}Quotes{{/link-to}} |
            {{#link-to "about"}}About{{/link-to}} |
            {{#link-to "contact"}}Contact{{/link-to}}
        </nav>
        {{outlet}}
    </script>
W efekcie otrzymyjemy działające linki pozwalające na przechodzenie na poszczególne strony-szablony. Zauważ że strona nie jest przeładowywana! Co więcej link do bieżącej strony będzie posiadał klasę active, co można wykorzystać w stylach.
Podlinkowany szablon i nawigacja

Podlinkowany szablon i nawigacja

Tworzenie rekordów

Teraz coś trudniejszego. Cel to stworzenie widoku do dodawania cytatów wykorzystując embera. W tym przypadku framework oferuje wbudowany widok pola tekstowego i automatyczne wiązanie wartości tego pola z wartością zmiennej w kontrolerze. Dzięki temu nie musimy za pomocą jQuery wyszukiwać DOM-noda i operować na nim.

Na początek może szablon:
    <script type="text/x-handlebars" data-template-name="add-quote">
        <h2>Add a quote</h2>
        <form>
            {{view Ember.TextField valueBinding="quote" placeholder="Add a quote"}}
        </form>
    </script>
Wykorzystałem tutaj wbudowany widok Ember.TextField, który automatycznie wiąże swoją wartość ze zmienną "quote" w kontrolerze (AddQuoteController). Pozwala to odczytać, czy wyczyścić wartość tego pola. Oto kod obsługujący ten szablon (plus dodanie szablonu do mapy):
    Quotes.AddQuoteController = Em.Controller.extend({
        quote: '',
        actions: {
            saveQuote: function(text) {
                if (text) {
                    this.store.createRecord('quote', {'quote': text}).save();
                    this.set('quote', '');
                    this.transitionToRoute('quotes-list');
                }
            }
        }
    });

    Quotes.AddQuoteView = Ember.View.extend({
        submit: function() {
            var text = this.get('controller.quote');
            this.get('controller').send('saveQuote', text);
        }
    });

Na początek może widok AddQuoteView. Zareaguje on na próbę wysłania formularza (submit). Gdy takie zdarzenie ma miejsce pobierana jest wartość z pola (choć równie dobrze można to zrobić w kontrolerze) i wywoływana jest odpowiednia "metoda" kontrolera. Tak jak pisałem na początku "trywialny" submit został przetworzony na "saveQuote".

W kontrolerze AddQuoteController mamy zmienną, z którą powiązany jest input. Akcja saveQuote tworzy nowy komentarz, czyści wartość inputa i przekierowuje użytkownika na listę cytatów - gdzie zobaczy swój cytat (mimo iż tak naprawdę on dopiero się zapisuje).

Gdy teraz spróbujemy dodać cytat okaże się że Tastypie odrzuci próbę dodania cytatu. Zasób obecnie pozwala tylko na pobieranie. Trzeba to zmienić dodając do allowed_methods żądania POST oraz dodając prostą walidację zapisywanych danych:

from django.contrib.auth.models import User
from tastypie.authorization import Authorization
from tastypie.http import HttpUnauthorized
from tastypie.resources import fields
from tastypie.resources import ImmediateHttpResponse
from tastypie.resources import ModelResource

from quotes import models


class UserResource(ModelResource):
    class Meta:
        queryset = User.objects.all()
        allowed_methods = ['get']
        always_return_data = True
        fields = ['username']


class QuoteResource(ModelResource):
    poster = fields.ForeignKey(UserResource, 'poster')

    class Meta:
        queryset = models.Quote.objects.all()
        allowed_methods = ['get', 'post']
        always_return_data = True
        authorization = Authorization()

    def obj_create(self, bundle, **kwargs):
        if bundle.request.user.is_authenticated():
            return super(QuoteResource, self).obj_create(bundle,
                                                         poster=bundle.request.user,
                                                         **kwargs)
        raise ImmediateHttpResponse(HttpUnauthorized('Not authenticated'))

Metoda obj_create wywoływana jest przy tworzeniu rekordu. Nadpisujemy tą metodę by ustawić użytkownika, który dodaje cytat. Jeżeli użytkownik nie jest zalogowany zasób zwróci stosowny kod błędu (takie zdarzenia warto logować, szczególnie wtedy, kiedy nie spodziewamy się że będą miały miejsce).

Na zakończenie

W tym artykule przedstawiłem część z elementów ember.js. Kolejne w następnych artykułach. Framework ten zapowiada się bardzo ciekawie i mimo problemów młodości da się go już używać i tworzyć fajne aplikacje.

Możesz pobrać cały projekt i odpalić go u siebie lokalnie.

RkBlog

Django, 3 March 2013

Comment article
Comment article RkBlog main page Search RSS Contact