Neu: Rails Schulungen in Deutschland vom Autor dieses Buches.div class="para">HTTP-Caching versucht, bereits geladene Webseiten oder Dateien wiederzuverwenden. Wenn Sie z. B. mehrmals am Tag auf eine Webseite wie http://www.heise.de oder http://www.spiegel.de gehen, um sich dort immer die aktuellen Nachrichten durchzulesen, dann werden bestimmte Elemente dieser Seite (z. B. die Logo-Grafik am Kopf der Seite) beim zweiten Laden nicht noch mal geladen. Ihr Browser hat diese Dateien bereits im Cache und spart somit Zugriffszeit und Bandbreite.

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.

Last-Modified

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.

Etag

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.

Die Magie von touch

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.

stale?

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;
}

Autor

Stefan Wintermeyer