DEV Community

yangbongsoo
yangbongsoo

Posted on

Deep dive into ObjectMapper

1. serialize/deserialize issue

When operating on an EDA (Event-Driven Architecture) basis, particular attention must be paid to the serialization/deserialization of messages. This is because the systems are distributed.

For example, consider the scenario where System1, as depicted below, modifies a message-related object. System1 is responsible for serializing and publishing the message. However, other organizational systems like System2 and System3 might have different deployment timings. If the changes are not reflected, mismatches in consumption rules can lead to failures. Therefore, it is crucial to manage the ObjectMapper effectively.

Image description

1-1. FAIL_ON_UNKNOWN_PROPERTIES

The FAIL_ON_UNKNOWN_PROPERTIES option always requires careful attention.

The FAIL_ON_UNKNOWN_PROPERTIES option is an attribute that determines whether deserialization should fail when there is no matching field (unknown property) during deserialization.
cf) When a new field is added to an object for serialization and deployed, but the consuming systems (where the object is deserialized) do not have that field reflected.

If this attribute is set to true, a JsonMappingException exception occurs, leading to a failure. To prevent this, it should be set to false (to ignore unknown properties) or the object being deserialized should be additionally annotated with @JsonIgnoreProperties.

1-2. Spring Boot FAIL_ON_UNKNOWN_PROPERTIES

When using Spring Boot, the FAIL_ON_UNKNOWN_PROPERTIES option is adjusted to FALSE by default in Jackson2ObjectMapperFactoryBean.

However, if a new ObjectMapper is created directly, it does not go through Jackson2ObjectMapperFactoryBean, thus FAIL_ON_UNKNOWN_PROPERTIES remains true (the default value).

Therefore, it needs to be disabled as shown in the code below.

private static ObjectMapper objectMapper() {
    ObjectMapper objectMapper = JsonMapper.builder()
        .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
        .build()
}
Enter fullscreen mode Exit fullscreen mode

1-3. codehaus and fasterxml

There are services that mix the use of org.codehaus.jackson(Jackson 1.x) and com.fasterxml.jackson(Jackson 2.x). Given the service has a long history, it has multiple ObjectMappers. Therefore, although there's a desire to add options to the ObjectMapper, predicting the scope of impact is challenging, making modifications difficult. In other words, there is a significant risk when ObjectMapper management is neglected. If predicting the scope of impact becomes difficult upon changing ObjectMapper settings, it's safer to add the @JsonIgnoreProperties(ignoreUnknown = true) annotation to all classes.

In practice, there have been instances where problems occurred. Despite importing com.fasterxml.jackson.annotation.JsonIgnoreProperties and adding the @JsonIgnoreProperties annotation, the ObjectMapper used for deserialization was from codehaus. Consequently, a Could not read JSON: Unrecognized field error occurred. Mistakes during import can lead to issues, and there's a high chance that these might be overlooked even during PR reviews. In cases where services mix libraries, it's advisable to standardize on codehaus since Jackson 2 also supports it, making it a safe choice. Later, when phasing out Jackson 1, it's better to switch to fasterxml in a unified manner.


2. Understanding the Principles of Deserialization

If Jackson decides to deserialize into the Sample class type, it first looks for a no-argument constructor in the Sample class. If one exists, it uses that constructor to create a Sample instance and then looks for setters. It then checks for setters with the same name as the keys in the JSON string and injects the values accordingly.

public static class Sample {
    private String name;
    private String address;

    public Sample() { }

    public void setName(String name) {
        this.name = name;
    }

