Polymorphe Serialisierung und Deserialisierung mit Jackson

Wenn man mit Jackson instanzen als JSON serialisieren und deserialisieren will, gibt es eine Sache, über die man leicht mal stolpern kann: Polymorphie.

Problemstellung:

Angenommen, man hat ein interface wie das hier:

public interface SimpleTestInterface
{
    Supplier<?> getSupplier();
    void setSupplier(Supplier<?> supplier);
}

und eine konkrete Implementierung davon:

public class SimpleTestClass implements SimpleTestInterface
{
    private Supplier<?> supplier;

    @Override
    public Supplier<?> getSupplier()
    {
        return supplier;
    }

    @Override
    public void setSupplier(Supplier<?> supplier)
    {
        this.supplier = supplier;
    }
}

Nun gibt es eine konkrete Klasse, die das Supplier interface implementiert:

public class SimpleTestSupplier implements Supplier<String>
{
    @Override
    public String get()
    {
        return null;
    }
}

Nun kann man eine Instanz der SimpleTestClass erstellen und dort einen konkreten SimpleTestSupplier reinpacken, und das ganze serialisieren:

    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
    
    SimpleTestClass a = new SimpleTestClass();
    a.setSupplier(new SimpleTestSupplier());
    
    String string = objectMapper.writeValueAsString(a);
    System.out.println("String:\n" + string);

Das Ergebnis ist ernüchternd:

{
  "supplier" : { }
}

Das wieder zu deserialisieren ist natürlich nicht möglich: Man weiß nicht, welche (konkrete) Implementierung von SimpleTestInterface das ist, und welche (konkrete) Implementierung von Supplier sie enthält.

Die Lösung

Es gibt einen ganzen Wiki-Artikel über „Polymorphic Deserialization“, aber zumindest bei mir war es so, dass ich den durchgelesen habe, und mir dann gedacht habe: „Soso… und …jetzt?“ - eine einfache, universelle Lösung scheint es nicht zu geben. (Einiges kann man da, wie üblich, mit Annotationen lösen - aber ich will meine Klassen nicht mit Jackson-Annotationen zukleistern. Da kann man unterschiedliche Prioritäten setzen…)

Eine mögliche Lösung ist folgende: Man kann den ObjectMapper so konfigurieren, dass er für bestimmte Typen auf bestimmte Weise Klasseninformationen mit ins JSON packt. Dafür habe ich ein paar Utility-Klassen gebastelt. Im SimpleTypeResolverBuilder kann man interfaces registrieren, für die die konkrete Klasseninformaton rausgeschrieben werden soll:

    SimpleTypeResolverBuilder typeResolverBuilder =
        new SimpleTypeResolverBuilder();
    typeResolverBuilder.addType(SimpleTestInterface.class);
    typeResolverBuilder.addRawType(Supplier.class);
    objectMapper.setDefaultTyping(typeResolverBuilder);

Die Ausgabe ist damit

{
  "supplier" : {
    "@class" : "de.javagl.jackson.test.SimpleTestSupplier"
  }
}

Da kommt dann noch eine Schwierigkeit dazu: Die „Top-Level“-Klasseninformation ist bei der Ausgabe nicht dabei. Um das zu lösen, kann man objectMapper.enable(SerializationFeature.WRAP_ROOT_VALUE) setzen, und eine Utility-Funktion verwenden, die den „root type“ mit rausschreibt:

objectMapper.enable(SerializationFeature.WRAP_ROOT_VALUE);
String string = 
    JacksonTypeUtils.writeAsStringWithRootType(objectMapper, a);

Damit ist das Ergebnis folgendes:

{
  "de.javagl.jackson.test.SimpleTestClass" : {
    "supplier" : {
      "@class" : "de.javagl.jackson.test.SimpleTestSupplier"
    }
  }
}

Darin sind alle nötigen Informationen enthalten, und man kann das ganze mit

    SimpleTestClass readA = 
        JacksonTypeUtils.readWithRootType(objectMapper, string);

auch wieder zu einem echten Objekt machen.


Die beiden Klassen, JacksonTypeUtils und SimpleTypeResolverBuilder packe ich vielleicht irgendwann mal mit in GitHub - javagl/Common: Common utility classes for the javagl libraries , aber sie sind - abgesehen von den Jackson Dependencies - „standalone“, deswegen poste ich sie einfach mal hier, unter WTFPL:

JacksonTypeUtils.java:

package de.javagl.jackson;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.Reader;
import java.io.StringReader;
import java.util.Iterator;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;

/**
 * Utility methods for reading and writing objects with Jackson when the 
 * type of the root object is added as a fully qualified class name.
 */
