Dienstag, 21. Dezember 2010

Ein JSON-Parser in ABAP

Wenn es darum geht, strukturierte Daten zwischen verschiedenen Systemen auszutauschen, bietet sich das JSON-Datenformat an. Es unterstützt Arrays, Hashs, einige elementare Datentypen für Zahlen, Strings und Wahrheitswerte und erlaubt es, Datenobjekte beliebig tief ineinander zu schachteln.

Die Notation ist einfach und Programmierern der verschiedensten Sprachen (C, C++, C#, Java, JavaScript) in dieser oder ähnlicher Form geläufig. Im Vergleich zu XML gibt es weniger Overhead zur Definition der Struktur, so dass das Wesentliche, der eigentliche Dateninhalt, besser ins Auge fällt.

Hier einige einfache Beispiele:

[ "rot", "grün", "blau" ]

ist ein Array von Strings. Der folgende Hash definiert beispielhaft einige Name/Wert-Paare:

{ 
"Alter":42,
"Beruf":"Stenotypistin",
"Raucher":true
}

Natürlich lassen sich Arrays und Hashs beliebig kombinieren, zum Beispiel zu einem Hash of Arrays (HoA):

{
"Adam" : ["Kain", "Abel", "Seth"],
"Abraham" : ["Ismael", "Isaak"],
"Isaak" : ["Jakob", "Esau"]
}

Da JSON im wesentlichen der Notation von Daten in JavaScript entspricht, wird es häufig in Webanwendungen für die Kommunikation mit dem Server verwendet. Das Parsen im JavaScript-Layer einer Webanwendung kann einfach mit der Anweisung eval erfolgen.[1]

Um die Benutzeroberfläche portabel entwickeln und ohne grossen Aufwand zwischen verschiedenen Backends wählen zu können, empfiehlt sich ein standardisiertes Datenformat wie JSON für beide Richtungen des Datentransports, für das Senden wie das Empfangen der Daten.

Da ich seit kurzem an einer Webanwendung arbeite, die Ajax-Requests mit JSON-Daten an ein SAP-System sendet und von diesem empfängt, benötigte ich im ABAP-Stack einen JSON-Parser. Da es einen solchen anscheinend im SAP-System bisher nicht gibt, habe ich ihn selbst programmiert: Die Klasse ZCL_JSON_PARSER kann eine JSON-Eingabe in ein allgemein typisiertes ABAP-Datenobjekt umwandeln, das dann im Client Code durchsucht oder auf die anwendungsspezifischen Datenstrukturen abgebildet werden kann.

Bei Mappingaufgaben wie dieser ist das testgetriebene Entwickeln besonders effizient: Das liegt daran, dass wir nicht wie sonst mit Seiteneffekten zu tun haben - es gibt praktisch keine API-Aufrufe, die so oder so ausgehen können, und keine Abhängigkeit von den Inhalten irgendwelcher Datenbanktabellen. Wir haben ein Input und ein nur von diesem abhängendes Ouput. Das ruft geradezu danach, die Funktionalitäten Schritt für Schritt als Erfüllung von Testerwartungen zu implementieren. [3]

Bevor wir aber auch nur einen syntaktisch korrekten Test hinschreiben können, muss wenigstens die Schnittstelle der zu testenden Methode festgelegt sein. In unserem Fall werden wir eine öffentliche Methode parse haben, die sicher folgendermassen aussieht:

methods parse
importing
iv_json type string
returning
value(es_data) type zut_data
raising
zcx_parse_error.

Eingabe ist sicher ein String. Ausgabe ist ein Datenobjekt, das mit ABAP-Mitteln ausgewertet werden kann und beliebig tiefe Verschachtelungen zulässt. Hierzu benötigen wir einen generischen Datentyp, der beliebige Datenobjekte fassen kann. Der Datentyp any kann leider nicht verwendet werden, da er nur eine Abstraktion aller bestehenden Datentypen darstellt und dem Compiler nicht sagen kann, wieviele Bytes für diesen Typ alloziert werden sollen. Der Typ ref to data hat dieses Problem nicht. Er ist "ein Zeiger auf irgendetwas" und benötigt eine bekannte Anzahl von Bytes: Die Länge einer Speicheradresse, üblicherweise vier Bytes.

Der obige Rückgabetyp ist im Data Dictionary definiert. In ABAP würde man ihn äquivalent wie folgt notieren:
types: begin of zut_data,
type type c length 1,
data type ref to data,
end of zut_data.

Der Typschlüssel type spezifiert den Typ des Datenobjekts, auf das der Zeiger data zeigt. Das können elementare Datentypen wie String (S), Number (N), Boolean (B) sein, aber auch zusammengesetzte Typen wie Hash (h) oder Array (a).

Man beachte, dass die so typisierte Schnittstelle schon eine wichtige Designentscheidung enthält: Das Ziel unseres Mappings wird ein Datenobjekt, nicht die Instanz einer Klasse. In der Hierarchie der ABAP-Objekte haben Datenobjekte und Instanzen von Klassen keinen gemeinsamen Oberbegriff.

Man hätte alternativ eine Klasse zur Definition des Zieltyps in ABAP verwenden können. Das hätte gewisse Vorteile gebracht, zum Beispiel hätte der Typschlüssel eliminiert werden können [2] – aber um den Preis beträchtlicher Performanceeinbussen: Der create data Befehl hat wesentlich bessere Ausführungszeiten als create object.

Für die einfachen Datentypen können wir nun schon einige Tests hinschreiben:

* --- Zahlen erkennen
method test_42.

data: ls_data type zut_data.
ls_data = go_ref->parse( '42' ).

* Parser erkennt '42' als Zahl:
assert_equals( act = ls_data-type
exp = 'N' ).

* Wert 42 wurde korrekt ermittelt
_assert_equals( act = ls_data-data
exp = 42 ).

endmethod.

* --- Einen String erkennen
method test_string.
data: ls_data type zut_data.
ls_data = go_ref->parse( '"Abc"' ).

* Typ String wird erkannt
assert_equals( act = ls_data-type
exp = 'S' ).

* String wird richtig gelesen
_assert_equals( act = ls_data-data
exp = 'Abc' ).

endmethod. "test_string

Hierbei ist _assert_equals eine Hilfsmethode, die im wesentlichen wie assert_equals funktioniert; wenn aber act ein Zeiger ist, wird er vor dem Vergleich mit exp dereferenziert.

Wenn ein leerer Input hereingereicht wird, soll auch eine leere Struktur zurückgegeben werden:
* --- Keine Eingabe -> Keine Ausgabe
method test_nothing.
data: ls_data type zut_data.

ls_data = go_ref->parse( '' ).

* Leerer String ergibt leeres Ergebnis (keine Ausnahme)
assert_initial( ls_data ).

endmethod. "test_nothing

Wie ist es nun mit zusammengesetzten Typen - hier Hashs und Arrays? Es liegt nahe, Hashs und Arrays in ABAP durch interne Tabellen mit Hash- bzw. Standardzugriff abzubilden. Für Arrays ist dies relativ einfach:
types: zut_array_tab 
type standard table of zut_data.

leistet das Gewünschte: Denn jedes Arrayelement kann ja wieder ein beliebiges Datenobjekt enthalten. Also ist der Zeilentyp des Arrays wieder zut_data.

Hashs sind Mengen von Schlüssel/Wert-Paaren. Der Schlüssel ist ein String, der Wert ist wieder ein beliebiges Datenobjekt. Ein Hash-Element sieht also folgendermassen aus:
types: begin of zut_hash_element,
key type string.
include type zut_data as value.
types: end of zut_hash_element.

Der Hash selbst ist nun im ABAP-Sinne eine Hash-Tabelle mit Zeilentyp zut_hash_element und Schlüsselspalte key:
types: zut_hash_tab type hashed table of zut_hash_element 
with unique key key.


Nun können wir auch Tests für Hashs und Arrays formulieren, mit dem einfachsten Fall beginnend:

Es soll sicher der leere Array erkannt werden (go_ref ist das Object Under Test, also die zu testende Instanz von ZCL_JSON_PARSER):

* --- Der leere Array []
method test_empty_array.

data: ls_data type zut_data.

field-symbols: <lt_array> type zut_array_tab.

ls_data = go_ref->parse( ' [ ] ' ).

* Typ array wird erkannt
assert_equals( act = ls_data-type
exp = 'a' ).

assert_bound( ls_data-data ).

assign ls_data-data->* to <lt_array>.
assert_subrc( act = sy-subrc
msg = 'Datenobjekt hat nicht den korrekten Typ' ).

* Es wurde der leere array ermittelt
assert_initial( <lt_array> ).

endmethod. "test_empty_array

Der letzte und komplizierte Testfall, das Parsen einer verschachtelten komplexen Struktur soll zugleich zeigen, wie die resultierende Struktur in ABAP ausgewertet werden kann, um die in der Anwendung gewünschten Daten zu extrahieren:
* --- Verschachtelte Struktur
method test_complex_nested.

data: ls_data type zut_data,
lv_array_length type i.

field-symbols: <lt_array> type zut_array_tab,
<lt_hash> type zut_hash_tab,
<lt_hash2> type zut_hash_tab,
<ls_line> type zut_data,
<ls_element> type zut_hash_element,
<ls_element2> type zut_hash_element.


ls_data = go_ref->parse(
' [ 1, { x:[1,2,3],y:{"c":1} }, "a" , true ] '
).

* Basistyp Array wird erkannt
assert_equals( act = ls_data-type
exp = 'a' ).

* Äusseren Array anschauen
assign ls_data-data->* to <lt_array>.

* Er hat vier Elemente
describe table <lt_array> lines lv_array_length.
assert_equals( act = lv_array_length
exp = 4 ).

* Auf zweites Element positionieren
read table <lt_array> assigning <ls_line>
index 2.
assert_equals( act = <ls_line>-type exp = 'h' ).
assign <ls_line>-data->* to <lt_hash>.

* Den Hash (das zweite Element des äusseren Arrays) anschauen
* Auf Element zum Schlüssel 'y' positionieren
read table <lt_hash> assigning <ls_element>
with table key key = 'y'.
assert_subrc( sy-subrc ).

* Das Element zum Schlüssel 'y' ist wieder ein Hash
assert_equals( act = <ls_element>-type exp = 'h' ).
assign <ls_element>-data->* to <lt_hash2>.

* Dieser innerste Hash enthält zum Schlüssel 'c' die Zahl 1:
read table <lt_hash2> assigning <ls_element2>
with table key key = 'c'.
assert_subrc( sy-subrc ).
assert_equals( act = <ls_element2>-type exp = 'N' ).
_assert_equals( act = <ls_element2>-data exp = 1 ).

endmethod.

Zum Entwurf der Klasse ZCL_JSON_PARSER noch einige Anmerkungen:

Kernstück ist die Methode get_any, die ein beliebiges JavaScript-Datenobjekt einliest. Sie wird nicht nur von der einzigen öffentlichen Methode parse für das Top-Datenobjekt aufgerufen, sondern auch an den dafür vorgesehenen Plätzen in Arrays und Hashs, was zu rekursiven Aufrufen führt.

Es gefiel mir, die Klasse vollständig von globalen Variablen freizuhalten: Sie besitzt überhaupt keine Attribute. Der Preis dafür war lediglich, alle Teilmethoden mit einem Parameter cv_pos zu versehen, der die aktuelle Position im String angibt und von den Teilmethoden bei erfolgreichem Lesen eines Ausdrucks erhöht werden kann. Die Aufrufe der Teilmethoden werden dadurch etwas unhandlicher als sie sein könnten: Jede Methode übergibt per Referenz den vollständigen zu parsenden String und die Position im String als Changing-Parameter. Dazu gibt es meist den Exportparameter ev_found, ein Flag, das angibt, ob das gesuchte Teilobjekt gefunden wurde.

Die Klasse kann ein bisschen mehr als das JSON-Format: Schlüssel von Hashes können wie in JavaScript ohne Anführungszeichen hingeschrieben werden, wenn sie einen gültigen Variablennamen darstellen. Und Strings können auch durch einfache Hochkommata begrenzt werden. Einzige Einschränkung für Strings: Unicode-Zeichen in der Notation \uXXXX sind nicht unterstützt.


[1] Wegen der theoretischen Möglichkeit von Code Injection-Angriffen wird für Anwendungen, die im Public Internet laufen, allerdings empfohlen, auch in JavaScript einen ausprogrammierten Parser zu verwenden, der wirklich nur Datenobjekte evaluiert und keine JavaScript-Anweisungen ausführt. Die bekannteren JavaScript-Frameworks wie Prototype oder jQuery enthalten einen JSON-Parser.
[2] Das ist Martin Fowlers Refactoringmuster: "Typschlüssel durch Unterklassen ersetzen".
[3] Das ist auch der Grund, warum in TDD-Einführungen häufig Mappingaufgaben als Beispiel verwendet werden, etwa ein Konverter von Ganzzahlen in römische Zahlwerte.

Nachtrag am 12.3.2013

Durch die Integration von JSON in den ABAP Kernel werden sowohl der hier vorgestellte JSON-Parser als auch der JSON-Builder ab SAP-Basisrelease 7.02 obsolet.

Im einfachsten Fall - wenn man das aus den ABAP-Daten abgeleitete "kanonische JSON-Format" akzeptieren kann - kommt man mit einem Zwei- bis Dreizeiler zur Transformation von ABAP-Daten in JSON-Daten und umgekehrt aus. Hierbei liegt die Anweisung call transformation id zugrunde. Um ein abweichendes JSON-Format herzustellen, kann man statt id eine selbstdefinierte XSLT-Transformation verwenden, wie in meinem Blog Developing a REST API in ABAP beschrieben.

Kommentare :

Nick Heylen hat gesagt…

Dear,

Is is possible to download this json parser class?

Friendly regards,

Nick

Rüdiger Plantiko hat gesagt…

Dear Nick,

you can inspect (and copy) the different components of the class

http://bsp.mits.ch/code/clas/zcl_json_parser

For your convenience, I have copied the complete class (using SE24's hidden function code SHOW_CLIF) into a pastebin. Maybe this is helpful for you:

http://pastebin.com/2RXxL0Du

Regards,
Rüdiger

Unknown hat gesagt…

I just found this after posting about my own JSON to ABAP data program. Mine is a very simplistic, trusting parser (which of course caters for nested hashes and arrays) but does not apply any real JSON syntax checking. My assumption also is that you will have a specific (deep) ABAP structure already into which you want to load the JSON data, so I have a CHANGING parameter for the ABAP data structure. You can see my effort here: http://ceronio.net/2012/04/json-to-abap-data-structure-program/

Rüdiger Plantiko hat gesagt…

@Unknown:

Nice approach, using type sniffing of the result parameter for assigning the components. My approach was to use a universal intermediate object (type ZUT_DATA) instead.

This requires some postprocessing code to map the parsed data into the target structures. If certain tasks occur frequently, I would add public methods to the parser class performing this step. Till now, I didn't find it necessary.

I made a branch of your gist adding some module tests.

https://gist.github.com/2282935

Your form could be improved by mapping boolean values to flag values ABAP_TRUE / ABAP_FALSE, as this is what is used as boolean in ABAP. See test_bool_true() and test_bool_false().

Also, I didn't understand how deep structures, e.g. an array of hashs, can be obtained from your rotutine. My expectation on how the form routine works did fail: See test_deep().