1、前言
Protobuf是Google開源的一種混合語言數據標準,已被各種互聯網項目大量使用。
Protobuf最大的特點是數據格式擁有極高的壓縮比,這在移動互聯時代是極具價值的(因為移動網絡流量到目前為止仍然昂貴的),如果你的APP能比競品更省流量,無疑這也將成為您產品的亮點之一。
現在,尤其IM、消息推送這類應用中,Protobuf的應用更是非常廣泛,基於它的優秀表現,微信和手機QQ這樣的主流IM應用也早已在使用它。現在隨着WebSocket協議的越來越成熟,瀏覽器支持的越來越好,Web端的即時通訊應用也逐漸擁有了真正的“實時”能力,相關的技術和應用也是層出不窮,而Protobuf也同樣可以用在WebSocket的通信中。
而且目前比較活躍的WebSocket開源方案中,都是用NodeJS實現的,比如:socket.io和sockjs都是如此,因而本文介紹Protobuf在NodeJS上的使用,也恰是時候。
學習交流:
- 移動端IM開發入門文章:《新手入門一篇就夠:從零開發移動端IM》
-
開源IM框架源碼:https://github.com/JackJiang2...(備用地址點此)
(本文同步發佈於:http://www.52im.net/thread-41...)2、系列文章
本文是系列文章中的第 7 篇,本系列總目錄如下:
《IM通訊協議專題學習(一):Protobuf從入門到精通,一篇就夠!》
《IM通訊協議專題學習(二):快速理解Protobuf的背景、原理、使用、優缺點》
《IM通訊協議專題學習(三):由淺入深,從根上理解Protobuf的編解碼原理》
《IM通訊協議專題學習(四):從Base64到Protobuf,詳解Protobuf的數據編碼原理》
《IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測!》
《IM通訊協議專題學習(六):手把手教你如何在Android上從零使用Protobuf》(稍後發佈..)
《IM通訊協議專題學習(七):手把手教你如何在NodeJS中從零使用Protobuf》(* 本文)
《IM通訊協議專題學習(八):金蝶隨手記團隊的Protobuf應用實踐(原理篇) 》(稍後發佈..)
《IM通訊協議專題學習(九):金蝶隨手記團隊的Protobuf應用實踐(實戰篇) 》(稍後發佈..)3、Protobuf是個什麼鬼?
Protocol Buffer(下文簡稱Protobuf)是Google提供的一種數據序列化協議,下面是我從網上找到的Google官方對Protobuf的定義:Protocol Buffers 是一種輕便高效的結構化數據存儲格式,可以用於結構化數據序列化,很適合做數據存儲或 RPC 數據交換格式。它可用於通訊協議、數據存儲等領域的語言無關、平台無關、可擴展的序列化結構數據格式。目前提供了 C++、Java、Python 三種語言的 API。
道理我們都懂,然後並沒有什麼卵用,看完上面這段定義,對於Protobuf是什麼我還是一臉懵逼。
4、NodeJS開發者為何要跟Protobuf打交道
作為JavaScript開發者,對我們最友好的數據序列化協議當然是大名鼎鼎的JSON啦!我們本能的會想protobuf是什麼鬼?還我JSON!這就要説到protobuf的歷史了。Protobuf由Google出品,08年的時候Google把這個項目開源了,官方支持C++,Java,C#,Go和Python五種語言,但是由於其設計得很簡單,所以衍生出很多第三方的支持,基本上常用的PHP,C,Actoin Script,Javascript,Perl等多種語言都已有第三方的庫。
由於protobuf協議相較於之前流行的XML更加的簡潔高效(後面會提到這是為什麼),因此許多後台接口都是基於protobuf定製的數據序列化協議。而作為NodeJS開發者,跟C++或JAVA編寫的後台服務接口打交道那是家常便飯的事兒,因此我們很有必要掌握protobuf協議。
為什麼説使用使用類似protobuf的二進制協議通信更好呢?
- 1)二進制協議對於電腦來説更容易解析,在解析速度上是http這樣的文本協議不可比擬的;
- 2)有tcp和udp兩種選擇,在一些場景下,udp傳輸的效率會更高;
- 3)在後台開發中,後台與後台的通信一般就是基於二進制協議的。甚至某些native app和服務器的通信也選擇了二進制協議(例如騰訊視頻)。
但由於web前端的存在,後台同學往往需要特地開發維護一套http接口專供我們使用,如果web也能使用二進制協議,可以節省許多後台開發的成本。在大公司,最重要的就是優化效率、節省成本,因此二進制協議明顯優於http這樣的文本協議。下面舉兩個簡單的例子,應該有助於我們理解protobuf。
5、選擇支持protobuf的NodeJS第三方模塊
當前在Github上比較熱門的支持protobuf的NodeJS第三方模塊有如下3個:
根據star數和文檔完善程度兩方面綜合考慮,我們決定選擇protobuf.js(後面2個的地址:Google protobuf js、protocol-buffers)。
6、使用 Protobuf 和NodeJS開發一個簡單的例子
6.1 概述我打算使用 Protobuf 和NodeJS開發一個十分簡單的例子程序。該程序由兩部分組成:第一部分被稱為 Writer,第二部分叫做 Reader。Writer 負責將一些結構化的數據寫入一個磁盤文件,Reader 則負責從該磁盤文件中讀取結構化數據並打印到屏幕上。準備用於演示的結構化數據是 HelloWorld,它包含兩個基本數據:1)ID:為一個整數類型的數據;2)Str:這是一個字符串。6.2 書寫.proto文件首先我們需要編寫一個 proto 文件,定義我們程序中需要處理的結構化數據,在 protobuf 的術語中,結構化數據被稱為 Message。proto 文件非常類似 java 或者 C 語言的數據定義。代碼清單 1 顯示了例子應用中的 proto 文件內容。清單 1. proto 文件:package lm;message helloworld{ required int32 id = 1; // ID required string str = 2; // str optional int32 opt = 3; //optional field}一個比較好的習慣是認真對待 proto 文件的文件名。比如將命名規則定於如下:packageName.MessageName.proto在上例中,package 名字叫做 lm,定義了一個消息 helloworld,該消息有三個成員,類型為 int32 的 id,另一個為類型為 string 的成員 str。opt 是一個可選的成員,即消息中可以不包含該成員。1、2、3這幾個數字是這三個字段的唯一標識符,這些標識符是用來在消息的二進制格式中識別各個字段的,一旦開始使用就不能夠再改變。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規範的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.重點關注- -target就好,由於我們是在Node環境中使用,因此選擇生成符合commonjs規範的文件。命令如下:# ./pbjs ../../lm.message.proto -t commonjs > ../../lm.message.js得到編譯後的符合commonjs規範的js文件:module.exports = require("protobufjs").newBuilder({})'import'.build();6.4 編寫 Writervar HelloWorld = require('./lm.helloworld.js')'lm';var fs = require('fs');// 除了這種傳入一個對象的方式, 你也可以使用get/set 函數用來修改和讀取結構化數據中的數據成員varhw = newHelloWorld({ 'id': 101, 'str': 'Hello'})varbuffer = hw.encode();fs.writeFile('./test.log', buffer.toBuffer(), function(err) { if(!err) { console.log('done!'); }});6.5 編寫Readervar HelloWorld = require('./lm.helloworld.js')'lm';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 運行結果
由於我們沒有在Writer中給可選字段opt字段賦值,因此Reader讀出來的opt字段值為null。這個例子本身並無意義,但只要您稍加修改就可以將它變成更加有用的程序。比如將磁盤替換為網絡 socket,那麼就可以實現基於網絡的數據交換任務。而存儲和交換正是 Protobuf 最有效的應用領域。
7、使用 Protobuf 和NodeJS實現基於網絡數據交換的例子
俗話説得好:“世界上沒有什麼技術問題是不能用一個helloworld的栗子解釋清楚的,如果不行,那就用兩個!”在這個栗子中,我們來實現基於網絡的數據交換任務。
7.1 編寫.protocover.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 編譯器生成目標語言所需要的源代碼文件。將這些生成的代碼和應用程序一起編譯。可是在某些情況下,人們無法預先知道 .proto 文件,他們需要動態處理一些未知的 .proto 文件。比如一個通用的消息轉發中間件,它不可能預知需要處理怎樣的消息。這需要動態編譯 .proto 文件,並使用其中的 Message。我們這裏決定利用protobuf文件可以動態編譯的特性,在代碼中直接讀取proto文件,動態生成我們需要的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 書寫serverserver.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 運行結果
8、其他高級特性
8.1 嵌套Messagemessage
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 域。這使得人們可以定義更加複雜的數據結構。
8.2 Import Message
在一個 .proto 文件中,還可以用 Import 關鍵字引入在其他 .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包內。Import Message 的用處主要在於提供了方便的代碼管理機制,類似 C 語言中的頭文件。您可以將一些公用的 Message 定義在一個 package 中,然後在別的 .proto 文件中引入該 package,進而使用其中的消息定義。Google Protocol Buffer 可以很好地支持嵌套 Message 和引入 Message,從而讓定義複雜的數據結構的工作變得非常輕鬆愉快。
9、總結一下Protobuf
9.1 優點
簡單説來 Protobuf 的主要優點就是:簡潔,快。
為什麼這麼説呢?
1)簡潔:
因為Protocol Buffer 信息的表示非常緊湊,這意味着消息的體積減少,自然需要更少的資源。比如網絡上傳輸的字節數更少,需要的 IO 更少等,從而提高性能。對於代碼清單 1 中的消息,用 Protobuf 序列化後的字節序列為: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 個字節,這些奇怪的數字需要稍微解釋一下,其含義用 ASCII 表示如下:<helloworld> <id>101</id> <name>hello</name></helloworld>我相信與XML一樣同為文本序列化協議的JSON也不會好到哪裏去。
2)快:
首先我們來了解一下 XML 的封解包過程:
- 1)XML 需要從文件中讀取出字符串,再轉換為 XML 文檔對象結構模型;
- 2)之後,再從 XML 文檔對象結構模型中讀取指定節點的字符串;
- 3)最後再將這個字符串轉換成指定類型的變量。
這個過程非常複雜,其中將 XML 文件轉換為文檔對象結構模型的過程通常需要完成詞法文法分析等大量消耗 CPU 的複雜計算。反觀 Protobuf:它只需要簡單地將一個二進制序列,按照指定的格式讀取到編程語言對應的結構類型中就可以了。而消息的 decoding 過程也可以通過幾個位移操作組成的表達式計算即可完成。速度非常快。
9.2 缺點
作為二進制的序列化協議,它的缺點也顯而易見——人眼不可讀!
10、參考資料
[1] Protobuf 官方開發者指南(中文譯版)
[2] Protobuf官方手冊
[3] Why do we use Base64?
[4] The Base16, Base32, and Base64 Data Encodings
[5] Protobuf從入門到精通,一篇就夠!
[5] 如何選擇即時通訊應用的數據傳輸格式
[7] 強列建議將Protobuf作為你的即時通訊應用數據傳輸格式
[8] APP與後台通信數據格式的演進:從文本協議到二進制協議
[9] 面試必考,史上最通俗大小端字節序詳解
[10] 移動端IM開發需要面對的技術問題(含通信協議選擇)
[11] 簡述移動端IM開發的那些坑:架構設計、通信協議和客户端
[12] 理論聯繫實際:一套典型的IM通信協議設計詳解
[13] 58到家實時消息系統的協議設計等技術實踐分享
(本文同步發佈於:http://www.52im.net/thread-41...)