網(wǎng)友很強(qiáng)大,發(fā)現(xiàn)了Go并發(fā)下載的Bug
閱讀本文大概需要 5 分鐘。
大家好,我是 polarisxu。
前幾天我寫(xiě)了一篇文章:Go項(xiàng)目實(shí)戰(zhàn):一步步構(gòu)建一個(gè)并發(fā)文件下載器,有小伙伴評(píng)論問(wèn),請(qǐng)求 https://studygolang.com/dl/golang/go1.16.5.src.tar.gz 為什么沒(méi)有返回 Accept-Ranges。在寫(xiě)那篇文章時(shí),我也試了,確實(shí)沒(méi)有返回,因此我以為它不支持。
但有一個(gè)小伙伴很認(rèn)真,他改用 GET 方法請(qǐng)求這個(gè)地址,結(jié)果卻有 Accept-Ranges,于是就很困惑,問(wèn)我什么原因。經(jīng)過(guò)一頓操作猛如虎,終于知道原因了。記錄下排查過(guò)程,供大家參考?。ㄐ』锇榈牧粞钥梢圆榭茨瞧恼拢?/p>
01 排查過(guò)程
通過(guò) curl 命令,分別用 GET 和 HEAD 方法請(qǐng)求這個(gè)地址,結(jié)果如下:
$ curl -X GET --head https://studygolang.com/dl/golang/go1.16.5.src.tar.gz
HTTP/1.1 303 See Other
Server: nginx
Date: Wed, 07 Jul 2021 09:09:35 GMT
Content-Length: 0
Connection: keep-alive
Location: https://golang.google.cn/dl/go1.16.5.src.tar.gz
X-Request-Id: 83ee595c-6270-4fb0-a2f1-98fdc4d315be
$ curl --head https://studygolang.com/dl/golang/go1.16.5.src.tar.gz
HTTP/1.1 200 OK
Server: nginx
Date: Wed, 07 Jul 2021 09:09:44 GMT
Connection: keep-alive
X-Request-Id: f2ba473d-5bee-44c3-a591-02c358551235
雖然都沒(méi)有 Accept-Ranges,但有一個(gè)奇怪現(xiàn)象:一個(gè)狀態(tài)碼是 303,一個(gè)是 200。很顯然,303 是正確的,HEAD 為什么會(huì)是 200?
我以為是 Nginx 對(duì) HEAD 請(qǐng)求做了特殊處理,于是直接訪問(wèn) Go 服務(wù)的方式(不經(jīng)過(guò) Nginx 代理),結(jié)果一樣。
于是,我用 Go 實(shí)現(xiàn)一個(gè)簡(jiǎn)單的 Web 服務(wù),Handler 里面也重定向。
func main() {
http.HandleFunc("/dl", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
})
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello World")
})
http.ListenAndServe(":2022", nil)
}
用 curl 請(qǐng)求 http://localhost:2022/dl,GET 和 HEAD 都返回 303。于是我懷疑是不是 Echo 框架哪里的問(wèn)題(studygolang 使用 Echo 框架構(gòu)建的)。
所以,我用 Echo 框架寫(xiě)個(gè) Web 服務(wù)測(cè)試:
func main() {
e := echo.New()
e.GET("/dl", func(ctx echo.Context) error {
return ctx.Redirect(http.StatusSeeOther, "/")
})
e.GET("/", func(ctx echo.Context) error {
return ctx.String(http.StatusOK, "Hello World!")
})
e.Logger.Fatal(e.Start(":2022"))
}
同樣用 curl 請(qǐng)求 http://localhost:2022/dl,GET 返回 303,而 HEAD 報(bào) 405 Method Not Allowed,這符合預(yù)期。我們的路由設(shè)置只允許 GET 請(qǐng)求。但為什么 studygolang 沒(méi)有返回 405,因?yàn)樗蚕拗浦荒?GET 請(qǐng)求。
于是我對(duì)隨便一個(gè)地址發(fā)起 HEAD 請(qǐng)求,發(fā)現(xiàn)都返回 200,可見(jiàn) HTTP 錯(cuò)誤被“吞掉”了。查找 studygolang 的中間件,發(fā)現(xiàn)了這個(gè):
func HTTPError() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
if err := next(ctx); err != nil {
if !ctx.Response().Committed {
if he, ok := err.(*echo.HTTPError); ok {
switch he.Code {
case http.StatusNotFound:
if util.IsAjax(ctx) {
return ctx.String(http.StatusOK, `{"ok":0,"error":"接口不存在"}`)
}
return Render(ctx, "404.html", nil)
case http.StatusForbidden:
if util.IsAjax(ctx) {
return ctx.String(http.StatusOK, `{"ok":0,"error":"沒(méi)有權(quán)限訪問(wèn)"}`)
}
return Render(ctx, "403.html", map[string]interface{}{"msg": he.Message})
case http.StatusInternalServerError:
if util.IsAjax(ctx) {
return ctx.String(http.StatusOK, `{"ok":0,"error":"接口服務(wù)器錯(cuò)誤"}`)
}
return Render(ctx, "500.html", nil)
}
}
}
return nil
}
}
}
這里對(duì) 404、403、500 錯(cuò)誤都做了處理,但其他 HTTP 錯(cuò)誤直接忽略了,導(dǎo)致最后返回了 200 OK。只需要在上面 switch 語(yǔ)句加一個(gè) default 分支,同時(shí)把 err 原樣 return,采用系統(tǒng)默認(rèn)處理方式:
default:
return err
這樣 405 Method Not Allowed 會(huì)正常返回。
同時(shí),為了解決 HEAD 能用來(lái)判斷下載行為,針對(duì)下載路由,我加上了允許 HEAD 請(qǐng)求,這樣就解決了小伙伴們的困惑。
02 curl 和 Go 代碼行為異同
不知道大家發(fā)現(xiàn)沒(méi)有,通過(guò) curl 請(qǐng)求 https://studygolang.com/dl/golang/go1.16.5.src.tar.gz 和 Go 代碼請(qǐng)求,結(jié)果是不一樣的:
$ curl -X GET --head https://studygolang.com/dl/golang/go1.16.5.src.tar.gz
HTTP/1.1 303 See Other
Server: nginx
Date: Thu, 08 Jul 2021 02:05:10 GMT
Content-Length: 0
Connection: keep-alive
Location: https://golang.google.cn/dl/go1.16.5.src.tar.gz
X-Request-Id: 14d741ca-65c1-4b05-90b8-bef5c8b5a0a3
返回的是 303 重定向,自然沒(méi)有 Accept-Ranges 頭。
但改用如下 Go 代碼:
resp, err := http.Get("https://studygolang.com/dl/golang/go1.16.5.src.tar.gz")
if err != nil {
fmt.Println("get err", err)
return
}
fmt.Println(resp)
fmt.Println("ranges", resp.Header.Get("Accept-Ranges"))
返回的是 200,且有 Accept-Ranges 頭。可以猜測(cè),應(yīng)該是 Go 根據(jù)重定向遞歸請(qǐng)求重定向后的地址??梢圆榭丛创a確認(rèn)下。
通過(guò)這個(gè)可以看到:https://docs.studygolang.com/src/net/http/client.go?s=20406:20458#L574,核心代碼如下(比較容易看懂):
// 循環(huán)處理所有需要處理的 url(包括重定向后的)
for {
// For all but the first request, create the next
// request hop and replace req.
if len(reqs) > 0 {
// 如果是重定向,請(qǐng)求重定向地址
loc := resp.Header.Get("Location")
if loc == "" {
resp.closeBody()
return nil, uerr(fmt.Errorf("%d response missing Location header", resp.StatusCode))
}
u, err := req.URL.Parse(loc)
if err != nil {
resp.closeBody()
return nil, uerr(fmt.Errorf("failed to parse Location header %q: %v", loc, err))
}
host := ""
if req.Host != "" && req.Host != req.URL.Host {
// If the caller specified a custom Host header and the
// redirect location is relative, preserve the Host header
// through the redirect. See issue #22233.
if u, _ := url.Parse(loc); u != nil && !u.IsAbs() {
host = req.Host
}
}
ireq := reqs[0]
req = &Request{
Method: redirectMethod,
Response: resp,
URL: u,
Header: make(Header),
Host: host,
Cancel: ireq.Cancel,
ctx: ireq.ctx,
}
if includeBody && ireq.GetBody != nil {
req.Body, err = ireq.GetBody()
if err != nil {
resp.closeBody()
return nil, uerr(err)
}
req.ContentLength = ireq.ContentLength
}
// Copy original headers before setting the Referer,
// in case the user set Referer on their first request.
// If they really want to override, they can do it in
// their CheckRedirect func.
copyHeaders(req)
// Add the Referer header from the most recent
// request URL to the new one, if it's not https->http:
if ref := refererForURL(reqs[len(reqs)-1].URL, req.URL); ref != "" {
req.Header.Set("Referer", ref)
}
err = c.checkRedirect(req, reqs)
// Sentinel error to let users select the
// previous response, without closing its
// body. See Issue 10069.
if err == ErrUseLastResponse {
return resp, nil
}
// Close the previous response's body. But
// read at least some of the body so if it's
// small the underlying TCP connection will be
// re-used. No need to check for errors: if it
// fails, the Transport won't reuse it anyway.
const maxBodySlurpSize = 2 << 10
if resp.ContentLength == -1 || resp.ContentLength <= maxBodySlurpSize {
io.CopyN(io.Discard, resp.Body, maxBodySlurpSize)
}
resp.Body.Close()
if err != nil {
// Special case for Go 1 compatibility: return both the response
// and an error if the CheckRedirect function failed.
// See https://golang.org/issue/3795
// The resp.Body has already been closed.
ue := uerr(err)
ue.(*url.Error).URL = loc
return resp, ue
}
}
reqs = append(reqs, req)
var err error
var didTimeout func() bool
if resp, didTimeout, err = c.send(req, deadline); err != nil {
// c.send() always closes req.Body
reqBodyClosed = true
if !deadline.IsZero() && didTimeout() {
err = &httpError{
// TODO: early in cycle: s/Client.Timeout exceeded/timeout or context cancellation/
err: err.Error() + " (Client.Timeout exceeded while awaiting headers)",
timeout: true,
}
}
return nil, uerr(err)
}
// 確認(rèn)重定向行為
var shouldRedirect bool
redirectMethod, shouldRedirect, includeBody = redirectBehavior(req.Method, resp, reqs[0])
if !shouldRedirect {
return resp, nil
}
req.closeBody()
}
可以進(jìn)一步看 redirectBehavior 函數(shù) https://docs.studygolang.com/src/net/http/client.go?s=20406:20458#L497:
func redirectBehavior(reqMethod string, resp *Response, ireq *Request) (redirectMethod string, shouldRedirect, includeBody bool) {
switch resp.StatusCode {
case 301, 302, 303:
redirectMethod = reqMethod
shouldRedirect = true
includeBody = false
// RFC 2616 allowed automatic redirection only with GET and
// HEAD requests. RFC 7231 lifts this restriction, but we still
// restrict other methods to GET to maintain compatibility.
// See Issue 18570.
if reqMethod != "GET" && reqMethod != "HEAD" {
redirectMethod = "GET"
}
case 307, 308:
redirectMethod = reqMethod
shouldRedirect = true
includeBody = true
// Treat 307 and 308 specially, since they're new in
// Go 1.8, and they also require re-sending the request body.
if resp.Header.Get("Location") == "" {
// 308s have been observed in the wild being served
// without Location headers. Since Go 1.7 and earlier
// didn't follow these codes, just stop here instead
// of returning an error.
// See Issue 17773.
shouldRedirect = false
break
}
if ireq.GetBody == nil && ireq.outgoingLength() != 0 {
// We had a request body, and 307/308 require
// re-sending it, but GetBody is not defined. So just
// return this response to the user instead of an
// error, like we did in Go 1.7 and earlier.
shouldRedirect = false
}
}
return redirectMethod, shouldRedirect, includeBody
}
很清晰了吧。
03 總結(jié)
很開(kāi)心,還是有讀者很認(rèn)真的在看我的文章,在跟著動(dòng)手實(shí)踐,還對(duì)其中的點(diǎn)提出質(zhì)疑。希望通過(guò)這篇文章,大家能夠?qū)?HTTP 協(xié)議有更深的認(rèn)識(shí),同時(shí)體會(huì)問(wèn)題排查的思路。
有其他問(wèn)題,也歡迎留言交流!
我是 polarisxu,北大碩士畢業(yè),曾在 360 等知名互聯(lián)網(wǎng)公司工作,10多年技術(shù)研發(fā)與架構(gòu)經(jīng)驗(yàn)!2012 年接觸 Go 語(yǔ)言并創(chuàng)建了 Go 語(yǔ)言中文網(wǎng)!著有《Go語(yǔ)言編程之旅》、開(kāi)源圖書(shū)《Go語(yǔ)言標(biāo)準(zhǔn)庫(kù)》等。
堅(jiān)持輸出技術(shù)(包括 Go、Rust 等技術(shù))、職場(chǎng)心得和創(chuàng)業(yè)感悟!歡迎關(guān)注「polarisxu」一起成長(zhǎng)!也歡迎加我微信好友交流:gopherstudio
