Golang:HttpRouter 源码分析(一)

·  阅读 588

本文正在参加技术专题18期-聊聊Go语言框架

作者水平有限,如有错误,望批评指正!

前言

作者刚学Go不久,在了解了web框架Gin后,对它简介上所致谢的HttpRouter库产生了兴趣,正好拿来学习一下Go的代码风格和规范。虽然代码不多,但全分析完还是不小的工作量,于是将本题目分为多篇来写。

本文主要分析官方示例代码的执行调用过程。

简介

HttpRouter 是Go的一个轻量高性能的多路复用器 multiplexer,著名的Go web框架 Gin 的高性能正是得益于此。

HttpRouter 通过压缩Trie树(也称为基数树 radix tree)来实现高效的路径匹配。

HttpRouter 具有以下特性:

  • 仅提供精准匹配
  • 对结尾斜杠 / 自动重定向
  • 路径自动修正
  • 路由参数
  • 错误处理
  • RESTful APIs & OPTIONS 请求
  • ......

算法概览

此处仅为示意,感兴趣可自行查阅 wiki

学过算法的同学肯定对 Trie 树 (也叫字典树、前缀树)并不陌生,其本质上是一个确定有限状态自动机 DFA ,通过状态转移的方式实现高效率的查询( O(n) )。

2022-07-27-22-31-42-image.png

基数树是空间优化的Trie,当某个节点的子节点唯一时,将子节点与该节点合并。

2022-07-27-22-44-54-image.png

源码分析

版本 httprouter v1.3.0

本篇文章会省略示例代码未执行的源码部分,在之后的文章再进一步分析。

读者可以一边调试代码一边阅读以下内容。

类型定义

先让我们来看一看httprouter中都定义了那些类型

// router.go
// 用于存储路由参数
type Param struct {
    Key   string
    Value string
}
type Params []Param
// 相当于给net/http中的HandlerFunc加了路由参数
type Handle func(http.ResponseWriter, *http.Request, Params)
复制代码
// router.go
type Router struct {
    // 不同请求方法与其对应基数树的映射
    trees map[string]*node

    // 对结尾斜杠自动重定向
    // 例如: 请求 /foo/ ,但只存在 /foo ,则重定向到 /foo,
    // 并对GET请求返回301,对其他请求返回307
    RedirectTrailingSlash bool

    // 自动尝试修复路径并重定向
    // 首先,移除像 ../ 或 // 的多余元素;然后做一次大小写不敏感的查找
    // 例如 /FOO 和 /..//Foo 可能被重定向到 /foo
    RedirectFixedPath bool

    // 检查请求方法是否被禁止
    // 当请求路径无法匹配时,检查当前路径是否有其他允许的请求方式,
    // 如果有返回405,否则返回404
    HandleMethodNotAllowed bool

    // 路由器自动回复OPTIONS请求
    // 自定义的 OPTIONS handlers 优先级更高
    HandleOPTIONS bool

    // 自动回复OPTIONS请求时调用的handler
    GlobalOPTIONS http.Handler

    // 缓存全局允许的请求方法
    globalAllowed string

    // 路径无法找到时调用的handler
    // 默认为 http.NotFound
    NotFound http.Handler

    // 请求方法被禁止时调用的handler
    // 默认为带 http.StatusMethodNotAllowed 的 http.Error
    MethodNotAllowed http.Handler

    // 服务器内部出现错误时调用的handler
    // 应该生成一个error页面,并返回500
    // 该handler使你的服务免于因为未发现的错误而崩溃
    PanicHandler func(http.ResponseWriter, *http.Request, interface{})
}
复制代码
// tree.go
type nodeType uint8

const (
    static nodeType = iota // default
    root
    param
    catchAll
)
// 树的节点
type node struct {
    path      string    // 包含的路径片段
    wildChild bool      // 子节点是否为参数节点
    nType     nodeType  // 节点类型:静态(默认)、根、命名参数捕获、任意参数捕获
    maxParams uint8     // 最大参数个数
    priority  uint32    // 优先级
    indices   string    // 索引
    children  []*node   // 子节点
    handle    Handle    // 该节点所代表路径的handle
}
复制代码

示例分析

下面,我们从官方提供的简单示例入手,逐步分析其执行过程以及代码含义。

