ps: 本文所有代碼可在 這里 查看。
背景
記得之前有一個場景,網關接收各種類型設備上傳過來的數據包,然后根據不同類型的數據,通過 MQ
轉發到相應的處理器進行消費。舉個例子:現在有2種類型的設備采集器,分別為 水位監測器 和 溫度監測器,最后會分發到各自的處理器進行處理。
解決方案
一般做法
所有處理不同類型數據的隊列,監聽同一個 Topic
,然后消費時,通過判斷數據的類型來決定是否需要處理。比如上面的例子,每來一條數據,2個處理器都會去消費這條數據,對于 水位監測器 的處理器,如果是 水位監測器 的數據,那剛好,正常消費,如果是 溫度監測器 的數據,直接跳過。
這種做法,優點很明顯,即不用增加其他配置,只需在消費時做下類型判斷;但缺點也特別明顯,所有消息,每一個隊列都需要消費一次。為什么這么說呢?我們都知道,消息在投遞過程中,消息是需要序列化和反序列化的(一般使用的是 json
),序列化和反序列化是需要耗系統資源的,而且投遞過程中也是需要占用帶寬的,而消息到達消費端時,大部分情況下都會因為類型不符而跳過處理,最后還要通知交換機處理結果,這樣就會造成不必要的資源浪費。
可以看到,如果數據量小,分類不多,缺點并不會造成多嚴重的后果,但如果數據量一大,分類一多,那將會極大的浪費系統資源。我們都知道,物聯網的各種設備何止千千萬,不同設備類型更是繁多,那么一旦使用這種方案,本來10臺機器能搞定的事情,最后可能需要幾十臺,數據分類多的話,可能還需要更多。而且數據量如果突然劇增,系統也很容易就扛不住。
綜上,這種方案大多情況下是不適用的。那有沒有更好的方案,比如不同處理器,只處理一種對應的設備上報的數據包?答案是肯定的,那就是——動態路由。
動態路由
何為 動態路由?簡單的說,就是:消息到達交換機后,會根據動態的 routingKey
,投遞到與交換機綁定時 bindingKey
相同的隊列中。
舉個例子,水位監測器 的隊列與交換機綁定時使用的 bindingKey
為 waterLevel
,這時如果來了一條監測到的水位數據,消息在發布時使用的動態 routingKey
也為 waterLevel
,那這條數據 水位監測器 的處理器能正常處理,而 bindingKey
為 temperature
的 溫度監測器 隊列則收不到這條數據。
我們都知道, bindingKey
可以通過配置 spring.cloud.stream.rabbit.bindings.<channelName>.consumer.bindingRoutingKey
來達到效果,那難點就剩下:如何在發布消息時指定想要的 routingKey
。翻了下官方文檔,找到這樣一個配置:
很明顯,這就是我們想要的,支持一個 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;
}
}
測試結果
再看一下可視化界面的隊列詳情:
可以看到,
routing key
為我們配置的 waterLevel
。
如果將其中某一個的 bindingRoutingKey
去掉或改成默認值 #
,結果如下:
代碼分析
其實最關鍵的一行代碼是:
// ...
Message<PacketModel> message = MessageBuilder.withPayload(model).setHeader("type", model.getType()).build();
// ...
構建消息時,自定義一個 key
為 type
的 header
,而我們在定義生產者時,指定了 routingKeyExpression
為 headers.type
,也就是說,在投遞時會以 type
的值作為最后的 routingKey
。所以,這樣也就達到了我們想要的效果。
總結
這種配置方式,適合:生產者只有一個,消費者有多個,且需要將不同的消息投遞到不同的目標隊列。這樣的場景很多,除了上面舉的例子,還有:不同平臺(天貓、淘寶、京東、有贊等)的訂單,需要被各自的處理器進行消費。
相關鏈接
推薦閱讀
Spring Cloud 進階玩法
統一異常處理介紹及實戰
Spring Cloud Stream 進階配置——使用延遲隊列實現“定時關閉超時未支付訂單”
Spring Cloud Stream 進階配置——高可用(二)——死信隊列