Endlich verstehen: Unicode mit PHP5 und MySQL

Wenn es um PHP und Unicode geht, am Besten auch noch in Zusammenhang mit MySQL, gibt es eine Vielzahl an Tipps und Howtos. Manche davon sind falsch, die meisten zum Glück zwar richtig, doch selbst die, die Richtiges vermitteln, tun das manchmal nur durch Zufall, und oftmals fehlt das, was für eine verlässliche Handhabung am wichtigsten ist: Eine Erklärung, warum man dieses und jenes so und nicht anders machen soll.

Dieser Artikel hat den hehren Anspruch, Erklärungen zu liefern, Verständnis für Zusammenhänge zu wecken und schließlich dabei unterstützen, Unicode mit PHP richtig zu machen – ohne dass Sie dafür ein dickes Handbuch lesen müssen. Natürlich bleiben dabei viele Details auf der Strecke, deshalb sehen Sie diesen Artikel pragmatisch: Er sollte für den gewöhnlichen Alltag reichen und Sie dabei nicht ganz dumm sterben lassen. Für tiefergehende Informationen – lesen Sie ein Buch.

Zunächst einmal gilt es eine Sache zu verstehen, nämlich:

„Zeichen“ und „Bytes“ sind etwas völlig Verschiedenes.

Das ist vor allem wichtig für diejenigen, die bei diesem Thema erstmal gleich die gute alte Zeichensatztabelle im Kopf haben. Da haben wir nämlich gelernt: Ein Zeichen ist ein Byte; mit einem Byte kann man 256 verschiedene Zeichen darstellen. Wenn man gut aufgepasst hat, weiß man vielleicht auch noch, dass die ersten 128 Zeichen immer gleich waren („US-ASCII“) und die nächsten 128 Zeichen darüber zeichensatzspezifisch waren – dass das Zeichen mit der Nummer 188 z.B. in ISO-8859-1 „¼“ bedeutet, während es in ISO-8859-2 „ź“ bedeutet.

Lektion Nummer 1: Diese Zeichensatztabellen bitte alle sofort vergessen. Zu einem späteren Zeitpunkt, wenn das mit Unicode klar geworden ist, kann man sich gerne wieder daran erinnern und sie sauber in einen technischen Zusammenhang bringen, aber für den Moment: Weg damit. Und damit auch gleich weg mit der Vorstellung, dass „1 Byte“ in irgendeiner Art und Weise „1 Zeichen“ bedeuten würde. In der gebräuchlichen UTF-8-Kodierung kann ein Zeichen nämlich durchaus bis zu vier Bytes benötigen.

Hier nun daher der große, grundsätzliche Pferdefuß von PHP in allen Versionen < 6, direkt aus der offiziellen Quelle:

A string is series of characters, therefore, a character is the same as a byte. That is, there are exactly 256 different characters possible. This also implies that PHP has no native support of Unicode.

Bitte hier kurz innehalten, durchatmen und sich noch einmal ganz deutlich vor Augen führen: PHP hat keine Ahnung von Unicode.

Um das ad hoc etwas greifbarer zu machen, hier ein konkretes Beispiel für ein PHP-Script, das ich mit einem Texteditor erstellt habe, der die Datei UTF-8-kodiert abspeichert (was heutzutage eigentlich alle tun):

$ cat test-strlen.php
<?php echo strlen("ä"); ?>

$ php test-strlen.php
2

Überrascht? Wichtig ist das vor allem für Leute, die sonst in anderen Programmiersprachen entwickeln, die von Haus aus UTF-8-tauglich sind. Nehmen wir zum Beispiel Perl: Dort werden intern Strings als Zeichen verarbeitet; ein interner UTF-8-Marker hilft Perl dabei, zu verstehen, ob die im RAM liegenden Bytes einer Zeichenkette als UTF-8 interpretiert werden müssen, um sinnvolle Zeichen zu ergeben, oder nicht. Mit PHP muss in diesem Punkt grundsätzlich anders gearbeitet werden.