package main

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

    "github.com/julienschmidt/httprouter"
)

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

func Hello(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
    fmt.Fprintf(w, "hello, %s!\n", ps.ByName("name"))
}

func main() {
    router := httprouter.New()
    router.GET("/", Index)
    router.GET("/hello/:name", Hello)

    log.Fatal(http.ListenAndServe(":8080", router))
}
复制代码

新建路由

首先新建一个默认路由,(如需自定义所需功能,可仿照下面的格式自行创建路由)。

// router.go
func New() *Router {
    return &Router{
        RedirectTrailingSlash:  true,
        RedirectFixedPath:      true,
        HandleMethodNotAllowed: true,
        HandleOPTIONS:          true,
    }
}
复制代码

注册第一个请求

然后,注册第一个请求 router.GET("/", Index)

// router.go
func (r *Router) GET(path string, handle Handle) {
    r.Handle(http.MethodGet, path, handle)
}
复制代码

GET 以及其他方法都是对 Handle 函数的调用。

// router.go
func (r *Router) Handle(method, path string, handle Handle) {
    // 检查路径是否合法(存在且以/开头)
    if len(path) < 1 || path[0] != '/' {
        panic("path must begin with '/' in path '" + path + "'")
    }
    // 创建方法到树的映射
    if r.trees == nil {
        r.trees = make(map[string]*node)
    }

    root := r.trees[method] // 注意root是一个指针,对root操作就是对r.trees[method]操作
    if root == nil {
        root = new(node)
        r.trees[method] = root

        r.globalAllowed = r.allowed("*", "") // 全局添加路由允许的请求方法
    }

    root.addRoute(path, handle) // 向树中插入路径和handle
}
复制代码

Handle 接收了一种新的请求方法时,创建该方法的根节点并将其添加进全局 globalAllowed 缓存中。

// router.go
func (r *Router) allowed(path, reqMethod string) (allow string) {
    allowed := make([]string, 0, 9)

    if path == "*" {
        if reqMethod == "" { // 空方法表示刷新缓存
            for method := range r.trees {
                if method == http.MethodOptions {
                    continue
                }
                allowed = append(allowed, method)
            }
        } else {
            return r.globalAllowed
        }
    } else { 
        // ...
    }

    if len(allowed) > 0 {
        allowed = append(allowed, http.MethodOptions)
        // 对允许的方法按字典序排序,统一形式
        for i, l := 1, len(allowed); i < l; i++ {
            for j := i; j > 0 && allowed[j] < allowed[j-1]; j-- {
                allowed[j], allowed[j-1] = allowed[j-1], allowed[j]
            }
        }
        // 转换为字符串,以逗号+空格分隔每种方法
        return strings.Join(allowed, ", ")
    }
    return
}
复制代码

allowed 函数不仅可以刷新全局缓存也可以查询带 Handle 的方法,是实现 Router.HandleMethodNotAllowed 机制的一部分(不在本文中详细阐述)

// tree.go
func (n *node) addRoute(path string, handle Handle) {
    fullPath := path
    n.priority++
    numParams := countParams(path) // 通过数':'和'*'的个数得出参数个数,最大为255 

    // 非空树
    if len(n.path) > 0 || len(n.children) > 0 {
        // ...
    } else { // 空树直接插入子节点
        n.insertChild(numParams, path, fullPath, handle)
        n.nType = root
    }
}
复制代码
// tree.go
func (n *node) insertChild(numParams uint8, path, fullPath string, handle Handle) {
    var offset int // 已经处理完的path位置

    // 遍历路径并搜索参数捕获片段
    for i, max := 0, len(path); numParams > 0; i++ {
        // ...
    }

    // 插入路径的剩余部分以及handle
    n.path = path[offset:]
    n.handle = handle
}
复制代码

此时的 router

Snipaste_2022-07-28_20-23-44.png

注册第二个请求

然后,注册第二个请求 router.GET("/hello/:name", Hello)

我们直接看 root.addRoute(path, handle)

