ProtoBuf详解(一)概念和语法

关于ProtoBuf

ProtoBuf 是谷歌开源的一套与语言无关,平台无关,可扩展性强,兼容性好并且效率很高的数据序列化方法,非常适合用于做二进制数据的通信协议和数据存储。这里可以访问官方文档。

很多人都喜欢将 ProtoBuf 和 XML 和 JSON 这两个常用于数据格式做比较,但并不合适,这两者完全就是两个侧重点,其中 ProtoBuf 是二进制协议,二后两者为文本协议。

首先 ProtoBuf 的突出点是在二进制方面,主要用于做二进制协议,而后两者都是突出字符串文本的可读性上,主要用来做文本协议,而且纯文本协议很难单独作为协议格式,一般是作为某个协议的一部分出现。效率上当然是 ProtoBuf 快的不是一点,其次 ProtoBuf 突出的是网络通信协议和数据的序列化,而 XML 虽然可用于通信的协议,但是其占用空间大,解析效率低,虽然人为可读性不错,但是做网络传输难免有些差劲,很多人喜欢用 XML 来做配置文件。其次是 JSON 了,JSON 同样不能单独作为协议,而是作为协议的一部分,如 HTTP 协议中应用非常广泛,做过 JAVA 后端的应该对 JSON 不能再熟悉了,相比于 XML ,JSON确实是在有比较好的可读性上并缩小了空间的占用,但是有一个致命的弱点就是传输二进制的时候非常鸡肋,需要将二进制使用 Base64 算法(参考另一篇文章)先将二进制先转换为文本字符串,我们知道 Base64 转换后空间占用会增加,而且对方得到数据后,还需要反解析数据,所以用 JSON 传输二进制同时增加了空间开销和时间开销,但其优点就是其应用广泛,解析工具非常丰富,如果只用来表示文本格式或少量二进制数据,是比较好的选择。最后再来说一下 ProtoBuf,开始就说过,它非常适合做传输二进制的通信协议,同时作为序列化工具,它的空间占用相当小且效率高,相比 JSON,其时间和空间上的效率都是要快上好几倍,非常适合游戏相关项目使用。

总结 ProtoBuf 特点如下:

  1. 与平台无关,且支持语言广泛,包括 C++、Golang、Java、C#、Lua、Python 等。
  2. 序列化和反序列化速度快,且数据体积小,很适合做通信协议和数据序列化存储等。
  3. 其 Proto 语法灵活简洁,容易上手,并且提供的接口丰富,使用非常便利。
  4. 扩展性强,兼容性好,在更新数据结构后,不影响或破坏原有的数据结构。

安装ProtoBuf

ProtoBuf 已经在 Github 开源,我们可以访问这里查看。

这里我使用的环境是 WSL(Ubuntu 20.04),下载其源代码并安装的过程如下:

sudo apt-get install autoconf automake libtool curl make g++ unzip
git clone https://github.com/google/protobuf.git
cd protobuf
git submodule update --init --recursive # 如果失败,可以跳过这一步
./autogen.sh
./configure
make -j8 # 指定核心数编译
make check
sudo make install
sudo ldconfig # 刷新共享库缓存
protoc --version # 完成后查看一下版本

protoc工具

概要的说,protoc 工具相当于一个编译器,是将定义的 .proto 转换成选择的语言的代码,除此之外还会检测 .proto 文件中是否有 proto 的语法错误。

首先暂时先不看 ProtoBuf 语法,我们直接将创建一个简单的 hello.proto 文件,内容如下:

syntax = "proto3";
package hello;

message UserInfo
{
    uint32 id = 1;
    string name = 2;
    uint32 age = 3;
}

.proto 文件中是定义 proto 消息的文件(注意:这篇文章中为了通俗,所提到的 proto 消息就是我们在 proto 文件中定义的数据结构),而我们实际使用中需要将其转换成不同平台的源代码,ProtoBuf 提供了一个 protoc 工具,一般 Linux 下安装都会有一个 protoc 的工具,如果我们不清楚该怎么使用,可以输入 protoc -h 查看 protoc 的帮助文档:

$ protoc -h
Usage: protoc [OPTION] PROTO_FILES
Parse PROTO_FILES and generate output based on the options given:
  -IPATH, --proto_path=PATH   Specify the directory in which to search for
                              imports.  May be specified multiple times;
                              directories will be searched in order.  If not
                              given, the current working directory is used.
                              If not found in any of the these directories,
                              the --descriptor_set_in descriptors will be
                              checked for required proto file.
  --version                   Show version info and exit.
  -h, --help                  Show this text and exit.
  --encode=MESSAGE_TYPE       Read a text-format message of the given type
                              from standard input and write it in binary
                              to standard output.  The message type must
                              be defined in PROTO_FILES or their imports.
  --deterministic_output      When using --encode, ensure map fields are
                              deterministically ordered. Note that this order
                              is not canonical, and changes across builds or
                              releases of protoc.
  --decode=MESSAGE_TYPE       Read a binary message of the given type from
                              standard input and write it in text format
                              to standard output.  The message type must
                              be defined in PROTO_FILES or their imports.
  --decode_raw                Read an arbitrary protocol message from
                              standard input and write the raw tag/value
                              pairs in text format to standard output.  No
                              PROTO_FILES should be given when using this
                              flag.
  --descriptor_set_in=FILES   Specifies a delimited list of FILES
                              each containing a FileDescriptorSet (a
                              protocol buffer defined in descriptor.proto).
                              The FileDescriptor for each of the PROTO_FILES
                              provided will be loaded from these
                              FileDescriptorSets. If a FileDescriptor
                              appears multiple times, the first occurrence
                              will be used.
  -oFILE,                     Writes a FileDescriptorSet (a protocol buffer,
    --descriptor_set_out=FILE defined in descriptor.proto) containing all of
                              the input files to FILE.
  --include_imports           When using --descriptor_set_out, also include
                              all dependencies of the input files in the
                              set, so that the set is self-contained.
  --include_source_info       When using --descriptor_set_out, do not strip
                              SourceCodeInfo from the FileDescriptorProto.
                              This results in vastly larger descriptors that
                              include information about the original
                              location of each decl in the source file as
                              well as surrounding comments.
  --dependency_out=FILE       Write a dependency output file in the format
                              expected by make. This writes the transitive
                              set of input file paths to FILE
  --error_format=FORMAT       Set the format in which to print errors.
                              FORMAT may be 'gcc' (the default) or 'msvs'
                              (Microsoft Visual Studio format).
  --fatal_warnings            Make warnings be fatal (similar to -Werr in
                              gcc). This flag will make protoc return
                              with a non-zero exit code if any warnings
                              are generated.
  --print_free_field_numbers  Print the free field numbers of the messages
                              defined in the given proto files. Groups share
                              the same field number space with the parent
                              message. Extension ranges are counted as
                              occupied fields numbers.
  --plugin=EXECUTABLE         Specifies a plugin executable to use.
                              Normally, protoc searches the PATH for
                              plugins, but you may specify additional
                              executables not in the path using this flag.
                              Additionally, EXECUTABLE may be of the form
                              NAME=PATH, in which case the given plugin name
                              is mapped to the given executable even if
                              the executable's own name differs.
  --cpp_out=OUT_DIR           Generate C++ header and source.
  --csharp_out=OUT_DIR        Generate C# source file.
  --java_out=OUT_DIR          Generate Java source file.
  --js_out=OUT_DIR            Generate JavaScript source.
  --kotlin_out=OUT_DIR        Generate Kotlin file.
  --objc_out=OUT_DIR          Generate Objective-C header and source.
  --php_out=OUT_DIR           Generate PHP source file.
  --python_out=OUT_DIR        Generate Python source file.
  --ruby_out=OUT_DIR          Generate Ruby source file.
  @                 Read options and filenames from file. If a
                              relative file path is specified, the file
                              will be searched in the working directory.
                              The --proto_path option will not affect how
                              this argument file is searched. Content of
                              the file will be expanded in the position of
                              @ as in the argument list. Note
                              that shell expansion is not applied to the
                              content of the file (i.e., you cannot use
                              quotes, wildcards, escapes, commands, etc.).
                              Each line corresponds to a single argument,
                              even if it contains spaces.

这里我使用 protoc 工具将 proto 文件输出为 C++ 的代码,其命令如下:

$ protoc --cpp_out=./ hello.proto

--cpp_out 指定了输出语言为 C++,./ 表示生成的文件就在当前目录下,hello.proto 就是要生成 C++ 的 proto 文件,生成其他语言只要换个选项即可,然后我们发现当前目录下多了两个文件:

$ ls
hello.pb.cc  hello.pb.h  hello.proto

一个 hello.pb.h 头文件和一个 hello.pb.cc 源文件,其中生成的 C++ 文件名和 proto 的文件名保持一致,本例都是 hello,后面的 .pb.h 和 .pb.cc 就表示是使用 protobuf 文件生成的头文件和源文件,这样看起来也很直观。

我们先简要的看一下生成的头文件和源文件,大概生成的 C++ 头文件格式如下:

namespace hello
{
    class UserInfo final : public ::PROTOBUF_NAMESPACE_ID::Message
    {
    public:
        inline UserInfo() : UserInfo(nullptr) {}
        ~UserInfo() override;
        //其他方法...
    public:
        const std::string &name() const;
        template 
        void set_name(ArgT0 &&arg0, ArgT... args);
        //其他方法...
    public:
        ::PROTOBUF_NAMESPACE_ID::uint32 id() const;
        void set_id(::PROTOBUF_NAMESPACE_ID::uint32 value);
        //其他方法...
    public:
        ::PROTOBUF_NAMESPACE_ID::uint32 age() const;
        void set_age(::PROTOBUF_NAMESPACE_ID::uint32 value);
        //其他方法...
    };
}

只选择了部分代码,至少我们可以看到生成的 C++ 头文件代码中包含了对我们 proto 文件中定义的字段设置值和获取值的函数。还有 proto 中的包名就是生成的 C++ 代码中的作用域,聪明的你应该已经看出其中的一些奥秘了。

protoc 编译器会以你选择的语言生成相应的代码,不同的代码其后缀的文件也不一样,且生成的文件也不一样。每种语言都有自己的 API。想要了解更多请阅读 API 参考。

第一个简单实例

通过上面讲述,我们已经有了一定的了解,现在继续通过 hello.proto 文件来查看一个非常简单的实例:

#include 
#include 
#include 
#include "hello.pb.h"

int main(int argc, char **argv)
{
    hello::UserInfo userinfo;
    userinfo.set_id(10001);
    userinfo.set_name("codepeak");
    userinfo.set_age(18);
    //序列化
    size_t buf_size = userinfo.ByteSize();
    char *serialize_buf = new char(buf_size);
    userinfo.SerializeToArray(serialize_buf, buf_size);
    //反序列化
    hello::UserInfo userinfo_2;
    userinfo_2.ParseFromArray(serialize_buf, buf_size);
    std::cout << userinfo_2.id() << " " << userinfo_2.name() << " " << userinfo_2.age() << std::endl;

    google::protobuf::ShutdownProtobufLibrary();
    return 0;
}

这个实例中,我们定义了一个 userinfo 的 proto 消息,并为每个字段设置上了值,然后将其序列化到一个缓冲区中,又定义了另一个相同类型的消息对象,并从该缓冲区中反序列化得到数据,并输出。

这里使用如下命令编译:

$ g++ test.cpp hello.pb.cc -o testproto -lprotobuf -std=c++11 -lpthread

我们看到后面不仅链接了 -protobuf,还链接了 -lpthread ,虽然你的程序中并没有用到多线程,但是如果不加这个选项,运行时可能会崩溃,加上后就可以正常运行,应该是 ProtoBuf 中用到了。

如果出现了如下错误:

error while loading shared libraries: libprotobuf.so.22: cannot open shared object file: No such file or directory

方法一:执行 export LD_LIBRARY_PATH=/usr/local/lib

