This article is also available in English
Ich erinnere mich noch daran, dass am Anfang meiner Karriere in der Softwareentwicklung XML allgegenwärtig war. Es wurde sowohl für Konfigurationsdateien als auch als textbasiertes Austauschformat genutzt. Vor allem dadurch, dass SOAP und auch der Browser via AJAX XML nutzten, sorgte dafür, dass es an XML kein Vorbeikommen gab. XML hatte schon damals oft den Ruf geschwätzig und, vor allem in Kombination mit Schemas, kompliziert zu sein. Über die Zeit wurde es dann langsam immer mehr von JSON verdrängt. Heute spielt, zumindest in meiner Welt, eigentlich nur noch JSON eine Rolle.
Wollen wir jedoch in Java JSON verarbeiten oder erzeugen, stellen wir schnell fest, dass es im JDK selbst keine Programmierschnittstelle dafür gibt. Der primäre Grund dafür ist, dass die begrenzte Kapazität des JDK-Teams nicht durch zusätzliche APIs weiter belastet werden soll. Wir müssen uns also anderswo nach einer passenden Bibliothek umschauen. Wie erwartet liefert uns eine Suche nicht nur ein Ergebnis, sondern wir können direkt aus einer Reihe von Bibliotheken auswählen.
Dieser Artikel zeigt dazu das Programmiermodell von vier verschiedenen Bibliotheken für Java: org.json, Gson, Jackson sowie JSON-P und JSON-B aus der Jakarta EE-Welt. Außerdem werden wir zum Abschluss auch noch einen kurzen Blick auf Performanz und Sicherheit werfen.
org.json
Die Bibliothek org.json gibt es bereits seit Ende 2010 und wurde initial von Douglas Crockford, seines Zeichens Erfinder von JSON, implementiert. Darum behauptet diese auch von sich selbst, die Referenzimplementierung für JSON in Java zu sein.
In Summe handelt es sich dabei um eine einfach zu nutzende
Programmierschnittstelle, die im Kern aus den beiden Klassen JSONObject
und
JSONArray
besteht. Diese bilden die in der JSON-Spezifikation
definierten Elemente ab, welche nicht durch in Java vorhandene Klassen bereits
abgedeckt sind. Um JSON nun programmatisch zu erzeugen, reichen uns diese beiden
Klassen und deren Konstruktoren und Methoden. Listing 1 zeigt die Konstruktion
eines JSON-Objekts mit diversen Werten. Dadurch, dass die put
-Methode sich
selbst zurückgibt, entsteht durch das Aneinanderketten der Methodenaufrufe eine
kompakte Deklaration.
Ähnlich einfach ist das Parsen von JSON umgesetzt. Hierzu können wir dem
Konstruktor von JSONObject
oder JSONArray
eine Instanz eines JSONTokener
übergeben. Diesen wiederum können wir mit einem String
, Reader
oder
InputStream
erzeugen. Listing 2 zeigt, wie wir die JSON-Struktur aus Listing 1
aus einem String parsen können.
Um JSON zu schreiben, gibt es zwei Wege. Den ersten Weg nutzen wir, wenn wir
bereits ein JSONObject
oder JSONArray
haben. Hier nutzen wir, wie in
Listing 3 zu sehen, die Methode write
, der wir einen Writer
und optional
noch einen Einrückungsfaktor übergeben können. Alternativ können wir auch
mittels JSONWriter
JSON direkt ausgeben, ohne dass wir vorher Objekte erzeugen
müssen. In Listing 4 schreiben wir die bereits bekannte Struktur direkt auf die
Standardausgabe unseres Prozesses.
Um im Code mit einem JSONObject
oder JSONArray
zu arbeiten, stehen uns eine
Menge an Methoden zur Verfügung. So können wir mittels has
oder isNull
prüfen, ob ein Feld vorhanden und nicht null
ist. Dabei gibt isNull
jedoch
auch für nicht vorhandene Felder true
zurück.
Als Methoden, um einzelne Feldwerte abzufragen, stehen uns diverse
getXxx
-Methoden zur Verfügung, die uns den Wert im angeforderten Java-Datentyp
zurückgeben. Fragen wir dabei ein nicht vorhandenes Feld ab, wird eine
JSONException
geworfen. Parallel dazu können wir deshalb eine der
optXxx
-Methoden nutzen. Diese werfen keine Exception, sondern geben uns einen
Standardwert zurück. Listing 5 zeigt einige Beispiele für die Nutzung dieser
Methoden.
Alles in allem lassen sich die Methoden wie erwartet nutzen, ich wurde jedoch an
der einen oder anderen Stelle überrascht. So liefert die Abfrage mittels
getString
eine Exception, sollte es sich beim Wert um eine Zahl handeln.
Dasselbe Feld mit optString
abgefragt gibt jedoch den Wert der Zahl als String
zurück. Andersherum wird sowohl mit getInt
als auch mit optInt
auf einem
String-Feld der String in eine Zahl geparst. Und auch der für optString
als
Standardwert gewählte leere String ist gewöhnungsbedürftig.
Zuletzt werden für Abfragen auch noch JSON-Pointer unterstützt. Diese bieten uns, ähnlich wie XPath für XML, die Möglichkeit, über einen einzelnen Ausdruck Werte aus einem bestehenden Objekt oder Array zu extrahieren. In Listing 6 sind dazu zwei Beispiele zu sehen.
Gson
Gson von Google gibt es sogar schon länger als org.json, nämlich seit 2008.
Ähnlich wie org.json erlaubt uns Gson das Lesen, Erstellen und Schreiben von
„generischen“ JSON-Objekten. Die Abbildung der Typen aus der JSON-Spezifikation
findet mit den Klassen JsonObject
, JsonArray
, JsonPrimitive
und JsonNull
statt, die alle von JsonElement
erben. Im alltäglichen Umgang spielen
JsonPrimitive
und JsonNull
allerdings in der Regel keine Rolle, da wir hier
die Primitiven von Java nutzen können und Gson dann nur intern in diese Klassen
konvertiert.
Beim Erstellen, siehe Listing 7, fällt sofort ins Auge, dass wir hier keine
Möglichkeit haben, die Methoden direkt aneinanderzureihen, da die Methoden add
und addProperty
als Rückgabetyp void
haben.
Um vorhandenes JSON in ein JsonElement
zu parsen, nutzen wir die Klasse
JsonParser
. Allerdings erlaubt uns Gson auch mittels JsonReader
eine
Streaming basierte Lösung. Bei dieser müssen wir selbst von Token zu Token
springen. Gerade für die Verarbeitung von sehr großen Datenmengen, die wir nicht
komplett in den Speicher lesen möchten, ist diese Möglichkeit von Vorteil. Beide
Möglichkeiten sind in Listing 8 zu sehen.
Für das Schreiben von JSON können wir entweder mittels JsonWriter
JSON direkt
beim Erzeugen schreiben oder mittels der Klasse Gson
unser JsonElement
serialisieren.
Zwar unterstützt Gson im Gegensatz zu org.json keine JSON-Pointer, dafür aber
Databinding. Mittels Databinding und der Klasse Gson
können wir JSON auf
bestehende Java-Objekte binden und diese auch wieder als JSON schreiben. In
Listing 9 ist zu sehen, wie wir erst eine Instanz unserer Klasse Test
aus JSON
erzeugen und diese anschließend wieder nach JSON schreiben.
Gson baut hierzu darauf, dass die genutzte Klasse einen Default-Konstruktor besitzt, und nutzt anschließend Reflection, um sämtliche Felder der Klasse, und der Elternklassen, zu finden. Support für die mit JDK 16 eingeführten Records bietet Gson aktuell jedoch noch nicht.
Zusätzlich ermöglicht es uns Gson, mittels TypeAdapter
auch eigene
Mappinglogik für Typen zu hinterlegen. Standardmäßig ist so beispielsweise auch
möglich, java.net.URL
zu verwenden, welche auf einen JSON-String gemappt wird.
Außerdem ist es, mittels Annotationen, möglich, einen vom Java-Feldnamen
abweichenden Namen in JSON zu verwenden oder das Binding auf bestimmte Felder zu
beschränken.
Gson kann uns auch noch bei der Evolution unseres Datenformates unterstützen.
Hierzu gibt es einen eingebauten Support für Versionierung. Dabei müssen wir
Felder oder Klassen mit @Since
und/oder @Until
annotieren und mit einer
Version markieren. Beim Erzeugen der Gson
-Klasse können wir dann angeben,
welche Version diese Instanz unterstützt. Beim Lesen und Schreiben von JSON wird
Gson dann nur die Felder auswerten, die in der angegebenen Version unterstützt
werden.
Jackson
Auch Jackson gibt es bereits, wie Gson, seit 2008. Sie ist aus meiner Sicht die aktuell wohl die am meisten eingesetzte Bibliothek für JSON-Verarbeitung mit Java. Das liegt vor allem daran, dass es in Spring Boot als Standard gesetzt ist.
Obwohl Jackson im Kern eine Streaming basierte Programmierschnittstelle inklusive JSON-Implementierung besitzt, wird diese in der Regel nicht genutzt, sondern Jackson sticht vor allem durch das weitumfassende und konfigurierbare Databinding hervor. In Listing 10 ist dabei nur ein Ausschnitt aus den Möglichkeiten zu sehen.
Wie im Listing zu sehen, unterstützt Jackson bereits jetzt Records, und auch mit
Vererbung kann Jackson umgehen. Um das Mapping zu beeinflussen, stehen uns eine
Menge an Annotationen zur Verfügung. Neben @JsonProperty
aus dem Listing gibt
es beispielsweise noch @JsonFormat
, um das Format für ein Datum zu
spezifizieren. Zusätzlich haben wir mit der @JsonView
-Annotation die
Möglichkeit, je nach Anwendungsfall verschiedene Felder des Objekts beim
Schreiben zu exkludieren, ohne für jede Kombination eigene Klassen extra für die
Serialisierung anzulegen.
Neben der Konfiguration des Mappings lässt sich aber auch Jackson selber noch
vielfach konfigurieren. So ist es möglich, anzugeben, ob Felder mit dem Wert
null
geschrieben oder weggelassen werden oder wir bei einer Liste im
Java-Modell auch zulassen wollen, dass im JSON nur ein Objekt oder Wert
geschrieben werden kann. Außerdem können wir Jackson um eigene Datentypen
erweitern.
Neben dem Kern von Jackson gibt es noch eine Menge an zusätzlichen Modulen, die sich vor allem in zwei Bereiche aufteilen. Zum einen gibt es fertige Module, die Unterstützung für weitere Datentypen, wie Eclipse Collections oder Joda-Time, mitbringen. Die anderen Module befassen sich vor allem mit verschiedenen Datenformaten. Da Jackson im Kern generisch und unabhängig vom konkreten Format ist, können wir so auch Databinding für Formate wie XML, YAML oder Protobuf mit Jackson nutzen.
JSON-P und JSON-B
Natürlich gibt es auch in der Welt von Jakarta EE Unterstützung für JSON. Hierzu gibt es, wie üblich, eine Spezifikation, die anschließend von verschiedenen Bibliotheken implementiert werden kann.
Ähnlich wie in Jackson wurde dabei die JSON-Unterstützung in zwei Teile getrennt. Mit JSON-P gibt es eine Spezifikation, die sich nur um das Lesen und Schreiben von JSON kümmert. Auf dieser aufbauend gibt es dann mit JSON-B Unterstützung für Databinding. Die Arbeit mit JSON-P, siehe Listing 11, erinnert dabei an org.json und Gson. Und es werden sogar, wie in org.json, JSON-Pointer (Listing 12) unterstützt.
Als Besonderheit enthält JSON-P auch noch eine Unterstützung für JSON Patch. JSON Patch erlaubt es uns, in JSON Operationen zu definieren, die dann auf einem JSON-Objekt angewandt werden können und dieses verändern. In Listing 13 ist zu sehen, wie wir einen solchen Patch per Builder in Java erzeugen und auf ein Objekt anwenden.
Das Databinding mit JSON-B erfolgt dann analog zu Jackson. Wir nutzen unsere Klassen und können mittels Annotationen noch individuelle Dinge, wie beispielsweise den Feldnamen, anpassen. Natürlich besteht auch hier die Möglichkeit, Adapter für eigene Typen hinzuzufügen.
Zum aktuellen Zeitpunkt werden von JSON-B, ähnlich wie bei Gson, Records noch nicht von Haus aus unterstützt. Allerdings können wir auch jetzt schon mit wenigen Handkniffen JSON-BRecords dafür sorgen, dass diese doch genutzt werden können.
Performanz
Neben Programmiermodell und -schnittstelle einer Bibliothek spielt auch die Performanz für das Verarbeiten von JSON häufig eine Rolle. Natürlich müssen wir hier für unser konkretes Problem selbst messen, ob die erreichte Performanz einer Bibliothek ausreicht oder nicht.
Wenn wir nicht selbst messen möchten, kann uns der Java JSON Benchmark einen ersten Eindruck vermitteln. Bereits auf den ersten Blick ist dabei zu erkennen, dass je nach Wahl der Testdaten und ob wir das Lesen oder Schreiben betrachten größere Unterschiede auftreten können.
Von der Tendenz her hat in diesem Benchmark, von unseren vier Kandidaten, Jackson in Summe die Nase leicht vorn, auch ohne, dass wir mit Jackson Afterburner ein zusätzliches Modul zur Steigerung der Performanz nutzen müssten. Die JSON-B-Referenzimplementierung Yasson ist hingegen interessanterweise beim Schreiben deutlich besser platziert als beim Lesen.
Bei den beiden deutlich kleineren Bibliotheken org.json und Gson hat Gson einen kleinen Vorteil. Beide liegen aber, zumindest in diesem Benchmark, deutlich hinter Jackson, was mich persönlich überrascht hat.
Die, in diesem Benchmark, deutlich schnellste Bibliothek dsljson setzt im Gegensatz zu unseren vier Bibliotheken auf Codegenerierung mittels Java-Annotation-Prozessor. Die hierdurch eingesparte Nutzung von Reflection zur Laufzeit macht, wie erwartet, bereits einen großen Unterschied.
Sicherheit
Beim Thema Sicherheit spielt vor allem das Lesen von JSON eine Rolle. Schließlich setzen wir die JSON-Bibliotheken meistens auch dafür ein, eingehende Daten, beispielsweise in einer HTTP API, zu verarbeiten. Da wir dabei in der Regel keine vollständige Kontrolle über die Clients haben, besteht hier ein hohes Angriffspotenzial.
Ein möglicher Angriffsvektor besteht darin, mittels sehr großer oder tief verschachtelter JSON-Objekte dafür zu sorgen, dass das Parsen so lange dauert oder so viel Speicher braucht, dass die Anwendung nicht mehr reagieren kann, also ein Denial of Service entsteht.
Die andere Möglichkeit besteht darin zu versuchen, beim Databinding mit Vererbung für ein Objekt eine Subklasse zu forcieren und mittels dieser beliebigen Code auszuführen. Um diese Art von Angriff besser zu verstehen, bietet sich der Artikel von Brian Vermeer an.
Für beide Angriffsvektoren müssen wir uns neben der Bibliothek selbst auch mit deren gewählter Konfiguration auseinandersetzen. Nutzen wir beim Lesen beispielsweise nie Objekte mit Vererbung, können wir das Feature dazu deaktivieren und den zweiten Angriffsvektor so quasi ausschließen.
Neben einer sichereren Konfiguration, die alle im Artikel genannten Bibliotheken im Standard besitzen sollten, geht es vor allem auch darum, die Bibliothek regelmäßig und zeitnah auf den aktuellen Stand zu bringen.