背景:个别与服务端交互频繁的需要,能够应用轮询机制来实现。然而一些业务场景,比方游戏大厅、直播、即时聊天等,这些需要都能够或者说更适宜应用长连贯来实现,一方面能够缩小轮询带来的流量节约,另一方面能够缩小对服务的申请压力,同时也能够更实时的与服务端进行音讯交互。

背景常识

HTTP vs WebSocket

名词解释

  1. HTTP:是一个用于传输超媒体文档(如HTML)的应用层的无连贯、无状态协定。
  2. WebSocket:HTML5开始提供的一种浏览器与服务器进行全双工通信的网络技术,属于应用层协定,基于TCL传输协定,并复用HTTP的握手通道。

特点

  1. HTTP
  2. WebSocket

    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也是一个构造函数,能够调配一段能够存放数据的间断内存区域

var buf = new  ArrayBuffer(32); // 生成一段32字节的内存区域,每个字节的值默认都是0

为了读写buf,须要为它指定视图。

  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?)

  • 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 数据交换格局。可用于通信协定、数据存储等畛域的语言无关、平台无关、可扩大的序列化构造数据格式。

  • 毛病

音讯构造可读性不高,序列化后的字节序列为二进制序列不能简略的剖析有效性;

整体设计

为了保护用户在线状态,须要和服务端放弃长连贯,决定采纳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工具进行解析:

  1. 装置pbjs工具
  • 基于node.js,首先装置protobufjs
$ npm install -g protobufjs
  • 装置 pbjs须要的库 命令行执行下“pbjs”就ok
$ pbjs
  1. 应用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": {            ...          }        }      }    }  }}
  1. 采纳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实例的生成须要通过以下步骤:

  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,在开发者工具能失常应用,真机却报错:

起因是protobufjs 代码外面有用到 Function() {} 来执行一段代码,在小程序中Function 和 eval 相干的动静执行代码形式都给屏蔽了,是不容许开发者应用的,导致这个库不能失常应用。

解决办法:搜了一圈github,找到有人专门针对这个问题,批改了dcodeIO 的protobuf.js局部实现形式,写了一个能在小程序中运行的protobuf.js。

  1. 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/