前言
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:https://github.com/koajs/session
建議感興趣的同學可以下載代碼先看一眼解讀過程中貼出的代碼,部分有精簡
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 方式。另外關於登錄還有單點登錄,第三方登錄等。如果大家有興趣,可以在後面的文章繼續給大家剖析。