客户端也需要中间件
我们经常给 HTTP 服务端写中间件:请求日志、鉴权、恢复 panic、请求 ID。其实 HTTP 客户端也有类似需求。调用外部 API 时,你可能希望统一加 User-Agent、加请求 ID、记录耗时、对 502/503 做有限重试。Go 标准库通过 http.RoundTripper 支持这种扩展。
http.Client 里有一个字段:
Transport http.RoundTripper
RoundTripper 负责执行一次 HTTP 请求。我们可以包装默认 transport,在请求前后做事情。这篇文章用标准库实现几个简单客户端中间件。
RoundTripper 是什么
接口:
type RoundTripper interface {
RoundTrip(*http.Request) (*http.Response, error)
}
最小包装:
type LoggingTransport struct {
Base http.RoundTripper
}
func (t LoggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.Base
if base == nil {
base = http.DefaultTransport
}
start := time.Now()
resp, err := base.RoundTrip(req)
if err != nil {
log.Printf("http request method=%s url=%s error=%v duration=%s",
req.Method, req.URL.String(), err, time.Since(start))
return nil, err
}
log.Printf("http request method=%s url=%s status=%d duration=%s",
req.Method, req.URL.String(), resp.StatusCode, time.Since(start))
return resp, nil
}
使用:
client := &http.Client{
Timeout: 5 * time.Second,
Transport: LoggingTransport{},
}
现在通过这个 client 发出的请求都会记录日志。
添加请求 ID
type HeaderTransport struct {
Base http.RoundTripper
RequestID string
}
func (t HeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.Base
if base == nil {
base = http.DefaultTransport
}
cloned := req.Clone(req.Context())
if t.RequestID != "" {
cloned.Header.Set("X-Request-ID", t.RequestID)
}
cloned.Header.Set("User-Agent", "my-go-client/1.0")
return base.RoundTrip(cloned)
}
为什么要 clone request?因为请求可能被复用或被调用方继续使用。中间件修改请求时,克隆更稳。
组合:
transport := LoggingTransport{
Base: HeaderTransport{
RequestID: "req-123",
},
}
client := &http.Client{
Timeout: 5 * time.Second,
Transport: transport,
}
请求会先经过 LoggingTransport,再经过 HeaderTransport,最后到默认 transport。
简单重试
重试要谨慎,只对幂等请求或明确支持幂等键的请求使用。GET 通常比较适合,POST 创建订单就要小心。
type RetryTransport struct {
Base http.RoundTripper
Attempts int
}
func (t RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.Base
if base == nil {
base = http.DefaultTransport
}
attempts := t.Attempts
if attempts <= 0 {
attempts = 3
}
var lastErr error
for i := 0; i < attempts; i++ {
resp, err := base.RoundTrip(req)
if err != nil {
lastErr = err
} else if resp.StatusCode == http.StatusBadGateway ||
resp.StatusCode == http.StatusServiceUnavailable ||
resp.StatusCode == http.StatusGatewayTimeout {
resp.Body.Close()
lastErr = fmt.Errorf("temporary status: %d", resp.StatusCode)
} else {
return resp, nil
}
select {
case <-time.After(time.Duration(i+1) * 200 * time.Millisecond):
case <-req.Context().Done():
return nil, req.Context().Err()
}
}
return nil, lastErr
}
这个例子为了入门保持简单。真实重试还要考虑请求体是否能重复读取、是否有幂等键、是否应该限制总耗时。
组合顺序会影响日志
Transport 像洋葱一样一层包一层,顺序不同,看到的行为也不同。比如你把日志包在重试外面,日志可能只记录一次整体调用;如果把日志放在重试里面,每次尝试都会记录一行。两种都合理,取决于你想观察什么。
client := &http.Client{
Timeout: 5 * time.Second,
Transport: LoggingTransport{
Base: RetryTransport{
Base: http.DefaultTransport,
},
},
}
上面这种组合更像“记录业务视角的一次调用”。如果调换顺序:
client := &http.Client{
Timeout: 5 * time.Second,
Transport: RetryTransport{
Base: LoggingTransport{
Base: http.DefaultTransport,
},
},
}
日志会更接近“记录每一次网络尝试”。线上排查外部依赖抖动时,后者信息更多;日常访问日志里,前者更简洁。中间件的好处是组合灵活,代价是你必须把顺序写得明确,最好在构造 client 的地方集中完成,不要散落在各个业务函数里。
小结
Go HTTP 客户端可以通过 RoundTripper 实现中间件式扩展。日志、请求头、请求 ID、重试都可以围绕 RoundTrip 包装。组合时要注意顺序,修改请求时最好 clone,重试时要确认请求是否幂等。
客户端中间件能让外部调用更统一,但不要把复杂策略藏得太深。超时、日志、错误和重试规则都应该清楚可见。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。