Einfache Content-Negotiation für Nginx

Content-Negotiation (wörtlich: Inhalts-Aushandlung) - ein Schlüsselkonzept für mehrsprachige Websites - ist für Nginx{:_target=_blank} nur als Patch erhältlich. Das Aushandeln der Sprache ist für die meisten Sites aber eine ziemlich triviale Angelegenheit. Statt den Webserver zu patchen, reichen oft ein paar Zeilen Perl-Code.

In Mehrsprachige Websites mit Jekyll habe ich beschrieben, wie sich mit Jekyll{:target=_blank} eine mehrsprachige statische Site aufsetzen lässt. Die Beschreibung der serverseitigen Konfiguration war ich noch schuldig geblieben. Natürlich funktioniert die beschriebene Technik unabhängig von Jekyll.

Wie funktioniert Content-Negotiation?

Wer das alles schon weiß, kann die folgenden Erlärungen auch überspringen und direkt die Lösung des Problems lesen.

Manchmal hält ein Webserver eine Ressource in verschiedenen Ausprägungen vor. So kann zum Beispiel eine Textdatei als HTML-Seite, als PDF, oder OpenOffice-Dokument vorliegen, ein Bild als PNG und GIF, eine große Textdatei mit gzip oder compress komprimiert sein.

Fast alle Browser schicken im Request dafür Accept-Header mit, welche die Präferenzen der Benutzerin und Fähigkeiten des Browsers widerspiegeln. Folgende Header sind definiert:

Accept
Content-Type
Accept-Charset
Character-Set
Accept-Encoding
Kodierung (praktisch immer Kompression)
Accept-Language
Sprache

Wir beschränken uns hier auf das Aushandeln der Sprache. Für die anderen Accept-Header läuft die Sache analog. Weitergehende Informationen lassen unter https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html finden.

Die Sprachpräferenzen des Benutzers übermittelt der Browser mit folgender Syntax im Header Accept-Language:

Accept-Language: de-DE; de, q=0.9; fr; q=0.7; en; q=0.3

Die einzelnen Sprachbezeichner sind durch Kommata getrennt. Sprachbezeichner bestehen dabei aus ein bis acht US-ASCII-Zeichen für den Primärbezeichner (engl.: primary tag), optional gefolgt von einer Reihe von Unterbezeichnern (engl.: sub tag), jeweils in der Form Bindestrich und ein bis acht US-ASCII-Zeichen. Der Primärbezeichner dient eigentlich immer zur Bezeichnung der Sprache, während der erste Unterbezeichner für die Kennzeichnung der Region bzw. des Landes verwendet wird. Mehr als ein Unterbezeichner kommt in der Praxis nicht vor. Alternativ kann auch eine Wildcard (*) angegeben werden, was soviel wie jede beliebige Sprache bedeutet. Aktuelle Browser scheinen dieses Wildcard-Feature nicht zu verwenden.

Dem Sprachbezeichner kann optional ein Qualitätswert (q wie Qualität) folgen, der durch ein Semikolon abgetrennt wird. Der Qualitätswert ist ein Fließkommawert im Bereich von 0 bis 1. Fehlt der Qualitätswert, wird 1 angenommen. Ein Qualitätswert von 0 bedeutet inakzeptabel.

Mit diesem Wissen übersetzen wir jetzt obiges Beispiel:

Accept-Language: de-DE; de, q=0.9; fr; q=0.7; en; q=0.3

Präferiert werden Dokumente auf Deutsch für Deutschland (Qualitätswert 1), gefolgt von Deutsch im Allgemeinen (Qualitätswert 0.9). Gibt es nichts auf Deutsch, wird Französisch gewünscht; sollte auch das nicht vorhanden sein, Englisch.

Preisfrage: Wenn der Server ein Dokument in Deutsch für Österreich (de-AT) und Englisch (en) hat, welches sollte er ausliefern? Das englische! Der Bezeichner de ist nicht gleichbedeutend mit de-*, wobei es solche Wildcards bei den primären oder Unterbezeichnern leider überhaupt nicht gibt.

Struktur einer mehrsprachigen Site

Mehr oder weniger die Referenzimplementierung für Content-Negotiation ist das Apache{:target=_blank}-Modul mod_negotiation{:target=_blank}. Liegen die unterschiedlichen Sprachvarianten einer bestimmten Resource innerhalb eines Verzeichnisses im Document-Root, wird dabei folgende Namenskonvention erwartet:

