背景:一般与服务端交互频繁的需求,可以使用轮询机制来实现。然而一些业务场景,比如游戏大厅、直播、即时聊天等,这些需求都可以或者说更适合使用长连接来实现,一方面可以减少轮询带来的流量浪费,另一方面可以减少对服务的请求压力,同时也可以更实时的与服务端进行消息交互。
背景知识
HTTP vs WebSocket
名词解释
特点
二进制数组
名词解释
举个栗子
ArrayBuffer也是一个构造函数,可以分配一段可以存放数据的连续内存区域
var buf = new ArrayBuffer(32); // 生成一段32字节的内存区域,每个字节的值默认都是0
为了读写buf,需要为它指定视图。
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?)
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整除。
note:如果想从任意字节开始解读ArrayBuffer对象,必须使用DataView视图,因为TypedArray视图只提供9种固定的解读格式。
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 是一个东西,前者是一(大)块内存,后者用来访问这块内存。
Protocol Buffers
我们编码的目的是将结构化数据写入磁盘或用于网络传输,以便他人来读取,写入方式有多种选择,比如将数据转换为字符串,然后将字符串写入磁盘。也可以将需要处理的结构化数据由 .proto 文件描述,用 Protobuf 编译器将该文件编译成目标语言。
名词解释
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介绍:
https://halfrost.com/protobuf_encode/
作者:byte
出处:https://segmentfault.com/a/1190000024456875