XML in Haskell - Datentypen und Serialisierung
Die Frage „Wie parse oder erzeuge ich ein XML-Dokument mit Haskell?“ ist mir schon oft gestellt worden und ich stelle sie mir auch selbst immer wieder. Es gibt viele XML-Libraries für Haskell, so dass man oft gar nicht weiss wo man als erstes schauen sollte. In dieser Serie von Blogartikeln möchten wir zeigen, wie man XML-Dokumente erzeugt, wie man vorhandene Dokumente in eigene Datenstrukturen einliest und wie man in Dokumenten suchen und diese transformieren kann.
In der Welt des World Wide Web werden Daten fast nur noch im JSON-Format ausgetauscht. In der Industrie spielt XML jedoch weiterhin eine sehr wichtige Rolle, so dass sich wahrscheinlich jeder irgendwann mit dem Erzeugen oder Verarbeiten von XML-Dokumenten beschäftigen wird. Da wir nicht davon ausgehen, dass jeder bereits Erfahrung mit XML hat, stellen wir in jedem Artikel auch XML-Technologien unabhängig von Haskell vor.
Dieser erste Artikel der Serie erklärt die Grundbegriffe von XML, zeigt auf wie die zugehörigen Haskell Datentypen aus dem Paket xml-conduit aussehen und veranschaulicht die Nutzung durch Erzeugung und Serialisierung eines XML-Dokument mit Hilfe dieser Datentypen.
Was ist XML und warum sollte ich XML überhaupt verwenden?
Die eXtensible Markup Language ist ein Regelwerk, das genutzt werden kann, um textbasierte Datenaustauschformate zu beschreiben. XML kann also genutzt werden um zum Beispiel Blogartikel, Rechnungen oder Arztbriefe maschinenlesbar zu beschreiben. Wenn man Daten speichern oder austauschen möchte ist es vorteilhaft XML zu verwenden, weil es für jede Programmiersprache gute Bibliotheken zum Einlesen und Ausgeben von XML-Dokumenten gibt. Was der Inhalt eines XML-Dokuments bedeutet und wie die Daten strukturiert sind, wird durch die XML-Spezifikation nicht festgelegt. Jede Anwendung, die XML benutzen möchte, muss also selbst festlegen wie ein XML-Dokument strukturiert sein darf. XML gibt vor wie die Kodierung der Struktur erfolgen muss. Hier ein Beispiel für ein XML-Dokument aus unserer digitalen Krankenakte Checkpad MED:
Ein Beispieldokument
<?xml version="1.0" encoding="UTF-8"?>
<patient xmlns="http://www.checkpad.de/ns/checkpad" id="Patient/432442">
<name>
<given-name>Jana</given-name>
<family-name>Braun</family-name>
<display-name>Jana Braun</display-name>
</name>
<case-number>432442</case-number>
<patient-number>55555502</patient-number>
<birth-date>1952-07-18</birth-date>
<sex>female</sex>
<admission>2011-02-10T08:41:00+01:00</admission
></patient>
Dieses Dokument enthält Daten zu unserer Demo-Patientin Jana Braun. Zu
diesen Daten gehören der Name, Identifikationsnummern für die Patientin
und den Krankenhausaufenthalt, Geburtsdatum, Geschlecht und Aufnahmedatum.
Aus Sicht des XML-Parsers sind die eigentlichen Daten irrelevant - es
zählt nur, dass das Dokument syntaktisch korrekt ist. Man sagt auch: Das
XML-Dokument ist well-formed. Ein Dokument darf (aber muss nicht) mit
einem Prolog <?xml version="1.0" encoding="UTF-8"?>
beginnen, der
festlegt welcher XML-Spezifikation das Dokument entspricht und in welcher
Zeichenkodierung (hier UTF-8) es abgelegt ist. Was jedes XML-Dokument
aber braucht ist ein Wurzelelement, das hier patient
heisst.
Elemente und Attribute
Ein Element wird durch ein Start-Tag in spitzen Klammern (hier <patient
...>
) geöffnet und durch ein End-Tag (hier </patient>
) wieder
geschlossen. Durch diese beiden Tags wird der Inhalt des Elements, die
sogenannten Kindknoten, umschlossen. Kindknoten eines Elements können
weitere Elemente oder Textknoten sein. Das Start-Tag kann zwischen dem
Namen und der schliessenden, spitzen Klammer noch Attribute enthalten, die
durch beliebig viele Leerzeichen getrennt werden. Das Start-Tag des
patient
Element hat hier die Attribute mit dem Namen xmlns
und id
deren Wert nach dem Gleichheitszeichen in einfachen oder doppelten
Anführungszeichen angegeben werden muss.
In Haskell sieht die Datentypdefinition zur Beschreibung von XML so aus:
-- Ein `Document` ist ein Record mit den drei Feldern `documentPrologue`
-- (alles vor dem Root-Element), `documentRoot` (dem Root-Element) und
-- `documentEpilogue` (alles nach dem Root-Element). Vor und nach dem
-- Root-Element stehen keine Inhalte sondern nur Kommentare und
-- Verarbeitungsanweisungen.
data Document
= Document
{ documentPrologue :: Prologue
, documentRoot :: Element
, documentEpilogue :: [Miscellaneous]
}
-- Der Prolog eins XML-Dokuments besteht aus einer Liste von Kommentaren
-- und Verarbeitungsanweisungen (Miscellaneous), einer optionalen Referenz
-- auf eine Document Type Definition und weiteren Kommentaren und
-- Verarbeitungsanweisungen.
data Prologue
= Prologue
{ prologueBefore :: [Miscellaneous]
, prologueDoctype :: Maybe Doctype
, prologueAfter :: [Miscellaneous]
}
-- Die genaue Definition der Typen Doctype und Miscellaneous lassen wir
-- hier aus.
data Doctype = Doctype
data Miscellaneous = Miscellaneous
-- Ein `Element` ist ein Record mit drei Feldern. `elementName` für den
-- Namen des Elements. `elementAttributes` ist ein Mapping von Attributname
-- auf den Textwert des Attribut. `elementNodes` ist die Liste der
-- Kindknoten vom Typ `Node`.
data Element
= Element
{ elementName :: Name
, elementAttributes :: Map Name Text
, elementNodes :: [Node]
}
-- Ein Knoten ist entweder ein `NodeElement` mit dem einem Element als
-- Inhalt oder ein `NodeContent` und enthält dann nur einen Textstring.
data Node
= NodeElement Element
| NodeContent Text
Das Element name
aus unserem Beispiel hat drei Kindelemente, nämlich
given-name
, family-name
und display-name
. Das sind aber nicht alle
Kindknoten des name
Element. Es sind nur die Elemente, denn zwischen
der schließenden, spitzen Klammer des Start-Tag <name>
und der
öffnenden, spitzen Klammer des Start-Tag <given-name>
ist auch noch ein
Textknoten (NodeContent
), der einen Zeilenumbruch und einige Leerzeichen
enthält. Für unsere Anwendung Checkpad MED sind diese Textknoten
irrelevant und könnten ignoriert werden. Da der XML-Parser aber die Daten
nicht versteht wird er diese Textknoten nicht ignorieren. Es könnte beim
Inhalt ja auch um den Programmcode einer Programmiersprache handeln, wo
solches Whitespace zur Einrückung wichtig ist.
Was ist aber mit dem Zeilenumbruch innerhalb des End-Tag des Elements `admission‘? Hier ist sogar ein Zeilenumbruch enthalten. Whitespace innerhalb von Tags wird vom XML-Parser ignoriert und nicht an die XML verarbeitende Anwendung zurückgeliefert. Welche Informationen genau von einem Parser an die Anwendung zur Weiterverarbeitung geliefert werden müssen und was ignoriert werden darf, ist in der Spezifkation des XML Information Set definiert.
Namen
Jedes Element und jedes Attribut hat einen XML-Namen, der aus drei Bestandteilen besteht:
- dem
local name
(hier z.B.patient
) - einem optionalen
namespace name
(hierhttp://www.checkpad.de/ns/checkpad
) und - einem optionalen
namespace prefix
(im Beispiel gibt es keine)
Ein Präfix wird vor den Namen des Elements geschrieben und durch einen
Doppelpunkt abgetrennt. Zu jedem Präfix muss mit Hilfe eines Attributs
ein Namespace definiert werden. Ein Attribut zur Zuordnung eines
Namespace zu einem Präfix trägt das Präfix xmlns
und hat den lokalen
Namen des Präfix das definiert werden soll. xmlns:foo=...
bindet also
das Namespacepräfix foo
. Ein Namespace, der keinem Präfix zugeordnet
ist, definiert den Default-Namespace der für alle Elemente gilt, die kein
Präfix haben. Er wird mit xmlns=...
festgelegt. Ein Namespace gilt
immer ab dem Element in dessen Start-Tag er definiert wird für das Element
und alle enthaltenen Knoten. Wenn kein Default-Namespace definiert ist,
dann ist das Element keinem Namespace zugeordnet. Das Präfix wird
normalerweise von XML verarbeitenden Anwendungen ignoriert und es wird nur
der Name des Namespace zur Identifizierung verwendet. Das folgende
Dokument würde daher von unserer Anwendung gleich verarbeitet werden:
<?xml version="1.0" encoding="UTF-8"?>
<cpm:patient cpm:xmlns="http://www.checkpad.de/ns/checkpad" id="Patient/432442">
<name xmlns="http://www.checkpad.de/ns/checkpad">
<given-name>Jana</given-name>
<family-name>Braun</family-name>
<cpm:display-name>Jana Braun</cpm:display-name>
</name>
<cpm:case-number>432442</cpm:case-number>
<cpm:patient-number>55555502</cpm:patient-number>
<cpm:birth-date>1952-07-18</cpm:birth-date>
<test:sex xmlns:test="http://www.checkpad.de/ns/checkpad">female</test:sex>
<cpm:admission>2011-02-10T08:41:00+01:00</cpm:admission>
</cpm:patient>
Als Haskell Datentyp sieht ein Name dann so aus:
data Name
= Name
{ nameLocalName :: Text
, nameNamespace :: Maybe Text
, namePrefix :: Maybe Text
}
Das Beispieldokument als Haskell-Wert
Wir können nun das Beispieldokument mit den definierten Haskell-Datentypen repräsentieren. Wir definieren dazu aber zunächst einige Hilfsfunktionen.
import Text.XML
import qualified Data.Text as T
import qualified Data.Map as Map
-- Erzeugt einen XML-Namen mit dem angegebenen lokalen Namen, ohne Präfix
-- im Checkpad Namespace.
mkName :: T.Text -> Name
mkName localName =
Name
{ nameLocalName = localName
, nameNamespace = Just "http://www.checkpad.de/ns/checkpad"
, namePrefix = Nothing
}
-- Erzeugt ein Element mit angegebenem Namen und genau einem Textknoten
mkTextElem :: T.Text -> T.Text -> Element
mkTextElem n txt = Element (mkName n) Map.empty [NodeContent txt]
-- Erzeugt ein Element mit dem angegebenem Namen und den angegebenen
-- Elementen als Kindelemente.
mkElemWithElems :: T.Text -> [Element] -> Element
mkElemWithElems n els = Element (mkName n) Map.empty (map NodeElement els)
Mit diesen Hilfsfunktionen ist es nun einfach das Dokument aufzubauen.
Beginnen wir mit dem Element name
:
name :: Element
name =
mkElemWithElems "name"
[ mkTextElem "given-name" "Jana"
, mkTextElem "family-name" "Braun"
, mkTextElem "display-name" "Jana Braun"
]
Nun können wir schon das Wurzelelement definieren:
patient :: Element
patient =
mkElemWithElems "patient"
[ name
, mkTextElem "case-number" "432442"
, mkTextElem "patient-number" "55555502"
, mkTextElem "birth-date" "1952-07-18"
, mkTextElem "sex" "female"
, mkTextElem "admission" "2011-02-10T08:41:00+01:00"
]
Jetzt haben wir nur das id
Attribut von patient
noch nicht
hinzugefügt. Dafür können wir eine Hilfsfunktion definieren und beim
Erzeugen des gesamten Dokuments aufrufen:
-- Fügt ein Attribut mit lokalen Namen und Wert zu einem Element hinzu
addAttr :: T.Text -> T.Text -> Element -> Element
addAttr key value elem =
let oldAttrs = elementAttributes elem
newAttrs = Map.insert (Name key Nothing Nothing) value oldAttrs
in elem { elementAttributes = newAttrs }
doc :: Document
doc =
Document
{ documentPrologue = Prologue [] Nothing []
, documentRoot = addAttr "id" "Patient/432442" patient
, documentEpilogue = []
}
Serialisierung des Dokuments
Das so definierte Dokument kann man nur einfach als Datei speichern. Dazu
stellt das Modul Text.XML
die Funktion writeFile :: RenderSettings ->
FilePath -> Document -> IO ()
zur Verfügung:
main :: IO ()
main = Text.XML.writeFile def "patient.xml" doc
Da wir die Textknoten zwischen dem End-Tag von patient
und dem
Start-Tag von name
in Haskell nicht eingefügt haben sieht die Ausgabe
nicht exakt so aus wie das oben stehende Beispiel, sondern alles steht in
einer Zeile.
So, das war‘s für heute. Das nächste Mal schauen wir uns an, wie man XML-Dateien einlesen und verarbeiten kann. Ich freue mich über Feedback!