Go语言动手写Web框架 - Gee第四天 分组控制

Group 设计的原因

随着 Gee 支持了动态路由匹配,新的结构性问题也随之出现。 在真实的 Web 开发中,路由并非同质的,而是天然存在语义分层。例如:以 /post 开头的路由允许匿名访问;以 /admin 开头的路由需要鉴权;以 /api 开头的路由面向第三方平台,需要额外的鉴权与限制。

如果希望对所有 /admin 类型接口统一做鉴权,在当前仅支持动态路由匹配的框架能力下,就只能在每一个 /admin 路由中重复编写鉴权逻辑。这不仅带来大量重复代码,也违背了开闭原则(OCP),并且使横切逻辑难以维护和扩展。同样地,如果希望对 /post 路由统一增加一组中间件来扩展能力,现有结构也难以优雅支持。

问题因此自然演化为:如何在不修改已有路由定义的前提下,为一类路由统一施加行为? 这实际上要求框架中引入"路由作用域"的概念,而不仅仅是"路径匹配"。

一个直观的想法是:是否可以直接在 router 结构体中加入分组相关的数据结构?

go 复制代码
type router struct {
        roots    map[string]*node
        handlers map[string]HandlerFunc
}

但这在设计上并不合理。router 的职责被严格限定为:根据 Method + Path 进行匹配并找到对应的 handler。 它是一个偏算法和数据结构的组件,只关心匹配规则本身,而不关心任何业务语义。

中间件则恰恰相反:它承载的是强业务语义(如鉴权、日志、限流),与作用域密切相关,却与具体的路径匹配算法无关。如果将中间件直接挂载在 router 上,就会迫使 router 同时承担"路径匹配"和"业务控制"两种职责,从而破坏其单一职责原则,也使路由匹配层被业务语义污染。

因此,引入 RouterGroup 本质上是引入一个独立于匹配算法之外的"路由作用域"抽象。它以 URL 前缀作为边界,将一组路由组织到同一语义空间中,使中间件能够按组复用、按层叠加,而 router 仍然只专注于路径匹配本身。这正是中间件必须基于 Group,而不能直接基于 Router 的根本原因。

Group 具体设计

接下来我们考虑如何设计 Group 的结构。既然 Group 是用来区分不同路由分组以及承载中间件的,那么肯定要有一个 perfix 来用于区分不同分组(根据路由的前缀进行分组),而用于承载中间件的话可以使用一个 []HandlerFunc 用于存放多个中间件。另外,我们支持分组嵌套,例如对于 /admin/golang 这个路由来说,/admin 包含的中间件 /admin/golang 也应该拥有,为了实现这一点,还需要有一个 parent 属性用于记录当前路由的上级路由,这样上级路由所拥有的中间件就可以集成到后续的子路由中了。

最后,既然 Group 对象需要进行一些路由操作,那么还需要有访问 Router 的能力。为了方便,我们可以在 Group 中保存一个指针,指向 Engine,因为整个框架的资源都是由 Engine 统一协调的,这样做之后就可以通过 Engine 间接的访问各种借口了。因此,最终 Group 的结构如下:

go 复制代码
type RouterGroup struct {
        prefix      string
        middlewares []HandlerFunc // 支持中间件
        parent      *RouterGroup  // 支持嵌套
        engine      *Engine       // 所有 group 共享一个 Engine 实例
}

我们还可以进一步地抽象,将Engine作为最顶层的分组,也就是说Engine拥有RouterGroup所有的能力。

bash 复制代码
type Engine struct {
        *RouterGroup
        router *router
        groups []*RouterGroup // 存储所有 groups
}

有关为什么要进一步抽象来将 Engine 作为最顶层,具体原因如下:如果不将 Engine 修改为拥有 RouterGroup 的能力,Engine 仅仅定义 RouterGroup 的话,后续会在创建和管理路由分组时遇到问题。比如,我们需要频繁地通过 Engine 访问 RouterGroup,并且在 RouterGroup 中定义路由的功能会显得不够直观,尤其是在需要分组嵌套或统一管理多个分组的情况下,代码会变得更加复杂且不易扩展。进而我们想到将 Engine 修改为直接包含 RouterGroup 的能力,这样 Engine 就能像 RouterGroup 一样,直接支持路由分组的创建、管理和中间件的使用,使得路由分组的功能更加统一和灵活。修改后,Engine 不仅管理路由,还能管理分组和中间件,简化了代码结构,避免了冗余的接口调用,使得代码更加简洁、易于维护和扩展。

