ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

API网关实战:用Go httputil实现反向代理与自定义网关

API网关实战:用Go httputil实现反向代理与自定义网关

1. 从六个微服务到统一入口:我为什么用 Go httputil 手写 API 网关

前端同学第三次来找我的时候,手里拿着六份接口文档。六个微服务,六个域名端口,跨域配置要写六遍,认证逻辑每个服务各写一套,改一次 Token 校验规则得改六个仓库。这个场景下,API 网关不是「要不要做」的问题,而是「怎么快速做出来」的问题。

API 网关本质上是一个反向代理加上一堆横切逻辑。反向代理负责把外部请求转发到内部服务,横切逻辑负责认证、限流、日志、路由。Go 标准库里的net/http/httputil包提供了ReverseProxy,几行代码就能跑起一个反向代理,再配合自定义的Director、ModifyResponse、ErrorHandler,就能拼出一个生产可用的自定义网关。

这篇文章适合谁:正在做微服务拆分、需要统一入口的 Go 后端开发者;想理解反向代理底层原理、不想直接上 Kong/APISIX 的团队;以及需要把上游 endpoint 统一收口到某个 API 通道(比如用统一 Key 管理多家模型服务)的工程同学。我会从最简单的NewSingleHostReverseProxy开始,一步步加上路径重写、Header 透传、超时控制、动态路由,最后给出本地 curl 验证步骤,以及如何把上游地址改到 TaoToken 的 API 通道完成联调。

我试过直接用 Nginx 顶一阵子,但 Nginx 改配置要 reload,而且没法在里面写 Go 的认证逻辑。后来决定用 Go 自己写,灵活性高,认证、限流、埋点全用 Go 写,团队里会 Go 的人都能维护。下面把完整过程拆开讲。

2. TaoToken 前置准备:统一 Key 与 API 通道的接入配置

在讲网关代码之前,先把上游 endpoint 的事情说清楚。很多团队的自建网关后面挂的不只是自家微服务,还有外部 API 通道,比如模型对话、代码补全这类服务。如果每个服务各自管一套 Key,网关的认证和转发逻辑会变得很乱。比较合理的做法是:网关统一持有上游通道的 Key,内部服务不直接接触外部凭证。

TaoToken 在这里扮演的就是「统一 API 通道」的角色。它提供一个兼容 OpenAI 风格的接口地址,你可以在网关里把某条路由的上游 target 指向它,由网关统一注入 Key。这样内部服务调用时只需要带内部 Token,网关负责换成上游 Key 再转发。

先拿到接入所需的三样东西:Base URL、API Key、Model ID。Base URL 是https://taotoken.net/api,注意这个地址不带任何查询参数,是纯粹的 API 入口。API Key 在控制台的 API Keys 页面创建,创建后只显示一次,记得存到环境变量或配置中心,不要硬编码进代码。

控制台地址在这里:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=gateway_console

API Keys 管理页面:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=gateway_apikeys

接入文档(里面有完整的请求格式和错误码说明):https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=gateway_doc

Model ID 需要根据你实际要调用的模型来填,在模型对话页面可以看到当前可用的模型列表:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=gateway_chat

把这三样东西准备好之后,网关的上游配置就可以写成这样一份 JSON,放在config/upstream.json:

{ "upstreams": [ { "name": "taotoken", "base_url": "https://taotoken.net/api", "api_key_env": "TAOTOKEN_API_KEY", "model_id": "your-model-id", "timeout_seconds": 120 } ] }

注意api_key_env字段,它指向的是环境变量名而不是 Key 本身。网关启动时从环境变量读取,这样配置文件可以进版本库,Key 不会泄露。启动前先导出环境变量:

export TAOTOKEN_API_KEY="sk-你的实际Key"

如果你用的是 Claude Code 这类工具做本地联调,它的配置里同样需要填 Base URL、Key、Model ID 三件套。Claude Code 的接入说明在文档里有专门章节,地址是 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=gateway_claudecode

前置准备到这里就够了。核心是三件事:Base URL 用https://taotoken.net/api,Key 从环境变量读,Model ID 按实际调用填。下面进入网关代码部分。

3. 可复制的 ReverseProxy 配置:路径重写、Header 透传与超时控制

这一节给出可以直接复制运行的完整代码。我把它拆成三个文件:main.go负责启动,gateway.go负责网关核心逻辑,routes.json负责路由配置。先看路由配置,这是整个网关的「路由表」。

[ { "prefix": "/api/user", "target": "http://127.0.0.1:8081", "strip_prefix": true, "timeout_seconds": 10 }, { "prefix": "/api/order", "target": "http://127.0.0.1:8082", "strip_prefix": true, "timeout_seconds": 10 }, { "prefix": "/api/ai", "target": "https://taotoken.net/api", "strip_prefix": true, "timeout_seconds": 120, "inject_auth": true } ]

strip_prefix为 true 时,网关会把/api/user/profile转发成/profile给上游。inject_auth为 true 时,网关会从环境变量读取 Key 并注入Authorization头,内部服务不需要自己带外部凭证。timeout_seconds按路由区分,AI 类接口给 120 秒,普通接口 10 秒。

下面是网关核心代码gateway.go:

package main import ( "encoding/json" "log" "net" "net/http" "net/http/httputil" "net/url" "os" "strings" "sync" "time" ) type RouteConfig struct { Prefix string `json:"prefix"` Target string `json:"target"` StripPrefix bool `json:"strip_prefix"` TimeoutSeconds int `json:"timeout_seconds"` InjectAuth bool `json:"inject_auth"` } type Gateway struct { mu sync.RWMutex routes map[string]*httputil.ReverseProxy } func NewGateway(configPath string) (*Gateway, error) { gw := &Gateway{routes: make(map[string]*httputil.ReverseProxy)} if err := gw.LoadRoutes(configPath); err != nil { return nil, err } return gw, nil } func (gw *Gateway) LoadRoutes(path string) error { data, err := os.ReadFile(path) if err != nil { return err } var configs []RouteConfig if err := json.Unmarshal(data, &configs); err != nil { return err } newRoutes := make(map[string]*httputil.ReverseProxy) for _, cfg := range configs { target, err := url.Parse(cfg.Target) if err != nil { log.Printf("跳过无效路由 %s: %v", cfg.Prefix, err) continue } prefix := cfg.Prefix strip := cfg.StripPrefix inject := cfg.InjectAuth timeout := time.Duration(cfg.TimeoutSeconds) * time.Second proxy := &httputil.ReverseProxy{ Director: func(req *http.Request) { req.URL.Scheme = target.Scheme req.URL.Host = target.Host req.Host = target.Host if strip { req.URL.Path = strings.TrimPrefix(req.URL.Path, prefix) if req.URL.Path == "" { req.URL.Path = "/" } } // Header 透传:保留原始 X-Forwarded-For,追加当前客户端 IP clientIP, _, _ := net.SplitHostPort(req.RemoteAddr) if prior := req.Header.Get("X-Forwarded-For"); prior != "" { req.Header.Set("X-Forwarded-For", prior+", "+clientIP) } else { req.Header.Set("X-Forwarded-For", clientIP) } req.Header.Set("X-Gateway", "go-httputil-gateway") // 统一注入上游 Key if inject { key := os.Getenv("TAOTOKEN_API_KEY") if key != "" { req.Header.Set("Authorization", "Bearer "+key) } } }, ModifyResponse: func(resp *http.Response) error { resp.Header.Set("X-Gateway-Version", "1.0") return nil }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { log.Printf("上游错误 path=%s err=%v", r.URL.Path, err) w.WriteHeader(http.StatusBadGateway) w.Write([]byte(`{"error":"upstream unavailable"}`)) }, Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: 5 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, ResponseHeaderTimeout: timeout, IdleConnTimeout: 90 * time.Second, MaxIdleConns: 100, MaxIdleConnsPerHost: 20, }, } newRoutes[prefix] = proxy } gw.mu.Lock() gw.routes = newRoutes gw.mu.Unlock() log.Printf("加载了 %d 条路由", len(newRoutes)) return nil } func (gw *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { gw.mu.RLock() var bestMatch string for prefix := range gw.routes { if strings.HasPrefix(r.URL.Path, prefix) && len(prefix) > len(bestMatch) { bestMatch = prefix } } proxy, ok := gw.routes[bestMatch] gw.mu.RUnlock() if !ok { w.WriteHeader(http.StatusNotFound) w.Write([]byte(`{"error":"route not found"}`)) return } proxy.ServeHTTP(w, r) }

main.go只负责启动:

package main import ( "log" "net/http" ) func main() { gw, err := NewGateway("routes.json") if err != nil { log.Fatal(err) } log.Println("API 网关启动在 :8080") if err := http.ListenAndServe(":8080", gw); err != nil { log.Fatal(err) } }

几个关键点解释一下。Director里做路径重写和 Header 透传,X-Forwarded-For采用追加而不是覆盖,这样上游能看到完整的调用链。Transport里的ResponseHeaderTimeout是等待上游返回响应头的最大时间,这个值设小了会导致大文件上传或长耗时接口被网关提前断开,后面第五节会专门讲这个坑。inject_auth为 true 的路由,网关统一从环境变量读 Key 注入,内部服务不需要接触外部凭证。

如果你需要更细粒度的超时控制,比如按 Content-Length 区分大文件和小文件,可以自定义一个RoundTripper:

type SmartTransport struct { base *http.Transport } func (t *SmartTransport) RoundTrip(req *http.Request) (*http.Response, error) { transport := t.base.Clone() if req.ContentLength > 10*1024*1024 { transport.ResponseHeaderTimeout = 5 * time.Minute } else { transport.ResponseHeaderTimeout = 10 * time.Second } return transport.RoundTrip(req) }

把这个SmartTransport赋给ReverseProxy.Transport即可。这样大文件上传走长超时,普通请求走短超时快速失败。

4. 本地 curl 验证:从启动到成功拿到上游响应

代码写完了,接下来验证。先准备一个最简单的上游服务,用 Go 起一个返回 JSON 的 HTTP 服务,监听 8081:

package main import ( "encoding/json" "net/http" ) func main() { http.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{ "service": "user-service", "path": r.URL.Path, "xff": r.Header.Get("X-Forwarded-For"), "gateway": r.Header.Get("X-Gateway"), }) }) http.ListenAndServe(":8081", nil) }

启动上游服务,再启动网关:

go run upstream.go & go run main.go

网关日志会打印加载了 3 条路由和API 网关启动在 :8080。现在用 curl 验证路径重写和 Header 透传:

curl -i http://127.0.0.1:8080/api/user/profile

预期返回:

{ "service": "user-service", "path": "/profile", "xff": "127.0.0.1", "gateway": "go-httputil-gateway" }

注意path是/profile而不是/api/user/profile,说明strip_prefix生效了。xff和gateway两个字段说明 Header 透传正常。响应头里应该能看到X-Gateway-Version: 1.0。

再验证 AI 路由的 Key 注入。先确认环境变量已导出:

echo $TAOTOKEN_API_KEY

然后请求 AI 路由:

curl -i -X POST http://127.0.0.1:8080/api/ai/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "your-model-id", "messages": [{"role": "user", "content": "你好"}] }'

这条请求会被网关转发到https://taotoken.net/api/v1/chat/completions,网关自动注入Authorization: Bearer $TAOTOKEN_API_KEY。如果返回 200 并且 body 里有正常的模型回复,说明整条链路通了。如果返回 401,先检查环境变量是否导出、Key 是否有效。

验证路由不存在的情况:

curl -i http://127.0.0.1:8080/api/unknown/path

预期返回 404 和{"error":"route not found"}。

验证上游不可用的情况,把上游服务停掉再请求:

curl -i http://127.0.0.1:8080/api/user/profile

预期返回 502 和{"error":"upstream unavailable"},网关日志会打印具体的连接错误。这一步验证的是ErrorHandler是否生效。

到这里,一个具备路由转发、路径重写、Header 透传、Key 注入、超时控制、错误处理的反向代理型网关就跑起来了。整个过程不需要任何第三方依赖,纯标准库。

5. 常见报错排查:401、local proxy failed、reading choices、OAuth

网关跑起来之后,实际联调中会遇到几类典型报错。这一节按报错信息对照排查,每条都给出定位思路。

401 Unauthorized。如果请求 AI 路由返回 401,先看网关日志里转发出去的请求有没有带Authorization头。常见原因是环境变量没导出,或者inject_auth字段拼写错了。检查routes.json里对应路由的inject_auth是否为 true,再确认TAOTOKEN_API_KEY在当前 shell 里能echo出来。如果 Key 是从配置中心读的,确认读取逻辑在Director执行之前已经完成。

local proxy failed。这个报错通常出现在用某些客户端工具通过本地代理访问上游时。排查顺序:先确认网关进程本身能直连上游,用curl -v https://taotoken.net/api测试;再确认网关的Transport没有配置错误的Proxy字段。如果你的环境里设置了HTTP_PROXY环境变量,Go 的http.Transport默认会读取它,可能导致请求走了一个不可用的本地代理。可以在Transport里显式设置Proxy: nil来绕过环境变量。

reading choices 相关报错。这类报错一般出现在流式响应场景,网关在读取上游 SSE 流时提前关闭了连接。原因是ResponseHeaderTimeout或IdleConnTimeout设置过短,流式响应在两次数据块之间超过了空闲超时。解决办法是把流式路由的timeout_seconds调大,并且在Transport里把IdleConnTimeout设为 0(不限制)或一个较大的值。另外确认ModifyResponse里没有对resp.Body做缓冲读取,流式响应必须保持 Body 可流式读取。

OAuth 相关报错。如果上游通道需要 OAuth 流程,网关不能简单地注入静态 Key。这种情况下建议在网关里加一个 Token 刷新中间件,定期用 refresh token 换新的 access token,缓存到内存里,Director执行时从缓存读取。注意刷新逻辑要加锁,避免并发刷新。如果用的是 Claude Code 这类工具做本地联调,它的 OAuth 流程在文档里有说明,地址是 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=gateway_oauth

连接被重置或超时。先看网关日志里的上游错误行,里面会打印具体的 error。如果是context deadline exceeded,说明ResponseHeaderTimeout到了,调大对应路由的timeout_seconds。如果是connection refused,说明上游地址或端口不对,用curl直接测上游地址确认。如果是EOF,通常是上游主动断开了连接,检查上游服务的日志。

路由匹配不到。网关用的是最长前缀匹配,如果/api/user和/api/user/profile两条路由同时存在,请求/api/user/profile/xxx会匹配到更长的那条。如果发现请求走了错误的路由,检查routes.json里有没有前缀重叠的配置。另外注意strip_prefix为 true 时,转发给上游的路径是去掉前缀之后的,上游服务的路由注册要对应调整。

排查的核心思路是:先看网关日志,再看上游日志,最后用 curl 直接测上游。网关日志里我特意打印了path和err,定位问题时先看这两项。

6. 把上游 endpoint 收口到统一通道:联调与后续扩展

网关跑通之后,最后一步是把上游 endpoint 统一收口。前面routes.json里已经有一条/api/ai路由指向https://taotoken.net/api,并且开启了inject_auth。这样内部服务调用 AI 能力时,只需要请求网关的/api/ai/v1/chat/completions,不需要自己管 Key,也不需要知道上游的真实地址。

这种收口方式的好处有三个。第一,Key 只在网关一处配置,轮换 Key 时只改一个环境变量,不用改所有服务。第二,网关可以统一做限流和审计,哪个服务调了多少次一目了然。第三,上游地址变更时只改routes.json,内部服务无感知。

如果你需要长期跑编码类 Agent 或者高频调用,可以考虑用 Coding Plan 来管理额度,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=gateway_codingplan

联调完成后,网关还可以继续扩展几个方向。一是加 JWT 认证中间件,在ServeHTTP里路由匹配之前先校验内部 Token。二是加令牌桶限流,按路由或按调用方维度限制 QPS。三是加请求日志和链路追踪,把X-Request-ID透传到上游,方便全链路排查。四是把routes.json换成从配置中心动态拉取,配合文件监听实现热加载,加新服务不用重启网关。

代码层面的扩展点很清晰:认证和限流加在ServeHTTP里proxy.ServeHTTP之前,日志加在Director和ModifyResponse里,动态路由加一个fsnotify监听routes.json变更后重新调用LoadRoutes。这些都不需要改动ReverseProxy的核心逻辑,属于叠加式扩展。

最后提醒一个实际部署时的细节:网关的Transport建议全局复用一个实例,不要每条路由都 new 一个,否则连接池无法共享,高并发下会频繁建连。把Transport提到Gateway结构体里作为字段,所有路由共用,MaxIdleConnsPerHost根据实际并发量调整。这个细节在压测时差异很明显,连接复用做好之后,网关本身的 CPU 占用会低很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进