运用google-protobuf的IM消息应用开发(前端篇)

前言:

  公司本来使用了第三方提供的IM消息系统,随着业务发展须要,三方的服务有限,而且出现问题也很难处理和排查,因此此次新版本迭代,咱们的server同事呕心沥血作了一个新的IM消息系统,咱们也所以配合作了一些事情。 对于前端来讲,被告知须要用到protocol buffer,什么gui?最开始我一直没弄懂究竟是个什么东西,感受和平时接触的技术差异比较大。 还有二进制什么的,之前感受历来就没在前端使用过。 久经波折,此次的旅途学到了不少东西,因此做此博客。javascript

protocol buffer:

  简称protobuf,google开源项目,是一种数据交换的格式,google 提供了多种语言的实现:php、JavaScript、java、c#、c++、go 和 python等。 因为它是一种二进制的格式,比使用 xml, json 进行数据交换快许多。以上描述太官方很差理解,通俗点来解释一下,就是经过protobuf定义好数据结构生成一个工具类,这个工具类能够把数据封装成二进制数据来进行传输,在另外一端收到二进制数据再用工具类解析成正常的数据。php

 

为何用protobuf(如下是后端大大“邱桑”的意思):

优:

  1.json占用流量大,用了protobuf的二进制传输会帮助传输更轻量,节约用户和服务端流量 。以前旧消息系统使用json的时候发现,当一台服务器访问量很大的时候,cpu占用很低,可是带宽已经满了,服务器承载量也就满了。
 
  2.json太随意太灵活了,字段想加就加,PM提的需求后来维护的人不考虑什么就加上去, 搞乱架构,用protobuf,一看到这东西,就会谨慎。
  
  3.生成代码对于除了javascript来讲的其它语言,真的就是福利,用json的话,后端要写好多类,去把对象解析到类上,而后遇到有子对象,还要再解析,都是体力活,传上来一个protobuf,后端拿到就能够decode了,不用再关心类型,不用再去挨个判断作解析,很开心啊,另外,虽然php也是弱类型,拿到json也能够decode成数组或stdClass,但那个没有意义,不是具体的业务实体, 代码依旧很难维护。(对于JavaScript这样的语言来讲,确实不是福利,用json会更好操做,引入编译和解析二进制数据不只增长了工做量,还要对protobuf生成的js再次作一次封装方便业务开发,加大了业务复杂度)
 
  4.有必定安全性, 传输过程当中是二进制,抓包是看不出具体数据的,而且是本身定义protobuf生成的代码,才能正确解析出数据(有点相似于对称加密)。 前端代码压缩过,即使想经过前端来看懂,也比较费劲,只能说比通常的json安全性高一些。

劣:

  1.可读性比较差,须要经过工具来解析
  2.对于JavaScript,没有json友好,会增长解析和封装的工做量。
 
 

 使用protobuf:

  对于web前端来讲,用它主要是能为用户节约流量,可是业务代码变得多了一层,会复杂一点,可是确实对传输性能作了很大提高。下面咱们来看看大体的使用流程。使用protobuf,你须要安装protobuf编译器,而后经过.proto文件配置自定义的数据格式,以下代码(person.proto):
message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;
}

  定义了人的类,有三个描述变量。经过protobuf编译器,把当前配置的类编译成你所须要语言的代码。 好比编译成JavaScript,这个时候会生成一个js文件,咱们重命名就叫person.js吧,里面的代码依赖google-protobuf,因此咱们要先npm google-protobuf,而后经过webpack或者browserify之类的打包工具把 google-protobuf 引入到当前 person.js 中,最后再引入到咱们的工程中。html

  定义的person,前端要使用的话大体代码以下:前端

//封装
var person = new proto.protocal.Person();
person.setName('子慕');
person.setId('1');
person.setEmail('xx@xx.com');
var binary = person.serializeBinary();
//解析
var person= new proto.protocal.Person.deserializeBinary(unit8array);
var obj = {
  name: person.getName();
  id: person.getId();
  email: person.getEmail();
}
 前端经过websocket拿到后端下行的arrayBuffer对象,把它转化成unit8array,Person的deserializeBinary方法就能把二进制解析成Person对象,能够经过get+变量命拿到相应值。 serializeBinary方法能够直接把当前的对象转换成二进制数据,用于发送到另外一端。

  可是,这样就显得很是难以使用了,甚至数据类型不少,结构也都不同,若是每次收发一个消息都要这样去处理的话,太麻烦了。这里须要进行一层封装处理,方便业务使用,封装后使用大概以下代码:java

