Golang:HttpRouter 源码分析(三)

528 阅读3分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第5天,点击查看活动详情

前言

前情提要:

Golang:HttpRouter 源码分析(一)

Golang:HttpRouter 源码分析(二)

终于,来到路由树构建的最后的一篇了(确信)。本篇文章会把 insertChild 函数讲完,再顺便提一下节点优先级是怎么定的,这样就把整个树的构建讲解完了。让我们继续从示例开始今天的旅程。

insertChild

示例

package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/julienschmidt/httprouter"
)

func Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
    fmt.Fprint(w, "Welcome!\n")
}

func main() {
    router := httprouter.New()
    router.GET("/hello/", Index)
    router.GET("/hello/:name", Index)
    router.GET("/hel/:name/config", Index)
    router.GET("/hel/:name/city/*config", Index)

    log.Fatal(http.ListenAndServe(":8080", router))
}

概览

源码在这里 ↓

func (n *node) insertChild(numParams uint8, path, fullPath string, handle Handle)

fullPath 这个参数仅用于报错。

insertChild 这个函数会在树的叶子节点上继续插入新的路径节点,它不关注也不需要关注前面的路径信息,因为 addRoute 函数已经帮它都处理好了。我们先来看看整个函数大体上都干了什么:

  • 函数在开头定义了 offset 变量来标记已经处理过的路径位置;
  • 接下来,它循环遍历整条插入路径并对不同的节点进行处理;
  • 由于参数节点比较特殊,所以在遍历路径的过程中,优先查找通配符 :* 的起始以及结束的位置;
    • 如果没有参数节点,循环结束;
    • 如果是命名参数捕获,把前面的路径插入,插入参数节点,继续遍历;
    • 如果是任意参数捕获,把前面的路径插入,插入参数节点,直接结束;
  • 最后,把剩下的路径插入,handle挂在叶子节点上。

详细分析

router.GET("/hello/", Index)

由于是第一个路径,这里将直接插入。

var offset int

for i, max := 0, len(path); numParams > 0; i++ {
    c := path[i]
    if c != ':' && c != '*' {
        continue  // 如果没有匹配到参数节点,这里会一直continue到循环结束
    }
    // ...
}
// 直接插入 /hello/
n.path = path[offset:]
n.handle = handle

router.GET("/hello/:name", Index)

这里要处理命名捕获参数,它从 : 开始到下一个 / 之前(不包括 / )或者路径结尾结束。

// 这一部分处理是两种参数共用的
c := path[i]
if c != ':' && c != '*' {
    continue
}

end := i + 1  // i为0
for end < max && path[end] != '/' {
    switch path[end] {
    case ':', '*': // 在同一个参数片段中不能包含多个通配符,如::na:me/ 、:na*me
        panic("only one wildcard per path segment is allowed, has: '" +
            path[i:] + "' in path '" + fullPath + "'")
    default:
        end++
    }
} // end为5

// 能执行到这里,就说明插入路径中有参数,而参数作为子节点时,它的父节点不能有其他子节点
if len(n.children) > 0 {
    panic("wildcard route '" + path[i:end] +
        "' conflicts with existing children in path '" + fullPath + "'")
}
// 参数必须要有名字
if end-i < 2 {
    panic("wildcards must be named with a non-empty name in path '" + fullPath + "'")
}

if c == ':' {
    // 判断一下 ':' 前面有没有路径,有就插入
    if i > 0 {
        n.path = path[offset:i]
        offset = i
    }
    // 创建参数节点并把参数节点挂在父节点下
    child := &node{
        nType:     param,
        maxParams: numParams,
    }
    n.children = []*node{child}
    n.wildChild = true
    n = child
    n.priority++
    numParams--
    // 如果后面还有路径,这里就需要把参数节点的路径完成,并创建新节点,然后继续遍历
    if end < max {
        n.path = path[offset:end]
        offset = end

        child := &node{
            maxParams: numParams,
            priority:  1,
        }
        n.children = []*node{child}
        n = child
    }

}

router.GET("/hel/:name/config", Index)

这个是给 if end < max 代码块写的示例,它的流程就是:

  • 要插入的路径是 /:name/config

  • 先匹配到 : 的位置和它后面第一个 / 的位置;

  • : 前面的 / 插入;

  • 发现 :name 后面还有路径(有 / 就是有路径,只有一个 / 也是路径),把参数节点补充完整并创建新节点;

  • 后面没有参数了,遍历到路径结尾结束循环,执行最后两行代码直接插入 /config

  • n.path = path[offset:]
    n.handle = handle
    

此时,完整的路由树是这样的:

- "/hel"                   indices: "l/"
    - "lo/"
        - ":name"
    - "/"
        - ":name"
            - "/config"

router.GET("/hel/:name/city/*config", Index)

这里要插入的路径是 ity/*config

else {
    // 任意捕获必须为路径的最后一个片段,它后面不能再有路径了
    if end != max || numParams > 1 {
        panic("catch-all routes are only allowed at the end of the path in path '" + fullPath + "'")
    }
    // 如果
    if len(n.path) > 0 && n.path[len(n.path)-1] == '/' {
        panic("catch-all conflicts with existing handle for the path segment root in path '" + fullPath + "'")
    }

    // 任意捕获节点前需要有 '/'
    i--
    if path[i] != '/' {
        panic("no / before catch-all in path '" + fullPath + "'")
    }
    // 插入'/'之前的路径
    n.path = path[offset:i]
    // 插入一个路径为空的节点,指示下面有任意捕获节点
    child := &node{
        wildChild: true,
        nType:     catchAll,
        maxParams: 1,
    }

    if n.maxParams < 1 {
        n.maxParams = 1
    }
    n.children = []*node{child}
    n.indices = string(path[i])
    n = child
    n.priority++
    // 这个节点是真正存储参数变量的节点
    child = &node{
        path:      path[i:],
        nType:     catchAll,
        maxParams: 1,
        handle:    handle,
        priority:  1,
    }
    n.children = []*node{child}

    return // 后面不可能有路径了,直接返回
}

这部分路径关系应该是这样的:

- "ity"                indices: "/"
    - ""
        - "/*config"

小结

insertChild 函数终于分析完了,路由构建的部分也即将迎来尾声,我们先把这一部分小结一下。

整体上来说,insertChild 代码写得非常干净,逻辑顺畅,所以理解起来也比较容易。整个函数只负责一件事,就是在同一条节点链上依次去插入每个节点。代码在编写时,考虑到节点参数的特殊性,用参数片段将路径切分,并顺序插入到路由树中。此外,函数对于各种可能发生的异常情况也都提供了完整清晰的报错信息。

incrementChildPrio

在进入路由服务之前,让我们把剩下的节点优先级排序讲完。

树的每个层上的子节点会按照优先级排序,节点的优先级仅仅是该节点下面的所有子节点(儿子、孙子等等)的 handle 总数量。这在以下两个方面有所帮助:

  • 优先匹配属于大多数路由路径的节点。这有助于使尽可能多的路线能够尽快到达。

  • 这是某种成本补偿。总是可以优先匹配最长的可到达路径(最高的成本)。

对于优先级的设置,有一部分掺杂在前面所讲的两个函数的代码中。还有一部分是在插入过程中,遇到有多个子节点时,在增加其中一个节点优先级后,要对它们的优先级进行重排序的函数。

我们来看看代码:

func (n *node) incrementChildPrio(pos int) int {
    n.children[pos].priority++ // 需要增加优先级的子节点
    prio := n.children[pos].priority

    // 调整子节点间的位置顺序(优先级高的排在前面)
    newPos := pos // 拷贝当前子节点的位置
    for newPos > 0 && n.children[newPos-1].priority < prio {
        // 如果当前节点的优先级比前一个节点高,则交换两者位置
        n.children[newPos-1], n.children[newPos] = n.children[newPos], n.children[newPos-1]

        newPos-- // 交换后,当前节点位置减一
    }

    // 更改索引顺序
    if newPos != pos {
        n.indices = n.indices[:newPos] + // 没有变化的部分,可能为空
            n.indices[pos:pos+1] + // 正在移动的子节点的索引
            n.indices[newPos:pos] + // 被往后挪的部分
            n.indices[pos+1:] // 剩余的部分
    }

    return newPos // 返回新的位置索引
}

总结

至此,所有路由树构建的部分就讲完了,撒花 ✿✿ヽ(°▽°)ノ✿ !下一篇中,我们将开启路由服务部分的旅途,分析 HttpRouter 是如何匹配请求路径并从中获取到参数信息的。

如果本篇文章对你有帮助的话,(或者觉得作者打这么多字挺辛苦的话),不要忘记点个赞哦,感谢支持~