一个Go语言HTTP包Client使用的坑
时间:2021-9-18 作者:smarteng 分类: Go语言
一句话总结
Go 的 http
包默认不指定请求超时,允许外部服务劫持你的 goroutine
。
连接到外部服务时,始终需要自定义 http.Client
,至少要自定义超时时间。
示例问题
假设开发者想通过 JSON REST API 与 spacely-sprockets.com 进行通信并查看Sprocket的列表。 开发者可能会编写以下代码:
//error checking omitted for brevity
var sprockets SprocketsResponse
response, _ := http.Get("spacely-sprockets.com/api/sprockets")
buf, _ := ioutil.ReadAll(response.Body)
json.Unmarshal(buf, &sprockets)
这些代码编译和运行一切正常。开发者通过上面的程序将 API 包引入到 Web 应用程序中。Web应用程序的一个页面通过调用 API 向用户显示 Spacely Sprockets 库存列表。
一切运行得都很顺利,直到有一天应用停止响应。开发者查看日志,但没有任何迹象表明存在问题。然后开发者使用监控工具查看CPU、内存和 I/O,同样没什么问题。最后开发者启动了一个沙盒,它似乎工作正常。所以导致应用停止响应的原因是什么?
开发者查看 Twitter 并注意到 Spacely Sprockets 开发团队的一条推文,称他们经历了短暂的中断,但现在一切都恢复正常了。开发者检查Spacely Sprockets的 API 状态页面,并看到Spacely Sprockets的中断比自己的Web应用程序的中断要早几分钟。这似乎是一个不太可能的巧合,但开发者无法弄清楚这两个中断是如何关联的,因为Web应用程序中的 API 代码会优雅地处理错误。现在还没有找到这个问题的原因。为了找到这个问题的真实原因,我们前往Go的HTTP包中寻找答案。
Go的HTTP包
Go 的 HTTP 包使用一个名为 Client
的结构来管理通过 HTTP(S)
进行通信的内部结构。 客户端是并发安全对象,包含配置、管理 TCP 状态、处理 cookie
等。当开发者使用 http.Get(url)
时,使用的是 http.DefaultClient
,这是一个定义客户端默认配置的包变量。 对此的声明是
var DefaultClient:=&Client{}
关于 Client
的相关信息,可以查看:https://pkg.go.dev/net/http#Client
除其他事项外,http.Client 配置了一个使长时间运行的连接短路的超时。 此值的默认值为 0,这被解释为“无超时”。 这对于包来说可能是一个合理的默认值,但它是一个令人讨厌的陷阱,也是我们的应用程序在上面的例子中失败的原因。 事实证明,Spacely Sprockets 的 API 中断导致连接尝试挂起(这并不总是发生,但在我们的示例中确实发生)。 只要发生故障的服务器决定等待,它们就会继续挂起。 因为 API 调用是为服务用户请求而进行的,这导致服务用户请求的 goroutines 也挂起。 一旦有足够多的用户访问 sprockets 页面,应用就会崩溃,这很可能是因为达到了资源限制。
下面这部分代码显示了这个问题:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"time"
)
func main() {
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(time.Hour)
}))
defer svr.Close()
fmt.Println("making request")
http.Get(svr.URL)
fmt.Println("finished request")
}
运行时,该程序将向将休眠一小时的服务器发出请求。 因此,程序将等待一小时然后退出。
解决方法
此问题的解决方案是始终为用例定义一个具有合理超时的 http.Client
。
下面是一个例子:
var netClient = &http.Client{
Timeout: time.Second * 10,
}
response, _ := netClient.Get(url)
上面的代码将发出的请求超时时限设置为10 秒。如果 API 服务器超过10秒, Get()
将返回如下错误:
&httpError{
err: err.Error() + " (Client.Timeout exceeded while awaiting headers)",
timeout: true,
}
如果需要对请求生命周期进行更细粒度的控制,可以另外指定自定义 net.Transport
和 net.Dialer
。
Transport
是客户端用来管理底层 TCP 连接的结构,它的 Dialer
是管理连接建立的结构。 Go 的 net 包也有一个默认的 Transport
和 Dialer
。这是使用自定义的示例:
fmt.Println("finished request")
var netTransport = &http.Transport{
Dial: (&net.Dialer{
Timeout: 5 * time.Second,
}).Dial,
TLSHandshakeTimeout: 5 * time.Second,
}
var netClient = &http.Client{
Timeout: time.Second * 10,
Transport: netTransport,
}
response, _ := netClient.Get(url)
上面的代码将限制 TCP 连接和 TLS 握手超时,以及建立端到端请求超时。
结论
Go 的 net
和 http
包是一个经过深思熟虑的、方便的 HTTP(S)
通信基础。 然而,请求缺少默认的超时时间是一个可能引发问题的漏洞,因为该包提供了像 http.Get(url)
这样的便捷方法。 一些语言(例如 Java)有同样的问题,其他语言(例如 Ruby 有一个默认的 60 秒读取超时)没有。 联系远程服务时不设置请求超时会使应用程序受该服务的支配。 出现故障或恶意的服务可能会永远挂在应用程序的连接上,可能会使应用程序崩溃。