本文介绍了在Go语言中如何使用oneof字段以及如何通过使用google/protobuf/wrappers.proto中定义的类型区分默认值和没有传值;最后演示了Go语言中借助fieldmask-utils库使用google/protobuf/field_mask.proto实现部分更新的方法。

oneof

如果你有一条包含多个字段的消息,并且最多同时设置其中一个字段,那么你可以通过使用oneof来实现并节省内存。

oneof字段类似于常规字段,只不过oneof中的所有字段共享内存,而且最多可以同时设置一个字段。设置其中的任何成员都会自动清除所有其他成员。

可以在oneof中添加除了map字段和repeated字段外的任何类型的字段。

protobuf 定义

假设我的博客系统支持为读者朋友们发送博客更新的通知信息,系统支持通过邮件和短信两个方式发送通知。但每一次只允许使用一种方式发送通知。

在这个场景下我们就可以使用oneof字段来定义通知的方式——notice_way

// 通知读者的消息
message NoticeReaderRequest{
    string msg = 1;
    oneof notice_way{
        string email = 2;
        string phone = 3;
    }
}

client端代码

Go语言创建oneof字段的client端示例代码。

// 使用邮件通知的请求消息
noticeReq := proto.NoticeReaderRequest{
	Msg: "李文周的博客更新啦~",
	NoticeWay: &proto.NoticeReaderRequest_Email{
		Email: "123@xx.com",
	},
}
// 使用短信通知的请求消息
noticeReq2 := proto.NoticeReaderRequest{
	Msg: "李文周的博客更新啦~",
	NoticeWay: &proto.NoticeReaderRequest_Phone{
		Phone: "123456789",
	},
}

server端代码

Go语言操作oneof字段的server端示例代码。下面的代码中使用switch case的方式,根据请求消息中的通知类型选择执行不同的业务逻辑。

// ... liwenzhou.com ...

// 根据`NoticeWay`的不同而执行不同的操作
switch v := noticeReq.NoticeWay.(type) {
case *proto.NoticeReaderRequest_Email:
	noticeWithEmail(v)
case *proto.NoticeReaderRequest_Phone:
	noticeWithPhone(v)
}

// ... liwenzhou.com ...

// 发送通知相关的功能函数
func noticeWithEmail(in *proto.NoticeReaderRequest_Email) {
	fmt.Printf("notice reader by email:%v\n", in.Email)
}

func noticeWithPhone(in *proto.NoticeReaderRequest_Phone) {
	fmt.Printf("notice reader by phone:%v\n", in.Phone)
}

WrapValue

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

例如,当我们有如下消息定义时,我们拿到一个book消息,当book.Price = 0时我们没办法区分book.Price字段是未赋值还是被赋值为0。

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

protobuf 定义

类似这种场景推荐使用google/protobuf/wrappers.proto中定义的WrapValue,本质上就是使用自定义message代替基本类型。

// google/protobuf/wrappers.proto

// ...

// Wrapper message for `float`.
//
// The JSON representation for `FloatValue` is JSON number.
message FloatValue {
  // The float value.
  float value = 1;
}

// Wrapper message for `int64`.
//
// The JSON representation for `Int64Value` is JSON string.
message Int64Value {
  // The int64 value.
  int64 value = 1;
}

// ... 

在这个示例中,我们就可以使用Int64Value代替int64,修改后的protobuf文件如下。

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

client端代码

使用了wrappers.proto中定义的包装类型后,我们在赋值的时候就需要额外包一层。

import "google.golang.org/protobuf/types/known/wrapperspb"

book := proto.Book{
	Title: "《跟七米学Go语言》",
	Price: &wrapperspb.Int64Value{Value: 9900},
}

server端代码

WrapValue本质上类似于标准库sql中定义的sql.NullInt64sql.NullString,即将基本数据类型包装为一个结构体类型。在使用时通过判断某个字段是否为nil(空指针)来区分该字段是否被赋值。

