大概需要阅读这篇文章 5 分钟。
大家好,我是 polarisxu。
前几天写了一篇文章:Go项目实战:一步步构建并发文件下载器,有朋友评论问,请求 https://studygolang.com/dl/golang/go1.16.5.src.tar.gz
为什么不返回? Accept-Ranges。写那篇文章的时候也试过,真的没回来,所以觉得不支持。
但是有一个小伙伴很认真,他改用了 GET 该方法要求该地址,但结果是 Accept-Ranges,所以我很困惑,问我为什么。经过一顿激烈的操作,我终于知道了原因。记录调查过程供参考(您可以查看小伙伴的信息)
01 排查过程
通过 curl 单独使用命令 GET 和 HEAD 结果如下:
$curl-XGET--headhttps://studygolang.com/dl/golang/go1.16.5.src.tar.gz HTTP/1.1303SeeOther Server:nginx Date:Wed,07Jul202109:09:35GMT 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--headhttps://studygolang.com/dl/golang/go1.16.5.src.tar.gz HTTP/1.1200OK Server:nginx Date:Wed,07Jul202109:09:44GMT Connection:keep-alive X-Request-Id:f2ba473d-5bee-44c3-a591-02c358551235
虽然都没有 Accept-Ranges,但是有一个奇怪的现象:状态代码是 303,一个是 200。很显然,303 是正确的,HEAD 为什么会是 200?
我以为是 Nginx 对 HEAD 请求经过特殊处理,直接访问 Go 服务方式(不通过 Nginx 代理),结果是一样的。
于是,我用 Go 实现简单 Web 服务,Handler 里面也重定向。
funcmain(){ http.HandleFunc("/dl",func(whttp.ResponseWriter,r*http.Request){ http.Redirect(w,r,"/",http.StatusSeeOther) }) http.HandleFunc("/",func(whttp.ResponseWriter,r*http.Request){ fmt.Fprintf(w,"HelloWorld") }) http.ListenAndServe(":2022",nil) }
用 curl 请求 http://localhost:2022/dl
,GET 和 HEAD 都返回 所以我怀疑是不是。 Echo 框架问题在哪里?(studygolang 使用 Echo 框架构造)。
所以,我用 Echo 框架写个 Web 服务测试:
funcmain(){ e:=echo.New() e.GET("/dl",func(ctxecho.Context)error{ returnctx.Redirect(http.StatusSeeOther,"/") }) e.GET("/",func(ctxecho.Context)error{ returnctx.String(http.StatusOK,"HelloWorld!") }) e.Logger.Fatal(e.Start(":2022")) }
同样用 curl 请求 http://localhost:2022/dl
,GET 返回 303,而 HEAD 报 405 Method Not Allowed,这符合预期。我们的路由设置只允许 GET 但是为什么呢? studygolang 没有返回 405,因为它只能限制 GET 请求。
所以我发起了任何地址 HEAD 请求,发现都返回 200,可见 HTTP 错误被吞下。 studygolang 中间件,发现这一点:
funcHTTPError()echo.MiddlewareFunc{ returnfunc(nextecho.HandlerFunc)echo.HandlerFunc{ returnfunc(ctxecho.Context)error{ iferr:=next(ctx);err!=nil{ if!ctx.Response().Committed{ ifhe,ok:=err.(*echo.HTTPError);ok{ switchhe.Code{ casehttp.StatusNotFound: ifutil.IsAjax(ctx){ returnctx.String(http.StatusOK,`{"ok":0,"error":"接口不存在"}`) } returnRender(ctx,"404.html",nil) casehttp.StatusForbidden: ifutil.IsAjax(ctx){ returnctx.String(http.StatusOK,`{"ok":0,"error":"无权访问"}`) } returnRender(ctx,"403.html",map[string]interface{}{"msg":he.Message}) casehttp.StatusInternalServerError: ifutil.IsAjax(ctx){ returnctx.String(http.StatusOK,`{"ok":0,"error":"接口服务器错误"}`) } returnRender(ctx,"500.html",nil) } } } returnnil } } }
这里对 404、403、500 所有的错误都处理好了,但其他的都处理好了, HTTP 错误被直接忽略,导致最终返回 200 OK。只需要在上面 switch 语句加一个 default 同时分支 err 样 return,采用系统默认处理方式:
default:
return err
这样 405 Method Not Allowed 会正常返回。
同时,为了解决 HEAD 能用来判断下载行为,针对下载路由,我加上了允许 HEAD 请求,这样就解决了小伙伴们的困惑。
02 curl 和 Go 代码行为异同
不知道大家发现没有,通过 curl 请求 https://studygolang.com/dl/golang/go1.16.5.src.tar.gz
和 Go 代码请求,结果是不一样的:
$ 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 重定向,自然没有 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 头。可以猜测,应该是 Go 根据重定向递归请求重定向后的地址。可以查看源码确认下。
通过这个可以看到:https://docs.studygolang.com/src/net/http/client.go?s=20406:20458#L574,核心代码如下(比较容易看懂):
// 循环处理所有需要处理的 url(包括重定向后的)
for {
// For all but the first request, create the next
// request hop and replace req.
if len(reqs) > 0 {
// 如果是重定向,请求重定向地址
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)
}
// 确认重定向行为
var shouldRedirect bool
redirectMethod, shouldRedirect, includeBody = redirectBehavior(req.Method, resp, reqs[0])
if !shouldRedirect {
return resp, nil
}
req.closeBody()
}
可以进一步看 redirectBehavior 函数 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 总结
很开心,还是有读者很认真的在看我的文章,在跟着动手实践,还对其中的点提出质疑。希望通过这篇文章,大家能够对 HTTP 协议有更深的认识,同时体会问题排查的思路。
有其他问题,也欢迎留言交流!
Go项目实战:从零构建一个并发文件下载器
我是 polarisxu,北大硕士毕业,曾在 360 等知名互联网公司工作,10多年技术研发与架构经验!2012 年接触 Go 语言并创建了 Go 语言中文网!著有《Go语言编程之旅》、开源图书《Go语言标准库》等。
坚持输出技术(包括 Go、Rust 等技术)、职场心得和创业感悟!欢迎关注「polarisxu」一起成长!也欢迎加我微信好友交流:gopherstudio