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?
Advertisement
Answer
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>