3、Go HTTP客户端基础:net/http包介绍、发送GET请求、发送POST请求、处理响应与错误

在契约压测中,所有接口调用最终都依赖HTTP客户端。Go标准库net/http提供了强大且稳定的客户端实现,是构建压测工具的基石。本章将系统讲解如何使用net/http发送GET/POST请求、处理响应结构以及管理常见错误,为后续编写压测脚本打下坚实基础。

3.1 net/http客户端核心结构

net/http包的核心是http.Client结构体,它负责管理连接池、超时设置和请求重定向策略。压测场景下,必须复用同一个Client实例,避免每次请求都创建新连接。

结构体/类型 作用 压测关键点
http.Client 发送HTTP请求并接收响应 复用实例、设置超时、控制连接池
http.Transport 底层连接管理(TCP连接池、TLS配置) 调整MaxIdleConns和MaxConnsPerHost
http.Request 封装请求方法、URL、Header、Body 每次请求新建,避免Body复用
http.Response 封装响应状态码、Header、Body 必须关闭Body,否则连接泄漏
压测最佳实践:始终使用&http.Client{Timeout: 30 * time.Second}创建客户端,并配置Transport的MaxIdleConnsPerHost为压测并发数,避免端口耗尽。

3.2 发送GET请求

GET请求用于从服务端获取资源。在压测中,GET请求通常用于查询接口、健康检查或数据拉取。以下代码展示了标准GET请求的完整流程:

package main

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

func main() {
    // 创建复用客户端,设置5秒超时
    client := &http.Client{
        Timeout: 5 * time.Second,
    }

    // 构建GET请求
    req, err := http.NewRequest("GET", "https://api.example.com/users/123", nil)
    if err != nil {
        fmt.Printf("创建请求失败: %v\n", err)
        return
    }

    // 设置请求头(模拟真实客户端)
    req.Header.Set("User-Agent", "GoPTest/1.0")
    req.Header.Set("Accept", "application/json")

    // 发送请求
    resp, err := client.Do(req)
    if err != nil {
        fmt.Printf("请求执行失败: %v\n", err)
        return
    }
    defer resp.Body.Close() // 关键:必须关闭Body

    // 读取响应体
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        fmt.Printf("读取响应体失败: %v\n", err)
        return
    }

    fmt.Printf("状态码: %d\n", resp.StatusCode)
    fmt.Printf("响应体: %s\n", string(body))
}
常见陷阱:忘记defer resp.Body.Close()会导致TCP连接无法复用,压测时很快耗尽系统文件描述符。即使不关心响应体内容,也必须读取并关闭Body。

3.3 发送POST请求

POST请求用于向服务端提交数据。压测中POST请求常用于创建资源、提交表单或发送JSON负载。根据Content-Type不同,Body编码方式也有差异:

Content-Type Body编码方式 适用场景
application/json json.Marshal + bytes.NewReader REST API JSON接口
application/x-www-form-urlencoded url.Values.Encode + strings.NewReader 传统表单提交
multipart/form-data multipart.Writer + bytes.Buffer 文件上传

以下代码演示发送JSON格式的POST请求:

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "time"
)

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

func main() {
    client := &http.Client{Timeout: 10 * time.Second}

    // 构造请求体
    user := CreateUserRequest{
        Name:  "张三",
        Email: "zhangsan@example.com",
    }
    jsonData, _ := json.Marshal(user)

    // 创建POST请求
    req, err := http.NewRequest("POST", "https://api.example.com/users",
        bytes.NewReader(jsonData))
    if err != nil {
        fmt.Printf("创建请求失败: %v\n", err)
        return
    }

    // 设置JSON Content-Type
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer your-token-here")

    // 发送请求
    resp, err := client.Do(req)
    if err != nil {
        fmt.Printf("请求执行失败: %v\n", err)
        return
    }
    defer resp.Body.Close()

    // 读取并打印响应
    body, _ := io.ReadAll(resp.Body)
    fmt.Printf("状态码: %d, 响应: %s\n", resp.StatusCode, string(body))
}

3.4 处理响应与错误

HTTP响应包含状态码、Header和Body三部分。压测中需要精确解析每个部分,并区分网络错误与业务错误。

3.4.1 响应结构解析

// 典型响应处理模式
resp, err := client.Do(req)
if err != nil {
    // 网络层错误:超时、DNS解析失败、连接拒绝等
    fmt.Printf("网络错误: %v\n", err)
    return
}
defer resp.Body.Close()

// 1. 检查状态码
if resp.StatusCode != http.StatusOK {
    body, _ := io.ReadAll(resp.Body)
    fmt.Printf("业务错误: 状态码=%d, 响应=%s\n", resp.StatusCode, string(body))
    return
}

// 2. 读取Header
contentType := resp.Header.Get("Content-Type")
fmt.Printf("Content-Type: %s\n", contentType)

// 3. 读取Body(限制读取大小防止内存溢出)
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB限制
if err != nil {
    fmt.Printf("读取Body失败: %v\n", err)
    return
}

3.4.2 常见错误分类

错误类型 典型场景 压测处理策略
url.Error (超时) 服务端响应超过Timeout设置 记录超时率,调整超时时间或降低并发
url.Error (连接拒绝) 目标端口未监听或防火墙拦截 立即停止压测,检查服务状态
io.ErrUnexpectedEOF 响应Body被截断 记录为异常响应,计入错误统计
net.OpError DNS解析失败、TLS握手失败 区分瞬时错误与配置错误
HTTP 4xx/5xx 服务端返回业务错误 按状态码分类统计,不视为网络错误
压测错误处理原则:网络错误(超时、连接失败)应单独统计为"系统错误";HTTP 4xx/5xx应视为"业务错误"并记录响应体用于分析。两者在压测报告中需要分开展示。

3.5 请求生命周期SVG流程图

下图展示了使用net/http客户端发送一次完整请求的生命周期,包含关键操作节点和错误处理路径:

net/http 客户端请求生命周期 创建 http.Client 构建 http.Request 设置 Header / Body client.Do(req) err != nil? 处理网络错误 defer resp.Body.Close() 读取状态码 / Body 状态码判断 是 否 关键原则 复用Client · 关闭Body · 区分错误类型

3.6 压测场景下的最佳实践

在契约压测中,net/http的使用需要遵循以下原则,以确保压测结果的准确性和稳定性:

  • 复用Client实例:全局只创建一个http.Client,所有压测协程共享该实例,充分利用连接池。
  • 配置Transport参数:设置MaxIdleConns(默认100)和MaxIdleConnsPerHost(默认2),压测时建议将MaxIdleConnsPerHost设置为并发数。
  • 禁用重定向:压测场景下通常不需要跟随重定向,设置CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }。
  • 超时分层设置:使用http.Client.Timeout作为总超时,或通过context.WithTimeout实现更精细的超时控制。
  • Body必须关闭:即使不读取响应体,也要调用resp.Body.Close(),否则连接无法归还连接池。
压测客户端初始化模板:
transport := &http.Transport{
    MaxIdleConns:        200,
    MaxIdleConnsPerHost: 100,
    IdleConnTimeout:     90 * time.Second,
    DisableCompression:  true, // 压测时关闭压缩减少CPU开销
}
client := &http.Client{
    Transport: transport,
    Timeout:   30 * time.Second,
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        return http.ErrUseLastResponse
    },
}

3.7 常见错误排查速查表

错误信息 可能原因 解决方案
dial tcp: i/o timeout TCP连接超时,服务端未响应 检查服务端负载,增加超时时间
connection refused 端口未监听或服务未启动 确认服务端进程是否运行
no such host DNS解析失败 检查域名配置或使用IP直连
unexpected EOF 响应Body被截断 检查服务端是否主动关闭连接
http: server gave HTTP response to HTTPS client 协议不匹配 检查URL使用http还是https
tls: first record does not look like a TLS handshake HTTPS请求发到了HTTP端口 确认端口对应的协议类型