$ ls htdocs/path/to/directory
index.html.bg    index.html.de-CH index.html.en    index.html.it
index.html.de    index.html.de-DE index.html.es    index.html.ru
index.html.de-AT index.html.de-IT index.html.fr

Fordert ein Browser eine Datei an, die existiert, bekommt er sie auch. Content-Negotiation kommt dann zum Zuge, wenn die Datei nicht existiert. Fordert der Browser also zum Beispiel index.html.de-AT an, bekommt er sie auch. Fragt er dagegen einfach nach index.html, muss der Server eine der passenden Ressourcen auswählen.

Dazu analysiert er wie oben beschrieben den Header Accept-Language, und wird diejenige Datei auswählen, die den Präreferenzen der Benutzerin am nächsten kommen. Das Ganze lässt sich auch noch mit Präferenzen für Charset und Kodierung kombinieren, was dazu führt, dass der Server eventuell bei einer Anfrage nach index.html tatsächlich die Datei index.utf-8.html.de-DE.gz ausliefert.

Content-Negotiation aus Benutzer-Sicht

Besucher einer mehrsprachigen Site erwarten, dass der Server die im Browser hinterlegten Präferenzen für die Sprache einerseits honoriert, andererseits jedoch die einmal gewählte Sprache innerhalb einer Session nicht willkürlich ändert.

Habe ich in meinem Browser also beispielsweise Französisch als bevorzugte Sprache hinterlegt, und rufe die Dokumentation von mod_negotiation{:target=_blank} auf, erwarte ich die französische Variante. Entscheide ich mich aber, diese technische Dokumentation lieber auf Englisch zu lesen, und klicke im Sprachumschalter auf Englisch, erwarte ich, dass ich von da an die englischen Versionen der Dokumente präsentiert bekomme und nicht jedesmal aufs Neue auf Französisch zurückfalle.

Das geht einfach (und ohne Cookies!), indem interne Links jeweils explizit auf die Ressource in der aktuellen Sprache verweisen, also nicht auf index.html, sondern auf index.html.en.

Viele Sites verwenden statt seitenbasierter Content-Negotiation (index.html.de, index.html.fr, etc.) eine Organisation in sprachspezifischen Verzeichnissen. So wären dann beispielsweise alle deutschen Dokumente unter http://www.example.com/de/ verfügbar. Das hat einen kleinen Nachteil: Der Link http://www.example.com/news/000123/index.html unterliegt beim Apache auch bei einer statischen Site der Content-Negotitation, er lässt sich also sprachunabhängig verbreiten. Bei verzeichnisbasierter Struktur wird der analoge Link http://www.example.com/de/news/000123/index.html dagegen immer explizit auf die deutsche Version verweisen. Die verzeichnisbasierte Organisation ist also weniger flexibel, weil Content-Negotiation nicht mehr für jede beliebige Landing-Page funktioniert.

Sprechende URLs

Heute werden aus SEO-Gründen meist sogenannte sprechende URLs bevorzugt, mit möglichst wenigen URL-Bestandteilen, die keinen Content enthalten. So ist die Adresse dieses Posts beispielsweise http://www.guido-flohr.net/einfache-content-negotiation-fuer-nginx/ und nicht etwa http://www.guido-flohr.net/blog/2016/02/28/. Ich persönlich finde das überbewertet, aber es ist State-Of-The-Art.

Das impliziert jedoch, dass nur noch eine einzige Adresse Content-Negotiation unterstützen muss, nämlich die Landing-Page, normalerweise also /. Alle anderen URLs müssen sich bei einer statischen Site unterscheiden, und zwar sprachübergreifend. Das vereinfacht unser Ausgangsproblem stark. Wir müssen Nginx also nur für die Location / beibringen, den Header Accept-Language auszuwerten.

Nginx-Konfiguration

Der Webserver muss unseren Perl-Handler aufrufen:

location = / {
        proxy_set_header X-Forwarded-Host $http_host;
        proxy_set_header X-Forwarded-Port 4001;

        proxy_pass http://localhost:4002;
    }
