Spring Cloud Stream 進階配置——動態路由

ps: 本文所有代碼可在 這里 查看。

背景

記得之前有一個場景,網關接收各種類型設備上傳過來的數據包,然后根據不同類型的數據,通過 MQ 轉發到相應的處理器進行消費。舉個例子:現在有2種類型的設備采集器,分別為 水位監測器溫度監測器,最后會分發到各自的處理器進行處理。

解決方案

一般做法

所有處理不同類型數據的隊列,監聽同一個 Topic,然后消費時,通過判斷數據的類型來決定是否需要處理。比如上面的例子,每來一條數據,2個處理器都會去消費這條數據,對于 水位監測器 的處理器,如果是 水位監測器 的數據,那剛好,正常消費,如果是 溫度監測器 的數據,直接跳過。

這種做法,優點很明顯,即不用增加其他配置,只需在消費時做下類型判斷;但缺點也特別明顯,所有消息,每一個隊列都需要消費一次。為什么這么說呢?我們都知道,消息在投遞過程中,消息是需要序列化和反序列化的(一般使用的是 json),序列化和反序列化是需要耗系統資源的,而且投遞過程中也是需要占用帶寬的,而消息到達消費端時,大部分情況下都會因為類型不符而跳過處理,最后還要通知交換機處理結果,這樣就會造成不必要的資源浪費。

可以看到,如果數據量小,分類不多,缺點并不會造成多嚴重的后果,但如果數據量一大,分類一多,那將會極大的浪費系統資源。我們都知道,物聯網的各種設備何止千千萬,不同設備類型更是繁多,那么一旦使用這種方案,本來10臺機器能搞定的事情,最后可能需要幾十臺,數據分類多的話,可能還需要更多。而且數據量如果突然劇增,系統也很容易就扛不住。

綜上,這種方案大多情況下是不適用的。那有沒有更好的方案,比如不同處理器,只處理一種對應的設備上報的數據包?答案是肯定的,那就是——動態路由。

動態路由

何為 動態路由?簡單的說,就是:消息到達交換機后,會根據動態的 routingKey,投遞到與交換機綁定時 bindingKey 相同的隊列中。

舉個例子,水位監測器 的隊列與交換機綁定時使用的 bindingKeywaterLevel,這時如果來了一條監測到的水位數據,消息在發布時使用的動態 routingKey 也為 waterLevel,那這條數據 水位監測器 的處理器能正常處理,而 bindingKeytemperature溫度監測器 隊列則收不到這條數據。

我們都知道, bindingKey 可以通過配置 spring.cloud.stream.rabbit.bindings.<channelName>.consumer.bindingRoutingKey 來達到效果,那難點就剩下:如何在發布消息時指定想要的 routingKey。翻了下官方文檔,找到這樣一個配置:

routingKeyExpression

很明顯,這就是我們想要的,支持一個 SpEL 表達式,如果是固定的 routingKey,寫一個常量字符串即可。

文檔鏈接在 這里

接下來,我們來進行一個簡單的 demo 試一下。

application-dynamic.yml

spring:
  cloud:
    stream:
      bindings:
        packetUplinkOutput:
          destination: packetUplinkTopic
          content-type: application/json
          binder: rabbit

        waterLevelInput:
          destination: packetUplinkTopic
          content-type: application/json
          group: ${spring.application.name}.waterLevel
          binder: rabbit

        temperatureInput:
          destination: packetUplinkTopic
          content-type: application/json
          group: ${spring.application.name}.temperature
          binder: rabbit

      rabbit:
        bindings:
          packetUplinkOutput:
            producer:
              # 生產者配置RabbitMq的動態路由鍵
              routingKeyExpression: headers.type

          waterLevelInput:
            consumer:
              bindingRoutingKey: waterLevel # 將queue綁定到exchange時使用的routing key。默認'#'
          temperatureInput:
            consumer:
              bindingRoutingKey: temperature # 將queue綁定到exchange時使用的routing key。默認'#'