public class JacksonTypeUtils
{
    /**
     * Creates an object mapper where the feature to wrap the root value
     * into a single-property object is enabled.
     *  
     * @return The object mapper
     */
    public static ObjectMapper createObjectMapper()
    {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.enable(SerializationFeature.WRAP_ROOT_VALUE);
        return objectMapper;
    }
    
    /**
     * Read the object from the given string, assuming that the root JSON
     * node contains the type information of the object
     * 
     * @param <T> The result type
     * @param objectMapper The object mapper
     * @param string The string
     * @return The object
     * @throws IOException If an IO error occurs
     */
    public static <T> T readWithRootType(
        ObjectMapper objectMapper, String string) throws IOException
    {
        return readWithRootType(objectMapper, new StringReader(string));
    }
    
    /**
     * Read the object from the given stream, assuming that the root JSON
     * node contains the type information of the object
     * 
     * @param <T> The result type
     * @param objectMapper The object mapper
     * @param inputStream The stream
     * @return The object
     * @throws IOException If an IO error occurs
     */
    public static <T> T readWithRootType(
        ObjectMapper objectMapper, InputStream inputStream) throws IOException
    {
        try (Reader reader = new InputStreamReader(inputStream))
        {
            return readWithRootType(objectMapper, reader);
        }
    }
    
    /**
     * Read the object from the given reader, assuming that the root JSON
     * node contains the type information of the object
     * 
     * @param <T> The result type
     * @param objectMapper The object mapper
     * @param reader The reader
     * @return The object
     * @throws IOException If an IO error occurs
     */
    public static <T> T readWithRootType(
        ObjectMapper objectMapper, Reader reader) throws IOException
    {
        JsonNode tree = objectMapper.readTree(reader);
        Iterator<String> fieldNames = tree.fieldNames();
        String rootName = fieldNames.next();
        try
        {
            Class<?> type = Class.forName(rootName);
            Object value = objectMapper.convertValue(tree.get(rootName), type);
            @SuppressWarnings("unchecked")
            T result = (T) value;
            return result;
        }
        catch (ClassNotFoundException e)
        {
            throw new IOException(e);
        }
    }
    
    /**
     * Write the given object as a string, inserting the type of the object
     * as a string as the root name in the resulting JSON
     * 
     * @param objectMapper The object mapper
     * @param object The object
     * @return The string
     * @throws IOException If an IO error occurs
     */
    public static String writeAsStringWithRootType(
        ObjectMapper objectMapper, Object object) throws IOException
    {
        return objectMapper.writer()
            .withRootName(object.getClass().getName())
            .writeValueAsString(object);
    }
    
    /**
     * Write the given object to the given stream, inserting the type of the 
     * object as a string as the root name in the resulting JSON
     * 
     * @param objectMapper The object mapper
     * @param outputStream The stream
     * @param object The object
     * @throws IOException If an IO error occurs
     */
    public static void writeWithRootType(ObjectMapper objectMapper,
        OutputStream outputStream, Object object) throws IOException
    {
        objectMapper.writer()
            .withRootName(object.getClass().getName())
            .writeValue(outputStream, object);
    }
    
    
    /**
     * Private constructor to prevent instantiation
     */
    private JacksonTypeUtils()
    {
        // Private constructor to prevent instantiation
    }

}

SimpleTypeResolverBuilder.java:

package de.javagl.jackson;

import java.util.ArrayList;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper.DefaultTypeResolverBuilder;
import com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping;
import com.fasterxml.jackson.databind.type.TypeFactory;

/**
 * Implementation of a type resolver builder where the handled types
 * can be added manually.<br>
 * <br>
 * Example:
 * <pre><code>
 * SimpleTypeResolverBuilder typeResolverBuilder =
 *     new SimpleTypeResolverBuilder();
 * typeResolverBuilder.addType(SomeGenericType.class, ParameterType.class);
 * typeResolverBuilder.addType(SomeType.class);
 * typeResolverBuilder.addRawType(SomeRawType.class);
 * objectMapper.setDefaultTyping(typeResolverBuilder);
 * </code></pre>
 * 
 */
public class SimpleTypeResolverBuilder extends DefaultTypeResolverBuilder
{
    /**
     * Serial UID
     */
    private static final long serialVersionUID = 1L;
    
    /**
     * The java types
     */
    private final List<JavaType> javaTypes;
    
    /**
     * The raw types
     */
    private final List<Class<?>> rawTypes;
    
    /**
     * Default constructor
     */
    public SimpleTypeResolverBuilder()
    {
        super(DefaultTyping.NON_FINAL);
        this.javaTypes = new ArrayList<JavaType>();
        this.rawTypes = new ArrayList<Class<?>>();
        init(JsonTypeInfo.Id.CLASS, null);
        inclusion(JsonTypeInfo.As.PROPERTY);
        typeProperty("@class");
    }
    