方法二:root 权限下编辑 /etc/ld.so.conf 中加入 /usr/local/lib 这一行,保存之后,再运行 ldconfig 更新一下配置即可。

最终,该程序的输出结果如下:

$ ./testproto 
10001 codepeak 18

可以看到数据成功的反序列化,到这里我们仍然没有开始讲述任何 proto 语法相关的内容,但是相信你应该已经打开了 ProtoBuf 的大门。

ProtoBuf3语法

官方语法:https://developers.google.com/protocol-buffers/docs/overview

在这里只介绍 proto3 的语法,因为 proto2 中的某些语法因为不符合 proto 的设计初衷在 proto3 中被去除,下面将逐个介绍。

定义消息

假设你要定义一个用户信息的消息 UserInfo,消息中需要包含用户 ID,用户名,性别,年龄。那么你需要在 .proto 文件中定义的消息如下所示:

syntax = "proto3";
message UserInfo
{
    uint64 id = 1;
    string name = 2;
    int32 sex = 3;
    int32 age = 4;
}

这个实例中,syntax = "proto3"; 表示你指定的使用 proto3 的语法,如果不显示指定,将会认为你使用 proto2 语法,这也是为了做向前兼容处理,其次如果已经指定了 proto3 的语法,如果在其中使用了 proto2 的语法,也无法被编译通过(这里的编译指的是从 proto 转向对应语言的代码)。

字段类型

而 message 后面跟的 UserInfo 是需要定义的消息名,其中有四个字段,每个字段都有一个类型和一个字段名。在这个例子中,所有的字段都是 Protobuf 标准中定义的类型,我们也可以为某个字段指定复合类型,也就是该字段的类型是自定义的另一个消息的类型。

字段编号

消息的定义中每个字段都有一个唯一的编号。这些字段编号用于在序列化后的二进制中标识您的字段,一旦某个编号已经被某个字段使用,就不应该再去更改。关于编号,也有需要注意的地方:1~15 编号范围内的字段占用一个字节进行编码,包括字段编号和字段类型(具体参考 ProtoBuf Encoding);16~2047 编号范围内的字段占用两个字节。因此你应该为非常频繁出现的消息字段保留 1~15 的编号,也就是为将来可能添加的而且是频繁出现的字段保留编号。

可指定的最小字段编号为 1,最大字段编号为 2^29 -1 (536 870 911),但是不能使用数字 19000 ~ 19999(也就是proto 中的 FieldDescriptor::kFirstReservedNumber ~ FieldDescriptor::kLastReservedNumber),因为这些编号是为 ProtoBuf 的实现保留的(如果你在 .proto 中使用了这些保留的数字,编译器将会报错),类似你也不能使用任何先前已保留的字段编号。

字段规则

消息的字段可以是如下两种:

单数:默认的消息就是单数形式的,也就是一个字段中存储一个值(在 proto2 中也就是 optional)。

repeated:相当编程语言中的数组,repeated 字段的值可以叠加多个值,而且这多个值的叠加的顺序将被保留。在 proto3 中,repeated 修饰的 ProtoBuf 内置数字类型的字段将默认采用 packed 编码(这里参考 packed encoding)

定义多个消息类型

上面仅仅在 .proto 中定义了一个消息类型,如果需要定义多个消息,就可以在其中定义多个 message 的消息。比如:有个请求用户信息和返回用户信息的消息,你可以将其添加到同一个 .proto 文件中,例如:

message ReqUserInfo
{
    uint64 id = 1;
}

message RspUserInfo
{
    uint64 id = 1;
    string name = 2;
    int32 sex = 3;
    int32 age = 4;
}

内容注释

如果想要在 .proto 文件中添加注释,请使用 C/C++ 的样式,也就是 ///* */。例如为上面的消息添加注释如下:

/*
    下面定义的是用户信息相关的消息
*/

//请求用户信息
message ReqUserInfo
{
    uint64 id = 1;  //请求的用户id
}

//返回用户信息
message RspUserInfo
{
    uint64 id = 1; //返回的用户id
    string name = 2; //用户名
    int32 sex = 3;  //性别
    int32 age = 4;  //年龄
}

reserved 保留字段

如果你通过完全删除一个字段来更新一个消息类型,或者把它注释掉,那么未来的用户在对该类型进行自己的更新时可以重复使用这个字段号。如果他们后来加载同一 .proto 的旧版本,这可能会导致严重的问题,包括数据损坏、隐私错误等等。确保不会发生这种情况的一种方法是指定已删除的字段编号(或名称)为 reserved(但可能导致 JSON 序列化问题)。如果未来任何用户想尝试使用这些字段标识符,ProtoBuf 编译器将会报错。也就是说,使用 reserved 指定的字段号或者字段名将不能被之后的新添加的字段继续使用,如果使用了,也不会通过编译,因为 ProtoBuf 的字段号关系到编码,所以使用了一个之前已经使用过的编号是非常危险的,而且也禁止这样做。保留字段的实例如下:

message UserInfo
{
    reserved 1, 2, 3 to 4;
    reserved "id", "name";
}

需要注意:不能在同一个 reserved 语句中混合使用字段名称和字段编号。

proto 的字段类型

proto 语法中的字段类型又称为 Scalar Value Types ,翻译过来为:标量值类型。

一个标量消息字段有自己的类型,如下给出了 .proto 文件中指定的类型,以及生成不同语言的相应类型:

