Golang:HttpRouter 源码分析(五)

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

前言

前情提要:

终于把专栏搞好啦,以后可以直接在专栏里看本系列的往期文章~

Golang:HttpRouter 源码分析

截止到上一篇文章,我们已经讲完了 HttpRouter 路由的基本核心功能,拥有了这些功能后,我们的路由就已经可以正常进行服务了。本篇文章,我们将继续分析 HttpRouter 的一些特色功能,正是这些附加功能使 HttpRouter 成为一款优秀、友好的路由组件。

findCaseInsensitivePath

首先,来看一下路由的另外一种查找方式——大小写非敏感的查找。 findCaseInsensitivePath 方法会对传入的路径进行大小写非敏感的查找并试图找到一个 handler。它会返回大小写正确的路径以及查找是否成功的标记。

源码

讲解

先来看一下外层函数:

func (n *node) findCaseInsensitivePath(path string, fixTrailingSlash bool) (ciPath []byte, found bool) {
    return n.findCaseInsensitivePathRec(
        path,
        make([]byte, 0, len(path)+1), // 提前为新路径分配足够的空间
        [4]byte{},                    // 空的 rune 缓存
        fixTrailingSlash,
    )
}
复制代码

fixTrailingSlash 是一个可选配置,由路由中是否启用了 RedirectTrailingSlash 功能决定,它和 RedirectFixedPath 功能是独立的。

func shiftNRuneBytes(rb [4]byte, n int) [4]byte 这个函数和位运算比较类似,是以一个字节为单位向左移 n 位,空出来的值为 0 ,因为 utf-8 字符在 string 中按字节存储,它的长度从 1 字节到 4 字节不等,为了处理字符,我们需要这样一个对 4 字节的缓存的操作方法。

提示:文章中提到的字符都是指 utf-8 字符。

下面来看 findCaseInsensitivePathRec 函数,这里的Rec 是递归(Recursive)的意思,整个函数是一个大循环里面套递归,这样设计的原因会在分析到递归部分时说明。

概览

    npLen := len(n.path)
walk:
    for len(path) >= npLen && (npLen == 0 || strings.EqualFold(path[1:npLen], n.path[1:])) {
        oldPath := path // 另存一下 path
        path = path[npLen:] // 去掉已匹配的路径
        ciPath = append(ciPath, n.path...) // 最终要返回的修正后的路径
        if len(path) > 0 {
            if !n.wildChild {
            
            }
            n = n.children[0]
            switch n.nType {
            case param:
            case catchAll:
            default:
            }
        } else {

        }
    }
    if fixTrailingSlash {
        // 如果路由中启用了该功能
    }
    return ciPath, false
复制代码

函数整体上逻辑还是比较明了的,和 getValue 并没有差很多。首先是一个大循环来遍历路由树,里面又分为是否匹配到最后一个节点,没有匹配到最后一个节点时又按照子节点是否为参数节点来划分,参数节点当然也要分两类来讨论。

虽然整体逻辑并不复杂,但由于这里面涉及到字节上的操作,使得代码在细节上读起来可能会遇到一些困惑之处,下面让我们来具体分析一下。

子节点非参数

先补充一下循环进行的条件(代码在上面)是:

  • 当前未匹配路径长度不短于节点路径长度;

  • 并且 &&

    • npLen==0 条件表示的是 catchAll 参数节点的第一个空节点;

    • 或者节点路径是路径的前缀,即匹配(从 1 开始是因为在上一次匹配时索引部分已经匹配过了)

strings.EqualFold 对两个字符串进行大小写非敏感的匹配。

该节点路径匹配上后,把它添加到返回的修正路径里;当后面还有路径时,继续匹配。先看子节点非参数节点的部分,在执行了 rb = shiftNRuneBytes(rb, npLen) ,把已经匹配过的字符跳过(从缓存中清除)后,它实际上又分为两种情况:缓存中还有字节未处理,以及缓存中字节已处理完成(缓存为空)。为了便于理解,我们先讲第二种情况。

var rv rune
// 找到字符的开始,rune 最多只占4字节
var off int
for max := min(npLen, 3); off < max; off++ {
    if i := npLen - off; utf8.RuneStart(oldPath[i]) {
        rv, _ = utf8.DecodeRuneInString(oldPath[i:])
        break
    }
}
复制代码

这里有两个函数:utf8.RuneStart 用来判断传入字节是否为某个字符的开始, utf8.DecodeRuneInString 用来从字符串中解码出第一个字符。

重点: 这里还要再多补充几句,因为之前的文章中没有提到 utf-8 字符,可能大家会默认字符都是一个字节,但实际上路径中是可以出现如中文之类的字符的,所以路由树在构建过程中划分时是按照字节进行划分,而不是按照字符进行划分,也就是说在一个节点中可能存储的不是完整的字符路径。例如,一个中文字符占 3 个字节,它可能与另一个字的前两个字节是公共前缀,所以节点路径只有前两个字节,所以这里在寻找开始字符时可能需要往回减至多 3 个字节。

下面先将字符转为小写,尝试匹配:

lo := unicode.ToLower(rv)
utf8.EncodeRune(rb[:], lo) // 字符转字节
rb = shiftNRuneBytes(rb, off) // 跳过已处理的字节
for i := 0; i < len(n.indices); i++ {
    if n.indices[i] == rb[0] {   //匹配索引
        if out, found := n.children[i].findCaseInsensitivePathRec(
            path, ciPath, rb, fixTrailingSlash,
        ); found {
            return out, true
        }
        break
    }
}
复制代码

if 语句的写法要看清楚,只是中间换了行,不是什么特殊写法哈。

这里套了一个递归,因为每次都有大小写两种可能,一种可能不行,还需要退回来尝试另一种可能,只用循环是无法实现的。

if up := unicode.ToUpper(rv); up != lo {
    utf8.EncodeRune(rb[:], up)
    rb = shiftNRuneBytes(rb, off)
    for i, c := 0, rb[0]; i < len(n.indices); i++ {
        if n.indices[i] == c {
                n = n.children[i]
                npLen = len(n.path)
                continue walk
        }
    }
}
复制代码

小写匹配不成功,就转为大写匹配(前提是大小写字符不同),这时如果匹配成功,就不需要递归了,因为后面没有其他可能了,直接继续循环即可。

return ciPath, (fixTrailingSlash && path == "/" && n.handle != nil)

最后,如果匹配失败,检查一下是不是就多了一个尾斜杠,并且启用了修复尾斜杠的功能,以此判断是否进行重定向。

现在,我们回到第一种情况,应该就比较好理解了。当缓存中还有字节未处理完时,相当于一个字符中的内容被划分成了多个节点,当完整的字符匹配时,我们走了该字符中的一部分字节,还要继续往下面走。

if rb[0] != 0 {
    for i := 0; i < len(n.indices); i++ {
        if n.indices[i] == rb[0] {
            n = n.children[i]
            npLen = len(n.path)
            continue walk
        }
    }
}
复制代码

子节点为参数

先看命名捕获参数,这一部分逻辑和 getValue 中的几乎是一样的,只是删除了存储参数的代码,并增加了记录路径的代码(因为这部分路径是参数,所以不需要大小写修正),就不贴代码了,可以到前面的源码里自己看一下。

任意捕获参数也很简单,就只有一句: return append(ciPath, path...), true ,因为后面的路径都是参数,直接添加进去就行。

匹配到最后一个可能节点

if n.handle != nil {
        return ciPath, true
}
if fixTrailingSlash {
    for i := 0; i < len(n.indices); i++ {
        if n.indices[i] == '/' {  // 索引中有 '/'
            n = n.children[i] // 走到子节点
            if (len(n.path) == 1 && n.handle != nil) ||
                (n.nType == catchAll && n.children[0].handle != nil) {
                return append(ciPath, '/'), true  // 子节点路径为'/'或类型为任意捕获参数且有handle
            }
            return ciPath, false
        }
    }
}
return ciPath, false
复制代码

当路径匹配到最后一个可能的节点时,有 handle 就直接返回,无 handle 看一下是否可以进行 TSR 建议。

什么都匹配不到

if fixTrailingSlash {
    if path == "/" {
        return ciPath, true
    }
    if len(path)+1 == npLen && n.path[len(path)] == '/' &&
        strings.EqualFold(path[1:], n.path[1:len(path)]) && n.handle != nil {
        return append(ciPath, n.path...), true
    }
}
return ciPath, false
复制代码

最后,如果前面都匹配不到的话,看看添加或删除尾斜杠能否匹配上。

总结

本篇文章的重点实际上是理解路由树中存储的字符串可能不是语义完整的原字符串,节点与节点之间是按照字节来划分的,这一内容可能需要大家多多思考一下,也算是给之前的文章填了一个大坑。

下一篇文章,我们会将 HttpRouter 的剩余内容全部分析完,敬请期待~

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

猜你喜欢

转载自juejin.im/post/7128050589295968287
今日推荐