Webentwicklung

UTF-8 vom Browser bis MariaDB - Text in einer PHP-Anwendung intakt halten

UTF-8 vom Browser bis MariaDB - Text in einer PHP-Anwendung intakt halten

Ein Formular nimmt einen Namen korrekt entgegen, PHP gibt ihn in einer Debug-Meldung korrekt aus und MariaDB speichert etwas, das weiterhin plausibel aussieht. Später zeigt die Seite Ersatzzeichen, eine Suche findet ein scheinbar identisches Wort nicht oder ein Emoji verschwindet. Welche Schicht hat den Text verändert?

Für eine Webanwendung gibt es keinen einzelnen „UTF-8-Schalter“. Text überschreitet mehrere Grenzen: vom Browser zur HTTP-Anfrage, von den Anfrage-Bytes zu PHP-Strings, von PHP zur Datenbankverbindung, von der Verbindung zur Spalte und von den gespeicherten Bytes zurück zu einer HTML-Antwort. Jede Grenze hat ihren eigenen Vertrag. Eine falsche Annahme kann verborgen bleiben, während eine ausschließlich mit ASCII arbeitende Testsuite weiterhin erfolgreich ist.

Dieser Artikel entwickelt eine schichtweise Prüfung für eine kleine PHP-Anwendung mit PDO und MariaDB. Sie richtet sich an neue oder bereits UTF-8-basierte Systeme. Die Reparatur von Altdaten ist ein eigenes forensisches Problem, denn das Ändern einer Bezeichnung kann nicht offenlegen, wie beschädigte Bytes ursprünglich interpretiert wurden.

Zeichen, Bytes und Vergleichsregeln trennen

Unicode weist Zeichen Codepoints zu. UTF-8 ist eine Kodierung, die Unicode-Skalarwerte als Bytefolgen darstellt. Beides hängt zusammen, ist aber nicht synonym. Der WHATWG Encoding Standard definiert Kodierung als Abbildung zwischen Skalarwerten und Bytes, verlangt UTF-8 für neue Formate und Kontexte und dokumentiert, warum unterschiedliche Kodierungsannahmen bei Erzeuger und Empfänger Korrektheit und Sicherheit beeinträchtigen können.

Eine Datenbankkollation beantwortet eine andere Frage: Wie sollen Strings verglichen und sortiert werden? Zwei Spalten können beide utf8mb4 verwenden und dennoch unterschiedliche Regeln für Groß- und Kleinschreibung, Akzente oder sprachliche Sortierung anwenden. Eine Schriftart bildet eine weitere unabhängige Schicht, die bestimmt, welches Glyph auf dem Bildschirm erscheint. Ein leeres Kästchen kann auf ein Schriftproblem hinweisen; U+FFFD, das Ersatzzeichen, deutet meist auf eine ungültige oder falsch dekodierte Bytefolge hin. Keine der beiden Beobachtungen zeigt für sich allein, wo der ursprüngliche Fehler entstand.

Aus dieser Trennung ergibt sich eine nützliche Debugging-Gewohnheit: An jeder Grenze sollte geklärt werden, welche Zeichen gemeint sind, welche Bytes vorliegen, welche Kodierung deklariert ist und welche Vergleichsrichtlinie gilt. „Hier sieht es richtig aus“ ist ein schwächerer Beleg als diese vier Antworten.

UTF-8 an der Webgrenze deklarieren

Eine HTML-Antwort sollte ihre Kodierung konsistent angeben. PHP kann Medientyp und Charset im HTTP-Header senden, während das Dokument früh eine Metadaten-Deklaration enthält:

<?php
header('Content-Type: text/html; charset=UTF-8');
?>
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Text boundary test</title>
</head>

Der HTML Standard definiert <meta charset="utf-8"> als Deklaration der Dokumentkodierung und empfiehlt, Kodierungsinformationen früh zu platzieren. HTTP-Antwort und HTML sollten übereinstimmen, statt sich auf die Erkennung des Browsers zu verlassen. Auch PHP-Quelldateien, Templates, Übersetzungsdateien und Import-Fixtures müssen als UTF-8 gespeichert werden; eine korrekte Antwortdeklaration transkodiert keine Quelldatei, die in einer anderen Kodierung geschrieben wurde.

