为什么需要状态管理?
HTTP 协议本身是无状态的。
也就是说,服务器每次收到请求时,默认都不知道这个请求是谁发来的。就像一家理发店,如果客人每次来都不报身份,店员根本分不清你是新客还是老客。
为了解决这个问题,就出现了Cookie。
Cookie 是什么?
Cookie 可以简单理解成:店家发给你的一张小纸条。
- 服务器通过
Set-Cookie把这张纸条发给浏览器。 - 浏览器会把纸条存起来。
- 以后你每次来店里,浏览器都会自动把这张纸条带上。
Cookie 本身不是状态管理方案,它只是一个载体(用来传递信息的工具)。真正的状态管理方案(Session、Token、JWT)都会用到它,或者用类似的方式传递信息。
方案一:Session —— 把会员卡放在店里
你去理发店办了张会员卡,但你怕卡丢了,于是跟店家说:
“卡你帮我保管吧,给我一个卡号就行。”
店家同意了:
- 把你的会员信息(姓名、余额、消费记录等)锁进店里的保险柜。
- 只给你一张写着「卡号」的小纸条(这个卡号就是 SessionId)。
- 这张小纸条通常放在 Cookie 里,浏览器会自动带着。
以后你每次来,只要报出卡号,店员就去保险柜里查出你的完整信息。
特点
- 真正的用户信息存在服务器(保险柜 = 内存 / Redis / 数据库)。
- 客户端只拿着一个随机字符串(卡号)。
- 服务器对信息有完全的控制权(想过期就过期,想改就改)。
这就是经典的 Session 方案。
方案二:不透明 Token —— 换了一种传卡号的方式
随着业务发展,有人发现 Session 有些不方便:
- 它主要依赖 Cookie,浏览器会自动带,但手机 App、跨域请求、前后端分离项目用起来没那么灵活。
- 前端几乎感知不到自己有“登录凭证”。
于是出现了 不透明 Token(Opaque Token)。
它的核心机制其实和 Session 几乎一样:
- 服务器生成一个随机字符串(Token)。
- 把这个 Token 和用户信息的对应关系存在 Redis 里。
- 把 Token 发给客户端。
- 客户端以后每次请求都主动带上这个 Token(通常放在请求头
Authorization: Bearer xxxxx)。 - 服务器拿到 Token 后,去 Redis 查询对应的用户信息。
和 Session 的主要区别
| 对比点 | Session | 不透明 Token |
|---|---|---|
| 传递方式 | 放在 Cookie,浏览器自动携带 | 放在 Header,客户端主动携带 |
| 适用场景 | 传统网站(同域名) | 前后端分离、手机 App、跨域 API |
| 客户端感知 | 几乎无感知 | 明确知道自己有一个 Token,可控性强 |
| 本质 | 服务器存数据 + 客户端拿钥匙 | 服务器存数据 + 客户端拿钥匙 |
所以可以简单记:不透明 Token ≈ 现代化的 Session,只是传递方式从 Cookie 换成了 Header。
方案三:JWT —— 把信息直接写在卡上
当分店越开越多(分布式系统)时,Session 和不透明 Token 都遇到了同一个问题:
所有分店都必须能访问同一个保险柜(Redis),否则就查不到会员信息。
虽然用 Redis 可以解决,但总归还是依赖共享存储。
有没有办法让每一家分店自己就能认出你,而不用每次都问总店?
于是 JWT(JSON Web Token) 出现了。
JWT 的做法
店家直接把会员信息写在卡上,并盖上一个防伪章:
- Header:写明“这张卡用什么算法做防伪”。
- Payload:真正的会员信息(userId、角色、过期时间等)。注意:这里不能放隐私信息,因为内容是可见的(只是 Base64 编码,不是加密)。
- Signature(签名):店家用只有自己知道的密钥,对前面两部分做一个防伪印章。
最终这张卡长这样:
xxxxx.yyyyy.zzzzz
(Header.Payload.Signature)
分店怎么验证?
任意一家分店拿到这张卡后:
- 用同样的密钥重新计算一遍签名。
- 和卡上的签名对比。
- 一致 → 说明没被篡改,直接信任卡上写的信息。
- 完全不需要再打电话问总店。
这就是 JWT 能解决分布式问题的核心原因:信息自包含 + 可本地验证。
重要提醒
JWT 解决的是「认证」(你是谁?这个请求合法吗?),而不是把所有用户数据都塞进去。
实际开发中:
- 认证阶段:验证签名即可,不需要查库。
- 需要完整用户信息时(比如余额、详细资料、最新状态):还是会用 Payload 里的
userId再去查一次数据库或缓存。
三种方案如何应对「开分店」(分布式)
| 方案 | 分布式下的做法 | 是否需要共享存储 | 是否真正无状态 |
|---|---|---|---|
| Session | 所有服务器共享同一个 Redis | 需要 | 否 |
| 不透明 Token | 所有服务器共享同一个 Redis | 需要 | 否 |
| JWT | 所有服务器只需共享同一个密钥 | 不需要 | 是(认证层面) |
方案对比总结
| 方式 | 数据主要存在哪里 | 传递方式 | 分布式友好度 | 能否立即作废 | 典型使用场景 |
|---|---|---|---|---|---|
| Session | 服务器 | Cookie(自动) | 中 | 容易 | 传统后台管理系统 |
| 不透明 Token | 服务器 | Header(主动) | 中 | 容易 | 前后端分离、移动端 API |
| JWT | 客户端(Token 自身) | Header(主动) | 高 | 较难 | 微服务、无状态 API、跨系统 |
写在最后
用一句话总结三者的本质:
- Session / 不透明 Token:服务器保管会员档案,你只拿钥匙。
- JWT:把会员信息直接写在卡上,并盖上防伪章,任何分店都能自己验真。
没有绝对的好坏,只有适不适合当前场景。
实际项目中最常见的组合是:
- 简单后台 → Session
- 前后端分离 / 移动端 → 不透明 Token + Redis,或 JWT + 必要的数据库查询
- 高并发、微服务 → JWT(认证) + Redis(缓存用户详细信息)
Go简单实现JWT
本文使用Go + gin + github.com/golang-jwt/jwt/v5 来简单实现登陆鉴权。
下面给你一套清晰、可直接学习的 Go JWT 实现模板(使用当前最主流的 github.com/golang-jwt/jwt/v5)。
我会分成两部分:
- 最基础核心实现(自己手写,方便理解原理)
- 配合 Gin 的完整可运行示例(含中间件)
1. 安装依赖
go get github.com/golang-jwt/jwt/v5
2. 核心 JWT 工具(推荐单独放一个文件)
// pkg/jwt/jwt.go
package jwt
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
// 密钥(生产环境一定要从环境变量读取,不要写死)
var jwtSecret = []byte("your-256-bit-secret-change-me-please")
// 自定义 Claims(可以把需要的用户信息放进去)
type Claims struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// GenerateToken 生成 JWT
func GenerateToken(userID int64, username, role string) (string, error) {
// 过期时间(建议 access token 15分钟 ~ 2小时)
expirationTime := time.Now().Add(2 * time.Hour)
claims := Claims{
UserID: userID,
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "dance-elite", // 你的项目名
},
}
// 库会自动生成 Header 基于使用的算法 {"alg": "HS256","typ": "JWT"} 进行Base64URL 编码 变成header
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// 整个Claims就会变成payload字段内容,也要进行base64编码,变成 JWT 的第二部分。
// SignedString 会完成「Base64URL 编码 header/payload + HMAC-SHA256 签名」三步,
// 返回 header.payload.signature 三段拼接的最终字符串。
return token.SignedString(jwtSecret) // 具体进行签名的部分
}
// ParseToken 解析并验证 Token
func ParseToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
// 强制校验签名算法,防止算法混淆攻击
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return jwtSecret, nil
})
if err != nil {
return nil, err
}
// 校验是否有效
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
3. Gin 中间件(鉴权)
// middleware/auth.go
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"your_project/pkg/jwt" // 改成你自己的路径
)
// JWTAuth 中间件
func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
// 1. 从 Header 获取 Authorization
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "缺少 Authorization 头"})
c.Abort()
return
}
// 2. 格式必须是 Bearer <token>
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization 格式错误,应为 Bearer <token>"})
c.Abort()
return
}
// 3. 解析 Token
claims, err := jwt.ParseToken(parts[1])
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效或过期的 Token", "detail": err.Error()})
c.Abort()
return
}
// 4. 把用户信息存到上下文,后续 handler 可以直接用
// userID, exists := c.Get("user_id")
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Next()
}
}
4. 完整使用示例(Login + 受保护接口)
// 示例 main.go 或 router
package main
import (
"net/http"
"github.com/gin-gonic/gin"
"your_project/middleware"
"your_project/pkg/jwt"
)
func main() {
r := gin.Default()
// 登录接口(公开)
r.POST("/login", LoginHandler)
// 需要登录才能访问的接口
auth := r.Group("/api")
auth.Use(middleware.JWTAuth())
{
auth.GET("/profile", ProfileHandler)
}
r.Run(":8080")
}
// LoginHandler 登录
func LoginHandler(c *gin.Context) {
type LoginReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
var req LoginReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// TODO: 这里应该查数据库验证账号密码
// 这里先写死演示
if req.Username != "admin" || req.Password != "123456" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
// 生成 Token
token, err := jwt.GenerateToken(1, req.Username, "admin")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成 Token 失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"expire": "2h",
})
}
// ProfileHandler 受保护接口
func ProfileHandler(c *gin.Context) {
userID, _ := c.Get("user_id")
username, _ := c.Get("username")
role, _ := c.Get("role")
c.JSON(http.StatusOK, gin.H{
"user_id": userID,
"username": username,
"role": role,
"msg": "这是受保护的接口,你已通过 JWT 验证",
})
}
5. 测试方法
- 登录获取 Token
curl -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
- 携带 Token 访问受保护接口
curl http://localhost:8080/api/profile \
-H "Authorization: Bearer 你刚才拿到的token"
学习重点总结
| 步骤 | 关键点 |
|---|---|
| 生成 Token | jwt.NewWithClaims + SignedString |
| 自定义 Claims | 嵌入 jwt.RegisteredClaims |
| 解析验证 | ParseWithClaims + 校验签名算法 |
| 过期时间 | ExpiresAt: jwt.NewNumericDate(...) |
| 安全点 | 强制检查 SigningMethodHMAC,防止算法攻击 |
| 中间件 | 从 Authorization: Bearer xxx 取 Token |
希望这个理发店的故事能帮你真正理解这些概念。
如果还有不清楚的地方,欢迎在评论区继续交流。