目录

Protobuf-05 Well-Known Types 与 JSON 映射

本文是 Protobuf 系列的第 5 篇。

前面四篇讲的是「Protobuf 本身」,这一篇讲生态里那些你不该自己造的轮子:官方预定义的 Well-Known Types,以及 protobuf 与 JSON 之间的官方映射规则。

1. 为什么要用官方类型

看一段很常见的定义:

message Order {
  int64 created_at = 1;      // 单位是秒?毫秒?纳秒?时区?
  int32 timeout = 2;         // 秒?分钟?
}

这段代码的问题不是不能用,而是语义只存在于注释和口头约定里。跨团队、跨语言、跨半年之后,created_at 的单位一定会被搞错——毫秒当秒解析出 1970 年,是最经典的线上事故之一。

Well-Known Types(WKT)是官方随 protobuf 一起分发的一组预定义 message,放在 google/protobuf/ 下。用它们的好处不只是省事:

  • 语义写进了类型,不需要注释约定;
  • 各语言有原生映射(Go 的 time.Time、Java 的 Instant),不用手写转换;
  • JSON 表示是标准化的Timestamp 是 RFC 3339 字符串,而不是一个数字);
  • 生态工具(gRPC、grpc-gateway、各种网关)认识它们并特殊处理。

2. Timestamp 与 Duration

最常用的两个。

import "google/protobuf/timestamp.proto";
import "google/protobuf/duration.proto";

message Order {
  google.protobuf.Timestamp created_at = 1;
  google.protobuf.Duration timeout = 2;
}

它们内部都是 seconds(int64)+ nanos(int32)两个字段:

Timestamp Duration
语义 时间点,必须是 UTC,与时区无关 时间长度,可为负
起点 Unix epoch(1970-01-01T00:00:00Z)
有效范围 0001-01-01 ~ 9999-12-31 约 ±10000 年
nanos 约束 0 ~ 999,999,999 与 seconds 同号
JSON 表示 "1972-01-01T10:00:20.021Z" "3.000000001s" / "1.5s"

Go 里的转换是一行的事:

import (
    "google.golang.org/protobuf/types/known/timestamppb"
    "google.golang.org/protobuf/types/known/durationpb"
)

// 写
order := &pb.Order{
    CreatedAt: timestamppb.Now(),               // 或 timestamppb.New(t)
    Timeout:   durationpb.New(30 * time.Second),
}

// 读
t := order.GetCreatedAt().AsTime()          // time.Time(UTC)
d := order.GetTimeout().AsDuration()        // time.Duration

三个坑:

Timestamp 是 message,所以它有显式存在性(见第 3 篇)。nil 表示「未设置」,这通常正是你想要的——但要记得判断:

// nil 的 AsTime() 返回 1970-01-01,不会 panic,但几乎肯定不是你想要的值
if order.GetCreatedAt() != nil {
    t := order.GetCreatedAt().AsTime()
}

② 必须是 UTC。 timestamppb.New 会正确处理带时区的 time.Time,但如果你手工填 seconds,别忘了先转 UTC。反过来读出来的 AsTime() 也是 UTC,展示给用户前要自己转本地时区。

③ 校验。 从外部输入构造时,ts.CheckValid() 可以检查是否在有效范围内、nanos 是否合法。不校验的话,一个恶意的 nanos: -1 可能让下游某个语言的实现出问题。

3. Empty

不需要参数或返回值的 RPC 用它,而不是自己定义一个空 message:

import "google/protobuf/empty.proto";

service Health {
  rpc Ping(google.protobuf.Empty) returns (google.protobuf.Empty);
}

JSON 表示是 {}

不过实践中有个更值得推荐的做法:即使现在不需要参数,也定义自己的 PingRequest / PingResponse 空 message。因为将来要加字段时,Empty 是改不了的(它是 Google 的类型),你只能做一次破坏性变更。Google 自己的 API 设计指南也是这个建议。

4. Any:装任意类型

Any 是 proto3 里替代 proto2 扩展机制的方案,内部存「类型 URL + 序列化字节」:

import "google/protobuf/any.proto";

message ErrorStatus {
  string message = 1;
  repeated google.protobuf.Any details = 2;
}

Go 用法:

import "google.golang.org/protobuf/types/known/anypb"

// pack
detail := &pb.QuotaFailure{Subject: "user/1001"}
a, err := anypb.New(detail)
st := &pb.ErrorStatus{Message: "quota exceeded", Details: []*anypb.Any{a}}

// unpack:方式一,已知目标类型
var qf pb.QuotaFailure
if err := st.Details[0].UnmarshalTo(&qf); err != nil { /* 类型不匹配 */ }

// unpack:方式二,不知道类型,从全局注册表里找
msg, err := st.Details[0].UnmarshalNew()
switch m := msg.(type) {
case *pb.QuotaFailure:
    // ...
}

JSON 表示会多一个 @type 字段:

{
  "@type": "type.googleapis.com/example.v1.QuotaFailure",
  "subject": "user/1001"
}

如果被包装的类型自己有特殊 JSON 表示(比如 Timestamp),则放在 value 里:

{"@type": "type.googleapis.com/google.protobuf.Timestamp", "value": "1972-01-01T10:00:20.021Z"}

使用建议Any 会丢掉编译期类型安全,UnmarshalNew() 还依赖目标类型已被链接进二进制(否则找不到)。能用 oneof 穷举的场景就别用 Any——Any 适合的是真正开放的扩展点,比如错误详情、审计事件载荷、插件配置。

5. Struct / Value / ListValue:表达任意 JSON

需要一个「schema 未知的 JSON 块」时(比如用户自定义的 metadata),用这一组:

import "google/protobuf/struct.proto";

message Config {
  google.protobuf.Struct metadata = 1;   // 对应 JSON object
  google.protobuf.Value  anything = 2;   // 对应任意 JSON 值
}
  • Struct = map<string, Value>,对应 JSON object
  • Value 是一个 oneof:null_value / number_value / string_value / bool_value / struct_value / list_value
  • ListValue = repeated Value
  • NullValue 是一个只有 NULL_VALUE = 0 的枚举,用来表达 JSON 的 null

Go 里构造:

import "google.golang.org/protobuf/types/known/structpb"

s, err := structpb.NewStruct(map[string]any{
    "region":  "cn-north",
    "retries": 3,
    "nested":  map[string]any{"enabled": true},
})
cfg := &pb.Config{Metadata: s}

// 转回 Go map
m := cfg.GetMetadata().AsMap()

它们的 JSON 表示就是原生 JSON,非常自然。代价是完全没有类型检查和 schema 约束——用 Struct 等于放弃了 Protobuf 最大的优点,只在确实无法预知结构时才用。

6. FieldMask:部分更新

第 3 篇提过,repeatedmap 没有存在性概念,「清空」和「不改」无法区分。FieldMask 就是解法——把要更新的字段路径显式列出来:

import "google/protobuf/field_mask.proto";

message UpdateUserRequest {
  User user = 1;
  google.protobuf.FieldMask update_mask = 2;
}

路径语法:

  • 用字段名(proto 里的 snake_case)以点号连接子字段:user.display_name
  • 多个路径用逗号分隔
  • 指定父字段(如 user)表示整个子树
  • JSON 表示是一个逗号分隔的字符串,且路径转成 lowerCamelCase"displayName,photo.url"
import "google.golang.org/protobuf/types/known/fieldmaskpb"

req := &pb.UpdateUserRequest{
    User:       &pb.User{Id: 1001, Tags: nil},
    UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"tags"}},   // 明确表示「清空 tags」
}

服务端的处理原则:在 mask 里的字段就更新(哪怕值是零值/空列表),不在 mask 里的一律不动

