Drei Möglichkeiten, Jackson für JSON in Java zu verwenden

May 07, 2020
Autor:in:

Drei Möglichkeiten, Jackson für JSON in Java zu verwenden

Hallo und Danke fürs Lesen! Dieser Blogpost ist eine Übersetzung von Three ways to use Jackson for JSON in Java. Während wir unsere Übersetzungsprozesse verbessern, würden wir uns über Dein Feedback an help@twilio.com freuen, solltest Du etwas bemerken, was falsch übersetzt wurde. Wir bedanken uns für hilfreiche Beiträge mit Twilio Swag :)

Wenn du in einer statisch typisierten Sprache wie Java arbeitest, kann der Umgang mit JSON schwierig sein. JSON hat keine Typdefinitionen und es fehlen einige Funktionen, die wir möchten – es gibt nur Strings, Zahlen, Boolesche Werte und null. Um andere Typen (wie Datum oder Uhrzeit) zu speichern, müssen wir eine auf Strings basierende Konvention verwenden. Trotz seiner Mängel ist JSON das häufigste Format für APIs im Web, also brauchen wir eine Möglichkeit, damit in Java zu arbeiten.

Jackson ist eine der beliebtesten Java JSON-Bibliotheken und wird am häufigsten verwendet. In diesem Beitrag werde ich ein ziemlich komplexes JSON-Dokument und drei Abfragen auswählen, die ich mit Jackson durchführen möchte. Ich werde drei verschiedene Ansätze vergleichen:

  1. Baummodell
  2. Datenbindung
  3. Pfadabfragen

Der gesamte in diesem Beitrag verwendete Code befindet sich in diesem Repository. Es funktioniert ab Java 8.

Andere Java-Bibliotheken für die Arbeit mit JSON

Die beliebtesten Java-Bibliotheken für die Arbeit mit JSON, gemessen an der Verwendung in Maven Central und GitHub-Stars sind Jackson und Gson. In diesem Beitrag werde ich Jackson verwenden, und es gibt hier einen entsprechenden Beitrag mit Gson-Codebeispielen.

Du kannst die Jackson-Abhängigkeit für die Beispiele hier sehen.

Beispieldaten und Fragen

Um einige Beispieldaten zu finden, habe ich Tildes letzten Beitrag 7 coole APIs, von denen du nicht wusstest, dass du sie brauchst gelesen und die Near Earth Object Web Service-API aus den NASA-APIs herausgesucht. Diese API wird von dem großartig benannten SpaceRocks-Team gepflegt.

Die NeoWS Feed API-Anforderung gibt eine Liste von allen Asteroiden aus, deren Annäherung an die Erde innerhalb der nächsten 7 Tage erfolgt. Ich werde zeigen, wie die folgenden Fragen in Java beantwortet werden:

  • Wie viele sind es?
    Dies kann durch einen Blick auf den Schlüssel element_count im Stammverzeichnis des JSON-Objekts herausgefunden werden.
  • Wie viele von ihnen sind potenziell gefährlich?
    Wir müssen jedes NEO durchlaufen und den Schlüssel is_potentially_hazardous_asteroid überprüfen, der ein boolescher Wert im JSON ist. (Spoiler: es sind nicht null)
  • Wie heißt das schnellste erdnahe Objekt und wie schnell ist es?
    Wieder müssen wir eine Schleife durchlaufen, aber diesmal sind die Objekte komplexer. Wir müssen uns auch darüber im Klaren sein, dass Geschwindigkeiten als Zeichenfolgen und nicht als Zahlen gespeichert werden, z. B. "kilometers_per_second": 6.076659807. Dies ist in JSON-Dokumenten üblich, da Präzisionsprobleme bei sehr kleinen oder sehr großen Zahlen vermieden werden.

Ein Baummodell für JSON

Mit Jackson kannst du JSON in ein Baummodell einlesen: Java-Objekte, die JSON-Objekte, Arrays und Werte darstellen. Diese Objekte heißen z. B. JsonNode oder JsonArray und werden von Jackson zur Verfügung gestellt.

Vorteile:

  • Du musst keine eigenen zusätzlichen Klassen erstellen
  • Jackson kann einige implizite und explizite Typ-Zwänge für dich ausüben

Nachteile:

  • Dein Code, der mit Jacksons Baummodellobjekten funktioniert, kann sehr lang sein
  • Es ist sehr verlockend, Jackson-Code mit Anwendungslogik zu mischen, was das Lesen und Testen des Codes erschweren kann

Beispiele für Jackson-Baummodelle

Jackson benutzt eine Klasse namens ObjectMapper als Haupteinstiegspunkt. Normalerweise erstelle ich einen neuen ObjectMapper beim Start der Anwendung und da ObjectMapper-Instanzen threadsicher sind, ist es in Ordnung, sie wie einen Singleton zu behandeln.

Ein ObjectMapper kann JSON aus einer Vielzahl von Quellen mit einer überladenen readTree-Methode lesen. In diesem Fall habe ich einen String angegeben. readTree gibt einen JsonNode aus, dies ist die Root des JSON-Dokuments. JsonNode Instanzen können JsonObjectsJsonArrays oder eine Vielzahl von „Wert“-Knoten wie TextNode oder IntNode sein.

Hier ist der Code zum Parsen eines JSON-Strings in einen JsonNode:

ObjectMapper mapper = new ObjectMapper();
JsonNode neoJsonNode = mapper.readTree(SourceData.asString());

[Dieser Code im Beispiel-Repo]]

Wie viele NEOs gibt es?

Wir müssen den Schlüssel element_count im JsonNode finden und ihn als int zurückgeben. Der Code liest sich ganz natürlich:

int getNeoCount(JsonNode neoJsonNode) {
   return neoJsonNode
       .get("element_count")
       .asInt();
}

Fehlerbehandlung: Wenn element_count fehlt, dann gibt .get("element_count") null aus und es wird eine NullPointerException bei .asInt () geben. Jackson kann das Null-Objektmuster verwenden, um diese Art von Ausnahme zu vermeiden, wenn du .path anstatt von .get verwendest. In beiden Fällen musst du mit Fehlern umgehen, daher verwende ich eher .get.

Wie viele potenziell gefährliche Asteroiden gibt es diese Woche?

Ich gebe zu, dass ich erwartet hatte, dass die Antwort hier null lautet. Es sind derzeit 19 – aber ich bin (noch) nicht in Panik. Um dies aus der Root JsonNode zu berechnen, müssen wir Folgendes tun:

  • alle NEOs durchlaufen – es gibt eine Liste von diesen für jedes Datum, so dass wir eine verschachtelte Schleife benötigen
  • einen Zähler erhöhen, wenn das is_potential_hazardous_asteroid Feld true ist

Der Code sieht so aus:

int getPotentiallyHazardousAsteroidCount(JsonNode neoJsonNode) {
   int potentiallyHazardousAsteroidCount = 0;
   JsonNode nearEarthObjects = neoJsonNode.path("near_earth_objects");
   for (JsonNode neoClosestApproachDate : nearEarthObjects) {
       for (JsonNode neo : neoClosestApproachDate) {
           if (neo.get("is_potentially_hazardous_asteroid").asBoolean()) {
               potentiallyHazardousAsteroidCount += 1;
           }
       }
   }
   return potentiallyHazardousAsteroidCount;
}

[Dieser Code im Beispiel-Repo]]

Dies ist etwas umständlich – Jackson erlaubt uns nicht direkt, die Streams API zu verwenden. Ich verwende also verschachtelte for-Schleifen. asBoolean gibt den Wert für boolesche Felder in JSON zurück, kann aber auch für andere Typen aufgerufen werden:

  • numerische Knoten werden als true aufgelöst, wenn sie ungleich null sind
  • Textknoten sind true, wenn der Wert "true" ist.

Wie heißt das schnellste erdnahe Objekt und wie heißt es?

Die Methode zum Suchen und Durchlaufen jedes erdnahen Objekts ist dieselbe wie im vorherigen Beispiel, aber bei jedem Objekt ist die Geschwindigkeit einige Ebenen tief verschachtelt, so dass du durch diese gehen musst, um den Wert kilometers_per_second auszuwählen.

"close_approach_data": [
 {
    ...
     "relative_velocity": {
         "kilometers_per_second": "6.076659807",
         "kilometers_per_hour": "21875.9753053124",
         "miles_per_hour": "13592.8803223482"
   },
  ...
 }
]

Ich habe eine kleine Klasse erstellt, die beide aufgerufenen Werte NeoNameAndSpeed enthält. Dies könnte ein record in der Zukunft sein. Der Code erstellt eines dieser Objekte:

NeoNameAndSpeed getFastestNEO(JsonNode neoJsonNode) {
   NeoNameAndSpeed fastestNEO = null;
   JsonNode nearEarthObjects = neoJsonNode.path("near_earth_objects");
   for (JsonNode neoClosestApproachDate : nearEarthObjects) {
       for (JsonNode neo : neoClosestApproachDate) {
           double speed = neo
               .get("close_approach_data")
               .get(0)
               .get("relative_velocity")
               .get("kilometers_per_second")
               .asDouble();
           if ( fastestNEO == null ||  speed > fastestNEO.speed ){
               fastestNEO = new NeoNameAndSpeed(neo.get("name").asText(), speed);
           }
       }
   }
   return fastestNEO;
}

