消息總線 spring cloud bus

前言

在微服務架構的系統中,我們通常會使用輕量級的消息代理來構建一個共用的消息主題讓系統中所有微服務實例都能連接上來,由于該主題中產生的消息會被所有實例監聽和消費,所以我們稱它為消息總線。在總線上的各個實例都可以方便地廣播一些需要讓其他連接在該主題上的實例都知道的消息,例如配置信息的變更或者其他一些管理操作等。

由于消息總線在微服務架構系統的廣泛使用,所以它同配置中心一樣,幾乎是微服務架構中的必備組件。spring cloud作為微服務架構綜合性的解決方案,對此自然也有自己的實現,這就是spring cloud bus。通過spring cloud bus,可以非常容易的搭建起消息總線,同時實現了一些消息總線中的常用功能,比如配合spring cloud config實現微服務應用配置信息的動態更新等。

消息代理

消息代理(message broker)是一種消息驗證,傳輸,路由的架構模式。它在應用程序之間起到通信并最小化應用之間的依賴的作用,使得應用程序可以高效地解耦通信過程。消息代理是一個中間件產品,它的核心是一個消息的路由程序,用來實現接收和分發消息,并根據設定好的消息處理流來轉發給正確的應用。它包括獨立的通信和消息傳遞協議,能夠實現組織內部和組織間的網絡通信。設計代理的目的就是為了能夠從應用程序中傳入消息,并執行一些特別的操作,下面這些是企業應用中,我們經常使用消息代理的場景:

  • 將消息路由到一個或多個目的地。
  • 消息轉化為其他的表現方式。
  • 執行消息的聚集,消息的分解,并將結果發送到它們的目的地,然后重新組合響應返回給消息用戶。
  • 調用web服務來檢索數據。
  • 響應事件或錯誤。
  • 使用發布-訂閱模式來提供內容和基于主題的消息路由。

目前已經有非常多的開源產品可以供大家使用,比如:

  • activemq
  • kafka
  • rabbitmq
  • rocketmq
  • ...

當前版本的spring cloud bus僅支持兩款中間件產品:rabbitmqkafka

rabbitmq實現消息總線

rabbitmq是實現了高級消息隊列協議(AMQP)的開源消息代理軟件,也稱為面向消息的中間件。Rabbitmq服務是高性能,可伸縮性而聞名的Erlang語言編寫而成的,其集群和故障轉移是構建在開放電信平臺框架的。

AMQP是Advanced Message Queuing Protocol的簡稱,它是一個面向消息中間件的開發式標準應用層協議,它定義了以下這些特性:

  • 消息方向
  • 消息隊列
  • 消息路由(包括點到點和發布-訂閱模式)
  • 可靠性
  • 安全性

AMQP要求消息的提供者和客戶端接收者的行為要實現對不同的供應商可以用相同的方式(比如SMTP,HTTP,FTP等)進行互相操作。在以往的中間件標準中,主要還是建立在api級別的,比如jms,集中于通過不同的中間件實現來建立標準化的程序間的互操作性,而不是在多個中間件產品間實現互操作性。

AMQP與JMS不同,JMS定義了一個API和一組消息收發必須要實現的行為,而AMQP是一個線路級協議。線路級協議描述的是通過網絡發送的數據傳輸格式。因此,任何符合該數據格式的消息發送和接收工具都能互相兼容和進行操作,這樣就能輕易實現跨技術平臺的架構方案。

RabbitMQ以AMQP協議實現,所以它可以支持多種操作系統,多種編程語言,幾乎可以覆蓋所有主流的企業級技術平臺。在微服務架構消息中間件的選型中,它是一個非常適合且優秀的選擇。因此,在spring cloud bus中包含了對rabbit的自動化默認配置。

基本概念

