Protobuf 协议简介

2018-05-24 linux network

Protobuf (Google Protocol Buffers) 是 google 开发的的一套用于数据存储,网络通信时用于协议编解码的工具库,与 XML 和 JSON 数据格式类似,但采用的是二进制的数据格式,具有更高的传输,打包和解包效率。

相比 JSON 来说,Protobuf 的效率、编解码速度更快、数据体积更小,带来的问题是数据可读性变差,协议升级比较麻烦。

protobuf introduce

简介

可以通过 protoc 生成编译后的文件,例如。

$ protoc --go_out=plugins=grpc:. users.proto

生成一个 users.pb.go 文件,其中定义了客户端和服务端的方法。

常用参数如下。

-IPATH, --proto_path=PATH
    用来指定 import 的查找目录,可以多次指定,此时将会按照顺序查找,不指定则默认使用当前目录。
	当多个 proto 文件之间有互相依赖时,需要 import 其他几个 proto 文件,这时候就要用 -I 来指定搜索目录。

--go_out
   通过 golang 的编译插件,支持 golang 的编译,支持以下参数。
       plugins=  指定插件,目前只支持grpc,也就是 plugins=grpc
       最后是指定生成的目录路径,注意是与proto文件的相对路径。

GoLang 语言支持

在 GoLang 中使用 Protobuf ,有两个可选的包 goprotobuf(官方) 和 gogoprotobuf ,或者完全兼容前者,而且据说它生成的代码质量和编解码性能均比官方的要高一些。

安装

首先是安装官方的版本。

----- 安装protobuf库文件
$ go get github.com/golang/protobuf/proto
----- 安装插件,用来生成proto.pb.go文件
$ go get github.com/golang/protobuf/protoc-gen-go

接着可以安装 gogo 版本,其中有 protoc-gen-gogoprotoc-gen-gofast 两个插件可用,后者生成的代码更加复杂,但是性能也更高 (快5~7倍)。

----- 安装两个插件
$ go get github.com/gogo/protobuf/protoc-gen-gogo
$ go get github.com/gogo/protobuf/protoc-gen-gofast
----- 安装库文件(可选)
$ go get github.com/gogo/protobuf/gogoproto

最后是使用上述的插件生成编译后的 go 文件。

$ protoc --go_out=. *.proto
$ protoc --gogo_out=. *.proto
$ protoc --gofast_out=. *.proto

示例1

这里采用的是 proto2 的示例,目录结构如下。

 |-foobar.go
 `-example/
   `-example.proto
syntax = "proto2";

package example;

enum FOO { X = 17; };

message Test {
    required string label = 1;
    optional int32 type = 2 [default=77];
    repeated int64 reps = 3;
    optional group OptionalGroup = 4 {
        required string RequiredField = 5;
    }
}
package main

import (
    "fmt"
    "log"
    "reflect"

    "./example"
    "github.com/golang/protobuf/proto"
)

func main() {
    test := &example.Test{
        Label: proto.String("hello"),
        Type:  proto.Int32(17),
        Optionalgroup: &example.Test_OptionalGroup{
            RequiredField: proto.String("good bye"),
        },
    }

    data, err := proto.Marshal(test)
    if err != nil {
        log.Fatal("marshaling error: ", err)
    }
    fmt.Println("type:", reflect.TypeOf(data))

    newTest := &example.Test{}
    err = proto.Unmarshal(data, newTest)
    if err != nil {
        log.Fatal("unmarshaling error: ", err)
    }

    if test.GetLabel() != newTest.GetLabel() {
        log.Fatalf("data mismatch %q != %q", test.GetLabel(), newTest.GetLabel())
    }
}

然后通过 protoc --go_out=. *.proto 生成,其中 Marshal() 接口返回的数据是 []uint8 或者 []byte 类型。

示例2

