RkBlog

Hardware, programming and astronomy tutorials and reviews.

Stronicowanie encji w Datastore

Opis metod stronicowania (paginacji) encji pobranych z Datastore w GAE takich jak stronicowanie po wartościach, po kluczach, czy po kluczu i nieunikalnej właściwości.

Dość często na stronach www musimy wyświetlić dużą ilość danych - np. listę komentarzy. Żeby strona nie była zbyt duża stronicuje się wyniki, np. 20 wpisów na stronę dając użytkownikowi możliwość przejścia do podstron z kolejnymi wpisami. W GAE dostępne jest fetch(limit, offset=0), lecz jak dokumentacja donosi - limit i offset nie mogą być większe niż 1000. Można użyć tego rozwiązania tylko przy bardzo małych ilościach wpisów (im większy offset tym gorsza wydajność).

Stronicowanie po właściwości

Zamiast stosować fetch można zaimplementować znacznie lepsze rozwiązanie. Poniżej etapami zbudujemy efektywny mechanizm stronicowania (paginacji) wyników po właściwości encji.

Oto model, dla którego zrobimy stronicowanie (model przechowujący sugestie użytkowników strony):

PAGESIZE = 10

class Suggestion(db.Model):
  suggestion = db.StringProperty()
  when = db.DateTimeProperty(auto_now_add=True)
Pierwszym wymogiem stronicowania jest obecność indeksu dla właściwości, na której można wykonywać filtry nierówności. Powiedzmy że chcemy pobrać sugestie w odwrotnej kolejności chronologicznej, 10 sztuk:
def get(self):
  suggestions = Suggestion.all().order("-when").fetch(PAGESIZE)

  # ..szablon itd..
Musimy jednak wiedzieć, czy pokazać użytkownikowi link "następna strona" do kolejnych sugestii. Żeby to określić musimy wiedzieć czy jest więcej niż 10 elementów. W tym celu zamiast pobierać 10 (PAGESIZE) pobierzmy o jeden więcej: PAGESIZE+1.
def get(self):
  next = None
  suggestions = Suggestion.all().order("-when").fetch(PAGESIZE + 1)
  if len(suggestions) == PAGESIZE + 1:
    next = suggestions[-1].when
  suggestions = suggestions[:PAGESIZE]

  # ..szablon itd..
Teraz generując stronę możemy użyć obecność dodatkowego elementu by określić czy wyświetlić link do kolejnej strony, czy też nie. Musimy też jakoś obsłużyć zapytanie wyświetlające kolejną porcję wpisów. Mając tą dodatkową encję możemy wykorzystać jej wartości w parametrze GET, które następnie trafią do zapytania:
def get(self):
  next = None
  # parametr z GET
  bookmark = self.request.get("bookmark")
  if bookmark:
    suggestions = Suggestion.all().order("-when").filter('when <=', bookmark).fetch(PAGESIZE+1)
  else:
    suggestions = Suggestion.all().order("-when").fetch(PAGESIZE+1)
  if len(suggestions) == PAGESIZE+1:
    next = suggestions[-1].when
    suggestions = suggestions[:PAGESIZE]

  # ..render template..

Jeżeli stronicujesz używając właściwości posiadającej unikalne wartości to powyższy przykład wystarczy i spełni swoje zadania. W naszym przykładzie właściwość "when" nie jest (nie musi być) unikalna pośród wszystkich encji. Istnieje możliwość że więcej niż jedna encja może zostać dodana w tym samym czasie:

offset suggestion when
... ... ...
9 Stock Jolt 2008-10-26 04:38:00
10 Allow dogs in the office 2008-10-26 03:35:58
11 Allow cats in the office 2008-10-26 03:35:58
12 Buy some multicolored exercise balls 2008-10-26 01:10:03
13 ... ...
Pokazując 10 najnowszych sugestii datą końcową byłoby: 2008-10-26 04:38:02. Niestety 10 i 11 encja mają tą samą wartość "when" i element 10 pojawiłby się jako ostatni na pierwszej stronie i pierwszy na drugiej. Przy większej ilości takich "duplikatów" problem byłby jeszcze poważniejszy.

Należy zagwarantować unikalność wartości właściwości. Jednym ze sposób jest dodanie licznika, który będziemy inkrementować przy każdym dodaniu sugestii do datastore. Wartość licznika byłaby dodawana do każdej wartości "when" gwarantując unikalność:

offset suggestion when
... ... ...
9 Stock Jolt 2008-10-26 04:38:00|09
10
Allow dogs in the office 2008-10-26 03:35:58|10
11
Allow cats in the office 2008-10-26 03:35:58|11
12 Buy some multicolored exercise balls 2008-10-26 01:10:03|12
13 ... ...

