前言:
公司原本使用了第三方提供的IM消息系統,隨着業務發展需要,三方的服務有限,並且出現問題也很難處理和排查,所以這次新版本迭代,我們的server同事嘔心瀝血做了一個新的IM消息系統,我們也因此配合做了一些事情。 對於前端來說,被告知需要用到protocol buffer,什么gui?最開始我一直沒弄懂到底是個什么東西,感覺和平時接觸的技術差別比較大。 還有二進制什么的,以前感覺從來就沒在前端使用過。 久經波折,這次的旅途學到了很多東西,所以作此博客。
protocol buffer:
簡稱protobuf,google開源項目,是一種數據交換的格式,google 提供了多種語言的實現:php、JavaScript、java、c#、c++、go 和 python等。 由於它是一種二進制的格式,比使用 xml, json 進行數據交換快許多。以上描述太官方不好理解,通俗點來解釋一下,就是通過protobuf定義好數據結構生成一個工具類,這個工具類可以把數據封裝成二進制數據來進行傳輸,在另一端收到二進制數據再用工具類解析成正常的數據。
為什么用protobuf(以下是后端大大“邱桑”的意思):
優:
劣:
使用protobuf:
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 中,最后再引入到我們的工程中。
定義的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方法可以直接把當前的對象轉換成二進制數據,用於發送到另一端。
但是,這樣就顯得非常難以使用了,甚至數據類型很多,結構也都不一樣,如果每次收發一個消息都要這樣去處理的話,太麻煩了。這里需要進行一層封裝處理,方便業務使用,封裝后使用大概如下代碼:
//我們封裝生成的對象假比就叫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生成一個兩個語言的工具類,相互通信都要通過各自的類解析和封裝,如下圖:

實際是我們會有三個前端:
長連接:
一個消息系統是需要長連接的,前端需要隨時接收消息,APP使用了tcp長連接,前端就是websocket了。 websocket也是基於tcp的,相當於在tcp基礎上封裝了一層。 某種程度來說tcp的性能優於websocket,因為websocket就是在tcp的基礎上多了一層轉化,但是websocket使用更簡單,用tcp的app端需要自己去讀tcp流,根據包頭和包體組裝數據包,而websocket不需要,因為websocket會是一個整包的數據並不是流的形式。 具體來說,后端通過緩存區把數據沖刷(flush)給前端,app端拿到tcp數據流,需要根據消息頭給定的消息體長度,去拿取后面多少位的數據,然后組裝成一個數據包。 而websocket傳輸過來就是一個個的包,也就是幀並不是數據流,所以后端在給websocket數據的時候,必須要把一個整包,在緩沖區一次性沖刷過來,而給tcp的話就可以自由沖刷。
(引用)概念上,WebSocket確實只是TCP上面的一層,做下面的工作:
- 為瀏覽器添加web 的origin-based的安全模型。
- 添加定位和協議命名機制來支持在同一個端口上提供多個服務和同一個IP上有多個主機名。
- 在TCP上實現幀機制,來回到IP包機制,而沒有長度限制。
- 在帶內包含額外的關閉握手,是為了能在有代理和其他中間設施的地方工作。
ArrayBuffer:
前端也許很少會接觸到二進制,至少我沒怎么接觸過。 之前說的二進制傳輸,通過設置websocket對象的binaryType屬性: binaryType = 'arraybuffer'(如果沒有配置默認返回的是個Blob對象,protobuf解析時會報錯),消息下行的時候 onmessage 拿到的 MessageEvent.data 會是一個ArrayBuffer對象,如圖:
關於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.slice 和 Unit8Array.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。

4.websocket斷線重連把自己踢下線的問題:
我們會避免用戶重復登錄websocket,如果當前用戶第二次連接websocket的話 會把上一次登錄的一端給踢下線,被踢下線的一端會收到一個消息,當收到踢下線的消息之后我便不會進行重連。 因為網絡原因、異常原因或者后端主動要求我重連,我便會去進行重連,但是有時候出現就在同一個地方執行了重復連接,實際都是自己這一個端,那么就會出現登錄上之后,又收到踢下去的消息,把自己給踢下去了,踢下去就不會再重連了,這樣就永久斷開了,這屬於邏輯沒控制好。 解決這個問題是首先要保證重連之前先主動對當前的websocket執行一次close,close的時候后端是會收到斷開的通知,這樣我們再去連接就不會重復登錄了。
結語:
這次自己碰到很多不熟悉的知識,也問了server同事很多問題,學到很多,有靠譜的大牛同事就是爽! 也出過一些bug和問題,多次反復追溯才查出問題的根源,有時候1個bug可能是幾個地方代碼寫錯造成的問題。 第一個版本已經順利上線,后面還有很多重要的工作要做,單從前端來說,還需要把封裝的websocket和ImInstance寫得更好,文檔,擴展性這些都要考慮(已經是一個公共類了,以后還會作為sdk開放給三方平台);還需要做一個監控展示,幫助實時監控服務器CPU,帶寬,性能等。 經歷了一次大版本的迭代,加了一個月的班,熬了幾天夜,和團隊一起在進步,收獲到這么多經驗包也是很開心的。