Eines ist aber so oder so wichtig zu verstehen: Erst die Angabe eines Zeichensatzes kann eine Folge von Bytes sinnvoll in eine Folge von Zeichen überführen, oder griffiger für einen Klebezettel an der Kühlschranktür ausgedrückt:

1-4 Bytes + Zeichensatzangabe = Zeichen

Wann immer also auf einer Website oder in einer E-Mail „kaputte“ Umlaute zu beobachten sind, so liegt das immer daran, dass entweder eine Zeichensatzangabe vorhanden ist, die aber nicht stimmt, oder dass gar keine vorhanden ist, der Client den Zeichensatz rät und dabei den falschen rät. Das kann man ihm aber kaum zum Vorwurf machen – es ist Aufgabe des Erstellers des Inhalts, sich um die korrekte Zeichensatzangabe zu kümmern.

Nun findet auf Websites aber jene Menge Interaktion statt, und so gibt es gleich eine ganze Reihe von Bereichen, in denen Unicode eine Rolle spielt:

  • ein PHP-Script sendet Daten an den Browser
  • ein Browser sendet Daten an ein PHP-Script
  • ein PHP-Script liest Daten aus einer Datenbank
  • ein PHP-Script schreibt Daten in eine Datenbank

Jegliche Art von Kommunikation basiert aber auf Bytes und nicht auf Zeichen, was in der Konsequenz bedeutet: Bei jeglicher Art von Kommunikation muss eine Zeichensatzangabe mitgegeben werden, damit nichts schiefgeht. Das klingt auf den ersten Blick aufwendig, aber um es ganz deutlich zu sagen: Daran führt kein Weg vorbei.

Nehmen wir den einfachsten Fall: Ein PHP-Script sendet Daten an den Browser. Ein PHP-Script ist eine Textdatei, insofern wird der Zeichensatz dieser Textdatei durch den Editor festgelegt, der sie speichert. Dann ist wichtig, dass der Browser über den Zeichensatz informiert wird. Da gibt es viele Möglichkeiten; die wichtigsten wären:

  • das PHP-Script führt header("Content-type: text/html; charset=utf-8"); aus
  • im HTML-Header wird <meta http-equiv="Content-type" content="text/html; charset=utf-8"> angegeben
  • in der php.ini wird default_charset = utf-8 angegeben
  • in der Apache-Konfiguration wird AddDefaultCharset UTF-8 angegeben

Der umgekehrte Weg, dass ein Browser Daten an ein Script sendet, ist überraschend unklar: HTTP-Requests sehen nämlich keinen Zeichensatz-Header vor. Dabei wäre es doch schon durchaus wichtig zu wissen, ob ein vom Website-Besucher in einem Formular angegebenes „ä“ nun als ein oder als zwei Bytes übermittelt wird! In der Praxis wird ein Browser die Formulardaten im gleichen Zeichensatz kodieren wie der der Seite, die er abgerufen hatte, mit einem Fallback auf ISO-8859-1. Für den Alltag reicht das aus. Entwickler von HTTP-basierenden Schnittstellen, bei denen ein Client initiativ Formulardaten an den Webserver schickt, sind aber gut beraten, in der Schnittstellendefinition kurzerhand ausdrücklich festzulegen, was erwartet wird.

Nun haben wir also Formulardaten erhalten, und diese Formulardaten haben irgendeinen Zeichensatz, idealerweise UTF-8, wenn wir dem Browser schon das Formular als UTF-8 präsentiert haben. So weit, so gut.

Nun müssen die Daten in eine Datenbank, wobei ich hier als Beispiel einfach mal MySQL verwende. Und auch hier gibt es eine „Grundwahrheit“, die man sich am Besten gleich neben den Klebezettel von vorhin an den Kühlschrank heften sollte:

Bei MySQL sind Zeichensatzangaben für zwei Dinge wichtig:

  1. für die Information, in welchem Zeichensatz Daten gespeichert werden sollen;
  2. für die Information, in welchem Zeichensatz Daten übermittelt werden sollen.

