Go HTTP 客户端入门:请求、超时、重试和 JSON API 调用

本文讲解 Go 标准库 HTTP 客户端的请求构造、响应读取、JSON 编解码、超时、重试和错误处理,适合刚开始对接外部 API 的学习者。

调用外部 API 是后端程序的日常

很多 Go 服务不只是接收请求,也会调用别的服务:支付网关、短信平台、内部用户中心、搜索服务、AI 接口、Webhook 地址。HTTP 客户端代码看似简单,一个 http.Get 就能拿到响应,但真实项目里需要考虑超时、状态码、JSON 解析、错误消息、重试和日志。

初学者最容易写出的问题是:没有超时、忘记关闭响应体、不检查状态码、把所有非 200 都当成同一种错误、读取大响应时不设边界。这些问题短期可能不明显,一旦外部服务变慢或返回异常,就会拖垮自己的程序。

Go 标准库的 net/http 已经足够写出可靠的 API 客户端。我们会从最小 GET 请求开始,逐步整理成一个小客户端类型。

最小 GET 请求

最简单的写法:

resp, err := http.Get("https://example.com")
if err != nil {
	return err
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
	return err
}

fmt.Println(string(body))

这段代码有两个必须记住的点。第一,err 只表示请求过程出错,比如 DNS 失败、连接失败、超时等;如果服务器返回 500,err 仍然可能是 nil。第二,响应体必须关闭,否则连接资源无法复用或释放。

所以真实代码要检查状态码:

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
	return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}

很多 API 会在错误响应里返回 JSON 错误信息。你可以读取一小段响应体放进错误里,但不要无限读取很大的错误响应。

使用带超时的 Client

不要在服务代码里长期使用没有超时的默认请求。创建客户端:

client := &http.Client{
	Timeout: 5 * time.Second,
}

调用:

resp, err := client.Get("https://example.com")

Timeout 是整个请求的上限,包括连接、重定向和读取响应体。对入门项目来说,这是最简单有效的保护。

如果你需要跟随请求 context 取消,可以使用 NewRequestWithContext:

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
	return err
}

resp, err := client.Do(req)

服务端 handler 中应该传入 r.Context(),这样用户取消请求时,外部调用也能尽早停止。

调用 JSON API

定义响应结构体:

type UserProfile struct {
	ID    int64  `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

读取并解码:

func fetchUser(ctx context.Context, client *http.Client, baseURL string, id int64) (UserProfile, error) {
	url := fmt.Sprintf("%s/users/%d", strings.TrimRight(baseURL, "/"), id)

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return UserProfile{}, fmt.Errorf("create request: %w", err)
	}
	req.Header.Set("Accept", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		return UserProfile{}, fmt.Errorf("send request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusNotFound {
		return UserProfile{}, fmt.Errorf("user not found")
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return UserProfile{}, fmt.Errorf("unexpected status: %d", resp.StatusCode)
	}

	var profile UserProfile
	if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
		return UserProfile{}, fmt.Errorf("decode response: %w", err)
	}

	return profile, nil
}

这里没有先 io.ReadAll 再 json.Unmarshal,而是直接用 json.Decoder 从响应体解码。对于 JSON API,这是很常见的写法。

POST JSON 请求

请求结构体:

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

发送:

func createUser(ctx context.Context, client *http.Client, baseURL string, input CreateUserRequest) (UserProfile, error) {
	var body bytes.Buffer
	if err := json.NewEncoder(&body).Encode(input); err != nil {
		return UserProfile{}, fmt.Errorf("encode request: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/users", &body)
	if err != nil {
		return UserProfile{}, fmt.Errorf("create request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		return UserProfile{}, fmt.Errorf("send request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusCreated {
		return UserProfile{}, fmt.Errorf("unexpected status: %d", resp.StatusCode)
	}

	var profile UserProfile
	if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
		return UserProfile{}, fmt.Errorf("decode response: %w", err)
	}
	return profile, nil
}

bytes.Buffer 实现了 io.Reader,可以作为请求体。发送 JSON 时记得设置 Content-Type。

封装成客户端类型

如果同一个 API 会调用多次,封装类型更清楚:

type APIClient struct {
	baseURL string
	client  *http.Client
}

func NewAPIClient(baseURL string) *APIClient {
	return &APIClient{
		baseURL: strings.TrimRight(baseURL, "/"),
		client: &http.Client{
			Timeout: 5 * time.Second,
		},
	}
}

func (c *APIClient) User(ctx context.Context, id int64) (UserProfile, error) {
	url := fmt.Sprintf("%s/users/%d", c.baseURL, id)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return UserProfile{}, err
	}
	req.Header.Set("Accept", "application/json")

	resp, err := c.client.Do(req)
	if err != nil {
		return UserProfile{}, fmt.Errorf("get user: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return UserProfile{}, fmt.Errorf("get user status %d", resp.StatusCode)
	}

	var profile UserProfile
	if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
		return UserProfile{}, fmt.Errorf("decode user: %w", err)
	}
	return profile, nil
}

这样调用处很干净:

api := NewAPIClient("https://api.example.com")
user, err := api.User(ctx, 1001)

封装不是为了隐藏所有细节,而是把重复的 baseURL、client、header 和错误上下文集中起来。

重试要谨慎

外部请求失败时,重试有时有用,但不能无脑重试。GET 这类幂等请求相对安全,创建订单、扣款、发短信这类操作如果没有幂等键,重试可能造成重复副作用。

一个简单 GET 重试:

func doWithRetry(ctx context.Context, attempts int, fn func() error) error {
	var lastErr error
	for i := 0; i < attempts; i++ {
		if err := fn(); err != nil {
			lastErr = err
			select {
			case <-time.After(time.Duration(i+1) * 200 * time.Millisecond):
			case <-ctx.Done():
				return ctx.Err()
			}
			continue
		}
		return nil
	}
	return lastErr
}

真实项目里还要判断哪些错误可重试,比如网络临时错误、502、503、504;哪些不该重试,比如 400、401、403。重试也应该有上限和退避等待,避免外部服务已经故障时被你打得更严重。

小结

Go HTTP 客户端入门要抓住几个关键点:创建带超时的 http.Client,用 NewRequestWithContext 绑定取消信号,发送后一定关闭响应体,检查状态码,再解码 JSON。错误消息要带上请求动作,让排查时知道在哪一步失败。

封装客户端类型可以减少重复,也能把 baseURL、超时、header 和公共错误处理集中起来。重试要谨慎,只对适合重试的请求使用,并且设置上限。

对接外部 API 时,稳定性比“能请求成功一次”更重要。只要从一开始把超时、状态码和错误处理写清楚,你的 Go 服务就不会轻易被一个慢接口拖住。

常见问题与解答

http.Client 要不要复用?

要。http.Client 内部有连接池,复用可以减少 TCP 连接建立开销。不要每个请求都创建新的 client。除非你需要不同的超时或 Transport 配置。

怎么限制请求体大小?

服务端用 r.Body = http.MaxBytesReader(w, r.Body, maxSize)。客户端发送大文件时,使用 io.Pipe 或 mime/multipart 流式构造请求体。

怎么做请求重试?

只对幂等请求重试(GET、PUT、DELETE)。POST 请求如果没有幂等机制,重试可能造成重复副作用。退避策略:固定间隔、线性退避或指数退避。

自定义 Transport

transport := &http.Transport{
    MaxIdleConns:        100,
    MaxIdleConnsPerHost: 10,
    IdleConnTimeout:     90 * time.Second,
    TLSHandshakeTimeout: 10 * time.Second,
}

client := &http.Client{
    Transport: transport,
    Timeout:   30 * time.Second,
}

请求签名

调用需要签名的 API:

func (c *Client) signRequest(req *http.Request) {
    timestamp := strconv.FormatInt(time.Now().Unix(), 10)
    req.Header.Set("X-Timestamp", timestamp)
    
    payload := req.Method + req.URL.Path + timestamp
    mac := hmac.New(sha256.New, []byte(c.secret))
    mac.Write([]byte(payload))
    sig := hex.EncodeToString(mac.Sum(nil))
    req.Header.Set("X-Signature", sig)
}

连接池监控

func clientStats(client *http.Client) {
    if transport, ok := client.Transport.(*http.Transport); ok {
        fmt.Printf("IdleConns: %d\n", transport.IdleConnCount())
    }
}

实践练习

完成以下练习以巩固所学知识:

  1. 阅读 Go 官方文档相关章节
  2. 编写一个完整的示例程序
  3. 为示例程序编写单元测试
  4. 使用 go test 和 go benchmark 验证实现
  5. 尝试优化内存分配和运行时间

推荐阅读

真实项目用例

在实际团队协作中,下面是几个推荐的工作流:

代码审查清单

  • 函数是否处理了所有 error 返回值
  • 并发代码是否有明确的退出路径和 WaitGroup
  • 用户输入是否经过校验和清洗
  • 敏感配置是否通过环境变量或加密存储注入
  • 测试是否覆盖了正常路径和至少一个错误路径
  • 日志是否包含足够的上下文信息但不泄露敏感数据
  • 接口设计是否符合最小接口原则

CI/CD 集成建议

  • 每次提交前运行 go fmt ./...
  • CI 中运行 go vet ./... 和 golangci-lint run
  • 单元测试使用 go test -race ./... 检测数据竞争
  • 关键路径的 benchmark 加入回归测试
  • 使用 go mod verify 确保依赖完整性

性能调优检查点

  • 使用 pprof 分析 CPU 和内存使用
  • 关注 benchmark 的 allocs/op,减少高频路径的堆分配
  • 检查数据库查询是否使用索引
  • 确认外部 HTTP 调用有合理的超时设置
  • 缓存热点数据,但注意缓存一致性和过期策略

面试高频考点

如果你正在准备 Go 相关面试,以下概念是高频考点:

  1. goroutine 和线程的区别
  2. channel 的缓冲和非缓冲用法
  3. defer 的执行顺序和与返回值的关系
  4. map 的并发不安全性和解决方案
  5. interface 的隐式实现和类型断言
  6. slice 的底层数组和 append 机制
  7. GC 的基本原理和调优参数
  8. context 的使用场景和超时控制
  9. error 的包装和 errors.Is/errors.As
  10. sync.Mutex vs sync.RWMutex vs atomic

掌握这些概念意味着你具备了独立开发 Go 服务的基础能力。继续在实际项目中磨练,你会越来越熟悉 Go 的工程风格和最佳实践。

常见问题(FAQ)

Q: 这个特性在实际项目中真的有用吗?
A: 是的。本文介绍的技术来源于真实后端开发场景。无论是标准库工具还是工程实践,在日常服务开发中都会反复用到。

Q: Go 版本会影响示例代码吗?
A: 本文代码主要针对 Go 1.20+ 编写。较新版本(如 1.22、1.23)的语法可能有微调,但核心概念保持不变。如有版本差异,文中会特别说明。

Q: 学习 Go 应该先学标准库还是直接上框架?
A: 强烈建议先学标准库。框架是对标准库的封装和扩展。只有理解了标准库的能力边界,才能正确选择和使用框架,也才能在框架出问题时快速定位。

Q: 代码里的错误处理为什么都是显式的 if err != nil?
A: 这是 Go 的设计哲学。显式错误处理让失败路径清晰可见,不会隐藏在任何 try-catch 之后。习惯了之后,你会发现这种写法实际上降低了排查错误的难度。

Q: 并发相关代码怎么测试?
A: 使用 Go 内置的 -race 标志检测数据竞争:go test -race ./...。结合 sync.WaitGroup 和 context.WithTimeout 编写有退出路径的并发测试,避免 goroutine 泄漏。

常见坑与避坑指南

  1. 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
  2. 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。defer 是一个好习惯。
  3. 不要忽略错误:即使 defer file.Close() 可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。
  4. 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用 sync.WaitGroup 和 context 管理生命周期。
  5. 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
  6. 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。

延伸阅读与实践建议

读完本文后,建议完成以下实践:

  1. 把文中所有示例代码在自己的机器上跑一遍
  2. 给示例代码补充错误分支的测试用例
  3. 尝试基于本文内容构建一个小型完整项目
  4. 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
  5. 订阅 Go 官方博客,关注语言演进和最佳实践更新

参考资源

  • Go 官方网站:https://go.dev/
  • Go 标准库文档:https://pkg.go.dev/std
  • Go by Example:https://gobyexample.com/
  • Effective Go:https://go.dev/doc/effective_go
  • Go 常见问题:https://go.dev/doc/faq
  • Go 项目实战社区案例和开源项目源码

本文力求在讲解技术细节的同时兼顾工程实用性。Go 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练