1
0
mirror of https://github.com/chai2010/advanced-go-programming-book.git synced 2025-05-24 12:32:21 +00:00
2018-12-24 11:31:00 +08:00

251 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 5.2 router 请求路由
在常见的Web框架中router是必备的组件。Go语言圈子里router也时常被称为http的multiplexer。在上一节中我们通过对Burrow代码的简单学习已经知道如何用http标准库中内置的mux来完成简单的路由功能了。如果开发Web系统对路径中带参数没什么兴趣的话用http标准库中的mux就可以。
RESTful是几年前刮起的API设计风潮在RESTful中除了GET和POST之外还使用了http协议定义的几种其它的标准化语义。具体包括
```go
const (
MethodGet = "GET"
MethodHead = "HEAD"
MethodPost = "POST"
MethodPut = "PUT"
MethodPatch = "PATCH" // RFC 5789
MethodDelete = "DELETE"
MethodConnect = "CONNECT"
MethodOptions = "OPTIONS"
MethodTrace = "TRACE"
)
```
来看看RESTful中常见的请求路径
```shell
GET /repos/:owner/:repo/comments/:id/reactions
POST /projects/:project_id/columns
PUT /user/starred/:owner/:repo
DELETE /user/starred/:owner/:repo
```
相信聪明的你已经猜出来了这是github官方文档中挑出来的几个API设计。RESTful风格的API重度依赖请求路径。会将很多参数放在请求URI中。除此之外还会使用很多并不那么常见的HTTP状态码不过本节只讨论路由所以先略过不谈。
如果我们的系统也想要这样的URI设计使用标准库的mux显然就力不从心了。
## 5.2.1 httprouter
较流行的开源go Web框架大多使用httprouter或是基于httprouter的变种对路由进行支持。前面提到的github的参数式路由在httprouter中都是可以支持的。
因为httprouter中使用的是显式匹配所以在设计路由的时候需要规避一些会导致路由冲突的情况例如
```
conflict:
GET /user/info/:name
GET /user/:id
no conflict:
GET /user/info/:name
POST /user/:id
```
简单来讲的话如果两个路由拥有一致的http method(指 GET/POST/PUT/DELETE)和请求路径前缀且在某个位置出现了A路由是wildcard(指 :id 这种形式)参数B路由则是普通字符串那么就会发生路由冲突。路由冲突会在初始化阶段直接panic
```shell
panic: wildcard route ':id' conflicts with existing children in path '/user/:id'
goroutine 1 [running]:
github.com/cch123/httprouter.(*node).insertChild(0xc4200801e0, 0xc42004fc01, 0x126b177, 0x3, 0x126b171, 0x9, 0x127b668)
/Users/caochunhui/go_work/src/github.com/cch123/httprouter/tree.go:256 +0x841
github.com/cch123/httprouter.(*node).addRoute(0xc4200801e0, 0x126b171, 0x9, 0x127b668)
/Users/caochunhui/go_work/src/github.com/cch123/httprouter/tree.go:221 +0x22a
github.com/cch123/httprouter.(*Router).Handle(0xc42004ff38, 0x126a39b, 0x3, 0x126b171, 0x9, 0x127b668)
/Users/caochunhui/go_work/src/github.com/cch123/httprouter/router.go:262 +0xc3
github.com/cch123/httprouter.(*Router).GET(0xc42004ff38, 0x126b171, 0x9, 0x127b668)
/Users/caochunhui/go_work/src/github.com/cch123/httprouter/router.go:193 +0x5e
main.main()
/Users/caochunhui/test/go_web/httprouter_learn2.go:18 +0xaf
exit status 2
```
还有一点需要注意因为httprouter考虑到字典树的深度在初始化时会对参数的数量进行限制所以在路由中的参数数目不能超过255否则会导致httprouter无法识别后续的参数。不过这一点上也不用考虑太多毕竟URI是人设计且给人来看的相信没有长得夸张的URI能在一条路径中带有200个以上的参数。
除支持路径中的wildcard参数之外httprouter还可以支持`*`号来进行通配,不过`*`号开头的参数只能放在路由的结尾,例如下面这样:
```shell
Pattern: /src/*filepath
/src/ filepath = ""
/src/somefile.go filepath = "somefile.go"
/src/subdir/somefile.go filepath = "subdir/somefile.go"
```
这种设计在RESTful中可能不太常见主要是为了能够使用httprouter来做简单的http静态文件服务器。
除了正常情况下的路由支持httprouter也支持对一些特殊情况下的回调函数进行定制例如404的时候
```go
r := httprouter.New()
r.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("oh no, not found"))
})
```
或者内部panic的时候
```go
r.PanicHandler = func(w http.ResponseWriter, r *http.Request, c interface{}) {
log.Printf("Recovering from panic, Reason: %#v", c.(error))
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(c.(error).Error()))
}
```
目前开源界最为流行(star数最多)的Web框架[gin](https://github.com/gin-gonic/gin)使用的就是httprouter的变种。
## 5.2.2 原理
httprouter和众多衍生router使用的数据结构被称为压缩字典树(Radix Tree)。读者可能没有接触过压缩字典树,但对字典树(Trie Tree)应该有所耳闻。*图 5-1*是一个典型的字典树结构:
![trie tree](../images/ch6-02-trie.png)
*图 5-1 字典树*
字典树常用来进行字符串检索例如用给定的字符串序列建立字典树。对于目标字符串只要从根节点开始深度优先搜索即可判断出该字符串是否曾经出现过时间复杂度为O(n)n可以认为是目标字符串的长度。为什么要这样做字符串本身不像数值类型可以进行数值比较两个字符串对比的时间复杂度取决于字符串长度。如果不用字典树来完成上述功能要对历史字符串进行排序再利用二分查找之类的算法去搜索时间复杂度只高不低。可认为字典树是一种空间换时间的典型做法。
普通的字典树有一个比较明显的缺点,就是每个字母都需要建立一个孩子节点,这样会导致字典树的层数比较深,压缩字典树相对好地平衡了字典树的优点和缺点。是典型的压缩字典树结构:
![radix tree](../images/ch6-02-radix.png)
*图 5-2 压缩字典树*
每个节点上不只存储一个字母了,这也是压缩字典树中“压缩”的主要含义。使用压缩字典树可以减少树的层数,同时因为每个节点上数据存储也比通常的字典树要多,所以程序的局部性较好(一个节点的path加载到 cache 即可进行多个字符的对比)从而对CPU缓存友好。
## 5.2.3 压缩字典树创建过程
我们来跟踪一下httprouter中一个典型的压缩字典树的创建过程路由设定如下
```
PUT /user/installations/:installation_id/repositories/:repository_id
GET /marketplace_listing/plans/
GET /marketplace_listing/plans/:id/accounts
GET /search
GET /status
GET /support
补充路由:
GET /marketplace_listing/plans/ohyes
```
最后一条补充路由是我们臆想的除此之外所有API路由均来自于api.github.com。
### 5.2.3.1 root 节点创建
httprouter的Router结构体中存储压缩字典树使用的是下述数据结构
```go
// 略去了其它部分的 Router struct
type Router struct {
// ...
trees map[string]*node
// ...
}
```
`trees`中的`key`即为http 1.1的RFC中定义的各种方法具体有
```shell
GET
HEAD
OPTIONS
POST
PUT
PATCH
DELETE
```
每一种方法对应的都是一棵独立的压缩字典树,这些树彼此之间不共享数据。具体到我们上面用到的路由,`PUT``GET`是两棵树而非一棵。
简单来讲,某个方法第一次插入的路由就会导致对应字典树的根节点被创建,我们按顺序,先是一个`PUT`
```go
r := httprouter.New()
r.PUT("/user/installations/:installation_id/repositories/:reposit", Hello)
```
这样`PUT`对应的根节点就会被创建出来。把这棵`PUT`的树画出来:
![put radix tree](../images/ch6-02-radix-put.png)
*图 5-3 插入路由之后的压缩字典树*
radix的节点类型为`*httprouter.node`,为了说明方便,我们留下了目前关心的几个字段:
```
path: 当前节点对应的路径中的字符串
wildChild: 子节点是否为参数节点,即 wildcard node或者说 :id 这种类型的节点
nType: 当前节点类型,有四个枚举值: 分别为 static/root/param/catchAll。
static // 非根节点的普通字符串节点
root // 根节点
param // 参数节点,例如 :id
catchAll // 通配符节点,例如 *anyway
indices: 子节点索引,当子节点为非参数类型,即本节点的 wildChild 为 false 时,会将每个子节点的首字母放在该索引数组。说是数组,实际上是个 string。
```
当然,`PUT`路由只有唯一的一条路径。接下来我们以后续的多条GET路径为例讲解子节点的插入过程。
### 5.2.3.2 子节点插入
当插入`GET /marketplace_listing/plans`类似前面PUT的过程GET树的结构如*图 5-4*
![get radix step 1](../images/ch6-02-radix-get-1.png)
*图 5-4 插入第一个节点的压缩字典树*
因为第一个路由没有参数path都被存储到根节点上了。所以只有一个节点。
然后插入 `GET /marketplace_listing/plans/:id/accounts`,新的路径与之前的路径有共同的前缀,且可以直接在之前叶子节点后进行插入,那么结果也很简单,插入后的树结构见*图 5-5*:
![get radix step 2](../images/ch6-02-radix-get-2.png)
*图 5-5 插入第二个节点的压缩字典树*
由于`:id`这个节点只有一个字符串的普通子节点所以indices还依然不需要处理。
上面这种情况比较简单,新的路由可以直接作为原路由的子节点进行插入。实际情况不会这么美好。
### 5.2.3.3 边分裂
接下来我们插入`GET /search`,这时会导致树的边分裂,见*图 5-6*。
![get radix step 3](../images/ch6-02-radix-get-3.png)
*图 5-6 插入第三个节点,导致边分裂*
原有路径和新的路径在初始的`/`位置发生分裂这样需要把原有的root节点内容下移再将新路由 `search`同样作为子节点挂在root节点之下。这时候因为子节点出现多个root节点的indices提供子节点索引这时候该字段就需要派上用场了。"ms" 代表子节点的首字母分别为m(marketplace)和s(search)。
我们一口作气,把`GET /status``GET /support`也插入到树中。这时候会导致在`search`节点上再次发生分裂,最终结果见*图 5-7*
![get radix step 4](../images/ch6-02-radix-get-4.png)
*图 5-7 插入所有路由后的压缩字典树*
### 5.2.3.4 子节点冲突处理
在路由本身只有字符串的情况下不会发生任何冲突。只有当路由中含有wildcard(类似 :id)或者catchAll的情况下才可能冲突。这一点在前面已经提到了。
子节点的冲突处理很简单,分几种情况:
1. 在插入wildcard节点时父节点的children数组非空且wildChild被设置为false。例如`GET /user/getAll``GET /user/:id/getAddr`,或者`GET /user/*aaa``GET /user/:id`
2. 在插入wildcard节点时父节点的children数组非空且wildChild被设置为true但该父节点的wildcard子节点要插入的wildcard名字不一样。例如`GET /user/:id/info``GET /user/:name/info`
3. 在插入catchAll节点时父节点的children非空。例如`GET /src/abc``GET /src/*filename`,或者`GET /src/:id``GET /src/*filename`
4. 在插入static节点时父节点的wildChild字段被设置为true。
5. 在插入static节点时父节点的children非空且子节点nType为catchAll。
只要发生冲突都会在初始化的时候panic。例如在插入我们臆想的路由`GET /marketplace_listing/plans/ohyes`出现第4种冲突情况它的父节点`marketplace_listing/plans/`的wildChild字段为true。