Jackson am Telefon

„Ich habe mir immer gewünscht, dass mein Computer so leicht zu bedienen ist wie mein Telefon; mein Wunsch ging in Erfüllung: Mein Telefon kann ich jetzt auch nicht mehr bedienen.“

Bjarne Stroustrup

Immer wieder kommt es vor, dass man im eigenen Spring Boot REST-Controller Klassen verwenden möchte, die nicht dafür konstruiert wurden. In der Regel trifft dies auf Klassen zu, die aus Dritt-Bibliotheken stammen.

Für die Vereinsverwaltung der Ahnenforscher sollen die Mitglieder mit ihrer Telefonnummer dargestellt werden. Dafür wird in der JSON Response ein Attribut phone benötigt, dass die Telefonnummer als Text enthält.

{
  "firstname": "Jens",
  "lastname": "Kaiser",
  "phone": "+49 521 112233",
  "member-id": "A-0001"
}

In der Mitgliederverwaltung existiert das folgende DTO Member.

@Getter
@Setter
public class Member {
  @NotBlank
  private String firstname; 
  @NotBlank
  private String lastname;
  @NotNull @GermanCountryCode
  private InternationalPhoneNumber phone; 
  @NotNull @MemberId
  private String memberId;
  @NotNull @Past
  private LocalDate dayOfBirth;
}

In der Klasse ist das Attribute phone vom Typ InternationalPhoneNumber aus der Telephone Bibliothek. Diese Klasse wird aber leider nicht, wie gewünscht, in der JSON Antwort dargestellt.

Unter der Haube von Spring Boot arbeitet die Bibliothek Jackson, die für solche Fälle Serialisierer und Deserialisierer zur Verfügung stellt.

Der Serialisierer schreibt den Wert in die JSON Ausgabe und kann durch die Erweiterung der Klasse StdSerializer implementiert werden.

final lass InternationalPhoneNumberSimpleSerializer extends StdSerializer<InternationalPhoneNumber> {

  InternationalPhoneNumberSimpleSerializer() {
    super(InternationalPhoneNumber.class);
  }

  @Override
  public void serialize(InternationalPhoneNumber value, JsonGenerator gen, SerializerProvider serializers)
      throws IOException {
    gen.writeString(value.toString());
  }
}

Die Implementierung des InternationalPhoneNumberSimpleSerializer ist sehr einfach, weil die toString() Methode der InternationalPhoneNumber schon die korrekte Ausgabe produziert.

final class InternationalPhoneNumberSimpleDeserializer extends StdDeserializer<InternationalPhoneNumber> {

  InternationalPhoneNumberSimpleDeserializer() {
    super(InternationalPhoneNumber.class);
  }

  @Override
  public InternationalPhoneNumber deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
    try {
      return InternationalPhoneNumber.parse(p.getValueAsString());
    } catch (IllegalArgumentException e) {
      return (InternationalPhoneNumber) ctxt.handleWeirdStringValue(_valueClass, p.getValueAsString(),
          "not a valid representation (error: %s)", ClassUtil.exceptionMessage(e));
    }
  }
}

Die InternationalPhoneNumberSimpleDeserializer Implementierung ist etwas komplizierter, da hier mögliche Fehler beim Auswerten der Textdarstellung abgefangen werden muss. Fehler, die beim Parsen der Telefonnummer auftreten, werden durch die Methode handleWeirdStringValue zu einer Jackson spezifischen Fehlermeldung konvertiert.

Beide Implementierungen müssen im Jackson ObjectMapper bekannt gemacht werden. Eine schöne Möglichkeit die Konfigurationen zusammenzuhalten, ist das Jackson Modul Konzept. Die Konfiguration wird in der setupModule() Methode des eigenen TelephoneModuls durchgeführt und das Modul seinerseits über die registerModule() Methode am ObjectMapper registriert.

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

Die Implementierung des TelephoneModule fügt jeweils eine Instanz des InternationalPhoneNumberSimpleSerializer und InternationalPhoneNumberSimpleDeserializer in den SetupContext ein.

public class TelephoneModule extends SimpleModule {

    @Override
    public void setupModule(SetupContext context) {
        setUpModules(context, new InternationalPhoneNumberSimpleSerializer(), new InternationalPhoneNumberSimpleDeserializer());
    }

    private void setUpModules(SetupContext context, JsonSerializer<?> ser, JsonDeserializer<?> deser) {
        context.addSerializers(new SimpleSerializers(List.of(ser)));
        context.addDeserializers(new SimpleDeserializers(Map.of(InternationalPhoneNumber.class, deser)));
    }
}

Um das TelephoneModule in der eigenen Spring Boot Anwendung zu nutzen, fehlt nur noch die Anpassung des dort verwendeten ObjectMappers.

@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
    return builder -> builder.modulesToInstall(new TelephoneModule());
}

Es sollte darauf geachtet werden modulesInstall() und nicht modules() zu verwenden. Ansonsten werden alle standardmäßig konfigurierten Module überschrieben.

Damit ist eine erste Version der Jackson Unterstützung für InternationalPhoneNumber implementiert. Im nächsten Beitrag wird alles noch ein bisschen komfortabler.

Schreibe einen Kommentar