一、Server与路由树

2021-02-11T14:21:02+08:00 | 20分钟阅读 | 更新于 2021-02-12T14:21:02+08:00

@

学习目标

学完本章你应该能够:

  1. 讲清 Web 框架三大抽象(Server / Context / 路由树)各自的职责与边界。
  2. http.Handler 接口出发,解释框架如何用 v1 / v2 两种 Server 设计平衡"易用"与"可控"。
  3. 用一颗多叉树(按 HTTP 方法分册)描述路由注册与查找的数据结构。
  4. 说清静态 / 参数 / 通配符三种匹配的优先级,以及 findRoute 如何按优先级逐段匹配。
  5. 在面试中把"路由树算法 = 前缀树"“为什么注册用 panic"“路由树为何非线程安全"讲清楚。

前置知识:

  • Go 基础:interfacemapslice、字符串处理
  • net/http 的基本使用(知道 http.ListenAndServe 怎么用)

本章你会动手做的事:

  1. 跑通 v2 全静态路由的 addRoute / findRoute 单元测试,观察路由树怎么长出来。
  2. 给 v3 加一条 /user/:id 路由,验证路径参数能被正确提取到 ctx.PathParams
  3. 故意重复注册同一条路由,确认框架按设计 panic,而不是静默覆盖。

1.1 Web 核心三大抽象

在框架对比中我们注意到,对于一个 Web 框架来说,至少要提供三个核心抽象:

  • Server:代表服务器的抽象
  • Context:代表上下文的抽象
  • 路由树:负责路由匹配与分发

类比:Web 框架像个快递分拣中心。Server 是整栋厂房(负责开门营业、关门打烊);Context 是贴在每个包裹上的面单(一次请求的全部信息都在上面);路由树 是墙上的分拣地图(根据地址把包裹送到对应窗口)。三者各管一摊,谁也不越界。

下面这张图把三大抽象和它们之间的关系画出来:

flowchart TD
    S[Server 服务器抽象] -->|启动/关闭 路由注册| R[路由树 匹配与分发]
    S -->|每次请求构建| C[Context 请求上下文]
    C -->|携带路径参数| R

本章我们重点探讨 Server 抽象的设计与实现。

1.2 Server 的核心功能

从框架对比来看,对于一个 Web 框架来说,首先要有一个整体代表服务器的抽象,也就是 Server。Server 从特性上来说,至少要提供三部分功能:

  • 生命周期控制:即启动、关闭。后期还可以考虑增加生命周期回调特性
  • 路由注册接口:提供路由注册功能
  • 桥梁作用:作为 http 包到 Web 框架的桥梁

1.3 http.Handler 接口

http 包暴露了一个接口 Handler,它是我们引入自定义 Web 框架的连接点。任何 Web 框架要与 Go 标准库的 http 包协作,都需要实现这个接口。

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

1.4 Server 接口设计

版本一:组合 http.Handler

Server 定义版本一:只组合 http.Handler

优点:

  • 用户在使用的时候只需要调用 http.ListenAndServe 就可以
  • 和 HTTPS 协议完全无缝衔接
  • 极简设计

缺点:

  • 难以控制生命周期,并且在控制生命周期的时候增加回调支持
  • 缺乏控制力:如果将来希望支持优雅退出的功能,将难以支持

版本二:增加 Start 方法

Server 定义版本二:组合 http.Handler 并且增加 Start 方法。

优点:

  • Server 既可以当成普通的 http.Handler 来使用,又可以作为一个独立的实体,拥有自己的管理生命周期的能力
  • 完全的控制,可以为所欲为

缺点:

  • 如果用户不希望使用 ListenAndServeTLS,那么 Server 需要提供 HTTPS 的支持

注意:Start 方法可以不需要 addr 参数,那么在创建实现类的时候传入地址就可以。

版本一和版本二都直接耦合了 Go 自带的 http 包,如果我们希望切换为 fasthttp 或者类似的 http 包,则会非常困难。

1.5 v1 版本:基础 Server 实现

首先我们定义 HandleFunc 类型和 Server 接口:

package web

import (
	"net/http"
)

// HandleFunc 定义业务处理函数类型
// 注意:我们采用 HandleFunc 而不是 HandlerFunc,动词 Handle 更符合 Go 命名风格
type HandleFunc func(ctx *Context)

// Server 代表服务器的核心抽象
type Server interface {
	http.Handler
	// Start 启动服务器
	Start(addr string) error

	// AddRoute 注册路由
	// 核心方法只需要一个:AddRoute
	// 其他 Get/Post 等方法都委托给 AddRoute
	AddRoute(method string, path string, handler HandleFunc)

	// 以下是针对不同 HTTP 方法的便捷注册方法
	Get(path string, handler HandleFunc)
	Post(path string, handler HandleFunc)
	Put(path string, handler HandleFunc)
	Delete(path string, handler HandleFunc)
	// ... 其他 HTTP 方法
}

接下来实现 HTTPServer:

package web

import (
	"fmt"
	"net/http"
)

// HTTPServer 是 Server 的基于 net/http 的实现
type HTTPServer struct {
	// addr 服务器监听地址
	addr string

	// router 路由树
	router
}

// NewHTTPServer 创建 HTTPServer 实例
// 用户只能通过 NewHTTPServer 来创建服务器实例
// 避免用户直接 s := &HTTPServer{} 引起 panic
func NewHTTPServer(addr string) *HTTPServer {
	return &HTTPServer{
		addr:   addr,
		router: newRouter(),
	}
}

// Start 启动服务器
// v1 版本直接使用 http.ListenAndServe
// 后续可以替换为内部创建 http.Server,或者使用 http.Serve 获得更大灵活性
func (h *HTTPServer) Start(addr string) error {
	return http.ListenAndServe(addr, h)
}

// AddRoute 注册路由
// 这是核心的路由注册方法,所有 Get/Post 等便捷方法都委托给它
func (h *HTTPServer) AddRoute(method string, path string, handler HandleFunc) {
	h.router.addRoute(method, path, handler)
}

// ServeHTTP 是整个 Web 框架的核心入口
// 作为 http 包与 Web 框架的关联点
// 在这里完成:
// 1. Context 构建
// 2. 路由匹配
// 3. 执行业务逻辑
func (h *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// Step 1: 构建 Context
	ctx := &Context{
		Req:  r,
		Resp: w,
	}

	// Step 2 & 3: 查找路由树并执行业务逻辑
	h.serve(ctx)
}

func (h *HTTPServer) serve(ctx *Context) {
	// 根据 HTTP 方法和路径查找路由
	mi, ok := h.router.findRoute(ctx.Req.Method, ctx.Req.URL.Path)
	if !ok || mi.n == nil || mi.n.handler == nil {
		// 未找到路由,返回 404
		ctx.Resp.WriteHeader(http.StatusNotFound)
		_, _ = ctx.Resp.Write([]byte("404 NOT FOUND"))
		return
	}

	// 将路径参数注入到 Context 中
	ctx.PathParams = mi.pathParams

	// 执行命中的业务逻辑
	mi.n.handler(ctx)
}

类比:一次 HTTP 请求进来的完整旅程,像餐厅点单——前台(ServeHTTP)接单、写上订单号(构建 Context),后厨按菜单(路由树)找到对应厨师(handler),做不出来就回一句"没有这道菜”(404)。

下面这张时序图把"请求进来 → 构建 Context → 查路由 → 执行 / 404"的链路画清楚:

sequenceDiagram
    participant C as 客户端
    participant S as HTTPServer
    participant R as 路由树
    C->>S: HTTP 请求
    S->>S: ServeHTTP 构建 Context
    S->>R: findRoute(method, path)
    R-->>S: matchInfo(节点 + 路径参数)
    alt 命中且有 handler
        S->>S: 执行业务逻辑 handler(ctx)
    else 未命中
        S-->>C: 404 NOT FOUND
    end

1.6 Context 定义

Context 是代表请求上下文的抽象,目前先定义一个基础版本:

package web

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
)

// Context 代表请求上下文
type Context struct {
	// Req 原始请求对象
	Req *http.Request
	// Resp 响应写入器
	Resp http.ResponseWriter

	// PathParams 路径参数
	// 例如 /user/:id 匹配后,id 的值存在这里
	PathParams map[string]string

	// 缓存的查询参数
	queryValues url.Values

	// 响应状态码
	StatusCode int
}

// JSON 返回 JSON 响应
func (c *Context) JSON(status int, data interface{}) {
	c.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
	c.Resp.WriteHeader(status)
	encoder := json.NewEncoder(c.Resp)
	if err := encoder.Encode(data); err != nil {
		http.Error(c.Resp, err.Error(), http.StatusInternalServerError)
	}
}

// String 返回字符串响应
func (c *Context) String(status int, msg string) {
	c.Resp.Header().Set("Content-Type", "text/plain; charset=utf-8")
	c.Resp.WriteHeader(status)
	_, _ = c.Resp.Write([]byte(msg))
}

// Query 获取查询参数
func (c *Context) Query(key string) string {
	if c.queryValues == nil {
		c.queryValues = c.Req.URL.Query()
	}
	return c.queryValues.Get(key)
}

// PostForm 获取表单参数
func (c *Context) PostForm(key string) string {
	return c.Req.FormValue(key)
}

1.7 注册路由 API 设计

大体上有两类方法:

  • 针对任意方法的:如 Gin 和 Iris 的 Handle 方法、Echo 的 Add 方法
  • 针对不同 HTTP 方法的:如 GetPOSTDelete,这一类方法基本上都是委托给前一类方法

所以实际上,核心方法只需要有一个:AddRoute。其它的方法都建立在这上面。

// Get 注册 GET 方法路由
func (h *HTTPServer) Get(path string, handler HandleFunc) {
	h.AddRoute(http.MethodGet, path, handler)
}

// Post 注册 POST 方法路由
func (h *HTTPServer) Post(path string, handler HandleFunc) {
	h.AddRoute(http.MethodPost, path, handler)
}