    /**
     * Add the given type
     * 
     * @param typeReference The type reference
     */
    public void addType(TypeReference<?> typeReference)
    {
        TypeFactory typeFactory = TypeFactory.defaultInstance();
        JavaType javaType = typeFactory.constructType(typeReference);
        javaTypes.add(javaType);
    }

    /**
     * Add the given type
     * 
     * @param type The type 
     * @param parameterTypes The parameter types
     */
    public void addType(Class<?> type, Class<?> ... parameterTypes)
    {
        if (type.getTypeParameters().length != parameterTypes.length)
        {
            throw new IllegalArgumentException(
                "Type " + type + " requires " + type.getTypeParameters().length
                + " type parameters, but " + parameterTypes.length
                + " are given. Use addRawType to omit type parameters.");
        }
        TypeFactory typeFactory = TypeFactory.defaultInstance();
        JavaType javaType = typeFactory.constructParametricType(
            type, parameterTypes);
        javaTypes.add(javaType);
    }

    /**
     * Add the given type
     * 
     * @param <T> The type
     * 
     * @param type The type
     */
    public <T> void addType(Class<T> type)
    {
        if (type.getTypeParameters().length != 0)
        {
            throw new IllegalArgumentException(
                "Type " + type + " requires " + type.getTypeParameters().length
                + " type parameters, but no parameter types"
                + " are given. Use addRawType to omit type parameters.");
        }
        TypeFactory typeFactory = TypeFactory.defaultInstance();
        JavaType javaType = typeFactory.constructType(type);
        javaTypes.add(javaType);
    }
    
    /**
     * Add the given type
     * 
     * @param <T> The type
     * 
     * @param type The type
     */
    public <T> void addRawType(Class<T> type)
    {
        rawTypes.add(type);
    }
    

    @Override
    public boolean useForType(JavaType t)
    {
        if (javaTypes.contains(t))
        {
            //System.out.println("Do     use for " + t + " (javaType)");
            return true;
        }
        if (rawTypes.contains(t.getRawClass()))
        {
            //System.out.println("Do     use for " + t + " (rawType)");
            return true;
        }
        //System.out.println("Do NOT use for " + t);
        return false;
    }
}

Ein interessanter Ansatz, für ein Problem vor dem ich mich bisher erfolgreich drücken konnte, indem ich sowas zwar gebrauchen könnte aber immer nur geplant und überlegt habe und mich dann anderem zugewandt habe.

Am besten wäre es natürlich wenn man das ganze noch „abstrakter“ machen kann, da ich z.B. Gson bevorzuge (ich kann aber nicht erklären wieso)

Inwieweit das auf andere JSON-Libs übertragbar ist, weiß ich nicht. Auf Ebene des JSONs natürlich: Mehr, als überall das @class dazuzupacken, passiert da ja nicht. Die Frage ist eher, ob/wie andere Libs die Mögichkeit bieten, sich „so tief“ in den Serialisierungsprozess einzuklinken. In diesem Fall (d.h. bei Jackson) war das für mich mit einigem Gestocher verbunden. Wie gesagt: Das Wiki schreibt da was, es gibt verschiedene Optionen, und die „root value“ mit Typinformationen rauszuschreiben ist ein weiterer Stolperstein - es ist IMHO alles andere als offensichtlich, wie man das machen kann (deswegen der Beitrag :slight_smile: )

Es war auch nur eine Anmerkung, im Sinne eines „hach wäre die Welt nicht schön, wenn…“
Ich weiß, dass man bei Gson eigene De/Serializer registrieren kann für unterschiedliche Typen, aber inwieweit man dann auf spezielle Konstrukte innerhalb des JSON Teils hat weiß ich nicht.

Meine bisherige Erfahrung in dem Teil beschränkt sich darauf nur bestimmte Felder von Objekten zu benutzen, wo wahrscheinlich ein transient gereicht hätte

Hmja, einerseits ist Jackson toll: Man hat seine Objektstruktur, und kann einfach schreiben JacksonMagic.dumpThat(object), und bekommt magisch JSON zurück, das man auch wieder einlesen kann. Zauberei…

…wenn …

ja, wenn alles aus konkreten, public classes besteht (Java Beans oder equivalent). Ein einziges interface kann das ganze raushauen. Natürlich kann man Custom Serializer einklinken. Und das ist relativ einfach. Aber schon „verschachtelte“ custom serializer sind nicht mehr so offensichtlich (das hatte ich mal in https://stackoverflow.com/a/51893976/3182664 beschrieben).

(Ich weiß, ich weiß: Vieles davon könnte man mit Annotations viel einfacher machen, aber es gibt für mich (zu) viele gute Gründe, darauf zu verzichten…)