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
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());
}