By zaimplementować takie rozwiązanie musimy zmienić nieco nasz model. Właściwość "when" będzie łańcuchem (bo dostawiamy dodatkowe elementy do daty), a żeby mieć łatwą do odczytu datę dodania sugestii - dodajemy nową właściwość:

class Suggestion(db.Model):
  suggestion = db.StringProperty()
  created = db.DateTimeProperty(auto_now_add=True)
  when = db.StringProperty()
Jeżeli częstotliwość dodawania sugestii nie jest za duża to to rozwiązanie spełni swoje zadanie. Przy większej częstotliwości pojawi się problem z przepustowością datastore - aktualizacji licznika. Rozwiązać można to shardując licznik (co opisano w oddzielnym artykule). W tym przykładzie użyjemy shardowania licznika po użytkownika - będziemy dodawać ID użytkownika do wartości "when".

Musimy więc przebudować model do postaci:

class Contributor (db.Model):
   counter = db.IntegerProperty(default=0)

def unique_user(user):
  """
  Tworzy unikalny łańcuch używając inkrementujący się licznik
  shardowany per użytkownika
  """
  email = user.email()

  def txn():
    contributor = Contributor.get_by_key_name(email)
    if contributor == None:
      contributor = Contributor(key_name=email)
    contributor.count += 1
    contributor.put()
    return contributor.count

  count = db.run_in_transaction(txn)

  return email + "|" + str(count)

Teraz dane wyglądają znacznie lepiej ;)

offset suggestion when
... ... ...
9 Stock Jolt 2008-10-26 04:38:00|joe@bitworking.org|1
10 Allow dogs in the office 2008-10-26 03:35:58|fred@example.org|1
11 Allow cats in the office 2008-10-26 03:35:58|joe@bitworking.org|2
12 Buy some multicolored exercise balls 2008-10-26 01:10:03|joe@bitworking.org|3
13 ... ...

Używając adresu email użytkownika w wartości, po której stronicujemy będziemy ujawniać je w linkach stronicowania. Żeby temu zapobiec wartość właściwości "when" możemy zahaszować za pomocą np. md5:

def _unique_user(user):
  """
  Tworzy unikalny łańcuch używając inkrementujący się licznik
  shardowany per użytkownika
  Końcowy wynik haszujemy dla ukrycia adresu email
  """
  email = user.email()

  def txn():
    contributor = Contributor.get_by_key_name(email)
    if contributor == None:
      contributor = Contributor(key_name=email)
    contributor.count += 1
    contributor.put()
    return contributor.count

  count = db.run_in_transaction(txn)

  return hashlib.md5( email + "|" + str(count)).hexdigest()
offset suggestion when
... ... ...
9 Stock Jolt 2008-10-26 04:38:00|aee15ab24b7b3718596e3acce04fba85
10 Allow dogs in the office 2008-10-26 03:35:58|404a3235076f6651914358680acf3cb5
11 Allow cats in the office 2008-10-26 03:35:58|7574b989df099d4e2b95619c9cf0c2a0
12 Buy some multicolored exercise balls 2008-10-26 01:10:03|c675e87cc990a718979afecc93a77bc1
13 ... ...

Stronicowanie bez właściwości

Powyższy przykład dotyczył stronicowania i sortowania po konkretnej właściwości. Gdy potrzebujesz stronicować encje, ale nie potrzebujesz określonego sortowania (lub kolejność kluczy ci odpowiada) to możesz zastosować stronicowanie po kluczach encji stosując właściwość __key__. Klucz encji jest unikalny i nie trzeba dodatkowych środków zapewniających unikalność:

def get(self):
  next = None
  bookmark = self.request.get("__key__")
  if bookmark:
    suggestions = Suggestion.all().order("__key__").filter('__key__ >=', bookmark).fetch(PAGESIZE+1)
  else:
    suggestions = Suggestion.all().order("-when").fetch(PAGESIZE+1)
  if len(suggestions) == PAGESIZE+1:
    next = suggestions[-1].when
    suggestions = suggestions[:PAGESIZE]

  # ..szablon itd..

Stronicowanie po kluczu i nieunikalnej właściwości

Można także użyć do stronicowania klucza i nieunikalnej właściwości. Zaleta to brak konieczności dodatkowej unikalnej właściwości w modelu, lecz wadą: większa ilość zapytań. Więcej można poczytać o tym na liście dyskusyjnej GAE.

Na podstawie Paging through large datasets.
RkBlog

11 August 2009;

Comment article