for _, path := range req.GetUpdateMask().GetPaths() {
    switch path {
    case "display_name":
        u.DisplayName = req.GetUser().GetDisplayName()
    case "tags":
        u.Tags = req.GetUser().GetTags()      // nil 就是清空
    default:
        return nil, status.Errorf(codes.InvalidArgument, "unknown field path: %s", path)
    }
}

注意几点:

  • fieldmaskpb.New(msg, paths...)校验路径在该 message 里真实存在,比手写字符串安全。
  • 一定要处理未知路径(返回 InvalidArgument),否则客户端拼错字段名会变成静默的「什么都没更新」。
  • mask 为空时的语义要在 API 文档里写清楚:是「全量替换」还是「什么都不改」?Google 的惯例是空 mask 视为全量更新所有字段,但这一点各家不统一,必须明说。

7. wrappers

BoolValueInt32ValueStringValue 等九个包装类型,作用是给标量字段提供存在性。optional 可用(3.15+)之后基本被取代,只在需要 JSON null 时仍有价值。详见第 3 篇第 7 节

8. 官方 JSON 映射规则

Protobuf 定义了一套标准的 JSON 映射,gRPC 网关、protojson、各语言实现都遵循它。这张表里有几条会让第一次见的人很意外

proto 类型 JSON 类型 说明
message object
字段名 lowerCamelCase result_per_pageresultPerPage;解析时两种写法都接受
int32 uint32 float double number
int64 uint64 fixed64 sfixed64 string ⚠️ 因为 JS 的 number 存不下 64 位整数,会精度丢失
bool true / false
string string
bytes base64 字符串 标准 base64,带 padding
enum 枚举名字符串 解析时也接受数字;UseEnumNumbers 可强制输出数字
repeated array
map object key 一律转成字符串(即使 proto 里是 int32)
oneof 只出现被设置的那个成员
Timestamp RFC 3339 字符串 "1972-01-01T10:00:20.021Z"
Duration s 后缀的字符串 "1.5s"
Any object + @type 见第 4 节
Struct / Value 原生 JSON
FieldMask 逗号分隔的 camelCase 路径字符串 "displayName,photo"
wrappers 裸值或 null
Empty {}
NaN / Infinity "NaN" / "Infinity" / "-Infinity" 字符串,不是 number

两条行为规则:

  • 默认值字段默认被省略(不输出 key),可以用 EmitUnpopulated / EmitDefaults 强制输出。
  • 解析时 null 被接受,等同于该字段的默认值Value 类型除外,那里 null 是真的 null)。

int64 变字符串这一条是接口联调时最容易吵起来的地方:后端 proto 里明明写的 int64 id = 1,前端拿到的 JSON 里 "id": "1001" 是个字符串。这是规范行为,不是 bug,前端需要按字符串处理(或者后端在网关层单独转换)。

8.1 Go 里用 protojson

不要用 encoding/json 处理 protobuf 消息——它不认识 oneof、不认识 WKT 的特殊表示、也不会做 camelCase 转换,输出的东西不符合规范。一律用 protojson

import "google.golang.org/protobuf/encoding/protojson"

// 序列化
b, err := protojson.Marshal(msg)

// 带选项
opts := protojson.MarshalOptions{
    Multiline:       true,     // 换行
    Indent:          "  ",
    EmitUnpopulated: true,     // 输出零值字段(前端常要求)
    UseProtoNames:   true,     // 用 snake_case 而不是 camelCase
    UseEnumNumbers:  false,    // 枚举输出名字
}
b, err = opts.Marshal(msg)

// 反序列化
err = protojson.Unmarshal(b, msg)
err = protojson.UnmarshalOptions{DiscardUnknown: true}.Unmarshal(b, msg)  // 忽略未知字段

