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()方法

该方法需要再返回一个标识该节点是否为参数路径节点的标量

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()方法

1.3.1 为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()方法就不能再返回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()方法

因为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.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

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()方法

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