Gin框架源码分析(2)—— 路由匹配算法

490 阅读7分钟

为什么用前缀树实现

Go官方提供的http的路由基于hashmap实现,适用于整段路径和前缀路径匹配,

  • 整段路径/stu/allen 能匹配 /stu/allen
  • 前缀匹配/stu/ 能匹配 /stu/allen/stu/bob

Gin的路由匹配基于 httprouter实现,主要是能支持路径参数匹配,例如:

/stu/:id/class 能匹配 /stu/1/class /stu/2/class

其实用普通的前缀树也能解决,但是httprouter使用 Radix tree实现,树的高度更小,查询速度更快,更省内存

本文将介绍Gin的路由注册,路由匹配的实现原理

数据结构

Engine 是 gin 框架的实例,在 Engine 结构中,trees 是一个数组,针对框架支持的每一种http方法,都会创建一棵树,每棵树用根节点代表,例如 GET、POST 是 trees 的两个元素。

type Engine struct {

   // ...
   
   trees            methodTrees
   
   // ,,,
}

type methodTrees []methodTree

type methodTree struct {
   method string
   root   *node
}

前缀树节点定义:

type node struct {
   // 当前节点代表的路径
   path      string
   // 子节点的首字母
   indices   string
   // 子节点中是否有路径参数节点
   wildChild bool
   // 当前节点类型
   nType     nodeType
   // 有多少路径经过当前节点
   priority  uint32
   // 子节点
   children  []*node
   // 若是叶子节点,存放该节点对应的处理器链
handlers  HandlersChain
   fullPath  string
}
  • indices:维护了子节点的首字母

    • 例如:节点n的path 为:/stu/,子节点为 ["/allen","/bob"],那么n.indices为 "ab"
    • 作用在于查找子节点时只用比对首字母,效率更高
    • 如果给节点n添加一个 "/apple"的子节点,那么数会分裂成以下形式,确保indices中每个字符,只会对应一个子节点
    • /stu
          /a
              llen
              pple
          /bob
      
  • nType:节点类型,主要用于区分是静态节点,还是路径参数节点,还是通配符节点

    • 通配符节点很少用到,本文不涉及
type nodeType uint8

const (
   root nodeType = iota + 1  // 1
   param                    // 2
   catchAll                 // 3
)

一颗拥有如下路径的前缀树:

r.GET( "/pig" , funcA)
r.GET( "/ping/:stu/allen" , funcB)
r.GET( "/ping/:stu/tom" , func)

其内部形式为:

流程图.jpg

源码分析

添加路由

从trees获取到http方法对应的根节点后,调用根节点的addRoute方法添加路由

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)
func (n *node) addRoute(path string, handlers HandlersChain) {
   fullPath := path
   // 经过该节点的路径数+1
   n.priority++

   // Empty tree
   if len(n.path) == 0 && len(n.children) == 0 {
      n.insertChild(path, fullPath, handlers)
      n.nType = root
      return
   }
   
   // ... 
}

如果是空树,第一次加入路径,调用insertChild添加,后面可以看到,当在一棵空的子树上添加剩余的路径时,也是调该方法:

func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) {
   for {
      // 检查path中是否有路径参数
      // 返回路径参数名,路径参数起始位置,是否合法
      // 关于具体怎么检查,下文分析
      wildcard, i, valid := findWildcard(path)
      // 如果没有路径参数,直接跳出循环
      if i < 0 { 
         break
      }

      // 路径参数必须是合法的,即同一段路径不能有大于1个路径参数
      if !valid {
         panic( "only one wildcard per path segment is allowed, has: '" +
            wildcard + "' in path '" + fullPath + "'" )
      }

      // 路径参数必须有名字
      if len(wildcard) < 2 {
         panic( "wildcards must be named with a non-empty name in path '" + fullPath + "'"           )
      }

      // 是路径参数
      if wildcard[0] == ':' { // param
      if i > 0 {
            // 当前节点n为 前面的静态路径
            n.path = path[:i]
            // 把前面部分阶段,下次循环从后面部分开始添加
            path = path[i:]
         }

         // child为路径参数节点,
         child := &node{
            nType:    param,
            // path为路径参数名
            path:     wildcard,
            fullPath: fullPath,
         }
         n.addChild(child)
         // n的child有路径参数
         n.wildChild = true
         n = child
         n.priority++

         // 如果path不以路径参数结尾,则从路径参数后一个字符继续分割
         if len(wildcard) < len(path) {
            path = path[len(wildcard):]

            child := &node{
               priority: 1,
               fullPath: fullPath,
            }
            n.addChild(child)
            n = child
            continue
         }

         // 否则到结尾了,将handlers设置给n
         n.handlers = handlers
         return
      }

      // catchAll部分,忽略
     }

   // 如果没有路径参数,直接将完成的path赋值给node
   n.path = path
   n.handlers = handlers
   n.fullPath = fullPath
}
  • 首先查找path是否有路径参数,如果没有,直接将完成的path赋值给node,返回
  • 调用findWildcard,返回path中第一个路径参数的参数名,开始位置,是否合法

    • 路径参数必须是合法的,即同一段路径不能有大于1个路径参数

      • 例如:/:id:name 就是不合法的
    • 路径参数必须有名字

      • 例如: /: 就是不合法的
  • 将路径参数前面部分的path赋值给当前node
  • 然后给当前node创建一个child, nType为 param,表示路径参数节点,path为路径参数名,n跳到该child
  • 如果path不以路径参数结尾,则从路径参数后一个字符继续分割,继续循环添加节点
  • 否则到尾了,将handlers赋值给尾结点,返回