//咱们封装生成的对象假比就叫ImInstance
//发送时候直接写一个json,会自动封装
ImInstance.send({
  person:{
      id: '1',
      name: '子慕',
      email: 'xx@xx.com'
  }  
})
//接收也会自动,解析
var msg= ImInstance.parse(arrayBuffer);//{person:{name:'x',id:'x',email:'xxx'}}

   假如咱们后端是php,前端是web,protobuf生成一个两个语言的工具类,相互通讯都要经过各自的类解析和封装,以下图:python

 

 

  实际是咱们会有三个前端:webpack

 

 长链接:

   一个消息系统是须要长链接的,前端须要随时接收消息,APP使用了tcp长链接,前端就是websocket了。 websocket也是基于tcp的,至关于在tcp基础上封装了一层。 某种程度来讲tcp的性能优于websocket,由于websocket就是在tcp的基础上多了一层转化,可是websocket使用更简单,用tcp的app端须要本身去读tcp流,根据包头和包体组装数据包,而websocket不须要,由于websocket会是一个整包的数据并非流的形式。 具体来讲,后端经过缓存区把数据冲刷(flush)给前端,app端拿到tcp数据流,须要根据消息头给定的消息体长度,去拿取后面多少位的数据,而后组装成一个数据包。 而websocket传输过来就是一个个的包,也就是帧并非数据流,因此后端在给websocket数据的时候,必需要把一个整包,在缓冲区一次性冲刷过来,而给tcp的话就能够自由冲刷。c++

(引用)概念上,WebSocket确实只是TCP上面的一层,作下面的工做:git

  •   为浏览器添加web 的origin-based的安全模型。
  •   添加定位和协议命名机制来支持在同一个端口上提供多个服务和同一个IP上有多个主机名。
  •   在TCP上实现帧机制,来回到IP包机制,而没有长度限制。
  •   在带内包含额外的关闭握手,是为了能在有代理和其余中间设施的地方工做。

 

ArrayBuffer:

  前端也许不多会接触到二进制,至少我没怎么接触过。 以前说的二进制传输,经过设置websocket对象的binaryType属性: binaryType = 'arraybuffer'(若是没有配置默认返回的是个Blob对象,protobuf解析时会报错),消息下行的时候 onmessage 拿到的 MessageEvent.data 会是一个ArrayBuffer对象,如图:github

 

  关于ArrayBuffer,MDN解释: ArrayBuffer对象被用来表示一个通用的,固定长度的二进制数据缓冲区。你不能直接操纵ArrayBuffer的内容相反,你应该建立一个表示特定格式的buffer的类型化数组对象(typed array objects)或数据视图对象DataView 来对buffer的内容进行读取和写入操做。

  类型化数组(typed array objects)有下图这些类型:

   实际就是一个ArrayBuffer咱们是不能直接操做它的,须要转成能够操做的对象类型,咱们是须要转换成Unit8Array,好比这样:

var unit8= new Uint8Array(arrayBuffer);

  可是我发如今微信里这样用会报错,在手机默认的浏览器里仍是好的,看来还存在必定兼容问题。后来用到DataView才没问题的:

var dataview = new DataView(arrayBuffer);
var unit8= new Uint8Array(dataview.buffer, dataview.byteOffset, dataview.byteLength);

  兼容问题不止这一点,在phone5测试的时候,一直有问题(同事说那台手机被苹果封过,不晓得会不会和这个有关系),一步步查下去,发现是Unit8Array一些方法在phone5里显示undefined,好比 Unit8Array.sliceUnit8Array.from,把 Unit8Array.slice用 Unit8Array.subarray 替换,Unit8Array.from 用 new 替换,像这样:Uint8Array.from([1, 0, 0]) == new Uint8Array([1, 0, 0]),目前来讲就没出现其余兼容问题了。

 

websocket和重连机制:

  咱们会封装一个独立的websocket类,处理websocket的创建、链接、重连、心跳、监听等,提供一些钩子函数,配合前面说的ImInstance实现业务功能。长链接确定是会出现断开或者弱网等一系类状况,保证业务的健壮和稳定性,须要作心跳重连。这块以前的博客已经写过,此次项目以后又对代码和博客进行了一些完善,具体能够看以前的博客《初探和实现websocket心跳重连》和心跳的github源码《https://github.com/zimv/WebSocketHeartBeat》

 

