区分 Protobuf 中缺失值和默认值
背景
Protobuf 是目前非常主流的二进制序列化格式,GRPC 默认使用 Protobuf v3 格式,下面是 Protobuf 消息定义的例子:
# 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
方案一:用特殊值区分,尽量避免 null
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) 也是不允许的,这背后的设计考虑显而易见。
方案二:显式定义 boolean 字段(不建议)
message Account {
string name = 1;
double profit_rate = 2;
bool has_profit_rate = 3;
}
这个办法很直白,但浪费内存和网络带宽,而且每次设置 profit_rate 之后,要记得也设置 has_profit_rate 字段,麻烦。
方案三:使用 oneof 黑科技
message Account {
string name = 1;
oneof profit_rate { # 可以加个 _present 后缀什么的
double profit_rate = 2;
}
oneof 的用意是达到 C 语言 union 数据类型的效果,但极富创造力的群众发现可以用 oneof 表达“缺失值”的概念。
- 在 Java 中,依然对于原始数据类型没有 hasXxx(),需要用 XxxCase() == XxxCase.XXX_NOT_SET 或者 XxxCase().getNumber == 0 判断是否设置了。
-
在 JavaScript 中,
很鸡贼的对 oneof 类型生成了 hasXxx()
,不清楚这种用法将来会不会一直支持下去。也可以用 Java 的类似语法判断。 注意不能用 getProfitRate() == null 判断,因为没设置的情况下,这个函数返回默认值 0.
在 JavaScript 里 msg.toObject() 虽然很方便转换成 plain object,但是对于 unset field,会转成默认值,失去 unset 语意。
方案四:使用 wrapper 类型
import "google/protobuf/wrappers.proto";