「Go 工具箱」web 中的 session 管理,推薦使用 gorilla-sessions 包

大家好,我是漁夫子。本號新推出「Go 工具箱」系列,意在給大家分享使用 go 語言編寫的、實用的、好玩的工具。同時瞭解其底層的實現原理,以便更深入地瞭解 Go 語言。

在 web 開發中,大家一定會使用到 session。在 go 的很多 web 框架中並沒有集成 session 管理的中間件。要想使用 session 功能,我推薦大家使用這個包:gorilla/sessions。以下是該包的基本情況:

zuMvVD

一、什麼是 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{
            IDbase64.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
}

目前 服務端雖然可以存儲所有用戶的 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 數據了。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/SbenRJ_Wya3tHkCHQVgnQA