Skip to content

Commit a752acc

Browse files
authored
add generic typed message (#287)
* add generic typed message * update doc * fix: failing test
1 parent b284835 commit a752acc

File tree

6 files changed

+207
-26
lines changed

6 files changed

+207
-26
lines changed

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ ext {
8383

8484
subprojects {
8585
group = "com.github.sonus21"
86-
version = "4.0.0-RELEASE"
86+
version = "4.0.0-RC1"
8787

8888
dependencies {
8989
// https://mvnrepository.com/artifact/org.springframework/spring-messaging

docs/CHANGELOG.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,36 @@ layout: default
88

99
All notable user-facing changes to this project are documented in this file.
1010

11+
## Release [4.0.0.RC1] 18-Mar-2026
12+
13+
{: .highlight}
14+
This is a release candidate for 4.0.0. It targets Spring Boot 4.x and Spring Framework 7.x.
15+
Please test thoroughly before using in production.
16+
17+
### Features
18+
* **Spring Boot 4.x support** — compatible with Spring Boot 4.0.1 and above.
19+
* **Spring Framework 7.x support** — built against Spring Framework 7.0.3, taking
20+
advantage of the updated messaging and context APIs.
21+
* **Java 21 baseline** — Java 21 is now the minimum supported runtime.
22+
* **Jackson 3.x support** — updated serialization layer to use Jackson 3.x
23+
(`tools.jackson` packages).
24+
* **Lettuce 7.x support** — Redis client updated to Lettuce 7.2.x.
25+
* `GenericMessageConverter` now supports generic envelope types such as `Event<T>`.
26+
The type parameter is resolved from the runtime class of the corresponding field
27+
value, enabling transparent round-trip serialization without requiring a custom
28+
message converter.
29+
30+
### Migration Notes
31+
* Requires Java 21+.
32+
* Requires Spring Boot 4.x / Spring Framework 7.x. Not backward compatible with
33+
Spring Boot 3.x — use the 3.x release line for older Spring Boot versions.
34+
* Jackson package namespace changed from `com.fasterxml.jackson` to `tools.jackson`
35+
in Jackson 3.x. Update any custom `ObjectMapper` configuration accordingly.
36+
1137
## Release [3.4.0] 22-July-2025
1238
### Fixes
13-
* Improved unique message enqueuing to reject duplicates upfront rather than during
14-
processing. #259
39+
* Improved unique message enqueuing to reject duplicates upfront rather than during
40+
processing. #259
1541

1642

1743
## Release [3.3.0] 29-June-2025

docs/configuration/configuration.md

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -172,36 +172,76 @@ public class BootstrapController {
172172

173173
## Message Converter Configuration
174174

175-
To customize message conversion, set the property
176-
`rqueue.message.converter.provider.class` to the fully qualified name of your provider
177-
class. This class must implement the `MessageConverterProvider` interface and return
175+
To customize message conversion, set the property
176+
`rqueue.message.converter.provider.class` to the fully qualified name of your provider
177+
class. This class must implement the `MessageConverterProvider` interface and return
178178
a Spring `MessageConverter`.
179179

180180
{: .note}
181-
Your custom provider must implement
181+
Your custom provider must implement
182182
`com.github.sonus21.rqueue.converter.MessageConverterProvider`.
183183

184184
```java
185185
class MyMessageConverterProvider implements MessageConverterProvider {
186186

187187
@Override
188188
public MessageConverter getConverter() {
189-
// here any message converter can be returned except null
189+
// here any message converter can be returned except null
190190
return new MyMessageConverter();
191191
}
192192
}
193193
```
194194

195-
The `DefaultRqueueMessageConverter` handles serialization for most use cases, but it
196-
may fail if classes are not shared between producing and consuming applications. To
197-
avoid shared dependencies, consider using JSON-based converters like
198-
`com.github.sonus21.rqueue.converter.JsonMessageConverter` or Spring's
199-
`JacksonJsonMessageConverter`. These serialize payloads into JSON, improving
195+
The `DefaultRqueueMessageConverter` handles serialization for most use cases, but it
196+
may fail if classes are not shared between producing and consuming applications. To
197+
avoid shared dependencies, consider using JSON-based converters like
198+
`com.github.sonus21.rqueue.converter.JsonMessageConverter` or Spring's
199+
`JacksonJsonMessageConverter`. These serialize payloads into JSON, improving
200200
interoperability.
201201

202-
Other serialization formats like MessagePack or Protocol Buffers (ProtoBuf) can also
202+
Other serialization formats like MessagePack or Protocol Buffers (ProtoBuf) can also
203203
be implemented based on your requirements.
204204

205+
### Generic Envelope Types
206+
207+
`GenericMessageConverter` (used by the default converter) supports **single-level
208+
generic envelope types** such as `Event<T>`. The type parameter is resolved at
209+
serialization time by inspecting the runtime class of the field value that corresponds
210+
to `T`.
211+
212+
```java
213+
// A generic envelope type
214+
public class Event<T> {
215+
private String id;
216+
private T payload;
217+
// getters/setters ...
218+
}
219+
220+
// Enqueue
221+
Event<Order> event = new Event<>("evt-123", order);
222+
rqueueMessageEnqueuer.enqueue("order-queue", event);
223+
224+
// Consume
225+
@RqueueListener(value = "order-queue")
226+
public void onEvent(Event<Order> event) { ... }
227+
```
228+
229+
The serialized form encodes both the envelope class and the type parameter:
230+
231+
```
232+
{"msg":"...","name":"com.example.Event#com.example.Order"}
233+
```
234+
235+
**Constraints:**
236+
237+
- The type parameter `T` must be a **non-generic** concrete class (e.g. `Order`, not
238+
`List<Order>`).
239+
- At least one field of type `T` on the envelope class must be **non-null** at
240+
serialization time, so the runtime type can be determined.
241+
- For `List<T>`, items must also be non-generic concrete classes. Envelopes like
242+
`List<Event<Order>>` are not supported.
243+
- Multi-level nesting (e.g. `Wrapper<Event<T>>`) is not supported.
244+
205245
## Additional Configuration
206246

207247
- **`rqueue.retry.per.poll`**: Determines how many times a polled message is retried

rqueue-core/src/main/java/com/github/sonus21/rqueue/converter/GenericMessageConverter.java

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import static org.springframework.util.Assert.notNull;
2020

2121
import com.github.sonus21.rqueue.utils.SerializationUtils;
22+
import java.lang.reflect.Field;
2223
import java.lang.reflect.TypeVariable;
2324
import java.util.Collection;
2425
import java.util.List;
@@ -37,8 +38,8 @@
3738

3839
/**
3940
* A converter to turn the payload of a {@link Message} from serialized form to a typed String and
40-
* vice versa. This class does not support generic class except {@link List},even for list the
41-
* entries should be non generic.
41+
* vice versa. Supports {@link List} and single-level generic envelope types (e.g. {@code Event<T>})
42+
* where type parameters are non-generic and can be resolved from non-null field values.
4243
*/
4344
@Slf4j
4445
public class GenericMessageConverter implements SmartMessageConverter {
@@ -137,7 +138,12 @@ private String getClassNameForCollection(String name, Collection<?> payload) {
137138
if (payload.isEmpty()) {
138139
return null;
139140
}
140-
String itemClassName = getClassName(((List<?>) payload).get(0));
141+
Object firstItem = ((List<?>) payload).get(0);
142+
// Only support non-generic item classes in lists to avoid ambiguous encoding
143+
if (firstItem.getClass().getTypeParameters().length > 0) {
144+
return null;
145+
}
146+
String itemClassName = getClassName(firstItem);
141147
if (itemClassName == null) {
142148
return null;
143149
}
@@ -146,12 +152,40 @@ private String getClassNameForCollection(String name, Collection<?> payload) {
146152
return null;
147153
}
148154

149-
private String getGenericFieldBasedClassName(Class<?> clazz) {
155+
private Class<?> resolveTypeVariable(Class<?> clazz, TypeVariable<?> tv, Object payload) {
156+
// TypeVariable instances are scoped to the class that declares them, so
157+
// field.getGenericType().equals(tv) can only match fields declared on clazz itself.
158+
// Superclass fields reference their own TypeVariable instances, which are distinct objects.
159+
for (Field field : clazz.getDeclaredFields()) {
160+
if (field.getGenericType().equals(tv)) {
161+
field.setAccessible(true);
162+
try {
163+
Object value = field.get(payload);
164+
if (value != null) {
165+
return value.getClass();
166+
}
167+
} catch (IllegalAccessException e) {
168+
log.debug("Cannot access field {}", field.getName(), e);
169+
}
170+
}
171+
}
172+
return null;
173+
}
174+
175+
private String getGenericFieldBasedClassName(Class<?> clazz, Object payload) {
150176
TypeVariable<?>[] typeVariables = clazz.getTypeParameters();
151177
if (typeVariables.length == 0) {
152178
return clazz.getName();
153179
}
154-
return null;
180+
StringBuilder sb = new StringBuilder(clazz.getName());
181+
for (TypeVariable<?> tv : typeVariables) {
182+
Class<?> resolved = resolveTypeVariable(clazz, tv, payload);
183+
if (resolved == null || resolved.getTypeParameters().length > 0) {
184+
return null;
185+
}
186+
sb.append('#').append(resolved.getName());
187+
}
188+
return sb.toString();
155189
}
156190

157191
private String getClassName(Object payload) {
@@ -160,7 +194,7 @@ private String getClassName(Object payload) {
160194
if (payload instanceof Collection) {
161195
return getClassNameForCollection(name, (Collection<?>) payload);
162196
}
163-
return getGenericFieldBasedClassName(payloadClass);
197+
return getGenericFieldBasedClassName(payloadClass, payload);
164198
}
165199

166200
private JavaType getTargetType(Msg msg) throws ClassNotFoundException {

rqueue-core/src/test/java/com/github/sonus21/rqueue/converter/GenericMessageConverterTest.java

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,44 @@ void toAndFromMessageList() {
116116
}
117117

118118
@Test
119-
void genericMessageToReturnNull() {
119+
void envelopeEventToAndFromMessage() {
120+
Event<Comment> event = new Event<>("evt-1", comment);
121+
Message message =
122+
genericMessageConverter.toMessage(event, RqueueMessageHeaders.emptyMessageHeaders());
123+
Event<Comment> fromMessage =
124+
(Event<Comment>) genericMessageConverter.fromMessage(message, null);
125+
assertEquals(event, fromMessage);
126+
}
127+
128+
@Test
129+
void envelopeEventWithNullPayloadToReturnNull() {
130+
Event<Comment> event = new Event<>("evt-1", null);
131+
Message message =
132+
genericMessageConverter.toMessage(event, RqueueMessageHeaders.emptyMessageHeaders());
133+
assertNull(message);
134+
}
135+
136+
@Test
137+
void envelopeEventWithInheritedTypeToAndFromMessage() {
138+
// T=Notification extends Alert extends BaseAlert — runtime class is Notification,
139+
// so the type parameter is resolved to Notification, not the base type.
140+
Notification notification = new Notification("n-1", "hello", 42);
141+
Event<Notification> event = new Event<>("evt-2", notification);
142+
Message message =
143+
genericMessageConverter.toMessage(event, RqueueMessageHeaders.emptyMessageHeaders());
144+
Event<Notification> fromMessage =
145+
(Event<Notification>) genericMessageConverter.fromMessage(message, null);
146+
assertEquals(event, fromMessage);
147+
}
148+
149+
@Test
150+
void genericEnvelopeToAndFromMessage() {
120151
GenericTestData<Comment> data = new GenericTestData<>(10, comment);
121152
Message message =
122153
genericMessageConverter.toMessage(data, RqueueMessageHeaders.emptyMessageHeaders());
123-
assertNull(message);
154+
GenericTestData<Comment> fromMessage =
155+
(GenericTestData<Comment>) genericMessageConverter.fromMessage(message, null);
156+
assertEquals(data, fromMessage);
124157
}
125158

126159
@Test
@@ -369,6 +402,15 @@ public static class MultiGenericTestDataSameType<T> extends MappingRegistrar {
369402
private MultiGenericTestData<T, T> multiGenericTestData;
370403
}
371404

405+
@Data
406+
@NoArgsConstructor
407+
@AllArgsConstructor
408+
public static class Event<T> {
409+
410+
private String id;
411+
private T payload;
412+
}
413+
372414
@Data
373415
@NoArgsConstructor
374416
@AllArgsConstructor
@@ -377,4 +419,41 @@ public static class GenericTestDataWithPredefinedType {
377419
private Integer index;
378420
private MultiGenericTestData<Comment, Email> data;
379421
}
422+
423+
// Three-level hierarchy: Notification extends Alert extends BaseAlert
424+
@Data
425+
@NoArgsConstructor
426+
@AllArgsConstructor
427+
public static class BaseAlert {
428+
429+
private String id;
430+
}
431+
432+
@Data
433+
@EqualsAndHashCode(callSuper = true)
434+
@NoArgsConstructor
435+
@AllArgsConstructor
436+
public static class Alert extends BaseAlert {
437+
438+
private String message;
439+
440+
public Alert(String id, String message) {
441+
super(id);
442+
this.message = message;
443+
}
444+
}
445+
446+
@Data
447+
@EqualsAndHashCode(callSuper = true)
448+
@NoArgsConstructor
449+
@AllArgsConstructor
450+
public static class Notification extends Alert {
451+
452+
private int priority;
453+
454+
public Notification(String id, String message, int priority) {
455+
super(id, message);
456+
this.priority = priority;
457+
}
458+
}
380459
}

rqueue-core/src/test/java/com/github/sonus21/rqueue/core/support/RqueueMessageUtilsTest.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,8 @@ void buildMessageWithDelay() {
136136

137137
@Test
138138
void buildMessageNull() {
139+
// id is null so the type parameter T cannot be resolved, making conversion fail
139140
GenericClass<String> genericClass = new GenericClass<>();
140-
genericClass.id = UUID.randomUUID().toString();
141141
try {
142142
RqueueMessageUtils.buildMessage(
143143
messageConverter,
@@ -155,8 +155,8 @@ void buildMessageNull() {
155155

156156
@Test
157157
void buildPeriodicMessageNull() {
158+
// id is null so the type parameter T cannot be resolved, making conversion fail
158159
GenericClass<String> genericClass = new GenericClass<>();
159-
genericClass.id = UUID.randomUUID().toString();
160160
try {
161161
RqueueMessageUtils.buildPeriodicMessage(
162162
messageConverter,
@@ -174,8 +174,9 @@ void buildPeriodicMessageNull() {
174174

175175
@Test
176176
void buildMessageReturnInvalidType() {
177+
// id is null so GenericMessageConverter returns null; falls through to NoMessageConverter
178+
// which wraps the object in a GenericMessage with a non-String/non-byte[] payload
177179
GenericClass<String> genericClass = new GenericClass<>();
178-
genericClass.id = UUID.randomUUID().toString();
179180
try {
180181
RqueueMessageUtils.buildMessage(
181182
messageConverter2,
@@ -193,8 +194,9 @@ void buildMessageReturnInvalidType() {
193194

194195
@Test
195196
void buildPeriodicMessageReturnInvalidType() {
197+
// id is null so GenericMessageConverter returns null; falls through to NoMessageConverter
198+
// which wraps the object in a GenericMessage with a non-String/non-byte[] payload
196199
GenericClass<String> genericClass = new GenericClass<>();
197-
genericClass.id = UUID.randomUUID().toString();
198200
try {
199201
RqueueMessageUtils.buildPeriodicMessage(
200202
messageConverter2,

0 commit comments

Comments
 (0)