從koa-session原始碼解讀session原理

暖風叔叔發表於2019-03-03

前言

Session,又稱為“會話控制”,儲存特定使用者會話所需的屬性及配置資訊。存於伺服器,在整個使用者會話中一直存在。

然而:

  • session 到底是什麼?
  • session 是存在伺服器記憶體裡,還是web伺服器原生支援?
  • http請求是無狀態的,為什麼每次伺服器能取到你的 session 呢?
  • 關閉瀏覽器會過期嗎?

本文將從 koa-session(koa官方維護的session中介軟體) 的原始碼詳細解讀 session 的機制原理。希望大家讀完後,會對 session 的本質,以及 session 和 cookie 的區別有個更清晰的認識。

基礎知識

相信大家都知道一些關於 cookie 和 session 的概念,最通常的解釋是 cookie 存於瀏覽器,session 存於伺服器。

cookie 是由瀏覽器支援,並且http請求會在請求頭中攜帶 cookie 給伺服器。也就是說,瀏覽器每次訪問頁面,伺服器都能獲取到這次訪問者的 cookie 。

從koa-session原始碼解讀session原理

但對於 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原始碼解讀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 變數,來驗證下:

從koa-session原始碼解讀session原理

從koa-session原始碼解讀session原理

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 方式。另外關於登入還有單點登入,第三方登入等。如果大家有興趣,可以在後面的文章繼續給大家剖析。

相關文章