Golang Gin框架(四):后端框架分层与会话管理
配置层 config/
一个真实的项目需要管理各种配置,比如数据库连接信息、服务器端口、Redis 地址、JWT 密钥等。这些配置不应该硬编码在代码里。
- 职责:提供一个统一的入口来加载和读取配置信息(通常来自配置文件如 config.yaml 或环境变量)。
- 常用库:Viper
Model层 models/:数据定义和业务逻辑
Model层封装数据库逻辑等业务逻辑,供不同的控制器调用

唉?那我把entity/层改成model/层不就行了嘛
服务层 services/:进一步拆分业务逻辑
这是在 Controller 和 Model 之间增加的一个逻辑层。
- 痛点:有时候,一个业务逻辑非常复杂,可能需要操作多个 Model。比如“用户注册”这个操作,
Controller接到请求后,可能需要:- 调用
UserModel创建用户。 - 调用
AccountModel为用户创建一个初始账户。 - 调用
EmailModel(或服务) 发送一封欢迎邮件。
如果把这些复杂的编排逻辑都写在Controller里,Controller就会变得非常臃肿。
- 调用
- 解决方案:引入 Service 层。
Controller只调用一个UserService.Register()方法。而 UserService 内部去负责协调UserModel,AccountModel,EmailModel来完成所有工作。 - 职责:Service 层是专门用来编排和组合复杂的业务逻辑的地方。 它让 Controller 保持“薄”,只关心 HTTP 相关的部分,让 Model 保持“纯粹”,只关心单一的数据表操作。
控制器层 controllers/:封装和整理业务逻辑
控制器controllers/ VS 路由routers/ (AI)
“控制器”不是 Gin 的一个功能或组件,它是一种设计模式 (Design Pattern),一种被社区广泛采纳的、用来组织代码的最佳实践。它借鉴了经典的 MVC (Model-View-Controller) 架构思想。
在 Gin 的世界里,一个 Controller 通常是:
- 一个包含了一组相关业务逻辑处理函数的 Go 结构体 (struct)。
- 这组处理函数(现在是结构体的方法),每一个都符合 Gin Handler 的签名
func(c *gin.Context)
它的核心作用是:将相关的业务逻辑(比如所有关于用户的操作:获取用户、创建用户、删除用户)从路由定义中解耦出来,聚合到一个独立的、高内聚的模块中。
在Gin: Hello World 一节中,我们传给路由的是匿名函数——路由定义和函数命名是一起的:
r.GET("/", func(c *gin.Context) {
c.String(http.StatusOK, "Hello World")
})后来接触了路由组(r.Group("/api/v1")),可以第二第三级API,可以把API定义放在其他包中(如api包):
但我们仍然要在main.go中注册路由
再然后我们知道了路由抽离,可以弄个函数把r *gin.Engine传进去,以套为单位注册路由
——但到这里,业务逻辑(具体的func() {})仍然是和路由耦合在一起的,如果我们需要在其他地方复用业务逻辑该怎么办呢?
这时候就需要控制器 了,将若干个属于同一模块的组织成这个控制器结构体实例的receiver方法
package controllers
import "github.com/gin-gonic/gin"
// UserController 聚合了所有用户相关的业务逻辑
type UserController struct {
// 这里可以放依赖,比如数据库连接
// DB *gorm.DB
}
// GetUser 是一个方法,负责获取用户
func (uc *UserController) GetUser(c *gin.Context) {
// 真正的业务逻辑在这里
c.JSON(200, gin.H{"message": "get user detail from controller"})
}
// CreateUser 是一个方法,负责创建用户
func (uc *UserController) CreateUser(c *gin.Context) {
// 真正的业务逻辑在这里
c.JSON(200, gin.H{"message": "create user from controller"})
}然后在路由层里干净地注册控制器中的路由:
package routers
import (
"my-project/controllers"
"github.com/gin-gonic/gin"
)
func InitUserRoutes(r *gin.Engine) {
// 创建一个 UserController 的实例
userController := controllers.UserController{}
userRoutes := r.Group("/users")
{
// 将具体的业务逻辑处理,指向控制器的方法
userRoutes.GET("/:id", userController.GetUser)
userRoutes.POST("", userController.CreateUser)
}
}路由 (Router) 与控制器 (Controller) 的异同
| 特性 | 路由 (Router) | 控制器 (Controller) |
|---|---|---|
| 本质 (Nature) | Gin 框架的核心功能 | 一种组织代码的设计模式 |
| 职责 (Role) | 交通警察 / 调度员 (Dispatcher) | 业务执行者 (Executor) |
| 关注点 (Focus) | URL 路径 (/users/:id), HTTP 方法 (GET), 中间件 | 具体的业务逻辑(数据校验、数据库操作、错误处理等) |
| 代码形式 | router.GET(...), router.Group(...) | type UserController struct {}, func (uc *UserController) GetUser(...) |
| 关系 (Relation) | 路由使用控制器的方法作为其处理函数 (Handler)。路由决定了“什么时候调用”,控制器定义了“调用后做什么”。 |
一个形象的比喻:
- 路由 (Router) 就像一个公司的前台总机。它看到来电显示(URL 和请求方法),然后说:“好的,您要找用户部门办理业务,我帮您转接到工位 A(GetUser 方法)”。
- 控制器 (Controller) 就是这个用户部门,UserController 是部门本身,而 GetUser、CreateUser 这些方法就是部门里具体负责不同业务的员工。他们才是真正处理工作的人。
总结
- 相同点:它们都是为了处理一个 HTTP 请求而存在的,紧密协作。
- 不同点:它们的职责完全不同,实现了关注点分离 (Separation of Concerns),这是软件工程最重要的原则之一。
- 路由关心的是请求的入口和流向。
- 控制器关心的是请求的实现和处理。
适配器模式
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
const AppContextKey = "app_context"
// AppContext 结构体保持不变
type AppContext struct {
DB *gorm.DB
Log *zap.SugaredLogger
}
// AppHandlerFunc 这是我们想要的控制器函数签名!
// 它同时接收 gin.Context 和我们的 AppContext。
type AppHandlerFunc func(c *gin.Context, appCtx *AppContext)
// AdaptHandler 将我们的自定义 Handler 适配成 Gin 的 Handler
func AdaptHandler(appCtx *AppContext, handler AppHandlerFunc) gin.HandlerFunc {
// 返回一个标准的 gin.HandlerFunc
return func(c *gin.Context) {
// 在这里完成重复的工作:获取和断言
// 注意:因为中间件肯定会注入它,这里我们简化了错误处理
// 实际项目中可以做得更健壮
ctxWithDeps := &AppContext{
DB: appCtx.DB, // 共享同一个DB连接池
Log: appCtx.Log.With("trace_id", "some-unique-id"), // 可以为每个请求扩展Logger
}
// 调用我们真正的、拥有漂亮签名的控制器函数
handler(c, ctxWithDeps)
}
}
// GetUserHandlerClean 现在控制器变得非常干净!
// 它的签名是我们定义的 AppHandlerFunc。
func GetUserHandlerClean(c *gin.Context, appCtx *AppContext) {
// 没有重复代码,直接使用!
appCtx.Log.Info("Fetching user data with clean handler...")
userID := c.Param("id")
appCtx.Log.Infof("User ID is %s", userID)
// appCtx.DB.First(&user, userID)
c.JSON(http.StatusOK, gin.H{
"message": "Hello from clean handler!",
"user_id": userID,
})
}
func main() {
// 1. 初始化依赖 (与之前相同)
logger, _ := zap.NewProduction()
sugar := logger.Sugar()
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
log.Fatalf("failed to connect database: %v", err)
}
appContext := &AppContext{
DB: db,
Log: sugar,
}
// 2. 设置 Gin
r := gin.Default()
// 注意:在使用适配器模式后,我们甚至不再需要 ContextInjectorMiddleware 了
// 因为适配器函数已经持有了 appContext 的引用。
// 3. 注册路由时使用适配器
r.GET("/user/:id", AdaptHandler(appContext, GetUserHandlerClean))
r.Run(":8080")
}示例
// entity/book.go
package entity
import "github.com/google/uuid"
type Book struct {
UUID string `binding:"omitempty" form:"uuid" json:"uuid"` // UUID
Title string `binding:"required" form:"title" json:"title"` // 书名
Author string `binding:"required" form:"author" json:"author"` // 作者
Field string `binding:"omitempty" form:"field" json:"field"` // 分类
}
var BookListLen = 128
var Books = make(map[string]*Book, BookListLen)
func NewBook(title, author, field string) *Book {
// 使用Google的UUID库
uuid := uuid.New().String()
return &Book{
UUID: uuid,
Title: title,
Author: author,
Field: field,
}
}
func init() {
Books = map[string]*Book{
"Go语言基础": NewBook("Go语言基础", "小王子", "Go语言"),
"Go语言进阶": NewBook("Go语言进阶", "小王子", "Go语言"),
"Go语言实战": NewBook("Go语言实战", "小王子", "Go语言"),
"Go语言微服务": NewBook("Go语言微服务", "小王子", "Go语言"),
"Go语言分布式": NewBook("Go语言分布式", "小王子", "Go语言"),
"Go语言区块链": NewBook("Go语言区块链", "小王子", "Go语言"),
"Go语言爬虫": NewBook("Go语言爬虫", "小王子", "Go语言"),
}
}// controller/book-controller.go
package controllers
import (
"GinFourthDemo/entity"
"fmt" "net/http"
"github.com/gin-gonic/gin") // 导入Book结构体
type BookController struct {
BookMap map[string]*entity.Book // 初始化的时候直接把entity里的books放进去
}
func NewBookController() *BookController {
return &BookController{
BookMap: entity.Books,
}
}
func (bc *BookController) AddBook(c *gin.Context) {
var book entity.Book
err := c.ShouldBind(&book)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
bc.BookMap[book.Title] = &book
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("成功添加图书《%v》", book.Title)})
}
func (bc *BookController) GetBook(c *gin.Context) {
title := c.DefaultQuery("title", "")
if title == "" {
// 返回整个列表
c.JSON(http.StatusOK, gin.H{"data": bc.BookMap})
} else {
selectedBook, ok := bc.BookMap[title]
if !ok {
c.JSON(http.StatusNotFound, gin.H{"message": "没有找到该图书"})
return
}
c.JSON(http.StatusOK, gin.H{"data": selectedBook})
}
}
func (bc *BookController) UpdateBook(c *gin.Context) {
var book entity.Book
err := c.ShouldBind(&book)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
_, ok := bc.BookMap[book.Title]
if !ok { // 找不到就找不到
c.JSON(http.StatusNotFound, gin.H{"message": "没有找到该图书"})
} else {
bc.BookMap[book.Title] = &book
c.JSON(http.StatusOK, gin.H{"message": "更新图书成功"})
}
}
func (bc *BookController) DeleteBook(c *gin.Context) {
title := c.DefaultQuery("title", "")
if title == "" {
c.JSON(http.StatusBadRequest, gin.H{"message": "请提供要删除的图书的标题"})
return
}
_, ok := bc.BookMap[title]
if !ok {
c.JSON(http.StatusNotFound, gin.H{"message": "没有找到该图书"})
return
}
delete(bc.BookMap, title)
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}// router/book-router.go
package routers
import (
"GinFourthDemo/controllers"
"github.com/gin-gonic/gin")
func BookRouterInit(r *gin.Engine) {
bookRouterGroup := r.Group("/book")
{
bookController := controllers.NewBookController()
bookRouterGroup.POST("/", bookController.AddBook)
bookRouterGroup.GET("/", bookController.GetBook)
bookRouterGroup.PUT("/", bookController.UpdateBook)
bookRouterGroup.DELETE("/", bookController.DeleteBook)
}
}// main.go
package main
import (
"GinFourthDemo/routers"
"log" "net/http"
"github.com/gin-gonic/gin")
func main() {
r := gin.Default()
r.GET("/", func(c *gin.Context) {
c.String(http.StatusOK, "Hello World!")
})
routers.BookRouterInit(r) // 简单注册图书组API路由
err := r.Run(":8080")
if err != nil {
log.Fatal(err)
}
}
路由层routers/(进阶):决定请求流向
路由抽离
// routers/*.go
func ExportedRouterInit (r *gin.Engine) {
// r := r.Group("/another-group") // 必要的话可以再建一个路由组
r.GET("/index")
r.GET("/main")
...
}
// main.go
r := gin.Default()
ExportedRouterInit(r)
需要和main.go的r一个类型
当main.go里有成百上千个路由时就必须进行路由抽离了
中间件 gin.HandlerFunc:在请求进入业务逻辑前进行认证
中间件的哲学:标准化的“处理单元” (AI)
gin.HandlerFunc 的源码定义极其简单:
// HandlerFunc defines the handler used by gin middleware as return value.
type HandlerFunc func(*Context)它是一个函数类型。这意味着,任何一个接收 *gin.Context 指针作为唯一参数并且没有返回值的函数,在 Go 的世界里,都可以被看作是一个 gin.HandlerFunc。
它的设计哲学可以总结为三点:
- 统一契约 (Uniform Contract):Gin 规定,所有处理 HTTP 请求的“工作单元”,无论是最终的业务逻辑,还是路上的某个检查站(中间件),都必须长成同一个样子。这个“样子”就是
func(c *gin.Context)。这就像乐高积木,每一块都有着标准化的接口,所以它们可以被任意组合和串联。 - 上下文驱动 (Context-Driven):这个“处理单元”不返回任何东西,它所有的输入、操作和输出都通过唯一的参数
c *gin.Context来完成。这个Context对象就像一个随身工具箱,封装了本次请求的所有信息(Request)、响应写入器(Response Writer)、请求参数、中间数据等。处理单元只需要专注于对这个工具箱进行操作,而不用关心其他事情。 - 可组合性 (Composability):正是因为有了统一的契约,我们才能像串糖葫芦一样,把多个
HandlerFunc串在一起,形成一个处理链。例如:router.GET("/profile", Logger(), AuthCheck(), GetUserProfile())
这里的Logger、AuthCheck和GetUserProfile本质上都是HandlerFunc类型的函数。Gin 会按顺序依次调用它们。
小结:gin.HandlerFunc是 Gin 框架的原子构建块 (Atomic Building Block)。它的设计追求极致的简洁、统一和可组合,为中间件模式的实现奠定了坚实的基础。
中间件的作用:请求处理的“洋葱模型” (AI)
现在,我们来揭开中间件的神秘面纱。
把整个 HTTP 请求处理过程想象成一次安检,去见一位非常重要的人物(你的业务逻辑处理函数)。
- 请求 (Request):是你这个人。
- 业务逻辑处理函数 (e.g.,
GetUserProfile):是你要见的重要人物。 - 中间件 (Middleware):是你一路上必须经过的每一个安检站。
这个过程就像剥洋葱,所以也叫“洋葱模型”:
- 你的请求(你)到达了最外层,这是第一个安检站(比如,
Logger中间件)。- 进站检查:安检员(
Logger)记录下你到达的时间,说:“请进”。
- 进站检查:安检员(
- 你进入下一层,到达第二个安检站(比如,
AuthCheck中间件)。- 进站检查:安检员(
AuthCheck)检查你的身份证件(Token)。 - 通过:证件有效,他让你继续前进,说:“请进”。
- 拦截:证件无效,他直接把你拦下,并通知总部(返回一个 401 Unauthorized 错误),你根本见不到那位重要人物。
- 进站检查:安检员(
- 你通过了所有安检,终于见到了核心人物(
GetUserProfile业务逻辑)。- 你和他交谈,他把一份机密文件(JSON 数据)交给你。
- 现在,你要原路返回。
- 你离开核心区域,再次经过第二个安检站 (
AuthCheck)。- 出站检查:安检员看到你出来了,但他没什么要做的,就直接让你走了。
- 你到达第一个安检站 (
Logger)。- 出站检查:安检员看了看表,记录下你离开的时间,并计算出你这次访问总共花费了多长时间。
- 你带着机密文件(响应数据)离开了整个大楼。
中间件在后端架构中的作用:
在上面的比喻中,每一个“安检站”(中间件)都承担了一种与核心业务无关,但又非常重要的通用功能。这些功能被称为横切关注点 (Cross-Cutting Concerns)。
中间件的核心作用就是用来优雅地处理这些“横切关注点”。主要包括:
- 身份验证与授权 (Authentication & Authorization):检查用户是否登录(
AuthCheck安检站),以及是否有权限访问特定资源。 - 日志记录 (Logging):记录每个请求的详细信息,如 IP、路径、耗时、状态码等(
Logger安检站)。 - 错误恢复 (Recovery):捕获处理过程中可能发生的
panic,防止整个程序崩溃,并返回一个 500 错误。这是 Gin 默认就提供的一个中间件。 - 跨域资源共享 (CORS):为响应添加特定的头信息,允许来自不同源的前端应用访问你的 API。
- 数据压缩 (Compression):例如使用 Gzip 压缩响应体,减少网络传输量。
- 缓存控制:为某些响应添加缓存头。
c.Next() 和 c.Abort() 的魔力
在 Gin 的中间件中,有两个关键函数来控制这个“安检流程”:
c.Next():相当于安检员说“请进,去下一个安检站”。它将控制权移交给处理链中的下一个HandlerFunc。代码写在c.Next()之前的,是“进站检查”;写在c.Next()之后的,是“出站检查”(会在核心业务及内层中间件都执行完后才执行)。c.Abort()或c.AbortWithStatusJSON():相当于安检员说“站住!你不准再往前了!”。它会立即中断这个“洋葱”的深入过程,并开始向外返回。后续的中间件和核心业务函数将不会被执行。
一个简单的 Logger 中间件示例:
func LoggerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 1. "进站检查" - 在核心业务之前执行
startTime := time.Now()
fmt.Println("Request Started:", c.Request.Method, c.Request.URL.Path)
// 2. 调用 c.Next() 将控制权交给下一个处理程序
c.Next()
// 3. "出站检查" - 在核心业务之后执行
// 所有内层的 Handler 执行完毕后,代码会回到这里
endTime := time.Now()
latency := endTime.Sub(startTime)
fmt.Println("Request Finished. Status:", c.Writer.Status(), "Latency:", latency)
}
}与 Python 的联系
这个模式在 Python Web 框架中也是普遍存在的:
- Django:
settings.py文件中的MIDDLEWARE列表就是完全一样的“洋葱模型”。请求会按列表顺序穿过每一个中间件,响应则按相反的顺序返回。 - Flask:虽然没有一个内聚的中间件“链”,但它的装饰器,如
@app.before_request和@app.after_request,实现了类似的功能,允许你在请求处理前后注入逻辑。
总结:
中间件是一种极其强大和优雅的设计模式。它允许你将通用的功能逻辑从核心业务逻辑中剥离,形成可复用、可插拔的组件,极大地提高了代码的模块化程度、可维护性和复用性。掌握了中间件,你就真正掌握了现代 Web 后端架构的精髓。
全局中间件
格式:
func 中间件名() gin.HandlerFunc {
return func(c *gin.Context) {
// 函数体
}
}然后用r.Use方法注册组件:
func (engine *Engine) Use(middleware ...HandlerFunc) IRoutes {
engine.RouterGroup.Use(middleware...)
engine.rebuild404Handlers()
engine.rebuild405Handlers()
return engine
}
// r.Use(MiddleWare())示例(转载自topgoer文档):
package main
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
)
// 定义中间
func MiddleWare() gin.HandlerFunc {
return func(c *gin.Context) {
t := time.Now()
fmt.Println("中间件开始执行了")
// 设置变量到Context的key中,可以通过Get()取
c.Set("request", "中间件")
status := c.Writer.Status()
fmt.Println("中间件执行完毕", status)
t2 := time.Since(t)
fmt.Println("time:", t2)
}
}
func main() {
// 1.创建路由
// 默认使用了2个中间件Logger(), Recovery()
r := gin.Default()
// 注册中间件
r.Use(MiddleWare())
// {}为了代码规范
{
r.GET("/ce", func(c *gin.Context) {
// 取值
req, _ := c.Get("request")
fmt.Println("request:", req)
// 页面接收
c.JSON(200, gin.H{"request": req})
})
}
r.Run()
}Next方法
在上面的示例中,我们会发现时间没算对
我们需要c.Next方法来触发之后的计算:
(之后的代码一样)
现在就能算出中间件耗时了
【AI】进一步理解中间件
简单来说,c.Next() 在中间件里的作用是:暂停当前中间件的执行,并将控制权交给处理链中的下一个处理程序(可能是另一个中间件,也可能是最终的业务处理函数)。
让我们再次请出那个“安检站”的比喻,这次我们聚焦于安检员的行为。
c.Next() 的核心作用:分割“来时路”与“返回路”
在一个中间件函数中,c.Next() 就像一条分界线。
- 写在
c.Next()之前的代码:是在“安检进入时”执行的逻辑。 - 写在
c.Next()之后的代码:是在“安检离开时”执行的逻辑,也就是在所有内层中间件和最终业务处理函数都执行完毕后,才会倒序回来执行。
代码执行流程的可视化
假设请求处理链是这样的:请求 -> Middleware1 -> Middleware2 -> 最终业务处理函数 -> 响应
Middleware1.go
func Middleware1() gin.HandlerFunc {
return func(c *gin.Context) {
fmt.Println("Middleware 1: 进站检查 (Before Next)")
c.Next() // 把控制权交给 Middleware2
fmt.Println("Middleware 1: 出站检查 (After Next)")
}
}Middleware2.go
func Middleware2() gin.HandlerFunc {
return func(c *gin.Context) {
fmt.Println("Middleware 2: 进站检查 (Before Next)")
c.Next() // 把控制权交给最终业务处理函数
fmt.Println("Middleware 2: 出站检查 (After Next)")
}
}main.go
func main() {
router := gin.Default()
// 使用中间件
router.Use(Middleware1(), Middleware2())
router.GET("/test", func(c *gin.Context) {
fmt.Println("--- 执行核心业务逻辑 ---")
c.JSON(http.StatusOK, gin.H{"message": "ok"})
})
router.Run()
}当我们访问 /test 时,服务器控制台的输出顺序将会是:
Middleware 1: 进站检查 (Before Next)
Middleware 2: 进站检查 (Before Next)
--- 执行核心业务逻辑 ---
Middleware 2: 出站检查 (After Next)
Middleware 1: 出站检查 (After Next)发生了什么?
- 请求到达
Middleware1,打印“进站检查”,然后调用c.Next(),暂停自己。 - 控制权交给
Middleware2,打印“进站检查”,然后调用c.Next(),暂停自己。 - 控制权交给最终的业务处理函数,打印“执行核心业务逻辑”,然后该函数执行完毕。
- 请求开始“返回”,控制权回到
Middleware2中c.Next()的下一行,打印“出站检查”,Middleware2执行完毕。 - 控制权回到
Middleware1中c.Next()的下一行,打印“出站检查”,Middleware1执行完毕。 - 完整的响应被发送给客户端。
c.Next()就是那个“暂停并移交控制权”的魔法按钮。
重要问题:如果我不调用 c.Next() 会怎么样?
如果你在一个中间件中没有调用 c.Next(),那么这个处理链就会被中断。
这个中间件就变成了事实上的终点处理函数 (Terminating Handler)。
这不是一个错误,而是一个非常有用的特性!最典型的应用场景就是身份验证。
示例:一个身份验证中间件
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token != "a-valid-token" {
// 验证失败!
fmt.Println("认证失败,请求被中断!")
// 1. 返回一个错误响应
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
// 2. 调用 c.Abort() 来确保后续的 Handler 不会被执行
c.Abort()
// 3. 直接 return,因为没有调用 c.Next(),处理链到此为止
return
}
// 验证成功!
fmt.Println("认证成功,继续前进!")
// 把用户信息存入 context,供后续的 Handler 使用
c.Set("userID", "12345")
// 调用 c.Next(),放行请求到下一个处理程序
c.Next()
}
}在这个例子中:
- 如果 token 验证失败,代码会发送一个 401 错误响应,然后
return。因为没有调用c.Next(),所以洋葱的内层(其他中间件和核心业务)将永远不会被访问到。 - 如果 token 验证成功,代码才会调用
c.Next(),允许请求继续它的旅程。
总结
| 操作 | 作用 | 核心思想 | 典型场景 |
|---|---|---|---|
调用 c.Next() | 暂停当前函数,将控制权交给处理链的下一个环节。 | 放行 (Continue) | 日志记录、性能监控、数据压缩等需要在请求前后都执行操作的场景。 |
不调用 c.Next() | 中断处理链,使当前中间件成为最终处理者。 | 拦截 (Intercept / Block) | 身份验证、权限校验等需要根据条件决定是否允许访问核心业务的场景。 |
所以,c.Next() 不仅仅是一个简单的函数调用,它是 Gin 中间件模式的流程控制核心,赋予了开发者精确控制请求处理流程的能力,让开发者能够构建出强大而灵活的 Web 服务。
局部中间件
局部中间件是注册到具体的路由上的
中间件定义不变
注册方式如下:
// 注册到路由上
r.GET("/index", MiddleWare(), api函数)
// 注册到路由组上
r.Group("/group", MiddleWare())// GET is a shortcut for router.Handle("GET", path, handlers).
func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
return group.handle(http.MethodGet, relativePath, handlers)
}
// Group creates a new router group. You should add all the routes that have common middlewares or the same path prefix.
// For example, all the routes that use a common middleware for authorization could be grouped.
func (group *RouterGroup) Group(relativePath string, handlers ...HandlerFunc) *RouterGroup {
return &RouterGroup{
Handlers: group.combineHandlers(handlers),
basePath: group.calculateAbsolutePath(relativePath),
engine: group.engine,
}
}我寻思函数签名一样,那么传参也应该是一样的
注意: 这里函数传参的最后一个参数 是handlers ...HandlerFunc,也就是说可以注册不止一个中间件
中间件练习
- 定义程序计时中间件,然后定义2个路由,执行函数后应该打印统计的执行时间(原文档要求)
直接加在之前的router上
func tickerMiddleWare() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
// fmt.Println("请求耗时:", time.Since(start)) // 底层就是time.Now().Sub(start)
duration := time.Since(start).Milliseconds()
log.Printf("请求耗时:%v", duration)
}
}
func BookRouterInit(r *gin.Engine) {
bookRouterGroup := r.Group("/book", tickerMiddleWare())
{
bookController := controllers.NewBookController()
bookRouterGroup.POST("/", bookController.AddBook)
bookRouterGroup.GET("/", bookController.GetBook)
// 2025/11/01 00:02:51 请求耗时:538微秒
bookRouterGroup.PUT("/", bookController.UpdateBook)
bookRouterGroup.DELETE("/", bookController.DeleteBook)
}
}项目结构整理 (AI)
/my-awesome-project
├── cmd/
│ └── main.go # 启动器
├── config/
│ └── config.yaml # 配置文件
├── internal/ # (或者直接在根目录)
│ ├── controllers/ # 控制器层
│ ├── middlewares/ # 中间件层
│ ├── models/ # 模型层
│ ├── routers/ # 路由层
│ └── services/ # 服务层
├── pkg/ # (或者叫 utils/)
│ ├── database/ # 数据库初始化
│ ├── logger/ # 日志工具
│ └── utils/ # 通用工具函数 (e.g., 密码加密)
├── go.mod
└── go.sum一个请求的完整生命周期:
- Request In ->
main.go启动的 Gin 服务 - -> Router (找到匹配的路由规则)
- -> Global Middlewares (Logger, Recovery, CORS...)
- -> Route-specific Middlewares (e.g., AuthMiddleware)
- -> Controller (解析请求,调用 Service)
- -> Service (执行复杂的业务逻辑编排)
- -> Model(s) (与数据库等进行数据交互)
- -> Database/Redis
- <- Model(s) (返回数据给 Service)
- <- Service (返回处理结果给 Controller)
- <- Controller (将结果格式化成 JSON)
- <- Middlewares (倒序执行“出站”逻辑)
- <- Router
- -> Response Out
会话控制
在Gin中使用Cookie
cookie, error := c.Cookie("cookieKey"):获取指定键名的Cookie值c.SetCookie("cookieKey", "cookieValue", 60, "/", "example.com", false, true)
参数依次为:- Cookie键
- Cookie键对应的Cookie值
- Cookie有效时间,单位为秒
- Cookie所在目录
- Cookie归属的域(域名)
- 是否智能通过HTTPS访问
- 是否启用HTTPOnly(即是否能在本地使用JS访问)
- Gin没有提供显式的Cookie吊销方法,但我们可以通过把过期时间设为负数或过去的某个时间来无效化Cookie:
c.SetCookie("cookieKey", "", -1, "/", "example.com", false, true)
示例
models/user.go创建用户信息结构体,声明用户键值对,并定义增删查改方法controllers/user-controller.go封装Model层中的用户方法,接收c *gin.Context作为传参routers/user-router.go为控制器层中的用户注册和登录逻辑注册路由middleware/user-middleware.go封装控制器方法,实现中间件;在路由层中注册这些中间件main.go注册user-router路由
// models/user.go
package models
import "fmt"
type User struct {
// Id 表单与JSON标记键均为id; 表单绑定时不做校验
Id int `binding:"omitempty" form:"id" json:"id"`
// Username 表单与JSON标记键均为username; 表单绑定时做非空校验
Username string `binding:"required" form:"username" json:"username"`
// Password 表单与JSON标记键均为password; 表单绑定时做非空校验,长度最小为6
// min等校验函数前面不能有空格
Password string `binding:"required,min=6" form:"password" json:"password"`
}
// UserMap 模拟数据库中的对象
type UserMap map[string]User
// PresudoUserDB 模拟数据库
type PresudoUserDB struct {
Users UserMap
}
var PresudoUserDBInst *PresudoUserDB
func init() {
PresudoUserDBInst = &PresudoUserDB{
Users: make(UserMap, 16), // 不够的话自己会扩容的
}
_, _ = PresudoUserDBInst.AddUser(User{
Username: "root",
Password: "root123456",
})
_, _ = PresudoUserDBInst.AddUser(User{
Username: "admin",
Password: "admin123456",
})
}
// AddUser 添加用户
//
// 当用户名已存在时报错; 方法不会校验密码长度小于6的情况
func (db *PresudoUserDB) AddUser(user User) (ok int, err error) {
// 假设用户名不能重复
if _, ok := db.Users[user.Username]; ok {
return 0, fmt.Errorf("用户名 %s 已存在", user.Username)
}
// 相信上层调用不会传入长度过短的密码
db.Users[user.Username] = user
return 1, nil
}
// GetUser 获取用户
//
// 返回值如下:
// 1. 用户对象 User实例
// 2. 用户是否存在 bool值
// 3. 报错消息——当查询的用户名不存在时报错 error实例
func (db *PresudoUserDB) GetUser(username string) (user User, ok int, err error) {
if username == "" {
return user, 0, fmt.Errorf("用户名不能为空")
}
if user, ok := db.Users[username]; ok {
return user, 1, nil
}
return user, 0, fmt.Errorf("用户名 %s 不存在", username)
}
// GetAllUsers 获取所有用户
//
// 返回用户切片指针, 而非切片本体
func (db *PresudoUserDB) GetAllUsers() *[]User {
users := make([]User, 0, len(db.Users))
for _, user := range db.Users {
users = append(users, user)
}
return &users
}
// UpdateUser 更新用户
//
// 当用户不存在时报错; 方法不会校验密码长度小于6的情况
// 返回值:
// - int 1: 更新成功; 0: 更新失败
// - err: 错误消息
func (db *PresudoUserDB) UpdateUser(username string, user User) (ok int, err error) {
if _, ok := db.Users[username]; !ok {
return 0, fmt.Errorf("用户名 %s 不存在", username)
}
db.Users[username] = user
return 1, nil
}
// DeleteUser 删除用户
//
// 当用户不存在时报错
// 返回值:
// - int 1: 删除成功; 0: 删除失败
// - err: 错误消息
func (db *PresudoUserDB) DeleteUser(username string) (ok int, err error) {
if _, ok := db.Users[username]; !ok {
return 0, fmt.Errorf("用户名 %s 不存在", username)
}
delete(db.Users, username)
return 1, nil
}// controllers/user-controller.go
package controllers
import (
"GinFourthDemo/models"
"fmt" "log/slog" "net/http"
"github.com/gin-gonic/gin" "github.com/google/uuid")
type UserController struct {
}
var UserControllerInst *UserController
func init() {
UserControllerInst = &UserController{}
}
// UserLogin 登录API——获取POST参数username
func (uc *UserController) UserLogin(c *gin.Context) {
var userReq models.User
err := c.ShouldBind(&userReq)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
username := userReq.Username
user, ok, err := models.PresudoUserDBInst.GetUser(username)
if ok == 1 {
if username != user.Username {
c.JSON(http.StatusInternalServerError, gin.H{"error": "登录失败"})
slog.Error(fmt.Sprintf("伪数据库实例返回的用户名(%v)与用户请求的用户名(%v)不一致!", user.Username, username))
return
}
// 登录成功并且用户名没错时就会跳出if结构
} else {
c.JSON(200, gin.H{"message": "登录失败", "error": err.Error()})
return
}
// 查询成功才能来到这里
if user.Password == userReq.Password {
token := uuid.New().String()
// 登录成功,设置Cookie
c.SetCookie("token", token, 3600, "/", "localhost", false, true)
c.JSON(200, gin.H{"message": "登录成功"})
return
} else {
c.JSON(403, gin.H{"message": "登录失败", "error": "密码错误"})
return
}
}
// UserRegister 注册API——获取POST参数username和password
func (uc *UserController) UserRegister(c *gin.Context) {
var user models.User
if err := c.ShouldBind(&user); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ok, err := models.PresudoUserDBInst.AddUser(user)
if ok == 1 {
c.JSON(200, gin.H{"message": "注册成功"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"message": "注册失败", "error": err.Error()})
}
return
}
// UserLogout 登出API——无参数
func (uc *UserController) UserLogout(c *gin.Context) {
// 删除Cookie键token的值 // 通过将注销时间设为负数来无效化Cookie
c.SetCookie("token", "", -1, "/", "localhost", false, true)
c.JSON(200, gin.H{"message": "登出成功"})
}
// UserInfo 获取用户信息API——获取GET参数username并查询
//
// API本身是无状态的, 会造成水平越权攻击
func (uc *UserController) UserInfo(c *gin.Context) {
username := c.DefaultQuery("username", "")
user, ok, err := models.PresudoUserDBInst.GetUser(username)
if ok == 1 {
// 移除user实例中的Password字段
user.Password = ""
c.JSON(200, gin.H{"message": "查询成功", "data": user})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"message": "查询失败", "error": err.Error()})
}
return
}// routers/user-router.go
package routers
import (
"GinFourthDemo/controllers"
"GinFourthDemo/middlewares"
"github.com/gin-gonic/gin")
func UserRouterInit(r *gin.Engine) {
userRouterGroup := r.Group("/user")
{
userController := controllers.UserControllerInst
// 登录路由不接受已存在Cookie的请求
userRouterGroup.POST("/login", middlewares.CookieDuplicated(), userController.UserLogin)
// 注意这里的中间件注册顺序, 是先检查COOKIE是否重复, 然后再进入业务逻辑的
// 业务逻辑也是中间件
userRouterGroup.POST("/register", userController.UserRegister)
// 登出路由需要携带Cookie才能登出
userRouterGroup.GET("/logout", middlewares.CookieRequired(), userController.UserLogout)
userRouterGroup.GET("/info", middlewares.CookieRequired(), userController.UserInfo)
}
}// middlewares/user-middleware.go
package middlewares
import (
"log"
"net/http"
"github.com/gin-gonic/gin")
// CookieRequired 身份验证中间件
//
// 检查用户请求是否携带名为token的Cookie,若无则返回401, 若有则继续处理请求
func CookieRequired() gin.HandlerFunc {
return func(c *gin.Context) {
// 获取token
_, err := c.Cookie("token")
urlPath := c.Request.URL.Path
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": -1,
"msg": "请求未携带token",
})
log.Printf("对%v路径的请求未携带token, err: %v", urlPath, err)
c.Abort()
return
}
// 身份验证逻辑到此为止
c.Next() // 交给下一个中间件或业务来处理请求
}
}
// CookieDuplicated 身份验证中间件
//
// 检查用户是否携带Cookie, 若有则返回401, 若无则继续处理请求
func CookieDuplicated() gin.HandlerFunc {
return func(c *gin.Context) {
// 获取token
_, err := c.Cookie("token")
urlPath := c.Request.URL.Path
if err == nil {
c.JSON(http.StatusTooManyRequests, gin.H{
"code": -1,
"msg": "请求已携带Cookie, 请不要重新尝试登录或其他会刷新Cookie的操作",
})
log.Printf("对%v路径的请求携带了token, err: %v", urlPath, err)
c.Abort()
}
c.Next() // 交给下一个中间件或业务来处理请求
}
}// main.go
package main
import (
"GinFourthDemo/routers"
"log" "net/http" "time"
"github.com/gin-gonic/gin")
func ReqTickerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
urlPath := c.Request.URL.Path
c.Next()
duration := time.Since(start).Microseconds()
log.Printf("对路径%v的请求耗时:%v微秒", urlPath, duration)
}
}
func main() {
r := gin.Default()
r.Use(ReqTickerMiddleware()) // 注册全局中间件, 用于调试检查请求耗时
routers.UserRouterInit(r) // 注册用户组API路由
err := r.Run(":8080")
if err != nil {
log.Fatal(err)
}
}中间件注册顺序补充说明
回顾中间件的类型声明:
// HandlerFunc defines the handler used by gin middleware as return value.
type HandlerFunc func(*Context)再来看看控制器方法的函数签名:
// UserLogin 登录API——获取POST参数username
func (uc *UserController) UserLogin(c *gin.Context)最后看看r.Handle方法的文档:(注意那个handlers ...HandlerFunc中间件切片
// Handle registers a new request handle and middleware with the given path and method.// The last handler should be the real handler, the other ones should be middleware that can and should be shared among different routes.
// See the example code in GitHub.
//
// For GET, POST, PUT, PATCH and DELETE requests the respective shortcut// functions can be used.
//
// This function is intended for bulk loading and to allow the usage of less
// frequently used, non-standardized or custom methods (e.g. for internal
// communication with a proxy).
func (group *RouterGroup) Handle(httpMethod, relativePath string, handlers ...HandlerFunc) IRoutes {
if matched := regEnLetter.MatchString(httpMethod); !matched {
panic("http method " + httpMethod + " is not valid")
}
return group.handle(httpMethod, relativePath, handlers)
}我们会发现……唉?业务逻辑不也是一种中间件吗!?
最终的业务处理函数,本质上就是处理链中的最后一个中间件。
在 Gin 的世界里,它并不区分“中间件”和“业务逻辑函数”。对于 Gin 的路由器来说,当我们这样写代码时:
router.GET("/some/path", middleware1, middleware2, myFinalHandler)middleware1, middleware2, myFinalHandler 在 Gin 看来,它们的身份是完全平等的。它们都是 gin.HandlerFunc 类型。
Gin 所做的,就是把它们按我们注册的顺序,放进一个切片(slice)里,形成一个处理程序链 (Handler Chain)。当请求来临时,Gin 会像启动多米诺骨牌一样,从第一个开始,依次执行这个链条。
所以,业务逻辑函数,就是这个链条上最后一个不会(也不需要)调用 c.Next() 的环节。
因此,上面的示例中的登录注销逻辑应该是:
// 登出路由需要携带Cookie才能登出
userRouterGroup.GET("/logout", middlewares.CookieRequired(), userController.UserLogout)而不是:
userRouterGroup.GET("/logout", userController.UserLogout, middlewares.CookieRequired())Cookie的缺点
转载自Topgoer文档,下面的同级标题同理
- 不安全,明文
- 增加带宽消耗
- 可以被禁用
- cookie有上限
Sessions(介绍)
Gorilla/session
gorilla/sessions为自定义session后端提供cookie和文件系统session以及基础结构。
主要功能是:
- 简单的API:将其用作设置签名(以及可选的加密)cookie的简便方法。
- 内置的后端可将session存储在cookie或文件系统中。
- Flash消息:一直持续读取的session值。
- 切换session持久性(又称“记住我”)和设置其他属性的便捷方法。
- 旋转身份验证和加密密钥的机制。
- 每个请求有多个session,即使使用不同的后端也是如此。
- 自定义session后端的接口和基础结构:可以使用通用API检索并批量保存来自不同商店的session。
关于Gorilla/session的一些问题(AI)
gorilla/sessions 是 Go Web 开发领域的“上古大神”,非常经典,所以很多老教程(包括 Topgoer 的部分内容)都会以它为范例。但是,它确实存在一些问题:
- 项目已归档 (Archived):这是最重要的一点。gorilla/sessions 及其所属的 Gorilla Web Toolkit 官方已经不再积极维护。对于新项目来说,依赖一个不再更新的库存在长期的安全和兼容性风险。
- 关注点不纯粹:gorilla/session提供了多种存储后端(Store),其中 filesystem 存储就是将 session 数据以文件的形式保存在服务器硬盘上。这并不是说它在做“文件系统管理”,而是它提供了“用文件系统来存储会话” 的这个功能。但对于初学者来说,这确实会引入额外的认知负担。
【AI】其他第三方库推荐
对于 Gin 开发者来说,主要有两个主流且备受推崇的选择
推荐一:gin-contrib/sessions (最贴合 Gin 的选择)
这是 Gin 官方社区维护的会话管理库,是无缝集成 Gin 的首选。
- 优点:
- 官方血统:作为
gin-contrib的一部分,它与 Gin 的集成度最高,兼容性最好。 - 接口简单:完全以 Gin 中间件的形式提供,使用体验非常“Gin化”。
- 后端可拔插:它本身是一个“管理器”,你可以非常轻松地选择用什么来存储你的 session 数据,比如:
sessions.NewCookieStore():加密后的数据直接存在客户端的 Cookie 里。(最简单,适合起步)redis.NewStore():存在 Redis 里。(生产环境最常用,性能高,易于扩展)memstore.NewStore():存在服务器内存里。(仅用于开发测试,服务重启后 session 丢失)
- 官方血统:作为
- 如何使用:它的用法和我们学过的其他中间件一样。
代码示例 (CookieStore 版本):
package main
import (
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"net/http"
)
func main() {
r := gin.Default()
// 1. 创建一个基于 cookie 的存储引擎
// "secret" 是一个密钥,用于加密 session 数据,防止篡改
store := cookie.NewStore([]byte("a-very-secret-key"))
// 2. 注册 session 中间件
// "mysession" 是 session 的名字,也是 cookie 的名字
r.Use(sessions.Sessions("mysession", store))
// 登录路由
r.GET("/login", func(c *gin.Context) {
// 必须先获取 session 对象
session := sessions.Default(c)
// 检查 session 是否已存在
if session.Get("loggedIn") == true {
c.JSON(http.StatusOK, gin.H{"message": "You are already logged in"})
return
}
// 设置 session
session.Set("loggedIn", true)
session.Set("userID", "123")
// 保存 session 的改动
if err := session.Save(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Login successful"})
})
// 受保护的路由
r.GET("/profile", func(c *gin.Context) {
session := sessions.Default(c)
userID := session.Get("userID")
if userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
c.JSON(http.StatusOK, gin.H{"user_id": userID})
})
r.Run(":8080")
}推荐二:alexedwards/scs (更现代、功能更强大的选择)
这是一个非常流行、功能强大且与框架无关的会话管理库。虽然不是 Gin 官方的,但它在整个 Go 社区的声誉极高。
- 优点:
- 安全优先:提供了很多高级安全特性,比如 session token 自动续期、空闲超时和绝对超时。
- 高性能:设计优秀,对性能影响极小。
- 框架无关:不与 Gin 绑定,未来如果你的项目迁移到其他 Go 框架,这部分代码可以无缝复用。
- 文档优秀:作者 Alex Edwards 写的文档和博客文章质量非常高。
- 如何使用:同样是以中间件的形式集成,只需要一个简单的适配器。
代码示例 (Redis 版本):
package main
import (
"github.com/alexedwards/scs/redisstore"
"github.com/alexedwards/scs/v2"
"github.com/gin-gonic/gin"
"github.com/gomodule/redigo/redis"
"net/http"
"time"
)
// 全局的 session 管理器
var sessionManager *scs.SessionManager
func main() {
// 1. 初始化 session 管理器
sessionManager = scs.New()
// 2. 配置存储后端为 Redis
pool := &redis.Pool{
MaxIdle: 10,
Dial: func() (redis.Conn, error) { return redis.Dial("tcp", "localhost:6379") },
}
sessionManager.Store = redisstore.New(pool)
// 3. 配置 session 生命周期
sessionManager.Lifetime = 24 * time.Hour
r := gin.Default()
// 4. 将 scs 管理器包装成 Gin 中间件
r.Use(SessionLoad)
r.GET("/login", func(c *gin.Context) {
// 设置 session
sessionManager.Put(c.Request.Context(), "userID", "123")
c.JSON(http.StatusOK, gin.H{"message": "Login successful"})
})
r.GET("/profile", func(c *gin.Context) {
// 获取 session
userID := sessionManager.GetString(c.Request.Context(), "userID")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
c.JSON(http.StatusOK, gin.H{"user_id": userID})
})
r.Run(":8080")
}
// 这是一个适配器函数,将 scs 的 http.Handler 包装成 gin.HandlerFunc
func SessionLoad(c *gin.Context) {
var token string
cookie, err := c.Cookie(sessionManager.Cookie.Name)
if err == nil {
token = cookie
}
ctx, err := sessionManager.Load(c.Request.Context(), token)
if err != nil {
// handle error
}
// 把加载了 session 数据的上下文包装回 gin.Context
c.Request = c.Request.WithContext(ctx)
c.Next()
}总结与建议
| 特性 | gin-contrib/sessions | alexedwards/scs |
|---|---|---|
| 集成难度 | 极低,专为 Gin 设计 | 低,需要一个简单的适配器 |
| 社区 | Gin 官方社区 | 整个 Go 社区,非常活跃 |
| 功能 | 基础且够用 | 非常强大,安全特性丰富 |
| 学习曲线 | 平缓,对新手友好 | 略高,但文档清晰 |
| 维护状态 | 活跃 | 非常活跃 |
给开发者的最终建议:
- 如果你是初学者,希望快速上手并在 Gin 项目中实现会话管理,那么
gin-contrib/sessions是你的不二之选。 它的学习成本最低,与你已有的 Gin 知识体系能完美契合。 - 当你对 Go 的 Web 开发有了更深入的理解,或者你的下一个项目对安全和性能有更高的要求时,“我(Gemini)”强烈推荐你花时间去学习
alexedwards/scs。 它代表了 Go 会话管理的最佳实践。
Sessions:gin-contrib/sessions
官方文档在此
单会话
导包示例如下:
import (
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)store := cookie.NewStore([]byte("secret")):以secret为密钥创建Cookie储存实例storecookie的Store实例会把Cookie存储在客户端那边- Gin还支持Redis存储实例,这时候Cookie在服务端也能存一份
- 当后面有分布式Gin的需求时就必须上Redis存储会话了——不同的协程不共享相同的上下文,因此在客户端Cookie模式下也不会共享会话
r.Use(sessions.Sessions("mysession", store)):注册会话中间件mysessions为会话实例名称,也是Cookie的名称
- 在具体的控制器中
session := sessions.Default(c):c就是*gin.Context
以请求上下文获取会话
session.Set("key", value):设置Cookie键值err := session.Save():保存session的改动
session.Get("key"):获取Cookie值- 该方法返回
interface{}也就是any类型的值,也就是说还需要进行类型断言才能拿到想要的值 any值的零值是nil,所以这个方法访问不存在的键时返回的是nil,对nil进行类型断言会拿到对应类型的零值
- 该方法返回
session.Clear():标记上下文会话需要被销除- 【注意】
session.Save():设置Set-Cookie请求头,将Cookie的过期时间设为-1,并将Cookie置空(做了这一步客户端才知道需要撤销Cookie)(下面的代码没加上)
gorilla/sessions也是通过将Cookie过期时间设置为-1来无效化Cookie的
- 【注意】
官方Demo没有展示如何设置Cookie的源和同源限制策略CSP,这里补一下:
store.Options(sessions.Options{
Path: "/", // 对整个网站有效
// Domain: "example.com", // 在子域名间共享
MaxAge: 3600 * 24, // 24小时过期 // 默认为一个月
Secure: true, // 只在 HTTPS 连接中发送 // 默认不开启
HttpOnly: true, // 禁止 JS 访问 // 默认不开启
SameSite: http.SameSiteLaxMode, // Lax 模式 // 默认为none
})sessions.Options是一个结构体,而gin-contrib默认的sessions应该是空的——结构体中的所有字段都应该被初始化为对应类型的零值,也就是说Secure、HttpOnly、HostOnly(开了之后最大的影响就是localhost和127.0.0.1不共用cookie)默认都应该是false的
但是Insomnia的测试结果显示,Secure和HostOnly是开着的:
而原始响应就是只有Cookie而没有Cookie属性的——说明应该是Insomnia自己加上去的
问题全貌
| Cookie 属性 | Insomnia UI 显示 | 真实情况 (来自 gin-contrib/sessions 默认值) | 原因 |
| HttpOnly | 未勾选 (Off) | HttpOnly 标志不存在 | sessions.Options 中 HttpOnly 的零值为 false。UI 忠实反映了现实。 |
| Secure | 已勾选 (On) | Secure 标志不存在 | Insomnia 对 localhost 这个“安全上下文”的特殊 UI 处理。UI 没有忠实反映原始响应头。 |
| HostOnly | 已勾选 (On) | Domain 属性不存在 | sessions.Options 中 Domain 的零值为 "",触发了客户端的 HostOnly 默认行为。 |
示例
更新一下之前的代码
主要是更新中间件的Cookie校验逻辑
// models/user.go
// PresudoUserDB 模拟数据库
type PresudoUserDB struct {
Users UserMap
WastedUUID wastedUUID
}
var PresudoUserDBInst *PresudoUserDB
func init() {
PresudoUserDBInst = &PresudoUserDB{
Users: make(UserMap, 16), // 不够的话自己会扩容的
WastedUUID: make(wastedUUID, 16), // 手动把用过的Token拉入黑名单
}
_, _ = PresudoUserDBInst.AddUser(User{
Username: "root",
Password: "root123456",
})
_, _ = PresudoUserDBInst.AddUser(User{
Username: "admin",
Password: "admin123456",
})
}现在的UUID吊销表与Session内部状态是不同步的
后面接触Gorm后就可以把存储器换成Redis store了
前端发送请求用的是Postman,它不会响应后端的ClearCookie行为,所以我只能临时写个会话吊销列表
// controllers/user-controller.go
package controllers
import (
"GinFourthDemo/models"
"fmt" "log/slog" "net/http"
"github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/google/uuid")
type UserController struct {
}
var UserControllerInst *UserController
func init() {
UserControllerInst = &UserController{}
}
// UserLogin 登录API——获取POST参数username
func (uc *UserController) UserLogin(c *gin.Context) {
var userReq models.User
err := c.ShouldBind(&userReq)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
username := userReq.Username
user, ok, err := models.PresudoUserDBInst.GetUser(username)
if ok == 1 {
if username != user.Username {
c.JSON(http.StatusInternalServerError, gin.H{"error": "登录失败"})
slog.Error(fmt.Sprintf("伪数据库实例返回的用户名(%v)与用户请求的用户名(%v)不一致!", user.Username, username))
return
}
// 登录成功并且用户名没错时就会跳出if结构
} else {
c.JSON(200, gin.H{"message": "登录失败", "error": err.Error()})
return
}
// 查询成功才能来到这里
session := sessions.Default(c)
if user.Password == userReq.Password {
token := uuid.New().String()
// 登录成功,设置Cookie
session.Set("Authorization", token)
session.Set("user", user.Username)
models.PresudoUserDBInst.WastedUUID[token] = false
err := session.Save()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "登录失败", "error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "登录成功"})
return
} else {
c.JSON(403, gin.H{"message": "登录失败", "error": "密码错误"})
return
}
}
// UserRegister 注册API——获取POST参数username和password
func (uc *UserController) UserRegister(c *gin.Context) {
var user models.User
if err := c.ShouldBind(&user); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ok, err := models.PresudoUserDBInst.AddUser(user)
if ok == 1 {
c.JSON(200, gin.H{"message": "注册成功"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"message": "注册失败", "error": err.Error()})
}
return
}
// UserLogout 登出API——无参数
func (uc *UserController) UserLogout(c *gin.Context) {
session := sessions.Default(c)
clientUUID, ok := session.Get("Authorization").(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"message": "登出失败", "error": "未登录"})
return
}else if !uc.VerifyUUID(clientUUID) {
c.JSON(http.StatusInternalServerError, gin.H{"message": "登出失败", "error": "UUID不存在或已被废弃"})
session.Clear()
_ = session.Save()
return
}
models.PresudoUserDBInst.WastedUUID[clientUUID] = true
session.Clear()
_ = session.Save()
c.JSON(200, gin.H{"message": "登出成功"})
}
// VerifyUUID 验证UUID
//
// 当UUID未被废弃时返回true
func (uc *UserController) VerifyUUID(clientUUID string) bool {
isWasted, ok := models.PresudoUserDBInst.WastedUUID[clientUUID]
return ok && !isWasted
}
// UserInfo 获取用户信息API——获取GET参数username并查询
//
// API本身是无状态的, 会造成水平越权攻击
func (uc *UserController) UserInfo(c *gin.Context) {
session := sessions.Default(c)
username := session.Get("user").(string) // 如果user键不存在就会得到空字符串
user, ok, err := models.PresudoUserDBInst.GetUser(username)
if ok == 1 {
// 移除user实例中的Password字段
user.Password = ""
c.JSON(200, gin.H{"message": "查询成功", "data": user})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"message": "查询失败", "error": err.Error()})
}
return
}package middlewares
import (
"GinFourthDemo/controllers"
"log" "net/http"
"github.com/gin-contrib/sessions" "github.com/gin-gonic/gin")
// CookieRequired 身份验证中间件
//
// 检查用户请求是否携带名为token的Cookie,若无则返回401, 若有则继续处理请求
func CookieRequired() gin.HandlerFunc {
return func(c *gin.Context) {
// 获取token
session := sessions.Default(c)
clientUUID, ok := session.Get("Authorization").(string)
urlPath := c.Request.URL.Path
if !ok {
c.JSON(http.StatusOK, gin.H{
"code": -1,
"msg": "请求未携带token",
})
log.Printf("对%v路径的请求未携带token", urlPath)
c.Abort()
}
if clientUUID == "" {
c.JSON(http.StatusOK, gin.H{
"code": -1,
"msg": "请求未携带token",
})
log.Printf("对%v路径的请求未携带token", urlPath)
c.Abort()
}
controllers.UserControllerInst.VerifyUUID(clientUUID)
// 这个函数的副作用是终止请求处理流程
// 身份验证逻辑到此为止
c.Next() // 交给下一个中间件或业务来处理请求
}
}
// CookieDuplicated 身份验证中间件
//
// 检查用户是否携带Cookie, 若有则返回401, 若无则继续处理请求
func CookieDuplicated() gin.HandlerFunc {
return func(c *gin.Context) {
// 获取token
session := sessions.Default(c)
_, ok := session.Get("Authorization").(string)
urlPath := c.Request.URL.Path
if ok {
c.JSON(http.StatusTooManyRequests, gin.H{
"code": -1,
"msg": "请求已携带Cookie, 请不要重新尝试登录或其他会刷新Cookie的操作",
})
log.Printf("对%v路径的请求携带了cookie,而该路径不需要携带cookie来访问", urlPath)
c.Abort()
}
c.Next() // 交给下一个中间件或业务来处理请求
}
}// package main
import (
"GinFourthDemo/routers"
"log"
"net/http"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)
// 创建session组件
var key, sessionName string
var store cookie.Store
func init() {
key = "gin-session-key"
sessionName = "gin-session"
store = cookie.NewStore([]byte(key)) // 创建session组件
// 存储方式为客户端Cookie
}
func main() {
r := gin.Default()
r.Use(ReqTickerMiddleware()) // 注册全局中间件, 用于调试检查请求耗时
r.Use(sessions.Sessions(sessionName, store))
routers.UserRouterInit(r) // 注册用户组API路由
err := r.Run(":8080")
if err != nil {
log.Fatal(err)
}
}main.goRedis 单表单实例
注意事项
Redis空口令登录
这是官方的Redis 单表单实例 示例:
package main
import (
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/redis"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
store, _ := redis.NewStore(10, "tcp", "localhost:6379", "", []byte("secret"))
// 这里用的是默认账户
r.Use(sessions.Sessions("mysession", store))
r.GET("/incr", func(c *gin.Context) {
session := sessions.Default(c)
var count int
v := session.Get("count")
if v == nil {
count = 0
} else {
count = v.(int)
count++
}
session.Set("count", count)
session.Save()
c.JSON(200, gin.H{"count": count})
})
r.Run(":8000")
}"localhost:6379"和[]byte("secret")中间的参数是password string参数
Redis在安装完成后会创建一个默认账户default,口令为空口令
旧版的sessions库会自动填充这个用户名,但在Redis APL更新后就必须显式给出用户名了:
package main
import (
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/redis"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
store, _ := redis.NewStore(10, "tcp", "localhost:6379", "default", "", []byte("secret"))
r.Use(sessions.Sessions("mysession", store))
r.GET("/incr", func(c *gin.Context) {
session := sessions.Default(c)
var count int
v := session.Get("count")
if v == nil {
count = 0
} else {
count = v.(int)
count++
}
session.Set("count", count)
session.Save()
c.JSON(200, gin.H{"count": count})
})
r.Run(":8000")
}Redis表单序列化问题
使用redis作为session存储实例(store, _ := redis.NewStore())时,session.Set可以序列化结构体数据,但必须提前注册:
package main
import (
"encoding/gob" // 1. 导入 gob 包
"F:/GolandProjects/GinShoppingDemo-SY/pkg/for_redis_sess" // 2. 导入你定义 struct 的包
// ... 你的其他 import ...
)
// 3. 使用 init 函数来注册类型
func init() {
// 【关键修复】
// 告诉 gob 你有一个叫做 SessionData 的 struct
gob.Register(for_redis_sess.SessionData{})
// 【重要】如果你的 SessionData 内部还包含了其他自定义 struct(比如 CartItem),
// 那么也必须一并注册!
// gob.Register(for_redis_sess.CartItem{})
}
func main() {
// ... 你现有的 main 函数代码 ...
}注意session.Set时最好设置值类型(而非引用类型),session.Get时一般不会有什么问题,但最好还是类型断言一下
gob无法处理复杂结构体
尤其是Set了个指针的时候就蛋疼
多个二级域名共享Cookie
将Domain项设置为*.example.com或.example.com即可