Spring Cloud 入门 ---- Stream 消息驱动
简介
为什么引入 cloud Stream
官网:https://docs.spring.io/spring-cloud-stream/docs/3.0.8.RELEASE/reference/html/
现在我们使用的消息中间件有很多,如:ActiveMQ、RabbitMQ、RocketMQ、Kafka等。比方说我们用到了RabbitMQ 和 Kafka,由于这两个消息中间件的架构上的不同,像 RabbitMQ 有 exchange,kafka 有 Topic 和 Partitions 分区。这些中间件的差异性在项目实际开发中会给我们造成了一定的困扰,我们如果用到了两个消息队列的其中一种,后面的业务需求变动,我们想往另一种消息队列进行迁移,这时候无疑就是一个灾难性的,
一大堆东西需要重新推倒重做
;因为它跟我们的系统耦合了,这时候 Spring Cloud Stream 给我们提供了一种解耦合的方式。一句话:Spring Cloud Stream 可以屏蔽底层消息中间件的差异,降低切换成本,统一消息的编程模型。
什么是 Spring Cloud Stream
官方定义 Spring Cloud Stream 是一个构建消息驱动微服务的框架。
应用程序通过 inputs 或者 outputs 来与Spring Cloud Stream 中 binder 对象交互。通过我们配置来 binding(绑定),而 Spring Cloud Stream 的 binder 对象负责与消息中间件交互。所以,我们只需要搞清楚如何与 Spring Cloud Stream 交互就可以方便使用消息驱动的方式。
通过使用 Spring Integration 来连接消息代理中间件以实现消息事件驱动。Spring Cloud Stream 为一些供应商的消息中间件产品提供了个性化的自动化配置实现,引用了 发布-订阅、消费组、分区的三个核心概念。
目前仅支持 RabbitMQ Kafka。
Stream 设计思想
在没有绑定器这个概念的情况下,我们的 SpringBoot 应用要直接与消息中间件进行信息交互的时候,由于各消息中间件构建的初衷不同,它们的实现细节上会有较大的差异性。
通过定义 Binder(绑定器)作为中间层,完美的实现了应用程序与消息中间件细节之间的隔离
。通过应用程序暴露统一的 Channel 通道,使得应用程序不需要再考虑各种不同的消息中间件实现。Stream 对消息中间件的进一步封装,可以做到代码层面对中间件的无感知,甚至于动态的切换中间件(rabbitmq 切换为 kafka),使得微服务开发的高度解耦,服务可以更多关注于自己的业务流程。Stream 中的消息通信方式遵循了发布-订阅模式,Topic 主题进行广播,在 RabbitMQ 中就是 Exchange,在 Kafka 中就是 Topic。
Binder
INPUT 对应于消费者,OUTPUT 对应于生产者。
概念与常用注解
- Binder :绑定,用于连接中间件,屏蔽差异
- Channel:通道,是队列 Queue 的一种抽象,在消息通讯系统中就是实现存储和转发的媒介,通过 Channel 对队列进行配置。
- Source/Sink:简单的可以理解为参照对象是 Spring Cloud Stream 自身,从 Stream 发布消息就是输出,接收消息就是输入。
Stream 消息驱动之生产者
我们使用 RabbitMQ作为演示的中间件,由于前面的章节已经介绍了 RabbitMQ 的安装,这里就不做赘述了。官网:https://docs.spring.io/spring-cloud-stream-binder-rabbit/docs/3.0.8.RELEASE/reference/html/spring-cloud-stream-binder-rabbit.html
创建消息生产者
导入 pom 依赖
<!--stream-rabbitMQ-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
</dependency>
添加 yml 配置
server:
port: 8801
spring:
application:
name: stream-provider-service
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
virtual-host: /
cloud:
stream:
binders: #在此处配置要绑定的 rabbitmq的服务信息;
defaultRabbit: #表示定义的名称,用于与 binding 整合
type: rabbit #消息组件类型
bindings: #服务的整合处理
output: #这个名字是一个通道的名称
destination: studyExchange #表示要使用 Exchange 名称定义
content-tpe: application/json #设置消息类型,本次为json,文本则设置为 text/plain
eureka-connection:
name: akieay
password: 1qaz2wsx
eureka:
client:
#表示是否将自己注册进 Eureka Server服务 默认为true
register-with-eureka: true
#f是否从Eureka Server抓取已有的注册信息,默认是true。单点无所谓,集群必需设置为true才能配合ribbon使用负载均衡
fetch-registry: true
service-url: # 设置与 Eureka Server 交互的地址 查询服务与注册服务都需要这个地址
# defaultZone: http://localhost:7001/eureka
defaultZone: http://${eureka-connection.name}:${eureka-connection.password}@eureka7001.com:7001/eureka,http://${eureka-connection.name}:${eureka-connection.password}@eureka7002.com:7002/eureka
instance:
instance-id: stream-provider-8801
## 当调用getHostname获取实例的hostname时,返回ip而不是host名
prefer-ip-address: true
# Eureka客户端向服务端发送心跳的时间间隔,单位秒(默认30秒)
lease-renewal-interval-in-seconds: 10
# Eureka服务端在收到最后一次心跳后的等待时间上限,单位秒(默认90秒)
lease-expiration-duration-in-seconds: 30
主启动
@SpringBootApplication
@EnableEurekaClient
public class StreamProviderApplication {
public static void main(String[] args) {
SpringApplication.run(StreamProviderApplication.class, args);
}
}
业务类
//@EnableBinding 定义消息的推送管道
@EnableBinding(Source.class)
public class MessageProviderImpl implements MessageProvider {
/**
* 消息发送管道
*/
@Resource
private MessageChannel output;
@Override
public void send(String message) {
//构建消息
Message<String> build = MessageBuilder.withPayload(message).build();
//发送消息
output.send(build);
}
}
@RestController
@Slf4j
public class MessageProviderController {
@Resource
private MessageProvider messageProvider;
@GetMapping(value = "/sendMessage")
public String sendMessage() {
String serial = UUID.randomUUID().toString();
messageProvider.send(serial);
log.info("******* send message: " + serial);
return serial;
}
}
启动测试
启动 RabbitMQ,启动服务,进入 Eureka 注册中心查看服务是否注册成功;
访问:http://localhost:15672/#/exchanges 查看交换器列表,可以看到我们自定义的交换器
studyExchange
多次访问:http://localhost:8801/sendMessage,点击
studyExchange
即可看到我们发送消息的折线图。
Stream 消息驱动之消费者
创建消息消费者
导入 pom 依赖
<!--stream-rabbitMQ-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
</dependency>
添加 yml 配置文件
server:
port: 8803
spring:
application:
name: stream-consumer-service
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
virtual-host: /
cloud:
stream:
binders: #在此处配置要绑定的 rabbitmq的服务信息;
defaultRabbit: #表示定义的名称,用于与 binding 整合
type: rabbit #消息组件类型
bindings: #服务的整合处理
input: #这个名字是一个通道的名称
destination: studyExchange #表示要使用 Exchange 名称定义
content-type: application/json #设置消息类型,本次为json,文本则设置为 text/plain
eureka-connection:
name: akieay
password: 1qaz2wsx
eureka:
client:
#表示是否将自己注册进 Eureka Server服务 默认为true
register-with-eureka: true
#f是否从Eureka Server抓取已有的注册信息,默认是true。单点无所谓,集群必需设置为true才能配合ribbon使用负载均衡
fetch-registry: true
service-url: # 设置与 Eureka Server 交互的地址 查询服务与注册服务都需要这个地址
# defaultZone: http://localhost:7001/eureka
defaultZone: http://${eureka-connection.name}:${eureka-connection.password}@eureka7001.com:7001/eureka,http://${eureka-connection.name}:${eureka-connection.password}@eureka7002.com:7002/eureka
instance:
instance-id: stream-consumer-8803
## 当调用getHostname获取实例的hostname时,返回ip而不是host名
prefer-ip-address: true
# Eureka客户端向服务端发送心跳的时间间隔,单位秒(默认30秒)
lease-renewal-interval-in-seconds: 10
# Eureka服务端在收到最后一次心跳后的等待时间上限,单位秒(默认90秒)
lease-expiration-duration-in-seconds: 30
主启动
@SpringBootApplication
@EnableEurekaClient
public class StreamConsumerApplication {
public static void main(String[] args) {
SpringApplication.run(StreamConsumerApplication.class, args);
}
}
业务类
//@EnableBinding 定义消息的接收管道
@Component
@Slf4j
@EnableBinding(Sink.class)
public class ReceiveMessageListener {
@Value("${server.port}")
private String serverPort;
@StreamListener(Sink.INPUT)
public void input(Message<String> message) {
log.info("消息消费者----->接收到消息: " + message.getPayload() + "\t serverPort: " + serverPort);
}
}
启动测试
启动服务,打开 Eureka 注册中心查看服务是否注册成功,打卡 rabbitmq 控制台
studyExchange
交换器,查看绑定列表。
多次访问:http://localhost:8801/sendMessage 发送消息,点击
studyExchange.anonymous.1xwWv_V5QKGkQpd5fjRPRg
进入队列 即可看到消息的折线图和统计信息。
进入控制台,即可看到消息消费者接收打印出来的消息信息。
至此,简单的消息发送与接收的演示完毕,在接收消息后,可根据实际情况添加自己的业务出来逻辑。
分组消费与持久化
消费者集群准备
修改 消费服务 的配置文件名称为
application-one.yml
,并复制一份为application-two.yml
并修改端口号为 8804。
server:
port: 8804
eureka:
client:
instance-id: stream-consumer-8804
修改原来服务的启动配置,并 copy 该服务创建第二个服务节点。
启动消息消费者集群节点 8803 8804,注册中心查看是否注册成功。
访问:http://localhost:8801/sendMessage ,查看控制台,可以看到同一份消息 8803 与 8804 都有消费,这就产生了
重复消费
的问题。
Stream 之消息重复消费
比如在如下场景中,订单系统我们做了集群部署,都会从 RabbitMQ 中获取订单信息,
如果一个订单同时被两个服务获取到
,那么就会造成数据错误,我们得避免出现这种情况。这时我们就可以使用Stream 中的消息分组
来解决这个问题。
注意:在 Stream 中处于
同一个 group 中的多个消费者是竞争关系
,就能够保证消息只会被其中一个应用消费一次。不同的组是可以全面消费的(重复消费)
,比如我们上面测试 8803 与 8804 服务产生的重复消费问题,这是由于我们没有指定分组时,系统默认会为每一个服务单独创建一个分组,如下图:
Stream 分组解决重复消费问题
从上面的分析我们可以看出,导致重复消费的原因是由于默认分组 group 是不同的,所以我们可以通过自定义配置分组解决重复消费的问题。修改消费者配置文件,两个配置文件都添加分组,并且分组都为:studyGroup
spring:
cloud:
stream:
bindings: #服务的整合处理
input: #这个名字是一个通道的名称
destination: studyExchange #表示要使用 Exchange 名称定义
content-type: application/json #设置消息类型,本次为json,文本则设置为 text/plain
group: studyGroup #设置分组
重启服务,多次访问:http://localhost:8801/sendMessage 并查看控制台,可以看到同一条消息将只会在 8803 或 8804 其中一个服务中消费一次,不再会出现重复消费的现象。
Stream 之消息持久化
通过上述,我们解决了重复消费的问题,再看看持久化。消息持久化:当我们的消息消费者服务在运行期间突然宕机时,重启消费者服务后,消费者会不会从 rabbitMQ 中读取我们在宕机期间发送的消息并消费。
运行演示
首先我们需要关闭消费者 8803 与 8804,并删除 8803 的分组配置,如下:
8801 先发送4条消息到 rabbitmq,即访问四次:http://localhost:8801/sendMessage
重启 消息消费者 8803 并查看控制台,从日志中我们可以看到 8803 重启后,并没有重 rabbitmq 中获取我们之前发送的消息。
重启 消费者 8804 并查看控制台,从日志中我们可以看到 8804 重启后,从 rabbitmq 中获取到了我们之前发送的消息并消费。
总结:
由此我们可以看出,在创建服务消费者时是有必要指定 group 的,它不仅可以解决重复消费问题,还可以解决消息的持久化问题
。