Zum Inhalt

XML verstehen und verarbeiten

Viele Fremdsysteme sprechen XML: SOAP-Dienste, Bank- und Behördenformate, Exportdateien aus Altsystemen. Diese Seite erklärt XML von Grund auf und zeigt, wie du es in KCForms liest, durchsuchst, änderst und selbst baust.

Vorwissen ist nicht nötig. Wenn dir Begriffe wie Variable, Liste oder Schleife noch nichts sagen, lies vorher JavaScript-Grundlagen; die Verwandtschaft zu Objekten steht unter JSON und Objekte verstehen.

Zum Mitmachen

Alle Beispiele laufen in der Skript-Konsole: System-Administration → Werkzeuge & Diagnose → Skript-Konsole. Hineinkopieren, Ausführen klicken, unten die Ausgabe lesen.


Wie ein XML-Dokument aufgebaut ist

XML beschreibt Daten mit Etiketten, die man Tags nennt. Jedes Etikett wird geöffnet und wieder geschlossen; dazwischen steht der Inhalt:

<kunde nr="K-1001">
  <name>Meyer &amp; Sohn</name>
  <adresse>
    <ort>Kiel</ort>
  </adresse>
</kunde>

Die Bestandteile:

  • Element — ein Etikettenpaar samt Inhalt: <ort>Kiel</ort>. Der Name des Elements ist ort.
  • Attribut — eine Zusatzangabe im Start-Tag: nr="K-1001".
  • Text — was zwischen Start- und End-Tag steht: Kiel.
  • Verschachtelung — Elemente stecken in Elementen. adresse enthält ort.
  • Wurzel — das äußerste Element, hier kunde. Davon gibt es genau eines.

Ein Element ohne Inhalt darf man kurz schreiben: <ort/> statt <ort></ort>.

XML oder JSON — derselbe Gedanke, andere Schreibweise

Kennst du schon JSON, kommt dir das bekannt vor. Dieselben Daten in beiden Schreibweisen:

XML JSON
Bezeichnung <ort>Kiel</ort> "ort": "Kiel"
Karte in der Karte <adresse><ort>…</ort></adresse> "adresse": { "ort": "…" }
Mehrere gleiche Einträge <position/><position/> "positionen": [ … ]
Zusatzangaben Attribute (nr="K-1001") gibt es nicht — alles ist ein Feld
Sonderzeichen müssen maskiert werden (&amp;) nur Anführungszeichen

Zwei Unterschiede sind im Alltag wichtig:

  • XML kennt Attribute und Text — JSON kennt nur Werte.
  • XML sagt dir nicht, ob etwas eine Liste ist. Ein einzelnes <position> sieht genauso aus wie das erste von zwanzig. Deshalb fragt man in KCForms mit findAll(...) nach allen und schaut auf length.

Vom Text zum Knoten

Ein XML-Dokument kommt immer als Text an — aus einer Datei, aus einer SOAP-Antwort, aus einem Formularfeld. xml.parse(text) macht daraus einen Knoten, in dem du dich bewegen kannst:

var doc = xml.parse("<kunde nr='K-1001'><name>Meyer</name></kunde>");

log.info("Wurzel: " + doc.name);          // Wurzel: kunde
log.info("Nummer: " + doc.attr("nr"));    // Nummer: K-1001

Jeder Knoten hat fünf Eigenschaften und ein paar Handgriffe:

Eigenschaft Was sie liefert
node.name der Name des Elements (nur lesen)
node.text der Textinhalt — lesen und setzen
node.attrs alle Attribute
node.children die direkten Kind-Elemente als Liste
node.parent das Element eine Ebene höher
Handgriff Was er tut
node.find(tag) das erste direkte Kind mit diesem Namen, sonst null
node.findAll(tag) alle direkten Kinder mit diesem Namen, als Liste
node.findDeep(tag) das erste Element im ganzen Teilbaum
node.findAllDeep(tag) alle Elemente im ganzen Teilbaum
node.attr(name) Attribut lesen (null, wenn es fehlt)
node.attr(name, wert) Attribut setzen; null als Wert entfernt es
node.addChild(tag[, text]) neues Kind anlegen, liefert das neue Element
node.removeChild(kind) ein Kind entfernen
xml.stringify(node[, true]) zurück in Text; true = eingerückt

Werte herauslesen

find steigt eine Ebene hinab, .text holt den Inhalt:

var doc = xml.parse(
  "<kunde><name>Meyer</name><adresse><ort>Kiel</ort></adresse></kunde>");

var name = doc.find("name").text;                 // Meyer
var ort  = doc.find("adresse").find("ort").text;  // Kiel

log.info(name + " aus " + ort);

Bei tiefer Verschachtelung wird diese Kette schnell lang. findDeep sucht deshalb im ganzen Dokument — egal, wie weit unten das Element steckt:

var doc = xml.parse(
  "<antwort><daten><kunde><adresse><ort>Kiel</ort></adresse></kunde></daten></antwort>");

log.info(doc.findDeep("ort").text);   // Kiel — ohne den ganzen Weg zu tippen
sucht in liefert
find nur direkte Kinder ein Element oder null
findAll nur direkte Kinder eine Liste (evtl. leer)
findDeep dem ganzen Teilbaum ein Element oder null
findAllDeep dem ganzen Teilbaum eine Liste (evtl. leer)

find kann null liefern

Fehlt das Element, bekommst du null — und null.text bricht das Skript ab („Cannot read property 'text' of null"). Sichere es ab, wenn du dem Fremdsystem nicht blind vertraust:

var doc = xml.parse("<kunde><name>Meyer</name></kunde>");

var ort = doc.findDeep("ort");
log.info(ort ? ort.text : "kein Ort angegeben");

Durch mehrere Einträge laufen

findAll liefert eine ganz normale Liste — mit length, forEach, filter und map, genau wie im JSON-Kapitel beschrieben:

var doc = xml.parse(
    "<bestellung>"
  + "  <position><artikel>Stift</artikel><menge>3</menge><preis>1,20</preis></position>"
  + "  <position><artikel>Block</artikel><menge>2</menge><preis>2,50</preis></position>"
  + "</bestellung>");

var posten = doc.findAll("position");
var summe = 0;

posten.forEach(function (p) {
  summe += kc.num(p.find("menge").text) * kc.num(p.find("preis").text);
});

log.info(posten.length + " Positionen, Summe " + summe.toFixed(2));

Beachte kc.num(...): Aus XML kommt immer Text, nie eine Zahl — genau wie aus einem Formularfeld.

Ein XML-Dokument in ein Formular übernehmen

Das ist der häufigste Fall im Alltag: Antwort lesen, Felder füllen, Zeilen in eine Detail-Tabelle schreiben.

var doc = xml.parse(
    "<kunde nr='K-1001'><name>Meyer</name>"
  + "<position><artikel>Stift</artikel><menge>3</menge></position>"
  + "<position><artikel>Block</artikel><menge>2</menge></position></kunde>");

ctx.form.kundennr = doc.attr("nr");
ctx.form.name     = doc.find("name").text;

// Detail-Tabelle: eine Liste von Objekten, ein Objekt je Zeile
ctx.form.positionen = doc.findAll("position").map(function (p) {
  return {
    artikel: p.find("artikel").text,
    menge:   kc.num(p.find("menge").text)
  };
});

log.info(ctx.form.positionen.map(function (z) { return z.artikel; }).join(", "));

Werte ändern

Ein eingelesener Knoten lässt sich verändern und danach wieder ausgeben:

var doc = xml.parse("<position nr='1'><artikel>Stift</artikel><menge>3</menge></position>");

doc.find("menge").text = "9";          // Text ersetzen
doc.attr("nr", "2");                   // Attribut setzen
doc.attr("veraltet", null);            // Attribut entfernen (gab es hier gar nicht)
doc.addChild("bemerkung", "Eilt");     // Kind anhängen

log.info(xml.stringify(doc));

XML selbst bauen

xml.create(tag) legt ein leeres Dokument an, addChild hängt Elemente daran. Jedes addChild liefert das neue Element zurück — so baust du dich Ebene für Ebene nach unten:

var auftrag = xml.create("auftrag");
auftrag.attr("nr", "4711");

var kunde = auftrag.addChild("kunde");
kunde.addChild("name", "Meyer");
kunde.addChild("ort", "Kiel");

var pos = auftrag.addChild("position");
pos.addChild("artikel", "Stift");
pos.addChild("menge", "3");

log.info(xml.stringify(auftrag, true));

Das Ergebnis:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<auftrag nr="4711">
  <kunde>
    <name>Meyer</name>
    <ort>Kiel</ort>
  </kunde>
  <position>
    <artikel>Stift</artikel>
    <menge>3</menge>
  </position>
</auftrag>

Sonderzeichen erledigt KCForms

&, < und > werden beim Ausgeben automatisch maskiert, beim Einlesen automatisch zurückverwandelt. Du musst also nichts von Hand ersetzen — und solltest XML nie mit "<tag>" + wert + "</tag>" zusammenkleben: Ein & im Wert macht das Dokument sonst ungültig.

Wohin mit dem fertigen XML? Die üblichen Wege:

Ziel So
Datei fs.write("ausgang/auftrag.xml", xml.stringify(doc)) — fs
SOAP-Dienst ws.soap.sendReceive(endpunkt, aktion, doc) — ws
HTTP-Schnittstelle http.post(url, xml.stringify(doc), { contentType: "application/xml" }) — http
Mail-Anhang mp.addBytes("auftrag.xml", …) — mail

Namensräume und Präfixe

In SOAP-Antworten tragen Elemente oft einen Präfix: <soap:Body>, <ns2:Material>. Dieser Präfix gehört in KCForms zum Namen — du suchst also genau so, wie es im Dokument steht:

var antwort = xml.parse(
    "<soap:Envelope xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/'>"
  + "<soap:Body><ns2:MaterialResponse><ns2:nummer>4711</ns2:nummer>"
  + "</ns2:MaterialResponse></soap:Body></soap:Envelope>");

log.info(antwort.findDeep("ns2:nummer").text);   // 4711

Praktischer Rat: Präfixe sind nicht garantiert stabil — manche Dienste schicken heute ns2:, morgen ns5:. Wenn dich nur der Inhalt interessiert, suche über den Teilbaum und vergleiche selbst:

var antwort = xml.parse(
    "<soap:Envelope><soap:Body><ns2:MaterialResponse>"
  + "<ns2:nummer>4711</ns2:nummer></ns2:MaterialResponse></soap:Body></soap:Envelope>");

var treffer = antwort.findAllDeep("*").filter(function (n) {
  return n.name.replace(/^.*:/, "") === "nummer";   // Präfix wegschneiden
});

log.info("gefunden: " + (treffer.length ? treffer[0].text : "–"));

Stolpersteine

Stolperstein So ist es richtig
find("Ort") findet nichts Groß- und Kleinschreibung zählt: <ort> ≠ <Ort>
<ns2:nummer> wird nicht gefunden Präfix gehört zum Namen — mitsuchen
null.text bricht ab find kann null liefern; vorher prüfen
doc.text ist leer text liefert nur den eigenen Text, nicht den der Kinder
Gerechnet wird falsch XML liefert Text; mit kc.num(...) in Zahlen wandeln
JSON.stringify(node.attrs) zeigt {} attrs ist eine Java-Map — attr("nr") benutzen oder mit for (var k in node.attrs) durchgehen (warum)
Dokument mit <!DOCTYPE …> wird abgelehnt DOCTYPE und externe Entitäten sind aus Sicherheitsgründen abgeschaltet (Schutz vor XXE-Angriffen); solche Dateien vorher aufbereiten
XML per + zusammengebaut xml.create(...) benutzen — sonst zerlegt ein & das Dokument

Der Unterschied bei text in einem Beispiel:

var doc = xml.parse("<kunde><name>Meyer</name></kunde>");

log.info("eigener Text: [" + doc.text + "]");          // [] — kunde hat nur Kinder
log.info("Kind-Text: [" + doc.find("name").text + "]"); // [Meyer]

Von E4X kommend

Wer früher mit E4X gearbeitet hat (XML-Literale direkt im JavaScript, x..artikel, x.@nr), findet das hier nicht mehr — und auch sonst nirgends: E4X war nie Teil des JavaScript-Standards, Firefox hat es 2013 entfernt, und die in KCForms verwendete GraalVM-Engine kennt es nicht. Ein XML-Literal scheitert schon beim Einlesen des Skripts:

var x = <root><a>1</a></root>;
// SyntaxError: Expected an operand but found <

Die gute Nachricht: Für jedes E4X-Idiom gibt es eine direkte Entsprechung.

E4X (früher) KCForms heute
new XML(text) xml.parse(text)
var x = <auftrag/> xml.create("auftrag")
x.position doc.find("position") bzw. doc.findAll("position")
x..artikel doc.findDeep("artikel") bzw. doc.findAllDeep("artikel")
x.@nr doc.attr("nr")
x.@nr = "42" doc.attr("nr", "42")
x.position.length() doc.findAll("position").length
x.menge = "9" doc.find("menge").text = "9"
x.appendChild(<artikel>Stift</artikel>) doc.addChild("artikel", "Stift")
delete x.a doc.removeChild(kind)
for each (var p in x.position) doc.findAll("position").forEach(function (p) { … })
x.toXMLString() xml.stringify(doc, true)
x.parent() node.parent

Dasselbe Beispiel in beiden Welten — erst E4X, dann KCForms:

// FRÜHER (E4X) — läuft heute nicht mehr
var doc = new XML(text);
log.info(doc.@nr);
for each (var p in doc.position) {
  log.info(p.artikel + " x " + p.menge);
}
// HEUTE
var doc = xml.parse("<bestellung nr='42'>"
  + "<position><artikel>Stift</artikel><menge>3</menge></position>"
  + "<position><artikel>Block</artikel><menge>2</menge></position></bestellung>");

log.info(doc.attr("nr"));
doc.findAll("position").forEach(function (p) {
  log.info(p.find("artikel").text + " x " + p.find("menge").text);
});

Drei Gewohnheiten musst du dabei ablegen:

  • Kein default xml namespace. Namensräume werden nicht aufgelöst; der Präfix gehört zum Namen (siehe oben).
  • Kein automatisches Umwandeln. p.menge war in E4X je nach Zusammenhang Text oder Zahl. Hier ist es immer Text — kc.num(...) macht eine Zahl daraus.
  • Kein DOCTYPE. Dokumente mit Dokumenttyp-Deklaration oder externen Entitäten weist der Parser ab; das ist der Schutz gegen XXE-Angriffe.

Spickzettel

Ich will … So geht's
Text zu XML var doc = xml.parse(text);
neues Dokument var doc = xml.create("wurzel");
Element eine Ebene tiefer doc.find("kunde")
Element irgendwo im Baum doc.findDeep("ort")
alle Einträge doc.findAll("position")
Textinhalt lesen node.text
Textinhalt setzen node.text = "neu"
Attribut lesen / setzen node.attr("nr") / node.attr("nr", "42")
Kind anlegen node.addChild("artikel", "Stift")
Kind entfernen node.removeChild(kind)
eine Ebene höher node.parent
alle Einträge durchgehen doc.findAll("x").forEach(function (n) { … })
Zahl aus XML kc.num(node.text)
zurück zu Text xml.stringify(doc) bzw. xml.stringify(doc, true)

Weiter