背景

Protobuf 是目前非常主流的二进制序列化格式,GRPC 默认使用 Protobuf v3 格式,下面是 Protobuf 消息定义的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# proto2
message Account {
	required string name = 1;    # 必需
    optional double profit_rate = 2 [default=-1.0];  # 可选,默认值修改成 -1.0,有 hasProfitRate()
}

# proto3
message Account {
	string name = 1;			# 可选,默认值为空字符串,无 hasName()
	double profit_rate = 2;		# 可选,默认值为 0.0,无 hasProfitRate()
}

在 Protobuf 2 中,消息的字段可以加 required 和 optional 修饰符,也支持 default 修饰符指定默认值。默认配置下,一个 optional 字段如果没有设置,或者显式设置成了默认值,在序列化成二进制格式时,这个字段会被去掉,导致反序列化后,无法区分是当初没有设置还是设置成了默认值但序列化时被去掉了,即使 Protobuf 2 对于原始数据类型字段都有 hasXxx() 方法,在反序列化后,对于这个“缺失”字段,hasXxx() 总是 false——失去了其判定意义。

在 Protobuf 3 中,更进一步,直接去掉了 required 和 optional 修饰符,所有字段都是 optional 的, 而且对于原始数据类型字段,压根不提供 hasXxx() 方法。来自 Google 的 GRPC 核心成员Eric Anderson 在 StackOverflow 网站很好的解释了这个设计决策的原因:Why required and optional is removed in Protocol Buffers 3 因此,在 Protobuf 3 中,同学们往往有一个疑问:比如收益率字段,怎么知道是收益率还没算出来(值为 NULL),还是收益率是 0.0 呢?两种情况下 getProfitRate() 都是返回 0.0。

Protobuf 2 中有个设置选项,可以让序列化时保留显式设置的默认值,但 GRPC 主流使用的 Protobuf 3,所以下面只讲述 Protobuf 3 中的解决方案,大部分来自伟大的 StackOverflow 网站:How to define an optional field in protobuf 3

字段含义和默认值区分

Protobuf 3 为每个字段都提供默认值,除了 Eric 提到的考虑,这也是个极好的编程实践,与业界逐渐意识到 null 的危害而转向 Optional 类型相呼应。 原始数据类型保证不出现 null,这会极大的简化代码判断,提高健壮性。

绝大部份情况下,“没设置”跟默认 0 / 0.0 / false / "" 等价,是不会破坏业务逻辑的,比如“未收取手续费”跟“收取了 0.0 元手续费”是一个意思,如果业务逻辑一定要区分,比如收益率,可以考虑用特殊值区分,比如 -1.0,Double.MAX_VALUE 等,这跟大家习惯的函数返回值既表示错误也表示正常返回值的做法类似:open() 函数返回 -1 表示失败,否则表示成功。

另一个策略是把紧密相关的字段打包成消息类型,由于不再是原始数据类型,比如 profit_rate_with_date,就可以用 hasXxx() 判断了。注意不能用 getProfitRateWithDate() == null 判断,因为没有显式设置时,getProfitRateWithDate() 返回 default instance,而且 setProfitRateWithDate(null) 也是不允许的,这背后的设计考虑显而易见。

增加标识字段

众所周知,在Go中数字类型的默认值为0(这里仅以数字类型举例),这在某些场景下往往会引起一定的歧义。

以is_show字段为例,如果没有该字段表示不更新DB中的数据,如果有该字段且值为0则表示更新DB中的数据为不可见,如果有该字段且值为1则表示更新DB中的数据为可见。 上述场景中,实际要解决的问题是如何区分默认值和缺失字段。增加标识字段是通过额外增加一个字段来达到区分的目的。

例如:增加一个has_show_field字段标识is_show是否为有效值。如果has_show_field为true则is_show为有效值,否则认为is_show未设置值。

此方案虽然直白,但每次设置is_show的值时还需设置has_show_field的值,甚是麻烦故笔者十分不推荐。

