如果接触过go的多种 web 框架,就会发现每一个框架都提供了三种模块的抽象:
- Server: 代表服务器的抽象
- Context:代表请求上下文的抽象
- 路由树抽象(每个框架命名不同,最直观的为Route)
下面开始尝试自定义模拟的 web 框架来了解这三个核心模块的功能
一、 Server
从各个框架的对比不难看出,对于一个 web 框架,首先就是要有一个整体代表服务器的抽象 Server (可能各个框架命名不同,但都会有一个功能相同的模块抽象),从功能特性的角度出发,Server 至少要提供三部分功能:
- 生命周期控制:即启动、关闭。如果在后期,还要考虑增加生命周期回调特性
- 路由注册接口:提供路由注册功能
- 作为 http 包到 Web 框架的桥梁
而 Server 的核心也包括以下接口的实现;
1.1 http.Handler 接口
http 包暴露了一个接口 Handler。它是引入自定义 Web 框架相关的连接点。
1.2 Server 接口的自定义
V1: 在 Server 的定义时组合 http.Handler。
type Server interface {
http.Handler
}
func TestServer(t *testing.T) {
var s Server
http.ListenAndServe(":8080", s)
http.ListenAndServeTLS(":4000",
"cret file", "key file", s)
}
优点:
- 用户在使用的时候可以直接要调用 http.ListenAndServe
- 无缝衔接 HTTPS 协议
- 极简设计
缺点:
• 难以控制生命周期,并且在控制生命周期的时候增加回调支持,因为启动过程要分成两步,第一,监听端口,第二执行回调。ListenAndServe 没办法在两个步骤中间插入第其他步骤
• 缺乏控制力:如果将来希望支持优雅退出的功能,将难以支持
V2:为了改进 V1 的缺点,给 Server 接口增加 Start 方法
type Server interface {
http.Handler
// Start 启动服务器
// addr 是监听地址。如果只指定端口,可以使用 ":8081"
// 或者 "localhost:8082"
Start(addr string) error
}
func TestServer(t *testing.T) {
var s Server
http.ListenAndServe(":8080", s)
http.ListenAndServeTLS(":4000",
"cret file", "key file", s)
s.Start(":8081")
}
优点:
• Server 既可以当成普通的 http.Handler 来使用,又可以作为一个独立的实体,拥有自己的管理生命周期的能力
• 完全的控制,可以为所欲为
缺点:
• 如果用户不希望使用 ListenAndServeTLS,那么 Server 需要提供 HTTPS 的支持
V1 和 V2 都直接耦合了 Go 自带的 http 包,如果用户希望切换为 fasthttp 或者类似的 http 包,则会非常困难。
注意:Start 方法可以不需要 addr 参数,那么在创建实现类的时候传入地址就可以。
1.3 HTTPServer 定义与实现
func (s *HTTPServer) ServeHTTP(writer http.ResponseWriter, req *http.Request) {
ctx := &Context{
Request: req,
Response: writer,
}
s.serve(ctx)
}
func (s *HTTPServer) Start(addr string) error {
// linstener, err := net.Listen("tcp", addr)
// if err != nil {
// return err
// }
// return http.Serve(linstener, s)
return http.ListenAndServe(addr, s)
}
type HTTPServer struct {
}
var _ Server = &HTTPServer{}
该实现这里直接使用 http.ListenAndServe 来启动,后续可以根据需要替换为:
- 使用 http.Serve 来启动,换取更大的灵活性,如将端口监听和服务器启动分离等
- 可以看到 http.Serve 方法内部是 创建 http.Server 来启动的
ServeHTTP 则是我们整个 Web 框架的核心入口。这里将在整个方法内部完成:
- Context 构建
- 路由匹配
- 执行业务逻辑
1.4 路由注册 API 设计
可以考虑先站在用户的角度,考虑如何注册路由;首先可以先看看各个框架提供的注册路由的接口和模块。
Gin
Iris
Echo
总的来说有两类方法:
- 针对任意方法的:如 Gin 和 Iris 的 Handle 方法、Echo 的 Add 方法
- 针对不同 HTTP 方法的:如 Get、POST、Delete,这一类方法基本上都是委托给前一类方法
综合各框架的设计,可以看出核心方法只需要有一个,例如 Handle。其它的方法都建立在这上面。
1.4.1 AddRoute 方法
type HandleFunc func(ctx *Context)
type Server interface {
http.Handler
// Start 启动服务器
// addr 是监听地址。如果只指定端口,可以使用 ":8081"
// 或者 "localhost:8082"
Start(addr string) error
// addRoute 注册一个路由
// method 是 HTTP 方法
// path 是路径,必须以 / 为开头
AddRoute(method string, path string, handler HandleFunc)
// 暂时并不采取这种设计方案
// addRoute(method string, path string, handlers... HandleFunc)
}
- AddRoute 方法只接收一个 HandleFunc。因为我希望它只注册业务逻辑。即便真有多个的场景,用户可以自己组合成一个。
- 如果允许注册多个,那么在实现的时候就要考虑,其中一个失败了,是否还允许继续执行下去;反过来,如果其中一个 HandleFunc 要中断执行,怎么中断。
- 这里采用了新的名字 AddRoute。
- Handle:看上去像是处理什么东西,而实质上这里只是注册路由,所以用 AddRoute 会更加合适
这里 为什么不考虑 支持多个 HandleFunc 的设计呢?目前Gin、Beego、Iris、Echo框架都是允许注册多个,但其实用起来体验不会很好。
• Gin 和 Iris 最后一个是不定长参数,那么完全可以一个都不传,如 PUT(“path”)。这个在编译期无法发现
• Echo 则是存在我希望 h 传入 nil 的可能。实际上 Echo 是将中间件注册逻辑和路由注册逻辑合并在了一起
针对不同 HTTP 方法的注册 API,都可以委托给 Handle 方法。这种设计思路很常用,AddRoute 最终会和路由树交互
func (s *HTTPServer) Post(path string, handleFunc HandleFunc) {
s.AddRoute(http.MethodPost, path, handleFunc)
}
func (s *HTTPServer) Get(path string, handleFunc HandleFunc) {
s.AddRoute(http.MethodGet, path, handleFunc)
}
func (s *HTTPServer) AddRoute(method, path string, handleFunc HandleFunc) {
panic("implement me")
}
二、路由树
通常一颗路由树要支持以下匹配规则:
- 全静态匹配
- 支持通配符匹配
- 支持参数路由
- 正则匹配
在这之前,需要再一次稍微考察一下各个框架的路由树是怎么实现的
2.1 Gin
2.1.1 Beego 核心实现
Gin 的核心结构体非常直观:
这是 Gin 中常用的声明:
func TestGinSession(t *testing.T) {
r := gin.Default()
store := cookie.NewStore([]byte("secret"))
r.Use(sessions.Sessions("mysession", store))
r.GET("/hello", getSession)
_ = r.Run(":8000")
}
- methodTrees:也就是路由树是按照 HTTP 方法组织的,例如 GET 会有一棵路由树(每一个方法都对应着一颗路由树)
- methodTree:定义了单棵树。树在 Gin 里面采用的是 children 的定义方式,即树由节点构成(注意对比 Beego)
- node:代表树上的一个节点,里面维持住了children,即子节点。同时有 nodeType 和 wildChild 来标记一些特殊节点
2.1.2 Beego 路由树设计抽象
路由的原理是大量使用公共前缀的树结构,它基本上是一个紧凑的 Trie tree(或者只是 Radix Tree)。具有公共前缀的节点也共享一个公共父节点
什么是前缀树
前缀树其实就是 Tire tree,是哈希树的变种,通常大家都叫它单词查找树。前缀树多应用于统计,排序和保存大量字符串。因为前缀树能够利用字符串的公共前缀减少查询时间,最大限度地减少不必要的字符串比较。所以前缀树也经常被搜索引擎系统用于文本词频统计。前缀树拥有以下特点:
- 根节点不包含字符,其他节点都包含字符
- 每一层的节点内容不同
- 从根节点到某一个节点,路径上经过的字符连接起来,为该节点对应的字符串
- 每个节点的子节点通常有一个标志位,用来标识单词的结束
Gin 中的前缀树(紧凑前缀树)
Gin 中的前缀树相比普通的前缀树减少了查询的层级,用户想查找的“contact”其中 co 做为共有的部分
通过上面的内容可以看出,Gin 中前缀树整条查询的地址只需通过路由树中每个节点的拼接即可获得。那么 Gin 是如何完成在这些节点的增加的呢,每个节点中又存放了什么内容?这个问题我们可以通过 Gin 的源码得到答案。
具体源码解析如下
type RouterGroup struct {
Handlers HandlersChain
basePath string
engine *Engine
root bool
}
// trees 路由树这一部分由一个带有method 和root字段的node列表维护
// 每个node代表了路由树中的每一个节点
type Engine struct {
RouterGroup
.......
trees methodTrees
......
}
type methodTrees []methodTree
type methodTree struct {
method string
root *node
}
// node所具有的字段内容如下
type node struct {
path string // 当前节点的绝对路径
indices string // 缓存下一节点的第一个字符 在遇到子节点为通配符类型的情况下,indices=''
// 默认是 false,当 children 是 通配符类型时,wildChild 为 true 即 indices=''
wildChild bool // 默认是 false,当 children 是 通配符类型时,wildChild 为 true
// 节点的类型,因为在通配符的场景下在查询的时候需要特殊处理,
// 默认是static类型
// 根节点为 root类型
// 对于 path 包含冒号通配符的情况,nType 是 param 类型
// 对于包含 * 通配符的情况,nType 类型是 catchAll 类型
nType nodeType
// 用于记录有几条路由会经过此节点,用于在节点判断路由的优先级
priority uint32
// 子节点列表
children []*node // child nodes, at most 1 :param style node at the end of the array
handlers HandlersChain
// 是从 root 节点到当前节点的全部 path 部分;如果此节点为终结节点 handlers 为对应的处理链,否则为 nil;
// maxParams 是当前节点到各个叶子节点的包含的通配符的最大数量
fullPath string
}
// 具体节点类型如下
const (
static nodeType = iota // default, 静态节点,普通匹配(/user)
root // 根节点 (/)
param // 参数节点(/user/:id)
catchAll // 通用匹配,匹配任意参数(*user)
)
注册路由的核心方法handle:
// handle函数中会将绝对路径转换为相对路径
// 并将 请求方法、相对路径、处理方法 传给addRoute
func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
absolutePath := group.calculateAbsolutePath(relativePath)
handlers = group.combineHandlers(handlers)
group.engine.addRoute(httpMethod, absolutePath, handlers)
return group.returnObj()
}
// 路由的添加主要在addRoute这个函数中完成
func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
// 校验
// 路径必须以 / 开头
// 请求方法不允许为空
// 处理方法不允许为空
assert1(path[0] == '/', "path must begin with '/'")
assert1(method != "", "HTTP method can not be empty")
assert1(len(handlers) > 0, "there must be at least one handler")
// 如果开启了gin的debug模式,则对应处理
debugPrintRoute(method, path, handlers)
// 根据请求方式获取对应的树的根
// 每一个请求方法都有自己对应的一颗紧凑前缀树,这里通过请求方法拿到最顶部的根
root := engine.trees.get(method)
// 如果根为空,则表示这是第一个路由,则自己创建一个以 / 为path的根节点
if root == nil {
// 如果没有就创建
root = new(node)
root.fullPath = "/"
engine.trees = append(engine.trees, methodTree{method: method, root: root})
}
// 此处的path是子路由
// 以上内容是做了一层预校验,避免书写不规范导致的请求查询不到
// 接下来是添加路由的正文
root.addRoute(path, handlers)
}
node addRoute 的方法如下
// addRoute adds a node with the given handle to the path.
// Not concurrency-safe! 并发不安全
func (n *node) addRoute(path string, handlers HandlersChain) {
fullPath := path
// 添加完成后,经过此节点的路由条数将会+1
n.priority++
// Empty tree
// 如果为空树, 即只有一个根节点"/" 则插入一个子节点, 并将当前节点设置为root类型的节点
if len(n.path) == 0 && len(n.children) == 0 {
n.insertChild(path, fullPath, handlers)
n.nType = root
return
}
parentFullPathIndex := 0
walk:
for {
// Find the longest common prefix.
// This also implies that the common prefix contains no ':' or '*'
// since the existing key can't contain those chars.
// 找到最长的共有前缀的长度 即到i位置 path[i] == n.path[i]
i := longestCommonPrefix(path, n.path)
// Split edge
// 假设当前节点存在的前缀信息为 hello
// 现有前缀信息为heo的结点进入, 则当前节点需要被拆分
// 拆分成为 he节点 以及 (llo 和 o 两个子节点)
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}
// []byte for proper unicode char conversion, see #65
n.indices = bytesconv.BytesToString([]byte{n.path[i]})
n.path = path[:i]
n.handlers = nil
n.wildChild = false
n.fullPath = fullPath[:parentFullPathIndex+i]
}
// Make new node a child of this node
// 将新来的节点插入新的parent节点作为子节点
if i < len(path) {
path = path[i:]
c := path[0]
// '/' after param
// 如果是参数节点 形如/:i
if n.nType == param && c == '/' && len(n.children) == 1 {
parentFullPathIndex += len(n.path)
n = n.children[0]
n.priority++
continue walk
}
// Check if a child with the next path byte exists
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
}
}
// Otherwise insert it
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 {
// inserting a wildcard node, need to check if it conflicts with the existing wildcard
n = n.children[len(n.children)-1]
n.priority++
// Check if the wildcard matches
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
}
// Wildcard conflict
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 +
"'")
}
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
}
}
Priority 优先级
为了能快速找到并组合完整的路由,Gin 在添加路由的同时,会在每个节点中添加 Priority 这个属性。在查找时根据 Priority 进行排序,常用节点(通过次数理论最多的节点) 在最前,并且同一层级里面 Priority 值越大,越优先进行匹配。
为什么要将 9 种请求方法放在 slice 而不是 map 中呢?
这是因为 9 个请求方法对应 9 棵路由树,而 Gin 对应的所有请求方法都维护了一颗路由树,同时这些关键信息都被包裹在 Node 结构体内,并被放置在一个数组当中而非 map 中。这样是为了固定请求数量,同时在项目启动后请求方法会被维护在内存当中,采用固定长度的 slice 从而在保证一定查询效率的同时减少内存占用。
查找路由
路由树构建完毕之后,服务开始正常接收请求。第一步是从 ServeHTTP 开始解析路由地址,而查找的过程处理逻辑如下:
- 申请一块内存用来填充响应体
- 处理请求信息
- 从 trees 中遍历比较请求方法,拿到最对应请求方法的路由树
- 获取根节点
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)
}
func (engine *Engine) handleHTTPRequest(c *Context) {
// ...
t := engine.trees
for i, tl := 0, len(t); i < tl; i++ {
// 根据请求方法进行判断
if t[i].method != httpMethod {
continue
}
root := t[i].root
// 在该方法树上查找路由
value := root.getValue(rPath, c.params, unescape)
if value.params != nil {
c.Params = *value.params
}
// 执行处理函数
if value.handlers != nil {
c.handlers = value.handlers
c.fullPath = value.fullPath
c.Next() // 涉及到gin的中间件机制
// 到这里时,请求已经处理完毕,返回的结果也存储在对应的结构体中了
c.writermem.WriteHeaderNow()
return
}
// ...
break
}
if engine.HandleMethodNotAllowed {
for _, tree := range engine.trees {
if tree.method == httpMethod {
continue
}
if value := tree.root.getValue(rPath, nil, c.skippedNodes, unescape); value.handlers != nil {
c.handlers = engine.allNoMethod
serveError(c, http.StatusMethodNotAllowed, default405Body)
return
}
}
}
}
Gin 支持的路由很多。首先 Gin 支持了标准的 HTTP 方法,而路由形态它支持:
- 静态匹配
- 路径参数,其语法形式也是 :key
- 通配符:Gin 的通配符是允许指定部分路径的,例如 /*action,那么/abcaction 可以命中
支持用户从 Gin 的路由中将路径参数取出来
2.2 Beego
2.2.1 Beego 核心实现
Beego 的核心结构体是三个:
- ControllerRegister:用来存放所有的路由树,在路由匹配功能中扮演着容器的角色
- 路由树是按照 HTTP method 来组织的,例如 GET 方法也会对应有一棵路由树(每一个方法都对应着一颗路由树)
- Tree:它代表的就是路由树,在 Beego 里面,一棵路由树被看做是由子树组成的
- leafInfo:代表叶子节点
2.2.2 Beego 路由树设计抽象
Beego 的树定义并没有采用 children 式的定义,而是采用递归式的定义,即一棵树是由根节点 + 子树构成。即树形结构+递归算法实现路由的注册与匹配:
以下是对应的简化版源代码:
func (t *Tree) addseg(segments []string, route interface{}, wildcards []string, reg string) {
if len(segments) == 0 {
// 路径为空,添加叶子节点
t.leaves = append(t.leaves, &leafInfo{runObject: route, wildcards: filterCards, regexps: regexp.MustCompile("^" + reg + "$")})
} else {
// 解析路径
seg := segments[0] // 取路径前面一段
iswild, params, regexpStr := splitSegment(seg) // 解析该段是否为正则段,并提取参数名
// 为正则节点
if iswild {
if t.wildcard == nil {
t.wildcard = NewTree()
}
........
........
t.wildcard.addseg(segments[1:], route, append(wildcards, params...), reg+regexpStr)
} else {
// 非正则节点
subTree, ok := t.fixrouters[seg]
if !ok {
subTree = NewTree()
t.fixrouters[seg] = subTree
}
subTree.addseg(segments[1:], route, wildcards, reg)
}
}
}
Beego 和 Gin 比起来,还额外支持了正则路由匹配,它的语法形式多种多样:
- /user/:id([0-9]+)
- :id:int 这种可以看做是正则表达式的语法糖,Beego 内置了 int 的正则表达式,类似地还内置了 string 的表达式
Beego 路由还提供了额外的大小写是否敏感的控制选项。除了这种语法形式以外,Beego 还提供了很多语法糖。例如自动路由:
beego.AutoRouter(&controllers.UserController{})
这种其实就是 beego 内部自己解析 UserController 的信息,将公开方法按照一定的规律转化为对应的路由,目前来说就是按照方法名字来匹配。
另外还有一种注解路由:
// @router /user/list/:id([0-9]+) [get]
func (u *UserController) List() {
u.Ctx.WriteString("UserController@List func\n")
id := u.Ctx.Input.Param(":id")
u.Ctx.WriteString(id)
}
相比之下,Beego Web 的路由功能是最强的。但是 Beego Web 的各种路由以及语法糖的代码混合在一起,很难搞清楚。
2.3 Echo
2.3.1 Echo 核心实现
Echo 的实现稍微有点复杂,并且概念之间不是很清晰:在 Echo 结构体里面维护了routers,但是 routers 并不是按照 HTTP 方法组织的,而是按照所谓的 Host 来组织的,Host 可以看做一个命名空间
- Router:代表路由注册中心,里面维护了路由树
- Route:代表具体的路由
- node:则是中规中矩的树节点设计,它内部依旧采用类型的数据
2.3.2 Echo 路由树设计抽象
Echo 的路由基于 radix tree ,它让路由的查询非常快, 且使用 sync pool 来重复利用内存并且几乎达到了零内存占用。看路由的结构,跟字典树的结构一致,基数树就是字典树的一种优化结构。所以,通过请求来查找 handler 会比 http 提供的路由要快。在 http 的路由查找中是通过遍历方式的O(n),这里使用基数树O(K)的时间复杂度比直接遍历要好的多,同样比普通的Trie树的效率也要高。
路由绑定
看看是如何把路由信息装入到 Router 树的
根据整个分析流程,先把整颗树还原成图,用广度优先搜索把树遍历出来。
// 广度层序打印节点
func (e *Echo) BfsShowRoute() {
bfsTree(e.router.tree)
}
func bfsTree(n *node) error {
var queue []*node
queue = append(queue, n)
for len(queue) > 0 {
queueLen := len(queue)
fmt.Println("----------------level----------------")
for i := queueLen; i > 0; i-- {
cn := queue[0]
parentPrefix := ""
if cn.parent != nil {
parentPrefix = cn.parent.prefix
}
fmt.Println("kind:", cn.kind, ",lable:", string(cn.label), ",prefix:", cn.prefix, ",ppath:", cn.ppath, ",pnames:", cn.pnames, ",handler:", runtime.FuncForPC(reflect.ValueOf(cn.methodHandler.get).Pointer()).Name(), ",parent->", parentPrefix)
if len(cn.children) > 0 {
queue = append(queue, cn.children...)
}
queue = queue[1:len(queue)]
}
}
return nil
}
在注册路由后,我们可以用上面的代码去遍历打印路由树,可以得到结果
----------------level----------------
kind: 0 ,lable: / ,prefix: / ,ppath: / ,pnames: [] ,handler: github.com/labstack/echo.(*Echo).Add.func1 ,parent->
----------------level----------------
kind: 0 ,lable: u ,prefix: u ,ppath: ,pnames: [] ,handler: ,parent-> /
----------------level----------------
kind: 0 ,lable: s ,prefix: sers/ ,ppath: /users/ ,pnames: [] ,handler: github.com/labstack/echo.(*Echo).Add.func1 ,parent-> u
kind: 0 ,lable: p ,prefix: ps/ ,ppath: /ups/ ,pnames: [] ,handler: github.com/labstack/echo.(*Echo).Add.func1 ,parent-> u
----------------level----------------
kind: 0 ,lable: n ,prefix: new ,ppath: /users/new ,pnames: [] ,handler: github.com/labstack/echo.(*Echo).Add.func1 ,parent-> u
kind: 1 ,lable: : ,prefix: : ,ppath: /users/:name ,pnames: [name] ,handler: github.com/labstack/echo.(*Echo).Add.func1 ,parent-> sers/
kind: 0 ,lable: 1 ,prefix: 1/files/ ,ppath: ,pnames: [] ,handler: ,parent-> sers/
----------------level----------------
kind: 2 ,lable: * ,prefix: * ,ppath: /users/1/files/* ,pnames: [*] ,handler: github.com/labstack/echo.(*Echo).Add.func1 ,parent-> 1/files/
根据结果可以还原这颗路由树的图形
大概了解了 Echo 整个路由树之后,来研究 Echo 路由树构建的源码 看看这颗树是怎么构建的。
// 注册三要素 方法类型,路由path,handler函数
func (r *Router) Add(method, path string, h HandlerFunc) {
// 校验是否空路径
if path == "" {
panic("echo: path cannot be empty")
}
// 规范路径
if path[0] != '/' {
path = "/" + path
}
pnames := []string{} // 路径参数
ppath := path // 原始路径
// 按字符挨个遍历
for i, l := 0, len(path); i < l; i++ {
// 参数路径
if path[i] == ':' {
j := i + 1
r.insert(method, path[:i], nil, skind, "", nil)
// 找到参数路径的参数
for ; i < l && path[i] != '/'; i++ {
}
// 把参数路径存入 pnames
pnames = append(pnames, path[j:i])
// 拼接路径 继续查找是否还有 参数路径
path = path[:j] + path[i:]
i, l = j, len(path)
// 已经结束 插入参数路径节点
if i == l {
r.insert(method, path[:i], h, pkind, ppath, pnames)
return
}
r.insert(method, path[:i], nil, pkind, "", nil)
// 全量路径
} else if path[i] == '*' {
r.insert(method, path[:i], nil, skind, "", nil)
// 全量参数都是"*"
pnames = append(pnames, "*")
r.insert(method, path[:i+1], h, akind, ppath, pnames)
return
}
}
// 普通路径
r.insert(method, path, h, skind, ppath, pnames)
}
// 核心函数,构建字典树
func (r *Router) insert(method, path string, h HandlerFunc, t kind, ppath string, pnames []string) {
// 调整最大参数
l := len(pnames)
if *r.echo.maxParam < l {
*r.echo.maxParam = l
}
cn := r.tree // 当前节点 root
if cn == nil {
panic("echo: invalid method")
}
search := path
for {
sl := len(search)
pl := len(cn.prefix)
l := 0
// LCP
max := pl
if sl < max {
max = sl
}
// 找到共同前缀的位置 例如 users/ 和 users/new 的共同前缀为 users/
for ; l < max && search[l] == cn.prefix[l]; l++ {
}
if l == 0 {
// root 节点处理
cn.label = search[0]
cn.prefix = search
if h != nil {
cn.kind = t
cn.addHandler(method, h)
cn.ppath = ppath
cn.pnames = pnames
}
} else if l < pl {
// 分离共同前缀 users/ 和 users/new 创建一个 prefix为new 的节点
n := newNode(cn.kind, cn.prefix[l:], cn, cn.children, cn.methodHandler, cn.ppath, cn.pnames)
// 重置父节点 prefix 为 users
cn.kind = skind
cn.label = cn.prefix[0]
cn.prefix = cn.prefix[:l]
// 清空该节点的孩子节点
cn.children = nil
cn.methodHandler = new(methodHandler)
cn.ppath = ""
cn.pnames = nil
// 给该节点加上 prefix 为new的节点
cn.addChild(n)
if l == sl {
// 如果是
cn.kind = t
cn.addHandler(method, h)
cn.ppath = ppath
cn.pnames = pnames
} else {
// 创建子节点
n = newNode(t, search[l:], cn, nil, new(methodHandler), ppath, pnames)
n.addHandler(method, h)
cn.addChild(n)
}
} else if l < sl {
search = search[l:]
// 找到 lable 一样的节点,用 lable 来判断共同前缀
c := cn.findChildWithLabel(search[0])
if c != nil {
// 找到共同节点 继续
cn = c
continue
}
// 创建子节点
n := newNode(t, search, cn, nil, new(methodHandler), ppath, pnames)
n.addHandler(method, h)
cn.addChild(n)
} else {
// 节点已存在
if h != nil {
cn.addHandler(method, h)
cn.ppath = ppath
if len(cn.pnames) == 0 { // Issue #729
cn.pnames = pnames
}
}
}
return
}
}
经过分析源码得出,整个节点是根据新增的路由不断调整而生成的radix tree。了解了整个结构之后,再来看看如何查找路由的handler。
请求路径匹配 handler
http Server 调用 ServeHTTP 接口来具体处理请求
serverHandler{c.server}.ServeHTTP(w, w.req)
// ServeHTTP 实现了 `http.Handler` 接口, 该接口用来处理请求
func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 获取 context,这里为啥用pool实现?据文档说是为了节省内存
c := e.pool.Get().(*context)
c.Reset(r, w)
h := NotFoundHandler
// 没有请求前需要调用的 http 中间件
if e.premiddleware == nil {
// 查找 handler的方法
e.router.Find(r.Method, getPath(r), c)
h = c.Handler()
h = applyMiddleware(h, e.middleware...)
} else {
h = func(c Context) error {
e.router.Find(r.Method, getPath(r), c)
h := c.Handler()
h = applyMiddleware(h, e.middleware...)
return h(c)
}
h = applyMiddleware(h, e.premiddleware...)
}
// 执行处理逻辑
if err := h(c); err != nil {
e.HTTPErrorHandler(err, c)
}
// 释放 context
e.pool.Put(c)
}
// 通过 method 和 path 查找注册的 handler,解析 URL 参数并把参数装进 context
func (r *Router) Find(method, path string, c Context) {
ctx := c.(*context)
ctx.path = path
fmt.Println(ctx.path, ctx.query, ctx.pvalues, method, path, "ctx....")
cn := r.tree // 当前节点
var (
search = path
child *node // 子节点
n int // 参数计数器
nk kind // 下一个节点的 Kind
nn *node // 下一个节点
ns string // 下一个 search 字符串
pvalues = ctx.pvalues
)
// 搜索顺序 static > param > any
for {
if search == "" {
break
}
pl := 0 // Prefix length
l := 0 // LCP length
if cn.label != ':' {
sl := len(search)
pl = len(cn.prefix)
// LCP
max := pl
if sl < max {
max = sl
}
// 找到共同前缀的起始点
for ; l < max && search[l] == cn.prefix[l]; l++ {
}
}
if l == pl {
// 重合 继续搜索
search = search[l:]
} else {
cn = nn
search = ns
if nk == pkind {
goto Param
} else if nk == akind {
goto Any
}
// 没有找到子节点 直接返回
return
}
if search == "" {
break
}
// Static 节点
if child = cn.findChild(search[0], skind); child != nil {
// Save next
if cn.prefix[len(cn.prefix)-1] == '/' { // Issue #623
nk = pkind
nn = cn
ns = search
}
cn = child
continue
}
// Param 节点
Param:
if child = cn.findChildByKind(pkind); child != nil {
// Issue #378
if len(pvalues) == n {
continue
}
// Save next
if cn.prefix[len(cn.prefix)-1] == '/' { // Issue #623
nk = akind
nn = cn
ns = search
}
cn = child
i, l := 0, len(search)
for ; i < l && search[i] != '/'; i++ {
}
pvalues[n] = search[:i]
n++
search = search[i:]
continue
}
// Any 节点
Any:
if cn = cn.findChildByKind(akind); cn == nil {
if nn != nil {
cn = nn
nn = cn.parent // Next (Issue #954)
search = ns
if nk == pkind {
goto Param
} else if nk == akind {
goto Any
}
}
// Not found
return
}
pvalues[len(cn.pnames)-1] = search
break
}
ctx.handler = cn.findHandler(method)
ctx.path = cn.ppath
ctx.pnames = cn.pnames
// NOTE: Slow zone...
if ctx.handler == nil {
ctx.handler = cn.checkMethodNotAllowed()
// Dig further for any, might have an empty value for *, e.g.
// serving a directory. Issue #207.
if cn = cn.findChildByKind(akind); cn == nil {
return
}
if h := cn.findHandler(method); h != nil {
ctx.handler = h
} else {
ctx.handler = cn.checkMethodNotAllowed()
}
ctx.path = cn.ppath
ctx.pnames = cn.pnames
pvalues[len(cn.pnames)-1] = ""
}
return
2.4 各框架路由树设计总结
这些框架都普遍提供了一种分组功能,或者 namespace 功能。本质上分组功能并不属于核心功能(虽然有些框架在核心实现里面嵌入了该功能,但这是一种侵入式地做法,并不值得提倡),而仅仅是一个类似“语法糖”的东西。
经过对多个 web 框架的路由功能的分析,可以得出路由树设计的以下要点:
- 归根结底就是设计一颗多叉树
- 同时按照 HTTP 方法来组织路由树,每个 HTTP 方法一棵树
- 节点维持住自己的子节点
2.5 路由树的详细设计
基本上路由树都是利用前缀树来实现的。例如:
- 这种路由树的核心是构造最长公共子串。例如/v1 和 /v2 最长公共子串就是 v,因此 v 成了一个公共祖先节点。
- 并且可以观察到,它们并不是按照路径分隔符 / 来进行分割的,而是直接利用 / 分割完之后,还要查找公共子串。
- 这种实现方式会导致它们的代码非常复杂,大部分代码都是在处理最长公共子串,以及调整节点。
- 这里将采取另外一种简化的操作,即我们直接按照 / 分割,并且不再查找最长公共子串。同样的路由,我们最终生成的树是:
注意这两棵路由树,第一颗更深,而第二颗的更宽。这也就是两种设计的取舍。
实际上,从性能的角度来说,两者相差不多。但是无疑后者的代码要简化很多,因为没有最长公共子串的概念。但是在路由树非常庞大的情况,例如有几万条路由的时候,那么前者性能会更加好。只是大多数情况下,一个系统支持的路由数量,超过一千就已经很难维护了。鉴于此,这里采用这种简化的实现。
2.5.1 全静态匹配
利用全静态匹配来构建路由树,后面再考虑重构路由树以支持通配符匹配、参数路由和正则路由等复杂匹配。所谓的静态匹配,就是路径的每一段都必须严格相等。
1. 接口设计
func newRouter() router {
return router{
trees: make(map[string]*node, 12),
}
}
type router struct {
// trees 是按照 HTTP 方法来组织的
// 如 GET => *node
trees map[string]*node
}
type node struct {
path string
// children 子节点
// 子节点的 path => node
children map[string]*node
// handler 命中路由之后执行的逻辑
handler HandleFunc
}
func (r *router) AddRoute(method string, path string, handler HandleFunc) {
panic("implement me")
}
关键类型:
- router:维持住了所有的路由树,它是整个路由注册和查找的总入口。router 里面维护了个 map,是按照 HTTP 方法来组织路由树的
- node:代表的是节点。它里面有一个 children 的 map结构,使用 map 结构是为了快速查找到子节点
2. 测试用例
func Test_router_AddRoute(t *testing.T) {
testRoutes := []struct {
method string
path string
}{
{
method: http.MethodGet,
path: "/",
},
{
method: http.MethodGet,
path: "/user",
},
{
method: http.MethodGet,
path: "/user/home",
},
{
method: http.MethodGet,
path: "/order/detail",
},
{
method: http.MethodPost,
path: "/order/create",
},
{
method: http.MethodPost,
path: "/login",
},
}
mockHandler := func(ctx *Context) {}
r := newRouter()
for _, tr := range testRoutes {
r.addRoute(tr.method, tr.path, mockHandler)
}
wantRouter := &router{
trees: map[string]*node{
http.MethodGet: {path: "/", children: map[string]*node{
"user": {path: "user", children: map[string]*node{
"home": {path: "home", handler: mockHandler},
}, handler: mockHandler},
"order": {path: "order", children: map[string]*node{
"detail": {path: "detail", handler: mockHandler},
}},
}, handler: mockHandler},
http.MethodPost: {path: "/", children: map[string]*node{
"order": {path: "order", children: map[string]*node{
"create": {path: "create", handler: mockHandler},
}},
"login": {path: "login", handler: mockHandler},
}},
},
}
msg, ok := wantRouter.equal(r)
assert.True(t, ok, msg)
}
func (r router) equal(y router) (string, bool) {
for k, v := range r.trees {
yv, ok := y.trees[k]
if !ok {
return fmt.Sprintf("目标 router 里面没有方法 %s 的路由树", k), false
}
str, ok := v.equal(yv)
if !ok {
return k + "-" + str, ok
}
}
return "", true
}
func (n *node) equal(y *node) (string, bool) {
if y == nil {
return "目标节点为 nil", false
}
if n.path != y.path {
return fmt.Sprintf("%s 节点 path 不相等 x %s, y %s", n.path, n.path, y.path), false
}
nhv := reflect.ValueOf(n.handler)
yhv := reflect.ValueOf(y.handler)
if nhv != yhv {
return fmt.Sprintf("%s 节点 handler 不相等 x %s, y %s", n.path, nhv.Type().String(), yhv.Type().String()), false
}
if len(n.children) != len(y.children) {
return fmt.Sprintf("%s 子节点长度不等", n.path), false
}
if len(n.children) == 0 {
return "", true
}
for k, v := range n.children {
yv, ok := y.children[k]
if !ok {
return fmt.Sprintf("%s 目标节点缺少子节点 %s", n.path, k), false
}
str, ok := v.equal(yv)
if !ok {
return n.path + "-" + str, ok
}
}
return "", true
}
3. 非法用例
func Test_router_AddRoute(t *testing.T) {
testRoutes := []struct {
method string
path string
}{
{
method: http.MethodGet,
path: "/",
},
{
method: http.MethodGet,
path: "/user",
},
{
method: http.MethodGet,
path: "/user/home",
},
{
method: http.MethodGet,
path: "/order/detail",
},
{
method: http.MethodPost,
path: "/order/create",
},
{
method: http.MethodPost,
path: "/login",
},
}
mockHandler := func(ctx *Context) {}
r := newRouter()
for _, tr := range testRoutes {
r.addRoute(tr.method, tr.path, mockHandler)
}
wantRouter := &router{
trees: map[string]*node{
http.MethodGet: {path: "/", children: map[string]*node{
"user": {path: "user", children: map[string]*node{
"home": {path: "home", handler: mockHandler},
}, handler: mockHandler},
"order": {path: "order", children: map[string]*node{
"detail": {path: "detail", handler: mockHandler},
}},
}, handler: mockHandler},
http.MethodPost: {path: "/", children: map[string]*node{
"order": {path: "order", children: map[string]*node{
"create": {path: "create", handler: mockHandler},
}},
"login": {path: "login", handler: mockHandler},
}},
},
}
msg, ok := wantRouter.equal(r)
assert.True(t, ok, msg)
// 非法用例
r = newRouter()
// 空字符串
assert.PanicsWithValue(t, "web: 路由是空字符串", func() {
r.addRoute(http.MethodGet, "", mockHandler)
})
// 前导没有 /
assert.PanicsWithValue(t, "web: 路由必须以 / 开头", func() {
r.addRoute(http.MethodGet, "a/b/c", mockHandler)
})
// 后缀有 /
assert.PanicsWithValue(t, "web: 路由不能以 / 结尾", func() {
r.addRoute(http.MethodGet, "/a/b/c/", mockHandler)
})
// 根节点重复注册
r.addRoute(http.MethodGet, "/", mockHandler)
assert.PanicsWithValue(t, "web: 路由冲突[/]", func() {
r.addRoute(http.MethodGet, "/", mockHandler)
})
// 普通节点重复注册
r.addRoute(http.MethodGet, "/a/b/c", mockHandler)
assert.PanicsWithValue(t, "web: 路由冲突[/a/b/c]", func() {
r.addRoute(http.MethodGet, "/a/b/c", mockHandler)
})
// 多个 /
assert.PanicsWithValue(t, "web: 非法路由。不允许使用 //a/b, /a//b 之类的路由, [/a//b]", func() {
r.addRoute(http.MethodGet, "/a//b", mockHandler)
})
assert.PanicsWithValue(t, "web: 非法路由。不允许使用 //a/b, /a//b 之类的路由, [//a/b]", func() {
r.addRoute(http.MethodGet, "//a/b", mockHandler)
})
}
4. 代码实现
对 path 进行校验
func (r *router) AddRoute(method, path string, handleFunc HandleFunc) {
if path == "" {
panic("web: 路由是空字符串")
}
if path[0] != '/' {
panic("web: 路由必须以 / 开头")
}
if path != "/" && path[len(path)-1] == '/' {
panic("web: 路由不能以 / 结尾")
}
........
}
处理根节点
func (r *router) AddRoute(method, path string, handleFunc HandleFunc) {
........
root, ok := r.trees[method]
if !ok {
root = &node{path: "/"}
r.trees[method] = root
}
if path == "/" {
if root.handler != nil {
panic("web: 路由冲突[/]")
}
root.handler = handleFunc
return
}
........
}
沿着子节点层层深入下去
func (r *router) AddRoute(method, path string, handleFunc HandleFunc) {
.......
segs := strings.Split(path[1:], "/")
for _, s := range segs {
if s == "" {
panic("web: 非法路由。不允许使用 //a/b, /a//b 之类的路由")
}
root = root.childOrCreate(s)
}
if root.handler != nil {
panic(fmt.Sprintf("web: 路由冲突[%s]", path))
}
root.handler = handleFunc
}
查找或者创建一个子节点
// childOrCreate 查找子节点,如果子节点不存在就创建一个
// 并且将子节点放回去了 children 中
func (n *node) childOrCreate(path string) *node {
if n.children == nil {
n.children = make(map[string]*node)
}
child, ok := n.children[path]
if !ok {
child = &node{path: path}
n.children[path] = child
}
return child
}
(1) http 的 method 是否需要校验?
对 path 进行了校验,但是却没有对 method 进行校验。理论上 method 只能是合法的 HTTP 方法。
(2) 将 AddRoute 改成私有的
type Server interface {
http.Handler
// Start 启动服务器
// addr 是监听地址。如果只指定端口,可以使用 ":8081"
// 或者 "localhost:8082"
Start(addr string) error
// addRoute 注册一个路由
// method 是 HTTP 方法
addRoute(method string, path string, handler HandleFunc)
// 我们并不采取这种设计方案
// addRoute(method string, path string, handlers... HandleFunc)
}
func (s *HTTPServer) Post(path string, handler HandleFunc) {
s.addRoute(http.MethodPost, path, handler)
}
func (s *HTTPServer) Get(path string, handler HandleFunc) {
s.addRoute(http.MethodGet, path, handler)
}
- 用户只能通过 Get 或者 Post 方法来注册,那么可以确保 method 参数永远都是对的
- addRoute 在接口里面是私有的,限制了用户将无法实现 Server。实际上如果用户想要实现 Server,就约等于自己实现一个 Web 框架了
path 之所以会有那么强的约束,是因为要统一使用者的路由定义风格,例如:有的使用者注册路由喜欢加 /,有些人不喜欢加 / ,那么该框架将不允许注册风格不一致的路由。
(3) 路由查找
// findRoute 查找对应的节点
// 注意,返回的 node 内部 HandleFunc 不为 nil 才算是注册了路由
func (r *router) findRoute(method string, path string) (*node, bool) {
root, ok := r.trees[method]
if !ok {
return nil, false
}
if path == "/" {
return root, true
}
segs := strings.Split(strings.Trim(path, "/"), "/")
for _, s := range segs {
root, ok = root.childOf(s)
if !ok {
return nil, false
}
}
return root, true
}
func (n *node) childOf(path string) (*node, bool) {
if n.children == nil {
return nil, false
}
res, ok := n.children[path]
return res, ok
}
findRoute 只是返回节点,但是并没有进一步判断究竟有没有 HandleFunc,所以调用者需要进一步检查。
以下是部分测试用例
func Test_router_findRoute(t *testing.T) {
testRoutes := []struct {
method string
path string
}{
{
method: http.MethodGet,
path: "/",
},
{
method: http.MethodGet,
path: "/user",
},
{
method: http.MethodPost,
path: "/order/create",
},
}
mockHandler := func(ctx *Context) {}
testCases := []struct {
name string
method string
path string
found bool
wantNode *node
}{
{
name: "method not found",
method: http.MethodHead,
},
{
name: "path not found",
method: http.MethodGet,
path: "/abc",
},
{
name: "root",
method: http.MethodGet,
path: "/",
found: true,
wantNode: &node{
path: "/",
handler: mockHandler,
},
},
{
name: "user",
method: http.MethodGet,
path: "/user",
found: true,
wantNode: &node{
path: "user",
handler: mockHandler,
},
},
{
name: "no handler",
method: http.MethodPost,
path: "/order",
found: true,
wantNode: &node{
path: "order",
},
},
{
name: "two layer",
method: http.MethodPost,
path: "/order/create",
found: true,
wantNode: &node{
path: "create",
handler: mockHandler,
},
},
}
r := newRouter()
for _, tr := range testRoutes {
r.addRoute(tr.method, tr.path, mockHandler)
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
n, found := r.findRoute(tc.method, tc.path)
assert.Equal(t, tc.found, found)
if !found {
return
}
wantVal := reflect.ValueOf(tc.wantNode.handler)
nVal := reflect.ValueOf(n.handler)
assert.Equal(t, wantVal, nVal)
})
}
}
(4) Server 集成 router
// 确保 HTTPServer 肯定实现了 Server 接口
var _ Server = &HTTPServer{}
type HTTPServer struct {
router
}
func NewHTTPServer() *HTTPServer {
return &HTTPServer{
router: newRouter(),
}
}
func (s *HTTPServer) serve(ctx *Context) {
n, ok := s.findRoute(ctx.Req.Method, ctx.Req.URL.Path)
if !ok || n.handler == nil {
ctx.Resp.WriteHeader(404)
ctx.Resp.Write([]byte("Not Found"))
return
}
n.handler(ctx)
}
- 在这种情况下,用户只能使用 NewHTTPServer 来创建服务器实例。
- 如果考虑到用户可能自己 s := &HTTPServer 引起 panic,那么可以将 HTTPServer 做成私有的,即改名为 httpServer。
(5) 启动 Server 测试
func TestServer(t *testing.T) {
s := NewHTTPServer()
s.Get("/", func(ctx *Context) {
ctx.Resp.Write([]byte("hello, world"))
})
s.Get("/user", func(ctx *Context) {
ctx.Resp.Write([]byte("hello, user"))
})
s.Start(":8081")
}
2.5.2 通配符匹配
通配符匹配,是指用 * 号来表达匹配任何路径。这要考虑以下几个问题:
- 如果路径是 /a/b/c 能不能命中 /a/* 路由?
- 如果注册了两个路由 /user/123/home 和 /user//。那么输入路径 /user/123/detail 能不能命中 /user//?
这两个都是理论上可以,但是不应该命中。
- 从实现的角度来说,其实并不难
- 从用户的角度来说,他们不应该设计这种路由。给用户自由,但是也要限制不良实践
- 后者要求的是一种可回溯的路由匹配。即发现 /user/123/home 匹配不上之后要回溯回去 /user/* 进一步查找,典型的投入大产出低的特性
1. node 变更
// node 代表路由树的节点
// 路由树的匹配顺序是:
// 1. 静态完全匹配
// 2. 通配符匹配
// 这是不回溯匹配
type node struct {
path string
// children 子节点
// 子节点的 path => node
children map[string]*node
// handler 命中路由之后执行的逻辑
handler HandleFunc
// 通配符 * 表达的节点,任意匹配
starChild *node
}
2. childOf 变更
func (n *node) childOf(path string) (*node, bool) {
if n.children == nil {
return n.starChild, n.starChild != nil
}
res, ok := n.children[path]
if !ok {
return n.starChild, n.starChild != nil
}
return res, ok
}
3. childOrCreate 变更
// childOrCreate 查找子节点,如果子节点不存在就创建一个
// 并且将子节点放回去了 children 中
func (n *node) childOrCreate(path string) *node {
if path == "*" {
if n.starChild == nil {
n.starChild = &node{path: "*"}
}
return n.starChild
}
if n.children == nil {
n.children = make(map[string]*node)
}
child, ok := n.children[path]
if !ok {
child = &node{path: path}
n.children[path] = child
}
return child
}
4. addRoute 测试用例
func Test_router_AddRoute(t *testing.T) {
testRoutes := []struct{
method string
path string
} {
{
method: http.MethodGet,
path: "/",
},
{
method: http.MethodGet,
path: "/user",
},
{
method: http.MethodGet,
path: "/user/home",
},
{
method: http.MethodGet,
path: "/order/detail",
},
{
method: http.MethodPost,
path: "/order/create",
},
{
method: http.MethodPost,
path: "/login",
},
// 通配符测试用例
{
method: http.MethodGet,
path: "/order/*",
},
{
method: http.MethodGet,
path: "/*",
},
{
method: http.MethodGet,
path: "/*/*",
},
{
method: http.MethodGet,
path: "/*/abc",
},
{
method: http.MethodGet,
path: "/*/abc/*",
},
}
mockHandler := func(ctx *Context) {}
r := newRouter()
for _, tr := range testRoutes {
r.addRoute(tr.method, tr.path, mockHandler)
}
wantRouter := &router{
trees: map[string]*node{
http.MethodGet: {
path: "/",
children: map[string]*node{
"user": {path: "user", children: map[string]*node{
"home": {path: "home", handler: mockHandler},
}, handler: mockHandler},
"order": {path: "order", children: map[string]*node{
"detail": {path: "detail", handler: mockHandler},
}, starChild: &node{path: "*", handler: mockHandler}},
},
starChild:&node{
path: "*",
children: map[string]*node{
"abc": {
path: "abc",
starChild: &node{path: "*", handler: mockHandler},
handler: mockHandler},
},
starChild: &node{path: "*", handler: mockHandler},
handler: mockHandler},
handler: mockHandler},
http.MethodPost: { path: "/", children: map[string]*node{
"order": {path: "order", children: map[string]*node{
"create": {path: "create", handler: mockHandler},
}},
"login": {path: "login", handler: mockHandler},
}},
},
}
msg, ok := wantRouter.equal(r)
assert.True(t, ok, msg)
// 非法用例
r = newRouter()
// 空字符串
assert.PanicsWithValue(t, "web: 路由是空字符串", func() {
r.addRoute(http.MethodGet, "", mockHandler)
})
// 前导没有 /
assert.PanicsWithValue(t, "web: 路由必须以 / 开头", func() {
r.addRoute(http.MethodGet, "a/b/c", mockHandler)
})
// 后缀有 /
assert.PanicsWithValue(t, "web: 路由不能以 / 结尾", func() {
r.addRoute(http.MethodGet, "/a/b/c/", mockHandler)
})
// 根节点重复注册
r.addRoute(http.MethodGet, "/", mockHandler)
assert.PanicsWithValue(t, "web: 路由冲突[/]", func() {
r.addRoute(http.MethodGet, "/", mockHandler)
})
// 普通节点重复注册
r.addRoute(http.MethodGet, "/a/b/c", mockHandler)
assert.PanicsWithValue(t, "web: 路由冲突[/a/b/c]", func() {
r.addRoute(http.MethodGet, "/a/b/c", mockHandler)
})
// 多个 /
assert.PanicsWithValue(t, "web: 非法路由。不允许使用 //a/b, /a//b 之类的路由, [/a//b]", func() {
r.addRoute(http.MethodGet, "/a//b", mockHandler)
})
assert.PanicsWithValue(t, "web: 非法路由。不允许使用 //a/b, /a//b 之类的路由, [//a/b]", func() {
r.addRoute(http.MethodGet, "//a/b", mockHandler)
})
}
func (r router) equal(y router) (string, bool) {
for k, v := range r.trees {
yv, ok := y.trees[k]
if !ok {
return fmt.Sprintf("目标 router 里面没有方法 %s 的路由树", k), false
}
str, ok := v.equal(yv)
if !ok {
return k + "-" + str, ok
}
}
return "", true
}
func (n *node) equal(y *node) (string, bool) {
if y == nil {
return "目标节点为 nil", false
}
if n.path != y.path {
return fmt.Sprintf("%s 节点 path 不相等 x %s, y %s", n.path, n.path, y.path), false
}
nhv := reflect.ValueOf(n.handler)
yhv := reflect.ValueOf(y.handler)
if nhv != yhv {
return fmt.Sprintf("%s 节点 handler 不相等 x %s, y %s", n.path, nhv.Type().String(), yhv.Type().String()), false
}
if len(n.children) != len(y.children) {
return fmt.Sprintf("%s 子节点长度不等", n.path), false
}
if len(n.children) == 0 {
return "", true
}
if n.starChild != nil {
str, ok := n.starChild.equal(y.starChild)
if !ok {
return fmt.Sprintf("%s 通配符节点不匹配 %s", n.path, str), false
}
}
for k, v := range n.children {
yv, ok := y.children[k]
if !ok {
return fmt.Sprintf("%s 目标节点缺少子节点 %s", n.path, k), false
}
str, ok := v.equal(yv)
if !ok {
return n.path + "-" + str, ok
}
}
return "", true
}
5. findRoute 测试用例
func Test_router_findRoute(t *testing.T) {
testRoutes := []struct{
method string
path string
} {
{
method: http.MethodGet,
path: "/",
},
{
method: http.MethodGet,
path: "/user",
},
{
method: http.MethodPost,
path: "/order/create",
},
{
method: http.MethodGet,
path: "/user/*/home",
},
{
method: http.MethodPost,
path: "/order/*",
},
}
mockHandler := func(ctx *Context) {}
testCases := []struct {
name string
method string
path string
found bool
wantNode *node
}{
{
name: "method not found",
method: http.MethodHead,
},
{
name: "path not found",
method: http.MethodGet,
path: "/abc",
},
{
name: "root",
method: http.MethodGet,
path: "/",
found: true,
wantNode: &node{
path: "/",
handler: mockHandler,
},
},
{
name: "user",
method: http.MethodGet,
path: "/user",
found: true,
wantNode: &node{
path: "user",
handler: mockHandler,
},
},
{
name: "no handler",
method: http.MethodPost,
path: "/order",
found: true,
wantNode: &node{
path: "order",
},
},
{
name: "two layer",
method: http.MethodPost,
path: "/order/create",
found: true,
wantNode: &node{
path: "create",
handler: mockHandler,
},
},
// 通配符匹配
{
// 命中/order/*
name: "star match",
method: http.MethodPost,
path: "/order/delete",
found: true,
wantNode: &node{
path: "*",
handler: mockHandler,
},
},
{
// 命中通配符在中间的
// /user/*/home
name: "star in middle",
method: http.MethodGet,
path: "/user/Tom/home",
found: true,
wantNode: &node{
path: "home",
handler: mockHandler,
},
},
{
// 比 /order/* 多了一段
name: "overflow",
method: http.MethodPost,
path: "/order/delete/123",
},
}
r := newRouter()
for _, tr := range testRoutes {
r.addRoute(tr.method, tr.path, mockHandler)
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
n, found := r.findRoute(tc.method, tc.path)
assert.Equal(t, tc.found, found)
if !found {
return
}
assert.Equal(t, tc.wantNode.path, n.path)
wantVal := reflect.ValueOf(tc.wantNode.handler)
nVal := reflect.ValueOf(n.handler)
assert.Equal(t, wantVal, nVal)
})
}
}
2.5.3 路径参数路由
所谓路径参数路由,就是指在路径中带上参数,同时这些参数对应的值可以被业务取出来使用。 例如:/user/:id 。如果输入路径 /user/123,那么会命中这个路由,并且 id = 123。那么要考虑:
- 允不允许同样的参数路径和通配符匹配一起注册?例如同时注册 /user/* 和 /user/:id
- 可以,但是没必要,用户也不应该设计这种路由
1. node 变更
// node 代表路由树的节点
// 路由树的匹配顺序是:
// 1. 静态完全匹配
// 2. 路径参数匹配:形式 :param_name
// 3. 通配符匹配:*
// 这是不回溯匹配
type node struct {
path string
// children 子节点
// 子节点的 path => node
children map[string]*node
// handler 命中路由之后执行的逻辑
handler HandleFunc
// 通配符 * 表达的节点,任意匹配
starChild *node
paramChild *node
}
2. childOf 变更
// child 返回子节点
// 第一个返回值 *node 是命中的节点
// 第二个返回值 bool 代表是否是命中参数路由
// 第三个返回值 bool 代表是否命中
func (n *node) childOf(path string) (*node, bool, bool) {
if n.children == nil {
if n.paramChild != nil {
return n.paramChild, true, true
}
return n.starChild, false, n.starChild != nil
}
res, ok := n.children[path]
if !ok {
if n.paramChild != nil {
return n.paramChild, true, true
}
return n.starChild, false, n.starChild != nil
}
return res, false, ok
}
3. childOrCreate 变更
// childOrCreate 查找子节点,
// 首先会判断 path 是不是通配符路径
// 其次判断 path 是不是参数路径,即以 : 开头的路径
// 最后会从 children 里面查找,
// 如果没有找到,那么会创建一个新的节点,并且保存在 node 里面
func (n *node) childOrCreate(path string) *node {
if path == "*" {
if n.paramChild != nil {
panic(fmt.Sprintf("web: 非法路由,已有路径参数路由。不允许同时注册通配符路由和参数路由 [%s]", path))
}
if n.starChild == nil {
n.starChild = &node{path: path}
}
return n.starChild
}
// 以 : 开头,我们认为是参数路由
if path[0] == ':' {
if n.starChild != nil {
panic(fmt.Sprintf("web: 非法路由,已有通配符路由。不允许同时注册通配符路由和参数路由 [%s]", path))
}
if n.paramChild != nil {
if n.paramChild.path != path {
panic(fmt.Sprintf("web: 路由冲突,参数路由冲突,已有 %s,新注册 %s", n.paramChild.path, path))
}
} else {
n.paramChild = &node{path: path}
}
return n.paramChild
}
if n.children == nil {
n.children = make(map[string]*node)
}
child, ok := n.children[path]
if !ok {
child = &node{path: path}
n.children[path] = child
}
return child
}
4. 获取参数值
在路径匹配上来的时候,要把参数对应的值给带出来。例如 /user/:id,输入路径 /user/123,那么应该把 id = 123 这个信息带出来。
type matchInfo struct {
n *node
pathParams map[string]string
}
type Context struct {
Req *http.Request
Resp http.ResponseWriter
PathParams map[string]string
}
(1) 在 findRoute 中提取参数路径
例如/user/:id,输入路径 /user/123,那么就相当于把 id = 123 这样一个键值对放进去了 mi (matchInfo) 里面
// findRoute 查找对应的节点
// 注意,返回的 node 内部 HandleFunc 不为 nil 才算是注册了路由
func (r *router) findRoute(method string, path string) (*matchInfo, bool) {
root, ok := r.trees[method]
if !ok {
return nil, false
}
if path == "/" {
return &matchInfo{n: root}, true
}
segs := strings.Split(strings.Trim(path, "/"), "/")
mi := &matchInfo{}
for _, s := range segs {
var matchParam bool
root, matchParam, ok = root.childOf(s)
if !ok {
return nil, false
}
if matchParam {
mi.addValue(root.path[1:], s)
}
}
mi.n = root
return mi, true
}
func (m *matchInfo) addValue(key string, value string) {
if m.pathParams == nil {
// 大多数情况,参数路径只会有一段
m.pathParams = map[string]string{key:value}
}
m.pathParams[key] = value
}
在 serve 方法中将路径参数放到 context
func (s *HTTPServer) serve(ctx *Context) {
mi, ok := s.findRoute(ctx.Req.Method, ctx.Req.URL.Path)
if !ok || mi.n == nil || mi.n.handler == nil {
ctx.Resp.WriteHeader(404)
ctx.Resp.Write([]byte("Not Found"))
return
}
ctx.PathParams = mi.pathParams
mi.n.handler(ctx)
}
2.5.4 正则路由
正则匹配通用性更强一点,确切说,前面讨论的通配符匹配,路径参数都可以看做是正则匹配的一种特殊形态。
例如开发者注册一个正则匹配路由 /user/:id(^[0-9]+$),那么在这种情况下,用户输入 /user/123 能够匹配上注册的路由,而 /user/xiaoming 则无法匹配。
正则匹配存在一种复合场景,即开发者可能注册两个正则路由,例如 /user/:id(^[0-9]+$),同时又注册了 /user/:username(.+)。这种时候,/user/123 将匹配前者,而 /user/xiaoming 将匹配后者。但是在这里,我们将不会支持这种用法。开发者不应该设计这种路由。
1. node 变更
type nodeType int
const (
// 静态路由
nodeTypeStatic = 1
// 正则路由
nodeTypeReg = 2
// 路径参数路由
nodeTypeParam = 3
// 通配符路由
nodeTypeAny = 4
)
// node 代表路由树的节点
// 路由树的匹配顺序是:
// 1. 静态完全匹配
// 2. 通配符匹配
// 这是不回溯匹配
type node struct {
typ nodeType
path string
// children 子节点 (静态路由节点)
// 子节点的 path => node
children map[string]*node
// handler 命中路由之后执行的逻辑
handler HandleFunc
// route 到达该节点的完整的路由路径
route string
// 通配符 * 表达的节点,任意匹配
starChild *node
// 参数路由节点
paramChild *node
// 正则路由和参数路由都会使用这个字段
paramName string
// 正则表达式路由节点
regChild *node
// 正常表达式API
regExpr *regexp.Regexp
}
2. childOf 变更
func (n *node) childOf(path string) (*node, bool) {
if n.children == nil {
return n.childOfNotStatic()
}
root, ok := n.children[path]
if !ok {
return n.childOfNotStatic()
}
return root, ok
}
func (n *node) childOfNotStatic() (*node, bool) {
if n.regChild != nil {
return n.regChild, true
}
if n.paramChild != nil {
return n.paramChild, true
}
return n.starChild, n.starChild != nil
}
3. childOrCreate 变更
// 静态路由创建
func (n *node) staticRouteOrCreate(path string) *node {
if n.children == nil {
n.children = make(map[string]*node)
}
child, ok := n.children[path]
if !ok {
child = &node{typ: nodeTypeStatic, path: path}
n.children[path] = child
}
return child
}
// 通配符路由创建
func (n *node) starRouteOrCreate(path string) *node {
if n.starChild == nil {
n.starChild = &node{typ: nodeTypeAny, path: path}
}
return n.starChild
}
// 参数路由创建
func (n *node) paramRouteOrCreate(path string) *node {
if n.paramChild == nil {
n.paramChild = &node{typ: nodeTypeParam, path: path, paramName: path[1:]}
}
return n.paramChild
}
// 正则路由创建
func (n *node) regexRouteOrCreate(path string) *node {
if n.regChild == nil {
segs := strings.Split(path, "(")
if len(segs) != 2 {
panic(fmt.Sprintf("web: 非法路由,不符合正则规范, 必须是 :name(你的正则)的格式 [%s]", path))
}
paramName := segs[0][1:]
reg := regexp.MustCompile("(" + segs[1])
if reg == nil {
panic(fmt.Sprintf("web: 非法路由,正则预编译对象不能为 nil"))
}
n.regChild = &node{path: path, typ: nodeTypeReg, paramName: paramName, regExpr: reg}
}
return n.regChild
}
// 通配符路由冲突检测
func (n *node) starRouteConflict(path string) (string, bool) {
if n.paramChild != nil {
return fmt.Sprintf("web: 非法路由,已有路径参数路由。不允许同时注册通配符路由和参数路由 [%s]", path), true
}
if n.regChild != nil {
return fmt.Sprintf("web: 非法路由,已有正则路由。不允许同时注册通配符路由和正则路由 [%s]", path), true
}
return "", false
}
// 参数路由冲突检测
func (n *node) paramRouteConflict(path string) (string, bool) {
if n.starChild != nil {
return fmt.Sprintf("web: 非法路由,已有通配符路由。不允许同时注册通配符路由和参数路由 [%s]", path), true
}
if n.paramChild != nil && n.paramChild.path != path {
return fmt.Sprintf("web: 路由冲突,参数路由冲突,已有 %s,新注册 %s", n.paramChild.path, path), true
}
if n.regChild != nil {
return fmt.Sprintf("web: 非法路由,已有正则路由。不允许同时注册通配符路由和正则路由 [%s]", path), true
}
return "", false
}
func (n *node) regxRouteConflict(path string) (string, bool) {
if n.paramChild != nil {
return fmt.Sprintf("web: 非法路由,已经有路径参数路由,不允许同时注册通配符路由和参数路由 [%s]", path), true
}
if n.starChild != nil {
return fmt.Sprintf("web: 非法路由,已有通配符路由。不允许同时注册通配符路由和参数路由 [%s]", path), true
}
if n.regChild != nil && n.regChild.path != path {
return fmt.Sprintf("web: 非法路由, 重复注册正则路由 [%s]", path), true
}
return "", false
}
// childOrCreate 查找子节点,如果子节点不存在就创建一个
// 并且将子节点放回去了 children 中
func (n *node) childOrCreate(path string) *node {
// 如果为正则路由
if strings.ContainsAny(path, `()`) {
panicInfo, ok := n.regxRouteConflict(path)
if ok {
panic(panicInfo)
}
return n.regexRouteOrCreate(path)
}
// 通配符路由
if path == "*" {
panicInfo, ok := n.starRouteConflict(path)
if ok {
panic(panicInfo)
}
return n.starRouteOrCreate(path)
}
// 以 : 开头,我们认为是参数路由
if path[0] == ':' {
panicInfo, ok := n.paramRouteConflict(path)
if ok {
panic(panicInfo)
}
return n.paramRouteOrCreate(path)
}
return n.staticRouteOrCreate(path)
}
4. findRoute 变更
// findRoute 查找对应的节点
// 注意,返回的 node 内部 HandleFunc 不为 nil 才算是注册了路由
func (r *router) findRoute(method, path string) (*matchInfo, bool) {
root, ok := r.trees[method]
if !ok {
return nil, false
}
if path == "/" {
return &matchInfo{n: root}, true
}
// segs := strings.Split(path[1:], "/")
segs := strings.Split(strings.Trim(path, "/"), "/")
mi := &matchInfo{}
cur := root
for _, s := range segs {
// var matchParam bool
if s == "" {
return nil, false
}
//root, matchParam, ok = root.childOf(s)
cur, ok = cur.childOf(s)
if !ok {
return nil, false
}
if cur.typ == nodeTypeReg {
fmt.Println(s)
subMatch := cur.regExpr.MatchString(s)
if subMatch {
mi.addValue(cur.paramName, s)
} else {
return nil, false
}
}
if cur.typ == nodeTypeParam {
mi.addValue(cur.paramName, s)
}
if cur.typ == nodeTypeAny && cur.notChild() {
break
}
}
mi.n = cur
return mi, true
}
5. addRoute 变更
func (r *router) checkLegalPath(path string) {
if path == "" {
panic("web: 路由是空字符串")
}
if path[0] != '/' {
panic("web: 路由必须以 / 开头")
}
if path != "/" && path[len(path)-1] == '/' {
panic("web: 路由不能以 / 结尾")
}
}
// addRoute 注册路由。
// method 是 HTTP 方法
// path 必须以 / 开始并且结尾不能有 /,中间也不允许有连续的 /
func (r *router) addRoute(method, path string, handleFunc HandleFunc) {
r.checkLegalPath(path)
root, ok := r.trees[method]
if !ok {
// 这是一个全新的 HTTP 方法,创建根节点
root = &node{path: "/"}
r.trees[method] = root
}
if path == "/" {
if root.handler != nil {
panic("web: 路由冲突[/]")
}
root.handler = handleFunc
return
}
// 开始一段段处理
segs := strings.Split(path[1:], "/")
for _, s := range segs {
if s == "" {
panic(fmt.Sprintf("web: 非法路由。不允许使用 //a/b, /a//b 之类的路由, [%s]", path))
}
root = root.childOrCreate(s)
}
if root.handler != nil {
panic(fmt.Sprintf("web: 路由冲突[%s]", path))
}
root.handler = handleFunc
root.route = path
}
2.6 路由树总结
2.6.1 注册路由的注意事项
- 已经注册了的路由,无法被覆盖。例如 /user/home 注册两次,会冲突
- path 必须以 / 开始并且结尾不能有 /,中间也不允许有连续的 /
- 不能在同一个位置注册不同的参数路由,例如 /user/:id 和 /user/:name 冲突
- 不能在同一个位置同时注册通配符路由和参数路由,例如/user/:id 和 /user/* 冲突
- 同名路径参数,在路由匹配的时候,值会被覆盖。例如 /user/:id/abc/:id,那么 /user/123/abc/456 最终 id = 456
把这个放在 addRoute 方法上,那么但凡用户发现 panic 的时候看一眼这个注释,都知道出了什么问题。最后一条是可以考虑在注册路由的时候强制 panic 的。
2.6.2 为什么在注册路由用panic
为什么注册路由的过程我们有一大堆panic?
-
这个地方确实可以考虑返回 error,例如 Get 方法,但是这要求用户必须处理返回的 error。
-
从另外一个角度来说,用户必须要注册完路由,才能启动 HTTPServer。那么我们就可以采用 panic,因为启动之前就代表应用还没运行。
2.6.3 路由树是线程安全的吗?
显然不是线程安全的。
- 要求用户必须要注册完路由才能启动 HTTPServer。而正常的用法都是在启动之前依次注册路由,不存在并发场景。
- 至于运行期间动态注册路由,没必要支持。这是典型的为了解决 1% 的问题,引入 99% 的代码。
2.7 面试要点
- 路由树算法?核心就是前缀树。前缀的意思就是,两个节点共同的前缀,将会被抽取出来作为父亲节点。在实现里面,是按照 / 来切割,每一段作为一个节点
- 路由匹配的优先级?本质上这是和 Web 框架相关的。在我们的设计里面是静态匹配 > 路径参数 > 通配符匹配
- 路由查找会回溯吗?这也是和 Web 框架相关的,我们在课程上是不支持的。在这里可以简单描述可回溯和不可回溯之间的区别,可以是用课程例子 /user/123/home 和 /user// 。这里不支持是因为这个特性非常鸡肋
- Web 框架是怎么组织路由树的?一个 HTTP 方法一颗路由树,也可以考虑一颗路由树,每个节点标记自己支持的 HTTP 方法。前者是比较主流的
- 路由查找的性能受什么影响?或者说怎么评估路由查找的性能?核心是看路由树的高度,次要因素是路由树的宽度(想想 children 字段)
- 路由树是线程安全的吗?严格来说也是跟 Web 框架相关的。大多数都不是线程安全的,这是为了性能。所以才要求大家一定要先注册路由,后启动 Web 服务器。如果有运行期间动态添加路由的需求,只需要利用装饰器模式,就可以将一个线程不安全的封装为线程安全的路由树
三、Context
3.1 Gin Context 设计
Gin 的 Context 放了一些和输入输 出有关的字段。 这些字段不必细究都是用来干什么的。 另外一个是它内部还有缓存,避免了重复读取和解析的开销。
3.1.1 Gin 处理输入
这一类是从 Keys 里面读取数据的方法
从不同的部位读取数据
Bind 和 ShouldBind 类方法都是将 输入转化为一个具体的结构体
3.1.2 Gin 处理输出
返回具体格式的响应
因为 Gin 的 Context 还控制着 Handler 的 调度,所以还有这种中断后续 Handler 执 行的方法
还提供了渲染页面的方法 HTML
3.2 Beego Context 设计
Input: 对输入的封装Output:对输出的封装Response: 对响应的封装
- 反向引用了
Context - 直接耦合了
Session - 维持了不同部分的输入
- 反向引用了
Context - 维持住了
Status,即HTTP响应码
3.2.1 Beego 处理输入方法
Context 中 Bind 一族方法,用于将 Body 转化为具体的结构体
Input 中尝试从各个部位获取输入的方法
Input 中各种判断的方法
Input 中 Bind 的方法,尝试将各个部分的输入都绑定到一个结构体里面看,例如 Body、表单、查询参数等混在一起
3.2.2 Beego 处理输出的方法
Context 中 Resp 一族方法,用于将输入序列化之后输出
Render 方法尝试渲染模板,输出响应
Output 中定义的输出各种格式数据的方法
3.3 Echo Context 设计
- Echo 的 Context 被设计为接口,和 Beego、Gin 都有点不 太一样。但是实际上,它只有一个实现 context
- context 和 Beego、Gin 的都差不多,也 就是各种字段维护了来自各个部分的输 入或者输出。
- 特殊之处在于 context 本身维护了一个 logger,还维护了一个 lock。将 logger 做到 context 维度和将 context 用锁保护 起来,都是很罕见的做法。
3.3.1 Echo 处理输入和输出
处理各个部分的输入
处理各种格式的输出, 包括渲染页面
3.4 Iris Context 设计
Iris 的 Context 被设计为接口,和 Beego、Gin 都有点不太 一样。它允许用户接入自己的实现,从这个角度来说, 它的设计要比 Echo 优秀,也比 Beego 和 Gin 更为抽象
3.4.1 Iris 处理输入和输出
处理各个部分的输入
处理各种输出
3.5 Context 处理输入的设计
处理输入要解决的问题:
- 反序列化输入:将 Body 字节流转换成一个具体的类型
- 处理表单输入:可以看做是一个和 JSON 或者 XML 差不多的一种特殊序列化方式
- 处理查询参数:指从 URL 中的查询参数中读取值,并且转化为对应的类型 • 处理路径参数:读取路径参数的值,并且转化为具体的类型
- 重复读取 body:http.Request 的 Body 默认是只能读取一段,不能重复读取的
- 读取 Header:从 Header 里面读取出来特定的值,并且转化为对应的类型
- 模糊读取:按照一定的顺序,尝试从 Body、Header、路径参数或者 Cookie 里面读取值,并且 转化为特定类型
3.5.1 Body 输入
func (ctx *Context) BindJSON(val any) error {
if ctx.Request.Body == nil {
return errors.New("web: body 为 nil")
}
decoder := json.NewDecoder(ctx.Request.Body)
return decoder.Decode(val)
}
- JSON 作为最为常见的输入格式,可以率先支持。其余的类似于 XML 或者 protobuf 都可以按照类似的思路支持。
- 这里的支持非常简单,用户即便不使用 BindJSON 方法,也完全可以自己手动处理掉。
3.5.2 JSON 输入控制选项
在 JSON 的反序列化过程中,其实有两个参数:
- UseNumber:如果为 true,并且我们没有显示声明数字类型,会使用 json 的 Number 类型来作为数字类型
- DisallowUnknownFields:禁止未知的字段,如果出现未知字段,那么会返回 error
那么我们要不要引入类似下边这样的方法?
func (c *Context) BindJSON(val any, useNumber bool, disallowUnknown bool) error {
if c.Req.Body == nil {
return errors.New("web: body 为 nil")
}
decoder := json.NewDecoder(c.Req.Body)
if useNumber {
decoder.UseNumber()
}
if disallowUnknown {
decoder.DisallowUnknownFields()
}
return decoder.Decode(val)
}
严谨地说,如果用户有这种需求,那么他可能需要的是:
- 整个应用级别上控制 useNumber 或者 disableUnknownFields
- 单一 HTTPServer 实例上控制 useNumber 或者 disableUnknownFields
- 特定路径下,例如在 /user/** 都为 true 或者都为 false
- 特定路由下,例如在 /user/details 下都为 true 或者 false
// 整个应用级别控制
var JSONUseNumber = false
var JSONDisallowUnknownFields = false
type HTTPServer struct {
router
// 单一 HTTPServer 实例上控制
JSONUseNumber bool
JSONDisallowUnknownFields bool
}
不同情况下框架支持的方式也不一样:
- 整个应用级别:维持两个全局变量
- HTTPServer 级别:在 HTTPServer 里面定义两个字段
- 引入类似 BindJSONOpt 的方法,用户灵活控制
个人认为完全不需要提供 UseNumber 和 DisableKnownFields 的支持。, 对于绝大多数用户来说,他们不会尝试控制着两个选项。即便真的要控制,他们完全可以自己实现一个方法,如下图 BindReqJSONOpt。
func (c *Context) BindReqJSONOpt(val any, useNumber bool, disallowUnknown bool) error {
if c.Req.Body == nil {
return errors.New("web: body 为 nil")
}
decoder := json.NewDecoder(c.Req.Body)
if useNumber {
decoder.UseNumber()
}
if disallowUnknown {
decoder.DisallowUnknownFields()
}
return decoder.Decode(val)
}
如果一个小众需求,用户可以自己解决,那么就不要在框架核心上支持。
3.5.3 表单输入
表单在 Go 的 http.Request 里面有两个
- Form: 是 URL 里面的查询参数和 PATCH、POST、PUT的表单数据
- PostForm: PATCH、POST 或者 PUT body 参数
但是不管使用哪个,都要先使用 ParseForm 解析表单数据。
1. Form 和 PostForm
表单在 Go 的 http.Request 里面有两个:
Form:基本上可以认为,所有的表单数据都能拿到PostForm:在编码是 x-www-form-urlencoded 的时候才能拿到
实际中是不建议大家使用表单的,一般只在 Body 里面用 JSON 通信,或者用 protobuf 通信。
2. FormValue 方法
实现很简单,但是问题来了:
func (ctx *Context) FormValue(key string) (string, error) {
if err := ctx.Req.ParseForm(); err != nil {
return "", err
}
ctx.Req.FormValue(key), nil
}
- 每一次都调用 ParseForm,不会引起重复解析吗?
- 比如说我调用 ctx.FormValue(key1) 再调用 ctx.FormValue(key2),不会引起 ParseForm 重复解析吗?
- 不会引起重复解析,因为 ParseForm 调用是幂等的。
3. FormValueAsInt64?
FormValue 只能返回 string 类型的数据,能不能有别的类似的方法,返回别的数据类型?
例如下面的方法这确实是一种可行的方法。
func (c *Context) FormValueAsInt64(key string) (string, error) {
if err := c.Req.ParseForm(); err != nil {
return "", err
}
val := c.Req.FormValue(key)
return strconv.ParseInt(val, 10, 64)
}
但是,有了 FormValueAsInt64,要不要有 FormValueAsInt32,FormValueAstInt16, FormValueAsInt8?那么是不是全部基础类型都得来一个?
3.5.4 查询参数
func (c *Context) QueryValue(key string) (string, error) {
params = c.Req.URL.Query()
if params == nil {
return "", errors.New("无查询参数")
}
vals, ok := params[key]
if !ok {
return "", errors.New("web: 找不到这个 key")
}
return vals[0], nil
}
所谓的查询参数,就是指在 URL 问号之后的部分。例如 URL:http://localhost:8081/form?name=xiaoming&age=18那么查询参数有两个:name=xiaoming 和 age = 18前面我们注意到,如果要是调用了 ParseForm,那么这部分也可以在 Form 里面找到。
相关问题:这个 ParseQuery 每次都会解析一遍查询串,在这里就是 name=xiaoming&age=18 字符串
1. 查询参数缓存
type Context struct {
Req *http.Request
Resp http.ResponseWriter
PathParams map[string]string
// 缓存的数据
cacheQueryValues url.Values
}
func (c *Context) QueryValue(key string) (string, error) {
if c.cacheQueryValues == nil {
c.cacheQueryValues = c.Req.URL.Query()
}
vals, ok := c.cacheQueryValues[key]
if !ok {
return "", errors.New("web: 找不到这个 key")
}
return vals[0], nil
}
类似 Gin 框架那样,引入查询参数缓存。这个缓存是不存在所谓的失效不一致问题的,因为对于 Web 框架来说,请求收到之后,就是确切无疑,不会再变的
2. QueryValueAsInt64?
func (c *Context) QueryValueAsInt64(key string) (int64, error) {
val, err := c.QueryValue(key)
if err != nil {
return 0, err
}
return strconv.ParseInt(val, 10, 64)
}
面临着和 Form 差不多的问题:要不要所有的基本类型都搞一个方法出来? 还是和 Form 一样没有必要,理由是一样的。
3.5.5 路径参数
func (c *Context) PathValue(key string) (string, error) {
val, ok := c.PathParams[key]
if !ok {
return "", errors.New("web: 找不到这个 key")
}
return val, nil
}
基本上和前面的表单、查询参数没太大的区别
3.5.6 AsInt64, AsInt32... 等类型的处理
除了前面提到的类似 FormValueAsInt64 这种在 Context 上加方法的方案,还可以考虑 StringValue 的方案。这种方案参考了sql.Row 。
type StringValue struct {
val string
err error
}
func (s StringValue) String() (string, error) {
return s.val, s.err
}
func (s StringValue) ToInt64() (int64, error) {
if s.err != nil {
return 0, s.err
}
return strconv.ParseInt(s.val, 10, 64)
}
func (c *Context) FormValue(key string) StringValue {
if err := c.Req.ParseForm(); err != nil {
return StringValue{err: err}
}
return StringValue{val: c.Req.FormValue(key)}
}
func (c *Context) QueryValue(key string) StringValue {
if c.cacheQueryValues == nil {
c.cacheQueryValues = c.Req.URL.Query()
}
vals, ok := c.cacheQueryValues[key]
if !ok {
return StringValue{err: errors.New("web: 找不到这个 key")}
}
return StringValue{val: vals[0]}
}
func (c *Context) PathValue(key string) StringValue {
val, ok := c.PathParams[key]
if !ok {
return StringValue{err: errors.New("web: 找不到这个 key")}
}
return StringValue{val: val}
}
StringValue 这种设计可以保证在大多数的情况下,不会发生内存逃逸。
3.6 Context 处理输出的设计
3.6.1 JSON 响应
func (ctx *Context) RespJSON(code int, val any) error {
bs, err := json.Marshal(val)
if err != nil {
return err
}
ctx.Resp.WriteHeader(code)
_, err = ctx.Resp.Write(bs)
return err
}
- 这种设计非常简单,就是帮助用户将 val 转化一下。其它格式的输出也是类似的写法。
- 也可以考虑提供一个更加方便的 RespJSONOK 方法,这个就是看个人喜好了。
- 这里有一个问题,如果 val 已经是 string 或者 []byte 了,那么用户该怎么办?
- val 是 string 或者 []byte 肯定不需要调用 RespJSON 了,自己直接操作 Resp。
3.6.2 设置 Cookie
func (ctx *Context) SetCookie(cookie *http.Cookie) {
http.SetCookie(ctx.Response, cookie)
}
其实类似这种方法不是很有必要在 Context 里面再自己定义一遍的。因为用户完全可以自己调用这个 http.SetCookie 的方法。只不过对于新人来说,有这么一个方法可能更好一点。因为他们可能找不到 http.SetCookie 这个方法。
3.6.3 需要支持错误页面吗?
通常有一个需求,是如果一个响应返回了 404,那么应该重定向到一个默认页面,比如说重定位到首页。那么该怎么处理?
这里有一个很棘手的点:不是所有的 404 都是要重定向的。比如说你是异步加载数据的 RESTful 请求,例如在打开页面之后异步加载用户详情,即便 404 了也不应该重定向。
func (s *HTTPServer) serve(ctx *Context) {
mi, ok := s.findRoute(ctx.Req.Method, ctx.Req.URL.Path)
if !ok || mi.n == nil || mi.n.handler == nil{
ctx.Resp.WriteHeader(404)
ctx.Resp.Write([]byte("Not Found"))
return
}
ctx.PathParams = mi.pathParams
mi.n.handler(ctx)
}
3.7 Context 总结
3.7.1 Context 是线程安全的吗?
显然不是,和路由树不是线程安全的理由不太一样。
- Context 不需要保证线程安全,是因为这个 Context 只会被用户在一个方法里面使用,而且不应该被多个 goroutine 操作。
- 对于绝大多数人来说,他们不需要一个线程安全的 Context。即便真要线程安全,也可以提供一个装饰器,让用户在使用前手动创建装饰器来转换一下。
3.7.2 Context 为什么不设计为接口?
- 目前来看,看不出来设计为接口的必要性。
- Echo 设计为接口,但是只有一个实现,就足以说明设计为接口有点过度设计的感觉。
- 即便 Iris 设计为接口,而且允许用户提供自定义实现,但是看起来也不是那么有用。
3.7.3 Context 能不能用泛型?
这边直接编译错误
其实在 Context 里面,似乎也有使用泛型的场景,例如说处理表单数据、查询参数、路径参数……
答案是不能。因为 Go 泛型有一个限制,结构体本身可以是泛型的,但是它不能声明泛型方法。这边直接编译错误同样的道理,StringValue 也不能声明为泛型。
3.8 面试要点
- 能不能重复读取 HTTP 协议的 Body 内容?原生 API 是不可以的。但是可以通过封装来允许重复读取,核心步骤是我们将 Body 读取出来之后放到一个地方,后续都从这个地方读取。
- 能不能修改 HTTP 协议的响应?原生 API 也是不可以的。但是可以用 RespData 这种机制,在最后再把数据刷新到网络中,在刷新之前,都可以修改。
- Form 和 PostForm 的区别?正常的情况下 API 优先使用 Form 就不太可能出错。
- Web 框架是怎么支持路径参数的?Web 框架在发现匹配上了某个路径参数之后,将这段路径记录下来作为路径参数的值,这个值默认是 string 类型,用户自己有需要就可以转化为不同的类型。