WebSocket在現代瀏覽器中的應用已經算是比較普遍了,在某些業務場景下,要求必須能夠在伺服器端推送訊息至客戶端。在沒有WebSocket的年代,我們使用過dwr,在那個時候dwr真實一個非常棒的方案。但是在WebSocket興起之後,我們更願意使用標準實現來解決問題、
首先交代一下,本篇文章不講解WebSocket的配置,主要講的是針對在微服務架構叢集模式下解決方案的選擇。
微服務架構大家應該都不陌生了,在微服務架構下,服務是分散式的,而且為了保證業務的可用性,每個服務都是以叢集的形式存在。在叢集模式下,要保證叢集的每一個節點的訪問得到相同的結果就需要做到資料一致性,如快取、session等。
微服務叢集快取通常使用分散式快取redis解決,session一致性也通常會通過redis解決,但是現在更流行的是無狀態的Http,即無session化,最常見的解決方案就是OAuth。
WebSocket有所不同,它是與服務端建立一個長連線,在叢集模式下,顯然不可能把前端與服務叢集中的每一個節點建立連線,一個可行的思路是像解決http session的共享一樣,通過redis來實現websocket的session共享,但是websocket session的數量是遠多於http session的數量的(因為每開啟一個頁面都會建立一個websocket連線),所以隨著使用者量的增長,共享的資料量太大,很容易造成瓶頸。
另一個思路是,websocket總歸會與叢集中某個節點建立連線,那麼,只要找到連線所在的節點,就可以向服務端推送訊息了,那麼要解決的問題就是如何找到一個websocket連線所在的節點。要找到連線在哪個節點上,我們需要一個唯一的識別符號用於尋找連線,然而在基於stomp的釋出-訂閱模式下,一個訊息的推送可能是面向若干個連線的,可能分佈在叢集中的每一個節點上,這樣去尋找連線的代價也很高。既然這樣,我們不妨換種思路,每一個websocket訊息,我們在叢集的每個節點上都進行推送,訂閱了該訊息的連線,不管有一個還是一萬個,最終肯定都能收到這個訊息。基於這個思路,我們做了一些技術選型:
-
RabbitMQ
-
Spring Cloud Stream
首先說RabbitMQ,高階訊息佇列,可以實現訊息廣播(當然kafka一樣可以做到,這裡只介紹一種),另一項技術是Spring Cloud Stream,stream是一個用於構建高度可擴充套件事件驅動型微服務的框架,並且它可以跟RabbitMQ、Kafka以及其他多種訊息服務整合,使用了stream,要把rabbitmq換成kafka只不過是改改配置的事情。接下來重點介紹使用方法:
引入依賴
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-binder-rabbit</artifactId>
</dependency>
複製程式碼
配置Binder
binder是stream中的重要概念,是用於配置用於stream釋出和訂閱事件的訊息中介軟體。先看一段配置:
spring:
cloud:
stream:
binders:
defaultRabbit:
type: rabbit
environment:
spring:
rabbitmq:
host: localhost
username: username
password: password
virtual-host: /
複製程式碼
配置中的 defaultRabbit 是binder的名稱,一會會在其他配置中引用,type指定了訊息中介軟體的型別,environment是對訊息中介軟體的配置,這裡的配置結構和spring.rabbitmq名稱空間下的配置項一模一樣的,可以參照著進行配置(這樣配置的作用是可以把stream的rabbitmq配置和專案中其他地方使用的rabbitmq區分開,如果這裡不配置environment,binder會沿用spring.rabbitmq名稱空間下的配置),比如你的專案中的rabbitmq的配置是這樣的:
spring:
rabbitmq:
host: localhost
username: username
password: password
virtual-host: /
複製程式碼
那上門的binder的environment配置完全可以去掉。
訊息流與binder的繫結
微服務要接收揮著釋出事件訊息,根據spring cloud stream的名字,顧名思義,需要使用流,所以需要在配置中宣告兩個事件流,一個輸入流,一個輸出流:
spring:
cloud:
stream:
bindings:
websocketMessageIn:
destination: websocketMessage
binder: defaultRabbit
websocketMessageOut:
destination: websocketMessage
binder: defaultRabbit
複製程式碼
這裡我們看到,事件流引用了binder,表示這兩個流使用rabbitmq這個中介軟體(看到這裡想必大家已經明白了,在一個專案中完全可以同時使用rabbit和kafka作為事件流的訊息中介軟體)。
websocketMessageIn,websocketMessageOut是事件流的名字(可以自己隨便起),destination指定了兩個事件流的destination是同一個,這決定了寫入和讀取是指向同一個地方(不一定是同一個訊息佇列)。
事件流宣告
事件流使用介面進行定義:
/**
* websocket訊息事件流介面
* Created by 吳昊 on 18-11-8.
*
* @author 吳昊
* @since 1.4.3
*/
interface WebSocketMessageStream {
companion object {
const val INPUT: String = "webSocketMessageIn"
const val OUTPUT: String = "webSocketMessageOut"
}
/**
* 輸入
*/
@Input(INPUT)
fun input(): SubscribableChannel
/**
* 輸出
*/
@Output(OUTPUT)
fun output(): MessageChannel
}
複製程式碼
宣告事件流介面,這裡面定義了兩個常量,分別對應配置中的兩個流名稱,通過呼叫input()方法獲取輸入流,通過呼叫output()獲取輸出流。
該介面的實現由spring cloud stream完成,不需要自己實現。
使用事件流
宣告一個bean:
@Component
@EnableBinding(WebSocketMessageStream::class)
class WebSocketMessageService {
……
複製程式碼
這裡的@EnableBinding 註解指明瞭事件流介面類,只有新增了這個註解(要能被Spring識別到,可以加在入口類上,也可以加在@Configuration註解的類上),該介面才會被實現,並且加入到Spring的容器中(可以注入)。
上面WebSocketMessageService的內容如下:
@Autowired
private lateinit var stream: WebSocketMessageStream
@Autowired
private lateinit var template: SimpMessagingTemplate
@StreamListener(WebSocketMessageStream.INPUT)
fun messageReceived(message: WebSocketMessage) {
template.convertAndSend(message.destination, message.body)
}
fun send(destination: String, body: Any) {
stream.output().send(
MutableMessage(WebSocketMessage(destination, body))
)
}
複製程式碼
接收訊息
@StreamListener 註解指明瞭要監聽的事件流,方法接收的引數即事件的訊息內容(使用jackson反序列化),這裡的messageReceived方法直接將接收到的訊息直接用websocket傳送給前端
傳送訊息
同樣,傳送也很簡單,將訊息直接傳送到輸入流中,上面的send方法即是將原本應該用SimpMessagingTemplate傳送給websocket的訊息傳送到spring cloud stream的事件流中。這樣做以後,專案中所有需要向前端推送webSocket訊息的操作都應該呼叫send方法來進行。
講到這裡大家可能還有點糊塗,也有一些疑問,為什麼這樣每個微服務節點就能收到事件訊息了?或者單個節點接收事件訊息和多個節點接收的配置是怎麼控制的。各位不要著急,待我慢慢道來,接下來就要結合rabbit的知識來講解 了:
首先看一下rabbit的訊息佇列:
從圖中看到,存在多個以webSocketMessage開頭的佇列,這是每一個微服務節點建立了一個訊息佇列,再來看exchange:
exchange繫結的訊息佇列這裡的exchange名稱和上面訊息佇列的名稱字首均是webSocketMessage, 這個都是由前面的binding配置中的destination指定的,和destination名稱保持一致
當應用向輸入流中寫入事件時,使用destination作為key(即webSocketMessage),將訊息寫入名為webSocketMessage的exchange,由於exchange繫結的訊息佇列字首均為webSocketMessage且routing key都是#,所以exchange會將訊息路由到每一個webSocketMessage開頭的訊息佇列上(這裡涉及到rabbitmq的知識點,如過不懂請自行查閱資料),這樣每一個微服務都能接收到相同的訊息。
我們再來看前面提出的問題,這樣的配置可以把訊息推送到每一個微服務節點,那麼如果需要一個訊息只被一個節點接收,該怎麼配置呢?很簡單,一個配置項就可以搞定:
spring:
cloud:
stream:
bindings:
websocketMessageIn:
group: test
destination: websocketMessage
binder: defaultRabbit
複製程式碼
可以看到,相比前面的配置,僅僅多了一個group的配置,這樣配置之後,rabbitmq會生成一個名為websocketMessage.test的訊息佇列(前面講到的每個微服務建立的訊息佇列是自動刪除的,即微服務斷開連線後訊息佇列就被刪除,而這個訊息佇列是持久化的,也就是即使所有的微服務節點全部斷開連線也不會被刪除),所有的微服務節點監聽這一個佇列,當佇列中有訊息時,只會被一個節點消費。
要講的內容到此結束,spring cloud stream的配置遠不止這些,但是這些配置已足夠完成我所需要做的事情,其他的配置請參考spring cloud stream官方文件: