Dienstag, 24. Januar 2012

XML Parser zum letzten

Da, wie bereits früher erwähnt, meine Blogs mit XML-Themen am meisten gelesen werden und da ich auch hierzu die meisten Fragen erhalte, soll dieser Artikel die grundlegende Bedienungsfragen des XML-Parsers in ABAP ein für allemal klären. In einem früheren Blog habe ich den ereignisbezogenen, "SAX"-artigen XML-Parser erklärt. Hier soll es um den DOM-Parser gehen, der für kleinere Dokumente häufig die richtige Wahl ist.[1]

Es ist "nur" knapp 14 Jahre her, dass die erste Version des XML-Formats vom World Wide Web Konsortium herausgebracht wurde. Das ist ziemlich viel Zeit, vor allem in der IT. XML wurde in den ersten Jahren euphorisch begrüsst und hat sich in der Softwarewelt schnell verbreitet. Seine universelle Verwendbarkeit als Container für beliebige strukturierte Daten und die Existenz von Parsern in allen möglichen Sprachen und Systemen, sowie die Lesbarkeit und sogar Änderbarkeit mit einem beliebigen Klartext-Editor machten es zum Format der Wahl für alle möglichen Anwendungsdaten.

Mittlerweile ist man soweit, auch die Schattenseiten von XML wieder klar zu sehen. Die vielen spitzen Klammern, die Notwendigkeit, geöffnete Elemente wieder zu schliessen, die Notation für Attribute - all dies erzeugt ziemlich viel syntaktisches Rauschen, was die Lesbarkeit erschwert (das Argument vom vermeidbaren Daten-Overhead einmal ausser Acht gelassen). Das Argument, dass man wenigstens über Parser verfügt, die man nicht selbst entwickeln muss, verliert viel an Bedeutung, seit das Wissen um Parsergeneratoren nicht mehr auf die Hörsäle der Universitäten beschränkt ist, sondern sich in weiten Kreisen verbreitet hat, und seit Entwicklungsumgebungen wie Eclipse eine eingebaute Unterstützung für Parsergeneratoren bieten. Damit ist es im Handumdrehen möglich, Parser für selbst erfundene Sprachen zu generieren, deren Syntax genau auf ein spezifisches Problem zugeschnitten ist - die domänenspezifischen Sprachen (DSL).

Ironischerweise hat das Konzept der DSL, dem viele sich nun zuwenden, noch viel mehr Jahre auf dem Buckel als XML. Als Vision war die "Natürlichsprachlichkeit" bereits bei den ersten für kommerzielle Anwendungen entworfenen Programmiersprachen wie COBOL präsent: COBOL-Programme sollten für "Business People" flüssig lesbar sein, wenn sie natürlich auch – wie jede Programmiersprache – festen Syntaxregeln folgen mussten.

Aber wie dem auch sei. XML wird noch lange Zeit ein verbreitetes Datenformat bleiben. Für die ABAP-Programmierung spielt es immer dann eine Rolle, wenn mit der Aussenwelt kommuniziert werden muss - sei es mit dem Web-Browser in einer Webanwendung, sei es mit Nicht-SAP-Systemen zum Datenaustausch.

Zurück zum DOM-Parser in ABAP. Hier ist ein Programm, das ein als String gegebenes XHTML-Dokument auf das Vorkommen bestimmter XML-Elemente im DOM untersucht:

http://pastebin.com/eBGPGjrc

Ich werde es nun blockweise dokumentieren.

report  zz_dom_parse_xhtml.

Die Form eines Reports mit Unterprogrammen als einziger Modularisierungseinheit habe ich gewählt, weil dieser Report self-contained ist und daher ohne weiteres per Copy/Paste in einem SAP-System angelegt, studiert und ausgeführt werden kann. Mit Klassen wäre das nicht so einfach möglich. Ich hätte den Report selbst mit lokalen Klassen gestalten können. Aber Unterprogramme sind einfacher, ohne dass mir die lokalen Klassen in diesem Fall einen Vorteil verschaffen. Daher Unterprogramme - und keine Methoden lokaler Klassen.

