HTTP 与 gRPC 共存:对外 REST、对内 RPC
浏览器、移动端、第三方开放接口只认 HTTP + JSON,服务之间你希望用 gRPC 拿性能与契约;两套协议并存不是妥协,而是分工:边缘(edge)说 REST,内部说 gRPC。grpc-gateway 让一份 .proto 同时生成 gRPC 服务端骨架与 REST 反向代理,接口定义只有一处,不必两套接口各改一半。
为什么分层
| 位置 | 协议 | 原因 |
|---|---|---|
| 浏览器 / 小程序 | HTTP + JSON | 浏览器拿不到 HTTP/2 trailer,无法直接说 gRPC;gRPC-Web 还需要额外代理 |
| 第三方开放平台 | REST + JSON + OpenAPI | 文档、调试工具、SDK 生态成熟,签名鉴权好做 |
| 服务与服务 | gRPC + protobuf | 二进制体积小、强类型契约、原生支持流式、代码可生成 |
一句话记:对外求通用,对内求效率。网关只做协议转换,不放业务逻辑;内部服务之间的调用不该绕道 REST,那等于白白多两次序列化。
用注解定义 REST 映射
protoc-gen-grpc-gateway 读取 .proto 里的 google.api.http 注解,生成 *_pb.gw.go,内容是一个标准库 http.Handler,收到 REST 请求后转成 gRPC 客户端调用(默认直连远端,也可同进程直调):
curl/浏览器 --HTTP+JSON--> gateway mux(*_pb.gw.go)--gRPC--> 业务服务
syntax = "proto3";
package im.v1;
import "google/api/annotations.proto";
option go_package = "example.com/im/gen/im/v1;imv1";
service UserService {
rpc GetUser(GetUserRequest) returns (User) {
option (google.api.http) = {
get: "/v1/users/{user_id}" // 路径参数按同名字段绑定
additional_bindings { get: "/v1/me" } // 一个方法暴露多个路径
};
}
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) {
option (google.api.http) = { get: "/v1/users" }; // 其余字段自动来自 query
}
}
绑定规则要背下来:{user_id} 从路径取、其余字段从 query string 取;body: "*" 表示整个请求体反序列化成 message,body: "user" 表示只映射到 user 字段;additional_bindings 用来挂短路径或兼容旧版本;字段默认同时接受 camelCase 与 snake_case,需要别的名字用 json_name。
生成与挂载
# 需要的插件:protoc-gen-go、protoc-gen-go-grpc、protoc-gen-grpc-gateway、protoc-gen-openapiv2
protoc -I . -I third_party/googleapis \
--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 \
--openapiv2_out=docs --openapiv2_opt=allow_merge=true \
proto/im/v1/user.proto
google/api/annotations.proto 要放进 third_party/ 或用 buf 依赖(buf.build/googleapis/googleapis),否则 protoc 直接报找不到。用 buf 的项目写进 buf.gen.yaml,一条 buf generate 出全部产物。挂载方式如下:
mux := runtime.NewServeMux(
runtime.WithIncomingHeaderMatcher(func(k string) (string, bool) {
switch http.CanonicalHeaderKey(k) {
case "Authorization", "X-Request-Id", "X-Trace-Id":
return k, true // metadata key 会被转成小写
default: return runtime.DefaultHeaderMatcher(k)
}
}),
)
// 直连远端服务;同进程可换 RegisterUserServiceHandlerServer
if err := imv1.RegisterUserServiceHandlerFromEndpoint(ctx, mux, "127.0.0.1:9000", opts); err != nil {
log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":8080", mux))
Authorization 是特例:不显式加进白名单,下游拦截器就从 metadata 里读不到令牌,鉴权会全量 401。traceId 同理,前端传的 X-Request-Id 要能在 gRPC 侧日志里看到同一个值。
错误映射
gRPC 状态码必须翻译成 HTTP 状态码,否则前端只会收到一坨 500。
runtime.HTTPError = func(ctx context.Context, mux *runtime.ServeMux, m runtime.Marshaler,
w http.ResponseWriter, _ *http.Request, err error) {
st := status.Convert(err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(runtime.HTTPStatusFromCode(st.Code()))
_ = json.NewEncoder(w).Encode(map[string]any{
"code": int(st.Code()), "message": st.Message(),
"details": st.Details(), // 保留 BadRequest.FieldViolation 等详情
})
}
| gRPC 状态码 | 默认 HTTP 码 | 说明 |
|---|---|---|
InvalidArgument / OutOfRange | 400 | 参数问题,前端可直接提示 |
Unauthenticated | 401 | 未登录或令牌过期,触发刷新 |
PermissionDenied | 403 | 已登录但无权限,别提示「请登录」 |
NotFound | 404 | 资源不存在 |
AlreadyExists / Aborted | 409 | 冲突,配合幂等键重试 |
ResourceExhausted | 429 | 限流,回 Retry-After |
Unavailable / DeadlineExceeded | 503 / 504 | 可重试类,但要先确认幂等 |
Internal / Unknown | 500 | 别把内部堆栈与 SQL 原样吐给前端 |
流式接口怎么暴露
服务端流在 REST 上没有对应语义,grpc-gateway 以 chunked 分块 JSON 持续写出(每个对象包一层 {"result":...}),前端用 fetch 读 response.body 流即可;长连接推送更适合 SSE 或 WebSocket,另写 handler,不要硬套 gateway。客户端流与双向流别指望 REST 映射,改成「分片上传 + 一次提交」这类设计。
与其它方案的分工
| 方案 | 适合 | 代价 |
|---|---|---|
| grpc-gateway | 已有 .proto,一份定义两套协议,Go 技术栈 | 需维护 HTTP 注解,调试多一层 |
| 手写 REST handler(gin/echo) | REST 资源建模复杂、文件上传、管理后台 | 两套 DTO,改一处漏一处 |
| Envoy / nginx 转码 | 多语言服务、统一网关入口 | 配置复杂,注解仍要写在 proto 里 |
落地顺序:内部服务先把 gRPC 跑顺,边缘用 grpc-gateway 暴露最小 REST 面(登录、列表、详情、提交),文件上传与管理后台继续手写 handler,两者在前置 nginx 上按路径分流。
上线检查清单
| 检查项 | 通过标准 |
|---|---|
| 路径版本 | REST 路径带 /v1,与 proto package 版本一致 |
| 错误体 | 前端能拿到稳定的 code/message/details,无内部堆栈 |
| 鉴权 | 令牌能从 HTTP header 进 metadata,下游拦截器实测读得到 |
| 请求体大小 | gateway、后端 MaxRecvMsgSize、nginx client_max_body_size 三者一致 |
| 超时链 | 客户端超时 < 网关超时 < 服务端 deadline,避免级联堆积 |
| 观测 | 日志与 trace 里 REST 路径与 gRPC 方法能对上 |
小结:把「对外 REST、对内 gRPC」当固定边界,用 google.api.http 注解让一份 .proto 同时产出 gRPC 骨架与 REST 代理;重点盯住 metadata 透传、错误码映射、超时链与请求体大小四处;流式接口按 REST 的能力重新设计,别硬套。