网关层扛下 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.protogoogle/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(OKNOT_FOUNDINTERNAL 等),HTTP 则有几十个。默认映射表很粗糙,比如 INVALID_ARGUMENTFAILED_PRECONDITIONOUT_OF_RANGE 全部映射成 400,客户端根本不知道具体错在哪。

步骤一:定义一套 gRPC 错误细节扩展,同时承载业务错误码。

Google 的 google.rpc.Statusgoogle.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 流程是:

  1. proto 仓库 MR 合并后,CI 自动编译 descriptor 文件,上传到对象存储
  2. Envoy 的 sidecar 或 gateway 在启动时通过 init container 拉取最新的 descriptor
  3. 配合 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 的映射路径。