类型 说明 C++ Java/Kotlin[1] Python[3] Go Ruby C# PHP Dart
double double double float float64 Float double float double
float float float float float32 Float float float double
int32 使用可变长度编码,对负数编码效率低(如果你的字段可能有负数,请使用 sint32 代替)。 int32 int int int32 Fixnum or Bignum (as required) int integer int
int64 使用可变长度编码,对负数编码效率低(如果你的字段可能有负数,请使用 sint64 代替)。 int64 long int/long[4] int64 Bignum long integer/string[6] Int64
uint32 使用可变长度编码。 uint32 int[2] int/long[4] uint32 Fixnum or Bignum (as required) uint integer int
uint64 使用可变长度编码。 uint64 long[2] int/long[4] uint64 Bignum ulong integer/string[6] Int64
sint32 使用可变长度编码。带符号的整数值。与常规的 int32 相比,它们对负数的编码效率更高。 int32 int int int32 Fixnum or Bignum (as required) int integer int
sint64 使用可变长度编码。带符号的整数值。与常规的 int64 相比,它们对负数的编码效率更高。 int64 long int/long[4] int64 Bignum long integer/string[6] Int64
fixed32 总是四个字节。如果值经常大于2^28,则效率比 uint32 更高。 uint32 int[2] int/long[4] uint32 Fixnum or Bignum (as required) uint integer int
fixed64 总是八个字节。如果值经常大于 2^56,则效率比 uint64 更高。 uint64 long[2] int/long[4] uint64 Bignum ulong integer/string[6] Int64
sfixed32 总是四个字节。 int32 int int int32 Fixnum or Bignum (as required) int integer int
sfixed64 总是八个字节。 int64 long int/long[4] int64 Bignum long integer/string[6] Int64
bool bool boolean bool bool TrueClass/FalseClass bool boolean bool
string 字符串必须始终包含 UTF-8 编码或 7 位 ASCII 文本,并且长度不能超过 2^32。 string String str/unicode[5] string String (UTF-8) string string String
bytes 可以包含任何不超过 2^32 的任意字节序列。 string ByteString str []byte String (ASCII-8BIT) ByteString string List

参考 ProtoBuf Encoding 就可以找到更多关于这些类型在你序列化消息时如何编码的信息。

上面的引用说明如下:

[1] Kotlin 使用 Java 中的相应类型,甚至是无符号类型,以确保 Java/Kotlin 混合代码库的兼容性。

[2] 在 Java 中,无符号的 32 位和 64 位整数使用它们的有符号的对应类型来表示,最上面的位只是被存储在符号位上。

[3] 在所有情况下,向一个字段设置值将执行类型检查,以确保它是有效的。

[4] 64 位或无符号的 32 位整数在解码时总是表示为 long,但如果在设置字段时给出 int,则可以是 int。在所有情况下,值必须符合设置时代表的类型。参见 [2]。

[5] Python 字符串在解码时表示为 unicode,但如果给定的是 ASCII 字符串,则可以是 str (这可能会有变化)。

[6] 在 64 位机器上使用 Integer,在 32 位机器上使用 string。

默认值

在 proto2 中,提供了 default 选项,可以为某一字段指定值,proto3 中移除了 default 选项的语法,默认值只能根据字段的类型系统决定,也就是说默认值全部是约定好的。当消息被解析时,如果编码后的消息不包含一个特定的单数,解析对象中相应字段就会被设置为该字段的默认值,其默认值的类型如下:

类型 默认值
字符串 空字符串
字节 空字节
bool false
数字类型 0
枚举 第一个定义的枚举值,必须是0
消息字段 该字段不被设置,确切值与具体语言有关。
repeated字段 默认是空,具体的语言一般是空列表

注意:对于标量消息字段,一旦消息被解析,就没有办法知道一个字段是否被明确地设置为默认值(例如,一个布尔值是否被设置为false)或只是根本没有设置,在定义你的消息类型时,你应该记住这一点。例如,如果你不希望某些行为在默认情况下发生,就不要让一个布尔值在设置为 false 时开启某些行为。还要注意的是,如果一个标量消息字段被设置为默认值,那么该值将不会被序列化。

关于默认值在生成的代码中如何工作的更多细节,请参考你所选语言的生成代码指南。

enum 枚举

当你定义一个消息类型时,你可能希望它的一个字段只具有预定义的值的列表中的一个。例如你想给每个 UserInfo 添加一个用户类型字段,用户类型可以是 NORMAL(普通用户)、PAYING(付费用户)、VIP(VIP 用户)等。你可以通过在你的 .proto 文件中添加一个枚举定义,并为每个可能的值添加一个常数来做到这一点。

下面我们添加一个 UserType的枚举,包含了一些枚举值,以及一个 UserType 类型的字段:

message RspUserInfo
{
    uint64 id = 1;
    string name = 2;
    int32 sex = 3;
    int32 age = 4;

    enum UserType //用户类型枚举
    {
        NORMAL = 0;
        PAYING = 1;
        VIP = 2;
    }
    UserType = 5; //用户类型
}

proto3 中枚举的第一个常量必须是 0,且每个枚举定义必须包含一个映射为 0 的常量作为其第一个元素,因为:

  1. 必须有一个零值,我们才可以使用 0 作为数字默认值。
  2. 零值必须是第一个元素。

你可以通过给不同的枚举常量分配相同的值来定义别名。要做到这一点,需要将 allow_alias 选项设置为 true,否则当发现了别名时,proto 编译器就会报错。如下枚举:

message MyMessage1
{
    enum EnumAllowingAlias
    {
        option allow_alias = true; //指定该选项后,枚举值常量就可以重复
        UNKNOWN = 0;
        STARTED = 1;
        RUNNING = 1; //不会报错,因为是STARTED的别名
    }
}

枚举量必须在 32 位整数的范围内。由于枚举值使用 varint 编码,负值的效率很低,因此不推荐使用。你可以在一个消息定义中定义枚举,就像上面的例子一样,也可以在外面定义,这些枚举可以在你的 .proto 文件中的任何消息定义中重复使用。你也可以使用语法 _MessageType_._EnumType_,将一条消息中声明的枚举类型作为另一条消息中字段的类型。

当你在使用枚举的 .proto 上运行 protoc 编译器时,生成的代码对于Java、Kotlin 或 C++ 将有一个相应的枚举,用于在运行时生成的类中创建一组具有整数值的符号常数。

注意:生成的代码可能会受到特定语言对枚举器数量的限制。请查看你使用的语言的限制。

在反序列化过程中,未被识别的枚举值将被保留在消息中,尽管当消息被反序列化时如何表示是取决于语言的。在支持开放枚举类型的语言中,其值超出了指定的符号范围,如 C++ 和 Go,未知的枚举值被简单地存储为其基础的整数表示。在具有封闭枚举类型的语言中,如 Java,枚举中的一个枚举值被用来表示一个未被识别的值,而底层的整数可以用特殊的访问器来访问。在这两种情况下,如果消息被序列化,未被识别的值仍将与消息一起被序列化。

关于如何在你的应用程序中使用消息枚举的更多信息,请参阅你所选择的语言的生成代码指南。

reserved枚举保留值

和消息字段的保留类似,枚举也可以定义保留值,如果你已经定义了当前枚举的枚举名和枚举值,将来有任何用户识图使用这些标识符,protoc 编译器将会报错,你可以使用 max 关键字指定你的保留数值范围,直到可能的最大值。如下实例:

enum Foo
{
    reserved 2, 15, 9 to 11, 40 to max;
    reserved "FOO", "BAR";
}

和字段保留一样,不能再同一个保留语句中混合使用字段名称和数值。

使用自定义的消息类型

你可以使用自己定义的消息类型作为字段类型。例如,假设你想在每个 SearchResponse 消息中包含结果消息,要做到这一点,你可以在同一个 .proto 中定义一个结果消息类型,然后在 SearchResponse 中指定一个结果类型的字段。

message SearchResponse
{
    repeated Result results = 1; //自定义的消息类型
}
  
message Result
{
    string url = 1;
    string title = 2;
    repeated string snippets = 3;
}

导入定义的proto消息

注意:这个功能在 java 中是不用的。

上面的例子中,Result 消息类型与 SearchResponse 定义在同一个文件中,如果你想在当前文件中使用另一个文件中定义的消息,需要在 .proto 文件的顶部加上一条导入语句:

import "myproject/other_protos.proto"

默认情况下,你仅能使用直接导入的 .proto 文件中的定义。然而,有时你可能需要将一个 .proto 文件移动到另一个位置。于其直接移动 .proto 文件并一次更改所有的调用点,现在你可以在旧的位置放一个假的 .proto 文件,使用 import public 的概念将所有的导入转移到新的位置。import public 的依赖关系可以被任何导入包含 import public 语句的 proto 的依赖。

比如:

//new.proto
//所有的定义都移动到这里

//old.proto
//这是所有clients需要导入的proto
import public "new.proto";
import "other.proto";

//client.proto
import "old.proto"
//你使用了old.proto和new.proto的定义,但没有使用other.proto的定义。

protoc 编译器在命令上使用 -I / --proto_path 标志指定一组目录中搜索导入的文件,如果没有给出标志,他就在编译器被调用的目录中寻找。一般来说,你应该将 --proto_path 标志设置为项目的根目录,并对所有导入文件使用完全合格的名称。

嵌套类型

你可以在定义的消息类型中定义和使用其他消息类型,就像下面的例子一样,结果消息被定义在SearchResponse消息中:

message SearchResponse
{
    message Result
    {
        string url = 1;
        string title = 2;
        repeated string snippets = 3;
    }
    repeated Result results = 1;
}

如果你想在其父级消息类型中重复使用这个消息类型,引用它的格式是_Parent_._Type_。例如:

message SomeOtherMessage
{
    SearchResponse.Result result = 1;
}

可以通过意愿在消息进行深度嵌套:

message Outer // Level 0
{                  
    message MiddleAA   // Level 1
    {
        message Inner // Level 2
        {
            int64 ival = 1;
            bool  booly = 2;
        }
    }
    
    message MiddleBB // Level 1
    {
        message Inner // Level 2
        {
            int32 ival = 1;
            bool  booly = 2;
        }
    }
}

更新消息类型

如果现有的消息类型不再满足你的所有需求,比如你希望消息里有一个额外的字段,但你仍然想要使用旧的格式创建代码,不要担心,更新消息类型不会破坏你现有的任何代码,只要记住以下几条规则:

  1. 不要改变任何现有字段的字段号。
  2. 添加的任何字段都应该是 optional 或 repeated 类型的,这意味着任何使用你的“旧”消息格式的代码系列化的消息可以被你新的代码解析,因为不会缺少任何 require 字段。同样新的代码创建的消息也可以被旧的代码解析,旧的二进制文件在解析时只忽略新字段。然而,未知的字段也不会被丢弃,如果消息后来被序列化,位置字段也会被一起序列化,所以消息被传递到新的代码中,新字段仍然可用。
  3. 非 required 字段可以被删除,只要字段号不会再你更新的消息类型中再次使用。你可能想要重命名这个字段,或者加上前缀 OBSOLETE_,或者让这个字段号保留下来,这样你的 proto 的未来用户就不能意外的重复使用这个字段号。
  4. 一个非 required 字段可以被转换成一个 extension 字段,反之亦然,只要类型和数字保持不变。
  5. int32、uint32、int64、uint64 和 bool 都是兼容的,这意味着你可以将一个字段从这些类型中的一个改成另一个,而不会破坏向前或向后的兼容性。但是如果解析出的数字不符合相应的类型,将会进行截断,得到的结果不一定正确,比如 C++ 中,一个 64 位的数字被读作 int32,它将被截取为 32 位。
  6. sint32 和 sint64 是相互兼容的,与其他整数类型不兼容。
  7. string 和 byte 是兼容的,只要字节是有效的 UTF-8。
  8. 如果 bytes 包含消息的编码版本,则嵌入的消息与 bytes 兼容。
  9. 对于 string,bytes 和 message 字段,optional 与 repeated 兼容,给出的 repeated 字段的序列化数据作为输入,如果客户端期望这个字段是 optional 将采取最后的输入值,如果是 message 类型的字段,将合并所有的输入元素。注意,这对于数字类型,包括 bool 和 enum 通常是不安全的,数字类型的重复字段可以使用 packed 进行序列化,当期望有一个 optional 字段时,将不能被正确解析。
  10. 改变一个默认值是允许的,只要你不会再网上发送,因此,如果一个程序收到一个没有设置特定字段的消息,该程序将看到默认值,因为它是在该程序的协议版本中定义的,而不会看到发件人代码中定义的默认值。
  11. enum 兼容 int32、uint32、int64 和 uint64 (如果数值不合适,将会被截断),但是需要注意的是,如果消息被反序列化时,客户端代码可能会对他们进行不同的处理。值得注意的是,当消息被反序列化,未被识别的枚举值会被丢弃,这使得字段的访问器返回 false,其 getter 返回枚举定义中列出的第一个值,或者默认值。在重复枚举字段的情况下,任何未被识别的值都会从列表中剥离出来,然而,一个整数字段将始终保留其值,正因为如此,把一个整数升级为美剧时,需要非常小心,以免收到超界的枚举值。
  12. 在现在 Java 和 C++ 的实现中,当未识别的枚举值被剥离出来时,它们会与其他的字段一起被存储,注意:如果这些数据被序列化,然后被识别这些值的客户端重新解析,可能导致奇怪的行为。在可选字段的情况下,即时在原始消息被反序列化后写了一个新的值,旧的值仍会被识别它的客户端读取。在重复字段的情况下,旧值将出现在任何已识别和新添加的值之后,这意味着顺序将不会被保留下来。
  13. 将一个单一的 optional 字段值改成一个新的 oneof 成员时安全的,而且是二进制兼容的。将多个 optional 移动到一个新的 oneof 中可能是安全的,如果你确信没有代码同时设置多个字段的话。将任何字段移入一个现有的 oneof 是不安全的。
  14. map 和相应的 repeated 消息字段之间改变是二进制兼容的(关于消息布局,看下文的 Maps)。然而,改变的安全性取决于应用:当反序列化和重新序列化消息时,使用 repeated 字段定义的客户端将产生一个语义上相同的结果,然而,使用 map 字段定义的客户端可能会重新排序条目并放弃有重复键的条目。

