浏览知识库目录

Go

HTTP、JSON 与 API 客户端

构建复用连接、带 context 超时、响应大小限制、状态校验与 httptest 测试的 HTTP JSON 客户端。

HTTP、JSON 与 API 客户端

可靠 HTTP 客户端不只是调用 http.Get。它要复用连接、传播取消、限制响应、检查状态、验证 JSON、区分是否可重试,并在测试中完全控制服务端行为。网络边界上的每一个默认值都应被审视。


一、学习目标

  • 复用 http.Client 与 Transport
  • 为请求传播 context 和分层超时
  • 正确关闭、限制和读取响应体
  • 设计状态码、JSON 与幂等契约
  • 使用 httptest.Server 覆盖失败场景

二、客户端生命周期

http.Client 与 Transport 应长期复用,以利用连接池:

type Client struct {
    baseURL *url.URL
    http    *http.Client
}

不要为每次请求创建新 Transport,也不要修改正在并发使用的 Transport 配置。构造函数解析并验证 base URL,复制调用者传入的配置。默认 Client 没有整体超时,生产代码必须选择明确策略。


三、Context 与超时

请求绑定调用方 context:

req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body)

Client.Timeout 是包括读取响应体在内的总上限,Transport 还可设置连接、TLS 握手和响应头超时。应用层可用 context.WithTimeout 限定一次同步。分层超时应由外到内递减,避免内层比调用方存活更久。

判断取消用 errors.Is(err, context.Canceled)DeadlineExceeded,同时保留网络错误链。


四、响应处理

拿到响应后立即安排关闭:

defer resp.Body.Close()
limited := io.LimitReader(resp.Body, maxResponseBytes+1)
data, err := io.ReadAll(limited)

先检查实际读取长度,再解码。非 2xx 状态不应直接按成功 DTO 解码;读取一个同样受限的错误体,提取稳定错误码并保留状态。若希望连接复用,需要在安全上限内消费响应体。

Content-Type 可作为契约检查,但服务端可能附带 charset,应用 mime.ParseMediaType 而不是字符串相等。


五、幂等与重试

GET 通常可重试,POST 是否可重试取决于服务端是否支持幂等键。仅因为请求“没收到响应”不能断定服务端没处理。StudyTasks 的 sync 为每批生成稳定幂等键,并只在明确的临时网络错误或允许状态码上重试。

重试使用指数退避、随机抖动、最大次数和总 deadline,并尊重 context。不要在 401、验证错误或任意 4xx 上重试;不要把一次大请求无限重复。


六、可控测试

httptest.Server 提供真实本地 HTTP 边界:

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    io.WriteString(w, `{"accepted":1}`)
}))
defer server.Close()

测试正常响应、慢响应、超大体积、损坏 JSON、错误 Content-Type、429、500、连接中断和取消。断言请求方法、路径、Header、幂等键和正文,而不是依赖真实互联网 API。


七、从知识点到工程契约

本篇的示例最终要进入可维护的 Go 包,而不是停留在 main 中的一次性片段。先把目标写成调用者可以观察的契约:输入是否允许零值或 nil,返回值是否是快照,错误能否通过 errors.Is/As 分类,函数是否启动 goroutine、取得资源或修改共享状态。然后再选择结构体、接口、函数值或泛型;抽象形式必须服务于契约,而不是反过来决定需求。

可以用以下顺序把知识点落到工程代码:

  1. 在独立小函数中写出最小成功路径,并让 go test 能直接调用。
  2. 加入一个与“每次请求新建 Transport,失去连接池并泄露资源”相关的失败样例,确认失败可观察且不会留下半完成状态。
  3. 把文件、网络、时间、环境或并发等外部因素改成显式依赖,测试使用临时目录、固定时钟或本地服务。
  4. 运行 gofmt、vet 和相关测试;涉及共享状态时追加 -race,涉及解析器时追加有上限的 fuzz。
  5. 最后再评估 API 是否需要导出。只在同一模块内部使用的能力保留在 internal,避免过早形成公共兼容负担。

