/*
* Copyright 2007-2017 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
*
* http://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.integration.redis.outbound;
import java.util.Collection;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Properties;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.BoundZSetOperations;
import org.springframework.data.redis.core.RedisConnectionUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.data.redis.support.collections.RedisCollectionFactoryBean;
import org.springframework.data.redis.support.collections.RedisCollectionFactoryBean.CollectionType;
import org.springframework.data.redis.support.collections.RedisList;
import org.springframework.data.redis.support.collections.RedisMap;
import org.springframework.data.redis.support.collections.RedisProperties;
import org.springframework.data.redis.support.collections.RedisSet;
import org.springframework.data.redis.support.collections.RedisStore;
import org.springframework.data.redis.support.collections.RedisZSet;
import org.springframework.expression.Expression;
import org.springframework.expression.common.LiteralExpression;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.integration.expression.ExpressionUtils;
import org.springframework.integration.expression.FunctionExpression;
import org.springframework.integration.handler.AbstractMessageHandler;
import org.springframework.integration.redis.support.RedisHeaders;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageHandler;
import org.springframework.messaging.MessageHandlingException;
import org.springframework.util.Assert;
import org.springframework.util.NumberUtils;
/**
* Implementation of {@link MessageHandler} which writes Message data into a Redis store
* identified by a key {@link String}.
*
* It supports the collection types identified by {@link CollectionType}.
*
* It supports batch updates or single item entry.
*
* "Batch updates" means that the payload of the Message may be a Map or Collection.
* With such a payload, individual items from it are added to the corresponding Redis store.
* See {@link #handleMessageInternal(Message)} for more details.
*
* You can instead choose to persist such a payload as a single item if the
* {@link #extractPayloadElements} property is set to false (default is true).
*
* @author Oleg Zhurakousky
* @author Gary Russell
* @author Mark Fisher
* @author Artem Bilan
*
* @since 2.2
*/
public class RedisStoreWritingMessageHandler extends AbstractMessageHandler {
private Expression zsetIncrementScoreExpression =
new FunctionExpression<Message<?>>(m ->
m.getHeaders().get(RedisHeaders.ZSET_INCREMENT_SCORE));
private Expression keyExpression =
new FunctionExpression<Message<?>>(m ->
m.getHeaders().get(RedisHeaders.KEY));
private Expression mapKeyExpression =
new FunctionExpression<Message<?>>(m ->
m.getHeaders().get(RedisHeaders.MAP_KEY));
private boolean mapKeyExpressionExplicitlySet;
private StandardEvaluationContext evaluationContext;
private RedisTemplate<String, ?> redisTemplate = new StringRedisTemplate();
private boolean redisTemplateExplicitlySet;
private CollectionType collectionType = CollectionType.LIST;
private boolean extractPayloadElements = true;
private RedisConnectionFactory connectionFactory;
private volatile boolean initialized;
/**
* Constructs an instance using the provided {@link RedisTemplate}.
* The RedisTemplate must be fully initialized.
* @param redisTemplate The Redis template.
*/
public RedisStoreWritingMessageHandler(RedisTemplate<String, ?> redisTemplate) {
Assert.notNull(redisTemplate, "'redisTemplate' must not be null");
this.redisTemplate = redisTemplate;
this.redisTemplateExplicitlySet = true;
}
/**
* Constructs an instance using the provided {@link RedisConnectionFactory}.
* It will use either a {@link StringRedisTemplate} if {@link #extractPayloadElements} is
* true (default) or a {@link RedisTemplate} with {@link StringRedisSerializer}s for
* keys and hash keys and {@link JdkSerializationRedisSerializer}s for values and
* hash values, when it is false.
* @param connectionFactory The connection factory.
* @see #setExtractPayloadElements(boolean)
*/
public RedisStoreWritingMessageHandler(RedisConnectionFactory connectionFactory) {
Assert.notNull(connectionFactory, "'connectionFactory' must not be null");
this.connectionFactory = connectionFactory;
}
/**
* Specifies the key for the Redis store. If an expression is needed, then call
* {@link #setKeyExpression(Expression)} instead of this method (they are mutually exclusive).
* If neither setter is called, the default expression will be 'headers.{@link RedisHeaders#KEY}'.
* @param key The key.
* @see #setKeyExpression
*/
public void setKey(String key) {
Assert.hasText(key, "key must not be empty");
this.setKeyExpression(new LiteralExpression(key));
}
/**
* Specifies a SpEL Expression to be used to determine the key for the Redis store.
* If an expression is not needed, then a literal value may be passed to the
* {@link #setKey(String)} method instead of this one (they are mutually exclusive).
* If neither setter is called, the default expression will be 'headers.{@link RedisHeaders#KEY}'.
* @param keyExpression The key expression.
* @see #setKey(String)
* @since 5.0
*/
public void setKeyExpressionString(String keyExpression) {
Assert.hasText(keyExpression, "'keyExpression' must not be empty");
setKeyExpression(EXPRESSION_PARSER.parseExpression(keyExpression));
}
/**
* Specifies a SpEL Expression to be used to determine the key for the Redis store.
* If an expression is not needed, then a literal value may be passed to the
* {@link #setKey(String)} method instead of this one (they are mutually exclusive).
* If neither setter is called, the default expression will be 'headers.{@link RedisHeaders#KEY}'.
* @param keyExpression The key expression.
* @see #setKey(String)
*/
public void setKeyExpression(Expression keyExpression) {
Assert.notNull(keyExpression, "keyExpression must not be null");
this.keyExpression = keyExpression;
}
/**
* Sets the collection type for this handler as per {@link CollectionType}.
* @param collectionType The collection type.
*/
public void setCollectionType(CollectionType collectionType) {
this.collectionType = collectionType;
}
/**
* Sets the flag signifying that if the payload is a "multivalue" (i.e., Collection or Map),
* it should be saved using addAll/putAll semantics. Default is 'true'.
* If set to 'false' the payload will be saved as a single entry regardless of its type.
* If the payload is not an instance of "multivalue" (i.e., Collection or Map),
* the value of this attribute is meaningless as the payload will always be
* stored as a single entry.
* @param extractPayloadElements true if payload elements should be extracted.
*/
public void setExtractPayloadElements(boolean extractPayloadElements) {
this.extractPayloadElements = extractPayloadElements;
}
/**
* Sets the expression used as the key for Map and Properties entries.
* Default is 'headers.{@link RedisHeaders#MAP_KEY}'
* @param mapKeyExpression The map key expression.
* @since 5.0
*/
public void setMapKeyExpressionString(String mapKeyExpression) {
Assert.hasText(mapKeyExpression, "'mapKeyExpression' must not be empty");
setMapKeyExpression(EXPRESSION_PARSER.parseExpression(mapKeyExpression));
}
/**
* Sets the expression used as the key for Map and Properties entries.
* Default is 'headers.{@link RedisHeaders#MAP_KEY}'
* @param mapKeyExpression The map key expression.
*/
public void setMapKeyExpression(Expression mapKeyExpression) {
Assert.notNull(mapKeyExpression, "'mapKeyExpression' must not be null");
this.mapKeyExpression = mapKeyExpression;
this.mapKeyExpressionExplicitlySet = true;
}
/**
* Set the expression used as the INCR flag for the ZADD command in case of ZSet collection.
* Default is 'headers.{@link RedisHeaders#ZSET_INCREMENT_SCORE}'
* @param zsetIncrementScoreExpression The ZADD INCR flag expression.
* @since 5.0
*/
public void setZsetIncrementExpressionString(String zsetIncrementScoreExpression) {
Assert.hasText(zsetIncrementScoreExpression, "'zsetIncrementScoreExpression' must not be empty");
setZsetIncrementExpression(EXPRESSION_PARSER.parseExpression(zsetIncrementScoreExpression));
}
/**
* Set the expression used as the INCR flag for the ZADD command in case of ZSet collection.
* Default is 'headers.{@link RedisHeaders#ZSET_INCREMENT_SCORE}'
* @param zsetIncrementScoreExpression The ZADD INCR flag expression.
* @since 5.0
*/
public void setZsetIncrementExpression(Expression zsetIncrementScoreExpression) {
Assert.notNull(zsetIncrementScoreExpression, "'zsetIncrementScoreExpression' must not be null");
this.zsetIncrementScoreExpression = zsetIncrementScoreExpression;
}
@Override
public String getComponentType() {
return "redis:store-outbound-channel-adapter";
}
@Override
protected void onInit() throws Exception {
this.evaluationContext =
ExpressionUtils.createStandardEvaluationContext(this.getBeanFactory());
Assert.state(!this.mapKeyExpressionExplicitlySet ||
(this.collectionType == CollectionType.MAP || this.collectionType == CollectionType.PROPERTIES),
"'mapKeyExpression' can only be set for CollectionType.MAP or CollectionType.PROPERTIES");
if (!this.redisTemplateExplicitlySet) {
if (!this.extractPayloadElements) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
StringRedisSerializer serializer = new StringRedisSerializer();
template.setKeySerializer(serializer);
template.setHashKeySerializer(serializer);
this.redisTemplate = template;
}
this.redisTemplate.setConnectionFactory(this.connectionFactory);
this.redisTemplate.afterPropertiesSet();
}
this.initialized = true;
}
/**
* Will extract the payload from the Message and store it in the collection identified by the
* key (which may be determined by an expression). The type of collection is specified by the
* {@link #collectionType} property. The default CollectionType is LIST.
* <p>
* The rules for storing the payload are:
* <p>
* <b>LIST/SET</b>
* If the payload is of type Collection and {@link #extractPayloadElements} is 'true' (default),
* the payload will be added using the addAll() method. If {@link #extractPayloadElements}
* is set to 'false', then regardless of the payload type, the payload will be added using add().
* <p>
* <b>ZSET</b>
* In addition to the rules described for LIST/SET, ZSET allows 'score' information
* to be provided. The score can be provided using the {@link RedisHeaders#ZSET_SCORE} message header
* when the payload is not a Map, or by sending a Map as the payload where each Map 'key' is a
* value to be saved and each corresponding Map 'value' is the score assigned to it.
* If {@link #extractPayloadElements} is set to 'false' the map will be stored as a single entry.
* If the 'score' can not be determined, the default value (1) will be used.
* <p>
* <b>MAP/PROPERTIES</b>
* You can also add items to a Map or Properties based store.
* If the payload itself is of type Map or Properties, it can be stored either as a batch or single
* item following the same rules as described above for other collection types.
* If the payload itself needs to be stored as a value of the map/property then the map key
* must be specified via the mapKeyExpression (default {@link RedisHeaders#MAP_KEY} Message header).
*/
@SuppressWarnings("unchecked")
@Override
protected void handleMessageInternal(Message<?> message) throws Exception {
String key = this.keyExpression.getValue(this.evaluationContext, message, String.class);
Assert.hasText(key, () -> "Failed to determine a key for the Redis store based on the message: " + message);
RedisStore store = this.createStoreView(key);
Assert.state(this.initialized,
"handler not initialized - afterPropertiesSet() must be called before the first use");
try {
if (this.collectionType == CollectionType.ZSET) {
writeToZset((RedisZSet<Object>) store, message);
}
else if (this.collectionType == CollectionType.SET) {
writeToSet((RedisSet<Object>) store, message);
}
else if (this.collectionType == CollectionType.LIST) {
writeToList((RedisList<Object>) store, message);
}
else if (this.collectionType == CollectionType.MAP) {
writeToMap((RedisMap<Object, Object>) store, message);
}
else if (this.collectionType == CollectionType.PROPERTIES) {
writeToProperties((RedisProperties) store, message);
}
}
catch (Exception e) {
throw new MessageHandlingException(message, "Failed to store Message data in Redis collection", e);
}
}
@SuppressWarnings("unchecked")
private void writeToZset(RedisZSet<Object> zset, final Message<?> message) throws Exception {
final Object payload = message.getPayload();
final BoundZSetOperations<String, Object> ops =
(BoundZSetOperations<String, Object>) this.redisTemplate.boundZSetOps(zset.getKey());
boolean zsetIncrementHeader = extractZsetIncrementHeader(message);
if (this.extractPayloadElements) {
if ((payload instanceof Map<?, ?> && this.verifyAllMapValuesOfTypeNumber((Map<?, ?>) payload))) {
Map<Object, Number> payloadAsMap = (Map<Object, Number>) payload;
processInPipeline(() -> {
for (Entry<Object, Number> entry : payloadAsMap.entrySet()) {
Number d = entry.getValue();
incrementOrOverwrite(ops, entry.getKey(), d == null ?
determineScore(message) :
NumberUtils.convertNumberToTargetClass(d, Double.class),
zsetIncrementHeader);
}
});
}
else if (payload instanceof Collection<?>) {
processInPipeline(() -> {
for (Object object : ((Collection<?>) payload)) {
incrementOrOverwrite(ops, object, determineScore(message), zsetIncrementHeader);
}
});
}
else {
incrementOrOverwrite(ops, payload, this.determineScore(message), zsetIncrementHeader);
}
}
else {
incrementOrOverwrite(ops, payload, this.determineScore(message), zsetIncrementHeader);
}
}
private boolean extractZsetIncrementHeader(Message<?> message) {
Boolean value = this.zsetIncrementScoreExpression.getValue(this.evaluationContext, message, Boolean.class);
return value != null ? value : false;
}
private void writeToList(RedisList<Object> list, Message<?> message) {
Object payload = message.getPayload();
if (this.extractPayloadElements) {
if (payload instanceof Collection<?>) {
list.addAll((Collection<?>) payload);
}
else {
list.add(payload);
}
}
else {
list.add(payload);
}
}
@SuppressWarnings("unchecked")
private void writeToSet(final RedisSet<Object> set, Message<?> message) {
final Object payload = message.getPayload();
if (this.extractPayloadElements && payload instanceof Collection<?>) {
BoundSetOperations<String, Object> ops =
(BoundSetOperations<String, Object>) this.redisTemplate.boundSetOps(set.getKey());
processInPipeline(() -> {
for (Object object : ((Collection<?>) payload)) {
ops.add(object);
}
});
}
else {
set.add(payload);
}
}
private void writeToMap(final RedisMap<Object, Object> map, Message<?> message) {
final Object payload = message.getPayload();
if (this.extractPayloadElements && payload instanceof Map<?, ?>) {
processInPipeline(() -> map.putAll((Map<?, ?>) payload));
}
else {
Object key = this.determineMapKey(message, false);
map.put(key, payload);
}
}
private void writeToProperties(final RedisProperties properties, Message<?> message) {
final Object payload = message.getPayload();
if (this.extractPayloadElements && payload instanceof Properties) {
processInPipeline(() -> properties.putAll((Properties) payload));
}
else {
Assert.isInstanceOf(String.class, payload, "For property, payload must be a String.");
Object key = this.determineMapKey(message, true);
properties.put(key, payload);
}
}
private void processInPipeline(PipelineCallback callback) {
RedisConnection connection =
RedisConnectionUtils.bindConnection(this.redisTemplate.getConnectionFactory());
try {
connection.openPipeline();
callback.process();
}
finally {
connection.closePipeline();
RedisConnectionUtils.unbindConnection(this.redisTemplate.getConnectionFactory());
}
}
private Object determineMapKey(Message<?> message, boolean property) {
Object mapKey = this.mapKeyExpression.getValue(this.evaluationContext, message);
Assert.notNull(mapKey, () -> "Cannot determine a map key for the entry based on the message: " + message);
if (property) {
Assert.isInstanceOf(String.class, mapKey, "For property, key must be a String");
}
return mapKey;
}
private void incrementOrOverwrite(BoundZSetOperations<String, Object> ops, Object object, Double score,
boolean zsetIncrementScore) {
if (score != null) {
doIncrementOrOverwrite(ops, object, score, zsetIncrementScore);
}
else {
this.logger.debug("Zset Score could not be determined. Using default score of 1");
doIncrementOrOverwrite(ops, object, 1d, zsetIncrementScore);
}
}
private void doIncrementOrOverwrite(BoundZSetOperations<String, Object> ops, Object object, Double score,
boolean increment) {
if (increment) {
ops.incrementScore(object, score);
}
else {
ops.add(object, score);
}
}
private boolean verifyAllMapValuesOfTypeNumber(Map<?, ?> map) {
for (Object value : map.values()) {
if (!(value instanceof Number)) {
if (this.logger.isWarnEnabled()) {
this.logger.warn("failed to extract payload elements because '" +
value + "' is not of type Number");
}
return false;
}
}
return true;
}
private RedisStore createStoreView(String key) {
RedisCollectionFactoryBean fb = new RedisCollectionFactoryBean();
fb.setKey(key);
fb.setTemplate(this.redisTemplate);
fb.setType(this.collectionType);
fb.afterPropertiesSet();
return fb.getObject();
}
private double determineScore(Message<?> message) {
Object scoreHeader = message.getHeaders().get(RedisHeaders.ZSET_SCORE);
if (scoreHeader == null) {
return 1d;
}
else {
Assert.isInstanceOf(Number.class, scoreHeader, "Header " + RedisHeaders.ZSET_SCORE + " must be a Number");
Number score = (Number) scoreHeader;
return Double.valueOf(score.toString());
}
}
private interface PipelineCallback {
void process();
}
}