oneof、WrapValue 和 FieldMask

判断零值

在 go 语言中,区分一个字段的值是零值还是赋予和零值相同的值的方法有:

  1. 使用结构体,判断是否为 nil 来判断。如果为 nil,则是零值。
  2. 使用指针的方式,还是判断是否为 nil 来判断。如果为 nil,则是零值。
1
2
3
4
type Book struct{
Price sql.NullInt64 // 使用结构体
Desc *string // 使用指针
}

oneof

oneof是Protocol Buffers中的一个关键字,用于定义一组互斥的字段。具体来说,oneof语句会告诉编译器在这个oneof语句所在的message中,只能同时存在这些字段中的一个。

例如,我们可以如下定义一个带有oneof字段的message:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
syntax = "proto3";

option go_package="oneof/pb";

package oneof;

message MyMessage {
oneof foo {
int32 option1 = 1;
string option2 = 2;
bool option3 = 3;
}
}

// protoc --proto_path=pb --go_out=pb --go_opt=paths=source_relative oneof.proto

上述代码中,我们定义了一个MyMessage消息,其中包含了一个foo字段,它被定义成了一个oneof类型,包含了三个可能的选项:option1option2option3。 这意味着,在这个消息中,只能同时出现option1option2option3中的一个,并且任何时候只有一个选项是有效的。

使用oneof可以使消息更加紧凑和易于处理,因为只需要考虑每个oneof中的一个字段,而不需要处理其他的字段。

客户端使用 oneof

在使用了 protoc 命令生成 go 文件后,将 oneof 将字段生成了MyMessage_Option1,MyMessage_Option2,MyMessage_Option3 类似的选项。创建 MyMessage 的时候,直接选取一个创建即可。

1
2
3
4
5
6
7
// 模拟客户端如何使用 oneof 字段

// 使用 Option3
message1 := pb.MyMessage{Foo: &pb.MyMessage_Option3{Option3: false}}
// 使用 Option1
message2 := pb.MyMessage{Foo: &pb.MyMessage_Option1{Option1: 100}}
fmt.Println(message1, message2)

服务端使用 oneof

服务端接收到了 MyMessage 后,需要对 Foo 字段进行断言处理。

1
2
3
4
5
6
7
8
9
// 模拟服务端接收到 oneof 字段后该如何处理
switch v := message1.Foo.(type) {
case *pb.MyMessage_Option1:
fmt.Printf("用户 Foo 字段传递了 Option1, %v", v)
case *pb.MyMessage_Option2:
fmt.Printf("用户 Foo 字段传递了 Option2, %v", v)
case *pb.MyMessage_Option3:
fmt.Printf("用户 Foo 字段传递了 Option3, %v", v)
}

完整示例

完整代码可参考:https://github.com/rexyan/Go-Microservice/tree/main/oneof

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
"fmt"
"oneof/pb"
)

func main() {
// 模拟客户端如何使用 oneof 字段

// 使用 Option3
message1 := pb.MyMessage{Foo: &pb.MyMessage_Option3{Option3: false}}
// 使用 Option1
message2 := pb.MyMessage{Foo: &pb.MyMessage_Option1{Option1: 100}}
fmt.Println(message1, message2)

// 模拟服务端接收到 oneof 字段后该如何处理
switch v := message1.Foo.(type) {
case *pb.MyMessage_Option1:
fmt.Printf("用户 Foo 字段传递了 Option1, %v", v)
case *pb.MyMessage_Option2:
fmt.Printf("用户 Foo 字段传递了 Option2, %v", v)
case *pb.MyMessage_Option3:
fmt.Printf("用户 Foo 字段传递了 Option3, %v", v)
}
}

WrapValue

完整代码可参考:https://github.com/rexyan/Go-Microservice/tree/main/WrapValue

protobuf v3在删除required的同时把optional也一起删除了(v3.15.0又加回来了),这使得我们没办法轻易判断某些字段究竟是未赋值还是其被赋值为零值。

例如,下面示例中,当book.Price = 0时我们没办法区分book.Price字段是未赋值还是被赋值为0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
syntax = "proto3";

option go_package="WrapValue/pb";

package WrapValue;

import "google/protobuf/wrappers.proto";

message Book {
string title = 1;
string author = 2;
google.protobuf.Int64Value price = 3;
}

// protoc --proto_path=pb --go_out=pb --go_opt=paths=source_relative WrapValue.proto