审查代码时至少回答四个问题:谁拥有数据,谁允许修改,失败由谁处理,工作由谁停止。Go 的垃圾回收只解决不可达内存回收,不会替你关闭文件、取消请求、等待 goroutine 或恢复被覆盖的数据。只要其中一个问题没有答案,就先缩小函数或包的边界。

本篇最重要的能力是“复用 http.Client 与 Transport”。不要用注释替代可执行约束:能由类型表达的就交给类型,能由构造或验证表达的就返回错误,能由测试观察的就保存回归用例。示例扩展到 StudyTasks 时,还要保持领域包不导入命令行、文件和 HTTP 细节。


八、验证策略与复盘

验证分为静态、动态和故障三层。静态层检查格式、模块图和分析器;动态层用正常输入证明结果;故障层主动制造取消、权限、损坏数据、超时或竞态。一次测试通过只能说明执行过的路径符合断言,不能证明所有输入都安全,因此需要让每条关键契约至少对应一个成功用例和一个反例。

建议保存下面的复盘记录:

项目 需要记录的证据
版本 go version、模块与 toolchain 指令
输入 最小正常值、零值、边界值和非法值
状态 调用前后数据、资源和 goroutine 的所有者
输出 返回值、错误链、stdout/stderr 与日志字段
失败 第一个失败点、清理动作和可恢复状态
工具 实际运行的 test、race、vet、benchmark 或 build 命令

完成验证后,用另一份干净临时目录重跑,不读取开发机的用户配置、缓存数据或真实网络。若测试只能按特定顺序成功,就说明状态隔离仍不完整。若为了让测试通过必须长时间 sleep,应改用 channel、WaitGroup、context 或可注入时钟表达确定的同步条件。

本篇可以用以下目标做验收:为请求传播 context 和分层超时;正确关闭、限制和读取响应体;设计状态码、JSON 与幂等契约。把它们逐项转成命令输出或断言,而不是写成“人工看起来正确”。当实现与预期不符时,先保存最小失败样例,再调整设计。

发布前再做一次反向审查:从调用方而不是实现内部出发,写出一个完全不知道具体类型和文件布局的使用示例;从故障点出发,假设进程在每个 I/O 之后被取消;从升级出发,假设下一版改变字段或默认值。若调用方必须知道未公开细节、故障会留下无法判断的状态,或升级只能覆盖旧数据,契约就还不完整。把这三个场景加入测试或文档,比继续增加抽象更有价值。

最后检查示例能否被复制到一份最小程序独立运行,所有导入、错误处理和清理是否完整。教学代码可以省略与主题无关的界面,却不能省略会改变正确性的 context、Close、边界检查或同步。对为了篇幅省略的部分要明确标注,不能让读者把伪代码误当成生产承诺。


九、StudyTasks 实践

实现 syncclient:复用注入的 http.Client,限制响应为 1 MiB,严格解析 JSON,传播 context,并只对明确幂等请求重试。用 httptest.Server 验证成功、超时、取消、429 后成功和损坏响应。

完成本节后,不要只保存代码或 SQL。请同时保存执行命令、关键输出和失败案例;学习笔记真正有价值的部分,是能够说明输入、状态变化、输出以及失败后的恢复方式。


十、常见错误

  • 每次请求新建 Transport,失去连接池并泄露资源
  • 使用没有任何超时的默认客户端
  • 未限制响应体就调用 io.ReadAll
  • 收到网络错误便盲目重试非幂等 POST
  • 单元测试依赖真实第三方服务

十一、练习与自测

  1. 为响应体大小限制写刚好等于和超过上限的测试。
  2. 实现带抖动的退避,并让测试使用可注入等待函数。
  3. 验证调用方取消后服务端 handler 能观察到 request context 结束。
  4. 设计远端业务错误类型并支持 errors.As。

自测时应在干净的临时目录或临时数据库中重新执行,而不是依赖上一节遗留的状态。如果结果与预期不同,先记录实际输出,再缩小问题范围。


十二、官方资料

版本行为与二手文章不一致时,以本系列固定版本的官方文档、命令输出和可重复测试结果为准。

上一篇:JSON 持久化、安全写入与仓储 下一篇:Goroutine、Channel、Context 与并发