出處:https://segmentfault.com/a/1190000024456875
背景:一般與服務端互動頻繁的需求,可以使用輪詢機制來實現。然而一些業務場景,比如遊戲大廳、直播、即時聊天等,這些需求都可以或者說更適合使用長連線來實現,一方面可以減少輪詢帶來的流量浪費,另一方面可以減少對服務的請求壓力,同時也可以更實時的與服務端進行訊息互動。
背景知識
HTTP vs WebSocket
名詞解釋
HTTP:是一個用於傳輸超媒體文件(如HTML)的應用層的無連線、無狀態協議。WebSocket:HTML5開始提供的一種瀏覽器與伺服器進行全雙工通訊的網路技術,屬於應用層協議,基於TCL傳輸協議,並複用HTTP的握手通道。特點
HTTPWebSocket建立在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 // 3typedArray[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)系統整體設計
時序圖
技術要點
互動協議
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.protopackage 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對應的jsonimport awesomeConfig from './awesome.js'; // 載入JSON descriptorconst 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 => ArrayBufferconst 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();// unit8Arrayreturn array;};
發現傳送的型別直接是Unit8Array,開發者工具沒有對其進行轉換,這個資料是能直接被服務端解析的,然而在真機被轉換成了String,導致服務端解析不了,更改程式碼,將Unit8Array轉換成ArrayBuffer,問題得到解決,在真機和開發者工具都正常:
const encodeMsg = (msg) => {const message = AwesomeMessage.create(msg);const array = AwesomeMessage.encode(message).finish(); console.log('加密後即將傳送的訊息', array);// unit8Array => ArrayBuffer,只支援ArrayBufferreturn 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/
出處:https://segmentfault.com/a/1190000024456875