引言
微服务之间用 gRPC 通信能享受 HTTP/2 多路复用、Protobuf 二进制序列化和强类型契约的红利,但 gRPC 对浏览器与移动端并不友好:浏览器无法直接发起 gRPC 调用(需要 gRPC-Web 代理),移动端与第三方开发者更习惯 JSON over HTTP。于是团队陷入两难——维护一份 gRPC Proto 契约,再为 Web 另写一套 REST 接口?契约分裂、语义漂移、双重维护成本随之而来。
gRPC 网关(Gateway)与 HTTP 转码(Transcoding) 正是为破解这一困局而生:以 Protobuf 为唯一契约源,通过注解或网关配置自动把 gRPC 方法映射为 HTTP 路由,让一个后端同时对外暴露 gRPC 与 REST/JSON 两套协议。本文将系统讲解两条主流路径——Envoy gRPC-JSON 转码(运行时零代码转码)与 grpc-gateway 代码生成(编译期生成反向代理),并给出统一接入、鉴权、限流与版本管理的生产实践。
关于 gRPC 的四种通信模式与 Proto 定义基础,可先阅读 https://plumephp.com/grpc-streaming-bidirectional-communication/;REST/gRPC/GraphQL 的整体选型可参考 https://plumephp.com/api-design-rest-grpc-graphql/。
目录
- 1. 为什么需要 gRPC 网关
- 2. gRPC-JSON 转码原理
- 3. google.api.http 注解与 Proto 定义
- 4. Envoy gRPC-JSON 转码配置
- 5. grpc-gateway 代码生成
- 6. 统一 REST/gRPC 接入:路由、鉴权与限流
- 7. 版本管理:REST 与 gRPC 契约对齐
- 8. 生产实践与性能对比
- 9. 总结与选型矩阵
- 延伸阅读
1. 为什么需要 gRPC 网关
1.1 双协议出口的困境
| 需求方 | 期望协议 | 直接使用 gRPC 的问题 |
|---|---|---|
| 微服务内部 | gRPC | 无问题,天然契合 |
| 浏览器前端 | HTTP/JSON | 需 gRPC-Web 代理,流式能力受限 |
| 移动端 App | HTTP/JSON | 网络库不识别 HTTP/2 二进制帧 |
| 第三方开放平台 | HTTP/JSON | 开发者生态以 REST 为主 |
| 运维/调试 | HTTP/JSON | curl 无法直接调 gRPC |
1.2 两条主流路径
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Envoy gRPC-JSON 转码 | 网关在运行时把 HTTP/JSON 请求映射为 gRPC 调用 | 无需生成额外代码、热更新映射、天然享受网关流量治理 | 依赖 Proto 注解、流式方法支持受限 |
| grpc-gateway | 编译期生成 REST 反向代理,注册到 gRPC server | 纯代码可控、可深度定制、可嵌入 Go 服务 | 需代码生成步骤、多一层进程或 Handler |
两者可以组合:用 grpc-gateway 生成 REST 层,再统一放到 Envoy 后面做流量治理。
2. gRPC-JSON 转码原理
转码的核心是把「HTTP 语义 ↔ gRPC 语义」做双向映射:
HTTP 客户端 Envoy 网关 gRPC 后端
───────────── ────────── ──────────
GET /v1/users/123 ─── HTTP/JSON ───▶ 转码 Filter ─── gRPC ───▶ GetUser(User{id:123})
│ 路径 → 方法
│ JSON → Protobuf
│ 状态码 → gRPC code
映射规则:
- HTTP 路径与查询参数 → 从
google.api.http注解生成,路径模板中的{id}绑定到消息字段 - HTTP 方法 →
GET/POST/PUT/PATCH/DELETE对应到 gRPC 方法 - JSON body →
json_name或字段名映射到 Protobuf 消息 - HTTP 状态码 → gRPC 状态码映射表(见下)
2.1 gRPC code → HTTP 状态码映射
| gRPC Code | 数值 | HTTP 状态码 | 场景 |
|---|---|---|---|
| OK | 0 | 200 | 成功 |
| INVALID_ARGUMENT | 3 | 400 | 参数非法 |
| NOT_FOUND | 5 | 404 | 资源不存在 |
| ALREADY_EXISTS | 6 | 409 | 资源已存在 |
| PERMISSION_DENIED | 7 | 403 | 权限不足 |
| UNAUTHENTICATED | 16 | 401 | 未认证 |
| RESOURCE_EXHAUSTED | 8 | 429 | 限流 |
| UNIMPLEMENTED | 12 | 501 | 未实现 |
| INTERNAL | 13 | 500 | 内部错误 |
| UNAVAILABLE | 14 | 503 | 服务不可用 |
| DEADLINE_EXCEEDED | 4 | 504 | 超时 |
3. google.api.http 注解与 Proto 定义
3.1 引入 annotations
转码依赖 google/api/annotations.proto,在 buf.yaml 或 protoc 中引入 googleapis 依赖:
# buf.yaml
version: v2
deps:
- buf.build/googleapis/googleapis
3.2 带注解的 Proto 定义
syntax = "proto3";
package user.v1;
import "google/api/annotations.proto";
option go_package = "github.com/example/user/gen/user/v1;userv1";
service UserService {
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) {
option (google.api.http) = {
get: "/v1/users"
};
}
rpc GetUser(GetUserRequest) returns (User) {
option (google.api.http) = {
get: "/v1/users/{id}"
};
}
rpc CreateUser(CreateUserRequest) returns (User) {
option (google.api.http) = {
post: "/v1/users"
body: "*"
};
}
rpc UpdateUser(UpdateUserRequest) returns (User) {
option (google.api.http) = {
patch: "/v1/users/{user.id}"
body: "user"
};
}
rpc DeleteUser(DeleteUserRequest) returns (google.protobuf.Empty) {
option (google.api.http) = {
delete: "/v1/users/{id}"
};
}
}
message User {
int64 id = 1;
string name = 2;
string email = 3;
string created_at = 4; // 注意:json_name 默认将 created_at 序列化为 created_at
}
message GetUserRequest { int64 id = 1; }
message ListUsersRequest { int32 page_size = 1; string page_token = 2; }
message ListUsersResponse {
repeated User users = 1;
string next_page_token = 2;
}
message CreateUserRequest { User user = 1; }
message UpdateUserRequest { User user = 1; }
message DeleteUserRequest { int64 id = 1; }
3.3 路径模板语法
| 模板 | 示例 | 绑定字段 |
|---|---|---|
{id} | /v1/users/{id} | 绑定顶层字段 id |
{user.id} | /v1/users/{user.id} | 嵌套绑定 user.id |
* (body) | body: "*" | 整个请求体绑定到该字段 |
{id=users/*} | 自定义通配匹配 | 用于更精确的路径匹配 |
3.4 多个 HTTP 映射(additional_bindings)
一个 RPC 可绑定多个 HTTP 方法:
rpc SearchUsers(SearchUsersRequest) returns (ListUsersResponse) {
option (google.api.http) = {
get: "/v1/users:search"
additional_bindings {
post: "/v1/users:search"
body: "*"
}
};
}
4. Envoy gRPC-JSON 转码配置
4.1 基本配置
Envoy 通过 grpc_json_transcoder HTTP 过滤器实现转码,需要提供 Proto 描述符文件:
static_resources:
listeners:
- name: ingress_listener
address:
socket_address: { address: 0.0.0.0, port_value: 8080 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: backend
domains: ["*"]
routes:
- match: { prefix: "/" }
route: { cluster: user_grpc_service }
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: "/data/user_service.pb"
services: ["user.v1.UserService"]
print_options:
add_whitespace: true
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
4.2 生成 Proto 描述符文件
# 方式一:protoc
protoc -I . --include_imports --include_source_info \
--descriptor_set_out=user_service.pb \
user/v1/user.proto
# 方式二:buf(推荐,自动处理依赖)
buf build -o user_service.pb
注意 --include_imports 必须加上,否则 Envoy 无法解析 google/api/annotations.proto 等依赖。
4.3 请求转换行为
| 配置项 | 作用 |
|---|---|
ignore_unknown_query_parameters | 忽略未映射的查询参数 |
auto_mapping | 自动把同名 JSON 字段映射到 Proto 字段(无需注解) |
match_incoming_request_route | 按 x-envoy-original-path 匹配路由 |
max_request_body_size | 限制请求体大小(转码需先缓冲) |
4.4 用 curl 验证
# HTTP/JSON 请求,Envoy 自动转码为 gRPC
curl -s http://localhost:8080/v1/users/123 \
-H "Authorization: Bearer <token>"
# 返回 JSON
{"id":"123","name":"Alice","email":"alice@example.com"}
# 创建用户
curl -s -X POST http://localhost:8080/v1/users \
-H "Content-Type: application/json" \
-d '{"name":"Bob","email":"bob@example.com"}'
5. grpc-gateway 代码生成
5.1 安装工具
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
5.2 生成网关代码
protoc -I . \
--go_out ./gen --go_opt paths=source_relative \
--go-grpc_out ./gen --go-grpc_opt paths=source_relative \
--grpc-gateway_out ./gen --grpc-gateway_opt paths=source_relative \
--grpc-gateway_opt generate_unbound_methods=true \
user/v1/user.proto
用 Buf 封装为更可复用的模板:
# buf.gen.yaml
version: v2
inputs:
- directory: proto
plugins:
- local: protoc-gen-go
out: gen
opt: paths=source_relative
- local: protoc-gen-go-grpc
out: gen
opt: paths=source_relative
- local: protoc-gen-grpc-gateway
out: gen
opt: paths=source_relative
- local: protoc-gen-openapiv2
out: gen/openapiv2
5.3 在 Go 服务中注册网关
package main
import (
"context"
"net/http"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
userv1 "github.com/example/user/gen/user/v1"
)
func main() {
ctx := context.Background()
// gRPC 服务监听地址
grpcAddr := "localhost:9090"
// 创建 gateway mux
mux := runtime.NewServeMux(
runtime.WithErrorHandler(runtime.DefaultHTTPErrorHandler),
)
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
if err := userv1.RegisterUserServiceHandlerFromEndpoint(ctx, mux, grpcAddr, opts); err != nil {
panic(err)
}
// REST 入口
if err := http.ListenAndServe(":8080", mux); err != nil {
panic(err)
}
}
5.4 流式方法的网关支持
grpc-gateway 对流式方法的支持不如普通方法成熟,Server Streaming 可用,双向流通常仍需 gRPC 原生或 WebSocket。流式场景详见 https://plumephp.com/grpc-streaming-bidirectional-communication/。
6. 统一 REST/gRPC 接入:路由、鉴权与限流
6.1 统一入口架构
┌──────────────┐
REST 客户端 ── HTTP ──▶ │ │
│ Envoy 网关 │── gRPC ──▶ 服务 A
gRPC 客户端 ── gRPC ─▶ │ (转码+治理) │── gRPC ──▶ 服务 B
└──────────────┘
- 同一 Listener 接收 HTTP 与 gRPC:Envoy 通过
typed_filter_config自动识别 HTTP/2 prior knowledge - 鉴权前置:在转码 Filter 之前插入 JWT/ExtAuthz Filter,统一校验,避免每个服务重复实现
- 限流前置:转码后请求仍走 HTTP 语义,可复用 https://plumephp.com/api-gateway-ratelimiting-circuitbreaker-practice/ 中限流熔断实践
6.2 鉴权 Filter 顺序
http_filters:
- name: envoy.filters.http.jwt_authn # 1. 先鉴权
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
providers:
issuer:
issuer: https://issuer.example.com
audiences: ["api.example.com"]
remote_jwks:
http_uri:
uri: https://issuer.example.com/.well-known/jwks.json
cluster: jwks_cluster
cache_duration: 300s
- name: envoy.filters.http.grpc_json_transcoder # 2. 再转码
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_json_transcoder.v3.GrpcJsonTranscoder
proto_descriptor: "/data/user_service.pb"
services: ["user.v1.UserService"]
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
6.3 元数据透传
HTTP 请求头如何变成 gRPC metadata?Envoy 会把以 Grpc-Metadata- 或 Grpc-Timeout 等开头的头映射为 gRPC metadata;反向,gRPC metadata 可通过 grpc_metadata_in_transcoder 配置映射为 HTTP 头:
typed_config:
...
auto_mapping: true
# 将后端返回的 x-request-id metadata 暴露为 HTTP 头
response_headers_to_ignore: []
生产实践建议:统一透传 x-trace-id、x-user-id,保证 REST 与 gRPC 两条链路的可观测性上下文一致(详见 https://plumephp.com/distributed-tracing-opentelemetry-guide/)。
7. 版本管理:REST 与 gRPC 契约对齐
7.1 以 Proto 包名作为版本载体
在 gRPC 生态中,版本通常体现在 package 名与 message 名中:
package user.v1; // v1 版本
package user.v2; // v2 版本
service UserService {
rpc GetUser(user.v2.GetUserRequest) returns (user.v2.User);
}
REST 路径 /v2/users 与 gRPC 方法 user.v2.UserService/GetUser 严格对齐。这样 契约单一,版本同步演进,不存在 REST 与 gRPC 版本错位的问题。
7.2 版本兼容矩阵
| 兼容性 | gRPC | REST |
|---|---|---|
| 新增字段/方法 | 向后兼容(默认忽略未知字段) | 向后兼容 |
| 修改字段编号 | 破坏性 | 无感 |
| 修改字段类型 | 破坏性 | 视 JSON 语义 |
| 删除字段 | 破坏性(客户端仍引用) | 破坏性 |
| 改变默认值 | 破坏性 | 破坏性 |
7.3 多版本共存
/v1/users → user.v1.UserService (老版本,只修 bug)
/v2/users → user.v2.UserService (新版本,正常迭代)
配合 API 契约治理中的兼容性门禁(https://plumephp.com/api-contract-governance/),任何破坏性变更在 CI 阶段即被拦截。整体版本策略可参考 https://plumephp.com/api-versioning-strategies-best-practices/。
8. 生产实践与性能对比
8.1 转码开销
gRPC-JSON 转码需要把 JSON 解析为 Protobuf 再序列化,带来一定 CPU 开销,但相比网络传输节省仍然显著:
| 指标 | 直接 REST (JSON) | gRPC + 转码 | gRPC 原生 |
|---|---|---|---|
| 传输体积 | 100% 基线 | ~60-70% | ~35-45% |
| 服务端 CPU 转码开销 | 无 | +10-20% | 无 |
| 端到端延迟(同机房) | 基线 | +0.2-0.5ms | 最低 |
| 浏览器友好度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐(需代理) |
8.2 超时与重试
转码后的请求仍是 HTTP 语义,但后端是 gRPC,需要显式设置 gRPC 超时:
route:
cluster: user_grpc_service
timeout: 5s
retry_policy:
retry_on: connect-failure,reset,resource-exhausted
num_retries: 2
per_try_timeout: 2s
8.3 健康检查与优雅下线
- Envoy
health_check指向 gRPC 健康检查服务grpc.health.v1.Health/Check - 转码入口与 gRPC 后端共享同一健康状态,避免 REST 正常但 gRPC 失败的不一致
8.4 常见坑
| 坑 | 现象 | 解法 |
|---|---|---|
忘记 --include_imports | Envoy 报 descriptor 不完整 | 生成描述符时带依赖 |
body: "*" 与路径字段冲突 | 请求体吞掉路径字段 | 路径绑定字段不要放 body |
| 64 位整型 JSON 精度丢失 | id 变成科学计数法 | 转码时用 string 表示 int64,或前端用 BigInt |
| HTTP/2 优先级无感 | 性能测试偏低 | 忽略优先级,聚焦吞吐 |
| 流式方法转码失败 | 网关 501 | 流式走原生 gRPC 或 WebSocket |
9. 总结与选型矩阵
| 场景 | 推荐方案 |
|---|---|
| 已有 Envoy 网关,想零代码暴露 REST | Envoy gRPC-JSON 转码 |
| Go 服务,想要代码级控制与自定义错误体 | grpc-gateway |
| 浏览器前端需要调用流式 API | gRPC-Web + Envoy,或 WebSocket |
| 对外开放平台,REST 为主 | grpc-gateway + OpenAPI 输出 |
| 内部微服务间 | 原生 gRPC |
最佳实践组合:
内部通信:gRPC(原生,最强性能与类型安全)
外部接入:Envoy 转码 或 grpc-gateway(JSON 兼容)
契约单一:一份 .proto 文件同时驱动两套协议
版本对齐:Proto package 版本号 = REST 路径版本号
流量治理:统一在网关层做鉴权、限流、熔断与可观测性
gRPC 网关不是 REST 的替代品,而是让 REST 与 gRPC 共享同一份契约的粘合层。它消除了「双契约维护」的熵增,让团队既保有 gRPC 的内部效率,又不失去 JSON 的生态兼容性。结合 https://plumephp.com/api-contract-governance/ 的契约治理流程,即可构建一套可长期演进的 API 体系。
延伸阅读
- googleapis google/api/http.proto 规范
- Envoy gRPC-JSON Transcoder 官方文档
- grpc-gateway 官方仓库
- Google API Design Guide:HTTP 与 gRPC 映射
- Buf 官方文档
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。