【ProtoBuf】proto 3 语法 -- 详解

这个部分会对通讯录进行多次升级,使用 2.x 表示升级的版本,最终将会升级如下内容:

  • 不再打印联系人的序列化结果,而是将通讯录序列化后并写入文件中。
  • 从文件中将通讯录解析出来,并进行打印。
  • 新增联系人属性,共包括:姓名、年龄、电话信息、地址、其他联系方式、备注

一、字段规则

消息的字段可以用下面几种规则来修饰:
  • singular:消息中可以包含该字段零次或⼀次(不超过一次)。 proto3 语法中,字段默认使用该规则。
  • repeated:消息中可以包含该字段任意多次(包括零次),其中重复值的顺序会被保留。可以理解为定义了一个数组。

更新 contacts.proto, PeopleInfo 消息中新增 phone_numbers 字段,表示一个联系人有多个号码,可将其设置为 repeated,写法如下:


二、消息类型的定义与使用

1、定义

在单个 .proto 文件中可以定义多个消息体,且支持定义嵌套类型的消息(任意多层)。每个消息体中的字段编号可以重复。

更新 contacts.proto,我们可以将 phone_number 提取出来,单独成为一个消息:

嵌套写法:

非嵌套写法:


2、使用

(1)消息类型可作为字段类型使用


(2)可导入其他 .proto 文件的消息并使用

例如:Phone 消息定义在 phone.proto 文件中:

contacts.proto 中的 PeopleInfo 使用 Phone 消息:

注意 :在 proto3 文件中可以导入 proto2 消息类型并使用它们,反之亦然。

3、创建通讯录 2.0 版本

通讯录 2.x 的需求是向文件中写入通讯录列表,以上只是定义了一个联系人的消息,并不能存放通讯录列表,所以还需要再完善一下 contacts.proto(终版通讯录 2.0):

接着进行一次编译:

contacts.pb.h 更新的部分代码展示:

新增了 PeopleInfo_Phone 类:

更新了 PeopleInfo 类:

新增了 Contacts 类:

上述的例子中:

  • 每个字段都有⼀个 clear_ 方法,可以将字段重新设置回 empty 状态。
  • 每个字段都有设置和获取的方法, 获取方法的方法名称与小写字段名称完全相同。但如果是消息类型的字段,其设置方法为 mutable_ 方法,返回值为消息类型的指针,这类方法会为我们开辟好空间,可以直接对这块空间的内容进行修改。
  • 对于使用 repeated 修饰的字段,也就是数组类型,pb 为我们提供了 add_ 方法来新增⼀个值,并且提供了 _size 方法来判断数组存放元素的个数。

(1)通讯录 2.0 的写入实现


A. write.cc(通讯录 2.0)


B. Makefile


C. 运行


D. 查看二进制文件

解释:

  • hexdump:是 Linux 下的一个二进制文件查看工具,它可以将二进制文件转换为 ASCII、八进制、十进制、十六进制格式进行查看。
  • -C:表示每个字节显示为 16 进制和相应的 ASCII 字符。

(2)通讯录 2.0 的读取实现

A. read.cc(通讯录 2.0)


B. Makefile(升级)


C. 运行


D. 另⼀种验证方法  --decode

可以用命令:protoc -h 来查看 ProtoBuf 为我们提供的所有命令 option。

其中 ProtoBuf 提供一个命令选项 --decode,表示从标准输入中读取给定类型的二进制消息,并将其以文本格式写入标准输出。

消息类型必须在 .proto 文件或导入的文件中定义。

在这里是将 utf-8 汉字转为八进制格式输出了。


三、enum 类型

1、定义规则

语法支持我们定义枚举类型并使用。在 .proto 文件中枚举类型的书写规范为:

(1)枚举类型名称

使用驼峰命名法首字母大写。 例如:MyEnum

(2)常量值名称

全大写字母多个字母之间用 _ 连接。例如:ENUM_CONST = 0;

我们可以定义一个名为 PhoneType 的枚举类型,定义如下:

要注意枚举类型的定义有以下几种规则:

  1. 0 值常量必须存在,且要作为第一个元素。这是为了与 proto2 的语义兼容:第一个元素作为默认值,且值为 0。
  2. 枚举类型可以在消息外定义,也可以在消息体内定义(嵌套)。
  3. 枚举的常量值在 32 位整数的范围内。但因负值无效因而不建议使用(与编码规则有关)。

2、定义时注意

将两个 ‘具有相同枚举值名称’ 的枚举类型放在单个 .proto 文件下测试时,编译后会报错:某某某常量已经被定义!所以这里要注意:

  • 同级(同层)的枚举类型,各个枚举类型中的常量不能重名。

  • 单个 .proto 文件下,最外层枚举类型和嵌套枚举类型,不算同级。

  • 多个 .proto 文件下,若一个文件引入了其他文件,且每个文件都未声明 package,每个 proto 文件中的枚举类型都在最外层,算同级。

  • 多个 .proto 文件下,若一个文件引入了其他文件,且每个文件都声明了 package,不算同级。


(1)情况 1:同级枚举类型包含相同枚举值名称

编译后报错:MP 已经定义。

(2)情况 2:不同级枚举类型包含相同枚举值名称

用法正确。


(3)情况 3:多文件下都未声明 package

test_enum.proto:

test_enum2.proto:

编译后报错:MP 已经定义。

(4)情况 4:多文件下都声明了 package

test_enum.proto:

test_enum2.proto:

用法正确。


3、升级通讯录至 2.1 版本

更新 contacts.proto(通讯录 2.1),新增枚举字段并使用,更新内容如下:

编译:


(1)contacts.pb.h 更新的部分代码展示:

A. 新生成的 PeopleInfo_Phone_PhoneType 枚举类


B. 更新的 PeopleInfo_Phone 类

上述的代码中:
  • 对于在 .proto 文件中定义的枚举类型,编译生成的代码中会含有与之对应的枚举类型、校验枚举值是否有效的方法 _IsValid、以及获取枚举值名称的方法 _Name。

  • 对于使用了枚举类型的字段,包含设置和获取字段的方法,已经清空字段的方法 clear_。


(2)更新 write.cc(通讯录 2.1)


(3)更新 read.cc(通讯录 2.1)


(4)代码完成后,编译后进行读写验证

张三的联系电话类型打印出 MP 是因为未设置该字段,导致用了枚举的第一个元素作为默认值。


四、Any 类型

字段还可以声明为 Any 类型,可以理解为泛型类型。使用时可以在 Any 中存储任意消息类型,Any 类型的字段也用 repeated 来修饰。

Any 类型是 Google 已经帮我们定义好的类型,在安装 ProtoBuf 时,其中的 include 目录下查找所有 Google 已经定义好的 .proto 文件。


1、升级通讯录至 2.2 版本

通讯录 2.2 版本会新增联系人的地址信息,我们可以使用 any 类型的字段来存储地址信息。

(1)更新 contacts.proto(通讯录 2.2)

编译:


(2)contacts.pb.h 更新的部分代码展示

A. 新生成的 Address


B. 更新的 PeopleInfo 类

上述的代码中,对于 Any 类型字段:
  • 设置和获取:获取方法的方法名称与小写字段名称完全相同。设置方法可以使用 mutable_ 方法,返回值为 Any 类型的指针,这类方法会为我们开辟好空间,可以直接对这块空间的内容进行修改。

(3)更新 write.cc(通讯录 2.2)


(4)更新 read.cc(通讯录 2.2)


(5)代码编写完成后,编译后进行读写


五、oneof 类型

如果消息中有很多可选字段, 并且将来同时只有一个字段会被设置, 那么就可以使用 oneof 加强这个行为,也能有节约内存的效果。


1、升级通讯录至 2.3 版本

通讯录 2.3 版本想新增联系⼈的其他联系方式,比如 qq 或者微信号二选一,我们就可以使用 oneof 字段来加强多选一这个行为。

oneof 字段定义的格式为:oneof 字段名 { 字段1; 字段2; ... }


(1)更新 contacts.proto(通讯录 2.3)

  • 可选字段中的字段编号,不能与非可选字段的编号冲突。
  • 不能在 oneof 中使用 repeated 字段。
  • 将来在设置 oneof 字段中值时,如果将 oneof 中的字段设置多个,那么只会保留最后一次设置的成,之前设置的 oneof 成员会自动清除。

编译:


(2)Acontacts.pb.h 更新的部分代码展示

A. 更新的 PeopleInfo 类

上述的代码中,对于 oneof 字段:

  • 会将 oneof 中的多个字段定义为一个枚举类型。
  • 设置和获取:对 oneof 内的字段进行常规的设置和获取即可,但要注意只能设置⼀个。如果设置多个,那么只会保留最后一次设置的成员。
  • 清空 oneof 字段:clear_ 方法。
  • 获取当前设置了哪个字段:_case 方法。

(3)更新 write.cc(通讯录 2.3)


(4)更新 read.cc(通讯录 2.3)


(5)代码编写完成后,编译后进行读写


六、map 类型

语法支持创建一个关联映射字段,也就是可以使用 map 类型去声明字段类型,格式为:

map<key_type, value_type> map_field = N;
  • key_type 是除了 float 和 bytes 类型以外的任意标量类型。value_type 可以是任意类型。
  • map 字段不可以用 repeated 修饰。
  • map 中存入的元素是无序的。

1、升级通讯录至 2.4 版本

最后,通讯录 2.4 版本想新增联系人的备注信息,可以使用 map 类型的字段来存储备注信息

(1)更新 contacts.proto(通讯录 2.4)

编译:


(2)contacts.pb.h 更新的部分代码展示

A. 更新的 PeopleInfo 类

上述的代码中,对于 Map 类型的字段:
  • 清空 map:clear_ 方法。
  • 设置和获取:获取方法的方法名称与小写字段名称完全相同。设置方法为 mutable_ 方法,返回值为 Map 类型的指针,这类方法会为我们开辟好空间,可以直接对这块空间的内容进行修改。

(3)更新 write.cc(通讯录 2.4)


(4)更新 read.cc(通讯录 2.4)


(5)代码编写完成后,编译后进行读写

到此,我们对通讯录 2.x 要求的任务全部完成。


七、默认值

反序列化消息时,如果被反序列化的二进制序列中不包含某个字段,反序列化对象中相应字段时,就会设置为该字段的默认值。

不同的类型对应的默认值不同:

  • 对于字符串默认值为空字符串
  • 对于字节默认值为空字节
  • 对于布尔值默认值为 false
  • 对于数值类型默认值为 0
  • 对于枚举默认值是第⼀个定义的枚举值, 必须为 0。
  • 对于消息字段,未设置该字段。它的取值是依赖于语言
  • 对于设置了 repeated 的字段的默认值是空的(通常是相应语⾔的⼀个空列表)。
  • 对于消息字段oneof 字段和 any 字段,C++ 和 Java 语言中都有 has_ 方法来检测当前字段是否被设置。
  • 对于标量数据类型,在 proto3 语法下,没有生成 has_ 方法

八、更新消息

1、更新规则

如果现有的消息类型已经不再满足我们的需求,例如需要扩展一个字段,在不破坏任何现有代码的情况下更新消息类型非常简单。

遵循如下规则即可:

  • 禁止修改任何已有字段的字段编号

  • 若是移除老字段,要保证不再使用移除字段的字段编号。正确的做法是保留字段编号 (reserved),以确保该编号将不能被重复使用。(不建议直接删除或注释掉字段)

  • int32,uint32,int64,uint64 和 bool 是完全兼容的。可以从这些类型中的一个改为另一个,而不破坏前后兼容性。若解析出来的数值与相应的类型不匹配,会采用与 C++ 一致的处理方案(例如:若将 64 位整数当做 32 位进行读取,它将被截断为 32 位)。

  • sint32 和 sint64 相互兼容,但不与其他的整型兼容。

  • string 和 bytes 在合法 UTF-8 字节前提下也是兼容的。

  • bytes 包含消息编码版本的情况下,嵌套消息与 bytes 也是兼容的。

  • fixed32 与 sfixed32 兼容,fixed64 与 sfixed64兼容。

  • enum 与 int32,uint32, int64 和 uint64 兼容(注意:如果值不匹配会被截断)。但要注意当反序列化消息时会根据语言采用不同的处理方案:例如:未识别的 proto3 枚举类型会被保存在消息中,但是当消息反序列化时如何表示是依赖于编程语言的。整型字段总是会保持其的值。

oneof:

  • ​​​​​​​将一个单独的值更改为新 oneof 类型成员之一是安全和二进制兼容的。

  • 若确定没有代码一次性设置多个值,那么将多个字段移入一个新 oneof 类型也是可行的。

  • 将任何字段移入已存在的 oneof 类型是不安全的。


2、保留字段 reserved

如果通过删除或注释掉字段来更新消息类型,未来的用户在添加新字段时,有可能会使用以前已经存在,但已经被删除或注释掉的字段编号。将来使用该 .proto 的旧版本时的程序会引发很多问题:数据损坏、隐私错误等等。

如果要删除老字段,要保证不使用已经被删除或者已经被注释掉的字段编号。确保不会发生上述这种情况的一种方法是:使用 reserved 将指定字段的编号或名称设置为保留项 。当我们再使用这些编号或名称时,protocol buffer 的编译器将会警告这些编号或名称不可用。举个例子:​​​​​​​

这里它推荐我们改成:int32 birthday = 4;

注意 不要在一行 reserved 声明中同时声明字段编号和名称

(1)创建通讯录 3.0 版本 —— 验证错误删除字段造成的数据损坏

现模拟有两个服务,他们各自使用一份通讯录 .proto 文件,内容约定好了是一模一样的。

  • 服务 1(service):负责序列化通讯录对象,并写入文件中。
  • 服务 2(client):负责读取文件中的数据,解析并打印出来。

  • 一段时间后,service 更新了自己的 .proto 文件,更新内容为:删除了某个字段,并新增了一个字段,新增的字段使用了被删除字段的字段编号,并将新的序列化对象写进了文件。
  • 但 client 并没有更新自己的 .proto 文件。

根据结论,可能会出现数据损坏的现象,下面来验证下这个结论:

新建两个目录:service、client,分别存放两个服务的代码。

A. service 目录下新增 contacts.proto(通讯录 3.0)


B. client 目录下新增 contacts.proto(通讯录 3.0)

分别对两个文件进行编译,可自行操作。


C. 继续对 service 目录下新增 service.cc(通讯录 3.0),负责向文件中写通讯录消息


D. service 目录下新增 makefile


E. client 目录下新增 client.cc(通讯录 3.0),负责向读出文件中的通讯录消息


F. client 目录下新增 makefile


代码编写完成后:再对 service 目录下的 contacts.proto 文件进行更新:删除 age 字段,新增 birthday 字段,新增的字段使用被删除字段的字段编号。

G. 更新后的 contacts.proto(通讯录 3.0)

H. 编译文件 .proto 后,还需要更新一下对应的 service.cc(通讯录 3.0)

这时输入的生日在反序列化时,会被设置到了使用了相同字段编号的年龄上。

结论:如果是移除老字段,要保证不再使用移除字段的字段编号,不建议直接删除或注释掉字段。那么正确的做法是:保留字段编号(reserved),以确保该编号将不能被重复使用。


I. 正确 service 目录下的 contacts.proto 写法如下(终版通讯录 3.0)

编译 .proto 文件后,还需要重新编译下 service.cc,让 service 程序保持使用新生成的 pb C++ 文件。

可以发现 ‘王五’ 的年龄为 0。这是由于新增时未设置年龄,通过 client 程序反序列化时,给年龄字段设置了默认值 0。

再解释一下,假设我们之前有一个存储的数据 ‘李四’,年龄依旧使用了之前设置的生日字段 ‘1221’。那么这是因为在新增 ‘李四’ 时,生日字段的字段编号依旧为 2,并且已经被序列化到文件中了。那么最后再读取时,字段编号依旧为 2。因为使用了 reserved 关键字,ProtoBuf 在编译阶段就拒绝了我们使用已经保留的字段编号。

如果使用了 reserved 2 了,那么 service 给 ‘王五’ 设置的生日 ‘1020’,client 就没法读到了吗?

答案是可以的。


3、未知字段

在通讯录 3.0 版本中,我们向 service 目录下的 contacts.proto 新增了 ‘生日’ 字段,但对于 client 相关的代码并没有任何改动。验证后发现新代码序列化的消息(service)也可以被旧代码(client)解析。新增的 ‘生日’ 字段在旧程序(client)中其实并没有丢失,而是会作为旧程序的未知字段

  • 未知字段解析结构良好的 protocol buffer 已序列化数据中的未识别字段的表示方式。例如,当旧程序解析带有新字段的数据时,这些新字段就会成为旧程序的未知字段。
  • proto3 在解析消息时总是会丢弃未知字段,但在 3.5 版本中重新引⼊了对未知字段的保留机制。所以,在 3.5 或更高版本中,未知字段在反序列化时会被保留,同时也会包含在序列化的结果中。

(1)未知字段从哪获取

A. 了解相关类关系图


B. MessageLite 类介绍(了解)
  • MessageLite 从名字看是轻量级的 message,仅仅提供序列化、反序列化功能。
  • 类定义在 google 提供的 message_lite.h 中。

C. Message 类介绍(了解)
  • 我们自定义的 message 类,都是继承自 Message。
  • Message 最重要的两个接口 GetDescriptor / GetReflection,可以获取该类型对应的 Descriptor 对象指针和 Reflection 对象指针。
  • 类定义在 google 提供的 message.h 中。

D. Descriptor 类介绍(了解)
  • Descriptor:是对 message 类型定义的描述,包括 message 的名字、所有字段的描述、原始的 proto 文件内容等。
  • 类定义在 google 提供的 descriptor.h 中。


E. Descriptor 类介绍(了解)
  • Descriptor:是对 message 类型定义的描述,包括 message 的名字、所有字段的描述、原始的 proto 文件内容等。
  • 类定义在 google 提供的 descriptor.h 中。


F. Reflection 类介绍(了解)
  • Reflection接口类,主要提供了动态读写消息字段的接口,对消息对象的自动读写主要通过该类完成。
  • 提供方法来动态访问 / 修改 message 中的字段,对每种类型,Reflection 都提供了⼀个单独的接口用于读写字段对应的值。

针对所有不同的field类型 FieldDescriptor::TYPE_* ,需要使⽤不同的 Get*() / Set*() / Add*() 接口;

repeated 类型需要使用 GetRepeated*() / SetRepeated*() 接口,不可以和非 repeated 类型接口混用;

message 对象只可以被由它自身的 reflectionmessage.GetReflection()) 来操作;

  • 类中还包含了访问 / 修改未知字段的方法。
  • 类定义在 google 提供的 message.h 中。


G. UnknownFieldSet 类介绍(重要)
  • UnknownFieldSet 包含在分析消息时遇到但未由其类型定义的所有字段。
  • 若要将 UnknownFieldSet 附加到任何消息,请调用 Reflection::GetUnknownFields()。
  • 类定义在 unknown_field_set.h 中。


H. UnknownField 类介绍(重要)
  • 表示未知字段集中的⼀个字段。
  • 类定义在 unknown_field_set.h 中。


(2)升级通讯录 3.1 版本 —— 验证未知字段

A. 更新 client.cc(通讯录 3.1)

在这个版本中,需要打印出未知字段的内容。更新的代码如下:

其他文件均不用做任何修改,重新编译 client.cc,进行一次读操作可得如下结果:

类型为什么为 0 呢?

前面介绍 UnknownField 类中讲到了类中包含了未知字段的几种类型:

enum Type {
 TYPE_VARINT,
 TYPE_FIXED32,
 TYPE_FIXED64,
 TYPE_LENGTH_DELIMITED,
 TYPE_GROUP
};
// 类型为 0,即为 TYPE_VARINT。

(4)前后兼容性

根据上述的例子可以得出,pb 是具有向前兼容的。为了叙述方便,把增加了 “生日” 属性的 service 称为 “新模块”;未做变动的 client 称为 “老模块”。

  • 向前兼容:老模块能够正确识别新模块生成或发出的协议。这时新增加的 “生日” 属性会被当作未知字段(pb 3.5 版本及之后)。
  • 向后兼容:新模块也能够正确识别老模块生成或发出的协议。

前后兼容的作用:当我们维护⼀个很庞大的分布式系统时,由于我们无法同时升级所有模块,为了保证在升级过程中,整个系统能够尽可能不受影响,就需要尽量保证通讯协议的 “向后兼容” 或 “向前兼容”。


九、选项 option

.proto 文件中可以声明许多选项,使用 option 标注。选项能影响 proto 编译器的某些处理方式。


1、选项分类

选项的完整列表在 google/protobuf/descriptor.proto 中定义。部分代码:

syntax = "proto2"; // descriptor.proto 使⽤ proto2 语法版本

message FileOptions { ... } // ⽂件选项 定义在 FileOptions 消息中

message MessageOptions { ... } // 消息类型选项 定义在 MessageOptions 消息中

message FieldOptions { ... } // 消息字段选项 定义在 FieldOptions 消息中

message OneofOptions { ... } // oneof字段选项 定义在 OneofOptions 消息中

message EnumOptions { ... } // 枚举类型选项 定义在 EnumOptions 消息中

message EnumValueOptions { .. } // 枚举值选项 定义在 EnumValueOptions 消息中

message ServiceOptions { ... } // 服务选项 定义在 ServiceOptions 消息中

message MethodOptions { ... } // 服务⽅法选项 定义在 MethodOptions 消息中

...

由此可见,选项分为文件级、消息级、字段级等等, 但并没有⼀种选项能作用于所有的类型


2、常用选项列举

(1)optimize_for

该选项为文件选项,可以设置 protoc 编译器的优化级别,分别为 SPEED、CODE_SIZE、 LITE_RUNTIME。受该选项影响,设置不同的优化级别,编译 .proto 文件后生成的代码内容不同。

  • SPEED:protoc 编译器将⽣成的代码是高度优化的,代码运行效率高,但是由此生成的代码编译后会占用更多的空间SPEED 是默认选项。
  • CODE_SIZEproto 编译器将生成最少的类,会占用更少的空间,是依赖基于反射的代码来实现序列化、反序列化和各种其他操作。但和 SPEED 恰恰相反,它的代码运行效率较低。这种方式适合用在包含大量的 .proto 文件,但并不盲目追求速度的应用中。
  • LITE_RUNTIME生成的代码执行效率高,同时生成代码编译后的所占用的空间也是非常少。这是以牺牲 Protocol Buffer 提供的反射功能为代价的,仅仅提供 encoding + 序列化功能,所以我们在链接 BP 库时仅需链接 libprotobuf-lite,而非 libprotobuf。这种模式通常用于资源有限的平台,例如移动手机平台中。
option optimize_for = LITE_RUNTIME;


(2)allow_alias

允许将相同的常量值分配给不同的枚举常量,用来定义别名,该选项为枚举选项。举个例子:


3、设置自定义选项

ProtoBuf 允许自义选项并使用,有兴趣可以参考:

https://protobuf.dev/programming-guides/proto2/

  • 22
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

炫酷的伊莉娜

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值