Innerhalb des Rails-Frameworks ist es unser Ziel, die Fragestellung
"Hat sich eine Seite verändert?" bereits im Controller zu beantworten.
Denn normalerweise wird die meiste Zeit beim Rendern der Seite in einem
View verbraucht. In
„Liste
aller Firmen (Index-View)“ kann
man das gut sehen: Von insgesamt 85 ms werden alleine 71,9 ms und damit
mehr als 80 % der Gesamtzeit mit dem Rendern des Views verbraucht.
Wichtig
Bitte passen Sie die in den Beispielen verwendeten Uhrzeiten an
Ihre Gegebenheiten an.
Der Webbrowser weiß, wann er eine Webseite downgeloadet und danach
in den Cache gelegt hat. Diese Information kann er dem Webserver in
einem If-Modified-Since:
Header übergeben. Der Webserver
kann diese Information dann mit der entsprechenden Datei vergleichen und
entweder eine neuere Version ausliefern oder einen HTTP 304 Not Modified
Code als Antwort liefern. Bei einem 304 liefert der Webbrowser die
gecachete Version. Sie werden jetzt sagen "Das ist ja schön bei
Grafiken, aber das nützt mir ja bei dynamisch generierten Webseiten wie
dem Index-View der Firmen nichts". Da haben Sie aber die Fähigkeiten von
Rails unterschätzt. ;-)
Ändern Sie bitte in der Controller-Datei
app/controllers/companies_controller.rb
die
index
- und
show
-Methoden wie folgt ab:
def index
@companies = Company.order(:id)
fresh_when last_modified: @companies.maximum(:updated_at)
end
def show
@company = Company.find(params[:id])
fresh_when last_modified: @company.updated_at
end
Anmerkung
Wir nehmen
@companies = Company.order(:id)
anstelle
von
@companies = Company.all
, um ActiveRecords Lazy
Loading (siehe
„Lazy Loading“) benutzen zu
können.
Nach einem Neustart der Rails-Applikation schauen wir uns mal den
HTTP-Header von
http://0.0.0.0:3000/companies
an:
MacBook:~ xyz$ curl -I http://0.0.0.0:3000/companies
HTTP/1.1 200 OK
Last-Modified: Fri, 13 Jul 2012 12:14:50 GMT
Content-Type: text/html; charset=utf-8
Cache-Control: max-age=0, private, must-revalidate
X-Ua-Compatible: IE=Edge
X-Request-Id: a2b4f9bc64f53637691a4665563568f6
X-Runtime: 0.066358
Content-Length: 0
Server: WEBrick/1.3.1 (Ruby/1.9.3/2012-04-20)
Date: Fri, 13 Jul 2012 14:22:24 GMT
Connection: Keep-Alive
Set-Cookie: _phone_book_session=BAh7B0kiD3Nlc3Npb25faWQGOgZFRkkiJTliNWQxOTQ4ZDNmNTI2M2Q0ZjZiZTI5ZjdjYzIyY2EwBjsAVEkiEF9jc3JmX3Rva2VuBjsARkkiMVg1ZjNBNFBWcWxZSWJQRzM3aVczS2hiTzBtckx4SzFjeWQwOEZGWHVwNkU9BjsARg%3D%3D--08bd7983f93a82133df64e0c742953808c2e6d1f; path=/; HttpOnly
MacBook:~ xyz$
Der
Last-Modified
-Eintrag im HTTP-Header wurde von
fresh_when
im Controller generiert. Wenn wir
später die gleiche Webseite abrufen und dabei diese Uhrzeit mit angeben,
dann bekommen wir nicht die Webseite, sondern einen
304 Not
Modified
zurück:
MacBook:~ xyz$ curl -I http://0.0.0.0:3000/companies --header 'If-Modified-Since: Fri, 13 Jul 2012 12:14:50 GMT'
HTTP/1.1 304 Not Modified
Last-Modified: Fri, 13 Jul 2012 12:14:50 GMT
Cache-Control: max-age=0, private, must-revalidate
X-Ua-Compatible: IE=Edge
X-Request-Id: 7802f078add46dc372adaec92f343fe2
X-Runtime: 0.008647
Server: WEBrick/1.3.1 (Ruby/1.9.3/2012-04-20)
Date: Fri, 13 Jul 2012 14:27:15 GMT
Connection: close
MacBook:~ xyz$
Im Rails-Log finden wir Folgendes:
Started HEAD "/companies" for 127.0.0.1 at 2012-07-13 16:29:53 +0200
Processing by CompaniesController#index as */*
(0.2ms) SELECT MAX("companies"."updated_at") AS max_id FROM "companies"
Completed 304 Not Modified in 2ms (ActiveRecord: 0.2ms)
Rails hat für die Beantwortung dieser Anfrage 2 ms im Vergleich zu
67 ms zur Standard-Variante benötigt. Das ist mehr als 40 mal schneller!
Sie haben also rund 40 mal weniger Resourcen auf dem Server benötigt.
Und es wurde massiv Bandbreite eingespart. Der Anwender bekommt die
Seite viel schneller angezeigt.
Erzielt wurde dieses Ergebnis durch
@companies.maximum(:updated_at)
im Controller. Wir haben
nur abfragen müssen, wann das letzte Update in der Datenbank gemacht
wurde. Sobald sich ein einzelner Company Datensatz ändert, so wird der
Wert auf den dann aktuellen Zeitpunkt gesetzt und es wird wieder die
ganze Webseite ausgeliefert. Mit dieser Methode können Sie auch
dynamisch generierte Webseiten per Last-Modified
Header
ausliefern lassen.
Manchmal ist das
update_at
-Feld eines
bestimmten Objektes nicht alleine aussagefähig. Wenn Sie z. B. eine
Webseite haben, auf der sich Anwender einloggen können und die
Webseiten-Inhalte dann anhand eines Rollen-Models generiert, dann kann
es sein, dass der Anwender A als Admin einen Edit-Link angezeigt bekommt
und Anwender B als normaler User diesen Edit-Link nicht angezeigt
bekommt. Bei solchen Szenarien nützt uns der in
„Last-Modified“ erklärte Last-Modified Header
nichts.
Bei solchen Szenarien können wir den Etag Header benutzen. Das
Etag wird vom Webserver generiert und beim ersten Aufruf einer Webseite
mitgeliefert. Der Browser kann bei späteren Abfragen der gleichen URL
mit der Anfrage If-None-Match:
beim Webserver anfragen, ob
sich die entsprechende Webseite geändert hat.
Ändern Sie bitte in der Controller-Datei
app/controllers/companies_controller.rb
die
index-
und
show
-Methoden wie folgt ab:
def index
@companies = Company.all
fresh_when etag: @companies
end
def show
@company = Company.find(params[:id])
fresh_when etag: @company
end
Beim Etag kommt allerdings noch eine Rails-Besonderheit ins Spiel:
Rails setzt automatisch bei jedem neuen Besucher der Webseite ein neues
CSRF-Token. Damit werden Cross-Site Request Forgery Angriffe vermieden
(siehe
http://de.wikipedia.org/wiki/Cross-Site_Request_Forgery
).
Dadurch bekommt aber auch jeder neue Anwender einer Webseite ein neues
Etag für die an sich gleiche Seite. Um bei gleichen Anwendern auch
identische CSRF-Tokens zu bekommen, werden diese vom Webbrowser in einem
Cookie gespeichert und somit bei jedem Aufruf einer Webseite an den
Webserver zurückgeschickt. Das von uns zum Entwickeln benutzte curl
macht das aber nicht standardmäßig. Wir können Curl aber sagen, dass
alle Cookies in einer Datei gespeichert werden sollen und diese Cookies
später bei einer Anfrage auch übertragen.
Das Abspeichern erfolgt mit dem
-c cookies.txt
Parameter.
MacBook:~ xyz$ curl -I http://0.0.0.0:3000/companies -c cookies.txt
HTTP/1.1 200 OK
Etag: "b5f711016cb2e5fce352230e607ceffe"
Content-Type: text/html; charset=utf-8
Cache-Control: max-age=0, private, must-revalidate
[...]
MacBook:~ xyz$
Mit dem
-b cookies.txt
-Parameter sendet curl bei
einer Anfrage diese Cookies an den Webserver. Jetzt bekommen wir bei
zwei aufeinanderfolgenden Anfragen den gleichen Etag geliefert:
MacBook:~ xyz$ curl -I http://0.0.0.0:3000/companies -b cookies.txt
HTTP/1.1 200 OK
Etag: "132c1be24595b9b5f7b2c08b300592b1"
Content-Type: text/html; charset=utf-8
Cache-Control: max-age=0, private, must-revalidate
[...]
MacBook:~ xyz$ curl -I http://0.0.0.0:3000/companies -b cookies.txt
HTTP/1.1 200 OK
Etag: "132c1be24595b9b5f7b2c08b300592b1"
Content-Type: text/html; charset=utf-8
Cache-Control: max-age=0, private, must-revalidate
[...]
MacBook:~ xyz$
Jetzt benutzen wir diesen Etag, um mit If-None-Match in der
Anfrage herauszubekommen, ob die von uns gecachete Version noch aktuell
ist:
MacBook:~ xyz$ curl -I http://0.0.0.0:3000/companies -b cookies.txt --header 'If-None-Match: "132c1be24595b9b5f7b2c08b300592b1"'
HTTP/1.1 304 Not Modified
Etag: "132c1be24595b9b5f7b2c08b300592b1"
Cache-Control: max-age=0, private, must-revalidate
[...]
MacBook:~ xyz$
Wir bekommen einen
304 Not Modified
geliefert.
Schauen wir noch ins Rails-Log:
Started HEAD "/companies" for 127.0.0.1 at 2012-07-13 18:45:38 +0200
Processing by CompaniesController#index as */*
Company Load (0.3ms) SELECT "companies".* FROM "companies"
Completed 304 Not Modified in 3ms (ActiveRecord: 0.3ms)
Rails hat für die Verarbeitung der Anfrage nur 3 ms benötigt. Fast
30 mal schneller als die Variante ohne Cache! Plus wieder die
eingesparte Bandbreite. Der Anwender freut sich wieder über die schnelle
Web-Applikation.
current_user und
andere potenzielle Parameter
Wir können als Grundlage für die Generierung eines Etags nicht
nur ein Objekt, sondern auch ein Array von Objekten übergeben. So
können wir das Problem mit dem eingeloggten User lösen. Nehmen wir
einmal an, dass ein eingeloggter User mit der Methode
current_user
ausgegeben wird. Dann würden die
index-
und
show
-Methoden im
app/controllers/companies_controller.rb
Controller so aussehen:
def index
@companies = Company.all
fresh_when etag: [@companies, current_user]
end
def show
@company = Company.find(params[:id])
fresh_when etag: [@company, current_user]
end
Sie können beliebig viele Objekte in diesem Array unterbringen
und somit definieren, wann eine Seite sich nicht verändert hat.
Etag und
Last-Modified kombinieren
Sie können
Etag
und
Last-Modified
auch
zusammen anwenden. Das sieht dann so aus:
def index
@companies = Company.order(:id)
fresh_when :etag => @companies.all,
:last_modified => @companies.maximum(:updated_at)
end
def show
@company = Company.find(params[:id])
fresh_when @company
end
Sie sehen, dass es beim show View eine Kurzform gibt. Das liegt
daran, dass @company
eine Methode
updated_at
hat. Diese wird dann automatisch von
fresh_when
benutzt.
Was passiert, wenn ein
Employee
verändert
oder gelöscht wird? Dann würde sich auf jeden Fall der Show-View und
evt. auch der Index-View ändern müssen. Das ist der Grund für die Zeile
belongs_to :company, :touch => true
im Employee Model. Jedes Mal, wenn ein Objekt der Klasse
Employee
verändert abgespeichert wird, updatet
ActiveRecord bei der Benutzung von
:touch => true
das
darüberliegende
Company
-Element in der Datenbank.
Das
updated_at
-Feld wird auf die aktuelle Uhrzeit
gesetzt. Es wird ge-"touch"-t.
Deshalb ist sichergestellt, dass auch dann wieder eine korrekte
Webseite ausgeliefert wird.
Bis jetzt sind wir immer davon ausgegangen, dass nur HTML-Seiten
ausgeliefert werden. Deshalb konnten wir
fresh_when
benutzen und danach auf den
respond_to do |format|
Block verzichten. HTTP-Caching ist
aber nicht auf HTML-Seiten beschränkt. Wenn wir allerdings zusätzlich z.
B. JSON rendern und via HTTP-Caching ausliefern wollen, so müssen wir
die
stale?
-Methode benutzen. Die Anwendung von
stale?
ähnelt sonst der von
fresh_when
. Das Beispiel aus
„Etag und
Last-Modified kombinieren“ würde bei Verwendung
von
stale?
und beim zusätzlichen Rendern von
JSON wie folgt aussehen:
def index
@companies = Company.order(:id)
if stale? :etag => @companies.all,
:last_modified => @companies.maximum(:updated_at)
respond_to do |format|
format.html
format.json { render json: @companies }
end
end
end
def show
@company = Company.find(params[:id])
if stale? @company
respond_to do |format|
format.html
format.json { render json: @company }
end
end
end
Proxies mitbenutzen
(public)
Bis jetzt sind wir immer von einem Cache im Webbrowser
ausgegangen. Es gibt aber im Internet sehr viele Proxies, die oft näher
beim Anwender sind und deshalb bei Seiten die nicht personalisiert sind
sinnvoll cachen können. Wenn es sich in unserem Beispiel um ein
öffentlich zugängliches Telefonbuch handeln würde, dann könnten wir die
für uns kostenlose Dienste der Proxies mit dem Parameter
public:
true
in
fresh_when
oder
stale?
aktivieren. Das Beispiel aus
„Etag und
Last-Modified kombinieren“ würde bei Verwendung
von
public: true
so aussehen:
def index
@companies = Company.order(:id)
fresh_when :etag => @companies.all,
:last_modified => @companies.maximum(:updated_at),
:public => true
end
def show
@company = Company.find(params[:id])
fresh_when @company, public: true
end
Beim Aufruf der Webseite bekommen wir die Ausgabe:
MacBook:rails-buch stefan$ curl -I http://0.0.0.0:3000/companies
HTTP/1.1 200 OK
Etag: "d45a37972109e8ccea1160d81a6ff79d"
Last-Modified: Sat, 14 Jul 2012 12:40:25 GMT
Content-Type: text/html; charset=utf-8
Cache-Control: public
[...]
Der Header Cache-Control: public
sagt allen Proxies,
dass sie diese Webseite auch cachen können.
Warnung
Die Benutzung von Proxies muss immer mit großer Vorsicht
geschehen. Auf der einen Seite sind sie hervorrangend geeignet, die
eigene Webseite schneller an mehr User auszuliefern, aber auf der
anderen Seite muss man auch ganz sicher sein, dass keine
personalisierten Seiten auf öffentlichen Proxies gecachet werden. So
haben CSRF-Tags und Flash-Nachrichten nichts in einem öffentlichen
Proxy zu suchen. Um bei CSRF-Tags ganz sicherzugehen, empfiehlt es
sich, im Default
app/views/layouts/application.html.erb
Layout die
Ausgabe von
csrf_meta_tag
davon abhängig zu
machen, ob die Seite öffentlich gecachet werden darf oder
nicht:
<%= csrf_meta_tag unless response.cache_control[:public] %>
Cache-Control mit einem Zeitlimit
Bei der Benutzung von
Etag
und
Last-Modified
gehen wir in
„Etag“ und
„Last-Modified“ davon aus, dass der Webbrowser
auf jeden Fall noch mal beim Webserver nachfragt, ob die gecachete
Version einer Webseite noch aktuell ist. Das ist eine sehr sichere
Vorgehensweise.
Allerdings kann man die Optimierung durch eine Prognose in die
Zukunft noch ein Stück weiter treiben: Wenn ich mir beim Ausliefern
einer Webseite sicher bin, dass diese Webseite sich in den nächsten zwei
Minuten, Stunden oder Tagen nicht verändert, dann kann ich das dem
Webbrowser direkt mitteilen. Dann braucht er in dieser Zeitspanne nicht
noch einmal nachzufragen. Diese Overhead-Einsparung hat besonders bei
mobilen Webbrowsern mit relativ hohen Latenzen Vorteile. Außerdem spart
man natürlich auf dem Webserver noch einmal Server-Load.
Bei der Ausgabe des HTTP-Headers wird Ihnen bei den
Etag-
und
Last-Modified
-Beispielen vielleicht
schon die entsprechende Zeile
Cache-Control: max-age=0, private, must-revalidate
aufgefallen
sein. Der Eintrag
must-revalidate
sagt dem Webbrowser, dass
er auf jeden Fall noch mal beim Webserver nachfragen soll, ob sich eine
Webseite mittlerweile verändert hat. Der zweite Parameter
private
bedeutet, dass nur der Webbrowser diese Seite
cachen darf. Auf dem Weg liegende Proxies dürfen diese Seite nicht
cachen.
Wenn wir bei unserem Telefonbuch sagen, dass die Webseite
mindestens für 2 Minuten so bleiben wird, so können wir das
„Etag und
Last-Modified kombinieren“ Beispiel um die dafür
vorgesehene Methode
expires_in
erweitern. Der
Controller
app/controllers/companies.rb
würde dann
folgenden Code für die
index-
und
show
-Methode enthalten:
def index
@companies = Company.order(:id)
expires_in 2.minutes
fresh_when :etag => @companies.all, :last_modified => @companies.maximum(:updated_at)
end
def show
@company = Company.find(params[:id])
expires_in 2.minutes
fresh_when @company
end
Jetzt bekommen wir bei einer Abfrage eine andere Cache-Control
Information:
MacBook:rails-buch stefan$ curl -I http://0.0.0.0:3000/companies
HTTP/1.1 200 OK
Etag: "d45a37972109e8ccea1160d81a6ff79d"
Last-Modified: Sat, 14 Jul 2012 12:40:25 GMT
Content-Type: text/html; charset=utf-8
Cache-Control: max-age=120, private
[...]
Die zwei Minuten werden in Sekunden angegeben
(max-age=120
) und zusätzlich entfällt
must-revalidate
. Der Webbrowser muss also in den nächsten
120 Sekunden nicht mehr beim Webserver nachfragen, ob sich der Inhalt
dieser Seite geändert hat.
Anmerkung
Dieser Mechanismus wird auch von der Asset Pipeline benutzt.
Dort im Produktivbetrieb erstellte Assets sind durch die Prüfsumme im
Dateinamen eindeutig identifizierbar und können sehr lange sowohl im
Webbrowser als auch in öffentlichen Proxies gecachet werden. Deswegen
haben wir in
„nginx-Konfiguration“ in
der nginx Konfigurationsdatei den Abschnitt:
location ^~ /assets/ {
gzip_static on;
expires max;
add_header Cache-Control public;
}