(Ja, das ist schnöde vereinfacht, denn schließlich gibt es in MySQL ein client character set, ein connection character set, ein results character set, ein system character set, ein database character set und ein server character set. Wir wollen es aber für den Moment nicht zu kompliziert machen und uns auf die Dinge konzentrieren, bei denen es normalerweise Probleme gibt. Ganz pragmatisch, wissenschon.)

Über den ersten Punkt ist sicher fast jeder schon mal gestolpert, der mit einer halbwegs aktuellen phpMyAdmin-Version hantiert hat: Für jede Spalte, in die textliche Angaben hineinkommen (CHAR, VARCHAR, TEXT, …), muss ein Zeichensatz angegeben werden, wobei der Defaultwert von der Tabelle und der wiederum von der Datenbank und der wiederum vom Server geerbt wird. Und eines sei an dieser Stelle direkt gesagt: Man mag von phpMyAdmin halten, was man will, aber das mit den Zeichensätzen macht es richtig. Wenn Sie also durch die Daten blättern und über Umlaute stolpern, die kaputt sind: Suchen Sie den Fehler bei sich. phpMyAdmin hat immer recht. Wiederholen Sie das.

Bevor es weitergeht, wenden wir uns dem Herren weiter hinten im Publikum zu, der schon seit einer Minute mit dem Finger schnippst und behauptet, das könne nicht stimmen, denn seine Applikation würde prima funktionieren, aber phpMyAdmin würde die Umlaute falsch anzeigen. Nun: Lassen Sie sich nicht foppen. Suchen Sie den Fehler bei sich, und erinnern Sie sich an das Mantra: phpMyAdmin hat immer recht. Es wird reiner Zufall, um nicht zu sagen: schieres Glück sein, dass es trotzdem funktioniert. Typischerweise passiert das dann, wenn Sie UTF-8-kodierte Daten in einer als Latin1 markierten Spalte ablegen. Hier werden die Zeichen dann zweimal falsch verarbeitet: Wenn MySQL die Bytes aus dem Datensatz entsprechend dem Zeichensatz der Spalte interpretiert, und wenn es dann dieses fehlerhafte Ergebnis in den gewünschten Zeichensatz der Verbindung konvertiert, der vermutlich Latin1 und nicht UTF-8 ist und somit die erhaltenen Bytes weitestgehend unbehelligt lässt. Und zweimal falsch ergibt in diesem speziellen Zusammenhang ausnahmsweise sogar mal richtig: Sie geben faktisch eine Reihe von Bytes aus, die in Latin1 ein krummes „Büttelborn“ ergeben und schreiben dann für den Browser eiskalt drüber: So, das ist jetzt aber UTF-8. Und erst damit kommt das Zeichensatzproblem wie auf magische Weise in Ordnung, und der Browser rendert „Büttelborn“ aus den schnöden Bytes.

Wenn Sie dann aber Ihre selbstgeschriebene Applikation später mal auf einen anderen Server übertragen, oder wenn Sie MySQL updaten, oder wenn Sie PHP updaten, … und plötzlich die Umlaute nicht mehr funktionieren, dann wissen Sie, dass Sie etwas falsch gemacht haben, und zwar nicht mit dem Update, sondern mit Ihrem Code, schon vor langer Zeit. Sagen Sie nicht, ich hätte Sie nicht gewarnt.

Es wird somit Zeit für den nächsten Kühlschrankzettel:

SET NAMES utf8;

Damit stellen Sie ein, in welchem Zeichensatz Sie die Daten zwischen PHP und MySQL übermitteln. Das betrifft sowohl die Daten, die Sie in die Datenbank schreiben, als auch die, die Sie aus der Datenbank beziehen. Und jetzt der Clou: Die Information, in welchem Zeichensatz MySQL die Daten speichert, hat damit absolut nichts zu tun. Mit SET NAMES utf8 bekommen Sie immer UTF-8 raus, egal welchen Zeichensatz die Strings in der Tabelle faktisch haben. Und auch umgekehrt schicken Sie einfach Bytes in dem Zeichensatz rüber, den Sie bei SET NAMES angegeben haben, und MySQL kümmert sich darum, diese Folge von Bytes anhand des angegebenen Zeichensatzes als Zeichen zu interpretieren und dann diese Zeichen anhand des Zeichensatzes der Spaltendefinition in eine Folge von Bytes zu konvertieren, dies es dann fröhlich auf die Festplatte schreiben kann. Um so besser, wenn die beiden Zeichensätze identisch sind: Dann muss nämlich überhaupt nichts konvertiert werden.

Das Mantra für PHP-Entwickler muss also lauten:

Es spielt für Ihr Script überhaupt keine Rolle, welchen Zeichensatz die Spalten in der Datenbank haben.

Es spielt ausschließlich eine Rolle, in welchem Zeichensatz Sie mit MySQL kommunizieren.

(Na gut, erwischt. Das ist natürlich nicht die volle Wahrheit. Es spielt insofern doch eine Rolle, als dass es nicht jedes Zeichen in jedem Zeichensatz gibt. Übermitteln Sie also das Zeichen „✌“ UTF-8-kodiert an MySQL und verlangen vom Datenbankserver, dass er dieses Zeichen in eine als Latin1 formatierte Spalte schreibt, obwohl Latin1 das Zeichen gar nicht kennt, dann haben Sie schlechte Karten. Wunder kann MySQL nicht vollbringen. Insofern: Formatieren Sie Ihre Spalten lieber gleich als UTF-8.)

Gewöhnen Sie sich also einfach an, nach jedem mysql_connect(...) direkt ein mysql_query("SET NAMES utf8"); auszuführen. Wenn Sie zu den verantwortungsvollen Entwicklern gehören, die den Aufbau der Datenbankverbindung an einer zentralen Stelle untergebracht haben, sollte das ein Klacks sein.

Nun sind sie schon mal ziemlich weit: Sie kriegen die Daten aus Ihrer Datenbank als Bytes, die – wenn man UTF-8-Kodierung auf sie anwendet – hübsch korrekte Zeichen ergeben. Wenn Sie diese Zeichen mit echo ausgeben und dazu einen Content-type-Header mit „charset=utf-8“ (siehe oben), wird ein Browser die Zeichen richtig darstellen. Sie bekommen bei einem abgeschickten Formular Bytes rein, und wenn Sie diese Bytes an MySQL schicken, nachdem Sie ihm mit SET NAMES utf8; mitgeteilt haben, dass diese Bytes, als UTF-8 interpretiert, hübsche Zeichen ergeben, wird MySQL das auch verstehen und dann schließlich ganz ohne Ihr Zutun die Zeichenkette auf die Festplatte schreiben, in dem Zeichensatz, der in der Spaltendefinition steht.

Was noch fehlt, ist PHP selbst. Denken Sie an das obige Beispiel mit dem strlen("ä"), das 2 als – nun gar nicht mehr so – überraschendes Ergebnis liefert, weil ein „ä“ in UTF-8 eben zwei Bytes entspricht. Denken Sie an die unzähligen Vorkommen von strlen, strpos oder auch strtoupper: Letztere Funktion kann natürlich aus einem „u“ ein „U“ machen. Aber kann sie auch aus einem „ü“ ein „Ü“ machen? Wenn Sie bedenken, dass PHP einen String als eine Folge von Bytes ansieht, dann sollten Sie sich vor Augen halten, dass die zwei Bytes, die in der UTF-8-Kodierung ein „ü“ ergeben, im Latin1-Zeichensatz für zwei einzelne Zeichen stehen, nämlich für ein „Ó und ein „¼“. Diese Zeichen in Großbuchstaben umzuwandeln, dürfte schwierig sein. Das findet dann auch PHP:

$ cat test-strtoupper.php
<?php echo strtoupper("über"); ?>

