写在前面
该文参考来自 程序猿DD 的Spring Cloud 微服务实战一书,该文是作为阅读了
spring cloud bus
一章的读书笔记。书中版本比较老,我选择了最新稳定版的spring cloud Greenwich.SR2
版本,该版本较书中版本有些变动。非常感谢作者提供了这么好的学习思路,谢谢!文章也参考了Spring-cloud-config
的官方文档。
Spring cloud bus
使用轻量级消息代理连接分布式系统的节点。这可以用来广播状态更改(例如配置更改)或其他管理指令。一个关键的想法是,总线就像一个分布式执行器,用于扩展的SpringBoot
应用程序。不过,它也可以用作应用程序之间的通信通道。该项目为启动者提供了AMQP
代理或Kafka
作为传输。
1. 入门
当 Spring Cloud Bus
在类路径上检测到自己,它会通过 Spring Boot autoconfiguration
来工作。要启用消息总线,只需要将 spring-cloud-starter-bus-amqp
或者 spring-cloud-starter-bus-kafka
加入到依赖项中。Spring cloud 将负责其余的事情。确保代理(RabbitMQ
或Kafka
)可用并已配置。在本地主机上运行时,您不需要做任何事情。如果您远程运行,请使用Spring Cloud连接器或Spring Boot约定来定义消息代理凭据,如下面的Rabbit示例所示:
spring:
rabbitmq:
host: mybroker.com
port: 5672
username: user
password: secret
导入依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
在导入以上依赖之后,我们需要写下我们的测试。
-
构建 rabbit 消息监听器:
@Component @RabbitListener(bindings = { @QueueBinding(value = @Queue("testSpringCloudRabbit"), exchange = @Exchange("exchange"), key = "routingKey") }) public class Receiver { @RabbitHandler public void process(String msg){ System.out.println("receiving : " + msg); } }
-
发送消息:
@RestController public class TestController { @Autowired private AmqpTemplate amqpTemplate; @GetMapping("/send") public void send(@RequestParam String msg){ amqpTemplate.convertAndSend("exchange", "routingKey", msg); } }
有关 Rabbit
的一些概念,需要参考其它文章了。
启动程序,访问 /send?msg=helloworld
,控制台能成功打印出消息。似乎这只是一个使用 RabbitMq
的用例,那么在 Spring Cloud 的环境下,它能够给我们什么便利呢?
-
总线能够支持将消息发送到监听某个特定服务的所有节点
-
/bus/*
actuator 命名空间有一些 HTTP 端点,目前有两个实现:-
/bus/env
通过发送键值对来更新每个节点的 Spring 环境 -
/bus/refresh
重新加载每个应用程序的配置,就好像我们之前在Spring cloud config
中提到过的通过访问服务的/refresh
端点来实现动态刷新一样想象一下,在之前的配置中心,当有文件改变,我们需要实现动态更新,则需要手动去访问需要更新的每个服务的
/refresh
端点(Git 仓库的 Web hook可以避免手动更新)。但使用消息总线,则连接到消息总线上的多个应用(单服务多实例)都会接收到更新请求。
-
2. Bus 端点
Spring Cloud Bus
提供了两个端点,/actuator/bus-refresh
和/actuator/bus-env
,它们分别对应于Spring Cloud Commons
中的单个actuator
端点,/actuator/refresh
和/actuator/env
。为了暴露这两个端点,需要额外的配置:
management.endpoints.web.exposure.include=bus-refresh,bus-env
2.1 Bus Refresh 端点
/actuator/bus-refresh
端点清除 RefreshScope
缓存,重新绑定 @ConfigurationProperties
。
2.2 Bus Env 端点
actuator/bus-env
端点使用指定的跨多个实例的键/值对更新每个实例环境。
实践
创建一个可配置化属性类:
@ConfigurationProperties(prefix = "duofei.test")
public class ConfigPro {
private String pro;
public String getPro() {
return pro;
}
public void setPro(String pro) {
this.pro = pro;
}
}
使用 @ConfigurationProperties
注解生成自己的配置元数据文件,需要引入下面的依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
在 yaml
文件中添加如下数据:
duofei:
test:
pro: hello,world
Controller
中新增如下测试接口:
@Autowired
private ConfigPro configPro;
@GetMapping("/pro")
public String pro(){
return configPro.getPro();
}
对了,不能忘了将它声明成 bean,在启动类中加入:
@Bean
public ConfigPro configPro(){
return new ConfigPro();
}
访问接口,能够得到 hello,world
值返回。现在,通过 /actuatorbus-env
端点更新该值,使用 curl 工具请求该接口:
curl -X POST http://192.168.3.18:60022/actuator/bus-env -H "Content-Type:application/json" -d {\"name\":\"duofei.test.pro\",\"value\":\"1111\"}
这里的 post 传参是固定格式,{“name":"…", “value”:"…"},至于反斜杠是因为在我这里,单单使用双引号是无法传递到服务器的,我并不确定这是否与我使用的控制台工具有关。
此时,重新访问该接口,返回 ‘1111’;
3. 定位一个实例
应用程序的每个实例都有一个服务ID,可以使用spring.cloud.bus.id
设置其值。值应该是一个冒号分隔的标识符列表,按从最不特定到最特定的顺序排列。默认值是由环境作为spring.application.name
和server.port
(或spring.application.index
,如果设置)的组合构造的。ID的默认值以 app:index:index
的形式构造,其中:
-
app
是vcap.application.name
, 如果存在的话,或spring.application.name
-
index
是vcap.application.instance_index
(如果存在),或者spring.application.index
,local.server.port
,server.port
,或0(按此顺序)。 -
id
是vcap.application.instance_id
(如果存在)或一个随机值。
当我们在使用上述的 HTTP 端点时,我们可以只针对某个实例执行操作,端点接收 destination
路径参数。destination
的值是服务id,只有 destination
参数指定的服务才会处理消息,其它的服务实例会忽略这条消息。
4. 定位一个服务的所有实例
在Spring PathMatcher
中使用 “destination
” 参数(路径分隔符为冒号 : )来确定实例是否处理消息。使用前面的示例,/bus-env/customers:**
针对 “customers
” 服务的所有实例,而不考虑服务ID的其余部分。
5. 服务ID 必须唯一
总线尝试两次以消除对事件的处理—一次来自原始 ApplicationEvent
,一次来自队列。为此,它根据当前服务ID检查发送服务ID。如果一个服务的多个实例具有相同的ID,则不处理事件。在本地机器上运行时,每个服务都位于不同的端口上,该端口是ID的一部分。
6. 自定义事件
自定义的事件类需要继承 RemoteApplicationEvent
;
创建一个通知事件:
public class NotifyRemoteApplicationEvent extends RemoteApplicationEvent {
public NotifyRemoteApplicationEvent(Object source, String originService) {
super(source, originService);
}
}
事件需要被扫描到,通过在启动类上使用 @RemoteApplicationEventScan({"com.duofei.event"})
注解。尽管是需要远程处理该事件,但我们依然可以在该程序中配置事件监听器,然后通过启动多个实例来实现远程的监听。
/**
* @author duofei
* @date 2019/11/13
*/
@Component
public class LocalEventListener {
@EventListener
public void handleNotify(NotifyRemoteApplicationEvent event) {
System.out.println("你:" + event.getSource());
System.out.println("我:知道啦!" );
}
}
在 controller
中新增接口,用于发布事件:
@Autowired
private ApplicationContext applicationContext;
@GetMapping("/notify")
public void notifyOthers(@RequestParam String msg){
// config-client 为 spring.application.name 的值
applicationContext.publishEvent(new NotifyRemoteApplicationEvent(msg, "config-client"));
}
启动两个实例(暂且名为 A, B),调用 A 实例的该接口,观察输出发现,A 能正常输出,但B 实例似乎并没有监听到该事件;
什么原因呢?我们查看 RemoteApplicationEvent
的子类,能够找到其他的子类,我们只需要研究其他子类是怎样去发布远程事件的,那是不是能解决我们的问题呢?
找到 RefreshRemoteApplicationEvent
事件,该事件是在调用 HTTP 端点的时候发布的,查看 /bus-refresh
对应的端点类 RefreshBusEndpoint
类,该类的具体代码如下:
@Endpoint(id = "bus-refresh") // TODO: document new id
public class RefreshBusEndpoint extends AbstractBusEndpoint {
public RefreshBusEndpoint(ApplicationEventPublisher context, String id) {
super(context, id);
}
@WriteOperation
public void busRefreshWithDestination(@Selector String destination) { // TODO:
// document
// destination
publish(new RefreshRemoteApplicationEvent(this, getInstanceId(), destination));
}
@WriteOperation
public void busRefresh() {
publish(new RefreshRemoteApplicationEvent(this, getInstanceId(), null));
}
}
很容易找到该类的发布事件是通过 ApplicationEventPublisher
类发布的。该类只有一个构造函数,我们只需要找到它在哪里构造的,看一下 ApplicationEventPublisher
是如何传入进来的。定位到BusRefreshAutoConfiguration
类,其中的以下代码定义了RefreshBusEndpoint
Bean:
@Configuration
@ConditionalOnBean(ContextRefresher.class)
@ConditionalOnClass(name = {
"org.springframework.boot.actuate.endpoint.annotation.Endpoint",
"org.springframework.cloud.context.scope.refresh.RefreshScope" })
protected static class BusRefreshEndpointConfiguration {
@Bean
@ConditionalOnEnabledEndpoint
public RefreshBusEndpoint refreshBusEndpoint(ApplicationContext context,
BusProperties bus) {
return new RefreshBusEndpoint(context, bus.getId());
}
}
呀!它也是使用ApplicationContext
呀,嗯… 注意到了嘛?它的远程事件接收到了 BusProperties
的 id 值,作为构造函数的入参 (originService
) 的值。原来,我理解错了,看来,我也需要一个这样的值,修改 controller
中的测试接口为如下:
@Autowired
private BusProperties busProperties;
@GetMapping("/notify")
public void notifyOthers(@RequestParam String msg){
applicationContext.publishEvent(new NotifyRemoteApplicationEvent(msg, busProperties.getId()));
}
重新启动实例,运行。A,B是收到了事件,不过监听器处理时出现了异常:
org.springframework.messaging.converter.MessageConversionException: Could not read JSON: Cannot construct instance of
com.duofei.event.NotifyRemoteApplicationEvent, problem: null source
远程事件构造函数需要一个 source
,找了好久,才发现 RemoteApplicationEvent
类上有如下注解:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonIgnoreProperties("source")
也就是 无论如何,sorce 我是传不过去的,那么 我需要为 NotifyRemoteApplicationEvent
新增一个构造函数,将该类改为如下:
public class NotifyRemoteApplicationEvent extends RemoteApplicationEvent {
private String msg;
private NotifyRemoteApplicationEvent(){}
public NotifyRemoteApplicationEvent(String msg, Object source, String originService) {
super(source, originService);
this.msg = msg;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
}
问题解决了,反观RemoteApplicationEvent
子类都会有一个无参构造,哪怕是 private
的。
测试接口中则需要这样发布事件:
applicationContext.publishEvent(new NotifyRemoteApplicationEvent(msg, new Object() , busProperties.getId()));
我想这是合理的,因为需要的数据可以通过定义事件的其它属性来获得。当然,也不乏其它的看法。