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
转载请注明出处,版权归原作者所有。
管理员
黑卡会员