简体   繁体   中英

Can Jackson polymorphic deserialization be used to serialize to a subtype if a specific field is present?

Using a spin on the zoo example:

public class ZooPen {
    public String type;
    public List<Animal> animals;
}

public class Animal {
    public String name;
    public int age;
}

public class Bird extends Animal {
    public double wingspan;
}

I want to use polymorphic deserialization to construct Animal instances if no wingspan is specified, and Bird instances if it is. In Jackson, untyped deserialization typically looks something like this:

@JsonTypeInfo( 
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "wingspan",
    visible = true,
    defaultImpl = Animal.class
)
@JsonSubTypes({
    @Type(value = Bird.class, name = "bird")
})  
public class Animal {
    ...
}

The wingspan value can be anything, and without that matching something specifically, Jackson falls back on the defaultImpl class.

I could probably use @JsonCreator :

@JsonCreator
public static Animal create(Map<String,Object> jsonMap) 
        throws JsonProcessingException {

    ObjectMapper mapper = new ObjectMapper();
    if (jsonMap.get("wingspan") == null) {
        // Construct and return animal
    } else {
        // Construct and return bird
    }
}

However, then I have to manually handle extra values and throw consistent exceptions, and it's not clear if the Animal would be serialized properly later.

It seems I can use my own TypeResolver or TypeIdResolver , but that seems like more work than just deserializing the raw json myself. Additionally, TypeResolver and TypeIdResolver seem to intrinsically assume that type info is serialized, so those aren't good to use.

Would it be feasible to implement my own JsonDeserializer that hooks into the lifecycle to specify type, but still uses basic Jackson annotation processing functionality? I've been having a look at JsonDeserializer.deserializeWithType(...) , but that seems to delegate deserialization entirely to a TypeDeserializer . There's also the issue that I'd need to deserialize some of the object before I know which type to use.

Alternatively, there might be a way to target the type of zoo pen, even though it's in the parent object.

Is there a way to do what I want with polymorphic type handling?

Jackson 2.12 added " deduction-based polymorphism " to automatically deduce subtypes based on the presence of properties distinct to a particular subtype. As of Jackson 2.12.2, it is possible to specify a type to be used when there isn't a subtype uniquely identifiable by the subtype-specific properties.

These features can be used to accomplish the requested deserialization in Jackson 2.12.2 or later. To do so, use @JsonTypeInfo(use=Id.DEDUCTION, defaultImpl = Animal.class) alongside the full list of supported subtypes provided by @JsonSubTypes :

@JsonTypeInfo(use=Id.DEDUCTION, defaultImpl = Animal.class)
@JsonSubTypes({@Type(Bird.class)})
public class Animal {
    public String name;
    public int age;
}

Deduction-based polymorphism

The deduction-based polymorphism feature was implemented per jackson-databind#43 , and is summarized in the 2.12 release notes :

It basically allows omitting of actual Type Id field or value, as long as the subtype can be deduced ( @JsonTypeInfo(use=DEDUCTION) ) from existence of fields. That is, every subtype has a distinct set of fields they included, and so during deserialization type can be uniquely and reliably detected.

This behavior is improved by jackson-databind#3055 in Jackson 2.12.2:

In the absence of a single candidate, defaultImpl should be the target type regardless of suitability.

A slightly longer explanation of deduction-based polymorphism is given in the Jackson 2.12 Most Wanted (1/5): Deduction-Based Polymorphism article written by the Jackson creator.

While not directly answering your question, I did think it was worth pointing out that it's not overly burdensome to use @JsonCreator :

@JsonCreator
public static Animal create(Map<String,Object> jsonMap) {
    String name = (String) jsonMap.get("name");
    int age = (int) jsonMap.get("age");
    if (jsonMap.keySet().contains("wingspan")) {
        double wingspan = (double) jsonMap.get("wingspan");
        return new Bird(name, age, wingspan);
    } else {
        return new Animal(name, age);
    }
}

No need to throw JsonProcessingException . This custom deserializer would fail for exactly the same reasons that the built-in Jackson deserializer would, namely, casting exceptions. For complex deserialization I prefer this way of doing things, as it makes the code much easier to understand and modify.

EDIT: If you can use the latest Jackson release candidate, your problem is solved. I assembled a quick demo here https://github.com/MariusSchmidt/de.denktmit.stackoverflow/tree/main/de.denktmit.jackson

You should take a look at this thread https://github.com/FasterXML/jackson-databind/issues/1627 , as it discusses your problem and proposes a solution. There is a Merge, that looks promising to me https://github.com/FasterXML/jackson-databind/pull/2813 . So you might try to follow the path of @JsonTypeInfo(use = DEDUCTION).

If however you can not use the latest upcoming Jackson version, here is what I would likely do:

Backport the merge request, OR

  1. Use Jackson to deserialize the input into a general JsonNode
  2. Use https://github.com/json-path/JsonPath check for one or more properties existence. Some container class could wrap all the paths needed to uniquely identify a class type.
  3. Map the JsonNode to the determined class, as outlined here Convert JsonNode into POJO

This way, you can leverage the full power of Jackson without handling low-level mapping logic

Best regards,

Marius

Animal

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.denktmit.stackoverflow.jackson.polymorphic.deductive.Bird;
import de.denktmit.stackoverflow.jackson.polymorphic.deductive.Fish;
import org.junit.jupiter.api.Test;

import java.util.List;

import static com.fasterxml.jackson.annotation.JsonTypeInfo.Id.DEDUCTION;
import static org.assertj.core.api.Assertions.assertThat;

@JsonTypeInfo(use = DEDUCTION)
@JsonSubTypes( {@JsonSubTypes.Type(Bird.class), @JsonSubTypes.Type(Fish.class)})
public class Animal {
    private String name;
    private int age;

    public String getName() {
        return name;
    }

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

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

Bird

public class Bird extends de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal {
    private double wingspan;

    public double getWingspan() {
        return wingspan;
    }

    public void setWingspan(double wingspan) {
        this.wingspan = wingspan;
    }
}

Fish

public class Fish extends de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal {

    private boolean freshwater;

    public boolean isFreshwater() {
        return freshwater;
    }

    public void setFreshwater(boolean freshwater) {
        this.freshwater = freshwater;
    }
}

ZooPen

public class ZooPen {

    private String type;
    private List<de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal> animals;

    public String getType() {
        return type;
    }

    public void setType(String type) {
        this.type = type;
    }

    public List<de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal> getAnimals() {
        return animals;
    }

    public void setAnimals(List<de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal> animals) {
        this.animals = animals;
    }
}

The test

import com.fasterxml.jackson.databind.ObjectMapper;
        import de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal;
        import de.denktmit.stackoverflow.jackson.polymorphic.deductive.Bird;
        import de.denktmit.stackoverflow.jackson.polymorphic.deductive.Fish;
        import de.denktmit.stackoverflow.jackson.polymorphic.deductive.ZooPen;
        import org.junit.jupiter.api.Test;

        import static org.assertj.core.api.Assertions.assertThat;

public class DeductivePolymorphicDeserializationTest {

    private static final String birdString = "{\n" +
            "      \"name\": \"Tweety\",\n" +
            "      \"age\": 79,\n" +
            "      \"wingspan\": 2.9\n" +
            "    }";

    private static final String fishString = "{\n" +
            "      \"name\": \"Nemo\",\n" +
            "      \"age\": 16,\n" +
            "      \"freshwater\": false\n" +
            "    }";

    private static final String zooPenString = "{\n" +
            "  \"type\": \"aquaviary\",\n" +
            "  \"animals\": [\n" +
            "    {\n" +
            "      \"name\": \"Tweety\",\n" +
            "      \"age\": 79,\n" +
            "      \"wingspan\": 2.9\n" +
            "    },\n" +
            "    {\n" +
            "      \"name\": \"Nemo\",\n" +
            "      \"age\": 16,\n" +
            "      \"freshwater\": false\n" +
            "    }\n" +
            "  ]\n" +
            "}";
    private final ObjectMapper mapper = new ObjectMapper();

    @Test
    void deserializeBird() throws Exception {
        de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal animal = mapper.readValue(birdString, de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal.class);
        assertThat(animal).isInstanceOf(de.denktmit.stackoverflow.jackson.polymorphic.deductive.Bird.class);
    }

    @Test
    void deserializeFish() throws Exception {
        de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal animal = mapper.readValue(fishString, de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal.class);
        assertThat(animal).isInstanceOf(de.denktmit.stackoverflow.jackson.polymorphic.deductive.Fish.class);
    }

    @Test
    void deserialize() throws Exception {
        de.denktmit.stackoverflow.jackson.polymorphic.deductive.ZooPen zooPen = mapper.readValue(zooPenString, de.denktmit.stackoverflow.jackson.polymorphic.deductive.ZooPen.class);
        assertThat(zooPen).isInstanceOf(de.denktmit.stackoverflow.jackson.polymorphic.deductive.ZooPen.class);
    }
}

You can use pretius-jddl deserialization to achieve your goal. I extended the class hierarchy a bit to show how it works. Here's a sample code:

public class SOAnswer3 {

    @ToString @Getter @Setter
    @AllArgsConstructor @NoArgsConstructor
    public static class Animal {
        String name;
        int age;
    }

    @ToString(callSuper = true) @Getter @Setter
    @AllArgsConstructor @NoArgsConstructor
    public static class Bird extends Animal {
        double wingspan;
    }

    @ToString(callSuper = true) @Getter @Setter
    @AllArgsConstructor @NoArgsConstructor
    public static class Elephant extends Animal {
        double trunkLength;
    }