⚠️ protojson 的输出不保证字节稳定:Go 实现有意在缩进/空格上引入了不确定性,就是为了防止有人依赖输出的确切字节。所以:

  • 不要对 protojson 的输出做字符串比较来写测试,用 protojson.Unmarshal 回来再 proto.Equal,或者用 protocmp.Transform() 配合 cmp.Diff
  • 不要把它的输出拿去算签名或做缓存 key。

9. 自定义 option

option 不只有官方那些,你可以定义自己的——这就是 proto3 里唯一保留的 extend 用法。

定义(扩展 google.protobuf 的各种 Options message):

// annotations.proto
syntax = "proto3";
package mycompany.annotations;

import "google/protobuf/descriptor.proto";

extend google.protobuf.FieldOptions {
  bool sensitive = 50001;          // 标记敏感字段
}

extend google.protobuf.MethodOptions {
  string required_role = 50002;    // 标记接口所需角色
}

使用:

import "annotations.proto";

message User {
  string name = 1;
  string id_card = 2 [(mycompany.annotations.sensitive) = true];
}

service AdminService {
  rpc DeleteUser(DeleteUserRequest) returns (google.protobuf.Empty) {
    option (mycompany.annotations.required_role) = "admin";
  }
}

可扩展的目标有:FileOptionsMessageOptionsFieldOptionsEnumOptionsEnumValueOptionsServiceOptionsMethodOptionsOneofOptions

扩展号的规矩5000099999 保留给内部/私有使用,随便挑不会冲突;如果你要对外公开发布定义了扩展的 .proto,应该去 protobuf 官方的全局扩展号注册表登记,避免和别人撞号。

Go 里读取自定义 option(做代码生成、做统一鉴权中间件时常用):

import (
    "google.golang.org/protobuf/proto"
    "google.golang.org/protobuf/reflect/protoreflect"
)

func requiredRole(md protoreflect.MethodDescriptor) string {
    opts := md.Options().(*descriptorpb.MethodOptions)
    if proto.HasExtension(opts, annotationspb.E_RequiredRole) {
        return proto.GetExtension(opts, annotationspb.E_RequiredRole).(string)
    }
    return ""
}

生态里几个知名的自定义 option,都是这个机制:

option 来源 作用
google.api.http googleapis grpc-gateway 的 HTTP 路由映射
buf.validate.field protovalidate 声明式字段校验(required、范围、正则)
grpc.gateway.protoc_gen_openapiv2 grpc-gateway 生成 OpenAPI 文档

其中 protovalidate 值得专门推荐:它正好补上了 proto3 移除 required 后留下的空白,而且校验规则是可以随时改的运行时逻辑,不像 required 那样刻进 schema:

import "buf/validate/validate.proto";

message CreateUserRequest {
  string email = 1 [(buf.validate.field).string.email = true];
  int32  age   = 2 [(buf.validate.field).int32 = {gte: 0, lte: 150}];
}

10. 小结

  • 时间一律用 Timestamp / Duration,不要用裸 int64 ——单位和时区的语义应该由类型承载。注意 Timestamp 是 message(有存在性),且必须是 UTC。
  • 空请求/响应建议定义自己的空 message 而不是用 Empty,给将来加字段留路。
  • Any 用于真正开放的扩展点;能用 oneof 穷举就别用它。
  • Struct / Value 能装任意 JSON,代价是放弃全部类型约束。
  • FieldMask 是部分更新的标准方案,也是 repeated / map 「清空 vs 不改」的唯一解法;记得校验未知路径。
  • JSON 映射里最反直觉的三条:int64 变字符串bytes 变 base64字段名变 lowerCamelCase
  • Go 里处理 JSON 一律用 protojson,且不要依赖它的输出字节稳定
  • 自定义 option 是 proto3 保留的 extend 用法,内部用 50000–99999 号段;protovalidaterequired 移除后的现代替代品。

下一篇是最后的工程实践篇:buf 工具链与 Go API 的正确用法。


系列目录

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