// router.go
func (n *node) addRoute(path string, handle Handle) {
    fullPath := path
    n.priority++
    numParams := countParams(path)

    // 非空树
    if len(n.path) > 0 || len(n.children) > 0 {
    walk:
        for {
            // 更新最大参数个数
            if numParams > n.maxParams {
                n.maxParams = numParams
            }

            // 查找最长公共前缀
            // 最长公共前缀中不会含有 ':' 或 '*'
            i := 0
            max := min(len(path), len(n.path))
            for i < max && path[i] == n.path[i] {
                i++
            }

            // 新公共前缀比原公共前缀短,需要将当前的节点按公共前缀分成父子节点
            // 如: /hello & /hel/user 将 /hello 分成 /hel -> lo 的形式
            if i < len(n.path) {
                // ...
            }

            // 创建此时节点 n 的新的子节点
            // 如: /hel & /hello
            if i < len(path) {
                path = path[i:] // 刨去公共前缀后的部分

                // 如果子节点是参数节点,检查是否发生冲突
                if n.wildChild {
                    // ...
                }

                c := path[0] // 子路径的第一个字符

                // 如果是参数节点后的斜杠
                if n.nType == param && c == '/' && len(n.children) == 1 {
                    // ...
                }

                // 如果和子节点有公共前缀
                for i := 0; i < len(n.indices); i++ {
                    // ...
                }

                // 否则直接插入
                if c != ':' && c != '*' {
                    // 将子节点路径的第一个字符添加为索引
                    n.indices += string([]byte{c})
                    child := &node{
                        maxParams: numParams,
                    }
                    n.children = append(n.children, child) // 添加子节点
                    n.incrementChildPrio(len(n.indices) - 1) // 更新子节点优先级
                    n = child
                }
                n.insertChild(numParams, path, fullPath, handle) // 向子节点插入路径
                return

            } else if i == len(path) { // 插入的路径刚好是到此节点所表示的路径
                // 如: /hello/user & /hello/city & /hello/
                // ...
            }
            return
        }
    } else { // 空树
        // ...
    }
}
复制代码
Snipaste_2022-07-28_20-42-26.png

addRoute 负责查找最长公共前缀,或者说是新路径后缀的插入位置,找到位置后由 insertChild 来插入。

// tree.go
func (n *node) insertChild(numParams uint8, path, fullPath string, handle Handle) {
    var offset int // 已经处理完的path位置

    // 遍历路径
    for i, max := 0, len(path); numParams > 0; i++ {
        // 查找通配符的起始
        c := path[i]
        if c != ':' && c != '*' {
            continue
        }

        // 查找通配符的结尾('/'或路径结尾)
        end := i + 1
        for end < max && path[end] != '/' {
            switch path[end] {
            // 每个片段只能有一个':'或'*'
            case ':', '*':
                panic("only one wildcard per path segment is allowed, has: '" +
                    path[i:] + "' in path '" + fullPath + "'")
            default:
                end++
            }
        }

        // 参数节点不能与其他节点共存于路径的同一位置中,会发生冲突
        // 例如:/hello/:name 和 /hello/user
        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 == ':' { // 命名参数捕获节点
            // 分割path从还未处理的部分的开始到通配符之前
            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
            }

        } else { // 任意参数捕获节点
            // ...
    }

    // 插入路径的剩余部分以及handle
    n.path = path[offset:]
    n.handle = handle
}
复制代码

由于参数节点比较特殊,它是其父节点的唯一子节点且不会和父节点合并,所以 insertChild 函数会先查找路径中的参数捕获片段,来分割路径的节点,即最终节点关系为:调用函数的节点 -> 参数节点前的路径节点 -> 参数节点 -> 参数节点后的路径节点。

最终 router 如下:

Snipaste_2022-07-28_21-47-24.png

启动服务

最后,启动服务 http.ListenAndServe(":8080", router)

可以看出 httprouter.Router 本质上就是重写的 http.Handler

router.go 中有这样一个语句 var _ http.Handler = New() ,用来确保 Router 符合 http.Handler 接口。

总结

本文通过对示例代码调用过程的逐步分析,阐释了httprouter库的部分核心内容以及路由构建逻辑。通过对源码的深入剖析,我们可以了解库的构建规则并实现一些自定义内容,这些将在总结篇中举例介绍。

参考资料

Trie : en.wikipedia.org/wiki/Trie

Radix tree : en.wikipedia.org/wiki/Radix_…

net/http : pkg.go.dev/net/http@go…

httprouter : pkg.go.dev/github.com/…

分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改