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;
}
}