Dr. REST oder: Wie ich lernte Jackson zu lieben

Wird eine neue Software Technologie eingesetzt, dann taumeln die beteiligten Entwickler von anfänglicher Euphorie schnell in tiefste Depression. Die Entwickler sind keinem Borderline Syndrom erlegen, aber häufig lässt man sich, in der Hoffnung auf Neues, von einigen Buzz-Words täuschen. Vor einigen Jahren bekam unser Team eine REST API vorgelegt, die unser Kunde gerne angeschlossen haben wollte.

Es war eine für die damalige Zeit typische API, ein paar flott benannte URLs und einige JSON Strukturbeschreibungen. Leider hatten die Autoren sich keine großen Gedanken über REST gemacht, sondern ein altes prioritäres Transportformat durch HTTP und JSON ersetzt.

Die API hätte mit einfachen Mitteln geändert werden können, aber hier folgte man im Management einem klassischen Sprichwort aus dem Projektbereich.

“He who pays the piper calls the tune!”

Glücklicherweise fand unser Team einen guten Freund in der Jackson Bibliothek, die uns an vielen Stellen half, die Kundenwünsche zu erfüllen.

Manche der hier aufgeführten Features müssen erst in Jackson aktiviert werden. Dazu kann dies programmatisch am ObjectMapper erfolgen oder beim Einsatz von Spring Boot über eine Property.

ObjectMapper mapper = new ObjectMapper();
mapper.enable(SerializationFeature.WRAP_ROOT_VALUE);

Hier wird das Feature Wrap Root Value direkt am ObjectMapper aktiviert. Im weiteren Beitrag wird der Einfachheit halber nur die entsprechende Spring Boot Property genannt.

Unpassende und uneindeutige Name

Ein Problem bei entworfenen APIs ist die geringe Rücksichtnahme auf die Implementierungssprache. Nur weil eine Syntax für Namen möglich ist, ist sie nicht immer angeraten. Beliebt ist es zusammengesetzte Namen durch Bindestriche zu verbinden (Kebab-Case), durch Unterstriche (Snake-Case), oder in Camel-Case oder Uppercase zu verwenden. Während Snake-Case und Uppercase nur ein Ärgernis darstellen, weil die Java Attribute nicht den üblichen Coding Guides entsprechend geschrieben werden können, sind Java Variablenname mit Bindestrich unmöglich.

{ "FIRSTNAME": "Jens", "last-name": "Kaiser" }

Das obige Beispiel ist ohne besondere Hilfe nicht direkt auf Java Attribute zu mappen, aber mit Hilfe der Annotation @JsonProperty kann das Problem umgangen werden.

@JsonProperty("FIRSTNAME")
private String firstName;

@JsonProperty("last-name")
private String lastName;

Die Annotation steht an dem Attribut auf das gemappt werden soll und enthält den Namen des Attributes im JSON Konstrukt.

Um Probleme zu vermeiden sollten grundsätzlich alle JSON Attribute in Kleinbuchstaben ohne Sonderzeichen geschrieben werden. Nichts ist ärgerlicher als die stundenlanges Debuggen eines Fehlers in der Produktion wegen einem uneindeutigen Camel-Case Schreibweise OelGasTurbinenVentilSteuerungsSensor und OelGasTurbinenVentilsteuerungsSensor.

Ein weiteres Ärgernis ist es, wenn die API für unterschiedliche Aufrufe mit strukturell identischen Daten andere Namen verwendet. Solche Inkonsitenzen können die Menge der Mapping Klassen erhöhen und die Schnittstellenimplementierung unübersichtlich werden lassen. Unterscheiden sich nur die Namen der Attribute, dann kann dies durch die Annotation @JsonAlias umgangen werden.

@JsonAlias({ "FIRSTNAME", "firstname", "vorname" })
private String firstName; 

Die Annotation enthält eine Reihe von alternativen Namen für das Attribute. Im Beispiel ist das Attribute also mit den Namen firstName, FIRSTNAME, firstname und vorname verwendbar.

Unbekannte und unsichere Attribute

Bei der Entwicklung neuer Schnittstellen kann niemand sicher sein, was auf der anderen Seite alles in die JSON Anfragen und Antworten gesteckt wird. Bei bislang unbekannten Attributen kommt es dann zu Fehlern, weil die Mapper auf solche Überraschungen allergisch reagieren.

@JsonIgnoreProperties(ignoreUnknown=true)

Die Annotation @JsonIgnoreProperties erlaubt es für jede Klasse zu entscheiden, ob unbekannte Attribute ignoriert werden können. Wird Spring Boot verwendet, dann kann dies auch für alle Klassen über eine Property geregelt werden.

spring.jackson.deserialization.fail-on-unknown-properties=false

