大家好,我是渔夫子。本号新推出「Go 工具箱」系列,意在给大家分享使用 go 语言编写的、实用的、好玩的工具。同时了解其底层的实现原理,以便更深入地了解 Go 语言。
在 web 开发中,大家一定会使用到 session。在 go 的很多 web 框架中并没有集成 session 管理的中间件。要想使用 session 功能,我推荐大家使用这个包:gorilla/sessions。以下是该包的基本情况:
一、什么是 session
session 就是用来在服务端存储相关数据的,以便在同一个用户的多次请求之间保存用户的状态,比如登录的状态。因为 HTTP 协议是无状态的,要想让客户端(一般浏览器代指一个客户端或用户)的前、后请求关联在一起,就需要给客户端一个唯一的标识来告诉服务端请求是来自于同一个用户,这个标识就是所谓的 sessionid。该 sessionid 由服务端生成,并存储客户端(cookie、url)中。 当客户端再次发起请求的时候,就会携带该标识,服务端根据该标识就能查找到存在服务端上的相关数据。其工作原理如下:
二、gorilla/sessions 包
2.1 简介
gorilla/sessions 包提供了将 session 数据存储于 cookie 和文件中的功能。同时还支持自定义的后端存储,比如将 session 数据存储于 redis、mysql 等。目前已基于该包实现的后端存储如下:
可以说基本上常用的存储方式都已经有对应的实现了,完全满足日常的需求。
2.2 安装
通过 go get 命令安装该包,如下:
go get github.com/gorilla/sessions
复制代码
2.3 基本使用
该包的使用可以分 5 步:定义存储 session 的变量、程序启动时实例化具体的 session 存储类型、在 handler 中获取 session、读取或存储数据到 session、持久化 session。
下面是使用示例,该示例以文件存储类型为例,即将 session 的数据存储到指定文件中。
package main
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"net/http"
"os"
)
// 第一步 定义全局的存储session数据的变量,
var Store sessions.Store
func main() {
r := gin.Default()
// 第二步,程序启动后,指定具体的存储类型:redis、mysql还是本地文件
Store = sessions.NewFilesystemStore("/tm//godemo", []byte("Hello"))
r.GET("/sigin", func(ctx *gin.Context){
// 第三步,在具体的handler中获取session
session, _ := Store.Get(ctx.Request, "sessionid")
//第四步,从session中读取或存储数据。
session.Values["userid"] = "123456"
userid := session.Values["userid"]
fmt.Println("userid:", userid)
//第五步,保存session数据。本质上是将内存中的数据持久化到存储介质中。本例是存储到文件中
session.Save(ctx.Request, ctx.Writer)
ctx.Writer.Write([]byte("Hello World"))
})
r.Run(":8080")
}
复制代码
在该示例中,第一步中的 sessions.Store 本质上是一个接口类型,只要实现了该接口,就可以存储 session 的数据。所以我们在第二步中就指定了具体的存储类型:文件存储。当然也可以是 mysql 或 redis 都可以。
在第三步获取 session 时,Store.Get 有两个参数,一个是请求参数 Request,一个是 session-name。这个 session-name 是存储 session-id 的变量名,存储于 cookie 或 url 的 query 中,当然也可以是在 Header 头中。服务端从 Request 中通过该参数名获取 session-id,再根据该 session-id 从后端存储中(文件、redis 或 mysql 等)获取对应的数据,如果有已经存在的数据,则读取出来并解析到 session 对象中,否则就初始化一个新的 session 对象。
第五步的操作本质上是持久化。因为在第四步的复制只是把数据存储在了内存中,需要调用 Save 才能将数据持久化到对应的存储介质上。
2.4 实现原理
session 的存储本质上就是在服务端给每一个用户存储一行记录。服务端给每个用户分配一个唯一的 session-id,以 session-id 为主键,存储对应的值。如果存储在 mysql 中,sessioin-id 就是主键;如果存储在 redis 中,session-id 就是 key;如果存储在文件中,session-id 就是对应的文件名,文件内容就是存储的 session 数据。
2.4.1 在内存中存储 session 数据
我们以最简单的将 session 存储在内存中为例一步一步实现。首先定义一个 session 对象,用于存储 session 数据:
type Session struct {
// session-id,每个用户具有唯一的id
ID string
// 存储在session中的数据,key-value形式
Values map[interface{}]interface{}
}
复制代码
好了,现在我们可以在服务端存储 session 数据了。如下:
package main
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"net/http"
"os"
)
func main() {
r := gin.Default()
r.GET("/sigin", func(ctx *gin.Context){
// 初始化一个session对象,Values用于保存数据
session := &Session{
Values: make(map[string]interface{}),
}
session.Values["userid"] = "123456"
userid := session.Values["userid"]
fmt.Println("userid:", userid)
ctx.Writer.Write([]byte("Hello World"))
})
r.Run(":8080")
}
复制代码
2.4.2 如何存储不同用户的 session
这是最简单的在服务端存储数据的方式。同时只有一个 session 对象,不能区分不同用户的数据。所以,需要给 session 一个唯一的标识。唯一的标识有不同的算法,可以使用数据库中的自增字段,也可能使用 uuid。我们这里使用 go 标准库中的读取随机值的方式,如下:
func GenerateRandomKey(length int) []byte {
k := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, k); err != nil {
return nil
}
return k
}
复制代码
那么,初始化 session 的代码演变成如下:
func main() {
r := gin.Default()
r.GET("/sigin", func(ctx *gin.Context){
// 初始化一个session对象,Values用于保存数据
session := &Session{
ID:base64.RawStdEncoding.EncodeToString(GenerateRandomKey(32)), //初始化session-id
Values: make(map[string]interface{}),
}
session.Values["userid"] = "123456"
userid := session.Values["userid"]
fmt.Println("userid:", userid)
ctx.Writer.Write([]byte("Hello World"))
})
}
复制代码
因为产生的随机数是字节序列,而非可见字符,所以需要使用 base64 编码将其变成可见字符。现在 session 的唯一标识有了,那在服务端如何存储所有用户的 session 呢?使用 map。在 map 中以 sessionid 为 key,Session 中的 Values 作为值。所以我们定义一个全局的 SessionMap 对象。如下:
// 存储所有用户的session数据
var SessionMap map[string]*Session
func main() {
r := gin.Default()
r.GET("/sigin", func(ctx *gin.Context){
sessionId := base64.RawStdEncoding.EncodeToString(GenerateRandomKey(32))
var session *Session
var ok bool
if session, ok = SessionMap[sessionId]; !ok {
// 初始化一个session对象,Values用于保存数据
session = &Session{
ID:, sessionId//初始化session-id
Values: make(map[string]interface{}),
}
}
session.Values["userid"] = "123456"
userid := session.Values["userid"]
fmt.Println("userid:", userid)
// 将session存储到SessionMap中
SessionMap[sessionId] = session
ctx.Writer.Write([]byte("Hello World"))
})
}
func GenerateRandomKey(length int) []byte {
k := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, k); err != nil {
return nil
}
return k
}
复制代码
2.4.3 cookie 中持久保存 sessionid
目前 服务端虽然可以存储所有用户的 session 数据了。但这里还有一个问题就每次请求 sigin 接口的时候都会重新生成一个 sessionId。那如何将一个用户的前后请求关联起来呢? 没错,就是让用户请求的时候在 cookie 或 url 的 query 中携带 sessionid。该 sessionid 是由服务端在第一次生成的时候下发给客户端的。 我们以下发给 cookie 为例。
// 存储所有用户的session数据
var SessionMap map[string]*Session
func main() {
r := gin.Default()
r.GET("/sigin", func(ctx *gin.Context){
var sessionId string
// 从cookie中获取sessionid
cookie, err := ctx.Request.Cookie("session-id")
if err != nil {
sessionId = base64.RawStdEncoding.EncodeToString(GenerateRandomKey(32))
}else {
//cookie
sessionId = cookie.Value)
}
var session *Session
var ok bool
if session, ok = SessionMap[sessionId]; !ok {
// 初始化一个session对象,Values用于保存数据
session = &Session{
ID:, sessionId//初始化session-id
Values: make(map[string]interface{}),
}
}
session.Values["userid"] = "123456"
userid := session.Values["userid"]
fmt.Println("userid:", userid)
// 将session存储到SessionMap中
SessionMap[sessionId] = session
// 将sessionId写到cookie中
http.SetCookie(ctx.Writer, &http.Cookie{
Name: "session-id",
Value: sessionId,
Path: "/",
Domain: "",
Expires: time.Now().Add(24*time.Hour),
})
ctx.Writer.Write([]byte("Hello World"))
})
}
复制代码
此时,就可以先从 cookie 中获取 session-id 的值,如果存在,则直接使用之前的 session-id,这样就能从服务端获取到已经存在的 session 数据。如果从 cookie 中没获取到 session-id,则生成一个新的 ID,并下发给客户端。
这样,我们就可以区分不同用户、并能根据 session-id 获取用户之前存储在服务端上的 session 数据了。
2.4.4 session 包中 Store 的抽象
当然,如果是需要持久化存储到 mysql、redis 或文件中时,则需要将 session.Value 中的数据以及 ID 存储到对应的介质中即可。这也是在使用 session 包时最后需要使用 session.Save 方法的原因。
在 session 包中,实质上是对存储进行了抽象。不同的存储实例需要实现该抽象接口。在程序入口启动处就指定具体的存储对象,然后调用相同的操作接口。如开始实例中初始化 Store 的代码:
// 这里的[]byte("Hello")实际上是用于数据存储加密的秘钥。
Store = sessions.NewFilesystemStore("/tm//godemo", []byte("Hello"))
复制代码
如果我们将 session 存储在内存中的方式 以 Store 扩展的形式进行改写,则只要实现 Store 的三个接口即可。如下:
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"io"
"net/http"
"os"
"time"
)
type MemoryStore struct {
Options *sessions.Options // 用于设置cookie的属性
Cache map[string]*sessions.Session
}
func NewMemoryStore() *MemoryStore {
ms := &MemoryStore{
Options: &sessions.Options{
Path: "/",
MaxAge: 86400 * 30,
},
Cache: make(map[string]*sessions.Session, 0),
}
ms.MaxAge(ms.Options.MaxAge)
return ms
}
func (m *MemoryStore) Get(r *http.Request, name string) (*sessions.Session, error) {
return sessions.GetRegistry(r).Get(m, name)
}
func (m *MemoryStore) New(r *http.Request, name string) (*sessions.Session, error) {
session := sessions.NewSession(m, name)
options := *m.Options
session.Options = &options
session.IsNew = true
c, err := r.Cookie(name)
if err != nil {
// Cookie not found, this is a new session
return session, nil
}
if err != nil {
return session, err
}
session.ID = c.Value
v, ok := m.Cache[session.ID]
if !ok {
return session, nil
}
session = v
session.IsNew = false
return session, nil
}
func (m *MemoryStore) Save(r *http.Request, w http.ResponseWriter,
s *sessions.Session) error {
var cookieValue string
if s.Options.MaxAge < 0 {
cookieValue = ""
delete(m.Cache, s.ID)
for k := range s.Values {
delete(s.Values, k)
}
} else {
if s.ID == "" {
s.ID = base64.RawStdEncoding.EncodeToString(GenerateRandomKey(32))
}
cookieValue = s.ID
m.Cache[s.ID] = s
}
http.SetCookie(w, &http.Cookie{
Name: s.Name(),
Value: cookieValue,
Path: "/",
Domain: "",
Expires: time.Now().Add(24*time.Hour),
})
return nil
}
func (m *MemoryStore) MaxAge(age int) {
m.Options.MaxAge = age
}
// 第一步 定义全局的存储session数据的变量,
var Store sessions.Store
func main() {
r := gin.Default()
Store = NewMemoryStore()
r.GET("/sigin", func(ctx *gin.Context){
session, _ := Store.Get(ctx.Request, "session-id2")
session.Values["userid"] = 456789
session.Save(ctx.Request, ctx.Writer)
ctx.Writer.Write([]byte("Hello World"))
})
r.Run(":8080")
}
func GenerateRandomKey(length int) []byte {
k := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, k); err != nil {
return nil
}
return k
}
复制代码
3 总结
通过阅读 session 包,我们可以了解到服务端 session 实现的底层逻辑。session 的实现本质上就是通过给用户分配一个唯一的 ID,以该 ID 为主键,然后将数据存储到不同的介质中。最后再将该 ID 下发给 cookie,当客户端后续发送请求时,服务端就可以通过 cookie 中的 ID 获取到对应的 session 数据了。
---特别推荐---
特别推荐:一个专注 go 项目实战、项目中踩坑经验及避坑指南、各种好玩的 go 工具的公众号,「Go 学堂」,专注实用性,非常值得大家关注。点击下方公众号卡片,直接关注。关注送《100 个 go 常见的错误》pdf 文档。
评论