ScasDynamicRoutingTest

@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("dynamic")
@EnableBinding({ScasDynamicRoutingTest.MessageSink.class, ScasDynamicRoutingTest.MessageSource.class})
public class ScasDynamicRoutingTest {

    @Autowired
    private PacketUplinkProducer packetUplinkProducer;

    private Random random = new Random();
    private List<String> devEuis = new ArrayList<>(10);

    @PostConstruct
    private void initDevEuis() {
        devEuis.add("10001");
        devEuis.add("10002");
        devEuis.add("10003");
        devEuis.add("10004");
        devEuis.add("10005");
        devEuis.add("10006");
        devEuis.add("10007");
        devEuis.add("10008");
        devEuis.add("10009");
        devEuis.add("10010");
    }

    @Test
    public void test() throws InterruptedException {
        
        for (int i = 0; i < 5; i++) {
            String devEui = getDevEuis();
            String type = "waterLevel";
            packetUplinkProducer.publish(new PacketModel(devEui, type));
        }

        for (int i = 0; i < 5; i++) {
            String devEui = getDevEuis();
            String type = "temperature";
            packetUplinkProducer.publish(new PacketModel(devEui, type));
        }

        Thread.sleep(10000000);

    }

    private String getDevEuis() {
        return devEuis.get(random.nextInt(10));
    }

    @Component
    public static class PacketUplinkProducer {

        @Autowired
        private MessageSource messageSource;

        public void publish(PacketModel model) {
            log.info("發布上行數據包消息. model: [{}].", model);
            Message<PacketModel> message = MessageBuilder.withPayload(model).setHeader("type", model.getType()).build();
            messageSource.packetUplinkOutput().send(message);
        }

    }

    @Component
    public static class PacketUplinkHandler {

        @StreamListener("waterLevelInput")
        public void handleWaterLevelPacket(PacketModel model) throws InterruptedException {
            log.info("消費【水位監測器】數據包消息. model: [{}].", model);
        }

        @StreamListener("temperatureInput")
        public void handleTemperaturePacket(PacketModel model) throws InterruptedException {
            log.info("消費【溫度監測器】數據包消息. model: [{}].", model);
        }

    }

    public interface MessageSink {

        @Input("waterLevelInput")
        SubscribableChannel waterLevelInput();

        @Input("temperatureInput")
        SubscribableChannel temperatureInput();

    }

    public interface MessageSource {

        @Output("packetUplinkOutput")
        MessageChannel packetUplinkOutput();

    }

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    static class PacketModel {
        /**
         * 設備 eui
         */
        private String devEui;

        /**
         * 設備類型
         */
        private String type;
    }

}

測試結果

result

再看一下可視化界面的隊列詳情:

waterLevel queue

可以看到,routing key 為我們配置的 waterLevel

如果將其中某一個的 bindingRoutingKey 去掉或改成默認值 #,結果如下:

another result

代碼分析

其實最關鍵的一行代碼是:

// ...
Message<PacketModel> message = MessageBuilder.withPayload(model).setHeader("type", model.getType()).build();
// ...

構建消息時,自定義一個 keytypeheader,而我們在定義生產者時,指定了 routingKeyExpressionheaders.type,也就是說,在投遞時會以 type 的值作為最后的 routingKey。所以,這樣也就達到了我們想要的效果。

總結

這種配置方式,適合:生產者只有一個,消費者有多個,且需要將不同的消息投遞到不同的目標隊列。這樣的場景很多,除了上面舉的例子,還有:不同平臺(天貓、淘寶、京東、有贊等)的訂單,需要被各自的處理器進行消費。

相關鏈接

Spring Cloud Stream Guide

推薦閱讀

Spring Cloud 進階玩法
統一異常處理介紹及實戰
Spring Cloud Stream 進階配置——使用延遲隊列實現“定時關閉超時未支付訂單”
Spring Cloud Stream 進階配置——高可用(二)——死信隊列

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