if book.GetPrice() == nil {  // price没赋值
	fmt.Println("book with no price")
} else {
	fmt.Printf("book with price:%v\n", book.GetPrice().GetValue())
}

v3.15.0+使用optional

Protobuf v3.15.0 版本之后又支持使用optional显式指定字段为可选。

下面的示例中,我们使用optional标识price为可选字段。

message Book {
    string title = 1;
    string author = 2;
    //google.protobuf.Int64Value price = 3;
    optional int64 price = 3;  // 使用optional
}

修改了proto文件后,重新编译。

client端代码

现在price字段就是*int64类型了,我们需要使用google.golang.org/protobuf/proto包提供的系列函数完成赋值操作。

import "google.golang.org/protobuf/proto"

book := proto.Book{
	Title: "《跟七米学Go语言》",
	Price: proto.Int64(9900),
}

server端代码

如果需要判断price字段是否赋值,可以判断是否为nil

if book.Price == nil {  // price没赋值
	fmt.Println("book with no price")
} else {
	fmt.Printf("book with price:%v\n", book.GetPrice())
}

FieldMask

假设现在需要实现一个更新书籍信息接口,我们可能会定义如下更新书籍的消息。

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

但是如果我们的Book中定义有很多很多字段时,我们不太可能每次请求都去全量更新Book的每个字段,因为通常每次操作只会更新1到2个字段。

那么我们该如何确定每次更新操作涉及到了哪些具体字段呢?

答案是使用google/protobuf/field_mask.proto,它能够记录在一次更新请求中涉及到的具体字段路径。

为了实现一个支持部分更新的接口,我们把UpdateBookRequest消息修改如下。

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

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

client端代码

我们通过paths记录本次更新的字段路径,如果是嵌套的消息类型则通过x.y的方式标识。

import "google.golang.org/protobuf/types/known/fieldmaskpb"

paths := []string{"title", "read"} // 记录更新的字段路径
updateReq := proto.UpdateBookRequest{
	Book: &proto.Book{
		Title: "《跟七米学Go语言》",
		Read:  true,
	},
	UpdateMask: &fieldmaskpb.FieldMask{Paths: paths},
}

server端代码

在收到更新消息后,我们需要根据UpdateMask字段中记录的更新路径去读取更新数据。这里借助第三方库github.com/mennanov/fieldmask-utils实现。

import "github.com/golang/protobuf/protoc-gen-go/generator"
import fieldmask_utils "github.com/mennanov/fieldmask-utils"

mask, _ := fieldmask_utils.MaskFromProtoFieldMask(updateReq.UpdateMask, generator.CamelCase)
var bookDst = make(map[string]interface{})
// 将数据读取到map[string]interface{}
// fieldmask-utils支持读取到结构体等,更多用法可查看文档。
fieldmask_utils.StructToMap(mask, updateReq.Book, bookDst)
// do update with bookDst
fmt.Printf("bookDst:%#v\n", bookDst)

2022-11-20更新:由于github.com/golang/protobuf/protoc-gen-go/generator包已弃用,而MaskFromProtoFieldMask函数(签名如下)

func MaskFromProtoFieldMask(fm *field_mask.FieldMask, naming func(string) string) (Mask, error)

接收的naming参数本质上是一个将字段掩码字段名映射到 Go 结构中使用的名称的函数,它必须根据你的实际需求实现。

例如在我们这个示例中,还可以使用github.com/iancoleman/strcase包提供的ToCamel方法:

import "github.com/iancoleman/strcase"
import fieldmask_utils "github.com/mennanov/fieldmask-utils"

mask, _ := fieldmask_utils.MaskFromProtoFieldMask(updateReq.UpdateMask, strcase.ToCamel)
var bookDst = make(map[string]interface{})
// 将数据读取到map[string]interface{}
// fieldmask-utils支持读取到结构体等,更多用法可查看文档。
fieldmask_utils.StructToMap(mask, updateReq.Book, bookDst)
// do update with bookDst
fmt.Printf("bookDst:%#v\n", bookDst)

参考资料:


扫码关注微信公众号