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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentgateway_consoleAPI Keys 管理页面https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentgateway_apikeys接入文档里面有完整的请求格式和错误码说明https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentgateway_docModel ID 需要根据你实际要调用的模型来填在模型对话页面可以看到当前可用的模型列表https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentgateway_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_KEYsk-你的实际Key如果你用的是 Claude Code 这类工具做本地联调它的配置里同样需要填 Base URL、Key、Model ID 三件套。Claude Code 的接入说明在文档里有专门章节地址是 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentgateway_claudecode前置准备到这里就够了。核心是三件事Base URL 用https://taotoken.net/apiKey 从环境变量读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.gopackage 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 区分大文件和小文件可以自定义一个RoundTrippertype 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 服务监听 8081package 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentgateway_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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentgateway_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 占用会低很多。