一些踩到的坑汇总:

  下面两个问题有一个知识点: Number类型统一按浮点数处理,64位(bit)存储,整数是按最大54位(bit)来算最大最小数的,不然会丧失精度;某些操做(如数组索引还有位操做)是按32位处理的。

  1.位移运算:

   每一条消息有个惟一id,id是根据时间戳加上一些其余参数再经过位移运算得出的。 自己根据id能够得出时间,因此就没有专门给时间的字段,这里就须要前端对id进行一次运算,得出时间,可是我在作位移操做的时候发现得出的值不对。 后来才查到了上面的知识点。 server给咱们的是64位的int,可是js的位移是按照32位处理的,因此得出的值不对,后来邱桑找到了一个Long.js库,它能够把64位整数拆分红两个32位的去计算,最后我就获得了正确的时间。Long.js 

  

  2.number丢失精度:

  由于js的整数最大只支持到54bit,范围在 −9007199254740992 到 9007199254740992,而咱们的id是超过了54bit的(这一点受到了后端同事的疯狂嘲笑)。  在作消息回执(收到一条消息,发送当前消息的id给后端,告知我收到这个消息了)的时候,由于超过了js的最大值,因此前端传出去的id就会是错误的。 好比后端返回了一个id为111111111111111111的值(18个1),前端经过protobuf类解析以后拿到的值直接变成了111111111111111100(16个1加2个0),由于超过了最大值,js用0来占位显示,这样回执给后端的id就是111111111111111100了。 我觉得当前存放数字的变量就已是这个值了,我无论作什么都没用了,那么我但愿后端给我一个字符串的id我才好处理(发现这个问题的时候项目正在准备上线),可是邱桑以为这样多一个字段太浪费。 后来他查了一些资料告诉我,就用Long.js,它能够帮我转换成正确的字符串,我不信,我认为js存不到那么大的数据,js直接把数据给丢失了,而邱桑说值实际还在内存里精度没有丢失,只是js展现不出来,并且很是确定,我当时不信,在他强烈的要求下,我使用了Long.js的转换方法,结果他是对的。  虽然收到的值超过了js的范围,可是数值仍然是原封不动的在内存里,这个也是被狠狠的打了一下脸,果真仍是邱桑厉害!  Long.js的代码量仍是比较多,当时我想我只用位移就把位移的相关代码抽出来整合了一下,这样比较节约。  后来发现我如今说的这个问题也须要用到Long.js的其它方法,我又尝试抽离,发现要抽的代码太多了,后来干脆就直接把Long.js所有引入进来了(装逼失败)。

ps:因为当时咱们的id是18位的number,经过long.js转换是没有问题的。可是后面id到19位之后,全部的结果都再也不正确了。js中的number超过安全限制之后,开始变得不安全,有些19位的number能够解析成功,有些不能够,当超过20位之后几乎所有出问题。因此咱们的结论是id若是可能特别长,尽可能用string。

 

   3.微信localstorage:
  官方说退出微信帐号后,将会清空全部Cookie和localStorage。 网上有人说还有部分机型听说会出现没法存储或者退出webview以后就会被清除(这个没有亲自作验证)。 那么我须要作的未读消息状态就无法保证在任何状况下都能正确存取。 解决方案是后端提供一个能够读取的接口,我去存取一个key和value,本身来维护状态。 在h5作未读消息状态还真不容易,我须要在接收到消息的时候作一个判断,若是当前用户没有在和某一我的的对话页面,那么这我的的消息确定是未读的,我须要总未读计数+1,和这我的的未读计数+1,当进到某我的的会话页面,这我的的未读数将被清空,第一次登录以后还会拉取离线消息,而后把离线消息的整理一下作次统计,每次未读消息出现变动都须要把以前的数据进行对比而且更新,页面跳转或者未读出现变化的时候须要给底部tab和消息列表dom作一次状态更新。给一个静态图,看下效果:

  

  4.websocket断线重连把本身踢下线的问题:

  咱们会避免用户重复登陆websocket,若是当前用户第二次链接websocket的话 会把上一次登陆的一端给踢下线,被踢下线的一端会收到一个消息,当收到踢下线的消息以后我便不会进行重连。 由于网络缘由、异常缘由或者后端主动要求我重连,我便会去进行重连,可是有时候出现就在同一个地方执行了重复链接,实际都是本身这一个端,那么就会出现登陆上以后,又收到踢下去的消息,把本身给踢下去了,踢下去就不会再重连了,这样就永久断开了,这属于逻辑没控制好。 解决这个问题是首先要保证重连以前先主动对当前的websocket执行一次close,close的时候后端是会收到断开的通知,这样咱们再去链接就不会重复登陆了。

 

 结语:

  此次本身碰到不少不熟悉的知识,也问了server同事不少问题,学到不少,有靠谱的大牛同事就是爽! 也出过一些bug和问题,屡次反复追溯才查出问题的根源,有时候1个bug多是几个地方代码写错形成的问题。 第一个版本已经顺利上线,后面还有不少重要的工做要作,单从前端来讲,还须要把封装的websocket和ImInstance写得更好,文档,扩展性这些都要考虑(已是一个公共类了,之后还会做为sdk开放给三方平台);还须要作一个监控展现,帮助实时监控服务器CPU,带宽,性能等。 经历了一次大版本的迭代,加了一个月的班,熬了几天夜,和团队一块儿在进步,收获到这么多经验包也是很开心的。

相关文章
相关标签/搜索