Ein Lieferantenkatalog von 400 MB soll jede Nacht eingelesen werden. Der erste Versuch mit SimpleXML endet nach wenigen Sekunden mit einem Speicherfehler, und das Anheben des Limits verschiebt das Problem nur bis zum nächsten größeren Katalog.
Im Bild wandert das Dokument als Band durch einen schmalen Leser, und der Speicherbalken darunter bleibt flach. Genau dieses Verhalten beschreibt der Text.
Was PHP xml_parse() macht
PHP xml_parse gehört zur Expat-Anbindung der Sprache und arbeitet ereignisgesteuert. Sie baut kein Dokument auf, sondern liest den Text durch und meldet jedes Ereignis an eine vorher registrierte Funktion. Die Signatur lautet xml_parse(XMLParser $parser, string $data, bool $is_final = false): int und liefert 1 bei Erfolg oder 0 bei einem Fehler.
Seit PHP 8.0 ist der Parser ein Objekt der Klasse XMLParser und keine Ressource mehr. Der früher übliche Aufruf von xml_parser_free() ist seither wirkungslos und in neuem Code überflüssig.
Die englischen Parameternamen aus dem Handbuch tauchen in Fehlermeldungen und in fremdem Code wieder auf, deshalb hier die Beschreibung im Original:
xml_parse(XMLParser $parser, string $data, bool $is_final = false): int
/* parser - an xml parser created by xml_parser_create()
data - a chunk of the xml document, not necessarily complete
is_final - true for the last chunk of the xml data
return - 1 on success, 0 on failure while parsing the xml file */
Bemerkenswert ist die Formulierung zum zweiten Parameter: Ein Block muss kein vollständiges Dokument sein. Genau darauf beruht die blockweise Verarbeitung weiter unten.
Baum oder Ereignis: wann sich der Aufwand lohnt
SimpleXML und DOM bauen aus dem Dokument einen Baum, den man danach bequem durchsuchen kann. Dafür muss das ganze Dokument in den Speicher passen. PHP xml_parse baut keinen Baum, sondern meldet jedes Element im Vorbeigehen. Deshalb ist der Speicherbedarf unabhängig von der Dateigröße.
Daraus ergibt sich eine klare Arbeitsteilung. Eine Datei von wenigen hundert Kilobyte verarbeitet man am besten mit dem Baum, weil der Code kürzer und lesbarer ausfällt. Wer damit anfangen will, findet in dem PHP XML Tutorial zum Lesen, Parsen und Erstellen den passenden Einstieg, und für das nachträgliche Ändern eines Dokuments ist PHP DOMDocument zum Parsen und Manipulieren das richtige Werkzeug. Erst wenn die Datei groß oder ihre Größe unbekannt ist, lohnt der Mehraufwand hier.
Die drei Handler von PHP xml_parse()
Registriert werden vor dem ersten Aufruf von PHP xml_parse drei Rückruffunktionen: eine für öffnende Elemente, eine für schließende und eine für den Text dazwischen. Das folgende Beispiel protokolliert nur, wer wann aufgerufen wird.
<?php
$xml = '<buch><titel>PHP</titel></buch>';
$parser = xml_parser_create('UTF-8');
xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, false);
xml_set_element_handler(
$parser,
function ($p, string $name, array $attribute): void {
echo 'START ' . $name . PHP_EOL;
},
function ($p, string $name): void {
echo 'ENDE ' . $name . PHP_EOL;
}
);
xml_set_character_data_handler(
$parser,
function ($p, string $text): void {
echo 'TEXT "' . $text . '"' . PHP_EOL;
}
);
xml_parse($parser, $xml, true);
/* START buch
START titel
TEXT "PHP"
ENDE titel
ENDE buch */
Die Option XML_OPTION_CASE_FOLDING verdient Aufmerksamkeit. Ohne sie liefert Expat alle Elementnamen in Großbuchstaben, was jede Fallunterscheidung im Handler unnötig verkompliziert. Auf false gesetzt bleiben die Namen so, wie sie im Dokument stehen.
Warum Zeichendaten in Bruchstücken ankommen
Hier liegt die häufigste Fehlerquelle im Umgang mit PHP xml_parse. Der Handler für Zeichendaten darf denselben Textknoten mehrfach melden, besonders an Blockgrenzen und rund um Entitäten. Wer den Wert zuweist statt ihn anzuhängen, verliert alles bis auf das letzte Bruchstück.
<?php
/* Falsch: jeder Aufruf ueberschreibt den vorherigen.
Aus "Grüne Schränke" wird am Ende nur "nke". */
$wert = '';
$falsch = function ($p, string $text) use (&$wert): void {
$wert = $text;
};
/* Richtig: die Bruchstuecke werden aneinandergehaengt. */
$richtig = function ($p, string $text) use (&$wert): void {
$wert .= $text;
};
Der Fehler fällt lange nicht auf, weil er bei kurzen Werten ohne Sonderzeichen nie auftritt. Erst ein Umlaut oder ein Wert, der genau auf einer Blockgrenze liegt, macht ihn sichtbar. Zurückgesetzt wird die Sammelvariable im Handler für das öffnende Element.
Mit PHP xml_parse() blockweise verarbeiten
Jetzt kommt der eigentliche Zweck von PHP xml_parse zum Tragen. Die Datei wird in Blöcken gelesen und jeder Block sofort an den Parser gegeben.
<?php
$parser = xml_parser_create('UTF-8');
xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, false);
$aktuell = '';
$puffer = '';
$anzahl = 0;
xml_set_element_handler(
$parser,
function ($p, string $name) use (&$aktuell, &$puffer): void {
$aktuell = $name;
$puffer = '';
},
function ($p, string $name) use (&$puffer, &$anzahl): void {
if ($name === 'artikel') {
$anzahl++;
}
}
);
xml_set_character_data_handler(
$parser,
function ($p, string $text) use (&$puffer): void {
$puffer .= $text;
}
);
$fp = fopen('katalog.xml', 'rb');
while (!feof($fp)) {
$block = fread($fp, 8192);
xml_parse($parser, $block, feof($fp));
}
fclose($fp);
echo $anzahl . ' Artikel verarbeitet.';
Der dritte Parameter ist der Punkt, an dem die meisten Versuche scheitern. Solange weitere Blöcke folgen, steht dort false. Beim letzten Aufruf muss er true sein, sonst meldet der Parser das Dokument als unvollständig, obwohl alles gelesen wurde. Der Ausdruck feof($fp) leistet genau das, weil er nach dem letzten Block wahr wird.
Der Speicherbedarf bleibt dabei praktisch konstant. Gemessen an einer Datei von 190 MB liegt die Spitze bei der Baumvariante deutlich über einem Gigabyte, bei dieser Schleife im niedrigen einstelligen Megabyte-Bereich. Der Grund ist einfach: es liegt immer nur ein Block von 8 KB und der aktuelle Textpuffer im Speicher.
Fehler finden mit PHP xml_parse()
Bricht die Verarbeitung ab, hilft die bloße Information "es hat nicht geklappt" nicht weiter. Expat kennt die genaue Stelle und gibt sie auf Nachfrage heraus.
<?php
if (xml_parse($parser, $block, feof($fp)) !== 1) {
$meldung = sprintf(
'XML-Fehler %d: %s in Zeile %d, Spalte %d',
xml_get_error_code($parser),
xml_error_string(xml_get_error_code($parser)),
xml_get_current_line_number($parser),
xml_get_current_column_number($parser)
);
fclose($fp);
exit($meldung);
}
/* XML-Fehler 5: not well-formed (invalid token)
in Zeile 84213, Spalte 17 */
Mit Zeile und Spalte lässt sich die kaputte Stelle in einer 400 MB großen Datei in Sekunden finden. Ohne diese Angaben bleibt nur das Durchsuchen von Hand.
Nur bestimmte Elemente einsammeln
Selten werden alle Felder gebraucht, und PHP xml_parse zwingt zu keiner Vollständigkeit. Eine Zustandsvariable sorgt dafür, dass nur innerhalb des interessierenden Bereichs gesammelt wird und der Rest ohne Aufwand vorbeizieht.
<?php
$imArtikel = false;
$feld = '';
$satz = [];
xml_set_element_handler(
$parser,
function ($p, string $name) use (&$imArtikel, &$feld, &$satz): void {
if ($name === 'artikel') {
$imArtikel = true;
$satz = [];
}
$feld = $name;
},
function ($p, string $name) use (&$imArtikel, &$satz): void {
if ($name === 'artikel') {
$imArtikel = false;
/* hier wird ein vollstaendiger Datensatz gespeichert */
}
}
);
xml_set_character_data_handler(
$parser,
function ($p, string $text) use (&$imArtikel, &$feld, &$satz): void {
if ($imArtikel && ($feld === 'ean' || $feld === 'preis')) {
$satz[$feld] = ($satz[$feld] ?? '') . trim($text);
}
}
);
Aus dreissig Feldern pro Artikel landen so nur zwei im Speicher. Bei Millionen von Datensätzen ist das der Unterschied zwischen einem Import, der durchläuft, und einem, der nach zwanzig Minuten abbricht.
Namensräume
Dokumente aus dem Behörden- und Rechnungsumfeld arbeiten fast immer mit Namensräumen. Der gewöhnliche Parser reicht das Präfix als Teil des Elementnamens durch, was die Fallunterscheidung unnötig macht.
<?php
/* Das zweite Argument ist das Trennzeichen zwischen
Namensraum-URI und Elementname. */
$parser = xml_parser_create_ns('UTF-8', ':');
/* Der Handler bekommt jetzt Namen wie
urn:beispiel:rechnung:ID */
Damit lässt sich der Namensraum sauber vom Namen trennen, statt sich auf ein Präfix zu verlassen, das jedes Dokument anders wählen darf.
flowchart TD
A[Datei blockweise lesen] --> B[xml_parse mit Block]
B --> C{Ereignis}
C -->|Start-Tag| D[Zustand merken]
C -->|Zeichendaten| E[Text anhaengen]
C -->|End-Tag| F[Datensatz verarbeiten]
F --> G{Datei zu Ende}
G -->|nein| A
G -->|ja| H[Letzter Aufruf is_final]
Das Diagramm zeigt die Schleife, die allen Beispielen oben zugrunde liegt. Der einzige Ausstieg führt über den letzten Aufruf mit gesetztem Endekennzeichen.
xml_parse() oder XMLReader?
Beide Verfahren lösen dasselbe Speicherproblem, gehen es aber unterschiedlich an. PHP xml_parse ruft den eigenen Code auf, sobald etwas passiert, weshalb der Zustand in Variablen außerhalb der Handler mitgeführt werden muss. XMLReader dreht das um: der Code fragt den Leser, ob es weitergeht, und entscheidet selbst, wann er wohin schaut.
Für neue Projekte ist XMLReader deshalb meist die angenehmere Wahl, weil der Ablauf ohne Zustandsvariablen auskommt und leichter zu lesen bleibt. Veraltet ist die Expat-Anbindung damit nicht: sie wird weiter gepflegt, ist in jedem PHP-Bestand zu finden und schlägt sich bei sehr großen Dokumenten hervorragend. Wer vorhandenen Code pflegt, hat keinen Grund umzubauen.
Fazit
PHP xml_parse ist das Werkzeug für XML-Dateien, die nicht in den Speicher passen. Der Preis dafür sind drei Handler und eine Zustandsverwaltung, der Gewinn ist ein Speicherbedarf, der unabhängig von der Dateigröße bleibt. Wer den Zeichendaten-Handler anhängen statt zuweisen lässt und beim letzten Block das Endekennzeichen setzt, hat die beiden Fallen bereits umgangen, an denen die meisten Versuche scheitern.