# 反向代理 Touka 内置了反向代理能力,可以直接把某一组请求转发到后端服务,同时保留 Touka 的路由、中间件与统一错误处理风格。 `touka.ReverseProxy` 返回一个 `HandlerFunc`,因此它可以像普通路由处理器一样直接挂到 `GET`、`ANY`、路由组等位置。 ## 最简单的用法 ```go package main import ( "log" "net/url" "github.com/infinite-iroha/touka" ) func main() { r := touka.Default() target, err := url.Parse("http://127.0.0.1:9000") if err != nil { log.Fatal(err) } r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Target: target, })) _ = r.Run(touka.WithAddr(":8080")) } ``` 当客户端访问 `http://127.0.0.1:8080/api/users` 时,请求会被转发到 `http://127.0.0.1:9000/api/users`。 ## 带基础路径的代理 如果目标服务部署在一个子路径下,可以直接把目标地址写成带路径的 URL: ```go target, _ := url.Parse("http://127.0.0.1:9000/backend") r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Target: target, })) ``` 此时: - `/api/users` 会转发到 `/backend/api/users` - `/api/orders?id=10` 会转发到 `/backend/api/orders?id=10` 目标 URL 自身携带的查询参数也会被保留并与原请求查询参数合并。 合并后的出站查询串会再经过一次规范化处理,因此某些非标准分隔符(例如 `;`)或非法参数片段可能被重编码、折叠或直接丢弃。 这是为了尽量让代理链各跳对查询参数的解析结果保持一致,并减少参数走私这类解析歧义风险。 ## 配置项说明 ```go type ReverseProxyConfig struct { Target *url.URL Targets []string LoadBalancing ReverseProxyLoadBalancingConfig PassiveHealth ReverseProxyPassiveHealthConfig Transport http.RoundTripper FlushInterval time.Duration BufferPool BufferPool AllowH2CUpstream bool ModifyRequest func(*http.Request) ModifyResponse func(*http.Response) error ErrorHandler func(http.ResponseWriter, *http.Request, error) ForwardedHeaders ForwardedHeadersPolicy ForwardedBy string Via string PreserveHost bool } ``` ### `Target` 与 `Targets` 二选一。表示单个后端目标地址,至少需要提供 `scheme` 和 `host`。 ```go target, _ := url.Parse("http://backend:9000") ``` ### `Targets` 可选。用于配置多个后端目标地址。 - `Target` 与 `Targets` 互斥,只能使用其中一种 - `Targets` 的每一项都必须是完整 URL - 每个 target 仍然可以自带 base path 和 query ```go r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Targets: []string{ "http://127.0.0.1:9001/base?from=a", "http://127.0.0.1:9002/base?from=b", }, })) ``` 这意味着不同 upstream 仍然可以保留各自的路径前缀和固定查询参数。 ### `LoadBalancing` 用于配置 upstream 选择策略和重试行为。 ```go type ReverseProxyLoadBalancingConfig struct { Policy ReverseProxyLBPolicy Retries int TryDuration time.Duration TryInterval time.Duration } ``` 当前内置策略: - `touka.LBRandom()` - `touka.LBRoundRobin()` - `touka.LBFirst()` - `touka.LBLeastConn()` - `touka.LBIPHash()` - `touka.LBClientIPHash()` - `touka.LBURIHash()` - `touka.LBHeader("X-Upstream", fallback)` - `touka.LBQuery("tenant", fallback)` 其中: - `LBFirst()` 适合主备/故障转移顺序 - `LBHeader` / `LBQuery` 只有在对应 header/query **缺失**时才会走 fallback - 如果 `LBHeader` / `LBQuery` 没有显式 fallback,则默认回退到 `LBRandom()` ```go r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Targets: []string{ "http://127.0.0.1:9001", "http://127.0.0.1:9002", }, LoadBalancing: touka.ReverseProxyLoadBalancingConfig{ Policy: touka.LBHeader("X-Upstream", touka.LBFirst()), Retries: 1, }, })) ``` 重试说明: - 只对未开始收到上游响应的失败进行重试 - 默认仅对 RFC 定义的安全方法(`GET` / `HEAD` / `OPTIONS` / `TRACE`)重试 - `Retries` 表示额外重试次数 - `TryDuration` 表示总尝试时间预算;如果配置了它,会优先于重试次数控制停止时机 - `TryInterval` 表示两次重试之间的等待间隔 ### `PassiveHealth` 用于配置被动健康检查。它不会后台探测 upstream,而是根据真实代理请求的失败结果临时把某个 upstream 视为不健康。 ```go type ReverseProxyPassiveHealthConfig struct { FailDuration time.Duration MaxFails int UnhealthyStatus []int } ``` - `FailDuration > 0` 时启用被动健康跟踪 - `MaxFails <= 0` 时默认按 `1` 处理 - `UnhealthyStatus` 中的状态码会被记为一次失败,但当前请求仍会先收到该响应;后续请求才会绕过这个 upstream ```go r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Targets: []string{ "http://127.0.0.1:9001", "http://127.0.0.1:9002", }, LoadBalancing: touka.ReverseProxyLoadBalancingConfig{ Policy: touka.LBFirst(), }, PassiveHealth: touka.ReverseProxyPassiveHealthConfig{ FailDuration: time.Minute, UnhealthyStatus: []int{http.StatusServiceUnavailable}, }, })) ``` ### `AllowH2CUpstream` 允许代理使用未加密 HTTP/2(h2c)与 `http://` upstream 通信。 - 默认关闭 - 这是一个显式配置项 - 启用后,Touka 会为该 upstream 使用 h2c prior-knowledge 方式连接上游 - 这意味着上游本身也必须显式支持 h2c;它不是“先试 h2c,失败再自动回退到 h1”的协商模式 ```go r.GET("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Target: target, AllowH2CUpstream: true, })) ``` 对于下游 HTTP/2 extended `CONNECT` websocket 场景,Touka 会只在该特殊桥接路径上强制与上游使用 HTTP/1.1 websocket upgrade,以匹配 Caddy 风格的桥接语义;普通 HTTP 请求不会因为这个特性而被强制降级为 HTTP/1.1。 ### `Transport` 可选。用于自定义底层转发所使用的 `http.RoundTripper`。 如果留空,则默认使用 `http.DefaultTransport`。 ```go proxyTransport := &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 20, } r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Target: target, Transport: proxyTransport, })) ``` ### `FlushInterval` 控制代理在复制响应体时的主动刷新间隔: - `0`:不额外定时刷新 - `> 0`:按指定间隔刷新 - `< 0`:每次写入后立即刷新 对于 SSE 和无 `Content-Length` 的流式响应,Touka 会自动立即刷新,不依赖该配置。 ### `BufferPool` 可选。用于为响应体复制过程提供可复用的字节缓冲区,以减少大响应或高并发代理场景下的临时内存分配。 如果留空,Touka 会在复制响应体时按需分配默认缓冲区。 ```go type bytePool struct { pool sync.Pool } func (p *bytePool) Get() []byte { if buf, ok := p.pool.Get().([]byte); ok { return buf } return make([]byte, 32*1024) } func (p *bytePool) Put(buf []byte) { if cap(buf) >= 32*1024 { p.pool.Put(buf[:32*1024]) } } proxyPool := &bytePool{} r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Target: target, BufferPool: proxyPool, })) ``` 通常只有在您已经观察到明显的分配压力,或代理的响应体较大、吞吐较高时,才需要专门配置它。 ### `ModifyRequest` 在请求真正发往后端前,对出站请求做最后修改。 如果启用了多 upstream 重试,`ModifyRequest` 可能会在同一个客户端请求里被调用多次:每一次实际发往 upstream 的尝试都会重新构造一份请求并再次执行它。因此,这个回调最好保持幂等,不要依赖“只会执行一次”的副作用。 常见用途: - 覆盖 `Host` - 增加鉴权头 - 重写路径 - 注入内部追踪头 ```go r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Target: target, ModifyRequest: func(req *http.Request) { req.Header.Set("X-Internal-Token", "gateway-token") }, })) ``` ### `ModifyResponse` 在后端返回响应后、写回客户端前,对响应做额外处理。 注意:`ModifyResponse` 也会作用于 `101 Switching Protocols` 响应。 如果该代理路由需要转发 WebSocket 或其他 Upgrade 流量,请不要在这里消费、完全缓冲,或替换 `resp.Body` 为只读对象;后续升级流程仍然要求它保留 `io.ReadWriteCloser` 能力。 更稳妥的做法是对 `101` 响应直接跳过这类处理。 ```go r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Target: target, ModifyResponse: func(resp *http.Response) error { if resp.StatusCode == http.StatusSwitchingProtocols { return nil } resp.Header.Set("X-Proxy", "touka") return nil }, })) ``` 如果该函数返回错误,会转入 `ErrorHandler` 或默认的 `502 Bad Gateway` 处理流程。 ### `ErrorHandler` 用于处理连接后端失败、协议升级失败、`ModifyResponse` 返回错误等情况。 ```go r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Target: target, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { w.WriteHeader(http.StatusBadGateway) _, _ = w.Write([]byte("upstream unavailable")) }, })) ``` ### `PreserveHost` 默认情况下,代理请求的 `Host` 会跟随后端目标地址。 如果设置为 `true`,则会保留客户端原始 `Host`。 ```go r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Target: target, PreserveHost: true, })) ``` 这在某些依赖原始域名进行路由或租户识别的后端服务中会比较有用。 ## 转发头策略 Touka 支持两类常见的代理转发头: - 兼容性更好的 `X-Forwarded-*` - 标准化的 `Forwarded`(RFC 7239) 可选值: ```go const ( ForwardedBoth ForwardedHeadersPolicy = iota ForwardedNone ForwardedXForwardedOnly ForwardedRFC7239Only ) ``` 推荐默认使用 `ForwardedBoth`。 ```go r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Target: target, ForwardedHeaders: touka.ForwardedBoth, ForwardedBy: "_gateway-1", Via: "edge-1", })) ``` 如果您配置了 `ForwardedBy`,它必须是一个符合 RFC 7239 的 node identifier。 - IPv4:`203.0.113.43` - IPv6 / 带端口:`[2001:db8::17]:443` - 匿名标识:`_gateway-1` - 未知:`unknown` 像 `gateway-1` 这类普通 token 不再被视为合法的 `by=` 值。 `Via` 不是“留空即禁用”的开关。当前实现中: - 如果 `Via` 非空,则使用该值追加 `Via` - 如果 `Via` 为空,则会回退到固定值 `touka-engine` 因此,把 `Via` 留空时,发送出去的请求仍会包含 `Via` 头,只是使用默认标识 `touka-engine`。 如果您希望上游清楚区分不同入口、环境或网关实例,仍然建议显式设置一个稳定且可公开暴露的代理标识,例如: ```go r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Target: target, Via: "edge-gateway", })) ``` 当前版本没有提供“完全禁用追加 Via”的单独配置项,因此不要把空字符串当作关闭手段。 ### Touka 会如何处理这些头? Touka 会尽量遵循代理链语义: - 已有的 `X-Forwarded-For` 会保留,并在末尾追加当前 hop 的客户端 IP - 已有的 `Forwarded` 会保留,并在末尾追加当前 hop 的条目 - 已有的 `X-Forwarded-Host` 与 `X-Forwarded-Proto` 会优先保留;如果缺失,则由当前请求补齐 - `Via` 会追加当前代理标识 这意味着在 Touka 前面还有一层可信代理(如 Nginx、Traefik、Cloudflare、网关)时,上游服务仍然可以看到完整的代理链。 如果您**不信任**客户端传入的这些头,请在进入 `ReverseProxy` 之前自行清理,或在 `ModifyRequest` 中显式重写。 ## 协议升级与流式响应 Touka 的反向代理实现支持以下能力: - `CONNECT` 隧道转发(HTTP/1.x) - HTTP/2 extended `CONNECT` - `Connection: Upgrade` / `Upgrade` 协议升级转发 - WebSocket 等 101 Switching Protocols 场景 - SSE(Server-Sent Events)立即刷新 - Trailer 透传 - 1xx 响应透传 - `TRACE` / `OPTIONS` 上的 `Max-Forwards` 递减与本地终止处理 例如,代理 WebSocket 服务: ```go target, _ := url.Parse("http://127.0.0.1:9001") r.ANY("/ws/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Target: target, })) ``` ## Hop-by-hop 头处理 根据 HTTP 代理语义,Touka 在转发时会移除连接级别的 hop-by-hop 头,避免把只应作用于单跳连接的头继续传给下游。 典型包括: - `Connection` - `Proxy-Connection` - `Keep-Alive` - `Proxy-Authenticate` - `Proxy-Authorization` - `TE` - `Trailer` - `Transfer-Encoding` - `Upgrade` 同时,若请求本身是合法的协议升级请求,Touka 会在剥离后重新补回必要的 `Connection: Upgrade` 与 `Upgrade` 头。 ## 一个更完整的例子 ```go package main import ( "log" "net/http" "net/url" "time" "github.com/infinite-iroha/touka" ) func main() { r := touka.Default() target, err := url.Parse("http://127.0.0.1:9000") if err != nil { log.Fatal(err) } r.ANY("/api/*path", touka.ReverseProxy(touka.ReverseProxyConfig{ Target: target, ForwardedHeaders: touka.ForwardedBoth, ForwardedBy: "_gateway-1", Via: "gateway-1", FlushInterval: 100 * time.Millisecond, ModifyRequest: func(req *http.Request) { req.Header.Set("X-Gateway", "touka") }, ModifyResponse: func(resp *http.Response) error { resp.Header.Set("X-Proxy", "touka") return nil }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { w.WriteHeader(http.StatusBadGateway) _, _ = w.Write([]byte("bad gateway")) }, })) if err := r.Run(touka.WithAddr(":8080"), touka.WithGracefulShutdown(10*time.Second)); err != nil { log.Fatal(err) } } ``` ## 与 `SetForwardByClientIP` 的关系 `ReverseProxy` 负责把请求转发给后端,并维护代理链头。 而 `SetForwardByClientIP` / `SetRemoteIPHeaders` 是 Touka 在**接收请求**时,用于解析当前请求客户端 IP 的逻辑。 两者通常会一起出现,但解决的是两个不同方向的问题: - `ReverseProxy`:出站转发 - `SetForwardByClientIP`:入站解析 如果您的 Touka 本身就部署在其他代理之后,建议同时正确配置这两部分。