// Put 注册 PUT 方法路由
func (h *HTTPServer) Put(path string, handler HandleFunc) {
	h.AddRoute(http.MethodPut, path, handler)
}

// Delete 注册 DELETE 方法路由
func (h *HTTPServer) Delete(path string, handler HandleFunc) {
	h.AddRoute(http.MethodDelete, path, handler)
}

1.8 AddRoute 方法设计思考

  • AddRoute 方法只接收一个 HandleFunc。因为我希望它只注册业务逻辑。即便真有多个的场景,用户可以自己组合成一个。
  • 如果允许注册多个,那么在实现的时候就要考虑,其中一个失败了,是否还允许继续执行下去;反过来,如果其中一个 HandleFunc 要中断执行,怎么中断。
  • 这里采用新的名字 AddRoute,更加贴近这个方法本意(注册路由)。

为什么只允许注册一个 HandleFunc:

  • Gin 和 Iris 最后一个是不定参数,那么完全可以一个都不传,如 PUT("path"),这个在编译期无法发现
  • Echo 是将中间件注册逻辑和路由注册逻辑合并在了一起

1.9 关于 addRoute 访问修饰符的设计

在接口 Server 中,AddRoute 被声明为公开方法。但在 HTTPServer 的具体实现中,底层的 addRoute 方法被设计为私有方法(小写 a)。这带来两个好处:

  • 限制用户实现 Server 接口:用户只能通过 GetPost 等便捷方法来注册路由,无法绕过这些方法直接调用 addRoute,从而保证了路由注册的统一性和安全性。
  • 强制使用合法 HTTP 方法:由于用户只能通过 GetPost 等封装好的方法来注册,method 参数永远都是合法的 HTTP 方法(如 GETPOST),因此无需对 method 参数做额外校验。

注意:同理,HTTPServer 本身也可以设计为私有类型(httpServer),用户只能通过 NewHTTPServer 工厂方法来创建实例,避免用户直接 s := &HTTPServer{} 引起 panic。


二、路由树 v2:全静态匹配

2.1 路由树概述

我们分成以下步骤来实现一颗路由树:

  1. 全静态匹配(v2)
  2. 支持通配符匹配(v3)
  3. 支持参数路由(v3)

所谓的静态匹配,就是路径的每一段都必须严格相等。

2.2 各框架路由树实现对比

Beego 实现

  • ControllerRegister:类似于容器,放着所有的路由树
  • 路由树是按照 HTTP method 来组织的
  • Tree:一棵树由根节点 + 子树构成(递归式定义)
  • leafInfo:代表叶子节点

Gin 实现

  • methodTrees:按照 HTTP 方法组织路由树
  • methodTree:单棵树,采用 children 的定义方式
  • node:节点维持 children,有 nodeType 和 wildChild 标记特殊节点
  • Gin 利用路由的公共前缀来构造路由树(前缀树)

Echo 实现

  • routers 按照 Host 组织(命名空间)
  • Router:路由注册中心
  • Route:具体路由
  • node:树节点设计

开源,讲究的是一个以简为美;但是工作,讲究刷 KPI 唬人为要。因此开源要克制,而工作要泛滥。

2.3 设计总结

  • 归根结底就是设计一颗多叉树
  • 按照 HTTP 方法来组织路由树,每个 HTTP 方法一棵树
  • 节点维持住自己的子节点
  • children 方式比递归子树方式实现简单,性能差异不大

类比:路由树就像公司通讯录,按"HTTP 方法"分册(GET 册、POST 册……),每册里再按路径一段段往下翻。所以 router 本质是一个 map[method]*node,每个方法一棵树。

下面这张图示意 router 如何按方法分册、再逐段生长出子节点:

flowchart LR
    RT[router 总入口] -->|GET| T1[GET 树根 /]
    RT -->|POST| T2[POST 树根 /]
    T1 --> N1[user]
    N1 --> N2[home]
    N1 --> N3[profile]
    N1 --> N4[create]

2.4 核心数据结构

首先定义路由树的核心类型:

package web

import "strings"

// router 维持住了所有的路由树
// 是整个路由注册和查找的总入口
// 维护一个 map,按照 HTTP 方法来组织路由树
type router struct {
	// trees key 是 HTTP 方法,value 是该方法对应的路由树根节点
	trees map[string]*node
}

func newRouter() router {
	return router{
		trees: make(map[string]*node),
	}
}

// node 代表路由树上的一个节点
type node struct {
	// path 当前节点对应的路径段
	path string

	// children 子节点 map
	// 使用 map 结构是为了快速查找到子节点
	children map[string]*node

	// handler 该节点对应的业务处理函数
	// 只有叶子节点才有 handler
	handler HandleFunc

	// ============ v3 新增字段 ============
	// isWild 是否是通配符节点 *
	isWild bool
	// isParam 是否是参数节点 :id
	isParam bool
	// paramName 参数名,例如 :id 的 "id"
	paramName string
	// wildChild 通配符子节点 *
	wildChild *node
	// paramChild 参数子节点 :xxx
	paramChild *node
}

2.5 TDD 测试驱动开发

