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 objectValue是一个 oneof:null_value/number_value/string_value/bool_value/struct_value/list_valueListValue=repeated ValueNullValue是一个只有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 篇提过,repeated 和 map 没有存在性概念,「清空」和「不改」无法区分。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
BoolValue、Int32Value、StringValue 等九个包装类型,作用是给标量字段提供存在性。在 optional 可用(3.15+)之后基本被取代,只在需要 JSON null 时仍有价值。详见第 3 篇第 7 节。
8. 官方 JSON 映射规则
Protobuf 定义了一套标准的 JSON 映射,gRPC 网关、protojson、各语言实现都遵循它。这张表里有几条会让第一次见的人很意外:
| proto 类型 | JSON 类型 | 说明 |
|---|---|---|
message |
object | |
| 字段名 | lowerCamelCase | result_per_page → resultPerPage;解析时两种写法都接受 |
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";
}
}
可扩展的目标有:FileOptions、MessageOptions、FieldOptions、EnumOptions、EnumValueOptions、ServiceOptions、MethodOptions、OneofOptions。
扩展号的规矩:50000–99999 保留给内部/私有使用,随便挑不会冲突;如果你要对外公开发布定义了扩展的 .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 号段;protovalidate是required移除后的现代替代品。
下一篇是最后的工程实践篇:buf 工具链与 Go API 的正确用法。
系列目录
- 从 wire format 开始:一个字节一个字节读懂编码
- proto3 语法完全指南
- 存在性 presence:Protobuf 最难的一章
- schema 演进与兼容性:哪些改动会炸
- Well-Known Types 与 JSON 映射(本篇)
- Go 工程化实践:buf 工具链与 API 用法
- proto2 与 Protobuf Editions
xingliuhua