Skip to content

Commit 4c54bf9

Browse files
christophstroblmp911de
authored andcommitted
DATAREDIS-553 - Support caching null values via RedisCache.
We now support caching `null` values in `RedisCache` by storing a dedicated `NullValue` reference as a placeholder. To enable this feature please set up `RedisCacheManager` accordingly and make sure the used `RedisSerializer` is capable of dealing the `NullValue` type. Both the `JdkSerializationRedisSerializer` and the `GenericJackson2JsonRedisSerializer` support this out of the box. Original pull request: spring-projects#221.
1 parent 72d6df0 commit 4c54bf9

File tree

8 files changed

+259
-28
lines changed

8 files changed

+259
-28
lines changed

src/main/asciidoc/reference/redis.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,4 +546,5 @@ NOTE: By default `RedisCacheManager` will not participate in any ongoing transac
546546

547547
NOTE: By default `RedisCacheManager` does not prefix keys for cache regions, which can lead to an unexpected growth of a `ZSET` used to maintain known keys. It's highly recommended to enable the usage of prefixes in order to avoid this unexpected growth and potential key clashes using more than one cache region.
548548

549+
NOTE: By default `RedisCache` will not cache any `null` values as keys without a value get dropped by Redis itself. However you can explicitly enable `null` value caching via `RedisCacheManager` which will store `org.springframework.cache.support.NullValue` as a placeholder.
549550

src/main/java/org/springframework/data/redis/cache/RedisCache.java

Lines changed: 92 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
import java.util.Set;
2424
import java.util.concurrent.Callable;
2525

