前言
Session,又稱為“會話控制”,儲存特定使用者會話所需的屬性及配置資訊。存於伺服器,在整個使用者會話中一直存在。
然而:
- session 到底是什麼?
- session 是存在伺服器記憶體裡,還是web伺服器原生支援?
- http請求是無狀態的,為什麼每次伺服器能取到你的 session 呢?
- 關閉瀏覽器會過期嗎?
本文將從 koa-session(koa官方維護的session中介軟體)
的原始碼詳細解讀 session 的機制原理。希望大家讀完後,會對 session 的本質,以及 session 和 cookie 的區別有個更清晰的認識。
基礎知識
相信大家都知道一些關於 cookie 和 session 的概念,最通常的解釋是 cookie 存於瀏覽器,session 存於伺服器。
cookie 是由瀏覽器支援,並且http請求會在請求頭中攜帶 cookie 給伺服器。也就是說,瀏覽器每次訪問頁面,伺服器都能獲取到這次訪問者的 cookie 。
但對於 session 存在伺服器哪裡,以及伺服器是通過什麼對應到本次訪問者的 session ,其實問過一些後端同學,解釋得也都比較模糊。因為一般都是服務框架自帶就有這功能,都是直接用。背後的原理是什麼,並不一定會去關注。
如果我們使用過koa框架,就知道koa自身是無法使用 session 的,這就似乎說明了 session 並不是伺服器原生支援,必須由 koa-session 中介軟體去支援實現。
那它到底是怎麼個實現機制呢,接下來我們就進入原始碼解讀。
原始碼解讀
koa-session:github.com/koajs/sessi…
建議感興趣的同學可以下載程式碼先看一眼
解讀過程中貼出的程式碼,部分有精簡
koa-session結構
來看 koa-session 的目錄結構,非常簡單;主要邏輯集中在 context.js 。
├── index.js // 入口
├── lib
│ ├── context.js
│ ├── session.js
│ └── util.js
└── package.json
複製程式碼
先給出一個 koa-session 主要模組的腦圖,可以先看個大概:
屢一下流程
我們從 koa-session 的初始化,來一步步看下它的執行流程:
先看下 koa-sessin 的使用方法:
const session = require('koa-session');
const Koa = require('koa');
const app = new Koa();
app.keys = ['some secret hurr'];
const CONFIG = {
key: 'koa:sess', // 預設值,自定義cookie中的key
maxAge: 86400000
};
app.use(session(CONFIG, app)); // 初始化koa-session中介軟體
app.use(ctx => {
let n = ctx.session.views || 0; // 每次都可以取到當前使用者的session
ctx.session.views = ++n;
ctx.body = n + ' views';
});
app.listen(3000);
複製程式碼
初始化
初始化 koa-session 時,會要求傳入一個app例項。
實際上,正是在初始化的時候,往 app.context 上掛載了session物件,並且 session 物件是由 lib/context.js
例項化而來,所以我們使用的 ctx.session 就是 koa-session 自己構造的一個類。
我們開啟koa-session/index.js
:
module.exports = function(opts, app) {
opts = formatOpts(opts); // 格式化配置項,設定一些預設值
extendContext(app.context, opts); // 劃重點,給 app.ctx 定義了 session物件
return async function session(ctx, next) {
const sess = ctx[CONTEXT_SESSION];
if (sess.store) await sess.initFromExternal();
await next();
if (opts.autoCommit) {
await sess.commit();
}
};
};
複製程式碼
通過內部的一次初始化,返回一個koa中介軟體函式。
一步一步的來看,formatOpts 是用來做一些預設引數處理,extendContext 的主要任務是對 ctx 做一個攔截器,如下:
function extendContext(context, opts) {
Object.defineProperties(context, {
[CONTEXT_SESSION]: {
get() {
if (this[_CONTEXT_SESSION]) return this[_CONTEXT_SESSION];
this[_CONTEXT_SESSION] = new ContextSession(this, opts);
return this[_CONTEXT_SESSION];
},
},
session: {
get() {
return this[CONTEXT_SESSION].get();
},
set(val) {
this[CONTEXT_SESSION].set(val);
},
configurable: true,
}
});
}
複製程式碼
走到上面這段程式碼時,事實上就是給 app.context 下掛載了一個“私有”的 ContextSession 物件 ctx[CONTEXT_SESSION]
,有一些方法用來初始化它(如initFromExternal、initFromCookie)。然後又掛載了一個“公共”的 session 物件。
為什麼說到“私有”、“公共”呢,這裡比較細節。用到了 Symbol 型別,使得外部不可訪問到 ctx[CONTEXT_SESSION]
。只通過 ctx.session
對外暴露了 (get/set) 方法。
再來看下 index.js
匯出的中介軟體函式
return async function session(ctx, next) {
const sess = ctx[CONTEXT_SESSION];
if (sess.store) await sess.initFromExternal();
await next();
if (opts.autoCommit) {
await sess.commit();
}
};
複製程式碼
這裡,將 ctx[CONTEXT_SESSION]
例項賦值給了 sess ,然後根據是否有 opts.store ,呼叫了 sess.initFromExternal
,字面意思是每次經過中介軟體,都會去調一個外部的東西來初始化 session ,我們後面會提到。
接著看是執行了如下程式碼,也即執行我們的業務邏輯。
await next()
複製程式碼
然後就是下面這個了,看樣子應該是類似儲存 session 的操作。
sess.commit();
複製程式碼
經過上面的程式碼分析,我們看到了 koa-session
中介軟體的主流程以及儲存操作。
那麼 session 在什麼時候被建立呢?回到上面提到的攔截器 extendContext
,它會在接到http請求的時候,從 ContextSession類
例項化出 session 物件。
也就是說,session 是中介軟體自己建立並管理的,並非由web伺服器產生。
我們接著看核心功能 ContextSession
。
ContextSession類
先看建構函式:
constructor(ctx, opts) {
this.ctx = ctx;
this.app = ctx.app;
this.opts = Object.assign({}, opts);
this.store = this.opts.ContextStore ? new this.opts.ContextStore(ctx) : this.opts.store;
}
複製程式碼
居然啥屁事都沒幹。往下看 get()
方法:
get() {
const session = this.session;
// already retrieved
if (session) return session;
// unset
if (session === false) return null;
// cookie session store
if (!this.store) this.initFromCookie();
return this.session;
}
複製程式碼
噢,原來是一個單例模式(等到使用時候再生成物件,多次呼叫會直接使用第一次的物件)。
這裡有個判斷,是否傳入了 opts.store 引數,如果沒有則是用 initFromCookie()
來生成 session 物件。
那如果傳了 opts.store 呢,又啥都不幹嗎,WTF?
顯然不是,還記得初始化裡提到的那句 initFromExternal
函式呼叫麼。
if (sess.store) await sess.initFromExternal();
複製程式碼
所以,這裡是根據是否有 opts.store ,來選擇兩種方式不同的生成 session 方式。
問:store是什麼呢?
答:store可以在
initFromExternal
中看到,它其實是一個外部儲存。問:什麼外部儲存,存哪裡的?
答:同學莫急,先往後看。
initFromCookie
initFromCookie() {
const ctx = this.ctx;
const opts = this.opts;
const cookie = ctx.cookies.get(opts.key, opts);
if (!cookie) {
this.create();
return;
}
let json = opts.decode(cookie); // 列印json的話,會發現居然就是你的session物件!
if (!this.valid(json)) { // 判斷cookie過期等
this.create();
return;
}
this.create(json);
}
複製程式碼
在這裡,我們發現了一個很重要的資訊,session 居然是加密後直接存在 cookie 中的。
我們 console.log
一下 json
變數,來驗證下:
initFromeExternal
async initFromExternal() {
const ctx = this.ctx;
const opts = this.opts;
let externalKey;
if (opts.externalKey) {
externalKey = opts.externalKey.get(ctx);
} else {
externalKey = ctx.cookies.get(opts.key, opts);
}
if (!externalKey) {
// create a new `externalKey`
this.create();
return;
}
const json = await this.store.get(externalKey, opts.maxAge, { rolling: opts.rolling });
if (!this.valid(json, externalKey)) {
// create a new `externalKey`
this.create();
return;
}
// create with original `externalKey`
this.create(json, externalKey);
}
複製程式碼
可以看到 store.get()
,有一串資訊是存在 store 中,可以 get 到的。
而且也是在不斷地要求呼叫 create()
。
create
create()
到底做了什麼呢?
create(val, externalKey) {
if (this.store) this.externalKey = externalKey || this.opts.genid();
this.session = new Session(this, val);
}
複製程式碼
它判斷了 store ,如果有 store ,就會設定上 externalKey
,或者生成一個隨機id。
基本可以看出,是在 sotre 中儲存一些資訊,並且可以通過 externalKey 去用來獲取。
由此基本得出推斷,session 並不是伺服器原生支援,而是由web服務程式自己建立管理。
存放在哪裡呢?不一定要在伺服器,可以像 koa-session 一樣騷氣地放在 cookie 中!
接著看最後一個 Session 類。
Session類
老規矩,先看建構函式:
constructor(sessionContext, obj) {
this._sessCtx = sessionContext;
this._ctx = sessionContext.ctx;
if (!obj) {
this.isNew = true;
} else {
for (const k in obj) {
// restore maxAge from store
if (k === '_maxAge') this._ctx.sessionOptions.maxAge = obj._maxAge;
else if (k === '_session') this._ctx.sessionOptions.maxAge = 'session';
else this[k] = obj[k];
}
}
}
複製程式碼
接收了 ContextSession 例項傳來 sessionContext 和 obj ,其他沒有做什麼。
Session 類僅僅是用於儲存 session 的值,以及_maxAge,並且提供了toJSON方法用來獲取過濾了_maxAge等欄位的,session物件的值。
session如何持久化儲存
看完以上程式碼,我們大致知道了 session 可以從外部或者 cookie 中取值,那它是如何儲存的呢,我們回到 koa-session/index.js
中提到的 commit
方法,可以看到:
await next();
if (opts.autoCommit) {
await sess.commit();
}
複製程式碼
思路立馬就清晰了,它是在中介軟體結束 next()
後,進行了一次 commit()
。
commit()
方法,可以在 lib/context.js
中找到:
async commit() {
// ...省略n個判斷,包括是否有變更,是否需要刪除session等
await this.save(changed);
}
複製程式碼
再來看save()
方法:
async save(changed) {
const opts = this.opts;
const key = opts.key;
const externalKey = this.externalKey;
let json = this.session.toJSON();
// save to external store
if (externalKey) {
await this.store.set(externalKey, json, maxAge, {
changed,
rolling: opts.rolling,
});
if (opts.externalKey) {
opts.externalKey.set(this.ctx, externalKey);
} else {
this.ctx.cookies.set(key, externalKey, opts);
}
return;
}
json = opts.encode(json);
this.ctx.cookies.set(key, json, opts);
}
複製程式碼
豁然開朗了,實際就是預設把資料 json ,塞進了 cookie ,即 cookie 來儲存加密後的 session 資訊。
然後,如果設定了外部 store ,會呼叫 store.set()
去儲存 session 。具體的儲存邏輯,儲存到哪裡,由 store 物件自己決定!
小結
koa-session 的做法說明了,session 僅僅是一個物件資訊,可以存到 cookie ,也可以存到任何地方(如記憶體,資料庫)。存到哪,可以開發者自己決定,只要實現一個 store 物件,提供 set,get 方法即可。
延伸擴充套件
通過以上原始碼分析,我們已經得到了我們文章開頭那些疑問的答案。
koa-session 中還有哪些值得我們思考呢?
外掛設計
不得不說,store 的外掛式設計非常優秀。koa-session 不必關心資料具體是如何儲存的,只要外掛提供它所需的存取方法。
這種外掛式架構,反轉了模組間的依賴關係,使得 koa-session 非常容易擴充套件。
koa-session對安全的考慮
這種預設把使用者資訊儲存在 cookie 中的方式,始終是不安全的。
所以,現在我們知道使用的時候要做一些其他措施了。比如實現自己的 store ,把 session 存到 redis 等。
這種session的登入方式,和token有什麼區別呢
這其實要從 token 的使用方式來說了,用途會更靈活,這裡就先不多說了。
後面會寫一下各種登入策略的原理和比較,有興趣的同學可以關注我一下。
總結
回顧下文章開頭的幾個問題,我們已經有了明確的答案。
- session 是一個概念,是一個資料物件,用來儲存訪問者的資訊。
- session 的儲存方式由開發者自己定義,可存於記憶體,redis,mysql,甚至是 cookie 中。
- 使用者第一次訪問的時候,我們就會給使用者建立一個他的 session ,並在 cookie 中塞一個他的 “鑰匙key” 。所以即使 http請求 是無狀態的,但通過 cookie 我們就可以拿到訪問者的 “鑰匙key” ,便可以從所有訪問者的 session 集合中取出對應訪問者的 session。
- 關閉瀏覽器,服務端的 session 是不會馬上過期的。session 中介軟體自己實現了一套管理方式,當訪問間隔超過 maxAge 的時候,session 便會失效。
那麼除了 koa-session 這種方式來實現使用者登入,還有其他方法嗎?
其實還有很多,可以儲存 cookie 實現,也可以用 token 方式。另外關於登入還有單點登入,第三方登入等。如果大家有興趣,可以在後面的文章繼續給大家剖析。