IM通訊協(xié)議專題學(xué)習(xí)(七):手把手教你如何在NodeJS中從零使用Protobuf
Posted on 2023-01-05 16:14 Jack Jiang 閱讀(152) 評論(0) 編輯 收藏1、前言
Protobuf是Google開源的一種混合語言數(shù)據(jù)標(biāo)準(zhǔn),已被各種互聯(lián)網(wǎng)項目大量使用。
Protobuf最大的特點是數(shù)據(jù)格式擁有極高的壓縮比,這在移動互聯(lián)時代是極具價值的(因為移動網(wǎng)絡(luò)流量到目前為止仍然昂貴的),如果你的APP能比競品更省流量,無疑這也將成為您產(chǎn)品的亮點之一。現(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上的使用,也恰是時候。

學(xué)習(xí)交流:
- 移動端IM開發(fā)入門文章:《新手入門一篇就夠:從零開發(fā)移動端IM》
- 開源IM框架源碼:https://github.com/JackJiang2011/MobileIMSDK(備用地址點此)
(本文同步發(fā)布于:http://www.52im.net/thread-4111-1-1.html)
2、系列文章
本文是系列文章中的第 7 篇,本系列總目錄如下:
- 《IM通訊協(xié)議專題學(xué)習(xí)(一):Protobuf從入門到精通,一篇就夠!》
- 《IM通訊協(xié)議專題學(xué)習(xí)(二):快速理解Protobuf的背景、原理、使用、優(yōu)缺點》
- 《IM通訊協(xié)議專題學(xué)習(xí)(三):由淺入深,從根上理解Protobuf的編解碼原理》
- 《IM通訊協(xié)議專題學(xué)習(xí)(四):從Base64到Protobuf,詳解Protobuf的數(shù)據(jù)編碼原理》
- 《IM通訊協(xié)議專題學(xué)習(xí)(五):Protobuf到底比JSON快幾倍?全方位實測!》
- 《IM通訊協(xié)議專題學(xué)習(xí)(六):手把手教你如何在Android上從零使用Protobuf》(稍后發(fā)布..)
- 《IM通訊協(xié)議專題學(xué)習(xí)(七):手把手教你如何在NodeJS中從零使用Protobuf》(* 本文)
- 《IM通訊協(xié)議專題學(xué)習(xí)(八):金蝶隨手記團隊的Protobuf應(yīng)用實踐(原理篇) 》(稍后發(fā)布..)
- 《IM通訊協(xié)議專題學(xué)習(xí)(九):金蝶隨手記團隊的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é)議當(dāng)然是大名鼎鼎的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ù)男蕰撸?/li>
- 3)在后臺開發(fā)中,后臺與后臺的通信一般就是基于二進制協(xié)議的。甚至某些native app和服務(wù)器的通信也選擇了二進制協(xié)議(例如騰訊視頻)。但由于web前端的存在,后臺同學(xué)往往需要特地開發(fā)維護一套http接口專供我們使用,如果web也能使用二進制協(xié)議,可以節(jié)省許多后臺開發(fā)的成本。
在大公司,最重要的就是優(yōu)化效率、節(jié)省成本,因此二進制協(xié)議明顯優(yōu)于http這樣的文本協(xié)議。
下面舉兩個簡單的例子,應(yīng)該有助于我們理解protobuf。
5、選擇支持protobuf的NodeJS第三方模塊
當(dāng)前在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 負(fù)責(zé)將一些結(jié)構(gòu)化的數(shù)據(jù)寫入一個磁盤文件,Reader 則負(fù)責(zé)從該磁盤文件中讀取結(jié)構(gòu)化數(shù)據(jù)并打印到屏幕上。
準(zhǔn)備用于演示的結(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
}
一個比較好的習(xí)慣是認(rèn)真對待 proto 文件的文件名。比如將命名規(guī)則定于如下:
packageName.MessageName.proto
在上例中,package 名字叫做 lm,定義了一個消息 helloworld,該消息有三個成員,類型為 int32 的 id,另一個為類型為 string 的成員 str。opt 是一個可選的成員,即消息中可以不包含該成員。1、2、3這幾個數(shù)字是這三個字段的唯一標(biāo)識符,這些標(biāo)識符是用來在消息的二進制格式中識別各個字段的,一旦開始使用就不能夠再改變。
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 編譯器生成目標(biāo)語言所需要的源代碼文件。將這些生成的代碼和應(yīng)用程序一起編譯。
可是在某些情況下,人們無法預(yù)先知道 .proto 文件,他們需要動態(tài)處理一些未知的 .proto 文件。比如一個通用的消息轉(zhuǎn)發(fā)中間件,它不可能預(yù)知需要處理怎樣的消息。這需要動態(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 域。這使得人們可以定義更加復(fù)雜的數(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,從而讓定義復(fù)雜的數(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)換成指定類型的變量。
這個過程非常復(fù)雜,其中將 XML 文件轉(zhuǎn)換為文檔對象結(jié)構(gòu)模型的過程通常需要完成詞法文法分析等大量消耗 CPU 的復(fù)雜計算。
反觀 Protobuf:它只需要簡單地將一個二進制序列,按照指定的格式讀取到編程語言對應(yīng)的結(jié)構(gòu)類型中就可以了。而消息的 decoding 過程也可以通過幾個位移操作組成的表達式計算即可完成。速度非常快。
9.2 缺點
作為二進制的序列化協(xié)議,它的缺點也顯而易見——人眼不可讀!
10、參考資料
[1] Protobuf 官方開發(fā)者指南(中文譯版)
[2] Protobuf官方手冊
[4] The Base16, Base32, and Base64 Data Encodings
[5] 如何選擇即時通訊應(yīng)用的數(shù)據(jù)傳輸格式
[7] 強列建議將Protobuf作為你的即時通訊應(yīng)用數(shù)據(jù)傳輸格式
[8] APP與后臺通信數(shù)據(jù)格式的演進:從文本協(xié)議到二進制協(xié)議
[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)
作者:Jack Jiang (點擊作者姓名進入Github)
出處:http://www.52im.net/space-uid-1.html
交流:歡迎加入即時通訊開發(fā)交流群 215891622
討論:http://www.52im.net/
Jack Jiang同時是【原創(chuàng)Java
Swing外觀工程BeautyEye】和【輕量級移動端即時通訊框架MobileIMSDK】的作者,可前往下載交流。
本博文
歡迎轉(zhuǎn)載,轉(zhuǎn)載請注明出處(也可前往 我的52im.net 找到我)。