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.

JSONObject json = new JSONObject()
    .put("number", 1)
    .put("object", new JSONObject()
        .put("string", "Hallo"))
    .put("boolean", true)
    .put("array", new JSONArray()
        .put(47.11)
        .put("Hallo nochmal"));
Listing 1: Progammatische Erzeugung eines JSON-Objekts mit org.json

Ä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.

JSONObject json = new JSONObject(new JSONTokener("""
    {
        "number": 1,
        "object": {
            "string":"Hallo"
        },
        "boolean": true,
        "array": [
            47.11,
            "Hallo nochmal"
        ]
    }
"""));
Listing 2: Parsen eines JSON-Strings mit org.json

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.

JSONObject json = ...;

StringWriter writer = new StringWriter();
json.write(writer, 4, 0);
System.out.println(writer);
Listing 3: Schreiben eines JSONObject in einen String
new JSONWriter(System.out)
    .object()
        .key("number").value(1)
        .key("object").object()
            .key("string").value("Hallo")
        .endObject()
        .key("boolean").value(true)
        .key("array").array()
            .value(47.11)
            .value("Hallo nochmal")
        .endArray()
    .endObject();
Listing 4: Direktes Schreiben von JSON auf die Standardausgabe

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.

JSONObject json = new JSONObject()
    .put("number", 1)
    .put("array", new JSONArray()
        .put(2))
    .put("string", "5")
    .put("null", JSONObject.NULL);

json.isEmpty();           // -> false
json.has("not-there");    // -> false
json.isNull("not-there"); // -> true
json.has("null");         // -> true
json.isNull("null");      // -> true

json.optInt("string");    // -> 5
json.getInt("string");    // -> 5
json.getString("number"); // throws Exception
json.optString("number"); // -> "1"

json.increment("number");     // -> "number": 2
json.append("array", false);  // -> "array" [2, false]
json.accumulate("string", 2); // -> "string": ["1", 2]
Listing 5: Abfragen und Transformationen auf einem JSONObject

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.

JSONObject json = new JSONObject()
    .put("object", new JSONObject()
        .put("string", "Hallo"))
        .put("array", new JSONArray()
            .put(47.11)
            .put("Hallo nochmal")
            .put(new JSONObject()
                .put("string", "Und weg!")));

System.out.println(json.query("/object/string"));
System.out.println(json.query("/array/2/string"));
Listing 6: Abfragen mit JSON-Pointer auf einem JSONObject

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.

JsonObject object = new JsonObject();
object.addProperty("string", "Hallo");

JsonArray array = new JsonArray();
array.add(47.11);
array.add("Hallo nochmal");

JsonObject json = new JsonObject();
json.addProperty("number", 1);
json.add("object", object);
json.addProperty("boolean", true);
json.add("array", array);
Listing 7: Erzeugung eines JSON-Objekts mit Gson

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.

// JsonParser
JsonElement json = JsonParser.parseString("""
    {
        "number": 1,
        "object": {
            "string":"Hallo"
        },
        "boolean": true,
        "array": [
            47.11,
            "Hallo nochmal"
        ]
    }
""");

// JsonReader
try (JsonReader reader = new JsonReader(
        new StringReader(json.toString()))) {
    reader.beginObject();
    while(reader.hasNext()) {
        System.out.println(reader.nextName());
        reader.skipValue();
    }
}
Listing 8: Lesen von JSON mit Gson

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.

class Test {
    private int number;
    private String string;

    @Override
    public String toString() {
        return "Test { number=%d, string=%s }".formatted(number, string);
    }
}

Gson gson = new GsonBuilder().create();

Test test = gson.fromJson("""
    {
        "number": 5,
        "string": "Michael"
    }
""", Test.class);

String json = gson.toJson(test);
Listing 9: Databinding mit Gson

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.

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
    @JsonSubTypes.Type(value = Bar.class, name = "my-bar"),
    @JsonSubTypes.Type(value = Baz.class, name = "some-other")})