Die Wurzelseite der Site ist die einzige Landing-Page, bei der wir Content-Negotiation unterstützen müssen, denn von dort an wird die dort ausgewählte Sprachversionen beibehalten, es sei denn, dass die Sprache explizit über den Sprachumschalter gewechselt wird. Deshalb wird unser Handler in Zeile 1 auch nur für die eine Location `/` definiert. **Achtung!** Wer hier das Gleichheitszeichen vergisst, aktiviert den Handler für alle URLs, die mit einem Slash anfangen, und das sind alle. Das führt zu einem Redirect-Loop. Die Zeilen 2 und 3 sorgen dafür, dass nginx den Hostnamen und den Port des eigentlichen Servers in weiteren Request-Headern an den Handler übermittelt. Zeile 5 enthält die Adresse des Handlers. Wir müssen also einen weiteren Webserver starten, der auf Port 4002 auf dem IPv4-Loopback-Interface läuft. Perl-Handler ------------ Mein Handler ist in Perl geschrieben. Man kann natürlich jede beliebige andere Sprache benutzen. Hauptsache ist, wir bekommen irgendeinen möglichst einfachen und leichtgewichtigen Webserver an den Start. Bei Nginx-Handlern denken viele automatisch an [WSGI][wsgi]{:target="_blank"}, bzw. an [PSGI/Plack][psgi]{:target="_blank"} für Perl oder [Rack][rack]{:target="_blank"} für Ruby. PSGI/Plack hat aber derartig viele Abhängigkeiten, dass die Installation einer Installationsorgie gleichkommt, die spätestens bei jedem Perl-Update wiederholt werden muss. Weil das eigentliche Problem lächerlich einfach ist, stelle ich hier eine Lösung vor, die mit zwei Dependencies auskommt, nämlich libintl-perl und HTTP-Server-Simple. Die Bibliothek "libintl-perl" ist auf ziemlich jeder Plattform als vorkompiliertes Paket verfügbar (zum Beispiel als `libintl-perl`, `p5-libintl-perl`, `perl-libintl-perl` oder Ähnliches). Das Modul `HTTP::Server::Simple` wird man oft von Hand installieren müssen:
$ sudo cpan install HTTP::Server::Simple

Den eigentlichen Handler habe ich als lingua.pl gespeichert (x-Bit nicht vergessen!):

#! /usr/bin/env perl

use strict;

my %supported = ( 
    en => '/en/',
    de => '/de/',
);

my $server = ContentNegotiator->new(4002);
$server->host('127.0.0.1');
$server->run;

package ContentNegotiator;

use base qw(HTTP::Server::Simple::CGI);
use Locale::Util qw(parse_http_accept_language);

sub handle_request {
        my ($self, $cgi) = @_;

        my @linguas = parse_http_accept_language $ENV{HTTP_ACCEPT_LANGUAGE};
        my $lingua = 'en';
        my $server = "$ENV{HTTP_X_FORWARDED_HOST}:$ENV{HTTP_X_FORWARDED_PORT}";
        foreach my $l (@linguas) {
            if (exists $supported{$l}) {
                $lingua = $l;
                last;
            }
        }

print <<EOF;
HTTP/1.0 303 See Other
Location: http://$server$supported{$lingua}
Content-Length: 0

EOF
}

1;

Die Zeilen 5 bis 8 enthalten eine Art Konfiguration. Hier werden die verfügbaren Sprachen auf URLs gemappt. Ich habe nur Deutsch und Englisch, und die jeweilige Startseite ist /de/ und /en/.

In Zeile 10 wird die Serverklasse instanziiert, in Zeile 11 weisen wir den Server an, nur das Loopback-Interface zu bedienen, und in Zeile 11 wird der Server mit der Methode run() gestartet. Der Server kann statt mit run() auch als Daemon im Hintergrund mit background() gestartet werden, aber die meisten Init-Systeme kommen mit Skripten, die im Vordergrund laufen besser klar.

Ab Zeile 14 folgt die Definition der Serverklasse. Sie ist von HTTP::Server::Simple::CGI abgeleitet (Zeile 16), weiterhin importieren wir aus libintl-perl in Zeile 17 die Funktion parse_http_accept_language().

Die Sprachpräferenzen des Benutzer aus dem Header Accept-Language übergibt nginx uns in der Umgebungsvariablen HTTP_ACCEPT_LANGUAGE, zum Beispiel etwas wie de-DE; de, q=0.9; fr; q=0.7; en; q=0.3. Die Funktion parse_http_accept_language() erzeugt uns daraus in Zeile 22 eine nach Sprachpräferenz sortierte Liste, die wir in der Variablen @linguas ablegen.