介紹一些Rabbitmq的基本概念,

  • Broker:可以理解成消息隊列服務器的實體,它是一個中間件應用,負責接收消息生產者的消息,然后將消息發送到消息接收者或者其他的Broker。
  • Exchange:消息交換機,是消息第一個到達的地方,消息通過它指定的路由規則,分發到不同的消息隊列中去。
  • Queue:消息隊列,消息通過發發送和路由之后最終到達的地方,到達Queue的消息即進入邏輯上等待消費的狀態。每個消息都會被發送到一個或多個隊列。
  • Binding:綁定,它的作用就是把Exchange和Queue按照路由規則綁定起來,也就是Exchange和Queue之間的虛擬連接。
  • Routing Key:路由關鍵字,Exchange根據這個關鍵字進行消息投遞。
  • Virtual host:虛擬主機,它是對Broker的虛擬劃分,將消費者,生產者和它們的依賴的AMQP相關結構進行隔離,一般都是為了安全考慮。比如,我們可以在一個Broker中設置多個虛擬主機,對不同用戶進行權限的分離。
  • Connection:連接,代表生產者,消費者,Broker之間進行通信的物理網絡。
  • Channel:消息通道,用于連接生產者和消費者的邏輯結構。在客戶端的每個連接里,可建立多個Channel,每個Channel代表一個會話任務,通過Channel可以隔離同一個連接中的不同交互內容。
  • Producer:消息生產者,制造消息并發送消息的程序。
  • Consumer:消息消費者,接收消息并處理消息的程序。

消息投遞到隊列的整個過程大致如下:
1.客戶端連接到消息隊列服務器,打開一個Channel。
2.客戶端聲明一個Exchange,并設置相關屬性。
3.客戶端聲明一個Queue,并設置相關屬性。
4.客戶端使用Routing Key,在Exchange和Queue之間建立好綁定關系。
5.客戶端投遞消息到Exchange。

  1. Exchange接收到消息后,根據消息的key和已經設置的Binding,進行消息路由,將消息投遞到一個或多個Queue里。

Exchange也有幾種類型。
1.Direct交互機:完全根據Key進行投遞。比如,綁定時設置了Routing Key為abc,那么客戶端提交的消息,只有設置了key為Routing Key的才會被投遞到隊列。
2.Topic交互機:對Key進行模式匹配后進行投遞,可以使用符號#匹配一個或多個詞,符號*匹配正好一個詞。比如,abc.#匹配abc.def.ghi, abc.*只匹配abc.def.
3.Fanout交互機:不需要任何Key,它采用廣播的模式,一個消息進來時,投遞到與該交互機綁定的所有隊列。

Rabbitmq支持消息持久化,也就是將數據寫在磁盤上。為了數據安全考慮,大多數情況下都會選擇持久化。消息隊列持久化包括三個部分:

  1. Exchange持久化,在聲明時指定durable >=1.
  2. Queue持久化,在聲明時指定durable => 1.
  3. 消息持久化,在投遞時指定delivery_mode => 2(1是非持久化)。

如果Exchange和Queue都是持久化,那么它們之間的Binding也是持久化的。如果Exchange和Queue兩者之間有一個是持久化的,一個是非持久化的,就不允許建立綁定。

安裝

快速入門

springboot中整合Rabbitmq是一個非常容易的事情,

  • 新建一個spring boot工程,命名為springboot-rabbitmq
  • 在pom文件中引入依賴,其中
  <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.4.5.RELEASE</version>
    </parent>
    
<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
    </dependencies>
  • application.properties中配置關于Rabbitmq的連接和用戶信息,
spring:
  application:
    name: springboot-rabbitmq
  rabbitmq:
    host: 
    port: 5672
    username: 
    password: 
  • 創建生產者Sender。通過注入AmqpTemplate接口的實例來實現消息的發送,AmqpTemplate接口定義了一套針對AMQP協議的基礎操作,在spring boot中會根據配置來注入其具體的實現。

我們發送一字符串到zhihao.miao.order隊列中,

@Component
public class Sender {

    @Autowired
    private AmqpTemplate amqpTemplate;

    public void send(){
        String context = "hello "+ LocalDateTime.now().toString();
        System.out.println("Sender: "+context);
        this.amqpTemplate.convertAndSend("zhihao.miao.order",context);
    }
}
  • 創建消息消費者Receiver。通過@RabbitListener注解定義該類對指定隊列的監聽,并用@RabbitHandler注解來指定對消息的處理方法(不同的消息格式,@RabbitHandler配置的方法的入參就不用,默認是byte[] 類型)。所以,該消費者實現了對zhihao.miao.order隊列的消費,消費操作作為輸出消息的字符串內容。
@Component
@RabbitListener(queues = "zhihao.miao.order")
public class Receiver {
    