interface Foo {}

record Bar(int prop) implements Foo {}

record Baz(String property) implements Foo {}

record Test(@JsonProperty("nummer") int number,
        String string,
        List<Foo> foos) {}

ObjectMapper om = new ObjectMapper();

Test test = om.readValue("""
    {
        "nummer": 5,
        "string": "Michael",
        "foos": [
            {
                "type": "my-bar",
                "prop": 42
            },
            {
                "type": "some-other",
                "property": "Hallo"
            }
        ]
    }
""", Test.class);

String json = om.writeValueAsString(test);
Listing 10: Databinding mit Jackson

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.

// Erzeugen
JsonObject json = Json.createObjectBuilder()
    .add("number", 1)
    .add("object", Json.createObjectBuilder()
        .add("string", "Hallo"))
    .add("boolean", true)
    .add("array", Json.createArrayBuilder()
        .add(47.11)
        .add("Hallo nochmal"))
    .build();

// Lesen
try (JsonReader reader = Json.createReader(
        new StringReader(json.toString()))) {
    json = reader.readObject();
}

// Schreiben
StringWriter writer = new StringWriter();
try (JsonWriter jsonWriter = Json.createWriter(writer)) {
    jsonWriter.write(json);
    System.out.println(writer);
}
Listing 11: Erzeugen, Schreiben und Lesen von JSON mit JSON-P
JsonObject json = Json.createObjectBuilder()
    .add("object", Json.createObjectBuilder()
        .add("string", "Hallo"))
    .add("array", Json.createArrayBuilder()
        .add(47.11)
        .add("Hallo nochmal")
        .add(Json.createObjectBuilder()
            .add("string", "Und weg!")))
    .build();

System.out.println(Json.createPointer("/object/string").getValue(json));
System.out.println(Json.createPointer("/array/2/string").getValue(json));
Listing 12: JSON-Pointer mit JSON-P

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.

JsonObject json = Json.createObjectBuilder()
    .add("number", 1)
    .add("boolean", true)
    .add("array", Json.createArrayBuilder()
        .add(47.11)
        .add("Hallo nochmal"))
    .build();

JsonPatch patch = Json.createPatchBuilder()
    .add("/added", Json.createObjectBuilder()
        .add("foo", "bar").build())
    .remove("/boolean")
    .replace("/number", 3)
    .replace("/array/1", "Und weg")
    .add("/array/2", Json.createObjectBuilder()
        .add("foo", "bar").build())
    .build();

JsonObject patchedJson = patch.apply(json);
Listing 13: JSON Patch mit JSON-P

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.

Fazit

Wir haben in diesem Artikel mit org.json, Gson, Jackson und JSON-P/-B vier verschiedene Bibliotheken für den Umgang mit JSON in Java kennengelernt. Vom einfachen Schreiben und Lesen mit org.json, über Databinding mit Gson zur generischen Datenformat­Lösung mit Jackson und der standardisierten Jakarta EE API JSON-B ist dabei für jede Anforderung eine passende Lösung vorhanden.

Neben dem Programmiermodell haben wir uns auch, anhand eines vorhandenen Benchmarks, mit der Performanz beschäftigt und dabei gesehen, dass es hier noch weitere Bibliotheken, wie dsljson, gibt, die es, je nach Anwendungsfall, zu betrachten lohnt.

Natürlich spielt auch Sicherheit eine Rolle. Diese ist vor allem beim Lesen von JSON relevant, da hier die Daten häufig aus nicht von uns kontrollierbaren Quellen kommen. Mit regelmäßigen Updates und einer vernünftigen Konfiguration der Bibliothek lassen sich diese Angriffsvektoren jedoch auch deutlich verringern.

Der Code für die in diesem Artikel gezeigten Listings findet sich unter https://github.com/mvitz/javaspektrum-json, um lästiges Abtippen beim Ausprobieren zu vermeiden.

TAGS