知識背景
隨著物聯(lián)網(wǎng)的發(fā)展促進(jìn)傳統(tǒng)行業(yè)不斷轉(zhuǎn)型,在設(shè)備間通信的業(yè)務(wù)場景越來越多。其中很大一部分在于移動端和設(shè)備或服務(wù)端與設(shè)備的通信,例如已成主流的共享單車。但存在一個這樣小問題,當(dāng)指令下發(fā)完畢之后,設(shè)備不會同步返回指令執(zhí)行是否成功,而是異步通知或是服務(wù)端去主動查詢設(shè)備指令是否發(fā)送成功,這樣一來客戶端(前端)也無法同步獲取指令執(zhí)行情況,只能通過服務(wù)端異步通知來接收該狀態(tài)了。這也就引出了這篇博客想要探索的一項(xiàng)技術(shù):如何實(shí)現(xiàn)服務(wù)端主動通知前端? 其實(shí),這樣的業(yè)務(wù)場景還有很多,但這樣的解決方案卻不是非常成熟,方案概括過來就兩個大類。1.前端定時請求輪詢 2.前端和服務(wù)端保持長連接,以持續(xù)進(jìn)行數(shù)據(jù)交互,這個可以包括較為成熟的WebSocket。我們可以看看張小龍在知乎問題 如何在大型 Web 應(yīng)用中保持?jǐn)?shù)據(jù)的同步更新? 的回答,更加清楚的認(rèn)識這個過程。
這個問題在10年前已經(jīng)被解決過無數(shù)次了,最簡單的例子就是網(wǎng)頁聊天室。題主的需求稍微復(fù)雜些,需要支持的數(shù)據(jù)格式更多,然而只要定義好了通訊規(guī)范,多出來的也只是搬磚的活兒了。
整個過程可以分為5個環(huán)節(jié):1 包裝數(shù)據(jù)、2 觸發(fā)通知、3 通訊傳輸、4 解析數(shù)據(jù)、5 渲染數(shù)據(jù)。這5個環(huán)節(jié)中有三點(diǎn)很關(guān)鍵:1 通訊通道選擇、2 數(shù)據(jù)格式定義、3 渲染數(shù)據(jù)。
1 通訊通道選擇:這個很多前端高手已經(jīng)回答了,基本就是兩種方式:輪詢和長連接,這種情況通常的解決方式是長連接,Web端可以用WebSocket來解決,這也是業(yè)界普遍采用的方案,比如環(huán)信、用友有信、融云等等。通訊環(huán)節(jié)是相當(dāng)耗費(fèi)服務(wù)器資源的一個環(huán)節(jié),而且開發(fā)成本偏高,建議將這些第三方的平臺直接集成到自己的項(xiàng)目中,以降低開發(fā)的成本。
2 數(shù)據(jù)格式定義:數(shù)據(jù)格式可以定義得五花八門,不過為了前端的解析,建議外層統(tǒng)一數(shù)據(jù)格式,定義一個類似type的屬性來標(biāo)記數(shù)據(jù)屬性(是IM消息、微博數(shù)據(jù)還是發(fā)貨通知),然后定義一個data屬性來記錄數(shù)據(jù)的內(nèi)容(一般對應(yīng)數(shù)據(jù)表中的一行數(shù)據(jù))。統(tǒng)一數(shù)據(jù)格式后,前端解析數(shù)據(jù)的成本會大大降低。
3 渲染數(shù)據(jù)渲染數(shù)據(jù)是關(guān)系到前端架構(gòu)的,比如是React、Vue還是Angular(BTW:不要用Angular,個人認(rèn)為Angular在走向滅亡)。這些框架都用到了數(shù)據(jù)綁定,這已經(jīng)成為業(yè)界的共識了(只需要對數(shù)據(jù)進(jìn)行操作,不需要操作DOM),這點(diǎn)不再論述。在此種需求場景下,數(shù)據(jù)流會是一個比較大的問題,因?yàn)榭赡苊恳粭l新數(shù)據(jù)都需要尋找對應(yīng)的組件去傳遞數(shù)據(jù),這個過程會特別惡心。所以選擇單一樹的數(shù)據(jù)流應(yīng)該會很合適,這樣只需要對一棵樹的節(jié)點(diǎn)進(jìn)行操作即可:定義好type和樹節(jié)點(diǎn)的對應(yīng)關(guān)系,然后直接定位到對應(yīng)的節(jié)點(diǎn)對數(shù)據(jù)增刪改就可以,例如Redux。
以上三點(diǎn)是最核心的環(huán)節(jié),涉及到前后端的數(shù)據(jù)傳輸、前端數(shù)據(jù)渲染,其他的內(nèi)容就比較簡單了,也簡單說下。
后端:包裝數(shù)據(jù)、觸發(fā)通知這個對后端來說就很Easy了,建一個隊(duì)列池,不斷的往池子里丟任務(wù),讓池子去觸發(fā)通知。
前端:解析數(shù)據(jù)解析數(shù)據(jù)就是多出來的搬磚的活兒,過濾type、取data。技術(shù)難度并不大,主要點(diǎn)還是在于如何能低開發(fā)成本、低維護(hù)成本地達(dá)到目的,上面是一種比較綜合的低成本的解決方案。
對于對實(shí)時性要求較高的業(yè)務(wù)場景,輪詢顯然是無法滿足需求的,而長連接的缺點(diǎn)在于長期占了服務(wù)端的連接資源,當(dāng)前端用戶數(shù)量指數(shù)增長到一定數(shù)量時,服務(wù)端的分布式須另辟蹊徑來處理WebSocket的連接匹配問題。它的優(yōu)點(diǎn)也很明顯,對于傳輸內(nèi)容不大的情況下,有非??斓慕换ニ俣?,因?yàn)樗皇腔?code>HTTP請求的,而是瀏覽器端擴(kuò)展的Socket通信。
Spring boot接入WebSocket
Maven Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
Config
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry stompEndpointRegistry) {
// 添加服務(wù)端點(diǎn),可以理解為某一服務(wù)的唯一key值
stompEndpointRegistry.addEndpoint("/chatApp");
//當(dāng)瀏覽器支持sockjs時執(zhí)行該配置
stompEndpointRegistry.addEndpoint("/chatApp").setAllowedOrigins("*").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 配置接受訂閱消息地址前綴為topic的消息
config.enableSimpleBroker("/topic");
// Broker接收消息地址前綴
config.setApplicationDestinationPrefixes("/app");
}
}
MessageMapping
@Autowired
private SimpMessagingTemplate template;
//接收客戶端"/app/chat"的消息,并發(fā)送給所有訂閱了"/topic/messages"的用戶
@MessageMapping("/chat")
@SendTo("/topic/messages")
public OutputMessage receiveAndSend(InputMessage inputMessage) throws Exception {
System.out.println("get message (" + inputMessage.getText() + ") from client!");
System.out.println("send messages to all subscribers!");
String time = new SimpleDateFormat("HH:mm").format(new Date());
return new OutputMessage(inputMessage.getFrom(), inputMessage.getText(), time);
}
//或者直接從服務(wù)端發(fā)送消息給指定客戶端
@MessageMapping("/chat_user")
public void sendToSpecifiedUser(@Payload InputMessage inputMessage, SimpMessageHeaderAccessor headerAccessor) throws Exception {
System.out.println("get message from client (" + inputMessage.getFrom() + ")");
System.out.println("send messages to the specified subscriber!");
String time = new SimpleDateFormat("HH:mm").format(new Date());
this.template.convertAndSend("/topic/" + inputMessage.getFrom(), new OutputMessage(inputMessage.getFrom(), inputMessage.getText(), time));
}
clients
<!DOCTYPE html>
<!DOCTYPE html>
<html>
<head>
<title>Chat WebSocket</title>
<script src="http://cdn.jsdelivr.net/sockjs/0.3.4/sockjs.min.js"></script>
<script src="js/stomp.js"></script>
<script type="text/javascript">
var apiUrlPre = "http://10.200.0.126:9041/discovery";
var stompClient = null;
function setConnected(connected) {
document.getElementById('connect').disabled = connected;
document.getElementById('disconnect').disabled = !connected;
document.getElementById('conversationDiv').style.visibility = connected ? 'visible' : 'hidden';
document.getElementById('response').innerHTML = '';
}
function connect() {
var socket = new SockJS('http://localhost:9041/discovery/chatApp');
var from = document.getElementById('from').value;
stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
setConnected(true);
console.log('Connected: ' + frame);
//stompClient.subscribe('/topic/' + from, function(messageOutput) {
stompClient.subscribe('/topic/messages', function(messageOutput) {
// alert(messageOutput.body);
showMessageOutput(JSON.parse(messageOutput.body));
});
});
}
function disconnect() {
if(stompClient != null) {
stompClient.disconnect();
}
setConnected(false);
console.log("Disconnected");
}
function sendMessage() {
var from = document.getElementById('from').value;
var text = document.getElementById('text').value;
//stompClient.send("/app/chat_user", {},
stompClient.send("/app/chat", {},
JSON.stringify({
'from': from,
'text': text
})
);
}
function showMessageOutput(messageOutput) {
var response = document.getElementById('response');
var p = document.createElement('p');
p.style.wordWrap = 'break-word';
p.appendChild(document.createTextNode(messageOutput.from + ": " +
messageOutput.text + " (" + messageOutput.time + ")"));
response.appendChild(p);
}
</script>
</head>
<body onload="disconnect()">
<div>
<div>
<input type="text" id="from" placeholder="Choose a nickname" />
</div>
<br />
<div>
<button id="connect" onclick="connect();">Connect</button>
<button id="disconnect" disabled="disabled" onclick="disconnect();">
Disconnect
</button>
</div>
<br />
<div id="conversationDiv">
<input type="text" id="text" placeholder="Write a message..." />
<button id="sendMessage" onclick="sendMessage();">Send</button>
<p id="response"></p>
</div>
</div>
</body>
</html>
結(jié)果


總結(jié)
這是spring-boot接入WebSocket最簡單的方法了,很直觀的表現(xiàn)了socket在瀏覽器段通信的便利,但根據(jù)不同的業(yè)務(wù)場景,對該技術(shù)的使用還需要斟酌,例如如何使WebSocket在分布式服務(wù)端保持服務(wù),如何在連接上集群后下發(fā)消息找到長連接的服務(wù)端機(jī)器。我也在為這個問題苦苦思考,思路雖有,實(shí)踐起來卻舉步維艱,特別是網(wǎng)上談到比較多的將連接序列化到緩存中,統(tǒng)一管理讀取分配,分享幾個好思路,也希望自己能給找到較好的方案再分享一篇博客。
來自Push notifications with websockets in a distributed Node.js app
- Configure Nginx to send websocket requests from each browser to all the server in the cluster. I could not figure out how to do it. Load balancing does not support broadcasting.
- Store websocket connections in the databse, so that all servers had access to it. I am not sure how to serialize the websocket connection object to store it in MongoDB.
- Set up a communication mechanism among the servers in the cluster (some kind message bus) and whenever event happens, have all the servers notify the websocket clients they are tracking. This somewhat complicates the system and requires the nodes to know the addresses of each other. Which package is most suitable for such a solution?
再分享幾個討論:
springsession如何對spring的WebSocketSession進(jìn)行分布式配置?
websocket多臺服務(wù)器之間怎么共享websocketSession?