一个请求进入 Gin,到底经过了哪几步?这篇从 ServeHTTP 的源码开始,用实验验证每一步:Context 是不是真的复用、reset 清了什么、每次请求分配多少内存、状态行什么时候发出,以及第一次请求时被悄悄执行的那一步。
gin.go 的 ServeHTTP(Gin v1.12.0):
662 | func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
663 | engine.routeTreesUpdated.Do(func() {
664 | engine.updateRouteTrees()
665 | })
666 |
667 | c := engine.pool.Get().(*Context)
668 | c.writermem.reset(w)
669 | c.Request = req
670 | c.reset()
671 |
672 | engine.handleHTTPRequest(c)
673 |
674 | engine.pool.Put(c)
675 | }
gin.go 的 handleHTTPRequest(前半)(Gin v1.12.0):
690 | func (engine *Engine) handleHTTPRequest(c *Context) {
691 | httpMethod := c.Request.Method
692 | rPath := c.Request.URL.Path
693 | unescape := false
694 |
695 | if engine.UseEscapedPath {
696 | rPath = c.Request.URL.EscapedPath()
697 | unescape = engine.UnescapePathValues
698 | } else if engine.UseRawPath && len(c.Request.URL.RawPath) > 0 {
699 | rPath = c.Request.URL.RawPath
700 | unescape = engine.UnescapePathValues
701 | }
702 |
703 | if engine.RemoveExtraSlash {
704 | rPath = cleanPath(rPath)
705 | }
706 |
707 | // Find root of the tree for the given HTTP method
708 | t := engine.trees
709 | for i, tl := 0, len(t); i < tl; i++ {
710 | if t[i].method != httpMethod {
711 | continue
712 | }
713 | root := t[i].root
714 | // Find route in tree
715 | value := root.getValue(rPath, c.params, c.skippedNodes, unescape)
716 | if value.params != nil {
717 | c.Params = *value.params
718 | }
719 | if value.handlers != nil {
720 | c.handlers = value.handlers
721 | c.fullPath = value.fullPath
722 | c.Next()
723 | c.writermem.WriteHeaderNow()
724 | return
725 | }
726 | if httpMethod != http.MethodConnect && rPath != "/" {
727 | if value.tsr && engine.RedirectTrailingSlash {
728 | redirectTrailingSlash(c)
729 | return
730 | }
731 | if engine.RedirectFixedPath && redirectFixedPath(c, root, engine.RedirectFixedPath) {
732 | return
733 | }
734 | }
735 | breakServeHTTP 的代码不到二十行:第一次调用时用 sync.Once 执行 updateRouteTrees,然后从 sync.Pool 取 Context、绑定 writer 和 request、reset、handleHTTPRequest、放回 pool。handleHTTPRequest 负责选路径、在对应方法的树里查找、命中就跑完 handler 链,没命中则走重定向、405 或 404(前面几篇已经分别验证过)。
context.go 的 reset(Gin v1.12.0):
103 | func (c *Context) reset() {
104 | c.Writer = &c.writermem
105 | c.Params = c.Params[:0]
106 | c.handlers = nil
107 | c.index = -1
108 |
109 | c.fullPath = ""
110 | c.Keys = nil
111 | c.Errors = c.Errors[:0]
112 | c.Accepted = nil
113 | c.queryCache = nil
114 | c.formCache = nil
115 | c.sameSite = 0
116 | *c.params = (*c.params)[:0]
117 | *c.skippedNodes = (*c.skippedNodes)[:0]
118 | }
请求 a Context=0x6ac7820a4300 进入时 Keys=map[] Errors=0 Params=[{id a}]
请求 b Context=0x6ac7820a4300 进入时 Keys=map[] Errors=0 Params=[{id b}]
请求 c Context=0x6ac7820a4300 进入时 Keys=map[] Errors=0 Params=[{id c}]
三次请求是否同一个 Context: true
请求结束后还拿着第 1 个 Context 指针去读:Keys=map[secret:来自请求 c] Errors=1 Params=[{id c}](已经是第 3 个请求的了)串行请求拿到的是同一个 Context。reset 把 Keys、Errors、Params、查询与表单缓存等全部清掉,所以不会有数据泄漏到下一个请求——但这也意味着请求结束后任何还持有这个指针的 goroutine 读到的都是别的请求的数据。要在 goroutine 里用,必须先 c.Copy()(后面专门验证)。
静态路由,空 handler 0 allocs/请求
1 个参数 /u/42 0 allocs/请求
3 个参数 0 allocs/请求
通配 /f/x/y/z 0 allocs/请求
404 0 allocs/请求
c.Status(204) 0 allocs/请求
c.String(200, "hello") 1 allocs/请求
c.JSON(200, gin.H{"a":1}) 9 allocs/请求
3 个空中间件 + 静态路由 0 allocs/请求
gin.Default()(Logger+Recovery)+ 静态路由 10 allocs/请求路由匹配本身不分配:静态、参数、通配、404、空中间件,每个请求都是 0 次分配。分配来自 handler 自己:c.String 1 次,c.JSON 9 次(包含 gin.H 这个 map 和序列化)。带 Logger 和 Recovery 的 gin.Default() 即使 handler 为空,每个请求也有 10 次分配,主要来自 Logger 格式化日志。
/nothing → 状态 200 正文 "" X-A=""
/status → 状态 201 正文 "" X-A=""
/header → 状态 200 正文 "" X-A="1"
/after → 状态 200 正文 "先写正文" X-A=""handler 链结束后 WriteHeaderNow 才把还没发出的状态行补上,所以 c.Status(201) 不写正文也能让客户端看到 201,什么都不做是默认的 200。反过来,一旦已经写了正文,状态行就发出去了,之后再 c.Status(500) 无效。
gin.go 的 updateRouteTree(Gin v1.12.0):
504 | func updateRouteTree(n *node) {
505 | n.path = strings.ReplaceAll(n.path, escapedColon, colon)
506 | n.fullPath = strings.ReplaceAll(n.fullPath, escapedColon, colon)
507 | n.indices = strings.ReplaceAll(n.indices, backslash, colon)
508 | if n.children == nil {
509 | return
510 | }
511 | for _, child := range n.children {
512 | updateRouteTree(child)
513 | }
514 | }
第一次请求前 路由表:"/v1/\\:action"
第一次请求前 路由表:"/v1/items/:id"
GET /v1/:action → 200 命中 /v1/:action
第一次请求后 路由表:"/v1/:action"
第一次请求后 路由表:"/v1/items/:id"
GET /v1/other → 404(\: 是字面量冒号,不是参数)路由里用 \: 转义的冒号在注册时保留反斜杠,避免被当成参数;第一次 ServeHTTP 时才统一还原成 :,所以在第一次请求之前读 Routes() 会看到带反斜杠的路径。这样的路由匹配的是字面量的冒号,比如 /v1/:action。