目录

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:

  1. 用户关不掉邮件通知。客户端传 email_notify = false,服务端的 if req.EmailNotify 判断为假,直接跳过,通知永远关不掉。
  2. 用户没法把每日额度设成 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 标量) 隐式 数值、boolstringbytesenum 都是
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.Boolproto.Int32proto.Int64proto.Uint32proto.Uint64proto.Float32proto.Float64proto.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;
}

完整列表:DoubleValueFloatValueInt64ValueUInt64ValueInt32ValueUInt32ValueBoolValueStringValueBytesValue

现在还需要用吗?大多数情况不需要了optional 更轻、更直观(wrapper 每个值都要多一层 message 的编码开销)。但有两个场景它仍然有价值:

  1. 需要在 JSON 里表达 null。wrapper 类型在 protojson 里映射为「裸值或 null」,而 optional 字段未设置时是整个 key 消失。如果你的 API 契约要求出现 "daily_limit": null,wrapper 才能做到。
  2. 兼容还在用 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 的字段repeatedmap 没有存在性概念
  • optional 通过 synthetic oneof 实现,这既保证了对存量反射代码的兼容,也解释了 Go 为什么生成指针。
  • 读字段用 GetXxx()(nil 安全),判断存在性用 字段 != nil,构造用 proto.Int32 这类辅助函数。
  • repeated / map 的「清空 vs 不改」只能靠 FieldMask 表达。
  • wrapper types 是 3.15 之前的老办法,现在基本被 optional 取代,只在需要 JSON null 时仍有价值。
  • 判断标准:「未设置」和「零值」是否导致不同行为。是则用 presence,否则别加。

下一篇讲 schema 演进——哪些改动是安全的,哪些会在线上静默炸掉。


系列目录

  1. 从 wire format 开始:一个字节一个字节读懂编码
  2. proto3 语法完全指南
  3. 存在性 presence:Protobuf 最难的一章(本篇)
  4. schema 演进与兼容性:哪些改动会炸
  5. Well-Known Types 与 JSON 映射
  6. Go 工程化实践:buf 工具链与 API 用法
  7. proto2 与 Protobuf Editions