从源码角度深入浅出 Golang Gin 基本的工作原理。

557 阅读13分钟

从源码角度深入浅出 Golang Gin 基本的工作原理。

分享下很喜欢的一句现代改编诗词:少年应有鸿鹄志,当骑骏马踏平川。

本文将会从 Go 1.20.2Gin v1.9.0 来重点进行分析 Gin 的基本工作原理,包含:net/http 基本工作原理、Gin适配net/http、Gin 中间件工作原理、Gin 路由树工作原理等。

  • 如果你现在还不清楚Golang 基本的net/http工作原理,那么阅读本文,你将会收获net/http基本工作原理。
  • 如果你现在还不清楚Gin是如何适配net/http的,那么阅读本文,你将会收获Gin适配net/http的基本工作原理。
  • 如果你现在还不清楚Gin中间件工作原理,那么阅读本文,你将会更加清楚理解Gin中间件基本工作原理。
  • 如果你现在还不清楚Gin路由树的数据结构,亦或者对路由树的添加或者查询不是很清楚,那么阅读本文,你将会对Gin路由树的了解更加深入。

一、Golang 原生的net/http工作原理。

既然我们要了解net/http原生的http工作原理,那么我们直接从下面的代码来进行分析:

func main() {
	http.HandleFunc("/test", func(writer http.ResponseWriter, request *http.Request) {
		_, err := writer.Write([]byte("Hello Word Golang HTTP"))
		if err != nil {
			panic(err)
		}
	})
	if err := http.ListenAndServe("8899", nil); err != nil {
		log.Fatal(err)
	}
}

我们可以直观的通过上面的代码来看到,我们通过http.HandlerFunc注册了一个路径为/test,并且在内定的回调函数内响应了Hello Word Golang HTTP等字符,之后我们通过http.ListenAndServe开启服务器,等待客户端的请求。原生的net/http整体上的工作原理就是如此,而我们也知道HTTP本身是依赖于TCP来进行信息交换的,所以我们可以断定net/http本身也是通过解析TCP字节流来实现HTTP的能力的。那么下面就跟源码分析的脚步一步一步的去深入了解原生net/http的基本工作原理。

net/http包提供了如下的接口,而该接口正是在后文中解析TCP后要执行的Handler回调,而net/http包下本身通过ServerMux实现了该接口,也就是 DefaultServeMux,具体的结构体为:ServeMux

type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

type ServeMux struct {
	mu    sync.RWMutex
    m     map[string]muxEntry // 具体URL路由信息对应具体的muxEntry(Handler)
	es    []muxEntry // slice of entries sorted from longest to shortest.
	hosts bool       // whether any patterns contain hostnames
}

ServeMux添加路由信息很简单,只是将对应的URL以及对应的Handler封装成一个muxEntry结构体,并和URL保存在ServerMux.m中,以便通过0(1)的时间复杂度快速查找到一个URL对应的具体路由信息,源码如下:

// 使用http.HandleFunc直接添加对应的Handler。
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
	DefaultServeMux.HandleFunc(pattern, handler)
}

func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
	if handler == nil {
		panic("http: nil handler")
	}
	mux.Handle(pattern, HandlerFunc(handler))
}

// Handle registers the handler for the given pattern.
// If a handler already exists for pattern, Handle panics.
func (mux *ServeMux) Handle(pattern string, handler Handler) {
	mux.mu.Lock()
	defer mux.mu.Unlock()
    (...)
	e := muxEntry{h: handler, pattern: pattern}
    // 这里我们可以看到,其通过URL作为Key,对应的Entry作为Handler来进行后续的路由信息查找。
	mux.m[pattern] = e
	if pattern[len(pattern)-1] == '/' {
		mux.es = appendSorted(mux.es, e)
	}
}

分析到这里,我想我们应该都已经知道URL和对应的Handler是如何进行存储的了,那么下面一个关键的问题就是:当监听的TCP字节流解析后,是如何找到对应的URL之前注册的Handler进而去执行对应的业务逻辑呢?我们直接来看对应的 http.ListenAndServe监听函数:

// ListenAndServe 监听并且启动Serve。
func ListenAndServe(addr string, handler Handler) error {
	server := &Server{Addr: addr, Handler: handler}
	return server.ListenAndServe()
}

func (srv *Server) ListenAndServe() error {
    (...)
    // 监听服务地址,同时获取到监听状态的Listener。
	ln, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}
	return srv.Serve(ln)
}

