Protobuf-03 存在性 presence:Protobuf 最难的一章
本文是 Protobuf 系列的第 3 篇。
语法看完就能写
.proto了,但真正在生产上写出 bug 的地方,八成集中在这一章:「这个字段到底是没传,还是传了个 0?」
1. 从一个真实的 bug 说起
有个用户设置接口,proto 长这样:
message UpdateUserRequest {
int64 user_id = 1;
string nickname = 2;
bool email_notify = 3;
int32 daily_limit = 4;
}
服务端实现得很自然:
func (s *Server) UpdateUser(ctx context.Context, req *pb.UpdateUserRequest) (*pb.User, error) {
u, _ := s.store.Get(req.UserId)
if req.Nickname != "" {
u.Nickname = req.Nickname
}
if req.EmailNotify { // ← 炸弹在这里
u.EmailNotify = req.EmailNotify
}
if req.DailyLimit != 0 { // ← 还有这里
u.DailyLimit = req.DailyLimit
}
return s.store.Save(u)
}
上线后收到两个 bug:
- 用户关不掉邮件通知。客户端传
email_notify = false,服务端的if req.EmailNotify判断为假,直接跳过,通知永远关不掉。 - 用户没法把每日额度设成 0(0 表示不限制)。同理,
daily_limit = 0被当成「没传」。
问题的根源不是代码写得糙,而是这个 proto 定义根本没有能力表达「没传」。bool 字段没设置和设成 false,在 proto3 的 singular 语义下、在序列化后的字节流里,是完全一样的东西——两者都不出现在字节流中。服务端不可能区分,写多少 if 都没用。
这就是 presence(存在性)问题。
2. 两种存在性模型
Protobuf 里字段有两种存在性语义:
| 隐式存在性 implicit presence |
显式存在性 explicit presence |
|
|---|---|---|
| 能否区分「未设置」和「零值」 | 不能 | 能 |
| 零值时是否序列化 | 不序列化 | 序列化(只要被赋过值) |
有无 has 语义 |
无 | 有 |
| Go 生成的类型 | string / int32 |
*string / *int32 |
关键在第二行:
// 隐式存在性字段
m1 := &pb.T{Count: 0}
b1, _ := proto.Marshal(m1)
fmt.Println(len(b1)) // 0 —— 空字节流!赋值 0 和没赋值,序列化结果一模一样
// 显式存在性字段(optional)
m2 := &pb.T{OptCount: proto.Int32(0)}
b2, _ := proto.Marshal(m2)
fmt.Println(len(b2)) // 2 —— tag + value,明确表达「我设置了,值是 0」
「零值不上线」是隐式存在性的定义性特征,也是它省空间的原因(一个全默认值的消息序列化后是 0 字节)。代价就是丢失了「设置过」这个信息。
3. proto3 里哪些字段有显式存在性
这张表建议记住,它比什么都实用:
| 字段形态 | 存在性 | 说明 |
|---|---|---|
int32 a = 1;(singular 标量) |
隐式 | 数值、bool、string、bytes、enum 都是 |
optional int32 a = 1; |
显式 | 3.15+ 可用 |
Foo a = 1;(message 类型) |
显式 | 不写 optional 也有,因为它天然可以是 nil |
oneof 的成员 |
显式 | oneof 本身就带「哪个被设置了」的信息 |
repeated int32 a = 1; |
无此概念 | 空列表 == 未设置,无法区分 |
map<string, int32> a = 1; |
无此概念 | 空 map == 未设置 |
三个容易记错的点:
① message 类型字段本来就有显式存在性。 这是很多人没意识到的:
message Profile {
string bio = 1; // 隐式:分不清「没填」和「填了空串」
Address address = 2; // 显式:nil 就是没填,&Address{} 是填了个空地址
}
所以早年(3.15 之前)想给标量拿到 presence,标准套路就是把它包进一个 message——这正是下面 wrapper types 的原理。
② repeated 和 map 没有存在性概念。 这不是缺陷,是设计:wire format 里 repeated 字段就是「若干条同号记录」,零条记录和不存在没有任何区别。后果是「清空列表」和「不修改列表」无法区分,这个问题只能靠 FieldMask 解决(见第 6 节)。
③ optional 加在 message 字段上是合法但无意义的,它本来就有 presence,加了不会有任何变化。
4. optional 是怎么实现的:synthetic oneof
proto3 加回 optional 时有个约束:不能破坏已有的反射代码。因为 proto3 的描述符里,singular 字段本来就被标成了 LABEL_OPTIONAL,而大量存量代码依赖「proto3 里 LABEL_OPTIONAL 意味着没有 presence」这个假设。如果直接改语义,老代码会静默丢弃新的存在性信息——这是数据丢失级别的 bug。
官方的解法很巧:把每个 optional 字段包装成一个只有一个成员的合成 oneof(synthetic oneof)。
message Profile {
optional string bio = 2;
}
在描述符层面,它等价于:
message Profile {
oneof _bio { // 合成的,你看不见
string bio = 2;
}
}
因为 oneof 本来就有明确的存在性语义,所有存量的反射、JSON、text format 实现都不用改一行代码就能正确处理 proto3 optional。
这也解释了 Go 生成代码的样子:
type Profile struct {
Bio *string `protobuf:"bytes,2,opt,name=bio,proto3,oneof"`
// ↑ 注意这个 oneof 标记
}
为什么是指针? 因为需要一个额外的状态表达「未设置」,而指针的 nil 天然可用。各语言选择不同:Go 用指针,Java 用 hasBio() 方法,Python 用 HasField()。
5. Go 里怎么用
message UpdateUserRequest {
int64 user_id = 1;
optional string nickname = 2;
optional bool email_notify = 3;
optional int32 daily_limit = 4;
}
开头那个 bug 现在可以正确修掉了:
func (s *Server) UpdateUser(ctx context.Context, req *pb.UpdateUserRequest) (*pb.User, error) {
u, err := s.store.Get(req.GetUserId())
if err != nil {
return nil, err
}
// 判断「是否设置」而不是「是否非零」
if req.Nickname != nil {
u.Nickname = req.GetNickname()
}
if req.EmailNotify != nil {
u.EmailNotify = req.GetEmailNotify() // false 也能正确写入
}
if req.DailyLimit != nil {
u.DailyLimit = req.GetDailyLimit() // 0 也能正确写入
}
return s.store.Save(u)
}
构造侧用 proto 包提供的辅助函数取指针,别自己搞临时变量:
req := &pb.UpdateUserRequest{
UserId: proto.Int64(1001),
EmailNotify: proto.Bool(false), // 明确表达「关闭」
DailyLimit: proto.Int32(0), // 明确表达「设为 0」
// Nickname 不设置 → nil → 表示「不修改昵称」
}
proto 包对每个标量类型都有对应的构造函数:proto.Bool、proto.Int32、proto.Int64、proto.Uint32、proto.Uint64、proto.Float32、proto.Float64、proto.String。
5.1 getter 对 nil 是安全的
这是 Go 生成代码里一个非常实用的性质:生成的 GetXxx() 方法内部带 nil 检查,对 nil receiver 也能安全调用。
var req *pb.UpdateUserRequest // nil
fmt.Println(req.GetUserId()) // 0,不 panic
var p *pb.Profile // nil
fmt.Println(p.GetAddress().GetCity()) // "" —— 整条链路都安全
所以读字段时一律用 GetXxx(),可以省掉大量 nil 判断;只在需要判断存在性时才直接访问字段并比较 nil。
5.2 关于 Opaque API 的演进
Go Protobuf 后来引入了 Opaque API(把结构体字段隐藏起来,改用 HasXxx() / SetXxx() / ClearXxx() 方法访问),存在性判断会变成:
if req.HasEmailNotify() {
u.EmailNotify = req.GetEmailNotify()
}
它同时提供 Open(传统)、Hybrid、Opaque 三种模式,便于渐进迁移。如果你的项目还在用传统的直接字段访问(绝大多数项目都是),本文的 != nil 写法继续有效;新项目值得了解一下 Opaque API 的存在。
6. repeated / map 的存在性问题:FieldMask
optional 解决不了 repeated。看这个需求:
message UpdateUserRequest {
int64 user_id = 1;
repeated string tags = 2;
}
客户端传了空的 tags,服务端应该理解为「清空所有标签」还是「不修改标签」?从消息本身无法判断,因为 repeated 没有存在性概念。
标准解法是 google.protobuf.FieldMask——把「要更新哪些字段」显式列出来:
import "google/protobuf/field_mask.proto";
message UpdateUserRequest {
User user = 1;
google.protobuf.FieldMask update_mask = 2;
}
// 客户端:明确说「我要更新 tags,且值是空列表」
req := &pb.UpdateUserRequest{
User: &pb.User{Id: 1001, Tags: nil},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"tags"}},
}
服务端遍历 update_mask.Paths 决定改哪些字段——在 mask 里就改(哪怕值是零值/空列表),不在 mask 里就不动。这也是 Google API 设计指南里 PATCH 语义的官方做法。FieldMask 的完整用法见第 5 篇。
7. wrapper types:3.15 之前的老办法
在 optional 可用之前,想给标量拿到存在性,办法是把它包进 message(因为 message 字段天然有 presence)。官方为此提供了一组包装类型:
import "google/protobuf/wrappers.proto";
message UpdateUserRequest {
google.protobuf.BoolValue email_notify = 3; // nil = 未设置
google.protobuf.Int32Value daily_limit = 4;
}
完整列表:DoubleValue、FloatValue、Int64Value、UInt64Value、Int32Value、UInt32Value、BoolValue、StringValue、BytesValue。
现在还需要用吗?大多数情况不需要了,optional 更轻、更直观(wrapper 每个值都要多一层 message 的编码开销)。但有两个场景它仍然有价值:
- 需要在 JSON 里表达
null。wrapper 类型在 protojson 里映射为「裸值或null」,而optional字段未设置时是整个 key 消失。如果你的 API 契约要求出现"daily_limit": null,wrapper 才能做到。 - 兼容还在用 3.15 以下 protoc 的下游。
8. 常见踩坑清单
① 用零值当哨兵值。 就是开头那个 bug。凡是「0 / 空串 / false 是合法业务值」的字段,一律需要显式存在性。
② 数据库层把零值写进去。 即使 proto 侧正确了,ORM 那层也常犯同样的错——用结构体零值判断「是否更新」。两层都要用 presence 判断。
③ 忘记 repeated 不能靠 optional 救。 optional repeated 是非法语法,编译报错。需要区分就上 FieldMask。
④ proto.Equal 会区分「未设置」和「设为零值」。 这是正确行为,但容易让测试断言意外失败:
a := &pb.T{} // OptCount 未设置
b := &pb.T{OptCount: proto.Int32(0)} // OptCount = 0
proto.Equal(a, b) // false
⑤ 显式存在性字段的零值会占字节。 大量高频小消息全用 optional 会有可观的体积和内存开销(每个字段多一次堆分配)。不要无脑全加。
⑥ 别把 optional 当校验用。 它只表达「有没有设置」,不表达「必须设置」。必填校验请放业务层或用 buf.validate 这类声明式校验。
⑦ 存在性信息在 JSON 转换中可能丢失。 protojson 默认省略未设置和零值字段;开了 EmitUnpopulated 后,未设置的 optional 字段会被输出成零值——一次 JSON 往返就可能把「未设置」变成「设为 0」。跨 JSON 边界传递部分更新语义时要特别小心。
9. 什么时候该用显式存在性
该用:
- 部分更新(PATCH)语义的请求体——这是最主要的场景。
- 布尔开关:
false几乎总是有意义的值。 - 数值 0 有业务含义:额度 0、评分 0、库存 0、价格 0。
- 映射外部可空数据:数据库的
NULL列、可选的第三方字段。 - 需要**区分「查询过但没有」和「没查过」**的场景。
不必用:
- 只读的响应体、事件/日志消息——它们描述的是「事实」,零值就是零值,没有「未设置」的概念。
- 内部高频消息、超大批量数据——省下的指针和分配是实打实的。
- 零值本身就代表「无」的字段,比如一个必然大于 0 的 ID。
一句话原则:如果「未设置」和「零值」在业务上会导致不同的行为,就必须用显式存在性;否则不要用。
10. 小结
- 隐式存在性(singular 标量)的定义性特征是零值不序列化,因此「未设置」和「零值」在字节流层面就是同一件事,服务端不可能区分。
- proto3 里天然有显式存在性的是:message 类型字段、oneof 成员、标了
optional的字段;repeated和map没有存在性概念。 optional通过 synthetic oneof 实现,这既保证了对存量反射代码的兼容,也解释了 Go 为什么生成指针。- 读字段用
GetXxx()(nil 安全),判断存在性用字段 != nil,构造用proto.Int32这类辅助函数。 - repeated / map 的「清空 vs 不改」只能靠 FieldMask 表达。
- wrapper types 是 3.15 之前的老办法,现在基本被
optional取代,只在需要 JSONnull时仍有价值。 - 判断标准:「未设置」和「零值」是否导致不同行为。是则用 presence,否则别加。
下一篇讲 schema 演进——哪些改动是安全的,哪些会在线上静默炸掉。
系列目录
- 从 wire format 开始:一个字节一个字节读懂编码
- proto3 语法完全指南
- 存在性 presence:Protobuf 最难的一章(本篇)
- schema 演进与兼容性:哪些改动会炸
- Well-Known Types 与 JSON 映射
- Go 工程化实践:buf 工具链与 API 用法
- proto2 与 Protobuf Editions
xingliuhua