9.1 spring cloud stream 简介
spring cloud stream 是一个用来为微服务应用提供消息驱动能力的框架. 它可以基于 springboot 来单独的创建独立的,可用于生产的 spring 的应用程序. 它通过使用 spring integration 来连接消息代理中间件以实现消息事件驱动. 它为一些一些供应商的消息中间件产品提供了个性化的自动化配置,并且引入了发布-订阅,消费组以及分区这三个核心概念.
快速入门
下面我们通过构建一个简单的示例来对Spring Cloud Stream有一个初步认识。该示例主要目标是构建一个基于Spring Boot的微服务应用,这个微服务应用将通过使用消息中间件RabbitMQ来接收消息并将消息打印到日志中。所以,在进行下面步骤之前请先确认已经在本地安装了RabbitMQ
构建一个Spring Cloud Stream消费者
-
创建一个基础的Spring Boot工程,命名为:stream-hello
-
编辑 build.gradle 中的依赖关系,引入Spring Cloud Stream对RabbitMQ的支持,具体如下:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.cloud:spring-cloud-starter-stream-rabbit'
// implementation 'org.springframework.cloud:spring-cloud-starter-stream-kafka'
}
- 创建用于接收来自RabbitMQ消息的消费者SinkReceiver,具体如下:
@EnableBinding(Sink.class)
public class SinkReceiver {
private static Logger logger = LoggerFactory.getLogger(SinkReceiver.class);
@StreamListener(Sink.INPUT)
public void receive(Object payload) {
logger.info("Received: " + payload);
}
}
- 创建应用主类,这里同其他Spring Boot一样,没有什么特别之处,具体如下:
@SpringBootApplication
public class SinkApplication {
public static void main(String[] args) {
SpringApplication.run(SinkApplication.class, args);
}
}
手工测试验证
- 我们先来看一下Spring Boot应用的启动日志。
...
INFO 16272 --- [main] o.s.c.s.b.r.RabbitMessageChannelBinder : declaring queue for inbound: input.anonymous.Y8VsFILmSC27eS5StsXp6A, bound to: input
INFO 16272 --- [main] o.s.a.r.c.CachingConnectionFactory : Created new connection: SimpleConnection@3c78e551 [delegate=amqp://guest@127.0.0.1:5672/]
INFO 16272 --- [main] o.s.integration.channel.DirectChannel : Channel 'input.anonymous.Y8VsFILmSC27eS5StsXp6A.bridge' has 1 subscriber(s).
INFO 16272 --- [main] o.s.i.a.i.AmqpInboundChannelAdapter : started inbound.input.anonymous.Y8VsFILmSC27eS5StsXp6A
...
从上面的日志内容中,我们可以获得以下信息:
- 使用guest用户创建了一个指向127.0.0.1:5672位置的RabbitMQ连接,在RabbitMQ的控制台中我们也可以发现它。
- 声明了一个名为input.anonymous.Y8VsFILmSC27eS5StsXp6A的队列,并通过RabbitMessageChannelBinder将自己绑定为它的消费者。这些信息我们也能在RabbitMQ的控制台中发现它们。
- 下面我们可以在RabbitMQ的控制台中进入input.anonymous.Y8VsFILmSC27eS5StsXp6A队列的管理页面,通过Publish Message功能来发送一条消息到该队列中。
- 此时,我们可以在当前启动的Spring Boot应用程序的控制台中看到下面的内容:
INFO 16272 --- [C27eS5StsXp6A-1] com.didispace.HelloApplication : Received: [B@7cba610e
我们可以发现在应用控制台中输出的内容就是SinkReceiver中receive方法定义的,而输出的具体内容则是来自消息队列中获取的对象。这里由于我们没有对消息进行序列化,所以输出的只是该对象的引用,在后面的小节中我们会详细介绍接收消息后的处理。
在顺利完成上面快速入门的示例后,我们简单解释一下上面的步骤是如何将我们的Spring Boot应用连接上RabbitMQ来消费消息以实现消息驱动业务逻辑的。
首先,我们对Spring Boot应用做的就是引入spring-cloud-starter-stream-rabbit依赖,该依赖包是Spring Cloud Stream对RabbitMQ支持的封装,其中包含了对RabbitMQ的自动化配置等内容。从下面它定义的依赖关系中,我们还可以知道它等价于spring-cloud-stream-binder-rabbit依赖。
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-binder-rabbit</artifactId>
</dependency>
</dependencies>
接着,我们再来看看这里用到的几个Spring Cloud Stream的核心注解,它们都被定义在SinkReceiver中:
- @EnableBinding,该注解用来指定一个或多个定义了@Input或@Output注解的接口,以此实现对消息通道(Channel)的绑定。在上面的例子中,我们通过@EnableBinding(Sink.class)绑定了Sink接口,该接口是Spring Cloud Stream中默认实现的对输入消息通道绑定的定义,它的源码如下:
public interface Sink {
String INPUT = "input";
@Input(Sink.INPUT)
SubscribableChannel input();
}
它通过@Input注解绑定了一个名为input的通道。除了Sink之外,Spring Cloud Stream还默认实现了绑定output通道的Source接口,还有结合了Sink和Source的Processor接口,实际使用时我们也可以自己通过@Input和@Output注解来定义绑定消息通道的接口。当我们需要为@EnableBinding指定多个接口来绑定消息通道的时候,可以这样定义:@EnableBinding(value = {Sink.class, Source.class})。
- @StreamListener:该注解主要定义在方法上,作用是将被修饰的方法注册为消息中间件上数据流的事件监听器,注解中的属性值对应了监听的消息通道名。在上面的例子中,我们通过@StreamListener(Sink.INPUT)注解将receive方法注册为对input消息通道的监听处理器,所以当我们在RabbitMQ的控制页面中发布消息的时候,receive方法会做出对应的响应动作。
绑定消息通道
在 spring cloud stream 中,我们可以在接口中通过 @Input 和 @Output 注解来定义消息通道,而用于绑定消息通道的接口则可以被 @EnableBinding 注解的 value 参数来指定,从而在应用启动时实现对定义消息通道的绑定.
@Input 和 @Output 注解都还有一个 value 属性,该属性可以用来设置消息通道的名称,这里 Sink 和 Source 中指定的消息通道名称分别为 input 和 output. 如果我们直接使用这两个注解,而没有指定具体的 value 值,将默认使用方法名作为消息通道的名称.
注意了: 在通过项目中,如果 @Input 与 @Outpu 的 value 值一样, 会报错的, 我们为了确保输入与输出都是用相同的 queue, 需要在 application.properties 中使用 # 指定 input 通道的 queue spring.cloud.stream.bindings.{输入/输出的消息通道}.destination=xxxx
9.2 核心概念
对Spring Cloud Stream的工作模式已经有了一些基础概念,比如:输入、输出通道的绑定,通道消息事件的监听等。
下图是官方文档中对于Spring Cloud Stream应用模型的结构图。从中我们可以看到,Spring Cloud Stream构建的应用程序与消息中间件之间是通过绑定器Binder相关联的,绑定器对于应用程序而言起到了隔离作用,它使得不同消息中间件的实现细节对应用程序来说是透明的。所以对于每一个Spring Cloud Stream的应用程序来说,它不需要知晓消息中间件的通信细节,它只需要知道Binder对应用程序提供的概念去实现即可,而这个概念就是在快速入门中我们提到的消息通道:Channel。如下图案例,在应用程序和Binder之间定义了两条输入通道和三条输出通道来传递消息,而绑定器则是作为这些通道和消息中间件之间的桥梁进行通信。
绑定器
Binder绑定器是Spring Cloud Stream中一个非常重要的概念。在没有绑定器这个概念的情况下,我们的Spring Boot应用要直接与消息中间件进行信息交互的时候,由于各消息中间件构建的初衷不同,它们的实现细节上会有较大的差异性,这使得我们实现的消息交互逻辑就会非常笨重,因为对具体的中间件实现细节有太重的依赖,当中间件有较大的变动升级、或是更换中间件的时候,我们就需要付出非常大的代价来实施。
通过定义绑定器作为中间层,完美地实现了应用程序与消息中间件细节之间的隔离。通过向应用程序暴露统一的Channel通道,使得应用程序不需要再考虑各种不同的消息中间件实现。当我们需要升级消息中间件,或是更换其他消息中间件产品时,我们要做的就是更换它们对应的Binder绑定器而不需要修改任何Spring Boot的应用逻辑。这一点在上一章实现消息总线时,从RabbitMQ切换到Kafka的过程中,已经能够让我们体验到这一好处。
目前版本的Spring Cloud Stream为主流的消息中间件产品RabbitMQ和Kafka提供了默认的Binder实现,在快速入门的例子中,我们就使用了RabbitMQ的Binder。另外,Spring Cloud Stream还实现了一个专门用于测试的TestSupportBinder,开发者可以直接使用它来对通道的接收内容进行可靠的测试断言。如果要使用除了RabbitMQ和Kafka以外的消息中间件的话,我们也可以通过使用它所提供的扩展API来实现其他中间件的Binder。
仔细的读者可能已经发现,我们在快速入门示例中,并没有使用application.properties或是application.yml来做任何属性设置。那是因为它也秉承了Spring Boot的设计理念,提供了对RabbitMQ默认的自动化配置。当然,我们也可以通过Spring Boot应用支持的任何方式来修改这些配置,比如:通过应用程序参数、环境变量、application.properties或是application.yml配置文件等。比如,下面就是通过配置文件来对RabbitMQ的连接信息以及input通道的主题进行配置的示例:
spring.cloud.stream.bindings.input.destination=raw-sensor-data
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=springcloud
spring.rabbitmq.password=123456
发布-订阅模式
在Spring Cloud Stream中的消息通信方式遵循了发布-订阅模式,当一条消息被投递到消息中间件之后,它会通过共享的Topic主题进行广播,消息消费者在订阅的主题中收到它并触发自身的业务逻辑处理。这里所提到的Topic主题是Spring Cloud Stream中的一个抽象概念,用来代表发布共享消息给消费者的地方。在不同的消息中间件中,Topic可能对应着不同的概念,比如:在RabbitMQ中的它对应了Exchange、而在Kakfa中则对应了Kafka中的Topic。
在快速入门的示例中,我们通过RabbitMQ的Channel进行发布消息给我们编写的应用程序消费,而实际上Spring Cloud Stream应用启动的时候,在RabbitMQ的Exchange中也创建了一个名为input的Exchange交换器,由于Binder的隔离作用,应用程序并无法感知它的存在,应用程序只知道自己指向Binder的输入或是输出通道。为了直观的感受发布-订阅模式中,消息是如何被分发到多个订阅者的,我们可以使用快速入门的例子,通过命令行的方式启动两个不同端口的进程。此时,我们在RabbitMQ控制页面的Channels标签页中看到如下图所示的两个消息通道,它们分别绑定了启动的两个应用程序。
而在Exchanges标签页中,我们还能找到名为input的交换器,点击进入可以看到如下图所示的详情页面,其中在Bindings中的内容就是两个应用程序绑定通道中的消息队列,我们可以通过Exchange页面的Publish Message来发布消息,此时可以发现两个启动的应用程序都输出了消息内容。
下图总结了我们上面所做尝试的基础结构,我们启动的两个应用程序分别是“订阅者-1”和“订阅者-2”,他们都建立了一条输入通道绑定到同一个Topic(RabbitMQ的Exchange)上。当该Topic中有消息发布进来后,连接到该Topic上的所有订阅者可以收到该消息并根据自身的需求进行消费操作。
相对于点对点队列实现的消息通信来说,Spring Cloud Stream采用的发布-订阅模式可以有效的降低消息生产者与消费者之间的耦合,当我们需要对同一类消息增加一种处理方式时,只需要增加一个应用程序并将输入通道绑定到既有的Topic中就可以实现功能的扩展,而不需要改变原来已经实现的任何内容。
消费组
虽然Spring Cloud Stream通过发布-订阅模式将消息生产者与消费者做了很好的解耦,基于相同主题的消费者可以轻松的进行扩展,但是这些扩展都是针对不同的应用实例而言的,在现实的微服务架构中,我们每一个微服务应用为了实现高可用和负载均衡,实际上都会部署多个实例。很多情况下,消息生产者发送消息给某个具体微服务时,只希望被消费一次,按照上面我们启动两个应用的例子,虽然它们同属一个应用,但是这个消息出现了被重复消费两次的情况。为了解决这个问题,在Spring Cloud Stream中提供了消费组的概念。
如果在同一个主题上的应用需要启动多个实例的时候,我们可以通过spring.cloud.stream.bindings.input.group属性为应用指定一个组名,这样这个应用的多个实例在接收到消息的时候,只会有一个成员真正的收到消息并进行处理。如下图所示,我们为Service-A和Service-B分别启动了两个实例,并且根据服务名进行了分组,这样当消息进入主题之后,Group-A和Group-B都会收到消息的副本,但是在两个组中都只会有一个实例对其进行消费。
默认情况下,当我们没有为应用指定消费组的时候,Spring Cloud Stream会为其分配一个独立的匿名消费组。所以,如果同一主题下所有的应用都没有指定消费组的时候,当有消息被发布之后,所有的应用都会对其进行消费,因为它们各自都属于一个独立的组中。大部分情况下,我们在创建Spring Cloud Stream应用的时候,建议最好为其指定一个消费组,以防止对消息的重复处理,除非该行为需要这样做(比如:刷新所有实例的配置等)。
消息分区
通过引入消费组的概念,我们已经能够在多实例的情况下,保障每个消息只被组内一个实例进行消费。通过上面对消费组参数设置后的实验,我们可以观察到,消费组并无法控制消息具体被哪个实例消费。也就是说,对于同一条消息,它多次到达之后可能是由不同的实例进行消费的。但是对于一些业务场景,就需要对于一些具有相同特征的消息每次都可以被同一个消费实例处理,比如:一些用于监控服务,为了统计某段时间内消息生产者发送的报告内容,监控服务需要在自身内容聚合这些数据,那么消息生产者可以为消息增加一个固有的特征ID来进行分区,使得拥有这些ID的消息每次都能被发送到一个特定的实例上实现累计统计的效果,否则这些数据就会分散到各个不同的节点导致监控结果不一致的情况。而分区概念的引入就是为了解决这样的问题:当生产者将消息数据发送给多个消费者实例时,保证拥有共同特征的消息数据始终是由同一个消费者实例接收和处理。
Spring Cloud Stream为分区提供了通用的抽象实现,用来在消息中间件的上层实现分区处理,所以它对于消息中间件自身是否实现了消息分区并不关心,这使得Spring Cloud Stream为不具备分区功能的消息中间件也增加了分区功能扩展。
9.3 消费组
使用消费组实现消息消费的负载均衡
通常在生产环境,我们的每个服务都不会以单节点的方式运行在生产环境,当同一个服务启动多个实例的时候,这些实例都会绑定到同一个消息通道的目标主题(Topic)上。
默认情况下,当生产者发出一条消息到绑定通道上,这条消息会产生多个副本被每个消费者实例接收和处理,但是有些业务场景之下,我们希望生产者产生的消息只被其中一个实例消费,这个时候我们需要为这些消费者设置消费组来实现这样的功能,实现的方式非常简单,我们只需要在服务消费者端设置spring.cloud.stream.bindings.input.group属性即可,比如我们可以这样实现:
- 先创建一个消费者应用SinkReceiver,实现了greetings主题上的输入通道绑定,它的实现如下:
@EnableBinding(value = {Sink.class})
public class SinkReceiver {
private static Logger logger = LoggerFactory.getLogger(SinkReceiver.class);
@StreamListener(Sink.INPUT)
public void receive(User user) {
logger.info("Received: " + user);
}
}
- 为了将SinkReceiver的输入通道目标设置为greetings主题,以及将该服务的实例设置为同一个消费组,做如下设置:
# 指定消费组
spring.cloud.stream.bindings.input.group=Service-A
# 指定 input 通道的 queue
spring.cloud.stream.bindings.input.destination=greetings
通过spring.cloud.stream.bindings.input.group属性指定了该应用实例都属于Service-A消费组,而spring.cloud.stream.bindings.input.destination属性则指定了输入通道对应的目标(rabbitmq 是 exchange, 而 kafka 则是 topic)。
- 完成了消息消费者之后,我们再来实现一个消息生产者应用SinkSender,具体如下:
@EnableBinding(value = {Source.class})
public class SinkSender {
private static Logger logger = LoggerFactory.getLogger(SinkSender.class);
@Bean
@InboundChannelAdapter(value = Source.OUTPUT, poller = @Poller(fixedDelay = "2000"))
public MessageSource<String> timerMessageSource() {
return () -> new GenericMessage<>("{\"name\":\"didi\", \"age\":30}");
}
}
- 为消息生产者SinkSender做一些设置,让它的输出通道绑定目标也指向greetings主题,具体如下:
spring.cloud.stream.bindings.output.destination=greetings
到这里,对于消费分组的示例就已经完成了。分别运行上面实现的生产者与消费者,其中消费者我们启动多个实例。通过控制台,我们可以发现每个生产者发出的消息,会被启动的消费者以轮询的方式进行接收和输出。
9.4 消费分区
使用消息分区
在Spring Cloud Stream中实现消息分区非常简单,我们可以根据消费组示例做一些配置修改就能实现,具体如下:
- 在消费者应用SinkReceiver中,我们对配置文件做一些修改,具体如下:
spring.cloud.stream.bindings.input.group=Service-A
spring.cloud.stream.bindings.input.destination=greetings
spring.cloud.stream.bindings.input.consumer.partitioned=true
spring.cloud.stream.instanceCount=2
spring.cloud.stream.instanceIndex=0
从上面的配置中,我们可以看到增加了这三个参数:
-
spring.cloud.stream.bindings.input.consumer.partitioned:通过该参数开启消费者分区功能;
-
spring.cloud.stream.instanceCount:该参数指定了当前消费者的总实例数量;
-
spring.cloud.stream.instanceIndex:该参数设置当前实例的索引号,从0开始,最大值为spring.cloud.stream.instanceCount参数 - 1。我们试验的时候需要启动多个实例,可以通过运行参数来为不同实例设置不同的索引值。
- 在生产者应用SinkSender中,我们对配置文件也做一些修改,具体如下:
spring.cloud.stream.bindings.output.destination=greetings
spring.cloud.stream.bindings.output.producer.partitionKeyExpression=payload
spring.cloud.stream.bindings.output.producer.partitionCount=2
从上面的配置中,我们可以看到增加了这两个参数:
- spring.cloud.stream.bindings.output.producer.partitionKeyExpression:通过该参数指定了分区键的表达式规则,我们可以根据实际的输出消息规则来配置SpEL来生成合适的分区键;
- spring.cloud.stream.bindings.output.producer.partitionCount:该参数指定了消息分区的数量。
到这里消息分区配置就完成了,我们可以再次启动这两个应用,同时消费者启动多个,但需要注意的是要为消费者指定不同的实例索引号,这样当同一个消息被发给消费组时,我们可以发现只有一个消费实例在接收和处理这些相同的消息。
注意: 这里当我们使用输出功能时, 程序不会为我们创建一个指定的 queue队列. 只有当前启用具有输入功能的程序时才会创建对应的队列.
这里说的输入指的是将消息从消息中间件中输入到程序中. 同样,输出指的是将消息从程序中输出到消息中间件.
当定义目标绑定接口比如 Source, Sink 类似的接口, input() 与 output()的value 需要指定成为一样的, 同下面的配置: spring.cloud.stream.bindings.input.destination=greetings 与 spring.cloud.stream.bindings.output.destination=greetings
9.5 如何消费自己生产的消息
常见错误
在放出标准答案前,先放出一个常见的错误姿势和告警信息(以便您可以通过搜索引擎找到这里^_^)。以下错误基于Spring Boot 2.0.5、Spring Cloud Finchley SR1。
首先,根据入门示例,为了生产和消费消息,需要定义两个通道:一个输入、一个输出。比如下面这样:
public interface TestTopic {
String OUTPUT = "example-topic";
String INPUT = "example-topic";
@Output(OUTPUT)
MessageChannel output();
@Input(INPUT)
SubscribableChannel input();
}
通过INPUT和OUTPUT使用相同的名称,让生产消息和消费消息指向相同的Topic,从而实现消费自己发出的消息。
接下来,创建一个HTTP接口,并通过上面定义的输出通道触来生产消息,比如:
@Slf4j
@RestController
public class TestController {
@Autowired
private TestTopic testTopic;
@GetMapping("/sendMessage")
public String messageWithMQ(@RequestParam String message) {
testTopic.output().send(MessageBuilder.withPayload(message).build());
return "ok";
}
}
已经有生产消息的实现,下面来创建对输入通道的监听,以实现消息的消费逻辑。
@Slf4j
@Component
public class TestListener {
@StreamListener(TestTopic.INPUT)
public void receive(String payload) {
log.info("Received: " + payload);
throw new RuntimeException("BOOM!");
}
}
最后,在应用主类中,使用@EnableBinding注解来开启它,比如:
@EnableBinding(TestTopic.class)
@SpringBootApplication
public class TestApplication {
public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}
}
看似天衣无缝的操作,然而在启动的瞬间,你可能收到了下面这样的错误:
org.springframework.beans.factory.BeanDefinitionStoreException: Invalid bean definition with name 'example-topic' defined in com.didispace.stream.TestTopic: bean definition with this name already exists - Root bean: class [null]; scope=; abstract=false; lazyInit=false; autowireMode=0; dependencyCheck=0; autowireCandidate=true; primary=false; factoryBeanName=com.didispace.stream.TestTopic; factoryMethodName=input; initMethodName=null; destroyMethodName=null
at org.springframework.cloud.stream.binding.BindingBeanDefinitionRegistryUtils.registerBindingTargetBeanDefinition(BindingBeanDefinitionRegistryUtils.java:64) ~[spring-cloud-stream-2.0.1.RELEASE.jar:2.0.1.RELEASE]
at org.springframework.cloud.stream.binding.BindingBeanDefinitionRegistryUtils.registerOutputBindingTargetBeanDefinition(BindingBeanDefinitionRegistryUtils.java:54) ~[spring-cloud-stream-2.0.1.RELEASE.jar:2.0.1.RELEASE]
at org.springframework.cloud.stream.binding.BindingBeanDefinitionRegistryUtils.lambda$registerBindingTargetBeanDefinitions$0(BindingBeanDefinitionRegistryUtils.java:86) ~[spring-cloud-stream-2.0.1.RELEASE.jar:2.0.1.RELEASE]
at org.springframework.util.ReflectionUtils.doWithMethods(ReflectionUtils.java:562) ~[spring-core-5.0.9.RELEASE.jar:5.0.9.RELEASE]
at org.springframework.util.ReflectionUtils.doWithMethods(ReflectionUtils.java:541) ~[spring-core-5.0.9.RELEASE.jar:5.0.9.RELEASE]
at org.springframework.cloud.stream.binding.BindingBeanDefinitionRegistryUtils.registerBindingTargetBeanDefinitions(BindingBeanDefinitionRegistryUtils.java:76) ~[spring-cloud-stream-2.0.1.RELEASE.jar:2.0.1.RELEASE]
at org.springframework.cloud.stream.config.BindingBeansRegistrar.registerBeanDefinitions(BindingBeansRegistrar.java:45) ~[spring-cloud-stream-2.0.1.RELEASE.jar:2.0.1.RELEASE]
at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader.lambda$loadBeanDefinitionsFromRegistrars$1(ConfigurationClassBeanDefinitionReader.java:358) ~[spring-context-5.0.9.RELEASE.jar:5.0.9.RELEASE]
at java.util.LinkedHashMap.forEach(LinkedHashMap.java:684) ~[na:1.8.0_151]
at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader.loadBeanDefinitionsFromRegistrars(ConfigurationClassBeanDefinitionReader.java:357) ~[spring-context-5.0.9.RELEASE.jar:5.0.9.RELEASE]
at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader.loadBeanDefinitionsForConfigurationClass(ConfigurationClassBeanDefinitionReader.java:145) ~[spring-context-5.0.9.RELEASE.jar:5.0.9.RELEASE]
at org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader.loadBeanDefinitions(ConfigurationClassBeanDefinitionReader.java:117) ~[spring-context-5.0.9.RELEASE.jar:5.0.9.RELEASE]
at org.springframework.context.annotation.ConfigurationClassPostProcessor.processConfigBeanDefinitions(ConfigurationClassPostProcessor.java:328) ~[spring-context-5.0.9.RELEASE.jar:5.0.9.RELEASE]
at org.springframework.context.annotation.ConfigurationClassPostProcessor.postProcessBeanDefinitionRegistry(ConfigurationClassPostProcessor.java:233) ~[spring-context-5.0.9.RELEASE.jar:5.0.9.RELEASE]
at org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanDefinitionRegistryPostProcessors(PostProcessorRegistrationDelegate.java:271) ~[spring-context-5.0.9.RELEASE.jar:5.0.9.RELEASE]
at org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors(PostProcessorRegistrationDelegate.java:91) ~[spring-context-5.0.9.RELEASE.jar:5.0.9.RELEASE]
at org.springframework.context.support.AbstractApplicationContext.invokeBeanFactoryPostProcessors(AbstractApplicationContext.java:694) ~[spring-context-5.0.9.RELEASE.jar:5.0.9.RELEASE]
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:532) ~[spring-context-5.0.9.RELEASE.jar:5.0.9.RELEASE]
at org.springframework.boot.web.reactive.context.ReactiveWebServerApplicationContext.refresh(ReactiveWebServerApplicationContext.java:61) ~[spring-boot-2.0.5.RELEASE.jar:2.0.5.RELEASE]
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:780) [spring-boot-2.0.5.RELEASE.jar:2.0.5.RELEASE]
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:412) [spring-boot-2.0.5.RELEASE.jar:2.0.5.RELEASE]
at org.springframework.boot.SpringApplication.run(SpringApplication.java:333) [spring-boot-2.0.5.RELEASE.jar:2.0.5.RELEASE]
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1277) [spring-boot-2.0.5.RELEASE.jar:2.0.5.RELEASE]
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1265) [spring-boot-2.0.5.RELEASE.jar:2.0.5.RELEASE]
at com.didispace.stream.TestApplication.main(TestApplication.java:13) [classes/:na]
正确姿势
根据错误提示:Invalid bean definition with name 'example-topic' defined in com.didispace.stream.TestTopic: bean definition with this name already exists,没有启动成功的原因是已经存在了一个名为example-topic的Bean,那么为什么会重复创建这个Bean呢?
实际上,在F版的Spring Cloud Stream中,当我们使用@Output和@Input注解来定义消息通道时,都会根据传入的通道名称来创建一个Bean。而在上面的例子中,我们定义的@Output和@Input名称是相同的,因为我们系统输入和输出是同一个Topic,这样才能实现对自己生产消息的消费。
既然这样,我们定义相同的通道名是行不通了,那么我们只能通过定义不同的通道名,并为这两个通道配置相同的目标Topic来将这一对输入输出指向同一个实际的Topic。对于上面的错误程序,只需要做如下两处改动:
第一步:修改通道名,使用不同的名字
public interface TestTopic {
String OUTPUT = "example-topic-output";
String INPUT = "example-topic-input";
@Output(OUTPUT)
MessageChannel output();
@Input(INPUT)
SubscribableChannel input();
}
第二步:在配置文件中,为这两个通道设置相同的Topic名称,比如:
spring.cloud.stream.bindings.example-topic-input.destination=aaa-topic
spring.cloud.stream.bindings.example-topic-output.destination=aaa-topic
这样,这两个输入输出通道就会都指向名为aaa-topic的Topic了。
最后,再启动该程序,没有报错。然后访问接口:localhost:8080/sendMessage?message=hello-didi,可以在控制台中看到如下信息:
2018-11-17 23:24:10.425 INFO 32039 --- [ctor-http-nio-2] o.s.a.r.c.CachingConnectionFactory : Attempting to connect to: [localhost:5672]
2018-11-17 23:24:10.453 INFO 32039 --- [ctor-http-nio-2] o.s.a.r.c.CachingConnectionFactory : Created new connection: rabbitConnectionFactory.publisher#266753da:0/SimpleConnection@627fba83 [delegate=amqp://guest@127.0.0.1:5672/, localPort= 60752]
2018-11-17 23:24:10.458 INFO 32039 --- [ctor-http-nio-2] o.s.amqp.rabbit.core.RabbitAdmin : Auto-declaring a non-durable, auto-delete, or exclusive Queue (aaa-topic.anonymous.fNUxZ8C0QIafxrhkFBFI1A) durable:false, auto-delete:true, exclusive:true. It will be redeclared if the broker stops and is restarted while the connection factory is alive, but all messages will be lost.
2018-11-17 23:24:10.483 INFO 32039 --- [IafxrhkFBFI1A-1] com.didispace.stream.TestListener : Received: hello-didi
消费自己生产的消息成功了!读者也还可以访问一下应用的/actuator/beans端点,看看当前Spring上下文中有哪些Bean,应该可以看到有下面Bean,也就是上面分析的两个通道的Bean对象
"example-topic-output": {
"aliases": [],
"scope": "singleton",
"type": "org.springframework.integration.channel.DirectChannel",
"resource": null,
"dependencies": []
},
"example-topic-input": {
"aliases": [],
"scope": "singleton",
"type": "org.springframework.integration.channel.DirectChannel",
"resource": null,
"dependencies": []
},
后记
其实大部分开发者在使用Spring Cloud Stream时候碰到的问题都源于对Spring Cloud Stream的核心概念还是不够理解。
9.6 绑定器详解
绑定器是定义在应用程序与消息中间件之间的抽象层,用来屏蔽消息中间件对应用程序的复杂性,并提供简单而又统一的操作接口给应用程序使用.
绑定器 SPI
Service Provider Interface 服务提供接口. 绑定器 SPI 涵盖了一套可插拔的用于连接外部中间件的实现机制, 其中包含许多接口,开箱即用的实现类以及发现策略等内容. 其中最为关键的就是 Binder 接口,它是用来将输入和输出连接到外部中间件的抽象.
/**
* A strategy interface used to bind an app interface to a logical name. The name is
* intended to identify a logical consumer or producer of messages. This may be a queue, a
* channel adapter, another message channel, a Spring bean, etc.
*
* @param <T> the primary binding type (e.g. MessageChannel).
* @param <C> the consumer properties type.
* @param <P> the producer properties type.
* @author Mark Fisher
* @author David Turanski
* @author Gary Russell
* @author Jennifer Hickey
* @author Ilayaperumal Gopinathan
* @author Marius Bogoevici
* @since 1.0
*/
public interface Binder<T, C extends ConsumerProperties, P extends ProducerProperties> {
/**
* Bind the target component as a message consumer to the logical entity identified by
* the name.
* @param name the logical identity of the message source
* @param group the consumer group to which this consumer belongs - subscriptions are
* shared among consumers in the same group (a <code>null</code> or empty String, must
* be treated as an anonymous group that doesn't share the subscription with any other
* consumer)
* @param inboundBindTarget the app interface to be bound as a consumer
* @param consumerProperties the consumer properties
* @return the setup binding
*/
Binding<T> bindConsumer(String name, String group, T inboundBindTarget,
C consumerProperties);
/**
* Bind the target component as a message producer to the logical entity identified by
* the name.
* @param name the logical identity of the message target
* @param outboundBindTarget the app interface to be bound as a producer
* @param producerProperties the producer properties
* @return the setup binding
*/
Binding<T> bindProducer(String name, T outboundBindTarget, P producerProperties);
}
当应用程序对输入和输出通道进行绑定时,实际上就是通过该接口的实现来完成的.
- 向消息通道发送数据的生产者调用 bindProducer 方法来绑定输出通道时, 第一个参数代表了发往消息中间件的目标名称, 第二参数代表了发送消息的本地通道实例, 第三个参数是用来创建通道时使用的属性配置(比如分区键的表达式等).
- 从消息通道接收数据的消费者调用 bindConsumer 方法来绑定输入通道时, 第一个参数代表了接收消息中间件的目标名称, 第二参数代表了消费组的名称(如果多个消费者实例使用相同的组名,则消息将这些消费者实例实现复杂均衡, 每个生产者发出的消息只会被组内的一个消费者实例接收和处理), 第三个参数代表了接收消息的本地通道实例, 第四个参数代表用来创建通道时使用的属性配置.
自动化配置
spring cloud stream 通过绑定器 SPI 的实现将应用程序逻辑上的输入输出通道连接到物理上的消息中间件. 消息中间件之间通常都会有或多或少的差异性,所以为了适配不同的消息中间件,需要为它们实现各自独有的绑定器. 目前 spring cloud stream 中默认实现了 rabbitmq 和 kafka 的绑定器. 我们之前引入的 spring-cloud-starter-stream-rabbit 依赖就包含了 spring-cloud-starter-stream-binder-rabbit.
默认情况, spring cloud stream 在 classpath 下能够找到单个绑定器的实现,那么就会加载它. 而我们在 classpath 下引入绑定器的方法如下:
dependencies {
...
// rabbit
implementation 'org.springframework.cloud:spring-cloud-starter-stream-binder-rabbit'
// kafka
// implementation 'org.springframework.cloud:spring-cloud-starter-stream-binder-kafka'
...
}
多绑定器配置
当应用程序的 classpath 下存在多个绑定器时,spring cloud stream 在为消息做绑定操作时,无法判断应该使用哪个具体的绑定器,所以我们需要为每个输入或输出通道指定具体的绑定器.
我们在一个应用程序中使用多个绑定器时,往往其中一个绑定器是会主要使用的,而第二个可能是为了适应一些特殊的需求(比如性能等原因). 我们可以先设置默认的绑定器来为大部分的通道设置绑定器. 比如为 rabbitmq 设置绑定器.
# 设置默认的绑定器
spring.cloud.stream.default-binder=rabbit
再为一些少数的通道单独设置绑定器
# 单独为 input 通道设置绑定器
spring.cloud.stream.bindings.input.binder=kafka
需要注意的是,上面我们设置参数时用来指定具体的绑定器的值并不是消息中间件的名称,而是每个绑定器实现的 META-INF/spring.binders 文件中定义的标识(一个绑定器的标识可以定义多个,以逗号分隔),所以上面的配置 rabbit 和 kafka 分别来自于各自的配置定义.
另外,当需要一个应用程序中使用同一类型不同环境的绑定器时,我们也可以通过配置轻松实现通道绑定. 比如我们要连接两个不同的 rabbitmq 实例,可以参考以下配置:
# 配置多个 rabbitmq 实例
spring.cloud.stream.bindings.input.binder=rabbit1
spring.cloud.stream.bindings.output.binder=rabbit2
# 定义绑定器的类型
spring.cloud.stream.binders.rabbit1.type=rabbit
spring.cloud.stream.binders.rabbit1.environment.spring.rabbitmq.host=192.168.0.101
spring.cloud.stream.binders.rabbit1.environment.spring.rabbitmq.port=5672
spring.cloud.stream.binders.rabbit1.environment.spring.rabbitmq.username=admin
spring.cloud.stream.binders.rabbit1.environment.spring.rabbitmq.password=admin
spring.cloud.stream.binders.rabbit2.type=rabbit
spring.cloud.stream.binders.rabbit2.environment.spring.rabbitmq.host=192.168.0.102
spring.cloud.stream.binders.rabbit2.environment.spring.rabbitmq.port=5672
spring.cloud.stream.binders.rabbit2.environment.spring.rabbitmq.username=admin
spring.cloud.stream.binders.rabbit2.environment.spring.rabbitmq.password=admin
rabbitmq 与 kafka 绑定器
rabbitmq 与 kafka 的自身的结构不同, 它们是如何使用的消息中间中不同的概念来实现消息的生产与消息的消费.
- rabbitmq 绑定器: 通过 exchange 交换器来实现 spring cloud stream 的主题概念,所以消息通道的输入输出目标映射了一个具体的 exchange 交换器. 而对于每个消费组,则会为对应的 exchange 交换器绑定一个 queue 队列进行消息的收发.
- kafka 绑定器: 由于 kafka 自身就有 topic 概念,所有 spring cloud stream 的主题直接采用了 kafka 的 topic 主题概念,每个消费组的通道会直接连接 kafka 的主题进行消息的收发.
来源:oschina
链接:https://my.oschina.net/u/4150612/blog/3179469