Merge: mq-starter Phase2 message 切片接入(方案A)
Task9 切片(FrontDataMqListener LN_Topic 上行 / RecallMqSender Recall 下行, legacy 按 mq.type 互斥, rocketmq 缺省不变不双消费) + 真实 autoconfig 顺序 wiring 测试; 配套 mq-starter 装配顺序修复(@AutoConfigureBefore)+护栏已推 Gitea(9f6ffad..e599171); 最终全分支 review(opus) 无 Critical.
This commit is contained in:
@@ -100,12 +100,34 @@
|
|||||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
<version>2.7.12</version>
|
<version>2.7.12</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.njcn</groupId>
|
||||||
|
<artifactId>mq-spring-boot-starter</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<version>2.7.12</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>junit</groupId>
|
||||||
|
<artifactId>junit</artifactId>
|
||||||
|
<version>4.13.2</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<finalName>messageboot</finalName>
|
<finalName>messageboot</finalName>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>2.22.2</version>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-compiler-plugin</artifactId>
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import java.util.concurrent.TimeUnit;
|
|||||||
* @createTime 2023/8/11 15:32
|
* @createTime 2023/8/11 15:32
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
|
@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(
|
||||||
|
name = "mq.type", havingValue = "rocketmq", matchIfMissing = true)
|
||||||
@RocketMQMessageListener(
|
@RocketMQMessageListener(
|
||||||
topic = "LN_Topic",
|
topic = "LN_Topic",
|
||||||
consumerGroup = "ln_consumer",
|
consumerGroup = "ln_consumer",
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.njcn.message.mq;
|
||||||
|
|
||||||
|
import com.njcn.message.messagedto.MessageDataDTO;
|
||||||
|
import com.njcn.mq.annotation.MqListener;
|
||||||
|
import com.njcn.stat.api.MessAnalysisFeignClient;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "mq.type", havingValue = "redis-stream")
|
||||||
|
public class FrontDataMqListener {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private MessAnalysisFeignClient messAnalysisFeignClient;
|
||||||
|
|
||||||
|
@MqListener(topic = "LN_Topic", group = "ln_consumer")
|
||||||
|
public void onFrontData(MessageDataDTO msg) {
|
||||||
|
messAnalysisFeignClient.analysis(Collections.singletonList(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.njcn.message.mq;
|
||||||
|
|
||||||
|
import com.njcn.message.constant.BusinessTopic;
|
||||||
|
import com.njcn.message.message.RecallMessage;
|
||||||
|
import com.njcn.mq.core.MqTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class RecallMqSender {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private MqTemplate mqTemplate;
|
||||||
|
|
||||||
|
public void send(RecallMessage message, String nodeId) {
|
||||||
|
mqTemplate.send(nodeId + "_" + BusinessTopic.RECALL_TOPIC, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package com.njcn.message.mq;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSON;
|
||||||
|
import com.njcn.message.message.RecallMessage;
|
||||||
|
import com.njcn.message.messagedto.MessageDataDTO;
|
||||||
|
import com.njcn.middle.stream.autoconfig.RedisStreamAutoConfiguration;
|
||||||
|
import com.njcn.mq.autoconfig.MqCoreAutoConfiguration;
|
||||||
|
import com.njcn.mq.container.MqListenerRegistry;
|
||||||
|
import com.njcn.mq.core.MqTemplate;
|
||||||
|
import com.njcn.mq.driver.redis.RedisStreamMqDriver;
|
||||||
|
import com.njcn.mq.driver.redis.RedisStreamMqDriverAutoConfiguration;
|
||||||
|
import com.njcn.mq.driver.rocketmq.RocketMqDriver;
|
||||||
|
import com.njcn.mq.driver.rocketmq.RocketMqDriverAutoConfiguration;
|
||||||
|
import com.njcn.mq.spi.MqDriver;
|
||||||
|
import com.njcn.stat.api.MessAnalysisFeignClient;
|
||||||
|
import org.apache.rocketmq.spring.autoconfigure.RocketMQProperties;
|
||||||
|
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||||
|
import org.junit.jupiter.api.Assumptions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||||
|
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
|
||||||
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||||
|
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.connection.stream.StreamRecords;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.timeout;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MQ starter 切片接入验证测试(ApplicationContextRunner,不启动全应用)。
|
||||||
|
* 真实 Redis 要求:localhost:6379(docker redis-stream-it);仅上/下行收发用例需要,
|
||||||
|
* 纯装配用例不需要(Lettuce 懒连接,不触发实际连接)。
|
||||||
|
*
|
||||||
|
* <p>装配一律用 {@code withConfiguration(AutoConfigurations.of(...))},让 Spring 按
|
||||||
|
* <b>真实 autoconfig 顺序</b>(AutoConfigurationSorter)处理,与生产环境一致。以此
|
||||||
|
* 验证 {@link MqCoreAutoConfiguration} 的 {@code @ConditionalOnBean(MqDriver)} 能在
|
||||||
|
* 真实顺序下拿到 driver bean —— 这依赖 driver autoconfig 上的
|
||||||
|
* {@code @AutoConfigureBefore(MqCoreAutoConfiguration.class)}。若缺该保障,按类名字母序
|
||||||
|
* {@code com.njcn.mq.autoconfig.*} 先于 {@code com.njcn.mq.driver.*},core 会在 driver
|
||||||
|
* bean 注册前求值条件失败,导致 MqTemplate / MqListenerRegistry 不装配。
|
||||||
|
*/
|
||||||
|
public class MqSliceWiringTest {
|
||||||
|
|
||||||
|
private static boolean redisAvailable() {
|
||||||
|
try {
|
||||||
|
Socket s = new Socket();
|
||||||
|
s.connect(new InetSocketAddress("localhost", 6379), 500);
|
||||||
|
s.close();
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** redis-stream 模式:真实 autoconfig 顺序装 5 个 autoconfig + 业务 bean(上/下行用)。 */
|
||||||
|
private ApplicationContextRunner redisStreamRunner() {
|
||||||
|
return new ApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(
|
||||||
|
RedisAutoConfiguration.class,
|
||||||
|
RedisStreamAutoConfiguration.class,
|
||||||
|
RedisStreamMqDriverAutoConfiguration.class,
|
||||||
|
RocketMqDriverAutoConfiguration.class,
|
||||||
|
MqCoreAutoConfiguration.class))
|
||||||
|
.withUserConfiguration(FrontDataMqListener.class, RecallMqSender.class)
|
||||||
|
.withBean(MessAnalysisFeignClient.class,
|
||||||
|
() -> mock(MessAnalysisFeignClient.class))
|
||||||
|
.withPropertyValues(
|
||||||
|
"mq.type=redis-stream",
|
||||||
|
"spring.redis.host=localhost",
|
||||||
|
"spring.redis.port=6379");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 1. 装配:mq.type 二选一 + core bean 在真实 autoconfig 顺序下装配
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRedisStreamDriverSelected() {
|
||||||
|
// 纯装配检查,不依赖真实 Redis(Lettuce 懒连接)。
|
||||||
|
new ApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(
|
||||||
|
RedisAutoConfiguration.class,
|
||||||
|
RedisStreamAutoConfiguration.class,
|
||||||
|
RedisStreamMqDriverAutoConfiguration.class,
|
||||||
|
RocketMqDriverAutoConfiguration.class,
|
||||||
|
MqCoreAutoConfiguration.class))
|
||||||
|
.withPropertyValues(
|
||||||
|
"mq.type=redis-stream",
|
||||||
|
"spring.redis.host=localhost",
|
||||||
|
"spring.redis.port=6379")
|
||||||
|
.run(ctx -> {
|
||||||
|
assertThat(ctx).hasSingleBean(MqDriver.class);
|
||||||
|
assertThat(ctx.getBean(MqDriver.class)).isInstanceOf(RedisStreamMqDriver.class);
|
||||||
|
// 真实顺序下 core 的 @ConditionalOnBean(MqDriver) 必须命中
|
||||||
|
assertThat(ctx).hasSingleBean(MqTemplate.class);
|
||||||
|
assertThat(ctx).hasSingleBean(MqListenerRegistry.class);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRocketMqDriverSelected() {
|
||||||
|
// rocket 测试不依赖真实 Redis;不装 RedisAutoConfiguration,redis driver 因 mq.type 不匹配而 inactive。
|
||||||
|
new ApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(
|
||||||
|
RedisStreamMqDriverAutoConfiguration.class,
|
||||||
|
RocketMqDriverAutoConfiguration.class,
|
||||||
|
MqCoreAutoConfiguration.class))
|
||||||
|
.withBean(RocketMQTemplate.class, () -> mock(RocketMQTemplate.class))
|
||||||
|
.withBean(RocketMQProperties.class, RocketMQProperties::new)
|
||||||
|
.withPropertyValues("mq.type=rocketmq")
|
||||||
|
.run(ctx -> {
|
||||||
|
assertThat(ctx).hasSingleBean(MqDriver.class);
|
||||||
|
assertThat(ctx.getBean(MqDriver.class)).isInstanceOf(RocketMqDriver.class);
|
||||||
|
assertThat(ctx).hasSingleBean(MqTemplate.class);
|
||||||
|
assertThat(ctx).hasSingleBean(MqListenerRegistry.class);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 2. 上行(需要真实 Redis)
|
||||||
|
// 向 LN_Topic stream XADD 一条消息,验证 FrontDataMqListener 触发了
|
||||||
|
// messAnalysisFeignClient.analysis(...)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUpstreamFrontData() {
|
||||||
|
Assumptions.assumeTrue(redisAvailable(), "Redis not available at localhost:6379, skipping");
|
||||||
|
redisStreamRunner().run(ctx -> {
|
||||||
|
StringRedisTemplate redis = ctx.getBean(StringRedisTemplate.class);
|
||||||
|
MessAnalysisFeignClient mockClient = ctx.getBean(MessAnalysisFeignClient.class);
|
||||||
|
|
||||||
|
// 构造 RedisStreamMqDriver 消费时期望的字段格式
|
||||||
|
// enc=plain → body 即为原始 JSON(参见 MessageCodec.encodeBody(json, false))
|
||||||
|
// stream key = StreamKeyBuilder.buildStream("LN_Topic") = "LN_Topic"
|
||||||
|
// (默认 envIsolation=false,无 env 前缀)
|
||||||
|
MessageDataDTO dto = new MessageDataDTO();
|
||||||
|
dto.setDataType(1);
|
||||||
|
String json = JSON.toJSONString(dto);
|
||||||
|
|
||||||
|
Map<String, String> fields = new HashMap<String, String>();
|
||||||
|
fields.put("key", "test-key-upstream-1");
|
||||||
|
fields.put("tag", "");
|
||||||
|
fields.put("enc", "plain");
|
||||||
|
fields.put("body", json);
|
||||||
|
fields.put("ts", String.valueOf(System.currentTimeMillis()));
|
||||||
|
|
||||||
|
// XADD 到 LN_Topic(MqListenerRegistry.start() 已在 context 启动时订阅该 stream)
|
||||||
|
redis.opsForStream().add(
|
||||||
|
StreamRecords.newRecord().in("LN_Topic").ofMap(fields)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 等待消费线程处理(blockMs 默认 2000ms,timeout=5000ms 足够)
|
||||||
|
verify(mockClient, timeout(5000)).analysis(anyList());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 3. 下行(需要真实 Redis)
|
||||||
|
// 通过 RecallMqSender 发送 RecallMessage,验证 stream 内容正确
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDownstreamRecall() {
|
||||||
|
Assumptions.assumeTrue(redisAvailable(), "Redis not available at localhost:6379, skipping");
|
||||||
|
redisStreamRunner().run(ctx -> {
|
||||||
|
StringRedisTemplate redis = ctx.getBean(StringRedisTemplate.class);
|
||||||
|
RecallMqSender sender = ctx.getBean(RecallMqSender.class);
|
||||||
|
|
||||||
|
// 清理,确保只有本次发送的消息
|
||||||
|
redis.delete("n1_recall_Topic");
|
||||||
|
|
||||||
|
RecallMessage msg = new RecallMessage();
|
||||||
|
msg.setGuid("g1");
|
||||||
|
// MqTemplate.send("n1_recall_Topic", msg) → RedisStreamMqDriver XADD
|
||||||
|
// stream key = StreamKeyBuilder.buildStream("n1_recall_Topic") = "n1_recall_Topic"
|
||||||
|
sender.send(msg, "n1");
|
||||||
|
|
||||||
|
Long size = redis.opsForStream().size("n1_recall_Topic");
|
||||||
|
assertThat(size).isGreaterThanOrEqualTo(1L);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<MapRecord<String, Object, Object>> records =
|
||||||
|
redis.opsForStream().read(
|
||||||
|
StreamReadOptions.empty().count(1L),
|
||||||
|
StreamOffset.create("n1_recall_Topic", ReadOffset.from("0-0"))
|
||||||
|
);
|
||||||
|
assertThat(records).isNotEmpty();
|
||||||
|
|
||||||
|
Map<Object, Object> firstFields = records.get(0).getValue();
|
||||||
|
// DefaultMqTemplate.send(topic, payload) 使用 encodeBody(json, false) → enc="plain"
|
||||||
|
// 因此 body 即为原始 JSON,无需 gzip 解压
|
||||||
|
String body = (String) firstFields.get("body");
|
||||||
|
RecallMessage result = JSON.parseObject(body, RecallMessage.class);
|
||||||
|
assertThat(result.getGuid()).isEqualTo("g1");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user