背景:一般與服務端交互頻繁的需求,可以使用輪詢機制來實現。然而一些業務場景,比如游戲大廳、直播、即時聊天等,這些需求都可以或者說更適合使用長連接來實現,一方面可以減少輪詢帶來的流量浪費,另一方面可以減少對服務的請求壓力,同時也可以更實時的與服務端進行消息交互。
背景知識
HTTP vs WebSocket
名詞解釋
- HTTP:是一個用于傳輸超媒體文檔(如html)的應用層的無連接、無狀態協議。
- WebSocket:HTML5開始提供的一種瀏覽器與服務器進行全雙工通訊的網絡技術,屬于應用層協議,基于TCL傳輸協議,并復用HTTP的握手通道。

特點
- HTTP
- WebSocket建立在TCP協議之上,服務器端的實現比較容易;與HTTP協議有著良好的兼容性。默認端口也是80和443,并且握手階段采用HTTP協議,因此握手時不容易屏蔽,能通過各種HTTP代理服務器;數據格式比較輕量,性能開銷小,通信高效;可以發送文本(text),也可以發送二進制數據(ArrayBuffer);沒有同源限制,客戶端可以與任意服務器通信;協議標識符是ws(如果加密,則為wss),服務器網址就是URL;
二進制數組
名詞解釋
- ArrayBuffer?對象:代表原始的二進制數據。代表內存中的一段二進制數據,不能直接讀寫,只能通過“視圖”(?TypedArray?和?DataView?)進行操作(以指定格式解讀二進制數據)。“視圖”部署了數組接口,這意味著,可以用數組的方法操作內存。
- ?TypedArray?對象:代表確定類型的二進制數據。用來生成內存的視圖,通過9個構造函數,可以生成9種數據格式的視圖,數組成員都是同一個數據類型,比如:?Unit8Array?:(無符號8位整數)數組視圖?Int16Array?:(16位整數)數組視圖?Float32Array?:(32位浮點數)數組視圖
- ...
- ?DataView?對象:代表不確定類型的二進制數據。用來生成內存的視圖,可以自定義格式和字節序,比如第一個字節是?Uint8?(無符號8位整數)、第二個字節是?Int16?(16位整數)、第三個字節是?Float32?(32位浮點數)等等,數據成員可以是不同的數據類型。
舉個栗子
?ArrayBuffer?也是一個構造函數,可以分配一段可以存放數據的連續內存區域
var buf = new ArrayBuffer(32); // 生成一段32字節的內存區域,每個字節的值默認都是0
為了讀寫buf,需要為它指定視圖。
- ?DataView?視圖,是一個構造函數,需要提供?ArrayBuffer?對象實例作為參數:
var dataView = new DataView(buf); // 不帶符號的8位整數格式
dataView.getUnit8(0) // 0
- ?TypedArray?視圖,是一組構造函數,代表不同的數據格式。
var x1 = new Init32Array(buf); // 32位帶符號整數
x1[0] = 1;
var x2 = new Unit8Array(buf); // 8位不帶符號整數
x2[0] = 2;
x1[0] // 2 兩個視圖對應同一段內存,一個視圖修改底層內存,會影響另一個視圖
TypedArray(buffer, byteOffset=0, length?)
- buffer:必需,視圖對應的底層?ArrayBuffer?對象
- byteOffset:可選,視圖開始的字節序號,默認從0開始,必須與所要建立的數據類型一致,否則會報錯
var buffer = new ArrayBuffer(8);
var i16 = new Int16Array(buffer, 1);
// Uncaught RangeError: start offset of Int16Array should be a multiple of 2
因為,帶符號的16位整數需要2個字節,所以byteOffset參數必須能夠被2整除。
- length:可選,視圖包含的數據個數,默認直到本段內存區域結束
note:如果想從任意字節開始解讀?ArrayBuffer?對象,必須使用?DataView?視圖,因為?TypedArray?視圖只提供9種固定的解讀格式。
?TypedArray?視圖的構造函數,除了接受?ArrayBuffer?實例作為參數,還可以接受正常數組作為參數,直接分配內存生成底層的?ArrayBuffer?實例,并同時完成對這段內存的賦值。
var typedArray = new Unit8Array([0, 1, 2]);
typedArray.length // 3
typedArray[0] = 5;
typedArray // [5, 1, 2]
總結
?ArrayBuffer?是一(大)塊內存,但不能直接訪問?ArrayBuffer?里面的字節。?TypedArray?只是一層視圖,本身不儲存數據,它的數據都儲存在底層的?ArrayBuffer?對象之中,要獲取底層對象必須使用buffer屬性。其實?ArrayBuffer? 跟 ?TypedArray? 是一個東西,前者是一(大)塊內存,后者用來訪問這塊內存。
Protocol Buffers
我們編碼的目的是將結構化數據寫入磁盤或用于網絡傳輸,以便他人來讀取,寫入方式有多種選擇,比如將數據轉換為字符串,然后將字符串寫入磁盤。也可以將需要處理的結構化數據由 .proto 文件描述,用 Protobuf 編譯器將該文件編譯成目標語言。
名詞解釋
Protocol Buffers 是一種輕便高效的結構化數據存儲格式,可以用于結構化數據串行化,或者說序列化。它很適合做數據存儲或 RPC 數據交換格式。可用于通訊協議、數據存儲等領域的語言無關、平臺無關、可擴展的序列化結構數據格式。
基本原理
一般情況下,采用靜態編譯模式,先寫好 .proto 文件,再用 Protobuf 編譯器生成目標語言所需要的源代碼文件,將這些生成的代碼和應用程序一起編譯。
讀寫數據過程是將對象序列化后生成二進制數據流,寫入一個 fstream 流,從一個 fstream 流中讀取信息并反序列化。
優缺點
- 優點
Protocol Buffers 在序列化數據方面,它是靈活的,高效的。相比于 XML 來說,Protocol Buffers 更加小巧,更加快速,更加簡單。一旦定義了要處理的數據的數據結構之后,就可以利用 Protocol Buffers 的代碼生成工具生成相關的代碼。甚至可以在無需重新部署程序的情況下更新數據結構。只需使用 Protobuf 對數據結構進行一次描述,即可利用各種不同語言或從各種不同數據流中對你的結構化數據輕松讀寫。
Protocol Buffers 很適合做數據存儲或 RPC 數據交換格式。可用于通訊協議、數據存儲等領域的語言無關、平臺無關、可擴展的序列化結構數據格式。
- 缺點
消息結構可讀性不高,序列化后的字節序列為二進制序列不能簡單的分析有效性;
字節消息通道(Frontier)系統
整體設計
為了維護用戶在線狀態,需要和服務端保持長連接,決定采用websocket來跟服務端進行通信,同時使用消息通道系統來轉發消息。
時序圖

技術要點
交互協議
- connectSocket:創建一個WebSocket連接實例,并通過返回的?socketTask?操作該連接。
const wsUrl = `${domain}/ws/v2?aid=2493&device_id=${did}&fpid=100&access_key=${access_key}&code=${code}`
let socketTask = tt.connectSocket({
url: wsUrl,
protocols: ['p1']
});
- ?wsUrl?遵循?Frontier?的交互協議:
- aid:應用id,不是宿主App的appid,由服務端指定
- fpid:由服務端指定
- device_id:設備id,服務端通過aid+userid+did來維護長連接
- access_key:用于防止攻擊,一般用md5加密算法生成(?md5.hexMD5(fpid + appkey + did + salt);?)
- code:調用?tt.login?獲取的code,服務端通過 code2Session 可以將其轉化為open_id,然后進一步轉化為user_id用于標識用戶的唯一性。
- note:由于code具有時效性,每次重新建立?websocket?連接時,需要調用?tt.login?重新獲取code。
數據協議
前面介紹了那么多關于?Protobuf?的內容,小程序的?webSocket?接口發送數據的類型支持?ArrayBuffer?,再加上?Frontier?對?Protobuf?支持得比較好,因此和服務端商定采用?Protobuf?作為整個長連接的數據通信協議。
想要在小程序中使用?Protobuf?,首先將.proto文件轉換成js能解析的json,這樣也比直接使用.proto文件更輕量,可以使用pbjs工具進行解析:
- 安裝pbjs工具
- 基于node.js,首先安裝protobufjs
$ npm install -g protobufjs
- 安裝 pbjs需要的庫 命令行執行下“pbjs”就ok
$ pbjs
- 使用pbjs轉換.proto文件
- 和服務端約定好的.proto文件
// awesome.proto
package wenlipackage;
syntax = "proto2";
message Header {
required string key = 1;
required string value = 2;
}
message Frame {
required uint64 SeqID = 1;
required uint64 LogID = 2;
required int32 service = 3;
required int32 method = 4;
repeated Header headers = 5;
optional string payload_encoding = 6;
optional string payload_type = 7;
optional bytes payload = 8;
}
- 轉換awesome.proto文件
$ pbjs -t json awesome.proto > awesome.json
生成如下的awesom.json文件:
{
"nested": {
"wenlipackage": {
"nested": {
"Header": {
"fields": {
...
}
},
"Frame": {
"fields": {
...
}
}
}
}
}
}
- 此時的json文件還不能直接使用,必須采用?module.exports?的方式將其導出去,可生成如下的awesome.js文件供小程序引用。
module.exports = {
"nested": {
"wenlipackage": {
"nested": {
"Header": {
"fields": {
...
}
},
"Frame": {
"fields": {
...
}
}
}
}
}
}
- 采用Protobuf庫編/解碼數據
// 引入protobuf模塊
import * as protobuf from './weichatPb/protobuf';
// 加載awesome.proto對應的json
import awesomeConfig from './awesome.js';
// 加載JSON descriptor
const AwesomeRoot = protobuf.Root.fromJSON(awesomeConfig);
// Message類,.proto文件中定義了Frame是消息主體
const AwesomeMessage = AwesomeRoot.lookupType("Frame");
const payload = {test: "123"};
const message = AwesomeMessage.create(payload);
const array = AwesomeMessage.encode(message).finish();
// unit8Array => ArrayBuffer
const enMessage = array.buffer.slice(array.byteOffset, array.byteLength + array.byteOffset)
console.log("encodeMessage", enMessage);
// buffer 表示通過小程序this.socketTask.onMessage((msg) => {});接收到的數據
const deMessage = AwesomeMessage.decode(new Uint8Array(buffer));
console.log("decodeMessage", deMessage);
消息通信
一個?websocket?實例的生成需要經過以下步驟:
- 建立連接
- 建立連接后會返回一個websoket實例
- 連接打開
- 連接建立->連接打開是一個異步的過程,在這段時間內是監聽不到消息,更是無法發送消息的
- 監聽消息
- 監聽的時機比較關鍵,只有當連接建立并生成websocket實例后才能監聽
- 發送消息
- 發送當時機也很關鍵,只有當連接真正打開后才能發送消息
將小程序WebSocket的一些功能封裝成一個類,里面包括建立連接、監聽消息、發送消息、心跳檢測、斷線重連等等常用的功能。
- 封裝websocket類
export default class websocket {
constructor({ heartCheck, isReconnection }) {
this.socketTask = null;// websocket實例
this._isLogin = false;// 是否連接
this._netWork = true;// 當前網絡狀態
this._isClosed = false;// 是否人為退出
this._timeout = 10000;// 心跳檢測頻率
this._timeoutObj = null;
this._connectNum = 0;// 當前重連次數
this._reConnectTimer = null;
this._heartCheck = heartCheck;// 心跳檢測和斷線重連開關,true為啟用,false為關閉
this._isReconnection = isReconnection;
}
_reset() {}// 心跳重置
_start() {} // 心跳開始
onSocketClosed(options) {} // 監聽websocket連接關閉
onSocketError(options) {} // 監聽websocket連接關閉
onNetworkChange(options) {} // 檢測網絡變化
_onSocketOpened() {} // 監聽websocket連接打開
onReceivedMsg(callBack) {} // 接收服務器返回的消息
initWebSocket(options) {} // 建立websocket連接
sendWebSocketMsg(options) {} // 發送websocket消息
_reConnect(options) {} // 重連方法,會根據時間頻率越來越慢
closeWebSocket(){} // 關閉websocket連接
}
- 多個page使用同一個?websocket?對象
引入?vuex?維護一個全局?websocket?對象?globalWebsocket?,通過?mapMutations?的?changeGlobalWebsocket?方法改變全局?websocket?對象:
methods: {
...mapMutations(['changeGlobalWebsocket']),
linkWebsocket(websocketUrl) {
// 建立連接
this.websocket.initWebSocket({
url: websocketUrl,
success(res) { console.log('連接建立成功', res) },
fail(err) { console.log('連接建立失敗', err) },
complate: (res) => {
this.changeGlobalWebsocket(res);
}
})
}
}
- 通過WebSocket類建立連接,將tt.connectSocket返回的websocket實例透傳出來,全局共享。
computed: {
...mapState(['globalWebsocket']),
newGlobalWebsocket() {
// 只有當連接建立并生成websocket實例后才能監聽
if (this.globalWebsocket && this.globalWebsocket.socketTask) {
if (!this.hasListen) {
this.globalWebsocket.onReceivedMsg((res, data) => {
// 處理服務端發來的各類消息
this.handleServiceMsg(res, data);
});
this.hasListen = true;
}
if (this.globalWebsocket.socketTask.readyState === 1) {
// 當連接真正打開后才能發送消息
}
}
return this.globalWebsocket;
},
},
watch: {
newGlobalWebsocket(newVal, oldVal) {
if(oldVal && newVal.socketTask && newVal.socketTask !== oldVal.socketTask) {
// 重新監聽
this.globalWebsocket.onReceivedMsg((res, data) => {
this.handleServiceMsg(res, data);
});
}
},
},
由于需要監聽?websocket?的連接與斷開,因此需要新生成一個computed屬性?newGlobalWebsocket?,直接返回全局的?globalWebsocket?對象,這樣才能watch到它的變化,并且在重新監聽的時候需要控制好條件,只有?globalWebsocket?對象socketTask真正發生改變的時候才進行重新監聽邏輯,否則會收到重復的消息。
問題總結
- 直接引入google官方Protobuf庫(protobuf.js)將json => pb,在開發者工具能正常使用,真機卻報錯:


原因是protobufjs 代碼里面有用到 Function() {} 來執行一段代碼,在小程序中Function 和 eval 相關的動態執行代碼方式都給屏蔽了,是不允許開發者使用的,導致這個庫不能正常使用。
解決辦法:搜了一圈github,找到有人專門針對這個問題,修改了dcodeIO 的protobuf.js部分實現方式,寫了一個能在小程序中運行的 protobuf.js 。
- ?ArrayBuffer? vs ?Unit8Array? 到底是個什么關系??!
- 受小程序框架、protobuf.js工具以及Frontier系統限制,發送消息和接收消息的格式如下


可以看到:
- 發送消息經過protobuf.js編碼后的消息是?Unit8Array?格式的
- 接收到的服務器原始消息是?ArrayBuffer?格式的
上文介紹了?TyedArray?和?ArrayBuffer?的區別,?Unit8Array?是?TypedArray?對象的一種類型,用來表示?ArrayBuffer?的視圖,用來讀寫?ArrayBuffer?,要訪問?ArrayBuffer?的底層對象,必須使用?Unit8Array?的buffer屬性。
- 一開始跟服務端調websocket的連通性,發現用?AwesomeMessage.decode?解析服務端消息會解析失敗:

const msg = xxx; // ArrayBuffer類型
const res = AwesomeMessage.decode(msg); // 直接解析ArrayBuffer會報錯
const res = AwesomeMessage.decode(new Uint8Array(msg)); // ArrayBuffer => Unit8Array => decode => JSON
原因是原始msg是?ArrayBuffer?類型,protobuf.js在解碼的時候限制了類型是?TypedArray?類型,否則解析失敗,因此需要將其轉換為?TypedArray?對象,選擇?Uint8Array?子類型,才能解析成前端能讀取的json對象。
- 在開發者工具調通協議后,轉到真機,發現后端解析不了前端發的消息:


【開發者工具抓包消息】

【真機抓包消息】
抓包發現在開發者工具發送的消息是二進制(Binary)類型的,真機卻是文本(Text)類型,這就很奇怪了,仔細翻了下小程序文檔:

小程序框架對發送的消息類型進行了限制,只能是string(Text)或arraybuffer(Binary)類型的,真機為啥被轉成了text類型呢,首先肯定不是主動發送的string類型,一種可能就是發送的消息不是arraybuffer類型,默認被轉成了string。看了下代碼:
const encodeMsg = (msg) => {
const message = AwesomeMessage.create(msg);
const array = AwesomeMessage.encode(message).finish();// unit8Array
return array;
};
發現發送的類型直接是?Unit8Array?,開發者工具沒有對其進行轉換,這個數據是能直接被服務端解析的,然而在真機被轉換成了String,導致服務端解析不了,更改代碼,將?Unit8Array?轉換成?ArrayBuffer?,問題得到解決,在真機和開發者工具都正常:
const encodeMsg = (msg) => {
const message = AwesomeMessage.create(msg);
const array = AwesomeMessage.encode(message).finish();
console.log('加密后即將發送的消息', array);
// unit8Array => ArrayBuffer,只支持ArrayBuffer
return array.buffer.slice(array.byteOffset, array.byteLength + array.byteOffset)
};
其實還發現一個現象:

即收到的服務端原始消息最外層是?ArrayBuffer?類型的,解密后的業務數據payload卻是?Unit8Array?類型的,結合發送消息時encdoe后的類型也是?Unit8Array?類型,得出如下結論:
- protobuf.js庫和Frontier對數據的處理是以?Unit8Array?類型為準,服務端同時支持?ArrayBuffer?和?Unit8Array?兩種類型數據的解析;
- 小程序框架只支持?ArrayBuffer?和?String?類型數據,其余類型會默認當成?String?類型;
上述兩個規則限制導致在數據傳輸過程中,需要將數據格式轉成標準的?ArrayBuffer?即小程序框架支持的數據格式。
ps:至于為啥開發者工具和真機表現不一致,這是因為開發者工具其實是一個web,和小程序的運行時并不太一樣,同時由于兩者不統一,導致在開發調試過程中踩了許多的坑。 ♀?
參考文獻
小程序WebSocket接口文檔:
https://developer.toutiao.com/docs/api/connectSocket.html#%E8%BE%93%E5%85%A5
protocol buffers介紹:
https://halfrost.com/protobuf_encode/
作者:byte
出處:https://segmentfault.com/a/1190000024456875