Extensions扩展

extensions 允许你声明信息中的一系列字段编号可用于第三方扩展。扩展是一个字段的占位符,其类型没有被定义在源 .proto 文件中,这允许其他的 .proto 文件通过定义这些字段号的部分或全部字段的类型来增加你的消息定义。

看如下的实例:

message Foo
{
    // ...
    extensions 100 to 199;
}

这说明 Foo 中字段号 [100, 199] 的范围是保留给扩展的。其他用户现在可以在他们自己的 .proto 文件中向 Foo 添加新的字段,这些文件可以导入你的 .proto,使用你指定范围内的字段号,例如:

extend Foo
{
    optional int32 bar = 126;
}

这将在 Foo 的原始定义中添加一个名为 bar 的字段,字段号为 126。

当你的用户的 Foo 信息被编码时,其格式与用户在 Foo 内部定义新字段的情况完全相同。然而,你在应用程中访问扩展字段的方式与访问普通字段略有不同,你生成的数据访问代码有特殊的访问器用于处理扩展字段。

例如:在 C++ 中设置扩展字段 bar 的值:

Foo foo;
foo.SetExtension(bar, 15);

同样,Foo 类定义了模板化的访问器 HasExtension(),ClearExtension(),GetExtension(),MutableExtension() 和 AddExtension()。所有的语义都与正常字段的相应生成访问器相匹配。关于使用扩展的更多信息,请参见你所选择的语言的生成代码参考。

注意,extensions 可以是任何字段类型,包括 message 类型,但不能是 oneofs 或 map。

嵌套 Extensions

可以在另一个类型的范围内声明扩展,如下:

message Baz
{
    extend Foo
    {
        optional int32 bar = 126;
    }
    ...
}

在这种情况下,访问这个扩展的 C++ 代码是:

Foo foo;
foo.SetExtension(Baz::bar, 15);

从上面对比看出,bar 被定义在 Baz 的作用域内。

这是一个常见的混淆来源。声明一个嵌套在消息类型内的扩展块并不意味着外部类型和扩展类型之间的任何关系,上面的例子并不意味着 Baz 是 Foo 的任何种类的子类。仅仅意味着 bar 是在 Baz 的范围内声明的,它只是一个静态成员。

一个常见的模式是在扩展字段类型的范围内定义扩展,例如,下面有一个对 Baz 类型的 Foo 的扩展,扩展被定义为 Baz 的一部分。

message Baz
{
    extend Foo
    {
        optional Baz foo_ext = 127;
    }
    ...
}

然而,并不要求带有 message 类型的扩展被定义在该类型内。还可以这样做:

message Baz
{
    ...
}

// 这可以是不同的文件中
extend Foo
{
    optional Baz foo_baz_ext = 127;
}

事实上,为了避免混淆,这种语法可能是首选。上面的嵌套语法经常被那些还不熟悉扩展的用户误认为是子类。

选择扩展号

确保两个用户不使用相同的字段号为同一消息类型添加扩展名是非常重要的,如果一个扩展名被以外的解释为错误的类型,就会导致数据损坏。你需要考虑为你的项目于定义一个扩展编号,以防止这种情况发生。

如果你的编号可能涉及到有非常大的字段编号,你可以使用 max 关键字指定你的范围到最大可能的字段编号。

如下示例:

message Foo
{
    extensions 1000 to max;
}

max 值是 229 - 1(536870911)。

和选择字段号一样,你的编号管理也需要避免字段号19000 ~ 19999FieldDescriptor::kFirstReservedNumber ~ FieldDescriptor::kLastReservedNumber),这是保留给 Protobuf 的实现,你可以定义一个包括这个范围的扩展范围,但是不允许你用这些数组定义实际的扩展。

Oneof

如果你有一个有许多字段的信息,并且最多只能有一个字段会同时被设置,你可以通过使用 oneof 功能来强制执行这一行为并节省内存。

oneof 字段和普通字段一样,只是 oneof 中所有字段共享内存,而且最多只能同时设置一个字段。设置 oneof 中的任何成员都会自动清除所有其他成员。你可以使用一个特殊的 case() 或 WhichOneof() 方法来检查 oneof 中的哪个值被设置(如果有的话),这取决于你选择的语言。

使用Oneof

要在你的 .proto 中定义一个 oneof,你要使用 oneof 关键字,后面跟着你的 oneof 名称,如下实例的 test_oneof:

message SampleMessage
{
    oneof test_oneof
    {
        string name = 4;
        SubMessage sub_message = 9;
    }
}

然后你把你的 oneof 字段添加到 oneof 定义中。你可以添加任何类型的字段,但是除了map 字段和 repeated 字段。

在你生成的代码中,oneof 字段具有与普通字段相同的 getter 方法和 setter 方法。你还可以得到一个特殊的方法来检查 oneof 中的哪个值(如果有的话)被设置。可以查看你所选语言的 oneof API 的信息。

Oneof 特性

  • 设置一个 oneof 字段将自动清除 oneof 的所有其他成员。因此,如果你设置了几个 oneof 字段,只有你设置的最后一个字段仍然有一个值。
SampleMessage message;
message.set_name("name");
CHECK(message.has_name());
message.mutable_sub_message(); //这一步将清除掉name字段设置的值
CHECK(!message.has_name());
  • 如果在解析时遇到同一 oneof 的多个成员,在解析的消息中只使用最后看到的成员。
  • 一个 oneof 不能被重复。
  • 反射 API 对 oneof 字段起作用。
  • 如果你将一个 oneof 字段设置为默认值(比如将一个 int32 的 oneof 字段设置为 0),该 oneof 字段的 “case” 将被设置,并且该值也会被序列化。
  • 如果你使用C++,确保你的代码不会导致内存崩溃。下面的示例代码会崩溃,因为 sub_message 已经通过调用 set_name() 方法被删除了。
SampleMessage message;
SubMessage* sub_message = message.mutable_sub_message();
message.set_name("name");      // 这里将会删除sub_message
sub_message->set_...           // 出现内存崩溃
  • 同样在 C++ 中,如果你用 swap() 交换两个消息,每个消息最后都会有对方的 oneof 情况。在下面的例子中,msg1 会有一个 sub_message,msg2 会有一个 name。
SampleMessage msg1;
msg1.set_name("name");
SampleMessage msg2;
msg2.mutable_sub_message();
msg1.swap(&msg2);
CHECK(msg1.has_sub_message());
CHECK(msg2.has_name());

Oneof 的向后兼容问题

在添加或删除 oneof 字段时要小心。如果检查 oneof 的值返回 None/NOT_SET,这可能意味着 oneof 没有被设置,或者它被设置为不同版本的 oneof 中的一个字段。没有办法区分,因为没有办法知道一个未知字段是否是 oneof 的成员。

标签重复使用问题

  • 将字段移入或移出一个 oneof:在消息被序列化和解析后,你可能会失去一些信息(一些字段会被清除)。然而,你可以安全地将一个字段移入一个新的oneof中,如果知道只有一个字段被设置的话,也许可以移动多个字段。
  • 删除一个 oneof 字段,然后把它加回来:这可能会在消息被序列化和解析后清除你当前设置的 oneof 字段。
  • 拆分或合并 oneof:这与 move 常规字段有类似的问题。

映射(map)

如果你想创建一个关联映射作为数据的一部分,ProtoBuf 提供了一个方便的语法:

map map_field = N;

其中 key_type 可以是任何整形或字符串类型(也就是除了 float/double 和 bytes 之外的任何标量类型)。注意:enum 不是一个有效的 key_type。value_type 可以是任何类型,但除了另一个 map。

因此,如果你想创建一个名为 projects 的映射,且每一个 string 对应一个 Project,你可以定义它如下:

map projects = 3;
  • map 字段不能重复。
  • 你不能依赖 map 值有一个特定的顺序。
  • 在为 .proto 生成文本格式时,map 是按键排序的。数值键是按数字大小排序的。
  • 当对 map 解析或合并时,如果有重复的 map 键,则使用最后看到的键。当从文本格式解析一个 map 时,如果有重复的键,解析可能会失败。
  • 如果为一个 map 字段提供了一个 key,但没有提供 value,当字段被序列化时,其行为是与语言有关的。在 C++、Java、Kotlin 和 Python 中,该类型的默认值会被序列化,而在其他语言中,什么都不会被序列化。
  • 生成的 map API 目前可用于所有 proto3 支持的语言。你可以在相关的 API 参考中找到更多关于你所选语言的 map API。

map的向后兼容

map 语法等同于以下内容,所以不支持 map 的 ProtoBuf 版本仍然可以处理你的数据。

message MapFieldEntry
{
    key_type key = 1;
    value_type value = 2;
}
repeated MapFieldEntry map_field = N;

任何支持 map 的 Protobuf 也必须同时产生和接受可以被上面定义接受的数据。

包(package)

可以在 .proto 文件中添加一个可选的 package 指定器,以防止协议消息类型之间的名称冲突。

比如:

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

然后你可以在定义自定义类型的字段中使用包指定器。

message Foo
{
    //...
    foo.bar.Open open = 1;
    //...
}

包指定器影响生成代码的方式取决于你所选择的语言。比如:

  • 在 C++ 中,包名主要用于类的命名空间,其生成的类被包裹在一个 C++ 命名空间中。例如,Open 将被放在命名空间 foo::bar 中。
  • 在 Java 和 Kotlin 中,包被用作 Java 包,除非你在 .proto 文件中明确提供一个选项 java_package。
  • 在 Python 中,package 指令被忽略了,因为 Python 模块是根据它们在文件系统中的位置来组织的。
  • 在 Go 中,除非在 .proto 文件中明确提供选项 go_package,否则包会被用作 Go 包名。
  • 在 Ruby 中,生成的类被包裹在嵌套的 Ruby 命名空间中,并转换为所需的 Ruby 大写风格(第一个字母大写;如果第一个字符不是字母,则会预加 PB_)。例如,Open 将被放在命名空间 Foo::Bar 中。
  • 在 C# 中,除非你在你的 .proto 文件中明确提供一个选项 csharp_namespace,否则在转换为 PascalCase 后,包名会被用作命名空间。例如,Open 会被放在 Foo.Bar 的命名空间中。

package的名称解析

在 ProtoBuf 中,类型名称解析的工作方式与 C++ 类似:首先搜索最内层的范围,然后是下一个最内层的范围,以此内推,每个包都被认为是其父包的内部,最前面的 . (例如,foo.bar.Baz)意味着要从最外层的范围开始。