目录结构如下。

 |-foobar.go
 |-example.proto
 `-example/
syntax = "proto3";

package example;

message Person {
    string email = 1;

    enum PhoneType {
        MOBILE = 0;
        HOME = 1;
        WORK = 2;
    }

    message PhoneNumber {
        string number = 1;
        PhoneType type = 2;
    }

    repeated PhoneNumber phones = 2;

    oneof UserID {
        string name = 3;
        uint32 id =4;
    }

}
package main

import (
    "log"

    pb "./example"

    "github.com/golang/protobuf/proto"
)

func main() {
    p := pb.Person{
        Email: "foobar@example.com",
        Phones: []*pb.Person_PhoneNumber{
            {Number: "12345678", Type: pb.Person_HOME},
        },
        UserID: &pb.Person_Name{
            Name: "foobar",
        },
    }

    data, err := proto.Marshal(&p)
    if err != nil {
        log.Fatal("marshaling error: ", err)
    }

    ps := &pb.Person{}
    err = proto.Unmarshal(data, ps)
    if err != nil {
        log.Fatal("unmarshaling error: ", err)
    }

    switch x := ps.UserID.(type) {
    case *pb.Person_Name:
        log.Printf("Got name %s\n", ps.GetName())
    case *pb.Person_Id:
        log.Printf("Got ID %d\n", ps.GetId())
    case nil:
    default:
        log.Fatal("Person.UserID has unexpected type %T", x)
    }

    log.Printf("ID: %d, Name: %s, Email: %s\n", ps.GetId(), ps.GetName(), ps.GetEmail())
    for idx, val := range ps.GetPhones() {
        log.Printf("Phone%d = %+v\n", idx, val)
    }
}
$ protoc example.proto --go_out=plugins=grpc:example

示例3

目录结构如下。

 |-server.go
 |-client.go
 `-proto/
   |-userinfo/         用来保存生成的go文件
   `-userinfo.proto

其中 userinfo.proto 的内容如下。

syntax = "proto3";

package userinfo;

enum Gender {
    MALE = 0;
    FEMALE = 1;
};

message Addr {
    string city = 1;
}

message Contact {
    string email = 1;
}

message UserInfo {
    string name = 1;
    int32 age = 2;
    Gender gender = 3;

    oneof method {
        Addr addr = 5;
        Contact cont = 6;
    }
}

客户端和服务端的代码如下。

package main

import (
    pb "./proto/userinfo"
    "fmt"
    "net"

    "github.com/golang/protobuf/proto"
    //"github.com/gogo/protobuf/proto"
)

func main() {
    Addr := "localhost:6600"
    var conn net.Conn
    var err error

    conn, err = net.Dial("tcp", Addr)
    if err != nil {
        fmt.Println("connecting to", Addr, "fail", err)
        return
    }
    fmt.Println("connect to", Addr, "success")
    defer conn.Close()

    Request := &pb.UserInfo{
        Name:   "foobar",
        Age:    15,
        Gender: pb.Gender_MALE,
        Method: &pb.UserInfo_Addr{
            Addr: &pb.Addr{
                    City: "foobar",
            },
        },
    }

    data, err := proto.Marshal(Request)
    if err != nil {
        panic(err)
    }
    fmt.Printf("%d bytes per package\n", len(data))

    for i := 0; i < 100; i++ {
        conn.Write(data)
    }
}
package main

import (
    pb "./proto/userinfo"
    "fmt"
    "io"
    "net"

    "github.com/golang/protobuf/proto"
    //"github.com/gogo/protobuf/proto"
)

func main() {
    listener, err := net.Listen("tcp", "localhost:6600")
    if err != nil {
        panic(err)
    }

    for {
        conn, err := listener.Accept()
        if err != nil {
            panic(err)
        }
        fmt.Println("New connect from", conn.RemoteAddr())
        go readMessage(conn)
    }
}

func readMessage(conn net.Conn) {
    defer conn.Close()

    count := 0
    buf := make([]byte, 4096, 4096)
    for {
        cnt, err := conn.Read(buf)
        if err == io.EOF {
            fmt.Println("Client", conn.RemoteAddr(), "quit.")
            return
        } else if err != nil {
            panic(err)
        }

        offset := 0
        for {
            Response := &pb.UserInfo{}

            err = proto.Unmarshal(buf[offset:cnt], Response)
            if err != nil {
                panic(err)
            }
            bytes := proto.Size(Response)
            if bytes == 0 {
                break
            }
            count++

            fmt.Printf("[%03d] Got data(%d) from %s, data(%d) %v\n",
                    count, cnt, conn.RemoteAddr(), bytes, Response)

            switch x := Response.Method.(type) {
            case *pb.UserInfo_Addr:
                fmt.Printf("Addr %+v\n", x.Addr)
            case *pb.UserInfo_Cont:
                fmt.Println("Cont")
            case nil:
            default:
                panic(fmt.Sprintf("proto: unexpected type %T in oneof", x))
            }
            offset += bytes
        }
    }
}

然后,在根目录下执行如下命令。

$ protoc -I proto proto/userinfo.proto --go_out=plugins=grpc:proto/userinfo

$ GOPATH=$PWD:$GOPATH go run server.go
$ GOPATH=$PWD:$GOPATH go run client.go

C 语言支持

源码安装最新版本的 protoc 。

----- 如果非Release版本需要执行如下脚本生成configure文件
$ ./autogen.sh
----- 指定安装路径
$ ./configure --prefix=/usr/local/protobuf
$ make
----- 如下测试流程会很耗时,可以忽略
$ make check
# make install

protobuf-c 最新版本是支持 proto3 的,如果平台没有对应的包,那么可以直接从源码开始编译。

PKG_CONFIG_PATH=/usr/local/protobuf/lib/pkgconfig \
./configure --includedir=/usr/local/protobuf/include/google/protobuf --prefix=/usr/local/protobuf-c

注意,通过上述命令安装完 protobuf-c 之后,头文件实际会安装在 /usr/local/protobuf/include/google/protobuf/protobuf-c 目录下。

因为按转到了非标准目录下,在编译时就需要通过 -I-L 参数指定目录路径;包括在执行时添加 LD_LIBRARY_PATH=/usr/local/protobuf-c/lib/ 变量。

示例

如下是一个示例,相关的代码也可以参考 github protobuf-c examples

/* request.proto */
syntax = "proto2";

message Request {
    required string hostname = 1;

    enum ProtocolType {
        ICMP = 0;
        TCP = 1;
    };
    required ProtocolType protocol = 2;
    optional uint32 interval = 3;
    optional uint32 timeout = 4;
};

测试代码如下。

#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include "request.pb-c.h"

/* NOTE: Keep this same with request.proto */
enum {
    PROTOCOL_TYPE_ICMP = 0,
    PROTOCOL_TYPE_TCP  = 0
};

int main()
{
    Request req = REQUEST__INIT, *request;
    unsigned char *buffer = NULL;
    size_t len ;

    req.hostname = "192.168.9.10";
    req.protocol = PROTOCOL_TYPE_ICMP;
    req.has_interval = 1;
    req.interval = 10;

    len = request__get_packed_size(&req);
    fprintf(stdout, "Packed size is %lu\n", len);
    assert(len);
    buffer = (unsigned char *)malloc(len);
    if (buffer == NULL) {
        fprintf(stderr, "Out of memory\n");
        return -1;
    }
    request__pack(&req, buffer);

    request = request__unpack(NULL, len, buffer);
    if (request == NULL) {
        fprintf(stderr, "error unpacking incoming message\n");
        return -1;
    }

    printf("   hostname = %s\n", request->hostname);
    printf("   protocol = %d\n", request->protocol);
    if (request->has_interval)
        printf("   interval = %d\n", request->interval);

    request__free_unpacked(request, NULL);
    free(buffer);

    return 0;
}

Makefile 编译。

OUTDIR=proto-out
PROTOFILE=request

all: pre example

pre:
        mkdir -p ${OUTDIR}

example: example.c
        protoc-c --c_out=${OUTDIR} ${PROTOFILE}.proto
        gcc -Wall -I${OUTDIR} ${OUTDIR}/${PROTOFILE}.pb-c.c $^ -o $@ -lprotobuf-c

clean:
        rm -rf ${OUTDIR} example

版本 V3

V2 和 V3 的改动包括了:

  1. 只保留 repeated 来标记数组类型,而 optional 和 required 都被去掉了;
  2. 删除 default,当序列化和反序列化端有不同的默认值时,会导致最终的结果不一致;

另外,在 V3 的版本其中有个 oneof 特性,而 protobuf-c 的实现比较坑,没有提供对应的 API 接口,需要进行手动设置。例如:

message Request {
	oneof addr {
		string state = 1;
		string contry = 2;
	};
};

那么在进行序列化时,需要通过如下方式设置。

Request req = REQUEST__INIT;
req.addr_case = REQUEST__ADDR_STATE;
req.state = "china";

而在进行反序列化时,可以通过 switch 进行判断,例如:

switch (request->addr_case) {
case REQUEST__ADDR_STATE:
	printf("Got state: %s\n", request->state);
	break;
case REQUEST__ADDR__NOT_SET:
default:
	break;
}

插件实现

Google 的 Protocol Buffer 是一种以二进制格式对消息进行编码和解码的库,目前支持大部分的语言,如果要实现其它语言,需要先实现一个 protoc 编译器的插件。

CentOS 可通过 yum install protobuf protobuf-compiler 安装解析器,然后通过如下方式获取相应分支的源码:

$ git clone https://github.com/google/protobuf
$ git checkout v2.5.0

Python 插件

这里使用如下的一个 proto 文件 hello.proto

enum Greeting {
	NONE = 0;
	MR = 1;
	MRS = 2;
	MISS = 3;
}
message Hello {
	required Greeting greeting = 1;
	required string name = 2;
}

编译器 protoc 的接口非常简单,编译器将在 stdin 上传递一个 CodeGeneratorRequest 消息,你的插件将在 stdout 的 CodeGeneratorResponse 中输出生成的代码。

第一步是编写读取请求的代码并写一个空的响应:

#!/usr/bin/env python

import sys
from google.protobuf.compiler import plugin_pb2 as plugin

def generate_code(request, response):
    pass

if __name__ == '__main__':
    # Read request message from stdin
    data = sys.stdin.read()
    # Parse request
    request = plugin.CodeGeneratorRequest()
    request.ParseFromString(data)
    # Create response
    response = plugin.CodeGeneratorResponse()
    # Generate code
    generate_code(request, response)
    # Serialise response message
    output = response.SerializeToString()
    # Write to stdout
    sys.stdout.write(output)

核心部分是从 stdin 读取 protoc 请求的接口代码,遍历 AST 并在 stdout 上写入响应。