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
- Accept-Charset
- Accept-Encoding
- Accept-Language
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 --exec
den 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