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
相关推荐
有来技术1 天前
Spring Boot 4 + Vue3 企业级多租户 SaaS:从共享 Schema 架构到商业化套餐设计
java·vue.js·spring boot·后端
东东5161 天前
学院个人信息管理系统 (springboot+vue)
vue.js·spring boot·后端·个人开发·毕设
三水不滴1 天前
Redis缓存更新策略
数据库·经验分享·redis·笔记·后端·缓存
小邓吖1 天前
自己做了一个工具网站
前端·分布式·后端·中间件·架构·golang
大爱编程♡1 天前
SpringBoot统一功能处理
java·spring boot·后端
码界奇点1 天前
基于Gin与GORM的若依后台管理系统设计与实现
论文阅读·go·毕业设计·gin·源代码管理
迷迭香与樱花1 天前
Gin 框架
go·gin
好好研究1 天前
总结SSM设置欢迎页的方式
xml·java·后端·mvc
小马爱打代码1 天前
Spring Boot:第三方 API 调用的企业级容错设计
java·spring boot·后端
csdn2015_1 天前
springboot task
java·spring boot·后端