Jackson and class Hierarchy

This article discusses about changes we need to make to model beans to enable Jackson de-serialize properly.

We divide this article into three steps
  1. Setup
  2. De-serialization without changes
  3. De-serialization with changes

Setup

Lets consider following scenario:


Model classes

public class Device {
   @JsonProperty
   protected final String name;
   @JsonProperty
   protected final String modelNumber;

   public Device(String name, String modelNumber) {
      this.name = name;
      this.modelNumber = modelNumber;
   }
}

Note: There are no annotations in constructor as this is a parent class and we do not directly de-serialize it. We could even make it abstract. But we do have annotations on properties because we would definitely serialize them.

Children classes are as below

public class Phone extends Device {
   @JsonProperty
   private final int numberOfSimSlots;
   @JsonProperty
   private final String generationSupported; // 2G, 3G or 4G
  
   public Phone(@JsonProperty("name") String name,
         @JsonProperty("modelNumber") String modelNumber,
         @JsonProperty("numberOfSimSlots") int numberOfSimSlots,
         @JsonProperty("generationSupported") String generationSupported) {
      super(name, modelNumber);
      this.numberOfSimSlots = numberOfSimSlots;
      this.generationSupported = generationSupported;
   }
}



public class Tab extends Device{
   @JsonProperty
   private final int screenSize;
   @JsonProperty
   private final boolean hasWiFi;
   @JsonProperty
   private final boolean hasSimSlot;
  
   public Tab(@JsonProperty("name") String name,
         @JsonProperty("modelNumber") String modelNumber,
         @JsonProperty("screenSize") int screenSize,
         @JsonProperty("hasWiFi") boolean hasWiFi,
         @JsonProperty("hasSimSlot") boolean hasSimSlot) {
      super(name, modelNumber);
      this.screenSize = screenSize;
      this.hasWiFi = hasWiFi;
      this.hasSimSlot = hasSimSlot;
   }
}



public class Laptop extends Device {
   @JsonProperty
   private final int numberOfProcessors;
   @JsonProperty
   private final String operatingSystem;
  
   public Laptop(@JsonProperty("name") String name,
         @JsonProperty("modelNumber") String modelNumber,
         @JsonProperty("numberOfProcessors") int numberOfProcessors,
         @JsonProperty("operatingSystem") String operatingSystem) {
      super(name, modelNumber);
      this.numberOfProcessors = numberOfProcessors;
      this.operatingSystem = operatingSystem;
   }
}

Please assume that each model class has an awesome implementation of toString().

Model construction

List myDevices = new ArrayList<>();
myDevices.add(new Phone("Xiaomi", "1S", 2, "2G, 3G"));
myDevices.add(new Tab("iPad", "mini", 7, true, false));
myDevices.add(new Laptop("Macbook", "Pro", 2, "Mountain Lion"));
Person person = new Person("Harsh", myDevices);

Serialization

ObjectMapper mapper = new ObjectMapper();
System.out.println(mapper.writeValueAsString(person));

Serialization never has any issues. Serialized JSON is formed as
{
    "name": "Harsh",
    "devices": [
        {
            "name": "Xiaomi",
            "modelNumber": "1S",
            "numberOfSimSlots": 2,
            "generationSupported": "2G, 3G"
        },
        {
            "name": "iPad",
            "modelNumber": "mini",
            "screenSize": 7,
            "hasWiFi": true,
            "hasSimSlot": false
        },
        {
            "name": "Dell",
            "modelNumber": "Inspiron",
            "numberOfProcessors": 2,
            "operatingSystem": "Red Hat"
        }
    ]
}

De-serialization

Code looks like below
// personJson is simply stringified version of above mentioned JSON

private static String personJson = "{\"name\":\"Harsh\",\"devices\":[{\"name\":\"Xiaomi\",\"modelNumber\":\"1S\",\"numberOfSimSlots\":2,\"generationSupported\":\"2G, 3G\"},{\"name\":\"iPad\",\"modelNumber\":\"mini\",\"screenSize\":7,\"hasWiFi\":true,\"hasSimSlot\":false},{\"name\":\"Dell\",\"modelNumber\":\"Inspiron\",\"numberOfProcessors\":2,\"operatingSystem\":\"Red Hat\"}]}";
 Person readPerson = mapper.readValue(personJson, Person.class);
 System.out.println(readPerson);

Since toString() of each model class is overridden, we wouldn't have any issues with readPerson being printed.

De-serialization without changes

Well, obviously, this doesn't work. Otherwise, we would not need this article.

Jackson, while de-serializing, just knows that it needs to create a Device model object. But it doesn't know which child class of Device should be chosen. It could be Phone, Tab or Laptop.

We could argue that using @JsonProperty("") property in constructor signature, it could identify which child class to construct. But what if all the properties in each child constructor are same? So Jackson cannot bank on this .

Following is the exception it throws:
Exception in thread "main" com.fasterxml.jackson.databind.JsonMappingException: No suitable constructor found for type [simple type, class demo.Device]: can not instantiate from JSON object (need to add/enable type information?)

It couldn't find any suitable constructor. Because its trying to de-serialize directly a Device instance. But it couldn't understand how.

If Device was an abstract class, it would throw:
Exception in thread "main" com.fasterxml.jackson.databind.JsonMappingException: Can not construct instance of demo.Device, problem: abstract types either need to be mapped to concrete types, have custom deserializer, or be instantiated with additional type information

Again the same thing, but in a different language.

But a points worth noting here are
(need to add/enable type information?) AND

 or be instantiated with additional type information

Jackson is talking about some "type" information that it needs.

De-serialization with changes

So we change Device class. No, we do NOT change model. We just add a few more annotations.

@JsonTypeInfo( 
      use = JsonTypeInfo.Id.NAME, 
      include = JsonTypeInfo.As.PROPERTY, 
      property = "type") 
  @JsonSubTypes({ 
      @Type(value = Phone.class, name = "phone"), 
      @Type(value = Tab.class, name = "tab"),
      @Type(value = Laptop.class, name = "laptop")})

public abstract class Device {
   @JsonProperty
   protected final String name;
   @JsonProperty
   protected final String modelNumber;

   public Device(String name, String modelNumber) {
      this.name = name;
      this.modelNumber = modelNumber;
   }

   public String toString() { }
}

What we have done is that we added a property "type" in JSON version of Device object. This shall have values among "phone", "tab" and "laptop". This field would enable Jackson to identify associate child class it should de-serialize to.

JSON formed during serialization with these changes looks like:
Note: Note the new "type" field in BOLD.
{
    "name": "Harsh",
    "devices": [
        {
            "type": "phone",
            "name": "Xiaomi",
            "modelNumber": "1S",
            "numberOfSimSlots": 2,
            "generationSupported": "2G, 3G"
        },
        {
            "type": "tab",
            "name": "iPad",
            "modelNumber": "mini",
            "screenSize": 7,
            "hasWiFi": true,
            "hasSimSlot": false
        },
        {
            "type": "laptop",
            "name": "Dell",
            "modelNumber": "Inspiron",
            "numberOfProcessors": 2,
            "operatingSystem": "Red Hat"
        }
    ]
}

And output from de-serialization is:

Person [name=Harsh, devices=[Phone [numberOfSimSlots=2, generationSupported=2G, 3G], Tab [screenSize=7, hasWiFi=true, hasSimSlot=false], Laptop [numberOfProcessors=2, operatinSystem=Red Hat]]]

Of course, we modified the string JSON (personJson string object) to have "type" information.

Note: There was no exception. And we are successful.

Comments