使用 oneof

oneof 的用意是达到 C 语言 union 数据类型的效果,但是诸多大佬还是发现它可以标识缺失字段。

1
2
3
4
5
6
7
8
9
message Status {
  oneof show {
    int32 is_show = 1;
  }
}
message Test {
    int32 bar = 1;
    Status st = 2;
}

上述proto文件生成对应go文件后,Test.St为Status的指针类型,故通过此方案可以区分默认值和缺失字段。但是笔者认为此方案做json序列化时十分不友好,下面是笔者的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// oneof to json
ot1 := oneof.Test{
  Bar: 1,
  St: &oneof.Status{
    Show: &oneof.Status_IsShow{
      IsShow: 1,
    },
  },
}
bts, err := json.Marshal(ot1)
fmt.Println(string(bts), err)
// json to oneof failed
jsonStr := `{"bar":1,"st":{"Show":{"is_show":1}}}`
var ot2 oneof.Test
fmt.Println(json.Unmarshal([]byte(jsonStr), &ot2))

上述输出结果如下:

1
2
{"bar":1,"st":{"Show":{"is_show":1}}} <nil>
json: cannot unmarshal object into Go struct field Status.st.Show of type oneof.isStatus_Show

通过上述输出知,oneof的json.Marshal输出结果会额外多一层,而json.Unmarshal还会失败,因此使用oneof时需谨慎。

使用 wrapper 类型

这应该是google官方提出的解决方案,我们看看下面的例子:

1
2
3
4
5
6
7
8
import "google/protobuf/wrappers.proto";
message Status {
    google.protobuf.Int32Value is_show = 1;
}
message Test {
    int32 bar = 1;
    Status st = 2;
}

使用此方案需要引入google/protobuf/wrappers.proto。此方案生成对应go文件后,Test.St也是Status的指针类型。同样,我们也看一下它的json序列化效果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
wra1 := wrapper.Test{
  Bar: 1,
  St: &wrapper.Status{
    IsShow: wrapperspb.Int32(1),
  },
}
bts, err = json.Marshal(wra1)
fmt.Println(string(bts), err)
jsonStr = `{"bar":1,"st":{"is_show":{"value":1}}}`
// 可正常转json
var wra2 wrapper.Test
fmt.Println(json.Unmarshal([]byte(jsonStr), &wra2))

上述输出结果如下:

1
2
{"bar":1,"st":{"is_show":{"value":1}}} <nil>
<nil>

和oneof方案相比wrapper方案的json反序列化是没问题的,但是json.Marshal的输出结果也会额外多一层。

使用optional标签

前面几个方案估计在实践中还是不够尽善尽美。于是2020年5月16日protoc v3.12.0发布,该编译器允许proto3的字段也可使用 optional修饰。

下面看看例子:

1
2
3
4
5
6
7
message Status {
  optional int32 is_show = 1;
}
message Test {
    int32 bar = 1;
    Status st = 2;
}

此方案需要使用 protoc 3.15版本以上。下面继续看看该方案的json序列化效果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var isShow int32 = 1
p3o1 := p3optional.Test{
  Bar: 1,
  St:  &p3optional.Status{IsShow: &isShow},
}
bts, err = json.Marshal(p3o1)
fmt.Println(string(bts), err)
var p3o2 p3optional.Test
jsonStr = `{"bar":1,"st":{"is_show":1}}`
fmt.Println(json.Unmarshal([]byte(jsonStr), &p3o2))

上述输出结果如下:

1
2
{"bar":1,"st":{"is_show":1}} <nil>
<nil>

据上述结果知,此方案与oneof以及wrapper方案的json序列化相比更加符合预期.

总结

  1. 尽量缩小需要区分缺失值的地方
  2. 坚持用 hasXxx() 判断是否缺失值,这是 Protobuf 通行方式,不要用 Xxx == null or undefined 来判断,不具备可移植性

参考

区分 Protobuf 中缺失值和默认值 区分Protobuf 3中缺失值和默认值