運用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,帶寬,性能等。 經歷了一次大版本的迭代,加了一個月的班,熬了幾天夜,和團隊一塊兒在進步,收穫到這麼多經驗包也是很開心的。

相關文章
相關標籤/搜索