九. SpringCloud Stream訊息驅動

MPolaris發表於2021-03-04

1. 訊息驅動概述

1.1 是什麼

在實際應用中有很多訊息中介軟體,比如現在企業裡常用的有ActiveMQ、RabbitMQ、RocketMQ、Kafka等,學習所有這些訊息中介軟體無疑需要大量時間經歷成本,那有沒有一種技術,使我們不再需要關注具體的訊息中介軟體的細節,而只需要用一種適配繫結的方式,自動的在各種訊息中介軟體內切換呢?訊息驅動就是這樣的技術,它能 遮蔽底層訊息中介軟體的差異,降低切換成本,統一訊息的程式設計模型

SpringCloud Stream是一個構件訊息驅動微服務的框架。應用程式通過inputs和outputs來與SpringCloud Stream中的繫結器(binder)物件互動,通過配置來繫結,而SpringCloud Stream的繫結器物件負責與訊息中介軟體互動,所以,我們只需要搞清楚如何與SpringCloud Stream互動就可以方便使用訊息驅動的方式。但是 截至到目前 SpringCloud Stream僅支援RabbitMQ和Kafka

1.2 設計思想

標準MQ模型

  • 生產者 / 消費者之間靠訊息媒介傳遞資訊內容 - Messag
  • 訊息必須走特定的通道 - Message Channel
  • 訊息通道里的訊息如何被消費呢?誰負責處理? - 訊息通道 MessageChannel 的子介面 SubscribableChannel,由 MessageHandler 訊息處理器所訂閱
image-20210304184605474

為什麼使用Cloud Stream

比如說我們用到了RabbitMQ和Kafka,由於這兩個訊息中介軟體的架構上的不同,像RabbitMQ有exchange,Kafka有Topic和Partitions分割槽,這些中介軟體的差異性導致實際專案開發給我們造成了一定的困擾,我們如果用了兩個訊息佇列的其中一種,後面的業務需求如果又要往另外一種訊息佇列進行遷移,這無疑是一個災難,一大堆東西都要重新推到重做,因為它跟我們的系統耦合了,這時候SpringCloud Stream給我們提供了一種解耦合的方式。

image-20210304185448484

stream憑什麼可以統一底層差異

在沒有繫結器這個概念的情況下,我們的SpringBoot應用要直接與訊息中介軟體進行資訊互動的時候,由於各訊息中介軟體構建的初衷不同,它們的實現細節上會有較大的差異性。

通過定義繫結器作為中間層,完美的實現了 應用程式與訊息中介軟體細節之間的隔離。Stream對訊息中介軟體的進一步封裝(通過嚮應用程式暴露統一的Channel通道,使得應用程式不需要再考慮各種不同的訊息中介軟體實現),可以做到程式碼層面對中介軟體的無感知,甚至於動態的切換中介軟體(如RabbitMQ切換為Kafka),使得微服務開發的高度解耦,服務可以更多的關注自己的業務流程。

在訊息繫結器中,INPUT對應於消費者,OUTPUT對應於生產者

Stream中的訊息通訊方式遵循了 釋出-訂閱模式,用Topic(主題)進行廣播(RabbitMQ中對應於Exchange交換機,Kafka中就是Topic)。

1.3 SpringCloud Stream標準流程套路
  • Binder 很方便的連線中介軟體,遮蔽差異
  • Channel 通道,是佇列Queue的一種抽象,在訊息通訊系統中就是實現了儲存和轉發的媒介,通過Channel對佇列進行配置
  • SourceSink 簡單的可以理解為參照物件是SpringCloud Stream自身,從Stream釋出訊息就是輸出,接受訊息就是輸入
image-20210304191045523
1.4 SpringCloud Stream編碼API與常用註解
image-20210304191011194
組成 說明
Middleware 中介軟體,目前只支援RabbitMQ和Kafka
Binder Binder是應用與訊息中介軟體之間的封裝,目前實行了RabbitMQ和Kafka的Binder,通過Binder可以很方便的連線中介軟體,可以動態的改變訊息型別(對應於Kafka的topic,RabbitMQ的exchange),這些都可以通過配置檔案來實現
@Input 註解標識輸入通道,通過該輸入通道接收到的訊息進入應用程式
@Output 註解標識輸出通道,釋出的訊息將通過該通道離開應用程式
@StreamListner 監聽佇列,用於消費者的佇列的訊息接收
@EnableBinding 使通道Channel和交換機/主題(Exchange/Topic)繫結在一起

2. Spring Cloud Stream 案例

新建三個子模組分別對應於訊息的生產者和消費者:

模組名 微服務功能
cloud-stream-rabbitmq-provider8801 生產者,傳送訊息模組
cloud-stream-rabbitmq-consumer8802 消費者,接收訊息模組
cloud-stream-rabbitmq-consumer8803 消費者,接收訊息模組
2.1 訊息驅動之訊息生產者

新建Module:cloud-stream-rabbitmq-provider8801作為訊息的生產者用來傳送訊息,在其POM檔案中除引入web、actuator、eureka-client等必要啟動器外,還需要引入SpringCloud Stream對應實現RabbitMQ的啟動器依賴:

<dependency>
	<groupId>org.springframework.cloud</groupId>
	<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
</dependency>

編寫其配置檔案application.yml:

server:
  port: 8801

spring:
  application:
    name: cloud-stream-provider
  cloud:
    stream:
      binders: # 在此處配置要繫結的rabbitmq的服務資訊
        defaultRabbit: # 表示定義的名稱,用於於binding整合
          type: rabbit # 訊息元件型別
          environment: # 設定rabbitmq的相關的環境配置
            spring:
              rabbitmq:
                host: mpolaris.top
                port: 5672
                username: admin
                password: 1234321
      bindings: # 服務的整合處理
        output: # 這個名字是一個通道的名稱,OUTPUT表示這是訊息的傳送方
          # 表示要使用的Exchange名稱定義
          destination: testExchange 
          # 設定訊息型別,本次為json,文字則設定“text/plain”
          content-type: application/json 
          # 設定要繫結的訊息服務的具體設定
          default-binder: defaultRabbit

eureka:
  client: # 客戶端進行Eureka註冊的配置
    service-url:
      defaultZone: http://eureka7001.com:7001/eureka
  instance:
    # 設定心跳的時間間隔(預設是30秒)
    lease-renewal-interval-in-seconds: 2 
    # 如果現在超過了5秒的間隔(預設是90秒)
    lease-expiration-duration-in-seconds: 5 
    # 在資訊列表時顯示主機名稱yml
    instance-id: send-8801.com  
    # 訪問的路徑變為IP地址
    prefer-ip-address: true     

編寫其主啟動類

編寫業務類,在業務類中分別要編寫 傳送訊息介面 及其 實現類,並在傳送介面訊息的實現類中 新增 @EnableBinding 註解 用來繫結訊息的推送管道,訊息生產者繫結的訊息推送管道為 org.springframework.cloud.stream.messaging.Source

public interface IMessageProvider {
    public String send();
}
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.messaging.Source;
import org.springframework.integration.support.MessageBuilder;
import org.springframework.messaging.MessageChannel;

import javax.annotation.Resource;
import java.util.UUID;

/**
 * @Author polaris
 * @Date 2021/3/4 21:46
 */
@EnableBinding(Source.class) //定義訊息的推送管道
public class MessageProviderImpl implements IMessageProvider {

    @Resource
    private MessageChannel output; //訊息傳送管道

    @Override
    public String send() {
        String serial = UUID.randomUUID().toString();
        output.send(MessageBuilder.withPayload(serial).build()); //傳送訊息
        System.out.println("==> serial:" + serial);
        return null;
    }
}

注意我們在service的實現類中不再需要@Service註解,因為這個service不再是傳統意義上的和Controller、DAO資料等進行互動的service,而是要繫結繫結器打交道的service。

然後編寫其業務層的Controller:

@RestController
public class SendMessageController {
    @Autowired
    private IMessageProvider messageProvider;

    @GetMapping("/sendMessage")
    public String sendMessage() {
        return messageProvider.send();
    }
}

啟動服務註冊中心後和RabbitMQ後,啟動訊息生產者微服務,我們在RabbitMQ的控制皮膚中可以看見多出了一個名為testExchange的交換機,這個交換機恰恰就是我們之前在配置檔案中配置的交換機名字testExchange。

然後我們訪問 http://localhost:8801/sendMessage 使用訊息生產者微服務傳送訊息,在其微服務後臺我們看到了列印的訊息。

在RabbitMQ的控制皮膚中我們也看到了確實傳送了訊息。

image-20210304215848131
2.2 訊息驅動之訊息消費者

新建Module:cloud-stream-rabbitmq-consumer8802/8803作為訊息的生產者用來接收訊息,其POM檔案中引入的啟動器依賴和訊息生產者微服務的依賴幾乎相同,然後編寫其配置檔案application.yml,其配置檔案的書寫和訊息生產者的幾乎一致,特別需要注意的是,訊息生產者微服務用到的通道為OUTPUT,而訊息消費者微服務用到的通道為INPUT,其他的配置檔案資訊就只需要注意埠號、註冊服務名的區別即可:

spring:
  cloud:
      bindings: 
        input: # 這個名字是一個通道的名稱,INPUT表示訊息消費者

編寫主啟動類

編寫訊息消費者的業務類,由於是消費者,所以只需要編寫其Controller即可,在其Controller上同樣需要新增 @EnableBinding 註解用來繫結訊息的推送管道,訊息消費者繫結的訊息推送管道為import org.springframework.cloud.stream.messaging.Sink,在接收訊息的方法中需要使用 @StreamListner 註解來監聽其繫結的訊息推送管道:

@Component
@EnableBinding(Sink.class)
public class ReceiveMessageController {
    
    @Value("${server.port}")
    private String serverPort;
    
    @StreamListener(Sink.INPUT)
    public void input(Message<String> message) {
        System.out.println("消費者" + serverPort + "號,收到訊息:" 
                           + message.getPayload());
    }
}

然後啟動訊息傳送消費者服務,用生產者傳送訊息,我們可以發現在消費者端可以成功接收到訊息。

3. 分組消費和持久化

3.1 重複消費問題

當生產者傳送訊息後,此時的我們的消費者都接受了訊息並進行了消費,也就是說同一條訊息被多個訊息消費者所消費。

上述的問題就是訊息的 重複消費 問題,那麼這個問題為什麼如此重要呢?其實重複消費這個問題本身不可怕,可怕的是沒考慮到重複消費之後,怎麼保證冪等性。(冪等性 通俗的說,就一個資料,或者一個請求,重複很多次,需要確保對應的資料是不會改變的,不能出錯)。分散式微服務應用為了實現高可用和負載均衡,實際上同一功能的服務都會部署多個具體的服務例項。舉個例子,假設有一個系統,有一條訊息要求往資料庫裡插入一條資料,要是這個訊息重複消費兩次,結果就是向資料庫裡插入了兩條資料,這樣資料就錯了,就違背了冪等性原則,但是要是該訊息消費到第二次的時候,可以判斷一下已經消費過了,然後直接將該訊息丟棄,這就實現了只插入一條資料,一條訊息重複出現了兩次,但是隻有第一次真正被消費了,資料庫裡也就只插入了一條資料,這就保證了系統的冪等性。

上面簡單的介紹了訊息的重複消費問題,那如何解決這種重複消費問題呢,那就需要我們進行 分組和持久化屬性組 操作,利用SpringCloud Stream中的訊息分組來解決這個問題,需要注意的是在Stream中處於同一組中的多個訊息消費者是競爭關係,也就是保證生產者所傳送的同一個訊息只會被其中一個消費者消費一次。 不同組的消費者是可以對訊息進行全面消費(重複消費)的,只有同一組內才會發生競爭關係

在RabbitMQ中,預設分組group是不同的,組流水號不一樣,被認為不同組,我們檢視testExchange交換機,可以發現8802和8803兩個訊息消費者處於不同的組,所以8801訊息生產者傳送的訊息可以被這兩個消費者重複消費:

image-20210304230322826
3.2 分組解決重複消費問題

上面在RabbitMQ控制皮膚中我們看到的組流水號是系統隨機分配的,這樣無疑不好控制,所以我們應該自定義配置分組,將8802/8803兩個訊息消費者微服務分為同一個組,以此來解決訊息的重複消費問題。

先來演示如何自定義分組

在8802/8803微服務中的配置檔案中分別新增組名屬性:

spring:
  cloud:
    stream:
      bindings:
        input:
          group: A/B # 分組名稱

這裡我們將8802設定為A組,8803設定為B組,然後我們將訊息消費方的兩個微服務重啟,我們再次檢視其組流水號,發現不再是長長的隨機組流水號,而變成了我們自定義的分組:

image-20210304230642039

此時由於8802/8803位於兩個不同分組下,所以沒有競爭關係,訊息生產者傳送訊息後,仍然可以重複消費。

下面我們將這兩個訊息消費方微服務分到相同的消費組中,這樣每次就只有一個消費者,訊息生產者傳送的訊息只能被8802或8803其中一個接受到,這樣就避免了重複消費,將8802和8803的分組名都改為A,再次重啟兩個訊息消費方微服務,此時我們可以看到在分組A下已經有了兩個消費者。

image-20210304231043210

再用生產者傳送5條訊息,我們發現8802/8803分別消費了3條和2條不同的訊息,而沒有出現重複消費的問題。

3.3 持久化

通過上述,解決了重複消費問題,再來看看持久化

加上了group就自動支援持久化了

下面來演示一下持久化

  • 停止8802/8803並去除掉8802分組group:A(8803的分組group A沒有去掉)

  • 8801傳送4條訊息到rabbitmq

  • 先啟動8802(無分組屬性配置),後臺沒有打出來訊息(訊息丟失故障)

  • 再啟動8803(有分組屬性配置),後臺打出了4條訊息(消費持久化訊息)

相關文章