Spring Boot + Infinispan Embedded - 当要缓存的对象已被修改时如何防止 ClassCastException?

Posted

技术标签:

【中文标题】Spring Boot + Infinispan Embedded - 当要缓存的对象已被修改时如何防止 ClassCastException?【英文标题】:Spring Boot + Infinispan embedded - how to prevent ClassCastException when the object to be cached has been modified? 【发布时间】:2021-12-09 23:01:35 【问题描述】:

我有一个带有 Spring Boot 2.5.5 和嵌入式 Infinispan 12.1.7 的 Web 应用程序。

我有一个带有端点的控制器,可以通过 ID 获取 Person 对象:

@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);
    

以下是在getPerson 方法上使用@Cacheable 注释的PersonService 实现:

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;
    

这是 Person 类:

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 */
    ...

我将 infinispan 配置为使用基于文件系统的缓存存储:

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

我请求端点获取 id 为“1”的人:http://localhost:8090/assets-webapp/person/1PersonService.getPerson(String) 第一次被调用,结果被缓存。 我再次请求端点以获取 id 为“1”的人,并在缓存中检索结果。

我通过使用 getter/setter 删除 extra 字段来更新 Person 对象,并添加一个 extra2 字段:

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;
    

我再次请求端点获取 id 为“1”的人,但抛出了 ClassCastException

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/:?]

我通过删除 extra2 字段并添加 extra 字段来回滚 Person 对象修改。 我再次请求端点获取 id 为“1”的人,但总是抛出 ClassCastException

infinispan 使用的编组器是JavaSerializationMarshaller。

如果类已经重新编译,我猜 java 序列化不允许取消缓存数据。

但我想知道如何避免这种情况,尤其是能够在访问缓存数据时管理类的更新(添加/删除字段)而不会出现异常。

有人有解决办法吗?

【问题讨论】:

由于类转换异常没有意义(它是同一个类),我推测它是一个类加载器问题。 Spring缓存配置是什么? @cruftex 我刚刚添加了 \@EnableCaching 来启用缓存,我在 PersonServiceImpl.getPerson(String id) 方法上使用了 \@Cacheable。属性:spring.cache.type=infinispan, infinispan.embedded.configXml=config/cache/infinispan.xml 【参考方案1】:

受以下类的启发,我终于创建了自己的 Marshaller 序列化/反序列化 JSON: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();
        
    

SimpleKey 对象的序列化器/反序列化器:

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());
    


我配置 infinispan 如下:

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

【讨论】:

【参考方案2】:

最好的选择是将缓存编码更改为application/x-protostream 并序列化您的对象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 缓存默认将实际 Java 对象保存在内存中,而不对其进行序列化。配置的 marshaller 仅用于将条目写入磁盘。

当您修改类时,Spring 可能会在新的类加载器中创建一个具有相同名称的新类。但是缓存中的对象仍然使用旧类加载器中的类,因此它们与新类不兼容。

配置除application/x-java-object 之外的编码媒体类型会告诉 Infinispan 也序列化保留在内存中的对象。

您还可以将缓存编码更改为application/x-java-serialized-object,以便您的对象使用JavaSerializationMarshaller 存储在内存中,它已经用于在磁盘上存储对象。但是使用 Java 序列化来保持与旧版本的兼容性需要大量工作,并且需要提前计划:您需要一个 serialVersionUUID 字段,可能是一个版本字段,以及一个可以读取旧格式的 readExternal() 实现。使用 ProtoStream,因为它基于 Protobuf 模式,您可以轻松添加新的(可选)字段并忽略不再使用的字段,只要您不更改或重复使用字段编号。

【讨论】:

我测试了缓存编码到 application/x-protostream,没有其他配置(@ProtoField,@ProtoAdapter ...),我收到以下错误:org.infinispan.commons.marshall.MarshallingException : ISPN000615 : Unable to unmarshall 'com.example.controller.Person' as a marshaller is not present in the user or global SerializationContext] with root cause org.infinispan.commons.marshall.MarshallingException : ISPN000615 : Unable to unmarshall 'com.example.controller.Person' as a marshaller is not present in the user or global SerializationContext. 这个异常对我来说似乎很正常,因为我需要提供一些信息,以便 Infinispan 可以将我的对象编组到我的缓存中。我的例子很简单,我可以做到这一点。但我也有更复杂的情况,我有很多从 OpenAPI 文档(yml 文件)生成的类,并在调用 Web 服务后缓存。这意味着我必须使用编组适配器 (@ProtoAdapter) 为所有生成的类创建配置。是这样吗,还是有其他不需要太多配置的解决方案? @NicolasDosSantos 恐怕不会。使用 ProtoStream 进行编组需要每个字段精确地具有固定的标签号,以便您可以添加/删除字段并在更改类时保持兼容性。但是如果你的原始数据是yaml,也许你可以缓存yaml文本而不是解析的对象?

以上是关于Spring Boot + Infinispan Embedded - 当要缓存的对象已被修改时如何防止 ClassCastException?的主要内容,如果未能解决你的问题,请参考以下文章

Spring 和 WildFly Infinispan 缓存查找

spring boot学习(十三)SpringBoot缓存(EhCache 2.x 篇)

为啥休眠 5.3 不支持带有 infinispan 的事务缓存

Infinispan 初始状态传输超时

infinispan 中的 ISPN000313 错误

Infinispan - 没有删除缓存的选项吗?