26-
import org.springframework.cache.Cache;
26+
import org.springframework.cache.support.AbstractValueAdaptingCache;
27+
import org.springframework.cache.support.NullValue;
2728
import org.springframework.cache.support.SimpleValueWrapper;
2829
import org.springframework.dao.DataAccessException;
2930
import org.springframework.data.redis.RedisSystemException;
@@ -33,6 +34,10 @@
3334
import org.springframework.data.redis.connection.ReturnType;
3435
import org.springframework.data.redis.core.RedisCallback;
3536
import org.springframework.data.redis.core.RedisOperations;
37+
import org.springframework.data.redis.serializer.GenericToStringSerializer;
38+
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
39+
import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer;
40+
import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer;
3641
import org.springframework.data.redis.serializer.RedisSerializer;
3742
import org.springframework.data.redis.serializer.StringRedisSerializer;
3843
import org.springframework.util.ClassUtils;
@@ -47,7 +52,7 @@
4752
* @author Mark Paluch
4853
*/
4954
@SuppressWarnings("unchecked")
50-
public class RedisCache implements Cache {
55+
public class RedisCache extends AbstractValueAdaptingCache {
5156

5257
@SuppressWarnings("rawtypes")//
5358
private final RedisOperations redisOperations;
@@ -64,13 +69,46 @@ public class RedisCache implements Cache {
6469
*/
6570
public RedisCache(String name, byte[] prefix, RedisOperations<? extends Object, ? extends Object> redisOperations,
6671
long expiration) {
72+
this(name, prefix, redisOperations, expiration, false);
73+
}
74+
75+
/**
76+
* Constructs a new <code>RedisCache</code> instance.
77+
*
78+
* @param name cache name
79+
* @param prefix
80+
* @param redisOperations
81+
* @param expiration
82+
* @param allowNullValues
83+
* @since 1.8
84+
*/
85+
public RedisCache(String name, byte[] prefix, RedisOperations<? extends Object, ? extends Object> redisOperations,
86+
long expiration, boolean allowNullValues) {
87+
88+
super(allowNullValues);
6789

6890
hasText(name, "non-empty cache name is required");
6991
this.cacheMetadata = new RedisCacheMetadata(name, prefix);
7092
this.cacheMetadata.setDefaultExpiration(expiration);
7193

7294
this.redisOperations = redisOperations;
73-
this.cacheValueAccessor = new CacheValueAccessor(redisOperations.getValueSerializer());
95+
96+
RedisSerializer<?> serializer = redisOperations.getValueSerializer() != null ? redisOperations.getValueSerializer()
97+
: (RedisSerializer<?>) new JdkSerializationRedisSerializer();
98+
99+
this.cacheValueAccessor = new CacheValueAccessor(serializer);
100+
101+
if (allowNullValues) {
102+
103+
if (redisOperations.getValueSerializer() instanceof StringRedisSerializer
104+
|| redisOperations.getValueSerializer() instanceof GenericToStringSerializer
105+
|| redisOperations.getValueSerializer() instanceof JacksonJsonRedisSerializer
106+
|| redisOperations.getValueSerializer() instanceof Jackson2JsonRedisSerializer) {
107+
throw new IllegalArgumentException(String.format(
108+
"Redis does not allow keys with null value ¯\\_(ツ)_/¯. The chosen %s does not support generic type handling and therefore cannot be used with allowNullValues enabled. Please use a different RedisSerializer or disable null value support.",
109+
ClassUtils.getShortName(redisOperations.getValueSerializer().getClass())));
110+
}
111+
}
74112
}
75113

76114
/**
@@ -135,16 +173,19 @@ public RedisCacheElement get(final RedisCacheKey cacheKey) {
135173

136174
notNull(cacheKey, "CacheKey must not be null!");
137175

138-
byte[] bytes = (byte[]) redisOperations.execute(new AbstractRedisCacheCallback<byte[]>(new BinaryRedisCacheElement(
139-
new RedisCacheElement(cacheKey, null), cacheValueAccessor), cacheMetadata) {
176+
Boolean exists = (Boolean) redisOperations.execute(new RedisCallback<Boolean>() {
140177

141178
@Override
142-
public byte[] doInRedis(BinaryRedisCacheElement element, RedisConnection connection) throws DataAccessException {
143-
return connection.get(element.getKeyBytes());
179+
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
180+
return connection.exists(cacheKey.getKeyBytes());
144181
}
145182
});
146183

147-
return (bytes == null ? null : new RedisCacheElement(cacheKey, cacheValueAccessor.deserializeIfNecessary(bytes)));
184+
if (!exists.booleanValue()) {
185+
return null;
186+
}
187+
188+
return new RedisCacheElement(cacheKey, fromStoreValue(lookup(cacheKey)));
148189
}
149190

150191
/*
@@ -154,8 +195,24 @@ public byte[] doInRedis(BinaryRedisCacheElement element, RedisConnection connect
154195
@Override
155196
public void put(final Object key, final Object value) {
156197

157-
put(new RedisCacheElement(new RedisCacheKey(key).usePrefix(cacheMetadata.getKeyPrefix()).withKeySerializer(
158-
redisOperations.getKeySerializer()), value).expireAfter(cacheMetadata.getDefaultExpiration()));
198+
put(new RedisCacheElement(new RedisCacheKey(key).usePrefix(cacheMetadata.getKeyPrefix())
199+
.withKeySerializer(redisOperations.getKeySerializer()), toStoreValue(value))
200+
.expireAfter(cacheMetadata.getDefaultExpiration()));
201+
}
202+
203+
/*
204+
* (non-Javadoc)
205+
* @see org.springframework.cache.support.AbstractValueAdaptingCache#fromStoreValue(java.lang.Object)
206+
*/
207+
@Override
208+
protected Object fromStoreValue(Object storeValue) {
209+
210+
// we need this override for the GenericJackson2JsonRedisSerializer support.
211+
if (isAllowNullValues() && storeValue instanceof NullValue) {
212+
return null;
213+
}
214+
215+
return super.fromStoreValue(storeValue);
159216
}
160217

161218
/**
@@ -181,8 +238,8 @@ public void put(RedisCacheElement element) {
181238
public ValueWrapper putIfAbsent(Object key, final Object value) {
182239

183240
return putIfAbsent(new RedisCacheElement(new RedisCacheKey(key).usePrefix(cacheMetadata.getKeyPrefix())
184-
.withKeySerializer(redisOperations.getKeySerializer()), value)
185-
.expireAfter(cacheMetadata.getDefaultExpiration()));
241+
.withKeySerializer(redisOperations.getKeySerializer()), toStoreValue(value))
242+
.expireAfter(cacheMetadata.getDefaultExpiration()));
186243
}
187244

188245
/**
@@ -253,6 +310,29 @@ private ValueWrapper toWrapper(Object value) {
253310
return (value != null ? new SimpleValueWrapper(value) : null);
254311
}
255312

313+
/*
314+
* (non-Javadoc)
315+
* @see org.springframework.cache.support.AbstractValueAdaptingCache#lookup(java.lang.Object)
316+
*/
317+
@Override
318+
protected Object lookup(Object key) {
319+
320+
RedisCacheKey cacheKey = key instanceof RedisCacheKey ? (RedisCacheKey) key
321+
: new RedisCacheKey(key).usePrefix(this.cacheMetadata.getKeyPrefix())
322+
.withKeySerializer(redisOperations.getKeySerializer());
323+
324+
byte[] bytes = (byte[]) redisOperations.execute(new AbstractRedisCacheCallback<byte[]>(
325+
new BinaryRedisCacheElement(new RedisCacheElement(cacheKey, null), cacheValueAccessor), cacheMetadata) {
326+
327+
@Override
328+
public byte[] doInRedis(BinaryRedisCacheElement element, RedisConnection connection) throws DataAccessException {
329+
return connection.get(element.getKeyBytes());
330+
}
331+
});
332+
333+
return bytes == null ? null : cacheValueAccessor.deserializeIfNecessary(bytes);
334+
}
335+
256336
/**
257337
* Metadata required to maintain {@link RedisCache}. Keeps track of additional data structures required for processing
258338
* cache operations.

src/main/java/org/springframework/data/redis/cache/RedisCacheManager.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@
2929
import org.apache.commons.logging.LogFactory;
3030
import org.springframework.cache.Cache;
3131
import org.springframework.cache.CacheManager;
32+
import org.springframework.cache.support.NullValue;
3233
import org.springframework.cache.transaction.AbstractTransactionSupportingCacheManager;
3334
import org.springframework.cache.transaction.TransactionAwareCacheDecorator;
3435
import org.springframework.dao.DataAccessException;
3536
import org.springframework.data.redis.connection.RedisConnection;
3637
import org.springframework.data.redis.core.RedisCallback;
3738
import org.springframework.data.redis.core.RedisOperations;
39+
import org.springframework.data.redis.serializer.RedisSerializer;
3840
import org.springframework.util.Assert;
3941
import org.springframework.util.CollectionUtils;
4042

@@ -70,6 +72,8 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager
7072

7173
private Set<String> configuredCacheNames;
7274

75+
private final boolean cacheNullValues;
76+
7377
/**
7478
* Construct a {@link RedisCacheManager}.
7579
*
@@ -89,7 +93,25 @@ public RedisCacheManager(RedisOperations redisOperations) {
8993
*/
9094
@SuppressWarnings("rawtypes")
9195
public RedisCacheManager(RedisOperations redisOperations, Collection<String> cacheNames) {
96+
this(redisOperations, cacheNames, false);
97+
}
98+
99+
/**
100+
* Construct a static {@link RedisCacheManager}, managing caches for the specified cache names only. <br />
101+
* <br />
102+
* <strong>NOTE</strong> When enabling {@code cacheNullValues} please make sure the {@link RedisSerializer} used by
103+
* {@link RedisOperations} is capable of serializing {@link NullValue}.
104+
*
105+
* @param redisOperations {@link RedisOperations} to work upon.
106+
* @param cacheNames {@link Collection} of known cache names.
107+
* @param cacheNullValues set to {@literal true} to allow caching {@literal null}.
108+
* @since 1.8
109+
*/
110+
@SuppressWarnings("rawtypes")
111+
public RedisCacheManager(RedisOperations redisOperations, Collection<String> cacheNames, boolean cacheNullValues) {
112+
92113
this.redisOperations = redisOperations;
114+
this.cacheNullValues = cacheNullValues;
93115
setCacheNames(cacheNames);
94116
}
95117

@@ -235,7 +257,8 @@ protected Cache getMissingCache(String name) {
235257
@SuppressWarnings("unchecked")
236258
protected RedisCache createCache(String cacheName) {
237259
long expiration = computeExpiration(cacheName);
238-
return new RedisCache(cacheName, (usePrefix ? cachePrefix.prefix(cacheName) : null), redisOperations, expiration);
260+
return new RedisCache(cacheName, (usePrefix ? cachePrefix.prefix(cacheName) : null), redisOperations, expiration,
261+
cacheNullValues);
239262
}
240263

241264
protected long computeExpiration(String name) {

src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,22 @@
1515
*/
1616
package org.springframework.data.redis.serializer;
1717

18+
import java.io.IOException;
19+
20+
import org.springframework.cache.support.NullValue;
1821
import org.springframework.util.Assert;
1922
import org.springframework.util.StringUtils;
2023

2124
import com.fasterxml.jackson.annotation.JsonTypeInfo;
2225
import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
26+
import com.fasterxml.jackson.core.JsonGenerator;
2327
import com.fasterxml.jackson.core.JsonProcessingException;
2428
import com.fasterxml.jackson.databind.ObjectMapper;
2529
import com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping;
30+
import com.fasterxml.jackson.databind.SerializerProvider;
31+
import com.fasterxml.jackson.databind.module.SimpleModule;
2632
import com.fasterxml.jackson.databind.ser.SerializerFactory;
33+
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
2734

2835
/**
2936
* @author Christoph Strobl
@@ -51,6 +58,10 @@ public GenericJackson2JsonRedisSerializer(String classPropertyTypeName) {
5158

5259
this(new ObjectMapper());
5360

61+
// simply setting {@code mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)} does not help here since we need
62+
// the type hint embedded for deserialization using the default typing feature.
63+
mapper.registerModule(new SimpleModule().addSerializer(new NullValueSerializer(classPropertyTypeName)));
64+
5465
if (StringUtils.hasText(classPropertyTypeName)) {
5566
mapper.enableDefaultTypingAsProperty(DefaultTyping.NON_FINAL, classPropertyTypeName);
5667
} else {
@@ -119,4 +130,40 @@ public <T> T deserialize(byte[] source, Class<T> type) throws SerializationExcep
119130
throw new SerializationException("Could not read JSON: " + ex.getMessage(), ex);
120131
}
121132
}
133+
134+
/**
135+
* {@link StdSerializer} adding class information required by default typing. This allows de-/seriialization of
136+
* {@link NullValue}.
137+
*
138+
* @author Christoph Strobl
139+
* @since 1.8
140+
*/
141+
private class NullValueSerializer extends StdSerializer<NullValue> {
142+
143+
private static final long serialVersionUID = 1999052150548658808L;
144+
private final String classIdentifyer;
145+
146+
/**
147+
* @param classIdentifyer can be {@literal null} and will be defaulted to {@code @class}.
148+
*/
149+
NullValueSerializer(String classIdentifyer) {
150+
151+
super(NullValue.class);
152+
this.classIdentifyer = StringUtils.hasText(classIdentifyer) ? classIdentifyer : "@class";
153+
}
154+
155+
/*
156+
* (non-Javadoc)
157+
* @see com.fasterxml.jackson.databind.ser.std.StdSerializer#serialize(java.lang.Object, com.fasterxml.jackson.core.JsonGenerator, com.fasterxml.jackson.databind.SerializerProvider)
158+
*/
159+
@Override
160+
public void serialize(NullValue value, JsonGenerator jgen, SerializerProvider provider)
161+
throws IOException, JsonProcessingException {
162+
163+
jgen.writeStartObject();
164+
jgen.writeStringField(classIdentifyer, NullValue.class.getName());
165+
jgen.writeEndObject();
166+
}
167+
168+
}
122169
}

src/test/java/org/springframework/data/redis/cache/AbstractNativeCacheTest.java

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,8 @@
1616

1717
package org.springframework.data.redis.cache;
1818

19-
import static org.junit.Assert.assertEquals;
20-
import static org.junit.Assert.assertNotNull;
21-
import static org.junit.Assert.assertNull;
22-
import static org.junit.Assert.assertSame;
23-
import static org.junit.Assert.assertThat;
24-
import static org.springframework.data.redis.matcher.RedisTestMatchers.isEqual;
19+
import static org.junit.Assert.*;
20+
import static org.springframework.data.redis.matcher.RedisTestMatchers.*;
2521

2622
import org.junit.Before;
2723
import org.junit.Test;
@@ -38,22 +34,31 @@ public abstract class AbstractNativeCacheTest<T> {
3834
private T nativeCache;
3935
protected Cache cache;
4036
protected final static String CACHE_NAME = "testCache";
37+
private final boolean allowCacheNullValues;
38+
39+
protected AbstractNativeCacheTest(boolean allowCacheNullValues) {
40+
this.allowCacheNullValues = allowCacheNullValues;
41+
}
4142

4243
@Before
4344
public void setUp() throws Exception {
4445
nativeCache = createNativeCache();
45-
cache = createCache(nativeCache);
46+
cache = createCache(nativeCache, allowCacheNullValues);
4647
cache.clear();
4748
}
4849

4950
protected abstract T createNativeCache() throws Exception;
5051

51-
protected abstract Cache createCache(T nativeCache);
52+
protected abstract Cache createCache(T nativeCache, boolean allowCacheNullValues);
5253

5354
protected abstract Object getKey();
5455

5556
protected abstract Object getValue();
5657

58+
protected boolean getAllowCacheNullValues() {
59+
return allowCacheNullValues;
60+
}
61+
5762
@Test
5863
public void testCacheName() throws Exception {
5964
assertEquals(CACHE_NAME, cache.getName());

0 commit comments

Comments
 (0)