    public static void main(String[] args) {
        String json = "[{"
                + "    \"name\": \"Marty\","
                + "    \"age\": 3"
                + "},"
                + "{"
                + "    \"name\": \"Danny\","
                + "    \"age\": 7,"
                + "    \"wingspan\": 1.4159"
                + "},{"
                + "    \"name\": \"King\","
                + "    \"age\": 21,"
                + "    \"trunkLength\": 2.11"
                + "}]";
        
        // create a deserializer instance
        DynamicObjectDeserializer deserializer = new DynamicObjectDeserializer();
        
        // runtime-configure deserialization rules
        deserializer.addRule(DeserializationRuleFactory.newRule(1, // priority 
                DeserializationCriterionFactory.targetClassIsAssignableFrom(Animal.class)
                    .and((e) -> e.getJsonNode().has("wingspan")),
                DeserializationActionFactory.objectToType(Bird.class)));
        
        deserializer.addRule(DeserializationRuleFactory.newRule(1,
                DeserializationCriterionFactory.targetClassIsAssignableFrom(Animal.class)
                    .and((e) -> e.getJsonNode().has("trunkLength")),
                DeserializationActionFactory.objectToType(Elephant.class)));
        
        List<Animal> deserializedObjects = deserializer.deserializeArray(json, Animal.class);
        
        for (Animal obj : deserializedObjects) {
            System.out.println("Deserialized Class: " + obj.getClass().getSimpleName()+";\t value: "+obj.toString());
        }
    }
}

Result:

Deserialized Class: Animal;  value: SOAnswer3.Animal(name=Marty, age=3)
Deserialized Class: Bird;    value: SOAnswer3.Bird(super=SOAnswer3.Animal(name=Danny, age=7), wingspan=1.4159)
Deserialized Class: Elephant;    value: SOAnswer3.Elephant(super=SOAnswer3.Animal(name=King, age=21), trunkLength=2.11)

Maven depenendency for pretius-jddl (check newest version at maven.org/jddl :

<dependency>
  <groupId>com.pretius</groupId>
  <artifactId>jddl</artifactId>
  <version>1.0.0</version>
</dependency>

If you're not married to Jackson, I believe something similar to this can be accomplished using FlexJSON.

http://flexjson.sourceforge.net/javadoc/flexjson/JSONDeserializer.html

I'm unfamiliar with Jackson's methods for doing similar things, but I can say that FlexJSON is very performant, and in general intuitive to use during serialization/deserialziation steps.

Hi Shaun you can achieve this behaviour with quite easily actually with Jackson using inheritance. I have modeled the Animal and Bird scenario here.

The constructors inside the Impl s allow for the correct instance of the Animal to be instantiated (ie An Animal if name and age are present and Bird if name age and wingspan are present). This will work the same for retrieving values over an API using something like Jersey

@com.fasterxml.jackson.annotation.JsonSubTypes({
    @com.fasterxml.jackson.annotation.JsonSubTypes.Type(AnimalImpl.class)
})
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(as = AnimalImpl.class)
public interface Animal {

    public String getName();

    public int getAge();
}

public class AnimalImpl implements Animal {

    private final String name;
    private final int age;

    public AnimalImpl(
        @JsonProperty("name") final String name,
        @JsonProperty("age") final int age
    ) {
    this.name = Objects.requireNonNull(name);
    this.age = Objects.requireNonNull(age);
    }

    @Override
    public String getName() {
    return name;
    }

    @Override
    public int getAge() {
    return age;
    }
}

@com.fasterxml.jackson.annotation.JsonSubTypes({
    @com.fasterxml.jackson.annotation.JsonSubTypes.Type(BirdImpl.class)
})
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(as = BirdImpl.class)
public interface Bird extends Animal {

    public double getWingspan();
}

public class BirdImpl extends AnimalImpl implements Bird {

    private final double wingspan;

    public BirdImpl(
        @com.fasterxml.jackson.annotation.JsonProperty("name") final String name,
        @com.fasterxml.jackson.annotation.JsonProperty("age") final int age,
        @com.fasterxml.jackson.annotation.JsonProperty("wingspan") final double wingspan
    ) {
    super(name, age);
    this.wingspan = wingspan;
    }

    @Override
    public double getWingspan() {
    return wingspan;
    }
}

public class Test {

    public static void main(final String[] args) throws java.io.IOException {

    final com.fasterxml.jackson.databind.ObjectMapper objectMapper
        = new com.fasterxml.jackson.databind.ObjectMapper();

    final String animalJson = "{\"name\": \"the name\", \"age\": 42}";
    final Animal animal = objectMapper.readValue(animalJson, Animal.class);

    System.out.println(animal);

    final String birdJson = "{\"name\": \"the name\", \"age\": 42, \"wingspan\": 21}";
    final Bird bird = objectMapper.readValue(birdJson, Bird.class);

    System.out.println(bird);
    }
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM