Protocol Buffer v3 语法

标签:微服务首次发布:2023-11-14最近修改:2023-11-14

简单入门

Protobuf 是一种语言无关、平台无关、可扩展的序列化结构数据的协议(类似于 XML、JSON、YAML 等),可以用于通信协议、数据存储等领域。通过 protobuf 工具生成的代码文件,为开发者提供了在目标语言(如 Go、Java、Python 等)中使用 protobuf 定义的数据结构的便利,同时也自动实现了相应的序列化和反序列化操作。

先来新建一个 user.proto 文件,这个 user.proto 文件的作用就是定义各种消息体,然后通过 protoc 编译器来生成相应的语言能使用的文件。

text
syntax = "proto3";option go_package = "./service;userRPC";package user;message User{  string username = 1;  int32 age = 2;}

这个文件有一些需要说明的地方:

  • syntax = “proto3”;表示使用的协议为 proto3,proto3 和 proto2 这两个版本的语法有一点区别,所以需要指定这个文件使用的是那个版本的协议(语法)对数据进行序列化和反序列化

  • go_package 是一个 protobuf 选项。格式为:go_package="path;name"。在说详细一点:

    • path:path 表示生成的 go 文件的存放地址。这个路径如果是一个相对路径,那就是相对于当前的.proto 文件的相对路径。
    • name:name 表示生成的go文件所属的包名。
  • package user;表示定义了消息类型的命名空间。这样就可以解决在不同的.proto 文件中定义的消息类型产生命名冲突。例如,你可能在两个不同的.proto 文件中都定义了一个名为 User 的消息类型,那么使用 package 可以避免这种冲突。也就是可以使用 user.User 来引用定义在 user 包中的 User 消息。

  • message 用于定义一个消息体,消息体也可以支持嵌套定义(message 里面有 message)。

定义好消息后,就可以生成相应语言(如 Go、Java、Python 等)的对应文件。在这里使用 protoc --go_out=. user.proto 命令生成相应的 go 文件。protoc 表示使用 protoc 编辑器;–go_out 是一个编译选项;最后是需要编译的.proto 文件。编译完成之后就会得到一个 user.pb.go 文件,文件名当中的 pb 是 ProtocolBuffers 的缩写。

这个 user.pb.go 文件的作用就是定义 User 类型的结构体以及与之相关的序列化和反序列化的代码。这样,你的 Go 程序就可以创建 User 实例,设置它的字段,将它序列化为字节流,或是从字节流中反序列化出 User 实例。

写一个 demo

使用 protoc --go_out=. user.proto 命令生成 user.pb.go 文件后就可以使用 go 语言完成 user 消息的序列化和反序列化的操作了。

text
func main() {	user := userRPC.User{		UserName: "bing",		Age:      20,	}	//序列化	marshal, _ := proto.Marshal(&user)	fmt.Println(marshal)	//反序列化	var newUser = new(userRPC.User)	_ = proto.Unmarshal(marshal, newUser)	fmt.Println(newUser)}

运行结果如下:

image-20231104171941674

protocol buffers 语法详解

字段规则

在 Protocol Buffers v3 中,字段规则定义了字段的数量,即字段在消息中可以出现的次数。每个字段必须有一个字段规则,字段规则可以是以下之一:

  • optional:字段可以在消息中出现 0 次或 1 次。尽管 Protocol Buffers v3 中的所有字段都是可选的,但是在 .proto 文件中不需要明确地写出 optional 关键字,因为这是默认的字段规则。
  • repeated:字段可以在消息中出现任意次数,包括 0 次。如果一个字段被设置为 repeated,那么这个字段的值将被表示为一个数组。
  • required:字段必须在消息中出现 1 次。这个字段规则在 Protocol Buffers v3 中被移除了,因为这会使消息的更新变得困难。在 Protocol Buffers v3 中,所有字段都是可选的。

例如,以下是一个使用了 optional 和 repeated 字段规则的消息定义:

protobuf
syntax = "proto3";message Student {  string name = 1;  repeated int32 scores = 2;}

在这个例子中,name 字段是可选的,可以在消息中出现 0 次或 1 次,scores 字段是重复的,可以在消息中出现任意次数。

数据类型

Protocol Buffers 中的字段类型可以分为以下几类:

  1. 标量类型:包括整型、浮点型、布尔型、字符串型、字节型等。
  2. 枚举类型:定义一组命名的值。
  3. 消息类型:可以定义复杂的数据类型。
  4. 重复字段类型:可以包含一组同类型的元素。
  5. 映射字段类型:可以包含一组键值对。

以下是一个 user.proto 文件的示例,其中包含了所有这些类型:

text
// 枚举类型enum UserRole {  USER = 0;  ADMIN = 1;}// 消息类型message UserInfo {  string username = 1;  // 标量类型  int32 age = 2;  // 标量类型  UserRole role = 3;  // 枚举类型  repeated string email = 4;  // 重复字段类型  map<string, string> attributes = 5;  // 映射字段类型}

标量值类型

标量消息字段可以具有以下类型之一。该表显示了.proto 文件,以及自动生成类中的对应类型:

.proto Type Notes C++ Type Java/Kotlin Type[1] Python Type[3] Go Type
double double double float float64
float float float float float32
int32 使用可变长度编码。编码负数效率低下——如果你的字段可能有负值,则使用 sint32代替。 int32 int int int32
int64 使用可变长度编码。编码负数效率低下——如果你的字段可能有负值,则使用 sint64代替。 int64 long int/long[4] int64
uint32 使用变长编码。 uint32 int[2] int/long[4] uint32
uint64 使用变长编码。 uint64 int[2] int/long[4] uint64
sint32 使用可变长度编码。带符号的 int 值。这些编码比普通的 int32更有效地编码负数。 int32 int int int32
sint64 使用可变长度编码。带符号的 int 值。这些编码比普通的 int64更有效地编码负数。 int64 long int/long[4] int64
fixed32 总是四个字节。如果值经常大于228,则比 uint32更有效率。 uint32 int[2] int/long[4] uint32
fixed64 总是8字节。如果值经常大于256,则比 uint64更有效率。 uint64 integer/string[6]
sfixed32 总是四个字节。 int32 int int int32
sfixed64 总是八个字节。 int64 integer/string[6]
bool bool boolean bool bool
string 字符串必须始终包含 UTF-8编码的或7位 ASCII 文本,且不能长于232。 string String str/unicode[5] string
bytes 可以包含任何不超过232字节的任意字节序列。 string ByteString str (Python 2) bytes (Python 3) []byte

[1] Kotlin 使用来自 Java 的相应类型,甚至是无符号类型,以确保在混合 Java/Kotlin 代码库中的兼容性。 [2] 在 Java 中,无符号的 32 位和 64 位整数使用它们的有符号整数表示,顶部的位简单地存储在符号位中。 [3] 在所有情况下,将值设置为字段将执行类型检查,以确保它是有效的。 [4]64 位或无符号 32 位整数在解码时总是表示为 long,但如果在设置字段时给出了 int,则可以表示为 int。在所有情况下,值必须符合设置时表示的类型。参见[2]。 [5]Python 字符串在解码时表示为 unicode,但如果给出 ASCII 字符串则可以表示为 str(这可能会更改)。 [6] 整数在 64 位机器上使用,字符串在 32 位机器上使用

默认值

在 Protocol Buffers v3 中,每种数据类型都有一个默认值。如果在消息中没有设置字段的值,那么这个字段的值将被设置为其数据类型的默认值。

  • 对于字符串,默认值为空字符串。
  • 对于字节,默认值为空字节。
  • 对于 bool,默认值为 false。
  • 对于数字类型,默认值为零。
  • 对于枚举,默认值是第一个定义的 enum 值,必须为 0。
  • 对于消息字段,没有设置该字段。它的确切值取决于语言。
  • 重复字段的默认值是空数组。

注意

  1. 在 Protocol Buffers v3 中,如果字段的值是默认值,那么这个字段将不会被序列化,也就是说它不会出现在序列化后的字节流中。这是一种称为 “zero value omission” 的优化,可以帮助节省网络带宽和存储空间。

  2. 例如,如果你有一个 message,它的一个字段是 int32 类型的,值为 0(这是 int32 类型的默认值),那么在序列化这个 message 时,这个字段不会被包含在序列化后的字节流中。当反序列化这个字节流时,由于这个字段没有在字节流中,所以它的值将被设置为默认值,也就是 0。这对于可选字段和单一字段来说是非常有用的。

  3. 但是对于 repeated 字段和 map 字段来说,如果它们的值是空数组或者空映射,它们仍然会被序列化,因为这是有意义的信息:它告诉我们这个字段被设置了,只是它的值为空。

Unknown Fields

未知字段是指在解析一个消息时,消息中存在字段编号,但是在对应的消息定义中并没有找到这个字段编号对应的字段。这种情况通常发生在消息的定义已经发生了变化,但是正在解析的消息是旧版本的消息。

在 Protocol Buffers v3 中,未知字段会被忽略,也就是说它们不会被包含在解析后的消息中。但是它们会被存储在一个特殊的数据结构中,所以如果你再次将这个消息序列化,这些未知字段还会被包含在序列化后的字节流中。

为了便于理解,以下是一个示例:

  1. 首先,我们定义一个消息:
protobuf
syntax = "proto3";message ExampleMessage {  int32 id = 1;  string name = 2;}
  1. 然后,我们创建一个这样的消息,并将它序列化:
go
msg := &ExampleMessage{id: 123, name: "Alice"}data, _ := proto.Marshal(msg)
  1. 之后,我们改变 ExampleMessage 的定义,移除了 name 字段:
protobuf
syntax = "proto3";message ExampleMessage {  int32 id = 1;}
  1. 然后我们尝试将之前序列化的字节流反序列化为新的 ExampleMessage:
go
newMsg := &ExampleMessage{}_ = proto.Unmarshal(data, newMsg)

在这个例子中,name 字段在 ExampleMessage 的新定义中不存在,所以它是一个未知字段。当我们解析字节流时,name 字段的值会被忽略,不会被包含在 newMsg 中。但是如果我们再次将 newMsg 序列化,name 字段还会被包含在序列化后的字节流中。

保留字段

在 Protocol Buffer 中,有时可能需要改变一个消息类型的定义,比如删除一个字段或者修改字段的类型。这些改动可能会使得新的定义和旧的定义不兼容,导致解析出错。为了避免这种情况,Protocol Buffer 提供了保留字段(Reserved Fields)的特性。

如果你在一个消息类型中保留了一个字段,那么在这个消息类型的任何后续定义中都不能再使用这个字段的编号或者名称。

为了便于理解,以下是一个示例:

protobuf
message ExampleMessage {  reserved 2, 3;  reserved "foo", "bar";  int32 id = 1;  // 已删除或注释的字段:  // int32 foo = 2;  // string bar = 3;}

在这个例子中,我们在 ExampleMessage 中保留了编号为 2 和 3 的字段,以及名称为 “foo” 和 “bar” 的字段。这意味着在 ExampleMessage 的任何后续定义中,我们都不能再使用这些字段编号和字段名称。

如果我们尝试使用被保留的字段编号字段名称,编译器就会报错。例如,下面的定义就会导致编译错误:

protobuf
message ExampleMessage {  int32 qux = 2;  // 错误:字段编号 2 已经被保留  string bar = 4;  // 错误:字段名称 "bar" 已经被保留}

更新消息类型

在 Protocol Buffers 中,更新消息类型通常涉及添加新的字段或删除不再需要的字段。当你更改了消息类型的定义后,新的和旧的代码必须能够相互兼容,以确保系统的平稳运行。以下是一些注意事项:

  1. 添加新字段:当你添加新字段时,你应该为它分配一个新的、尚未使用的字段编号。旧代码读取包含新字段的消息时,会将新字段视为未知并忽略它。新代码读取不包含新字段的消息时,将为新字段使用默认值。
  2. 删除字段:当你不再需要某个字段时,你应该删除该字段的代码,并在消息中用 reserved 保留该字段的编号和名称。这样可以防止未来不小心重新使用这个编号或名称。
  3. 不要更改字段的编号和数据类型:如果你更改了字段的编号或数据类型,新的和旧的代码就可能无法正确解析彼此的消息。这可能导致数据丢失或其他错误。
  4. 枚举:如果你需要更新枚举类型,你可以添加新的枚举值,但不应删除现有的枚举值,也不应更改枚举值的名称或编号。这是因为旧的代码可能仍然在使用被删除或更改的枚举值。

总的来说,更新 Protocol Buffers 的消息类型需要小心,并遵循一些规则,以确保新的和旧的代码能够相互兼容。

Any

Any 是 Protocol Buffers 中的一个特殊类型,它可以存储任何类型的消息。Any 包含两部分信息:一个是消息的全名(包括包名和消息名),一个是消息的字节串。Any 类型的主要用途是让你可以将任何消息作为字段存储在另一个消息中,而不需要事先知道这个消息的具体类型。

为了便于理解,以下是一个示例:

  1. 首先,定义消息:
protobuf
import "google/protobuf/any.proto";message MessageA {  int32 a = 1;}message Wrapper {  google.protobuf.Any any = 1;}
  1. 然后,我们可以将 MessageA 的实例存储在 wrapper 中,再将 wrapper 进行序列化:
go
msgA := &MessageA{a: 123}anyA, _ := anypb.New(msgA)wrapper := &Wrapper{any: anyA}data, _ := proto.Marshal(wrapper)
  1. 之后,我们可以从 wrapper 中取出 Any 类型的字段,并将它转换回原来的类型:
go
newWrapper := &Wrapper{}_ = proto.Unmarshal(data, newWrapper)newMsgA := &MessageA{}if err := anypb.UnmarshalTo(newWrapper.any, newMsgA, proto.UnmarshalOptions{}); err == nil {  fmt.Printf("MessageA: %v\n", newMsgA)} else {  fmt.Printf("Error: %v\n", err)}

在这个例子当中,我们使用 google.protobuf.Any 作为 Wrapper 的一部分,这让我们可以将任何类型的消息存储在 Wrapper 中。最后打印的结果为:MessageA: a:123

oneof

在 Protocol Buffers v3 中,oneof 是一种特殊的字段类型,表示在一组字段中只能有一个设置值,或者说其中的字段是互斥的。这可以用于表示"或"关系,比如一个对象可以是 A 类型或者 B 类型,但不能同时是两种。

为了方便理解,举个例子:

  1. 先定义一个 Student 类型,这个类型包含一个 oneof 字段,可以是 home_address(家庭地址)或 dorm_address(宿舍地址)。
protobuf
syntax = "proto3";message Student {    string name = 1;    int32 age = 2;    oneof address {        string home_address = 3;        string dorm_address = 4;    }}
  1. 然后使用 protoc 编译器将文件进行编译后,就可以使用 go 语言来使用 Student 类型。
go
func main() {	// 创建一个 Student 实例	student := &demo.Student{		Name: "张三",		Age:  20,		Address: &demo.Student_HomeAddress{			HomeAddress: "翻斗大街翻斗花园2号楼1001室",		},	}	// 序列化 Student 对象	data, err := proto.Marshal(student)	if err != nil {		log.Fatal("Marshaling error: ", err)	}	// 创建一个新的 Student 对象	newStudent := &demo.Student{}	// 反序列化数据到新的 Student 对象	err = proto.Unmarshal(data, newStudent)	if err != nil {		log.Fatal("Unmarshal error: ", err)	}	// 打印新的 Student 对象	fmt.Println(newStudent)	// 单独打印地址信息	switch addr := newStudent.Address.(type) {	case *demo.Student_HomeAddress:		fmt.Println("Home address:", addr.HomeAddress)	case *demo.Student_DormAddress:		fmt.Println("Dorm address:", addr.DormAddress)	default:		fmt.Println("No address")	}}

在这个例子中,我们首先创建了一个 Student 对象,然后将这个对象序列化为字节流,再将这个字节流反序列化为一个新的 Student 对象。最后,我们使用类型断言和 switch 语句来检查 oneof 字段的类型,并打印相应的地址。

运行结果如下:

text
name:"张三"  age:20  home_address:"翻斗大街翻斗花园2号楼1001室"Home address: 翻斗大街翻斗花园2号楼1001室

JSON 映射

在 Protocol Buffers v3 中,JSON 映射是指将 Protocol Buffers 消息类型与 JSON 对象之间的转换规则。这种映射允许开发者方便地在 Protocol Buffers 和 JSON 之间进行数据转换。

为了便于理解,举个例子:

  1. 首先,定义一个 Protobuf 消息类型 Student:
protobuf
message Student {  string name = 1;  int32 age = 2;}
  1. 然后,就可以使用 protojson 包来将 Protobuf 消息转换为 JSON,或者将 JSON 转换为 Protobuf 消息:
go
func main() {	student := &demo.Student{Name: "张三", Age: 20}	// 将 Student 消息转换为 JSON	jsonData, err := protojson.Marshal(student)	if err != nil {		log.Fatal(err)	}	fmt.Println(string(jsonData))	// 将 JSON 转换为 Student 消息	jsonData = []byte(`{"name":"李四","age":21}`)	newStudent := &demo.Student{}	if err := protojson.Unmarshal(jsonData, newStudent); err != nil {		log.Fatal(err)	}	fmt.Printf("Name: %s, Age: %d\n", newStudent.GetName(), newStudent.GetAge())}

运行结果如下:

text
{"name":"张三","age":20}Name: 李四, Age: 21

定义服务

在 Protocol Buffers v3 中,你可以使用 service 关键字来定义一个服务。服务由一组 RPC(远程过程调用)方法组成,每个方法都有一个指定的请求类型和响应类型。

protobuf
message StudentRequest {  string id = 1;}message GetStudentResponse {  string name = 1;  int32 age = 2;}message CreateStudentResponse {  bool success = 1;}service StudentService {  rpc GetStudent(StudentRequest) returns (GetStudentResponse) {}  rpc CreateStudent(StudentRequest) returns (CreateStudentResponse) {}}

编译 gRPC 服务

  • 在 Go 的 Protocol Buffers API v2 版本及以后,原来的插件(protoc-gen-go)将不再提供对 gRPC 服务的生成。

  • 在 Protocol Buffers API v2 中,通过新的模块 google.golang.org/protobuf(protobuf 的 Go 实现)提供了许多增强的功能和改进,与旧模块 github.com/golang/protobuf 相区分。这个新模块能更好地与 Go 语言的特性和生态系统集成。

  • 所以,生成 protobuf 和 gRPC 代码的推荐做法就是分别使用 protoc-gen-goprotoc-gen-go-grpc这两个插件。

  • 安装插件:

    bash
    go install google.golang.org/protobuf/cmd/protoc-gen-go@latestgo install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

    这样,protoc 命令也发生了变化,从旧的单一插件命令变为了现在两个独立插件的形式:

    • protoc --go_out=. demo.proto 用于生成 protobuf 消息结构的代码。
    • protoc --go-grpc_out=. demo.proto 用于生成 gRPC 服务的代码。

    但是这两个命令还是可以合并为一个命令:protoc --go_out=. --go-grpc_out=. demo.proto