likes
comments
collection
share

Gin的路由器

作者站长头像
站长
· 阅读数 14

Go语言中的路由器

正如我们之前讲过的,Go语言搭建一个服务器非常简单,只需要用到几个方法:

http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
	_, _ = fmt.Fprintf(writer, "关注 香香编程喵喵喵,关注香香编程谢谢喵喵喵!")
})
panic(http.ListenAndServe(":8080", nil))

注册路由

Go语言中http.HandleFunc用来注册路由器,其底层是一个并发安全的Map,我们可以看下源码:

type ServeMux struct {
	mu    sync.RWMutex        //读写互斥锁 用来保护map和切片的并发安全
	m     map[string]muxEntry //装载路径与方法的MAP,Key是路径,Value是一个结构体。
	es    []muxEntry          //这个切片里的结构体是按照路径从长到短排好序的
	hosts bool                //标志位,当前的路径中有没有根路径,只起到一个匹配优先级的问题
}

type muxEntry struct {
	h       Handler //路径对应的方法
	pattern string  //这里冗余了一分路径
}

当我们调用HandleFunc方法时,它底层会使用DefaultServeMuxHandle方法:

func (mux *ServeMux) Handle(pattern string, handler Handler) {
	mux.mu.Lock() //互斥锁的经典用法,后续有对MAP和切片的操作,需要保证并发安全
	defer mux.mu.Unlock()

	if pattern == "" {
		panic("http: invalid pattern") //路径不能为空
	}
	if handler == nil {
		panic("http: nil handler") //方法不能为空
	}
	if _, exist := mux.m[pattern]; exist {
		panic("http: multiple registrations for " + pattern) //路径不能重复
	}

	if mux.m == nil {
		mux.m = make(map[string]muxEntry) //创建MAP
	}
	e := muxEntry{h: handler, pattern: pattern} //创建存储主体
	mux.m[pattern] = e //赋值
	if pattern[len(pattern)-1] == '/' { //如果当前路径的结尾是'/'
		mux.es = appendSorted(mux.es, e) //把当前路径放到es里,并按照字符串长度排序
	}

	if pattern[0] != '/' { //如果路径的头部不是'/'
		mux.hosts = true //就把hosts的标志位设置为true
	}
}

处理请求

ListenAndServe在经历一系列的周转后,会把请求信息转交到DefaultServeMuxhandler方法中,其底层又调用了match来匹配具体的路由:

func (mux *ServeMux) handler(host, path string) (h Handler, pattern string) {
	mux.mu.RLock()         //同样的,先把锁加上。
	defer mux.mu.RUnlock() //注意这里的逻辑,他并没有在match函数中加锁,而是在这一层就开始加锁了。

	// 如果存在主机名,就优先采用全匹配的方式 参见后续案例
	if mux.hosts { 
		h, pattern = mux.match(host + path)
	}
	// 正常匹配,如果没有匹配到,就返回一个默认方法
	if h == nil {
		h, pattern = mux.match(path)
	}
	if h == nil {
		h, pattern = NotFoundHandler(), ""
	}
	return
}

//这个方法只会在 handler中调用,因此加锁的操作放在handler里
func (mux *ServeMux) match(path string) (h Handler, pattern string) {
	// 先判断是否Map中是否存在这个路由
	v, ok := mux.m[path]
	if ok {
		return v.h, v.pattern
	}

	//如果Map里不存在,就在切片中匹配下前缀路由
	for _, e := range mux.es {
		if strings.HasPrefix(path, e.pattern) {
			return e.h, e.pattern
		}
	}
	//都不存在,返回空
	return nil, ""
}

总结一下

Go原生路由的数据组成形式为:

Gin的路由器

这里有一段测试代码:

func TestRouter1() {
	http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
		_, _ = fmt.Fprintf(writer, "关注香香编程喵喵喵,关注香香编程谢谢喵喵喵!")
	})
	http.HandleFunc("localhost/a123", func(writer http.ResponseWriter, request *http.Request) {
		//会使 mux的hosts变位true  访问http://localhost:8081/a123 会解析到这个方法
		_, _ = fmt.Fprintf(writer, "localhost/a123")
	})
	http.HandleFunc("/b/c", func(writer http.ResponseWriter, request *http.Request) {
		_, _ = fmt.Fprintf(writer, "/index/a/b/c/")
	})
	http.HandleFunc("/index/a/b/c", func(writer http.ResponseWriter, request *http.Request) {
		_, _ = fmt.Fprintf(writer, "/index/a/b/c/")
	})
	http.HandleFunc("/z/x/", func(writer http.ResponseWriter, request *http.Request) {
		//路径 /z/x/111 和 /z/x/222 都会解析到这个方法
		_, _ = fmt.Fprintf(writer, "/z/x/")
	})
	fmt.Printf("http mux:%+v\n", http.DefaultServeMux)
	panic(http.ListenAndServe(":8081", nil))
}

