本文作者:入雲
前言
説起 IM,大家應該都或多或少了解過一些,一般被熟知是在一些聊天場景裏應用的比較多;而一般情況下我們常接觸的業務中大多是做一些接口的查詢提交之類的操作,用正常的 Ajax 請求就足以滿足需求,比較難接觸到 IM 這種方案。
但如果涉及到一些需要頻繁更新數據的業務場景,使用常規接口查詢難免會給服務端造成比較大的性能開銷,並且數據更新的延遲也會很大;嘗試使用 IM 則可以讓我們在業務開發中更好地應對頻繁的數據更新場景,以提升用户體驗和業務價值。
近期在做一個多人實時打怪獸的場景,即多名玩家同時攻擊一個怪獸,任意一個玩家攻擊怪獸,其它玩家需要實時感知到怪獸的狀態更新,比如怪獸血量和玩家傷害排行等信息。本文將從此需求切入,探討下在類似這種高併發、低延遲的業務需求中,如何使用 IM 方案來解決頻繁的數據更新問題,也會順便介紹下 WebSocket 的基本運作流程。
可選的數據更新方案
在談論 IM 之前,對於數據的實時更新,除了使用 IM ,還有哪些可選用的方案,可能包括但不限於下面幾種:
接口輪詢
接口輪詢這種方式相信大家都很熟悉,主要是使用通過定期發送 HTTP 請求來達到數據更新的方式,實現起來也比較簡單,例如一些榜單數據的定時更新:
// 請求榜單接口
const refreshRank = (familyId) => {
getMonsterDamageRank({ familyId }).then((res) => {
setRank(res);
}).catch((err) => {
Toast.warn(err.message || '服務器繁忙');
});
};
// 每3秒刷新一次接口
setInterval(() => {
refreshRank(currentFamily.familyId);
}, 3000);
這裏使用 setInterval 每隔3秒請求一次榜單數據,用來更新排行榜信息,通常用於實現一些要求數據更新相對頻繁,但又允許有一定延遲的場景;同時輪詢也是一種實現起來最簡單的方案,但它也有幾個比較大的缺點,比如:
- 帶寬浪費:輪詢需要定期向服務器發送請求,即使服務端沒有新數據可用,這將會造成大量的帶寬和服務器資源浪費。
- 延遲高:數據的更新頻率受輪詢間隔影響,如果輪詢間隔時間過長,會導致數據更新的延遲較高。
- 負載過高:要降低數據的延遲,就必須提高接口輪詢的頻率,但輪詢的頻率過高,將會導致服務器負載過高,從而影響其他用户的體驗。
接口長輪詢
長輪詢(Long Polling)是一種改進的輪詢技術,它的主要思想是在客户端發送請求後,服務端保持連接打開,但並不立即響應,而是在有新數據可用時才響應給客户端。當客户端接收到響應後,再次發起請求,以保持連接打開。
相比於傳統的輪詢,長輪詢可以降低網絡延遲和服務器壓力;因為長輪詢的響應是異步的,服務器不需要在每個固定時間間隔內返回響應,這樣可以減少不必要的請求。同時,當服務器有新數據可用時,也可以立即返回響應,從而提高數據的實時性。
如上圖,長輪詢的實現通常分為下面幾個階段:
- 客户端向服務器發起請求。
- 服務器接收到請求後,如果沒有新數據可用,則保持連接打開。
- 服務器有新數據可用時,響應給客户端。
- 客户端接收到響應後,再次向服務器發起請求。
......
下面是使用 Node.js 實現的一個簡單的長輪詢服務端示例:
const http = require('http');
const messages = [];
// 開始每隔1秒檢查下messages中是否有信息
function waitForNewMessages(response) {
const intervalId = setInterval(() => {
if (messages.length > 0) { // message 中有消息之後返回響應
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(messages));
clearInterval(intervalId);
}
}, 1000);
setTimeout(() => { // 30秒無數據,返回一個空數組
clearInterval(intervalId);
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify([]));
}, 30000);
}
function handleRequest(request, response) {
if (request.url === '/messages') {
/** 請求到“/messages”時,如果有新消息,則立即向客户端發送響應
* 否則,等待一段時間後再次檢查是否有新消息
* */
waitForNewMessages(response);
} else {
response.writeHead(404);
response.end();
}
}
const server = http.createServer(handleRequest);
server.listen(3000);
在上面的代碼中,我們使用 setInterval 函數每秒檢查一次是否有新消息。如果有新消息,我們立即向客户端發送響應,並清除定時器。為了防止一直 pending,如果在30秒內沒有新的消息,我們會向客户端發送一個空數組作為響應。這樣,客户端就可以在收到新消息時立即更新頁面。
對於長輪詢的實現仍有許多細節需要注意,如連接保持、連接斷開重連等問題。此外,長輪詢仍然需要消耗大量的帶寬和服務器資源,因為每個連接都需要保持打開狀態,可以想象有很多個請求到達服務端,服務端需要開啓多個異步來保持鏈接在 pending 的狀態。
SSE(Server-Sent Events)
SSE 也是一種瀏覽器與服務器之間實現實時通信的技術。它允許服務器向瀏覽器發送數據。在 SSE 中,瀏覽器可通過 EventSource API 來建立與服務器的連接,並監聽來自服務器的事件。服務器通過向客户端發送特定格式的數據(包括事件名稱和數據),來觸發瀏覽器的事件監聽器。
下面同樣使用 Nodejs 來實現一個 Demo:
// Server 端
const http = require('http');
const server = http.createServer((req, res) => {
// 設置頭部信息
res.writeHead(200, {
'Content-Type': 'text/event-stream', // 設置響應類型為SSE
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*' // 允許跨域請求
});
// 發送數據到客户端
setInterval(() => {
res.write('data: ' + new Date().toISOString() + '\n\n'); // 發送SSE消息
}, 1000);
});
server.listen(3000, () => {
console.log('Server started on port 3000');
});
// Client端
const sse = new EventSource('http://localhost:3000');
// 監聽SSE消息
sse.addEventListener('message', (event) => {
console.log(event.data);
});
在客户端使用 new EventSource 訪問 “http://localhost:3000” 時,服務器會返回一個 SSE 流,這裏注意需要將響應頭中的 Content-Type 設置為 text/event-stream,表示該響應是 SSE 流;將 Cache-Control 設置為 no-cache,表示瀏覽器不緩存該響應, Connection 設置為 keep-alive,表示服務器與客户端之間的連接應該保持打開狀態。
在 SSE 流中,每一條消息都需要以 data: 開頭,並以兩個換行符(\n\n)結尾。在本例中,使用 setInterval() 函數每秒發送一條消息。
但對於SSE而言,也具有下面幾個缺點:
- 單向傳輸:只能從服務器向客户端推送數據,無法實現雙向通信。
- 只支持純文本:事件流只能傳輸一個簡單的文本數據流, 並且文本只能使用 UTF-8 格式編碼
- SSE對於一些瀏覽器的支持不夠完善:比如在 Safari 和 iOS 中,可能會對 SSE 連接的數量和連接時間等方面進行限制,從而影響 SSE 的穩定性和可靠性
HTTP/2 Server Push
相對於 HTTP/1.1 而言,HTTP/2 其實也是支持了服務端主動推送的,不過目前 HTTP/2 的主動推送,主要是用於提升頁面加載性能的,它允許服務器在響應請求時向客户端推送預先緩存的資源(例如,CSS、JavaScript 和圖像),以減少請求次數和延遲,是一種頁面加載的優化手段。但考慮到其相對於 WebSocket 而言,目前的安全性和穩定性還有待進一步提升,用於實現即時通信還不是特別成熟,所以這裏就不再贅述了。對與如何實現提前推送靜態文件,具體可以參考下 HTTP/2 服務器推送(Server Push)教程。
WebSocket
上面介紹的幾種方式,都是基於HTTP協議的,而 WebSocket 則是一種新的協議;WebSocket誕生於 2008 年 6 月,在 2011 年 12 月成為 RFC6455 國際標準,並且WebSocket協議是一種專門為實時通信而設計的協議。所以對於實現即時通信而言,WebSocket 可以説是最佳選擇。相對於上面幾種方式,它具有下面幾個優點:
- 低延遲:WebSocket 通過保持持久連接,避免了HTTP短連接頻繁地建立和關閉連接的開銷,從而降低了延遲。
- 雙向通信:WebSocket 協議支持雙向通信,客户端和服務器都可以向對方發送數據,從而實現更加靈活的通信方式。
- 跨域支持:WebSocket 協議支持跨域通信,可以在不同源之間傳輸數據,從而支持更多種場景下的應用。
- 更少的數據傳輸:WebSocket 協議支持二進制數據傳輸和數據壓縮,可以減少數據傳輸的延遲和帶寬消耗。
説到底,上面提到了好幾種方案,其實都可以在不同程度上實現數據的實時更新,但是它們跟本次需求中使用到的 IM 方案有什麼關係呢?或者説 IM 究竟是個什麼樣的方案呢?下面我們先明確下 IM 的概念。
IM
IM 具體是指什麼
即時通信(Instant Messaging,簡稱IM)是一種透過網絡進行實時通信的系統,允許兩人或多人使用網絡即時的傳遞文字消息、文件、語音與視頻交流。通常以網站、電腦軟件或移動應用程序的方式提供服務(來自維基百科)
換句話説,我們只要採用某種方式,能實現兩人或多人之間可以通過網絡實時的交換信息,就可以稱之為是一種 IM 方案。那麼上面所提到的幾種實現數據更新方式,都可以用做實現 IM 方案的底層實現方案。
Web 端 IM 的發展歷程
對於 Web 端 IM 的發展歷程,其實大致都囊括了上面提到的幾種實現方式;這些技術經過不斷優化,持續提升了用户體驗。其演變過程可以大致概括為從早期的輪詢技術到長輪詢,再發展到現代的 WebSocket、Server Push 的實現方式。而 WebSocket 的出現,則實現了更高效、更實時的即時通信。
本次要實現多人打怪獸同步信息的場景,對數據更新的實時性要求非常高,所以本次需求所依賴的 IM 方案,就是基於更穩定的 WebSocket 實現的,所以下面就詳細介紹下 WebSocket 和HTTP的區別,以及 WebSocket 的運作流程。
WebSocket
WebSocket VS HTTP
WebSocket 雖然是一種新的協議,但同 HTTP 協議一樣,WebSocket 協議也是運行在 TCP 協議之上的,與 HTTP 協議同屬於應用層網絡數據傳輸協議。那 WebSocket 和 HTTP 究竟有哪些不一樣呢?
- HTTP 屬於短連接,每發起一次請求都需要建立一次連接,請求結束後立即關閉連接,屬於“請求-響應模式”,即客户端需要主動發送請求才能獲取到服務器返回的數據。即便是我們上面介紹的“長輪詢”,也是需要依賴服務端來“hold”住請求。
- HTTP 是一種無狀態協議,每個請求都是獨立的,服務器不會保存客户端的狀態信息。所以每次客户端發送請求,都會在請求頭裏塞一些類似於 Cookie 這種信息用來標識當前請求屬於哪個用户。
- 不同於 HTTP,WebSocket 協議中客户端和服務端只需要完成一次握手,兩者之間就可以建立持久性的連接,並可以進行雙向的數據傳輸。
WebSocket 具體是如何建立連接的
Demo 跑起來看着是挺簡單的,但 WebSocket 長鏈接到底是怎麼建立的呢?在介紹連接建立之前,我們先來了解下 HTTP 協議請求頭中 Upgrade 這麼一個字段。
HTTP 協議是一種文本協議,雖然其靈活性很高,但在處理大量數據和多媒體內容時效率較低。將協議升級為 WebSocket 或 HTTP/2 可以支持更多數據格式的傳輸;所以為了支持將協議升級,在 HTTP/1.1 中新增了 Upgrade 請求頭,它允許客户端請求將其連接升級到另一個協議:
Upgrade: <protocol>
其中,protocol 表示希望升級到的協議名稱,例如 WebSocket、HTTP/2 等。
另外,Upgrade 頭部還可以與 Connection 頭部一起使用,以指示客户端希望使用持久連接。這可以減少每個請求的開銷,從而提高網絡性能和效率,要將協議升級為 WebSocket,就需要將這兩個字段結合起來:
Connection: Upgrade
Upgrade: WebSocket
這裏我們瞭解到 WebSocket 協議是通過 HTTP 協議升級而來的,那麼具體的長鏈接的生命週期是怎樣的呢?下面是一個大致的 WebSocket 流程圖:
如上圖,可以簡單的將 WebSocket 的生命週期大致分為三個階段:
- 通過一次HTTP握手建立 WebSocket 長鏈接(也就是協議升級的過程)
- 使用 WebSocket 協議進行數據傳輸
- 任意一方發送關閉幀,對方響應關閉幀後,長鏈接關閉
握手請求
WebSocket 的建立是通過一次 HTTP 請求握手來實現的,客服端通過發送一個 GET 請求,並在 Request Header 裏攜帶一些協議升級所需的參數,告訴服務器對本次 HTTP 請求進行升級。
GET ws://localhost:3000/ HTTP/1.1
Host: localhost:3000
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: nFPKUyeo5Ul58tbe7Dg5lA==
上面是一個 WebSocket 的請求快照,對於 Upgrade 字段上面已經介紹過,這裏看下剩下的幾個關鍵的參數:
Sec-WebSocket-Key:是由客户端生成的一次性隨機值,該值與服務端響應首部的Sec-WebSocket-Accept是配套的,提供基本的防護,比如防止惡意或者無意的連接。Sec-WebSocket-Version:這裏表明 WebSocket 協議的唯一可接受版本是13。
握手響應
一旦客户端發送了打開 WebSocket 連接的初始請求,它就會等待服務器的回覆。該回復必須有一個 HTTP 101 切換協議的響應代碼。HTTP 101 切換協議響應表明,服務器正在切換到客户端在其升級請求頭中所指定的協議。同樣的,在響應頭裏也會包括 Upgrade 字段,標識協議已被升級。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 89D1tEKizEJHFrVDhswIIpAf4ww=
此外,響應頭中的 Sec-WebSocket-Accept 是一個處理後的 base64 編碼,是通過客户端請求頭中的 Sec-WebSocket-Key 和 RFC6455 中定義的靜態值 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接來生成的,計算步驟為:
- 將
Sec-WebSocket-Key跟258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接 - 通過
SHA1計算出摘要,並轉成 base64 字符串
通過這樣一個HTTP的請求和響應,就表明長鏈接的握手過程已經完成,並將協議升級成了 WebSocket 協議,後續雙方就可以通過這個長鏈接通道傳輸數據了。那數據具體又是怎樣傳輸的呢?
數據傳輸
WebSocket 在傳輸數據的過程中,實際上會將大塊數據(消息)分成若干幀進行傳輸,RFC6455 中給出了幀的概述,這裏大致介紹下一些重要字段:
- FIN (Final):表示當前幀是否為最後一個片段;1 表示是消息的最後一個片段,0 表示不是消息的最後一個片段
- RSV1, RSV2, RSV3 (Reserved):擴展字段,各佔 1 比特,一般情況全為 0
-
opcode:每個幀都有一個操作碼,這個操作碼決定如何來解釋這個幀的有效載荷數據。具體分為以下幾個類型:
鏈接關閉
要關閉 WebSocket 連接,發送端需要發送一個關閉幀(opcode 0x8)。如果連接的任何一方收到一個關閉幀,它必須發送一個關閉幀作為響應,一旦雙方都收到了關閉幀,WebSocket 連接將會斷開。
如何自己實現一個 WebSocket 的升級流程
根據上面的 WebSocket 的流程描述,我們可以使用 Nodejs 實現一個簡單版的協議升級邏輯,並使用瀏覽器 Api 實現對應的客户端邏輯。
服務端邏輯
以下是一個使用Node.js原生模塊實現 WebSocket 服務端的例子:
// 導入所需的Node.js原生模塊
const http = require('http');
const crypto = require('crypto');
// 創建HTTP服務器
const server = http.createServer();
// 解析WebSocket幀
function parseFrame(buffer) {
// 這裏獲取操作碼(opcode),表示數據幀的類型
const opcode = buffer[0] & 0x0f;
// 這行代碼獲取負載長度(payload length)。它表示數據幀的實際數據長度。這裏僅考慮了較短的數據長度,實際上可能需要處理更長的數據長度
const payloadLength = buffer[1] & 0x7f;
// 數據幀的數據部分(payload)是以掩碼的形式發送的,需要使用掩碼來解碼。
const mask = buffer.slice(2, 6);
// 這裏獲取幀中的實際數據
const payload = buffer.slice(6);
let decodedPayload = '';
if (opcode === 1) { // 文本數據幀
for (let i = 0; i < payloadLength; i++) {
// 這裏使用異或操作對數據字節與相應的掩碼字節進行解碼:payload[i] ^ mask[i % 4]。這裏使用模運算(i % 4)確保在掩碼的 4 個字節之間循環。
// 使用 String.fromCharCode() 將解碼後的字節轉換為字符,並將解碼後的字符添加到 decodedPayload 字符串中。
decodedPayload += String.fromCharCode(payload[i] ^ mask[i % 4]);
}
} else if (opcode === 8) { // 關閉幀
return { type: 'close' };
}
return { type: 'text', data: decodedPayload };
}
// 根據給定的文本消息創建一個文本數據幀
function createTextFrame(message) {
// 根據消息長度分配一個緩衝區。這裏僅處理較短的消息,因此分配 2 個額外字節用於幀頭
const buffer = Buffer.alloc(2 + message.length);
// 設置幀頭的第一個字節。0x81 表示一個最終幀(FIN = 1)且操作碼為文本(opcode = 1)
buffer[0] = 0x81;
// 設置幀頭的第二個字節。這裏僅處理較短的消息,所以直接將消息長度設置為負載長度。這意味着沒有掩碼(mask = 0)
buffer[1] = message.length;
// 將消息寫入緩衝區。對於每個字符,獲取其字符編碼(Unicode 編碼)並將其添加到緩衝區。
for (let i = 0; i < message.length; i++) {
buffer[i + 2] = message.charCodeAt(i);
}
return buffer;
}
// 向客户端發送消息
function sendTextMessage(socket, message) {
const frame = createTextFrame(message);
socket.write(frame);
}
// 監聽服務器的upgrade事件
server.on('upgrade', (req, socket, head) => {
// 檢查WebSocket協議和版本
if (req.headers['upgrade'] !== 'websocket' || req.headers['sec-websocket-version'] !== '13') {
socket.destroy();
return;
}
// 獲取客户端發送的Sec-WebSocket-Key
const key = req.headers['sec-websocket-key'];
// 計算Sec-WebSocket-Accept
const sha1 = crypto.createHash('sha1');
sha1.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11');
const accept = sha1.digest('base64');
// 構建響應頭
const headers = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Accept: ' + accept,
'\r\n'
];
// 發送響應頭
socket.write(headers.join('\r\n'));
// 監聽數據
socket.on('data', (buffer) => {
const frame = parseFrame(buffer);
if (frame.type === 'text') {
console.log('Received message:', frame.data);
// 在此處實現處理收到的文本消息
// 向客户端發送消息
sendTextMessage(socket, 'Hello, client!');
} else if (frame.type === 'close') {
console.log('Client closed the connection.');
socket.destroy();
}
});
// 監聽關閉
socket.on('close', () => {
console.log('Socket has been closed.');
});
});
server.listen(3000, () => {
console.log('WebSocket server listening on port 3000');
});
這裏創建了一個基於 HTTP 的 WebSocket 服務,並監聽 3000 端口,其中細節部分已經在代碼裏註釋。當客户端發起升級請求時,服務器將在握手過程中驗證客户端的請求,並在成功升級到WebSocket連接後監聽來自客户端的數據同時發送一個 'Hello, client!' 作為回覆。
客户端邏輯
下面是對應客户端邏輯,使用瀏覽器原生 Api WebSocket 來實現長鏈接的建立:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>WebSocket demo</title>
</head>
<body>
<h1>WebSocket demo</h1>
<input type="text" id="message">
<button onclick="sendMessage()">Send</button>
<button onclick="handleEnd()">End</button>
<div id="output"></div>
<script>
// 創建 WebSocket 連接
const ws = new WebSocket('ws://localhost:3000/');
// 監聽消息
ws.onmessage = (event) => {
const output = document.getElementById('output');
output.innerHTML += `<p>${event.data}</p>`;
};
// 長鏈接斷開
ws.onclose = () => {
const output = document.getElementById('output');
output.innerHTML += `<p>WebSocket closed</p>`;
}
// 發送消息
function sendMessage() {
const message = document.getElementById('message').value;
ws.send(message);
}
// 關閉長鏈接
function handleEnd() {
ws.close();
}
</script>
</body>
</html>
在客户端創建一個 WebSocket 並連接到ws://localhost:3000服務器,這裏註冊了長鏈接的 onmessage 和 onclose 事件;在輸入框裏輸入信息點擊發送,會向服務端發送一個消息,服務端在接收到客户端消息時,緊接着會向客户端發送一個文本消息,同時在頁面中點擊 end 可以將長鏈接關閉。
客户端:
服務端:
對於瀏覽器 WebSocket Api 除了常用回調 onclose、onmessage、 onerror、onopen,WebSocket 實例本身還有一些屬性可以判斷長鏈接當前的狀態(如下圖),詳細參數可參考 MDN 中的詳細介紹「WebSocket」
IM 方案選擇
上面我們使用 Nodejs 和瀏覽器 WebSocket Api 實現了一個簡單的即時通信,但這還遠遠達不到可以在生產環境中使用的標準,比如涉及到網絡異常、掉線重連、較大數據量處理、兼容性處理等問題。
當然 WebSocket 有很多成熟的庫可以直接使用,比如Socket.IO、Ws,這些庫都是經過廣泛使用和測試的開源項目,具有良好的穩定性和可靠性。對於生產環境使用這些成熟的庫可以減少很多不必要的麻煩。作為本次需求的 IM 方案,我們則選擇使用的是由 網易雲信 提供的 Web IM 即時通訊能力;改方案提供了包括服務端和 Web 端一套完整的方案,可以快速集成到我們的工程中,實現即時通信的能力。
雲信 Web SDK 提供了多種常見聊天場景,例如單聊、羣聊、聊天室等。本次需求主要涉及多人場景,並且戰鬥不要求持久性,一場戰鬥由多人同時在線參與,並且在戰鬥結束後就解散,所以這裏選擇使用的是聊天室場景。對於消息的流轉如下:
客户端集成SDK
整體方案的集成可參考雲信官方網站,這裏僅介紹下客户端的集成過程,和所需要注意的問題。
對於客户端這裏選擇通過 npm 集成 SDK:
npm install @yxim/nim-web-sdk@latest
SDK 所包含的三個文件的説明如下:
dist/SDK
├── NIM_Web_Chatroom.js 提供聊天室功能,瀏覽器適配版(UMD 格式)
├── NIM_Web_NIM.js 提供 IM 功能,包括單聊、會話和羣聊等,但不包含聊天室。瀏覽器適配版(UMD 格式)
├── NIM_Web_SDK.js 提供 IM 功能和聊天室功能的集成包,瀏覽器適配版(UMD 格式)
這裏使用的是聊天室能力,可通過單例模式初始化登陸聊天室,如下:
import Chatroom from '@yxim/nim-web-sdk/dist/SDK/NIM_Web_Chatroom';
export class InitChatRoom {
static async getRoomInstance({ onChatMsg = () => {} }) {
if (!InitChatRoom.instance) {
InitChatRoom.instance = Chatroom.getInstance({
appKey: 'appKey', // 在雲信管理後台查看應用的 appKey
account: 'account', // 帳號, 應用內唯一
token: 'token', // 帳號的 token, 用於建立連接
chatroomId: 'chatroomId', // 聊天室 id
chatroomAddresses: [ // 聊天室地址列表
'address1',
'address2'
],
onconnect: () => {}, // 長鏈接建立成功回調
onmsgs: (data) => {}, // 消息觸達回調
ondisconnect: () => {}, // 長鏈接斷開回調
onwillreconnect: () => {} // 長鏈接即將重連
});
}
return InitChatRoom.instance;
}
}
消息的過濾
在本次需求中,怪獸血量和狀態的更新主要是依賴消息的推送。另外活動是每天定點開放,所以活動開始時會有大量用户同時涌入參與攻擊怪獸,在這種情況可能會造成消息在服務端的堆積,導致消息觸達到客户端時不能保證消息是按產生的先後時間到達的。
舉個例子,比如 20:01 產生的消息,由於消息堆積可能會在 20:02 產生的消息後面到達,這樣就可能會導致怪獸的血量忽大忽小的跳動;或者是怪獸已經死了,而怪獸掉血的消息才剛剛到達,此時就需要將這些過時的消息拋棄掉。
處理方式也比較簡單,就是針對每一個消息體都會添加一個消息產生的時間戳,通過這個時間戳可以將延遲觸達的消息過濾掉。
onmsgs: (data) => {
// 延遲消息的過濾,判斷掉血消息的時間是否大於之前的消息時間
const { msgTime } = data; // 當前消息產生時間
const preTime = monster?.msgTime || 0; // 上一條消息時間
if (msgTime > preTime) {
monster.remainingHp = remainHp; // 更新怪獸剩餘血量
monster.damage = damage;
monster.msgTime = msgTime;
}
}
總結
本次需求也是首次在日常活動需求中使用 IM 方案,整體看起來也沒有預期的那麼複雜,總的來講相對於之前常用的接口輪詢的方式,會減少很多對服務端的壓力,同時 IM 方案更新數據的及時性,也大幅提升了用户體驗;項目穩定運行一年多,也驗證了 IM 方案在日常需求中的可行性。感興趣的話歡迎下載“心遇APP”,體驗家族打怪獸活動。
參考資料
- 維基百科
- 阮一峯:WebSocket 教程
- RFC6455: WebSocket
- WebSocket網絡協議
本文發佈自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!