本文正在参加技术专题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)
)。
而基数树是空间优化的Trie,当某个节点的子节点唯一时,将子节点与该节点合并。
源码分析
版本 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
:
注册第二个请求
然后,注册第二个请求 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 { // 空树
// ...
}
}
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
如下:
启动服务
最后,启动服务 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/…