Commit be3fac75420232887eaad05c66ee028ec8baf52e

Authored by YunaiV
1 parent f5331ce6

1. 简单 redis stream 的 StreamMessage 和对应的消费者

2. 跑通 Redis Stream 的流程
src/main/java/cn/iocoder/dashboard/framework/redis/config/RedisConfig.java
1 1 package cn.iocoder.dashboard.framework.redis.config;
2 2  
  3 +import cn.hutool.core.net.NetUtil;
3 4 import cn.iocoder.dashboard.framework.redis.core.pubsub.AbstractChannelMessageListener;
  5 +import cn.iocoder.dashboard.framework.redis.core.stream.AbstractStreamMessageListener;
4 6 import lombok.extern.slf4j.Slf4j;
5 7 import org.springframework.context.annotation.Bean;
6 8 import org.springframework.context.annotation.Configuration;
7 9 import org.springframework.data.redis.connection.RedisConnectionFactory;
  10 +import org.springframework.data.redis.connection.stream.*;
8 11 import org.springframework.data.redis.core.RedisTemplate;
9 12 import org.springframework.data.redis.listener.ChannelTopic;
10 13 import org.springframework.data.redis.listener.RedisMessageListenerContainer;
11 14 import org.springframework.data.redis.serializer.RedisSerializer;
  15 +import org.springframework.data.redis.stream.StreamMessageListenerContainer;
  16 +import org.springframework.util.ErrorHandler;
12 17  
  18 +import java.time.Duration;
13 19 import java.util.List;
14 20  
15 21 /**
... ... @@ -48,4 +54,52 @@ public class RedisConfig {
48 54 return container;
49 55 }
50 56  
  57 + @Bean(initMethod = "start", destroyMethod = "stop")
  58 + public StreamMessageListenerContainer<String, ObjectRecord<String, String>> redisStreamMessageListenerContainer(
  59 + RedisConnectionFactory factory, List<AbstractStreamMessageListener<?>> listeners) {
  60 + // 创建配置对象
  61 + StreamMessageListenerContainer.StreamMessageListenerContainerOptions<String, ObjectRecord<String, String>>
  62 + streamMessageListenerContainerOptions = StreamMessageListenerContainer.StreamMessageListenerContainerOptions
  63 + .builder()
  64 + // 一次性最多拉取多少条消息
  65 + .batchSize(10)
  66 + // 执行消息轮询的执行器
  67 + // .executor(this.threadPoolTaskExecutor)
  68 + // 消息消费异常的handler
  69 + .errorHandler(new ErrorHandler() {
  70 + @Override
  71 + public void handleError(Throwable t) {
  72 + // throw new RuntimeException(t);
  73 + t.printStackTrace();
  74 + }
  75 + })
  76 + // 超时时间,设置为0,表示不超时(超时后会抛出异常)
  77 + .pollTimeout(Duration.ZERO)
  78 + // 序列化器
  79 + .serializer(RedisSerializer.string())
  80 + .targetType(String.class)
  81 + .build();
  82 +
  83 + // 根据配置对象创建监听容器对象
  84 + StreamMessageListenerContainer<String, ObjectRecord<String, String>> container = StreamMessageListenerContainer
  85 + .create(factory, streamMessageListenerContainerOptions);
  86 +
  87 + RedisTemplate<String, Object> redisTemplate = redisTemplate(factory);
  88 +
  89 + // 使用监听容器对象开始监听消费(使用的是手动确认方式)
  90 + String consumerName = NetUtil.getLocalHostName(); // TODO 需要优化下,晚点参考下 rocketmq consumer 的
  91 + for (AbstractStreamMessageListener<?> listener : listeners) {
  92 + try {
  93 + redisTemplate.opsForStream().createGroup(listener.getStreamKey(), listener.getGroup());
  94 + } catch (Exception ignore) {
  95 +// ignore.printStackTrace();
  96 + }
  97 +
  98 + container.receive(Consumer.from(listener.getGroup(), consumerName),
  99 + StreamOffset.create(listener.getStreamKey(), ReadOffset.lastConsumed()), listener);
  100 + }
  101 +
  102 + return container;
  103 + }
  104 +
51 105 }
... ...
src/main/java/cn/iocoder/dashboard/framework/redis/core/pubsub/ChannelMessage.java
... ... @@ -4,6 +4,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
4 4  
5 5 /**
6 6 * Redis Channel Message 接口
  7 + *
  8 + * @author 芋道源码
7 9 */
8 10 public interface ChannelMessage {
9 11  
... ... @@ -12,7 +14,7 @@ public interface ChannelMessage {
12 14 *
13 15 * @return Channel
14 16 */
15   - @JsonIgnore // 必须序列化
  17 + @JsonIgnore // 避免序列化
16 18 String getChannel();
17 19  
18 20 }
... ...
src/main/java/cn/iocoder/dashboard/framework/redis/core/stream/AbstractStreamMessageListener.java 0 → 100644
  1 +package cn.iocoder.dashboard.framework.redis.core.stream;
  2 +
  3 +import cn.hutool.core.util.ArrayUtil;
  4 +import lombok.Getter;
  5 +import lombok.SneakyThrows;
  6 +import org.springframework.beans.factory.annotation.Value;
  7 +import org.springframework.data.redis.connection.stream.ObjectRecord;
  8 +import org.springframework.data.redis.stream.StreamListener;
  9 +import sun.reflect.generics.reflectiveObjects.ParameterizedTypeImpl;
  10 +
  11 +import java.lang.reflect.Type;
  12 +
  13 +/**
  14 + * Redis Stream 监听器抽象类,用于实现集群消费
  15 + *
  16 + * @param <T> 消息类型。一定要填写噢,不然会报错
  17 + *
  18 + * @author 芋道源码
  19 + */
  20 +public abstract class AbstractStreamMessageListener<T extends StreamMessage>
  21 + implements StreamListener<String, ObjectRecord<String, String>> {
  22 +
  23 + /**
  24 + * 消息类型
  25 + */
  26 + private final Class<T> messageType;
  27 + /**
  28 + * Redis Channel
  29 + */
  30 + @Getter
  31 + private final String streamKey;
  32 +
  33 + /**
  34 + * Redis 消费者分组,默认使用 spring.application.name 名字
  35 + */
  36 + @Value("spring.application.name")
  37 + @Getter
  38 + private String group;
  39 +
  40 + @SneakyThrows
  41 + protected AbstractStreamMessageListener() {
  42 + this.messageType = getMessageClass();
  43 + this.streamKey = messageType.newInstance().getStreamKey();
  44 + }
  45 +
  46 + @Override
  47 + public void onMessage(ObjectRecord<String, String> message) {
  48 + System.out.println(message);
  49 + }
  50 +
  51 + /**
  52 + * 处理消息
  53 + *
  54 + * @param message 消息
  55 + */
  56 + public abstract void onMessage(T message);
  57 +
  58 + /**
  59 + * 通过解析类上的泛型,获得消息类型
  60 + *
  61 + * @return 消息类型
  62 + */
  63 + @SuppressWarnings("unchecked")
  64 + // TODO 芋艿:稍后重构
  65 + private Class<T> getMessageClass() {
  66 + Class<?> targetClass = getClass();
  67 + while (targetClass.getSuperclass() != null) {
  68 + // 如果不是 AbstractMessageListener 父类,继续向上查找
  69 + if (targetClass.getSuperclass() != AbstractStreamMessageListener.class) {
  70 + targetClass = targetClass.getSuperclass();
  71 + continue;
  72 + }
  73 + // 如果是 AbstractMessageListener 父类,则解析泛型
  74 + Type[] types = ((ParameterizedTypeImpl) targetClass.getGenericSuperclass()).getActualTypeArguments();
  75 + if (ArrayUtil.isEmpty(types)) {
  76 + throw new IllegalStateException(String.format("类型(%s) 需要设置消息类型", getClass().getName()));
  77 + }
  78 + return (Class<T>) types[0];
  79 + }
  80 + throw new IllegalStateException(String.format("类型(%s) 找不到 AbstractStreamMessageListener 父类", getClass().getName()));
  81 + }
  82 +
  83 +}
... ...
src/main/java/cn/iocoder/dashboard/framework/redis/core/stream/StreamMessage.java 0 → 100644
  1 +package cn.iocoder.dashboard.framework.redis.core.stream;
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonIgnore;
  4 +
  5 +/**
  6 + * Redis Stream Message 接口
  7 + *
  8 + * @author 芋道源码
  9 + */
  10 +public interface StreamMessage {
  11 +
  12 + /**
  13 + * 获得 Redis Stream Key
  14 + *
  15 + * @return Channel
  16 + */
  17 + @JsonIgnore // 避免序列化
  18 + String getStreamKey();
  19 +
  20 +}
... ...
src/main/java/cn/iocoder/dashboard/framework/redis/core/util/RedisMessageUtils.java
1 1 package cn.iocoder.dashboard.framework.redis.core.util;
2 2  
3 3 import cn.iocoder.dashboard.framework.redis.core.pubsub.ChannelMessage;
  4 +import cn.iocoder.dashboard.framework.redis.core.stream.StreamMessage;
4 5 import cn.iocoder.dashboard.util.json.JsonUtils;
  6 +import org.springframework.data.redis.connection.stream.RecordId;
  7 +import org.springframework.data.redis.connection.stream.StreamRecords;
5 8 import org.springframework.data.redis.core.RedisTemplate;
6 9  
7 10 /**
... ... @@ -17,8 +20,21 @@ public class RedisMessageUtils {
17 20 * @param redisTemplate Redis 操作模板
18 21 * @param message 消息
19 22 */
20   - public static <T extends ChannelMessage> void sendChannelMessage(RedisTemplate<?, ?> redisTemplate, T message) {
  23 + public static <T extends ChannelMessage> void sendChannelMessage(RedisTemplate<?, ?> redisTemplate, T message) {
21 24 redisTemplate.convertAndSend(message.getChannel(), JsonUtils.toJsonString(message));
22 25 }
23 26  
  27 + /**
  28 + * 发送 Redis 消息,基于 Redis Stream 实现
  29 + *
  30 + * @param redisTemplate Redis 操作模板
  31 + * @param message 消息
  32 + * @return 消息记录的编号对象
  33 + */
  34 + public static <T extends StreamMessage> RecordId sendStreamMessage(RedisTemplate<String, String> redisTemplate, T message) {
  35 + return redisTemplate.opsForStream().add(StreamRecords.newRecord()
  36 + .ofObject(JsonUtils.toJsonString(message)) // 设置内容
  37 + .withStreamKey(message.getStreamKey())); // 设置 stream key
  38 + }
  39 +
24 40 }
... ...
src/main/java/cn/iocoder/dashboard/modules/system/mq/consumer/mail/SysMailSendConsumer.java 0 → 100644
  1 +package cn.iocoder.dashboard.modules.system.mq.consumer.mail;
  2 +
  3 +import cn.iocoder.dashboard.framework.redis.core.stream.AbstractStreamMessageListener;
  4 +import cn.iocoder.dashboard.modules.system.mq.message.mail.SysMailSendMessage;
  5 +import org.springframework.stereotype.Component;
  6 +
  7 +@Component
  8 +public class SysMailSendConsumer extends AbstractStreamMessageListener<SysMailSendMessage> {
  9 +
  10 + @Override
  11 + public void onMessage(SysMailSendMessage message) {
  12 +
  13 + }
  14 +
  15 +}
... ...
src/main/java/cn/iocoder/dashboard/modules/system/mq/consumer/sms/SysSmsSendConsumer.java 0 → 100644
  1 +package cn.iocoder.dashboard.modules.system.mq.consumer.sms;
  2 +
  3 +import cn.iocoder.dashboard.framework.redis.core.stream.AbstractStreamMessageListener;
  4 +import cn.iocoder.dashboard.modules.system.mq.message.sms.SysSmsSendMessage;
  5 +import org.springframework.stereotype.Component;
  6 +
  7 +@Component
  8 +public class SysSmsSendConsumer extends AbstractStreamMessageListener<SysSmsSendMessage> {
  9 +
  10 + @Override
  11 + public void onMessage(SysSmsSendMessage message) {
  12 + System.out.println(message);
  13 + }
  14 +
  15 +}
... ...
src/main/java/cn/iocoder/dashboard/modules/system/mq/message/mail/SysMailSendMessage.java 0 → 100644
  1 +package cn.iocoder.dashboard.modules.system.mq.message.mail;
  2 +
  3 +import cn.iocoder.dashboard.framework.redis.core.stream.StreamMessage;
  4 +import lombok.Data;
  5 +
  6 +import javax.validation.constraints.NotNull;
  7 +import java.util.Map;
  8 +
  9 +/**
  10 + * 邮箱发送消息
  11 + *
  12 + * @author 芋道源码
  13 + */
  14 +@Data
  15 +public class SysMailSendMessage implements StreamMessage {
  16 +
  17 + /**
  18 + * 邮箱地址
  19 + */
  20 + @NotNull(message = "邮箱地址不能为空")
  21 + private String address;
  22 + /**
  23 + * 短信模板编号
  24 + */
  25 + @NotNull(message = "短信模板编号不能为空")
  26 + private String templateCode;
  27 + /**
  28 + * 短信模板参数
  29 + */
  30 + private Map<String, Object> templateParams;
  31 +
  32 + /**
  33 + * 用户编号,允许空
  34 + */
  35 + private Integer userId;
  36 + /**
  37 + * 用户类型,允许空
  38 + */
  39 + private Integer userType;
  40 +
  41 + @Override
  42 + public String getStreamKey() {
  43 + return "system.mail.send";
  44 + }
  45 +
  46 +}
... ...
src/main/java/cn/iocoder/dashboard/modules/system/mq/message/sms/SysSmsSendMessage.java 0 → 100644
  1 +package cn.iocoder.dashboard.modules.system.mq.message.sms;
  2 +
  3 +import cn.iocoder.dashboard.framework.redis.core.stream.StreamMessage;
  4 +import lombok.Data;
  5 +
  6 +import javax.validation.constraints.NotNull;
  7 +import java.util.Map;
  8 +
  9 +/**
  10 + * 短信发送消息
  11 + *
  12 + * @author 芋道源码
  13 + */
  14 +@Data
  15 +public class SysSmsSendMessage implements StreamMessage {
  16 +
  17 + /**
  18 + * 手机号
  19 + */
  20 + @NotNull(message = "手机号不能为空")
  21 + private String mobile;
  22 + /**
  23 + * 短信模板编号
  24 + */
  25 + @NotNull(message = "短信模板编号不能为空")
  26 + private String templateCode;
  27 + /**
  28 + * 短信模板参数
  29 + */
  30 + private Map<String, Object> templateParams;
  31 +
  32 + /**
  33 + * 用户编号,允许空
  34 + */
  35 + private Integer userId;
  36 + /**
  37 + * 用户类型,允许空
  38 + */
  39 + private Integer userType;
  40 +
  41 + @Override
  42 + public String getStreamKey() {
  43 + return "system.sms.send";
  44 + }
  45 +
  46 +}
... ...
src/test-integration/java/cn/iocoder/dashboard/BaseRedisIntegrationTest.java 0 → 100644
  1 +package cn.iocoder.dashboard;
  2 +
  3 +import cn.iocoder.dashboard.framework.redis.config.RedisConfig;
  4 +import org.redisson.spring.starter.RedissonAutoConfiguration;
  5 +import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
  6 +import org.springframework.boot.test.context.SpringBootTest;
  7 +import org.springframework.context.annotation.Import;
  8 +import org.springframework.test.context.ActiveProfiles;
  9 +
  10 +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = BaseRedisIntegrationTest.Application.class)
  11 +@ActiveProfiles("integration-test") // 设置使用 application-integration-test 配置文件
  12 +public class BaseRedisIntegrationTest {
  13 +
  14 + @Import({
  15 + // Redis 配置类
  16 + RedisAutoConfiguration.class, // Spring Redis 自动配置类
  17 + RedisConfig.class, // 自己的 Redis 配置类
  18 + RedissonAutoConfiguration.class, // Redisson 自动高配置类
  19 + })
  20 + public static class Application {
  21 + }
  22 +
  23 +}
... ...
src/test-integration/java/cn/iocoder/dashboard/framework/redis/core/stream/RedisStreamTest.java 0 → 100644
  1 +package cn.iocoder.dashboard.framework.redis.core.stream;
  2 +
  3 +import cn.hutool.core.thread.ThreadUtil;
  4 +import cn.iocoder.dashboard.BaseRedisIntegrationTest;
  5 +import cn.iocoder.dashboard.framework.redis.core.util.RedisMessageUtils;
  6 +import cn.iocoder.dashboard.modules.system.mq.consumer.mail.SysMailSendConsumer;
  7 +import cn.iocoder.dashboard.modules.system.mq.consumer.sms.SysSmsSendConsumer;
  8 +import cn.iocoder.dashboard.modules.system.mq.message.mail.SysMailSendMessage;
  9 +import cn.iocoder.dashboard.modules.system.mq.message.sms.SysSmsSendMessage;
  10 +import org.junit.jupiter.api.Test;
  11 +import org.springframework.context.annotation.Import;
  12 +import org.springframework.data.redis.core.StringRedisTemplate;
  13 +
  14 +import javax.annotation.Resource;
  15 +import java.util.concurrent.TimeUnit;
  16 +
  17 +public class RedisStreamTest {
  18 +
  19 + @Import({SysSmsSendConsumer.class, SysMailSendConsumer.class})
  20 + public static class ConsumerTest extends BaseRedisIntegrationTest {
  21 +
  22 + @Test
  23 + public void testConsumer() {
  24 + ThreadUtil.sleep(1, TimeUnit.DAYS);
  25 + }
  26 +
  27 + }
  28 +
  29 + public static class ProducerTest extends BaseRedisIntegrationTest {
  30 +
  31 + @Resource
  32 + private StringRedisTemplate stringRedisTemplate;
  33 +
  34 + @Test
  35 + public void testProducer01() {
  36 + // 创建消息
  37 + SysSmsSendMessage message = new SysSmsSendMessage();
  38 + message.setMobile("15601691300").setTemplateCode("test");
  39 + // 发送消息
  40 + RedisMessageUtils.sendStreamMessage(stringRedisTemplate, message);
  41 + }
  42 +
  43 + @Test
  44 + public void testProducer02() {
  45 + // 创建消息
  46 + SysMailSendMessage message = new SysMailSendMessage();
  47 + message.setAddress("fangfang@mihayou.com").setTemplateCode("test");
  48 + // 发送消息
  49 + RedisMessageUtils.sendStreamMessage(stringRedisTemplate, message);
  50 + }
  51 +
  52 + }
  53 +
  54 +}