In Zeile 23 wird die ausgehandelte Sprache in der Variablen $lingua auf unsere Fallbacksprache en wie Englisch initialisiert. Ab Zeile 25 gehen wir über die Liste mit den vom Browser übermittelten bevorzugten Sprachen und stoppen in Zeile 28 beim ersten Treffer, wenn also eine bevorzugte Sprache in unseren unterstützten Sprachen %supported existiert.

Bemerkung: Diese Lösung ist nicht perfekt, denn sie hat mindestens zwei Bugs: Wurde eine Sprache mit einem Qualitätswert von 0 als inakzeptabel markiert, kann sie dennoch von unserem Code ausgewählt werden. Außerdem werden Wildcards (*) innerhalb der Sprachbezeichner nicht unterstützt. Trotzdem wird der Code meines Erachtens in der Praxis funktionieren, weil keiner der großen Browser Qualitätswerte von 0 oder Wildcars in Sprachbezeichnern unterstützt.

Wurde kein Treffer gefunden, enthält $lingua noch immer en, unsere Vorgabesprache aus Zeile 23. Ab Zeile 32 schließlich drucken wir einen Redirect auf die Startseite für die jeweilige Sprachseite aus. Dabei verwenden wir die Protokollversion 1.0 von HTTP, die von allen Browsern verstanden wird. Ganz wichtig ist Zeile 35, wo die Content-Länge mit 0 angegeben wird. Gibt man den Header nicht aus, versucht nginx noch einige Sekunden weitere Daten vom Handler zu lesen, bis es aufgibt. Das verzögert unnötig.

Jetzt testen wir unseren Handler. Dazu starten wir ihn in einer Shell mit perl lingua.pl. In einer zweiten Shell starten wir eine Telnet-Session:

$ telnet 127.0.0.1 4002
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
GET / HTTP/1.1
Host: localhost 

HTTP/1.0 303 See Other
Location: http://:/en/
Content-Length: 0

Connection closed by foreign host.

Die Location Location: http://:/en/ sieht natürlich nicht so gut aus. Das liegt darin, dass wir die Header X-Forwarded-Host and X-Forwarded-Host aus der nginx-Konfiguration, auf die sich der Handler verlässt, nicht übergeben. Das können wir aber einfach emulieren:

$ telnet 127.0.0.1 4002
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
GET / HTTP/1.1
Host: localhost
X-Forwarded-Host: www.example.com
X-Forwarded-Port: 4242

HTTP/1.0 303 See Other
Location: http://www.example.com:4242/en/
Content-Length: 0

Connection closed by foreign host.

HTTP::Server::Simple übersetzt alle HTTP-Request-Header nach dem Schema X-Forwarded-Host => HTTP_X_FORWARDED-HOST in Umgebungsvariablen. Das ist ziemlich Standard.

Der finale Test geht auf den tatsächlichen Webserver:

$ telnet www.guido-flohr.net 80
Trying 62.75.204.82...
Connected to www.guido-flohr.net.
Escape character is '^]'.
GET / HTTP/1.1
Host: www.guido-flohr.net

HTTP/1.1 303 See Other
Server: nginx/1.6.3
Date: Sun, 28 Feb 2016 21:01:51 GMT
Content-Length: 0
Connection: keep-alive
Location: http://www.guido-flohr.net:80/en/

Hinweis: Zum Prompt kommt man mit STRG-D oder mit STRG-] gefolgt von close am Telnet-Prompt telnet> zurück.

Wie man sieht, macht Nginx für uns ein Protokoll-Upgrade auf HTTP 1.1 (Zeile 8) und fügt die Header Server, Date, und Connection automatisch zu.

Automatisches Starten des Perl-Handlers

Jetzt lässt uns nginx den Preis für unseren Verzicht auf Apache bezahlen. Wir müssen irgendwie dafür sorgen, dass der Perl-Handler automatisch mit nginx gestartet wird. Dafür haben wir drei Möglichkeiten:

Aufruf via Cron

Eine Quick-and-Dirty-Lösung, die es aber erstaunlich gut tut. Dazu editieren wir die Crontab-Datei für den Webserver-User oder root, und tragen ein:

* * * * * nohup /path/to/lingua.pl >/dev/null 2>&1 &

Wir müssen das Skript nicht mit einer PID-Datei gegen parallele Ausführung zu schützen, weil unser Handler immer auf dem gleichen Port hört. Läuft bereits eine Instanz, terminiert der Handler unmittelbar mit einer Fehlermeldung.

Aufruf via Init-Script

