Alejandro Gomez
Alejandro Gomez

Reputation: 51

How to store non-typed JSONs in Redis with Spring Boot 2 CacheManager

I'm using Redis as a cache storage in a Spring Boot 2 application. I'm using the @Cacheable annotation in some methods and I want to store the data in Redis as non-typed JSONs. With current my configuration, saving the data works fine but reading it is generating a ClassCastException.

All the solutions, answers, examples and tutorials use Jackson's ObjectMapper to configure either RedisTemplate or RedisCacheConfiguration adding a default typing attribute to the JSON. The thing here is this cache is going to be shared by different apps in different languages/technologies and I can't force the rest of the apps to work as Spring Boot does.

Here's what I have right now:

Config

@Bean
CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
    return RedisCacheManagerBuilder.fromConnectionFactory(connectionFactory)
            .cacheDefaults(cacheConfiguration())
            .build();
}

private RedisCacheConfiguration cacheConfiguration() {
    return RedisCacheConfiguration.defaultCacheConfig()
            .disableCachingNullValues()
            .serializeKeysWith(SerializationPair.fromSerializer(RedisSerializer.string()))
            .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(redisMapper())));
}

private ObjectMapper redisMapper() {
    return new ObjectMapper()
                //.enableDefaultTyping(DefaultTyping.NON_FINAL, As.PROPERTY)
                .setSerializationInclusion(JsonInclude.Include.NON_EMPTY);
}

Service

@Cacheable(key = "'persons::' + #id")
public Person getPerson(Long id) {
    // return person from DB
}

Result with the current config:

{
  "name": "John",
  "lastName": "Doe",
  "age": 31
}

When Spring tries to read the content from the cache using the deserializers, it doesn't find a "@class" attribute with the type information so it returns a LinkedHashMap. After that the CacheInterceptor tries to convert the LinkedHashMap to a Person and there's when the ClassCastException occurs.

Request processing failed; nested exception is java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to com.test.dto.Person

At this point I'm OK if I need to write a serializer per type I want to store or maybe I can create a custom one for all. So far my research has been unsuccessful.

Thanks for your time.

Upvotes: 2

Views: 3058

Answers (2)

Ondřej Stašek
Ondřej Stašek

Reputation: 859

I'm fighting with this one right now too. And even add typing won't help you because if you use @Cacheable on method returning "primitive" type such as Long (even in collections), you get Integer back, because jackson uses smart number mapping that returns 32-bit Integer if number fits in there.

And yes, you can now use flag USE_LONG_FOR_INTS, but that means it always return Long and not the type that was put in cache in the 1st place.

So if you want to save nice JSON to redis, you can't use @Cacheable, because you can end up with Class cast exception quite often.

BUT you can use cache manager manually, save JSON there as plain text and deserializing it yourself supplying desired return class to object mapper yourself (which unfortunately annotation can't do for you automatically).

Example code (not perfect and not handling exceptions from object mapper):

@Autowired
private CacheManager cacheManager;

@Autowired
private ObjectMapper objectMapper;

public Person getPerson(Long id) {
    Cache persons = cacheManager.getCache("persons");
    if (persons == null) return // cache not exist, should not happen, return person from DB

    return Optional.ofNullable(persons.get(id))
            .map(Cache.ValueWrapper::get)
            .map(o -> objectMapper.readerFor(Person.class).readValue(o))
            .orElseGet(() -> {
                Person p = // get person from DB
                persons.put(id, objectMapper.writeValueAsString(p));
                return p;
            });
}

Upvotes: 0

Henrique La Porta
Henrique La Porta

Reputation: 21

Honestly I'm not sure if this is the answer to your question because it doesn't create such a "clean" and "beautiful" JSON as your example, but it works for me to serialize and deserialize. In this example the cache manager is set TTL you can remove this if you want.

@Configuration
@EnableCaching
public class RedisCacheConfig {

  @Value("${spring.redis.host}")
  private String redisHostName;

  @Value("${spring.redis.port}")
  private int redisPort;

  @Bean
  public LettuceConnectionFactory redisConnectionFactory() {
    return new LettuceConnectionFactory(new RedisStandaloneConfiguration(redisHostName, redisPort));
  }

  @Bean
  public RedisTemplate<Object, Object> redisTemplate() {
    RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<Object, Object>();
    redisTemplate.setConnectionFactory(redisConnectionFactory());
    return redisTemplate;
  }

  @Bean
  @Primary
  public RedisCacheManager redisCacheManager(LettuceConnectionFactory lettuceConnectionFactory) {
    RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig().disableCachingNullValues()
      .entryTtl(Duration.ofMinutes(1))
      .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(RedisSerializer.json()));

    redisCacheConfiguration.usePrefix();

    return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(lettuceConnectionFactory)
      .cacheDefaults(redisCacheConfiguration).build();

  }
}

Upvotes: 1

Related Questions