我们使用简化版的 TDD:

  1. 定义 API
  2. 定义测试
  3. 添加测试用例
  4. 实现,确保通过测试
  5. 重复 3-4
  6. 重复 1-5

先编写测试用例:

package web

import (
	"fmt"
	"testing"
)

func TestRouter_addRoute(t *testing.T) {
	// 测试路由注册
	testRoutes := []struct {
		method string
		path   string
	}{
		{"GET", "/"},
		{"GET", "/user"},
		{"GET", "/user/home"},
		{"GET", "/user/profile"},
		{"POST", "/user/create"},
	}

	r := newRouter()
	for _, tr := range testRoutes {
		r.addRoute(tr.method, tr.path, func(ctx *Context) {
			ctx.String(200, fmt.Sprintf("hit: %s %s", tr.method, tr.path))
		})
	}

	// 验证根节点
	if r.trees["GET"] == nil {
		t.Fatal("根节点不应为空")
	}
	if r.trees["GET"].path != "/" {
		t.Fatalf("根节点路径应为 /, 实际是 %s", r.trees["GET"].path)
	}

	// 验证 /user 节点
	userNode := r.trees["GET"].children["user"]
	if userNode == nil {
		t.Fatal("/user 节点不应为空")
	}
}

func TestRouter_findRoute(t *testing.T) {
	// 先注册路由
	testRoutes := []struct {
		method string
		path   string
	}{
		{"GET", "/"},
		{"GET", "/user"},
		{"GET", "/user/home"},
		{"POST", "/user/create"},
	}

	r := newRouter()
	for _, tr := range testRoutes {
		path := tr.path
		r.addRoute(tr.method, tr.path, func(ctx *Context) {
			ctx.String(200, path)
		})
	}

	// 测试查找
	testCases := []struct {
		method   string
		path     string
		found    bool
		wantPath string
	}{
		{"GET", "/", true, "/"},
		{"GET", "/user", true, "/user"},
		{"GET", "/user/home", true, "/user/home"},
		{"POST", "/user/create", true, "/user/create"},
		{"GET", "/not/exist", false, ""},
		{"GET", "/user/profile", false, ""},
	}

	for _, tc := range testCases {
		t.Run(tc.method+" "+tc.path, func(t *testing.T) {
			mi, ok := r.findRoute(tc.method, tc.path)
			if ok != tc.found {
				t.Fatalf("期望 found=%v, 实际 %v", tc.found, ok)
			}
			if !ok {
				return
			}
			if mi.n.path != tc.wantPath {
				t.Fatalf("期望路径 %s, 实际 %s", tc.wantPath, mi.n.path)
			}
		})
	}
}

// 非法用例测试
func TestRouter_addRoute_Illegal(t *testing.T) {
	r := newRouter()

	// path 必须以 / 开头
	assertPanic(t, func() {
		r.addRoute("GET", "user", nil)
	})

	// path 结尾不能有 /
	assertPanic(t, func() {
		r.addRoute("GET", "/user/", nil)
	})

	// 中间不能有连续的 /
	assertPanic(t, func() {
		r.addRoute("GET", "/user//home", nil)
	})

	// 重复注册应该 panic
	r.addRoute("GET", "/user", func(ctx *Context) {})
	assertPanic(t, func() {
		r.addRoute("GET", "/user", func(ctx *Context) {})
	})
}

func assertPanic(t *testing.T, f func()) {
	t.Helper()
	defer func() {
		if err := recover(); err == nil {
			t.Fatal("期望 panic 但没有发生")
		}
	}()
	f()
}

2.6 addRoute 实现(全静态匹配)

// addRoute 注册路由
// 为什么用 panic 而不是返回 error?
// 因为用户必须注册完路由才能启动服务器,启动前 panic 可以尽早发现问题
// 如果返回 error,用户可能忽略错误处理
func (r *router) addRoute(method string, path string, handler HandleFunc) {
	// 校验 path
	if path == "" {
		panic("web: 路由路径不能为空")
	}
	if path[0] != '/' {
		panic("web: 路由路径必须以 / 开头")
	}
	if path != "/" && path[len(path)-1] == '/' {
		panic("web: 路由路径不能以 / 结尾")
	}
	if strings.Contains(path, "//") {
		panic("web: 路由路径不能包含连续的 //")
	}

	// 获取或创建该 HTTP 方法对应的根节点
	root, ok := r.trees[method]
	if !ok {
		root = &node{path: "/"}
		r.trees[method] = root
	}

	// 处理根节点
	if path == "/" {
		if root.handler != nil {
			panic("web: 路由冲突,重复注册 [/]")
		}
		root.handler = handler
		return
	}

	// 去掉开头的 /,然后按 / 切割
	segs := strings.Split(path[1:], "/")
	cur := root

	// 沿着子节点层层深入
	for _, seg := range segs {
		if seg == "" {
			// 空字符串说明有连续的 /,前面已经校验过了,理论上不会到这里
			panic("web: 路由路径格式错误")
		}

		// 查找或创建子节点
		child, ok := cur.children[seg]
		if !ok {
			child = &node{
				path:     seg,
				children: make(map[string]*node),
			}
			// v2 版本:静态匹配,children 初始化为空 map
			cur.children = initOrGetChildren(cur)
			cur.children[seg] = child
		}
		cur = child
	}

	// 到达目标节点,设置 handler
	if cur.handler != nil {
		panic(fmt.Sprintf("web: 路由冲突,重复注册 [%s]", path))
	}
	cur.handler = handler
}