Ich habe mir angewöhnt, Testprogramme, bereits während ich sie verfasse, durch Unterprogramme zu modularisieren - auch wenn ein kleiner Extraaufwand dazukommt, um für jedes Unterprogramm eine geeignete Schnittstelle zu definieren. Das rentiert sich recht bald: Wenn das Testprogramm erfolgreich war, kann ich den Code der Unterprogramme in Methoden einer passenden Klasse übernehmen, die ich dann für den produktiven Einsatz vorsehe. So auch hier: Unterprogramme von Testprogrammen im XML-Bereich sind als Methoden in meine Klasse ZCL_XML_HELPER gewandert, für die es mittlerweile sehr viele Verwender gibt.

data: go_xml type ref to if_ixml,
go_stream_factory type ref to if_ixml_stream_factory.

Sowenig globale Variablen wie möglich. Das XML-Basisobjekt und die XML Stream Factory sind Ausnahmen von dieser Regel. Ich benötige nur eine Instanz von ihnen, egal wie oft Code aus diesem Programm durchlaufen wird. Diese beiden Objekte mögen einen internen Zustand haben, der sich jedoch nicht auf die von ihnen erzeugten oder bearbeiteten Objekte auswirkt. Es gibt also kein Reentrance-Problem.

parameters: p_elnam type text30 default 'p',
p_ns type text128 default 'http://www.w3.org/1999/xhtml',
p_aware as checkbox default space.
Dies sind Testparameter. Das Beispielprogramm soll alle <p>-Elemente in einem XHTML-Dokument herausfinden und deren ID's und Textinhalt ausgeben. p_elnam gibt dabei den Elementnamen an, p_ns den Namensraum. Ich verwende hier die Datentypen textNNN, die im Gegensatz zu charNNN gross/klein-sensitiv arbeiten. Das Flag p_aware steuert, ob im Parser die Namespace Awareness explizit angeschaltet werden soll. Wir werden jedoch sehen, dass die verwendete Methode get_elements_by_tag_name_ns() unabhängig von diesem Flag arbeitet.

Wo werden die Daten initialisiert? Beim Laden des Reports. Ich habe mir einen Report-Rumpf als Muster in den Code Editor eingespeist, der mir beim Tippen der Schlüsselwörter initialization oder start-of-selection vorgeschlagen wird. Er verknüpft diese beiden Events mit entsprechenden Unterprogrammen, so dass ich ab dort in sauberen Stack-Einheiten mit lokalen Daten arbeiten kann:
initialization.
perform init.

start-of-selection.
perform start.

Die Initialisierungsroutine ist nun das Erwartete - sie erzeugt die beiden globalen Objekte mit den dafür vorgesehenen create-Methoden:
*---
form init.
go_xml = cl_ixml=>create( ).
go_stream_factory = go_xml->create_stream_factory( ).
endform. "init

Kommen wir nun zur Routine start, die beim Druck auf "Ausführen" aufgerufen wird. Hierbei werden nacheinander drei Schritte ausgeführt: Ein Beispieldokument wird in ein Dokument vom Typ if_ixml_document eingelesen. Danach werden bestimmte Elemente in diesem Dokument gesucht und in einer Internen Tabelle vom Typ dcxmlelems zurückgegeben. Diese werden schliesslich in einer ganz altbackenen ABAP-Liste mittels write ausgegeben.

Genau diese drei Schritte sind in der Routine start dokumentiert:
* ---
form start.

data: lo_xhtml type ref to if_ixml_document,
lt_elements type dcxmlelems,
lv_element_name type string,
lv_namespace type string.

try.

perform build_test_xhtml using p_aware changing lo_xhtml.

