buf.gen.yaml
在微服务里用 Go 写后端,gRPC 几乎是默认选项:proto 即契约,代码生成省事,性能也好。但前端和第三方接入方往往只认 REST,于是 grpc-gateway 就成了那个"既要又要"的方案——一份 proto,同时吐出 gRPC 接口和 HTTP/JSON 接口。
听起来很美好,真正落地时坑一个接一个。下面是我在一个中型项目里踩过的几处,记录一下,希望能帮后来人少熬两个晚上。
坑一:代码生成的三方打架
最开始我按官方文档分别装了 protoc-gen-go、protoc-gen-go-grpc、protoc-gen-grpc-gateway,然后手写一长串 protoc 命令。问题很快就来了:paths=source_relative 只加在了部分插件上,生成的 .pb.go 和 .pb.gw.go 落在不同目录,编译时报"找不到类型定义"。
更隐蔽的是 google/api/annotations.proto 的引入。HTTP 注解依赖 googleapis 的公共 proto,如果项目里用的版本和插件内置的不一致,会报重复定义或者 descriptor 冲突。
经验是:直接上 buf,别手写 protoc 命令。
version: v2
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
buf.lock 把依赖版本钉死,团队里谁生成的结果都一样,这一步值得提前做。
坑二:HandlerServer 与 HandlerFromEndpoint,选错丢拦截器
网关的注册有两个函数:RegisterXxxHandlerServer 和 RegisterXxxHandlerFromEndpoint。
前者是进程内直调,不走网络,性能好,调试方便,很多人第一反应就选它。但它的调用链是:HTTP 请求 → ServeMux → 直接调用你的 service 实现。gRPC 的拦截器链完全被绕过了。如果你的鉴权、日志、限流都写在 unary interceptor 里,网关这条路径上等于裸奔。
我当时就是先用了 HandlerServer,测试环境一切正常,上线后才发现网关进来的请求没有 trace id、没有权限校验。后来改成 RegisterXxxHandlerFromEndpoint,把地址指向本机监听的 gRPC 端口,虽然多了一次本地回环,但拦截器、metadata、超时控制全都统一了。
如果确实想用 HandlerServer(比如为了省掉序列化开销),那就得把中间件逻辑抽出来,HTTP 侧和 gRPC 侧各接一遍——这就是下一坑。
坑三:Header、Metadata 和鉴权的两套逻辑
网关会把 HTTP Header 转成 gRPC metadata,但不是所有 Header 都转。默认的 DefaultHeaderMatcher 只放行一批"永久头"和带 Grpc-Metadata- 前缀的自定义头,而且 Authorization 这类头会被加上前缀,导致拦截器里 md.Get("authorization") 拿到空字符串。
排查这个问题的正确姿势是在拦截器里把整个 md 打出来看一眼:
md, _ := metadata.FromIncomingContext(ctx)
log.Printf("%+v", md)
要透传自定义头(比如 X-Request-Id、X-Tenant-Id),得显式配置:
runtime.WithIncomingHeaderMatcher(func(key string) (string, bool) {
switch strings.ToLower(key) {
case "x-request-id", "x-tenant-id":
return strings.ToLower(key), true
}
return runtime.DefaultHeaderMatcher(key)
})
另外 CORS、访问日志、panic 恢复这些,属于 HTTP 层的中间件,得挂在 mux 外面,和 gRPC 拦截器是两套东西。我后来干脆写了一个薄薄的 middleware 包,两边复用同一批纯函数,避免逻辑漂移。
坑四:错误码映射和前端约定不一致
gRPC 的 status.Code 会被网关映射成 HTTP 状态码,比如 InvalidArgument → 400、NotFound → 404、Unknown → 500。默认的 JSON body 长这样:
{"code": 3, "message": "invalid phone", "details": []}
前端同学看到 code: 3 直接懵了。而且默认映射里,凡是没用 status.Error 包装的错误,一律变成 500 和一段英文内部信息,线上暴露细节也不太合适。
统一错误体是必须的:
```go
runtime.WithErrorHandler(func(ctx context.Context, mux *runtime.ServeMux,
m runtime.Marshaler, w http.ResponseWriter, r *http.Request, err error) {
st := status.Convert(err)
w.Header
转载请注明出处,版权归原作者所有。
正式会员
认证极客