func initOrGetChildren(n *node) map[string]*node {
	if n.children == nil {
		n.children = make(map[string]*node)
	}
	return n.children
}

2.7 findRoute 实现(全静态匹配)

首先定义 matchInfo 来保存匹配结果:

// matchInfo 路由匹配结果
type matchInfo struct {
	// n 匹配到的节点
	n *node

	// pathParams 路径参数
	pathParams map[string]string
}

findRoute 方法实现:

// findRoute 查找路由
// 注意:findRoute 只是返回节点,调用者需要进一步检查是否有 handler
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, "/"), "/")
	cur := root
	mi := &matchInfo{}

	for _, seg := range segs {
		// v2 版本:只支持全静态匹配
		child, ok := cur.children[seg]
		if !ok {
			return nil, false
		}
		cur = child
	}

	mi.n = cur
	return mi, true
}

说明findRoute 只负责返回匹配到的节点,并不会进一步判断该节点是否有 handler。调用者(如 HTTPServer.serve)需要自行检查 handler 是否为 nil,以决定返回 404 还是执行业务逻辑。这个设计让 findRoute 职责更单一,便于后续 v3 扩展。


三、路由树 v3:通配符与参数路由

3.1 通配符匹配设计

所谓通配符匹配,是指用 * 号来表达匹配任何路径。要考虑几个问题:

  • 如果路径是 /a/b/c 能不能命中 /a/* 路由?
  • 如果注册了两个路由 /user/123/home/user/*/*。那么输入路径 /user/123/detail 能不能命中 /user/*/*?

这两个都是理论上可以,但是不应该命中

  • 从用户的角度来说,他们不应该设计这种路由
  • 后者要求可回溯的路由匹配,典型的投入大产出低的特性

匹配优先级:静态匹配 > 参数路由 > 通配符匹配

为什么是这个顺序?因为最具体、最可预期的路由应该优先。静态路由是"写死的地址”,最确定;参数路由还能接受任意值,确定性次之;通配符 * 最"贪婪"、最不具体,所以垫底。

下面这张图展示 childOf 在遇到一个路径段时,如何按优先级逐层尝试:

flowchart TD
    REQ[收到一个路径段] --> S1{静态子节点匹配?}
    S1 -->|是| HIT1[命中静态路由]
    S1 -->|否| S2{有参数子节点 :xxx?}
    S2 -->|是| HIT2[命中参数路由 提取参数值]
    S2 -->|否| S3{有通配符子节点 *?}
    S3 -->|是| HIT3[命中通配符 吞掉剩余段]
    S3 -->|否| NF[匹配失败 返回 404]

3.2 参数路径设计

所谓参数路径,就是指在路径中带上参数,同时这些参数对应的值可以被业务取出来使用。

例如:/user/:id,如果输入路径 /user/123,那么会命中这个路由,并且 id = 123

需要考虑:

  • 允不允许同样的参数路径和通配符匹配一起注册?例如同时注册 /user/*/user/:id
    • 可以,但是没必要,用户也不应该设计这种路由

3.3 节点结构增强

我们需要增强 node 结构来支持通配符和参数路由:

// node 代表路由树上的一个节点(v3 完整版)
type node struct {
	// path 当前节点对应的路径段
	path string

	// children 静态子节点 map
	children map[string]*node

	// handler 该节点对应的业务处理函数
	handler HandleFunc

	// ============ v3 新增字段 ============
	// isWild 是否是通配符节点 *
	// * 匹配当前段及其后的所有段
	isWild bool
	// isParam 是否是参数节点 :xxx
	// :xxx 只匹配当前段
	isParam bool
	// paramName 参数名,例如 :id 的 "id"
	paramName string
	// wildChild 通配符子节点 *
	wildChild *node
	// paramChild 参数子节点 :xxx
	paramChild *node
}

3.4 childOrCreate 方法变更

创建子节点时需要处理通配符和参数节点:

// childOrCreate 查找或创建子节点
// v3 版本:支持通配符 * 和参数 :xxx
func (n *node) childOrCreate(seg string) *node {
	// 处理通配符 *
	if seg == "*" {
		if n.paramChild != nil {
			panic("web: 同一位置不能同时注册通配符路由和参数路由")
		}
		if n.wildChild != nil {
			return n.wildChild
		}
		n.wildChild = &node{
			path:   seg,
			isWild: true,
		}
		return n.wildChild
	}

	// 处理参数路由 :xxx
	if strings.HasPrefix(seg, ":") {
		if n.wildChild != nil {
			panic("web: 同一位置不能同时注册通配符路由和参数路由")
		}
		paramName := seg[1:]
		if paramName == "" {
			panic("web: 参数路由格式错误,: 后必须跟参数名")
		}
		if n.paramChild != nil {
			// 检查是否是同一个参数名
			if n.paramChild.paramName != paramName {
				panic("web: 同一位置不能注册不同的参数路由")
			}
			return n.paramChild
		}
		n.paramChild = &node{
			path:      seg,
			isParam:   true,
			paramName: paramName,
		}
		return n.paramChild
	}

	// 静态节点
	if n.children == nil {
		n.children = make(map[string]*node)
	}
	child, ok := n.children[seg]
	if !ok {
		child = &node{
			path: seg,
		}
		n.children[seg] = child
	}
	return child
}

3.5 addRoute 完整版(v3)

// addRoute v3 完整版:支持静态、通配符、参数路由
func (r *router) addRoute(method string, path string, handler HandleFunc) {
	if path == "" {
		panic("web: 路由路径不能为空")
	}
	if path[0] != '/' {
		panic("web: 路由路径必须以 / 开头")
	}
	if path != "/" && path[len(path)-1] == '/' {
		panic("web: 路由路径不能以 / 结尾")
	}
	if strings.Contains(path, "//") {
		panic("web: 路由路径不能包含连续的 //")
	}

	root, ok := r.trees[method]
	if !ok {
		root = &node{path: "/"}
		r.trees[method] = root
	}

	if path == "/" {
		if root.handler != nil {
			panic("web: 路由冲突,重复注册 [/]")
		}
		root.handler = handler
		return
	}

	segs := strings.Split(path[1:], "/")
	cur := root

	for _, seg := range segs {
		if seg == "" {
			panic("web: 路由路径格式错误")
		}
		// 使用 childOrCreate 查找或创建子节点
		cur = cur.childOrCreate(seg)
	}

	if cur.handler != nil {
		panic(fmt.Sprintf("web: 路由冲突,重复注册 [%s]", path))
	}
	cur.handler = handler
}

3.6 childOf 方法(查找子节点)

// childOf 查找匹配的子节点
// v3 版本:按优先级查找:静态 > 参数 > 通配符
func (n *node) childOf(seg string) (*node, bool) {
	// 1. 优先匹配静态节点
	if n.children != nil {
		if child, ok := n.children[seg]; ok {
			return child, true
		}
	}

	// 2. 其次匹配参数节点 :xxx
	if n.paramChild != nil {
		return n.paramChild, true
	}

	// 3. 最后匹配通配符节点 *
	if n.wildChild != nil {
		return n.wildChild, true
	}

	return nil, false
}

3.7 findRoute 完整版(v3)

// findRoute v3 完整版:支持静态、通配符、参数路由
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}, root.handler != nil
	}

	segs := strings.Split(strings.Trim(path, "/"), "/")
	cur := root
	mi := &matchInfo{
		pathParams: make(map[string]string),
	}

	for _, seg := range segs {
		var matched bool
		cur, matched = cur.childOf(seg)
		if !matched {
			return nil, false
		}

		// 如果是通配符节点 *,直接返回(* 匹配剩余所有段)
		if cur.isWild {
			mi.n = cur
			return mi, cur.handler != nil
		}

		// 如果是参数节点 :xxx,记录参数值
		if cur.isParam {
			mi.pathParams[cur.paramName] = seg
		}
	}

	mi.n = cur
	return mi, cur.handler != nil
}

参数值提取说明:在路径匹配的过程中,每当遇到参数节点(isParam == true),就将当前路径段 seg 的值记录到 mi.pathParams 中,以 paramName 为 key。这样业务逻辑层就可以通过 ctx.PathParams["id"] 直接取出对应的参数值。例如注册了 /user/:id,当请求 /user/123 时,最终 mi.pathParams["id"] = "123"

3.8 v3 版本测试用例

func TestRouter_wildcard(t *testing.T) {
	r := newRouter()

	// 注册通配符路由
	r.addRoute("GET", "/user/*", func(ctx *Context) {})
	r.addRoute("GET", "/static/*", func(ctx *Context) {})
	r.addRoute("GET", "/a/b/c", func(ctx *Context) {})

	// 测试通配符匹配
	testCases := []struct {
		path  string
		found bool
	}{
		{"/user/123", true},
		{"/user/profile", true},
		{"/user/a/b/c", true},
		{"/static/css/style.css", true},
		{"/static/js/app.js", true},
		{"/other/path", false},
	}

	for _, tc := range testCases {
		_, ok := r.findRoute("GET", tc.path)
		if ok != tc.found {
			t.Errorf("path %s: 期望 found=%v, 实际 %v", tc.path, tc.found, ok)
		}
	}
}