lv_element_name = p_elnam.
lv_namespace = p_ns.
perform search_elements using lo_xhtml lv_element_name lv_namespace
changing lt_elements.

perform write_result using lt_elements.

catch cx_ixml_parse_error.
* Error log has already been written
endtry.

endform. "start

Das XHTML-Dokument wird in zwei Schritten aufgebaut. Zuerst wird es als String erzeugt, den man irgendwie als gegeben betrachten kann. Er könnte z.B. aus einer Datei eingelesen worden sein oder über einen HTTP-Request aus dem Internet stammen. In einem zweiten Schritt wird dieser String dann geparsed, d.h. in das interne DOM-Format gewandelt:
* ---
form build_test_xhtml
using iv_ns_aware type flag
changing eo_xhtml type ref to if_ixml_document
raising cx_ixml_parse_error.

data: lv_xhtml type string.

perform get_test_html_as_string changing lv_xhtml.

perform parse using lv_xhtml iv_ns_aware
changing eo_xhtml.

endform. "build_test_xhtml

Die Routine get_test_html_as_string erzeugt den Beispielstring, den man drt für Testzwecke beliebig umbauen kann:
* ---
form get_test_html_as_string changing ev_xhtml type string.
data: lv_xhtml type string.
concatenate
`<?xml version="1.0" encoding="ISO-8859-1" ?>`
`<html xmlns="http://www.w3.org/1999/xhtml">`
`<head>`
`<meta http-equiv="content-type" content="text/html; charset=ISO-8859-1" />`
`<title>Wenn HTML zu XHTML wird</title>`
`</head>`
`<body>`
``
`<h1><a name="start" id="start">Wenn HTML zu XHTML wird</a></h1>`
``
`<p id="erster" >Erster Paragraph.</p>`
`<p id="zweiter">Zweiter Paragraph.</p>`
`<p id="dritter">Na, wievielter Paragraph wohl?</p>`
`</body>`
`</html>`
into ev_xhtml
separated by cl_abap_char_utilities=>cr_lf. " Für Zeilenausgabe in Fehlermeldungen
endform. "get_test_html_as_string

