一. Session 概述
HTTP 协议是无状态的。即每一个 HTTP 请求都是独立的,可以认为相互之间没有任何关系。但实际上在业务中,经常需要将某些请求归并为一组。最为直观的例子就是登录状态。一些资源是只有登录之后才可以访问的,所以一个 HTTP 请求过来,就要带上登录后的身份标识。进一步说,在登录之后,除了身份标识,还需要临时存放一些和用户相关的数据。这些东西就被合称为 Session,会话。
二. Session ID
核心就是让用户在 HTTP 请求里面带上这种凭证。这个凭证也叫做 session id。输入可能来自 HTTP 协议的各种部分,那么 session id 也可以放在这些位置:
Cookie:也是最为常见的方案。每次发送请求到同一个域名之下,Cookie 就会被浏览器自动带上。如果跨越了域名,Cookie 就不一定生效。HTTP Header:部分情况下,如果用户禁用了 Cookie,那么我们可以考虑放到 Header 里面。HTTP URL:在 URL 后面附上一个参数,例如sessid=xxx。HTTP Body:理论上也可以放在这里,但目前没怎么见过。
GitHub 上的 Session 设置,利用的是 Cookie。这是使用 Cookie-Editor 调出来看的。
三. 各框架 Session 的设计
3.1 Beego Session 设计
Controller 内置了 Session 的操作,也是一种侵入式的设计。
1. 使用例子
type MainController struct {
web.Controller
}
func (ctrl *MainController) PutSession() {
// put something into session
ctrl.SetSession("name", "web session")
// web-example/views/hello_world.html
ctrl.TplName = "hello_world.html"
ctrl.Data["name"] = "PutSession"
_ = ctrl.Render()
}
func (ctrl *MainController) ReadSession() {
// web-example/views/hello_world.html
ctrl.TplName = "hello_world.html"
ctrl.Data["name"] = ctrl.GetSession("name")
// don't forget this
_ = ctrl.Render()
}
func (ctrl *MainController) DeleteSession() {
// delete session all
ctrl.DestroySession()
// web-example/views/hello_world.html
ctrl.TplName = "hello_world.html"
_ = ctrl.Render()
}
2. Store 抽象
Store 是 Session 中数据存储在哪里的抽象,例如基于 Redis 的实现 Session 的存储。
3. Provider 抽象
Provider 实际上是管理 Session 本身的抽象。一般来说 Store 和 Provider 都是成对出现的。
4. 总结
3.2 Gin Session 设计
Gin 的 Session 是完全被拆出去作为一个独立模块的,连代码仓库都不一样:github.com/gin-contrib… 它通过嵌入一个 Middleware 来嵌入到请求处理过程中。这种完全无侵入式的方案,就是最为完美的方案。它体现了设计者认为 Session 本身就不应该归属到 Web 核心里面,撑死了算是一个扩展。
1. 使用例子
func TestGinSession(t *testing.T) {
r := gin.Default()
store := cookie.NewStore([]byte("secret"))
r.Use(sessions.Sessions("mysession", store))
r.GET("/hello", func(c *gin.Context) {
session := sessions.Default(c)
if session.Get("hello") != "world" {
session.Set("hello", "world")
session.Save()
}
c.JSON(200, gin.H{"hello": session.Get("hello")})
})
r.Run(":8000")
}
2. session Middleware
这个 Middleware 其实就是设置了一下 Session,操作非常轻量。
3. Session 接口
Session 大体上分成两部分:
- Get、Set 和 Delete: 这种操作键值对
- AddFlash 和 Flashes:操作 Flash 的
Flash,就是指存储之后,只能被取出来一次的数据。也就是说某些数据用过了之后就不能再次使用。从个人的观点来看,Session 和 Flash 是两种不同的东西,不能将 Flash 看做是一种一次性的临时的 Session 的键值对。也就是说,应该把Flash 和 Session 看成同等级的抽象。
4. session 结构体
session 结构体实现了 Session 接口,它封装了 sessions.Session。但是,这个 sessions.Session 并不是 Gin 的Session,而是另外一个包的 Session:github.com/gorilla/sessions ,即 gollira Session。
gollira Session:
Gin 中 Session 也可以看做是对 gollira Session 的封装。gollira 采用了一种刷新的设计策略。即数据最开始存放在内存,也就是 Values 里面,后面再调用Save 来真的刷新到存储里面,比如说刷新到 Redis里面。
gollira Store:
它实际上承担了真正的存储数据的职责,比如将 session 存到 Redis、存到 MySQL 之类的。另外一方面,从它接收 http.Request 和http.ResponseWriter 来看,它也承担了 session id 存放和提取的职责。
5. 总结
Gin 抽象了 Session 和 Store 两个概念。同时提供了 Middleware 往 Context 里面注入 Session 的功能,用户也能从 Context 里面把 Session 提取出来。Context 就是作为一个中介。
3.3 Echo Session 设计
和 Gin 半斤八两。机制基本一样,利用 Middleware 注入 Store,并且在请求进来的时候就把 Session 放到 Context 里面。然后在业务代码里尝试把Session 拿出来,并且从 Session 里读写值。
1. 使用例子
func TestSession(t *testing.T) {
e := echo.New()
e.Use(session.Middleware(sessions.NewCookieStore([]byte("secret"))))
e.GET("/", func(c echo.Context) error {
sess, _ := session.Get("session", c)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7,
HttpOnly: true,
}
sess.Values["foo"] = "bar"
sess.Save(c.Request(), c.Response())
return c.NoContent(http.StatusOK)
})
}
3.4 Iris Session 设计
1. Session
sid:也就是 session idflashes:存放的是 Flash 信息provider: 是一个关键的设计,它管理的是 Session 本身
2. provider
provider 也是一个比较常规设计: sessions:是在内存里放置的 Session 实例 db:是存储 Session 数据的地方。相当于 sessions 字段放的是皮,db 里面才是用户真的存储数据的地方
3. Database
Database 是真实存放数据的地方。Iris 提供了几个实现,比如说基于 Redis 的设计。大体上,可以依旧认为这是一个过度设计的产物。因为这里面的很多方法,其实不能说是核心方法,只能说是不同用户的个性需求,或者部分场景下的特殊需求。真正的核心方法应该就是 Set 和 Get 两个。
4. LifeTime
LifeTime 是用来管理 Session 生命周期的。妥妥的过度设计产物。对于大多数 Session 来说,无非就是创建、销毁,以及续命(延长过期时间,或者说刷新)。
为什么说 Iris 是过度设计呢,就是因为它构建的抽象复杂庞大,但本身大部分人是用不到的。相当于,里面90% 的代码是 90% 的用户根本用不上的。将个性需求做成共性(也就是抽象出来),是不值 得学习的。
5. 总结
- 一个直接为用户提供接口的 Session 结构体
- 一个控制 Session 创建和销毁的 provider
- 一个控制 Session 过期时间的 LifeTime
- 一个真实存储 Session 数据的 Database
它的设计是一种非常侵入式的设计,就是直接把 Session 做成了 Web 框架的一部分,还是那种偏核心的部分。
四. 自定义的 Session 设计
首先要搞清楚一个问题:Session 究竟是不是 Web 框架要解决的核心问题?
- 是:那么可以尝试采用侵入式的设计
- 否:那么就不能和 Web 框架主体耦合
可以参考前面的例子:
Beego:是,在它的核心里面有很多和 Session 处理有关的代码Gin:不是,它只是借用了 Middleware 来将 Session 注入到了 Context 里面
基本上,我个人认为 Session 是 Web 应用的一部分,但不是 Web 框架的一部分。也就是说,构建面向真实用户的 Web 应用的时候,确实需要 Session;但是这个职责不应该归属于 Web 框架,而应该是一个完全独立的东西。Web 框架对此应该毫无感知才对。
Gin 的设计很好,但是其实 Gin 可以不负责 Session 的管理,而是让用户直接使用 gorrila Session。
4.1 为什么要反对侵入式设计?
- 坚持开闭原则:所有的侵入式设计,都违背了这个原则。因为侵入式就要修改已有的实现,而不是提供新的实现。
- 代码难维护:侵入式设计属于典型的千里之堤毁于蚁穴的场景。每次侵入式增加一个功能,代码距离屎山又近了一步,到中期新人就不太可能读懂代码了。
- Do less:侵入式设计无法解决所有用户的问题,所以不如将选择权给用户,而作为设计者只提供解决问题的接入点。
4.2 总体抽象
从前面的框架分析,实现一个 Session 模块要解决以下问题:
- 管理 Session 的问题:如创建、查找、销毁和刷新
- Session 存储和查找用户数据:要考虑这些数据真实存储在什么地方
- session id 存储和提取的问题:基本上就是要和 HTTP 的请求响应打交道
在前面的设计里,Beego、gorilla、Gin 和 Echo 都是将 1 和 3 合并在一起的。这里将它们分开,因为这更加符合单一职责原则。
三个基本抽象,用户自由组合它们的实现
4.2.1 Session 管理
1. Session 抽象
type Session interface {
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key string, val string) error
ID() string
}
Session 本体需要考虑的就是存储和查找用户设置的数据。在这个设计里面不打算支持 Flash。(前面说过,它不能看做是一种特殊的 Session 键值对,而应该看做是一种新形态的东西)
- Get:根据 Key 来找数据
- Set:设置键值对
考虑到 Session 的数据可能存储在数据库或者 Redis 上,那么还可以引入批量方法:MultiGet、MultiSet。本次实现不会支持批量。另外一个可选的方案类似 Gin、gorilla,提供一个 Save 方法,Save 才会最终将数据写到对应的存储中间件里。
2. Store 抽象
该模块对应前面 Beego 的 Provider,或者gorilla、Gin、Echo、Iris 的 Store。
它需要支持:
- 创建 Session:例如在用户登录成功之后,为他创建一个 Session。Session 应该有过期时间。
- 销毁 Session:当用户退出登录的时候,要销毁掉数据。
- 查找:根据 HTTP 请求里面携带的 session id,验证 session id 并查找对应的 Session 实例。
- 刷新:如果用户持续保持活跃,那么 Session 应该在这期间一直有效。
// Store 管理 Session
// 从设计的角度来说,Generate 方法和 Refresh 在处理 Session 过期时间上有点关系
// 也就是说,如果 Generate 设计为接收一个 expiration 参数,
// 那么 Refresh 也应该接收一个 expiration 参数。
// 因为这意味着用户来管理过期时间
type Store interface {
// Generate 生成一个 session
Generate(ctx context.Context, id string) (Session, error)
// Refresh 这种设计是一直用同一个 id 的
// 如果想支持 Refresh 换 ID,那么可以重新生成一个,并移除原有的
// 又或者 Refresh(ctx context.Context, id string) (Session, error)
// 其中返回的是一个新的 Session
Refresh(ctx context.Context, id string) error
Remove(ctx context.Context, id string) error
Get(ctx context.Context, id string) (Session, error)
}
3. Store 和 Session 的实现
这里,默认将 session 存到缓存中
package memory
import (
"context"
"errors"
cache "github.com/patrickmn/go-cache"
"time"
"web/session"
)
func (s *Store) Refresh(ctx context.Context, id string) error {
sess, err := s.Get(ctx, id)
if err != nil {
return err
}
s.c.Set(sess.ID(), sess, s.expiration)
return nil
}
func (s *Store) Remove(ctx context.Context, id string) error {
s.c.Delete(id)
return nil
}
func (s *Store) Get(ctx context.Context, id string) (session.Session, error) {
sess, ok := s.c.Get(id)
if !ok {
return nil, errors.New("session not found")
}
return sess.(*memorySession), nil
}
func (s *Store) Generate(ctx context.Context, id string) (session.Session, error) {
sess := &memorySession{
id: id,
data: make(map[string]string),
}
s.c.Set(sess.ID(), sess, s.expiration)
return sess, nil
}
// NewStore 创建一个 Store 的实例
// 实际上,这里也可以考虑使用 Option 设计模式,允许用户控制过期检查的间隔
func NewStore(expiration time.Duration) *Store {
return &Store{
c: cache.New(expiration, time.Second),
}
}
type Store struct {
// 利用一个内存缓存来帮助我们管理过期时间
c *cache.Cache
expiration time.Duration
}
func (m *memorySession) Set(ctx context.Context, key string, val string) error {
m.data[key] = val
return nil
}
func (m *memorySession) Get(ctx context.Context, key string) (string, error) {
val, ok := m.data[key]
if !ok {
return "", errors.New("找不到这个 key")
}
return val, nil
}
func (m *memorySession) ID() string {
return m.id
}
type memorySession struct {
id string
data map[string]string
expiration time.Duration
}
4. cookie
cookie 用来存储用户的 session id,其实就是负责 session id 的管理的实现
package cookie
import "net/http"
type PropagatorOption func(propagator *Propagator)
func WithCookieOption(opt func(c *http.Cookie)) PropagatorOption {
return func(propagator *Propagator) {
propagator.cookieOpt = opt
}
}
type Propagator struct {
cookieName string
cookieOpt func(c *http.Cookie)
}
func NewPropagator(cookieName string, opt ...PropagatorOption) *Propagator {
res := &Propagator{
cookieName: cookieName,
cookieOpt: func(c *http.Cookie) {},
}
return res
}
func (c *Propagator) Inject(id string, writer http.ResponseWriter) error {
cookie := &http.Cookie{
Name: c.cookieName,
Value: id,
}
c.cookieOpt(cookie)
http.SetCookie(writer, cookie)
return nil
}
func (c *Propagator) Extract(req *http.Request) (string, error) {
cookie, err := req.Cookie(c.cookieName)
if err != nil {
return "", err
}
return cookie.Value, nil
}
func (c *Propagator) Remove(writer http.ResponseWriter) error {
cookie := &http.Cookie{
Name: c.cookieName,
MaxAge: -1,
}
c.cookieOpt(cookie)
http.SetCookie(writer, cookie)
return nil
}
4.2.2 Session ID 存储和提取
回顾开始提到,session id 可以存在很多地方,只是主流都是存储在 Cookie 里面而已。
Propagator 就是一个抽象层,不同的实现允许将 session id 存储在不同的地方。
Inject:将 session id 注入到 HTTP 响应里面Extract:将 session id 从 HTTP 请求里面提取出来
type Propagator interface {
// Inject 将 session id 注入到里面
// Inject 必须是幂等的
Inject(id string, writer http.ResponseWriter) error
// Extract 将 session id 从 http.Request 中提取出来
// 例如从 cookie 中将 session id 提取出来
Extract(req *http.Request) (string, error)
// Remove 将 session id 从 http.ResponseWriter 中删除
// 例如删除对应的 cookie
Remove(id string, writer http.ResponseWriter) error
}
理论上可以设计两个接口,但因为它们基本都是成对出现的,例如说存储在 Cookie 里面,那么自然也是从 Cookie 里面提取出来,所以我们只需要一个接口。
4.2.3 Context 支持用户自定义数据
虽然理论上来说,我们能够利用 Req.Context来传递,但是它总是会引起 http.Request 的拷贝,所以我们可以考虑在 Context 里面支持一个UserValues 字段。
type Context struct {
Request *http.Request
// Response 原生的 ResponseWriter。当你直接使用 Response 的时候,
// 那么相当于你绕开了 RespStatusCode 和 RespData。
// 响应数据直接被发送到前端,其它中间件将无法修改响应
// 其实我们也可以考虑将这个做成私有的
Response http.ResponseWriter
// 缓存的响应部分
// 这部分数据会在最后刷新
RespStatusCode int
RespData []byte
// 路径参数
PathParams map[string]string
// 命中的路由
MatchedRoute string
// 万一将来有需求,可以考虑支持这个,但是需要复杂一点的机制
// Body []byte 用户返回的响应
// Err error 用户执行的 Error
// 缓存的数据
cacheQueryValues url.Values
// 页面渲染的引擎
tplEngine TemplateEngine
// 用户可以自由决定在这里存储什么,
// 主要用于解决在不同 Middleware 之间数据传递的问题
// 但是要注意
// 1. UserValues 在初始状态的时候总是 nil,你需要自己手动初始化
UserValues map[string]any
}
注意:这里设计初始化为 nil,还是因为多数用户可能用不上。即便用得上,不同的人也需要不同的初始化容量。所以为了规避内存分配,将这个 UserValues的初始化过程交给了用户。当然也可以初始化为一个固定容量的 map,比如说make(map[string]any, 8)。但是这就相当于让多数用户为少数用户买单了——他们付出了内存和 CPU,结果自己用不上。
Gin、Echo 和 Iris 的 context 都有类似的设计。
比较奇怪的是,大家都没想过用context.Context 来传递, 为什么?因为 context.Context 的一个特性:父亲 context.Context 将无法访问子 context.Context 里面的内容。
4.2.4 Manager 用户友好的封装
type Manager struct {
Store
Propagator
SessCtxKey string
}
前面那些东西都可以理解为是必不可少的,而这个 Manager 则纯粹是为了用户体验搞出来的。虽然在用户眼里它是核心结构,但你站在一个设计者的角度,应该认识到,它就是一个胶水。
// GetSession 将会尝试从 ctx 中拿到 Session,
// 如果成功了,那么它会将 Session 实例缓存到 ctx 的 UserValues 里面
func (m *Manager) GetSession(ctx *web.Context) (Session, error) {
if ctx.UserValues == nil {
ctx.UserValues = make(map[string]any, 1)
}
val, ok := ctx.UserValues[m.SessCtxKey]
if ok {
return val.(Session), nil
}
id, err := m.Extract(ctx.Request)
if err != nil {
return nil, err
}
sess, err := m.Get(ctx.Request.Context(), id)
if err != nil {
return nil, err
}
ctx.UserValues[m.SessCtxKey] = sess
return sess, nil
}
// InitSession 初始化一个 session,并且注入到 http response 里面
func (m *Manager) InitSession(ctx *web.Context, id string) (Session, error) {
sess, err := m.Generate(ctx.Request.Context(), id)
if err != nil {
return nil, err
}
if err = m.Inject(id, ctx.Response); err != nil {
return nil, err
}
return sess, nil
}
// RefreshSession 刷新 Session
func (m *Manager) RefreshSession(ctx *web.Context) (Session, error) {
sess, err := m.GetSession(ctx)
if err != nil {
return nil, err
}
// 刷新存储的过期时间
err = m.Refresh(ctx.Request.Context(), sess.ID())
if err != nil {
return nil, err
}
// 重新注入 HTTP 里面
if err = m.Inject(sess.ID(), ctx.Response); err != nil {
return nil, err
}
return sess, nil
}
// RemoveSession 删除 Session
func (m *Manager) RemoveSession(ctx *web.Context) error {
sess, err := m.GetSession(ctx)
if err != nil {
return err
}
err = m.Store.Remove(ctx.Request.Context(), sess.ID())
if err != nil {
return err
}
return m.Propagator.Remove(ctx.Response)
}
注意到,GetSession 方法里为了加速获得 Session,在第一次从 ctx 里解析出来之后,尝试缓存在 ctx 里面,避免再次解析。
五. 自定义 Session 简单的使用例子
func LoginMiddleware(m *session.Manager) web.Middleware {
return func(next web.HandleFunc) web.HandleFunc {
return func(ctx *web.Context) {
// 执行校验
if ctx.Request.URL.Path != "/login" {
sess, err := m.GetSession(ctx)
// 不管发生了什么错误,对于用户我们都是返回未授权
if err != nil {
ctx.RespStatusCode = http.StatusUnauthorized
return
}
ctx.UserValues["sess"] = sess
_ = m.Refresh(ctx.Request.Context(), sess.ID())
next(ctx)
}
}
}
}
func TestManager(t *testing.T) {
s := web.NewHTTPServer()
m := &session.Manager{
SessCtxKey: "_sess",
Store: memory.NewStore(30 * time.Minute),
Propagator: cookie.NewPropagator("sessid",
cookie.WithCookieOption(func(c *http.Cookie) {
c.HttpOnly = true
})),
}
s.Use(LoginMiddleware(m))
}
六. Session 安全性
实际上 Session 这种 session id 的认证还是比较 弱的,如果没有做一些安全措施,那么不管是谁拿 到 session id,服务器都只认 session id 不认人。
一些可行的保护 session id 的方案:
- 在使用 Cookie 的时候,同时设置 http_only 和 secure 选项,限制 Cookie 只能在 HTTPS 协议里面被传输。
- 在 session id 编码的时候带上一些客户端信息, 如 agent 信息、MAC 地址之类的。如果服务端 检测到 session id 所携带的这些信息发生了变 化,就要求用户重新登录。
这就是 Session 劫持
如果在后端设置cookie的HttpOnly属性,那么在前端调用接口后,无法读取cookies,有效防止XSS攻击,增加cookies安全性
七. Session 总结
7.1 为什么中间件设计者不管 session id 生成?
session id 在当下的生成策略可以说是五花八门,实在管不过来。session id 的生成 策略可以要考虑:
- 是否要包含业务信息
- 是:
- 编码什么业务信息,用户决定,接口难以设计
- 编码用什么算法,用户决定,接口更难以设计
- 否:
- 都不用包含什么信息了,UUID 搞一下就可以了,用不着web框架来管
- 是:
虽然不考虑管理 session id,但是还是有一些基本原则可以参考:
- 如果要尝试将数据编码到 session id 里,那么就编码非敏感数据,敏感数据就不要编码进去了
- 如果因为 Redis 之类的压力很大,那么可以编码部分数据到 session id 里面,减少对 Redis 的访问
- 谨慎选择编码(加密)算法
7.2 什么时候刷新 Session
func LoginMiddleware(m *session.Manager) web.Middleware {
return func(next web.HandleFunc) web.HandleFunc {
return func(ctx *web.Context) {
// 是登录请求则执行校验
if ctx.Request.URL.Path != "/login" {
sess, err := m.GetSession(ctx)
// 不管发生了什么错误,对于用户我们都是返回未授权
if err != nil {
ctx.RespStatusCode = http.StatusUnauthorized
return
}
// 每次收到一个请求都刷新
ctx.UserValues["sess"] = sess
_ = m.Refresh(ctx.Request.Context(), sess.ID())
next(ctx)
}
}
}
}
- 如果我们一直不刷新 Session,时间一到,Session 就会直接过期。即便此时用户还在操作,也会导致 用户直接退出登录状态。
- 最直接的做法就是每次收到一个请求都刷新。体量不大的时候就这么干,简单直接好维护。 类似的做法还可以是前端定时心跳刷新,例如 5 秒 刷新一次。
- 高端一点的就是维护长短两个 token,可以看做是 两个过期时间不一样的 Session。每次先检查短过期时间的 token,找不到就去找长过期时间的 token,这时会重新生成一个短 token。
八. 面试要点
- Session 是什么?一种在服务端维护用户状态的机制
- Cookie 和 Session 的对比?其实两者都可以看做是维护用户状态的机制,只不过一个是在客户端,一个是在服务端
- 什么时候刷新 Session?一般是用户活跃的时候就可以刷,前端定时刷,或者后端每次收到请求都刷。但是这里要注意,频繁刷新 Session 可能给 Session 存储的地方带来庞大的压力,例如 Redis
- 怎么实现一个 Session?简单来说就是要构建出 Session 和 Store 两个抽象,其它就随意了
- 怎么生成 session id?看业务需求了,最简单的就是 UUID,高级一点的就用特定的算法进行加密,然后将业务需要的数据编码进去
- 为什么要把数据编码进 session id?为了减轻存储 Session 的压力,比如说 Redis。直接解码拿到数据之后就不需要访问 Redis 了
- 什么数据可以编码进 session id?一般来说也是看业务,不过关键是非核心数据才能编码进去,不然泄露了就凉了
- session id 可以放哪里?一般都是根据喜好,因为问题都不大。可以放 Cookie,放 Header,放查询参数。主流是 Cookie,但是用户可能禁用了 Cookie
- session id 放 cookie 有什么缺点?主要就是安全性的问题,一般来说我们会要求 Cookie 设置 HttpOnly,以及设置 SameSite 策略。最好的是启用 HTTPS 协议,并且设置 Cookie 的 Secure 选项。坦白说这是吹毛求疵,毕竟放 Header 或者查询参数连这些选项都没有