背景:一般与服务端交互频繁的需求,可以使用轮询机制来实现。然而一些业务场景,比如游戏大厅、直播、即时聊天等,这些需求都可以或者说更适合使用长连接来实现,一方面可以减少轮询带来的流量浪费,另一方面可以减少对服务的请求压力,同时也可以更实时的与服务端进行消息交互。
背景知识
HTTP vs WebSocket 名词解释
- HTTP:是一个用于传输超媒体文档(如HTML)的应用层的无连接、无状态协议。
- WebSocket:HTML5开始提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议,基于TCP传输协议,并复用HTTP的握手通道。
特点
- 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 // 3 typedArray[0] = 5; typedArray // [5, 1, 2]
总结
ArrayBuffer是一(大)块内存,但不能直接访问ArrayBuffer里面的字节。TypedArray只是一层视图,本身不储存数据,它的数据都储存在底层的ArrayBuffer对象之中,要获取底层对象必须使用buffer属性。其实ArrayBuffer 跟 TypedArray 是一个东西,前者是一(大)块内存,后者用来访问这块内存。
我们编码的目的是将结构化数据写入磁盘或用于网络传输,以便他人来读取,写入方式有多种选择,比如将数据转换为字符串,然后将字符串写入磁盘。也可以将需要处理的结构化数据由 .proto 文件描述,用 Protobuf 编译器将该文件编译成目标语言。
Protocol Buffers 名词解释
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工具进行解析:
- 安装pbjs工具
- 基于node.js,首先安装protobufjs
$ npm install -g protobufjs
- 安装 pbjs需要的库 命令行执行下“pbjs”就ok
$ pbjs
- 使用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": { ... } } } } } }
- 采用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实例的生成需要经过以下步骤:
- 建立连接
- 建立连接后会返回一个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真正发生改变的时候才进行重新监听逻辑,否则会收到重复的消息。
参考文献
小程序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
版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。