Share this page to your:
Mastodon

Jackson is what every Java programmer seems to use these days to serialize and deserialize json files. It's generally easy and obvious to use. But I found a tricky case worth documenting. Here's the situation: I have a class that contains a Map that, of course, has a key. But this key has generics. Instead of just Map<String, String> it is Map<Range,String> and using this class with Jackson is not so obvious. This is partly because generics are always a problem for systems that depend on reflection. Reflection doesn't always get you the generics, so Jackson can miss this and handle it wrong. You have to give Jackson a little more information. This is how. First, here's my class:

 public class ClassWithAMap {

    private Map<Range<Instant>, String> map;

    @JsonCreator
    public ClassWithAMap(
            @JsonProperty("map")
            @JsonDeserialize(keyUsing = RangeDeserializer.class)
            @JsonSerialize(keyUsing = RangeSerializer.class)
            Map<Range<Instant>, String> map) {
        this.map = map;
    }

    public Map<Range<Instant>, String> getMap() {
        return map;
    }

    public void setMap(Map<Range<Instant>, String> map) {
        this.map = map;
    }
  }

The special bit is in that constructor. It has annotations on its arguments specifying the serializer and deserializer. This tells Jackson to delegate those oprations to the named classes. I found various other ways to annotate the class, for example on the properties. But those didn't work for me (on Jackson 2.9.9) and I had to resort to StackOverflow to get the right answer.

Next I had to specify the serializer and deserializer. They're fairly simple:

public class RangeDeserializer extends KeyDeserializer {

    @Override
    public Range<Instant> deserializeKey(String key, DeserializationContext ctxt)
            throws IOException, JsonProcessingException {
        TypeReference<Range<Instant>> typeRef = new TypeReference<Range<Instant>>() {
        };
        Range<Instant> range = objectMapper().readValue(key, typeRef);
        return range;
    }
}

The usual way to call readValue is to just supply the class and Jackson takes care of the read. When there are generics involved you need that TypeReference which can specify the generics.

public class RangeSerializer extends JsonSerializer<Range<Instant>> {

    @Override
    public void serialize(Range<Instant> value, 
      JsonGenerator gen,
      SerializerProvider serializers) 
      throws IOException, JsonProcessingException {

        StringWriter writer = new StringWriter();
        objectMapper().writeValue(writer, value);
        gen.writeFieldName(writer.toString());
    }

}

This is the serializer and there is not much to it, but if you leave it out Jackson seems to call .toString() on the key to get the serialised value, and in this case that is wrong hence the need for the custom serialiser.

Now you can happily write code like this:

        Map<Range<Instant>,String> map = new HashMap<>();
        Range<Instant> key = Range.greaterThan(Instant.now());
        map.put(key, "some value");
        ClassWithAMap classWithAMap = new ClassWithAMap(map);

        String jsonInput = objectMapper()
                .writerWithDefaultPrettyPrinter()
                .writeValueAsString(classWithAMap);

        ClassWithAMap classWithMap = objectMapper()
                .readValue(jsonInput,
                ClassWithAMap.class);

But there's one more thing here. Range is in the Google Collections library and it needs some special configuration in the object mapper, as does Instant. This is what works:

    public ObjectMapper objectMapper() {
        return new ObjectMapper()
            .registerModules(
                new JavaTimeModule(),
                new Jdk8Module(),
                new GuavaModule());
    }

Previous Post Next Post