feat: Redis Stream Spring Boot starter 首次提交

封装 Redis Streams 收发,零业务依赖。含自动装配、发送模板、监听容器、
autoclaim 崩溃恢复+死信淘汰、stream 定时裁剪、gzip 编解码、环境隔离。

本次随提交的改进:
- per-handler 重试次数:getMaxRetryTimes() 覆写生效,全局 redis-stream.max-retry 兜底
- 死信回调传入反序列化后的业务消息(saveExceptionFromFields)
- pom 升级 maven-surefire-plugin 至 2.22.2,使 JUnit 5 测试真正执行
- 集成测试连接信息改为 -D 注入(默认 localhost:6379),凭据不入代码
- lombok 改为 optional,不污染下游依赖
- 新增 README 使用文档

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-06-24 20:55:12 +08:00
commit 74f68c9680
25 changed files with 2365 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
package com.njcn.middle.stream.autoconfig;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class RedisStreamPropertiesTest {
@Test
void hasSaneDefaults() {
RedisStreamProperties p = new RedisStreamProperties();
assertFalse(p.isEnvIsolation(), "envIsolation 默认 false");
assertEquals("", p.getEnv(), "env 默认空串");
assertEquals("", p.getConsumerName(), "consumerName 默认空串");
assertEquals(10, p.getReadCount(), "readCount 默认 10");
assertEquals(2000L, p.getBlockMs(), "blockMs 默认 2000");
assertEquals(3, p.getMaxRetry(), "maxRetry 默认 3");
}
@Test
void autoclaimHasDefaults() {
RedisStreamProperties.Autoclaim a = new RedisStreamProperties().getAutoclaim();
assertNotNull(a, "autoclaim 默认非 null");
assertEquals(60000L, a.getMinIdleMs(), "autoclaim.minIdleMs 默认 60000");
assertEquals(30000L, a.getIntervalMs(), "autoclaim.intervalMs 默认 30000");
}
@Test
void trimHasDefaults() {
RedisStreamProperties.Trim t = new RedisStreamProperties().getTrim();
assertNotNull(t, "trim 默认非 null");
assertEquals(60000L, t.getIntervalMs(), "trim.intervalMs 默认 60000");
assertEquals(100000L, t.getMaxlen(), "trim.maxlen 默认 100000");
}
}

View File

@@ -0,0 +1,68 @@
package com.njcn.middle.stream.codec;
import cn.hutool.core.util.StrUtil;
import com.njcn.middle.stream.codec.MessageCodec.Encoded;
import com.njcn.middle.stream.constant.StreamMessageConstant;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MessageCodecTest {
/** 构造一段足够大且高度可压缩的 json重复内容。 */
private static String bigCompressibleJson() {
return "{\"data\":\"" + StrUtil.repeat("abcdefgh", 200) + "\"}";
}
@Test
void plainRoundTrip() {
String json = "{\"a\":1,\"name\":\"hello\"}";
Encoded e = MessageCodec.encodeBody(json, false);
assertEquals(StreamMessageConstant.ENC_PLAIN, e.getEnc(), "不压缩时 enc=plain");
assertEquals(json, e.getBody(), "明文 body 即原 json");
assertEquals(json, MessageCodec.decodeBody(e.getEnc(), e.getBody()), "plain 往返原样");
}
@Test
void gzipRoundTrip() {
String json = bigCompressibleJson();
Encoded e = MessageCodec.encodeBody(json, true);
assertEquals(StreamMessageConstant.ENC_GZIP, e.getEnc(), "压缩时 enc=gzip");
assertEquals(json, MessageCodec.decodeBody(e.getEnc(), e.getBody()), "gzip 往返逐字节一致");
}
@Test
void gzipBodyIsShorterForCompressible() {
String json = bigCompressibleJson();
Encoded e = MessageCodec.encodeBody(json, true);
assertTrue(e.getBody().length() < json.length(),
"高可压缩内容压缩后 base64 应短于原 json" + e.getBody().length() + " < " + json.length());
}
@Test
void gzipBodyHasNoNewlines() {
String json = bigCompressibleJson();
Encoded e = MessageCodec.encodeBody(json, true);
assertFalse(e.getBody().contains("\n"), "base64 不应含 \\n");
assertFalse(e.getBody().contains("\r"), "base64 不应含 \\r");
}
@Test
void unicodeGzipRoundTrip() {
// UTF-8 多字节字符(中文 + emoji往返不丢
String json = "{\"msg\":\"中文测试🚀\"}";
Encoded e = MessageCodec.encodeBody(json, true);
assertEquals(json, MessageCodec.decodeBody(StreamMessageConstant.ENC_GZIP, e.getBody()),
"gzip 路径 UTF-8 中文/emoji 往返不丢");
}
@Test
void unknownEncFallsBackToPlain() {
// 消费侧只按 enc 解码;未知/缺省 enc 兜底按明文返回,不抛异常
String body = "{\"x\":1}";
assertEquals(body, MessageCodec.decodeBody("", body), "空 enc 兜底明文");
assertEquals(body, MessageCodec.decodeBody(StreamMessageConstant.ENC_PLAIN, body), "plain 直接返回");
}
}

View File

@@ -0,0 +1,96 @@
package com.njcn.middle.stream.container;
import com.njcn.middle.stream.autoconfig.RedisStreamProperties;
import com.njcn.middle.stream.domain.StreamBaseMessage;
import com.njcn.middle.stream.handler.EnhanceStreamConsumerHandler;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 单元测试:per-handler 重试次数解析({@link StreamAutoClaimer#resolveMaxRetry})。
*
* <p>不依赖 Redis仅校验「handler 自配优先、否则沿用全局 {@code redis-stream.max-retry}」的优先级。
*/
class StreamAutoClaimerMaxRetryTest {
private StreamAutoClaimer claimerWithGlobal(int globalMaxRetry) {
RedisStreamProperties props = new RedisStreamProperties();
props.setMaxRetry(globalMaxRetry);
// resolveMaxRetry 只用到 props 与 handlerredis/keyBuilder 传 null 即可
return new StreamAutoClaimer(null, null, props, Collections.emptyList());
}
@Test
void handlerInheritsGlobalWhenNotOverridden() {
StreamAutoClaimer claimer = claimerWithGlobal(5);
// 默认 handler 返回哨兵 INHERIT_GLOBAL_MAX_RETRY → 沿用全局 5
assertEquals(5, claimer.resolveMaxRetry(
new StubHandler(EnhanceStreamConsumerHandler.INHERIT_GLOBAL_MAX_RETRY)),
"未覆写时应沿用全局 max-retry");
}
@Test
void perHandlerOverrideWins() {
StreamAutoClaimer claimer = claimerWithGlobal(5);
assertEquals(10, claimer.resolveMaxRetry(new StubHandler(10)),
"handler 自配的正整数应优先于全局");
assertEquals(2, claimer.resolveMaxRetry(new StubHandler(2)),
"handler 可配置比全局更小的次数");
}
@Test
void nonPositiveHandlerValueFallsBackToGlobal() {
StreamAutoClaimer claimer = claimerWithGlobal(3);
assertEquals(3, claimer.resolveMaxRetry(new StubHandler(0)), "0 视为未配 → 全局");
assertEquals(3, claimer.resolveMaxRetry(new StubHandler(-7)), "负值视为未配 → 全局");
}
static class TestMsg extends StreamBaseMessage {
}
/** 可指定 getMaxRetryTimes() 返回值的测试桩。 */
static class StubHandler extends EnhanceStreamConsumerHandler<TestMsg> {
private final int maxRetry;
StubHandler(int maxRetry) {
this.maxRetry = maxRetry;
}
@Override
public String topic() {
return "T";
}
@Override
public String group() {
return "G";
}
@Override
public Class<TestMsg> messageType() {
return TestMsg.class;
}
@Override
public void handleMessage(TestMsg message) {
}
@Override
public boolean isRetry() {
return true;
}
@Override
public boolean throwException() {
return false;
}
@Override
public int getMaxRetryTimes() {
return maxRetry;
}
}
}

View File

@@ -0,0 +1,33 @@
package com.njcn.middle.stream.domain;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class StreamBaseMessageTest {
@Test
void newMessageHasDefaults() {
StreamBaseMessage m = new StreamBaseMessage();
// key 默认雪花 id非空
assertNotNull(m.getKey(), "key 应有雪花默认值");
assertFalse(m.getKey().isEmpty(), "key 不应为空串");
// tag/source 默认空串(非 null
assertEquals("", m.getTag(), "tag 默认空串");
assertEquals("", m.getSource(), "source 默认空串");
// sendTime 默认 now()
assertNotNull(m.getSendTime(), "sendTime 默认 now()");
// retryTimes 默认 0
assertEquals(0, m.getRetryTimes(), "retryTimes 默认 0");
}
@Test
void keysAreUnique() {
StreamBaseMessage a = new StreamBaseMessage();
StreamBaseMessage b = new StreamBaseMessage();
assertNotEquals(a.getKey(), b.getKey(), "不同实例 key 必须唯一");
}
}

View File

@@ -0,0 +1,223 @@
package com.njcn.middle.stream.handler;
import com.alibaba.fastjson.JSON;
import com.njcn.middle.stream.codec.MessageCodec;
import com.njcn.middle.stream.domain.StreamBaseMessage;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static com.njcn.middle.stream.constant.StreamMessageConstant.ENC_PLAIN;
import static com.njcn.middle.stream.constant.StreamMessageConstant.F_BODY;
import static com.njcn.middle.stream.constant.StreamMessageConstant.F_ENC;
import static com.njcn.middle.stream.handler.ConsumeResult.ACK;
import static com.njcn.middle.stream.handler.ConsumeResult.RETRY;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
class EnhanceStreamConsumerHandlerTest {
@Data
@EqualsAndHashCode(callSuper = true)
static class Foo extends StreamBaseMessage {
private String name;
}
/** 可配置行为、记录调用次数的测试桩。 */
static class FooHandler extends EnhanceStreamConsumerHandler<Foo> {
boolean retry = false;
boolean doFilter = false;
boolean throwOnHandle = false;
int handled = 0;
int succeeded = 0;
int exceptionLogged = 0;
Foo lastHandled;
Foo lastExceptionMsg;
String lastExceptionBody;
@Override
public String topic() {
return "T";
}
@Override
public String group() {
return "G";
}
@Override
public Class<Foo> messageType() {
return Foo.class;
}
@Override
public void handleMessage(Foo message) throws Exception {
if (throwOnHandle) {
throw new RuntimeException("boom");
}
handled++;
lastHandled = message;
}
@Override
public boolean isRetry() {
return retry;
}
@Override
public boolean throwException() {
return false;
}
@Override
public boolean filter(Foo message) {
return doFilter;
}
@Override
public void consumeSuccess(Foo message) {
succeeded++;
}
@Override
public void saveExceptionMsgLog(Foo message, String body, Exception e) {
exceptionLogged++;
lastExceptionMsg = message;
lastExceptionBody = body;
}
}
private static Map<String, String> fieldsOf(Foo foo, boolean compress) {
String json = JSON.toJSONString(foo);
MessageCodec.Encoded e = MessageCodec.encodeBody(json, compress);
Map<String, String> m = new HashMap<>();
m.put(F_ENC, e.getEnc());
m.put(F_BODY, e.getBody());
return m;
}
@Test
void happyPathAcksAndHandles() {
FooHandler h = new FooHandler();
Foo foo = new Foo();
foo.setName("hi");
ConsumeResult r = h.dispatchMessage(fieldsOf(foo, false));
assertEquals(ACK, r);
assertEquals(1, h.handled, "handleMessage 被调用");
assertEquals("hi", h.lastHandled.getName(), "反序列化内容正确");
assertEquals(1, h.succeeded, "consumeSuccess 被调用");
assertEquals(0, h.exceptionLogged);
}
@Test
void gzipBodyDecodesAndHandles() {
FooHandler h = new FooHandler();
Foo foo = new Foo();
foo.setName("hi");
ConsumeResult r = h.dispatchMessage(fieldsOf(foo, true));
assertEquals(ACK, r);
assertEquals(1, h.handled, "gzip body 能解码后处理");
assertEquals("hi", h.lastHandled.getName());
}
@Test
void filterTrueAcksWithoutHandling() {
FooHandler h = new FooHandler();
h.doFilter = true;
ConsumeResult r = h.dispatchMessage(fieldsOf(new Foo(), false));
assertEquals(ACK, r);
assertEquals(0, h.handled, "filter 真则不处理");
assertEquals(0, h.succeeded);
}
@Test
void handleExceptionWithRetryReturnsRetry() {
FooHandler h = new FooHandler();
h.throwOnHandle = true;
h.retry = true;
ConsumeResult r = h.dispatchMessage(fieldsOf(new Foo(), false));
assertEquals(RETRY, r, "异常 + isRetry → RETRY");
assertEquals(0, h.succeeded);
assertEquals(0, h.exceptionLogged, "retry 路径不落异常(留 PEL 由 autoclaim 兜)");
}
@Test
void handleExceptionWithoutRetryAcksAndLogs() {
FooHandler h = new FooHandler();
h.throwOnHandle = true;
h.retry = false;
ConsumeResult r = h.dispatchMessage(fieldsOf(new Foo(), false));
assertEquals(ACK, r, "异常 + 不 retry → ACK");
assertEquals(1, h.exceptionLogged, "落异常日志");
assertEquals(0, h.succeeded);
}
@Test
void poisonMessageAcksAndLogs() {
FooHandler h = new FooHandler();
Map<String, String> bad = new HashMap<>();
bad.put(F_ENC, ENC_PLAIN);
bad.put(F_BODY, "{not valid json");
ConsumeResult r = h.dispatchMessage(bad);
assertEquals(ACK, r, "毒消息 ACK不卡队列");
assertEquals(0, h.handled, "毒消息不进 handleMessage");
assertEquals(1, h.exceptionLogged, "毒消息落异常");
}
@Test
void saveExceptionFromFieldsParsesMessageForDeadLetter() {
FooHandler h = new FooHandler();
Foo foo = new Foo();
foo.setName("dead");
Map<String, String> fields = fieldsOf(foo, false);
h.saveExceptionFromFields(fields, new IllegalStateException("max retry exceeded"));
assertEquals(1, h.exceptionLogged);
assertNotNull(h.lastExceptionMsg, "死信回调应拿到反序列化后的消息");
assertEquals("dead", h.lastExceptionMsg.getName(), "消息内容正确");
assertEquals(fields.get(F_BODY), h.lastExceptionBody, "body 原样透传");
}
@Test
void saveExceptionFromFieldsParsesGzipBody() {
FooHandler h = new FooHandler();
Foo foo = new Foo();
foo.setName("dead-gzip");
h.saveExceptionFromFields(fieldsOf(foo, true), new IllegalStateException("x"));
assertNotNull(h.lastExceptionMsg);
assertEquals("dead-gzip", h.lastExceptionMsg.getName(), "gzip body 也能解析后回调");
}
@Test
void saveExceptionFromFieldsFallsBackToNullOnParseFailure() {
FooHandler h = new FooHandler();
Map<String, String> bad = new HashMap<>();
bad.put(F_ENC, ENC_PLAIN);
bad.put(F_BODY, "{not valid json");
h.saveExceptionFromFields(bad, new IllegalStateException("x"));
assertEquals(1, h.exceptionLogged);
assertNull(h.lastExceptionMsg, "解析失败时 message 兜底为 null");
assertEquals("{not valid json", h.lastExceptionBody, "body 仍透传");
}
}

View File

@@ -0,0 +1,197 @@
package com.njcn.middle.stream.integration;
import com.njcn.middle.stream.domain.StreamBaseMessage;
import com.njcn.middle.stream.handler.EnhanceStreamConsumerHandler;
import com.njcn.middle.stream.support.StreamKeyBuilder;
import com.njcn.middle.stream.template.RedisStreamEnhanceTemplate;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
import org.junit.jupiter.api.extension.ExecutionCondition;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.data.domain.Range;
import org.springframework.data.redis.connection.stream.PendingMessages;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 集成测试Gateautoclaim 对「永久失败 + isRetry」消息的死信淘汰必须在有限轮内触发并清空 PEL。
*
* <p>覆盖 review 发现的 Major{@code XCLAIM ... JUSTID} 不递增投递计数、且 listener 用
* {@code lastConsumed()} 永不重读 PEL会让 {@code getTotalDeliveryCount()} 永久冻结在首投值 1
* 使 {@code deliveries >= maxRetry} 恒 false、死信分支永不执行 → 消息被无限重投、PEL 不收敛。
*
* <p>本用例配 {@code maxRetry=2}(须 ≥2 才能暴露冻结,=1 时 1>=1 会被掩盖)、{@code min-idle-ms=0}、
* 小 {@code interval-ms},断言:有限时间内 handler 收到 max-retry 落异常回调,且该 group 的 PEL 清零。
*
* <p>Redis 不可达时整类禁用skipped。连接默认 {@code 127.0.0.1:6379} 无密码,指向其它 Redis 用
* {@code -Dspring.redis.host=… -Dspring.redis.port=… -Dspring.redis.password=…} 覆盖(凭据不入代码)。
* 本地起 Redis{@code docker run -d -p 6379:6379 redis:6.2}。
*/
@ExtendWith(StreamAutoClaimDeadLetterTest.RedisReachableCondition.class)
@SpringBootTest(
classes = StreamAutoClaimDeadLetterTest.TestApp.class,
properties = {
"redis-stream.env-isolation=false",
"redis-stream.block-ms=300",
"redis-stream.read-count=10",
"redis-stream.max-retry=2",
"redis-stream.consumer-name=dl-it-consumer",
"redis-stream.autoclaim.min-idle-ms=0",
"redis-stream.autoclaim.interval-ms=300"
})
class StreamAutoClaimDeadLetterTest {
@Autowired
private RedisStreamEnhanceTemplate template;
@Autowired
private StringRedisTemplate redis;
@Autowired
private StreamKeyBuilder keyBuilder;
@Test
void permanentlyFailingMessageIsDeadLetteredAndPelDrains() throws Exception {
AlwaysFailHandler.DEAD_LETTER_LATCH = new CountDownLatch(1);
AlwaysFailHandler.DEAD_LETTER_EX.set(null);
// 先清掉历史 stream避免上一轮残留 PEL 干扰),再重建消费组:
// delete 会把 listener 启动时建的组一并删除,而 XADD 不会重建组,故须手动用
// XGROUP CREATE ... MKSTREAM 重新建组,否则 listener/autoclaim 全部 NOGROUP。
String stream = keyBuilder.buildStream(AlwaysFailHandler.TOPIC);
redis.delete(stream);
redis.execute((RedisCallback<Object>) conn -> {
try {
return conn.execute("XGROUP",
"CREATE".getBytes(StandardCharsets.UTF_8),
stream.getBytes(StandardCharsets.UTF_8),
AlwaysFailHandler.GROUP.getBytes(StandardCharsets.UTF_8),
"$".getBytes(StandardCharsets.UTF_8),
"MKSTREAM".getBytes(StandardCharsets.UTF_8));
} catch (RuntimeException e) {
return null; // BUSYGROUP组已存在忽略
}
});
AlwaysFail msg = new AlwaysFail();
msg.setText("boom");
template.send(AlwaysFailHandler.TOPIC, msg, false);
// 永久失败 + isRetry=true经首投 RETRY → autoclaim 反复重投maxRetry=2 时必须在有限轮内落死信。
boolean deadLettered = AlwaysFailHandler.DEAD_LETTER_LATCH.await(15, TimeUnit.SECONDS);
assertTrue(deadLettered, "15s 内应触发 max-retry 死信落异常(当前代码因计数冻结永不触发)");
assertTrue(AlwaysFailHandler.DEAD_LETTER_EX.get() != null
&& AlwaysFailHandler.DEAD_LETTER_EX.get().contains("max retry exceeded"),
"死信落异常应携带 max-retry 信息,实际=" + AlwaysFailHandler.DEAD_LETTER_EX.get());
// 死信后 PEL 必须清空。强制 ACK 在 saveExceptionMsgLog 回调之后执行latch 先于 ACK trip
// 故轮询等待 ACK 完成,避免与 ACK 抢跑。
long pending = -1;
for (int i = 0; i < 50; i++) {
PendingMessages pel = redis.opsForStream()
.pending(stream, AlwaysFailHandler.GROUP, Range.unbounded(), 100);
pending = pel == null ? 0 : pel.size();
if (pending == 0) {
break;
}
Thread.sleep(100);
}
assertEquals(0L, pending, "死信后该 group 的 PEL 应清零,实际仍有 " + pending + "");
}
@SpringBootConfiguration
@EnableAutoConfiguration
static class TestApp {
@Bean
AlwaysFailHandler alwaysFailHandler() {
return new AlwaysFailHandler();
}
}
@Data
@EqualsAndHashCode(callSuper = true)
static class AlwaysFail extends StreamBaseMessage {
private String text;
}
static class AlwaysFailHandler extends EnhanceStreamConsumerHandler<AlwaysFail> {
static final String TOPIC = "IT_DeadLetter_Topic";
static final String GROUP = "IT_DeadLetter_Group";
static volatile CountDownLatch DEAD_LETTER_LATCH = new CountDownLatch(1);
static final AtomicReference<String> DEAD_LETTER_EX = new AtomicReference<>();
@Override
public String topic() {
return TOPIC;
}
@Override
public String group() {
return GROUP;
}
@Override
public Class<AlwaysFail> messageType() {
return AlwaysFail.class;
}
@Override
public void handleMessage(AlwaysFail message) {
throw new IllegalStateException("permanent failure: " + message.getText());
}
@Override
public boolean isRetry() {
return true;
}
@Override
public boolean throwException() {
return false;
}
/** autoclaim 超 maxRetry 落异常时回调dispatchMessage 的 RETRY 分支不会调用本方法)。 */
@Override
public void saveExceptionMsgLog(AlwaysFail message, String body, Exception e) {
String em = e == null ? null : e.getMessage();
if (em != null && em.contains("max retry exceeded")) {
DEAD_LETTER_EX.set(em);
DEAD_LETTER_LATCH.countDown();
}
}
}
/** Redis 不可达时禁用整个测试类(上下文加载即连 Redis故须在加载前判断。 */
static class RedisReachableCondition implements ExecutionCondition {
@Override
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
String host = System.getProperty("spring.redis.host", "127.0.0.1");
int port = Integer.parseInt(System.getProperty("spring.redis.port", "6379"));
try (Socket s = new Socket()) {
s.connect(new InetSocketAddress(host, port), 500);
return ConditionEvaluationResult.enabled("redis reachable at " + host + ":" + port);
} catch (Exception e) {
return ConditionEvaluationResult.disabled(
"no redis at " + host + ":" + port + ", skip integration test");
}
}
}
}

View File

@@ -0,0 +1,130 @@
package com.njcn.middle.stream.integration;
import com.njcn.middle.stream.domain.StreamBaseMessage;
import com.njcn.middle.stream.handler.EnhanceStreamConsumerHandler;
import com.njcn.middle.stream.template.RedisStreamEnhanceTemplate;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
import org.junit.jupiter.api.extension.ExecutionCondition;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* 集成测试Gate真实 Redis 下跑通 {@code XADD → XREADGROUP → XACK} 闭环。
*
* <p>Redis 不可达时整个类被 {@link RedisReachableCondition} 禁用skipped非 failed
* 保证无 Redis 环境 {@code mvn test} 仍全绿。
*
* <p>连接默认 {@code 127.0.0.1:6379} 无密码;指向其它 Redis 用系统属性覆盖(凭据不入代码):
* {@code mvn test -Dspring.redis.host=x.x.x.x -Dspring.redis.port=12379 -Dspring.redis.password=***}。
* 本地起 Redis{@code docker run -d -p 6379:6379 redis:6.2}。
*/
@ExtendWith(StreamRoundTripTest.RedisReachableCondition.class)
@SpringBootTest(
classes = StreamRoundTripTest.TestApp.class,
properties = {
"redis-stream.env-isolation=false",
"redis-stream.block-ms=500",
"redis-stream.consumer-name=it-consumer"
})
class StreamRoundTripTest {
@Autowired
private RedisStreamEnhanceTemplate template;
@Test
void sendAndReceiveRoundTrip() throws Exception {
PingHandler.RECEIVED.clear();
Ping ping = new Ping();
ping.setText("hello-it");
template.send(PingHandler.TOPIC, ping, false);
Ping got = PingHandler.RECEIVED.poll(5, TimeUnit.SECONDS);
assertNotNull(got, "5s 内应通过 XREADGROUP 收到消息");
assertEquals("hello-it", got.getText(), "内容往返一致");
assertEquals(ping.getKey(), got.getKey(), "key 往返一致");
}
@SpringBootConfiguration
@EnableAutoConfiguration
static class TestApp {
@Bean
PingHandler pingHandler() {
return new PingHandler();
}
}
@Data
@EqualsAndHashCode(callSuper = true)
static class Ping extends StreamBaseMessage {
private String text;
}
static class PingHandler extends EnhanceStreamConsumerHandler<Ping> {
static final String TOPIC = "IT_Ping_Topic";
static final BlockingQueue<Ping> RECEIVED = new LinkedBlockingQueue<>();
@Override
public String topic() {
return TOPIC;
}
@Override
public String group() {
return "IT_Ping_Group";
}
@Override
public Class<Ping> messageType() {
return Ping.class;
}
@Override
public void handleMessage(Ping message) {
RECEIVED.add(message);
}
@Override
public boolean isRetry() {
return false;
}
@Override
public boolean throwException() {
return false;
}
}
/** Redis 不可达时禁用整个测试类(上下文加载即连 Redis故须在加载前判断。 */
static class RedisReachableCondition implements ExecutionCondition {
@Override
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
String host = System.getProperty("spring.redis.host", "127.0.0.1");
int port = Integer.parseInt(System.getProperty("spring.redis.port", "6379"));
try (Socket s = new Socket()) {
s.connect(new InetSocketAddress(host, port), 500);
return ConditionEvaluationResult.enabled("redis reachable at " + host + ":" + port);
} catch (Exception e) {
return ConditionEvaluationResult.disabled(
"no redis at " + host + ":" + port + ", skip integration test");
}
}
}
}

View File

@@ -0,0 +1,29 @@
package com.njcn.middle.stream.support;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class StreamKeyBuilderTest {
@Test
void isolatedWithEnvAppendsSuffix() {
StreamKeyBuilder b = new StreamKeyBuilder(true, "prod");
assertEquals("FrontData_Topic_prod", b.buildStream("FrontData_Topic"),
"开隔离且 env 非空 → topic_env");
}
@Test
void notIsolatedReturnsTopicAsIs() {
StreamKeyBuilder b = new StreamKeyBuilder(false, "prod");
assertEquals("FrontData_Topic", b.buildStream("FrontData_Topic"),
"关隔离 → 原 topic忽略 env");
}
@Test
void isolatedWithBlankEnvReturnsTopicAsIs() {
assertEquals("T", new StreamKeyBuilder(true, "").buildStream("T"), "env 空串 → 原 topic");
assertEquals("T", new StreamKeyBuilder(true, " ").buildStream("T"), "env 全空白 → 原 topic");
assertEquals("T", new StreamKeyBuilder(true, null).buildStream("T"), "env null → 原 topic");
}
}

View File

@@ -0,0 +1,93 @@
package com.njcn.middle.stream.template;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import com.njcn.middle.stream.domain.StreamBaseMessage;
import com.njcn.middle.stream.support.StreamKeyBuilder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.data.redis.connection.stream.MapRecord;
import org.springframework.data.redis.connection.stream.RecordId;
import org.springframework.data.redis.core.StreamOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.Map;
import static com.njcn.middle.stream.constant.StreamMessageConstant.ENC_GZIP;
import static com.njcn.middle.stream.constant.StreamMessageConstant.ENC_PLAIN;
import static com.njcn.middle.stream.constant.StreamMessageConstant.F_BODY;
import static com.njcn.middle.stream.constant.StreamMessageConstant.F_ENC;
import static com.njcn.middle.stream.constant.StreamMessageConstant.F_KEY;
import static com.njcn.middle.stream.constant.StreamMessageConstant.F_TAG;
import static com.njcn.middle.stream.constant.StreamMessageConstant.F_TS;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class RedisStreamEnhanceTemplateTest {
@Data
@EqualsAndHashCode(callSuper = true)
static class Foo extends StreamBaseMessage {
private String name;
}
@SuppressWarnings({"unchecked", "rawtypes"})
private MapRecord<String, String, String> captureSend(boolean compress, Foo foo, String topic, StreamKeyBuilder kb) {
StringRedisTemplate redis = mock(StringRedisTemplate.class);
StreamOperations ops = mock(StreamOperations.class);
doReturn(ops).when(redis).opsForStream();
// send() 内 ofMap 返回 MapRecord编译期绑定 add(MapRecord) 重载(非 add(Record)
doReturn(RecordId.of("0-1")).when(ops).add(any(MapRecord.class));
RedisStreamEnhanceTemplate tpl = new RedisStreamEnhanceTemplate(redis, kb);
RecordId id = tpl.send(topic, foo, compress);
assertEquals("0-1", id.getValue(), "返回 XADD 的 RecordId");
ArgumentCaptor<MapRecord> cap = ArgumentCaptor.forClass(MapRecord.class);
verify(ops).add(cap.capture());
return (MapRecord<String, String, String>) cap.getValue();
}
@Test
void sendPlainFillsFiveFields() {
Foo foo = new Foo();
foo.setTag("t1");
foo.setName("hello");
MapRecord<String, String, String> rec = captureSend(false, foo, "MyTopic", new StreamKeyBuilder(false, ""));
assertEquals("MyTopic", rec.getStream(), "关隔离 → 原 topic 作 stream 名");
Map<String, String> v = rec.getValue();
assertEquals(5, v.size(), "固定五字段 key/tag/enc/body/ts");
assertEquals(foo.getKey(), v.get(F_KEY), "key 取自 message");
assertEquals("t1", v.get(F_TAG), "tag 取自 message");
assertEquals(ENC_PLAIN, v.get(F_ENC), "不压缩 enc=plain");
assertTrue(v.get(F_BODY).contains("hello"), "明文 body 含原文");
assertNotNull(v.get(F_TS), "ts 非空");
}
@Test
void sendCompressUsesGzipAndShorterBody() {
Foo foo = new Foo();
foo.setName(StrUtil.repeat("abcdefgh", 200));
String json = JSONObject.toJSONString(foo);
MapRecord<String, String, String> rec = captureSend(true, foo, "MyTopic", new StreamKeyBuilder(false, ""));
Map<String, String> v = rec.getValue();
assertEquals(ENC_GZIP, v.get(F_ENC), "压缩 enc=gzip");
assertTrue(v.get(F_BODY).length() < json.length(), "压缩 body 短于原 json");
}
@Test
void sendUsesEnvIsolatedStreamName() {
MapRecord<String, String, String> rec =
captureSend(false, new Foo(), "MyTopic", new StreamKeyBuilder(true, "prod"));
assertEquals("MyTopic_prod", rec.getStream(), "开隔离 → stream 名带 env 后缀");
}
}