gRPC网关与HTTP转码实战:Envoy gRPC-JSON转码与grpc-gateway统一接入

深入讲解gRPC网关与HTTP转码的完整落地路径,涵盖Envoy gRPC-JSON转码配置、grpc-gateway代码生成、google.api.http注解、统一REST/gRPC接入与版本管理,打造契约单一、双协议出口的API体系。

引言

微服务之间用 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 网关

1.1 双协议出口的困境

需求方期望协议直接使用 gRPC 的问题
微服务内部gRPC无问题,天然契合
浏览器前端HTTP/JSON需 gRPC-Web 代理,流式能力受限
移动端 AppHTTP/JSON网络库不识别 HTTP/2 二进制帧
第三方开放平台HTTP/JSON开发者生态以 REST 为主
运维/调试HTTP/JSONcurl 无法直接调 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 状态码场景
OK0200成功
INVALID_ARGUMENT3400参数非法
NOT_FOUND5404资源不存在
ALREADY_EXISTS6409资源已存在
PERMISSION_DENIED7403权限不足
UNAUTHENTICATED16401未认证
RESOURCE_EXHAUSTED8429限流
UNIMPLEMENTED12501未实现
INTERNAL13500内部错误
UNAVAILABLE14503服务不可用
DEADLINE_EXCEEDED4504超时

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 版本兼容矩阵

兼容性gRPCREST
新增字段/方法向后兼容(默认忽略未知字段)向后兼容
修改字段编号破坏性无感
修改字段类型破坏性视 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_importsEnvoy 报 descriptor 不完整生成描述符时带依赖
body: "*" 与路径字段冲突请求体吞掉路径字段路径绑定字段不要放 body
64 位整型 JSON 精度丢失id 变成科学计数法转码时用 string 表示 int64,或前端用 BigInt
HTTP/2 优先级无感性能测试偏低忽略优先级,聚焦吞吐
流式方法转码失败网关 501流式走原生 gRPC 或 WebSocket

9. 总结与选型矩阵

场景推荐方案
已有 Envoy 网关,想零代码暴露 RESTEnvoy gRPC-JSON 转码
Go 服务,想要代码级控制与自定义错误体grpc-gateway
浏览器前端需要调用流式 APIgRPC-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 体系。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Backend Engineering」更多文章

  1. HTTP/3 与 QUIC 接入实战:协议原理、部署踩坑与渐进式升级
  2. 配置漂移与安全基线:IaC漂移检测、CIS合规、供应链安全与密钥轮换
  3. 可观测性成本治理:采样降噪、数据生命周期与存储成本优化实战