From eafcb331d818f99342588af2f09cc2bb8c7e169d Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 6 Nov 2018 11:41:31 +0100 Subject: [PATCH] DATAREDIS-864 - Polishing. Exract nested Stream types into connection.stream package. Extract common code from Template-API based toObjectRecord handling into StreamObjectMapper. StreamReceiver and StreamMessageListenerContainer now can emit MapRecord or ObjectRecord for easier consumption of stream records. Original Pull Request: #356 --- .../asciidoc/reference/redis-streams.adoc | 63 +- .../DefaultStringRedisConnection.java | 8 + .../connection/DefaultedRedisConnection.java | 7 + .../connection/ReactiveStreamCommands.java | 13 +- .../redis/connection/RedisStreamCommands.java | 750 +----------------- .../connection/StringRedisConnection.java | 7 + .../LettuceReactiveStreamCommands.java | 14 +- .../lettuce/LettuceStreamCommands.java | 7 + .../connection/lettuce/StreamConverters.java | 6 +- .../connection/stream/ByteBufferRecord.java | 110 +++ .../redis/connection/stream/ByteRecord.java | 88 ++ .../redis/connection/stream/Consumer.java | 61 ++ .../redis/connection/stream/MapRecord.java | 148 ++++ .../redis/connection/stream/ObjectRecord.java | 73 ++ .../redis/connection/stream/ReadOffset.java | 84 ++ .../data/redis/connection/stream/Record.java | 101 +++ .../redis/connection/stream/RecordId.java | 157 ++++ .../redis/connection/stream/StreamOffset.java | 95 +++ .../connection/stream/StreamReadOptions.java | 95 +++ .../{ => stream}/StreamRecords.java | 110 ++- .../stream/StreamSerialization.java | 53 ++ .../redis/connection/stream/StringRecord.java | 50 ++ .../redis/connection/stream/package-info.java | 6 + .../redis/core/BoundStreamOperations.java | 10 +- .../core/DefaultBoundStreamOperations.java | 12 +- .../core/DefaultReactiveStreamOperations.java | 155 ++-- .../redis/core/DefaultStreamOperations.java | 168 ++-- .../data/redis/core/HashMapperProvider.java | 42 + .../redis/core/ReactiveRedisTemplate.java | 4 +- .../redis/core/ReactiveStreamOperations.java | 243 +++--- .../data/redis/core/RedisOperations.java | 4 +- .../data/redis/core/RedisTemplate.java | 3 +- .../data/redis/core/StreamObjectMapper.java | 202 +++++ .../data/redis/core/StreamOperations.java | 212 +++-- .../serializer/DefaultRedisElementWriter.java | 2 +- ...DefaultStreamMessageListenerContainer.java | 56 +- .../redis/stream/DefaultStreamReceiver.java | 110 ++- .../data/redis/stream/RawRedisSerializer.java | 40 + .../data/redis/stream/ReadOffsetStrategy.java | 4 +- .../data/redis/stream/StreamListener.java | 6 +- .../StreamMessageListenerContainer.java | 176 +++- .../data/redis/stream/StreamPollTask.java | 18 +- .../data/redis/stream/StreamReceiver.java | 260 ++++-- .../AbstractConnectionIntegrationTests.java | 10 +- ...ultStringRedisConnectionPipelineTests.java | 3 +- ...tStringRedisConnectionPipelineTxTests.java | 3 +- .../DefaultStringRedisConnectionTests.java | 11 +- .../DefaultStringRedisConnectionTxTests.java | 3 +- .../connection/StreamRecordsUnitTests.java | 11 +- .../redis/connection/lettuce/ApiSpike.java | 405 ---------- .../LettuceReactiveStreamCommandsTests.java | 8 +- .../DefaultReactiveStreamOperationsTests.java | 54 +- .../core/DefaultStreamOperationsTests.java | 24 +- .../stream/ReadOffsetStrategyUnitTests.java | 4 +- ...sageListenerContainerIntegrationTests.java | 113 ++- .../StreamReceiverIntegrationTests.java | 107 ++- 56 files changed, 2699 insertions(+), 1890 deletions(-) create mode 100644 src/main/java/org/springframework/data/redis/connection/stream/ByteBufferRecord.java create mode 100644 src/main/java/org/springframework/data/redis/connection/stream/ByteRecord.java create mode 100644 src/main/java/org/springframework/data/redis/connection/stream/Consumer.java create mode 100644 src/main/java/org/springframework/data/redis/connection/stream/MapRecord.java create mode 100644 src/main/java/org/springframework/data/redis/connection/stream/ObjectRecord.java create mode 100644 src/main/java/org/springframework/data/redis/connection/stream/ReadOffset.java create mode 100644 src/main/java/org/springframework/data/redis/connection/stream/Record.java create mode 100644 src/main/java/org/springframework/data/redis/connection/stream/RecordId.java create mode 100644 src/main/java/org/springframework/data/redis/connection/stream/StreamOffset.java create mode 100644 src/main/java/org/springframework/data/redis/connection/stream/StreamReadOptions.java rename src/main/java/org/springframework/data/redis/connection/{ => stream}/StreamRecords.java (75%) create mode 100644 src/main/java/org/springframework/data/redis/connection/stream/StreamSerialization.java create mode 100644 src/main/java/org/springframework/data/redis/connection/stream/StringRecord.java create mode 100644 src/main/java/org/springframework/data/redis/connection/stream/package-info.java create mode 100644 src/main/java/org/springframework/data/redis/core/HashMapperProvider.java create mode 100644 src/main/java/org/springframework/data/redis/core/StreamObjectMapper.java create mode 100644 src/main/java/org/springframework/data/redis/stream/RawRedisSerializer.java delete mode 100644 src/test/java/org/springframework/data/redis/connection/lettuce/ApiSpike.java diff --git a/src/main/asciidoc/reference/redis-streams.adoc b/src/main/asciidoc/reference/redis-streams.adoc index 0855026f9..8f99fbfb0 100644 --- a/src/main/asciidoc/reference/redis-streams.adoc +++ b/src/main/asciidoc/reference/redis-streams.adoc @@ -16,24 +16,25 @@ While Pub/Sub relies on the broadcasting of transient messages (i.e. if you don' The `org.springframework.data.redis.connection` and `org.springframework.data.redis.stream` packages provide the core functionality for Redis Streams. +NOTE: Redis Stream support is currently only available through the <> as it is not yet supported by <>. [[redis.streams.send]] == Appending -To send a record, you can use, as with the other operations, either the low-level `RedisConnection` or the high-level `StreamOperations`. Both entities offer the `add` (`xAdd`) method, which accepts the record and the destination stream as arguments. While `RedisConnection` requires raw data (array of bytes), the `StreamOperations` lets arbitrary objects be passed in as recods, as shown in the following example: +To send a record, you can use, as with the other operations, either the low-level `RedisConnection` or the high-level `StreamOperations`. Both entities offer the `add` (`xAdd`) method, which accepts the record and the destination stream as arguments. While `RedisConnection` requires raw data (array of bytes), the `StreamOperations` lets arbitrary objects be passed in as records, as shown in the following example: [source,java] ---- // append message through connection -RedisConnection con = //… -byte[] stream = //… -ByteRecord record = StreamRecords.rawBytes(...).withStreamKey(stream); +RedisConnection con = … +byte[] stream = … +ByteRecord record = StreamRecords.rawBytes(…).withStreamKey(stream); con.xAdd(record); // append message through RedisTemplate -RedisTemplate template = //… -StringRecord record = StreamRecods.ofStrings(...).withStreamKey("my-stream"); -template.streamOps().add(recod); +RedisTemplate template = … +StringRecord record = StreamRecords.string(…).withStreamKey("my-stream"); +template.streamOps().add(record); ---- Stream records carry a `Map`, key-value tuples, as their payload. Appending a record to a stream returns the `RecordId` that can be used as further reference. @@ -60,7 +61,7 @@ While stream consumption is typically associated with asynchronous processing, i RedisTemplate template = … List> messages = template.streamOps().read(StreamReadOptions.empty().count(2), - StreamOffset.create("my-stream", ReadOffset.latest())); + StreamOffset.latest("my-stream")); List> messages = template.streamOps().read(Consumer.from("my-group", "my-consumer"), StreamReadOptions.empty().count(2), @@ -88,14 +89,14 @@ In a fashion similar to a Message-Driven Bean (MDB) in the EJB world, the Stream [source,java] ---- -class ExampleStreamListener implements StreamListener { +class ExampleStreamListener implements StreamListener> { @Override - public void onMessage(StreamMessage message) { + public void onMessage(MapRecord message) { System.out.println("MessageId: " + message.getId()); System.out.println("Stream: " + message.getStream()); - System.out.println("Body: " + message.getBody()); + System.out.println("Body: " + message.getValue()); } } ---- @@ -108,7 +109,7 @@ message -> { System.out.println("MessageId: " + message.getId()); System.out.println("Stream: " + message.getStream()); - System.out.println("Body: " + message.getBody()); + System.out.println("Body: " + message.getValue()); }; ---- @@ -117,12 +118,12 @@ Once you’ve implemented your `StreamListener`, it’s time to create a message [source,java] ---- RedisConnectionFactory connectionFactory = … -StreamListener streamListener = … +StreamListener> streamListener = … -StreamMessageListenerContainerOptions containerOptions = StreamMessageListenerContainerOptions +StreamMessageListenerContainerOptions> containerOptions = StreamMessageListenerContainerOptions .builder().pollTimeout(Duration.ofMillis(100)).build(); -StreamMessageListenerContainer container = StreamMessageListenerContainer.create(connectionFactory, +StreamMessageListenerContainer> container = StreamMessageListenerContainer.create(connectionFactory, containerOptions); Subscription subscription = container.receive(StreamOffset.fromStart("my-stream"), streamListener); @@ -136,12 +137,12 @@ Reactive consumption of streaming data sources typically happens through a `Flux [source,java] ---- -Flux> messages = … +Flux> messages = … return messages.doOnNext(it -> { System.out.println("MessageId: " + message.getId()); System.out.println("Stream: " + message.getStream()); - System.out.println("Body: " + message.getBody()); + System.out.println("Body: " + message.getValue()); }); ---- @@ -151,11 +152,11 @@ Now we need to create the `StreamReceiver` and register a subscription to consum ---- ReactiveRedisConnectionFactory connectionFactory = … -StreamReceiverOptions options = StreamReceiverOptions.builder().pollTimeout(Duration.ofMillis(100)) +StreamReceiverOptions> options = StreamReceiverOptions.builder().pollTimeout(Duration.ofMillis(100)) .build(); -StreamReceiver receiver = StreamReceiver.create(connectionFactory, options); +StreamReceiver> receiver = StreamReceiver.create(connectionFactory, options); -Flux> messages = receiver.receive(StreamOffset.fromStart("my-stream")); +Flux> messages = receiver.receive(StreamOffset.fromStart("my-stream")); ---- Please refer to the Javadoc of the various message listener containers for a full description of the features supported by each implementation. @@ -193,10 +194,10 @@ Any Record sent to the stream needs to be serialized to its binary format. Due t .Stream Serialization [options="header,footer,autowidth"] |=== -| Stream Property | Serializer | Description -| key | keySerializer | used for `Record#getStream()` -| field | hashKeySerializer | used for each map key in the payload -| value | hashValueSerializer | used for each map value in the payload +| Stream Property | Serializer | Description +| key | keySerializer | used for `Record#getStream()` +| field | hashKeySerializer | used for each map key in the payload +| value | hashValueSerializer | used for each map value in the payload |=== Please make sure to review ``RedisSerializer``s in use and note that if you decide to not use any serializer you need to make sure those values are binary already. @@ -231,14 +232,16 @@ List> records = redisTemplate() Adding a complex value to the stream can be done in 3 ways: -. Convert to simple value using eg. a String Json representation. -. Serialize the value with a suitable `RedisSerializer`. -. Convert the value into a `Map` suitable for serialization using a `HashMapper`. +* Convert to simple value using eg. a String JSON representation. +* Serialize the value with a suitable `RedisSerializer`. +* Convert the value into a `Map` suitable for serialization using a `HashMapper`. The first variant is the most straight forward one but neglects the field value capabilities offered by the stream structure, still the values in the stream will be readable for other consumers. The 2nd option holds the same benefits as the first one, but may lead to a very specific consumer limitations as the all consumers must implement the very same serialization mechanism. The `HashMapper` approach is the a bit more complex one making use of the steams hash structure, but flattening the source. Still other consumers remain able to read the records as long as suitable serializer combinations are chosen. +NOTE: HashMappers convert the payload to a `Map` with specific types. Make sure to use Hash-Key and Hash-Value serializers that are capable of (de-)serializing the hash. + [source,java] ---- ObjectRecord record = StreamRecords.newRecord() @@ -255,8 +258,8 @@ List> records = redisTemplate() ---- <1> XADD user-logon * "_class" "com.example.User" "firstname" "night" "lastname" "angel" -By default `StreamOperations` use an <>. -You may provide the `HashMapper` suitable for your requirements when obtaining `StreamOperations`. +`StreamOperations` use by default <>. +You may provide a `HashMapper` suitable for your requirements when obtaining `StreamOperations`. [source,java] ---- @@ -264,4 +267,4 @@ redisTemplate() .opsForStream(new Jackson2HashMapper(true)) .add(record); <1> ---- -<1> XADD user-logon * "firstname" "night" "@class" "com.example.User" "lastname" "angel" \ No newline at end of file +<1> XADD user-logon * "firstname" "night" "@class" "com.example.User" "lastname" "angel" diff --git a/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java b/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java index 29dba7c45..e2c1bfd10 100644 --- a/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java @@ -34,6 +34,14 @@ import org.springframework.data.redis.connection.convert.Converters; import org.springframework.data.redis.connection.convert.ListConverter; import org.springframework.data.redis.connection.convert.MapConverter; import org.springframework.data.redis.connection.convert.SetConverter; +import org.springframework.data.redis.connection.stream.ByteRecord; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; +import org.springframework.data.redis.connection.stream.StringRecord; import org.springframework.data.redis.core.ConvertingCursor; import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.ScanOptions; diff --git a/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java b/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java index be2174770..d41842878 100644 --- a/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java @@ -28,6 +28,13 @@ import org.springframework.data.geo.Distance; import org.springframework.data.geo.GeoResults; import org.springframework.data.geo.Metric; import org.springframework.data.geo.Point; +import org.springframework.data.redis.connection.stream.ByteRecord; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.types.Expiration; diff --git a/src/main/java/org/springframework/data/redis/connection/ReactiveStreamCommands.java b/src/main/java/org/springframework/data/redis/connection/ReactiveStreamCommands.java index 15bf73265..fc0703061 100644 --- a/src/main/java/org/springframework/data/redis/connection/ReactiveStreamCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/ReactiveStreamCommands.java @@ -15,6 +15,7 @@ */ package org.springframework.data.redis.connection; +import org.springframework.data.redis.connection.stream.StreamRecords; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -30,12 +31,12 @@ import org.springframework.data.domain.Range; import org.springframework.data.redis.connection.ReactiveRedisConnection.CommandResponse; import org.springframework.data.redis.connection.ReactiveRedisConnection.KeyCommand; import org.springframework.data.redis.connection.ReactiveRedisConnection.NumericResponse; -import org.springframework.data.redis.connection.RedisStreamCommands.ByteBufferRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions; +import org.springframework.data.redis.connection.stream.ByteBufferRecord; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; import org.springframework.lang.Nullable; import org.springframework.util.Assert; diff --git a/src/main/java/org/springframework/data/redis/connection/RedisStreamCommands.java b/src/main/java/org/springframework/data/redis/connection/RedisStreamCommands.java index e9cb66b78..7f0857342 100644 --- a/src/main/java/org/springframework/data/redis/connection/RedisStreamCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/RedisStreamCommands.java @@ -15,30 +15,14 @@ */ package org.springframework.data.redis.connection; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.ToString; - -import java.nio.ByteBuffer; -import java.time.Duration; import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Map.Entry; -import java.util.function.Function; -import java.util.stream.Collectors; import org.springframework.data.domain.Range; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; -import org.springframework.data.redis.hash.HashMapper; -import org.springframework.data.redis.serializer.RedisSerializer; -import org.springframework.data.redis.util.ByteUtils; +import org.springframework.data.redis.connection.stream.*; import org.springframework.lang.Nullable; -import org.springframework.util.Assert; -import org.springframework.util.NumberUtils; -import org.springframework.util.StringUtils; /** * Stream-specific Redis commands. @@ -291,736 +275,4 @@ public interface RedisStreamCommands { */ @Nullable Long xTrim(byte[] key, long count); - - /** - * Value object representing read offset for a Stream. - */ - @EqualsAndHashCode - @ToString - @Getter - class ReadOffset { - - private final String offset; - - private ReadOffset(String offset) { - this.offset = offset; - } - - /** - * Read from the latest offset. - * - * @return - */ - public static ReadOffset latest() { - return new ReadOffset("$"); - } - - /** - * Read all new arriving elements with ids greater than the last one consumed by the consumer group. - * - * @return the {@link ReadOffset} object without a specific offset. - */ - public static ReadOffset lastConsumed() { - return new ReadOffset(">"); - } - - /** - * Read all arriving elements from the stream starting at {@code offset}. - * - * @param offset the stream offset. - * @return the {@link StreamOffset} object without a specific offset. - */ - public static ReadOffset from(String offset) { - - Assert.hasText(offset, "Offset must not be empty"); - - return new ReadOffset(offset); - } - - public static ReadOffset from(RecordId offset) { - - if (offset.shouldBeAutoGenerated()) { - return latest(); - } - - return from(offset.getValue()); - } - } - - /** - * Value object representing a Stream Id with its offset. - */ - @EqualsAndHashCode - @ToString - @Getter - class StreamOffset { - - private final K key; - private final ReadOffset offset; - - private StreamOffset(K key, ReadOffset offset) { - this.key = key; - this.offset = offset; - } - - /** - * Create a {@link StreamOffset} given {@code key} and {@link ReadOffset}. - * - * @param key the stream key. - * @param readOffset the {@link ReadOffset} to use. - * @return new instance of {@link StreamOffset}. - */ - public static StreamOffset create(K key, ReadOffset readOffset) { - return new StreamOffset<>(key, readOffset); - } - - /** - * Create a {@link StreamOffset} given {@code key} starting at {@link ReadOffset#latest()}. - * - * @param key he stream key. - * @param - * @return new instance of {@link StreamOffset}. - */ - public static StreamOffset latest(K key) { - return new StreamOffset(key, ReadOffset.latest()); - } - - /** - * Create a {@link StreamOffset} given {@code key} starting at {@link ReadOffset#from(String) - * ReadOffset#from("0-0")}. - * - * @param key he stream key. - * @param - * @return new instance of {@link StreamOffset}. - */ - public static StreamOffset fromStart(K key) { - return new StreamOffset(key, ReadOffset.from("0-0")); - } - - /** - * Create a {@link StreamOffset} using the given {@link Record#getId() record id} as reference to create the - * {@link ReadOffset#from(String)}. - * - * @param reference the record to be used as refrence point. - * @param - * @return new instance of {@link StreamOffset}. - */ - public static StreamOffset of(Record reference) { - return create(reference.getStream(), ReadOffset.from(reference.getId())); - } - } - - /** - * Options for reading messages from a Redis Stream. - */ - @EqualsAndHashCode - @ToString - @Getter - class StreamReadOptions { - - private static final StreamReadOptions EMPTY = new StreamReadOptions(null, null, false); - - private final @Nullable Long block; - private final @Nullable Long count; - private final boolean noack; - - private StreamReadOptions(@Nullable Long block, @Nullable Long count, boolean noack) { - this.block = block; - this.count = count; - this.noack = noack; - } - - /** - * Creates an empty {@link StreamReadOptions} instance. - * - * @return an empty {@link StreamReadOptions} instance. - */ - public static StreamReadOptions empty() { - return EMPTY; - } - - /** - * Disable auto-acknowledgement when reading in the context of a consumer group. - * - * @return {@link StreamReadOptions} with {@code noack} applied. - */ - public StreamReadOptions noack() { - return new StreamReadOptions(block, count, true); - } - - /** - * Use a blocking read and supply the {@link Duration timeout} after which the call will terminate if no message was - * read. - * - * @param timeout the timeout for the blocking read, must not be {@literal null} or negative. - * @return {@link StreamReadOptions} with {@code block} applied. - */ - public StreamReadOptions block(Duration timeout) { - - Assert.notNull(timeout, "Block timeout must not be null!"); - Assert.isTrue(!timeout.isNegative(), "Block timeout must not be negative!"); - - return new StreamReadOptions(timeout.toMillis(), count, noack); - } - - /** - * Limit the number of messages returned per stream. - * - * @param count the maximum number of messages to read. - * @return {@link StreamReadOptions} with {@code count} applied. - */ - public StreamReadOptions count(long count) { - - Assert.isTrue(count > 0, "Count must be greater or equal to zero!"); - - return new StreamReadOptions(block, count, noack); - } - } - - /** - * Value object representing a Stream consumer within a consumer group. Group name and consumer name are encoded as - * keys. - */ - @EqualsAndHashCode - @Getter - class Consumer { - - private final String group; - private final String name; - - private Consumer(String group, String name) { - this.group = group; - this.name = name; - } - - /** - * Create a new consumer. - * - * @param group name of the consumer group, must not be {@literal null} or empty. - * @param name name of the consumer, must not be {@literal null} or empty. - * @return the consumer {@link io.lettuce.core.Consumer} object. - */ - public static Consumer from(String group, String name) { - - Assert.hasText(group, "Group must not be null"); - Assert.hasText(name, "Name must not be null"); - - return new Consumer(group, name); - } - - @Override - public String toString() { - return String.format("%s:%s", group, name); - } - } - - /** - * The id of a single {@link Record} within a stream. Composed of two parts: - * {@literal -}. - * - * @author Christoph Strobl - * @see Redis Documentation - Entriy ID - */ - @EqualsAndHashCode - class RecordId { - - private static final String GENERATE_ID = "*"; - private static final String DELIMINATOR = "-"; - - /** - * Auto-generation of IDs by the server is almost always what you want so we've got this instance here shortcutting - * computation. - */ - private static final RecordId AUTOGENERATED = new RecordId(GENERATE_ID) { - - @Override - public Long getSequence() { - return null; - } - - @Override - public Long getTimestamp() { - return null; - } - - @Override - public boolean shouldBeAutoGenerated() { - return true; - } - }; - - private final String raw; - - /** - * Private constructor - validate input in static initializer blocks. - * - * @param raw - */ - private RecordId(String raw) { - this.raw = raw; - } - - /** - * Obtain an instance of {@link RecordId} using the provided String formatted as - * {@literal -}.
- * For server auto generated {@literal entry-id} on insert pass in {@literal null} or {@literal *}. Event better, - * just use {@link #autoGenerate()}. - * - * @param value can be {@literal null}. - * @return new instance of {@link RecordId} if no autogenerated one requested. - */ - public static RecordId of(@Nullable String value) { - - if (value == null || GENERATE_ID.equals(value)) { - return autoGenerate(); - } - - Assert.isTrue(value.contains(DELIMINATOR), - "Invalid id format. Please use the 'millisecondsTime-sequenceNumber' format."); - return new RecordId(value); - } - - /** - * Create a new instance of {@link RecordId} using the provided String formatted as - * {@literal -}.
- * For server auto generated {@literal entry-id} on insert use {@link #autoGenerate()}. - * - * @param millisecondsTime - * @param sequenceNumber - * @return new instance of {@link RecordId}. - */ - public static RecordId of(long millisecondsTime, long sequenceNumber) { - return of(millisecondsTime + DELIMINATOR + sequenceNumber); - } - - /** - * Obtain the {@link RecordId} signalling the server to auto generate an {@literal entry-id} on insert - * ({@code XADD}). - * - * @return {@link RecordId} instance signalling {@link #shouldBeAutoGenerated()}. - */ - public static RecordId autoGenerate() { - return AUTOGENERATED; - } - - /** - * Get the {@literal entry-id millisecondsTime} part or {@literal null} if it {@link #shouldBeAutoGenerated()}. - * - * @return millisecondsTime of the {@literal entry-id}. Can be {@literal null}. - */ - @Nullable - public Long getTimestamp() { - return value(0); - } - - /** - * Get the {@literal entry-id sequenceNumber} part or {@literal null} if it {@link #shouldBeAutoGenerated()}. - * - * @return sequenceNumber of the {@literal entry-id}. Can be {@literal null}. - */ - @Nullable - public Long getSequence() { - return value(1); - } - - /** - * @return {@literal true} if a new {@literal entry-id} shall be generated on server side when calling {@code XADD}. - */ - public boolean shouldBeAutoGenerated() { - return false; - } - - /** - * @return get the string representation of the {@literal entry-id} in - * {@literal -} format or {@literal *} if it - * {@link #shouldBeAutoGenerated()}. Never {@literal null}. - */ - public String getValue() { - return raw; - } - - @Override - public String toString() { - return raw; - } - - private Long value(int index) { - return NumberUtils.parseNumber(StringUtils.split(raw, DELIMINATOR)[index], Long.class); - } - } - - /** - * A single entry in the stream consisting of the {@link RecordId entry-id} and the actual entry-value (typically a - * collection of {@link MapRecord field/value pairs}). - * - * @param the type backing the {@link Record}. - * @author Christoph Strobl - * @see Redis Documentation - Stream Basics - */ - interface Record { - - /** - * The id of the stream (aka the {@literal key} in Redis). - * - * @return can be {@literal null}. - */ - @Nullable - S getStream(); - - /** - * The id of the entry inside the stream. - * - * @return never {@literal null}. - */ - RecordId getId(); - - /** - * @return the actual content. Never {@literal null}. - */ - V getValue(); - - /** - * Create a new {@link MapRecord} instance backed by the given {@link Map} holding {@literal field/value} pairs. - *
- * You may want to use the builders available via {@link StreamRecords}. - * - * @param map the raw map. - * @param the key type of the given {@link Map}. - * @param the value type of the given {@link Map}. - * @return new instance of {@link MapRecord}. - */ - static MapRecord of(Map map) { - - Assert.notNull(map, "Map must not be null!"); - return StreamRecords. mapBacked(map); - } - - /** - * Create a new {@link ObjectRecord} instance backed by the given {@literal value}. The value may be a simple type, - * like {@link String} or a complex one.
- * You may want to use the builders available via {@link StreamRecords}. - * - * @param value the value to persist. - * @param the type of the backing value. - * @return new instance of {@link MapRecord}. - */ - static ObjectRecord of(V value) { - - Assert.notNull(value, "Value must not be null!"); - return StreamRecords.objectBacked(value); - } - - /** - * Create a new instance of {@link Record} with the given {@link RecordId}. - * - * @param id must not be {@literal null}. - * @return new instance of {@link Record}. - */ - Record withId(RecordId id); - - /** - * Create a new instance of {@link Record} with the given {@literal key} to store the record at. - * - * @param key the Redis key identifying the stream. - * @param - * @return new instance of {@link Record}. - */ - Record withStreamKey(S1 key); - } - - /** - * A {@link Record} within the stream mapped to a single object. This may be a simple type, such as {@link String} or - * a complex one. - * - * @param the type of the backing Object. - */ - interface ObjectRecord extends Record { - - /* - * (non-Javadoc) - * @see org.springframework.data.redis.connection.RedisStreamCommands.Record#withId(org.springframework.data.redis.connection.RedisStreamCommands.RecordId) - */ - @Override - ObjectRecord withId(RecordId id); - - /* - * (non-Javadoc) - * @see org.springframework.data.redis.connection.RedisStreamCommands.Record#withStreamKey(java.lang.Object) - */ - ObjectRecord withStreamKey(S1 key); - - /** - * Apply the given {@link HashMapper} to the backing value to create a new {@link MapRecord}. An already assigned - * {@link RecordId id} is carried over to the new instance. - * - * @param mapper must not be {@literal null}. - * @param the key type of the resulting {@link MapRecord}. - * @param the value type of the resulting {@link MapRecord}. - * @return new instance of {@link MapRecord}. - */ - default MapRecord toMapRecord(HashMapper mapper) { - return Record. of(mapper.toHash(getValue())).withId(getId()).withStreamKey(getStream()); - } - } - - /** - * A {@link Record} within the stream backed by a collection of {@literal field/value} paris. - * - * @param the field type of the backing collection. - * @param the value type of the backing collection. - */ - interface MapRecord extends Record>, Iterable> { - - /* - * (non-Javadoc) - * @see org.springframework.data.redis.connection.RedisStreamCommands.Record#withId(org.springframework.data.redis.connection.RedisStreamCommands.RecordId) - */ - @Override - MapRecord withId(RecordId id); - - /* - * (non-Javadoc) - * @see org.springframework.data.redis.connection.RedisStreamCommands.Record#withStreamKey(java.lang.Object) - */ - MapRecord withStreamKey(S1 key); - - /** - * Apply the given {@link Function mapFunction} to each and every entry in the backing collection to create a new - * {@link MapRecord}. - * - * @param mapFunction must not be {@literal null}. - * @param the field type of the new backing collection. - * @param the value type of the new backing collection. - * @return new instance of {@link MapRecord}. - */ - default MapRecord mapEntries(Function, Entry> mapFunction) { - - Map mapped = new LinkedHashMap<>(); - iterator().forEachRemaining(it -> { - - Entry mappedPair = mapFunction.apply(it); - mapped.put(mappedPair.getKey(), mappedPair.getValue()); - }); - - return StreamRecords.newRecord().in(getStream()).withId(getId()).ofMap(mapped); - } - - default MapRecord map(Function, MapRecord> mapFunction) { - return mapFunction.apply(this); - } - - /** - * Serialize {@link #getStream() key} and {@link #getValue() field/value pairs} with the given - * {@link RedisSerializer}. An already assigned {@link RecordId id} is carried over to the new instance. - * - * @param serializer can be {@literal null} if the {@link Record} only holds binary data. - * @return new {@link ByteRecord} holding the serialized values. - */ - default ByteRecord serialize(@Nullable RedisSerializer serializer) { - return serialize(serializer, serializer, serializer); - } - - /** - * Serialize {@link #getStream() key} with the {@literal streamSerializer}, field names with the - * {@literal fieldSerializer} and values with the {@literal valueSerializer}. An already assigned {@link RecordId - * id} is carried over to the new instance. - * - * @param streamSerializer can be {@literal null} if the key is binary. - * @param fieldSerializer can be {@literal null} if the fields are binary. - * @param valueSerializer can be {@literal null} if the values are binary. - * @return new {@link ByteRecord} holding the serialized values. - */ - default ByteRecord serialize(@Nullable RedisSerializer streamSerializer, - @Nullable RedisSerializer fieldSerializer, @Nullable RedisSerializer valueSerializer) { - - MapRecord x = mapEntries(it -> Collections - .singletonMap(fieldSerializer != null ? fieldSerializer.serialize(it.getKey()) : (byte[]) it.getKey(), - valueSerializer != null ? valueSerializer.serialize(it.getValue()) : (byte[]) it.getValue()) - .entrySet().iterator().next()); - - return StreamRecords.newRecord() // - .in(streamSerializer != null ? streamSerializer.serialize(getStream()) : (byte[]) getStream()) // - .withId(getId()) // - .ofBytes(x.getValue()); - } - - /** - * Apply the given {@link HashMapper} to the backing value to create a new {@link MapRecord}. An already assigned - * {@link RecordId id} is carried over to the new instance. - * - * @param mapper must not be {@literal null}. - * @param type of the value backing the {@link ObjectRecord}. - * @return new instance of {@link ObjectRecord}. - */ - default ObjectRecord toObjectRecord(HashMapper mapper) { - return Record. of((OV) (mapper).fromHash((Map) getValue())).withId(getId()).withStreamKey(getStream()); - } - } - - /** - * A {@link Record} within the stream backed by a collection of binary {@literal field/value} paris. - * - * @author Christoph Strobl - */ - interface ByteBufferRecord extends MapRecord { - - /* - * (non-Javadoc) - * @see org.springframework.data.redis.connection.RedisStreamCommands.Record#withId(org.springframework.data.redis.connection.RedisStreamCommands.RecordId) - */ - @Override - ByteBufferRecord withId(RecordId id); - - ByteBufferRecord withStreamKey(ByteBuffer key); - - /** - * Deserialize {@link #getStream() key} and {@link #getValue() field/value pairs} with the given - * {@link RedisSerializer}. An already assigned {@link RecordId id} is carried over to the new instance. - * - * @param serializer can be {@literal null} if the {@link Record} only holds binary data. - * @return new {@link MapRecord} holding the deserialized values. - */ - default MapRecord deserialize(@Nullable RedisSerializer serializer) { - return deserialize(serializer, serializer, serializer); - } - - /** - * Deserialize {@link #getStream() key} with the {@literal streamSerializer}, field names with the - * {@literal fieldSerializer} and values with the {@literal valueSerializer}. An already assigned {@link RecordId - * id} is carried over to the new instance. - * - * @param streamSerializer can be {@literal null} if the key suites already the target format. - * @param fieldSerializer can be {@literal null} if the fields suite already the target format. - * @param valueSerializer can be {@literal null} if the values suite already the target format. - * @return new {@link MapRecord} holding the deserialized values. - */ - default MapRecord deserialize(@Nullable RedisSerializer streamSerializer, - @Nullable RedisSerializer fieldSerializer, - @Nullable RedisSerializer valueSerializer) { - - return mapEntries(it -> Collections. singletonMap( - fieldSerializer != null ? fieldSerializer.deserialize(ByteUtils.getBytes(it.getKey())) : (HK) it.getKey(), - valueSerializer != null ? valueSerializer.deserialize(ByteUtils.getBytes(it.getValue())) : (HV) it.getValue()) - .entrySet().iterator().next()) - .withStreamKey(streamSerializer != null ? streamSerializer.deserialize(ByteUtils.getBytes(getStream())) - : (K) getStream()); - } - - /** - * Turn a binary {@link MapRecord} into a {@link ByteRecord}. - * - * @param source must not be {@literal null}. - * @return new instance of {@link ByteRecord}. - */ - // static ByteBufferRecord of(MapRecord source) { - // return - // StreamRecords.newRecord().in(ByteBuffer.wrap(source.getStream())).withId(ByteBuffer.wrap(source.getId())).ofBytes(ByteBuffer.wrap(source.getValue())); - // } - - /** - * Turn a binary {@link MapRecord} into a {@link ByteRecord}. - * - * @param source must not be {@literal null}. - * @return new instance of {@link ByteRecord}. - */ - static ByteBufferRecord of(MapRecord source) { - return StreamRecords.newRecord().in(source.getStream()).withId(source.getId()).ofBuffer(source.getValue()); - } - - default ObjectRecord toObjectRecord( - HashMapper mapper) { - - Map targetMap = getValue().entrySet().stream().collect( - Collectors.toMap(entry -> ByteUtils.getBytes(entry.getKey()), entry -> ByteUtils.getBytes(entry.getValue()))); - - return Record. of((OV) (mapper).fromHash((Map) targetMap)).withId(getId()) - .withStreamKey(getStream()); - } - } - - /** - * A {@link Record} within the stream backed by a collection of binary {@literal field/value} paris. - * - * @author Christoph Strobl - */ - interface ByteRecord extends MapRecord { - - /* - * (non-Javadoc) - * @see org.springframework.data.redis.connection.RedisStreamCommands.Record#withId(org.springframework.data.redis.connection.RedisStreamCommands.RecordId) - */ - @Override - ByteRecord withId(RecordId id); - - ByteRecord withStreamKey(byte[] key); - - /** - * Deserialize {@link #getStream() key} and {@link #getValue() field/value pairs} with the given - * {@link RedisSerializer}. An already assigned {@link RecordId id} is carried over to the new instance. - * - * @param serializer can be {@literal null} if the {@link Record} only holds binary data. - * @return new {@link MapRecord} holding the deserialized values. - */ - default MapRecord deserialize(@Nullable RedisSerializer serializer) { - return deserialize(serializer, serializer, serializer); - } - - /** - * Deserialize {@link #getStream() key} with the {@literal streamSerializer}, field names with the - * {@literal fieldSerializer} and values with the {@literal valueSerializer}. An already assigned {@link RecordId - * id} is carried over to the new instance. - * - * @param streamSerializer can be {@literal null} if the key suites already the target format. - * @param fieldSerializer can be {@literal null} if the fields suite already the target format. - * @param valueSerializer can be {@literal null} if the values suite already the target format. - * @return new {@link MapRecord} holding the deserialized values. - */ - default MapRecord deserialize(@Nullable RedisSerializer streamSerializer, - @Nullable RedisSerializer fieldSerializer, - @Nullable RedisSerializer valueSerializer) { - - return mapEntries(it -> Collections - . singletonMap(fieldSerializer != null ? fieldSerializer.deserialize(it.getKey()) : (HK) it.getKey(), - valueSerializer != null ? valueSerializer.deserialize(it.getValue()) : (HV) it.getValue()) - .entrySet().iterator().next()) - .withStreamKey(streamSerializer != null ? streamSerializer.deserialize(getStream()) : (K) getStream()); - } - - /** - * Turn a binary {@link MapRecord} into a {@link ByteRecord}. - * - * @param source must not be {@literal null}. - * @return new instance of {@link ByteRecord}. - */ - static ByteRecord of(MapRecord source) { - return StreamRecords.newRecord().in(source.getStream()).withId(source.getId()).ofBytes(source.getValue()); - } - } - - /** - * A {@link Record} within the stream backed by a collection of {@link String} {@literal field/value} paris. - * - * @author Christoph Strobl - */ - interface StringRecord extends MapRecord { - - /* - * (non-Javadoc) - * @see org.springframework.data.redis.connection.RedisStreamCommands.Record#withId(org.springframework.data.redis.connection.RedisStreamCommands.RecordId) - */ - @Override - StringRecord withId(RecordId id); - - StringRecord withStreamKey(String key); - - /** - * Turn a {@link MapRecord} of {@link String strings} into a {@link StringRecord}. - * - * @param source must not be {@literal null}. - * @return new instance of {@link StringRecord}. - */ - static StringRecord of(MapRecord source) { - return StreamRecords.newRecord().in(source.getStream()).withId(source.getId()).ofStrings(source.getValue()); - } - } } diff --git a/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java b/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java index d6799b693..d1c22d3be 100644 --- a/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java @@ -28,6 +28,13 @@ import org.springframework.data.geo.Distance; import org.springframework.data.geo.GeoResults; import org.springframework.data.geo.Metric; import org.springframework.data.geo.Point; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.connection.stream.StringRecord; import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.ScanOptions; diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommands.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommands.java index 385459588..1373a3650 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommands.java @@ -32,12 +32,11 @@ import org.springframework.data.redis.connection.ReactiveRedisConnection.KeyComm import org.springframework.data.redis.connection.ReactiveRedisConnection.NumericResponse; import org.springframework.data.redis.connection.ReactiveStreamCommands; import org.springframework.data.redis.connection.ReactiveStreamCommands.GroupCommand.GroupCommandAction; -import org.springframework.data.redis.connection.RedisStreamCommands; -import org.springframework.data.redis.connection.RedisStreamCommands.ByteBufferRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions; -import org.springframework.data.redis.connection.StreamRecords; +import org.springframework.data.redis.connection.stream.ByteBufferRecord; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamReadOptions; +import org.springframework.data.redis.connection.stream.StreamRecords; import org.springframework.data.redis.util.ByteUtils; import org.springframework.util.Assert; @@ -270,7 +269,8 @@ class LettuceReactiveStreamCommands implements ReactiveStreamCommands { } @SuppressWarnings("unchecked") - private static StreamOffset[] toStreamOffsets(Collection> streams) { + private static StreamOffset[] toStreamOffsets( + Collection> streams) { return streams.stream().map(it -> StreamOffset.from(it.getKey(), it.getOffset().getOffset())) .toArray(StreamOffset[]::new); diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceStreamCommands.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceStreamCommands.java index b6c222163..ddaa67a94 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceStreamCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceStreamCommands.java @@ -28,8 +28,15 @@ import java.util.function.Function; import org.springframework.dao.DataAccessException; import org.springframework.data.domain.Range; +import org.springframework.data.redis.connection.stream.ByteRecord; +import org.springframework.data.redis.connection.stream.MapRecord; import org.springframework.data.redis.connection.RedisStreamCommands; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; import org.springframework.util.Assert; /** diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/StreamConverters.java b/src/main/java/org/springframework/data/redis/connection/lettuce/StreamConverters.java index 00d64ee6b..d01034639 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/StreamConverters.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/StreamConverters.java @@ -21,9 +21,9 @@ import io.lettuce.core.XReadArgs; import java.util.List; import org.springframework.core.convert.converter.Converter; -import org.springframework.data.redis.connection.RedisStreamCommands.ByteRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions; -import org.springframework.data.redis.connection.StreamRecords; +import org.springframework.data.redis.connection.stream.ByteRecord; +import org.springframework.data.redis.connection.stream.StreamReadOptions; +import org.springframework.data.redis.connection.stream.StreamRecords; import org.springframework.data.redis.connection.convert.ListConverter; /** diff --git a/src/main/java/org/springframework/data/redis/connection/stream/ByteBufferRecord.java b/src/main/java/org/springframework/data/redis/connection/stream/ByteBufferRecord.java new file mode 100644 index 000000000..1a9498fb4 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/stream/ByteBufferRecord.java @@ -0,0 +1,110 @@ +/* + * Copyright 2018 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.data.redis.connection.stream; + +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.data.redis.hash.HashMapper; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.util.ByteUtils; +import org.springframework.lang.Nullable; + +/** + * A {@link Record} within the stream backed by a collection of binary {@literal field/value} paris. + * + * @author Christoph Strobl + * @see 2.2 + */ +public interface ByteBufferRecord extends MapRecord { + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.RedisStreamCommands.Record#withId(org.springframework.data.redis.connection.RedisStreamCommands.RecordId) + */ + @Override + ByteBufferRecord withId(RecordId id); + + /** + * Create a new {@link ByteBufferRecord} with the associated stream {@literal key}. + * + * @param key the binary stream key. + * @return a new {@link ByteBufferRecord}. + */ + ByteBufferRecord withStreamKey(ByteBuffer key); + + /** + * Deserialize {@link #getStream() key} and {@link #getValue() field/value pairs} with the given + * {@link RedisSerializer}. An already assigned {@link RecordId id} is carried over to the new instance. + * + * @param serializer can be {@literal null} if the {@link Record} only holds binary data. + * @return new {@link MapRecord} holding the deserialized values. + */ + default MapRecord deserialize(@Nullable RedisSerializer serializer) { + return deserialize(serializer, serializer, serializer); + } + + /** + * Deserialize {@link #getStream() key} with the {@literal streamSerializer}, field names with the + * {@literal fieldSerializer} and values with the {@literal valueSerializer}. An already assigned {@link RecordId id} + * is carried over to the new instance. + * + * @param streamSerializer can be {@literal null} if the key suites already the target format. + * @param fieldSerializer can be {@literal null} if the fields suite already the target format. + * @param valueSerializer can be {@literal null} if the values suite already the target format. + * @return new {@link MapRecord} holding the deserialized values. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + default MapRecord deserialize(@Nullable RedisSerializer streamSerializer, + @Nullable RedisSerializer fieldSerializer, + @Nullable RedisSerializer valueSerializer) { + + return mapEntries(it -> Collections. singletonMap( + fieldSerializer != null ? fieldSerializer.deserialize(ByteUtils.getBytes(it.getKey())) : (HK) it.getKey(), + valueSerializer != null ? valueSerializer.deserialize(ByteUtils.getBytes(it.getValue())) : (HV) it.getValue()) + .entrySet().iterator().next()).withStreamKey( + streamSerializer != null ? streamSerializer.deserialize(ByteUtils.getBytes(getStream())) : (K) getStream()); + } + + /** + * Convert a binary {@link MapRecord} into a {@link ByteRecord}. + * + * @param source must not be {@literal null}. + * @return new instance of {@link ByteRecord}. + */ + static ByteBufferRecord of(MapRecord source) { + return StreamRecords.newRecord().in(source.getStream()).withId(source.getId()).ofBuffer(source.getValue()); + } + + /** + * Convert a binary {@link MapRecord} into an {@link ObjectRecord}. + * + * @param source must not be {@literal null}. + * @return new instance of {@link ByteRecord}. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + default ObjectRecord toObjectRecord( + HashMapper mapper) { + + Map targetMap = getValue().entrySet().stream().collect( + Collectors.toMap(entry -> ByteUtils.getBytes(entry.getKey()), entry -> ByteUtils.getBytes(entry.getValue()))); + + return Record. of((OV) (mapper).fromHash((Map) targetMap)).withId(getId()) + .withStreamKey(getStream()); + } +} diff --git a/src/main/java/org/springframework/data/redis/connection/stream/ByteRecord.java b/src/main/java/org/springframework/data/redis/connection/stream/ByteRecord.java new file mode 100644 index 000000000..d165e0a9d --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/stream/ByteRecord.java @@ -0,0 +1,88 @@ +/* + * Copyright 2018 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.data.redis.connection.stream; + +import java.util.Collections; + +import org.springframework.data.redis.connection.RedisStreamCommands; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.lang.Nullable; + +/** + * A {@link Record} within the stream backed by a collection of binary {@literal field/value} paris. + * + * @author Christoph Strobl + * @see 2.2 + */ +public interface ByteRecord extends MapRecord { + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.RedisStreamCommands.Record#withId(org.springframework.data.redis.connection.RedisStreamCommands.RecordId) + */ + @Override + ByteRecord withId(RecordId id); + + /** + * Create a new {@link ByteRecord} with the associated stream {@literal key}. + * + * @param key the binary stream key. + * @return a new {@link ByteRecord}. + */ + ByteRecord withStreamKey(byte[] key); + + /** + * Deserialize {@link #getStream() key} and {@link #getValue() field/value pairs} with the given + * {@link RedisSerializer}. An already assigned {@link RecordId id} is carried over to the new instance. + * + * @param serializer can be {@literal null} if the {@link Record} only holds binary data. + * @return new {@link MapRecord} holding the deserialized values. + */ + default MapRecord deserialize(@Nullable RedisSerializer serializer) { + return deserialize(serializer, serializer, serializer); + } + + /** + * Deserialize {@link #getStream() key} with the {@literal streamSerializer}, field names with the + * {@literal fieldSerializer} and values with the {@literal valueSerializer}. An already assigned {@link RecordId id} + * is carried over to the new instance. + * + * @param streamSerializer can be {@literal null} if the key suites already the target format. + * @param fieldSerializer can be {@literal null} if the fields suite already the target format. + * @param valueSerializer can be {@literal null} if the values suite already the target format. + * @return new {@link MapRecord} holding the deserialized values. + */ + default MapRecord deserialize(@Nullable RedisSerializer streamSerializer, + @Nullable RedisSerializer fieldSerializer, + @Nullable RedisSerializer valueSerializer) { + + return mapEntries(it -> Collections + . singletonMap(fieldSerializer != null ? fieldSerializer.deserialize(it.getKey()) : (HK) it.getKey(), + valueSerializer != null ? valueSerializer.deserialize(it.getValue()) : (HV) it.getValue()) + .entrySet().iterator().next()) + .withStreamKey(streamSerializer != null ? streamSerializer.deserialize(getStream()) : (K) getStream()); + } + + /** + * Convert a binary {@link MapRecord} into a {@link ByteRecord}. + * + * @param source must not be {@literal null}. + * @return new instance of {@link ByteRecord}. + */ + static ByteRecord of(MapRecord source) { + return StreamRecords.newRecord().in(source.getStream()).withId(source.getId()).ofBytes(source.getValue()); + } +} diff --git a/src/main/java/org/springframework/data/redis/connection/stream/Consumer.java b/src/main/java/org/springframework/data/redis/connection/stream/Consumer.java new file mode 100644 index 000000000..02ea29412 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/stream/Consumer.java @@ -0,0 +1,61 @@ +/* + * Copyright 2018 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.data.redis.connection.stream; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +import org.springframework.util.Assert; + +/** + * Value object representing a Stream consumer within a consumer group. Group name and consumer name are encoded as + * keys. + * + * @author Mark Paluch + * @see 2.2 + */ +@EqualsAndHashCode +@Getter +public class Consumer { + + private final String group; + private final String name; + + private Consumer(String group, String name) { + this.group = group; + this.name = name; + } + + /** + * Create a new consumer. + * + * @param group name of the consumer group, must not be {@literal null} or empty. + * @param name name of the consumer, must not be {@literal null} or empty. + * @return the consumer {@link io.lettuce.core.Consumer} object. + */ + public static Consumer from(String group, String name) { + + Assert.hasText(group, "Group must not be null"); + Assert.hasText(name, "Name must not be null"); + + return new Consumer(group, name); + } + + @Override + public String toString() { + return String.format("%s:%s", group, name); + } +} diff --git a/src/main/java/org/springframework/data/redis/connection/stream/MapRecord.java b/src/main/java/org/springframework/data/redis/connection/stream/MapRecord.java new file mode 100644 index 000000000..ad8d6cc04 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/stream/MapRecord.java @@ -0,0 +1,148 @@ +/* + * Copyright 2018 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.data.redis.connection.stream; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.function.Function; + +import org.springframework.data.redis.connection.stream.StreamRecords.MapBackedRecord; +import org.springframework.data.redis.hash.HashMapper; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * A {@link Record} within the stream backed by a collection of {@literal field/value} paris. + * + * @param the field type of the backing map. + * @param the value type of the backing map. + * @author Christoph Strobl + * @author Mark Paluch + * @since 2.2 + */ +public interface MapRecord extends Record>, Iterable> { + + /** + * Creates a new {@link MapRecord} associated with the {@code stream} key and {@link Map value}. + * + * @param stream the stream key. + * @param map the value. + * @return the {@link ObjectRecord} holding the {@code stream} key and {@code value}. + */ + static MapRecord create(S stream, Map map) { + + Assert.notNull(stream, "Stream must not be null"); + Assert.notNull(map, "Map must not be null"); + + return new MapBackedRecord<>(stream, RecordId.autoGenerate(), map); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.RedisStreamCommands.Record#withId(org.springframework.data.redis.connection.RedisStreamCommands.RecordId) + */ + @Override + MapRecord withId(RecordId id); + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.RedisStreamCommands.Record#withStreamKey(java.lang.Object) + */ + @Override + MapRecord withStreamKey(SK key); + + /** + * Apply the given {@link Function mapFunction} to each and every entry in the backing collection to create a new + * {@link MapRecord}. + * + * @param mapFunction must not be {@literal null}. + * @param the field type of the new backing collection. + * @param the value type of the new backing collection. + * @return new instance of {@link MapRecord}. + */ + default MapRecord mapEntries(Function, Entry> mapFunction) { + + Map mapped = new LinkedHashMap<>(); + iterator().forEachRemaining(it -> { + + Entry mappedPair = mapFunction.apply(it); + mapped.put(mappedPair.getKey(), mappedPair.getValue()); + }); + + return StreamRecords.newRecord().in(getStream()).withId(getId()).ofMap(mapped); + } + + /** + * Map this {@link MapRecord} by applying the mapping {@link Function}. + * + * @param mapFunction function to apply to this {@link MapRecord} element. + * @return the mapped {@link MapRecord}. + */ + default MapRecord map(Function, MapRecord> mapFunction) { + return mapFunction.apply(this); + } + + /** + * Serialize {@link #getStream() key} and {@link #getValue() field/value pairs} with the given + * {@link RedisSerializer}. An already assigned {@link RecordId id} is carried over to the new instance. + * + * @param serializer can be {@literal null} if the {@link Record} only holds binary data. + * @return new {@link ByteRecord} holding the serialized values. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + default ByteRecord serialize(@Nullable RedisSerializer serializer) { + return serialize((RedisSerializer) serializer, (RedisSerializer) serializer, (RedisSerializer) serializer); + } + + /** + * Serialize {@link #getStream() key} with the {@literal streamSerializer}, field names with the + * {@literal fieldSerializer} and values with the {@literal valueSerializer}. An already assigned {@link RecordId id} + * is carried over to the new instance. + * + * @param streamSerializer can be {@literal null} if the key is binary. + * @param fieldSerializer can be {@literal null} if the fields are binary. + * @param valueSerializer can be {@literal null} if the values are binary. + * @return new {@link ByteRecord} holding the serialized values. + */ + default ByteRecord serialize(@Nullable RedisSerializer streamSerializer, + @Nullable RedisSerializer fieldSerializer, @Nullable RedisSerializer valueSerializer) { + + MapRecord binaryMap = mapEntries( + it -> Collections.singletonMap(StreamSerialization.serialize(fieldSerializer, it.getKey()), + StreamSerialization.serialize(fieldSerializer, it.getValue())).entrySet().iterator().next()); + + return StreamRecords.newRecord() // + .in(streamSerializer != null ? streamSerializer.serialize(getStream()) : (byte[]) getStream()) // + .withId(getId()) // + .ofBytes(binaryMap.getValue()); + } + + /** + * Apply the given {@link HashMapper} to the backing value to create a new {@link MapRecord}. An already assigned + * {@link RecordId id} is carried over to the new instance. + * + * @param mapper must not be {@literal null}. + * @param type of the value backing the {@link ObjectRecord}. + * @return new instance of {@link ObjectRecord}. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + default ObjectRecord toObjectRecord(HashMapper mapper) { + return Record. of((OV) mapper.fromHash((Map) getValue())).withId(getId()).withStreamKey(getStream()); + } +} diff --git a/src/main/java/org/springframework/data/redis/connection/stream/ObjectRecord.java b/src/main/java/org/springframework/data/redis/connection/stream/ObjectRecord.java new file mode 100644 index 000000000..8054f5a69 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/stream/ObjectRecord.java @@ -0,0 +1,73 @@ +/* + * Copyright 2018 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.data.redis.connection.stream; + +import org.springframework.data.redis.connection.stream.StreamRecords.ObjectBackedRecord; +import org.springframework.data.redis.hash.HashMapper; +import org.springframework.util.Assert; + +/** + * A {@link Record} within the stream mapped to a single object. This may be a simple type, such as {@link String} or a + * complex one. + * + * @param the type of the backing Object. + * @author Christoph Strobl + * @author Mark Paluch + * @see 2.2 + */ +public interface ObjectRecord extends Record { + + /** + * Creates a new {@link ObjectRecord} associated with the {@code stream} key and {@code value}. + * + * @param stream the stream key. + * @param value the value. + * @return the {@link ObjectRecord} holding the {@code stream} key and {@code value}. + */ + static ObjectRecord create(S stream, V value) { + + Assert.notNull(stream, "Stream must not be null"); + Assert.notNull(value, "Value must not be null"); + + return new ObjectBackedRecord<>(stream, RecordId.autoGenerate(), value); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.RedisStreamCommands.Record#withId(org.springframework.data.redis.connection.RedisStreamCommands.RecordId) + */ + @Override + ObjectRecord withId(RecordId id); + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.RedisStreamCommands.Record#withStreamKey(java.lang.Object) + */ + ObjectRecord withStreamKey(SK key); + + /** + * Apply the given {@link HashMapper} to the backing value to create a new {@link MapRecord}. An already assigned + * {@link RecordId id} is carried over to the new instance. + * + * @param mapper must not be {@literal null}. + * @param the key type of the resulting {@link MapRecord}. + * @param the value type of the resulting {@link MapRecord}. + * @return new instance of {@link MapRecord}. + */ + default MapRecord toMapRecord(HashMapper mapper) { + return Record. of(mapper.toHash(getValue())).withId(getId()).withStreamKey(getStream()); + } +} diff --git a/src/main/java/org/springframework/data/redis/connection/stream/ReadOffset.java b/src/main/java/org/springframework/data/redis/connection/stream/ReadOffset.java new file mode 100644 index 000000000..438f8d7f1 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/stream/ReadOffset.java @@ -0,0 +1,84 @@ +/* + * Copyright 2018 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.data.redis.connection.stream; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +import org.springframework.util.Assert; + +/** + * Value object representing read offset for a Stream. + */ +@EqualsAndHashCode +@ToString +@Getter +public final class ReadOffset { + + private final String offset; + + private ReadOffset(String offset) { + this.offset = offset; + } + + /** + * Read from the latest offset. + * + * @return + */ + public static ReadOffset latest() { + return new ReadOffset("$"); + } + + /** + * Read all new arriving elements with ids greater than the last one consumed by the consumer group. + * + * @return the {@link ReadOffset} object without a specific offset. + */ + public static ReadOffset lastConsumed() { + return new ReadOffset(">"); + } + + /** + * Read all arriving elements from the stream starting at {@code offset}. + * + * @param offset the stream offset. + * @return the {@link ReadOffset} starting at {@code offset}. + */ + public static ReadOffset from(String offset) { + + Assert.hasText(offset, "Offset must not be empty"); + + return new ReadOffset(offset); + } + + /** + * Read all arriving elements from the stream starting at {@link RecordId}. Using a + * {@link RecordId#shouldBeAutoGenerated() auto-generated} {@link RecordId} returns the {@link #latest()} read offset. + * + * @param offset the stream offset. + * @return the {@link ReadOffset} starting at {@link RecordId}. + */ + public static ReadOffset from(RecordId offset) { + + if (offset.shouldBeAutoGenerated()) { + return latest(); + } + + return from(offset.getValue()); + } +} diff --git a/src/main/java/org/springframework/data/redis/connection/stream/Record.java b/src/main/java/org/springframework/data/redis/connection/stream/Record.java new file mode 100644 index 000000000..0ce3494eb --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/stream/Record.java @@ -0,0 +1,101 @@ +/* + * Copyright 2018 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.data.redis.connection.stream; + +import java.util.Map; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * A single entry in the stream consisting of the {@link RecordId entry-id} and the actual entry-value (typically a + * collection of {@link MapRecord field/value pairs}). + * + * @param the type backing the {@link Record}. + * @author Christoph Strobl + * @since 2.2 + * @see Redis Documentation - Stream Basics + */ +public interface Record { + + /** + * The id of the stream (aka the {@literal key} in Redis). + * + * @return can be {@literal null}. + */ + @Nullable + S getStream(); + + /** + * The id of the entry inside the stream. + * + * @return never {@literal null}. + */ + RecordId getId(); + + /** + * @return the actual content. Never {@literal null}. + */ + V getValue(); + + /** + * Create a new {@link MapRecord} instance backed by the given {@link Map} holding {@literal field/value} pairs. + *
+ * You may want to use the builders available via {@link StreamRecords}. + * + * @param map the raw map. + * @param the key type of the given {@link Map}. + * @param the value type of the given {@link Map}. + * @return new instance of {@link MapRecord}. + */ + static MapRecord of(Map map) { + + Assert.notNull(map, "Map must not be null!"); + return StreamRecords.mapBacked(map); + } + + /** + * Create a new {@link ObjectRecord} instance backed by the given {@literal value}. The value may be a simple type, + * like {@link String} or a complex one.
+ * You may want to use the builders available via {@link StreamRecords}. + * + * @param value the value to persist. + * @param the type of the backing value. + * @return new instance of {@link MapRecord}. + */ + static ObjectRecord of(V value) { + + Assert.notNull(value, "Value must not be null!"); + return StreamRecords.objectBacked(value); + } + + /** + * Create a new instance of {@link Record} with the given {@link RecordId}. + * + * @param id must not be {@literal null}. + * @return new instance of {@link Record}. + */ + Record withId(RecordId id); + + /** + * Create a new instance of {@link Record} with the given {@literal key} to store the record at. + * + * @param key the Redis key identifying the stream. + * @param + * @return new instance of {@link Record}. + */ + Record withStreamKey(SK key); +} diff --git a/src/main/java/org/springframework/data/redis/connection/stream/RecordId.java b/src/main/java/org/springframework/data/redis/connection/stream/RecordId.java new file mode 100644 index 000000000..799ea5f7f --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/stream/RecordId.java @@ -0,0 +1,157 @@ +/* + * Copyright 2018 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.data.redis.connection.stream; + +import lombok.EqualsAndHashCode; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.NumberUtils; +import org.springframework.util.StringUtils; + +/** + * The id of a single {@link Record} within a stream. Composed of two parts: + * {@literal -}. + * + * @author Christoph Strobl + * @since 2.2 + * @see Redis Documentation - Entriy ID + */ +@EqualsAndHashCode +public class RecordId { + + private static final String GENERATE_ID = "*"; + private static final String DELIMITER = "-"; + + /** + * Auto-generation of IDs by the server is almost always what you want so we've got this instance here shortcutting + * computation. + */ + private static final RecordId AUTOGENERATED = new RecordId(GENERATE_ID) { + + @Override + public Long getSequence() { + return null; + } + + @Override + public Long getTimestamp() { + return null; + } + + @Override + public boolean shouldBeAutoGenerated() { + return true; + } + }; + + private final String raw; + + /** + * Private constructor - validate input in static initializer blocks. + * + * @param raw + */ + private RecordId(String raw) { + this.raw = raw; + } + + /** + * Obtain an instance of {@link RecordId} using the provided String formatted as + * {@literal -}.
+ * For server auto generated {@literal entry-id} on insert pass in {@literal null} or {@literal *}. Event better, just + * use {@link #autoGenerate()}. + * + * @param value can be {@literal null}. + * @return new instance of {@link RecordId} if no autogenerated one requested. + */ + public static RecordId of(@Nullable String value) { + + if (value == null || GENERATE_ID.equals(value)) { + return autoGenerate(); + } + + Assert.isTrue(value.contains(DELIMITER), + "Invalid id format. Please use the 'millisecondsTime-sequenceNumber' format."); + return new RecordId(value); + } + + /** + * Create a new instance of {@link RecordId} using the provided String formatted as + * {@literal -}.
+ * For server auto generated {@literal entry-id} on insert use {@link #autoGenerate()}. + * + * @param millisecondsTime + * @param sequenceNumber + * @return new instance of {@link RecordId}. + */ + public static RecordId of(long millisecondsTime, long sequenceNumber) { + return of(millisecondsTime + DELIMITER + sequenceNumber); + } + + /** + * Obtain the {@link RecordId} signalling the server to auto generate an {@literal entry-id} on insert ({@code XADD}). + * + * @return {@link RecordId} instance signalling {@link #shouldBeAutoGenerated()}. + */ + public static RecordId autoGenerate() { + return AUTOGENERATED; + } + + /** + * Get the {@literal entry-id millisecondsTime} part or {@literal null} if it {@link #shouldBeAutoGenerated()}. + * + * @return millisecondsTime of the {@literal entry-id}. Can be {@literal null}. + */ + @Nullable + public Long getTimestamp() { + return value(0); + } + + /** + * Get the {@literal entry-id sequenceNumber} part or {@literal null} if it {@link #shouldBeAutoGenerated()}. + * + * @return sequenceNumber of the {@literal entry-id}. Can be {@literal null}. + */ + @Nullable + public Long getSequence() { + return value(1); + } + + /** + * @return {@literal true} if a new {@literal entry-id} shall be generated on server side when calling {@code XADD}. + */ + public boolean shouldBeAutoGenerated() { + return false; + } + + /** + * @return get the string representation of the {@literal entry-id} in {@literal -} + * format or {@literal *} if it {@link #shouldBeAutoGenerated()}. Never {@literal null}. + */ + public String getValue() { + return raw; + } + + @Override + public String toString() { + return raw; + } + + private Long value(int index) { + return NumberUtils.parseNumber(StringUtils.split(raw, DELIMITER)[index], Long.class); + } +} diff --git a/src/main/java/org/springframework/data/redis/connection/stream/StreamOffset.java b/src/main/java/org/springframework/data/redis/connection/stream/StreamOffset.java new file mode 100644 index 000000000..ca7c15aaf --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/stream/StreamOffset.java @@ -0,0 +1,95 @@ +/* + * Copyright 2018 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.data.redis.connection.stream; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +import org.springframework.util.Assert; + +/** + * Value object representing a Stream Id with its offset. + * + * @author Mark Paluch + * @see 2.2 + */ +@EqualsAndHashCode +@ToString +@Getter +public final class StreamOffset { + + private final K key; + private final ReadOffset offset; + + private StreamOffset(K key, ReadOffset offset) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(offset, "ReadOffset must not be null"); + + this.key = key; + this.offset = offset; + } + + /** + * Create a {@link StreamOffset} given {@code key} and {@link ReadOffset}. + * + * @param stream the stream key. + * @param readOffset the {@link ReadOffset} to use. + * @return new instance of {@link StreamOffset}. + */ + public static StreamOffset create(K stream, ReadOffset readOffset) { + return new StreamOffset<>(stream, readOffset); + } + + /** + * Create a {@link StreamOffset} given {@code key} starting at {@link ReadOffset#latest()}. + * + * @param stream the stream key. + * @param + * @return new instance of {@link StreamOffset}. + */ + public static StreamOffset latest(K stream) { + return new StreamOffset<>(stream, ReadOffset.latest()); + } + + /** + * Create a {@link StreamOffset} given {@code stream} starting at {@link ReadOffset#from(String) + * ReadOffset#from("0-0")}. + * + * @param stream the stream key. + * @param + * @return new instance of {@link StreamOffset}. + */ + public static StreamOffset fromStart(K stream) { + return new StreamOffset<>(stream, ReadOffset.from("0-0")); + } + + /** + * Create a {@link StreamOffset} from the given {@link Record#getId() record id} as reference to create the + * {@link ReadOffset#from(String)}. + * + * @param reference the record to be used as reference point. + * @param + * @return new instance of {@link StreamOffset}. + */ + public static StreamOffset from(Record reference) { + + Assert.notNull(reference, "Reference record must not be null"); + + return create(reference.getStream(), ReadOffset.from(reference.getId())); + } +} diff --git a/src/main/java/org/springframework/data/redis/connection/stream/StreamReadOptions.java b/src/main/java/org/springframework/data/redis/connection/stream/StreamReadOptions.java new file mode 100644 index 000000000..0681f9daa --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/stream/StreamReadOptions.java @@ -0,0 +1,95 @@ +/* + * Copyright 2018 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.data.redis.connection.stream; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +import java.time.Duration; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Options for reading messages from a Redis Stream. + * + * @author Mark Paluch + * @see 2.2 + */ +@EqualsAndHashCode +@ToString +@Getter +public class StreamReadOptions { + + private static final StreamReadOptions EMPTY = new StreamReadOptions(null, null, false); + + private final @Nullable Long block; + private final @Nullable Long count; + private final boolean noack; + + private StreamReadOptions(@Nullable Long block, @Nullable Long count, boolean noack) { + this.block = block; + this.count = count; + this.noack = noack; + } + + /** + * Creates an empty {@link StreamReadOptions} instance. + * + * @return an empty {@link StreamReadOptions} instance. + */ + public static StreamReadOptions empty() { + return EMPTY; + } + + /** + * Disable auto-acknowledgement when reading in the context of a consumer group. + * + * @return {@link StreamReadOptions} with {@code noack} applied. + */ + public StreamReadOptions noack() { + return new StreamReadOptions(block, count, true); + } + + /** + * Use a blocking read and supply the {@link Duration timeout} after which the call will terminate if no message was + * read. + * + * @param timeout the timeout for the blocking read, must not be {@literal null} or negative. + * @return {@link StreamReadOptions} with {@code block} applied. + */ + public StreamReadOptions block(Duration timeout) { + + Assert.notNull(timeout, "Block timeout must not be null!"); + Assert.isTrue(!timeout.isNegative(), "Block timeout must not be negative!"); + + return new StreamReadOptions(timeout.toMillis(), count, noack); + } + + /** + * Limit the number of messages returned per stream. + * + * @param count the maximum number of messages to read. + * @return {@link StreamReadOptions} with {@code count} applied. + */ + public StreamReadOptions count(long count) { + + Assert.isTrue(count > 0, "Count must be greater or equal to zero!"); + + return new StreamReadOptions(block, count, noack); + } +} diff --git a/src/main/java/org/springframework/data/redis/connection/StreamRecords.java b/src/main/java/org/springframework/data/redis/connection/stream/StreamRecords.java similarity index 75% rename from src/main/java/org/springframework/data/redis/connection/StreamRecords.java rename to src/main/java/org/springframework/data/redis/connection/stream/StreamRecords.java index 628a161ca..f0b2d6455 100644 --- a/src/main/java/org/springframework/data/redis/connection/StreamRecords.java +++ b/src/main/java/org/springframework/data/redis/connection/stream/StreamRecords.java @@ -1,4 +1,19 @@ -package org.springframework.data.redis.connection; +/* + * Copyright 2018 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.data.redis.connection.stream; import lombok.EqualsAndHashCode; @@ -7,20 +22,14 @@ import java.util.Iterator; import java.util.Map; import java.util.Map.Entry; -import org.springframework.data.redis.connection.RedisStreamCommands.ByteBufferRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ByteRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.MapRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ObjectRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; -import org.springframework.data.redis.connection.RedisStreamCommands.StringRecord; import org.springframework.data.redis.util.ByteUtils; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; /** - * {@link StreamRecords} provides utilities to create specific - * {@link org.springframework.data.redis.connection.RedisStreamCommands.Record} instances. + * {@link StreamRecords} provides utilities to create specific {@link Record} instances. * * @author Christoph Strobl * @since 2.2 @@ -83,15 +92,22 @@ public class StreamRecords { } /** - * Obtain new instance of {@link RecordBuilder} to fluently create - * {@link org.springframework.data.redis.connection.RedisStreamCommands.Record records}. + * Obtain new instance of {@link RecordBuilder} to fluently create {@link Record records}. * * @return */ - public static RecordBuilder newRecord() { - return new RecordBuilder(null, RecordId.autoGenerate()); + public static RecordBuilder newRecord() { + return new RecordBuilder<>(null, RecordId.autoGenerate()); } + // Utility constructor + private StreamRecords() {} + + /** + * Builder for {@link Record}. + * + * @param stream keyy type. + */ public static class RecordBuilder { private RecordId id; @@ -103,21 +119,49 @@ public class StreamRecords { this.id = recordId; } + /** + * Configure a stream key. + * + * @param stream the stream key, must not be null. + * @param + * @return {@literal this} {@link RecordBuilder}. + */ public RecordBuilder in(STREAM_KEY stream) { + + Assert.notNull(stream, "Stream key must not be null"); + return new RecordBuilder<>(stream, id); } + /** + * Configure a record Id given a {@link String}. Associates a user-supplied record id instead of using + * server-generated record Id's. + * + * @param id the record id. + * @return {@literal this} {@link RecordBuilder}. + * @see RecordId + */ public RecordBuilder withId(String id) { return withId(RecordId.of(id)); } + /** + * Configure a {@link RecordId}. Associates a user-supplied record id instead of using server-generated record Id's. + * + * @param id the record id. + * @return {@literal this} {@link RecordBuilder}. + */ public RecordBuilder withId(RecordId id) { + Assert.notNull(id, "RecordId must not be null"); + this.id = id; return this; } /** + * Create a {@link MapRecord}. + * * @param map * @param * @param @@ -128,17 +172,22 @@ public class StreamRecords { } /** + * Create a {@link StringRecord}. + * * @param map * @return new instance of {@link StringRecord}. + * @see MapRecord */ public StringRecord ofStrings(Map map) { return new StringMapBackedRecord(ObjectUtils.nullSafeToString(stream), id, map); } /** + * Create an {@link ObjectRecord}. + * * @param value * @param - * @return ni instance of {@link ObjectRecord}. + * @return new instance of {@link ObjectRecord}. */ public ObjectRecord ofObject(V value) { return new ObjectBackedRecord<>(stream, id, value); @@ -160,7 +209,7 @@ public class StreamRecords { */ public ByteBufferRecord ofBuffer(Map value) { - ByteBuffer streamKey = null; + ByteBuffer streamKey; if (stream instanceof ByteBuffer) { streamKey = (ByteBuffer) stream; @@ -176,13 +225,20 @@ public class StreamRecords { } } + /** + * Default implementation of {@link MapRecord}. + * + * @param + * @param + * @param + */ static class MapBackedRecord implements MapRecord { private @Nullable S stream; private RecordId recordId; private final Map kvMap; - MapBackedRecord(S stream, RecordId recordId, Map kvMap) { + MapBackedRecord(@Nullable S stream, RecordId recordId, Map kvMap) { this.stream = stream; this.recordId = recordId; @@ -263,6 +319,9 @@ public class StreamRecords { } } + /** + * Default implementation of {@link ByteRecord}. + */ static class ByteMapBackedRecord extends MapBackedRecord implements ByteRecord { ByteMapBackedRecord(byte[] stream, RecordId recordId, Map map) { @@ -274,11 +333,15 @@ public class StreamRecords { return new ByteMapBackedRecord(key, getId(), getValue()); } + @Override public ByteMapBackedRecord withId(RecordId id) { return new ByteMapBackedRecord(getStream(), id, getValue()); } } + /** + * Default implementation of {@link ByteBufferRecord}. + */ static class ByteBufferMapBackedRecord extends MapBackedRecord implements ByteBufferRecord { @@ -291,11 +354,15 @@ public class StreamRecords { return new ByteBufferMapBackedRecord(key, getId(), getValue()); } + @Override public ByteBufferMapBackedRecord withId(RecordId id) { return new ByteBufferMapBackedRecord(getStream(), id, getValue()); } } + /** + * Default implementation of StringRecord. + */ static class StringMapBackedRecord extends MapBackedRecord implements StringRecord { StringMapBackedRecord(String stream, RecordId recordId, Map stringStringMap) { @@ -307,11 +374,18 @@ public class StreamRecords { return new StringMapBackedRecord(key, getId(), getValue()); } + @Override public StringMapBackedRecord withId(RecordId id) { return new StringMapBackedRecord(getStream(), id, getValue()); } } + /** + * Default implementation of {@link ObjectRecord}. + * + * @param + * @param + */ @EqualsAndHashCode static class ObjectBackedRecord implements ObjectRecord { @@ -319,7 +393,7 @@ public class StreamRecords { private RecordId recordId; private final V value; - public ObjectBackedRecord(@Nullable S stream, RecordId recordId, V value) { + ObjectBackedRecord(@Nullable S stream, RecordId recordId, V value) { this.stream = stream; this.recordId = recordId; @@ -349,7 +423,7 @@ public class StreamRecords { } @Override - public ObjectRecord withStreamKey(S1 key) { + public ObjectRecord withStreamKey(SK key) { return new ObjectBackedRecord<>(key, recordId, value); } diff --git a/src/main/java/org/springframework/data/redis/connection/stream/StreamSerialization.java b/src/main/java/org/springframework/data/redis/connection/stream/StreamSerialization.java new file mode 100644 index 000000000..0bfd299bc --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/stream/StreamSerialization.java @@ -0,0 +1,53 @@ +/* + * Copyright 2018 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.data.redis.connection.stream; + +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.lang.Nullable; + +/** + * Utility methods for stream serialization. + * + * @author Mark Paluch + * @since 2.2 + */ +class StreamSerialization { + + /** + * Serialize the {@code value} using the optional {@link RedisSerializer}. If no conversion is possible, {@code value} + * is assumed to be a byte array. + * + * @param serializer the serializer. Can be {@literal null}. + * @param value the value to serialize. + * @return the serialized (binary) representation of {@code value}. + */ + @SuppressWarnings("unchecked") + static byte[] serialize(@Nullable RedisSerializer serializer, Object value) { + return canSerialize(serializer, value) ? ((RedisSerializer) serializer).serialize(value) : (byte[]) value; + } + + /** + * Returns whether the given {@link RedisSerializer} is capable of serializing the {@code value} to {@literal byte[]}. + * + * @param serializer the serializer. Can be {@literal null}. + * @param value the value to serialize. + * @return {@literal true} if the given {@link RedisSerializer} is capable of serializing the {@code value} to + * {@literal byte[]}. + */ + private static boolean canSerialize(@Nullable RedisSerializer serializer, Object value) { + return serializer != null && (value == null || serializer.canSerialize(value.getClass())); + } +} diff --git a/src/main/java/org/springframework/data/redis/connection/stream/StringRecord.java b/src/main/java/org/springframework/data/redis/connection/stream/StringRecord.java new file mode 100644 index 000000000..c26cb48aa --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/stream/StringRecord.java @@ -0,0 +1,50 @@ +/* + * Copyright 2018 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.data.redis.connection.stream; + +/** + * A {@link Record} within the stream backed by a collection of {@link String} {@literal field/value} paris. + * + * @author Christoph Strobl + * @since 2.2 + */ +public interface StringRecord extends MapRecord { + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.RedisStreamCommands.Record#withId(org.springframework.data.redis.connection.RedisStreamCommands.RecordId) + */ + @Override + StringRecord withId(RecordId id); + + /** + * Create a new {@link StringRecord} with the associated stream {@literal key}. + * + * @param key the stream key. + * @return a new {@link StringRecord}. + */ + StringRecord withStreamKey(String key); + + /** + * Convert a {@link MapRecord} of {@link String strings} into a {@link StringRecord}. + * + * @param source must not be {@literal null}. + * @return new instance of {@link StringRecord}. + */ + static StringRecord of(MapRecord source) { + return StreamRecords.newRecord().in(source.getStream()).withId(source.getId()).ofStrings(source.getValue()); + } +} diff --git a/src/main/java/org/springframework/data/redis/connection/stream/package-info.java b/src/main/java/org/springframework/data/redis/connection/stream/package-info.java new file mode 100644 index 000000000..07d5dd4ec --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/stream/package-info.java @@ -0,0 +1,6 @@ +/** + * Data structures and interfaces to interact with Redis Streams. + */ +@org.springframework.lang.NonNullApi +@org.springframework.lang.NonNullFields +package org.springframework.data.redis.connection.stream; diff --git a/src/main/java/org/springframework/data/redis/core/BoundStreamOperations.java b/src/main/java/org/springframework/data/redis/core/BoundStreamOperations.java index a581a627a..c7cebd8e9 100644 --- a/src/main/java/org/springframework/data/redis/core/BoundStreamOperations.java +++ b/src/main/java/org/springframework/data/redis/core/BoundStreamOperations.java @@ -19,11 +19,11 @@ import java.util.List; import java.util.Map; import org.springframework.data.domain.Range; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.MapRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamReadOptions; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; import org.springframework.lang.Nullable; diff --git a/src/main/java/org/springframework/data/redis/core/DefaultBoundStreamOperations.java b/src/main/java/org/springframework/data/redis/core/DefaultBoundStreamOperations.java index 005e9b171..6d290051c 100644 --- a/src/main/java/org/springframework/data/redis/core/DefaultBoundStreamOperations.java +++ b/src/main/java/org/springframework/data/redis/core/DefaultBoundStreamOperations.java @@ -20,12 +20,12 @@ import java.util.Map; import org.springframework.data.domain.Range; import org.springframework.data.redis.connection.DataType; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.MapRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; import org.springframework.lang.Nullable; diff --git a/src/main/java/org/springframework/data/redis/core/DefaultReactiveStreamOperations.java b/src/main/java/org/springframework/data/redis/core/DefaultReactiveStreamOperations.java index 36785b6f2..b25217262 100644 --- a/src/main/java/org/springframework/data/redis/core/DefaultReactiveStreamOperations.java +++ b/src/main/java/org/springframework/data/redis/core/DefaultReactiveStreamOperations.java @@ -25,23 +25,21 @@ import java.util.Collections; import java.util.Map; import java.util.Map.Entry; import java.util.function.Function; -import java.util.stream.Collectors; import org.reactivestreams.Publisher; -import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.convert.ConversionService; import org.springframework.data.domain.Range; import org.springframework.data.redis.connection.ReactiveStreamCommands; -import org.springframework.data.redis.connection.RedisStreamCommands.ByteBufferRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.MapRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; -import org.springframework.data.redis.core.convert.RedisCustomConversions; +import org.springframework.data.redis.connection.stream.ByteBufferRecord; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.Record; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; import org.springframework.data.redis.hash.HashMapper; -import org.springframework.data.redis.hash.ObjectHashMapper; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -58,20 +56,60 @@ class DefaultReactiveStreamOperations implements ReactiveStreamOperat private final ReactiveRedisTemplate template; private final RedisSerializationContext serializationContext; + private final StreamObjectMapper objectMapper; - private final RedisCustomConversions rcc = new RedisCustomConversions(); - private DefaultConversionService conversionService; - private HashMapper mapper; - - public DefaultReactiveStreamOperations(ReactiveRedisTemplate template, + DefaultReactiveStreamOperations(ReactiveRedisTemplate template, RedisSerializationContext serializationContext, @Nullable HashMapper hashMapper) { + this.template = template; this.serializationContext = serializationContext; + this.objectMapper = new StreamObjectMapper(hashMapper) { - this.conversionService = new DefaultConversionService(); - this.mapper = mapper != null ? mapper : (HashMapper) new ObjectHashMapper(); - rcc.registerConvertersIn(conversionService); + @Override + protected HashMapper doGetHashMapper(ConversionService conversionService, Class targetType) { + + if (objectMapper.isSimpleType(targetType) || ClassUtils.isAssignable(ByteBuffer.class, targetType)) { + + return new HashMapper() { + + @Override + public Map toHash(Object object) { + + Object key = "payload"; + Object value = object; + + if (serializationContext.getHashKeySerializationPair() == null) { + key = key.toString().getBytes(StandardCharsets.UTF_8); + } + if (serializationContext.getHashValueSerializationPair() == null) { + value = conversionService.convert(value, byte[].class); + } + + return Collections.singletonMap(key, value); + } + + @Override + public Object fromHash(Map hash) { + Object value = hash.values().iterator().next(); + if (ClassUtils.isAssignableValue(targetType, value)) { + return value; + } + + HV deserialized = deserializeHashValue((ByteBuffer) value); + + if (ClassUtils.isAssignableValue(targetType, deserialized)) { + return value; + } + + return conversionService.convert(deserialized, targetType); + } + }; + } + + return super.doGetHashMapper(conversionService, targetType); + } + }; } /* @@ -94,12 +132,14 @@ class DefaultReactiveStreamOperations implements ReactiveStreamOperat * @see org.springframework.data.redis.core.ReactiveStreamOperations#add(java.lang.Object, java.util.Map) */ @Override - public Mono add(MapRecord record) { + public Mono add(Record record) { Assert.notNull(record.getStream(), "Key must not be null!"); - Assert.notEmpty(record.getValue(), "Body must not be null!"); + Assert.notNull(record.getValue(), "Body must not be null!"); - return createMono(connection -> connection.xAdd(serializeRecord(record))); + MapRecord input = StreamObjectMapper.toMapRecord(this, record); + + return createMono(connection -> connection.xAdd(serializeRecord(input))); } /* @@ -234,64 +274,7 @@ class DefaultReactiveStreamOperations implements ReactiveStreamOperat @Override public HashMapper getHashMapper(Class targetType) { - - if (rcc.isSimpleType(targetType) || ClassUtils.isAssignable(ByteBuffer.class, targetType)) { - - return new HashMapper() { - - @Override - public Map toHash(V object) { - - HK key = (HK) "payload"; - HV value = (HV) object; - - if (serializationContext.getHashKeySerializationPair() == null) { - key = (HK) key.toString().getBytes(StandardCharsets.UTF_8); - } - if (serializationContext.getHashValueSerializationPair() == null) { - value = (HV) conversionService.convert(value, byte[].class); - } - - return Collections.singletonMap(key, value); - - // return (Map) Collections.singletonMap("payload".getBytes(StandardCharsets.UTF_8), - // serializeHashValueIfRequires((HV) object)); - } - - @Override - public V fromHash(Map hash) { - Object value = hash.values().iterator().next(); - if (ClassUtils.isAssignableValue(targetType, value)) { - return (V) value; - } - return (V) deserializeHashValue((ByteBuffer) value); - } - }; - } - - if (mapper instanceof ObjectHashMapper) { - - return new HashMapper() { - - @Override - public Map toHash(V object) { - return (Map) ((ObjectHashMapper) mapper).toObjectHash(object); - } - - @Override - public V fromHash(Map hash) { - - Map map = hash.entrySet().stream() - .collect(Collectors.toMap(e -> conversionService.convert((Object) e.getKey(), byte[].class), - e -> conversionService.convert((Object) e.getValue(), byte[].class))); - - return (V) mapper.fromHash((Map) map); - } - }; - - } - - return (HashMapper) mapper; + return objectMapper.getHashMapper(targetType); } @SuppressWarnings("unchecked") @@ -319,21 +302,24 @@ class DefaultReactiveStreamOperations implements ReactiveStreamOperat return serializationContext.getKeySerializationPair().write(key); } + @SuppressWarnings("unchecked") private ByteBuffer rawHashKey(HK key) { try { return serializationContext.getHashKeySerializationPair().write(key); } catch (IllegalStateException e) {} - return ByteBuffer.wrap(conversionService.convert(key, byte[].class)); + return ByteBuffer.wrap(objectMapper.getConversionService().convert(key, byte[].class)); } + @SuppressWarnings("unchecked") private ByteBuffer rawValue(HV value) { try { return serializationContext.getHashValueSerializationPair().write(value); } catch (IllegalStateException e) {} - return ByteBuffer.wrap(conversionService.convert(value, byte[].class)); + return ByteBuffer.wrap(objectMapper.getConversionService().convert(value, byte[].class)); } + @SuppressWarnings("unchecked") private HK readHashKey(ByteBuffer buffer) { return (HK) serializationContext.getHashKeySerializationPair().getReader().read(buffer); } @@ -342,6 +328,7 @@ class DefaultReactiveStreamOperations implements ReactiveStreamOperat return serializationContext.getKeySerializationPair().read(buffer); } + @SuppressWarnings("unchecked") private HV deserializeHashValue(ByteBuffer buffer) { return (HV) serializationContext.getHashValueSerializationPair().read(buffer); } @@ -355,12 +342,12 @@ class DefaultReactiveStreamOperations implements ReactiveStreamOperat .next(); } - private ByteBufferRecord serializeRecord(MapRecord record) { + private ByteBufferRecord serializeRecord(MapRecord record) { return ByteBufferRecord .of(record.map(it -> it.mapEntries(this::serializeRecordFields).withStreamKey(rawKey(record.getStream())))); } - private Entry serializeRecordFields(Entry it) { + private Entry serializeRecordFields(Entry it) { return Collections.singletonMap(rawHashKey(it.getKey()), rawValue(it.getValue())).entrySet().iterator().next(); } } diff --git a/src/main/java/org/springframework/data/redis/core/DefaultStreamOperations.java b/src/main/java/org/springframework/data/redis/core/DefaultStreamOperations.java index f2338ac15..e9468ea5d 100644 --- a/src/main/java/org/springframework/data/redis/core/DefaultStreamOperations.java +++ b/src/main/java/org/springframework/data/redis/core/DefaultStreamOperations.java @@ -21,24 +21,23 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; -import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.convert.ConversionService; import org.springframework.data.domain.Range; import org.springframework.data.redis.connection.RedisConnection; -import org.springframework.data.redis.connection.RedisStreamCommands.ByteRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.MapRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; -import org.springframework.data.redis.core.convert.RedisCustomConversions; +import org.springframework.data.redis.connection.stream.ByteRecord; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.Record; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; import org.springframework.data.redis.hash.HashMapper; -import org.springframework.data.redis.hash.ObjectHashMapper; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import org.springframework.util.ClassUtils; /** @@ -50,17 +49,61 @@ import org.springframework.util.ClassUtils; */ class DefaultStreamOperations extends AbstractOperations implements StreamOperations { - private final RedisCustomConversions rcc = new RedisCustomConversions(); - private DefaultConversionService conversionService; - private HashMapper mapper; + private final StreamObjectMapper objectMapper; + @SuppressWarnings("unchecked") DefaultStreamOperations(RedisTemplate template, @Nullable HashMapper mapper) { + super((RedisTemplate) template); - this.conversionService = new DefaultConversionService(); - this.mapper = mapper != null ? mapper : (HashMapper) new ObjectHashMapper(); - rcc.registerConvertersIn(conversionService); + this.objectMapper = new StreamObjectMapper(mapper) { + @Override + protected HashMapper doGetHashMapper(ConversionService conversionService, Class targetType) { + + if (isSimpleType(targetType)) { + + return new HashMapper() { + + @Override + public Map toHash(Object object) { + + Object key = "payload"; + Object value = object; + + if (!template.isEnableDefaultSerializer()) { + if (template.getHashKeySerializer() == null) { + key = key.toString().getBytes(StandardCharsets.UTF_8); + } + if (template.getHashValueSerializer() == null) { + value = serializeHashValueIfRequires((HV) object); + } + } + + return Collections.singletonMap(key, value); + } + + @Override + public Object fromHash(Map hash) { + Object value = hash.values().iterator().next(); + if (ClassUtils.isAssignableValue(targetType, value)) { + return value; + } + + HV deserialized = deserializeHashValue((byte[]) value); + + if (ClassUtils.isAssignableValue(targetType, deserialized)) { + return value; + } + + return conversionService.convert(deserialized, targetType); + } + }; + } + + return super.doGetHashMapper(conversionService, targetType); + } + }; } /* @@ -76,12 +119,17 @@ class DefaultStreamOperations extends AbstractOperations i /* * (non-Javadoc) - * @see org.springframework.data.redis.core.StreamOperations#add(java.lang.Object, java.util.Map) + * @see org.springframework.data.redis.core.StreamOperations#add(org.springframework.data.redis.connection.stream.Record) */ + @Nullable @Override - public RecordId add(MapRecord record) { + public RecordId add(Record record) { - ByteRecord binaryRecord = record.serialize(keySerializer(), hashKeySerializer(), hashValueSerializer()); + Assert.notNull(record, "Record must not be null"); + + MapRecord input = StreamObjectMapper.toMapRecord(this, record); + + ByteRecord binaryRecord = input.serialize(keySerializer(), hashKeySerializer(), hashValueSerializer()); return execute(connection -> connection.xAdd(binaryRecord), true); } @@ -148,7 +196,7 @@ class DefaultStreamOperations extends AbstractOperations i @Override public List> range(K key, Range range, Limit limit) { - return execute(new RecordDeserializingRedisCallback() { + return execute(new RecordDeserializingRedisCallback() { @Nullable @Override @@ -165,7 +213,7 @@ class DefaultStreamOperations extends AbstractOperations i @Override public List> read(StreamReadOptions readOptions, StreamOffset... streams) { - return execute(new RecordDeserializingRedisCallback() { + return execute(new RecordDeserializingRedisCallback() { @Nullable @Override @@ -182,7 +230,7 @@ class DefaultStreamOperations extends AbstractOperations i @Override public List> read(Consumer consumer, StreamReadOptions readOptions, StreamOffset... streams) { - return execute(new RecordDeserializingRedisCallback() { + return execute(new RecordDeserializingRedisCallback() { @Nullable @Override @@ -199,7 +247,7 @@ class DefaultStreamOperations extends AbstractOperations i @Override public List> reverseRange(K key, Range range, Limit limit) { - return execute(new RecordDeserializingRedisCallback() { + return execute(new RecordDeserializingRedisCallback() { @Nullable @Override @@ -218,68 +266,12 @@ class DefaultStreamOperations extends AbstractOperations i @Override public HashMapper getHashMapper(Class targetType) { - - if (rcc.isSimpleType(targetType)) { - - return new HashMapper() { - - @Override - public Map toHash(V object) { - - HK key = (HK) "payload"; - HV value = (HV) object; - - if (!template.isEnableDefaultSerializer()) { - if (template.getHashKeySerializer() == null) { - key = (HK) key.toString().getBytes(StandardCharsets.UTF_8); - } - if (template.getHashValueSerializer() == null) { - value = (HV) serializeHashValueIfRequires((HV) object); - } - } - - return Collections.singletonMap(key, value); - } - - @Override - public V fromHash(Map hash) { - Object value = hash.values().iterator().next(); - if (ClassUtils.isAssignableValue(targetType, value)) { - return (V) value; - } - return (V) deserializeHashValue((byte[]) value, (Class) targetType); - } - }; - } - - if (mapper instanceof ObjectHashMapper) { - - return new HashMapper() { - - @Override - public Map toHash(V object) { - return (Map) ((ObjectHashMapper) mapper).toObjectHash(object); - } - - @Override - public V fromHash(Map hash) { - - Map map = hash.entrySet().stream() - .collect(Collectors.toMap(e -> conversionService.convert((Object) e.getKey(), byte[].class), - e -> conversionService.convert((Object) e.getValue(), byte[].class))); - - return (V) mapper.fromHash((Map) map); - } - }; - - } - - return (HashMapper) mapper; + return objectMapper.getHashMapper(targetType); } protected byte[] serializeHashValueIfRequires(HV value) { return hashValueSerializerPresent() ? serialize(value, hashValueSerializer()) - : conversionService.convert(value, byte[].class); + : objectMapper.getConversionService().convert(value, byte[].class); } protected boolean hashValueSerializerPresent() { @@ -288,14 +280,14 @@ class DefaultStreamOperations extends AbstractOperations i protected HV deserializeHashValue(byte[] bytes, Class targetType) { return hashValueSerializerPresent() ? (HV) hashValueSerializer().deserialize(bytes) - : conversionService.convert(bytes, targetType); + : objectMapper.getConversionService().convert(bytes, targetType); } - byte[] serialize(Object value, RedisSerializer serializer) { + private byte[] serialize(Object value, RedisSerializer serializer) { Object _value = value; if (!serializer.canSerialize(value.getClass())) { - _value = conversionService.convert(value, serializer.getTargetType()); + _value = objectMapper.getConversionService().convert(value, serializer.getTargetType()); } return serializer.serialize(_value); } @@ -308,14 +300,14 @@ class DefaultStreamOperations extends AbstractOperations i .toArray(it -> new StreamOffset[it]); } - abstract class RecordDeserializingRedisCallback implements RedisCallback>> { + abstract class RecordDeserializingRedisCallback implements RedisCallback>> { public final List> doInRedis(RedisConnection connection) { - List x = inRedis(connection); + List raw = inRedis(connection); List> result = new ArrayList<>(); - for (ByteRecord record : x) { + for (ByteRecord record : raw) { result.add(record.deserialize(keySerializer(), hashKeySerializer(), hashValueSerializer())); } diff --git a/src/main/java/org/springframework/data/redis/core/HashMapperProvider.java b/src/main/java/org/springframework/data/redis/core/HashMapperProvider.java new file mode 100644 index 000000000..0fe268c4c --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/HashMapperProvider.java @@ -0,0 +1,42 @@ +/* + * Copyright 2018 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.data.redis.core; + +import org.springframework.data.redis.hash.HashMapper; + +/** + * Function that returns a {@link HashMapper} for a given {@link Class type}. + *

+ * Implementors of this interface can return a generic or a specific {@link HashMapper} implementation, depending on the + * serialization strategy for the requested {@link Class target type}. + * + * @param + * @param + * @author Mark Paluch + * @since 2.2 + */ +@FunctionalInterface +public interface HashMapperProvider { + + /** + * Get the {@link HashMapper} for a specific type. + * + * @param targetType must not be {@literal null}. + * @param the value target type. + * @return the {@link HashMapper} suitable for a given type; + */ + HashMapper getHashMapper(Class targetType); +} diff --git a/src/main/java/org/springframework/data/redis/core/ReactiveRedisTemplate.java b/src/main/java/org/springframework/data/redis/core/ReactiveRedisTemplate.java index 8112b35cd..07ec87e0b 100644 --- a/src/main/java/org/springframework/data/redis/core/ReactiveRedisTemplate.java +++ b/src/main/java/org/springframework/data/redis/core/ReactiveRedisTemplate.java @@ -16,6 +16,7 @@ package org.springframework.data.redis.core; import org.springframework.data.redis.hash.HashMapper; +import org.springframework.data.redis.hash.ObjectHashMapper; import org.springframework.lang.Nullable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -645,9 +646,10 @@ public class ReactiveRedisTemplate implements ReactiveRedisOperations ReactiveStreamOperations opsForStream( RedisSerializationContext serializationContext) { - return opsForStream(serializationContext, null); + return opsForStream(serializationContext, (HashMapper) new ObjectHashMapper()); } protected ReactiveStreamOperations opsForStream( diff --git a/src/main/java/org/springframework/data/redis/core/ReactiveStreamOperations.java b/src/main/java/org/springframework/data/redis/core/ReactiveStreamOperations.java index 09ee7ad34..d33273f46 100644 --- a/src/main/java/org/springframework/data/redis/core/ReactiveStreamOperations.java +++ b/src/main/java/org/springframework/data/redis/core/ReactiveStreamOperations.java @@ -23,17 +23,18 @@ import java.util.Map; import org.reactivestreams.Publisher; import org.springframework.data.domain.Range; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.MapRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ObjectRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.Record; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; -import org.springframework.data.redis.connection.StreamRecords; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.Record; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; +import org.springframework.data.redis.connection.stream.StreamRecords; import org.springframework.data.redis.hash.HashMapper; +import org.springframework.util.Assert; /** * Redis stream specific operations. @@ -42,7 +43,7 @@ import org.springframework.data.redis.hash.HashMapper; * @author Christoph Strobl * @since 2.2 */ -public interface ReactiveStreamOperations { +public interface ReactiveStreamOperations extends HashMapperProvider { /** * Acknowledge one or more records as processed. @@ -88,7 +89,7 @@ public interface ReactiveStreamOperations { * @return the record Ids. * @see Redis Documentation: XADD */ - default Flux add(K key, Publisher> bodyPublisher) { + default Flux add(K key, Publisher> bodyPublisher) { return Flux.from(bodyPublisher).flatMap(it -> add(key, it)); } @@ -96,12 +97,12 @@ public interface ReactiveStreamOperations { * Append a record to the stream {@code key}. * * @param key the stream key. - * @param body record body. + * @param content record content as Map. * @return the {@link Mono} emitting the {@link RecordId}. * @see Redis Documentation: XADD */ - default Mono add(K key, Map body) { - return add(StreamRecords.newRecord().in(key).ofMap(body)); + default Mono add(K key, Map content) { + return add(StreamRecords.newRecord().in(key).ofMap(content)); } /** @@ -111,18 +112,20 @@ public interface ReactiveStreamOperations { * @return the {@link Mono} emitting the {@link RecordId}. * @see Redis Documentation: XADD */ - Mono add(MapRecord record); + @SuppressWarnings("unchecked") + default Mono add(MapRecord record) { + return add((Record) record); + } /** * Append the record, backed by the given value, to the stream. The value will be hashed and serialized. * * @param record must not be {@literal null}. - * @param - * @return + * @return the {@link Mono} emitting the {@link RecordId}. + * @see MapRecord + * @see ObjectRecord */ - default Mono add(Record record) { - return add(toMapRecord(record)); - } + Mono add(Record record); /** * Removes the specified records from the stream. Returns the number of records deleted, that may be different from @@ -163,7 +166,7 @@ public interface ReactiveStreamOperations { * * @param key * @param group name of the consumer group. - * @return the {@link Mono} emitting {@literal ok} if successful.. {@literal null} when used in pipeline / + * @return the {@link Mono} emitting {@literal OK} if successful.. {@literal null} when used in pipeline / * transaction. */ default Mono createGroup(K key, String group) { @@ -176,7 +179,7 @@ public interface ReactiveStreamOperations { * @param key * @param readOffset * @param group name of the consumer group. - * @return the {@link Mono} emitting {@literal ok} if successful. + * @return the {@link Mono} emitting {@literal OK} if successful. */ Mono createGroup(K key, ReadOffset readOffset, String group); @@ -185,7 +188,7 @@ public interface ReactiveStreamOperations { * * @param key the stream key. * @param consumer consumer identified by group name and consumer key. - * @return the {@link Mono} {@literal ok} if successful. {@literal null} when used in pipeline / transaction. + * @return the {@link Mono} {@literal OK} if successful. {@literal null} when used in pipeline / transaction. */ Mono deleteConsumer(K key, Consumer consumer); @@ -194,7 +197,7 @@ public interface ReactiveStreamOperations { * * @param key the stream key. * @param group name of the consumer group. - * @return the {@link Mono} {@literal ok} if successful. {@literal null} when used in pipeline / transaction. + * @return the {@link Mono} {@literal OK} if successful. {@literal null} when used in pipeline / transaction. */ Mono destroyGroup(K key, String group); @@ -212,95 +215,131 @@ public interface ReactiveStreamOperations { * * @param key the stream key. * @param range must not be {@literal null}. - * @return the {@link Flux} emitting the records one by one. + * @return the {@link Flux} emitting records one by one. * @see Redis Documentation: XRANGE */ default Flux> range(K key, Range range) { return range(key, range, Limit.unlimited()); } - /** - * Read all records from a stream within a specific {@link Range}. - * - * @param key the stream key. - * @param range must not be {@literal null}. - * @return lthe {@link Flux} emitting the records one by one. - * @see Redis Documentation: XRANGE - */ - default Flux> range(K key, Range range, Class targetType) { - return range(key, range, Limit.unlimited(), targetType); - } - /** * Read records from a stream within a specific {@link Range} applying a {@link Limit}. * * @param key the stream key. * @param range must not be {@literal null}. * @param limit must not be {@literal null}. - * @return lthe {@link Flux} emitting the records one by one. + * @return lthe {@link Flux} emitting records one by one. * @see Redis Documentation: XRANGE */ Flux> range(K key, Range range, Limit limit); + /** + * Read all records from a stream within a specific {@link Range}. + * + * @param targetType the target type of the payload. + * @param key the stream key. + * @param range must not be {@literal null}. + * @return the {@link Flux} emitting records one by one. + * @see Redis Documentation: XRANGE + */ + default Flux> range(Class targetType, K key, Range range) { + return range(targetType, key, range, Limit.unlimited()); + } + /** * Read records from a stream within a specific {@link Range} applying a {@link Limit}. * + * @param targetType the target type of the payload. * @param key the stream key. * @param range must not be {@literal null}. * @param limit must not be {@literal null}. - * @return lthe {@link Flux} emitting the records one by one. + * @return the {@link Flux} emitting records one by one. * @see Redis Documentation: XRANGE */ - default Flux> range(K key, Range range, Limit limit, Class targetType) { - return range(key, range, limit).map(it -> toObjectRecord(it, targetType)); + default Flux> range(Class targetType, K key, Range range, Limit limit) { + + Assert.notNull(targetType, "Target type must not be null"); + + return range(key, range, limit).map(it -> StreamObjectMapper.toObjectRecord(this, it, targetType)); + } + + /** + * Read records from a {@link StreamOffset} as {@link ObjectRecord}. + * + * @param stream the stream to read from. + * @return the {@link Flux} emitting records one by one. + * @see Redis Documentation: XREAD + */ + default Flux> read(StreamOffset stream) { + + Assert.notNull(stream, "StreamOffset must not be null"); + + return read(StreamReadOptions.empty(), new StreamOffset[] { stream }); + } + + /** + * Read records from a {@link StreamOffset} as {@link ObjectRecord}. + * + * @param targetType the target type of the payload. + * @param stream the stream to read from. + * @return the {@link Flux} emitting records one by one. + * @see Redis Documentation: XREAD + */ + default Flux> read(Class targetType, StreamOffset stream) { + + Assert.notNull(stream, "StreamOffset must not be null"); + + return read(targetType, StreamReadOptions.empty(), new StreamOffset[] { stream }); } /** * Read records from one or more {@link StreamOffset}s. * - * @param targetType * @param streams the streams to read from. - * @return list with members of the resulting stream. + * @return the {@link Flux} emitting records one by one. + * @see Redis Documentation: XREAD + */ + default Flux> read(StreamOffset... streams) { + return read(StreamReadOptions.empty(), streams); + } + + /** + * Read records from one or more {@link StreamOffset}s as {@link ObjectRecord}. + * + * @param targetType the target type of the payload. + * @param streams the streams to read from. + * @return the {@link Flux} emitting records one by one. * @see Redis Documentation: XREAD */ default Flux> read(Class targetType, StreamOffset... streams) { return read(targetType, StreamReadOptions.empty(), streams); } - /** - * Read records from one or more {@link StreamOffset}s. - * - * @param streams the streams to read from. - * @return list with members of the resulting stream. - * @see Redis Documentation: XREAD - */ - default Flux> read(StreamOffset... streams) { - return read(StreamReadOptions.empty(), streams); - } - /** * Read records from one or more {@link StreamOffset}s. * * @param readOptions read arguments. * @param streams the streams to read from. - * @return list with members of the resulting stream. + * @return the {@link Flux} emitting records one by one. * @see Redis Documentation: XREAD */ Flux> read(StreamReadOptions readOptions, StreamOffset... streams); /** - * Read records from one or more {@link StreamOffset}s. + * Read records from one or more {@link StreamOffset}s as {@link ObjectRecord}. * - * @oaram targetType + * @param targetType the target type of the payload. * @param readOptions read arguments. * @param streams the streams to read from. - * @return list with members of the resulting stream. + * @return the {@link Flux} emitting records one by one. * @see Redis Documentation: XREAD */ default Flux> read(Class targetType, StreamReadOptions readOptions, StreamOffset... streams) { - return read(readOptions, streams).map(it -> toObjectRecord(it, targetType)); + Assert.notNull(targetType, "Target type must not be null"); + + return read(readOptions, streams).map(it -> StreamObjectMapper.toObjectRecord(this, it, targetType)); } /** @@ -308,7 +347,7 @@ public interface ReactiveStreamOperations { * * @param consumer consumer/group. * @param streams the streams to read from. - * @return list with members of the resulting stream. + * @return the {@link Flux} emitting records one by one. * @see Redis Documentation: XREADGROUP */ default Flux> read(Consumer consumer, StreamOffset... streams) { @@ -316,11 +355,12 @@ public interface ReactiveStreamOperations { } /** - * Read records from one or more {@link StreamOffset}s using a consumer group. + * Read records from one or more {@link StreamOffset}s using a consumer group as {@link ObjectRecord}. * + * @param targetType the target type of the payload. * @param consumer consumer/group. * @param streams the streams to read from. - * @return list with members of the resulting stream. + * @return the {@link Flux} emitting records one by one. * @see Redis Documentation: XREADGROUP */ default Flux> read(Class targetType, Consumer consumer, StreamOffset... streams) { @@ -333,24 +373,27 @@ public interface ReactiveStreamOperations { * @param consumer consumer/group. * @param readOptions read arguments. * @param streams the streams to read from. - * @return list with members of the resulting stream. + * @return the {@link Flux} emitting records one by one. * @see Redis Documentation: XREADGROUP */ Flux> read(Consumer consumer, StreamReadOptions readOptions, StreamOffset... streams); /** - * Read records from one or more {@link StreamOffset}s using a consumer group. + * Read records from one or more {@link StreamOffset}s using a consumer group as {@link ObjectRecord}. * - * @param targetType + * @param targetType the target type of the payload. * @param consumer consumer/group. * @param readOptions read arguments. * @param streams the streams to read from. - * @return list with members of the resulting stream. + * @return the {@link Flux} emitting records one by one. * @see Redis Documentation: XREADGROUP */ default Flux> read(Class targetType, Consumer consumer, StreamReadOptions readOptions, StreamOffset... streams) { - return read(consumer, readOptions, streams).map(it -> toObjectRecord(it, targetType)); + + Assert.notNull(targetType, "Target type must not be null"); + + return read(consumer, readOptions, streams).map(it -> StreamObjectMapper.toObjectRecord(this, it, targetType)); } /** @@ -358,40 +401,53 @@ public interface ReactiveStreamOperations { * * @param key the stream key. * @param range must not be {@literal null}. - * @return list with members of the resulting stream. + * @return the {@link Flux} emitting records one by one. * @see Redis Documentation: XREVRANGE */ default Flux> reverseRange(K key, Range range) { return reverseRange(key, range, Limit.unlimited()); } - default Flux> reverseRange(Class targetType, K key, Range range) { - return reverseRange(targetType, key, range, Limit.unlimited()); - } - /** * Read records from a stream within a specific {@link Range} applying a {@link Limit} in reverse order. * * @param key the stream key. * @param range must not be {@literal null}. * @param limit must not be {@literal null}. - * @return list with members of the resulting stream. + * @return the {@link Flux} emitting records one by one. * @see Redis Documentation: XREVRANGE */ Flux> reverseRange(K key, Range range, Limit limit); /** - * Read records from a stream within a specific {@link Range} applying a {@link Limit} in reverse order. + * Read records from a stream within a specific {@link Range} in reverse order as {@link ObjectRecord}. * - * @param targetType + * @param targetType the target type of the payload. + * @param key the stream key. + * @param range must not be {@literal null}. + * @return the {@link Flux} emitting records one by one. + * @see Redis Documentation: XREVRANGE + */ + default Flux> reverseRange(Class targetType, K key, Range range) { + return reverseRange(targetType, key, range, Limit.unlimited()); + } + + /** + * Read records from a stream within a specific {@link Range} applying a {@link Limit} in reverse order as + * {@link ObjectRecord}. + * + * @param targetType the target type of the payload. * @param key the stream key. * @param range must not be {@literal null}. * @param limit must not be {@literal null}. - * @return list with members of the resulting stream. + * @return the {@link Flux} emitting records one by one. * @see Redis Documentation: XREVRANGE */ default Flux> reverseRange(Class targetType, K key, Range range, Limit limit) { - return reverseRange(key, range, limit).map(it -> toObjectRecord(it, targetType)); + + Assert.notNull(targetType, "Target type must not be null"); + + return reverseRange(key, range, limit).map(it -> StreamObjectMapper.toObjectRecord(this, it, targetType)); } /** @@ -411,37 +467,6 @@ public interface ReactiveStreamOperations { * @param * @return the {@link HashMapper} suitable for a given type; */ + @Override HashMapper getHashMapper(Class targetType); - - /** - * App - * - * @param value - * @param - * @return - */ - default MapRecord toMapRecord(Record value) { - - if (value instanceof ObjectRecord) { - - ObjectRecord entry = ((ObjectRecord) value); - - // TODO: should we have this? - if (entry.getValue() instanceof Map) { - return StreamRecords.newRecord().in(value.getStream()).withId(value.getId()).ofMap((Map) entry.getValue()); - } - - return entry.toMapRecord(getHashMapper(entry.getValue().getClass())); - } - - if (value instanceof MapRecord) { - return (MapRecord) value; - } - - return Record.of(((HashMapper) getHashMapper(value.getClass())).toHash(value)).withStreamKey(value.getStream()); - } - - default ObjectRecord toObjectRecord(MapRecord entry, Class targetType) { - return entry.toObjectRecord(getHashMapper(targetType)); - } } diff --git a/src/main/java/org/springframework/data/redis/core/RedisOperations.java b/src/main/java/org/springframework/data/redis/core/RedisOperations.java index 58c206721..b34512c84 100644 --- a/src/main/java/org/springframework/data/redis/core/RedisOperations.java +++ b/src/main/java/org/springframework/data/redis/core/RedisOperations.java @@ -24,6 +24,7 @@ import java.util.concurrent.TimeUnit; import org.springframework.data.redis.connection.DataType; import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.stream.ObjectRecord; import org.springframework.data.redis.core.query.SortQuery; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.data.redis.core.types.RedisClientInfo; @@ -627,8 +628,7 @@ public interface RedisOperations { /** * Returns the operations performed on Streams. * - * @param hashMapper the {@link HashMapper} to use when converting - * {@link org.springframework.data.redis.connection.RedisStreamCommands.ObjectRecord}. + * @param hashMapper the {@link HashMapper} to use when converting {@link ObjectRecord}. * @return stream operations. * @since 2.2 */ diff --git a/src/main/java/org/springframework/data/redis/core/RedisTemplate.java b/src/main/java/org/springframework/data/redis/core/RedisTemplate.java index 3ded1c546..eac373618 100644 --- a/src/main/java/org/springframework/data/redis/core/RedisTemplate.java +++ b/src/main/java/org/springframework/data/redis/core/RedisTemplate.java @@ -46,6 +46,7 @@ import org.springframework.data.redis.core.script.RedisScript; import org.springframework.data.redis.core.script.ScriptExecutor; import org.springframework.data.redis.core.types.RedisClientInfo; import org.springframework.data.redis.hash.HashMapper; +import org.springframework.data.redis.hash.ObjectHashMapper; import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.SerializationUtils; @@ -1310,7 +1311,7 @@ public class RedisTemplate extends RedisAccessor implements RedisOperation public StreamOperations opsForStream() { if (streamOps == null) { - streamOps = new DefaultStreamOperations<>(this, null); + streamOps = new DefaultStreamOperations<>(this, new ObjectHashMapper()); } return (StreamOperations) streamOps; } diff --git a/src/main/java/org/springframework/data/redis/core/StreamObjectMapper.java b/src/main/java/org/springframework/data/redis/core/StreamObjectMapper.java new file mode 100644 index 000000000..aec5b2130 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/StreamObjectMapper.java @@ -0,0 +1,202 @@ +/* + * Copyright 2018 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.data.redis.core; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.Record; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.convert.RedisCustomConversions; +import org.springframework.data.redis.hash.HashMapper; +import org.springframework.data.redis.hash.ObjectHashMapper; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Utility to provide a {@link HashMapper} for Stream object conversion. + *

+ * This utility can use generic a {@link HashMapper} or adapt specifically to {@link ObjectHashMapper}'s requirement to + * convert incoming data into byte arrays. This class can be subclassed to override template methods for specific object + * mapping strategies. + * + * @author Mark Paluch + * @since 2.2 + * @see ObjectHashMapper + * @see #doGetHashMapper(ConversionService, Class) + */ +class StreamObjectMapper { + + private final DefaultConversionService conversionService = new DefaultConversionService(); + private final RedisCustomConversions customConversions = new RedisCustomConversions(); + private final HashMapper mapper; + private final @Nullable HashMapper objectHashMapper; + + /** + * Creates a new {@link StreamObjectMapper}. + * + * @param mapper the configured {@link HashMapper}. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + StreamObjectMapper(HashMapper mapper) { + + Assert.notNull(mapper, "HashMapper must not be null"); + + this.customConversions.registerConvertersIn(conversionService); + this.mapper = (HashMapper) mapper; + + if (mapper instanceof ObjectHashMapper) { + + ObjectHashMapper ohm = (ObjectHashMapper) mapper; + this.objectHashMapper = new HashMapper() { + + @Override + public Map toHash(Object object) { + return (Map) ohm.toHash(object); + } + + @Override + public Object fromHash(Map hash) { + + Map map = hash.entrySet().stream() + .collect(Collectors.toMap(e -> conversionService.convert(e.getKey(), byte[].class), + e -> conversionService.convert(e.getValue(), byte[].class))); + + return ohm.fromHash(map); + } + }; + } else { + this.objectHashMapper = null; + } + } + + /** + * Convert the given {@link Record} into a {@link MapRecord}. + * + * @param provider provider for {@link HashMapper} to apply mapping for {@link ObjectRecord}. + * @param source the source value. + * @return the converted {@link MapRecord}. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + static MapRecord toMapRecord(HashMapperProvider provider, Record source) { + + if (source instanceof ObjectRecord) { + + ObjectRecord entry = ((ObjectRecord) source); + + if (entry.getValue() instanceof Map) { + return StreamRecords.newRecord().in(source.getStream()).withId(source.getId()).ofMap((Map) entry.getValue()); + } + + return entry.toMapRecord(provider.getHashMapper(entry.getValue().getClass())); + } + + if (source instanceof MapRecord) { + return (MapRecord) source; + } + + return Record.of(((HashMapper) provider.getHashMapper(source.getClass())).toHash(source)) + .withStreamKey(source.getStream()); + } + + /** + * Convert the given {@link Record} into an {@link ObjectRecord}. + * + * @param provider provider for {@link HashMapper} to apply mapping for {@link ObjectRecord}. + * @param source the source value. + * @param targetType the desired target type. + * @return the converted {@link ObjectRecord}. + */ + static ObjectRecord toObjectRecord(HashMapperProvider provider, + MapRecord source, Class targetType) { + return source.toObjectRecord(provider.getHashMapper(targetType)); + } + + /** + * Map a {@link List} of {@link MapRecord}s to a {@link List} of {@link ObjectRecord}. Optimizes for empty, + * single-element and multi-element list transformation.l + * + * @param records the {@link MapRecord} that should be mapped. + * @param hashMapperProvider + * @param targetType the requested {@link Class target type}. + * @return the resulting {@link List} of {@link ObjectRecord} or {@literal null} if {@code records} was + * {@literal null}. + */ + @Nullable + static List> map(@Nullable List> records, + HashMapperProvider hashMapperProvider, Class targetType) { + + if (records == null) { + return null; + } + + if (records.isEmpty()) { + return Collections.emptyList(); + } + + if (records.size() == 1) { + return Collections.singletonList(toObjectRecord(hashMapperProvider, records.get(0), targetType)); + } + + List> transformed = new ArrayList<>(records.size()); + HashMapper hashMapper = hashMapperProvider.getHashMapper(targetType); + + for (MapRecord record : records) { + transformed.add(record.toObjectRecord(hashMapper)); + } + + return transformed; + } + + @SuppressWarnings("unchecked") + final HashMapper getHashMapper(Class targetType) { + return (HashMapper) doGetHashMapper(conversionService, targetType); + } + + /** + * Returns the actual {@link HashMapper}. Can be overridden by subclasses. + * + * @param conversionService the used {@link ConversionService}. + * @param targetType the target type. + * @return + */ + protected HashMapper doGetHashMapper(ConversionService conversionService, Class targetType) { + return this.objectHashMapper != null ? objectHashMapper : this.mapper; + } + + /** + * @param targetType + * @return {@literal true} if {@link Class targetType} is a simple type. + * @see org.springframework.data.convert.CustomConversions#isSimpleType(Class) + */ + boolean isSimpleType(Class targetType) { + return customConversions.isSimpleType(targetType); + } + + /** + * @return used {@link ConversionService}. + */ + ConversionService getConversionService() { + return this.conversionService; + } +} diff --git a/src/main/java/org/springframework/data/redis/core/StreamOperations.java b/src/main/java/org/springframework/data/redis/core/StreamOperations.java index aaaf9d19b..1a81fdc20 100644 --- a/src/main/java/org/springframework/data/redis/core/StreamOperations.java +++ b/src/main/java/org/springframework/data/redis/core/StreamOperations.java @@ -20,21 +20,21 @@ import reactor.core.publisher.Mono; import java.util.Arrays; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import org.springframework.data.domain.Range; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.MapRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ObjectRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.Record; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; -import org.springframework.data.redis.connection.StreamRecords; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.Record; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; +import org.springframework.data.redis.connection.stream.StreamRecords; import org.springframework.data.redis.hash.HashMapper; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; /** * Redis stream specific operations. @@ -43,7 +43,7 @@ import org.springframework.lang.Nullable; * @author Christoph Strobl * @since 2.2 */ -public interface StreamOperations { +public interface StreamOperations extends HashMapperProvider { /** * Acknowledge one or more records as processed. @@ -91,8 +91,9 @@ public interface StreamOperations { * @return the record Id. {@literal null} when used in pipeline / transaction. * @see Redis Documentation: XADD */ + @SuppressWarnings("unchecked") @Nullable - default RecordId add(K key, Map content) { + default RecordId add(K key, Map content) { return add(StreamRecords.newRecord().in(key).ofMap(content)); } @@ -104,25 +105,29 @@ public interface StreamOperations { * @see Redis Documentation: XADD */ @Nullable - RecordId add(MapRecord record); - - /** - * Append the record, backed by the given value, to the stream. The value will be hashed and serialized. - * - * @param record must not be {@literal null}. - * @param - * @return - */ - default RecordId add(Record record) { - return add(toMapRecord(record)); + @SuppressWarnings("unchecked") + default RecordId add(MapRecord record) { + return add((Record) record); } /** - * Removes the specified entries from the stream. Returns the number of items deleted, that may be different from the - * number of IDs passed in case certain IDs do not exist. + * Append the record, backed by the given value, to the stream. The value is mapped as hash and serialized. + * + * @param record must not be {@literal null}. + * @return the record Id. {@literal null} when used in pipeline / transaction. + * @see MapRecord + * @see ObjectRecord + */ + @SuppressWarnings("unchecked") + @Nullable + RecordId add(Record record); + + /** + * Removes the specified records from the stream. Returns the number of records deleted, that may be different from + * the number of IDs passed in case certain IDs do not exist. * * @param key the stream key. - * @param recordIds stream record id's. + * @param recordIds stream record Id's. * @return number of removed entries. {@literal null} when used in pipeline / transaction. * @see Redis Documentation: XDEL */ @@ -159,7 +164,7 @@ public interface StreamOperations { * * @param key * @param group name of the consumer group. - * @return {@literal ok} if successful. {@literal null} when used in pipeline / transaction. + * @return {@literal OK} if successful. {@literal null} when used in pipeline / transaction. */ default String createGroup(K key, String group) { return createGroup(key, ReadOffset.latest(), group); @@ -171,7 +176,7 @@ public interface StreamOperations { * @param key * @param readOffset * @param group name of the consumer group. - * @return {@literal ok} if successful. {@literal null} when used in pipeline / transaction. + * @return {@literal OK} if successful. {@literal null} when used in pipeline / transaction. */ @Nullable String createGroup(K key, ReadOffset readOffset, String group); @@ -231,31 +236,34 @@ public interface StreamOperations { @Nullable List> range(K key, Range range, Limit limit); - default List> range(K key, Range range, Class targetType) { - return range(key, range).stream().map(it -> toObjectRecord(it, targetType)).collect(Collectors.toList()); + /** + * Read all records from a stream within a specific {@link Range} as {@link ObjectRecord}. + * + * @param targetType the target type of the payload. + * @param key the stream key. + * @param range must not be {@literal null}. + * @return list with members of the resulting stream. {@literal null} when used in pipeline / transaction. + * @see Redis Documentation: XRANGE + */ + default List> range(Class targetType, K key, Range range) { + return range(targetType, key, range, Limit.unlimited()); } /** - * Read records from one or more {@link StreamOffset}s. + * Read records from a stream within a specific {@link Range} applying a {@link Limit} as {@link ObjectRecord}. * - * @param stream the streams to read from. + * @param targetType the target type of the payload. + * @param key the stream key. + * @param range must not be {@literal null}. + * @param limit must not be {@literal null}. * @return list with members of the resulting stream. {@literal null} when used in pipeline / transaction. - * @see Redis Documentation: XREAD + * @see Redis Documentation: XRANGE */ - @Nullable - default List> read(StreamOffset stream) { - return read(StreamReadOptions.empty(), new StreamOffset[] { stream }); - } + default List> range(Class targetType, K key, Range range, Limit limit) { - /** - * Read records from one or more {@link StreamOffset}s. - * - * @param streams the streams to read from. - * @return list with members of the resulting stream. {@literal null} when used in pipeline / transaction. - * @see Redis Documentation: XREAD - */ - default List> read(Class targetType, StreamOffset... streams) { - return read(targetType, StreamReadOptions.empty(), streams); + Assert.notNull(targetType, "Target type must not be null"); + + return StreamObjectMapper.map(range(key, range, limit), this, targetType); } /** @@ -271,16 +279,15 @@ public interface StreamOperations { } /** - * Read records from one or more {@link StreamOffset}s. - * - * @param readOptions read arguments. - * @param stream the streams to read from. + * Read records from one or more {@link StreamOffset}s as {@link ObjectRecord}. + * + * @param targetType the target type of the payload. + * @param streams the streams to read from. * @return list with members of the resulting stream. {@literal null} when used in pipeline / transaction. * @see Redis Documentation: XREAD */ - @Nullable - default List> read(StreamReadOptions readOptions, StreamOffset stream) { - return read(readOptions, new StreamOffset[] { stream }); + default List> read(Class targetType, StreamOffset... streams) { + return read(targetType, StreamReadOptions.empty(), streams); } /** @@ -295,7 +302,7 @@ public interface StreamOperations { List> read(StreamReadOptions readOptions, StreamOffset... streams); /** - * Read records from one or more {@link StreamOffset}s. + * Read records from one or more {@link StreamOffset}s as {@link ObjectRecord}. * * @param targetType the target type of the payload. * @param readOptions read arguments. @@ -306,20 +313,10 @@ public interface StreamOperations { @Nullable default List> read(Class targetType, StreamReadOptions readOptions, StreamOffset... streams) { - return read(readOptions, streams).stream().map(it -> toObjectRecord(it, targetType)).collect(Collectors.toList()); - } - /** - * Read records from one or more {@link StreamOffset}s using a consumer group. - * - * @param consumer consumer/group. - * @param stream the streams to read from. - * @return list with members of the resulting stream. {@literal null} when used in pipeline / transaction. - * @see Redis Documentation: XREADGROUP - */ - @Nullable - default List> read(Consumer consumer, StreamOffset stream) { - return read(consumer, StreamReadOptions.empty(), new StreamOffset[] { stream }); + Assert.notNull(targetType, "Target type must not be null"); + + return StreamObjectMapper.map(read(readOptions, streams), this, targetType); } /** @@ -336,7 +333,7 @@ public interface StreamOperations { } /** - * Read records from one or more {@link StreamOffset}s using a consumer group. + * Read records from one or more {@link StreamOffset}s using a consumer group as {@link ObjectRecord}. * * @param targetType the target type of the payload. * @param consumer consumer/group. @@ -349,20 +346,6 @@ public interface StreamOperations { return read(targetType, consumer, StreamReadOptions.empty(), streams); } - /** - * Read records from one or more {@link StreamOffset}s using a consumer group. - * - * @param consumer consumer/group. - * @param readOptions read arguments. - * @param stream the streams to read from. - * @return list with members of the resulting stream. {@literal null} when used in pipeline / transaction. - * @see Redis Documentation: XREADGROUP - */ - @Nullable - default List> read(Consumer consumer, StreamReadOptions readOptions, StreamOffset stream) { - return read(consumer, readOptions, new StreamOffset[] { stream }); - } - /** * Read records from one or more {@link StreamOffset}s using a consumer group. * @@ -376,7 +359,7 @@ public interface StreamOperations { List> read(Consumer consumer, StreamReadOptions readOptions, StreamOffset... streams); /** - * Read records from one or more {@link StreamOffset}s using a consumer group. + * Read records from one or more {@link StreamOffset}s using a consumer group as {@link ObjectRecord}. * * @param targetType the target type of the payload. * @param consumer consumer/group. @@ -388,8 +371,10 @@ public interface StreamOperations { @Nullable default List> read(Class targetType, Consumer consumer, StreamReadOptions readOptions, StreamOffset... streams) { - return read(consumer, readOptions, streams).stream().map(it -> toObjectRecord(it, targetType)) - .collect(Collectors.toList()); + + Assert.notNull(targetType, "Target type must not be null"); + + return StreamObjectMapper.map(read(consumer, readOptions, streams), this, targetType); } /** @@ -417,8 +402,35 @@ public interface StreamOperations { @Nullable List> reverseRange(K key, Range range, Limit limit); - default List> reverseRange(K key, Range range, Class targetType) { - return reverseRange(key, range).stream().map(it -> toObjectRecord(it, targetType)).collect(Collectors.toList()); + /** + * Read records from a stream within a specific {@link Range} in reverse order as {@link ObjectRecord}. + * + * @param targetType the target type of the payload. + * @param key the stream key. + * @param range must not be {@literal null}. + * @return list with members of the resulting stream. {@literal null} when used in pipeline / transaction. + * @see Redis Documentation: XREVRANGE + */ + default List> reverseRange(Class targetType, K key, Range range) { + return reverseRange(targetType, key, range, Limit.unlimited()); + } + + /** + * Read records from a stream within a specific {@link Range} applying a {@link Limit} in reverse order as + * {@link ObjectRecord}. + * + * @param targetType the target type of the payload. + * @param key the stream key. + * @param range must not be {@literal null}. + * @param limit must not be {@literal null}. + * @return list with members of the resulting stream. {@literal null} when used in pipeline / transaction. + * @see Redis Documentation: XREVRANGE + */ + default List> reverseRange(Class targetType, K key, Range range, Limit limit) { + + Assert.notNull(targetType, "Target type must not be null"); + + return StreamObjectMapper.map(reverseRange(key, range, limit), this, targetType); } /** @@ -439,31 +451,7 @@ public interface StreamOperations { * @param * @return the {@link HashMapper} suitable for a given type; */ + @Override HashMapper getHashMapper(Class targetType); - /** - * App - * - * @param value - * @param - * @return - */ - default MapRecord toMapRecord(Record value) { - - if (value instanceof ObjectRecord) { - - ObjectRecord entry = ((ObjectRecord) value); - return entry.toMapRecord(getHashMapper(entry.getValue().getClass())); - } - - if (value instanceof MapRecord) { - return (MapRecord) value; - } - - return Record.of(((HashMapper) getHashMapper(value.getClass())).toHash(value)).withStreamKey(value.getStream()); - } - - default ObjectRecord toObjectRecord(MapRecord entry, Class targetType) { - return entry.toObjectRecord(getHashMapper(targetType)); - } } diff --git a/src/main/java/org/springframework/data/redis/serializer/DefaultRedisElementWriter.java b/src/main/java/org/springframework/data/redis/serializer/DefaultRedisElementWriter.java index f927accc7..701b0439e 100644 --- a/src/main/java/org/springframework/data/redis/serializer/DefaultRedisElementWriter.java +++ b/src/main/java/org/springframework/data/redis/serializer/DefaultRedisElementWriter.java @@ -39,7 +39,7 @@ class DefaultRedisElementWriter implements RedisElementWriter { @Override public ByteBuffer write(T value) { - if (serializer != null) { + if (serializer != null && (value == null || serializer.canSerialize(value.getClass()))) { return ByteBuffer.wrap(serializer.serialize(value)); } diff --git a/src/main/java/org/springframework/data/redis/stream/DefaultStreamMessageListenerContainer.java b/src/main/java/org/springframework/data/redis/stream/DefaultStreamMessageListenerContainer.java index 20452f266..9656b9ff7 100644 --- a/src/main/java/org/springframework/data/redis/stream/DefaultStreamMessageListenerContainer.java +++ b/src/main/java/org/springframework/data/redis/stream/DefaultStreamMessageListenerContainer.java @@ -22,15 +22,17 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.concurrent.Executor; +import java.util.function.BiFunction; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.dao.DataAccessResourceFailureException; import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.Record; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.Record; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.StreamOperations; import org.springframework.util.Assert; @@ -45,7 +47,7 @@ import org.springframework.util.ErrorHandler; * @author Mark Paluch * @since 2.2 */ -class DefaultStreamMessageListenerContainer implements StreamMessageListenerContainer { +class DefaultStreamMessageListenerContainer> implements StreamMessageListenerContainer { private final Object lifecycleMonitor = new Object(); @@ -53,6 +55,8 @@ class DefaultStreamMessageListenerContainer implements StreamMessageListen private final ErrorHandler errorHandler; private final StreamReadOptions readOptions; private final RedisTemplate template; + private final StreamOperations streamOperations; + private final StreamMessageListenerContainerOptions containerOptions; private final List subscriptions = new ArrayList<>(); @@ -74,6 +78,13 @@ class DefaultStreamMessageListenerContainer implements StreamMessageListen this.errorHandler = containerOptions.getErrorHandler(); this.readOptions = getStreamReadOptions(containerOptions); this.template = createRedisTemplate(connectionFactory, containerOptions); + this.containerOptions = containerOptions; + + if (containerOptions.getHashMapper() != null) { + this.streamOperations = this.template.opsForStream(containerOptions.getHashMapper()); + } else { + this.streamOperations = this.template.opsForStream(); + } } private static StreamReadOptions getStreamReadOptions(StreamMessageListenerContainerOptions options) { @@ -92,9 +103,9 @@ class DefaultStreamMessageListenerContainer implements StreamMessageListen RedisTemplate template = new RedisTemplate<>(); template.setKeySerializer(containerOptions.getKeySerializer()); - template.setValueSerializer(containerOptions.getBodySerializer()); - template.setHashKeySerializer(containerOptions.getKeySerializer()); - template.setHashValueSerializer(containerOptions.getBodySerializer()); + template.setValueSerializer(containerOptions.getKeySerializer()); + template.setHashKeySerializer(containerOptions.getHashKeySerializer()); + template.setHashValueSerializer(containerOptions.getHashValueSerializer()); template.setConnectionFactory(connectionFactory); template.afterPropertiesSet(); @@ -193,9 +204,16 @@ class DefaultStreamMessageListenerContainer implements StreamMessageListen return doRegister(getReadTask(streamRequest, listener)); } + @SuppressWarnings("unchecked") private StreamPollTask getReadTask(StreamReadRequest streamRequest, StreamListener listener) { - StreamOperations streamOperations = template.opsForStream(); + BiFunction>> readFunction = getReadFunction(streamRequest); + + return new StreamPollTask<>(streamRequest, listener, errorHandler, (BiFunction) readFunction); + } + + @SuppressWarnings("unchecked") + private BiFunction>> getReadFunction(StreamReadRequest streamRequest) { if (streamRequest instanceof StreamMessageListenerContainer.ConsumerStreamReadRequest) { @@ -204,20 +222,20 @@ class DefaultStreamMessageListenerContainer implements StreamMessageListen StreamReadOptions readOptions = consumerStreamRequest.isAutoAck() ? this.readOptions : this.readOptions.noack(); Consumer consumer = consumerStreamRequest.getConsumer(); - return new StreamPollTask(consumerStreamRequest, listener, errorHandler, (key, offset) -> { - - return (List>) (List) streamOperations.read(consumer, readOptions, + if (this.containerOptions.getHashMapper() != null) { + return (key, offset) -> streamOperations.read(this.containerOptions.getTargetType(), consumer, readOptions, StreamOffset.create(key, offset)); - // List> x = (List>)(List) streamOperations.read(consumer, readOptions, - // StreamOffset.create(key, offset)); - // return x; + } - }); + return (key, offset) -> streamOperations.read(consumer, readOptions, StreamOffset.create(key, offset)); } - return new StreamPollTask<>(streamRequest, listener, errorHandler, (key, offset) -> { - return (List>) (List) streamOperations.read(readOptions, StreamOffset.create(key, offset)); - }); + if (this.containerOptions.getHashMapper() != null) { + return (key, offset) -> streamOperations.read(this.containerOptions.getTargetType(), readOptions, + StreamOffset.create(key, offset)); + } + + return (key, offset) -> streamOperations.read(readOptions, StreamOffset.create(key, offset)); } private Subscription doRegister(Task task) { diff --git a/src/main/java/org/springframework/data/redis/stream/DefaultStreamReceiver.java b/src/main/java/org/springframework/data/redis/stream/DefaultStreamReceiver.java index c55290ce0..5ec5d9afd 100644 --- a/src/main/java/org/springframework/data/redis/stream/DefaultStreamReceiver.java +++ b/src/main/java/org/springframework/data/redis/stream/DefaultStreamReceiver.java @@ -34,14 +34,14 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.reactivestreams.Subscription; import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.MapRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.Record; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.core.ReactiveStreamOperations; import org.springframework.data.redis.serializer.RedisSerializationContext; -import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair; /** * Default implementation of {@link StreamReceiver}. @@ -49,11 +49,13 @@ import org.springframework.data.redis.serializer.RedisSerializationContext.Seria * @author Mark Paluch * @since 2.2 */ -class DefaultStreamReceiver implements StreamReceiver { +class DefaultStreamReceiver> implements StreamReceiver { private final Log logger = LogFactory.getLog(getClass()); private final ReactiveRedisTemplate template; + private final ReactiveStreamOperations streamOperations; private final StreamReadOptions readOptions; + private final StreamReceiverOptions receiverOptions; /** * Create a new {@link DefaultStreamReceiver} given {@link ReactiveRedisConnectionFactory} and @@ -62,13 +64,14 @@ class DefaultStreamReceiver implements StreamReceiver { * @param connectionFactory must not be {@literal null}. * @param options must not be {@literal null}. */ - DefaultStreamReceiver(ReactiveRedisConnectionFactory connectionFactory, StreamReceiverOptions options) { + @SuppressWarnings("unchecked") + DefaultStreamReceiver(ReactiveRedisConnectionFactory connectionFactory, StreamReceiverOptions options) { + receiverOptions = options; - RedisSerializationContext serializationContext = RedisSerializationContext - . newSerializationContext(options.getKeySerializer()) // - .key((SerializationPair) options.getKeySerializer()) // - .value((SerializationPair) options.getBodySerializer()) // - .build(); + RedisSerializationContext serializationContext = RedisSerializationContext + . newSerializationContext(options.getKeySerializer()) // + .key(options.getKeySerializer()).hashKey(options.getHashKeySerializer()) + .hashValue(options.getHashValueSerializer()).build(); StreamReadOptions readOptions = StreamReadOptions.empty().count(options.getBatchSize()); if (!options.getPollTimeout().isZero()) { @@ -77,6 +80,12 @@ class DefaultStreamReceiver implements StreamReceiver { this.readOptions = readOptions; this.template = new ReactiveRedisTemplate(connectionFactory, serializationContext); + + if (options.getHashMapper() != null) { + this.streamOperations = this.template.opsForStream(options.getHashMapper()); + } else { + this.streamOperations = this.template.opsForStream(); + } } /* @@ -84,19 +93,27 @@ class DefaultStreamReceiver implements StreamReceiver { * @see org.springframework.data.redis.stream.StreamReceiver#receive(org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset) */ @Override - public Flux> receive(StreamOffset streamOffset) { + @SuppressWarnings("unchecked") + public Flux receive(StreamOffset streamOffset) { if (logger.isDebugEnabled()) { logger.debug(String.format("receive(%s)", streamOffset)); } + BiFunction>> readFunction; + + if (receiverOptions.getHashMapper() != null) { + readFunction = (key, readOffset) -> streamOperations.read(receiverOptions.getTargetType(), readOptions, + StreamOffset.create(key, readOffset)); + } else { + readFunction = (key, readOffset) -> streamOperations.read(readOptions, StreamOffset.create(key, readOffset)); + } + return Flux.defer(() -> { PollState pollState = PollState.standalone(streamOffset.getOffset()); - BiFunction>> readFunction = (key, readOffset) -> template - . opsForStream().read(readOptions, StreamOffset.create(key, readOffset)); - - return Flux.create(sink -> new StreamSubscription(sink, streamOffset.getKey(), pollState, readFunction).arm()); + return Flux.create( + sink -> new StreamSubscription(sink, streamOffset.getKey(), pollState, (BiFunction) readFunction).arm()); }); } @@ -105,19 +122,21 @@ class DefaultStreamReceiver implements StreamReceiver { * @see org.springframework.data.redis.stream.StreamReceiver#receiveAutoAck(org.springframework.data.redis.connection.RedisStreamCommands.Consumer, org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset) */ @Override - public Flux> receiveAutoAck(Consumer consumer, StreamOffset streamOffset) { + @SuppressWarnings("unchecked") + public Flux receiveAutoAck(Consumer consumer, StreamOffset streamOffset) { if (logger.isDebugEnabled()) { logger.debug(String.format("receiveAutoAck(%s, %s)", consumer, streamOffset)); } + BiFunction>> readFunction = getConsumeReadFunction(consumer, + this.readOptions); + return Flux.defer(() -> { PollState pollState = PollState.consumer(consumer, streamOffset.getOffset()); - BiFunction>> readFunction = (key, readOffset) -> template - . opsForStream().read(consumer, readOptions, StreamOffset.create(key, readOffset)); - - return Flux.create(sink -> new StreamSubscription(sink, streamOffset.getKey(), pollState, readFunction).arm()); + return Flux.create( + sink -> new StreamSubscription(sink, streamOffset.getKey(), pollState, (BiFunction) readFunction).arm()); }); } @@ -126,36 +145,47 @@ class DefaultStreamReceiver implements StreamReceiver { * @see org.springframework.data.redis.stream.StreamReceiver#receive(org.springframework.data.redis.connection.RedisStreamCommands.Consumer, org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset) */ @Override - public Flux> receive(Consumer consumer, StreamOffset streamOffset) { + @SuppressWarnings("unchecked") + public Flux receive(Consumer consumer, StreamOffset streamOffset) { if (logger.isDebugEnabled()) { logger.debug(String.format("receive(%s, %s)", consumer, streamOffset)); } - StreamReadOptions noack = readOptions.noack(); + BiFunction>> readFunction = getConsumeReadFunction(consumer, + this.readOptions.noack()); return Flux.defer(() -> { - PollState pollState = PollState.consumer(consumer, streamOffset.getOffset()); - BiFunction>> readFunction = (key, readOffset) -> template - . opsForStream().read(consumer, noack, StreamOffset.create(key, readOffset)); - - return Flux.create(sink -> new StreamSubscription(sink, streamOffset.getKey(), pollState, readFunction).arm()); + return Flux.create( + sink -> new StreamSubscription(sink, streamOffset.getKey(), pollState, (BiFunction) readFunction).arm()); }); } + @SuppressWarnings("unchecked") + private BiFunction>> getConsumeReadFunction(Consumer consumer, + StreamReadOptions readOptions) { + + if (receiverOptions.getHashMapper() != null) { + return (key, readOffset) -> streamOperations.read(receiverOptions.getTargetType(), consumer, readOptions, + StreamOffset.create(key, readOffset)); + } + + return (key, readOffset) -> streamOperations.read(consumer, readOptions, StreamOffset.create(key, readOffset)); + } + /** * A stateful Redis Stream subscription. */ @RequiredArgsConstructor class StreamSubscription { - private final Queue> overflow = Queues.> small().get(); + private final Queue overflow = Queues. small().get(); - private final FluxSink> sink; + private final FluxSink sink; private final K key; private final PollState pollState; - private final BiFunction>> readFunction; + private final BiFunction> readFunction; /** * Arm the subscription so {@link Subscription#request(long) demand} activates polling. @@ -250,15 +280,15 @@ class DefaultStreamReceiver implements StreamReceiver { String.format("[stream: %s] scheduleIfRequired(): Activating subscription, offset %s", key, readOffset)); } - Flux> poll = readFunction.apply(key, readOffset); + Flux poll = readFunction.apply(key, readOffset); poll.subscribe(getSubscriber()); } } - private CoreSubscriber> getSubscriber() { + private CoreSubscriber getSubscriber() { - return new CoreSubscriber>() { + return new CoreSubscriber() { @Override public void onSubscribe(Subscription s) { @@ -266,7 +296,7 @@ class DefaultStreamReceiver implements StreamReceiver { } @Override - public void onNext(MapRecord message) { + public void onNext(V message) { onStreamMessage(message); } @@ -294,7 +324,7 @@ class DefaultStreamReceiver implements StreamReceiver { }; } - private void onStreamMessage(MapRecord message) { + private void onStreamMessage(V message) { if (logger.isDebugEnabled()) { logger.debug(String.format("[stream: %s] onStreamMessage(%s)", key, message)); @@ -359,7 +389,7 @@ class DefaultStreamReceiver implements StreamReceiver { if (demand == Long.MAX_VALUE) { - MapRecord message = overflow.poll(); + V message = overflow.poll(); if (message == null) { if (logger.isDebugEnabled()) { @@ -377,7 +407,7 @@ class DefaultStreamReceiver implements StreamReceiver { } else if (pollState.setRequested(demand, demand - 1)) { - MapRecord message = overflow.poll(); + V message = overflow.poll(); if (message == null) { diff --git a/src/main/java/org/springframework/data/redis/stream/RawRedisSerializer.java b/src/main/java/org/springframework/data/redis/stream/RawRedisSerializer.java new file mode 100644 index 000000000..4e28e33c3 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/stream/RawRedisSerializer.java @@ -0,0 +1,40 @@ +/* + * Copyright 2018 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.data.redis.stream; + +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.SerializationException; +import org.springframework.lang.Nullable; + +/** + * @author Mark Paluch + */ +enum RawRedisSerializer implements RedisSerializer { + + INSTANCE; + + @Nullable + @Override + public byte[] serialize(@Nullable byte[] bytes) throws SerializationException { + return bytes; + } + + @Nullable + @Override + public byte[] deserialize(@Nullable byte[] bytes) throws SerializationException { + return bytes; + } +} diff --git a/src/main/java/org/springframework/data/redis/stream/ReadOffsetStrategy.java b/src/main/java/org/springframework/data/redis/stream/ReadOffsetStrategy.java index 052d78eb9..e5d187265 100644 --- a/src/main/java/org/springframework/data/redis/stream/ReadOffsetStrategy.java +++ b/src/main/java/org/springframework/data/redis/stream/ReadOffsetStrategy.java @@ -17,8 +17,8 @@ package org.springframework.data.redis.stream; import java.util.Optional; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.ReadOffset; /** * Strategy to determine the first and subsequent {@link ReadOffset}. diff --git a/src/main/java/org/springframework/data/redis/stream/StreamListener.java b/src/main/java/org/springframework/data/redis/stream/StreamListener.java index 77d2a6e0b..48c53c02c 100644 --- a/src/main/java/org/springframework/data/redis/stream/StreamListener.java +++ b/src/main/java/org/springframework/data/redis/stream/StreamListener.java @@ -15,7 +15,7 @@ */ package org.springframework.data.redis.stream; -import org.springframework.data.redis.connection.RedisStreamCommands.Record; +import org.springframework.data.redis.connection.stream.Record; /** * Listener interface to receive delivery of {@link Record messages}. @@ -26,12 +26,12 @@ import org.springframework.data.redis.connection.RedisStreamCommands.Record; * @since 2.2 */ @FunctionalInterface -public interface StreamListener { +public interface StreamListener> { /** * Callback invoked on receiving a {@link Record}. * * @param message never {@literal null}. */ - void onMessage(Record message); + void onMessage(V message); } diff --git a/src/main/java/org/springframework/data/redis/stream/StreamMessageListenerContainer.java b/src/main/java/org/springframework/data/redis/stream/StreamMessageListenerContainer.java index efb78ce5f..1f969a359 100644 --- a/src/main/java/org/springframework/data/redis/stream/StreamMessageListenerContainer.java +++ b/src/main/java/org/springframework/data/redis/stream/StreamMessageListenerContainer.java @@ -15,20 +15,21 @@ */ package org.springframework.data.redis.stream; -import lombok.AccessLevel; -import lombok.RequiredArgsConstructor; - import java.time.Duration; -import java.util.Map; import java.util.concurrent.Executor; import java.util.function.Predicate; import org.springframework.context.SmartLifecycle; import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.Record; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.hash.HashMapper; +import org.springframework.data.redis.hash.ObjectHashMapper; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.data.redis.stream.DefaultStreamMessageListenerContainer.LoggingErrorHandler; @@ -41,9 +42,9 @@ import org.springframework.util.ErrorHandler; * implemented externally. *

* Once created, a {@link StreamMessageListenerContainer} can subscribe to a Redis Stream and consume incoming - * {@link Record messages}. {@link StreamMessageListenerContainer} allows multiple stream read requests and - * returns a {@link Subscription} handle per read request. Cancelling the {@link Subscription} terminates eventually - * background polling. Messages are converted using {@link RedisSerializer key and value serializers} to support various + * {@link Record messages}. {@link StreamMessageListenerContainer} allows multiple stream read requests and returns a + * {@link Subscription} handle per read request. Cancelling the {@link Subscription} terminates eventually background + * polling. Messages are converted using {@link RedisSerializer key and value serializers} to support various * serialization strategies.
* {@link StreamMessageListenerContainer} supports multiple modes of stream consumption: *

    @@ -83,17 +84,17 @@ import org.springframework.util.ErrorHandler; * {@link StreamListener#onMessage(Record) listener callback}. *

    * {@link StreamMessageListenerContainer} tasks propagate errors during stream reads and - * {@link StreamListener#onMessage(Record) listener notification} to a configurable {@link ErrorHandler}. Errors - * stop a {@link Subscription} by default. Configuring a {@link Predicate} for a {@link StreamReadRequest} allows - * conditional subscription cancelling or continuing on all errors. + * {@link StreamListener#onMessage(Record) listener notification} to a configurable {@link ErrorHandler}. Errors stop a + * {@link Subscription} by default. Configuring a {@link Predicate} for a {@link StreamReadRequest} allows conditional + * subscription cancelling or continuing on all errors. *

    * See the following example code how to use {@link StreamMessageListenerContainer}: * *

      * RedisConnectionFactory factory = …;
      *
    - * StreamMessageListenerContainer container = StreamMessageListenerContainer.create(factory);
    - * Subscription subscription = container.receive(StreamOffset.create("my-stream", ReadOffset.from("0-0")), message -> …);
    + * StreamMessageListenerContainer> container = StreamMessageListenerContainer.create(factory);
    + * Subscription subscription = container.receive(StreamOffset.fromStart("my-stream"), message -> …);
      *
      * container.start();
      *
    @@ -115,7 +116,7 @@ import org.springframework.util.ErrorHandler;
      * @see RedisConnectionFactory
      * @see StreamReceiver
      */
    -public interface StreamMessageListenerContainer extends SmartLifecycle {
    +public interface StreamMessageListenerContainer> extends SmartLifecycle {
     
     	/**
     	 * Create a new {@link StreamMessageListenerContainer} using {@link StringRedisSerializer string serializers} given
    @@ -124,7 +125,8 @@ public interface StreamMessageListenerContainer extends SmartLifecycle {
     	 * @param connectionFactory must not be {@literal null}.
     	 * @return the new {@link StreamMessageListenerContainer}.
     	 */
    -	static StreamMessageListenerContainer> create(RedisConnectionFactory connectionFactory) {
    +	static StreamMessageListenerContainer> create(
    +			RedisConnectionFactory connectionFactory) {
     
     		Assert.notNull(connectionFactory, "RedisConnectionFactory must not be null!");
     
    @@ -140,7 +142,8 @@ public interface StreamMessageListenerContainer extends SmartLifecycle {
     	 * @param options must not be {@literal null}.
     	 * @return the new {@link StreamMessageListenerContainer}.
     	 */
    -	static  StreamMessageListenerContainer create(RedisConnectionFactory connectionFactory,
    +	static > StreamMessageListenerContainer create(
    +			RedisConnectionFactory connectionFactory,
     			StreamMessageListenerContainerOptions options) {
     
     		Assert.notNull(connectionFactory, "RedisConnectionFactory must not be null!");
    @@ -468,20 +471,38 @@ public interface StreamMessageListenerContainer extends SmartLifecycle {
     	 * @param  Stream value type.
     	 * @see StreamMessageListenerContainerOptionsBuilder
     	 */
    -	@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
    -	class StreamMessageListenerContainerOptions {
    +	class StreamMessageListenerContainerOptions> {
     
     		private final Duration pollTimeout;
     		private final int batchSize;
     		private final RedisSerializer keySerializer;
    -		private final RedisSerializer bodySerializer;
    +		private final RedisSerializer hashKeySerializer;
    +		private final RedisSerializer hashValueSerializer;
    +		private final @Nullable Class targetType;
    +		private final @Nullable HashMapper hashMapper;
     		private final ErrorHandler errorHandler;
     		private final Executor executor;
     
    +		@SuppressWarnings("unchecked")
    +		private StreamMessageListenerContainerOptions(Duration pollTimeout, int batchSize, RedisSerializer keySerializer,
    +				RedisSerializer hashKeySerializer, RedisSerializer hashValueSerializer,
    +				@Nullable Class targetType, @Nullable HashMapper hashMapper, ErrorHandler errorHandler,
    +				Executor executor) {
    +			this.pollTimeout = pollTimeout;
    +			this.batchSize = batchSize;
    +			this.keySerializer = keySerializer;
    +			this.hashKeySerializer = hashKeySerializer;
    +			this.hashValueSerializer = hashValueSerializer;
    +			this.targetType = (Class) targetType;
    +			this.hashMapper = (HashMapper) hashMapper;
    +			this.errorHandler = errorHandler;
    +			this.executor = executor;
    +		}
    +
     		/**
     		 * @return a new builder for {@link StreamMessageListenerContainerOptions}.
     		 */
    -		static StreamMessageListenerContainerOptionsBuilder> builder() {
    +		static StreamMessageListenerContainerOptionsBuilder> builder() {
     			return new StreamMessageListenerContainerOptionsBuilder<>().serializer(StringRedisSerializer.UTF_8);
     		}
     
    @@ -507,8 +528,26 @@ public interface StreamMessageListenerContainer extends SmartLifecycle {
     			return keySerializer;
     		}
     
    -		public RedisSerializer getBodySerializer() {
    -			return bodySerializer;
    +		public RedisSerializer getHashKeySerializer() {
    +			return hashKeySerializer;
    +		}
    +
    +		public RedisSerializer getHashValueSerializer() {
    +			return hashValueSerializer;
    +		}
    +
    +		@Nullable
    +		public HashMapper getHashMapper() {
    +			return hashMapper;
    +		}
    +
    +		public Class getTargetType() {
    +
    +			if (this.targetType != null) {
    +				return targetType;
    +			}
    +
    +			return Object.class;
     		}
     
     		/**
    @@ -532,12 +571,16 @@ public interface StreamMessageListenerContainer extends SmartLifecycle {
     	 * @param  Stream key and Stream field type
     	 * @param  Stream value type
     	 */
    -	class StreamMessageListenerContainerOptionsBuilder {
    +	@SuppressWarnings("unchecked")
    +	class StreamMessageListenerContainerOptionsBuilder> {
     
     		private Duration pollTimeout = Duration.ofSeconds(2);
     		private int batchSize = 1;
     		private RedisSerializer keySerializer;
    -		private RedisSerializer bodySerializer;
    +		private RedisSerializer hashKeySerializer;
    +		private RedisSerializer hashValueSerializer;
    +		private @Nullable HashMapper hashMapper;
    +		private @Nullable Class targetType;
     		private ErrorHandler errorHandler = LoggingErrorHandler.INSTANCE;
     		private Executor executor = new SimpleAsyncTaskExecutor();
     
    @@ -601,15 +644,19 @@ public interface StreamMessageListenerContainer extends SmartLifecycle {
     		}
     
     		/**
    -		 * Configure a key and value serializer.
    +		 * Configure a key, hash key and hash value serializer.
     		 *
     		 * @param serializer must not be {@literal null}.
     		 * @return {@code this} {@link StreamMessageListenerContainerOptionsBuilder}.
     		 */
    -		public  StreamMessageListenerContainerOptionsBuilder> serializer(RedisSerializer serializer) {
    +		public  StreamMessageListenerContainerOptionsBuilder> serializer(
    +				RedisSerializer serializer) {
    +
    +			Assert.notNull(serializer, "RedisSerializer must not be null");
     
     			this.keySerializer = (RedisSerializer) serializer;
    -			this.bodySerializer = (RedisSerializer) serializer;
    +			this.hashKeySerializer = (RedisSerializer) serializer;
    +			this.hashValueSerializer = (RedisSerializer) serializer;
     			return (StreamMessageListenerContainerOptionsBuilder) this;
     		}
     
    @@ -619,21 +666,81 @@ public interface StreamMessageListenerContainer extends SmartLifecycle {
     		 * @param serializer must not be {@literal null}.
     		 * @return {@code this} {@link StreamMessageListenerContainerOptionsBuilder}.
     		 */
    -		public  StreamMessageListenerContainerOptionsBuilder keySerializer(RedisSerializer serializer) {
    +		public > StreamMessageListenerContainerOptionsBuilder keySerializer(
    +				RedisSerializer serializer) {
    +
    +			Assert.notNull(serializer, "RedisSerializer must not be null");
     
     			this.keySerializer = (RedisSerializer) serializer;
     			return (StreamMessageListenerContainerOptionsBuilder) this;
     		}
     
     		/**
    -		 * Configure a value serializer.
    +		 * Configure a hash key serializer.
     		 *
     		 * @param serializer must not be {@literal null}.
     		 * @return {@code this} {@link StreamMessageListenerContainerOptionsBuilder}.
     		 */
    -		public  StreamMessageListenerContainerOptionsBuilder bodySerializer(RedisSerializer serializer) {
    +		public  StreamMessageListenerContainerOptionsBuilder> hashKeySerializer(
    +				RedisSerializer serializer) {
     
    -			this.bodySerializer = (RedisSerializer) serializer;
    +			Assert.notNull(serializer, "RedisSerializer must not be null");
    +
    +			this.hashKeySerializer = (RedisSerializer) serializer;
    +			return (StreamMessageListenerContainerOptionsBuilder) this;
    +		}
    +
    +		/**
    +		 * Configure a hash value serializer.
    +		 *
    +		 * @param serializer must not be {@literal null}.
    +		 * @return {@code this} {@link StreamMessageListenerContainerOptionsBuilder}.
    +		 */
    +		public  StreamMessageListenerContainerOptionsBuilder> hashValueSerializer(
    +				RedisSerializer serializer) {
    +
    +			Assert.notNull(serializer, "RedisSerializer must not be null");
    +
    +			this.hashValueSerializer = (RedisSerializer) serializer;
    +			return (StreamMessageListenerContainerOptionsBuilder) this;
    +		}
    +
    +		/**
    +		 * Configure a hash target type. Changes the emitted {@link Record} type to {@link ObjectRecord}.
    +		 *
    +		 * @param pair must not be {@literal null}.
    +		 * @return {@code this} {@link StreamMessageListenerContainerOptionsBuilder}.
    +		 */
    +		@SuppressWarnings("unchecked")
    +		public  StreamMessageListenerContainerOptionsBuilder> targetType(Class targetType) {
    +
    +			Assert.notNull(targetType, "Target type must not be null");
    +
    +			this.targetType = targetType;
    +
    +			if (this.hashMapper == null) {
    +
    +				hashKeySerializer(RawRedisSerializer.INSTANCE);
    +				hashValueSerializer(RawRedisSerializer.INSTANCE);
    +				return (StreamMessageListenerContainerOptionsBuilder) objectMapper(new ObjectHashMapper());
    +			}
    +
    +			return (StreamMessageListenerContainerOptionsBuilder) this;
    +		}
    +
    +		/**
    +		 * Configure a hash mapper. Changes the emitted {@link Record} type to {@link ObjectRecord}.
    +		 *
    +		 * @param hashMapper must not be {@literal null}.
    +		 * @return {@code this} {@link StreamMessageListenerContainerOptionsBuilder}.
    +		 */
    +		@SuppressWarnings("unchecked")
    +		public  StreamMessageListenerContainerOptionsBuilder> objectMapper(
    +				HashMapper hashMapper) {
    +
    +			Assert.notNull(hashMapper, "HashMapper must not be null");
    +
    +			this.hashMapper = (HashMapper) hashMapper;
     			return (StreamMessageListenerContainerOptionsBuilder) this;
     		}
     
    @@ -643,7 +750,8 @@ public interface StreamMessageListenerContainer extends SmartLifecycle {
     		 * @return new {@link StreamMessageListenerContainerOptions}.
     		 */
     		public StreamMessageListenerContainerOptions build() {
    -			return new StreamMessageListenerContainerOptions<>(pollTimeout, batchSize, keySerializer, bodySerializer,
    +			return new StreamMessageListenerContainerOptions<>(pollTimeout, batchSize, keySerializer, hashKeySerializer,
    +					hashValueSerializer, targetType, hashMapper,
     					errorHandler, executor);
     		}
     	}
    diff --git a/src/main/java/org/springframework/data/redis/stream/StreamPollTask.java b/src/main/java/org/springframework/data/redis/stream/StreamPollTask.java
    index 6b71ced18..c1cce80a7 100644
    --- a/src/main/java/org/springframework/data/redis/stream/StreamPollTask.java
    +++ b/src/main/java/org/springframework/data/redis/stream/StreamPollTask.java
    @@ -24,10 +24,10 @@ import java.util.function.BiFunction;
     import java.util.function.Predicate;
     
     import org.springframework.dao.DataAccessResourceFailureException;
    -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer;
    -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset;
    -import org.springframework.data.redis.connection.RedisStreamCommands.Record;
    -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset;
    +import org.springframework.data.redis.connection.stream.Consumer;
    +import org.springframework.data.redis.connection.stream.ReadOffset;
    +import org.springframework.data.redis.connection.stream.Record;
    +import org.springframework.data.redis.connection.stream.StreamOffset;
     import org.springframework.data.redis.stream.StreamMessageListenerContainer.ConsumerStreamReadRequest;
     import org.springframework.data.redis.stream.StreamMessageListenerContainer.StreamReadRequest;
     import org.springframework.util.ErrorHandler;
    @@ -38,19 +38,19 @@ import org.springframework.util.ErrorHandler;
      * @author Mark Paluch
      * @see 2.2
      */
    -class StreamPollTask implements Task {
    +class StreamPollTask> implements Task {
     
     	private final StreamReadRequest request;
     	private final StreamListener listener;
     	private final ErrorHandler errorHandler;
     	private final Predicate cancelSubscriptionOnError;
    -	private final BiFunction>> readFunction;
    +	private final BiFunction> readFunction;
     
     	private final PollState pollState;
     	private volatile boolean isInEventLoop = false;
     
     	StreamPollTask(StreamReadRequest streamRequest, StreamListener listener, ErrorHandler errorHandler,
    -			BiFunction>> readFunction) {
    +			BiFunction> readFunction) {
     
     		this.request = streamRequest;
     		this.listener = listener;
    @@ -135,9 +135,9 @@ class StreamPollTask implements Task {
     				// allow interruption
     				Thread.sleep(0);
     
    -				List> read = readFunction.apply(key, pollState.getCurrentReadOffset());
    +				List read = readFunction.apply(key, pollState.getCurrentReadOffset());
     
    -				for (Record message : read) {
    +				for (V message : read) {
     
     					listener.onMessage(message);
     					pollState.updateReadOffset(message.getId().getValue());
    diff --git a/src/main/java/org/springframework/data/redis/stream/StreamReceiver.java b/src/main/java/org/springframework/data/redis/stream/StreamReceiver.java
    index 922c174d4..9c04df042 100644
    --- a/src/main/java/org/springframework/data/redis/stream/StreamReceiver.java
    +++ b/src/main/java/org/springframework/data/redis/stream/StreamReceiver.java
    @@ -15,27 +15,33 @@
      */
     package org.springframework.data.redis.stream;
     
    -import org.springframework.data.redis.connection.RedisStreamCommands.MapRecord;
     import reactor.core.publisher.Flux;
     
     import java.nio.ByteBuffer;
     import java.time.Duration;
     
     import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
    -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer;
    -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset;
    -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset;
    +import org.springframework.data.redis.connection.stream.Consumer;
    +import org.springframework.data.redis.connection.stream.MapRecord;
    +import org.springframework.data.redis.connection.stream.ObjectRecord;
    +import org.springframework.data.redis.connection.stream.ReadOffset;
    +import org.springframework.data.redis.connection.stream.Record;
    +import org.springframework.data.redis.connection.stream.StreamOffset;
    +import org.springframework.data.redis.hash.HashMapper;
    +import org.springframework.data.redis.hash.ObjectHashMapper;
    +import org.springframework.data.redis.serializer.RedisSerializationContext;
     import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
     import org.springframework.data.redis.serializer.StringRedisSerializer;
    +import org.springframework.lang.Nullable;
     import org.springframework.util.Assert;
     
     /**
      * A receiver to consume Redis Streams using reactive infrastructure.
      * 

    - * Once created, a {@link StreamReceiver} can subscribe to a Redis Stream and consume incoming {@link org.springframework.data.redis.connection.RedisStreamCommands.Record - * messages}. Consider a {@link Flux} of {@link Record} infinite. Cancelling the - * {@link org.reactivestreams.Subscription} terminates eventually background polling. Messages are converted using - * {@link SerializationPair key and value serializers} to support various serialization strategies.
    + * Once created, a {@link StreamReceiver} can subscribe to a Redis Stream and consume incoming {@link Record records}. + * Consider a {@link Flux} of {@link Record} infinite. Cancelling the {@link org.reactivestreams.Subscription} + * terminates eventually background polling. Records are converted using {@link SerializationPair key and value + * serializers} to support various serialization strategies.
    * {@link StreamReceiver} supports three modes of stream consumption: *

      *
    • Standalone
    • @@ -49,36 +55,36 @@ import org.springframework.util.Assert; *
      * Standalone *
        - *
      • {@link ReadOffset#from(String)} Offset using a particular message Id: Start with the given offset and use the - * last seen {@link Record#getId() message Id}.
      • + *
      • {@link ReadOffset#from(String)} Offset using a particular record Id: Start with the given offset and use the last + * seen {@link Record#getId() record Id}.
      • *
      • {@link ReadOffset#lastConsumed()} Last consumed: Start with the latest offset ({@code $}) and use the last seen - * {@link Record#getId() message Id}.
      • + * {@link Record#getId() record Id}. *
      • {@link ReadOffset#latest()} Last consumed: Start with the latest offset ({@code $}) and use latest offset * ({@code $}) for subsequent reads.
      • *
      *
      * Using {@link Consumer} *
        - *
      • {@link ReadOffset#from(String)} Offset using a particular message Id: Start with the given offset and use the - * last seen {@link Record#getId() message Id}.
      • - *
      • {@link ReadOffset#lastConsumed()} Last consumed: Start with the last consumed message by the consumer ({@code >}) - * and use the last consumed message by the consumer ({@code >}) for subsequent reads.
      • + *
      • {@link ReadOffset#from(String)} Offset using a particular record Id: Start with the given offset and use the last + * seen {@link Record#getId() record Id}.
      • + *
      • {@link ReadOffset#lastConsumed()} Last consumed: Start with the last consumed record by the consumer ({@code >}) + * and use the last consumed record by the consumer ({@code >}) for subsequent reads.
      • *
      • {@link ReadOffset#latest()} Last consumed: Start with the latest offset ({@code $}) and use latest offset * ({@code $}) for subsequent reads.
      • *
      - * Note: Using {@link ReadOffset#latest()} bears the chance of dropped messages as messages can arrive in the - * time during polling is suspended. Use messagedId's as offset or {@link ReadOffset#lastConsumed()} to minimize the - * chance of message loss. + * Note: Using {@link ReadOffset#latest()} bears the chance of dropped records as records can arrive in the time + * during polling is suspended. Use recorddId's as offset or {@link ReadOffset#lastConsumed()} to minimize the chance of + * record loss. *

      * See the following example code how to use {@link StreamReceiver}: * *

        * ReactiveRedisConnectionFactory factory = …;
        *
      - * StreamReceiver receiver = StreamReceiver.create(factory);
      - * Flux> messages = receiver.receive(StreamOffset.create("my-stream", ReadOffset.from("0-0")));
      + * StreamReceiver receiver = StreamReceiver.create(factory);
      + * Flux> records = receiver.receive(StreamOffset.fromStart("my-stream"));
        *
      - * messageFlux.doOnNext(message -> …);
      + * recordFlux.doOnNext(record -> …);
        * 
      * * @author Mark Paluch @@ -90,7 +96,7 @@ import org.springframework.util.Assert; * @see ReactiveRedisConnectionFactory * @see StreamMessageListenerContainer */ -public interface StreamReceiver { +public interface StreamReceiver> { /** * Create a new {@link StreamReceiver} using {@link StringRedisSerializer string serializers} given @@ -99,7 +105,8 @@ public interface StreamReceiver { * @param connectionFactory must not be {@literal null}. * @return the new {@link StreamReceiver}. */ - static StreamReceiver create(ReactiveRedisConnectionFactory connectionFactory) { + static StreamReceiver> create( + ReactiveRedisConnectionFactory connectionFactory) { Assert.notNull(connectionFactory, "ReactiveRedisConnectionFactory must not be null!"); @@ -114,8 +121,8 @@ public interface StreamReceiver { * @param options must not be {@literal null}. * @return the new {@link StreamReceiver}. */ - static StreamReceiver create(ReactiveRedisConnectionFactory connectionFactory, - StreamReceiverOptions options) { + static > StreamReceiver create(ReactiveRedisConnectionFactory connectionFactory, + StreamReceiverOptions options) { Assert.notNull(connectionFactory, "ReactiveRedisConnectionFactory must not be null!"); Assert.notNull(options, "StreamReceiverOptions must not be null!"); @@ -124,25 +131,25 @@ public interface StreamReceiver { } /** - * Starts a Redis Stream consumer that consumes {@link Record messages} from the {@link StreamOffset stream}. - * Messages are consumed from Redis and delivered on the returned {@link Flux} when requests are made on the Flux. The - * receiver is closed when the returned {@link Flux} terminates. + * Starts a Redis Stream consumer that consumes {@link Record records} from the {@link StreamOffset stream}. Records + * are consumed from Redis and delivered on the returned {@link Flux} when requests are made on the Flux. The receiver + * is closed when the returned {@link Flux} terminates. *

      - * Every message must be acknowledged using + * Every record must be acknowledged using * {@link org.springframework.data.redis.connection.ReactiveStreamCommands#xAck(ByteBuffer, String, String...)} * * @param streamOffset the stream along its offset. * @return Flux of inbound {@link Record}s. * @see StreamOffset#create(Object, ReadOffset) */ - Flux> receive(StreamOffset streamOffset); + Flux receive(StreamOffset streamOffset); /** - * Starts a Redis Stream consumer that consumes {@link Record messages} from the {@link StreamOffset stream}. - * Messages are consumed from Redis and delivered on the returned {@link Flux} when requests are made on the Flux. The - * receiver is closed when the returned {@link Flux} terminates. + * Starts a Redis Stream consumer that consumes {@link Record records} from the {@link StreamOffset stream}. Records + * are consumed from Redis and delivered on the returned {@link Flux} when requests are made on the Flux. The receiver + * is closed when the returned {@link Flux} terminates. *

      - * Every message is acknowledged when received. + * Every record is acknowledged when received. * * @param consumer consumer group, must not be {@literal null}. * @param streamOffset the stream along its offset. @@ -150,14 +157,14 @@ public interface StreamReceiver { * @see StreamOffset#create(Object, ReadOffset) * @see ReadOffset#lastConsumed() */ - Flux> receiveAutoAck(Consumer consumer, StreamOffset streamOffset); + Flux receiveAutoAck(Consumer consumer, StreamOffset streamOffset); /** - * Starts a Redis Stream consumer that consumes {@link Record messages} from the {@link StreamOffset stream}. - * Messages are consumed from Redis and delivered on the returned {@link Flux} when requests are made on the Flux. The - * receiver is closed when the returned {@link Flux} terminates. + * Starts a Redis Stream consumer that consumes {@link Record records} from the {@link StreamOffset stream}. Records + * are consumed from Redis and delivered on the returned {@link Flux} when requests are made on the Flux. The receiver + * is closed when the returned {@link Flux} terminates. *

      - * Every message must be acknowledged using + * Every record must be acknowledged using * {@link org.springframework.data.redis.core.ReactiveStreamOperations#acknowledge(Object, String, String...)} after * processing. * @@ -167,7 +174,7 @@ public interface StreamReceiver { * @see StreamOffset#create(Object, ReadOffset) * @see ReadOffset#lastConsumed() */ - Flux> receive(Consumer consumer, StreamOffset streamOffset); + Flux receive(Consumer consumer, StreamOffset streamOffset); /** * Options for {@link StreamReceiver}. @@ -176,32 +183,51 @@ public interface StreamReceiver { * @param Stream value type. * @see StreamReceiverOptionsBuilder */ - class StreamReceiverOptions { + class StreamReceiverOptions> { private final Duration pollTimeout; private final int batchSize; private final SerializationPair keySerializer; - private final SerializationPair bodySerializer; - private final SerializationPair vaueSerializer; + private final SerializationPair hashKeySerializer; + private final SerializationPair hashValueSerializer; + private final @Nullable Class targetType; + private final @Nullable HashMapper hashMapper; private StreamReceiverOptions(Duration pollTimeout, int batchSize, SerializationPair keySerializer, - SerializationPair bodySerializer, SerializationPair valueSerializer) { + SerializationPair hashKeySerializer, SerializationPair hashValueSerializer, + @Nullable Class targetType, @Nullable HashMapper hashMapper) { + this.pollTimeout = pollTimeout; this.batchSize = batchSize; this.keySerializer = keySerializer; - this.bodySerializer = bodySerializer; - this.vaueSerializer = valueSerializer; + this.hashKeySerializer = hashKeySerializer; + this.hashValueSerializer = hashValueSerializer; + this.targetType = (Class) targetType; + this.hashMapper = (HashMapper) hashMapper; } /** * @return a new builder for {@link StreamReceiverOptions}. */ - static StreamReceiverOptionsBuilder builder() { + public static StreamReceiverOptionsBuilder> builder() { SerializationPair serializer = SerializationPair.fromSerializer(StringRedisSerializer.UTF_8); return new StreamReceiverOptionsBuilder<>().serializer(serializer); } + /** + * @return a new builder for {@link StreamReceiverOptions}. + */ + @SuppressWarnings("unchecked") + public static StreamReceiverOptionsBuilder> builder( + HashMapper hashMapper) { + + SerializationPair serializer = SerializationPair.fromSerializer(StringRedisSerializer.UTF_8); + SerializationPair raw = SerializationPair.raw(); + return new StreamReceiverOptionsBuilder<>().keySerializer(serializer).hashKeySerializer(raw) + .hashValueSerializer(raw).objectMapper(hashMapper); + } + /** * Timeout for blocking polling using the {@code BLOCK} option during reads. * @@ -224,8 +250,26 @@ public interface StreamReceiver { return keySerializer; } - public SerializationPair getBodySerializer() { - return bodySerializer; + public SerializationPair getHashKeySerializer() { + return hashKeySerializer; + } + + public SerializationPair getHashValueSerializer() { + return hashValueSerializer; + } + + @Nullable + public HashMapper getHashMapper() { + return hashMapper; + } + + public Class getTargetType() { + + if (this.targetType != null) { + return targetType; + } + + return Object.class; } } @@ -234,13 +278,15 @@ public interface StreamReceiver { * * @param Stream key and Stream field type. */ - class StreamReceiverOptionsBuilder { + class StreamReceiverOptionsBuilder> { private Duration pollTimeout = Duration.ofSeconds(2); private int batchSize = 1; private SerializationPair keySerializer; - private SerializationPair bodySerializer; - private SerializationPair valueSerializer; + private SerializationPair hashKeySerializer; + private SerializationPair hashValueSerializer; + private @Nullable HashMapper hashMapper; + private @Nullable Class targetType; private StreamReceiverOptionsBuilder() {} @@ -250,7 +296,7 @@ public interface StreamReceiver { * @param pollTimeout must not be {@literal null} or negative. * @return {@code this} {@link StreamReceiverOptionsBuilder}. */ - public StreamReceiverOptionsBuilder pollTimeout(Duration pollTimeout) { + public StreamReceiverOptionsBuilder pollTimeout(Duration pollTimeout) { Assert.notNull(pollTimeout, "Poll timeout must not be null!"); Assert.isTrue(!pollTimeout.isNegative(), "Poll timeout must not be negative!"); @@ -262,28 +308,48 @@ public interface StreamReceiver { /** * Configure a batch size for the {@code COUNT} option during reading. * - * @param messagesPerPoll must not be greater zero. + * @param recordsPerPoll must not be greater zero. * @return {@code this} {@link StreamReceiverOptionsBuilder}. */ - public StreamReceiverOptionsBuilder batchSize(int messagesPerPoll) { + public StreamReceiverOptionsBuilder batchSize(int recordsPerPoll) { - Assert.isTrue(messagesPerPoll > 0, "Batch size must be greater zero!"); + Assert.isTrue(recordsPerPoll > 0, "Batch size must be greater zero!"); - this.batchSize = messagesPerPoll; + this.batchSize = recordsPerPoll; return this; } /** - * Configure a key and value serializer. + * Configure a key, hash key and hash value serializer. * * @param pair must not be {@literal null}. * @return {@code this} {@link StreamReceiverOptionsBuilder}. */ - public StreamReceiverOptionsBuilder serializer(SerializationPair pair) { + public StreamReceiverOptionsBuilder> serializer(SerializationPair pair) { + + Assert.notNull(pair, "SerializationPair must not be null"); this.keySerializer = (SerializationPair) pair; - this.bodySerializer = (SerializationPair) pair; - this.valueSerializer = (SerializationPair) pair; + this.hashKeySerializer = (SerializationPair) pair; + this.hashValueSerializer = (SerializationPair) pair; + return (StreamReceiverOptionsBuilder) this; + } + + /** + * Configure a key, hash key and hash value serializer. + * + * @param pair must not be {@literal null}. + * @return {@code this} {@link StreamReceiverOptionsBuilder}. + */ + public StreamReceiverOptionsBuilder> serializer( + RedisSerializationContext serializationContext) { + + Assert.notNull(serializationContext, "RedisSerializationContext must not be null"); + + this.keySerializer = (SerializationPair) serializationContext.getKeySerializationPair(); + this.hashKeySerializer = serializationContext.getHashKeySerializationPair(); + this.hashValueSerializer = serializationContext.getHashValueSerializationPair(); + return (StreamReceiverOptionsBuilder) this; } @@ -293,21 +359,80 @@ public interface StreamReceiver { * @param pair must not be {@literal null}. * @return {@code this} {@link StreamReceiverOptionsBuilder}. */ - public StreamReceiverOptionsBuilder keySerializer(SerializationPair pair) { + public > StreamReceiverOptionsBuilder keySerializer( + SerializationPair pair) { + + Assert.notNull(pair, "SerializationPair must not be null"); this.keySerializer = (SerializationPair) pair; return (StreamReceiverOptionsBuilder) this; } /** - * Configure a value serializer. + * Configure a hash key serializer. * * @param pair must not be {@literal null}. * @return {@code this} {@link StreamReceiverOptionsBuilder}. */ - public StreamReceiverOptionsBuilder bodySerializer(SerializationPair pair) { + public StreamReceiverOptionsBuilder> hashKeySerializer( + SerializationPair pair) { - this.bodySerializer = (SerializationPair) pair; + Assert.notNull(pair, "SerializationPair must not be null"); + + this.hashKeySerializer = (SerializationPair) pair; + return (StreamReceiverOptionsBuilder) this; + } + + /** + * Configure a hash value serializer. + * + * @param pair must not be {@literal null}. + * @return {@code this} {@link StreamReceiverOptionsBuilder}. + */ + public StreamReceiverOptionsBuilder> hashValueSerializer( + SerializationPair pair) { + + Assert.notNull(pair, "SerializationPair must not be null"); + + this.hashValueSerializer = (SerializationPair) pair; + return (StreamReceiverOptionsBuilder) this; + } + + /** + * Configure a hash target type. Changes the emitted {@link Record} type to {@link ObjectRecord}. + * + * @param targetType must not be {@literal null}. + * @return {@code this} {@link StreamReceiverOptionsBuilder}. + */ + @SuppressWarnings("unchecked") + public StreamReceiverOptionsBuilder> targetType(Class targetType) { + + Assert.notNull(targetType, "Target type must not be null"); + + this.targetType = targetType; + + if (this.hashMapper == null) { + + hashKeySerializer(SerializationPair.raw()); + hashValueSerializer(SerializationPair.raw()); + return (StreamReceiverOptionsBuilder) objectMapper(new ObjectHashMapper()); + } + + return (StreamReceiverOptionsBuilder) this; + } + + /** + * Configure a hash mapper. Changes the emitted {@link Record} type to {@link ObjectRecord}. + * + * @param hashMapper must not be {@literal null}. + * @return {@code this} {@link StreamReceiverOptionsBuilder}. + */ + @SuppressWarnings("unchecked") + public StreamReceiverOptionsBuilder> objectMapper(HashMapper hashMapper) { + + Assert.notNull(hashMapper, "HashMapper must not be null"); + + this.hashMapper = (HashMapper) hashMapper; return (StreamReceiverOptionsBuilder) this; } @@ -316,8 +441,9 @@ public interface StreamReceiver { * * @return new {@link StreamReceiverOptions}. */ - public StreamReceiverOptions build() { - return new StreamReceiverOptions<>(pollTimeout, batchSize, keySerializer, bodySerializer, valueSerializer); + public StreamReceiverOptions build() { + return new StreamReceiverOptions<>(pollTimeout, batchSize, keySerializer, hashKeySerializer, hashValueSerializer, + targetType, hashMapper); } } } diff --git a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java index a3d3c0169..eb32f85a0 100644 --- a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java @@ -64,11 +64,11 @@ import org.springframework.data.redis.RedisVersionUtils; import org.springframework.data.redis.TestCondition; import org.springframework.data.redis.connection.RedisGeoCommands.GeoLocation; import org.springframework.data.redis.connection.RedisListCommands.Position; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.MapRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamOffset; import org.springframework.data.redis.connection.RedisStringCommands.BitOperation; import org.springframework.data.redis.connection.RedisStringCommands.SetOption; import org.springframework.data.redis.connection.RedisZSetCommands.Aggregate; diff --git a/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionPipelineTests.java b/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionPipelineTests.java index b90261aef..d7cfe1339 100644 --- a/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionPipelineTests.java +++ b/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionPipelineTests.java @@ -26,7 +26,8 @@ import org.junit.Before; import org.junit.Test; import org.springframework.data.geo.Distance; import org.springframework.data.redis.connection.RedisGeoCommands.DistanceUnit; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamRecords; /** * Unit test of {@link DefaultStringRedisConnection} that executes commands in a pipeline diff --git a/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionPipelineTxTests.java b/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionPipelineTxTests.java index 65781fc07..e90dac49e 100644 --- a/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionPipelineTxTests.java +++ b/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionPipelineTxTests.java @@ -27,7 +27,8 @@ import org.junit.Before; import org.junit.Test; import org.springframework.data.geo.Distance; import org.springframework.data.redis.connection.RedisGeoCommands.DistanceUnit; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamRecords; /** * @author Jennifer Hickey diff --git a/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionTests.java b/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionTests.java index ce3093924..ca338539b 100644 --- a/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionTests.java +++ b/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionTests.java @@ -45,11 +45,11 @@ import org.springframework.data.redis.connection.RedisGeoCommands.GeoLocation; import org.springframework.data.redis.connection.RedisGeoCommands.GeoRadiusCommandArgs; import org.springframework.data.redis.connection.RedisListCommands.Position; import org.springframework.data.redis.connection.RedisServerCommands.ShutdownOption; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; import org.springframework.data.redis.connection.RedisStringCommands.BitOperation; import org.springframework.data.redis.connection.RedisZSetCommands.Aggregate; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; @@ -57,6 +57,7 @@ import org.springframework.data.redis.connection.RedisZSetCommands.Tuple; import org.springframework.data.redis.connection.RedisZSetCommands.Weights; import org.springframework.data.redis.connection.StringRedisConnection.StringTuple; import org.springframework.data.redis.connection.convert.Converters; +import org.springframework.data.redis.connection.stream.StreamRecords; import org.springframework.data.redis.serializer.StringRedisSerializer; /** diff --git a/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionTxTests.java b/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionTxTests.java index 1c83b448f..906095660 100644 --- a/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionTxTests.java +++ b/src/test/java/org/springframework/data/redis/connection/DefaultStringRedisConnectionTxTests.java @@ -27,7 +27,8 @@ import org.junit.Ignore; import org.junit.Test; import org.springframework.data.geo.Distance; import org.springframework.data.redis.connection.RedisGeoCommands.DistanceUnit; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamRecords; /** * @author Jennifer Hickey diff --git a/src/test/java/org/springframework/data/redis/connection/StreamRecordsUnitTests.java b/src/test/java/org/springframework/data/redis/connection/StreamRecordsUnitTests.java index 9f3233b6d..e3818d66a 100644 --- a/src/test/java/org/springframework/data/redis/connection/StreamRecordsUnitTests.java +++ b/src/test/java/org/springframework/data/redis/connection/StreamRecordsUnitTests.java @@ -21,11 +21,12 @@ import java.util.Collections; import java.util.Map; import org.junit.Test; -import org.springframework.data.redis.connection.RedisStreamCommands.ByteRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.MapRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ObjectRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.Record; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; +import org.springframework.data.redis.connection.stream.ByteRecord; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.Record; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamRecords; import org.springframework.data.redis.hash.HashMapper; import org.springframework.data.redis.serializer.RedisSerializer; diff --git a/src/test/java/org/springframework/data/redis/connection/lettuce/ApiSpike.java b/src/test/java/org/springframework/data/redis/connection/lettuce/ApiSpike.java deleted file mode 100644 index 8bb65e9d2..000000000 --- a/src/test/java/org/springframework/data/redis/connection/lettuce/ApiSpike.java +++ /dev/null @@ -1,405 +0,0 @@ -package org.springframework.data.redis.connection.lettuce; - -import io.lettuce.core.Limit; -import io.lettuce.core.RedisClient; -import io.lettuce.core.RedisURI; -import io.lettuce.core.StreamMessage; -import io.lettuce.core.api.StatefulRedisConnection; -import lombok.Data; - -import java.nio.charset.StandardCharsets; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.springframework.core.convert.support.DefaultConversionService; -import org.springframework.data.domain.Range; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; -import org.springframework.data.redis.connection.RedisStreamCommands.MapRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ObjectRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.Record; -import org.springframework.data.redis.connection.StreamRecords; -import org.springframework.data.redis.core.convert.RedisCustomConversions; -import org.springframework.data.redis.hash.HashMapper; -import org.springframework.data.redis.hash.Jackson2HashMapper; -import org.springframework.data.redis.hash.ObjectHashMapper; -import org.springframework.data.redis.serializer.RedisSerializer; -import org.springframework.util.ClassUtils; - -/** - * @author Christoph Strobl - * @since 2018/10 - */ -public class ApiSpike { - - RedisClient client; - StatefulRedisConnection connection; - - LettuceConnection lc; - - @Before - public void setUp() { - - client = RedisClient.create(RedisURI.create("localhost", 6379)); - lc = new LettuceConnection(1, client); - } - - @After - public void tearDown() { - - lc.flushAll(); - lc.close(); - client.shutdown(); - } - - @Test - public void all() { - - plainStuff(); - System.out.println("-----------"); - simpleObject(); - System.out.println("-----------"); - writeWithHashReadWithMap(); - System.out.println("-----------"); - } - - @Test - public void plainStuff() { - - RedisStreamCommandsImpl imp = new RedisStreamCommandsImpl(lc); - - StreamOperationsImpl ops = new StreamOperationsImpl<>(imp, RedisSerializer.string(), - RedisSerializer.string(), RedisSerializer.java(), null); - - RecordId id = ops.xAdd("foo", Record.of(Collections.singletonMap("field", "value"))); - List> range = ops.xRange("foo", Range.unbounded()); - - range.forEach(it -> System.out.println(it.getId() + ": " + it.getValue())); - - List> stringRange = ops.xRange("foo", Range.unbounded(), String.class); - stringRange.forEach(System.out::println); - } - - @Test - public void simpleObject() { - - SimpleObject o = new SimpleObject(); - o.field1 = "value-1"; - o.field2 = 10L; - - RedisStreamCommandsImpl imp = new RedisStreamCommandsImpl(lc); - - StreamOperationsImpl ops = new StreamOperationsImpl<>(imp, RedisSerializer.string(), - RedisSerializer.string(), RedisSerializer.java(), new Jackson2HashMapper(false)); - - RecordId id = ops.xAdd("key", o); - List> list = ops.xRange("key", Range.unbounded(), SimpleObject.class); - - list.forEach(System.out::println); - } - - @Test - public void writeWithHashReadWithMap() { - - SimpleObject o = new SimpleObject(); - o.field1 = "value-1"; - o.field2 = 10L; - - RedisStreamCommandsImpl imp = new RedisStreamCommandsImpl(lc); - - StreamOperationsImpl ops = new StreamOperationsImpl<>(imp, RedisSerializer.string(), - RedisSerializer.string(), RedisSerializer.java(), null); - - RecordId id = ops.xAdd("key", o); - - List> list = ops.xRange("key", Range.unbounded()); - - list.forEach(System.out::println); - } - - @Data - static class SimpleObject { - - String field1; - Long field2; - } - - @Test - public void x2() { - - RedisCustomConversions rcc = new RedisCustomConversions(); - DefaultConversionService conversionService = new DefaultConversionService(); - rcc.registerConvertersIn(conversionService); - - } - - interface RedisStreamCommands { - - RecordId xAdd(byte[] key, MapRecord entry); - - List> xRange(byte[] key, Range range); - - } - - static class RedisStreamCommandsImpl implements RedisStreamCommands { - - private LettuceConnection connection; - - public RedisStreamCommandsImpl(LettuceConnection connection) { - this.connection = connection; - } - - @Override - public RecordId xAdd(byte[] key, MapRecord entry) { - return RecordId - .of(connection.getConnection().xadd(key, entry.getValue())); - } - - @Override - public List> xRange(byte[] key, Range range) { - - List> raw = connection.getConnection().xrange(key, - io.lettuce.core.Range.unbounded(), Limit.unlimited()); - return raw.stream() - .map(it -> StreamRecords.rawBytes(it.getBody()) - .withId(RecordId.of(it.getId()))) - .collect(Collectors.toList()); - } - } - - interface StreamOperations { - - default RecordId xAdd(K key, Object value) { - return xAdd(key, Record.of(value)); - } - - default RecordId xAdd(K key, Record value) { - return xAdd(key, objectToEntry(value)); - } - - RecordId xAdd(K key, MapRecord entry); - - List> xRange(K key, Range range); - - default List> xRange(K key, Range range, Class targetType) { - return xRange(key, range).stream().map(it -> entryToObject(it, targetType)).collect(Collectors.toList()); - } - - default MapRecord objectToEntry(V value) { - - if (value instanceof ObjectRecord) { - - ObjectRecord entry = ((ObjectRecord) value); - return Record.of(((HashMapper) getHashMapper(entry.getValue().getClass())).toHash(entry.getValue())) - .withId(entry.getId()); - } - - return Record.of(((HashMapper) getHashMapper(value.getClass())).toHash(value)); - } - - default ObjectRecord entryToObject(MapRecord entry, Class targetType) { - return entry.toObjectRecord(getHashMapper(targetType)); - } - - HashMapper getHashMapper(Class targetType); - } - - /* - * Conversion Rules - * - * 1) Simple types - * serialize: default value serializer (key known byte array of class) - * deserialized: default value serializer - * 2) Complex types - * serialize: HashMapper: check if all entries are binary - then pass on to serializer - * deserialize: deserialize then pass to hashMapper - * - * - * - * - */ - static class StreamOperationsImpl implements StreamOperations { - - private RedisSerializer keySerializer; - private RedisSerializer hashKeySerializer; - private RedisSerializer hashValueSerializer; - private RedisStreamCommands commands; - private final RedisCustomConversions rcc = new RedisCustomConversions(); - private DefaultConversionService conversionService; - - private HashMapper mapper; - - public StreamOperationsImpl(RedisStreamCommands commands, RedisSerializer keySerializer, - RedisSerializer hashKeySerializer, RedisSerializer hashValueSerializer, HashMapper mapper) { - - this.commands = commands; - this.keySerializer = keySerializer; - this.hashKeySerializer = hashKeySerializer; - this.hashValueSerializer = hashValueSerializer; - - this.conversionService = new DefaultConversionService(); - this.mapper = mapper != null ? mapper : (HashMapper) new ObjectHashMapper(); - rcc.registerConvertersIn(conversionService); - } - - @Override - public RecordId xAdd(K key, MapRecord entry) { - return commands.xAdd(serializeKeyIfRequired(key), entry.mapEntries(this::mapToBinary).withStreamKey(serializeKeyIfRequired(key))); - } - - @Override - public List> xRange(K key, Range range) { - - return commands.xRange(serializeKeyIfRequired(key), range).stream().map(it ->it.mapEntries(this::mapToObject).withStreamKey(deserializeKey(it.getStream(), null))) - .collect(Collectors.toList()); - } - - @Override - public HashMapper getHashMapper(Class targetType) { - - if (rcc.isSimpleType(targetType)) { - - return new HashMapper() { - - @Override - public Map toHash(V object) { - return (Map) Collections.singletonMap("payload".getBytes(StandardCharsets.UTF_8), - serializeHashValueIfRequires((HV) object)); - } - - @Override - public V fromHash(Map hash) { - Object value = hash.values().iterator().next(); - if (ClassUtils.isAssignableValue(targetType, value)) { - return (V) value; - } - return (V) deserializeHashValue((byte[]) value, (Class) targetType); - } - }; - } - - if (mapper instanceof ObjectHashMapper) { - - return new HashMapper() { - - @Override - public Map toHash(V object) { - return (Map) ((ObjectHashMapper) mapper).toObjectHash(object); - } - - @Override - public V fromHash(Map hash) { - - Map map = hash.entrySet().stream() - .collect(Collectors.toMap(e -> conversionService.convert((Object) e.getKey(), byte[].class), - e -> conversionService.convert((Object) e.getValue(), byte[].class))); - - return (V) mapper.fromHash((Map) map); - } - }; - - } - - return (HashMapper) mapper; - } - - protected byte[] serializeHashKeyIfRequired(HK key) { - - return hashKeySerializerPresent() ? serialize(key, hashKeySerializer) - : conversionService.convert(key, byte[].class); - } - - protected boolean hashKeySerializerPresent() { - return hashValueSerializer != null; - } - - protected byte[] serializeHashValueIfRequires(HV value) { - return hashValueSerializerPresent() ? serialize(value, hashValueSerializer) - : conversionService.convert(value, byte[].class); - } - - protected boolean hashValueSerializerPresent() { - return hashValueSerializer != null; - } - - protected byte[] serializeKeyIfRequired(K key) { - return keySerializerPresent() ? serialize(key, keySerializer) : conversionService.convert(key, byte[].class); - } - - protected boolean keySerializerPresent() { - return keySerializer != null; - } - - protected K deserializeKey(byte[] bytes, Class targetType) { - return keySerializerPresent() ? keySerializer.deserialize(bytes) : conversionService.convert(bytes, targetType); - } - - protected HK deserializeHashKey(byte[] bytes, Class targetType) { - - return hashKeySerializerPresent() ? (HK) hashKeySerializer.deserialize(bytes) - : conversionService.convert(bytes, targetType); - } - - protected HV deserializeHashValue(byte[] bytes, Class targetType) { - return hashValueSerializerPresent() ? (HV) hashValueSerializer.deserialize(bytes) - : conversionService.convert(bytes, targetType); - } - - byte[] serialize(Object value, RedisSerializer serializer) { - - Object _value = value; - if (!serializer.canSerialize(value.getClass())) { - _value = conversionService.convert(value, serializer.getTargetType()); - } - return serializer.serialize(_value); - } - - private Map.Entry mapToBinary(Map.Entry it) { - - return new Map.Entry() { - - @Override - public byte[] getKey() { - return serializeHashKeyIfRequired(it.getKey()); - } - - @Override - public byte[] getValue() { - return serializeHashValueIfRequires(it.getValue()); - } - - @Override - public byte[] setValue(byte[] value) { - return new byte[0]; - } - }; - } - - private Map.Entry mapToObject(Map.Entry pair) { - - return new Map.Entry() { - - @Override - public HK getKey() { - return deserializeHashKey(pair.getKey(), (Class) Object.class); - } - - @Override - public HV getValue() { - return deserializeHashValue(pair.getValue(), (Class) Object.class); - } - - @Override - public HV setValue(HV value) { - return value; - } - - }; - } - } - -} diff --git a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommandsTests.java b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommandsTests.java index c69a3a787..30ea5a1f1 100644 --- a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommandsTests.java +++ b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommandsTests.java @@ -20,7 +20,7 @@ import static org.junit.Assume.*; import io.lettuce.core.XReadArgs; import org.junit.Ignore; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; +import org.springframework.data.redis.connection.stream.RecordId; import reactor.test.StepVerifier; import java.util.Collections; @@ -29,9 +29,9 @@ import org.junit.Before; import org.junit.Test; import org.springframework.data.domain.Range; import org.springframework.data.redis.RedisTestProfileValueSource; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.StreamOffset; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; /** diff --git a/src/test/java/org/springframework/data/redis/core/DefaultReactiveStreamOperationsTests.java b/src/test/java/org/springframework/data/redis/core/DefaultReactiveStreamOperationsTests.java index da5085c59..7b0a4b844 100644 --- a/src/test/java/org/springframework/data/redis/core/DefaultReactiveStreamOperationsTests.java +++ b/src/test/java/org/springframework/data/redis/core/DefaultReactiveStreamOperationsTests.java @@ -33,23 +33,27 @@ import org.springframework.data.domain.Range; import org.springframework.data.domain.Range.Bound; import org.springframework.data.redis.ConnectionFactoryTracker; import org.springframework.data.redis.ObjectFactory; +import org.springframework.data.redis.Person; import org.springframework.data.redis.PersonObjectFactory; import org.springframework.data.redis.RedisTestProfileValueSource; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.RedisStreamCommands.MapRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; -import org.springframework.data.redis.connection.StreamRecords; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; +import org.springframework.data.redis.connection.stream.StreamRecords; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; +import org.springframework.data.redis.serializer.OxmSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair; import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; /** * Integration tests for {@link DefaultReactiveStreamOperations}. @@ -93,8 +97,7 @@ public class DefaultReactiveStreamOperationsTests { // See https://github.com/xetorthio/jedis/issues/1820 assumeTrue(redisTemplate.getConnectionFactory() instanceof LettuceConnectionFactory); - // TODO: Change to 5.0 after Redis 5 GA - assumeTrue(RedisTestProfileValueSource.matches("redisVersion", "4.9")); + assumeTrue(RedisTestProfileValueSource.matches("redisVersion", "5.0")); RedisSerializationContext context = null; if (serializer != null) { @@ -150,14 +153,15 @@ public class DefaultReactiveStreamOperationsTests { public void addShouldAddReadSimpleMessage() { assumeTrue(!(serializer instanceof Jackson2JsonRedisSerializer) - && !(serializer instanceof GenericJackson2JsonRedisSerializer)); + && !(serializer instanceof GenericJackson2JsonRedisSerializer) + && !(serializer instanceof JdkSerializationRedisSerializer) && !(serializer instanceof OxmSerializer)); K key = keyFactory.instance(); HV value = valueFactory.instance(); RecordId messageId = streamOperations.add(StreamRecords.objectBacked(value).withStreamKey(key)).block(); - streamOperations.range(key, Range.unbounded(), (Class) value.getClass()).as(StepVerifier::create) // + streamOperations.range((Class) value.getClass(), key, Range.unbounded()).as(StepVerifier::create) // .consumeNextWith(it -> { assertThat(it.getId()).isEqualTo(messageId); assertThat(it.getStream()).isEqualTo(key); @@ -168,6 +172,36 @@ public class DefaultReactiveStreamOperationsTests { .verifyComplete(); } + @Test // DATAREDIS-864 + public void addShouldAddReadSimpleMessageWithRawSerializer() { + + assumeTrue(!(serializer instanceof Jackson2JsonRedisSerializer) + && !(serializer instanceof GenericJackson2JsonRedisSerializer)); + + SerializationPair keySerializer = redisTemplate.getSerializationContext().getKeySerializationPair(); + + RedisSerializationContext serializationContext = RedisSerializationContext + . newSerializationContext(StringRedisSerializer.UTF_8).key(keySerializer) + .hashValue(SerializationPair.raw()).hashKey(SerializationPair.raw()).build(); + + ReactiveRedisTemplate raw = new ReactiveRedisTemplate<>(redisTemplate.getConnectionFactory(), + serializationContext); + + K key = keyFactory.instance(); + Person value = new PersonObjectFactory().instance(); + + RecordId messageId = raw.opsForStream().add(StreamRecords.objectBacked(value).withStreamKey(key)).block(); + + raw.opsForStream().range((Class) value.getClass(), key, Range.unbounded()).as(StepVerifier::create) // + .consumeNextWith(it -> { + + assertThat(it.getId()).isEqualTo(messageId); + assertThat(it.getStream()).isEqualTo(key); + assertThat(it.getValue()).isEqualTo(value); + }) // + .verifyComplete(); + } + @Test // DATAREDIS-864 public void rangeShouldReportMessages() { diff --git a/src/test/java/org/springframework/data/redis/core/DefaultStreamOperationsTests.java b/src/test/java/org/springframework/data/redis/core/DefaultStreamOperationsTests.java index b4c9eb7b6..da82d2240 100644 --- a/src/test/java/org/springframework/data/redis/core/DefaultStreamOperationsTests.java +++ b/src/test/java/org/springframework/data/redis/core/DefaultStreamOperationsTests.java @@ -35,21 +35,22 @@ import org.springframework.data.redis.ConnectionFactoryTracker; import org.springframework.data.redis.ObjectFactory; import org.springframework.data.redis.Person; import org.springframework.data.redis.RedisTestProfileValueSource; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; -import org.springframework.data.redis.connection.RedisStreamCommands.MapRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ObjectRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.connection.stream.StreamReadOptions; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; -import org.springframework.data.redis.connection.StreamRecords; +import org.springframework.data.redis.connection.stream.StreamRecords; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; /** * Integration test of {@link DefaultStreamOperations} * * @author Mark Paluch + * @author Christoph Strobl */ @RunWith(Parameterized.class) public class DefaultStreamOperationsTests { @@ -71,7 +72,6 @@ public class DefaultStreamOperationsTests { // See https://github.com/xetorthio/jedis/issues/1820 assumeTrue(redisTemplate.getConnectionFactory() instanceof LettuceConnectionFactory); - // TODO: Change to 5.0 after Redis 5 GA assumeTrue(RedisTestProfileValueSource.matches("redisVersion", "5.0")); this.redisTemplate = redisTemplate; @@ -133,7 +133,7 @@ public class DefaultStreamOperationsTests { RecordId messageId = streamOps.add(StreamRecords.objectBacked(value).withStreamKey(key)); - List> messages = streamOps.range(key, Range.unbounded(), (Class) value.getClass()); + List> messages = streamOps.range((Class) value.getClass(), key, Range.unbounded()); assertThat(messages).hasSize(1); @@ -212,7 +212,7 @@ public class DefaultStreamOperationsTests { RecordId messageId1 = streamOps.add(StreamRecords.objectBacked(value).withStreamKey(key)); RecordId messageId2 = streamOps.add(StreamRecords.objectBacked(value).withStreamKey(key)); - List> messages = streamOps.reverseRange(key, Range.unbounded(), (Class) value.getClass()); + List> messages = streamOps.reverseRange((Class) value.getClass(), key, Range.unbounded()); assertThat(messages).hasSize(2).extracting("id").containsSequence(messageId2, messageId1); @@ -254,7 +254,7 @@ public class DefaultStreamOperationsTests { HV value = hashValueFactory.instance(); RecordId messageId1 = streamOps.add(StreamRecords.objectBacked(value).withStreamKey(key)); - RecordId messageId2 = streamOps.add(StreamRecords.objectBacked(value).withStreamKey(key)); + streamOps.add(StreamRecords.objectBacked(value).withStreamKey(key)); List> messages = streamOps.read((Class) value.getClass(), StreamOffset.create(key, ReadOffset.from("0-0"))); diff --git a/src/test/java/org/springframework/data/redis/stream/ReadOffsetStrategyUnitTests.java b/src/test/java/org/springframework/data/redis/stream/ReadOffsetStrategyUnitTests.java index 3f724a324..6f232df15 100644 --- a/src/test/java/org/springframework/data/redis/stream/ReadOffsetStrategyUnitTests.java +++ b/src/test/java/org/springframework/data/redis/stream/ReadOffsetStrategyUnitTests.java @@ -20,8 +20,8 @@ import static org.assertj.core.api.Assertions.*; import java.util.Optional; import org.junit.Test; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.ReadOffset; /** * Unit tests for {@link ReadOffsetStrategy}. diff --git a/src/test/java/org/springframework/data/redis/stream/StreamMessageListenerContainerIntegrationTests.java b/src/test/java/org/springframework/data/redis/stream/StreamMessageListenerContainerIntegrationTests.java index 2885901b8..6155c5c2c 100644 --- a/src/test/java/org/springframework/data/redis/stream/StreamMessageListenerContainerIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/stream/StreamMessageListenerContainerIntegrationTests.java @@ -18,9 +18,11 @@ package org.springframework.data.redis.stream; import static org.assertj.core.api.Assertions.*; import static org.junit.Assume.*; +import lombok.AllArgsConstructor; +import lombok.Data; + import java.time.Duration; import java.util.Collections; -import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; @@ -35,14 +37,16 @@ import org.springframework.data.redis.SettingsUtils; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.RecordId; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.Record; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceTestClientResources; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.Record; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamOffset; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.stream.StreamMessageListenerContainer.StreamMessageListenerContainerOptions; import org.springframework.data.redis.stream.StreamMessageListenerContainer.StreamReadRequest; @@ -59,8 +63,8 @@ public class StreamMessageListenerContainerIntegrationTests { private static RedisConnectionFactory connectionFactory; - StringRedisTemplate redisTemplate = new StringRedisTemplate(connectionFactory); - StreamMessageListenerContainerOptions> containerOptions = StreamMessageListenerContainerOptions + private StringRedisTemplate redisTemplate = new StringRedisTemplate(connectionFactory); + private StreamMessageListenerContainerOptions> containerOptions = StreamMessageListenerContainerOptions .builder().pollTimeout(Duration.ofMillis(100)).build(); @BeforeClass @@ -79,8 +83,7 @@ public class StreamMessageListenerContainerIntegrationTests { connectionFactory = lettuceConnectionFactory; - // TODO: Upgrade to 5.0 - assumeTrue(RedisVersionUtils.atLeast("4.9", connectionFactory.getConnection())); + assumeTrue(RedisVersionUtils.atLeast("5.0", connectionFactory.getConnection())); } @AfterClass @@ -97,11 +100,12 @@ public class StreamMessageListenerContainerIntegrationTests { } @Test // DATAREDIS-864 - public void shouldReceiveMessages() throws InterruptedException { + public void shouldReceiveMapMessages() throws InterruptedException { - StreamMessageListenerContainer> container = StreamMessageListenerContainer.create(connectionFactory, + StreamMessageListenerContainer> container = StreamMessageListenerContainer + .create(connectionFactory, containerOptions); - BlockingQueue>> queue = new LinkedBlockingQueue<>(); + BlockingQueue> queue = new LinkedBlockingQueue<>(); container.start(); Subscription subscription = container.receive(StreamOffset.create("my-stream", ReadOffset.from("0-0")), queue::add); @@ -121,12 +125,62 @@ public class StreamMessageListenerContainerIntegrationTests { assertThat(subscription.isActive()).isFalse(); } + @Test // DATAREDIS-864 + public void shouldReceiveSimpleObjectHashRecords() throws InterruptedException { + + StreamMessageListenerContainerOptions> containerOptions = StreamMessageListenerContainerOptions + .builder().pollTimeout(Duration.ofMillis(100)).targetType(String.class).build(); + + StreamMessageListenerContainer> container = StreamMessageListenerContainer + .create(connectionFactory, containerOptions); + BlockingQueue> queue = new LinkedBlockingQueue<>(); + + container.start(); + Subscription subscription = container.receive(StreamOffset.create("my-stream", ReadOffset.from("0-0")), queue::add); + + subscription.await(Duration.ofSeconds(2)); + + redisTemplate.opsForStream().add(ObjectRecord.create("my-stream", "value1")); + + assertThat(queue.poll(1, TimeUnit.SECONDS)).isNotNull().extracting(Record::getValue).isEqualTo("value1"); + + cancelAwait(subscription); + + assertThat(subscription.isActive()).isFalse(); + } + + @Test // DATAREDIS-864 + public void shouldReceiveObjectHashRecords() throws InterruptedException { + + StreamMessageListenerContainerOptions> containerOptions = StreamMessageListenerContainerOptions + .builder().pollTimeout(Duration.ofMillis(100)).targetType(LoginEvent.class).build(); + + StreamMessageListenerContainer> container = StreamMessageListenerContainer + .create(connectionFactory, containerOptions); + BlockingQueue> queue = new LinkedBlockingQueue<>(); + + container.start(); + Subscription subscription = container.receive(StreamOffset.create("my-stream", ReadOffset.from("0-0")), queue::add); + + subscription.await(Duration.ofSeconds(2)); + + redisTemplate.opsForStream().add(ObjectRecord.create("my-stream", new LoginEvent("Walter", "White"))); + + assertThat(queue.poll(1, TimeUnit.SECONDS)).isNotNull().extracting(Record::getValue) + .isEqualTo(new LoginEvent("Walter", "White")); + + cancelAwait(subscription); + + assertThat(subscription.isActive()).isFalse(); + } + @Test // DATAREDIS-864 public void shouldReceiveMessagesInConsumerGroup() throws InterruptedException { - StreamMessageListenerContainer> container = StreamMessageListenerContainer.create(connectionFactory, + StreamMessageListenerContainer> container = StreamMessageListenerContainer + .create(connectionFactory, containerOptions); - BlockingQueue>> queue = new LinkedBlockingQueue<>(); + BlockingQueue> queue = new LinkedBlockingQueue<>(); RecordId messageId = redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value1")); redisTemplate.opsForStream().createGroup("my-stream", ReadOffset.from(messageId), "my-group"); @@ -138,7 +192,7 @@ public class StreamMessageListenerContainerIntegrationTests { redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value2")); - Record> message = queue.poll(1, TimeUnit.SECONDS); + MapRecord message = queue.poll(1, TimeUnit.SECONDS); assertThat(message).isNotNull(); assertThat(message.getValue()).containsEntry("key", "value2"); @@ -150,9 +204,10 @@ public class StreamMessageListenerContainerIntegrationTests { BlockingQueue failures = new LinkedBlockingQueue<>(); - StreamMessageListenerContainerOptions> containerOptions = StreamMessageListenerContainerOptions + StreamMessageListenerContainerOptions> containerOptions = StreamMessageListenerContainerOptions .builder().errorHandler(failures::add).pollTimeout(Duration.ofMillis(100)).build(); - StreamMessageListenerContainer> container = StreamMessageListenerContainer.create(connectionFactory, + StreamMessageListenerContainer> container = StreamMessageListenerContainer + .create(connectionFactory, containerOptions); container.start(); @@ -173,7 +228,8 @@ public class StreamMessageListenerContainerIntegrationTests { BlockingQueue failures = new LinkedBlockingQueue<>(); - StreamMessageListenerContainer> container = StreamMessageListenerContainer.create(connectionFactory, + StreamMessageListenerContainer> container = StreamMessageListenerContainer + .create(connectionFactory, containerOptions); StreamReadRequest readRequest = StreamReadRequest @@ -203,7 +259,8 @@ public class StreamMessageListenerContainerIntegrationTests { BlockingQueue failures = new LinkedBlockingQueue<>(); - StreamMessageListenerContainer> container = StreamMessageListenerContainer.create(connectionFactory, + StreamMessageListenerContainer> container = StreamMessageListenerContainer + .create(connectionFactory, containerOptions); StreamReadRequest readRequest = StreamReadRequest @@ -233,9 +290,10 @@ public class StreamMessageListenerContainerIntegrationTests { @Test // DATAREDIS-864 public void cancelledStreamShouldNotReceiveMessages() throws InterruptedException { - StreamMessageListenerContainer> container = StreamMessageListenerContainer.create(connectionFactory, + StreamMessageListenerContainer> container = StreamMessageListenerContainer + .create(connectionFactory, containerOptions); - BlockingQueue>> queue = new LinkedBlockingQueue<>(); + BlockingQueue> queue = new LinkedBlockingQueue<>(); container.start(); Subscription subscription = container.receive(StreamOffset.create("my-stream", ReadOffset.from("0-0")), queue::add); @@ -251,9 +309,10 @@ public class StreamMessageListenerContainerIntegrationTests { @Test // DATAREDIS-864 public void containerRestartShouldRestartSubscription() throws InterruptedException { - StreamMessageListenerContainer> container = StreamMessageListenerContainer.create(connectionFactory, + StreamMessageListenerContainer> container = StreamMessageListenerContainer + .create(connectionFactory, containerOptions); - BlockingQueue>> queue = new LinkedBlockingQueue<>(); + BlockingQueue> queue = new LinkedBlockingQueue<>(); container.start(); Subscription subscription = container.receive(StreamOffset.create("my-stream", ReadOffset.from("0-0")), queue::add); @@ -285,4 +344,10 @@ public class StreamMessageListenerContainerIntegrationTests { Thread.sleep(10); } } + + @Data + @AllArgsConstructor + static class LoginEvent { + String firstname, lastname; + } } diff --git a/src/test/java/org/springframework/data/redis/stream/StreamReceiverIntegrationTests.java b/src/test/java/org/springframework/data/redis/stream/StreamReceiverIntegrationTests.java index b8a27324a..8294d7f40 100644 --- a/src/test/java/org/springframework/data/redis/stream/StreamReceiverIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/stream/StreamReceiverIntegrationTests.java @@ -18,6 +18,8 @@ package org.springframework.data.redis.stream; import static org.assertj.core.api.Assertions.*; import static org.junit.Assume.*; +import lombok.AllArgsConstructor; +import lombok.Data; import reactor.core.publisher.Flux; import reactor.test.StepVerifier; @@ -34,14 +36,19 @@ import org.springframework.data.redis.RedisVersionUtils; import org.springframework.data.redis.SettingsUtils; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; -import org.springframework.data.redis.connection.RedisStreamCommands.Consumer; -import org.springframework.data.redis.connection.RedisStreamCommands.MapRecord; -import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset; -import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset; import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceTestClientResources; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.core.ReactiveRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair; +import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.data.redis.stream.StreamReceiver.StreamReceiverOptions; /** @@ -55,7 +62,8 @@ public class StreamReceiverIntegrationTests { SettingsUtils.getHost(), SettingsUtils.getPort()); private static LettuceConnectionFactory connectionFactory; - StringRedisTemplate redisTemplate = new StringRedisTemplate(connectionFactory); + private StringRedisTemplate redisTemplate = new StringRedisTemplate(connectionFactory); + private static ReactiveRedisTemplate reactiveRedisTemplate; @BeforeClass public static void beforeClass() { @@ -73,8 +81,13 @@ public class StreamReceiverIntegrationTests { connectionFactory = lettuceConnectionFactory; - // TODO: Upgrade to 5.0 assumeTrue(RedisVersionUtils.atLeast("5.0", connectionFactory.getConnection())); + + RedisSerializationContext serializationContext = RedisSerializationContext + . newSerializationContext(StringRedisSerializer.UTF_8).hashKey(SerializationPair.raw()) + .hashValue(SerializationPair.raw()).build(); + + reactiveRedisTemplate = new ReactiveRedisTemplate<>(connectionFactory, serializationContext); } @AfterClass @@ -91,19 +104,65 @@ public class StreamReceiverIntegrationTests { } @Test // DATAREDIS-864 - public void shouldReceiveMessages() { + public void shouldReceiveMapRecords() { - StreamReceiver receiver = StreamReceiver.create(connectionFactory); + StreamReceiver> receiver = StreamReceiver.create(connectionFactory); Flux> messages = receiver .receive(StreamOffset.create("my-stream", ReadOffset.from("0-0"))); messages.as(StepVerifier::create) // - .then(() -> redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value"))) + .then(() -> reactiveRedisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value")) + .subscribe()) .consumeNextWith(it -> { assertThat(it.getStream()).isEqualTo("my-stream"); - // assertThat(it.getValue()).containsEntry("key", "value"); + assertThat(it.getValue()).containsEntry("key", "value"); + }) // + .thenCancel() // + .verify(Duration.ofSeconds(5)); + } + + @Test // DATAREDIS-864 + public void shouldReceiveSimpleObjectHashRecords() { + + StreamReceiverOptions> receiverOptions = StreamReceiverOptions.builder() + .targetType(String.class).build(); + + StreamReceiver> receiver = StreamReceiver.create(connectionFactory, + receiverOptions); + + Flux> messages = receiver.receive(StreamOffset.fromStart("my-stream")); + + messages.as(StepVerifier::create) // + .then(() -> reactiveRedisTemplate.opsForStream().add(ObjectRecord.create("my-stream", "foobar")).subscribe()) + .consumeNextWith(it -> { + + assertThat(it.getStream()).isEqualTo("my-stream"); + assertThat(it.getValue()).isEqualTo("foobar"); + }) // + .thenCancel() // + .verify(Duration.ofSeconds(5)); + } + + @Test // DATAREDIS-864 + public void shouldReceiveObjectHashRecords() { + + StreamReceiverOptions> receiverOptions = StreamReceiverOptions.builder() + .targetType(LoginEvent.class).build(); + + StreamReceiver> receiver = StreamReceiver.create(connectionFactory, + receiverOptions); + + Flux> messages = receiver.receive(StreamOffset.fromStart("my-logins")); + + messages.as(StepVerifier::create) // + .then(() -> reactiveRedisTemplate.opsForStream() + .add(ObjectRecord.create("my-logins", new LoginEvent("Walter", "White"))).subscribe()) + .consumeNextWith(it -> { + + assertThat(it.getStream()).isEqualTo("my-logins"); + assertThat(it.getValue()).isEqualTo(new LoginEvent("Walter", "White")); }) // .thenCancel() // .verify(Duration.ofSeconds(5)); @@ -114,9 +173,10 @@ public class StreamReceiverIntegrationTests { // XADD/XREAD highly timing-dependent as this tests require a poll subscription to receive messages using $ offset. - StreamReceiverOptions options = StreamReceiverOptions.builder() + StreamReceiverOptions> options = StreamReceiverOptions.builder() .pollTimeout(Duration.ofSeconds(4)).build(); - StreamReceiver receiver = StreamReceiver.create(connectionFactory, options); + StreamReceiver> receiver = StreamReceiver.create(connectionFactory, + options); Flux> messages = receiver .receive(StreamOffset.create("my-stream", ReadOffset.latest())); @@ -126,18 +186,20 @@ public class StreamReceiverIntegrationTests { .then(() -> { try { Thread.sleep(500); - redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value1")); + reactiveRedisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value1")) + .subscribe(); } catch (InterruptedException e) {} }) // .expectNextCount(1) // .then(() -> { - redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value2")); + reactiveRedisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value2")).subscribe(); }) // .thenRequest(1) // .then(() -> { try { Thread.sleep(500); - redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value3")); + reactiveRedisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value3")) + .subscribe(); } catch (InterruptedException e) {} }).consumeNextWith(it -> { @@ -151,7 +213,7 @@ public class StreamReceiverIntegrationTests { @Test // DATAREDIS-864 public void shouldReceiveAsConsumerGroupMessages() { - StreamReceiver receiver = StreamReceiver.create(connectionFactory); + StreamReceiver> receiver = StreamReceiver.create(connectionFactory); Flux> messages = receiver.receive(Consumer.from("my-group", "my-consumer-id"), StreamOffset.create("my-stream", ReadOffset.lastConsumed())); @@ -180,10 +242,11 @@ public class StreamReceiverIntegrationTests { @Test // DATAREDIS-864 public void shouldStopReceivingOnError() { - StreamReceiverOptions options = StreamReceiverOptions.builder() + StreamReceiverOptions> options = StreamReceiverOptions.builder() .pollTimeout(Duration.ofMillis(100)).build(); - StreamReceiver receiver = StreamReceiver.create(connectionFactory, options); + StreamReceiver> receiver = StreamReceiver.create(connectionFactory, + options); Flux> messages = receiver.receive(Consumer.from("my-group", "my-consumer-id"), StreamOffset.create("my-stream", ReadOffset.lastConsumed())); @@ -194,8 +257,14 @@ public class StreamReceiverIntegrationTests { messages.as(StepVerifier::create) // .expectNextCount(1) // - .then(() -> redisTemplate.delete("my-stream")) // + .then(() -> reactiveRedisTemplate.delete("my-stream").subscribe()) // .expectError(RedisSystemException.class) // .verify(Duration.ofSeconds(5)); } + + @Data + @AllArgsConstructor + static class LoginEvent { + String firstname, lastname; + } }