在NodeJS中玩轉Protocol Buffer

Protocol Buffer入門教程

Protocol Buffer是個什麼鬼?

Protocol Buffer(下文簡稱protobuf)是Google提供的一種數據序列化協議,下面是我從網上找到的Google官方對protobuf的定義:

Protocol Buffers 是一種輕便高效的結構化數據存儲格式,可以用於結構化數據序列化,很適合做數據存儲或 RPC 數據交換格式。它可用於通訊協議、數據存儲等領域的語言無關、平臺無關、可擴展的序列化結構數據格式。目前提供了 C++、Java、Python 三種語言的 API。

道理我們都懂,然後並沒有什麼卵用,看完上面這段定義,對於protobuf是什麼我還是一臉懵逼~

理都懂

NodeJS開發者爲何要跟Protocol Buffer打交道

作爲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。

在NodeJS中實踐Protocol Buffer協議

選擇支持protobuf的NodeJS第三方模塊

一個栗子

舉個栗子

我打算使用 Protobuf 和NodeJS開發一個十分簡單的例子程序。
該程序由兩部分組成。第一部分被稱爲 Writer,第二部分叫做 Reader。
Writer 負責將一些結構化的數據寫入一個磁盤文件,Reader 則負責從該磁盤文件中讀取結構化數據並打印到屏幕上。
準備用於演示的結構化數據是 HelloWorld,它包含兩個基本數據:

  • ID,爲一個整數類型的數據
  • Str,這是一個字符串

書寫.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這幾個數字是這三個字段的唯一標識符,這些標識符是用來在消息的二進制格式中識別各個字段的,一旦開始使用就不能夠再改變。

編譯 .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']({
    "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();

編寫 Writer

var HelloWorld = require('./lm.helloworld.js')['lm']['helloworld'];
var fs = require('fs');

// 除了這種傳入一個對象的方式, 你也可以使用get/set 函數用來修改和讀取結構化數據中的數據成員
var hw = new HelloWorld({
    'id': 101,
    'str': 'Hello'
})

var buffer = hw.encode();

fs.writeFile('./test.log', buffer.toBuffer(), function(err) {
    if(!err) {
        console.log('done!');
    }
});

編寫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);
    }
})

運行結果

運行結果

由於我們沒有在Writer中給可選字段opt字段賦值,因此Reader讀出來的opt字段值爲null
然並卵

這個例子本身並無意義,但只要您稍加修改就可以將它變成更加有用的程序。比如將磁盤替換爲網絡 socket,那麼就可以實現基於網絡的數據交換任務。而存儲和交換正是 Protobuf 最有效的應用領域。

再舉一個栗子

俗話說得好:“世界上沒有什麼技術問題是不能用一個helloworld的栗子解釋清楚的,如果不行,那就用兩個!”

舉個栗子

在這個栗子中,我們來實現基於網絡的數據交換任務。

編寫.proto

cover.helloworld.proto文件:

package cover;

message helloworld {

    message helloCoverReq {
        required string name = 1;
    }

    message helloCoverRsp {
        required int32 retcode = 1;
        optional string reply = 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 = new HelloCoverReq({
    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);
});

書寫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 = new HelloCoverRsp({
        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);

運行結果

client.js

server.js

其他高級特性

嵌套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 域。這使得人們可以定義更加複雜的數據結構。

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,從而讓定義複雜的數據結構的工作變得非常輕鬆愉快。

總結一下

優點

簡單說來 Protobuf 的主要優點就是:簡潔,快。
爲什麼這麼說呢?

簡潔

因爲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也不會好到哪裏去。

首先我們來了解一下 XML 的封解包過程。XML 需要從文件中讀取出字符串,再轉換爲 XML 文檔對象結構模型。之後,再從 XML 文檔對象結構模型中讀取指定節點的字符串,最後再將這個字符串轉換成指定類型的變量。這個過程非常複雜,其中將 XML 文件轉換爲文檔對象結構模型的過程通常需要完成詞法文法分析等大量消耗 CPU 的複雜計算。
反觀 Protobuf,它只需要簡單地將一個二進制序列,按照指定的格式讀取到編程語言對應的結構類型中就可以了。而消息的 decoding 過程也可以通過幾個位移操作組成的表達式計算即可完成。速度非常快。

缺點

作爲二進制的序列化協議,人眼不可讀!

參考文檔

Google Protocol Buffer 的使用和原理
Protobuf 語法指南

發佈了32 篇原創文章 · 獲贊 51 · 訪問量 13萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章