关于websocket:小程序websocket开发指南

5次阅读

共计 9612 个字符,预计需要花费 25 分钟才能阅读完成。

背景:个别与服务端交互频繁的需要,能够应用轮询机制来实现。然而一些业务场景,比方游戏大厅、直播、即时聊天等,这些需要都能够或者说更适宜应用长连贯来实现,一方面能够缩小轮询带来的流量节约,另一方面能够缩小对服务的申请压力,同时也能够更实时的与服务端进行音讯交互。

背景常识

HTTP vs WebSocket

名词解释

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

特点

  1. HTTP
  2. WebSocket

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

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

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

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

  • 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​ 是一个货色,前者是一 (大) 块内存,后者用来拜访这块内存。

Protocol Buffers

咱们编码的目标是将结构化数据写入磁盘或用于网络传输,以便别人来读取,写入形式有多种抉择,比方将数据转换为字符串,而后将字符串写入磁盘。也能够将须要解决的结构化数据由 .proto 文件形容,用 Protobuf 编译器将该文件编译成目标语言。

名词解释

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 工具进行解析:

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

起因是 protobufjs 代码外面有用到 Function() {} 来执行一段代码,在小程序中 Function 和 eval 相干的动静执行代码形式都给屏蔽了,是不容许开发者应用的,导致这个库不能失常应用。

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

  1. ​ArrayBuffer​ vs ​Unit8Array​ 到底是个什么关系??!
  • 受小程序框架、protobuf.js 工具以及 Frontier 零碎限度,发送音讯和接管音讯的格局如下

能够看到:

  • 发送音讯通过 protobuf.js 编码后的音讯是​Unit8Array​格局的
  • 接管到的服务器原始音讯是​ArrayBuffer​格局的

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

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

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​类型,得出如下论断:

  • 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/

正文完
 0