[Dieser Code im Beispiel-Repo]]

Obwohl die Geschwindigkeiten als Zeichenfolgen im JSON gespeichert sind, könnte ich .asDouble() aufrufen – Jackson ist klug genug, Double.parseDouble für mich aufzurufen.

Datenbindung von JSON an benutzerdefinierte Klassen

Wenn du komplexere Abfragen deiner Daten hast oder Objekte aus JSON erstellen musst, die du an anderen Code übergeben kannst, passt das Baummodell nicht gut. Jackson bietet eine andere Betriebsart an, Datenbindung, wobei JSON direkt in Objekte deines Designs geparst wird. Standardmäßig verwendet Spring MVC Jackson auf diese Weise, wenn du Objekte von deinen Webcontrollern akzeptierst oder zurückgibst.

Vorteile:

  • Die Konvertierung von JSON in Objekte ist unkompliziert
  • Das Lesen von Werten aus den Objekten kann eine beliebige Java-API verwenden
  • Die Objekte sind unabhängig von Jackson und können daher in anderen Kontexten verwendet werden
  • Das Mapping kann mit Jackson-Modulen angepasst werden

Nachteile:

  • Vorarbeiten: Du musst Klassen erstellen, deren Struktur mit den JSON-Objekten übereinstimmt, und dann Jackson deine JSON in diese Objekte einlesen lassen.

Eine Einführung in die Datenbindung

Hier ist ein einfaches Beispiel, das auf einer kleinen Teilmenge des NEO JSON basiert:

{
 "id": "54016476",
 "name": "(2020 GR1)",
 "closeApproachDate": "2020-04-12",
}

Wir könnten uns eine Klasse vorstellen, in der diese Daten wie folgt gespeichert sind:

class NeoSummaryDetails {
    public int id;
    public String name;
    public LocalDate closeApproachDate;
}

Jackson ist fast in der Lage, zwischen JSON und passenden Objekten wie diesem sofort hin und her zu ordnen. Es kommt gut mit int id zurecht, das ein String ist, braucht aber Hilfe beim Konvertieren des Strings 2020-04-12 zu einem LocalDate-Objekt. Dies erfolgt mit einem benutzerdefinierten Modul, das eine Zuordnung von JSON zu benutzerdefinierten Objekttypen definiert.

Jackson-Datenbindung – benutzerdefinierte Typen

Für das LocalDate-Mapping bietet Jackson eine Abhängigkeit. Füge dies deinem Projekt hinzu und konfiguriere deinen ObjectMapper wie folgt:

   ObjectMapper objectMapper = new ObjectMapper()
       .registerModule(new JavaTimeModule());

[Dieser Code im Beispiel-Repo]]

Jackson-Datenbindung – benutzerdefinierte Feldnamen

Du hast vielleicht bemerkt, dass ich closeApproachDate in meinem Beispiel-JSON oben verwendet haben, wo die Daten von der NASA close_approach_date haben. Ich habe das gemacht, weil Jackson die Reflexionsfähigkeiten von Java verwendet, um JSON-Schlüssel mit Java-Feldnamen abzugleichen, und sie müssen genau übereinstimmen.

In den meisten Fällen kannst du deinen JSON nicht ändern – normalerweise stammt er von einer API, die du nicht steuerst – aber du möchtest trotzdem nicht, dass Felder in deinen Java-Klassen in snake_case geschrieben werden. Dies hätte mit einer Anmerkung auf dem closeApproachDate Feld gemacht werden können:

@JsonProperty("close_approach_date")
public LocalDate closeApproachDate;

[Dieser Code im Beispiel-Repo]]

Erstellen von benutzerdefinierten Objekten mit JsonSchema2Pojo

Im Moment denkst du wahrscheinlich, dass dies sehr zeitaufwändig werden kann. Feldumbenennung, benutzerdefinierte Leser und Schreiber, ganz zu schweigen von der schieren Anzahl von Klassen, die du möglicherweise erstellen musst.  Nun, du hast recht! Aber keine Angst, es gibt ein großartiges Tool, um die Klassen für dich zu erstellen.

JsonSchema2Pojo kann ein JSON-Schema oder (sinnvoller) ein JSON-Dokument nehmen und passende Klassen für dich generieren. Es kennt Jackson-Anmerkungen und verfügt über unzählige Optionen, obwohl die Standardeinstellungen sinnvoll sind. Normalerweise finde ich, dass es 90 % der Arbeit für mich erledigt, aber die Klassen brauchen oft etwas Feinschliff, sobald sie generiert sind.

