调用外部 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())
}
}
实践练习
完成以下练习以巩固所学知识:
- 阅读 Go 官方文档相关章节
- 编写一个完整的示例程序
- 为示例程序编写单元测试
- 使用
go test和go benchmark验证实现 - 尝试优化内存分配和运行时间
推荐阅读
- Go 官方博客: https://go.dev/blog/
- Effective Go: https://go.dev/doc/effective_go
- Go by Example: https://gobyexample.com/
- Go 标准库文档: https://pkg.go.dev/std
真实项目用例
在实际团队协作中,下面是几个推荐的工作流:
代码审查清单
- 函数是否处理了所有 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 相关面试,以下概念是高频考点:
- goroutine 和线程的区别
- channel 的缓冲和非缓冲用法
- defer 的执行顺序和与返回值的关系
- map 的并发不安全性和解决方案
- interface 的隐式实现和类型断言
- slice 的底层数组和 append 机制
- GC 的基本原理和调优参数
- context 的使用场景和超时控制
- error 的包装和 errors.Is/errors.As
- 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 泄漏。
常见坑与避坑指南
- 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
- 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。
defer是一个好习惯。 - 不要忽略错误:即使
defer file.Close()可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。 - 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用
sync.WaitGroup和context管理生命周期。 - 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
- 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。
延伸阅读与实践建议
读完本文后,建议完成以下实践:
- 把文中所有示例代码在自己的机器上跑一遍
- 给示例代码补充错误分支的测试用例
- 尝试基于本文内容构建一个小型完整项目
- 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
- 订阅 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 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。