/*
* Copyright 2017-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.redis.cache;
import java.lang.reflect.Method;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collection;
import java.util.Map;
import java.util.Map.Entry;
import java.util.StringJoiner;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
import org.jspecify.annotations.Nullable;
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.core.convert.ConversionFailedException;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.util.ByteUtils;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.ReflectionUtils;
/**
* {@link AbstractValueAdaptingCache Cache} implementation using Redis as the underlying store for cache data.
*
* Use {@link RedisCacheManager} to create {@link RedisCache} instances.
*
* @author Christoph Strobl
* @author Mark Paluch
* @author Piotr Mionskowski
* @author Jos Roseboom
* @author John Blum
* @since 2.0
*/
@SuppressWarnings("unused")
public class RedisCache extends AbstractValueAdaptingCache {
@SuppressWarnings("NullAway") static final byte[] BINARY_NULL_VALUE = RedisSerializer.java()
.serialize(NullValue.INSTANCE);
static final String CACHE_RETRIEVAL_UNSUPPORTED_OPERATION_EXCEPTION_MESSAGE = "The Redis driver configured with RedisCache through RedisCacheWriter does not support CompletableFuture-based retrieval";
private final RedisCacheConfiguration cacheConfiguration;
private final RedisCacheWriter cacheWriter;
private final String name;
/**
* Create a new {@link RedisCache} with the given {@link String name} and {@link RedisCacheConfiguration}, using the
* {@link RedisCacheWriter} to execute Redis commands supporting the cache operations.
*
* @param name {@link String name} for this {@link Cache}; must not be {@literal null}.
* @param cacheWriter {@link RedisCacheWriter} used to perform {@link RedisCache} operations by executing the
* necessary Redis commands; must not be {@literal null}.
* @param cacheConfiguration {@link RedisCacheConfiguration} applied to this {@link RedisCache} on creation; must not
* be {@literal null}.
* @throws IllegalArgumentException if either the given {@link RedisCacheWriter} or {@link RedisCacheConfiguration}
* are {@literal null} or the given {@link String} name for this {@link RedisCache} is {@literal null}.
*/
protected RedisCache(String name, RedisCacheWriter cacheWriter, RedisCacheConfiguration cacheConfiguration) {
super(cacheConfiguration.getAllowCacheNullValues());
Assert.notNull(name, "Name must not be null");
Assert.notNull(cacheWriter, "CacheWriter must not be null");
this.name = name;
this.cacheWriter = cacheWriter;
this.cacheConfiguration = cacheConfiguration;
}
/**
* Get the {@link RedisCacheConfiguration} used to configure this {@link RedisCache} on initialization.
*
* @return an immutable {@link RedisCacheConfiguration} used to configure this {@link RedisCache} on initialization.
*/
public RedisCacheConfiguration getCacheConfiguration() {
return this.cacheConfiguration;
}
/**
* Gets the configured {@link RedisCacheWriter} used to adapt Redis for cache operations.
*
* @return the configured {@link RedisCacheWriter} used to adapt Redis for cache operations.
*/
protected RedisCacheWriter getCacheWriter() {
return this.cacheWriter;
}
/**
* Gets the configured {@link ConversionService} used to convert {@link Object cache keys} to a {@link String} when
* accessing entries in the cache.
*
* @return the configured {@link ConversionService} used to convert {@link Object cache keys} to a {@link String} when
* accessing entries in the cache.
* @see RedisCacheConfiguration#getConversionService()
* @see #getCacheConfiguration()
*/
protected ConversionService getConversionService() {
return getCacheConfiguration().getConversionService();
}
@Override
public String getName() {
return this.name;
}
@Override
public RedisCacheWriter getNativeCache() {
return getCacheWriter();
}
/**
* Return the {@link CacheStatistics} snapshot for this cache instance.
*
* Statistics are accumulated per cache instance and not from the backing Redis data store.
*
* @return {@link CacheStatistics} object for this {@link RedisCache}.
* @since 2.4
*/
public CacheStatistics getStatistics() {
return getCacheWriter().getCacheStatistics(getName());
}
@Override
@SuppressWarnings("unchecked")
public @Nullable T get(Object key, Callable valueLoader) {
byte[] binaryKey = createAndConvertCacheKey(key);
byte[] binaryValue = getCacheWriter().get(getName(), binaryKey,
() -> serializeCacheValue(toStoreValue(loadCacheValue(key, valueLoader))), getTimeToLive(key),
getCacheConfiguration().isTimeToIdleEnabled());
ValueWrapper result = toValueWrapper(deserializeCacheValue(binaryValue));
return result != null ? (T) result.get() : null;
}
/**
* Loads the {@link Object} using the given {@link Callable valueLoader}.
*
* @param {@link Class type} of the loaded {@link Object cache value}.
* @param key {@link Object key} mapped to the loaded {@link Object cache value}.
* @param valueLoader {@link Callable} object used to load the {@link Object value} for the given {@link Object key}.
* @return the loaded {@link Object value}.
*/
protected T loadCacheValue(Object key, Callable valueLoader) {
try {
return valueLoader.call();
} catch (Exception ex) {
throw new ValueRetrievalException(key, valueLoader, ex);
}
}
@Override
protected @Nullable Object lookup(Object key) {
byte[] binaryKey = createAndConvertCacheKey(key);
byte[] binaryValue = getCacheConfiguration().isTimeToIdleEnabled()
? getCacheWriter().get(getName(), binaryKey, getTimeToLive(key))
: getCacheWriter().get(getName(), binaryKey);
return binaryValue != null ? deserializeCacheValue(binaryValue) : null;
}
private Duration getTimeToLive(Object key) {
return getTimeToLive(key, null);
}
private Duration getTimeToLive(Object key, @Nullable Object value) {
return getCacheConfiguration().getTtlFunction().getTimeToLive(key, value);
}
@Override
@SuppressWarnings("NullAway")
public void put(Object key, @Nullable Object value) {
Object cacheValue = processAndCheckValue(value);
byte[] binaryKey = createAndConvertCacheKey(key);
byte[] binaryValue = serializeCacheValue(cacheValue);
Duration timeToLive = getTimeToLive(key, value);
getCacheWriter().put(getName(), binaryKey, binaryValue, timeToLive);
}
@Override
@SuppressWarnings("NullAway")
public @Nullable ValueWrapper putIfAbsent(Object key, @Nullable Object value) {
Object cacheValue = preProcessCacheValue(value);
if (nullCacheValueIsNotAllowed(cacheValue)) {
return get(key);
}
Duration timeToLive = getTimeToLive(key, value);
byte[] binaryKey = createAndConvertCacheKey(key);
byte[] binaryValue = serializeCacheValue(cacheValue);
byte[] result = getCacheWriter().putIfAbsent(getName(), binaryKey, binaryValue, timeToLive);
return result != null ? new SimpleValueWrapper(fromStoreValue(deserializeCacheValue(result))) : null;
}
@Override
public void clear() {
clear("*");
}
/**
* Clear keys that match the given {@link String keyPattern}. Useful when cache keys are formatted in a style where
* Redis patterns can be used for matching these.
*
* Actual clearing may be performed in an asynchronous or deferred fashion, with subsequent lookups possibly still
* seeing the entries. This may for example be the case with transactional cache decorators. Use {@link #invalidate()}
* for guaranteed immediate removal of entries.
*
* @param keyPattern {@link String pattern} used to match Redis keys to clear.
* @since 3.0
*/
public void clear(String keyPattern) {
getCacheWriter().clear(getName(), createAndConvertCacheKey(keyPattern));
}
@Override
public boolean invalidate() {
return getCacheWriter().invalidate(getName(), createAndConvertCacheKey("*"));
}
/**
* Reset all statistics counters and gauges for this cache.
*
* @since 2.4
*/
public void clearStatistics() {
getCacheWriter().clearStatistics(getName());
}
@Override
public void evict(Object key) {
getCacheWriter().evict(getName(), createAndConvertCacheKey(key));
}
@Override
public boolean evictIfPresent(Object key) {
return getCacheWriter().evictIfPresent(getName(), createAndConvertCacheKey(key));
}
@Override
public CompletableFuture retrieve(Object key) {
if (!getCacheWriter().supportsAsyncRetrieve()) {
throw new UnsupportedOperationException(CACHE_RETRIEVAL_UNSUPPORTED_OPERATION_EXCEPTION_MESSAGE);
}
return retrieveValue(key);
}
@Override
@SuppressWarnings({ "unchecked", "NullAway" })
public CompletableFuture retrieve(Object key, Supplier> valueLoader) {
return retrieve(key).thenCompose(wrapper -> {
if (wrapper != null) {
return CompletableFuture.completedFuture((T) wrapper.get());
}
return valueLoader.get().thenCompose(value -> {
Object cacheValue = processAndCheckValue(value);
byte[] binaryKey = createAndConvertCacheKey(key);
byte[] binaryValue = serializeCacheValue(cacheValue);
Duration timeToLive = getTimeToLive(key, cacheValue);
return getCacheWriter().store(getName(), binaryKey, binaryValue, timeToLive).thenApply(v -> value);
});
});
}
private @Nullable Object processAndCheckValue(@Nullable Object value) {
Object cacheValue = preProcessCacheValue(value);
if (nullCacheValueIsNotAllowed(cacheValue)) {
throw new IllegalArgumentException(("Cache '%s' does not allow 'null' values; Avoid storing null"
+ " via '@Cacheable(unless=\"#result == null\")' or configure RedisCache to allow 'null'"
+ " via RedisCacheConfiguration").formatted(getName()));
}
return cacheValue;
}
/**
* Customization hook called before passing object to
* {@link org.springframework.data.redis.serializer.RedisSerializer}.
*
* @param value can be {@literal null}.
* @return preprocessed value. Can be {@literal null}.
*/
protected @Nullable Object preProcessCacheValue(@Nullable Object value) {
return value != null ? value : isAllowNullValues() ? NullValue.INSTANCE : null;
}
/**
* Serialize the given {@link String cache key}.
*
* @param cacheKey {@link String cache key} to serialize; must not be {@literal null}.
* @return an array of bytes from the given, serialized {@link String cache key}; never {@literal null}.
* @see RedisCacheConfiguration#getKeySerializationPair()
*/
protected byte[] serializeCacheKey(String cacheKey) {
return ByteUtils.getBytes(getCacheConfiguration().getKeySerializationPair().write(cacheKey));
}
/**
* Serialize the {@link Object value} to cache as an array of bytes.
*
* @param value {@link Object} to serialize and cache; must not be {@literal null}.
* @return an array of bytes from the serialized {@link Object value}; never {@literal null}.
* @see RedisCacheConfiguration#getValueSerializationPair()
*/
protected byte[] serializeCacheValue(Object value) {
if (isAllowNullValues() && value instanceof NullValue) {
return BINARY_NULL_VALUE;
}
return ByteUtils.getBytes(getCacheConfiguration().getValueSerializationPair().write(value));
}
/**
* Deserialize the given the array of bytes to the actual {@link Object cache value}.
*
* @param value array of bytes to deserialize; must not be {@literal null}.
* @return an {@link Object} deserialized from the array of bytes using the configured value
* {@link RedisSerializationContext.SerializationPair}; can be {@literal null}.
* @see RedisCacheConfiguration#getValueSerializationPair()
*/
protected @Nullable Object deserializeCacheValue(byte[] value) {
if (isAllowNullValues() && ObjectUtils.nullSafeEquals(value, BINARY_NULL_VALUE)) {
return NullValue.INSTANCE;
}
return getCacheConfiguration().getValueSerializationPair().read(ByteBuffer.wrap(value));
}
/**
* Customization hook for creating cache key before it gets serialized.
*
* @param key will never be {@literal null}.
* @return never {@literal null}.
*/
protected String createCacheKey(Object key) {
String convertedKey = convertKey(key);
return getCacheConfiguration().usePrefix() ? prefixCacheKey(convertedKey) : convertedKey;
}
/**
* Convert {@code key} to a {@link String} used in cache key creation.
*
* @param key will never be {@literal null}.
* @return never {@literal null}.
* @throws IllegalStateException if {@code key} cannot be converted to {@link String}.
*/
@SuppressWarnings("NullAway")
protected String convertKey(Object key) {
if (key instanceof String stringKey) {
return stringKey;
}
TypeDescriptor source = TypeDescriptor.forObject(key);
ConversionService conversionService = getConversionService();
if (conversionService.canConvert(source, TypeDescriptor.valueOf(String.class))) {
try {
return conversionService.convert(key, String.class);
} catch (ConversionFailedException ex) {
// May fail if the given key is a collection
if (isCollectionLikeOrMap(source)) {
return convertCollectionLikeOrMapKey(key, source);
}
throw ex;
}
}
if (hasToStringMethod(key)) {
return key.toString();
}
throw new IllegalStateException(("Cannot convert cache key %s to String; Please register a suitable Converter"
+ " via 'RedisCacheConfiguration.configureKeyConverters(...)' or override '%s.toString()'")
.formatted(source, key.getClass().getName()));
}
private CompletableFuture retrieveValue(Object key) {
CompletableFuture retrieve = getCacheConfiguration().isTimeToIdleEnabled()
? getCacheWriter().retrieve(getName(), createAndConvertCacheKey(key), getTimeToLive(key))
: getCacheWriter().retrieve(getName(), createAndConvertCacheKey(key));
return retrieve //
.thenApply(binaryValue -> binaryValue != null ? deserializeCacheValue(binaryValue) : null) //
.thenApply(this::toValueWrapper);
}
private @Nullable Object nullSafeDeserializedStoreValue(byte @Nullable [] value) {
return value != null ? fromStoreValue(deserializeCacheValue(value)) : null;
}
private boolean hasToStringMethod(Object target) {
return hasToStringMethod(target.getClass());
}
private boolean hasToStringMethod(Class> type) {
Method toString = ReflectionUtils.findMethod(type, "toString");
return toString != null && !Object.class.equals(toString.getDeclaringClass());
}
private boolean isCollectionLikeOrMap(TypeDescriptor source) {
return source.isArray() || source.isCollection() || source.isMap();
}
private String convertCollectionLikeOrMapKey(Object key, TypeDescriptor source) {
if (source.isMap()) {
int count = 0;
StringBuilder target = new StringBuilder("{");
for (Entry, ?> entry : ((Map, ?>) key).entrySet()) {
target.append(convertKey(entry.getKey())).append("=").append(convertKey(entry.getValue()));
target.append(++count > 1 ? ", " : "");
}
target.append("}");
return target.toString();
} else if (source.isCollection() || source.isArray()) {
StringJoiner stringJoiner = new StringJoiner(",");
Collection> collection = source.isCollection() ? (Collection>) key
: Arrays.asList(ObjectUtils.toObjectArray(key));
for (Object collectedKey : collection) {
stringJoiner.add(convertKey(collectedKey));
}
return "[" + stringJoiner + "]";
}
throw new IllegalArgumentException("Cannot convert cache key [%s] to String".formatted(key));
}
byte[] createAndConvertCacheKey(Object key) {
return serializeCacheKey(createCacheKey(key));
}
private String prefixCacheKey(String key) {
// allow contextual cache names by computing the key prefix on every call.
return getCacheConfiguration().getKeyPrefixFor(getName()) + key;
}
private boolean nullCacheValueIsNotAllowed(@Nullable Object cacheValue) {
return cacheValue == null && !isAllowNullValues();
}
}