背景:通常與服務端交互頻繁的需求,可使用輪詢機制來實現。然而一些業務場景,好比遊戲大廳、直播、即時聊天等,這些需求均可以或者說更適合使用長鏈接來實現,一方面能夠減小輪詢帶來的流量浪費,另外一方面能夠減小對服務的請求壓力,同時也能夠更實時的與服務端進行消息交互。
HTTP vs WebSocket
WebSockethtml
二進制數組
TypedArray對象:表明肯定類型的二進制數據。用來生成內存的視圖,經過9個構造函數,能夠生成9種數據格式的視圖,數組成員都是同一個數據類型,好比:前端
ArrayBuffer也是一個構造函數,能夠分配一段能夠存放數據的連續內存區域vue
var buf = new ArrayBuffer(32); // 生成一段32字節的內存區域,每一個字節的值默認都是0
爲了讀寫buf,須要爲它指定視圖。node
var dataView = new DataView(buf); // 不帶符號的8位整數格式 dataView.getUnit8(0) // 0
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?)git
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整除。github
note:若是想從任意字節開始解讀ArrayBuffer對象,必須使用DataView視圖,由於TypedArray視圖只提供9種固定的解讀格式。web
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 是一個東西,前者是一(大)塊內存,後者用來訪問這塊內存。vuex
Protocol Buffers
咱們編碼的目的是將結構化數據寫入磁盤或用於網絡傳輸,以便他人來讀取,寫入方式有多種選擇,好比將數據轉換爲字符串,而後將字符串寫入磁盤。也能夠將須要處理的結構化數據由 .proto 文件描述,用 Protobuf 編譯器將該文件編譯成目標語言。npm
Protocol Buffers 是一種輕便高效的結構化數據存儲格式,能夠用於結構化數據串行化,或者說序列化。它很適合作數據存儲或 RPC 數據交換格式。可用於通信協議、數據存儲等領域的語言無關、平臺無關、可擴展的序列化結構數據格式。
通常狀況下,採用靜態編譯模式,先寫好 .proto 文件,再用 Protobuf 編譯器生成目標語言所須要的源代碼文件,將這些生成的代碼和應用程序一塊兒編譯。
讀寫數據過程是將對象序列化後生成二進制數據流,寫入一個 fstream 流,從一個 fstream 流中讀取信息並反序列化。
Protocol Buffers 在序列化數據方面,它是靈活的,高效的。相比於 XML 來講,Protocol Buffers 更加小巧,更加快速,更加簡單。一旦定義了要處理的數據的數據結構以後,就能夠利用 Protocol Buffers 的代碼生成工具生成相關的代碼。甚至能夠在無需從新部署程序的狀況下更新數據結構。只需使用 Protobuf 對數據結構進行一次描述,便可利用各類不一樣語言或從各類不一樣數據流中對你的結構化數據輕鬆讀寫。
Protocol Buffers 很適合作數據存儲或 RPC 數據交換格式。可用於通信協議、數據存儲等領域的語言無關、平臺無關、可擴展的序列化結構數據格式。
消息結構可讀性不高,序列化後的字節序列爲二進制序列不能簡單的分析有效性;
爲了維護用戶在線狀態,須要和服務端保持長鏈接,決定採用websocket來跟服務端進行通訊,同時使用消息通道系統來轉發消息。
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'] });
前面介紹了那麼多關於Protobuf的內容,小程序的webSocket接口發送數據的類型支持ArrayBuffer,再加上Frontier對Protobuf支持得比較好,所以和服務端商定採用Protobuf做爲整個長鏈接的數據通訊協議。
想要在小程序中使用Protobuf,首先將.proto文件轉換成js能解析的json,這樣也比直接使用.proto文件更輕量,可使用pbjs工具進行解析:
$ npm install -g protobufjs
$ pbjs
// 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; }
$ pbjs -t json awesome.proto > awesome.json
生成以下的awesom.json文件:
{ "nested": { "wenlipackage": { "nested": { "Header": { "fields": { ... } }, "Frame": { "fields": { ... } } } } } }
module.exports = { "nested": { "wenlipackage": { "nested": { "Header": { "fields": { ... } }, "Frame": { "fields": { ... } } } } } }
// 引入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實例的生成須要通過如下步驟:
將小程序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鏈接 }
引入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); } }) } }
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真正發生改變的時候才進行從新監聽邏輯,不然會收到重複的消息。
緣由是protobufjs 代碼裏面有用到 Function() {} 來執行一段代碼,在小程序中Function 和 eval 相關的動態執行代碼方式都給屏蔽了,是不容許開發者使用的,致使這個庫不能正常使用。
解決辦法:搜了一圈github,找到有人專門針對這個問題,修改了dcodeIO 的protobuf.js部分實現方式,寫了一個能在小程序中運行的protobuf.js。
能夠看到:
上文介紹了TyedArray和ArrayBuffer的區別,Unit8Array是TypedArray對象的一種類型,用來表示ArrayBuffer的視圖,用來讀寫ArrayBuffer,要訪問ArrayBuffer的底層對象,必須使用Unit8Array的buffer屬性。
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類型,得出以下結論:
上述兩個規則限制致使在數據傳輸過程當中,須要將數據格式轉成標準的ArrayBuffer即小程序框架支持的數據格式。
ps:至於爲啥開發者工具和真機表現不一致,這是由於開發者工具實際上是一個web,和小程序的運行時並不太同樣,同時因爲二者不統一,致使在開發調試過程當中踩了許多的坑。🤷♀️
參考文獻
小程序WebSocket接口文檔:
https://developer.toutiao.com/docs/api/connectSocket.html#%E8%BE%93%E5%85%A5
protocol buffers介紹: