Reputation: 4612
I am using a third-party POJO class RetryOptions
that can only be created using a builder. The builder can only be instantiated using a static method RetryOptions.newBuilder()
, or by calling options.toBuilder()
on an existing instance.
I would like to create custom de/serializers for the third-party POJO (RetryOptions
). My first approach was to write the object as a builder, then read the object as a builder and return the built result:
class RetryOptionsSerializer extends StdSerializer<RetryOptions> {
@Override
public void serialize(RetryOptions value, JsonGenerator gen, SerializerProvider provider) throws IOException {
// Save as a builder
gen.writeObject(value.toBuilder());
}
}
class RetryOptionsDeserializer extends StdDeserializer<RetryOptions> {
@Override
public RetryOptions deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
// Read as builder, then build
return p.readValueAs(RetryOptions.Builder.class).build();
}
}
But the problem is that Jackson doesn't know how to create an instance of RetryOptions.Builder
in order to populate it's fields.
Is there a way I can instruct Jackson in how to create the builder instance, but let Jackson handle the parsing, reflection, and assignment of the fields?
Perhaps something like:
class RetryOptionsDeserializer extends StdDeserializer<RetryOptions> {
@Override
public RetryOptions deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
// Read as builder, then build
var builder = RetryOptions.newBuilder();
return p.readValueInto(builder).build();
}
}
Or perhaps there is a way to tell the object mapper how to create an instance of RetryOptions.Builder
:
var mapper = new ObjectMapper();
mapper.registerValueInstantiator(RetryOptions.Builder, () -> RetryOptions.newBuilder());
Or is there another way to slice this problem without resorting to my own reflection logic or a brute-force duplication of the third-party class?
Note: my solution must use the Jackson JSON library (no Guava, etc.)
Note: there are several classes in this third party library that run into this same issue, so a generic solution is helpful
Upvotes: 4
Views: 1667
Reputation: 150
There's another bit of seemingly dark magic involved here if you, for whatever reason, can't use the delegates deserialization method to initially create your object. To get back references working, I used a DelegatingDeserializer
as described by @GordonBean, but had to add this:
public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
final EntityCreateSpec spec = ctxt.readValue(p, EntityCreateSpec.class);
try {
final Entity entity = engine.createEntity(
(Class<? extends Entity>) ctxt.findClass(spec.getClazz()),
spec.getId(),
spec.getComponent());
// The Important Bit
// Bind entities for back references.
final ObjectIdReader reader = this.getObjectIdReader();
final ReadableObjectId roid = ctxt.findObjectId(entity.getId(), reader.generator, reader.resolver);
roid.bindItem(entity);
// End Important Bit
return entity;
} catch (ClassNotFoundException | FactoryException e) {
throw new RuntimeException("Unable to deserialize " + spec.getClazz(), e);
}
}
Where entity
here is the thing being deserialized. Note: This uses the delegates ObjectIdReader
to get a generator and resolver, uses the contexts findObjectId to create a ReadableObjectId
. From there, we bind the newly created object to the ReadableObjectId
to make sure it will resolve later!
Upvotes: 1
Reputation: 4612
Jackson can deserialize private fields as long as they have a getter (see https://www.baeldung.com/jackson-field-serializable-deserializable-or-not).
So, it turns out, in my scenario, that I don't need to deserialize RetryOptions
through the builder, I just need to be able to construct an instance of RetryOptions
that Jackson can use to populate the fields.
As I had multiple classes with this same constraint (no public constructors on a third-party class), I wrote the following method to generate ValueInstantiators
from a Supplier
lambda:
static ValueInstantiator createDefaultValueInstantiator(DeserializationConfig config, JavaType valueType, Supplier<?> creator) {
class Instantiator extends StdValueInstantiator {
public Instantiator(DeserializationConfig config, JavaType valueType) {
super(config, valueType);
}
@Override
public boolean canCreateUsingDefault() {
return true;
}
@Override
public Object createUsingDefault(DeserializationContext ctxt) {
return creator.get();
}
}
return new Instantiator(config, valueType);
}
Then I registered ValueInstantiators
for each of my classes, e.g:
var mapper = new ObjectMapper();
var module = new SimpleModule()
.addValueInstantiator(
RetryOptions.class,
createDefaultValueInstantiator(
mapper.getDeserializationConfig(),
mapper.getTypeFactory().constructType(RetryOptions.class),
() -> RetryOptions.newBuilder().validateBuildWithDefaults()
)
)
.addValueInstantiator(
ActivityOptions.class,
createDefaultValueInstantiator(
mapper.getDeserializationConfig(),
mapper.getTypeFactory().constructType(ActivityOptions.class),
() -> ActivityOptions.newBuilder().validateAndBuildWithDefaults()
)
);
mapper.registerModule(module);
No custom de/serializers are needed.
I found a way.
First, define a ValueInstantiator
for the class. The Jackson documentation strongly encourages you to extend StdValueInstantiator
.
In my scenario, I only needed the "default" (parameter-less) instantiator, so I overrode the canCreateUsingDefault
and createUsingDefault
methods.
There are other methods for creating from arguments if needed.
class RetryOptionsBuilderValueInstantiator extends StdValueInstantiator {
public RetryOptionsBuilderValueInstantiator(DeserializationConfig config, JavaType valueType) {
super(config, valueType);
}
@Override
public boolean canCreateUsingDefault() {
return true;
}
@Override
public Object createUsingDefault(DeserializationContext ctxt) throws IOException {
return RetryOptions.newBuilder();
}
}
Then I register my ValueInstantiator
with the ObjectMapper
:
var mapper = new ObjectMapper();
var module = new SimpleModule();
module.addDeserializer(RetryOptions.class, new RetryOptionsDeserializer());
module.addSerializer(RetryOptions.class, new RetryOptionsSerializer());
module.addValueInstantiator(
RetryOptions.Builder.class,
new RetryOptionsBuilderValueInstantiator(
mapper.getDeserializationConfig(),
mapper.getTypeFactory().constructType(RetryOptions.Builder.class))
);
mapper.registerModule(module);
Now I can deserialize an instance of RetryOptions
like so:
var options = RetryOptions.newBuilder()
.setInitialInterval(Duration.ofMinutes(1))
.setMaximumAttempts(7)
.setBackoffCoefficient(1.0)
.build();
var json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(options);
var moreOptions = mapper.readValue(json, RetryOptions.class);
Note: my solution makes use of the de/serializers defined in the question - i.e. that first convert the RetryOptions
instance to a builder before serializing, then deserializing back to a builder and building to restore the instance.
End of original response
Upvotes: 2