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

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

技術(shù)要點(diǎn)
交互協(xié)議
- connectSocket:創(chuàng)建一個WebSocket連接實(shí)例,并通過返回的?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?的交互協(xié)議:
- aid:應(yīng)用id,不是宿主App的appid,由服務(wù)端指定
- fpid:由服務(wù)端指定
- device_id:設(shè)備id,服務(wù)端通過aid+userid+did來維護(hù)長連接
- access_key:用于防止攻擊,一般用md5加密算法生成(?md5.hexMD5(fpid + appkey + did + salt);?)
- code:調(diào)用?tt.login?獲取的code,服務(wù)端通過 code2Session 可以將其轉(zhuǎn)化為open_id,然后進(jìn)一步轉(zhuǎn)化為user_id用于標(biāo)識用戶的唯一性。
- note:由于code具有時效性,每次重新建立?websocket?連接時,需要調(diào)用?tt.login?重新獲取code。
數(shù)據(jù)協(xié)議
前面介紹了那么多關(guān)于?Protobuf?的內(nèi)容,小程序的?webSocket?接口發(fā)送數(shù)據(jù)的類型支持?ArrayBuffer?,再加上?Frontier?對?Protobuf?支持得比較好,因此和服務(wù)端商定采用?Protobuf?作為整個長連接的數(shù)據(jù)通信協(xié)議。
想要在小程序中使用?Protobuf?,首先將.proto文件轉(zhuǎn)換成js能解析的json,這樣也比直接使用.proto文件更輕量,可以使用pbjs工具進(jìn)行解析:
- 安裝pbjs工具
- 基于node.js,首先安裝protobufjs
$ npm install -g protobufjs
- 安裝 pbjs需要的庫 命令行執(zhí)行下“pbjs”就ok
$ pbjs
- 使用pbjs轉(zhuǎn)換.proto文件
- 和服務(wù)端約定好的.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;
}
- 轉(zhuǎn)換awesome.proto文件
$ pbjs -t json awesome.proto > awesome.json
生成如下的awesom.json文件:
{
"nested": {
"wenlipackage": {
"nested": {
"Header": {
"fields": {
...
}
},
"Frame": {
"fields": {
...
}
}
}
}
}
}
- 此時的json文件還不能直接使用,必須采用?module.exports?的方式將其導(dǎo)出去,可生成如下的awesome.js文件供小程序引用。
module.exports = {
"nested": {
"wenlipackage": {
"nested": {
"Header": {
"fields": {
...
}
},
"Frame": {
"fields": {
...
}
}
}
}
}
}
- 采用Protobuf庫編/解碼數(shù)據(jù)
// 引入protobuf模塊
import * as protobuf from './weichatPb/protobuf';
// 加載awesome.proto對應(yīng)的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) => {});接收到的數(shù)據(jù)
const deMessage = AwesomeMessage.decode(new Uint8Array(buffer));
console.log("decodeMessage", deMessage);
消息通信
一個?websocket?實(shí)例的生成需要經(jīng)過以下步驟:
- 建立連接
- 建立連接后會返回一個websoket實(shí)例
- 連接打開
- 連接建立->連接打開是一個異步的過程,在這段時間內(nèi)是監(jiān)聽不到消息,更是無法發(fā)送消息的
- 監(jiān)聽消息
- 監(jiān)聽的時機(jī)比較關(guān)鍵,只有當(dāng)連接建立并生成websocket實(shí)例后才能監(jiān)聽
- 發(fā)送消息
- 發(fā)送當(dāng)時機(jī)也很關(guān)鍵,只有當(dāng)連接真正打開后才能發(fā)送消息
將小程序WebSocket的一些功能封裝成一個類,里面包括建立連接、監(jiān)聽消息、發(fā)送消息、心跳檢測、斷線重連等等常用的功能。
- 封裝websocket類
export default class websocket {
constructor({ heartCheck, isReconnection }) {
this.socketTask = null;// websocket實(shí)例
this._isLogin = false;// 是否連接
this._netWork = true;// 當(dāng)前網(wǎng)絡(luò)狀態(tài)
this._isClosed = false;// 是否人為退出
this._timeout = 10000;// 心跳檢測頻率
this._timeoutObj = null;
this._connectNum = 0;// 當(dāng)前重連次數(shù)
this._reConnectTimer = null;
this._heartCheck = heartCheck;// 心跳檢測和斷線重連開關(guān),true為啟用,false為關(guān)閉
this._isReconnection = isReconnection;
}
_reset() {}// 心跳重置
_start() {} // 心跳開始
onSocketClosed(options) {} // 監(jiān)聽websocket連接關(guān)閉
onSocketError(options) {} // 監(jiān)聽websocket連接關(guān)閉
onNetworkChange(options) {} // 檢測網(wǎng)絡(luò)變化
_onSocketOpened() {} // 監(jiān)聽websocket連接打開
onReceivedMsg(callBack) {} // 接收服務(wù)器返回的消息
initWebSocket(options) {} // 建立websocket連接
sendWebSocketMsg(options) {} // 發(fā)送websocket消息
_reConnect(options) {} // 重連方法,會根據(jù)時間頻率越來越慢
closeWebSocket(){} // 關(guān)閉websocket連接
}
- 多個page使用同一個?websocket?對象
引入?vuex?維護(hù)一個全局?websocket?對象?globalWebsocket?,通過?mapMutations?的?changeGlobalWebsocket?方法改變?nèi)?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實(shí)例透傳出來,全局共享。
computed: {
...mapState(['globalWebsocket']),
newGlobalWebsocket() {
// 只有當(dāng)連接建立并生成websocket實(shí)例后才能監(jiān)聽
if (this.globalWebsocket && this.globalWebsocket.socketTask) {
if (!this.hasListen) {
this.globalWebsocket.onReceivedMsg((res, data) => {
// 處理服務(wù)端發(fā)來的各類消息
this.handleServiceMsg(res, data);
});
this.hasListen = true;
}
if (this.globalWebsocket.socketTask.readyState === 1) {
// 當(dāng)連接真正打開后才能發(fā)送消息
}
}
return this.globalWebsocket;
},
},
watch: {
newGlobalWebsocket(newVal, oldVal) {
if(oldVal && newVal.socketTask && newVal.socketTask !== oldVal.socketTask) {
// 重新監(jiān)聽
this.globalWebsocket.onReceivedMsg((res, data) => {
this.handleServiceMsg(res, data);
});
}
},
},
由于需要監(jiān)聽?websocket?的連接與斷開,因此需要新生成一個computed屬性?newGlobalWebsocket?,直接返回全局的?globalWebsocket?對象,這樣才能watch到它的變化,并且在重新監(jiān)聽的時候需要控制好條件,只有?globalWebsocket?對象socketTask真正發(fā)生改變的時候才進(jìn)行重新監(jiān)聽邏輯,否則會收到重復(fù)的消息。
問題總結(jié)
- 直接引入google官方Protobuf庫(protobuf.js)將json => pb,在開發(fā)者工具能正常使用,真機(jī)卻報錯:


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


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

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?類型,否則解析失敗,因此需要將其轉(zhuǎn)換為?TypedArray?對象,選擇?Uint8Array?子類型,才能解析成前端能讀取的json對象。
- 在開發(fā)者工具調(diào)通協(xié)議后,轉(zhuǎn)到真機(jī),發(fā)現(xiàn)后端解析不了前端發(fā)的消息:


【開發(fā)者工具抓包消息】

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

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

即收到的服務(wù)端原始消息最外層是?ArrayBuffer?類型的,解密后的業(yè)務(wù)數(shù)據(jù)payload卻是?Unit8Array?類型的,結(jié)合發(fā)送消息時encdoe后的類型也是?Unit8Array?類型,得出如下結(jié)論:
- protobuf.js庫和Frontier對數(shù)據(jù)的處理是以?Unit8Array?類型為準(zhǔn),服務(wù)端同時支持?ArrayBuffer?和?Unit8Array?兩種類型數(shù)據(jù)的解析;
- 小程序框架只支持?ArrayBuffer?和?String?類型數(shù)據(jù),其余類型會默認(rèn)當(dāng)成?String?類型;
上述兩個規(guī)則限制導(dǎo)致在數(shù)據(jù)傳輸過程中,需要將數(shù)據(jù)格式轉(zhuǎn)成標(biāo)準(zhǔn)的?ArrayBuffer?即小程序框架支持的數(shù)據(jù)格式。
ps:至于為啥開發(fā)者工具和真機(jī)表現(xiàn)不一致,這是因?yàn)殚_發(fā)者工具其實(shí)是一個web,和小程序的運(yùn)行時并不太一樣,同時由于兩者不統(tǒng)一,導(dǎo)致在開發(fā)調(diào)試過程中踩了許多的坑。 ♀?
參考文獻(xiàn)
小程序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