背景:通常与服务端交互频繁的需求,可使用轮询机制来实现。然而一些业务场景,好比游戏大厅、直播、即时聊天等,这些需求均可以或者说更适合使用长链接来实现,一方面能够减小轮询带来的流量浪费,另外一方面能够减小对服务的请求压力,同时也能够更实时的与服务端进行消息交互。
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介绍: