Protobuf入门(大白话版)

5,834 阅读8分钟

嗯,又遇到新东西了, 再来认识下Protocol Buffer,用大白话总结最干的东西。

老规矩,最好的都是原汁原味。

开发指南--proto3

大佬译文1

大佬译文2

ProtoBuf

是什么

Protocol Buffer是Google提供的一种结构化数据的序列化协议.

大白话: protobuf是用来定义消息/结构体(message)和服务/方法(service)的.

Protocol Buffers 是一种轻便高效的结构化数据存储格式,可以用于结构化数据的序列化,很适合做数据存储或 RPC 数据交换格式。它可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。 官网链接

大体了解下怎么用

Protobuf 包含序列化格式的定义、各种语言的库以及一个IDL编译器。正常情况下你需要

  • step1:写proto文件, 定义 结构体message 和 方法service.
  • step2:然后使用IDL编译器编译成你需要的语言(本文是go)。

基础规范需要了解一下

  • 文件以.proto作为文件后缀,除结构定义之外的语句以分号结尾.
  • message 结构体命名采用驼峰命名方式,字段命名采用小写字母加下划线分隔方式.
  • enums 类型名采用驼峰命名方式,字段命名采用大写字母加下划线分隔方式.
  • servicerpc方法名统一采用驼峰式命名.

定义message

syntax = "proto3"; // 指定proto的版本,默认proto2

package product.subsystem.module // 定义包名(import path),防止message重名

// 定义message(可以定义多个消息类型)
message SearchRequest {
  string query = 1; // 定义字段: type fieldName "=" fieldNumber; 
  int32 page_number = 2;
  int32 result_per_page = 3;
}

解释一下上面的代码的要点:

要点一: 字段的fieldNumber

string query = 1; 这里的1(fieldNumber),不是字段query(fieldName)的值, 而是字段queryTag.(即: 在编码后1 就代表了 query这个字段).

换句话说,其实字段叫啥名在protobuf中并不重要, 因为在传输的时候,二进制里面用的是fieldNumber而不是fieldName.

NOTE: fieldNumber一旦被使用, 终生这个编号都不要改变

fieldNumber 的取值范围是1~2^29-1.

而常用的fieldNumber范围是: 1-15(只用1个byte编码), 16-2047(采用2个byte编码).

所以为了节省编码后的长度, 经常使用的一些字段名(如:name, id等), 分配1-15的fieldNumber.

要点二: 字段的定义规则

protobuf的字段定义,必须满足两个规则之一:

singular单数字段: protobuf的默认字段规则, 就是说这个字段只能出现0或者1次.

repeated重复字段: 说的复杂, 其实就是该字段是一个数组或者list. 数组里面可以有任意数量的元素. 如果有多个元素, 元素的顺序会被保留.

message Person {
    int32 id = 1; 
    string name = 2;
    
    repeated string phone_number = 3; // 一个人会有多个电话
}

protobuf的字段类型type和各语言的对应关系,参见简单类型 Scalar Value Types

要点三: 保留字段

保留字段的意思就是, 这些字段保留下来, 后续在protobuf中,不能再次使用了.(即: 防止字段名一样, 但是字段含义不同)

syntax = "proto3";

message Person {
	reserved 2, 3 to 7; 	// 保留这几个fieldNumber
    reserved "foo", "bar"; 	// 保留这几个字段名
}

举个例子解释下为啥要保留字段, 大白话会啰嗦, 但一看就懂

syntax = "proto3";

// 一开始的需求, UserInfo绑定的是微信的账号和密码
message UserInfo {
	int32 Id = 1;
    stirng name = 2;
    
    string wechat_account = 3;
    string wechat_pwd = 4;
}

// 现在需求变了,要求用户信息绑定QQ账号密码
// 此时我删除了 wechat_account wechat_pwd两个字段, 并添加QQ_account, QQ_pwd
// 同时, 之前分配给wechat_account和wechat_pwd的fieldNumber 3 4, 又再一次分配给了 QQ_account和QQ_pwd.
message UserInfo {
	int32 Id = 1;
    stirng name = 2;
    
    string QQ_account = 3;
    string QQ_pwd = 4;
}

// 想想会有什么问题?
// 别想了,我直接说了, 假如server端修改了protobuf的定义,但是client端还没有更新.
// 此时, 客户端传给server微信的账号/密码, 服务端作为QQ的账号密码去验证,肯定是错的.

// 所以呢? 所以修改(如删掉)的字段和对应的fieldNumber都应该保留, 后续都不能在使用了.

要点四: enum 枚举类型

枚举可以定义在message里面,也可以定义在外面(便于复用)

在另一个message类型中,可以通过UserInfo.Gender, 使用枚举类型.

reserved同样也可以适用于枚举类型.

message UserInfo {

	Gender gender = 1; // 使用Gender枚举类型
    
    // 定义枚举类型
	enum Gender {
    	FEMAIL = 0; // 必须从0开始
        MAIL = 1;
    }
}

要点五: message类型,也可以作为字段的类型

syntax = "proto3"; 

message Person {
    int32 id = 1; 
    string name = 2;

    Date birthday = 3; // 使用message类型作为字段的type
}

// 定义消息类型Date:生日
message Date {
    int32 year = 1;
    int32 mounth = 2;
    int32 day = 3;
}

一般来说,不相关的消息, 每个message,创建一个proto文件.

如果需要用到其他.proto文件中定义的message, 要通过import进行引入.

编译器会在--proto_path参数指定的路径下寻找相应的需要导入的proto文件. 不写默认在当前目录寻找.

要点六: package

给一个.proto文件指定package, 是为了避免和其他的.proto文件的message名称冲突.

// bar.proto
package foo.bar;
message Open { ... }

后面可以使用该.proto文件的包名去使用message Open

// foo.proto

import "bar.proto"
message Foo {
  ...
  foo.bar.Open open = 1;
  ...
}

当被IDL编译器翻译成GO语言后, Go代码的包名, 默认就是.proto文件的pacakge名称, 除非在.proto文件中显示的用go_pacakge指定IDL编译后的Go文件的import path.

要点七: import的搜寻路径是什么?

搜寻路径由protoc -I或者protoc --proto_path指定. 所以, import 要和 protoc -I/--proto_path 命令配合好.

定义service

// 请求结构体
message SearchRequest {
	....
}

// 相应结构体
message SearchResponse {
	....
}

// 服务api
service SearchApi {
	rpc search(SearchRequest) returns (SearchResponse);
}

IDL编译器,生成(go)代码

安装IDL编译器

各个公司可能会有自己的IDL编译器。(基本上都会依赖protoc,所以要先安装protoc 和 protoc-gen-go

官网(推荐)--安装protoc 和 protoc-gen-go

NOTE: 如果你没有下载protoc-gen-go包,

则文中的命令go install google.golang.org/protobuf/cmd/protoc-gen-go不适用,

需要采用如下的命令:go get google.golang.org/protobuf/cmd/protoc-gen-go

非官网--golang版

生成代码命令

安装完后,就可以编译.proto文件了

IDL编译器,生成代码:protoc -I . -I /usr/local/include -I $(GOPATH)/src --go_out=. simple.proto

-I 或 --proto_path: 		指定import搜索的文件夹路径
--go_out 		  :	 	 编译产生的 go代码 的生成位置

上面的命令的含义是:

  • 根据simple.proto去生成go代码
  • go代码的生成目录为 .
  • simple.proto导入的.proto文件, 去这些目录下下面搜索:
    • .
    • /usr/local/include
    • $(GOPATH)/src

代码生成规则

官网

  • 每个xxx.proto文件, 生成一个xxx.pb.go文件.

  • Go文件的package的生成规则: 分为三种情况

    • case1: .proto文件不包含package

      如果一个.proto文件中不包含package声明,生成的源代码将会使用.proto文件的文件名(去掉扩展名)作为Go包名,

      其中, 生成的Go的包名中会自动把.转换为_

      举例来说一个名为high.score.proto不包含package声明的proto文件, 将会生成文件high.score.pb.go,他的Go包名是high_score

    • case2: .proto文件包含package

      经过编译的Go代码的包名默认使用proto文件的package名称, 除非在.proto文件中使用option go_package显示进行指定Go文件的import path.

      其中, 生成的Go的包名中会自动把.转换为_

      举例来说proto包名example.high_score将会生成Go包名example_high_score

    • case3: .proto文件包含package, 同时包含option go_package

      option go_package 的作用是去指定生成的go文件的package的完整导入路径(import path)的, 如:

      // foo.proto
      option go_package = "example.com/foo/bar"; // 生成的foo.pb.go文件的package的import path就是 "example.com/foo/bar"
      

      并且Go文件的package的包名是import path的最后一部分.

      例如: 如果.proto文件是如上的定义, 则protoc --go_out build/gen foo.proto则有:

      • 生成的go文件的package的完整导入路径import pathexample.com/foo/bar.
      • 生成的go文件的package的包名是bar
      • 生成的go文件所在的路径是build/gen/example.com/foo/bar/foo.pb.go
  • 生成Go文件所在的目录:

    由两部分组成: output_directory/sub_directory/

    • 输出目录: output_directory
    • 输出目录子目录: sub_directory

    output_directoryprotoc --go_out参数指定, 注意这个目录必须事先存在!!

    sub_directory由 编译器参数--go_opt 和 proto文件的go_package共同决定, sub_directory可以编译器自己创建.

    假设我们有一个test.proto文件

    // 文件 test.proto
    syntax = "proto3";
    
    package = "test";
    option go_package = "example.com/mytest/test";
    
    
    • case1(默认情况): 命令 protoc --proto_path=. --go_out=build/gen test.proto 按照生成的 go 代码的包的完整导入路径(import path)去创建目录层级

      则:

      output_directorybuild/gen/

      sub_directory 是go文件的import path : example.com/mytest/test/

      即: go文件所在的路径是 build/gen/example.com/mytest/test/test.pb.go

    • case2: 命令protoc --proto_path=. --go_out=build/gen --go_opt=paths=source_relative test.proto 按照 proto 源文件的相对路径去创建 go 代码的目录层级

    这里的相对路径是指: 相对--proto_path指定的路径.

    `output_directory``build/gen/` 
    
    `sub_directory``test.proto`的相对路径(这里就是`./`)
    
    生成的go文件路径是: `build/gen/test.pb.go` (和`test.proto`文件同级目录)
    
  • message字段: message字段名用小写+下划线,转为 go 文件后

    • 首字母被大写: 表示可导出
    • _开头, 则_被替换为X
    • 如果在内部, 遇到_[a-zA-Z]的形式, 则内部的下划线被去除, 并且下划线后的字母被大写.

    举例:

    message Test {
        string test_field = 1;
        string _my_field_name_2 = 2
    }
    

    转换后

    type Test struct {
        TestField string // test_field ==> TestField
        XMyFieldName_2 string // _my_field_name_2 ==> XMyFieldName_2
        
    } 
    

项目

这里推荐一篇文章, 方便加深对以下内容的理解.

Protobuf 的 import 功能在 Go 项目中的实践

  • import
  • .proto文件的packagego_pacakge
  • protoc命令的进一步理解

参考链接

protobuf-ultimate-tutorial-in-go

Protobuf-language-guid

官网--Protocol Buffer Basics: Go

系统调优--编码方式