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
相关推荐
㳺三才人子3 小时前
初探 Flask
后端·python·flask·html
星栈独行3 小时前
我在 Rust 全栈项目里用 JWT 做无状态认证
开发语言·后端·rust·前端框架·开源·github·web
Java爱好狂.4 小时前
Java程序员体系化学习路线(2026最新版)
java·后端·java面试·java架构师·java程序员·java八股文·java学习路线
陈随易4 小时前
Redis 8.8发布,一定要更新
前端·后端·程序员
装不满的克莱因瓶4 小时前
SpringBoot 如何将 lib 目录中jar包打包进最终的jar包里面
spring boot·后端·maven·jar·mvn
ltl5 小时前
Transformer 原论文实验结果:为什么 28.4 BLEU 足以改写路线图
后端
excel6 小时前
为什么我推荐使用 Termius:现代 SSH 工具的完整体验
前端·后端
卷毛的技术笔记6 小时前
Java后端硬核实战:用Spring AI Alibaba+Redis给LLM装上“超强记忆中枢”
java·人工智能·redis·后端·spring·ai·系统架构
IT_陈寒7 小时前
Java的Optional差点让我掉坑里,这几个坑你别踩
前端·人工智能·后端
子兮曰8 小时前
Harness 驾驭工程深度教程:从 AGENTS.md 到全链路 AI 编码基础设施
前端·后端·ai编程