web-dev-qa-db-fra.com

Désérialisation des types polymorphes avec Jackson

Si j'ai une structure de classe comme ça:

public abstract class Parent {
    private Long id;
    ...
}

public class SubClassA extends Parent {
    private String stringA;
    private Integer intA;
    ...
}

public class SubClassB extends Parent {
    private String stringB;
    private Integer intB;
    ...
}

Existe-t-il une autre façon de désérialiser différents alors @JsonTypeInfo? En utilisant cette annotation sur ma classe parent:

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "objectType")

Je préfère ne pas avoir à forcer les clients de mon API à inclure "objectType": "SubClassA" pour désérialiser une sous-classe Parent.

À la place d'utiliser @JsonTypeInfo, Jackson fournit-il un moyen d'annoter une sous-classe et de la distinguer des autres sous-classes via une propriété unique? Dans mon exemple ci-dessus, ce serait quelque chose comme, "Si un objet JSON a "stringA": ... le désérialiser comme SubClassA, s'il a "stringB": ... le désérialiser comme SubClassB ".

25
Sam Berry

Cela ressemble à quelque chose @JsonTypeInfo et @JsonSubTypes devrait être utilisé, mais j'ai parcouru les documents et aucune des propriétés qui peuvent être fournies ne semble correspondre à ce que vous décrivez.

Vous pouvez écrire un désérialiseur personnalisé qui utilise @JsonSubTypes '"nom" et "valeur" propriétés d'une manière non standard pour accomplir ce que vous voulez. Le désérialiseur et @JsonSubTypes serait fourni sur votre classe de base et le désérialiseur utiliserait les valeurs "nom" pour vérifier la présence d'une propriété et si elle existe, puis désérialiser le JSON dans la classe fournie dans la propriété "valeur". Vos cours ressembleraient alors à ceci:

@JsonDeserialize(using = PropertyPresentDeserializer.class)
@JsonSubTypes({
        @Type(name = "stringA", value = SubClassA.class),
        @Type(name = "stringB", value = SubClassB.class)
})
public abstract class Parent {
    private Long id;
    ...
}

public class SubClassA extends Parent {
    private String stringA;
    private Integer intA;
    ...
}

public class SubClassB extends Parent {
    private String stringB;
    private Integer intB;
    ...
}
21
Erik Gillespie

Voici une solution que j'ai trouvée qui s'étend un peu sur celle d'Erik Gillespie. Il fait exactement ce que vous avez demandé et cela a fonctionné pour moi.

Utiliser Jackson 2.9

@JsonDeserialize(using = CustomDeserializer.class)
public abstract class BaseClass {

    private String commonProp;
}

// Important to override the base class' usage of CustomDeserializer which produces an infinite loop
@JsonDeserialize(using = JsonDeserializer.None.class)
public class ClassA extends BaseClass {

    private String classAProp;
}

@JsonDeserialize(using = JsonDeserializer.None.class)
public class ClassB extends BaseClass {

    private String classBProp;
}

public class CustomDeserializer extends StdDeserializer<BaseClass> {

    protected CustomDeserializer() {
        super(BaseClass.class);
    }

    @Override
    public BaseClass deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
        TreeNode node = p.readValueAsTree();

        // Select the concrete class based on the existence of a property
        if (node.get("classAProp") != null) {
            return p.getCodec().treeToValue(node, ClassA.class);
        }
        return p.getCodec().treeToValue(node, ClassB.class);
    }
}

// Example usage
String json = ...
ObjectMapper mapper = ...
BaseClass instance = mapper.readValue(json, BaseClass.class);

Si vous voulez devenir plus amateur, vous pouvez développer CustomDeserializer pour inclure un Map<String, Class<?>> qui mappe un nom de propriété qui, lorsqu'il est présent, mappe vers une classe spécifique. Une telle approche est présentée dans ce article .

Au fait, il y a un problème de github qui le demande ici: https://github.com/FasterXML/jackson-databind/issues/1627

21
bernie

Non. Une telle fonctionnalité a été demandée - elle pourrait être appelée "inférence de type", ou "type implicite" - mais personne n'a encore proposé une proposition générale viable sur la manière dont cela devrait fonctionner. Il est facile de penser à des moyens de prendre en charge des solutions spécifiques à des cas spécifiques, mais trouver une solution générale est plus difficile.

8
StaxMan

Comme d'autres l'ont souligné, il n'y a pas de consensus sur comment cela devrait fonctionner pour qu'il ne soit pas implémenté .

Si vous avez des classes Foo, Bar et leur solution parent FooBar semble assez évidente lorsque vous avez des JSON comme:

{
  "foo":<value>
}

ou

{
  "bar":<value>
}

mais il n'y a pas de réponse commune à ce qui se passe lorsque vous obtenez