Anfragen benötigen einen ebenso klaren Vertrag. Moderne HTML-Formulare, die als UTF-8 ausgeliefert werden, übertragen Text normalerweise als UTF-8; JSON-APIs verwenden im Web-Ökosystem von JSON üblicherweise ebenfalls UTF-8. Die Anwendung sollte dennoch festlegen, was sie akzeptiert, und fehlerhafte Eingaben an einer kontrollierten Grenze ablehnen, statt zwischen mehreren Kodierungen zu raten.

Erwartete Eingaben validieren statt blind zu konvertieren

PHP-Strings sind Bytefolgen; PHP weist nicht jedem String dauerhaft einen UTF-8-Typ zu. Die mbstring-Funktion mb_check_encoding kann prüfen, ob ein Bytestrom für eine angegebene Kodierung gültig ist:

function requireUtf8(string $value): string
{
    if (!mb_check_encoding($value, 'UTF-8')) {
        throw new InvalidArgumentException('Expected valid UTF-8 text.');
    }

    return $value;
}

Gültigkeit ist bewusst ein enges Ergebnis. Sie besagt, dass die Bytes gültiges UTF-8 bilden. Sie besagt nicht, dass der Text normalisiert, für HTML sicher, als Benutzername zulässig oder für den aktuellen Benutzer autorisiert ist. Die Prüfung erkennt zudem nicht jeden Fall von Mojibake: Text, der falsch dekodiert und anschließend als gültiges UTF-8 kodiert wurde, kann die Prüfung bestehen und trotzdem falsche Zeichen anzeigen.

Eine blinde Konvertierung ist daher riskant. Ein Aufruf, der „aus irgendeiner möglichen Kodierung“ konvertiert, kann Unsicherheit in dauerhaften Datenverlust verwandeln. Wenn ein Import tatsächlich eine Altkodierung verwendet, sollte diese aus der dokumentierten Quelle bestimmt, genau einmal an der Importgrenze konvertiert und mit genügend Nachweisen versehen werden, um den Prozess reproduzieren oder rückgängig machen zu können. Daten mit unbekannter Vorgeschichte sollten nicht wiederholt konvertiert werden.

Das Charset der Datenbankverbindung beim Verbindungsaufbau setzen

Eine utf8mb4-Spalte teilt MariaDB allein noch nicht mit, wie eingehende Bytes eines Clients zu interpretieren sind. Die Client-Server-Verbindung besitzt eigene Zeichensatzvariablen. Die Dokumentation des PDO_MYSQL-DSN bietet ein charset-Element, sodass die Absicht beim Verbindungsaufbau durch PDO festgelegt werden kann:

$pdo = new PDO(
    'mysql:host=127.0.0.1;dbname=appdb;charset=utf8mb4',
    $username,
    $password,
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]
);

charset=utf8mb4 ausdrücklich anzugeben ist verlässlicher als darauf zu hoffen, dass Server, Distribution oder eine aktualisierte Installation den erwarteten Standardwert besitzt. So findet die Aushandlung außerdem vor den Anwendungsabfragen statt. Prepared Statements bleiben notwendig, um SQL-Struktur und Werte zu trennen; die Zeichensatzoption ersetzt keine Parametrisierung.

Bei der Diagnose einer Umgebung sollte die aktive Sitzung und nicht nur eine Konfigurationsdatei geprüft werden:

SHOW SESSION VARIABLES LIKE 'character_set_%';
SHOW SESSION VARIABLES LIKE 'collation_connection';

Die relevanten Werte sollten für Client, Connection und Results ein konsistentes Bild ergeben. Diese Beobachtung ist ein Beleg für diese Verbindung, aber kein Nachweis dafür, dass jeder Worker oder Kommandozeilenimport dieselben Einstellungen verwendet.

Das Schema statt nur den Datenbankstandard prüfen

Die MariaDB-Dokumentation zu Zeichensätzen und Kollationen beschreibt Einstellungen auf Server-, Datenbank-, Tabellen-, Spalten- und Verbindungsebene. Standardwerte werden vererbt, doch eine ältere Tabelle oder eine ausdrücklich konfigurierte Spalte kann von ihrer Datenbank abweichen. Standards unterscheiden sich außerdem zwischen MariaDB-Versionen und Distributionen. Ein serverweiter Wert ist deshalb keine Schemaprüfung.

SHOW CREATE TABLE zeigt die wirksame Definition einer Tabelle. Über Information Schema lassen sich alle Textspalten inventarisieren:

SELECT
  TABLE_NAME,
  COLUMN_NAME,
  CHARACTER_SET_NAME,
  COLLATION_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'appdb'
  AND CHARACTER_SET_NAME IS NOT NULL
ORDER BY TABLE_NAME, ORDINAL_POSITION;

Für neue Schemas sollte utf8mb4 statt des historischen Namens utf8 ausdrücklich angegeben werden. Die Unicode-Dokumentation von MariaDB erklärt, dass utf8 ein Alias ist, dessen Bedeutung von der Konfiguration abhängen kann, während utf8mb4 Supplementary Characters speichert. Anschließend ist eine Kollation anhand der tatsächlichen Vergleichsanforderungen und der MariaDB-Versionen auszuwählen, die zusammenarbeiten müssen. Es gibt keine universelle Kollation für jede Sprache und jede Identifier-Richtlinie. Eine Suche nach Titeln ohne Beachtung der Großschreibung, ein case-sensitives Token und ein eindeutiger Benutzername können unterschiedliche Überlegungen erfordern.

Eine Änderung der Kollation ist nicht nur kosmetisch. Sie kann verändern, welche Werte als gleich gelten, wie Ergebnisse sortiert werden und ob vorhandene Werte weiterhin einen Unique Index erfüllen. Eine Zeichensatzänderung kann gefährlicher sein: MariaDB warnt vor möglichem Datenverlust, wenn die alte Deklaration nicht dem tatsächlichen Inhalt entspricht. Vor Änderungen an Produktionsspalten sollten ein Backup erstellt, repräsentative Bytes geprüft, die Umstellung an einer Kopie geprobt und das Anwendungsverhalten verifiziert werden.

Output Escaping als getrennte Kontrolle behandeln

Korrektes UTF-8 erhält Text, macht nicht vertrauenswürdigen Text jedoch nicht sicher für HTML. Zeichen wie <, >, & und Anführungszeichen besitzen weiterhin syntaktische Bedeutung. Für einfachen HTML-Text oder einen in Anführungszeichen gesetzten Attributkontext unterstützt die PHP-Dokumentation zu htmlspecialchars eine ausdrücklich angegebene Kodierung und Ersetzungsrichtlinie:

echo htmlspecialchars(
    $value,
    ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5,
    'UTF-8'
);

Andere Kontexte, darunter JavaScript, CSS, URLs und HTTP-Header, besitzen andere Kodierungsregeln. Ebenso sollte HTML Escaping normalerweise erfolgen, wenn Daten in einen HTML-Ausgabekontext gelangen, nicht vor der Speicherung. Bereits maskierten Text zu speichern vermischt Darstellung und Daten und begünstigt doppeltes Encoding, wenn derselbe Wert später anderswo verwendet wird.

Einen echten Roundtrip mit anspruchsvollem Text testen

Ein ASCII-Satz kann nicht zeigen, ob ein UTF-8-Pfad funktioniert, weil ASCII-Bytes innerhalb von UTF-8 dieselbe grundlegende Darstellung besitzen. Ein kleiner Testkorpus sollte verschiedene Schriftsysteme und Bytelängen abdecken, ein Supplementary Character wie ein Emoji sowie kanonisch äquivalente Folgen enthalten:

$samples = [
    'Semarang',
    'Grüße',
    '日本語',
    'مرحبا',
    'emoji: 🧭',
    "composed: é",
    "decomposed: e\u{0301}",
];

Der Test sollte diese Werte durch denselben Request Parser wie die Anwendung senden, über denselben PDO-Pfad binden, aus der Zielspalte lesen, über die normale Antwort serialisieren und das Ergebnis mit der ursprünglichen Erwartung vergleichen. Wenn ein Vergleich fehlschlägt, sind HTTP-Content-Type, Datenbank-Sitzungsvariablen, Spaltendefinition, gespeicherter Wert und zurückgegebene Bytes zu prüfen. Ein direktes Datenbank-Insert testet weniger vom System und kann eine fehlerhafte Webgrenze übersehen.