// Serve accepts incoming connections on the Listener l, creating a
// new service goroutine for each. The service goroutines read requests and
// then call srv.Handler to reply to them.
func (srv *Server) Serve(l net.Listener) error {
    (...)
	for {
        // 从TCP全连接队列中获取准备就绪的TCP链接。
		rw, err := l.Accept()
		if err != nil {
            (...)
            // 错误逻辑处理。
		}
        (...)
        // 将srv以及conn赋值给c.
		c := srv.newConn(rw)
		c.setState(c.rwc, StateNew, runHooks) // before Serve can return
        // 针对每一个业务逻辑开一个goroutine进行逻辑处理。
		go c.serve(connCtx)
	}
}

通过上面我们已经从TCP获取到了本次进行HTTP操作的net.conn,并封装成了一个conn结构体从而执行该conn的业务逻辑函数:serve。这里我们直接看源码即可:

// Serve a new connection.
func (c *conn) serve(ctx context.Context) {
    (...)
	for {
        // 解包。
		w, err := c.readRequest(ctx)
		if c.r.remain != c.server.initialReadLimitSize() {
			// If we read any bytes off the wire, we're active.
			c.setState(c.rwc, StateActive, runHooks)
		}
        (...)
		// 业务前序逻辑。
		serverHandler{c.server}.ServeHTTP(w, w.req)
        // 业务后续逻辑。
        (...)
	}
}

我们重点关注serverHandler,该结构体是实现了Handler接口的一个内部结构体,在该内部结构体内只做了一件事情,就是取出Server的Hander进行执行,如果Server的Handler为空,则使用默认的DefaultServerMux来进行业务逻辑处理。

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
	handler := sh.srv.Handler
	if handler == nil {
		handler = DefaultServeMux
	}
	if !sh.srv.DisableGeneralOptionsHandler && req.RequestURI == "*" && req.Method == "OPTIONS" {
		handler = globalOptionsHandler{}
	}
	// 执行对应Handler的ServeHTTP回调函数。
	handler.ServeHTTP(rw, req)
}

由上文我们已经知道了当有TCP链接进来时(也就是有新的HTTP链接),其会执行预先设定好的Handler.ServeHTTP回调函数,并传入对应的Request以及Response供业务逻辑函数进行自处理。而我们本文是通过DefaultServeMux来进行分析的,所以我们直接通过看该结构体的实现函数即可:=由于我们之前在添加对应的路由信息以及Handler时,将对应的Func封装成了一个HandlerFunc,所以当我们执行h.ServeHTTP(w, r)其实就是在执行HandlerFunc对应的ServeHTTP函数。在该函数内直接进行了f(w,r)这里的函数就是我们自定义的Handler函数。

func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
	if r.RequestURI == "*" {
		if r.ProtoAtLeast(1, 1) {
			w.Header().Set("Connection", "close")
		}
		w.WriteHeader(StatusBadRequest)
		return
	}
    // 根据Request.URL找到对应之前注册的Handler。
	h, _ := mux.Handler(r)
	h.ServeHTTP(w, r)
}


type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
	f(w, r)
}

OK,我相信通过上面的源码解析,大家对原生的net/http的工作流程已经了如指掌了,同时对于其如何利用原生net/http提供的基础能力来进行自定义处理业务逻辑函数等。如果我们要自定义一个WEB框架,我们只需要实现http/Handler接口即可,因为http库本身在解析完HTTP后会调用传入进来的Handler,如果我们自定义了Handler,那么就会执行我们自定义Hanlder里面的内容,具体后续如何进行路由分发以及中间件的处理逻辑,都可以进行自定义。

二、Gin是如何适配net/http的。

通过第一部分我们已经知道了net/http本身的工作原理,而Gin是依托于net/http库本身来进行二次开发的,同时通过第一部分我们也知道只要实现net/http库下的Handler接口之后,当获取到链接并解析后则会执行对应传入进来的Handler,也就是Gin.Engine。我们具体看下其实现:

// ServeHTTP conforms to the http.Handler interface.
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	c := engine.pool.Get().(*Context)
	c.writermem.reset(w)
	c.Request = req
	// 如果这里get的不是通过New获得的,那么这里必须进行reset.
	c.reset()

	engine.handleHTTPRequest(c)
	// 使用完之后放回。
	engine.pool.Put(c)
}

在函数:engine.handleHTTPRequest(c) 内就是在路由树上根据Path找到对应的节点信息,并执行对应的Handler信息:

func (engine *Engine) handleHTTPRequest(c *Context) {
	httpMethod := c.Request.Method
	rPath := c.Request.URL.Path
    (...)
	t := engine.trees
	for i, tl := 0, len(t); i < tl; i++ {
		if t[i].method != httpMethod {
			continue
		}
		// 该方法类型的根节点.
		// 根节点下面就是各种路由信息.
		root := t[i].root
		// Find route in tree
        // 根据路径前缀依次递归找到Path对应的nodeValue
		value := root.getValue(rPath, c.params, c.skippedNodes, unescape)
		if value.params != nil {
			c.Params = *value.params
		}
        // 执行当前Path对应的Handler。
        // 其中Handlers最后一个才是业务逻辑上的Handler,前面全部是注册的中间件。
		if value.handlers != nil {
			c.handlers = value.handlers
			c.fullPath = value.fullPath
			c.Next()
			c.writermem.WriteHeaderNow()
			return
		}
        (...)
		break
	}

    (...)
    // 如果未找到Path对应的Handler则返回404相关Handler。
	serveError(c, http.StatusNotFound, default404Body)
}

在函数handleHTTPRequest的实现逻辑上较为简单,具体如下:

  1. 根据Path在路由树中递归查找子节点,直到找到当前节点对应的nodeValue。
  2. 如果节点的Handlers不为空,则直接执行,该Handlers只有最后一个才是业务自定义Handler,前面都是业务自定义的中间件。
  3. 如果找不到则执行默认的404相关逻辑内容。

读到这里,我想大家对于Gin是如何适配net/http的已经掌握了,至于Gin是如何添加路由信息到路由树的,我们在下面章节深入进行分析。

三、Gin 中间件基本工作原理。

在我们WEB业务开发中经常需要用到一个中间件的逻辑:在执行业务逻辑之前,需要再业务逻辑执行前后执行一段自定义的逻辑,比如统计函数调用耗时等。这个就要用到Gin的中间件原理了。

既然我们要学习Gin的中间件,那么Gin的中间件存储在哪里呢?在访问某一个具体请求时Gin是如何做到先执行中间件后执行业务逻辑呢?我们带着这两个疑问我们去看下中间件源码:

// Use attaches a global middleware to the router. i.e. the middleware attached through Use() will be
// included in the handlers chain for every single request. Even 404, 405, static files...
// For example, this is the right place for a logger or error management middleware.
func (engine *Engine) Use(middleware ...HandlerFunc) IRoutes {
	engine.RouterGroup.Use(middleware...)
	engine.rebuild404Handlers()
	engine.rebuild405Handlers()
	return engine
}

// Use adds middleware to the group, see example code in GitHub.
func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes {
	group.Handlers = append(group.Handlers, middleware...)
	return group.returnObj()
}

如果我们没有使用分组的情况下(正常分组和这里类似,不再进行讲解,如果读者感兴趣可以根据本文介绍的基本原理而自行去探索),这里直接保存在Engine.RouterGroup下,而Use函数则是直接将对应的中间件保存在RouterGroup.Handlers数组中,这个数组后续再每次添加路由信息时都会用到。当我们的进程启动后,代码运行到这里就已经表示当前中间件已经生效了。这里有一个坑,我先提前和大家预防一下,具体是什么坑,我们后续进行详细说明

接下来我们先来看看添加路由前的准备工作:

func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
	absolutePath := group.calculateAbsolutePath(relativePath)
	// 先拷贝在定义在该router之前的中间件,在拷贝当前路由handler.
	handlers = group.combineHandlers(handlers)
	// handlers[0:len(group.Handlers)-2] 为中间件.
	// handlers[len(handlers)1:]为本次要添加的函数体.
	// 将当前handlers添加到engine中.
	group.engine.addRoute(httpMethod, absolutePath, handlers)
	return group.returnObj()
}

func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {
	finalSize := len(group.Handlers) + len(handlers)
	// Handlers表示的是中间件。
	assert1(finalSize < int(abortIndex), "too many handlers")
	mergedHandlers := make(HandlersChain, finalSize)
	// 将当前已经注册好的中间件以及当前添加的Handler全部进行合并,表示本次Path对应的Handler。
	copy(mergedHandlers, group.Handlers)
	copy(mergedHandlers[len(group.Handlers):], handlers)
	return mergedHandlers
}

在函数combineHandlers中我们可以看到,如果我们添加一个路由信息,那么其会优先将当前分组内已经注册好的中间件放在业务Handler之前,而将请求对应的Handler放在最后。这样可以保证对于之前已经注册好的中间件都对当前正在注册路由的Handler生效。但是这里会存在一个问题,也就是上文中我提前预防的一个问题,这里生效的中间件必须是在注册当前Handler之前就注册的中间件才会生效否则是不会生效的。这个特性也是由Gin的处理逻辑决定。

group.engine.addRoute(httpMethod, absolutePath, handlers)这行代码中就是将对应的handlers添加到absolutePath绝对路径所表示的nodeValue中,以便后续进行访问时格局Path信息获取到对应的handlers。而这里的handlers则包含中间件以及业务handler。

