Die Grundlagen der Cache-Mechanismen: Caching in Ruby on Rails
Unter Beachtung einiger Regeln skalieren Rails-Anwendungen gut mit zusätzlicher Hardware. Allerdings ist die Lösung unter Umständen teuer und sie erhöht durch den höheren Wartungsaufwand auch die Folgekosten. Günstiger ist in der Regel ein anderer Ansatz: das Caching, also das Zwischenspeichern. Dabei geht man davon aus, dass das Generieren der Webseiten, also das Ausführen der eigentlichen Webapplikation der „teure“, weil rechenaufwändige Vorgang ist, während das eigentliche Ausliefern der fertig „gerenderten“ Seite billig und schnell zu erledigen ist. Das gilt insbesondere, wenn ein Webserver wie Apache anstelle eines Applikationsservers die Arbeit erledigt.
Ganz einfach: Page-Caching
In Rails gibt es „out of the box“ drei verschiedene Ebenen des Cachings. An erster Stelle steht das performanteste, aber auch unflexibelste Verfahren, das so genannte „Page-Caching“. Page-Caching wird in einem Controller aktiviert, wobei die entsprechenden Actions zum Page-Caching angemeldet werden:
class MeinController < ApplicationController caches_page :index def index [...] end end
Listing 1
Wird die index-Action das erste Mal aufgerufen, liefert Rails zunächst die Webseite aus, speichert sie aber gleichzeitig als HTML-Datei im Webroot des Webservers (üblicherweise das „public/“-Verzeichnis, in dem auch die Bilder, Stylesheets und JavaScripts der Applikation liegen). Beim nächsten Request wird bei entsprechender Konfiguration des Webservers statt der Anfrage an die Rails-Applikation die gespeicherte Webseite ausgeliefert. Der Request kommt also gar nicht erst bei der Rails-Applikation an.
Dass es schneller geht, eine statische HTML-Seite vom Apache-Server ausliefern zu lassen als von der Rails-Applikation leuchtet ein. Zweckmäßig ist das Vorgehen aber nur dann, wenn die Action unabhängig von irgendwelchen Session-Daten oder zusätzlichen URL-Parametern ist. Allerdings muss die Seite nicht notwendigerweise statisch beziehungsweise unveränderlich sein, da es durchaus möglich ist, innerhalb der Applikation Caches zu leeren.
Page-Caching eignet sich also vor allem für Seiten, deren Inhalt sich nicht abhängig vom eingeloggten Benutzer oder ähnlichen Dingen ändert. Sehr gute Kandidaten dafür sind zum Beispiel Hilfeseiten, die zwar von einem Redaktionsteam über die Applikation gepflegt werden (weshalb sie nicht als statische HTML-Seiten unter „public/“ abgelegt sind), aber für jeden Benutzer gleich angezeigt werden.
Cache-Inhalte löschen
Werden Cache-Inhalte angelegt, sollte es auch eine Möglichkeit geben, sie zu löschen, beziehungsweise den Cache zu invalidieren. Das kann zum Beispiel explizit geschehen, wenn nach einer Änderung an einer Seite durch folgende Zeilen der Cache invalide wird:
def update [...] expire_page :controller => 'mein', :action => 'index' [...] end
Listing 2
Das Vorgehen ist allerdings meist nicht sinnvoll, da es die Cache-Funktionalität unnötigerweise an die beteiligten Controller koppelt. Üblicherweise hängt eine Invalidierung vielmehr mit den Modellen zusammen, das heißt sobald sich ein Modellobjekt ändert, das Daten für eine zwischengespeicherte Action bereitstellt, sollte der Cache gelöscht werden. Dehalb kann die Invalidierung von Caches über so genannte „Cache-Sweeper“ an Modelle gebunden werden.
Angenommen, über die oben genannte Action soll eine Liste von Büchern angezeigt werden. Es gäbe eine ActiveRecord-Klasse namens Book, und jedes Mal, wenn sich ein Buch ändert, dann würde der Cache invalide. Ein Sweeper (der üblicherweise unter „app/models/“ abgelegt wird) könnte folgendermaßen aussehen:
class BookSweeper < ActionController::Caching::Sweeper observe Book def after_create(book) expire_public_page end def after_update(book) expire_public_page end def after_destroy(book) expire_public_page end private def expire_public_page expire_page(:controller => "mein", :action => 'index') end end
Listing 3
Die Zeile „observe Book“ sorgt dafür, dass die nachfolgenden Methoden an den entsprechenden Stellen des Lebenszyklus des Book-Modells als Callback aufgerufen werden.
Bitte mit Filter: Action-Caching
Der Anwendungsbereich des Page-Cachings ist begrenzt. Vollkommen ausgeschlossen ist es für Controller beziehungsweise Actions, die eine Benutzerauthentifizierung voraussetzen, welche zum Beispiel über einen „before_filter“ implementiert ist. Der before_filter würde beim Page-Caching gar nicht erst aufgerufen.
Genau für diese Fälle ist das „Action-Caching“ gedacht. Beim Action-Caching wird in jedem Fall die Rails-Applikation angesprochen und alle before_filter im Controller werden aufgerufen. Somit steht einer Authentifizierung nichts im Wege: Ein before_filter könnte einen Redirect auf die Login-Seite ausführen, sollte der Benutzer nicht berechtigt sein, die Action aufzurufen. Das folgende Listing zeigt den entsprechenden Controller:
class MeinController < ApplicationController caches_action :index [...] end
Listing 4
Der Cache-Store kommt ins Spiel
Statt wie beim Page-Caching eine statische HTML-Seite im Dateisystem anzulegen, benutzt das Action-Caching den so genannten „Fragment-Cache-Store“ (zum eigentlichen „Fragment-Caching“ siehe unten), der prinzipiell wie ein Ruby-Hash funktioniert: Beliebige Dinge (in diesem Fall gerenderte HTML-Seiten) werden unter einem Key (hier Cache-Key genannt) abgelegt und mit Hilfe der Keys auch wieder hervorgeholt. Ganz ähnlich dem Dateinamen der gerenderten Page-Cache-Seite, besteht der Cache-Key aus der Action-URL, die, falls es sich um die index-Action handelt, um das Wort „index“ ergänzt wird.
Die Implementierung des Fragment-Cache-Stores ist wählbar: Der Standard ist das Ablegen der Keys als Datei im Dateisystem, was allerdings Schwierigkeiten beim Invalidieren macht, wenn mehrere Applikationsserver betrieben werden. Eine weitere Möglichkeit ist der Einsatz von „memcached“, einem sagenhaft performanten, über das Netzwerk anzusprechenden Datencontainer, der auch gern als Session-Store verwendet wird. Eine detailliertere Beschreibung der Optionen würde den Rahmen des Artikels sprengen, sodass hier auf die Hilfe im Internet verwiesen sei [1].
Auch die Invalidierung von Cache-Inhalten funktioniert bei Action-Caches analog zu den Page-Caches (wie auch das Schreiben von Observern):
def update [...] expire_action :controller => 'mein', :action => 'index' [...] end
Listing 5
Bedingungsloses Caching?
Manchmal kommt es vor, dass nicht nur before_filter wie oben beschrieben vor dem Cache ausgeführt werden sollen, sondern dass das Caching nur bedingt durchgeführt werden muss. Das klassische Beispiel dafür ist eine Seite, die für eingeloggte Benutzer einen personalisierten Bereich enthält, der keinesfalls im Cache landen darf, während die Seite, wie sie sich anonymen Benutzern darstellt, durchaus zwischengespeichert werden sollte. Für den Fall bietet Rails leider keine mitgelieferte Lösung. Es wäre möglich, das Problem zu umschiffen, indem registrierte Benutzer auf eine andere URL weitergeleitet werden. Doch ist das auch nicht die optimale Lösung, denn unter Umständen sind viele Actions im Controller gedoppelt, was nebenbei dem REST-Design einer Applikation widerspricht. Die alternative Lösung ist das „conditional_caching-Plugin“ der kanadischen Firma Redline Software [2]. Nach der Installation wird die Anweisung „caches_action“ um den Parameter „:if“ ergänzt, mit dem eine Methode als Callback angegeben wird. So lassen sich Bedingungen definieren, die erfüllt sein müssen, damit das Caching greift. Das folgende Listing zeigt, wie das aussehen kann:
class MeinController < ApplicationController caches_action :index, :if => :cache? [...] private def cache? !permit?('user') end end
Listing 6
Nur in Teilen: Fragment-Caching
Die letzte Ebene des Rails-Caching ist das „Fragment-Caching“. In Webanwendungen kommt es häufiger vor, dass bestimmte Seiten ihren Inhalt zum großen Teil aus dynamischen, benutzerabhängigen Daten beziehen, die sich nicht beziehungsweise kaum cachen lassen. Gibt es aber Bereiche auf den Seiten, die nicht personalisiert sind und gegebenenfalls aufwändig zu berechnen, liegt es nahe, nur diese Blöcke zwischenzuspeichern. Genau dafür existiert das Fragment-Caching. Um einen Block in einem Template zu cachen, wird folgender Code benötigt:
[...] <% cache do %> <ul> <% for book in @books %> <li><%= book.title %> <% end %> </ul> <% end %> [...]
Listing 7
Der Key, unter dem das Fragment abgelegt wird, besteht nun wieder aus der URL der generierten Seite. Das hat hier allerdings den Nachteil, dass nur ein Cache-Fragment pro Action erlaubt ist, weil sich sonst die Cache-Keys überschneiden würden. Auch lassen sich so die Blöcke nicht wiederverwendbar zwischenspeichern, was zum Beispiel ein Partial, das in mehreren Seiten eingebunden wird, unmöglich macht. Für diese Zwecke lassen sich Cache-Keys selber bauen und an die Cache-Methode übergeben:
[...] <% cache :controller => 'meiner', :action => 'list', :part => 'books' do %> <ul> <% for book in @books %> <li><%= book.title %> <% end %> </ul> <% end %> [...]
Listing 8
Da hier der normale Routing-Mechanismus von Rails verwendet wird, ist zu beachten, dass es entsprechende Routes gibt. Das heißt es lassen sich nicht beliebige Parameter verwenden.
Die Invalidierung wiederum funktioniert genauso wie bei den anderen Cache-Arten – so wie in Listing 9 zu sehen.
def update [...] expire_fragment :controller => 'mein', :action => 'list', :part => 'books' [...] end
Listing 9
Der aufmerksame Leser wird sich bei dem in Listing 8 angegebenen Code allerdings eine Frage stellen: Woher kommt eigentlich die Liste der Bücher in @books? Die Antwort ist: aus dem Controller, wie üblich. Allerdings ist es nicht sinnvoll, die Liste durch den Controller wahllos aus der Datenbank zu zerren, denn dann wäre das Caching ja vermutlich überflüssig. In der Regel ist schließlich nicht das Rendern der HTML-Seite langsam, sondern die Datenbankabfrage. Es gilt also, eine Methode zu finden, die das Kapseln und die Abfrage nur dann ausführt, wenn man die Daten braucht. Eine oft genannte Variante dazu finden Sie in Listing 10. Dort wird im Controller nachgesehen, ob ein Fragment bereits vorhanden ist und die Abfrage gegebenenfalls unterbunden.
def list unless read_fragment(:controller => 'meiner', :action => 'list', :part => 'books') @books = Book.find(:all) end end
Listing 10
In 99 Prozent der Fälle wird das Vorgehen funktionieren. Liegt allerdings viel Last auf der Applikation und die Seite wird sehr oft aufgerufen, kommt es unter Umständen zu einer unangenehmen Race Condition: Der Controller hat beschlossen, die Daten für das Fragment nicht zu besorgen, weil ein Fragment vorhanden war. Während anschließend der Rails-Prozess den View rendert, hat ein Administrator auf einem anderen Rails-Prozess beschlossen, ein Buch in der Datenbank zu löschen, was einen Observer anspringen lässt, der das Fragment löscht. Wird jetzt der View-Teil gerendert, der die „cache do…end“-Anweisung enthält, wird der Block ausgeführt und nicht das nicht mehr vorhandene Cache-Fragment ausgeliefert. Nun ist die benötigte @books-Instanzvariabel leer. Bei einem sehr defensiv programmierten View bleibt der Block leer, was (nur) unschön ist, während bei einem „normal“ programmierten View dem Benutzer eine weniger schöne 500er-Fehlermeldung entgegenspringt.
Pragmatismus über Prinzipien
Ganz genau genommen, muss die Abfrage der Daten innerhalb des Cache-Blocks erfolgen, doch heißt das nicht, dass nun ActiveRecord-Methoden im View aufgerufen werden. Einerseits handelt es sich beim Caching um Maßnahmen, auf die nur im Notfall zurückgegriffen wird, und dementsprechend ist Pragmatismus statt Paragraphenreiterei gefragt. Andererseits ist es dennoch sinnvoll, das Holen der Daten wenigstens in einen „Helper“ zu verpacken, wie es Listing 11 und 12 zeigen:
def book_list Book.find(:all) end
Listing 11
[...] <% cache :controller => 'meiner', :action => 'list', :part => 'books' do books = book_list %> <ul> <% for book in books %> <li><%= book.title %> <% end %> </ul> <% end %> [...]
Listing 12
Fazit
Mit den drei Ebenen Page-Caching, Action-Caching und Fragment-Caching bietet Rails smarte und funktionierende Lösungen, um eine Anwendung besser auf einen Benutzeransturm vorzubereiten. Der zu verwendende Mechanismus ergibt sich aus der Struktur der Anwendung und dem Anteil an dynamischen und vor allem Session-abhängigen Seitenbestandteilen.
Damit ist das Thema „Caching in Rails“ keineswegs erschöpfend behandelt. Vollkommen ausgespart bleibt hier zum Beispiel das Thema „Caching testen“. Außerdem gibt es weitere Möglichkeiten, um zum Beispiel Ergebnisse von Berechnungen oder von komplexen Datenbankabfragen auf Modellebene zu cachen (siehe z. B. das „Cache_Fu-Plugin“ von Chris Wanstrath [3] ). Für viele Fälle dürften allerdings die hier gezeigten Mechanismen vollkommen ausreichen.