小程序websocket开发指南

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

背景知识

HTTP vs WebSocket

名词解释

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

image.png

特色

  1. HTTP
  2. WebSockethtml

    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​也是一个构造函数,能够分配一段能够存放数据的连续内存区域vue

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

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

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

  • 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整除。github

  • length:可选,视图包含的数据个数,默认直到本段内存区域结束

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来跟服务端进行通讯,同时使用消息通道系统来转发消息。

时序图

image.png

技术要点

交互协议
  • 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.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": {
            ...
          }
        }
      }
    }
  }
}
  1. 采用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​实例的生成须要通过如下步骤:

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

image.png

image.png

缘由是protobufjs 代码里面有用到 Function() {} 来执行一段代码,在小程序中Function 和 eval 相关的动态执行代码方式都给屏蔽了,是不容许开发者使用的,致使这个库不能正常使用。

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

  1. ​ArrayBuffer​ vs ​Unit8Array​ 究竟是个什么关系??!
  • 受小程序框架、protobuf.js工具以及Frontier系统限制,发送消息和接收消息的格式以下

image.png

image.png

能够看到:

  • 发送消息通过protobuf.js编码后的消息是​Unit8Array​格式的
  • 接收到的服务器原始消息是​ArrayBuffer​格式的

上文介绍了​TyedArray​和​ArrayBuffer​的区别,​Unit8Array​是​TypedArray​对象的一种类型,用来表示​ArrayBuffer​的视图,用来读写​ArrayBuffer​,要访问​ArrayBuffer​的底层对象,必须使用​Unit8Array​的buffer属性。

  • 一开始跟服务端调websocket的连通性,发现用​AwesomeMessage.decode​解析服务端消息会解析失败:

image.png

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对象。

  • 在开发者工具调通协议后,转到真机,发现后端解析不了前端发的消息:

image.png

image.png

【开发者工具抓包消息】
image.png

【真机抓包消息】

抓包发如今开发者工具发送的消息是二进制(Binary)类型的,真机倒是文本(Text)类型,这就很奇怪了,仔细翻了下小程序文档:

image.png

小程序框架对发送的消息类型进行了限制,只能是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)
};

其实还发现一个现象:

image.png

即收到的服务端原始消息最外层是​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/

相关文章
相关标签/搜索