ProtoBuf 编译器通过解析导入的 .proto 文件来解决所有的类型名称。每种语言的代码生成器直到如何引用该语言中的每个类型,即使它有不同的范围规则。

定义Services

如果你想在 RPC(远程过程调用)中使用你的消息类型,你可以在 .proto 文件中定义一个 RPC 服务接口,ProtoBuf 百年一起将用你选择的语言生成服务接口代码。举例来说,如果你想定义一个 RPC 服务的方法,该方法接受你的 SearchRequest 并返回 SearchResponse,可以在 .proto 文件中定义它,如下:

service SearchService
{
    rpc Search(SearchRequest) returns (SearchResponse);
}

通常与 ProtoBuf 一起使用的 RPC 系统是 gRPC:一个 google 开发的跨平台跨语言的开源 RPC 。gRPC 与 ProtoBuf 一起工作非常好,并且让你使用一个特殊的 ProtoBuf 编译器插件从你的 .proto 文件直接生成相关的 RPC 代码。

如果不想使用 gRPC,也可以用你自己的 RPC 来使用 ProtuBuf。同时也有一些第三方项目,为 ProtoBuf 开发 RPC 实现。

关于 gRPC 这里不做过多赘述,请参考 gRPC 官方文档。

选项(optional)

.proto 文件中的各个声明可以用许多选项进行注释。选项不会改变声明的整体含义,但可能会影响它在特定上下文中的处理方式。可用选项的完整列表定义在 google/protobuf/descriptor.proto

有些选项是文件级选项,这意味着它们应该写在顶级范围内,而不是在任何消息、枚举或服务定义中。一些选项是消息级别的选项,这意味着它们应该写在消息定义中。有些选项是字段级选项,这意味着它们应该写在字段定义中。选项也可以写在枚举类型、枚举值、一个字段、服务类型和服务方法上。

以下是一些最常用的选项:

  • java_package(文件选项):你想为你生成的 Java/Kotlin 类使用的包。如果在 .proto 文件中没有给出明确的 java_package 选项,那么默认将使用 proto 包(在 .proto 文件中使用package 关键字指定)。然而,proto 包一般不会成为好的 Java 包,因为 proto 包不希望以反向域名开始。如果不生成 Java 或 Kotlin 代码,这个选项没有影响。
  • java_outer_classname(文件选项):你想要生成的封装 Java 类的类名(也就是文件名)。如果在 .proto 文件中没有明确指定 java_outer_classname,类名将通过将 .proto 文件名转换为骆驼字母大小写来构建(所以 foo_bar.proto 将变成 FooBar.java)。如果 java_multiple_files 选项被禁用,那么为 .proto 文件生成的所有其他类/枚举/等,将作为嵌套类/枚举/等在这个外层 Java 类中生成。如果不生成 Java代 码,这个选项没有影响。
option java_outer_classname = "Ponycopter";
  • java_multiple_files(文件选项):如果为 false,这个 .proto 文件将只生成一个 .java 文件,所有为顶层消息、服务和枚举生成的 Java 类/枚举/等将被嵌套在一个外层类中(见 java_outer_classname)。如果为 true,为顶层消息、服务和枚举生成的每个Java类/枚举/等都将生成单独的 .java 文件,为这个 .proto 文件生成的封装 Java 类将不包含任何嵌套的类/枚举/等。这是一个 bool 选项,默认为 false。如果不生成 Java 代码,这个选项没有影响。
option java_multiple_files = true;
  • optimize_for(文件选项):可以设置为 SPEED、CODE_SIZE 或 LITE_RUNTIME。这对 C++ 和 Java 的代码生成器有以下影响:
  1. SPEED(默认)。ProtoBuf 编译器将生成用于序列化、解析和对你的消息类型执行其他常见操作的代码。这个代码是高度优化的。
  2. CODE_SIZE:协议缓冲区编译器将生成最小的类,并依靠共享的、基于反射的代码来实现序列化、解析和其他各种操作。因此,生成的代码将比使用 SPEED 时小得多,但操作会更慢。类仍将实现与 SPEED 模式下完全相同的公共 API。这种模式在包含大量 .proto 文件且不需要很快的应用程序中最有用。
  3. LITE_RUNTIME。协议缓冲区编译器将生成只依赖 "精简 "运行时库(libprotobuf-lite 而不是 libprotobuf)的类。精简版运行时比完整版库小得多(大约小一个数量级),但省略了描述符和反射等某些功能。这对于在手机等受限平台上运行的应用程序特别有用。编译器仍然会像在 SPEED 模式下那样,生成所有方法的快速实现。生成的类将只实现每种语言中的 MessageLite 接口,它只提供完整的 Message 接口的方法的一个子集。
option optimize_for = CODE_SIZE;
  • cc_enable_arenas(文件选项):为 C++ 生成的代码启用 arena allocation。
  • objc_class_prefix(文件选项):设置 Objective-C 类的前缀,这个前缀会被加到所有从这个 .proto 生成的 Objective-C 类和枚举中。没有默认值。你应该使用苹果公司推荐的 3-5 个大写字符的前缀。注意,所有 2 个字母的前缀都被苹果保留了。
  • deprecated(字段选项):如果设置为 true,表示该字段已被废弃,不应该被新代码使用。在大多数语言中,这没有实际效果。在 Java 中,这成为一个 @Deprecated 注解。在将来其他特定语言的代码生成器可能会在字段的访问器上生成弃用注解,这也会导致在编译试图使用字段的代码时发出警告。在编译试图使用该字段的代码时发出警告。如果该字段不被任何人使用,并且你想阻止新用户使用它,可以考虑用一个 reserved 语句来替换字段声明。
int32 old_field = 6 [deprecated = true];

自定义选项

ProtoBuf 还允许你定义和使用你自己的选项。这是一个高级功能,大多数人不需要。如果你认为你需要创建你自己的选项,可以参阅 Proto2 语言指南中的细节。

你可能感兴趣的:(服务器后端开发,后端开发)