Um es für dieses Projekt zu verwenden, habe ich alle bis auf eines der NEOs entfernt und die folgenden Optionen ausgewählt:

JsonSchema2Pojo Screenshot

[Generierter Code im Beispiel-Repo]]

In Schlüsseln und Werten gespeicherte Daten

Der NeoWS JSON verfügt über eine etwas umständliche (aber nicht ungewöhnliche) Funktion – einige Daten werden in Schlüsseln anstatt von Werten der JSON-Objekte gespeichert. Die near_earth_objects-Karte hat Schlüssel, die dates sind. Dies ist ein kleines Problem, da die Daten nicht immer gleich sind, aber jsonschema2pojo weiß das natürlich nicht. Es hat ein Feld namens _20200412 erstellt. Um dies zu beheben, habe ich die Klasse _20200412 zu NeoDetails umbenannt und der Typ von nearEarthObjects wurde zu Map<String, List<NeoDetails>> (siehe hier). Ich konnte dann die jetzt unbenutzte NearEarthObjects-Klasse löschen.

Ich habe auch die Arten von Zahlen in Strings von String zu double geändert und wo nötig LocalDate hinzugefügt.

Jackson-Datenbindung für die Near-Earth Object API

Mit den von JsonSchema2Pojo generierten Klassen kann das gesamte große JSON-Dokument gelesen werden mit:

NeoWsDataJackson neoWsDataJackson = new ObjectMapper()
   .registerModule(new JavaTimeModule())
   .readValue(SourceData.asString(), NeoWsDataJackson.class);

Finden der gewünschten Daten

Jetzt, da wir einfache Java-Objekte haben, können wir den Feldzugriff und die Streams-API verwenden, um die gewünschten Daten zu finden:

System.out.println("NEO count: " + neoWsData.elementCount);


System.out.println("Potentially hazardous asteroids: " +
   neoWsData.nearEarthObjects.values()
       .stream().flatMap(Collection::stream) // this converts a Collection of Collections of objects into a single stream
       .filter(neo -> neo.isPotentiallyHazardousAsteroid)
       .count());


NeoDetails fastestNeo = neoWsData.nearEarthObjects.values()
   .stream().flatMap(Collection::stream)
   .max( Comparator.comparing( neo -> neo.closeApproachData.get(0).relativeVelocity.kilometersPerSecond ))
   .get();

System.out.println(String.format("Fastest NEO is: %s at %f km/sec",
   fastestNeo.name,
   fastestNeo.closeApproachData.get(0).relativeVelocity.kilometersPerSecond));

[Dieser Code im Beispiel-Repo]]

Dieser Code ist natürlicheres Java und enthält nicht alle Funktionen von Jackson, so dass es einfacher wäre, diese Version einem Unit-Test zu unterziehen. Wenn du häufig mit demselben JSON-Format arbeitest, lohnt sich die Investition in die Erstellung von Klassen wahrscheinlich.

Pfadabfragen von JSON mit JsonPointer

Mit Jackson kannst du auch einen JSON-Zeiger verwenden. Dies ist eine kompakte Art, sich auf einen bestimmten Einzelwert in einem JSON-Dokument zu beziehen:

JsonNode node = objectMapper.readTree(SourceData.asString());

JsonPointer pointer = JsonPointer.compile("/element_count");

System.out.println("NEO count: " + node.at(pointer).asText());

[Dieser Code im Beispiel-Repo]]

JSON-Zeiger können nur auf einen einzelnen Wert verweisen – du kannst nicht aggregieren oder Platzhalter verwenden, daher sind sie eher begrenzt.

Zusammenfassung der verschiedenen Verwendungsmöglichkeiten von Jackson

Für einfache Abfragen kann dir das Baummodell gute Dienste leisten, aber du wirst höchstwahrscheinlich die JSON-Parsing- und Anwendungslogik durcheinanderbringen, was das Testen und Verwalten erschweren kann.

Um einen einzelnen Wert aus einem JSON-Dokument abzurufen, könntest du die Verwendung eines JSON-Zeigers erwägen, aber der Code ist kaum einfacher als die Verwendung des Baummodells, also mache ich das nie.

Für komplexere Abfragen und insbesondere, wenn dein JSON-Parsing Teil einer größeren Anwendung ist, empfehle ich Datenbindung. Auf lange Sicht ist dies normalerweise am einfachsten, wenn man bedenkt, dass JsonSchema2Pojo den größten Teil der Arbeit für dich erledigen kann.

Wie arbeitest du am liebsten mit JSON in Java? Lass es mich auf Twitter wissen @ MaximumGilliardoder per E-Mail: ich mgilliard@twilio.com. Ich bin gespannt, von {"deinen": "Entwicklungen"}. zu hören.