$ php test-strtoupper.php
üBER

Ihnen wird schon ganz übel? Gut so, das ist der erste Schritt zur Heilung.

Hier kommt die mbstring-Erweiterung ins Spiel. Wenn Sie PHP nicht gerade selbst kompiliert haben (wo mbstring gerne vergessen wird), ist es typischerweise bei allen Distributionen mit einkompiliert. Schon mit phpMyAdmin werden Sie zeichensatzmäßig keine große Freude haben, wenn mbstring fehlt – so wenig, dass schon auf der Startseite deutlich gewarnt wird, dass Sie sich gar nicht einbilden müssen, irgendwie korrekt dargestellte Zeichen zu erwarten.

Mit der mbstring-Erweiterung bekommen Sie für nahezu alle stringverarbeitenden Funktionen Pendants mit dem Präfix mb_, der genau das gleiche tut, aber zeichensatzsensibel ist:

$ cat test-mb_strlen.php
<?php
echo strlen("ä") . "\n";
echo mb_strlen("ä", "utf8") . "\n";
?>

$ php test-mb_strlen.php
2
1

Ja, genau: Sie müssen den mb_-Funktionen den Zeichensatz mitteilen, in dem es den String aus Bytes interpretieren soll. Das sollte Sie inzwischen aber nicht mehr wundern. Sie können alternativ in der php.ini (oder mit ini_set()) auch mbstring.internal_encoding auf den gewünschten Zeichensatz setzen und sich die Angabe sparen.

Bleibt aber noch, überhaupt erstmal überall den mb_-Präfix einzustreuen.

Möglicherweise stöhnen Sie jetzt und lassen vor dem inneren Auge Tausende von Codezeilen vorbeiwandern, die anzupassen sind. Dafür hat sich das PHP-Team mbstring.func_overload ausgedacht. Das bedeutet nichts anderes, als dass die wichtigsten nicht multibytefähigen Stringfunktionen kurzerhand auf ihre multibytefähigen Pendants „umgebogen“ werden. Das kann allerdings nur in der php.ini, einer .htaccess-Datei oder in der httpd.conf gesetzt werden – nicht mit ini_set() im PHP-Script. Zu den nur verzeichnisspezifischen Einstellungen bemerkt die Dokumentation dann aber auch gleich:

It is not recommended to use the function overloading option in the per-directory context, because it’s not confirmed yet to be stable enough in a production environment and may lead to undefined behaviour.

Es bleibt also eigentlich nur der Einsatz in der php.ini – und genau dort ist er prädestiniert für „fix one, break another“: Vielleicht funktioniert Ihre Applikation danach problemlos; eine andere geht dafür kaputt. Sofern Sie den gesamten Server mit allen PHP-Scripts selbst kontrollieren, kann function overloading eine Option für Sie sein, unter Vorbehalt. Aber spätestens dann, wenn Sie Ihre Scripts z.B. auf eine andere Maschine portieren möchten oder gar auf einen Webspace, bei dem Sie gar keinen Zugriff auf die php.ini haben, stehen Sie dumm da und wünschen sich, doch lieber überall die Funktionen mit dem mb_-Präfix verwendet zu haben.

Bei PHP 6 wird dann übrigens alles anders. Dort wird es dann für Unicode-Strings und für Byte-Strings zwei verschiedene Typen geben, und die ganzen stringverarbeitenden Funktionen werden sich automatisch entsprechend des Typs des Strings korrekt verhalten. Soll heißen: Was in der guten alten Zeiten von Latin1 noch „einfach so“ funktionierte, wird mit PHP 6 dann vermutlich auch „einfach so“ funktionieren, mit schickem Unicode-Support.

Nur heute – heute ist es ein K(r)ampf. Nehmen Sie lieber Perl.

Update: Christian hat die Aufforderungen des Artikels erfreulich ernst genommen, bemerkt aber auch zerknirscht, dass seine Frau die Zettel wieder vom Kühlschrank weg haben möchte. Aber der Gedanke zählt!