IM通訊協(xié)議專題學習(七):手把手教你如何在NodeJS中從零使用Protobuf

1、前言
Protobuf是Google開源的一種混合語言數(shù)據(jù)標準,已被各種互聯(lián)網(wǎng)項目大量使用。
Protobuf最大的特點是數(shù)據(jù)格式擁有極高的壓縮比,這在移動互聯(lián)時代是極具價值的(因為移動網(wǎng)絡(luò)流量到目前為止仍然昂貴的),如果你的APP能比競品更省流量,無疑這也將成為您產(chǎn)品的亮點之一?,F(xiàn)在,尤其IM、消息推送這類應(yīng)用中,Protobuf的應(yīng)用更是非常廣泛,基于它的優(yōu)秀表現(xiàn),微信和手機QQ這樣的主流IM應(yīng)用也早已在使用它。
現(xiàn)在隨著WebSocket協(xié)議的越來越成熟,瀏覽器支持的越來越好,Web端的即時通訊應(yīng)用也逐漸擁有了真正的“實時”能力,相關(guān)的技術(shù)和應(yīng)用也是層出不窮,而Protobuf也同樣可以用在WebSocket的通信中。而且目前比較活躍的WebSocket開源方案中,都是用NodeJS實現(xiàn)的,比如:socket.io和sockjs都是如此,因而本文介紹Protobuf在NodeJS上的使用,也恰是時候。

學習交流:
- 移動端IM開發(fā)入門文章:《新手入門一篇就夠:從零開發(fā)移動端IM》
- 開源IM框架源碼:https://github.com/JackJiang2011/MobileIMSDK(備用地址點此)
(本文同步發(fā)布于:http://www.52im.net/thread-4111-1-1.html)
2、系列文章
本文是系列文章中的第?7?篇,本系列總目錄如下:
《IM通訊協(xié)議專題學習(一):Protobuf從入門到精通,一篇就夠!》
《IM通訊協(xié)議專題學習(二):快速理解Protobuf的背景、原理、使用、優(yōu)缺點》
《IM通訊協(xié)議專題學習(三):由淺入深,從根上理解Protobuf的編解碼原理》
《IM通訊協(xié)議專題學習(四):從Base64到Protobuf,詳解Protobuf的數(shù)據(jù)編碼原理》
《IM通訊協(xié)議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測!》
《IM通訊協(xié)議專題學習(六):手把手教你如何在Android上從零使用Protobuf》(稍后發(fā)布..)
《IM通訊協(xié)議專題學習(七):手把手教你如何在NodeJS中從零使用Protobuf》(* 本文)
《IM通訊協(xié)議專題學習(八):金蝶隨手記團隊的Protobuf應(yīng)用實踐(原理篇) 》(稍后發(fā)布..)
《IM通訊協(xié)議專題學習(九):金蝶隨手記團隊的Protobuf應(yīng)用實踐(實戰(zhàn)篇) 》(稍后發(fā)布..)
3、Protobuf是個什么鬼?
Protocol Buffer(下文簡稱Protobuf)是Google提供的一種數(shù)據(jù)序列化協(xié)議,下面是我從網(wǎng)上找到的Google官方對Protobuf的定義:
Protocol Buffers 是一種輕便高效的結(jié)構(gòu)化數(shù)據(jù)存儲格式,可以用于結(jié)構(gòu)化數(shù)據(jù)序列化,很適合做數(shù)據(jù)存儲或 RPC 數(shù)據(jù)交換格式。它可用于通訊協(xié)議、數(shù)據(jù)存儲等領(lǐng)域的語言無關(guān)、平臺無關(guān)、可擴展的序列化結(jié)構(gòu)數(shù)據(jù)格式。目前提供了 C++、Java、Python 三種語言的 API。
道理我們都懂,然后并沒有什么卵用,看完上面這段定義,對于Protobuf是什么我還是一臉懵逼。

4、NodeJS開發(fā)者為何要跟Protobuf打交道
作為JavaScript開發(fā)者,對我們最友好的數(shù)據(jù)序列化協(xié)議當然是大名鼎鼎的JSON啦!我們本能的會想protobuf是什么鬼?還我JSON!
這就要說到protobuf的歷史了。
Protobuf由Google出品,08年的時候Google把這個項目開源了,官方支持C++,Java,C#,Go和Python五種語言,但是由于其設(shè)計得很簡單,所以衍生出很多第三方的支持,基本上常用的PHP,C,Actoin Script,Javascript,Perl等多種語言都已有第三方的庫。
由于protobuf協(xié)議相較于之前流行的XML更加的簡潔高效(后面會提到這是為什么),因此許多后臺接口都是基于protobuf定制的數(shù)據(jù)序列化協(xié)議。而作為NodeJS開發(fā)者,跟C++或JAVA編寫的后臺服務(wù)接口打交道那是家常便飯的事兒,因此我們很有必要掌握protobuf協(xié)議。
為什么說使用使用類似protobuf的二進制協(xié)議通信更好呢?
1)二進制協(xié)議對于電腦來說更容易解析,在解析速度上是http這樣的文本協(xié)議不可比擬的;
2)有tcp和udp兩種選擇,在一些場景下,udp傳輸?shù)男蕰撸?/p>
3)在后臺開發(fā)中,后臺與后臺的通信一般就是基于二進制協(xié)議的。甚至某些native app和服務(wù)器的通信也選擇了二進制協(xié)議(例如騰訊視頻)。但由于web前端的存在,后臺同學往往需要特地開發(fā)維護一套http接口專供我們使用,如果web也能使用二進制協(xié)議,可以節(jié)省許多后臺開發(fā)的成本。
在大公司,最重要的就是優(yōu)化效率、節(jié)省成本,因此二進制協(xié)議明顯優(yōu)于http這樣的文本協(xié)議。
下面舉兩個簡單的例子,應(yīng)該有助于我們理解protobuf。
5、選擇支持protobuf的NodeJS第三方模塊
當前在Github上比較熱門的支持protobuf的NodeJS第三方模塊有如下3個:



?
根據(jù)star數(shù)和文檔完善程度兩方面綜合考慮,我們決定選擇protobuf.js(后面2個的地址:Google protobuf js、protocol-buffers)。
6、使用 Protobuf 和NodeJS開發(fā)一個簡單的例子
6.1 概述
我打算使用 Protobuf 和NodeJS開發(fā)一個十分簡單的例子程序。該程序由兩部分組成:第一部分被稱為 Writer,第二部分叫做 Reader。
Writer 負責將一些結(jié)構(gòu)化的數(shù)據(jù)寫入一個磁盤文件,Reader 則負責從該磁盤文件中讀取結(jié)構(gòu)化數(shù)據(jù)并打印到屏幕上。
準備用于演示的結(jié)構(gòu)化數(shù)據(jù)是 HelloWorld,它包含兩個基本數(shù)據(jù):
1)ID:為一個整數(shù)類型的數(shù)據(jù);
2)Str:這是一個字符串。
6.2 書寫.proto文件
首先我們需要編寫一個 proto 文件,定義我們程序中需要處理的結(jié)構(gòu)化數(shù)據(jù),在 protobuf 的術(shù)語中,結(jié)構(gòu)化數(shù)據(jù)被稱為 Message。proto 文件非常類似 java 或者 C 語言的數(shù)據(jù)定義。代碼清單 1 顯示了例子應(yīng)用中的 proto 文件內(nèi)容。
清單 1. proto 文件:
package lm;
message helloworld
{
???required int32???? id = 1;? // ID
???required string??? str = 2;? // str
???optional int32???? opt = 3;? //optional field
}
一個比較好的習慣是認真對待 proto 文件的文件名。比如將命名規(guī)則定于如下:
packageName.MessageName.proto
在上例中,package 名字叫做 lm,定義了一個消息 helloworld,該消息有三個成員,類型為 int32 的 id,另一個為類型為 string 的成員 str。opt 是一個可選的成員,即消息中可以不包含該成員。1、2、3這幾個數(shù)字是這三個字段的唯一標識符,這些標識符是用來在消息的二進制格式中識別各個字段的,一旦開始使用就不能夠再改變。
6.3 編譯 .proto 文件
我們可以使用protobuf.js提供的命令行工具來編譯 .proto 文件。
用法:
# pbjs <filename> [options] [> outFile]
我們來看看options:
??--help, -h??????? Show help? [boolean] 查看幫助
??--version, -v???? Show version number? [boolean] 查看版本號
??--source, -s????? Specifies the source format. Valid formats are:
???????????????????????json?????? Plain JSON descriptor
???????????????????????proto????? Plain .proto descriptor
指定來源文件格式,可以是json或proto文件。
??--target, -t????? Specifies the target format. Valid formats are:
???????????????????????amd??????? Runtime structures as AMD module
???????????????????????commonjs?? Runtime structures as CommonJS module
???????????????????????js???????? Runtime structures
???????????????????????json?????? Plain JSON descriptor
???????????????????????proto????? Plain .proto descriptor
指定生成文件格式,可以是符合amd或者commonjs規(guī)范的js文件,或者是單純的js/json/proto文件。
??--using, -u?????? Specifies an option to apply to the volatile builder
????????????????????loading the source, e.g. convertFieldsToCamelCase.
??--min, -m???????? Minifies the output.? [default: false] 壓縮生成文件
??--path, -p??????? Adds a directory to the include path.
??--legacy, -l????? Includes legacy descriptors from google/protobuf/ if
????????????????????explicitly referenced.? [default: false]
??--quiet, -q?????? Suppresses any informatory output to stderr.? [default: false]
??--use, -i???????? Specifies an option to apply to the emitted builder
????????????????????utilized by your program, e.g. populateAccessors.
??--exports, -e???? Specifies the namespace to export. Defaults to export
????????????????????the root namespace.
??--dependency, -d? Library dependency to use when generating classes.
????????????????????Defaults to 'protobufjs' for CommonJS, 'ProtoBuf' for
????????????????????AMD modules and 'dcodeIO.ProtoBuf' for classes.
重點關(guān)注- -target就好,由于我們是在Node環(huán)境中使用,因此選擇生成符合commonjs規(guī)范的文件。
命令如下:
# ./pbjs ../../lm.message.proto? -t commonjs > ../../lm.message.js
得到編譯后的符合commonjs規(guī)范的js文件:
module.exports = require("protobufjs").newBuilder({})['import']({
????"package": "lm",
????"messages": [
????????{
????????????"name": "helloworld",
????????????"fields": [
????????????????{
????????????????????"rule": "required",
????????????????????"type": "int32",
????????????????????"name": "id",
????????????????????"id": 1
????????????????},
????????????????{
????????????????????"rule": "required",
????????????????????"type": "string",
????????????????????"name": "str",
????????????????????"id": 2
????????????????},
????????????????{
????????????????????"rule": "optional",
????????????????????"type": "int32",
????????????????????"name": "opt",
????????????????????"id": 3
????????????????}
????????????]
????????}
????]
}).build();
6.4 編寫 Writer
var HelloWorld = require('./lm.helloworld.js')['lm']['helloworld'];
var fs = require('fs');
// 除了這種傳入一個對象的方式, 你也可以使用get/set 函數(shù)用來修改和讀取結(jié)構(gòu)化數(shù)據(jù)中的數(shù)據(jù)成員
varhw = newHelloWorld({
????'id': 101,
????'str': 'Hello'
})
varbuffer = hw.encode();
fs.writeFile('./test.log', buffer.toBuffer(), function(err) {
????if(!err) {
????????console.log('done!');
????}
});
6.5 編寫Reader
var HelloWorld = require('./lm.helloworld.js')['lm']['helloworld'];
var fs = require('fs');
var buffer = fs.readFile('./test.log', function(err, data) {
????if(!err) {
????????console.log(data); // 來看看Node里的Buffer對象長什么樣子。
????????var message = HelloWorld.decode(data);
????????console.log(message);
????}
})
6.6 運行結(jié)果

由于我們沒有在Writer中給可選字段opt字段賦值,因此Reader讀出來的opt字段值為null。
這個例子本身并無意義,但只要您稍加修改就可以將它變成更加有用的程序。比如將磁盤替換為網(wǎng)絡(luò) socket,那么就可以實現(xiàn)基于網(wǎng)絡(luò)的數(shù)據(jù)交換任務(wù)。而存儲和交換正是 Protobuf 最有效的應(yīng)用領(lǐng)域。
7、使用 Protobuf 和NodeJS實現(xiàn)基于網(wǎng)絡(luò)數(shù)據(jù)交換的例子
俗話說得好:“世界上沒有什么技術(shù)問題是不能用一個helloworld的栗子解釋清楚的,如果不行,那就用兩個!”
在這個栗子中,我們來實現(xiàn)基于網(wǎng)絡(luò)的數(shù)據(jù)交換任務(wù)。
7.1 編寫.proto
cover.helloworld.proto文件:
package cover;
message helloworld {
????message helloCoverReq {
????????required string name = 1;
????}
????message helloCoverRsp {
????????required int32 retcode = 1;
????????optional string reply = 2;
????}
}
7.2 編寫client
一般情況下,使用 Protobuf 的人們都會先寫好 .proto 文件,再用 Protobuf 編譯器生成目標語言所需要的源代碼文件。將這些生成的代碼和應(yīng)用程序一起編譯。
可是在某些情況下,人們無法預先知道 .proto 文件,他們需要動態(tài)處理一些未知的 .proto 文件。比如一個通用的消息轉(zhuǎn)發(fā)中間件,它不可能預知需要處理怎樣的消息。這需要動態(tài)編譯 .proto 文件,并使用其中的 Message。
我們這里決定利用protobuf文件可以動態(tài)編譯的特性,在代碼中直接讀取proto文件,動態(tài)生成我們需要的commonjs模塊。
client.js:
var dgram = require('dgram');
var ProtoBuf = require("protobufjs");
var PORT = 33333;
var HOST = '127.0.0.1';
var builder = ProtoBuf.loadProtoFile("./cover.helloworld.proto"),
????Cover = builder.build("cover"),
????HelloCoverReq = Cover.helloworld.helloCoverReq;
????HelloCoverRsp = Cover.helloworld.helloCoverRsp;
?
var hCReq = newHelloCoverReq({
????name: 'R U coverguo?'
})
?
var buffer = hCReq.encode();
var socket = dgram.createSocket({
????type: 'udp4',
????fd: 8080
}, function(err, message) {
????if(err) {
????????console.log(err);
????}
????console.log(message);
});
var message = buffer.toBuffer();
socket.send(message, 0, message.length, PORT, HOST, function(err, bytes) {
????if(err) {
????????throw err;
????}
????console.log('UDP message sent to '+ HOST +':'+ PORT);
});
socket.on("message", function(msg, rinfo) {
????console.log("[UDP-CLIENT] Received message: "+ HelloCoverRsp.decode(msg).reply + " from "+ rinfo.address + ":"+ rinfo.port);
????console.log(HelloCoverRsp.decode(msg));
????socket.close();
????//udpSocket = null;
});
socket.on('close', function(){
????console.log('socket closed.');
});
socket.on('error', function(err){
????socket.close();
????console.log('socket err');
????console.log(err);
});
7.3 書寫server
server.js:
var PORT = 33333;
var HOST = '127.0.0.1';
var ProtoBuf = require("protobufjs");
var dgram = require('dgram');
var server = dgram.createSocket('udp4');
var builder = ProtoBuf.loadProtoFile("./cover.helloworld.proto"),
????Cover = builder.build("cover"),
????HelloCoverReq = Cover.helloworld.helloCoverReq;
????HelloCoverRsp = Cover.helloworld.helloCoverRsp;
server.on('listening', function() {
????var address = server.address();
????console.log('UDP Server listening on '+ address.address + ":"+ address.port);
});
server.on('message', function(message, remote) {
????console.log(remote.address + ':'+ remote.port +' - '+ message);
????console.log(HelloCoverReq.decode(message) + 'from client!');
????var hCRsp = newHelloCoverRsp({
????????retcode: 0,
????????reply: 'Yeah!I\'m handsome cover!'
????})
?
????var buffer = hCRsp.encode();
????var message = buffer.toBuffer();
????server.send(message, 0, message.length, remote.port, remote.address, function(err, bytes) {
????????if(err) {
????????????throw err;
????????}
????????console.log('UDP message reply to '+ remote.address +':'+ remote.port);
????})
});
server.bind(PORT, HOST);
7.4 運行結(jié)果


?
8、其他高級特性
8.1 嵌套Message
message Person {
??required string name = 1;
??required int32 id = 2;??????? // Unique ID number for this person.
??optional string email = 3;
??enum PhoneType {
????MOBILE = 0;
????HOME = 1;
????WORK = 2;
??}
??message PhoneNumber {
????required string number = 1;
????optional PhoneType type = 2 [default = HOME];
??}
??repeated PhoneNumber phone = 4;
?}
在 Message Person 中,定義了嵌套消息 PhoneNumber,并用來定義 Person 消息中的 phone 域。這使得人們可以定義更加復雜的數(shù)據(jù)結(jié)構(gòu)。
8.2 Import Message
在一個 .proto 文件中,還可以用 Import 關(guān)鍵字引入在其他 .proto 文件中定義的消息,這可以稱做 Import Message,或者 Dependency Message。
比如下例:
import common.header;
?message youMsg{
??required common.info_header header = 1;
??required string youPrivateData = 2;
?}
其中 ,common.info_header定義在common.header包內(nèi)。
Import Message 的用處主要在于提供了方便的代碼管理機制,類似 C 語言中的頭文件。您可以將一些公用的 Message 定義在一個 package 中,然后在別的 .proto 文件中引入該 package,進而使用其中的消息定義。
Google Protocol Buffer 可以很好地支持嵌套 Message 和引入 Message,從而讓定義復雜的數(shù)據(jù)結(jié)構(gòu)的工作變得非常輕松愉快。
9、總結(jié)一下Protobuf
9.1 優(yōu)點
簡單說來 Protobuf 的主要優(yōu)點就是:簡潔,快。
為什么這么說呢?
1)簡潔:
因為Protocol Buffer 信息的表示非常緊湊,這意味著消息的體積減少,自然需要更少的資源。比如網(wǎng)絡(luò)上傳輸?shù)淖止?jié)數(shù)更少,需要的 IO 更少等,從而提高性能。
對于代碼清單 1 中的消息,用 Protobuf 序列化后的字節(jié)序列為:
08 65 12 06 48 65 6C 6C 6F 77
而如果用 XML,則類似這樣:
31 30 31 3C 2F 69 64 3E 3C 6E 61 6D 65 3E 68 65
?6C 6C 6F 3C 2F 6E 61 6D 65 3E 3C 2F 68 65 6C 6C
?6F 77 6F 72 6C 64 3E
一共 55 個字節(jié),這些奇怪的數(shù)字需要稍微解釋一下,其含義用 ASCII 表示如下:
<helloworld>
???<id>101</id>
???<name>hello</name>
</helloworld>
我相信與XML一樣同為文本序列化協(xié)議的JSON也不會好到哪里去。
2)快:
首先我們來了解一下 XML 的封解包過程:
1)XML 需要從文件中讀取出字符串,再轉(zhuǎn)換為 XML 文檔對象結(jié)構(gòu)模型;
2)之后,再從 XML 文檔對象結(jié)構(gòu)模型中讀取指定節(jié)點的字符串;
3)最后再將這個字符串轉(zhuǎn)換成指定類型的變量。
這個過程非常復雜,其中將 XML 文件轉(zhuǎn)換為文檔對象結(jié)構(gòu)模型的過程通常需要完成詞法文法分析等大量消耗 CPU 的復雜計算。
反觀 Protobuf:它只需要簡單地將一個二進制序列,按照指定的格式讀取到編程語言對應(yīng)的結(jié)構(gòu)類型中就可以了。而消息的 decoding 過程也可以通過幾個位移操作組成的表達式計算即可完成。速度非常快。
9.2 缺點
作為二進制的序列化協(xié)議,它的缺點也顯而易見——人眼不可讀!
10、參考資料
[1]?Protobuf 官方開發(fā)者指南(中文譯版)
[2]?Protobuf官方手冊
[3]?Why do we use Base64?
[4]?The Base16, Base32, and Base64 Data Encodings
[5]?Protobuf從入門到精通,一篇就夠!
[5]?如何選擇即時通訊應(yīng)用的數(shù)據(jù)傳輸格式
[7]?強列建議將Protobuf作為你的即時通訊應(yīng)用數(shù)據(jù)傳輸格式
[8]?APP與后臺通信數(shù)據(jù)格式的演進:從文本協(xié)議到二進制協(xié)議
[9]?面試必考,史上最通俗大小端字節(jié)序詳解
[10]?移動端IM開發(fā)需要面對的技術(shù)問題(含通信協(xié)議選擇)
[11]?簡述移動端IM開發(fā)的那些坑:架構(gòu)設(shè)計、通信協(xié)議和客戶端
[12]?理論聯(lián)系實際:一套典型的IM通信協(xié)議設(shè)計詳解
[13]?58到家實時消息系統(tǒng)的協(xié)議設(shè)計等技術(shù)實踐分享
(本文同步發(fā)布于:http://www.52im.net/thread-4111-1-1.html)