从零构建 Go API 网关:8 个实战踩坑与经验教训

背景

项目中有多个子域名和后端服务散落各处,没有统一的流量入口。决定用 Go 自研一个 API 网关作为所有请求的唯一入口,具备反向代理、WAF 安全防护、JWT 认证、速率限制等能力。

技术选型:

组件选型理由
HTTPnet/http 标准库轻量够用,无需框架
JWTgolang-jwt/jwt/v5Go 社区标准
配置gopkg.in/yaml.v3YAML 配置驱动
日志log/slogGo 1.21+ 标准库
部署Docker Compose容器化一键部署

最终请求处理链:

1
请求 → Recovery → Logging → RequestID → IP黑名单 → Body缓冲 → WAF检查 → 限流 → CORS → 安全头 → gzip → 路由匹配 → JWT认证 → 反向代理 → 后端

开发过程中踩了不少坑,以下是 8 个值得记录的经验教训。


1. 请求 Body 只能读一次

这是最隐蔽的 bug。WAF 中间件需要读取请求体做安全检查,但读完之后 Body 就空了,反向代理转发到后端时 POST 数据全部丢失。后端收不到任何参数,但没有任何报错。

Go 的 http.Request.Body 是一个 io.ReadCloser,读一次就消费完了。

解决方式:中间件先读取 Body 到内存,检查完再放回去:

1
2
3
4
5
6
// 读取
bodyBytes, _ := io.ReadAll(io.LimitReader(r.Body, 64*1024))

// 还原 — 后续中间件和反向代理可以正常读取
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
r.ContentLength = int64(len(bodyBytes))

通用规则:任何需要"偷看"请求体的中间件(日志、安全检查、参数校验),都必须走"读取 → 缓存 → 还原"的流程。


2. 包装 ResponseWriter 要实现全部接口

自定义了一个 gzipResponseWriter 来做响应压缩,只实现了 Write 方法。结果 SSE 流式推送和 WebSocket 连接全部卡死,没有任何错误日志。

原因:Go 的 http.ResponseWriter 是接口,但运行时还会检查它是否实现了 http.Flusherhttp.Hijackerhttp.Pusher 等可选接口。包装后这些接口就丢了。

解决方式:显式实现所有可选接口,透传到底层 Writer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
type gzipResponseWriter struct {
    http.ResponseWriter
    writer io.Writer
}

func (g *gzipResponseWriter) Flush() {
    if f, ok := g.ResponseWriter.(http.Flusher); ok {
        f.Flush()
    }
}

func (g *gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
    if h, ok := g.ResponseWriter.(http.Hijacker); ok {
        return h.Hijack()
    }
    return nil, nil, http.ErrNotSupported
}

教训:包装 http.ResponseWriter 时,必须检查并实现所有可选接口,否则下游功能会静默失效——不报错,只是不工作。


3. RemoteAddr 带端口,ParseIP 不认

IP 黑白名单功能一直不生效,所有请求都被放行。调试发现 r.RemoteAddr 返回的是 172.21.0.1:33098(带端口),直接喂给 net.ParseIP() 返回 nil,判断逻辑认为"解析失败 = 不在名单中 = 放行"。

解决方式:先用 net.SplitHostPort 剥离端口:

1
2
3
4
5
6
func StripPort(addr string) string {
    if host, _, err := net.SplitHostPort(addr); err == nil {
        return host
    }
    return addr
}

通用规则:Go 里拿到的网络地址基本都是 host:port 格式,解析前必须先剥离端口。这个坑在所有网络编程中都会遇到。


4. URL 参数是编码过的

WAF 检测 SQL 注入的正则是 union\s+select,但实际请求中查询参数 union select 被编码成了 union%20select\s+ 匹配的是空白字符,不是 %20,导致注入攻击直接绕过了 WAF。

解决方式:对 URL 查询参数做安全检查前,先解码:

1
2
decoded, _ := url.QueryUnescape(req.URL.RawQuery)
// 再用 decoded 去匹配正则

教训:URL 查询参数、Cookie、部分 Header 内容都可能被编码。做安全检查前必须先解码,否则规则形同虚设。


5. 中间件顺序决定行为

最初把 Logging 放在 WAF 后面,结果被 WAF 拦截的请求不记日志。线上出了攻击事件,查日志发现什么都没有。

正确的中间件顺序

1
Recovery → Logging → RequestID → 安全检查 → 限流 → CORS → 认证 → 业务
  • Recovery 在最外层:捕获所有 panic
  • Logging 紧跟其后:记录所有请求,包括被拦截的
  • 安全检查 在日志之后:拦截的同时有日志可查
  • 认证 在安全检查之后:先过滤恶意请求,再验证身份

教训:中间件的顺序不是随意的。日志和 Recovery 必须在最外层,安全检查在业务逻辑之前。


6. 认证中间件要跟着路由走

不同服务的认证策略不同——有的用 JWT,有的公开访问。最初的做法是创建一个全局 JWT 中间件,结果代码里写了 _ = jwtMW 直接丢弃,认证完全没生效。

解决方式:把认证配置挂在路由层,每个服务携带自己的认证中间件:

1
2
3
4
5
6
7
type ServiceRoute struct {
    Name    string
    Domains []string
    Paths   []PathMatch
    Backend *Backend
    Auth    *AuthMiddleware  // 每个服务独立的认证
}

路由匹配到哪个服务,就用哪个服务的认证配置。公开服务的 Auth.Type 设为 "none",跳过认证。

教训:认证不是全局的,是路由级别的。架构设计时就要把认证和路由绑定在一起。


7. 常见端口不要用

网关最初用 8080 端口,部署后发现这是几乎所有开发服务器的默认端口(Vue dev-server、Spring Boot、各类 Admin 面板),迟早会冲突。

解决方式:用不常见的端口(如 9000、9080),通过环境变量配置:

1
GATEWAY_PORT=9000

docker-compose.yml 中引用:

1
2
ports:
  - "${GATEWAY_PORT:-9000}:8080"

改端口只需改 .env 一行,不用动代码。

教训:内部服务避免使用 8080、3000、5000 等"默认端口",用环境变量隔离配置。


8. 跨项目收束服务要渐进式切换

把散落各处的服务收束到网关,涉及修改多个项目的 docker-compose.yml(去掉端口映射、加入共享网络)。一次性改所有文件风险很大——一个服务启动失败,全部不可用。

更好的做法

  1. 先在网关配好所有路由
  2. 逐个服务切换——先加入共享网络,验证通过网关能访问
  3. 确认无误后再去掉独立端口映射
  4. 每一步都可以回滚

教训:架构变更要渐进式推进,不要一次性改所有东西。先并行运行(新旧两套都通),再逐步切流,最后下线旧链路。


总结

坑点一句话教训
Body 只读一次做检查前先缓存,检查后还原
ResponseWriter 接口包装时实现 Flusher/Hijacker 全套
RemoteAddr 带端口先 SplitHostPort 再 ParseIP
URL 编码安全检查前先 QueryUnescape
中间件顺序日志最外层,安全在业务前
认证跟路由走不同服务不同认证策略
避免常见端口用环境变量配置端口
渐进式切换逐个服务收束,不要一刀切

这些坑看似简单,但在实际开发中很容易被忽略。希望对你有帮助。