Spring Boot встроенный Infinispan — как предотвратить исключение ClassCastException при изменении кэшируемого объекта?

#java #spring #spring-boot #caching #infinispan

Вопрос:

У меня есть веб-приложение с Spring Boot 2.5.5 и встроенным Infinispan 12.1.7.

У меня есть контроллер с конечной точкой для получения объекта 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);
    }
}
 

Ниже PersonService приведена реализация с использованием @Cacheable аннотации к getPerson методу :

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

А вот и класс Персон:

 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>
 

Я запрашиваю конечную точку, чтобы получить человека с идентификатором «1»: http://localhost:8090/assets-webapp/person/1
PersonService.getPerson(String) вызывается в первый раз, и результат кэшируется.
Я снова запрашиваю конечную точку, чтобы получить человека с идентификатором «1», и получаю результат в кэше.

Я обновляю Person объект, удаляя extra поле с помощью getter/setter, и добавляю 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;
    }
}
 

Я снова запрашиваю конечную точку, чтобы получить человека с идентификатором «1», но ClassCastException выбрасывается a:

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

Я откатываю изменения объекта Person, удаляя extra2 поле и добавляя extra поле.
Я снова запрашиваю конечную точку, чтобы получить человека с идентификатором «1», но a ClassCastException всегда выбрасывается

Маршаллер, используемый infinispan, — это JavaSerializationMarshaller.

Я предполагаю, что сериализация java не позволяет разархивировать кэшированные данные, если класс был перекомпилирован.

Но я хотел бы знать, как этого избежать, и особенно чтобы иметь возможность управлять обновлениями класса (добавлением/удалением полей) без исключения при доступе к кэшированным данным.

У кого-нибудь есть решение?

Комментарии:

1. Поскольку исключение приведения класса не имеет смысла (это один и тот же класс), я бы предположил, что это проблема с загрузчиком классов. Какова конфигурация кэша Spring?

2. @cruftex Я только что добавил @EnableCaching, чтобы включить кэширование, и я использую @Cacheable в методе PersonServiceImpl.getPerson(идентификатор строки). Свойства: spring.cache.type=infinispan, infinispan.embedded.configXml=config/cache/infinispan.xml

Ответ №1:

Я, наконец, создал свой собственный маршаллер, который сериализует/десериализует в 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 и сериализовать ваши объекты с помощью библиотеки ProtoStream.

 <local-cache-configuration name="mirrorFile">
   <encoding>
      <key media-type="application/x-protostream"/>
      <value media-type="application/x-protostream"/>
   </encoding>
</local-cache>
 

Infinispan кэширует по умолчанию для хранения фактического объекта Java в памяти без его сериализации. Настроенный маршаллер используется только для записи записей на диск.

При изменении класса Spring, скорее всего, создаст новый класс с тем же именем в новом загрузчике классов. Но объекты в кэше по-прежнему используют класс из старого загрузчика классов, поэтому они несовместимы с новым классом.

Настройка типа носителя кодирования, отличного от application/x-java-object , указывает Infinispan на сериализацию объектов, которые также остаются в памяти.

Вы также можете изменить кодировку кэша на application/x-java-serialized-object , чтобы ваши объекты хранились в памяти, используя JavaSerializationMarshaller то, что он уже использует для хранения объектов на диске. Но поддержание совместимости со старыми версиями с использованием сериализации Java-это большая работа, и она требует заблаговременного планирования: вам нужно serialVersionUUID поле, возможно, поле версии, и readExternal() реализация, которая может считывать старые форматы. С помощью ProtoStream, поскольку он основан на схемах Protobuf, вы можете легко добавлять новые (необязательные) поля и игнорировать больше не используемые поля, если вы не изменяете или не используете номера полей повторно.

Комментарии:

1. Я протестировал кодировку кэша в 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.

2. Это исключение кажется мне нормальным, потому что мне нужно предоставить некоторую информацию, чтобы Infinispan мог отправлять мой объект в кэш и из него. Мой пример прост, и я мог бы это сделать. Но у меня также есть более сложные случаи, когда у меня есть много классов, созданных из документов OpenAPI (файлов yml) и кэшированных после вызова веб — служб. Это означает, что мне придется создавать конфигурации для всех сгенерированных классов с помощью адаптера сортировки (@ProtoAdapter). Правильно ли это, или есть другое решение, которое не требует слишком большой настройки?

3. @NicolasDosSantos Боюсь, что нет. Для маршалинга с помощью ProtoStream требуется, чтобы каждое поле имело точный фиксированный номер тега, чтобы вы могли добавлять/удалять поля и сохранять совместимость при изменении класса. Но если ваши исходные данные-это yaml, может быть, вы можете кэшировать текст yaml вместо проанализированных объектов?