类似这种场景推荐使用google/protobuf/wrappers.proto中定义的 WrapValue,本质上就是使用自定义 message 代替基本类型。从而通过判断是否为 nil 来判断是零值还是赋值和零值相等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"WrapValue/pb"
"fmt"
"google.golang.org/protobuf/types/known/wrapperspb"
)

func main() {
// 客户端使用 Book
book := pb.Book{
Title: "《说明》",
Author: "张三",
Price: &wrapperspb.Int64Value{Value: 9900},
}

// 服务端解析值
// 判断 Price 封装的类型是否为 nil,来区分 Price 是零值还是没有值。
if book.GetPrice() != nil {
fmt.Println(book.GetPrice().GetValue())
}
}

以上是针对没有 optional 字段的时候的解决方法,当 protobuf 版本大于 v3.15.0 时,以上场景,我们可以使用 optional 字段来解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
syntax = "proto3";

option go_package="WrapValue/pb";

package WrapValue;

import "google/protobuf/wrappers.proto";

message Book {
string title = 1;
string author = 2;
google.protobuf.Int64Value price = 3;
}

message NewBook {
string title = 1;
string author = 2;
optional int64 price = 3;
}

// protoc --proto_path=pb --go_out=pb --go_opt=paths=source_relative WrapValue.proto

使用 optional 和使用 WrapValue 类似的方法,WrapValue 是使用封装一种类型来判断,而 optional 是使用指针来判断。两者都是判断是否为 nil,来区分是否是零值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import (
"WrapValue/pb"
"fmt"
"google.golang.org/protobuf/types/known/wrapperspb"
)

func main() {
// 客户端使用 Book
book := pb.Book{
Title: "《说明》",
Author: "张三",
Price: &wrapperspb.Int64Value{Value: 9900},
}

// 服务端解析值
// 判断 Price 封装的类型是否为 nil,来区分 Price 是零值还是没有值。
if book.GetPrice() != nil {
fmt.Println(book.GetPrice().GetValue())
}

// ====================使用 optional 字段=======================
// 客户端使用 Book
a := int64(100)
newBook := pb.NewBook{
Title: "《说明》",
Author: "张三",
Price: &a,
}

// 服务端解析值
if newBook.Price != nil {
fmt.Println(newBook.GetPrice())
}
}

FieldMask

完整代码可参考:https://github.com/rexyan/Go-Microservice/tree/main/FieldMask

假设现在需要实现一个更新书籍信息接口,但是如果我们的Book中定义有很多很多字段时,我们不太可能每次请求都去全量更新Book的每个字段,因为通常每次操作只会更新1到2个字段。

那么我们该如何确定每次更新操作涉及到了哪些具体字段呢?答案是使用google/protobuf/field_mask.proto,它能够记录在一次更新请求中涉及到的具体字段路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
syntax = "proto3";

option go_package="FieldMask/pb";
package FieldMask;

import "google/protobuf/field_mask.proto";

message Book{
string title=1;
int64 price=2;
}

// 更新 book 信息传递的消息
message UpdateBookRequest {
// 操作人
string op = 1;
// 要更新的书籍信息
Book book = 2;

// 记录要更新的字段
google.protobuf.FieldMask update_mask = 3;
}

// protoc --proto_path=pb --go_out=pb --go_opt=paths=source_relative hello.proto

在客户端发送更新请求的时候,使用 &fieldmaskpb.FieldMask{Paths: paths} 来传递要更新的字段信息。服务端借用第三方工具将 FieldMask 解析为具体的 go 的数据,例如下面中的 map

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import (
"FieldMask/pb"
"fmt"
"github.com/iancoleman/strcase"
fieldMaskUtils "github.com/mennanov/fieldmask-utils"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)

func main() {
// 模拟客户端如何使用 FieldMask 字段
// 设置要更新的字段(paths)
paths := []string{"price"}

// 构建更新 message UpdateBookRequest
updateBook := pb.UpdateBookRequest{
Op: "操作人",
Book: &pb.Book{
Title: "《示例设计》",
Price: 999,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: paths},
}

// 模拟服务端如何使用 FieldMask 字段
// 需要借助第三方工具 github.com/mennanov/fieldmask-utils 来解析 FieldMask
mask, err := fieldMaskUtils.MaskFromProtoFieldMask(updateBook.UpdateMask, strcase.ToCamel)
if err != nil {
return
}
dst := make(map[string]interface{})
// 将解析后的 mask 按照 Book 的格式,转换到 dst 中
err = fieldMaskUtils.StructToMap(mask, updateBook.Book, dst)
if err != nil {
return
}
fmt.Println(dst)
// map[Price:999]
}