Jak w Google AppEngine napisać własny blog z pomocą Django? Czytaj poniżej. To przedostatnia część cyklu. Przedstawię w niej modele, a w ostatniej widoki. Szablony i część administracyjną pominę, bo szablony nie zawierają nic odkrywczego, a prostego admina omówiłem we wcześniejszych częściach serii.
Modele
Zacznę od importów modeli:
from appengine_django.models import BaseModel
from google.appengine.ext import db
import datetime
from django.core import urlresolvers
from django.conf import settings
Tagi (kategorie)
Niniejszy blog stosuje bardzo prosty system tagów, ale samo zagadnienie w GAE nie należy do najprostszych. Z powodów wydajnościowych i architektonicznych (brak agregacji) nie możemy w DataStore trzymać zwykłej tablicy wiele do wielu między wpisami i tagami, a następnie zliczać statystykę tagów (kategorii). Całość zliczeń, dodawania i usuwania kategorii musi odbywać się w momencie dodawania lub usuwania wpisów. Innymi słowy, coś co w większości załatwia za nas standardowe Django lub aplikacja tagging, tutaj musimy wykonać sami. Na plus można jednak zaliczyć bardzo łatwe pobieranie danych dla bocznego podsumowania. Własny tag wykonujący to zadanie przedstawię na samym końcu.
class Tag(BaseModel):
name = db.StringProperty(required=True)
counter = db.IntegerProperty(default=0)
@classmethod
def most_popular(cls, num=5):
return Tag.all().order('-counter').fetch(num)
@classmethod
def find_differences(cls, old, new):
old_s = set(old)
new_s = set(new)
added = list(new_s - old_s)
removed = list(old_s - new_s)
return added, removed
@classmethod
def split_tags(cls, tags):
return tags.split()
@classmethod
def update_tags(cls, added=[], removed=[]):
def txn(name, inc):
tag = cls.get_by_key_name(name)
if inc:
if tag is None:
tag = cls(key_name=name, name=name)
tag.counter += 1
tag.put()
else:
if tag is None:
return
tag.counter -= 1
if tag.counter <= 0:
tag.delete()
else:
tag.put()
for name in added:
db.run_in_transaction(txn, name, True)
for name in removed:
db.run_in_transaction(txn, name, False)
Pierwsze wyjaśnienie dla osób znających Django: modele Google AppEngine nie implementują sygnałów, więc update_tags jest wywoływane przez funkcję zapamiętania wpisu.
Model Tag istnieje tylko i wyłącznie po to, by zliczać wystąpienia tagów w celach statystycznych i móc pobierać listę tagów. Każdy tag przechowuje licznik wystąpień. Opis metod:
most_popular-- zwraca obiekty najbardziej popularnych tagów (używane przez pasek boczny)
find_differences-- informuje o różnicach w tagach między nowym i starym wpisem (stare i nowe tagi są przekazywane jako listy)
split_tags-- rozbija pole tekstowe na tagi (jest to osobna funkcja, bo w zamyśle miała być bardziej zaawansowana, np. obsługa cudzysłowów dla tagów wielowyrazowych)
update_tags-- aktualizuje liczniki tagów, dodaje nowe lub usuwa stare tagi w miarę potrzeb
Nie chcę tutaj wyjaśniać sposobu działania transakcji w DataStore, ale muszę zaznaczyć, że przy blogu dla jednej osoby edytującej powyższe transakcje są nieco nadmiarowe. Generalnie jednak inkrementacja/dekrementacja liczników w DataStore powinna zawsze następować w transakcji w podany powyżej sposób.
Zauważ, że nazwa taga trafia również jako klucz (key_name), bo klucz to jedyny w DataStore system zapewniania unikatowości. Co więcej, ułatwia on również pobieranie danych w przyszłości. Ponieważ kod był pisany przed wprowadzeniem właściwości __key__ w wyszukiwaniu, nazwa jest pamiętana dwukrotnie. W nowszym kodzie, zapamiętując nazwę w kluczu moglibyśmy pominąć osobną właściwość nazwy.
Wpisy
Kod wpisów to kolejna dawka kilku sztuczek, ale tylko część z nich jest specyficzna dla Google App Engine.
Bardzo malutka klasa EntryArchive pełni dosyć sporą rolę. Po pierwsze tworzy listę miesięcy zawierających wpisy dla bocznego panelu (pamiętaj, że w DataStore nie ma agregacji, a co dopiero agregacji po części daty). Po drugie służy jako klucz nadrzędny do wpisów w danym miesiącu co pozwala na jednym z widoków efektywnie pobierać wszystkie wpisy z danego miesiąca i paginować po nich. DataStore dopuszcza tylko jeden warunek nierówności. Używam go w trakcie paginacji, więc by ograniczyć się do jednego miesiąca i nie stosować dodatkowych zapytań, używam ograniczenia po przodku. Tworząc w DataStore obiekt możemy określić jaki inny klucz ma być jego przodkiem. Standardowo takie grupy ze wspólnym przodkiem tworzy się na potrzeby transakcji, ale można ich również użyć do obejścia pewnych ograniczeń GAE.
Poniżej przedstawiam kod, który mam nadzieję nie okaże się zbyt trudny do zrozumienia, niemniej dokumentacją Google App Engine warto mieć w tym momencie przy sobie ;)
class EntryArchive(BaseModel):
year_month = db.StringProperty(required=True)
class Entry(BaseModel):
title = db.StringProperty(required=True)
slug = db.StringProperty(required=True)
text = db.TextProperty(required=True)
text_html = db.TextProperty()
pub_date = db.DateTimeProperty(auto_now_add=True)
tags = db.StringListProperty()
comments = db.IntegerProperty(default=0)
language = db.StringProperty(choices=[code for code, name in settings.LANGUAGES], required=True)
def __unicode__(self):
return self.title
def get_absolute_url(self):
bits = ('weblog-entry', (), {
'year': self.pub_date.year,
'month': '%02d' % self.pub_date.month,
'day': '%02d' % self.pub_date.day,
'slug': self.slug})
return urlresolvers.reverse(bits[0], None, *bits[1:3])
def put(self):
from templatetags.highlight_code import highlight_code
self.text_html = highlight_code(self.text)
if not self.is_saved():
Tag.update_tags(self.tags)
else:
old = self.get(self.key())
Tag.update_tags(*Tag.find_differences(old.tags, self.tags))
super(Entry, self).put()
@classmethod
def post(cls, year, month, **kwargs):
year_month = "%d%02d" % (year, month)
entry_archive = EntryArchive.get_by_key_name(u"_"+year_month)
if entry_archive is None:
archive_key = EntryArchive(key_name=u"_"+year_month, year_month=year_month).put()
entry_archive = EntryArchive.get(archive_key)
entry = Entry(parent=entry_archive, **kwargs)
entry.put()
def delete(self):
from google.appengine.api import datastore
Tag.update_tags(removed=self.tags)
archive = EntryArchive.get_by_key_name(u"_%d%02d" % (self.pub_date.year, self.pub_date.month))
if self.all().ancestor(archive.key()).count() > 1:
archive = None
else:
archive = archive.key()
comments = [comment.key() for comment in Comment.all().ancestor(self.key())]
def txn():
for comment in comments:
datastore.Delete(comment)
super(Entry, self).delete()
if archive is not None:
datastore.Delete(archive)
db.run_in_transaction(txn)
@classmethod
def most_commented(cls, num=5):
return cls.all().filter('comments >', 0).order('-comments').fetch(num)
@classmethod
def list_of_months(cls, trim_to=100):
return [datetime.date(int(row.year_month[0:4]), int(row.year_month[4:6]), 1) for row in EntryArchive.all().order('-year_month').fetch(trim_to)]
def allow_comments(self):
if datetime.datetime.now() - datetime.timedelta(days=30) > self.pub_date:
return False
return True
Najpierw krótki opis interesujących metod, co pozwoli lepiej prześledzić cały przepływ danych:
__unicode__-- tekstowa reprezentacja obiektu znana i używana w standardowym Django
get_absolute_url-- generowanie adresu wpisu jak w standardowym Django, używane później przez kod dla RSS, sitemap i listy
allow_comments-- wyłącza komentowanie po 30 dniach od dodania wpisu
list_of_months-- pobiera listę archiwalnych miesięcy i zwraca ją jako obiektydate
most_commented-- zwraca listę najczęściej komentowanych wpisów (pamiętaj, że GAE nie używa obiektów menedżerów jak standardowe Django stąd metody statyczne)
post-- wywoływana przy dodawaniu nowego wpisu; zapewnia dodatkową logikę tworzącą obiekt archiwum dla danego miesiąca, jeśli nie istnieje; dodatkowo przekazuje klucz tego obiektu miesiąca jako rodzica nowego wpisu
put-- przesłonięcie standardowej metodyputzapisującej dane; przed zapisem przetwarzam tekst Markdown na wersję HTML w celu optymalizacji wyświetlania wpisów; dodatkowo wywoływana jest tu logika dodawania i aktualizacji tagów wraz z ich licznikami; zauważ, że choć nie jest to jawnie wskazane, to nie można zmienić daty istniejącego wpisu, bo nie można zmienić przodka (trzeba usunąć obiekt i utworzyć nowy w innym miejscu)
delete-- usuwa wpis, usuwając wcześniej jego komentarze i tag, a po usunięciu wpisu również archiwum, jeśli był to jedyny wpis w danym miesiącu.
Kilka uwag:
zwróć uwagę, że wpis przechowuje licznik komentarzy
DataStore nie usuwa kluczy podrzędnych, jeśli usuniemy przodka; również kolejność usuwania nie ma znaczenia (mógłbym najpierw usunąć wpis, a potem jego komentarze) -- pod tym kątem DataStore przypomina stare dobre MyISAM z MySQL, czyli jak chcesz integralności, to zatroszcz się o nią sam :)
kod usuwania zakłada, że wpisów w miesiącu nie będzie więcej niż kilkaset, podobnie jak komentarzy (limit dla GAE to w tej sytuacji 1000)
dodawanie obiektu archiwum miesięcy nie jest objęte transakcją, choć teoretycznie powinno (GAE dostarcza metodęcreate_or_getdziałająca w transakcji)
zauważ, że transakcja przy usuwaniu używa tylko kluczy -- transakcje w DataStore nie mogą wykonywać zapytań (dane muszą pobierać na podstawie kluczy), więc wszystkie potrzebne klucze trzeba zebrać przed przejściem do transakcji (system domknięć w Pythonie bardzo w tym pomaga :)
Komentarze
Po całym wcześniejszym kodzie komentarze to niemalże ostoja spokoju. W zasadzie jedyną trudnością jest tutaj potrzeba aktualizacji licznika komentarzy danego wpisu. Dodatkowo ponownie stosuję grupę encji. Wszystkie komentarze wpisu są podgrupą wpisu, co umożliwia mi poprawne działanie transakcji, która dodaje komentarz i zwiększa licznik.
Dodatkowa uwaga dotycząca grup encji i transakcji. W tym systemie klucz stosuje hierarchię EntryArchive->Entry->Comment. W GAE transakcja blokuje całą grupę, więc dodanie komentarza, choć jest zabezpieczone pod kątem transakcji, blokuje wszystkie wpisy z miesiąca, czyli znacznie więcej niż powinno. W tym przypadku byłem tego świadom i nie zamierzam tego zmieniać, bo liczba konfliktów transakcyjnych jest na tym blogu minimalna. Co więcej, GAE stosuje optymistyczne blokady, więc znaczenie mają tylko zapisy. Problemy z prezentowanymi transakcjami rozpoczęłyby się, gdyby komentarze napływały do systemu częściej niż kilkanaście razy na sekundę. Jeśli wykluczymy atak, taka sytuacja nie zdarza się nawet na najbardziej popularnych blogach.
Po tym wprowadzeniu mogę przedstawić kod modelu komentarzy bez opisywania jego metod.
class Comment(BaseModel):
author = db.StringProperty(required=True)
date = db.DateTimeProperty(auto_now_add=True)
comment = db.TextProperty(required=True)
entry = db.ReferenceProperty(Entry, required=True)
def __unicode__(self):
return u'%s - %s' % (self.author, self.date)
@classmethod
def post(cls, **kwargs):
from templatetags.highlight_code import highlight_code
kwargs['comment'] = highlight_code(kwargs['comment'], True)
if isinstance(kwargs['entry'], Entry):
entry_key = kwargs['entry'].key()
else:
entry_key = kwargs['entry']
def txn():
entry = Entry.get(entry_key)
entry.comments += 1
entry.put()
comment = cls(parent=entry, **kwargs)
comment.put()
db.run_in_transaction(txn)
def delete(self):
entry_key = self.parent_key()
def txn():
entry = Entry.get(entry_key)
entry.comments -= 1
entry.put()
super(Comment,self).delete()
db.run_in_transaction(txn)
Znacznik szablonowy sitebar
Obiecałem jeszcze jako bonus przedstawić znacznik szablonowy generujący dane dla bocznego paska. Prezentuję go z jednego powodu -- poniższy kod używa trzech pojedynczych zapytań, by pobrać wszystkie dane bez jakiejkolwiek agregacji i złączeń! Trud wcześniejszych liczników i sztuczek czasem się spłaca :)
from django import template
from weblog.models import Entry, Tag
from django.core.cache import cache
register = template.Library()
@register.inclusion_tag('sidebar.html')
def sidebar():
value = cache.get('sidebar')
if value is None:
value = {
'most_commented': Entry.most_commented(num=5),
'months_list': Entry.list_of_months(),
'tag_list': Tag.most_popular(5)
}
cache.set('sidebar', value, 300)
return value
Podsumowanie
Nie będę zdziwiony, jeśli po tym tekście i analizie kodu osoby przyzwyczajone do relacyjnych baz danych będą siwe i nigdy nie spojrzą na Google AppEngine. Choć powyższego kodu nie ma dużo, to napisanie go w sposób w miarę efektywny dla GAE wymaga analizy rozkładu zapisów i odczytów, obejrzenia kilku prezentacji z Google IO i dokładnego przeczytania dokumentacji. Choć początkowy nakład pracy może być spory, po pewnym czasie można wyodrębnić pewne wzorce korzystania z DataStore. Mam nadzieję, że przedstawienie tu części z nich, choć nie zawsze idealnych (bo to moja pierwsza złożona przymiarka do GAE), pomoże innym w zrozumieniu ograniczeń Google App Engine i przestawieniu się na inny sposób myślenia.
2 komentarze: