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:
@@ -0,0 +1,78 @@
|
||||
package com.njcn.middle.stream.autoconfig;
|
||||
|
||||
import com.njcn.middle.stream.container.StreamAutoClaimer;
|
||||
import com.njcn.middle.stream.container.StreamListenerContainer;
|
||||
import com.njcn.middle.stream.handler.EnhanceStreamConsumerHandler;
|
||||
import com.njcn.middle.stream.support.StreamKeyBuilder;
|
||||
import com.njcn.middle.stream.support.StreamTrimScheduler;
|
||||
import com.njcn.middle.stream.template.RedisStreamEnhanceTemplate;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Redis Stream starter 自动装配。
|
||||
*
|
||||
* <p>handler 列表用 {@link ObjectProvider} 收集——Phase 1 缺省为空(容器空转),
|
||||
* Phase 2 由 message-boot 注入 Processor 即生效。装配在 {@link RedisAutoConfiguration} 之后,
|
||||
* 确保 {@link StringRedisTemplate} 已就绪。
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnClass(StringRedisTemplate.class)
|
||||
@AutoConfigureAfter(RedisAutoConfiguration.class)
|
||||
@EnableConfigurationProperties(RedisStreamProperties.class)
|
||||
public class RedisStreamAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public StreamKeyBuilder streamKeyBuilder(RedisStreamProperties props) {
|
||||
return new StreamKeyBuilder(props.isEnvIsolation(), props.getEnv());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public RedisStreamEnhanceTemplate redisStreamEnhanceTemplate(StringRedisTemplate redis,
|
||||
StreamKeyBuilder streamKeyBuilder) {
|
||||
return new RedisStreamEnhanceTemplate(redis, streamKeyBuilder);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public StreamListenerContainer streamListenerContainer(StringRedisTemplate redis,
|
||||
StreamKeyBuilder streamKeyBuilder,
|
||||
RedisStreamProperties props,
|
||||
ObjectProvider<EnhanceStreamConsumerHandler<?>> handlers) {
|
||||
return new StreamListenerContainer(redis, streamKeyBuilder, props, collect(handlers));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public StreamAutoClaimer streamAutoClaimer(StringRedisTemplate redis,
|
||||
StreamKeyBuilder streamKeyBuilder,
|
||||
RedisStreamProperties props,
|
||||
ObjectProvider<EnhanceStreamConsumerHandler<?>> handlers) {
|
||||
return new StreamAutoClaimer(redis, streamKeyBuilder, props, collect(handlers));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public StreamTrimScheduler streamTrimScheduler(StringRedisTemplate redis,
|
||||
StreamKeyBuilder streamKeyBuilder,
|
||||
RedisStreamProperties props,
|
||||
ObjectProvider<EnhanceStreamConsumerHandler<?>> handlers) {
|
||||
return new StreamTrimScheduler(redis, streamKeyBuilder, props, collect(handlers));
|
||||
}
|
||||
|
||||
private static List<EnhanceStreamConsumerHandler<?>> collect(ObjectProvider<EnhanceStreamConsumerHandler<?>> handlers) {
|
||||
return handlers.orderedStream().collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.njcn.middle.stream.autoconfig;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Redis Stream starter 配置属性,前缀 {@code redis-stream}。
|
||||
*
|
||||
* <p>env / env-isolation 归 starter(非 message.mq.*),整体开关 message.mq.type 留 message-boot。
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties("redis-stream")
|
||||
public class RedisStreamProperties {
|
||||
|
||||
/** 是否按环境隔离 stream 名(追加 {@code _env} 后缀)。 */
|
||||
private boolean envIsolation = false;
|
||||
|
||||
/** 环境标识(隔离开启时作为 stream 名后缀)。 */
|
||||
private String env = "";
|
||||
|
||||
/** 消费者名;为空时由容器生成。 */
|
||||
private String consumerName = "";
|
||||
|
||||
/** 单次 XREADGROUP 拉取条数。 */
|
||||
private int readCount = 10;
|
||||
|
||||
/** XREADGROUP 阻塞毫秒。 */
|
||||
private long blockMs = 2000;
|
||||
|
||||
/** 最大重试次数(autoclaim 维护)。 */
|
||||
private int maxRetry = 3;
|
||||
|
||||
private Autoclaim autoclaim = new Autoclaim();
|
||||
private Trim trim = new Trim();
|
||||
|
||||
/** 崩溃恢复:PEL 中 idle 超时消息的自动认领。 */
|
||||
@Data
|
||||
public static class Autoclaim {
|
||||
/** 消息 idle 多久(ms)后可被其它消费者认领。 */
|
||||
private long minIdleMs = 60000;
|
||||
/** autoclaim 扫描周期(ms)。 */
|
||||
private long intervalMs = 30000;
|
||||
}
|
||||
|
||||
/** 定时裁剪 stream 长度。 */
|
||||
@Data
|
||||
public static class Trim {
|
||||
/** XTRIM 执行周期(ms)。 */
|
||||
private long intervalMs = 60000;
|
||||
/** 近似保留的最大长度(MAXLEN ~)。 */
|
||||
private long maxlen = 100000;
|
||||
}
|
||||
}
|
||||
92
src/main/java/com/njcn/middle/stream/codec/MessageCodec.java
Normal file
92
src/main/java/com/njcn/middle/stream/codec/MessageCodec.java
Normal file
@@ -0,0 +1,92 @@
|
||||
package com.njcn.middle.stream.codec;
|
||||
|
||||
import com.njcn.middle.stream.constant.StreamMessageConstant;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
/**
|
||||
* 消息体编解码器。
|
||||
*
|
||||
* <p>XADD 字段契约对死 C++:仅 {@code body} 可压缩。
|
||||
* {@code enc=gzip} → base64(gzip(json))(RFC1952 gzip 头 + 无换行 base64);
|
||||
* {@code enc=plain} → 原 json 明文。Java 1.8:{@link Base64}(默认无换行)+ {@link GZIPOutputStream}。
|
||||
*/
|
||||
public final class MessageCodec {
|
||||
|
||||
private MessageCodec() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码 body。
|
||||
*
|
||||
* @param json 原始 json
|
||||
* @param compress 是否压缩;true → gzip+base64,false → 明文
|
||||
* @return 编码结果(enc + body)
|
||||
*/
|
||||
public static Encoded encodeBody(String json, boolean compress) {
|
||||
if (!compress) {
|
||||
return new Encoded(StreamMessageConstant.ENC_PLAIN, json);
|
||||
}
|
||||
byte[] raw = json.getBytes(StandardCharsets.UTF_8);
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream(Math.max(32, raw.length / 2));
|
||||
try (GZIPOutputStream gzip = new GZIPOutputStream(bos)) {
|
||||
gzip.write(raw);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("gzip encode failed", ex);
|
||||
}
|
||||
// Base64.getEncoder() 为基础编码器,不插入换行(区别于 getMimeEncoder)
|
||||
String body = Base64.getEncoder().encodeToString(bos.toByteArray());
|
||||
return new Encoded(StreamMessageConstant.ENC_GZIP, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 body。仅按 {@code enc} 解码,不 hardcode topic;未知/明文一律按原文返回。
|
||||
*
|
||||
* @param enc 编码方式
|
||||
* @param body body 内容
|
||||
* @return 原始 json
|
||||
*/
|
||||
public static String decodeBody(String enc, String body) {
|
||||
if (!StreamMessageConstant.ENC_GZIP.equals(enc)) {
|
||||
// plain 及未知 enc:兜底按明文返回
|
||||
return body;
|
||||
}
|
||||
byte[] compressed = Base64.getDecoder().decode(body);
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream(compressed.length * 2);
|
||||
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
|
||||
byte[] buf = new byte[1024];
|
||||
int n;
|
||||
while ((n = gzip.read(buf)) != -1) {
|
||||
bos.write(buf, 0, n);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("gzip decode failed", ex);
|
||||
}
|
||||
return new String(bos.toByteArray(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** 编码结果值对象。 */
|
||||
public static class Encoded {
|
||||
private final String enc;
|
||||
private final String body;
|
||||
|
||||
public Encoded(String enc, String body) {
|
||||
this.enc = enc;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
public String getEnc() {
|
||||
return enc;
|
||||
}
|
||||
|
||||
public String getBody() {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.njcn.middle.stream.constant;
|
||||
|
||||
/**
|
||||
* Redis Stream 消息相关常量。
|
||||
*
|
||||
* <p>XADD 字段契约对死 C++ 端:每条消息固定 5 个字段 {@code key/tag/enc/body/ts},
|
||||
* 仅 {@code body} 可压缩({@code enc=gzip} → base64(gzip(json))),其余明文。
|
||||
*/
|
||||
public final class StreamMessageConstant {
|
||||
|
||||
private StreamMessageConstant() {
|
||||
}
|
||||
|
||||
/** 单条消息标识。 */
|
||||
public static final String IDENTITY_SINGLE = "single";
|
||||
|
||||
/** 重试消息标识。 */
|
||||
public static final String IDENTITY_RETRY = "retry";
|
||||
|
||||
/** 重试相关 key 前缀。 */
|
||||
public static final String RETRY_PREFIX = "retry_";
|
||||
|
||||
/** XADD 字段名:消息业务 key。 */
|
||||
public static final String F_KEY = "key";
|
||||
|
||||
/** XADD 字段名:消息 tag(下行路由/过滤用)。 */
|
||||
public static final String F_TAG = "tag";
|
||||
|
||||
/** XADD 字段名:body 编码方式({@link #ENC_GZIP} / {@link #ENC_PLAIN})。 */
|
||||
public static final String F_ENC = "enc";
|
||||
|
||||
/** XADD 字段名:消息体(json 明文或 base64(gzip(json)))。 */
|
||||
public static final String F_BODY = "body";
|
||||
|
||||
/** XADD 字段名:发送时间戳(毫秒)。 */
|
||||
public static final String F_TS = "ts";
|
||||
|
||||
/** 编码方式:gzip 压缩后 base64。 */
|
||||
public static final String ENC_GZIP = "gzip";
|
||||
|
||||
/** 编码方式:明文 json。 */
|
||||
public static final String ENC_PLAIN = "plain";
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.njcn.middle.stream.container;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.njcn.middle.stream.autoconfig.RedisStreamProperties;
|
||||
import com.njcn.middle.stream.handler.ConsumeResult;
|
||||
import com.njcn.middle.stream.handler.EnhanceStreamConsumerHandler;
|
||||
import com.njcn.middle.stream.support.StreamKeyBuilder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.data.domain.Range;
|
||||
import org.springframework.data.redis.connection.stream.MapRecord;
|
||||
import org.springframework.data.redis.connection.stream.PendingMessage;
|
||||
import org.springframework.data.redis.connection.stream.PendingMessages;
|
||||
import org.springframework.data.redis.connection.stream.RecordId;
|
||||
import org.springframework.data.redis.core.RedisCallback;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 崩溃恢复:自动认领 PEL 中长时间未确认(消费者宕机)的消息。
|
||||
*
|
||||
* <p>spring-data-redis 2.3.x 无 {@code XAUTOCLAIM}(需 2.6+),故走
|
||||
* {@code pending} + 原生 {@code XCLAIM ... JUSTID} + {@code range} 取内容 + 重新 dispatch。
|
||||
* 属崩溃恢复兜底、非主链路(主链路重试由消费线程 RETRY 留 PEL 触发本认领)。
|
||||
*
|
||||
* <p>投递次数超过 {@code maxRetry} 仍失败 → 落异常 + 强制 ACK,避免死信无限滞留。
|
||||
* 正确性由 C11 集成测试覆盖。
|
||||
*/
|
||||
@Slf4j
|
||||
public class StreamAutoClaimer implements SmartLifecycle {
|
||||
|
||||
private final StringRedisTemplate redis;
|
||||
private final StreamKeyBuilder keyBuilder;
|
||||
private final RedisStreamProperties props;
|
||||
private final List<EnhanceStreamConsumerHandler<?>> handlers;
|
||||
|
||||
private ScheduledExecutorService scheduler;
|
||||
private volatile boolean running = false;
|
||||
private String claimConsumer;
|
||||
|
||||
public StreamAutoClaimer(StringRedisTemplate redis, StreamKeyBuilder keyBuilder,
|
||||
RedisStreamProperties props,
|
||||
List<EnhanceStreamConsumerHandler<?>> handlers) {
|
||||
this.redis = redis;
|
||||
this.keyBuilder = keyBuilder;
|
||||
this.props = props;
|
||||
this.handlers = handlers == null ? Collections.emptyList() : handlers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void start() {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
if (handlers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
claimConsumer = resolveConsumerName();
|
||||
long interval = props.getAutoclaim().getIntervalMs();
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "redis-stream-autoclaim");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
scheduler.scheduleWithFixedDelay(this::claimAll, interval, interval, TimeUnit.MILLISECONDS);
|
||||
log.info("[redis-stream] autoclaimer started, interval={}ms minIdle={}ms",
|
||||
interval, props.getAutoclaim().getMinIdleMs());
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void stop() {
|
||||
running = false;
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdownNow();
|
||||
scheduler = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
|
||||
private String resolveConsumerName() {
|
||||
if (StrUtil.isNotBlank(props.getConsumerName())) {
|
||||
return props.getConsumerName();
|
||||
}
|
||||
return "claimer-" + IdUtil.fastSimpleUUID();
|
||||
}
|
||||
|
||||
private void claimAll() {
|
||||
long minIdle = props.getAutoclaim().getMinIdleMs();
|
||||
for (EnhanceStreamConsumerHandler<?> handler : handlers) {
|
||||
String stream = keyBuilder.buildStream(handler.topic());
|
||||
String group = handler.group();
|
||||
int maxRetry = resolveMaxRetry(handler);
|
||||
try {
|
||||
claimStream(handler, stream, group, minIdle, maxRetry);
|
||||
} catch (Exception e) {
|
||||
log.warn("[redis-stream] autoclaim failed stream={} group={}", stream, group, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析本 handler 生效的最大投递次数:优先用 handler 自配的
|
||||
* {@link EnhanceStreamConsumerHandler#getMaxRetryTimes()}(正整数);未配
|
||||
* (返回 {@link EnhanceStreamConsumerHandler#INHERIT_GLOBAL_MAX_RETRY} 等 ≤0 值)时
|
||||
* 沿用全局 {@code redis-stream.max-retry}。
|
||||
*/
|
||||
int resolveMaxRetry(EnhanceStreamConsumerHandler<?> handler) {
|
||||
int perHandler = handler.getMaxRetryTimes();
|
||||
return perHandler > 0 ? perHandler : props.getMaxRetry();
|
||||
}
|
||||
|
||||
private void claimStream(EnhanceStreamConsumerHandler<?> handler, String stream, String group,
|
||||
long minIdle, int maxRetry) {
|
||||
int readCount = props.getReadCount();
|
||||
// 按 id 游标翻页扫描整个 PEL,避免只取最旧 readCount 条导致长 PEL 下尾部消息饥饿。
|
||||
// XPENDING start 为包含语义(兼容 Redis 5.0),故每页首条会与上页游标重复,跳过即可。
|
||||
String cursor = null;
|
||||
while (running) {
|
||||
Range<String> range = cursor == null
|
||||
? Range.unbounded()
|
||||
: Range.rightUnbounded(Range.Bound.inclusive(cursor));
|
||||
int fetch = cursor == null ? readCount : readCount + 1;
|
||||
PendingMessages pending = redis.opsForStream().pending(stream, group, range, fetch);
|
||||
if (pending == null || pending.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int fresh = 0;
|
||||
String lastId = cursor;
|
||||
for (PendingMessage pm : pending) {
|
||||
String id = pm.getIdAsString();
|
||||
if (id.equals(cursor)) {
|
||||
// 与上一页游标重复,已处理过,跳过
|
||||
continue;
|
||||
}
|
||||
fresh++;
|
||||
lastId = id;
|
||||
if (pm.getElapsedTimeSinceLastDelivery().toMillis() < minIdle) {
|
||||
continue;
|
||||
}
|
||||
claimOne(handler, stream, group, minIdle, maxRetry, id, pm.getTotalDeliveryCount());
|
||||
}
|
||||
if (fresh < readCount) {
|
||||
// 本页新条目不足一批 → 已扫到 PEL 尾部
|
||||
return;
|
||||
}
|
||||
if (lastId == null || lastId.equals(cursor)) {
|
||||
// 防御:游标无法推进时退出,避免死循环
|
||||
return;
|
||||
}
|
||||
cursor = lastId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单条 PEL 消息:取内容 → 死信判定前置 → 认领重投。
|
||||
*
|
||||
* <p>{@code deliveries} 为本轮 {@code XCLAIM} 之前的投递计数;因 {@link #claim} 用<b>不带 JUSTID</b>
|
||||
* 的 XCLAIM,每次认领会把投递计数 +1,故 deliveries 随 autoclaim 轮次单调增长。
|
||||
* {@code maxRetry} 语义 = 最多投递 maxRetry 次(含首投),第 maxRetry 次仍 RETRY 即落死信。
|
||||
* 死信判定<b>前置于 dispatch</b>:达上限直接落异常 + 强制 ACK,不再多跑一次业务副作用。
|
||||
*/
|
||||
private void claimOne(EnhanceStreamConsumerHandler<?> handler, String stream, String group,
|
||||
long minIdle, int maxRetry, String id, long deliveries) {
|
||||
List<MapRecord<String, Object, Object>> records =
|
||||
redis.opsForStream().range(stream, Range.closed(id, id));
|
||||
if (records == null || records.isEmpty()) {
|
||||
// 内容已不存在(可能被 trim)→ 强制 ACK 清理 PEL
|
||||
redis.opsForStream().acknowledge(stream, group, RecordId.of(id));
|
||||
return;
|
||||
}
|
||||
MapRecord<String, Object, Object> rec = records.get(0);
|
||||
Map<String, String> fields = toStringMap(rec.getValue());
|
||||
|
||||
if (deliveries >= maxRetry) {
|
||||
// 投递计数已达上限 → 落异常 + 强制 ACK(死信,不再无限滞留 PEL)
|
||||
// 由 handler 内部反序列化后回调,使死信回调能拿到业务消息对象(解析失败兜底 null)
|
||||
handler.saveExceptionFromFields(fields,
|
||||
new IllegalStateException("max retry exceeded, deliveries=" + deliveries));
|
||||
redis.opsForStream().acknowledge(stream, group, rec.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 认领到本 claimer(同时把投递计数 +1、重置 idle),再重投
|
||||
claim(stream, group, minIdle, id);
|
||||
ConsumeResult result = handler.dispatchMessage(fields);
|
||||
if (result == ConsumeResult.ACK) {
|
||||
redis.opsForStream().acknowledge(stream, group, rec.getId());
|
||||
}
|
||||
// 否则 RETRY 且未超限:留 PEL,下一轮(idle 到点后)再认领重试
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code XCLAIM stream group consumer minIdle id}(<b>不带 JUSTID</b>)。
|
||||
*
|
||||
* <p>不带 JUSTID 时 XCLAIM 会转移归属、重置 idle 并把投递计数 +1——后者是 maxRetry 死信判定的
|
||||
* 依据:listener 用 {@code XREADGROUP lastConsumed()} 永不重读 PEL,若此处用 JUSTID 则投递计数
|
||||
* 永久冻结在首投值 1,{@code deliveries >= maxRetry} 恒不成立、死信分支永不触发。
|
||||
*/
|
||||
private void claim(String stream, String group, long minIdle, String id) {
|
||||
redis.execute((RedisCallback<Object>) connection -> connection.execute("XCLAIM",
|
||||
stream.getBytes(StandardCharsets.UTF_8),
|
||||
group.getBytes(StandardCharsets.UTF_8),
|
||||
claimConsumer.getBytes(StandardCharsets.UTF_8),
|
||||
String.valueOf(minIdle).getBytes(StandardCharsets.UTF_8),
|
||||
id.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
private static Map<String, String> toStringMap(Map<Object, Object> raw) {
|
||||
Map<String, String> m = new HashMap<>(Math.max(8, raw.size() * 2));
|
||||
raw.forEach((k, v) -> m.put(String.valueOf(k), v == null ? null : String.valueOf(v)));
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.njcn.middle.stream.container;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.njcn.middle.stream.autoconfig.RedisStreamProperties;
|
||||
import com.njcn.middle.stream.handler.ConsumeResult;
|
||||
import com.njcn.middle.stream.handler.EnhanceStreamConsumerHandler;
|
||||
import com.njcn.middle.stream.support.StreamKeyBuilder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
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.StreamOffset;
|
||||
import org.springframework.data.redis.connection.stream.StreamReadOptions;
|
||||
import org.springframework.data.redis.core.RedisCallback;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Stream 监听容器:每个 handler 一条消费组消费线程。
|
||||
*
|
||||
* <p>{@code XREADGROUP} 阻塞读 → {@link EnhanceStreamConsumerHandler#dispatchMessage} →
|
||||
* 结果 ACK 则 {@code XACK},RETRY 则不确认(留 PEL 由 autoclaim 兜)。
|
||||
*
|
||||
* <p>无 handler 时容器空转(Phase 2 才注入 handler)。正确性由 C11 集成测试覆盖。
|
||||
*/
|
||||
@Slf4j
|
||||
public class StreamListenerContainer implements SmartLifecycle {
|
||||
|
||||
private final StringRedisTemplate redis;
|
||||
private final StreamKeyBuilder keyBuilder;
|
||||
private final RedisStreamProperties props;
|
||||
private final List<EnhanceStreamConsumerHandler<?>> handlers;
|
||||
|
||||
private final List<Thread> workers = new ArrayList<>();
|
||||
private volatile boolean running = false;
|
||||
|
||||
public StreamListenerContainer(StringRedisTemplate redis, StreamKeyBuilder keyBuilder,
|
||||
RedisStreamProperties props,
|
||||
List<EnhanceStreamConsumerHandler<?>> handlers) {
|
||||
this.redis = redis;
|
||||
this.keyBuilder = keyBuilder;
|
||||
this.props = props;
|
||||
this.handlers = handlers == null ? Collections.emptyList() : handlers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void start() {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
if (handlers.isEmpty()) {
|
||||
log.info("[redis-stream] no handler registered, listener container idle");
|
||||
return;
|
||||
}
|
||||
String consumer = resolveConsumerName();
|
||||
for (EnhanceStreamConsumerHandler<?> handler : handlers) {
|
||||
String stream = keyBuilder.buildStream(handler.topic());
|
||||
String group = handler.group();
|
||||
ensureGroup(stream, group);
|
||||
Thread t = new Thread(() -> loop(handler, stream, group, consumer),
|
||||
"redis-stream-" + stream + "-" + group);
|
||||
t.setDaemon(true);
|
||||
workers.add(t);
|
||||
t.start();
|
||||
}
|
||||
log.info("[redis-stream] listener container started, {} listener(s), consumer={}", workers.size(), consumer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void stop() {
|
||||
running = false;
|
||||
for (Thread t : workers) {
|
||||
t.interrupt();
|
||||
}
|
||||
workers.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
|
||||
private String resolveConsumerName() {
|
||||
if (StrUtil.isNotBlank(props.getConsumerName())) {
|
||||
return props.getConsumerName();
|
||||
}
|
||||
return "consumer-" + IdUtil.fastSimpleUUID();
|
||||
}
|
||||
|
||||
/** {@code XGROUP CREATE stream group $ MKSTREAM},吞 BUSYGROUP(组已存在)。 */
|
||||
private void ensureGroup(String stream, String group) {
|
||||
try {
|
||||
redis.execute((RedisCallback<Object>) connection -> connection.execute("XGROUP",
|
||||
"CREATE".getBytes(StandardCharsets.UTF_8),
|
||||
stream.getBytes(StandardCharsets.UTF_8),
|
||||
group.getBytes(StandardCharsets.UTF_8),
|
||||
"$".getBytes(StandardCharsets.UTF_8),
|
||||
"MKSTREAM".getBytes(StandardCharsets.UTF_8)));
|
||||
log.info("[redis-stream] group ensured stream={} group={}", stream, group);
|
||||
} catch (RuntimeException e) {
|
||||
if (isBusyGroup(e)) {
|
||||
// 消费组已存在,幂等忽略
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private void loop(EnhanceStreamConsumerHandler<?> handler, String stream, String group, String consumer) {
|
||||
while (running && !Thread.currentThread().isInterrupted()) {
|
||||
try {
|
||||
List<MapRecord<String, Object, Object>> records = redis.opsForStream().read(
|
||||
Consumer.from(group, consumer),
|
||||
StreamReadOptions.empty()
|
||||
.count(props.getReadCount())
|
||||
.block(Duration.ofMillis(props.getBlockMs())),
|
||||
StreamOffset.create(stream, ReadOffset.lastConsumed()));
|
||||
if (records == null || records.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (MapRecord<String, Object, Object> rec : records) {
|
||||
Map<String, String> fields = toStringMap(rec.getValue());
|
||||
ConsumeResult result = handler.dispatchMessage(fields);
|
||||
if (result == ConsumeResult.ACK) {
|
||||
redis.opsForStream().acknowledge(stream, group, rec.getId());
|
||||
}
|
||||
// RETRY:不确认,留 PEL,由 StreamAutoClaimer 后续认领重投
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (!running || Thread.currentThread().isInterrupted()) {
|
||||
break;
|
||||
}
|
||||
log.error("[redis-stream] read loop error stream={} group={}, backing off", stream, group, e);
|
||||
sleepQuietly(props.getBlockMs());
|
||||
}
|
||||
}
|
||||
log.info("[redis-stream] listener loop exit stream={} group={}", stream, group);
|
||||
}
|
||||
|
||||
private static Map<String, String> toStringMap(Map<Object, Object> raw) {
|
||||
Map<String, String> m = new HashMap<>(Math.max(8, raw.size() * 2));
|
||||
raw.forEach((k, v) -> m.put(String.valueOf(k), v == null ? null : String.valueOf(v)));
|
||||
return m;
|
||||
}
|
||||
|
||||
private static void sleepQuietly(long ms) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBusyGroup(Throwable e) {
|
||||
for (Throwable t = e; t != null; t = t.getCause()) {
|
||||
if (t.getMessage() != null && t.getMessage().contains("BUSYGROUP")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.njcn.middle.stream.domain;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Redis Stream 消息基类,业务消息继承此类。
|
||||
*
|
||||
* <p>自带基类、<b>不依赖 message-api</b>,保证 starter 零业务耦合。
|
||||
*/
|
||||
@Data
|
||||
public class StreamBaseMessage {
|
||||
|
||||
/**
|
||||
* 消息业务 key,默认雪花 id。
|
||||
*
|
||||
* <p>{@link IdUtil#getSnowflake()} 无参经 {@code Singleton} 返回全局单例 Snowflake,
|
||||
* 多次调用 sequence 连续递增、保证唯一;不可换成每次 {@code new Snowflake}(同毫秒不同实例会撞号)。
|
||||
*/
|
||||
private String key = IdUtil.getSnowflake().nextIdStr();
|
||||
|
||||
/** 消息 tag(下行路由/过滤用),默认空串。 */
|
||||
private String tag = "";
|
||||
|
||||
/** 消息来源标识,默认空串。 */
|
||||
private String source = "";
|
||||
|
||||
/** 发送时间,默认 now()。 */
|
||||
private LocalDateTime sendTime = LocalDateTime.now();
|
||||
|
||||
/** 已重试次数,默认 0。 */
|
||||
private Integer retryTimes = 0;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.njcn.middle.stream.handler;
|
||||
|
||||
/**
|
||||
* 消费结果。
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #ACK}:确认消费(含成功、被 filter 过滤、毒消息丢弃、不重试的失败);
|
||||
* <li>{@link #RETRY}:不确认,留在 PEL,由 autoclaim 后续重投。
|
||||
* </ul>
|
||||
*/
|
||||
public enum ConsumeResult {
|
||||
ACK,
|
||||
RETRY
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.njcn.middle.stream.handler;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.njcn.middle.stream.codec.MessageCodec;
|
||||
import com.njcn.middle.stream.constant.StreamMessageConstant;
|
||||
import com.njcn.middle.stream.domain.StreamBaseMessage;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 增强型 Stream 消费处理器基类(骨架,待 TDD 实现 dispatchMessage)。
|
||||
*
|
||||
* <p>对标 rocket starter 的 {@code EnhanceConsumerMessageHandler},但接收的是
|
||||
* 反序列化前的 {@code Map<String,String>}(Redis Stream 字段)。
|
||||
*/
|
||||
public abstract class EnhanceStreamConsumerHandler<T extends StreamBaseMessage> {
|
||||
|
||||
/**
|
||||
* {@link #getMaxRetryTimes()} 返回此哨兵值(或任意 ≤0 值)表示:本 handler 不单独配置重试次数,
|
||||
* 沿用全局配置 {@code redis-stream.max-retry}。
|
||||
*/
|
||||
public static final int INHERIT_GLOBAL_MAX_RETRY = -1;
|
||||
|
||||
/** 监听的 topic。 */
|
||||
public abstract String topic();
|
||||
|
||||
/** 消费组。 */
|
||||
public abstract String group();
|
||||
|
||||
/** 消息类型(用于反序列化)。 */
|
||||
public abstract Class<T> messageType();
|
||||
|
||||
/** 业务处理。 */
|
||||
public abstract void handleMessage(T message) throws Exception;
|
||||
|
||||
/** 失败是否重试。 */
|
||||
public abstract boolean isRetry();
|
||||
|
||||
/**
|
||||
* 是否向上抛异常。
|
||||
*
|
||||
* <p><b>Redis 模式 no-op</b>:Redis Stream 无 broker 重投语义,异常一律走 {@link #isRetry()}
|
||||
* → RETRY/落异常;此钩子仅为与 rocket starter 共用一套接口而保留,Phase 2 复用。
|
||||
*/
|
||||
public abstract boolean throwException();
|
||||
|
||||
/** 过滤:返回 true 则直接 ACK 丢弃,不处理。默认 false。 */
|
||||
public boolean filter(T message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 消费成功回调,默认空。 */
|
||||
public void consumeSuccess(T message) {
|
||||
}
|
||||
|
||||
/** 落异常日志,默认空。 */
|
||||
public void saveExceptionMsgLog(T message, String body, Exception e) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 死信落异常:先尝试反序列化 {@code fields} 得到业务消息,再回调 {@link #saveExceptionMsgLog}。
|
||||
*
|
||||
* <p>由 autoclaim 超 maxRetry 时调用。相比直接传 {@code null},本方法让死信回调能拿到解析后的业务
|
||||
* 对象(与 {@link #dispatchMessage} 非重试失败路径一致)。仅做反序列化、<b>不调用 {@link #handleMessage}</b>,
|
||||
* 不触发任何业务副作用;反序列化失败(理论上不会——毒消息在首投即 ACK,不入此路径)时 message 兜底为 null。
|
||||
*
|
||||
* @param fields stream 字段(含 enc/body)
|
||||
* @param cause 死信原因
|
||||
*/
|
||||
public void saveExceptionFromFields(Map<String, String> fields, Exception cause) {
|
||||
String body = fields.get(StreamMessageConstant.F_BODY);
|
||||
T message = null;
|
||||
try {
|
||||
String json = MessageCodec.decodeBody(fields.get(StreamMessageConstant.F_ENC), body);
|
||||
message = JSON.parseObject(json, messageType());
|
||||
} catch (Exception ignore) {
|
||||
// 解析失败:保持 message=null,仍把原始 body 透传给回调
|
||||
}
|
||||
saveExceptionMsgLog(message, body, cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本 handler 的最大投递次数(含首投),达到即落死信,由 autoclaim 维护。
|
||||
*
|
||||
* <p>默认返回 {@link #INHERIT_GLOBAL_MAX_RETRY},表示沿用全局配置 {@code redis-stream.max-retry};
|
||||
* 覆写为正整数(≥1)即对本 handler 单独生效,优先级高于全局配置。
|
||||
*/
|
||||
public int getMaxRetryTimes() {
|
||||
return INHERIT_GLOBAL_MAX_RETRY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分发一条消息(已从 stream 读出的字段 map)。
|
||||
*
|
||||
* <p>流程:decode(enc,body) → 反序列化 → filter → handleMessage → consumeSuccess → ACK。
|
||||
* 解码/反序列化失败(毒消息)落异常并 ACK(不卡队列);handleMessage 异常时 {@link #isRetry()}
|
||||
* 真则 RETRY(留 PEL 由 autoclaim 兜),否则落异常 ACK。
|
||||
*
|
||||
* <p>注:{@link StreamBaseMessage#getRetryTimes()} 主链路不读不判——重试计数与超限落异常由
|
||||
* autoclaim 维护(区别于 rocket 在 dispatch 内做 retryTimes 早退)。
|
||||
*
|
||||
* @param fields stream 字段(含 enc/body)
|
||||
* @return 消费结果
|
||||
*/
|
||||
public ConsumeResult dispatchMessage(Map<String, String> fields) {
|
||||
String body = fields.get(StreamMessageConstant.F_BODY);
|
||||
T message;
|
||||
try {
|
||||
String enc = fields.get(StreamMessageConstant.F_ENC);
|
||||
String json = MessageCodec.decodeBody(enc, body);
|
||||
message = JSON.parseObject(json, messageType());
|
||||
} catch (Exception e) {
|
||||
// 毒消息:解码/反序列化失败 → 落异常 + ACK,避免反复重投卡死队列
|
||||
saveExceptionMsgLog(null, body, e);
|
||||
return ConsumeResult.ACK;
|
||||
}
|
||||
|
||||
if (filter(message)) {
|
||||
return ConsumeResult.ACK;
|
||||
}
|
||||
|
||||
try {
|
||||
handleMessage(message);
|
||||
consumeSuccess(message);
|
||||
return ConsumeResult.ACK;
|
||||
} catch (Exception e) {
|
||||
if (isRetry()) {
|
||||
return ConsumeResult.RETRY;
|
||||
}
|
||||
saveExceptionMsgLog(message, body, e);
|
||||
return ConsumeResult.ACK;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.njcn.middle.stream.support;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
|
||||
/**
|
||||
* Stream 名(key)构造器。
|
||||
*
|
||||
* <p>开启环境隔离且 env 非空时,对 topic 追加 {@code _env} 后缀,使各环境(如 dev/test/prod)
|
||||
* 共用一套 Redis 时互不串流;否则原样返回 topic。
|
||||
*/
|
||||
public class StreamKeyBuilder {
|
||||
|
||||
private final boolean envIsolation;
|
||||
private final String env;
|
||||
|
||||
public StreamKeyBuilder(boolean envIsolation, String env) {
|
||||
this.envIsolation = envIsolation;
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由 topic 构造实际 stream 名。
|
||||
*
|
||||
* @param topic 业务 topic
|
||||
* @return 开隔离且 env 非空 → {@code topic_env};否则 {@code topic}
|
||||
*/
|
||||
public String buildStream(String topic) {
|
||||
if (envIsolation && StrUtil.isNotBlank(env)) {
|
||||
return topic + "_" + env;
|
||||
}
|
||||
return topic;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.njcn.middle.stream.support;
|
||||
|
||||
import com.njcn.middle.stream.autoconfig.RedisStreamProperties;
|
||||
import com.njcn.middle.stream.handler.EnhanceStreamConsumerHandler;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.data.redis.core.RedisCallback;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 定时裁剪 stream 长度,防止无界增长。
|
||||
*
|
||||
* <p>按 {@code trim.intervalMs} 周期对每个 handler 的 stream 执行
|
||||
* {@code XTRIM <stream> MAXLEN ~ <maxlen>}({@code ~} 近似裁剪,性能更好)。
|
||||
*
|
||||
* <p>无 handler 时不启动。正确性由 C11 集成测试覆盖。
|
||||
*/
|
||||
@Slf4j
|
||||
public class StreamTrimScheduler implements SmartLifecycle {
|
||||
|
||||
private final StringRedisTemplate redis;
|
||||
private final StreamKeyBuilder keyBuilder;
|
||||
private final RedisStreamProperties props;
|
||||
private final List<EnhanceStreamConsumerHandler<?>> handlers;
|
||||
|
||||
private ScheduledExecutorService scheduler;
|
||||
private volatile boolean running = false;
|
||||
|
||||
public StreamTrimScheduler(StringRedisTemplate redis, StreamKeyBuilder keyBuilder,
|
||||
RedisStreamProperties props,
|
||||
List<EnhanceStreamConsumerHandler<?>> handlers) {
|
||||
this.redis = redis;
|
||||
this.keyBuilder = keyBuilder;
|
||||
this.props = props;
|
||||
this.handlers = handlers == null ? Collections.emptyList() : handlers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void start() {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
if (handlers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long interval = props.getTrim().getIntervalMs();
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "redis-stream-trim");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
scheduler.scheduleWithFixedDelay(this::trimAll, interval, interval, TimeUnit.MILLISECONDS);
|
||||
log.info("[redis-stream] trim scheduler started, interval={}ms maxlen={}", interval, props.getTrim().getMaxlen());
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void stop() {
|
||||
running = false;
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdownNow();
|
||||
scheduler = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
|
||||
private void trimAll() {
|
||||
long maxlen = props.getTrim().getMaxlen();
|
||||
for (String stream : distinctStreams()) {
|
||||
try {
|
||||
redis.execute((RedisCallback<Object>) connection -> connection.execute("XTRIM",
|
||||
stream.getBytes(StandardCharsets.UTF_8),
|
||||
"MAXLEN".getBytes(StandardCharsets.UTF_8),
|
||||
"~".getBytes(StandardCharsets.UTF_8),
|
||||
String.valueOf(maxlen).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception e) {
|
||||
log.warn("[redis-stream] trim failed stream={}", stream, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> distinctStreams() {
|
||||
Set<String> streams = new LinkedHashSet<>();
|
||||
for (EnhanceStreamConsumerHandler<?> handler : handlers) {
|
||||
streams.add(keyBuilder.buildStream(handler.topic()));
|
||||
}
|
||||
return streams;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.njcn.middle.stream.template;
|
||||
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.njcn.middle.stream.codec.MessageCodec;
|
||||
import com.njcn.middle.stream.constant.StreamMessageConstant;
|
||||
import com.njcn.middle.stream.domain.StreamBaseMessage;
|
||||
import com.njcn.middle.stream.support.StreamKeyBuilder;
|
||||
import org.springframework.data.redis.connection.stream.MapRecord;
|
||||
import org.springframework.data.redis.connection.stream.RecordId;
|
||||
import org.springframework.data.redis.connection.stream.StreamRecords;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Redis Stream 发送模板。
|
||||
*
|
||||
* <p>组装 XADD 字段 {@code key/tag/enc/body/ts}(对死 C++ 契约),仅 body 按 compress 压缩;
|
||||
* key/tag 从 message 取,不单独传参(故 Phase 2 风格B 须先 {@code message.setTag(nodeId)})。
|
||||
*/
|
||||
public class RedisStreamEnhanceTemplate {
|
||||
|
||||
private final StringRedisTemplate redis;
|
||||
private final StreamKeyBuilder keyBuilder;
|
||||
|
||||
public RedisStreamEnhanceTemplate(StringRedisTemplate redis, StreamKeyBuilder keyBuilder) {
|
||||
this.redis = redis;
|
||||
this.keyBuilder = keyBuilder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送一条消息到 topic 对应的 stream。
|
||||
*
|
||||
* @param topic 业务 topic(经 keyBuilder 转实际 stream 名)
|
||||
* @param message 消息(key/tag 从其取)
|
||||
* @param compress 是否压缩 body
|
||||
* @return XADD 返回的 RecordId
|
||||
*/
|
||||
public <T extends StreamBaseMessage> RecordId send(String topic, T message, boolean compress) {
|
||||
String stream = keyBuilder.buildStream(topic);
|
||||
String json = JSONObject.toJSONString(message);
|
||||
MessageCodec.Encoded encoded = MessageCodec.encodeBody(json, compress);
|
||||
|
||||
Map<String, String> fields = new HashMap<>(8);
|
||||
fields.put(StreamMessageConstant.F_KEY, message.getKey());
|
||||
fields.put(StreamMessageConstant.F_TAG, message.getTag());
|
||||
fields.put(StreamMessageConstant.F_ENC, encoded.getEnc());
|
||||
fields.put(StreamMessageConstant.F_BODY, encoded.getBody());
|
||||
fields.put(StreamMessageConstant.F_TS, String.valueOf(System.currentTimeMillis()));
|
||||
|
||||
MapRecord<String, String, String> record =
|
||||
StreamRecords.newRecord().in(stream).ofMap(fields);
|
||||
return redis.opsForStream().add(record);
|
||||
}
|
||||
}
|
||||
2
src/main/resources/META-INF/spring.factories
Normal file
2
src/main/resources/META-INF/spring.factories
Normal file
@@ -0,0 +1,2 @@
|
||||
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
|
||||
com.njcn.middle.stream.autoconfig.RedisStreamAutoConfiguration
|
||||
Reference in New Issue
Block a user