bigywater.tech
gRPC · 28

proto3 默认值与 optional 在 RPC 接口里的坑

proto3 里 0、空字符串和没设置是一回事?想把一个字段清零、想区分“没填”和“填了 0”该怎么设计接口?这篇用字节级的实验把这些坑一个个摆出来。

0 字节
Req{} 与显式写了默认值的 Req{N: 0} 序列化后都是 0 字节,线上 DATA 只有 5 字节前缀
n=7 清不掉
普通 int32 的补丁 {n: 0} 经 proto.Merge 合并后,库里的 n 仍是 7
"big":"9007199254740993"
protojson 把 int64 输出成字符串

默认值不上线

实测 · 本机执行输出
  Req{}                              proto.Marshal 得到 0 字节;线上请求 DATA 帧载荷 [5] 字节;服务端看到 N=0
  Req{N: 0, Msg: ""}(显式写了默认值)        proto.Marshal 得到 0 字节;线上请求 DATA 帧载荷 [5] 字节;服务端看到 N=0
  Req{N: 1}                          proto.Marshal 得到 2 字节;线上请求 DATA 帧载荷 [7] 字节;服务端看到 N=1

proto3 的标量字段等于默认值时不序列化。接收方读到 0 时无法区分没设置和设成 0。

optional

实测 · 本机执行输出
  普通 int32,未设置           序列化 0 字节 ;接收方 Has(n)=false,值=0
  普通 int32,设为 0          序列化 0 字节 ;接收方 Has(n)=false,值=0
  optional int32,未设置     序列化 0 字节 ;接收方 Has(n)=false,值=0
  optional int32,设为 0    序列化 2 字节 0800;接收方 Has(n)=true,值=0

给字段加 optional 会记录是否设置:设为 0 也会上线,接收方可以用 Has 判断。需要区分有无的字段用它。

包装类型

实测 · 本机执行输出
  未设置              序列化 0 字节 ;接收方 Has(n)=false,值=-1(-1 表示没有)
  Int32Value{0}    序列化 2 字节 0a00;接收方 Has(n)=true,值=0(-1 表示没有)
  Int32Value{7}    序列化 4 字节 0a020807;接收方 Has(n)=true,值=7(-1 表示没有)

Int32Value 等包装类型靠子消息的存在表达有无,多 2 个字节,代码里多一层。是 optional 出现之前的做法。

部分更新

实测 · 本机执行输出
  普通 int32 n         补丁 {n: 0} 序列化为 0 字节;Merge 之后库里是 {msg=hello, n=7}
  optional int32 n   补丁 {n: 0} 序列化为 2 字节;Merge 之后库里是 {msg=hello, n=0}

用 Merge 合并补丁时,默认值不会覆盖已有值,清零做不到。用 optional 或 FieldMask 明确表达要改哪些字段。

子消息与 nil

实测 · 本机执行输出
  inner 未设置:序列化 0 字节 ;Has=false;读 inner.value(不 panic)=0
  inner 设置为空消息:序列化 2 字节 0a00;接收方 Has=true
  生成代码里对 nil 的 *pb.Req 调 GetN()/GetMsg():0 / ""(不 panic)

子消息有存在性:未设置和设为空消息不同。生成代码的 getter 对 nil 安全,返回默认值。

JSON

实测 · 本机执行输出
  默认:{"msg":"hi","big":"9007199254740993","x":1.5}
  EmitUnpopulated:{"msg":"hi","n":0,"big":"9007199254740993","x":1.5}
  JSON 里把 int64 写成数字 9007199254740993:err=<nil>;读到 {big=9007199254740993}
  JSON 里把 double 写成 "NaN":err=<nil>;读到 {x=NaN}

protojson 默认不输出零值;int64 输出成字符串,避免 JavaScript 丢精度。

分步演示

默认值
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1 / grpc-go v1.84.0 / protobuf-go v1.36.12 / golang.org/x/net v0.57.0 / protoc 36.2(Homebrew);服务端和客户端都是本机起的 Go 进程(127.0.0.1,端口 9800 以上,不访问任何外部服务);需要看线上字节时用我自己写的 TCP 抓包代理加 golang.org/x/net/http2 的 Framer 解析 HTTP/2 帧;计时用 time.Now 或 perf_counter,数字每次运行会有小幅抖动。线上 DATA 帧长度来自抓包代理;其余用 protobuf-go 在进程内构造的描述符(含 proto3_optional 的合成 oneof)与 dynamicpb 序列化、解析、Merge,字节为 proto.Marshal 真实输出;“补丁”是进程内模拟,没有经过 gRPC 的 Update 接口
    • 没有验证:FieldMask(fieldmaskpb)的实际用法与嵌套路径;oneof 的存在性语义;proto2 的 required/default 值;其它语言(Java、Python 的 has 方法)的行为;repeated/map 字段“空”与“未设置”的区别;自定义默认值(只有 proto2 有)
    京ICP备2026067502号-1京公网安备11010502063487号