Gin语言中的路由器

路由器的构成

Gin框架的核心代码都在主引擎Engine中,这个引擎又是由RouterGroup这个结构体加上其他一些字段组合起来的。

type Engine struct {
	RouterGroup //路由组核心字段
	....
	trees    methodTrees //路由树
	....
}

首先,我们先来看下路由接口的设计:

// IRouter 路由组的核心接口,本质是路由接口和Group方法的组合。
//这个接口的设计就是我们之前提到过的"小接口的组合"的思维。
type IRouter interface {
	IRoutes                                    //路由接口
	Group(string, ...HandlerFunc) *RouterGroup //分组方法,用来获取一新的RouterGroup
}

// IRoutes 路由的核心接口,我们常用的GET,POST方法都由这个接口提供。
//注意,这些方法的返回值都是一个IRoutes接口,这个接口往往是其本身。这样可以实现简单的链式调用
type IRoutes interface {
	Use(...HandlerFunc) IRoutes //向当前路由组添加中间件
	// Handle 根据传入的具体方法,注册一组路由
	Handle(string, string, ...HandlerFunc) IRoutes 
	// Any 唯一需要注意的,这个方法会一次创建所有类型的路由
	Any(string, ...HandlerFunc) IRoutes            
	GET(string, ...HandlerFunc) IRoutes
	POST(string, ...HandlerFunc) IRoutes
	DELETE(string, ...HandlerFunc) IRoutes
	PATCH(string, ...HandlerFunc) IRoutes
	PUT(string, ...HandlerFunc) IRoutes
	OPTIONS(string, ...HandlerFunc) IRoutes
	HEAD(string, ...HandlerFunc) IRoutes
	Match([]string, string, ...HandlerFunc) IRoutes

	//静态资源相关的方法,用的不多。
	StaticFile(string, string) IRoutes
	StaticFileFS(string, string, http.FileSystem) IRoutes
	Static(string, string) IRoutes
	StaticFS(string, http.FileSystem) IRoutes
}

然后,我们看下RouterGroup是如何实现IRouter的接口的:

type RouterGroup struct {
	// Handlers 一个函数链,底层是一个数组,装的是中间件的方法,子Group会继承父的中间件
	Handlers HandlersChain
	// basePath 定义的Group的主路径
	basePath string
	// engine 核心引擎的指针。需要注意,基于主Group创造出来的子Group们不仅会使用主中间件还会共用一个主引擎
	engine *Engine
	// root 是否为根Group,只有我们New出来的主引擎自带的Group这里才会是true
	root bool
}

最后,RouterGroup里有几个核心方法,我们可以学习一下,看他具体是如何工作的:

func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes {
	//非常简单的操作,将中间件方法Append到Handlers中
	group.Handlers = append(group.Handlers, middleware...)
	return group.returnObj()
}

// Group 该方法会返回一个全新的RouterGroup,并且会继承父Group的一些属性
func (group *RouterGroup) Group(relativePath string, handlers ...HandlerFunc) *RouterGroup {
	return &RouterGroup{
		//将多个中间件按照顺序组合到一起,底层使用的是内存copy,我们讲到中间件时会展开
		Handlers: group.combineHandlers(handlers),
		//将父级的路径组合到一起
		basePath: group.calculateAbsolutePath(relativePath),
		//把主引擎的指针继续传递下去
		engine: group.engine,
	}
}

// handle 核心中的核心。POST,GET等注册路由器方法的底层函数。
func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
	//获取当前的绝对路径相当于 Group的路径加上传入的路径
	absolutePath := group.calculateAbsolutePath(relativePath)
	//将当前函数和Group的中间件函数们串起来,得到一个新的切片
	handlers = group.combineHandlers(handlers)
	//将上述获得的绝对路径+方法链注册到路由树里。注意这里调用的是主引擎的addRoute方法。也就是说,路由树的管理权在主引擎手上。
	group.engine.addRoute(httpMethod, absolutePath, handlers)
	return group.returnObj()
}

// returnObj 函数定义是返回一个接口,实际返回的是自身。这是实现链式操作的一种方式
func (group *RouterGroup) returnObj() IRoutes {
	//root 字段的唯一用处,在根Group的情况下,返回主引擎。注意,主引擎其实也是IRoutes的实现,你懂得:)。
	if group.root {
		return group.engine
	}
	return group
}

注意,整个RouterGroup只负责绑定路由树,不负责匹配。

路由树的结构和原理

我们需要展开讲一下Gin框架的路由树的数据结构,有别于Go自身使用的Map,它使用的是数组+前缀树

我们这里先说下前缀树:

Gin的路由器

在Gin框架中路由的前缀树如下:

Gin的路由器

整体数据结构:

type methodTree struct {
	method string //方法类型:GET,POST等
	root   *node  //方法类型对应的具体路由树
}

type methodTrees []methodTree //主引擎中存储的路由树

// node 树的节点结构,适当调整了下字段顺序
type node struct {
	path      string // 节点路径,比如上面的blog,a,bout等等
	fullPath  string //完整路径
	wildChild bool   //通配符标识,比如带有:id的节点,这个值就是true

	//字母索引,对应的就是上边的a,下面的about和article的b和r
	//也就意味着,这个分支下面还有至少两个分支
	indices string
	//优先级,当前节点的子节点越多,这个值会越大
	priority uint32
	//当前节点的所有子节点
	children []*node
	//节点类型 有四种 static root param catchAll
	// static: 静态节点(默认),
	// root: 根节点
	// catchAll: 带有通配符的节点,也就是*
	// param: 参数节点
	nType nodeType

	handlers HandlersChain //函数链

}

图示:

Gin的路由器

对于树的节点node而言,它包含几个维护自身的方法,整体比较复杂(树节点的添加和再平衡本来就挺复杂的,好在前缀树没有删除某个节点的操作),比较重要的是:

  1. addRoute我们添加路由时,主引擎也会调用该方法将该节点添加到前缀树上。
  2. getValue我们请求过来后,会调用这个方法从树上找到具体的节点。

前缀树也是常用的一种数据结构,除了用在路由表中,还可以用来做敏感词过滤,城市或者国家的快速定位。

核心流程

当我们添加一个路由时,无论任何类型都会调用主引擎Engine里的addRoute方法,最终会调用node自带的addRoute方法来创建一个节点。

func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
	...
	// 获取请求方法对应的树
	root := engine.trees.get(method)
	if root == nil {
		//不存在的话,就创建一个该类型的树。
		root = new(node)
		root.fullPath = "/"
		engine.trees = append(engine.trees, methodTree{method: method, root: root})
	}
	//将当前路径和方法添加到前缀树里。
	root.addRoute(path, handlers)
	...
}

注册路由,并且维护前缀树的方法都在node中,主要是addRouteinsertChild这两个方法。我们简单过一下:

// addRoute 这个无法并发安全。言外之意就是,这个前缀树结构不是并发安全的。
//注意,gin框架经历了多次优化更新,这里采用的是1.9的版本。比着之前,代码量和整体逻辑简化不少
func (n *node) addRoute(path string, handlers HandlersChain) {
	fullPath := path
	//对路由树的 priority 属性进行自增操作,表示在此路由树中添加了一个新的节点。
	n.priority++

	// 如果路由树还没有任何节点,那么直接将传入的路由路径和处理函数添加到当前节点,此时路由树的类型被设置为 root。
	if len(n.path) == 0 && len(n.children) == 0 {
		n.insertChild(path, fullPath, handlers)
		n.nType = root
		return
	}

	parentFullPathIndex := 0

walk:
	//walk循环 Go代码块的一种用法,通常会结合着goto使用。一般业务代码中不建议使用
	//该循环的作用是寻找当前路径最合适的插入位置,本质上就是一种树的遍历,遍历的同时,适当调整树的结构。
	for {
		//查找当前路由节点和传入的路由路径的最长公共前缀
		i := longestCommonPrefix(path, n.path)

		//如果发现有前缀,并且前缀和当前前缀还小,相当于从 abc->ab,就会触发节点的分裂
		if i < len(n.path) {
			//分裂为新的节点,主要是path发生变化
			...
		}

		// 创建一个当前路径的节点
		if i < len(path) {
			path = path[i:]
			c := path[0]

			// 处理参数类型后 还带有/的特殊情况,直接略过
			if n.nType == param && c == '/' && len(n.children) == 1 {
				...
				continue walk
			}

			// 按照前缀继续往下查,直到没有重复前缀为止 此时的节点就是我们需要添加的位置了
			for i, max := 0, len(n.indices); i < max; i++ {
				...
			}

			// 如果此时的路径还有东西,并且类型不是通配符,可以认为是一个正常类型 我们的about 就会走到这里
			if c != ':' && c != '*' && n.nType != catchAll {
				// 向父节点上添加上子路径的首字母
				n.indices += bytesconv.BytesToString([]byte{c})
				//构造一下子节点
				child := &node{
					fullPath: fullPath,
				}
				//向当前节点上增加一个子节点
				n.addChild(child)
				n.incrementChildPrio(len(n.indices) - 1)
				//将游标指向新节点,准备结束循环
				n = child
			} else if n.wildChild {
				// 如果是通配符类型的,需要检查下是否有冲突,整体逻辑类似
				...
			}
			//向当前节点插入一个路径,以上所有的操作都只是找到位置,这里才是真正的插入节点
			n.insertChild(path, fullPath, handlers)
			return
		}

		// 当前节点已经被注册过了
		if n.handlers != nil {
			panic("handlers are already registered for path '" + fullPath + "'")
		}
		//如果当前节点根本就不需要走前缀索引,那么直接就可以创建节点了
		n.handlers = handlers
		n.fullPath = fullPath
		return
	}
}

当我们匹配一个路由时,会使用到主引擎的ServeHTTP它会调用handleHTTPRequest 方法,最后找到nodegetValue方法。这个代码很长,有很多业务逻辑,结构还是比较清晰的,我可以看下简化版本:

func (n *node) getValue(path string, params *Params, skippedNodes *[]skippedNode, unescape bool) (value nodeValue) {
	var globalParamsCount int16

walk:
	// 遍历整棵树,第一次遍历时,n是根路径
	for {
		prefix := n.path
		//在前缀树里查一下当前路径的位置
		if len(path) > len(prefix) {
			//前缀能匹配上
			if path[:len(prefix)] == prefix {
				...
				// 遍历所有非通配符的节点
				for i, c := range []byte(n.indices) {
					...
				}

				// 非通配符节点
				if !n.wildChild {
					...
				}

				// 处理通配符节点
				n = n.children[len(n.children)-1]
				globalParamsCount++

				switch n.nType {
				case param:
					...
				case catchAll:
					...
				default:
					panic("invalid node type")
				}
			}
		}

		//匹配到了具体的节点
		if path == prefix {
			//如果当前节点没有handler 或者当前路径不是 就需要回滚到最后一个有效的skippedNode
			if n.handlers == nil && path != "/" {
				...
			}

			//如果当前节点有handler 就算是匹配到具体的路由了
			if value.handlers = n.handlers; value.handlers != nil {
				value.fullPath = n.fullPath
				return
			}

			...

			//继续查询带有/的节点
			for i, c := range []byte(n.indices) {
				...
			}

			return
		}

		//判定下value.tsr 的值,这是个赋值语句……
		value.tsr = path == "/" ||
			(len(prefix) == len(path)+1 && prefix[len(path)] == '/' &&
				path == prefix[:len(prefix)-1] && n.handlers != nil)

		// 回退到最近一个有效的节点
		if !value.tsr && path != "/" {
			...
		}

		return
	}
}

前缀树的添加和匹配的操作都属于对树的遍历,本质上都是递归操作。代码里加入了大量的业务逻辑,看起来非常头疼,能抓住核心流程就好。

实现路由的其他方案

目前比较常见的有三种解决方案:

  1. Go原生的Map。常见又常用,简单有效,能解决大多数场景。
  2. Gin的前缀树。最常用的方案,其他语言的WEB框架中也经常采用前缀树方案。能支持几乎所有场景,但需要写很多维护前缀树的逻辑代码。
  3. gorilla/mux的正则匹配。这个方案在早期WEB框架里经常用到,现在用的比较少了。最大特点就是非常灵活,缺点是很多新人对于正则既熟悉又陌生。

总结

  1. Go原生的路由组是一个封装好的,支持并发安全的切片和MAP,切片用来装前缀路由,MAP用来装普通路由。
  2. Gin框架采用了切片和前缀树维护路由树。切片用来装不同的方法,前缀树用维护路由。