Ich kann in ABAP nicht nur einfache Anführungszeichen, sondern auch Backticks (`) als Stringbegrenzer verwenden. Das ist nützlich in Fällen wie diesem: Meist kommen in XML- oder HTML-Dokumenten zwar eine Menge einfacher und doppelter Anführungszeichen vor, aber keine Backticks. So muss ich normalerweise nirgends Fluchtsequenzen für das Stringbegrenzungszeichen einführen. Ich konkateniere die Zeilen zu einem String, wobei ich aber Zeilenumbrüche als Trennzeichen verwende. Das hat den Vorteil, dass allfällige vom Parser ausgegebene Fehlermeldungen über die Zeilennummer leicht dem Quelltext zugeordnet werden können.

Nun zum eigentlichen Parsevorgang. Der DOM-Parser möchte nicht direkt einen String zur Eingabe haben, sondern einen Eingabestrom. Daher muss der übergebene String in einen Stream verwandelt werden. Ausserdem muss eine leere Instanz des Zieldokuments vom Typ if_ixml_document erzeugen und dem Parser übergeben. Er schreibt sein Ergebnis dann in diese Instanz:
* ---
form parse using iv_xml type string
iv_ns_aware type flag
changing eo_doc type ref to if_ixml_document
raising cx_ixml_parse_error.

data: lo_parser type ref to if_ixml_parser,
lo_stream type ref to if_ixml_istream.

lo_stream = go_stream_factory->create_istream_string( iv_xml ).
eo_doc = go_xml->create_document( ).

lo_parser = go_xml->create_parser(
document = eo_doc
istream = lo_stream
stream_factory = go_stream_factory ).

if iv_ns_aware = 'X'.
lo_parser->set_namespace_mode(
if_ixml_parser=>co_namespace_aware
).
endif.

lo_parser->parse( ).

if lo_parser->num_errors( ) > 0.
perform do_parser_errors using lo_parser.
endif.

endform. "parse

Wenn das Parsen ohne Fehler funktioniert hat, enthält eo_doc nun ein strukturiertes Abbild des eingelesenen XML-Dokuments. Nun kann man das Interface if_ixml_document verwenden, um dieses Dokument zu durchsuchen. Wenn wir den try-catch-Block in der Routine start noch einmal betrachten,
  try.

perform build_test_xhtml using p_aware changing lo_xhtml.

lv_element_name = p_elnam.
lv_namespace = p_ns.
perform search_elements using lo_xhtml lv_element_name lv_namespace
changing lt_elements.

perform write_result using lt_elements.

catch cx_ixml_parse_error.

so sind wir nun mit dem Erzeugen des XHTML-Dokuments lo_xhtml fertig. Das Programm kehrt auf diese Stackebene zurück und setzt, wenn es keinen Fehler gab, mit dem Aufruf der Routine search_elements fort. Vor dem Aufruf müssen die Eingabeparameter p_elnam und p_ns leider noch in Strings verwandelt werden, denn diesen Datentyp erwartet die if_ixml_document-Methode get_elements_by_tagname_ns, die wir aufzurufen gedenken. MOVEs von dieser Art sind in ABAP oft unvermeidlich. Zum Beispiel hier: Einerseits können Parameter nicht den Datentyp String haben. Andererseits werden die Schnittstellenparameter oft im Datentyp String erwartet.[2]

Wie sieht nun die Routine search_elements aus? Sie ruft die Dokumentmethode get_elements_by_tag_name_ns( ) auf, die eine if_ixml_node_collection aller gefundenen Elemente zurückliefert - deren Name und Namensraum also mit den angegebenen Werten übereinstimmt. Da der Zugriff auf diese Collection im Code etwas schwerfällig ist, erlaube ich mir den Luxus, sie in eine interne Tabelle von Objekten vom Typ if_ixml_element zu wandeln. Eine interne Tabelle in einer Loop abzuarbeiten, ist in ABAP viel lesbarer als das Iterieren einer Collection. Glücklicherweise gibt es für "eine Standardtabelle von iXML-Elementen" bereits den mit der SAP-Basis ausgelieferten und definierten Datentyp dcxmlelems:
* ---
form search_elements using io_xhtml type ref to if_ixml_document
iv_elnam type string
iv_ns type string
changing et_elements type dcxmlelems.

data: lo_elements type ref to if_ixml_node_collection.

lo_elements = io_xhtml->get_elements_by_tag_name_ns(
name = iv_elnam
uri = iv_ns ).

perform element_table_from_collection
using lo_elements
changing et_elements.


endform. "search_elements

Was macht element_table_from_collection? Was man erwarten würde: Es durchläuft die Collection, typisiert die Nodes als Elemente und fügt sie in eine interne Tabelle ein:
* ---
form element_table_from_collection
using io_elements type ref to if_ixml_node_collection
changing et_elements type dcxmlelems.

data: lo_iterator type ref to if_ixml_node_iterator,
lo_node type ref to if_ixml_node,
lo_element type ref to if_ixml_element.

lo_iterator = io_elements->create_iterator( ).

do.

clear lo_node.
lo_node = lo_iterator->get_next( ).
if lo_node is not bound.
exit.
endif.

clear lo_element.
lo_element ?= lo_node->query_interface( ixml_iid_element ).
if lo_element is bound.
append lo_element to et_elements.
endif.

enddo.

endform. "element_table_from_collection

Wenn dies gemacht ist, geht es wieder in der Routine start weiter. Durch den Aufruf
      perform write_result using lt_elements.

werden die gesammelten Elemente an das Unterprogramm zur Ausgabe übergeben.
* ---
form write_result using it_elements type dcxmlelems.

data: lo_element type ref to if_ixml_element,
lv_id type string,
lv_text type string.

if it_elements is not initial.
loop at it_elements into lo_element.
lv_id = lo_element->get_attribute( 'id' ).
lv_text = lo_element->get_content_as_string( ).
write: / lv_id, at 15 ':', lv_text.
endloop.
else.
write: /
'Keine Elemente mit diesem Namen und Namespace gefunden'.
endif.

endform. "write_result

Zur Demonstration werden hier pro Element der Wert des Attributs id und der Textinhalt ausgegeben. Das erzeugt mit dem hier verwendeten Beispieldokument die Ausgabe:
DOM-Parser sucht nach einem Element in XHTML


erster : Erster Paragraph.
zweiter : Zweiter Paragraph.
dritter : Na, wievielter Paragraph wohl?

Was ist, wenn es nicht funktioniert? Der Parser schreibt ein Protokoll aller beim Lesen des Streams aufgetretenen Fehler. Die Gesamtzahl dieser Fehler kann mit der Methode lo_parser->num_errors( ) abgefragt werden. Oben war in der Routine zu sehen, dass bei Fehlern in die Routine do_parser_errors abgesprungen wurde. Diese erzeugt eine Listausgabe aller gesammelten Fehler und löst dann die Ausnahme cx_ixml_parse_error aus. Diese propagiert im Callstack nach oben in die Routine start und wirkt dort als Stopsignal. Da eine Ausgabe bereits geschrieben wurde, ist keine explizite Behandlung der Ausnahme mehr nötig. Es genügt, dass die Ausführung der nachfolgenden Routinen search_elements und write_result verhindert wird, die ja sinnlos sind, wenn kein wohlgeformtes XML-Dokument eingelesen werden konnte.
* ---
form do_parser_errors
using io_parser type ref to if_ixml_parser
raising cx_ixml_parse_error.

data: lo_error type ref to if_ixml_parse_error,
lv_maxnum type i,
lv_num type i,
lv_text type string,
lv_line type i,
lv_column type i,
lv_severity type i.


* Fehler im Nachrichtensammler einfügen
lv_maxnum = io_parser->num_errors( ).
while lv_num < lv_maxnum.
lo_error = io_parser->get_error( lv_num ).
lv_text = lo_error->get_reason( ).
lv_line = lo_error->get_line( ).
lv_column = lo_error->get_column( ).
lv_severity = lo_error->get_severity( ).
write: / lv_line left-justified no-gap,
'(' no-gap,
lv_column left-justified no-gap,
')',
lv_text.
add 1 to lv_num.
endwhile.

* Wenn Fehler auftraten, Ausnahme auslösen
if lv_maxnum > 0.
raise exception type cx_ixml_parse_error
exporting
reason = lv_text
line = lv_line
column = lv_column.
endif.

endform. "write_parser_errors


Die ganze Thematik ist auch wunderbar – und viel umfassender als es ein Blog je sein könnte – in der SAP-Hilfe dokumentiert. Einsteigern empfehle ich zur Lektüre unbedingt den ABAP XML Jumpstart.


[1] Ich habe allerdings die Erfahrung gemacht, dass für kleine XML-Dokumente oft eine XSLT-Transformation die beste Wahl ist, die die gewünschten Inhalte gleich in die Komponenten einer passenden ABAP-Datenstruktur hineintransformiert. Aber trotz der guten Integration in die Workbench und der Möglichkeit, sogar die Transformationen zu debuggen, bedeutet die Sprache XSLT für viele leider eine (rein psychologische) Extrahürde.
[2] ... und nicht in den generischen Typen csequence oder clike, die in Schnittstellen oft die bessere Wahl sind, da sie einen Oberbegriff von type c und type string darstellen, so dass Datenobjekte beider Typen als Aktualparameter akzeptiert werden.

Keine Kommentare :