    @RabbitHandler
    public void process(String hello){
        System.out.println("Receiver: "+hello);
    }
    
}
  • 創建RabbitMQ的配置類RabbitConfig,用來配置隊列,交換機,路由等高級信息。這里我們只配置隊列,已完成一個基本的生產消費過程。
    這一步相當于自動創建的過程,如果在控制臺上已經創建了該隊列,此步驟可以省略。
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitConfig {

    @Bean
    public Queue queue(){
        return new Queue("zhihao.miao.order");
    }

}
  • 創建啟動主類
@SpringBootApplication
public class RabbitMQApplication {
    public static void main(String[] args) {
        SpringApplication.run(RabbitMQApplication.class,args);
    }
}
  • 創建單元測試類,用來調用消息生產
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = RabbitMQApplication.class)
public class RabbitMQApplicationTest {
    
    @Autowired
    private Sender sender;
    
    @Test
    public void setSender() throws Exception{
        sender.send();
    }
}
  • 啟動應用主類,在控制臺看到創建了一個連接rabbitmq的連接


    springboot整合rabbitmq啟動創建連接

查看控制面板,查看連接信息


控制面板連接信息
控制面板連接信息
  • 運行單元測試類,發送消息


    控制臺顯示發送了消息

整合spring cloud bus

定義了四個項目,config-server-eurekaspring cloud config server服務),eureka-servereureka 服務),order-service(訂單服務,也是spring cloud config 客戶端),user-service(用戶服務,也是spring cloud config的客戶端),在git遠程倉庫中定義了二個項目,分別是user-service-configorder-service-config項目。

  • 對其進行改造,修改pom文件,在user-serviceorder-service中增加
   <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-bus-amqp</artifactId>
   </dependency>
  • user-serviceorder-service中的配置文件中增加關于Rabbitmq的連接和用戶信息
spring: 
  rabbitmq:
    host: 
    port: 5672
    username: 
    password: 
  • 啟動config-server-eureka,在啟動user-serviceorder-service,我們可以在user-serviceorder-service的控制臺上看到如下的內容,在啟動的時候多了一個/bus/refresh請求,
order-service的/bus/refresh
  • 訪問兩個服務的請求http://192.168.5.4:7070/user/indexhttp://192.168.5.4:6060/order/index查看配置中的配置內容,比如我order-service-config的生產環境配置的配置信息是:
spring:
  datasource:
    username: '{cipher}af9b9ea63ce1c027d78c1c3414b425ad6f0093c20c69ad144eacb5a8b4522e7c'

check:
  uri: pro-1.0

上面的spring.datasource.usernamezhihao.miao的對稱加密
user-service-config配置的配置信息是:

spring:
  datasource:
    username: user-pro

check:
  uri: pro-2.0

還有就是order-service我啟動了二個服務,6061服務,訪問http://192.168.5.4:6061/order/index內容和6060的結果一樣

  • 接著修改兩個配置文件的內容,user-service-configorder-service-configcheck.url屬性,修改如下:
spring:
  datasource:
    username: '{cipher}af9b9ea63ce1c027d78c1c3414b425ad6f0093c20c69ad144eacb5a8b4522e7c'

check:
  uri: pro-2.0

user-service服務的配置文件:

spring:
  datasource:
    username: user-pro

check:
  uri: pro-1.0

訪問對應的頁面,發現這些配置都沒有生效,發送/bus/refreshorder-serviceuser-serviceorder-service服務

curl -X POST http://localhost:6060/bus/refresh

發現6060的控制臺和6061的控制臺都打印了很多輸出內容,

刷新克隆倉庫的配置到本地

這樣只要請求order-service服務上的一個實例就可以更新order-service服務的所有實例的配置,依靠消息總線的功能實現。

user-service服務也是,

curl -X POST http://localhost:7070/bus/refresh

原理分析

整個方案的架構如下圖所示,其中包含了git倉庫,config server以及幾個微服務應用的實例,這些微服務應用的實例中都引入了spring cloud bus,所以它們都連接到了rabbitmq的消息總線上了。

原理圖

當我們將系統啟動起來之后,圖中的"server A"的三個實例會請求Config Server以獲取配置信息,config server根據應用配置規則從git倉庫中獲取配置信息并返回。