接着看看如何path中检查路径参数:

func findWildcard(path string) (wildcard string, i int, valid bool) {
   // Find start
 for start, c := range []byte(path) {
      // 碰到 ":"或 "*"
      if c != ':' && c != '*' {
         continue
      }

      valid = true
      // 从 "*" 开始往后遍历,一旦遇到结尾,或者 "/" ,或者再次遇到 ':' , '*'停止
      for end, c := range []byte(path[start+1:]) {
         switch c {
         case  '/' :
            return path[start : start+1+end], start, valid
         case  ':' , '*' :
            valid = false
         }
      }
      return path[start:], start, valid
   }
   return  "" , -1, false
}
  • 遍历path中的每个字符

    • 碰到 ":""*" 开始
    • 从 "*" 开始往后遍历,一旦遇到结尾,或者 "/" ,或者再次遇到':', '*'为止
    • 如果遇到 "/",或者结尾了,表示该路径参数合法,否则不合法
    • 如果整段都没有路径参数,表明这是个静态路径,返回 -1
  • 例如:

    • Path = "/stu/:id/name",会返回 :id,5,true

    • Path = "/stu/:id:name/",就会返回 valid = false,表示不合法,因为同一段路径只能有一个路径参数

如果不是第一次添加路径,就会进入addRoute中后面的流程:

walk:
   for {
      // 寻找path和节点.path最长公共前缀
      i := longestCommonPrefix(path, n.path)

      // 需要分裂n
      if i < len(n.path) {
         child := node{
            path:      n.path[i:],
            wildChild: n.wildChild,
            indices:   n.indices,
            children:  n.children,
            handlers:  n.handlers,
            priority:  n.priority - 1,
            fullPath:  n.fullPath,
         }

         n.children = []*node{&child}
         // 设置索引
         n.indices = bytesconv.BytesToString([]byte{n.path[i]})
         // 分裂后当前节点变为父节点,path为公共前缀
         n.path = path[:i]
         n.handlers = nil
         n.wildChild = false
         n.fullPath = fullPath[:parentFullPathIndex+i]
      }

      // 需要为添加新节点
      if i < len(path) {
         // 需要添加的path为从i开始到最后的部分
         path = path[i:]
         c := path[0]

         // 如果n为路径参数节点,且path以 '/' 开头,直接去n.children[0]匹配
         if n.nType == param && c == '/' && len(n.children) == 1 {
            parentFullPathIndex += len(n.path)
            n = n.children[0]
            n.priority++
            continue walk
         }

         // 检查c是否在n.indices存在,如果存在,就取对应的child匹配
         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)
            n = child
         } else  if n.wildChild {
            // 如果n的child为路径节点,且要插入的path也有路径参数
            n = n.children[len(n.children)-1]
            n.priority++

            // 要么len(path)大于len(n.path),且n.path == path[:len(n.path)],且path[len(n.path)] == '/'
            // 要么len(path)等于len(n.path),且n.path == path[:len(n.path)]
            // 否则就会出现路径参数冲突,直接panic
            if len(path) >= len(n.path) && n.path == path[:len(n.path)] &&
               // Adding a child to a catchAll is not possible
               n.nType != catchAll &&
               // Check for longer wildcard, e.g. :name and :names
              (len(n.path) >= len(path) || path[len(n.path)] == '/' ) {
                continue walk
            }

            // 冲突,会panic
            pathSeg := path
            if n.nType != catchAll {
               pathSeg = strings.SplitN(pathSeg, "/" , 2)[0]
            }
            prefix := fullPath[:strings.Index(fullPath, pathSeg)] + n.path
            panic( "'" + pathSeg +
               "' in new path '" + fullPath +
               "' conflicts with existing wildcard '" + n.path +
               "' in existing prefix '" + prefix +
               "'" )
         }

         // 需要插入新节点
         // 下一段path是普通节点,或者是路径参数节点但n的child不是路径参数节点,调用insertChild插入
         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
      }
}
  • 寻找path和节点.path最长公共前缀,返回前缀的长度i
  • 如果最长公共前缀比n.path短,说明n节点本身需要分裂

    • 分裂后n的path为前缀,并为n添加一个子节点
    • n.path 改为 n.path[:i],子节点的path为 n.path[i:]
    • 例如:n.path = "/allen",要添加的path = "/apple",n就会分裂为 '/a'和 'llen'两个节点
  • 如果 ****i < len(path),说明需要添加新节点,

    • 如果n为路径参数节点,且path以 '/' 开头,直接去n.children[0]匹配
  • 检查path[0]是否在n.indices存在

    • 如果存在,就取对应的child继续匹配

    • 如果不存在,说明需要插入新节点:

      • 如果path的下一段不是路径参数,那么创建一个子节点,当前节点跳到子节点,最后执行insertChild进行插入
  • 如果path下一段是路径参数,且当前节点的子节点也有路径参数,那么就需要看是否冲突:

    • 要么len(path)大于len(n.path),且n.path == path[:len(n.path)],且path[len(n.path)] == '/'
    • 要么len(path)等于len(n.path),且n.path == path[:len(n.path)]
    • 也就是说,要么path和n.path完全相同,要么path比n.path多的部分以 '/'开头才算合法,这里的逻辑是:路径参数的参数名必须相同,因为如果不同,请求过来就不知道匹配哪条路径
    • 如果不冲突,跳到child继续执行for循环
  • 最终会调用insertChild生成新的子树

匹配路由

入口为ServeHTTP,Engine实现了http.Handler接口,因此go http会将请求转发到Engine.ServeHTTP方法:

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
   c := engine.pool.Get().(*Context)
   c.writermem.reset(w)
   c.Request = req
   c.reset()
 
   engine.handleHTTPRequest(c)

   engine.pool.Put(c)
}

进入handleHTTPRequest:

t := engine.trees
for i, tl := 0, len(t); i < tl; i++ {
   if t[i].method != httpMethod {
      continue
}
   // 获取请求方法对应的根节点
   root := t[i].root
   // 调用root.getValue获取handlers和路径参数信息
   value := root.getValue(rPath, c.params, c.skippedNodes, unescape)
   if value.params != nil {
      c.Params = *value.params
   }
   if value.handlers != nil {
      c.handlers = value.handlers
      c.fullPath = value.fullPath
      c.Next()
      c.writermem.WriteHeaderNow()
      return
}

关键在于怎么从tree中获取到和请求路径对应的处理器链,以及怎么从请求路径中解析出路径参数:

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

walk:
 for {
      prefix := n.path
      if len(path) > len(prefix) {
         // path的前缀和 n.path相同
         if path[:len(prefix)] == prefix {
            // 接下来要找的path为后半段
            path = path[len(prefix):]

            // 尝试所有的indices
            idxc := path[0]
            for i, c := range []byte(n.indices) {
               // 匹配
               if c == idxc {
                  // 去下一个节点找
                  n = n.children[i]
                  continue walk
                }
            }

         
            // 如果静路由没有匹配,就需要找 路径参数的路由
            n = n.children[len(n.children)-1]
            globalParamsCount++

            switch n.nType {
            case  param:

               // 一直找到path的结尾,或者遇到 '/' 为止
               end := 0
               for end < len(path) && path[end] != '/' {
                  end++
               }

               // Save param value
               if params != nil && cap(*params) > 0 {
                  if value.params == nil {
                     value.params = params
                  }
                  // Expand slice within preallocated capacity
                  i := len(*value.params)
                  *value.params = (*value.params)[:i+1]
                  val := path[:end]
                  if unescape {
                     if v, err := url.QueryUnescape(val); err == nil {
                        val = v
                     }
                  }
                  // 找到和该路径参数匹配的值,存放到value.params中
                  (*value.params)[i] = Param{
                     Key:   n.path[1:],
                     Value: val,
                  }
               }

               // 如果path还有剩余,继续遍历后面的子树
               if end < len(path) {
                  if len(n.children) > 0 {
                     path = path[end:]
                     n = n.children[0]
                     continue walk
                  }

                  // ... but we can't
                  value.tsr = len(path) == end+1
                  return
               }

               // path没有剩余,则找到目标节点
               if value.handlers = n.handlers; value.handlers != nil {
                  value.fullPath = n.fullPath
                  return
               }
             
               return

            // catchAll的逻辑,忽略

            default:
               panic( "invalid node type" )
            }
         }
      }

      return
}
}

这里忽略了对catchAll节点,和skippedNodes的处理,只看静态路径和路径参数的核心逻辑:

  • 首先请求路径path的前缀,必须和当前节点n.path相同
  • 然后尝试n.indices 如果有匹配的,继续去匹配的child寻找
  • 如果没有匹配的,且child中有路径参数节点,就去该节点匹配:

    • 将path从当前位置,到结尾或遇到 '/'中间的所有字符当做路径参数的值,保存到value.params
  • 如果path还有剩余,继续匹配后面的路径