Reputation: 631
I have a web application with Spring Boot 2.5.5 and embedded Infinispan 12.1.7.
I have a Controller with an endpoint to get a Person object by ID:
@RestController
public class PersonController {
private final PersonService service;
public PersonController(PersonService service) {
this.service = service;
}
@GetMapping("/person/{id}")
public ResponseEntity<Person> getPerson(@PathVariable("id") String id) {
Person person = this.service.getPerson(id);
return ResponseEntity.ok(person);
}
}
The following is the PersonService
implementation with the use of the @Cacheable
annotation on the getPerson
method :
public interface PersonService {
Person getPerson(String id);
}
@Service
public class PersonServiceImpl implements PersonService {
private static final Logger LOG = LoggerFactory.getLogger(PersonServiceImpl.class);
@Override
@Cacheable("person")
public Person getPerson(String id) {
LOG.info("Get Person by ID {}", id);
Person person = new Person();
person.setId(id);
person.setFirstName("John");
person.setLastName("Doe");
person.setAge(35);
person.setGender(Gender.MALE);
person.setExtra("extra value");
return person;
}
}
And here is the Person class:
public class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String id;
private String firstName;
private String lastName;
private Integer age;
private Gender gender;
private String extra;
/* Getters / Setters */
...
}
I configured infinispan to use a filesystem-based cache store:
<?xml version="1.0" encoding="UTF-8"?>
<infinispan xmlns="urn:infinispan:config:12.1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:infinispan:config:12.1 https://infinispan.org/schemas/infinispan-config-12.1.xsd">
<cache-container default-cache="default">
<serialization marshaller="org.infinispan.commons.marshall.JavaSerializationMarshaller">
<allow-list>
<regex>com.example.*</regex>
</allow-list>
</serialization>
<local-cache-configuration name="mirrorFile">
<persistence passivation="false">
<file-store path="${infinispan.disk.store.dir}"
shared="false"
preload="false"
purge="false"
segmented="false">
</file-store>
</persistence>
</local-cache-configuration>
<local-cache name="person" statistics="true" configuration="mirrorFile">
<memory max-count="500"/>
<expiration lifespan="86400000"/>
</local-cache>
</cache-container>
</infinispan>
I request the endpoint to get the person with id '1': http://localhost:8090/assets-webapp/person/1
PersonService.getPerson(String)
is called the first time and the result is cached.
I request again the endpoint to get the person with id '1', and I retrieve the result in the cache.
I update the Person
object by removing the extra
field with getter/setter, and I add a extra2
field:
public class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String id;
private String firstName;
private String lastName;
private Integer age;
private Gender gender;
private String extra2;
...
public String getExtra2() {
return extra2;
}
public void setExtra2(String extra2) {
this.extra2 = extra2;
}
}
I request again the endpoint to get the person with id '1', but a ClassCastException
is thrown:
java.lang.ClassCastException: com.example.controller.Person cannot be cast to com.example.controller.Person] with root cause java.lang.ClassCastException: com.example.controller.Person cannot be cast to com.example.controller.Person
at com.example.controller.PersonServiceImpl$$EnhancerBySpringCGLIB$$ec42b86.getPerson(<generated>) ~[classes/:?]
at com.example.controller.PersonController.getPerson(PersonController.java:19) ~[classes/:?]
I rollback the Person object modifications by removing the extra2
field and adding the extra
field.
I request again the endpoint to get the person with id '1', but a ClassCastException
is always thrown
The marshaller used by infinispan is JavaSerializationMarshaller.
I guess java serialization does not allow to unmarchall the cached data if the class has been recompiled.
But I would like to know how to avoid this, and especially to be able to manage updates of the class (adding/removing fields) without having an exception when accessing the cached data.
Does anyone have a solution?
Upvotes: 2
Views: 1755
Reputation: 631
I finally created my own Marshaller that serialize/deserialize in JSON, inspired by the following class: GenericJackson2JsonRedisSerializer.java
public class JsonMarshaller extends AbstractMarshaller {
private static final byte[] EMPTY_ARRAY = new byte[0];
private final ObjectMapper objectMapper;
public JsonMarshaller() {
this.objectMapper = objectMapper();
}
private ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.enable(JsonGenerator.Feature.IGNORE_UNKNOWN);
objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
objectMapper.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY);
objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
// Serialize/Deserialize objects from any fields or creators (constructors and (static) factory methods). Ignore getters/setters.
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE);
objectMapper.setVisibility(PropertyAccessor.CREATOR, JsonAutoDetect.Visibility.ANY);
objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
// Register support of other new Java 8 datatypes outside of date/time: most notably Optional, OptionalLong, OptionalDouble
objectMapper.registerModule(new Jdk8Module());
// Register support for Java 8 date/time types (specified in JSR-310 specification)
objectMapper.registerModule(new JavaTimeModule());
// simply setting {@code mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)} does not help here since we need
// the type hint embedded for deserialization using the default typing feature.
objectMapper.registerModule(new SimpleModule("NullValue Module").addSerializer(new NullValueSerializer(null)));
objectMapper.registerModule(
new SimpleModule("SimpleKey Module")
.addSerializer(new SimpleKeySerializer())
.addDeserializer(SimpleKey.class, new SimpleKeyDeserializer(objectMapper))
);
return objectMapper;
}
@Override
protected ByteBuffer objectToBuffer(Object o, int estimatedSize) throws IOException, InterruptedException {
return ByteBufferImpl.create(objectToBytes(o));
}
private byte[] objectToBytes(Object o) throws JsonProcessingException {
if (o == null) {
return EMPTY_ARRAY;
}
return objectMapper.writeValueAsBytes(o);
}
@Override
public Object objectFromByteBuffer(byte[] buf, int offset, int length) throws IOException, ClassNotFoundException {
if (isEmpty(buf)) {
return null;
}
return objectMapper.readValue(buf, Object.class);
}
@Override
public boolean isMarshallable(Object o) throws Exception {
return true;
}
@Override
public MediaType mediaType() {
return MediaType.APPLICATION_JSON;
}
private static boolean isEmpty(byte[] data) {
return (data == null || data.length == 0);
}
/**
* {@link StdSerializer} adding class information required by default typing. This allows de-/serialization of {@link NullValue}.
*/
private static class NullValueSerializer extends StdSerializer<NullValue> {
private static final long serialVersionUID = 1999052150548658808L;
private final String classIdentifier;
/**
* @param classIdentifier can be {@literal null} and will be defaulted to {@code @class}.
*/
NullValueSerializer(String classIdentifier) {
super(NullValue.class);
this.classIdentifier = StringUtils.isNotBlank(classIdentifier) ? classIdentifier : "@class";
}
@Override
public void serialize(NullValue value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
jgen.writeStartObject();
jgen.writeStringField(classIdentifier, NullValue.class.getName());
jgen.writeEndObject();
}
}
}
The Serializer/Deserializer for SimpleKey object:
public class SimpleKeySerializer extends StdSerializer<SimpleKey> {
private static final Logger LOG = LoggerFactory.getLogger(SimpleKeySerializer.class);
protected SimpleKeySerializer() {
super(SimpleKey.class);
}
@Override
public void serialize(SimpleKey simpleKey, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeStartObject();
serializeFields(simpleKey, gen, provider);
gen.writeEndObject();
}
@Override
public void serializeWithType(SimpleKey value, JsonGenerator gen, SerializerProvider provider, TypeSerializer typeSer) throws IOException {
WritableTypeId typeId = typeSer.typeId(value, JsonToken.START_OBJECT);
typeSer.writeTypePrefix(gen, typeId);
serializeFields(value, gen, provider);
typeSer.writeTypeSuffix(gen, typeId);
}
private void serializeFields(SimpleKey simpleKey, JsonGenerator gen, SerializerProvider provider) {
try {
Object[] params = (Object[]) FieldUtils.readField(simpleKey, "params", true);
gen.writeArrayFieldStart("params");
gen.writeObject(params);
gen.writeEndArray();
} catch (Exception e) {
LOG.warn("Could not read 'params' field from SimpleKey {}: {}", simpleKey, e.getMessage(), e);
}
}
}
public class SimpleKeyDeserializer extends StdDeserializer<SimpleKey> {
private final ObjectMapper objectMapper;
public SimpleKeyDeserializer(ObjectMapper objectMapper) {
super(SimpleKey.class);
this.objectMapper = objectMapper;
}
@Override
public SimpleKey deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException {
List<Object> params = new ArrayList<>();
TreeNode treeNode = jp.getCodec().readTree(jp);
TreeNode paramsNode = treeNode.get("params");
if (paramsNode.isArray()) {
for (JsonNode paramNode : (ArrayNode) paramsNode) {
Object[] values = this.objectMapper.treeToValue(paramNode, Object[].class);
params.addAll(Arrays.asList(values));
}
}
return new SimpleKey(params.toArray());
}
}
And I configured infinispan like the following:
<?xml version="1.0" encoding="UTF-8"?>
<infinispan xmlns="urn:infinispan:config:12.1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:infinispan:config:12.1 https://infinispan.org/schemas/infinispan-config-12.1.xsd">
<cache-container default-cache="default">
<serialization marshaller="com.example.JsonMarshaller">
<allow-list>
<regex>com.example.*</regex>
</allow-list>
</serialization>
<local-cache-configuration name="mirrorFile">
<persistence passivation="false">
<file-store path="${infinispan.disk.store.dir}"
shared="false"
preload="false"
purge="false"
segmented="false">
</file-store>
</persistence>
</local-cache-configuration>
<local-cache name="person" statistics="true" configuration="mirrorFile">
<memory max-count="500"/>
<expiration lifespan="86400000"/>
</local-cache>
</cache-container>
</infinispan>
Upvotes: 2
Reputation: 7204
The best option would be to change the cache encoding to application/x-protostream
and to serialize your objects using the ProtoStream library.
<local-cache-configuration name="mirrorFile">
<encoding>
<key media-type="application/x-protostream"/>
<value media-type="application/x-protostream"/>
</encoding>
</local-cache>
Infinispan caches default to holding the actual Java object in memory, without serializing it. The configured marshaller is only used for writing the entries to disk.
When you modify the class, Spring likely creates a new class with the same name in a new classloader. But the objects in the cache still use the class from the old classloader, so they are not compatible with the new class.
Configuring an encoding media type other than application/x-java-object
tells Infinispan to serialize objects that stay in memory as well.
You could also change the cache encoding to application/x-java-serialized-object
, so that your objects are stored in memory using the JavaSerializationMarshaller
it is already using for storing objects on disk. But maintaining compatibility with older versions using Java serialization is a lot of work, and requires planning ahead of time: you need a serialVersionUUID
field, possibly a version field, and a readExternal()
implementation that can read the old formats. With ProtoStream, as it is based on Protobuf schemas, you can easily add new (optional) fields and ignore no-longer used fields, as long as you don't change or reuse field numbers.
Upvotes: 0