学习目标
学完本章你应该能够:
- 讲清 Web 框架三大抽象(Server / Context / 路由树)各自的职责与边界。
- 从
http.Handler接口出发,解释框架如何用 v1 / v2 两种 Server 设计平衡"易用"与"可控"。 - 用一颗多叉树(按 HTTP 方法分册)描述路由注册与查找的数据结构。
- 说清静态 / 参数 / 通配符三种匹配的优先级,以及
findRoute如何按优先级逐段匹配。 - 在面试中把"路由树算法 = 前缀树"“为什么注册用 panic"“路由树为何非线程安全"讲清楚。
前置知识:
- Go 基础:
interface、map、slice、字符串处理 net/http的基本使用(知道http.ListenAndServe怎么用)
本章你会动手做的事:
- 跑通 v2 全静态路由的
addRoute/findRoute单元测试,观察路由树怎么长出来。 - 给 v3 加一条
/user/:id路由,验证路径参数能被正确提取到ctx.PathParams。 - 故意重复注册同一条路由,确认框架按设计 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
end1.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 方法的:如
Get、POST、Delete,这一类方法基本上都是委托给前一类方法
所以实际上,核心方法只需要有一个: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 接口:用户只能通过
Get、Post等便捷方法来注册路由,无法绕过这些方法直接调用addRoute,从而保证了路由注册的统一性和安全性。 - 强制使用合法 HTTP 方法:由于用户只能通过
Get、Post等封装好的方法来注册,method参数永远都是合法的 HTTP 方法(如GET、POST),因此无需对method参数做额外校验。
注意:同理,
HTTPServer本身也可以设计为私有类型(httpServer),用户只能通过NewHTTPServer工厂方法来创建实例,避免用户直接s := &HTTPServer{}引起 panic。
二、路由树 v2:全静态匹配
2.1 路由树概述
我们分成以下步骤来实现一颗路由树:
- 全静态匹配(v2)
- 支持通配符匹配(v3)
- 支持参数路由(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:
- 定义 API
- 定义测试
- 添加测试用例
- 实现,确保通过测试
- 重复 3-4
- 重复 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 方法,但实现更复杂。
路由查找的性能受什么影响? 核心是路由树的高度——高度越低,查找越快。次要因素是路由树的宽度(即每个节点的
childrenmap 大小)。因此,合理的路由注册顺序(将更具体的路由放在前面)可以在一定程度上优化查找性能。路由树是线程安全的吗? 大多数 Web 框架的路由树都不是线程安全的,这是为了性能。我们要求先注册路由、后启动服务器,正常用法在启动前依次注册路由,不存在并发场景。如果需要运行时动态添加路由,用装饰器模式包装一层加锁即可。
具体匹配方式原理? 核心是划定优先级,然后按优先级挨个匹配。课程上讨论了静态匹配、通配符匹配和路径参数匹配三种。作业可以实现正则匹配,原理相同:划定优先级后依次尝试。
五、路由树 v4:正则匹配
自测题与动手练习
自测题(合上书能答出来,才算懂):
Web 框架三大抽象 Server / Context / 路由树,各自负责什么?少了一个会怎样?
答:Server 管生命周期与桥梁;Context 封装单次请求上下文;路由树管匹配分发。缺 Server 就没法接入
net/http;缺路由树 请求无处可去;缺 Context 业务拿不到请求信息。v1(只组合
http.Handler)与 v2(加Start)的核心取舍是什么?为什么作者说 v1/v2 都"直接耦合了 net/http"?答:v1 极简但没法控制生命周期(优雅退出困难);v2 拥有独立生命周期管理。两者都直接依赖 Go 自带 http 包,想换 fasthttp 很难。
路由树为什么按 HTTP 方法各建一棵树,而不是一颗大树?
答:方法分册实现简单、查找快、语义清晰;一颗大树要在每个节点标记支持的方法,复杂度和出错概率都更高。
findRoute的匹配优先级为什么是"静态 > 参数 > 通配符"?*为什么"贪婪"却垫底?答:最具体的路由应优先命中;
*能吞掉剩余所有段,最不具体,放最底避免误伤具体路由。为什么注册路由用
panic而不是返回error?路由树为什么不是线程安全的?答:注册必须在启动前完成,panic 能在开发期尽早暴露问题;路由树只在启动前被单线程注册,运行期不动态改,故不保证线程安全(动态注册需自行加锁)。
动手练习(建议真做一遍):
- 把 v2 的
addRoute/findRoute单元测跑通,加一条/order/list路由,断言findRoute("GET","/order/list")能命中。 - 给 v3 注册
/user/:id与/user/:id/order/:oid,请求/user/789/order/100,打印ctx.PathParams验证id=789, oid=100。 - 故意重复注册
/user/home,确认框架按设计 panic;再尝试运行期并发注册,观察竞态现象。
本章小结
- Web 框架 = Server(生命周期)+ Context(请求上下文)+ 路由树(匹配分发),三者边界清晰。
- Server 从 v1 到 v2 的演进,是在"易用"和"可控(优雅退出 / 生命周期)“之间找平衡。
router是map[method]*node,每个 HTTP 方法一颗前缀树,路径按/切段成节点。- 匹配优先级"静态 > 参数 > 通配符"保证最具体的路由优先;
findRoute职责单一,只返回节点。 - 注册用 panic 尽早暴露错误,路由树非线程安全是因为约定"先注册、后启动”。下一步可动手实现 v4 正则匹配。