Exakte Bytegleichheit ist angemessen, wenn die Anwendung verspricht, Eingaben unverändert zu erhalten. Suchschlüssel und Identifier können dagegen eine dokumentierte Normalisierungsrichtlinie benötigen. Dieser Unterschied sollte ausdrücklich festgelegt werden, statt erst aufzufallen, nachdem visuell gleiche Strings zu getrennten Konten oder Tags geworden sind.

UTF-8-Gültigkeit ist keine Unicode-Normalisierung

Das sichtbare Zeichen é kann als einzelner vorkomponierter Codepoint oder als e mit anschließendem kombinierenden Akut dargestellt werden. Beide Folgen können gültiges UTF-8 bilden und gleich aussehen, während ein byteweiser Vergleich fehlschlägt. Das W3C-Dokument Character Model for String Matching, derzeit ein First Public Working Draft, erklärt kanonische Äquivalenz und weist zugleich darauf hin, dass Normalisierung nicht jede identisch aussehende Zeichenfolge äquivalent machen kann.

Normalisierung ist daher eine Produkt- und Datenmodellentscheidung. Einen Identifier an einer klar dokumentierten Grenze nach NFC zu normalisieren kann Vergleiche berechenbarer machen. Eine unterschiedslose Compatibility Normalization kann hingegen relevante Unterscheidungen entfernen. Die Datenbankkollation kann manche Äquivalenzen beim Vergleich berücksichtigen, doch ihr Verhalten muss für die ausgewählte Version und Kollation getestet werden; es darf nicht angenommen werden, dass sie gespeicherten Text in eine bestimmte Normalisierungsform umschreibt.

Beschädigte Altdaten anhand von Belegen statt Vermutungen behandeln

Wenn vorhandener Text bereits falsch ist, kann eine Änderung des Antwort-Headers oder der Spaltendeklaration lediglich beeinflussen, wie dieselben Bytes beim nächsten Mal interpretiert werden. Ein String kann auch mehrfach falsch dekodiert und erneut kodiert worden sein. Es gibt keine sichere universelle SQL-Anweisung, die eine unbekannte Vorgeschichte rekonstruiert.

Eine vorsichtige Reparatur beginnt damit, unnötige Schreibvorgänge anzuhalten, ein verifiziertes Backup anzulegen, rohe Bytes zusammen mit den aktuellen Deklarationen zu untersuchen, den Ingestion-Pfad und die wahrscheinliche Quellkodierung zu bestimmen und die geplante Transformation an einer Kopie zu reproduzieren. Der Abnahmetest sollte bekannten Originaltext aus einer vertrauenswürdigen Quelle enthalten. Fehlen solche Belege, sollte die Unsicherheit dokumentiert werden, statt sie hinter einer Konvertierung zu verbergen, die lediglich einige Beispiele besser aussehen lässt.

Fazit

Zuverlässiger Unicode-Text in einer PHP/MariaDB-Anwendung ist eine Kette ausdrücklicher Vereinbarungen. Die Antwort deklariert UTF-8, Eingaben werden gegen einen bekannten Vertrag validiert, PDO handelt utf8mb4 aus, Schemaspalten verwenden bewusst gewählte Zeichensätze und Kollationen, Ausgaben werden für ihren Kontext maskiert und Tests durchlaufen den vollständigen Roundtrip mit mehr als nur ASCII.

Auch diese Kette hat Grenzen. Sie wählt nicht automatisch die passende Normalisierungs- oder Kollationsrichtlinie für ein Produkt, garantiert nicht, dass jede Schriftart jedes Glyph enthält, und repariert keine Bytes mit unbekannter Vorgeschichte. Sie liefert etwas Praktischeres: beobachtbare Grenzen. Wenn sich Text verändert, kann die Untersuchung feststellen, welcher Vertrag nicht übereinstimmte, statt eine weitere Konvertierung hinzuzufügen und auf das Verschwinden des Symptoms zu hoffen.

References