func TestRouter_param(t *testing.T) {
	r := newRouter()

	// 注册参数路由
	r.addRoute("GET", "/user/:id", func(ctx *Context) {})
	r.addRoute("GET", "/user/:id/profile", func(ctx *Context) {})
	r.addRoute("GET", "/user/:id/order/:oid", func(ctx *Context) {})

	// 测试参数匹配和参数提取
	testCases := []struct {
		path       string
		found      bool
		wantParams map[string]string
	}{
		{"/user/123", true, map[string]string{"id": "123"}},
		{"/user/456/profile", true, map[string]string{"id": "456"}},
		{"/user/789/order/100", true, map[string]string{"id": "789", "oid": "100"}},
	}

	for _, tc := range testCases {
		mi, ok := r.findRoute("GET", tc.path)
		if ok != tc.found {
			t.Errorf("path %s: 期望 found=%v, 实际 %v", tc.path, tc.found, ok)
			continue
		}
		if !ok {
			continue
		}
		for k, v := range tc.wantParams {
			if mi.pathParams[k] != v {
				t.Errorf("path %s: 参数 %s 期望 %s, 实际 %s", tc.path, k, v, mi.pathParams[k])
			}
		}
	}
}

func TestRouter_priority(t *testing.T) {
	// 测试匹配优先级:静态 > 参数 > 通配符
	r := newRouter()

	// 静态路由
	r.addRoute("GET", "/user/home", func(ctx *Context) {
		ctx.String(200, "static")
	})
	// 参数路由
	r.addRoute("GET", "/user/:id", func(ctx *Context) {
		ctx.String(200, "param")
	})
	// 通配符路由
	r.addRoute("GET", "/user/*", func(ctx *Context) {
		ctx.String(200, "wildcard")
	})

	// /user/home 应该命中静态路由
	mi, ok := r.findRoute("GET", "/user/home")
	if !ok {
		t.Fatal("应该匹配到路由")
	}
	// 验证命中的是静态节点(不是参数节点,也不是通配符节点)
	if mi.n.isParam || mi.n.isWild {
		t.Error("/user/home 应该命中静态路由")
	}

	// /user/123 应该命中参数路由
	mi, ok = r.findRoute("GET", "/user/123")
	if !ok {
		t.Fatal("应该匹配到路由")
	}
	if !mi.n.isParam {
		t.Error("/user/123 应该命中参数路由")
	}
	if mi.pathParams["id"] != "123" {
		t.Errorf("参数 id 应该是 123,实际是 %s", mi.pathParams["id"])
	}

	// /user/a/b 应该命中通配符路由
	mi, ok = r.findRoute("GET", "/user/a/b")
	if !ok {
		t.Fatal("应该匹配到路由")
	}
	if !mi.n.isWild {
		t.Error("/user/a/b 应该命中通配符路由")
	}
}

3.9 使用示例

package main

import (
	"fmt"
	"net/http"
	"web"
)

func main() {
	// 创建服务器
	s := web.NewHTTPServer(":8080")

	// 1. 静态路由
	s.Get("/", func(ctx *web.Context) {
		ctx.String(http.StatusOK, "Hello, Web Framework!")
	})

	s.Get("/user/home", func(ctx *web.Context) {
		ctx.String(http.StatusOK, "User Home Page")
	})

	// 2. 参数路由
	s.Get("/user/:id", func(ctx *web.Context) {
		id := ctx.PathParams["id"]
		ctx.String(http.StatusOK, fmt.Sprintf("User ID: %s", id))
	})

	s.Get("/user/:id/order/:oid", func(ctx *web.Context) {
		uid := ctx.PathParams["id"]
		oid := ctx.PathParams["oid"]
		ctx.String(http.StatusOK, fmt.Sprintf("User %s, Order %s", uid, oid))
	})

	// 3. 通配符路由
	s.Get("/static/*", func(ctx *web.Context) {
		ctx.String(http.StatusOK, "Static file: "+ctx.Req.URL.Path)
	})

	// POST 路由
	s.Post("/user/create", func(ctx *web.Context) {
		ctx.String(http.StatusOK, "Create User")
	})

	// 启动服务器
	fmt.Println("Server starting on :8080...")
	if err := s.Start(":8080"); err != nil {
		panic(err)
	}
}

3.10 路由树总结