    public void setAddress(String address) {
        this.address = address;
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's look at the other case. JSON delivers data such as name, address, and gender as follows

name : "ybs",
address : "seoul",
gender : "male"
Enter fullscreen mode Exit fullscreen mode

but in the Sample object being deserialized into, there is no setter for the gender field. In this case, the name and address are injected through their setters, and since there is no setter for gender, it checks for a field with the same name. If found, it injects the value using reflection. If not found, it either ignores it or throws an exception, depending on the FAIL_ON_UNKNOWN_PROPERTIES setting.

// 1. setter
// 2. reflection
public static class Sample {
    private String name;
    private String address;
    private String gender;

    public Sample() { }

    public void setName(String name) {
        this.name = name;
    }

    public void setAddress(String address) {
        this.address = address;
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, if there is an all-argument constructor, it injects the values through this constructor. However, simply adding the constructor is not enough; it must be indicated with @JsonCreator and @JsonProperty annotations.

// 1. constructor
// 2. setter
// 3. reflection
public static class Sample {
    private String name;
    private String address;
    private String gender;

    @JsonCreator
    public Sample(
        @JsonProperty("name") String name,
        @JsonProperty("address") String address,
        @JsonProperty("gender") String gender
    ) {
        this.name = name;
        this.address = address;
        this.gender = gender;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setAddress(String address) {
        this.address = address;
    }
}
Enter fullscreen mode Exit fullscreen mode

However, since the above method can be cumbersome, there is an alternative approach that involves using the @ConstructorProperties annotation, as follows.

public static class Sample {
    private final String name;
    private final String address;
    private final String gender;

    @ConstructorProperties({"name", "address", "gender"})
    public Sample(String name, String address, String gender) {
        this.name = name;
        this.address = address;
        this.gender = gender;
    }
}
Enter fullscreen mode Exit fullscreen mode

How does deserialization work with inheritance? First, it injects values through the constructor, but there's no field for the parent. That means it doesn't get set through the constructor. Next, it looks for a setter, but if there's no setter available, finally, it resorts to reflection to inject the field, since the field exists

// 1. constructor
// 2. setter
// 3. reflection
public static abstract class Parent {
    private String parent;
}

public static class Sample extends Parent {

    @ConstructorProperties({"name", "address"})
    public Sample(String name, String address) {
        this.name = name;
        this.address = address;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setAddress(String address) {
        this.address = address;
    }    
}
Enter fullscreen mode Exit fullscreen mode

However, since using setters is preferable to reflection, setters can be created as follows

public static abstract class Parent {
    private String parent;

    void setParent(String parent) {
        this.parent = parent;
    }
}
Enter fullscreen mode Exit fullscreen mode

For Collection types, there's an additional step (getter). As in the code below, there might not be a setter, but a getter could exist. If it's a Collection type, Jackson calls the getter. If the list returned by the getter is null, it proceeds to the next step. However, if the list is not null and a list is returned, Jackson adds to this list.

// 1. constructor
// 2. setter
// 3. getter !!
// 4. reflection
public static class Sample {
    private List<String> samples = new ArrayList<>();

    public List<String> getSamples() {
        return samples;
    }
}
Enter fullscreen mode Exit fullscreen mode

There's a caveat to be aware of. You should not use the List.of() operator as shown below because it results in ImmutableCollections.emptyList(), which is unmodifiable. Therefore, an error occurs the moment Jackson attempts to add to it.

public static class Sample {
    private List<String> samples = List.of();

    public List<String> getSamples() {
        return samples;
    }
}
Enter fullscreen mode Exit fullscreen mode

Similarly, if the getter wraps the list with unmodifiableList, an error occurs the moment Jackson attempts to add to it. Therefore, as a solution, one should either handle it with @JsonIgnore or avoid using unmodifiableList.

public static class Sample {
    private List<String> samples = new ArrayList<>();

    // Add @JsonIgnore or remove unmodifiableList()
    public List<String> getSamples() {
        return Collections.unmodifiableList(samples);
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Deserialization with polymorphism

When you specify the necessary concrete class (Sample) while using readValue with ObjectMapper, it automatically deserializes into the Sample type.

Sample sample = OBJECT_MAPPER.readValue(json, Sample.class);
Enter fullscreen mode Exit fullscreen mode

When you need to deserialize using an interface, it can occur when the framework automatically handles the deserialization for you. For example, consider the scenario where there are AEvent, BEvent, and CEvent classes that implement the Event interface. The raiseEvent method receives an argument of the Event type, but you want it to be deserialized into the appropriate concrete class based on the situation.

public static interface Event {
  ...
}

public static class AEvent implements Event { ... }
public static class BEvent implements Event { ... }
public static class CEvent implements Event { ... }

... 

protected void raiseEvent(Event event) {
    delegate.raiseEvent(event);
}
Enter fullscreen mode Exit fullscreen mode

Of course, we can create a class capable of holding all messages and deserialize into it. However, this approach requires understanding which fields are used and which are not, making it challenging to manage. The prevalence of nullable cases makes it convenient initially but difficult to maintain over time.

To deserialize into the appropriate concrete class even when dealing with an interface type, there needs to be something that can identify the correct class. This is where @JsonTypeInfo comes in handy. By attaching this annotation, the type information is embedded during serialization, indicating which class to deserialize into.

@JsonTypeInfo(use = Id.NAME, include = As.WRAPPER_OBJECT)
private List<Event> events = new ArrayList<>();
Enter fullscreen mode Exit fullscreen mode

In other words, when serializing an interface type, the type name is also serialized alongside the data to ensure that the type can be identified upon deserialization. This is done by embedding an id value, which is used to select the correct class to deserialize into. If the strategy is WRAPPER_OBJECT, the data is wrapped, resulting in an additional layer of depth. This can be observed in the JSON structure, where, for example, AEvent would be used as a key, and the actual data would be nested within an additional layer beneath it.

{
  "events":
    [
      {
        "AEvent":{"sourceVersion":100,"id":1}
      }

        ...
    ]
}
Enter fullscreen mode Exit fullscreen mode

Changing @JsonTypeInfo settings requires caution, as errors can occur if the deserialization side is not properly aligned. Since Id.NAME is used with @JsonTypeInfo, serialization passes only the class name, so changes in the package do not affect it. However, if Id.CLASS is used, it serializes the fully-qualified Java class name, including the package name, so any change in the package affects deserialization.

{
  "events":
    [
      {
        "com.toy.AEvent":{"sourceVersion":100,"id":1}
      }

        ...
    ]
}
Enter fullscreen mode Exit fullscreen mode

And if PROPERTY is used instead of WRAPPER_OBJECT, the type information is passed using the @type key."

{
  "events":
    [
      {
        "@type":"AEvent"
        "sourceVersion":100,
        "id":1
      }

        ...
    ]
}
Enter fullscreen mode Exit fullscreen mode

Next, the @JsonSubTypes annotation, which matches @JsonTypeInfo, is specified separately on the setter. This registers the mapping information to determine the restoration type based on the serialized name when deserializing an interface type.

@JsonSubTypes({
    @Type(value = AEvent.class, name = "AEvent"),
    @Type(value = BEvent.class, name = "BEvent"),
    @Type(value = CEvent.class, name = "CEvent"),
})
protected void setEvents(List<Event> Events) {
    this.Events = Events;
}
Enter fullscreen mode Exit fullscreen mode

cf) Caution: When adding a new Event type, it must be added here to ensure proper deserialization.

If DEvent is created but not added to JsonSubTypes, the following error occurs:

com.fasterxml.jackson.databind.exc.InvalidTypeIdException:
Could not resolve type id 'DEvent' as a subtype of `com.toy.Event`: known type ids = [AEvent, BEvent, CEvent] (for POJO property 'events')
at [Source: (String)"{"events":[{"AEvent":{"type":"AEvent","sourceVersion":100,"id":1}},{"BEvent":{"type":"BEvent","sourceVersion":200,"id":2}},{"CEvent":{"type":"CEvent","sourceVersion":300,"id":3}},{"DEvent":{"type":"DEvent","sourceVersion":400,"id":4}}]}"; line: 1, column: 181] (through reference chain: com.toy.Process["events"]->java.util.ArrayList[3])
Enter fullscreen mode Exit fullscreen mode

4. Other Options in ObjectMapper

First, we disable the ALLOW_FINAL_FIELDS_AS_MUTATORS setting.

.disable(MapperFeature.ALLOW_FINAL_FIELDS_AS_MUTATORS)
Enter fullscreen mode Exit fullscreen mode

"In the code below, the final field 'name' was initialized at the time of declaration, making it immutable thereafter. However, during deserialization, Jackson can use reflection to modify its value. To prevent this, we disable the ALLOW_FINAL_FIELDS_AS_MUTATORS setting.

public static class Sample {
    private final String name = "ybs";
}
Enter fullscreen mode Exit fullscreen mode

We also disable the FAIL_ON_EMPTY_BEANS setting.

.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
Enter fullscreen mode Exit fullscreen mode

When deserializing the Sample class, Jackson basically throws an error if there are no fields defined in the class. At least one field must be present. Therefore, we disable the FAIL_ON_EMPTY_BEANS setting.

public static class Sample {
}
Enter fullscreen mode Exit fullscreen mode

The situation where this option is necessary is as follows: when data of types A, B, and C is received from a Kafka topic, but you want to ignore type C. While filtering can be done at the front end using Kafka header values, there are cases where you need to deserialize the data to determine its type. However, since there's no need to define fields for type C that you won't use, you can create a shell class and skip it when necessary.

Type Cache

The LRUMap is used as a cache for the TypeFactory, allowing Jackson to internally cache information about the types during serialization and deserialization. This means that when searching for a required type, Jackson first checks the cache, retrieves it if found, and uses it.

return objectMapper.setTypeFactory(
    objectMapper.getTypeFactory()
        .withCache((LookupCache<Object, JavaType>)new LRUMap<Object, JavaType>(5120, 5120))
);
Enter fullscreen mode Exit fullscreen mode

The default cache size is 200.

protected TypeFactory(LookupCache<Object,JavaType> typeCache, TypeParser p,
                      TypeModifier[] mods, ClassLoader classLoader)
{
    if (typeCache == null) {
        // initialEntries : 16
        // maxEntries : 200
        typeCache = new LRUMap<>(16, 200);
    }
}
Enter fullscreen mode Exit fullscreen mode

When the cache becomes full, if a new type needs to be added, Jackson internally clears the LRUMap within a synchronized block and starts over from scratch. Since 200 slots can fill up quickly, the cache size was increased to 5120.

package com.fasterxml.jackson.databind.util;

public class LRUMap<K,V>
    implements LookupCache<K,V>, // since 2.12
        java.io.Serializable
{
    @Override
    public V put(K key, V value) {
        if (_map.size() >= _maxEntries) {
            // double-locking, yes, but safe here; trying to avoid "clear storms"
            synchronized (this) {
                if (_map.size() >= _maxEntries) {
                    clear();
                }
            }
        }
        return _map.put(key, value);
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

Lastly, let's examine exactly what is being cached. In the LookupCache<Object, JavaType>, the key is an Object, and the value is a JavaType. JavaType (com.fasterxml.jackson.databind.JavaType) contains information about the type corresponding to the Object (key), including super class type, super interface type, and so on.

When deserializing the following JSON string into a YBS object, the items field of the YBS class is of type List interface, but the actual concrete type created is ArrayList.

// json = "{\"items\":[\"item1\",\"item2\"]}";

@Getter
@Setter
public class YBS {

    private List<String> items;

    @ConstructorProperties({"items"})
    public YBS(List<String> items) {
        this.items = items;
    }
}
Enter fullscreen mode Exit fullscreen mode

As a result, JavaType is retrieved and cached for all objects associated with ArrayList.

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable { ... }
Enter fullscreen mode Exit fullscreen mode

Ultimately, it can be observed that various objects are cached for a single List field.

Image description

Top comments (0)