{
  "foo":<value>,
  "bar":<value>
}

À première vue, le dernier exemple semble être un cas évident de 400 Bad Request, mais il existe de nombreuses approches différentes dans la pratique:

  1. Traitez-le comme 400 Bad Request
  2. Priorité par type/champ (par exemple, si une erreur de champ existe, elle a une priorité plus élevée que certains autres champs foo)
  3. Cas plus complexes de 2.

Ma solution actuelle qui fonctionne dans la plupart des cas et essaie de tirer le meilleur parti possible de l'infrastructure Jackson existante (vous n'avez besoin que d'un désérialiseur par hiérarchie):

public class PresentPropertyPolymorphicDeserializer<T> extends StdDeserializer<T> {

    private final Map<String, Class<?>> propertyNameToType;

    public PresentPropertyPolymorphicDeserializer(Class<T> vc) {
        super(vc);
        this.propertyNameToType = Arrays.stream(vc.getAnnotation(JsonSubTypes.class).value())
                                        .collect(Collectors.toMap(Type::name, Type::value,
                                                                  (a, b) -> a, LinkedHashMap::new)); // LinkedHashMap to support precedence case by definition order
    }

    @Override
    public T deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        ObjectMapper objectMapper = (ObjectMapper) p.getCodec();
        ObjectNode object = objectMapper.readTree(p);
        for (String propertyName : propertyNameToType.keySet()) {
            if (object.has(propertyName)) {
                return deserialize(objectMapper, propertyName, object);
            }
        }

        throw new IllegalArgumentException("could not infer to which class to deserialize " + object);
    }

    @SuppressWarnings("unchecked")
    private T deserialize(ObjectMapper objectMapper,
                          String propertyName,
                          ObjectNode object) throws IOException {
        return (T) objectMapper.treeToValue(object, propertyNameToType.get(propertyName));
    }
}

Exemple d'utilisation:

@JsonSubTypes({
        @JsonSubTypes.Type(value = Foo.class, name = "foo"),
        @JsonSubTypes.Type(value = Bar.class, name = "bar"),
})
interface FooBar {
}
@AllArgsConstructor(onConstructor_ = @JsonCreator)
@Value
static class Foo implements FooBar {
    private final String foo;
}
@AllArgsConstructor(onConstructor_ = @JsonCreator)
@Value
static class Bar implements FooBar {
    private final String bar;
}

Configuration de Jackson

SimpleModule module = new SimpleModule();
module.addDeserializer(FooBar.class, new PresentPropertyPolymorphicDeserializer<>(FooBar.class));
objectMapper.registerModule(module);

ou si vous utilisez Spring Boot:

@JsonComponent
public class FooBarDeserializer extends PresentPropertyPolymorphicDeserializer<FooBar> {

    public FooBarDeserializer() {
        super(FooBar.class);
    }
}

Tests:

    @Test
    void shouldDeserializeFoo() throws IOException {
        // given
        var json = "{\"foo\":\"foo\"}";

        // when
        var actual = objectMapper.readValue(json, FooBar.class);

        // then
        then(actual).isEqualTo(new Foo("foo"));
    }

    @Test
    void shouldDeserializeBar() throws IOException {
        // given
        var json = "{\"bar\":\"bar\"}";

        // when
        var actual = objectMapper.readValue(json, FooBar.class);

        // then
        then(actual).isEqualTo(new Bar("bar"));

    }

    @Test
    void shouldDeserializeUsingAnnotationDefinitionPrecedenceOrder() throws IOException {
        // given
        var json = "{\"bar\":\"\", \"foo\": \"foo\"}";

        // when
        var actual = objectMapper.readValue(json, FooBar.class);

        // then
        then(actual).isEqualTo(new Foo("foo"));
    }
6
lpandzic

Mon application me demande de conserver l'ancienne structure, j'ai donc trouvé un moyen de prendre en charge le polymorphisme sans modifier les données. Voici ce que je fais:

  1. Étendre JsonDeserializer
  2. Convertir en arbre et lire le champ, puis renvoyer l'objet de sous-classe

    @Override public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        JsonNode jsonNode = p.readValueAsTree(); 
        Iterator<Map.Entry<String, JsonNode>> ite = jsonNode.fields();
        boolean isSubclass = false;
        while (ite.hasNext()) {
            Map.Entry<String, JsonNode> entry = ite.next();
            // **Check if it contains field name unique to subclass**
            if (entry.getKey().equalsIgnoreCase("Field-Unique-to-Subclass")) {
                isSubclass = true;
                break;
            }
        }
        if (isSubclass) {
            return mapper.treeToValue(jsonNode, SubClass.class);
        } else {
            // process other classes
        }
    }
    
3
Nicholas Ng