四、Gin 路由树的基本工作原理。

Gin路由树的基本工作原理对于查询访问以及增加路由的前缀工作已经在上文介绍的很详细了,这里我们重点介绍一下前缀树的插入逻辑,我们具体看代码:

func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
    (...)
	// 从engine.trees森林中获取当前请求方法类型的根节点.
	root := engine.trees.get(method)
	if root == nil {
		// 没有在这里进行设置root属性.
		root = new(node)
		root.fullPath = "/"
		engine.trees = append(engine.trees, methodTree{method: method, root: root})
	}
	root.addRoute(path, handlers)
    (...)
}

如果对应的Method类型不存在我们直接创建一个node并设置为root节点,之后我们根据该node将当前Path对应的handlers添加到对应的路由树上:

// addRoute adds a node with the given handle to the path.
// Not concurrency-safe!
func (n *node) addRoute(path string, handlers HandlersChain) {
	fullPath := path
	n.priority++

	// Empty tree
	// 空树直接将path以及handlers添加到当前节点上。
	if len(n.path) == 0 && len(n.children) == 0 {
		n.insertChild(path, fullPath, handlers)
		n.nType = root
		return
	}

	parentFullPathIndex := 0

walk:
	for {
		// 找到共同前缀后的第一个字符的下标
		i := longestCommonPrefix(path, n.path)
		// 当前node和当前要保存的节点之间存在共同前缀,此时针对node添加新的子节点,同时修改path为前缀path,同时将当前节点的handlers移步到子节点上。
		if i < len(n.path) {
			// 子节点.
			// 前缀树.
			child := node{
				path:      n.path[i:], // 当前子节点的绝对路径为父节点.path+child.path
				wildChild: n.wildChild,
				nType:     static,
				indices:   n.indices,
				children:  n.children, //  这里会将之前的子节点copy到当前node的子节点,这是因为前缀树.
				handlers:  n.handlers,
				priority:  n.priority - 1,
				fullPath:  n.fullPath,
			}
			n.children = []*node{&child}
			// []byte for proper unicode char conversion, see #65
			// indices 下一个子节点开始的字符?
			n.indices = bytesconv.BytesToString([]byte{n.path[i]})
			n.path = path[:i] // 当前节点保留前缀,子节点保留后缀,那么前缀对应的handler以及当前新增加节点对应的handler呢?
			n.handlers = nil
			n.wildChild = false
			n.fullPath = fullPath[:parentFullPathIndex+i]
		}

		// Make new node a child of this node
		// 除去共同前缀后,单独处理当前要添加节点的剩余字符串
		if i < len(path) {
			// 本次不同的path。
			path = path[i:]
			c := path[0]

            (...)

			// Check if a child with the next path byte exists
			// indices ""
			// 当前下一个字符和子节点的第一个字符相同,则直接获取到对应的子节点。
			for i, max := 0, len(n.indices); i < max; i++ {
				if c == n.indices[i] {
					parentFullPathIndex += len(n.path)
					i = n.incrementChildPrio(i)
					n = n.children[i]
					continue walk
				}
			}

			// 通配符.
			if c != ':' && c != '*' && n.nType != catchAll {
				// []byte for proper unicode char conversion, see #65
				n.indices += bytesconv.BytesToString([]byte{c})
				// 绝对路径.
				child := &node{
					fullPath: fullPath,
				}
                // 增加子节点。
				n.addChild(child)
				n.incrementChildPrio(len(n.indices) - 1)
                // 更换子节点,后续在子节点中执行insertChild函数逻辑。
				n = child
			} else if n.wildChild { // 判断父节点是不是通配符.
                (...)
			}

			// 将当前path以及handlers插入到当前节点n上。
			n.insertChild(path, fullPath, handlers)
			return
		}

		// Otherwise add handle to current node
		if n.handlers != nil {
			panic("handlers are already registered for path '" + fullPath + "'")
		}
		// 如果没有共同前缀,则直接添加
		n.handlers = handlers
		n.fullPath = fullPath
		return
	}
}

上面函数逻辑总结下来如下(省略了通配符等特殊情况,只考虑了通用情况):

  1. 如果函数类型树为空,则创建一个Root树,并将当前Path信息以及对应的Handlers存入到当前新创建的node节点上。
  2. 如果当前Path和当前Node.path存在共同前缀,则拆分当前Node,并且添加新的子节点,同时修改path为前缀path,同时将当前节点的handlers移步到子节点上。
  3. 绝对路径剔除掉共同前缀后,我们依据这部分路径从子节点中定位到node,并将当前path对应的handlers添加到子节点上。

个人博客:openxm.cn

个人公众号:社恐的小马同学