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: #221.
This commit is contained in:
Christoph Strobl
2016-09-16 11:09:42 +02:00
committed by Mark Paluch
parent 72d6df00c8
commit 4c54bf972a
8 changed files with 259 additions and 28 deletions

View File

@@ -546,4 +546,5 @@ NOTE: By default `RedisCacheManager` will not participate in any ongoing transac
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.
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.

View File

@@ -23,7 +23,8 @@ import java.util.Arrays;
import java.util.Set;
import java.util.concurrent.Callable;
import org.springframework.cache.Cache;
import org.springframework.cache.support.AbstractValueAdaptingCache;
import org.springframework.cache.support.NullValue;
import org.springframework.cache.support.SimpleValueWrapper;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.RedisSystemException;
@@ -33,6 +34,10 @@ import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.ReturnType;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.serializer.GenericToStringSerializer;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.util.ClassUtils;
@@ -47,7 +52,7 @@ import org.springframework.util.ObjectUtils;
* @author Mark Paluch
*/
@SuppressWarnings("unchecked")
public class RedisCache implements Cache {
public class RedisCache extends AbstractValueAdaptingCache {
@SuppressWarnings("rawtypes")//
private final RedisOperations redisOperations;
@@ -64,13 +69,46 @@ public class RedisCache implements Cache {
*/
public RedisCache(String name, byte[] prefix, RedisOperations<? extends Object, ? extends Object> redisOperations,
long expiration) {
this(name, prefix, redisOperations, expiration, false);
}
/**
* Constructs a new <code>RedisCache</code> instance.
*
* @param name cache name
* @param prefix
* @param redisOperations
* @param expiration
* @param allowNullValues
* @since 1.8
*/
public RedisCache(String name, byte[] prefix, RedisOperations<? extends Object, ? extends Object> redisOperations,
long expiration, boolean allowNullValues) {
super(allowNullValues);
hasText(name, "non-empty cache name is required");
this.cacheMetadata = new RedisCacheMetadata(name, prefix);
this.cacheMetadata.setDefaultExpiration(expiration);
this.redisOperations = redisOperations;
this.cacheValueAccessor = new CacheValueAccessor(redisOperations.getValueSerializer());
RedisSerializer<?> serializer = redisOperations.getValueSerializer() != null ? redisOperations.getValueSerializer()
: (RedisSerializer<?>) new JdkSerializationRedisSerializer();
this.cacheValueAccessor = new CacheValueAccessor(serializer);
if (allowNullValues) {
if (redisOperations.getValueSerializer() instanceof StringRedisSerializer
|| redisOperations.getValueSerializer() instanceof GenericToStringSerializer
|| redisOperations.getValueSerializer() instanceof JacksonJsonRedisSerializer
|| redisOperations.getValueSerializer() instanceof Jackson2JsonRedisSerializer) {
throw new IllegalArgumentException(String.format(
"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.",
ClassUtils.getShortName(redisOperations.getValueSerializer().getClass())));
}
}
}
/**
@@ -135,16 +173,19 @@ public class RedisCache implements Cache {
notNull(cacheKey, "CacheKey must not be null!");
byte[] bytes = (byte[]) redisOperations.execute(new AbstractRedisCacheCallback<byte[]>(new BinaryRedisCacheElement(
new RedisCacheElement(cacheKey, null), cacheValueAccessor), cacheMetadata) {
Boolean exists = (Boolean) redisOperations.execute(new RedisCallback<Boolean>() {
@Override
public byte[] doInRedis(BinaryRedisCacheElement element, RedisConnection connection) throws DataAccessException {
return connection.get(element.getKeyBytes());
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
return connection.exists(cacheKey.getKeyBytes());
}
});
return (bytes == null ? null : new RedisCacheElement(cacheKey, cacheValueAccessor.deserializeIfNecessary(bytes)));
if (!exists.booleanValue()) {
return null;
}
return new RedisCacheElement(cacheKey, fromStoreValue(lookup(cacheKey)));
}
/*
@@ -154,8 +195,24 @@ public class RedisCache implements Cache {
@Override
public void put(final Object key, final Object value) {
put(new RedisCacheElement(new RedisCacheKey(key).usePrefix(cacheMetadata.getKeyPrefix()).withKeySerializer(
redisOperations.getKeySerializer()), value).expireAfter(cacheMetadata.getDefaultExpiration()));
put(new RedisCacheElement(new RedisCacheKey(key).usePrefix(cacheMetadata.getKeyPrefix())
.withKeySerializer(redisOperations.getKeySerializer()), toStoreValue(value))
.expireAfter(cacheMetadata.getDefaultExpiration()));
}
/*
* (non-Javadoc)
* @see org.springframework.cache.support.AbstractValueAdaptingCache#fromStoreValue(java.lang.Object)
*/
@Override
protected Object fromStoreValue(Object storeValue) {
// we need this override for the GenericJackson2JsonRedisSerializer support.
if (isAllowNullValues() && storeValue instanceof NullValue) {
return null;
}
return super.fromStoreValue(storeValue);
}
/**
@@ -181,8 +238,8 @@ public class RedisCache implements Cache {
public ValueWrapper putIfAbsent(Object key, final Object value) {
return putIfAbsent(new RedisCacheElement(new RedisCacheKey(key).usePrefix(cacheMetadata.getKeyPrefix())
.withKeySerializer(redisOperations.getKeySerializer()), value)
.expireAfter(cacheMetadata.getDefaultExpiration()));
.withKeySerializer(redisOperations.getKeySerializer()), toStoreValue(value))
.expireAfter(cacheMetadata.getDefaultExpiration()));
}
/**
@@ -253,6 +310,29 @@ public class RedisCache implements Cache {
return (value != null ? new SimpleValueWrapper(value) : null);
}
/*
* (non-Javadoc)
* @see org.springframework.cache.support.AbstractValueAdaptingCache#lookup(java.lang.Object)
*/
@Override
protected Object lookup(Object key) {
RedisCacheKey cacheKey = key instanceof RedisCacheKey ? (RedisCacheKey) key
: new RedisCacheKey(key).usePrefix(this.cacheMetadata.getKeyPrefix())
.withKeySerializer(redisOperations.getKeySerializer());
byte[] bytes = (byte[]) redisOperations.execute(new AbstractRedisCacheCallback<byte[]>(
new BinaryRedisCacheElement(new RedisCacheElement(cacheKey, null), cacheValueAccessor), cacheMetadata) {
@Override
public byte[] doInRedis(BinaryRedisCacheElement element, RedisConnection connection) throws DataAccessException {
return connection.get(element.getKeyBytes());
}
});
return bytes == null ? null : cacheValueAccessor.deserializeIfNecessary(bytes);
}
/**
* Metadata required to maintain {@link RedisCache}. Keeps track of additional data structures required for processing
* cache operations.

View File

@@ -29,12 +29,14 @@ import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.support.NullValue;
import org.springframework.cache.transaction.AbstractTransactionSupportingCacheManager;
import org.springframework.cache.transaction.TransactionAwareCacheDecorator;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
@@ -70,6 +72,8 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager
private Set<String> configuredCacheNames;
private final boolean cacheNullValues;
/**
* Construct a {@link RedisCacheManager}.
*
@@ -89,7 +93,25 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager
*/
@SuppressWarnings("rawtypes")
public RedisCacheManager(RedisOperations redisOperations, Collection<String> cacheNames) {
this(redisOperations, cacheNames, false);
}
/**
* Construct a static {@link RedisCacheManager}, managing caches for the specified cache names only. <br />
* <br />
* <strong>NOTE</strong> When enabling {@code cacheNullValues} please make sure the {@link RedisSerializer} used by
* {@link RedisOperations} is capable of serializing {@link NullValue}.
*
* @param redisOperations {@link RedisOperations} to work upon.
* @param cacheNames {@link Collection} of known cache names.
* @param cacheNullValues set to {@literal true} to allow caching {@literal null}.
* @since 1.8
*/
@SuppressWarnings("rawtypes")
public RedisCacheManager(RedisOperations redisOperations, Collection<String> cacheNames, boolean cacheNullValues) {
this.redisOperations = redisOperations;
this.cacheNullValues = cacheNullValues;
setCacheNames(cacheNames);
}
@@ -235,7 +257,8 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager
@SuppressWarnings("unchecked")
protected RedisCache createCache(String cacheName) {
long expiration = computeExpiration(cacheName);
return new RedisCache(cacheName, (usePrefix ? cachePrefix.prefix(cacheName) : null), redisOperations, expiration);
return new RedisCache(cacheName, (usePrefix ? cachePrefix.prefix(cacheName) : null), redisOperations, expiration,
cacheNullValues);
}
protected long computeExpiration(String name) {

View File

@@ -15,15 +15,22 @@
*/
package org.springframework.data.redis.serializer;
import java.io.IOException;
import org.springframework.cache.support.NullValue;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.SerializerFactory;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
/**
* @author Christoph Strobl
@@ -51,6 +58,10 @@ public class GenericJackson2JsonRedisSerializer implements RedisSerializer<Objec
this(new ObjectMapper());
// 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.
mapper.registerModule(new SimpleModule().addSerializer(new NullValueSerializer(classPropertyTypeName)));
if (StringUtils.hasText(classPropertyTypeName)) {
mapper.enableDefaultTypingAsProperty(DefaultTyping.NON_FINAL, classPropertyTypeName);
} else {
@@ -119,4 +130,40 @@ public class GenericJackson2JsonRedisSerializer implements RedisSerializer<Objec
throw new SerializationException("Could not read JSON: " + ex.getMessage(), ex);
}
}
/**
* {@link StdSerializer} adding class information required by default typing. This allows de-/seriialization of
* {@link NullValue}.
*
* @author Christoph Strobl
* @since 1.8
*/
private class NullValueSerializer extends StdSerializer<NullValue> {
private static final long serialVersionUID = 1999052150548658808L;
private final String classIdentifyer;
/**
* @param classIdentifyer can be {@literal null} and will be defaulted to {@code @class}.
*/
NullValueSerializer(String classIdentifyer) {
super(NullValue.class);
this.classIdentifyer = StringUtils.hasText(classIdentifyer) ? classIdentifyer : "@class";
}
/*
* (non-Javadoc)
* @see com.fasterxml.jackson.databind.ser.std.StdSerializer#serialize(java.lang.Object, com.fasterxml.jackson.core.JsonGenerator, com.fasterxml.jackson.databind.SerializerProvider)
*/
@Override
public void serialize(NullValue value, JsonGenerator jgen, SerializerProvider provider)
throws IOException, JsonProcessingException {
jgen.writeStartObject();
jgen.writeStringField(classIdentifyer, NullValue.class.getName());
jgen.writeEndObject();
}
}
}

View File

@@ -16,12 +16,8 @@
package org.springframework.data.redis.cache;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThat;
import static org.springframework.data.redis.matcher.RedisTestMatchers.isEqual;
import static org.junit.Assert.*;
import static org.springframework.data.redis.matcher.RedisTestMatchers.*;
import org.junit.Before;
import org.junit.Test;
@@ -38,22 +34,31 @@ public abstract class AbstractNativeCacheTest<T> {
private T nativeCache;
protected Cache cache;
protected final static String CACHE_NAME = "testCache";
private final boolean allowCacheNullValues;
protected AbstractNativeCacheTest(boolean allowCacheNullValues) {
this.allowCacheNullValues = allowCacheNullValues;
}
@Before
public void setUp() throws Exception {
nativeCache = createNativeCache();
cache = createCache(nativeCache);
cache = createCache(nativeCache, allowCacheNullValues);
cache.clear();
}
protected abstract T createNativeCache() throws Exception;
protected abstract Cache createCache(T nativeCache);
protected abstract Cache createCache(T nativeCache, boolean allowCacheNullValues);
protected abstract Object getKey();
protected abstract Object getValue();
protected boolean getAllowCacheNullValues() {
return allowCacheNullValues;
}
@Test
public void testCacheName() throws Exception {
assertEquals(CACHE_NAME, cache.getName());

View File

@@ -27,6 +27,8 @@ import static org.junit.Assert.*;
import static org.junit.Assume.*;
import static org.springframework.data.redis.matcher.RedisTestMatchers.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
@@ -34,6 +36,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.AfterClass;
import org.junit.AssumptionViolatedException;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -46,6 +49,8 @@ import org.springframework.data.redis.ObjectFactory;
import org.springframework.data.redis.StringObjectFactory;
import org.springframework.data.redis.core.AbstractOperationsTestParams;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer;
import edu.umd.cs.mtc.MultithreadedTestCase;
@@ -63,7 +68,11 @@ public class RedisCacheTest extends AbstractNativeCacheTest<RedisTemplate> {
private ObjectFactory<Object> valueFactory;
private RedisTemplate template;
public RedisCacheTest(RedisTemplate template, ObjectFactory<Object> keyFactory, ObjectFactory<Object> valueFactory) {
public RedisCacheTest(RedisTemplate template, ObjectFactory<Object> keyFactory, ObjectFactory<Object> valueFactory,
boolean allowCacheNullValues) {
super(allowCacheNullValues);
this.keyFactory = keyFactory;
this.valueFactory = valueFactory;
this.template = template;
@@ -72,12 +81,30 @@ public class RedisCacheTest extends AbstractNativeCacheTest<RedisTemplate> {
@Parameters
public static Collection<Object[]> testParams() {
return AbstractOperationsTestParams.testParams();
Collection<Object[]> params = AbstractOperationsTestParams.testParams();
Collection<Object[]> target = new ArrayList<Object[]>();
for (Object[] source : params) {
Object[] cacheNullDisabled = Arrays.copyOf(source, source.length + 1);
Object[] cacheNullEnabled = Arrays.copyOf(source, source.length + 1);
cacheNullDisabled[source.length] = false;
cacheNullEnabled[source.length] = true;
target.add(cacheNullDisabled);
target.add(cacheNullEnabled);
}
return target;
}
@SuppressWarnings("unchecked")
protected RedisCache createCache(RedisTemplate nativeCache) {
return new RedisCache(CACHE_NAME, CACHE_NAME.concat(":").getBytes(), nativeCache, TimeUnit.MINUTES.toSeconds(10));
protected RedisCache createCache(RedisTemplate nativeCache, boolean allowCacheNullValues) {
return new RedisCache(CACHE_NAME, CACHE_NAME.concat(":").getBytes(), nativeCache, TimeUnit.MINUTES.toSeconds(10),
allowCacheNullValues);
}
protected RedisTemplate createNativeCache() throws Exception {
@@ -86,6 +113,13 @@ public class RedisCacheTest extends AbstractNativeCacheTest<RedisTemplate> {
@Before
public void setUp() throws Exception {
if (!(template.getValueSerializer() instanceof JdkSerializationRedisSerializer
|| template.getValueSerializer() instanceof GenericJackson2JsonRedisSerializer
|| template.getValueSerializer() == null) && getAllowCacheNullValues()) {
throw new AssumptionViolatedException(
"Null values can only be cachend with the Jdk or GenericJackson2 serialization");
}
ConnectionFactoryTracker.add(template.getConnectionFactory());
super.setUp();
}
@@ -288,6 +322,8 @@ public class RedisCacheTest extends AbstractNativeCacheTest<RedisTemplate> {
@Test
public void cachePutWithNullShouldNotAddStuffToRedis() {
assumeThat(getAllowCacheNullValues(), is(false));
Object key = getKey();
Object value = getValue();
@@ -302,6 +338,8 @@ public class RedisCacheTest extends AbstractNativeCacheTest<RedisTemplate> {
@Test
public void cachePutWithNullShouldRemoveKeyIfExists() {
assumeThat(getAllowCacheNullValues(), is(false));
Object key = getKey();
Object value = getValue();
@@ -327,6 +365,22 @@ public class RedisCacheTest extends AbstractNativeCacheTest<RedisTemplate> {
runOnce(new CacheGetWithValueLoaderIsThreadSafe((RedisCache) cache));
}
/**
* @see DATAREDIS-553
*/
@Test
public void cachePutWithNullShouldAddStuffToRedisWhenCachingNullIsEnabled() {
assumeThat(getAllowCacheNullValues(), is(true));
Object key = getKey();
Object value = getValue();
cache.put(key, null);
assertThat(cache.get(key, String.class), is(nullValue()));
}
@SuppressWarnings("unused")
private static class CacheGetWithValueLoaderIsThreadSafe extends MultithreadedTestCase {

View File

@@ -250,7 +250,7 @@ public class RedisCacheUnitTests {
}
});
verify(connectionMock, times(2)).get(eq(KEY_BYTES));
verify(connectionMock, times(1)).get(eq(KEY_BYTES));
verify(connectionMock, times(1)).multi();
verify(connectionMock, times(1)).set(eq(KEY_BYTES), eq(VALUE_BYTES));
verify(connectionMock, times(1)).exec();
@@ -267,7 +267,7 @@ public class RedisCacheUnitTests {
Callable<Object> callableMock = mock(Callable.class);
when(connectionMock.exists(KEY_BYTES)).thenReturn(true);
when(connectionMock.get(KEY_BYTES)).thenReturn(null).thenReturn(VALUE_BYTES);
when(connectionMock.get(KEY_BYTES)).thenReturn(VALUE_BYTES);
assertThat((String) cache.get(KEY, callableMock), equalTo(VALUE));
verifyZeroInteractions(callableMock);
@@ -313,7 +313,7 @@ public class RedisCacheUnitTests {
}
});
verify(clusterConnectionMock, times(2)).get(eq(KEY_BYTES));
verify(clusterConnectionMock, times(1)).get(eq(KEY_BYTES));
verify(clusterConnectionMock, times(1)).set(eq(KEY_BYTES), eq(VALUE_BYTES));
verify(clusterConnectionMock, never()).multi();

View File

@@ -16,9 +16,11 @@
package org.springframework.data.redis.serializer;
import static org.hamcrest.core.Is.*;
import static org.hamcrest.core.IsInstanceOf.*;
import static org.hamcrest.core.IsNull.*;
import static org.junit.Assert.*;
import static org.mockito.Matchers.*;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.test.util.ReflectionTestUtils.*;
import static org.springframework.util.ObjectUtils.*;
@@ -26,6 +28,8 @@ import static org.springframework.util.ObjectUtils.*;
import java.io.IOException;
import org.junit.Test;
import org.springframework.beans.BeanUtils;
import org.springframework.cache.support.NullValue;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.core.JsonGenerationException;
@@ -152,6 +156,23 @@ public class GenericJackson2JsonRedisSerializerUnitTests {
new GenericJackson2JsonRedisSerializer(objectMapperMock).deserialize(new byte[] { 1 });
}
/**
* @see DATAREDIS-553
*/
@Test
public void shouldSerializeNullValueSoThatItCanBeDeserializedWithDefaultTypingEnabled() {
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
NullValue nv = BeanUtils.instantiateClass(NullValue.class);
byte[] serializedValue = serializer.serialize(nv);
assertThat(serializedValue, is(notNullValue()));
Object deserializedValue = serializer.deserialize(serializedValue);
assertThat(deserializedValue, is(instanceOf(NullValue.class)));
}
private TypeResolverBuilder<?> extractTypeResolver(GenericJackson2JsonRedisSerializer serializer) {
ObjectMapper mapper = (ObjectMapper) getField(serializer, "mapper");