3. HTTP 请求录制基础:net/http 包结构、自定义 Transport 实现请求拦截、请求体与响应体读取与缓存

在业务环境回放系统中,第一步也是最关键的一步是录制。我们需要在不侵入业务代码的前提下,捕获所有发出的 HTTP 请求及其对应的响应。Go 标准库 net/http 提供了强大的扩展点,允许我们通过自定义 RoundTripper 来实现透明的请求拦截与录制。

3.1 net/http 包核心结构

理解 net/http 的请求-响应生命周期,是构建录制层的基础。下表列出了与请求拦截直接相关的核心类型及其职责:

类型 包路径 核心职责
http.Client net/http 用户发起请求的入口,管理 Cookie、超时、重定向策略。内部依赖 Transport 执行实际网络调用。
http.RoundTripper net/http 接口,定义了 RoundTrip(*Request) (*Response, error) 方法。所有 HTTP 请求最终都会经过此接口。
http.Transport net/http RoundTripper 的默认实现,负责连接池管理、TLS 握手、代理配置等底层网络细节。
http.Request net/http 代表客户端发起的 HTTP 请求,包含 Method、URL、Header、Body 等字段。
http.Response net/http 代表服务器返回的 HTTP 响应,包含 StatusCode、Header、Body 等字段。
关键洞察: http.Client 的 Transport 字段是一个接口。通过替换或包装该字段,我们可以在不修改业务代码的情况下,插入录制逻辑。

3.2 自定义 Transport 实现请求拦截

实现请求拦截的核心是编写一个实现了 http.RoundTripper 接口的结构体,并将其注入到 http.Client 中。这个自定义 Transport 会包装原始的 http.Transport,在请求发出前和响应返回后插入钩子函数。

以下是一个基础的录制 Transport 实现:

package recorder

import (
    "bytes"
    "io"
    "net/http"
    "time"
)

// RecordedRequest 代表一次录制的请求-响应记录
type RecordedRequest struct {
    Method        string
    URL           string
    Header        http.Header
    Body          []byte
    StatusCode    int
    ResponseHeader http.Header
    ResponseBody  []byte
    Timestamp     time.Time
    Duration      time.Duration
}

// RecordingTransport 自定义 Transport,用于拦截和录制 HTTP 流量
type RecordingTransport struct {
    // RoundTripper 是原始的 Transport,通常为 http.DefaultTransport
    RoundTripper http.RoundTripper
    // Recorder 是录制回调,每次请求完成后被调用
    Recorder func(record RecordedRequest)
}

// RoundTrip 实现 http.RoundTripper 接口
func (t *RecordingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    // 1. 读取并缓存请求体
    reqBody, err := t.readAndReplaceBody(req)
    if err != nil {
        return nil, err
    }

    start := time.Now()

    // 2. 执行原始请求
    resp, err := t.RoundTripper.RoundTrip(req)
    if err != nil {
        return nil, err
    }

    duration := time.Since(start)

    // 3. 读取并缓存响应体
    respBody, err := t.readAndReplaceResponseBody(resp)
    if err != nil {
        return nil, err
    }

    // 4. 构造录制记录并回调
    record := RecordedRequest{
        Method:         req.Method,
        URL:            req.URL.String(),
        Header:         req.Header.Clone(),
        Body:           reqBody,
        StatusCode:     resp.StatusCode,
        ResponseHeader: resp.Header.Clone(),
        ResponseBody:   respBody,
        Timestamp:      start,
        Duration:       duration,
    }

    if t.Recorder != nil {
        t.Recorder(record)
    }

    return resp, nil
}

// readAndReplaceBody 读取请求体并替换为新的 io.ReadCloser
func (t *RecordingTransport) readAndReplaceBody(req *http.Request) ([]byte, error) {
    if req.Body == nil {
        return nil, nil
    }
    bodyBytes, err := io.ReadAll(req.Body)
    if err != nil {
        return nil, err
    }
    req.Body.Close()
    // 替换为可重复读取的 Body
    req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
    return bodyBytes, nil
}

// readAndReplaceResponseBody 读取响应体并替换
func (t *RecordingTransport) readAndReplaceResponseBody(resp *http.Response) ([]byte, error) {
    if resp.Body == nil {
        return nil, nil
    }
    bodyBytes, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }
    resp.Body.Close()
    resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
    return bodyBytes, nil
}
使用示例:
// 创建自定义 Client
transport := &recorder.RecordingTransport{
    RoundTripper: http.DefaultTransport,
    Recorder: func(record recorder.RecordedRequest) {
        fmt.Printf("Recorded: %s %s [%d] in %v\n",
            record.Method, record.URL, record.StatusCode, record.Duration)
        // 此处可将 record 写入文件、数据库或发送到消息队列
    },
}
client := &http.Client{Transport: transport}

// 业务代码无需任何修改
resp, _ := client.Get("https://api.example.com/users")
defer resp.Body.Close()

3.3 请求体与响应体读取与缓存

HTTP 的 Body 是一个 io.ReadCloser,默认只能被读取一次。在录制场景中,我们必须解决两个问题:

  1. 读取后不破坏原始流程: 业务代码和中间件可能也需要读取 Body。
  2. Body 的重复使用: 录制后,原始请求/响应仍需正常流转。

解决方案是“读取-缓存-替换”模式:

  • 读取: 使用 io.ReadAll 将 Body 全部读入内存 []byte。
  • 缓存: 将读取到的字节切片存入录制记录中。
  • 替换: 使用 io.NopCloser(bytes.NewBuffer(bodyBytes)) 创建一个新的 io.ReadCloser,替换回原 Body 字段。
内存安全警告: 对于大文件上传或流式响应(如 SSE、大文件下载),将整个 Body 读入内存可能导致 OOM。在生产环境中,应设置 Body 大小上限,或使用临时文件作为缓冲。本章后续会介绍流式录制方案。

3.4 录制架构流程

下图展示了自定义 Transport 在请求-响应生命周期中的拦截位置与数据流向:

自定义 Transport 录制流程 业务代码 http.Client RecordingTransport RoundTrip() 拦截 读取 & 缓存 Body http.Transport 录制存储 (文件/DB) 说明: RecordingTransport 包装原始 Transport,在 RoundTrip 中先缓存请求体,执行网络请求后缓存响应体,最后将完整记录回调给 Recorder。

3.5 录制记录的数据结构

一个完整的录制记录应包含以下字段,以便后续回放时能精确还原请求上下文:

字段 类型 说明
ID string 全局唯一标识,用于去重和关联
Timestamp time.Time 请求发起时间,用于时序回放
Method string HTTP 方法 (GET, POST, PUT, DELETE 等)
URL string 完整的请求 URL,包含查询参数
Header map[string][]string 请求头,包含 Content-Type、Authorization 等
Body []byte 请求体原始字节
StatusCode int 响应状态码
ResponseHeader map[string][]string 响应头
ResponseBody []byte 响应体原始字节
Duration time.Duration 请求耗时,用于回放时的延迟模拟

3.6 常见陷阱与最佳实践

  • Body 关闭: 读取完 Body 后必须调用 Close(),否则会导致连接泄漏。替换后的新 Body 由业务代码负责关闭。
  • Header 克隆: 录制时使用 Header.Clone() 创建副本,避免后续修改影响录制数据。
  • 错误处理: 录制逻辑不应影响原始请求的成功/失败。建议在 Recorder 回调中使用 go func() 异步处理,或使用错误日志而非 panic。
  • HTTPS 支持: http.Transport 天然支持 HTTPS,自定义 Transport 无需额外处理 TLS 逻辑。
进阶提示: 在微服务架构中,建议将录制 Transport 封装为可插拔的中间件,通过配置开关控制是否启用录制,避免在性能敏感路径上产生额外开销。