Go 每日一庫之 gorilla-sessions
簡介
上一篇文章《Go 每日一庫之 securecookie》中,我們介紹了 cookie。同時提到 cookie 有兩個缺點,一是數據不宜過大,二是安全問題。session 是服務器端的存儲方案,可以存儲大量的數據,而且不需要向客戶端傳輸,從而解決了這兩個問題。但是 session 需要一個能唯一標識用戶的 ID,這個 ID 一般存放在 cookie 中發送到客戶端保存,隨每次請求一起發送到服務器。cookie 和 session 通常配套使用。
gorilla/sessions
是 gorilla web 開發工具包中管理 session 的庫。它提供了基於 cookie 和本地文件系統的 session。同時預留擴展接口,可以使用其它的後端存儲 session 數據。
本文先介紹sessions
提供的兩種 session 存儲方式,然後通過第三方擴展介紹在多個 Web 服務器實例間如何保持登錄狀態。
快速使用
本文代碼使用 Go Modules。
創建目錄並初始化:
$ mkdir gorilla/sessions && cd gorilla/sessions
$ go mod init github.com/darjun/go-daily-lib/gorilla/sessions
安裝gorilla/sessions
庫:
$ go get -u github.com/valyala/gorilla/sessions
現在我們實現在服務器端通過 session 存儲一些信息的功能:
package main
import (
"fmt"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"log"
"net/http"
"os"
)
var (
store = sessions.NewFilesystemStore("./", securecookie.GenerateRandomKey(32), securecookie.GenerateRandomKey(32))
)
func set(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "user")
session.Values["name"] = "dj"
session.Values["age"] = 18
err := sessions.Save(r, w)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprintln(w, "Hello World")
}
func read(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "user")
fmt.Fprintf(w, "name:%s age:%d\n", session.Values["name"], session.Values["age"])
}
func main() {
r := mux.NewRouter()
r.HandleFunc("/set", set)
r.HandleFunc("/read", read)
log.Fatal(http.ListenAndServe(":8080", r))
}
整個程序邏輯比較清晰,分別在/set
和/read
路徑下掛上設置和讀取的處理函數。重點是變量store
。我們調用session.NewFilesystemStore()
方法創建了一個*sessions.FilesystemStore
類型的對象,它會將我們的 session 內容存儲到文件系統(即本地磁盤上)。我們需要給NewFilesytemStore()
方法傳入至少 2 個參數,第一個參數指定 session 存儲的本地磁盤路徑。後續參數依次指定hashKey
和blockKey
(可省略),前者用於驗證,後者用於加密,我們可以使用securecookie
生成足夠隨機的 key,詳情見前一篇介紹securecookie
的文章。
sessions
爲所有的 session 存儲抽象了一個接口Store
:
type Store interface {
Get(r *http.Request, name string) (*Session, error)
New(r *http.Request, name string) (*Session, error)
Save(r *http.Request, w http.ResponseWriter, s *Session) error
}
實現這個接口可以自定義我們存儲 session 的位置和格式。
在set
處理函數中,我們調用store.Get(r, "user")
獲取名爲user
的 session,如果 session 不存在,則創建一個新的。sessions
庫支持爲同一個用戶創建多個 session,store.Get()
方法的第二個參數指定名字。獲取到的*Session
結構如下:
type Session struct {
ID string
Values map[interface{}]interface{}
Options *Options
IsNew bool
store Store
name string
}
數據直接存放在Session.Values
字段中,這是一個類型爲map[interface{}]interface{}
的字段,幾乎能保存任何類型的數據(之所以我這裏要說幾乎,因爲還要考慮序列化到存儲的限制,有些數據類型無法序列化爲字節流保存,如chan
)。
在set
處理函數中,我們直接操作Values
字段,最後我們調用store.Save(r, w, session)
將 session 數據保存到對應的存儲中。
在get
處理函數中,同樣地我們先調用store.Get(r, "user")
獲取*Session
對象,然後讀取裏面的name
和age
值。
運行:
$ go run main.go
首先訪問localhost:8080/set
,通過瀏覽器的開發者工具Application
頁籤查看 cookie:
我們發現 session 的名字會作爲 cookie 名發送到客戶端,session ID 被保存爲 cookie 的值。
然後我們訪問localhost:8080/read
,讀取到 session 保存的數據:
另前面說過FilesystemStore
數據是存儲在本地硬盤上的,在運行程序的本地目錄我們看到有以 session 開頭的文件,文件名 session 後面的部分就是 session ID:
cookie 存儲
除了默認的將本地文件系統作爲存儲外,sessions
還支持將 cookie 作爲存儲,也就是將 session 的數據直接通過 cookie 在客戶端和服務器之間傳輸。cookie 存儲的創建方式與文件系統存儲的創建方式類似:
var store = sessions.NewCookieStore(securecookie.GenerateRandomKey(32), securecookie.GenerateRandomKey(32))
sessions.NewCookieStore()
方法的第一個參數爲 hashKey 用於驗證,第二個參數爲 blockKey 用於加密,與sessions.NewFilesystemStore()
一樣。
其他部分的代碼完全不用修改,運行程序的結果與上面的一致。session 數據保存在 cookie 中,隨每次請求由客戶端傳給服務器。這種方式其實就是之前文章中介紹的 cookie 用法。
記錄登錄狀態
之前我們介紹gorilla/mux
時介紹過使用 cookie 保存登錄狀態。當時將用戶名和密碼經過簡單的 Base64 編碼後就直接存放在 cookie 中了,基本處於 “裸露” 狀態。只要有意,很容易就能竊取用戶名和密碼。現在我們將用戶關鍵信息存儲在 session 中,cookie 中只存儲一個 session ID。
首先,我們設計 3 個頁面,登錄頁面,主頁面,授權才能訪問的 secret 頁面。登錄頁面只需要用戶名 & 密碼的輸入框和登錄按鈕即可:
// login.tpl
<form action="/login" method="post">
<label>Username:</label>
<input ><br>
<label>Password:</label>
<input ><br>
<button type="submit">登錄</button>
</form>
登錄請求根據方法不同需要執行不同的操作,GET 方法表示請求登錄的頁面,POST 方法表示執行登錄操作。我們使用handlers.MethodHandler
這個中間件來處理同一個路徑的不同方法的請求:
r.Handle("/login", handlers.MethodHandler{
"GET": http.HandlerFunc(Login),
"POST": http.HandlerFunc(DoLogin),
})
Login
處理函數很簡單,只是展示頁面:
func Login(w http.ResponseWriter, r *http.Request) {
ptTemplate.ExecuteTemplate(w, "login.tpl", nil)
}
這裏我使用 Go 標準庫html/template
模版庫來加載和管理各個頁面的模板:
var (
ptTemplate *template.Template
)
func init() {
template.Must(template.New("").ParseGlob("./tpls/*.tpl"))
}
DoLogin
處理函數,需要驗證登錄請求,然後創建User
對象,保存在 session 中,接着重定向到主頁面:
func DoLogin(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
username := r.Form.Get("username")
password := r.Form.Get("password")
if username != "darjun" || password != "handsome" {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
SaveSessionUser(w, r, &User{Username: username})
http.Redirect(w, r, "/", http.StatusFound)
}
下面是主頁面的處理,我們可以從 session 中取出保存的User
對象,根據是否有User
對象顯示不同的頁面:
// home.tpl
{% if . %}
<p>Hi, {% .Username %}</p><br>
<a href="/secret">Goto secret?</a>
{% else %}
<p>Hi, stranger</p><br>
<a href="/login">Goto login?</a>
{% end %}
HomeHandler
代碼如下:
func HomeHandler(w http.ResponseWriter, r *http.Request) {
u := GetSessionUser(r)
ptTemplate.ExecuteTemplate(w, "home.tpl", u)
}
最後是 secret 頁面:
// secret.tpl
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit.
Inventore a cumque sunt pariatur nihil doloremque tempore,
consectetur ipsum sapiente id excepturi enim velit,
quis nisi esse doloribus aliquid. Incidunt, dolore.
</p>
<p>You have visited this page {% .Count %} times.</p>
顯示訪問了該頁面多少次。
SecretHandler
如下:
func SecretHandler(w http.ResponseWriter, r *http.Request) {
u := GetSessionUser(r)
if u == nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
u.Count++
SaveSessionUser(w, r, u)
ptTemplate.ExecuteTemplate(w, "secret.tpl", u)
}
如果沒有 session,則重定向到登錄頁面。反之顯示該頁面。這裏每次成功訪問 secret 頁面,都會增加計數器,保存在 session 中。
上面代碼中需要注意一點,由於 session 內容的序列化使用了標準庫中的encoding/gob
,所以不支持直接序列化結構體,我封裝了兩個函數,將User
對象序列化爲 JSON,然後保存到 session 中和從 session 中取出字符串反序列化爲User
對象:
func GetSessionUser(r *http.Request) *User {
session, _ := store.Get(r, "user")
s, ok := session.Values["user"]
if !ok {
return nil
}
u := &User{}
json.Unmarshal([]byte(s.(string)), u)
return u
}
func SaveSessionUser(w http.ResponseWriter, r *http.Request, u *User) {
session, _ := store.Get(r, "user")
data, _ := json.Marshal(u)
session.Values["user"] = string(data)
store.Save(r, w, session)
}
現在運行我們的程序,首先訪問localhost:8080
,由於沒有登錄,顯示歡迎陌生人,去登錄
:
點擊去登錄,跳轉到登錄界面,輸入用戶名和密碼:
點擊登錄,跳轉到主頁,這時由於記錄了登錄狀態,會顯示歡迎 darjun
:
點擊去隱祕鏈接:
不停刷新頁面,發現訪問次數一直累加。
如果未登錄時,直接訪問localhost:8080/secret
,會直接重定向到登錄界面。
上面程序有一個缺點,程序重啓啓動後,就需要重新登錄。因爲每次啓動我們都重新隨機 hashKey 和 blockKey,只需要固定這兩個值即可實現重啓也能保存登錄狀態。
登錄驗證類的功能非常適合放在中間件中處理,之前的文章已經介紹過如何編寫中間件了,這裏就不贅述了。
第三方後端存儲
將 session 存儲在本地文件系統,不利於水平擴展。一般稍微上點規模的網站,Web 服務器都會部署很多個實例,請求通過 Nginx 之類的反向代理轉發到一個後端實例處理。不能保證後面的請求與之前的請求在同一個實例中處理,故 session 一般需要存儲在一個公共的地方,例如 redis。
sessions
提供了擴展接口,方便擴展使用其他的後端存儲 session 內容。目前 GitHub 上已經有很多的第三方後端擴展了,詳細 list 見sessions
庫的 GitHub 首頁:
我們只介紹基於 redis 的後端存儲,其他的擴展感興趣可自行研究。首先安裝擴展:
$ go get gopkg.in/boj/redistore.v1
創建一個 redistore 的實例:
store, _ = redistore.NewRediStore(10, "tcp", ":6379", "", []byte("redis-key"))
參數依次爲:
-
size
:最大空閒連接數; -
network
:連接類型,一般是 TCP; -
addr
:網絡地址 + 端口; -
password
:redis 的密碼,如果未啓用,填空; -
keyPairs
:依次是 hashKey 和 blockKey(可省略),不再贅述。
爲了驗證,我們開啓多個服務器,所以將端口通過命令行參數傳入,使用標準庫flag
:
port = flag.Int("port", 8080, "port to listen")
func init() {
flag.Parse()
}
func main() {
// ...
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
}
爲了運行服務器,我們需要先開啓一個 redis-server。redis 的安裝就不多說了,在 windows 下,建議使用 chocolatey 安裝,chocolatey 類似於 Ubutnu 的 apt-get,Mac 的 brew,非常方便,強烈推薦。
爲了演示反向代理的效果,即通過一個地址可以隨機訪問部署的多個 Web 服務器,我們開啓 3 個 Web 服務器。終端 1:
$ go build
$ ./redis -port 8080
終端 2:
$ ./redis -port 8081
終端 3:
$ ./redis -port 8082
可以使用nginx
做反向代理,安裝 nginx,配置:
upstream mysvr {
server localhost:8080;
server localhost:8081;
server localhost:8082;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://mysvr;
}
}
這裏表示將localhost
隨機轉發到mysvr
這個組中的 3 個服務器上,啓動 nginx:
$ nginx -c nginx.conf
萬事俱備,現在使用瀏覽器訪問localhost
,通過控制檯日誌發現是 server3 處理了這個請求:
點擊去登錄,server1 處理了展示頁面的請求:
點擊登錄,server3 處理了 POST 類型的登錄請求:
登錄成功之後,重定向到主界面的請求又是 server1 處理的:
點擊私密鏈接,展示頁面的請求是 server2 處理的:
雖然每次處理的 server 不同,但是登錄狀態一直保存着。因爲我們使用了 redis 保存 session。
注意,我這裏每次都是隨機一個 server 去處理,你運行的結果不一定一樣。
總結
session 爲了解決存儲用戶大量數據和安全性的問題。sessions
庫爲 Go Web 開發中處理 session 提供了簡單,靈活的方法。它依賴較少,可以即插即用,非常方便。
大家如果發現好玩、好用的 Go 語言庫,歡迎到 Go 每日一庫 GitHub 上提交 issue😄
參考
-
gorilla/sessions GitHub:github.com/gorilla/sessions
-
Go 每日一庫 GitHub:https://github.com/darjun/go-daily-lib
我
我的博客:https://darjun.github.io
歡迎關注我的微信公衆號【GoUpUp】,共同學習,一起進步~
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/F3mk9uJ9Y_xL3f3mbUp8ig