注册路由的注意事项

  • 已经注册了的路由,无法被覆盖,例如 /user/home 注册两次,会冲突
  • path 必须以 / 开始并且结尾不能有 /,中间也不允许有连续的 /
  • 不能在同一个位置注册不同的参数路由,例如 /user/:id/user/:name 冲突
  • 不能在同一个位置同时注册通配符路由和参数路由,例如 /user/:id/user/* 冲突
  • 同名路径参数,在路由匹配的时候,值会被覆盖,例如 /user/:id/abc/:id,那么 /user/123/abc/456 最终 id = 456

为什么在注册路由用 panic?

俗话说,遇事不决用 error。为什么注册路由的过程我们用 panic?

  • 如果返回 error,例如 Get 方法返回 error,这要求用户必须处理返回的 error
  • 用户必须要注册完路由,才能启动 HTTPServer
  • 启动之前 panic 代表应用还没运行,可以在开发阶段尽早发现问题

路由树是线程安全的吗?

显然不是线程安全的。

  • 我们要求用户必须要注册完路由才能启动 HTTPServer
  • 正常用法都是在启动之前依次注册路由,不存在并发场景
  • 运行期间动态注册路由,没必要支持,典型的为了解决 1% 的问题,引入 99% 的代码
  • 如果真的需要动态注册,用装饰器模式包装一层加锁即可

四、面试要点

4.1 Server 相关

  • HTTP 服务器的生命周期? 一般来说就是启动、运行和关闭。在这三个阶段的前后都可以插入生命周期回调。面试生命周期多半是问生命周期回调,例如怎么做 Web 服务的服务发现?就是利用启动后回调,将服务注册到服务中心。

  • HTTP Server 功能? 不同框架有不同叫法(Gin 叫 Engine),基本功能都是:路由注册、生命周期控制、作为与 http 包结合的桥梁。

4.2 路由树相关

  • 路由树算法? 核心就是前缀树(Trie)。前缀的意思是:两个节点共同的前缀被抽取出来作为父亲节点,避免重复存储。在我们的实现中,按 / 切割路径,每一段作为一个节点。

  • 路由匹配的优先级? 和 Web 框架设计相关。我们的设计是:静态匹配 > 路径参数 > 通配符匹配。优先级越高,越先被尝试匹配。

  • 路由查找会回溯吗? 和框架相关,我们不支持。可回溯匹配是指:发现 /user/123/home 匹配不上后,回溯回去尝试 /user/*/* 进一步查找。这是典型的投入大产出低的特性——从实现角度看并不难,但从用户角度来说,不应该设计这种路由。

  • Web 框架怎么组织路由树? 一个 HTTP 方法一颗路由树是主流做法(Gin、Beego 都是如此)。也可以设计成一颗树,每个节点标记支持的 HTTP 方法,但实现更复杂。

  • 路由查找的性能受什么影响? 核心是路由树的高度——高度越低,查找越快。次要因素是路由树的宽度(即每个节点的 children map 大小)。因此,合理的路由注册顺序(将更具体的路由放在前面)可以在一定程度上优化查找性能。

  • 路由树是线程安全的吗? 大多数 Web 框架的路由树都不是线程安全的,这是为了性能。我们要求先注册路由、后启动服务器,正常用法在启动前依次注册路由,不存在并发场景。如果需要运行时动态添加路由,用装饰器模式包装一层加锁即可。

  • 具体匹配方式原理? 核心是划定优先级,然后按优先级挨个匹配。课程上讨论了静态匹配、通配符匹配和路径参数匹配三种。作业可以实现正则匹配,原理相同:划定优先级后依次尝试。

五、路由树 v4:正则匹配

自测题与动手练习

自测题(合上书能答出来,才算懂):

  1. Web 框架三大抽象 Server / Context / 路由树,各自负责什么?少了一个会怎样?

    答:Server 管生命周期与桥梁;Context 封装单次请求上下文;路由树管匹配分发。缺 Server 就没法接入 net/http;缺路由树 请求无处可去;缺 Context 业务拿不到请求信息。

  2. v1(只组合 http.Handler)与 v2(加 Start)的核心取舍是什么?为什么作者说 v1/v2 都"直接耦合了 net/http"?

    答:v1 极简但没法控制生命周期(优雅退出困难);v2 拥有独立生命周期管理。两者都直接依赖 Go 自带 http 包,想换 fasthttp 很难。

  3. 路由树为什么按 HTTP 方法各建一棵树,而不是一颗大树?

    答:方法分册实现简单、查找快、语义清晰;一颗大树要在每个节点标记支持的方法,复杂度和出错概率都更高。

  4. findRoute 的匹配优先级为什么是"静态 > 参数 > 通配符"?* 为什么"贪婪"却垫底?

    答:最具体的路由应优先命中;* 能吞掉剩余所有段,最不具体,放最底避免误伤具体路由。

  5. 为什么注册路由用 panic 而不是返回 error?路由树为什么不是线程安全的?

    答:注册必须在启动前完成,panic 能在开发期尽早暴露问题;路由树只在启动前被单线程注册,运行期不动态改,故不保证线程安全(动态注册需自行加锁)。

动手练习(建议真做一遍):

  1. 把 v2 的 addRoute / findRoute 单元测跑通,加一条 /order/list 路由,断言 findRoute("GET","/order/list") 能命中。
  2. 给 v3 注册 /user/:id/user/:id/order/:oid,请求 /user/789/order/100,打印 ctx.PathParams 验证 id=789, oid=100
  3. 故意重复注册 /user/home,确认框架按设计 panic;再尝试运行期并发注册,观察竞态现象。

本章小结

  • Web 框架 = Server(生命周期)+ Context(请求上下文)+ 路由树(匹配分发),三者边界清晰。
  • Server 从 v1 到 v2 的演进,是在"易用"和"可控(优雅退出 / 生命周期)“之间找平衡。
  • routermap[method]*node,每个 HTTP 方法一颗前缀树,路径按 / 切段成节点。
  • 匹配优先级"静态 > 参数 > 通配符"保证最具体的路由优先;findRoute 职责单一,只返回节点。
  • 注册用 panic 尽早暴露错误,路由树非线程安全是因为约定"先注册、后启动”。下一步可动手实现 v4 正则匹配。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!