小程序websocket開發指南

背景:通常與服務端交互頻繁的需求,可使用輪詢機制來實現。然而一些業務場景,好比遊戲大廳、直播、即時聊天等,這些需求均可以或者說更適合使用長鏈接來實現,一方面能夠減小輪詢帶來的流量浪費,另外一方面能夠減小對服務的請求壓力,同時也能夠更實時的與服務端進行消息交互。

背景知識

HTTP vs WebSocket

名詞解釋

  1. HTTP:是一個用於傳輸超媒體文檔(如HTML)的應用層的無鏈接、無狀態協議。
  2. WebSocket:HTML5開始提供的一種瀏覽器與服務器進行全雙工通信的網絡技術,屬於應用層協議,基於TCL傳輸協議,並複用HTTP的握手通道。

image.png

特色

  1. HTTP
  2. WebSockethtml

    1. 創建在TCP協議之上,服務器端的實現比較容易;
    2. 與HTTP協議有着良好的兼容性。默認端口也是80和443,而且握手階段採用HTTP協議,所以握手時不容易屏蔽,能經過各類HTTP代理服務器;
    3. 數據格式比較輕量,性能開銷小,通訊高效;
    4. 能夠發送文本(text),也能夠發送二進制數據(ArrayBuffer);
    5. 沒有同源限制,客戶端能夠與任意服務器通訊;
    6. 協議標識符是ws(若是加密,則爲wss),服務器網址就是URL;
二進制數組

名詞解釋

  1. ​ArrayBuffer​對象:表明原始的二進制數據。表明內存中的一段二進制數據,不能直接讀寫,只能經過「視圖」(​TypedArray​和​DataView​)進行操做(以指定格式解讀二進制數據)。「視圖」部署了數組接口,這意味着,能夠用數組的方法操做內存。
  2. ​TypedArray​對象:表明肯定類型的二進制數據。用來生成內存的視圖,經過9個構造函數,能夠生成9種數據格式的視圖,數組成員都是同一個數據類型,好比:前端

    1. ​Unit8Array​:(無符號8位整數)數組視圖
    2. ​Int16Array​:(16位整數)數組視圖
    3. ​Float32Array​:(32位浮點數)數組視圖
  1. ​DataView​對象:表明不肯定類型的二進制數據。用來生成內存的視圖,能夠自定義格式和字節序,好比第一個字節是​Uint8​(無符號8位整數)、第二個字節是​Int16​(16位整數)、第三個字節是​Float32​(32位浮點數)等等,數據成員能夠是不一樣的數據類型。

舉個栗子

​ArrayBuffer​也是一個構造函數,能夠分配一段能夠存放數據的連續內存區域vue

var buf = new  ArrayBuffer(32); // 生成一段32字節的內存區域,每一個字節的值默認都是0

爲了讀寫buf,須要爲它指定視圖。node

  1. ​DataView​視圖,是一個構造函數,須要提供​ArrayBuffer​對象實例做爲參數:
var dataView = new DataView(buf); // 不帶符號的8位整數格式
dataView.getUnit8(0) // 0
  1. ​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?)git

  • 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整除。github

  • length:可選,視圖包含的數據個數,默認直到本段內存區域結束

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來跟服務端進行通訊,同時使用消息通道系統來轉發消息。

時序圖

image.png

技術要點

交互協議
  • 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工具進行解析:

  1. 安裝pbjs工具
  • 基於node.js,首先安裝protobufjs
$ npm install -g protobufjs
  • 安裝 pbjs須要的庫 命令行執行下「pbjs」就ok
$ pbjs
  1. 使用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": {
            ...
          }
        }
      }
    }
  }
}
  1. 採用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​實例的生成須要通過如下步驟:

  1. 創建鏈接
  • 創建鏈接後會返回一個websoket實例
  1. 鏈接打開
  • 鏈接創建->鏈接打開是一個異步的過程,在這段時間內是監聽不到消息,更是沒法發送消息的
  1. 監聽消息
  • 監聽的時機比較關鍵,只有當鏈接創建並生成websocket實例後才能監聽
  1. 發送消息
  • 發送當時機也很關鍵,只有當鏈接真正打開後才能發送消息
將小程序WebSocket的一些功能封裝成一個類,裏面包括創建鏈接、監聽消息、發送消息、心跳檢測、斷線重連等等經常使用的功能。
  1. 封裝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鏈接
}
  1. 多個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真正發生改變的時候才進行從新監聽邏輯,不然會收到重複的消息。

問題總結

  1. 直接引入google官方Protobuf庫(protobuf.js)將json => pb,在開發者工具能正常使用,真機卻報錯:

image.png

image.png

緣由是protobufjs 代碼裏面有用到 Function() {} 來執行一段代碼,在小程序中Function 和 eval 相關的動態執行代碼方式都給屏蔽了,是不容許開發者使用的,致使這個庫不能正常使用。

解決辦法:搜了一圈github,找到有人專門針對這個問題,修改了dcodeIO 的protobuf.js部分實現方式,寫了一個能在小程序中運行的protobuf.js

  1. ​ArrayBuffer​ vs ​Unit8Array​ 究竟是個什麼關係??!
  • 受小程序框架、protobuf.js工具以及Frontier系統限制,發送消息和接收消息的格式以下

image.png

image.png

能夠看到:

  • 發送消息通過protobuf.js編碼後的消息是​Unit8Array​格式的
  • 接收到的服務器原始消息是​ArrayBuffer​格式的

上文介紹了​TyedArray​和​ArrayBuffer​的區別,​Unit8Array​是​TypedArray​對象的一種類型,用來表示​ArrayBuffer​的視圖,用來讀寫​ArrayBuffer​,要訪問​ArrayBuffer​的底層對象,必須使用​Unit8Array​的buffer屬性。

  • 一開始跟服務端調websocket的連通性,發現用​AwesomeMessage.decode​解析服務端消息會解析失敗:

image.png

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對象。

  • 在開發者工具調通協議後,轉到真機,發現後端解析不了前端發的消息:

image.png

image.png

【開發者工具抓包消息】
image.png

【真機抓包消息】

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

image.png

小程序框架對發送的消息類型進行了限制,只能是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)
};

其實還發現一個現象:

image.png

即收到的服務端原始消息最外層是​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/

相關文章
相關標籤/搜索