作者:byte
出处:https://segmentfault.com/a/1190000024456875
背景:一般与服务端交互频繁的需求,可以使用轮询机制来实现。然而一些业务场景,比如游戏大厅、直播、即时聊天等,这些需求都可以或者说更适合使用长连接来实现,一方面可以减少轮询带来的流量浪费,另一方面可以减少对服务的请求压力,同时也可以更实时的与服务端进行消息交互。
背景知识
HTTP vs WebSocket
名词解释
- HTTP:是一个用于传输超媒体文档(如HTML)的应用层的无连接、无状态协议。
- WebSocket:HTML5开始提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议,基于TCL传输协议,并复用HTTP的握手通道。
![8a4a275aa99387edf3caa96fb0ee3e5e.png](https://i-blog.csdnimg.cn/blog_migrate/eedebef27a747e8e087ece2f1a6c294d.jpeg)
特点
- HTTP
- WebSocket建立在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)系统
整体设计
为了维护用户在线状态,需要和服务端保持长连接,决定采用websocket来跟服务端进行通信,同时使用消息通道系统来转发消息。
时序图
![8e20c276f86150b2b82739ca9fe9505a.png](https://i-blog.csdnimg.cn/blog_migrate/22f64751ff8272c8caab815121901f1c.jpeg)
技术要点
交互协议
- 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,在开发者工具能正常使用,真机却报错:
![f2d80b5f054bf2c2b3b6fd457f807c25.png](https://i-blog.csdnimg.cn/blog_migrate/a13fd998f766bc64d7474836c5fe26d3.jpeg)
![b4d6438da02e5e5cc520fc0beade0a5a.png](https://i-blog.csdnimg.cn/blog_migrate/2f30733ecfea3f6b1c55447f48f9c71f.jpeg)
原因是protobufjs 代码里面有用到 Function() {} 来执行一段代码,在小程序中Function 和 eval 相关的动态执行代码方式都给屏蔽了,是不允许开发者使用的,导致这个库不能正常使用。
解决办法:搜了一圈github,找到有人专门针对这个问题,修改了dcodeIO 的protobuf.js部分实现方式,写了一个能在小程序中运行的 protobuf.js 。
- ArrayBuffer vs Unit8Array 到底是个什么关系??!
- 受小程序框架、protobuf.js工具以及Frontier系统限制,发送消息和接收消息的格式如下
![1bb044c5a2417bd421065f310608559a.png](https://i-blog.csdnimg.cn/blog_migrate/127891dea442e246328bb9bbddb89714.jpeg)
![be184ab469547beb339c1d84e7849912.png](https://i-blog.csdnimg.cn/blog_migrate/44db023f21a1715f2e59679dcb4863bf.jpeg)
可以看到:
- 发送消息经过protobuf.js编码后的消息是Unit8Array格式的
- 接收到的服务器原始消息是ArrayBuffer格式的
上文介绍了TyedArray和ArrayBuffer的区别,Unit8Array是TypedArray对象的一种类型,用来表示ArrayBuffer的视图,用来读写ArrayBuffer,要访问ArrayBuffer的底层对象,必须使用Unit8Array的buffer属性。
- 一开始跟服务端调websocket的连通性,发现用AwesomeMessage.decode解析服务端消息会解析失败:
![a481c52a7454178fd7209e6366e4e0a0.png](https://i-blog.csdnimg.cn/blog_migrate/090c9bc9986442492b5d062be7134af5.jpeg)
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对象。
- 在开发者工具调通协议后,转到真机,发现后端解析不了前端发的消息:
![10a84ce6decc634eb2bc89013f83f359.png](https://i-blog.csdnimg.cn/blog_migrate/cbe104b9043b72d8f9838b4232e17e03.jpeg)
![fbdcbfd545d994c9195a7868221afd57.png](https://i-blog.csdnimg.cn/blog_migrate/6ac63c686900f1d2a014c1c4286a87dd.jpeg)
【开发者工具抓包消息】
![a6e6646d281a809f34490bb8c18e4103.png](https://i-blog.csdnimg.cn/blog_migrate/9abb8c7312c71d5b3150a244dba01f7b.jpeg)
【真机抓包消息】
抓包发现在开发者工具发送的消息是二进制(Binary)类型的,真机却是文本(Text)类型,这就很奇怪了,仔细翻了下小程序文档:
![3ba2958e30cb755c536bae9370e110f7.png](https://i-blog.csdnimg.cn/blog_migrate/b15254dcf1ff312087e06e4d42b3c4e2.jpeg)
小程序框架对发送的消息类型进行了限制,只能是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)};
其实还发现一个现象:
![d5892468e6803b59b67810f7bd2252a0.png](https://i-blog.csdnimg.cn/blog_migrate/e1c01f8b25222f20ef8895ad9a812651.jpeg)
即收到的服务端原始消息最外层是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/
作者:byte
出处:https://segmentfault.com/a/1190000024456875