背景
项目中有多个子域名和后端服务散落各处,没有统一的流量入口。决定用 Go 自研一个 API 网关作为所有请求的唯一入口,具备反向代理、WAF 安全防护、JWT 认证、速率限制等能力。
技术选型:
| 组件 | 选型 | 理由 |
|---|---|---|
| HTTP | net/http 标准库 | 轻量够用,无需框架 |
| JWT | golang-jwt/jwt/v5 | Go 社区标准 |
| 配置 | gopkg.in/yaml.v3 | YAML 配置驱动 |
| 日志 | log/slog | Go 1.21+ 标准库 |
| 部署 | Docker Compose | 容器化一键部署 |
最终请求处理链:
| |
开发过程中踩了不少坑,以下是 8 个值得记录的经验教训。
1. 请求 Body 只能读一次
这是最隐蔽的 bug。WAF 中间件需要读取请求体做安全检查,但读完之后 Body 就空了,反向代理转发到后端时 POST 数据全部丢失。后端收不到任何参数,但没有任何报错。
Go 的 http.Request.Body 是一个 io.ReadCloser,读一次就消费完了。
解决方式:中间件先读取 Body 到内存,检查完再放回去:
| |
通用规则:任何需要"偷看"请求体的中间件(日志、安全检查、参数校验),都必须走"读取 → 缓存 → 还原"的流程。
2. 包装 ResponseWriter 要实现全部接口
自定义了一个 gzipResponseWriter 来做响应压缩,只实现了 Write 方法。结果 SSE 流式推送和 WebSocket 连接全部卡死,没有任何错误日志。
原因:Go 的 http.ResponseWriter 是接口,但运行时还会检查它是否实现了 http.Flusher、http.Hijacker、http.Pusher 等可选接口。包装后这些接口就丢了。
解决方式:显式实现所有可选接口,透传到底层 Writer:
| |
教训:包装 http.ResponseWriter 时,必须检查并实现所有可选接口,否则下游功能会静默失效——不报错,只是不工作。
3. RemoteAddr 带端口,ParseIP 不认
IP 黑白名单功能一直不生效,所有请求都被放行。调试发现 r.RemoteAddr 返回的是 172.21.0.1:33098(带端口),直接喂给 net.ParseIP() 返回 nil,判断逻辑认为"解析失败 = 不在名单中 = 放行"。
解决方式:先用 net.SplitHostPort 剥离端口:
| |
通用规则:Go 里拿到的网络地址基本都是 host:port 格式,解析前必须先剥离端口。这个坑在所有网络编程中都会遇到。
4. URL 参数是编码过的
WAF 检测 SQL 注入的正则是 union\s+select,但实际请求中查询参数 union select 被编码成了 union%20select。\s+ 匹配的是空白字符,不是 %20,导致注入攻击直接绕过了 WAF。
解决方式:对 URL 查询参数做安全检查前,先解码:
| |
教训:URL 查询参数、Cookie、部分 Header 内容都可能被编码。做安全检查前必须先解码,否则规则形同虚设。
5. 中间件顺序决定行为
最初把 Logging 放在 WAF 后面,结果被 WAF 拦截的请求不记日志。线上出了攻击事件,查日志发现什么都没有。
正确的中间件顺序:
| |
- Recovery 在最外层:捕获所有 panic
- Logging 紧跟其后:记录所有请求,包括被拦截的
- 安全检查 在日志之后:拦截的同时有日志可查
- 认证 在安全检查之后:先过滤恶意请求,再验证身份
教训:中间件的顺序不是随意的。日志和 Recovery 必须在最外层,安全检查在业务逻辑之前。
6. 认证中间件要跟着路由走
不同服务的认证策略不同——有的用 JWT,有的公开访问。最初的做法是创建一个全局 JWT 中间件,结果代码里写了 _ = jwtMW 直接丢弃,认证完全没生效。
解决方式:把认证配置挂在路由层,每个服务携带自己的认证中间件:
| |
路由匹配到哪个服务,就用哪个服务的认证配置。公开服务的 Auth.Type 设为 "none",跳过认证。
教训:认证不是全局的,是路由级别的。架构设计时就要把认证和路由绑定在一起。
7. 常见端口不要用
网关最初用 8080 端口,部署后发现这是几乎所有开发服务器的默认端口(Vue dev-server、Spring Boot、各类 Admin 面板),迟早会冲突。
解决方式:用不常见的端口(如 9000、9080),通过环境变量配置:
| |
docker-compose.yml 中引用:
| |
改端口只需改 .env 一行,不用动代码。
教训:内部服务避免使用 8080、3000、5000 等"默认端口",用环境变量隔离配置。
8. 跨项目收束服务要渐进式切换
把散落各处的服务收束到网关,涉及修改多个项目的 docker-compose.yml(去掉端口映射、加入共享网络)。一次性改所有文件风险很大——一个服务启动失败,全部不可用。
更好的做法:
- 先在网关配好所有路由
- 逐个服务切换——先加入共享网络,验证通过网关能访问
- 确认无误后再去掉独立端口映射
- 每一步都可以回滚
教训:架构变更要渐进式推进,不要一次性改所有东西。先并行运行(新旧两套都通),再逐步切流,最后下线旧链路。
总结
| 坑点 | 一句话教训 |
|---|---|
| Body 只读一次 | 做检查前先缓存,检查后还原 |
| ResponseWriter 接口 | 包装时实现 Flusher/Hijacker 全套 |
| RemoteAddr 带端口 | 先 SplitHostPort 再 ParseIP |
| URL 编码 | 安全检查前先 QueryUnescape |
| 中间件顺序 | 日志最外层,安全在业务前 |
| 认证跟路由走 | 不同服务不同认证策略 |
| 避免常见端口 | 用环境变量配置端口 |
| 渐进式切换 | 逐个服务收束,不要一刀切 |
这些坑看似简单,但在实际开发中很容易被忽略。希望对你有帮助。