3.11 路由树-参数路径之参数值
本节课工程结构如下:
(base) yanglei@yuanhong 16-valueOfParamRoute % tree ./
./
├── context.go
├── handleFunc.go
├── httpServer.go
├── httpServer_test.go
├── matchNode.go
├── node.go
├── router.go
├── router_test.go
└── serverInterface.go
0 directories, 9 files
PART1. 获取参数值
之前已经实现了参数路径的注册与查找,但仍有一个核心问题没有解决:我们没有办法将参数从路由中带到逻辑处理函数中.
1.1 定义新类型
我们现在定义的node
结构体是不包含参数路径中的参数名和参数值的.因此我们需要新定义一个类型,该类型除了表示命中的路由节点外,还包含该节点的参数名和参数值(如果该节点是参数路径节点的话)
matchNode.go
:
package valueOfParamRoute
// matchNode 用于保存匹配到的节点与路径参数
type matchNode struct {
// node 匹配到的节点
node *node
// pathParams 路径参数 若该节点不是参数节点,则该字段值为nil
pathParams map[string]string
}
注:这里我认为代码中出现Info
之类的指代意义不明的单词不太好,因此将老师上课时命名的matchInfo
修改为了matchNode
.
1.2 修改chlidOf()
方法
chlidOf()
方法该方法需要再返回一个标识该节点是否为参数路径节点的标量
node.go
:
// childOf 根据给定的path在当前节点的子节点映射中查找对应的子节点(即:匹配到了静态路由)
// 若未在子节点映射中找到对应子节点 则先尝试返回当前节点的参数路由子节点(即:匹配到了参数路由)
// 若参数路由子节点为空 则尝试返回当前节点的通配符子节点(即:匹配到了通配符路由)
// 优先级: 静态路由 > 参数路由 > 通配符路由
// child: 查找到的子节点
// isParamChild: 查找到的子节点是否为参数路由子节点
// found: 是否找到了对应的子节点
func (n *node) childOf(path string) (child *node, isParamChild bool, found bool) {
// 当前节点的子节点映射为空 则有可能匹配到 参数路由子节点 或通配符子节点
// 此处优先查找参数路由子节点 因为参数路由子节点更具体 所以参数路由的优先级高于通配符路由
if n.children == nil {
// 如果当前节点的参数子节点不为空 则尝试返回当前节点的参数子节点
if n.paramChild != nil {
return n.paramChild, true, true
}
// 如果当前节点的参数子节点为空 则尝试返回当前节点的通配符子节点
return n.wildcardChild, false, n.wildcardChild != nil
}
// 在子当前节点的节点映射中查找对应的子节点 若未找到同样尝试返回当前节点的参数子节点
// 若参数子节点为空 则尝试返回当前节点的通配符子节点
child, found = n.children[path]
if !found {
if n.paramChild != nil {
return n.paramChild, true, true
}
return n.wildcardChild, false, n.wildcardChild != nil
}
// 找到了对应的子节点 则返回该子节点
return child, false, found
}
1.3 修改findRoute()
方法
findRoute()
方法1.3.1 为matchNode
结构体新增方法
matchNode
结构体新增方法matchNode.go
:新增一个用于添加路径参数的方法addPathParams()
package valueOfParamRoute
// matchNode 用于保存匹配到的节点与路径参数
type matchNode struct {
// node 匹配到的节点
node *node
// pathParams 路径参数 若该节点不是参数节点,则该字段值为nil
pathParams map[string]string
}
// addPathParams 用于添加路径参数
func (m *matchNode) addPathParams(name string, value string) {
// Tips: 这里作为框架的设计者 你是没法确定用户会注册多少个参数路由的
// Tips: 因此给不给容量意义不大
if m.pathParams == nil {
m.pathParams = map[string]string{}
}
m.pathParams[name] = value
}
1.3.2 修改findRoute()
方法
findRoute()
方法这里findRoute()
方法就不能再返回node
了,因为需要将参数名/值和节点一起返回.因此需要返回matchNode
类型.
既然chlidOf()
方法已经返回了表达当前节点是否为参数路径节点的标量,那么findRoute()
方法需要做的事情就是:若当前节点为参数路径节点,则将参数名和参数值一同返回
router.go
:
// findRoute 根据给定的HTTP方法和路由路径,在路由森林中查找对应的节点
// 若该节点为参数路径节点,则不仅返回该节点,还返回参数名和参数值
// 否则,仅返回该节点
func (r *router) findRoute(method string, path string) (*matchNode, bool) {
targetMatchNode := &matchNode{}
root, ok := r.trees[method]
// 给定的HTTP动词在路由森林中不存在对应的路由树,则直接返回false
if !ok {
return nil, false
}
// 对根节点做特殊处理
if path == "/" {
targetMatchNode.node = root
return targetMatchNode, true
}
// 给定的HTTP动词在路由森林中存在对应的路由树,则在该路由树中查找对应的节点
// 去掉前导和后置的"/"
path = strings.Trim(path, "/")
segments := strings.Split(path, "/")
// Tips: 同样的 这里我认为用target作为变量名表现力更强
target := root
for _, segment := range segments {
child, isParamChild, found := target.childOf(segment)
// 如果在当前节点的子节点映射中没有找到对应的子节点,则直接返回
if !found {
return nil, false
}
// 若当前节点为参数节点,则将参数名和参数值保存到targetMatchNode中
if isParamChild {
// 参数名是形如 :id 的格式, 因此需要去掉前导的:
name := child.path[1:]
// 参数值就是当前路由路径中的路由段
value := segment
targetMatchNode.addPathParams(name, value)
}
// 如果在当前节点的子节点映射中找到了对应的子节点,则继续在该子节点中查找
target = child
}
// 如果找到了对应的节点,则返回该节点
// Tips: 此处有2种设计 一种是用标量表示是否找到了子节点
// Tips: 另一种是 return target, target.HandleFunc != nil
// Tips: 这种返回就表示找到了子节点且子节点必然有对应的业务处理函数
// 此处我倾向用第1种设计 因为方法名叫findRoute,表示是否找到节点的意思.而非表示是否找到了一个有对应的业务处理函数的节点
targetMatchNode.node = target
return targetMatchNode, true
}
PART2. 测试
2.1 修改已有的测试用例
2.1.1 修改路由查找功能的测试用例
这个用例其实并没有运行,就是为了编译通过而修改.因为这个用例里没有测试参数路径匹配.
router_test.go
:
// TestRouter_findRoute 测试路由查找功能
func TestRouter_findRoute(t *testing.T) {
// step1. 构造路由树
testRoutes := []TestNode{
// GET方法路由树
TestNode{
method: http.MethodGet,
path: "/order/detail",
},
TestNode{
method: http.MethodGet,
path: "/",
},
}
r := newRouter()
mockHandleFunc := func(ctx *Context) {}
for _, testRoute := range testRoutes {
r.addRoute(testRoute.method, testRoute.path, mockHandleFunc)
}
// step2. 构造测试用例
testCases := []struct {
name string
method string
path string
isFound bool
matchNode *matchNode
}{
// 测试HTTP动词不存在的用例
{
name: "method not found",
method: http.MethodDelete,
path: "/user",
isFound: false,
matchNode: nil,
},
// 测试完全命中的用例
{
name: "order detail",
method: http.MethodGet,
path: "/order/detail",
isFound: true,
matchNode: &matchNode{
node: &node{
path: "detail",
children: nil,
HandleFunc: mockHandleFunc,
},
},
},
// 测试命中了节点但节点的HandleFunc为nil的情况
{
name: "order",
method: http.MethodGet,
path: "/order",
isFound: true,
matchNode: &matchNode{
node: &node{
path: "order",
children: map[string]*node{
"detail": &node{
path: "detail",
children: nil,
HandleFunc: mockHandleFunc,
},
},
HandleFunc: nil,
},
},
},
// 测试根节点
{
name: "",
method: http.MethodGet,
path: "/",
isFound: true,
matchNode: &matchNode{
node: &node{
path: "/",
children: map[string]*node{
"order": &node{
path: "order",
children: map[string]*node{
"detail": &node{
path: "detail",
children: nil,
HandleFunc: mockHandleFunc,
},
},
HandleFunc: nil,
},
},
HandleFunc: mockHandleFunc,
},
},
},
// 测试路由不存在的用例
{
name: "path not found",
method: http.MethodGet,
path: "/user",
isFound: false,
matchNode: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
foundNode, found := r.findRoute(testCase.method, testCase.path)
// Tips: testCase.isFound是期望的结果,而found是实际的结果
assert.Equal(t, testCase.isFound, found)
// 没有找到路由就不用继续比较了
if !found {
return
}
// 此处和之前的测试一样 不能直接用assert.Equal()比较 因为HandleFunc不可比
// 所以要用封装的node.equal()方法比较
msg, found := testCase.matchNode.node.equal(foundNode.node)
assert.True(t, found, msg)
})
}
}
2.1.2 通配符路由查找功能的测试用例
这个用例其实并没有运行,就是为了编译通过而修改.因为这个用例里也没有测试参数路径匹配.
router_test.go
:
// TestRouter_findRoute_wildcard 测试针对通配符路由的查找功能
func TestRouter_findRoute_wildcard(t *testing.T) {
// step1. 构造路由树
testRoutes := []TestNode{
{
method: http.MethodGet,
path: "/order/*",
},
{
method: http.MethodGet,
path: "/order/detail",
},
}
r := newRouter()
mockHandleFunc := func(ctx *Context) {}
for _, testRoute := range testRoutes {
r.addRoute(testRoute.method, testRoute.path, mockHandleFunc)
}
// step2. 构造测试用例
testCases := []struct {
name string
method string
path string
isFound bool
matchNode *matchNode
}{
// 普通节点的通配符子节点测试用例
{
name: "order wildcard",
method: http.MethodGet,
path: "/order/abc",
isFound: true,
matchNode: &matchNode{
node: &node{
path: "*",
children: nil,
wildcardChild: nil,
HandleFunc: mockHandleFunc,
},
},
},
// 普通节点下普通子节点和通配符子节点共存的测试用例
{
name: "order detail",
method: http.MethodGet,
path: "/order/detail",
isFound: true,
matchNode: &matchNode{
node: &node{
path: "detail",
children: nil,
wildcardChild: nil,
HandleFunc: mockHandleFunc,
},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
foundNode, found := r.findRoute(testCase.method, testCase.path)
assert.Equal(t, testCase.isFound, found)
if !found {
return
}
msg, found := testCase.matchNode.node.equal(foundNode.node)
assert.True(t, found, msg)
})
}
}
2.1.3 针对参数路由查找功能的测试用例
// TestRouter_findRoute_param 测试针对参数路由的查找功能
func TestRouter_findRoute_param(t *testing.T) {
// step1. 构造路由树
testRoutes := []TestNode{
{
method: http.MethodGet,
path: "/order/detail/:id",
},
}
r := newRouter()
mockHandleFunc := func(ctx *Context) {}
for _, testRoute := range testRoutes {
r.addRoute(testRoute.method, testRoute.path, mockHandleFunc)
}
// step2. 构造测试用例
testCases := []struct {
name string
method string
path string
isFound bool
matchNode *matchNode
}{
// 普通节点的参数路由子节点测试用例
{
name: "order detail id",
method: http.MethodGet,
path: "/order/detail/123",
isFound: true,
matchNode: &matchNode{
node: &node{
path: ":id",
children: nil,
wildcardChild: nil,
paramChild: nil,
HandleFunc: mockHandleFunc,
},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
foundNode, found := r.findRoute(testCase.method, testCase.path)
assert.Equal(t, testCase.isFound, found)
if !found {
return
}
msg, found := testCase.matchNode.node.equal(foundNode.node)
assert.True(t, found, msg)
})
}
}
2.1.4 修改serve()
方法
serve()
方法因为findRoute()
方法的返回值有变动而造成的修改.
// serve 查找路由树并执行命中的业务逻辑
func (s *HTTPServer) serve(ctx *Context) {
method := ctx.Req.Method
path := ctx.Req.URL.Path
targetNode, ok := s.findRoute(method, path)
// 没有在路由树中找到对应的路由节点 或 找到了路由节点的处理函数为空(即NPE:none pointer exception 的问题)
// 则返回404
if !ok || targetNode.node.HandleFunc == nil {
ctx.Resp.WriteHeader(http.StatusNotFound)
// 此处确实会报错 但是作为一个WEB框架 遇上了这种错误也没有特别好的处理办法
// 最多只能是落个日志
_, _ = ctx.Resp.Write([]byte("Not Found"))
return
}
// 执行路由节点的处理函数
targetNode.node.HandleFunc(ctx)
}
2.2 运行测试用例
此处运行2.1.4小节的测试用例即可
PART3. 将参数名/值传递至业务处理函数
到目前为止,我们只是拿到了参数路径中的名值对,还没有实现让业务处理函数获取到这个名值对的功能.
3.1 修改Context
Context
context.go
:
package valueOfParamRoute
import "net/http"
// Context HandleFunc的上下文
type Context struct {
// Req 请求
Req *http.Request
// Resp 响应
Resp http.ResponseWriter
// PathParams 路径参数名值对
PathParams map[string]string
}
3.2 命中节点后将名值对传递给Context
Context
httpServer.go
:
// serve 查找路由树并执行命中的业务逻辑
func (s *HTTPServer) serve(ctx *Context) {
method := ctx.Req.Method
path := ctx.Req.URL.Path
targetNode, ok := s.findRoute(method, path)
// 没有在路由树中找到对应的路由节点 或 找到了路由节点的处理函数为空(即NPE:none pointer exception 的问题)
// 则返回404
if !ok || targetNode.node.HandleFunc == nil {
ctx.Resp.WriteHeader(http.StatusNotFound)
// 此处确实会报错 但是作为一个WEB框架 遇上了这种错误也没有特别好的处理办法
// 最多只能是落个日志
_, _ = ctx.Resp.Write([]byte("Not Found"))
return
}
// 命中节点则将路径参数名值对设置到上下文中
ctx.PathParams = targetNode.pathParams
// 执行路由节点的处理函数
targetNode.node.HandleFunc(ctx)
}
PART4. 参数路径的冲突问题
4.1 问题的产生
思考这样一个问题:我们是否允许形如/user/:id
和/user/:name
同时存在?
答案显而易见:肯定是不支持两个路由同时存在的.
4.2 修改childOrCreate()
方法
childOrCreate()
方法node.go
:
// childOrCreate 本方法用于在节点上获取给定的子节点,如果给定的子节点不存在则创建
func (n *node) childOrCreate(segment string) *node {
// 如果路径为参数 则查找当前节点的参数子节点 或创建一个当前节点的参数子节点 并返回
if strings.HasPrefix(segment, ":") {
// 若当前节点存在通配符子节点 则不允许注册参数子节点
if n.wildcardChild != nil {
panic("web: 非法路由,已有通配符路由.不允许同时注册通配符路由和参数路由")
}
// 若当前节点的参数子节点不为空 说明当前节点已被注册了一个参数子节点 不允许再注册参数子节点
if n.paramChild != nil {
msg := fmt.Sprintf("web: 路由冲突,参数路由冲突.已存在路由 %s", n.paramChild.path)
panic(msg)
}
n.paramChild = &node{
path: segment,
}
return n.paramChild
}
// 若路径为通配符 则查找当前节点的通配符子节点 或创建一个当前节点的通配符子节点 并返回
if segment == "*" {
// 若当前节点存在参数子节点 则不允许注册通配符子节点
if n.paramChild != nil {
panic("web: 非法路由,已有参数路由.不允许同时注册通配符路由和参数路由")
}
if n.wildcardChild == nil {
n.wildcardChild = &node{
path: segment,
}
}
return n.wildcardChild
}
// 如果当前节点的子节点映射为空 则创建一个子节点映射
if n.children == nil {
n.children = map[string]*node{}
}
res, ok := n.children[segment]
// 如果没有找到子节点,则创建一个子节点;否则返回找到的子节点
if !ok {
res = &node{
path: segment,
}
n.children[segment] = res
}
return res
}
4.3 测试
router_test.go
:
// TestRouter_findRoute_same_param_coexist 测试针对参数路由时,已有同名参数路由的情况
func TestRouter_findRoute_same_param_coexist(t *testing.T) {
// step1. 注册有冲突的路由
r := newRouter()
mockHandleFunc := func(ctx *Context) {}
r.addRoute(http.MethodGet, "/order/detail/:id", mockHandleFunc)
// step2. 断言非法用例
assert.Panicsf(t, func() {
r.addRoute(http.MethodGet, "/order/detail/:name", mockHandleFunc)
}, "web: 路由冲突,参数路由冲突.已存在路由 id")
}
Last updated