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.
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 Date
Instanz 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!