... ...
src/test-integration/resources/application-integration-test.yaml 0 → 100644
  1 +spring:
  2 + main:
  3 + lazy-initialization: true # 开启懒加载,加快速度
  4 + banner-mode: off # 单元测试,禁用 Banner
  5 +
  6 +--- #################### 数据库相关配置 ####################
  7 +
  8 +spring:
  9 + # 数据源配置项
  10 + datasource:
  11 + name: ruoyi-vue-pro
  12 + url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写
  13 + driver-class-name: org.h2.Driver
  14 + username: sa
  15 + password:
  16 + schema: classpath:sql/create_tables.sql # MySQL 转 H2 的语句,使用 https://www.jooq.org/translate/ 工具
  17 + druid:
  18 + async-init: true # 单元测试,异步初始化 Druid 连接池,提升启动速度
  19 + initial-size: 1 # 单元测试,配置为 1,提升启动速度
  20 +
  21 + # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
  22 + redis:
  23 + host: 127.0.0.1 # 地址
  24 + port: 6379 # 端口(单元测试,使用 16379 端口)
  25 + database: 0 # 数据库索引
  26 +
  27 +mybatis:
  28 + lazy-initialization: true # 单元测试,设置 MyBatis Mapper 延迟加载,加速每个单元测试
  29 +
  30 +--- #################### 定时任务相关配置 ####################
  31 +
  32 +--- #################### 配置中心相关配置 ####################
  33 +
  34 +--- #################### 服务保障相关配置 ####################
  35 +
  36 +# Lock4j 配置项(单元测试,禁用 Lock4j)
  37 +
  38 +# Resilience4j 配置项
  39 +resilience4j:
  40 + ratelimiter:
  41 + instances:
  42 + backendA:
  43 + limit-for-period: 1 # 每个周期内,允许的请求数。默认为 50
  44 + limit-refresh-period: 60s # 每个周期的时长,单位:微秒。默认为 500
  45 + timeout-duration: 1s # 被限流时,阻塞等待的时长,单位:微秒。默认为 5s
  46 + register-health-indicator: true # 是否注册到健康监测
  47 +
  48 +--- #################### 监控相关配置 ####################
  49 +
  50 +--- #################### 芋道相关配置 ####################
  51 +
  52 +# 芋道配置项,设置当前项目所有自定义的配置
  53 +yudao:
  54 + info:
  55 + version: 1.0.0
  56 + base-package: cn.iocoder.dashboard
  57 + web:
  58 + api-prefix: /api
  59 + controller-package: ${yudao.info.base-package}
  60 + security:
  61 + token-header: Authorization
  62 + token-secret: abcdefghijklmnopqrstuvwxyz
  63 + token-timeout: 1d
  64 + session-timeout: 30m
  65 + mock-enable: true
  66 + mock-secret: test
  67 + swagger:
  68 + enable: false # 单元测试,禁用 Swagger
  69 + captcha:
  70 + timeout: 5m
  71 + width: 160
  72 + height: 60
  73 + file:
  74 + base-path: http://127.0.0.1:${server.port}/${yudao.web.api-prefix}/file/get/
  75 + codegen:
  76 + base-package: ${yudao.info.base-package}.modules
  77 + db-schemas: ${spring.datasource.name}
  78 + xss:
  79 + enable: false
  80 + exclude-urls: # 如下两个 url,仅仅是为了演示,去掉配置也没关系
  81 + - ${spring.boot.admin.context-path}/** # 不处理 Spring Boot Admin 的请求
  82 + - ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求
... ...
src/test/java/cn/iocoder/dashboard/modules/system/service/dept/SysDeptServiceTest.java
... ... @@ -72,7 +72,6 @@ class SysDeptServiceTest extends BaseDbUnitTest {
72 72 // 断言 maxUpdateTime 缓存
73 73 Date maxUpdateTime = (Date) getFieldValue(deptService, "maxUpdateTime");
74 74 assertEquals(ObjectUtils.max(deptDO1.getUpdateTime(), deptDO2.getUpdateTime()), maxUpdateTime);
75   -
76 75 }
77 76  
78 77 @Test
... ...