Normalerweise ist das sehr simpel. Man kopiert sich ein ähnliches Startskript aus /etc/init.d und passt es an die eigenen Bedürfnisse an.
Die einfachste Lösung. Wir suchen in /etc/init.d/ einen vergleichbaren Dienst, kopieren das Skript und passen es an unseren Handler an. Für BSD-Varianten wird man in /etc/rc* fündig.

Exemplarisch hier ein /etc/init.d/nginx-lingua für Gentoo-Linux{:target=_blank}:

#!/sbin/runscript

depend() {
    need net
    before nginx
}

start() {
    ebegin "Starting nginx language negotiation."
    start-stop-daemon --start \
        --exec /var/lib/nginx/lingua.pl \
        --user nginx:nginx \
        --background --make-pidfile --pidfile /var/run/nginx-lingua.pid
    eend $?
}

stop() {
    ebegin "Stopping nginx language negotiation."
    start-stop-daemon --stop \
        --exec /var/lib/nginx/lingua.pl \
        --pidfile /var/run/nginx-lingua.pid
    eend $?
}

Zeile 4 definiert, dass wir den Service net, also Netzwerk brauchen, und Zeile 5, dass wir vor nginx gestartet werden sollten, damit nginx unseren Proxy von Anfang an verwenden kann.

Die Funktion start() ab Zeile 8 enthält nichts Besonderes. Wir starten den Service mit start-stop-daemon, geben mit der Option --execden Pfad zu unserem Skript an und sorgen mit der Option --user dafür, dass wir nicht mit Root-Privilegien laufen.

In Zeile 13 weisen wir den start-stop-daemon an, unser Skript in den Hintergrund zu legen, und eine PID-Datei /var/run/nginx-lingua.pid zu erzeugen.

Wäre es nicht schlauer, den Perl-Handler selber in den Hintergrund zu legen und die PID-Datei zu schreiben? Nein, denn start-stop-daemon macht das absolut zuverlässig und sicher im Gegensatz zu den meisten Daemon-Implementierungen für Perl oder andere Skriptsprachen.

Die Funktion stop() ab Zeile 17 ist jetzt selbsterklärend, weil sie der Start-Funktion entspricht. Ein paar Start-Optionen können wir uns jetzt aber natürlich schenken.

Schließlich müssen wir den Service noch starten und dem Default-Runlevel zufügen:

$ sudo /etc/init.d/nginx-lingua
$ sudo rc-update add nginx-lingua default

Systemd

Systemd{:target=_blank} ist eine zur Zeit sehr populäre Alternative zu klassischen Init-Systemen. Systemd gibt den flexiblen Ansatz von Init-Skripten zugunsten sogenannter Service-Dateien auf. Das Verzeichnis /etc/systemd/system scheint ein guter Ausgangspunkt für die Suche nach dem richtigen Ort für eigene Services zu sein.

Wir erzeugen daher die Datei /etc/systemd/system/nginx-lingua.service`:

[Unit]
Description=HTTP Language Negotiation For Nginx

[Service]
Type=simple
ExecStart=/usr/share/nginx/lingua.pl
User=nginx

[Install]
WantedBy=multi-user.target

Wir müssen den Handler jetzt noch starten und persisten machen:

$ sudo systemctl enable nginx-lingua
$ sudo systemctl start nginx-lingua

Hier wird unser Handler erst dem Default-Runlevel zugefügt und dann gestartet. Wichtig! Systemd ergänzt den Extender .service automatisch, wie man es von MS-DOS-basierten Systemen kennt. Man darf also nicht den vollständigen Dateinamen nginx-lingua.service angeben!

Fazit

Die vorgestellte Lösung bedient meine konkreten Requirements, lässt sich aber leicht an die eigenen Bedürfnisse anpassen. Wer HTTP::Server::Simple nicht mag, bekommt das Beispiel sehr leicht auf Plack oder Starman umgeschrieben. Wer libintl-perl nicht installieren mag, kopiert sich einfach die Funktion parse_http_accept_language() aus dem Quelltext von Locale::Util. Wer lieber Ruby oder Python oder was auch immer verwenden will, kann das Beispiel sehr leicht übertragen.

Es gibt auch noch einen subtilen Unterschied zur Content-Negotation bei Apache mit mod_negotiation{:target=_blank}. Während mod_negotiation normalerweise die angeforderte Ressource selber ausliefert, machen wir jeweils einen Redirect. Beides hat seine Vorzüge. Wer den Redirect nicht mag, bekommt das Beispiel sicher leicht umgeschrieben.


blog comments powered by Disqus