Friday, January 25, 2019

Spring Custom Serializers with @JsonIdentityInfo

Intro

Serialization/Deserialization from/to JSON in Spring is widely used in modern Spring-based applications. It is based on Jackson. Jackson can serialize any POJO into JSON and vice versa with ease. This code is well written. I never encountered any issues. It gets more difficult when custom serializers are involved. This post shows how to use custom serializers in Spring with autowired fields.

Defining a Custom Serializer

Usually a custom serializer for a class is inherited from com.fasterxml.jackson.databind.ser.std.StdSerializer. This class defines some constructors but the framework only need a no-argument constructor that should call the superclass, something like this:

public CustomSerializer() {
    this(null);
}

public CustomSerializer(Class<ObjectToSerializet) {
    super(t);
}

Then there is the main method that must be implemented to actually write the JSON:

@Override
public void serialize(ObjectToSerialize value, JsonGenerator gen, SerializerProvider providerthrows IOException {
    gen.writeStartObject();
    ...
    provider.defaultSerializeField("some field"value.getField(), gen);
    ...
    gen.writeEndObject();
}

When the serializer class is created it must be registered as the serializer for ObjectToSerialize. This can be done with the @JsonSerialize annotation on the class:

@JsonSerialize(using = CustomSerializer.class)
public class ObjectToSerialize {

Now Jackson will be using this custom serializer for all instances of this class. If necessary a custom deserializer can be written by subclassing com.fasterxml.jackson.databind.deser.std.StdDeserializer<T>

Circular References and @JsonIdentityInfo

For most commercial applications with Spring and Hibernate the issue of circular references manifests itself sooner or later. Here is a simple example. 
We have 2 classes:

public class Building {

    @Id
    @GeneratedValue(<parameters>)
    private Long id;

    private Set<Apartment> apartments;
}

public class Apartment {

    @Id
    @GeneratedValue(<parameters>)
    private Long id;

    private Building building;
}

If we try to serialize one building that has at least one apartment we get a StackOverflowException.

Jackson has a solution to this problem - @JsonIdentityInfo.

If the annotation @JsonIdentityInfo is added to the class like this:

@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
public class ObjectToSerialize {

then any ObjectMapper will break the cycle by replacing every occurrence of the object except the first with its id. Like this:

{
    "id": 1,
    "apartments": [
        {
            "id": 2,
            "building": 1 - the object is replaced with its ID
        },
        {
            "id": 3,
            "building": 1 - the object is replaced with its ID
        }
    ]
}

These are the tools Jackson provides to customize serialization and deal with circular references.

JSON Structure Problem


Problem

@JsonIdentityInfo works well for simple applications. But as the application grows in the default form it could affect the structure of the JSON. For example if some method returns the buildings and the districts in one response here is what may occur:
{
    "buildings": [
        {
            "id": 1,
            "apartments": [
                {
                    "id": 2,
                    "building": 1 - the object is replaced with its ID
                },
                {
                    "id": 3,
                    "building": 1 - the object is replaced with its ID
                }
            ]
        }
    ],
    "districts": [
         {
             "buildings": [
                 {
                     "id": 5,
                     ...
                 },
                 1, - the object is replaced with its ID
                 {
                     "id": 6,
                     ...
                 }
             ]
         }
    ]
}

This replacement could be quite unpredictable from the parser's point of view. Within an array it could encounter objects and IDs. And this could happen for any field and any object. Any object where the class is annotated with @JsonIdentityInfo is replaced with its ID if the serialization provider finds it more than once. Every second, third, fourth etc. instance with the same ID found by the serialization provider is replaced with its ID. 

Solution


The solution here is to use a separate ObjectMapper to write parts of the JSON. The lists of already seen IDs are stored in the serialization provider which is created by ObjectMapper. By creating a separate ObjectMapper (with a probably different configuration) the lists are reset. 
For a "composite" JSON result which returns different objects types a custom serializer can be written. In this custom serializer the "header" is written manually with JsonGenerator methods and when the correct level in the JSON is reached we create a new ObjectMapper and write a much better looking JSON.

{
    "buildings": [ - create a new ObjectMapper
        {
            "id": 1,
            "apartments": [
                {
                    "id": 2,
                    "building": 1 - the object is replaced with its ID
                },
                {
                    "id": 3,
                    "building": 1 - the object is replaced with its ID
                }
            ]
        }
    ],
    "districts": [ - create a new ObjectMapper
         {
             "buildings": [
                 {
                     "id": 5,
                     ...
                 },
                 { - the object is written as a JSON Object not an ID
                     "id": 1,
                     ...
                 },
                 {
                     "id": 6,
                     ...
                 }
             ]
         }
    ]
}

To write the JSON to the original generator we can use ObjectMapper.writeValueAsString and JsonGenerator.writeRawValue(String)

P.S. it is also possible to create a new serialization provider by means of DefaultSerializerProvider.createInstance(SerializationConfig, SerializerFactory) but it is potentially more complicated. 

Custom Serializer Autowire Problem


Problem

We'd like to be able to use @Autowire in our custom serializers. It is one of Spring's best features! Actually it works if the default ObjectMapper is used. But if we use the solution to the JSON structure problem it doesn't work for custom serializers instantiated by our own object mappers. 

Solution

Our own object mappers must be configured with a special HandlerInstantiator:

// try to use the default configuration as much as possible
ObjectMapper om = Jackson2ObjectMapperBuilder.json().build();
// This instantiator will handle autowiring properties into the custom serializers
om.setHandlerInstantiator(
new SpringHandlerInstantiator(this.applicationContext.getAutowireCapableBeanFactory()));

If the custom object mappers are created inside another custom serializer which is created by the default ObjectMapper then it can autowire the ApplicationContext.

No comments:

Post a Comment