那么定义好 Group 之后,我们就可以对 gee.go 中的内容进行重写了:

go 复制代码
// Group 用于创建一个新的 RouterGroup
func (group *RouterGroup) Group(prefix string) *RouterGroup {
        engine := group.engine
        newGroup := &RouterGroup{
                prefix: group.prefix + prefix,
                parent: group,
                engine: engine,
        }
        engine.groups = append(engine.groups, newGroup)
        return newGroup
}

// 工具函数,后续 GET 和 POST 会使用这个函数给 engine 的路由表添加路由
func (group *RouterGroup) addRoute(method string, comp string, handler HandlerFunc) {
        pattern := group.prefix + comp
        log.Printf("Route %4s - %s", method, pattern)
        group.engine.router.addRoute(method, pattern, handler)
        //engine.router.addRoute(method, pattern, handler)
}

// GET 提供给用户注册 GET 请求的便捷方法
func (group *RouterGroup) GET(pattern string, handler HandlerFunc) {
        group.addRoute("GET", pattern, handler)
}

// POST 提供给用户注册 POST 请求的便捷方法
func (group *RouterGroup) POST(pattern string, handler HandlerFunc) {
        group.addRoute("POST", pattern, handler)
}

最后,写完之后可以进行测试:

go 复制代码
// main.go
package main

import (
        "gee"
        "net/http"
)

func main() {
        r := gee.New()
        r.GET("/index", func(c *gee.Context) {
                c.HTML(http.StatusOK, "<h1>Index Page</h1>")
        })
        v1 := r.Group("/v1")
        {
                v1.GET("/", func(c *gee.Context) {
                        c.HTML(http.StatusOK, "<h1>Hello Gee</h1>")
                })

                v1.GET("/hello", func(c *gee.Context) {
                        // 期望 /hello?name=way2top
                        c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
                })
        }
        v2 := r.Group("/v2")
        {
                v2.GET("/hello/:name", func(c *gee.Context) {
                        // 期望 /hello/way2top
                        c.String(http.StatusOK, "hello %s, you're at %s\n", c.Param("name"), c.Path)
                })
                v2.POST("/login", func(c *gee.Context) {
                        c.JSON(http.StatusOK, gee.H{
                                "username": c.PostForm("username"),
                                "password": c.PostForm("password"),
                        })
                })
        }

        r.Run(":9999")
}

终端测试:

less 复制代码
Gee on  main [!] via  v1.24.3 
❯ curl http://localhost:9999/index   
<h1>Index Page</h1>%                              
Gee on  main [!] via  v1.24.3 
❯ curl http://localhost:9999/v1
<h1>Hello Gee</h1>%                               
Gee on  main [!] via  v1.24.3 
❯ curl http://localhost:9999/v1/
<h1>Hello Gee</h1>%                               
Gee on  main [!] via  v1.24.3 
❯ curl http://localhost:9999/v1/hello
hello , you're at /v1/hello

Gee on  main [!] via  v1.24.3 
❯ curl http://localhost:9999/v2      
404 NOT FOUND: &s
%!(EXTRA string=/v2)%                             
Gee on  main [!] via  v1.24.3 
❯ curl http://localhost:9999/v2/hello/way
hello way, you're at /v2/hello/way
相关推荐
喵叔哟18 小时前
17.核心服务实现(上)
后端·.net
李梨同学丶18 小时前
好虫子周刊:1-bit LLM、物理 AI、DeepSeek-R1
后端
bruce_哈哈哈19 小时前
go语言初认识
开发语言·后端·golang
最贪吃的虎19 小时前
Redis其实并不是线程安全的
java·开发语言·数据库·redis·后端·缓存·lua
武子康19 小时前
大数据-208 岭回归与Lasso回归:区别、应用与选择指南
大数据·后端·机器学习
qq_124987075319 小时前
基于springboot归家租房小程序的设计与实现(源码+论文+部署+安装)
java·大数据·spring boot·后端·小程序·毕业设计·计算机毕业设计
moxiaoran575319 小时前
Go语言的接口
开发语言·后端·golang
清风徐来QCQ19 小时前
Cookie和JWT
后端·cookie
2301_7806698619 小时前
List(特有方法、遍历方式、ArrayList底层原理、LinkedList底层原理,二者区别)
java·数据结构·后端·list