网关层扛下 gRPC 和 RESTful 两套协议转换时,路由、序列化与错误码映射的完整落地步骤
网关层做 gRPC 和 RESTful 协议转换,最难的不是单个请求的格式互转,而是把路由发现、序列化协商、错误码映射三件事揉进一条稳定的链路里,让上游和下游都感知不到协议差异。我最近把 Envoy 从 1.26 升到 1.31,顺带重写了整个 transcoding 链路,下面直接按落地顺序把步骤拆开讲。
路由层:让 gRPC 和 REST 共用一套规则
先做路由统一,序列化和错误码映射才有附着点。很多人一上来就配 transcoder filter,结果路由没理清楚,请求打不到正确的 gRPC 方法上,全在 404 和 503 之间跳。
步骤一:给每个 gRPC 方法显式声明 HTTP 映射,不要依赖自动推断。
proto 文件里的 google.api.http annotation 是整条链路的起点。举个例子,一个用户服务的 proto 写成这样:
syntax = "proto3";
import "google/api/annotations.proto";
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse) {
option (google.api.http) = {
get: "/v1/users/{user_id}"
};
}
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) {
option (google.api.http) = {
get: "/v1/users"
additional_bindings {
post: "/v1/users:search"
body: "*"
}
};
}
}
这里的关键是 additional_bindings。ListUsers 这个 RPC 同时暴露了 GET 和 POST 两条 REST 路径,GET 走 query params,POST 走 body。不做 additional_bindings 的话,客户端只能用 GET 传参,遇到复杂查询条件就尴尬了。
步骤二:用 proto descriptor 构建路由表,别手写映射配置。
把 proto 编译成 descriptor 文件,Envoy 的 gRPC-JSON transcoder 直接加载它来建立路由表:
protoc -I. -I$(go env GOPATH)/pkg/mod \
--include_imports \
--include_source_info \
--descriptor_set_out=user_service.pb \
user.proto
这个 .pb 文件就是 Envoy 的路由注册表。部署时把它挂进 Envoy 的配置卷,或者在启动时通过 xDS 下发。我踩过的坑:descriptor 文件必须包含所有依赖的 proto(比如 google/api/annotations.proto、google/api/http.proto),否则 Envoy 解析失败直接拒绝加载,日志里只会留一句 Failed to build file descriptor set,排查起来很痛苦。
步骤三:Envoy 侧的 filter 配置,精确控制路由匹配粒度。
http_filters:
- name: envoy.filters.http.grpc_json_transcoder
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_json_transcoder.v3.GrpcJsonTranscoder
proto_descriptor: "/etc/envoy/user_service.pb"
services:
- "example.v1.UserService"
print_options:
add_whitespace: false
always_print_enums_as_ints: true
preserve_proto_field_names: false
match_incoming_request_route: true
auto_mapping: false
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
match_incoming_request_route: true 让 Envoy 严格按 proto annotation 匹配路径,不会自作主张把 /UserService/GetUser 这种 gRPC 原生路径也暴露成 REST。auto_mapping: false 关掉自动映射,防止没标注 annotation 的 RPC 方法意外暴露。
序列化层:控制字段命名和枚举输出
路由通了之后,下一个卡点是序列化。gRPC 原生用 camelCase 字段名,proto3 的枚举默认输出数值,而大部分 REST 客户端期望 snake_case 和枚举字符串。如果这里不统一,前端同学会反复来找你问“为什么字段名对不上”。
步骤一:字段名转换交给 transcoder,不要在网关外面再套一层转换。
Envoy 的 preserve_proto_field_names: false 会把 proto 的 user_id 转成 JSON 的 userId。设成 true 则保留原始 snake_case。这一步要跟团队约定好——如果你的 REST API 文档已经按 camelCase 写了,就设 false;如果后端内部全部用 snake_case 且不想给前端两套命名,就设 true。
步骤二:枚举输出强制用字符串,避免客户端硬编码数字。
always_print_enums_as_ints: false(默认值)会让枚举输出字符串名称,比如 "ACTIVE" 而不是 1。我经历过一次事故:proto 里新增了一个枚举值插在中间,导致后续所有枚举值偏移,前端用数字硬编码直接错位,大量用户状态显示异常。从那以后所有对外接口的枚举都强制走字符串。
步骤三:处理 gRPC 流式响应的序列化边界。
gRPC server streaming 在 transcoding 时会变成 JSON 数组,但 Envoy 的默认行为是等到流结束再一次性输出完整数组。如果流持续时间长(比如日志订阅),客户端会一直等不到响应。解决方案是在 Envoy 配置里打开流式 JSON 输出:
print_options:
streaming_json: true
这样 Envoy 会逐条输出 \n 分隔的 JSON 对象,客户端可以按行解析。注意,这个行为从 Envoy 1.24 开始支持,1.23 及之前版本不认这个字段。
错误码映射:gRPC status 到 HTTP status 的可控转换
gRPC 的错误模型和 HTTP 差异很大。gRPC 用 16 种 status code(OK、NOT_FOUND、INTERNAL 等),HTTP 则有几十个。默认映射表很粗糙,比如 INVALID_ARGUMENT、FAILED_PRECONDITION、OUT_OF_RANGE 全部映射成 400,客户端根本不知道具体错在哪。
步骤一:定义一套 gRPC 错误细节扩展,同时承载业务错误码。
Google 的 google.rpc.Status 和 google.rpc.ErrorInfo 是标准做法。在 proto 里引入:
import "google/rpc/error_details.proto";
import "google/rpc/code.proto";
然后在服务端返回错误时填充 details:
st := status.New(codes.InvalidArgument, "email format invalid")
st, _ = st.WithDetails(&errdetails.ErrorInfo{
Domain: "example.com",
Reason: "EMAIL_FORMAT_INVALID",
Metadata: map[string]string{
"field": "email",
},
})
return st.Err()
Envoy 的 transcoder 会解析 grpc-status-details-bin 这个 metadata,把 ErrorInfo 塞进 HTTP 响应体。
步骤二:自定义 gRPC 到 HTTP 的映射表,不要用默认的。
Envoy 1.28 开始支持在 transcoder 配置里加 status_code_mapping:
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_json_transcoder.v3.GrpcJsonTranscoder
status_code_mapping:
- grpc_status_code: 3 # INVALID_ARGUMENT
http_status_code: 422
- grpc_status_code: 9 # FAILED_PRECONDITION
http_status_code: 409
- grpc_status_code: 11 # OUT_OF_RANGE
http_status_code: 422
INVALID_ARGUMENT 映射成 422 而不是 400,因为参数格式错误本质上是一个语义正确的请求(格式没问题,内容不对),422 更准确。FAILED_PRECONDITION 映射成 409,表示资源状态冲突。这些映射一旦定了就不要改,写进团队的 API 规范文档里。
步骤三:错误响应体结构保持一致,让客户端有统一的解析路径。
不管原始请求走的是 gRPC 还是 REST,网关返回的错误 JSON 结构应该一致。Envoy 默认输出的是:
{
"code": 422,
"message": "email format invalid",
"details": [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "EMAIL_FORMAT_INVALID",
"domain": "example.com",
"metadata": {
"field": "email"
}
}
]
}
这里有一个细节:code 字段是 HTTP 状态码,不是 gRPC 的数值码。如果客户端还在用 gRPC 的 code 做判断,需要从 details 里的 ErrorInfo.reason 或者自定义字段里拿业务错误码。我们在团队内部约定:所有业务错误都用 ErrorInfo.reason 标识,HTTP 状态码只用于网络层判断。
全链路联调:从 proto 变更到上线
这三个层面配完之后,真正的挑战在于持续维护。每次 proto 文件变更(加字段、加 RPC、改 HTTP 绑定),都要重新生成 descriptor 并部署到 Envoy。
我们的 CI 流程是:
- proto 仓库 MR 合并后,CI 自动编译 descriptor 文件,上传到对象存储
- Envoy 的 sidecar 或 gateway 在启动时通过 init container 拉取最新的 descriptor
- 配合 Envoy 的 hot restart 或者 xDS 动态更新,不中断流量
如果你用的是 Istio + Envoy sidecar,descriptor 文件建议挂载进 sidecar 的 /etc/envoy/ 目录,然后在 EnvoyFilter 里引用。注意 Istio 1.20 之前对 grpc_json_transcoder 的支持有 bug,descriptor 文件路径会被错误解析,升级到 1.20+ 解决。
常见问题
gRPC 的 stream 转 REST 后性能会掉多少?
server streaming 转 JSON 数组或行分隔 JSON 时,主要开销在序列化上。实测 protobuf → JSON 的序列化耗时大约是原生 protobuf 序列化的 2-3 倍。对于单次响应小于 100KB 的流,增加延迟在 5ms 以内。但如果流里每条消息都要单独序列化(streaming_json: true),延迟会线性累积。建议对延迟敏感的流式接口(比如实时日志推送)直接走 gRPC,不经过 transcoding。
proto 里改了字段名,REST 客户端会收到什么?
如果用的是 preserve_proto_field_names: false(camelCase 模式),改 proto 的 snake_case 字段名会直接改变 JSON 输出的 key。比如 user_name 改成 user_display_name,JSON key 会从 userName 变成 userDisplayName。这就是一次 breaking change。我们的规范是:字段名不可变,新增字段用新名字,旧字段标记 deprecated 保留至少两个版本。
为什么 Envoy 返回 503 而不是 gRPC 服务端返回的错误?
503 说明 Envoy 根本没连上 gRPC 后端。常见原因:gRPC 集群的健康检查没过(Envoy 默认用 GRPC health check,但很多服务只暴露了 HTTP health endpoint);descriptor 里的 package 名和服务的实际 package 对不上;TLS 配置不匹配,Envoy 侧开了 mTLS 但服务端没配。先看 Envoy 的 cluster 状态日志,确认 upstream 是否健康,再查 transcoding 的映射路径。