Nicht immer reicht es aus, die unbekannten Attribute zu ignorieren. Werden die Werte in irgendeiner Weise in der Weiterverarbeitung benötigt, hält Jackon ein Alternative bereit. Die Annotationen @JsonAnyGetter und @JsonAnySetter behandeln die Inhalte einer Map wie normale Attribute. Dabei entsprechen die Schlüssel in der Map den Namen der Attribute im Json.

class Tree {
  private String name;
  private Map<String, Object> properties;

  @JsonAnyGetter
  public Map<String, Object> getProperties() {
    return properties;
  }

  @JsonAnySetter
  public void add(String key, Objectvalue) {
    properties.put(key, value);
  }
}

Für die hier dargestellte Klasse Tree werden alle unbekannten Attribute in der Map properties gespeichert. Das folgende JSON Beispiel erzeugt einen Map Eintrag mit dem Schlüssel archived und dem Wert Boolean.TRUE und einen Eintrag mit dem Schlüssel author und als Wert eine weitere Map mit den Schlüsseln name und level.

{ 
  "name": "Familie Kaiser", 
  "archived": true, 
  "author" : {
    "name": "Jens Kaiser",
    "level": 42
  }
}

Unnötige Json Wrapper

In manchen Projekten wird es sehr geschätzt, wenn die tatsächlichen Daten-Objekte noch einmal in einem Wrapper Objekt verpackt sind.

{
  "tree": {
    "name": "Familie Kaiser", 
  }
}

Es ist natürlich ärgerlich ein spezielles Wrapper Objekt bereitzuhalten um eine solche API zu bedienen. Damit die eigenen Datenobjekt nicht vor jeder Ausgabe in ein weiteres Objekt gesteckt werden müssen, stellt Jackson die Annotation @JsonRootName bereit. Die folgende Klassendefinition erzeugt das gewünschte Format.

@JsonRootName("tree")
public class Tree{
  public String name;
}

Damit dies aber tatsächlich funktioniert muss noch das entsprechende SerializationFeature und DeserializationFeature aktiviert werden.

spring.jackson.deserialization.unwrap-root-value=true
spring.jackson.serialization.wrap-root-value=true

Unbekannte und unsichere Werte

Enthält ein Attribut nur eine begrenzte Anzahl unterschiedlicher String Werte, dann bietet es sich auf der Java Seite an, dieses Attribut als Enum zu definieren. Dabei treten immer wieder zwei Probleme auf. Es tauchen unbekannte Werte auf oder die Schreibweise bekannter Namen ändert sich.

Wenn unbekannte Werte nicht die Verarbeitung behindern sollen, kann ein solcher Wert entweder auf null oder einen Standardwert gemappt werden. Im fogenden Beispiel wird als Standardwert die Enum Konstante UNKNOWN verwendet. Dafür wird die Konstante mit der Annotation @JsonEnumDefaultValue annotiert.

public enum LifeEvent {
  @JsonEnumDefaultValue
  UNKNOWN,
  BIRTH,
  BAPTISM,
  MARRIAGE,
  DEATH
}

Auch dieses Feature muss expliziet aktiviert werden.

spring.jackson.deserialization.read-unknown-enum-values-using-default-value=true

Das hier definierte Enum LifeEvent setzt voraus, dass die Attribute im JSON die Werte BIRTH, BAPTISM, MARRIAGE, DEATH in genau dieser Schreibweise enthalten. Ändert sich die Schreibweise der Werte, dann müssen die Enum Konstanten umbenannt werden und damit Teile der Anwendung refaktoriert werden. Bei einer Änderung der Werte auf die Schreibweise le-birth, le-baptism, le-marriage, le-death ist dies nicht mehr möglich, denn Namen in Java können keinen Bindestrich enthalten.

public enum LifeEvent {
  @JsonEnumDefaultValue
  UNKNOWN,
  BIRTH,
  BAPTISM,
  MARRIAGE,
  DEATH

  @JsonValue
  public String getName() {
    return "le-" + name().getLowercase();
 }
}

Hier kann mit der Annotation @JsonValue eine Methode annotiert werden, die den geänderten JSON Namen für die Enum Konstante liefert. Im obigen Beispiel wird für alle Konstanten mit der Methode getName.

Manche REST APIs werden sehr sparsam entworfen und dann soll ein Attribut entweder eine Array oder eine einzelne Instanz enthalten. Was auf der JSON Seite nur das Fehlen von zwei eckigen Klammern ist, stellt auf der Java Seite ein unlösbares Problem da. Entweder ist das Attribute vom Type Liste oder vom Typ der Listenelemente.

Glücklicherweise kann der Deserialisierer von Jackson mit diesem Problem umgehen.

spring.jackson.deserialization.accept-single-value-as-array=true

Wurde das Feature aktiviert, dann wird ein Java Attribute vom Typ List, entweder mit dem angegebenen JSON Array oder mit dem einzelnen Element gefüllt.

Manchmal sind nicht die Werte unsicher oder unbekannt, sondern der Umgang mit Standards. Obwohl die ISO-8601 Formate mittlerweile überall Einzug gehalten haben sollten, finden sich in digitalen Austauschformaten noch immer Klassiker, die ihre Lebenberechtigung einem Bestellformular aus der Gründerzeit oder einem Anforderungsschein der kaiserlichen Kriegsmarine verdanken.

Für solche Exoten steht die Annotation @JsonFormat bereit, die attributspezifische Konvertierungen ermöglicht.

@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd.MM.yyyy hh:mm")
public Date eventDate;

Dieses Attribute verwendet das Pattern "dd.MM.yyy hh:mm" um Zeitpunkte zu kodieren. Damit können Werte wie etwa "24.08.2020 12:00" in eine DateInstanz konvertiert werden.

Json aus der Datenbank

Wurden JSON Inhalte in einer Datenbank gespeichert, dann müssen sie irgendwann auch wieder hervorgeholt werden. Als Teil einer größeren Json Struktur werden sie dann häufig aus der Textdarstellung in eine Klassen Instanz umgewandelt und in eine Instanz einer anderen Klasse eingefügt. Danach wird dieses Objekt als REST Antwort wieder in einen String umgewandelt und verschickt.

Wenn keinerlei Verarbeitung notwendig ist, weil das gespeicherte Json nur ausgeliefert werden soll, bietet Jackson die Annotation @JsonRawValue an.

@JsonRawValue
public String configuration;

Mit dieser Annotation versehene String Attribute geben ihren Inhalt nicht wie gewohnt in doppelten Anführungszeichen aus, sondern der Ihalt wird direkt in die Ausgabe eingefügt.

configuration = "{ \"label\": \"name\", \"limits\": { \"min\": 23, \"max\": 42}}";

Eine JSON Ausgabe für das obige Attribute configuration liefert also nicht die Ausgabe als String

"configuration": "{ \"label\": \"name\", \"limits\": { \"min\": 23, \"max\": 42}}"

Sondern die strukturierte Form

"configuration": { 
  "label": "name", 
  "limits": { 
    "min": 23, 
    "max": 42
  }
}

Unstrukturiertes Json

Manche JSON Ein- und Ausgangsformate könnten mehr Struktur aufweisen. Alles was es zu wissen gilt, wurde vom Entwickler in einer einzigen Ebene zusammengestellt. Bei einem Mapping muss ein entsprechende POJO alle genutzten Attribute enthalten. Aus objektorientierter Sicht nicht wirklich schön.

Im folgenden Beispiel liegen alle Daten zu einem Angestellten auf einer Ebene.

{
  "firstname": "Spongebob",
  "lastname": "Squarepants",
  "number": "124",
  "street": "Conch Street",
  "city": "Bikini Bottom",
  "job": "fry cook"
}

Auf Java Seite soll jedoch die Adressangabe in einer eigenen Klasse Address gespeichert werden.

@Data
public class Employee {
  private  String firstname;
  private String lastname;
  
  @JsonUnwrapped
  private Address address;
  
  private String job;
}

Damit dies automatisch geschieht, stellt Jackson die Annotation @JsonUnwrapped zur Verfügung. Sie fügt bei der Ausgabe die Attribute von Address in Employee ein und extrahiert diese bei der Eingabe, um sie in eine Instanz von Address zu speichern.

Ein weiterer kleiner Helfer für den Umgang mit unstrukturierten, genauer unorganisierten JSON Daten ist die Annotation @JsonPropertyOrder. Üblicherweise ist es völlg egal und sollte es auch völlig egal sein, in welcher Reihenfolge die Attribute in den JSON Daten vorliegen. Es existieren aber noch viele Legacy Systeme aus den Anfängen der JSON Verarbeitung, die mit selbstgeschriebenen Code die Daten aus dem JSON Format extrahierten. Dabei war es dann einfacher, wenn eine gewisse Ordnung in den Daten existierte. Um diese Ordnung bei der Ausgabe zu erhalten existiert die Annotation @JsonPropertyOrder.

@JsonPropertyOrder({"title", "firstname", "lastname", "birth", "death"})

In diesem Beispiel sorgt die Annotation dafür, dass die Attribute title, firstname, lastname, birth und death in genau dieser Reihenfolge in der Ausgabe erscheinen. Für zwanghafte Entwickler existiert außerdem die Variante @JsonPropertyOrder(alphabetic=true).

Dies war ein kurzer Ausblick auf eine Reihe von Jackson Annotationen, die in der Vergangenheit viele Arbeiten in ausweglos erscheinenden Projekten vereinfacht haben. Neben diesen existieren noch weitere, mächtigere Möglichkeiten in Jackson um selbst die abenteuerlichsten JSON Formate zu beherrschen.

Aber bei all den Möglichkeiten sollte nie vergessen werden, die beste Art mit problematischen JSON umzugehen, ist immer noch das Bereinigen und Vereinfachen der Schnittstelle! Egal wer die Musik bezahlt!