此時,我們需要修改"server A"的屬性。首先,通過git管理工具去倉庫中修改對應的屬性值,但是這個修改并不會觸發"server A"實例的屬性更新。我們向"server A"的實例3發送post請求,訪問/bus/refresh接口。此時。“server A”的實例1和實例2從總線中獲取到,并重新從config server中獲取它們的配置信息,從而實現配置信息的動態更新。

而從git倉庫中配置的修改到發起/bus/refresh的post請求這一步可以通過git倉庫的web hook來自動觸發。由于所有連接到消息總線上的應用都會接收到更新請求,所以在web hook中就不需要維護所有節點內容進行更新,從而解決通過web hook來逐個進行刷新的問題。(一般不會使用web hook功能)

使用git倉庫的web hook進行消息總線的事件自動觸發

配置WebHooks

URL:就是自動刷新的地址

當配置文件進行修改時會自動觸發刷新事件,導致配置文件刷新。

指定刷新范圍

局部刷新

我們通過向服務實例請求spring cloud bus/bus/refresh接口,從而觸發了總線上其他服務實例的/refresh。但是在一些特殊場景下,我們希望可以刷新微服務中某個具體實例的配置。

spring cloud bus對這種場景也有很好的支持,/bus/refresh接口提供了一個destination參數,用于指定具體要刷新的應用程序。比如,可以刷新user-service的6061端口的服務/bus/refresh?destination=customers:6061,此時總線上的各個應用實例會根據destination屬性的值來判斷是否為自己的實例名,若符合才進行配置刷新,若不符合則忽略該消息。

再去修改一下order-service-config的配置內容,執行刷新:

curl -X POST http://localhost:6061/bus/refresh?destination=order-service:6061

此時從控制臺上也可以看出,6061的控制臺上有刷新克隆倉庫配置到本地的日志,而同一個服務的不同實例6060去沒有日志輸出,再去訪問url請求驗證一下
http://192.168.5.4:6061/order/index配置已經改了。
http://192.168.5.4:6060/order/index配置沒有改變。

默認情況下,ApplicationContext IDspring.application.name:server.port(也就是上面destination參數后面的order-service:6061),詳見org.springframework.boot.context.ContextIdApplicationContextInitializer.getApplicationId(ConfigurableEnvironment) 方法。

destination參數除了可以定位具體的實例之外,還可以用來定位具體的服務。定位服務的原理是通過spring的PathMatecher(路徑匹配)來實現的,比如/bus/refresh?destination=customers:**,該請求會觸發customers服務的所有實例進行刷新。

再去修改order-service服務的倉庫order-service-configpro環境的配置:

spring:
  datasource:
    username: '{cipher}af9b9ea63ce1c027d78c1c3414b425ad6f0093c20c69ad144eacb5a8b4522e7c'

check:
  uri: pro-3.0

執行/bus/refresh刷新,訪問對應的頁面發現同一個order-service服務的不同實例都配置都是刷新了。

curl -X POST http://localhost:6061/bus/refresh?destination=order-service:**

應用的上下文id必須不一樣

我們上面知道ApplicationContext id是由三部分組成(name,profile,index),name是spring.application.name,profile當前指定的配置文件,index是${vcap.application.instance_index:${spring.application.index:${server.port:${PORT:null}}}}組成.

The bus tries to eliminate processing an event twice, once from the original ApplicationEvent and once from the queue. To do this, it checks the sending application context id againts the current application context id. If multiple instances of a service have the same application context id, events will not be processed. Running on a local machine, each service will be on a different port and that will be part of the application context id. Cloud Foundry supplies an index to differentiate. To ensure that the application context id is the unique, set spring.application.index to something unique for each instance of a service. For example, in lattice, set spring.application.index=${INSTANCE_INDEX} in application.properties (or bootstrap.properties if using configserver).

spring cloud bus執行一次刷新就能自動刷新一個服務下的不同實例。 為了達到這個目的,它會檢查發送應用程序上下文id是否一樣。 如果服務的多個實例具有相同的應用程序上下文id,則一次刷新不能刷新這個服務下的所有實例。 我們在本地機器上運行,每個服務將在不同的端口上,這個時候的ApplicationContext id是不一樣的,因為端口不一致。而如果實際生產中都是一個服務的不同實例是部署到不同的服務器上的,端口,應用名,當前配置文件(pro)都是一致的,這樣就會出現刷新事件的不能傳播。 Cloud Foundry提供區分的索引來標識一個服務的不同實例的ApplicationContext id是唯一的。 為了確保應用程序上下文id是唯一的,請將spring.application.index設置為服務的每個實例唯一的值。 例如,在application.properties中設置spring.application.index = $ {INSTANCE_INDEX}(如果使用configserver,請設置bootstrap.properties)。

自己沒有去測試。當使用下面的架構優化后通過訪問configserver的url和destination參數是不是只需要配置configserverspring.application.index的不一致即可還是configserverconfigclient都要去配置。(如果是通過刷新configclient則肯定要配置configclientspring.application.index就行了)

參考資料
Addressing all instances of a service

Application Context ID must be unique

架構優化

既然spring cloud bus/bus/refresh接口提供了針對服務和實例進行配置更新的參數,那么我們的架構也可以相應的做出一些調整。在之前的demo中。服務的配置更新需要通過向具體服務中的某個實例發送請求,再觸發對整個服務集群的配置更新,雖然能實現功能,但是這樣的結果是,我們指定的應用實例會不同于集群中的其他應用實例,這樣會增加集群內部的復雜度,不利于將來的運維工作。比如,需要對服務實例進行遷移,那么我們不得不修改web hook中的配置等。所以要盡可能地讓服務集群中的各個節點是對等的。

架構調整之后

我們主要做了下面的改動:
1.在config server中引入了spring cloud bus,將配置服務端也加入到消息總線來。
2./bus/refresh請求不再發送到具體的服務實例上,而是發送給config server,并通過destination參數類指定需要更新配置的服務或實例。

通過上面的改動,我們的服務實例不需要再承擔觸發配置更新的職責。同時,對于git的觸發等配置都只需要針對config server即可。從而簡化了集群上的一些維護工作。

進行改造吧,

config-server-eureka服務,
加入依賴:

<dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>

配置:

spring: 
  rabbitmq:
    host: 
    port: 5672
    username: 
    password: 

啟動config-server-eureka服務,然后發現控制臺上有/bus/refresh端點進行輸出。

服務啟動控制臺

就以order-service來進行測試吧,啟動二個實例(6060,6061),首先查看order-service-configpro配置文件內容:

spring:
  datasource:
    username: '{cipher}af9b9ea63ce1c027d78c1c3414b425ad6f0093c20c69ad144eacb5a8b4522e7c'

check:
  uri: pro-2.0

訪問localhost:6060/order/index結果顯示是

username=zhihao.miao,check.uri===pro-2.0。

我們對配置文件進行修改

spring:
  datasource:
    username: '{cipher}af9b9ea63ce1c027d78c1c3414b425ad6f0093c20c69ad144eacb5a8b4522e7c'

check:
  uri: pro-3.0

通過去訪問config-server-eureka服務提供的刷新端點(localhost:9090/bus/refresh)進行配置刷新,

curl -X POST http://localhost:9090/bus/refresh?destination=order-service:**

我們發現這命令的時候,config-server-eureka的控制臺輸出

config-server-eureka

而實際的order-service(6060,6061)服務的控制臺也輸出一些操作,比如說刷新,從遠程倉庫git clone新的代碼配置等等,說明我們的架構優化是成功的。

追蹤總線事件

Bus events (subclasses of RemoteApplicationEvent) can be traced by setting spring.cloud.bus.trace.enabled=true. If you do this then the Spring Boot TraceRepository (if it is present) will show each event sent and all the acks from each service instance. Example (from the /trace endpoint):

通過設置spring.cloud.bus.trace.enabled=true可以追蹤消息總線上的事件傳播。可以通過每個服務的/trace的端點來追蹤所有事件的發起和消息回執。

user-service中加入配置,如下,

spring:
  cloud:
    bus:
      trace:
        enabled: true

然后重啟服務,訪問url

http://localhost:7070/trace

頁面顯示如下:

trace節點

顯示RefreshRemoteApplicationEvent事件從user-service:7070發起,傳播到user-service下的所有節點。

參考資料
Tracing Bus Events

傳播自己的事件


參考資料
Broadcasting Your Own Events

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容