koa-session的使用方法
koa-session
是在koa应用中用于记录请求者身份的常用中间件,其使用方法如下:
const session = require('koa-session');
app.use(session(config, app));
app.use(ctx => {
// ignore favicon
if (ctx.path === '/favicon.ico') return;
let n = ctx.session.views || 0;
ctx.session.views = ++n;
ctx.body = n + ' views';
});
提出问题
那么它是如何区分每个请求的呢?毕竟我们使用时仅仅只是get 或者 set ctx.session.views
, 丝毫没有看出哪儿区分了不同的用户。
胡乱猜想
在未使用第三方存储的时候,比较符合我预期的使用可能是在服务端维护一个session对象,形如{[uniqueId]: {}}
。然后通过ctx.session[uniqueId].xxx
记录/修改每个请求的状态, 其中uniqueId是在cookie中保存的sessionId(或者其他指定的sessionKey),这样一来,相同的用户请求都会携带着同一个sessionId在cookie中(在cookie失效之前),node端维护着一个{[sessionId]: sessionData}的大对象,从而实现记录每个请求状态的效果。那么又如何实现形如ctx.session.view
这样的操作呢?借助于getter/setter操作符,将基于uniqueId的操作进行封装:
// 不考虑maxAge, expire, removeCookie等
// session 中的set方法只有在直接给obj.session赋值时才会触发,如obj.session={12: 12}
// obj.session.view = 1 的赋值,是通过obj.session 时触发getter,保持对sessionObj的引用实现的。
const obj={}
const sessionObj = {};
Object.defineProperties(obj, {
session: {
get() {
const sessionId = 11; // get sessionId from cookie
if(!sessionObj[sessionId]) {
/*
* init default value
* 以支持obj.session.view = 1
*/
sessionObj[sessionId] = {};
}
return sessionObj[sessionId];
},
set(value) {
let sessionId = 11; // get sessionId from cookie
sessionObj[sessionId] = Object.assign({}, this.session[sessionId], value);
}
}
});
答案简介
猜对了一部分,koa-session并没有在node中维护一个sessionId的大对象。那koa-session
又是怎样的内部实现,使得使用ctx.session可以如此简单,而无需用户关注是对哪个uniqueId的value进行操作呢?
koa-session本身并没有维护一个session对象在应用中,而是抽象出了一个session中间件模型,并且定义了需要子类实现的抽象接口Store,可以很方便的支持外部store的扩展。同时默认内置了基于cookie的存储方案。
-
默认存储到cookie
在未使用外部存储时,koa-session也不会维护一个session对象,而是将每个session的值通过base64编码(也可以传入自定义的encode/decode方法)后放到了cookie中,在每次请求到来时,再从该请求的cookie中取出数据挂到ctx.session上。对session get/set的流程:
get:
ctx.session --> get cookie(sessionKey) -> decodeBase64 -> sessionData({view: 1})
set:ctx.session.view = 1 --> (get ctx.session) --> sessionData.view = 1 --> encodeBase64 --> 利用中间件机制 await next() 后,set cookie
多说两句:
因为状态全部保存在cookie中不太安全,所以setCookie时也提供了一个签名,再加上设置httpOnly属性以及custom encode方法,也能满足一般的需求。但重要的用户信息还是要存在后端,也就是下面的外部存储方案 -
支持外部存储方案
koa-session支持了一个使用外部的Store,只要store实现了
get(key, maxAge, {rolling})
,set(key, sess={}, maxAge, {rolling, changed})
和destroy(key)
这三个方法就可以。然后将key(通常就是sessionId)放到cookie中。对session get/set的流程就是:get:
ctx.session -> get cookie(sessionKey) --> getDataFromStoreBySessionId --> sessionData({view: 1})
set:ctx.session.view = 1 --> (get ctx.session) --> sessionData.view = 1 --> setDataToStoreBySessionId -> 利用中间件机制 await next() 后,set cookie
koa-session 源码分析
为了解决最初的疑问,在此我们只关注代码的主要逻辑,以下代码示例有删减:
index.js, 先看入口代码
const CONTEXT_SESSION = Symbol('context#contextSession');
const _CONTEXT_SESSION = Symbol('context#_contextSession');
module.exports = function(ops, app) {
// 往ctx上挂载了session和CONTEXT_SESSION属性, session处理逻辑保存在全局唯一的CONTEXT_SESSION属性上
extendContext(app.context, opts);
return async function session(ctx, next) {
const sess = ctx[CONTEXT_SESSION];
if (sess.store) await sess.initFromExternal();
// 利用中间件,完成操作之后再set cookie.
try {
await next();
} catch (err) {
throw err;
} finally {
// opts.autoCommit默认为true
if (opts.autoCommit) {
await sess.commit();
}
}
};
};
// 可以看到该中间件的主逻辑就是就是extendContent方法,它往app.context上挂载了session属性
function extendContext(context, opts) {
// 单例模式
if (context.hasOwnProperty(CONTEXT_SESSION)) {
return;
}
// 挂载session对象到context
Object.defineProperties(context, {
// 使用Symbol作为key,保证全局唯一
[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
},
sessionOptions: {
get() {
return this[CONTEXT_SESSION].opts;
}
}
});
}
小结:入口文件主要干了这样几件事:
-
往context挂载session变量,实际逻辑都是contextSession中
- 在context上定义了Symbol变量来保存的contextSession的引用: 没有直接挂到session变量, 确保了contextSession的唯一性,保证不会被其他代码逻辑覆盖。
- 通过Object.defineProperty扩展context对象的属性,并且通过getter/setter存取描述符对session对象的处理进行拦截,从而实现了对[修改每个uniqueId所对应的值]的逻辑的隐藏。
const ses = ctx.session
就等同于get ContextSession的实例,也就是this[_CONTEXT_SESSION]
,并通过单例模式保证始终是对同一个对象的引用。所以对ctx.session的获取或者赋值,就是对ContextSession实例的get/set.
if needed, sess.initFromExternal();
建立中间件模型,请求结束时实现对externalStore/cookie的更新
注意: ctx.session.view += 1
并不会走session中的setter逻辑,setter只能实现对session本身修改的拦截,而不能深度的代理。所以对ctx.session.view的修改逻辑是:保持对this[_CONTEXT_SESSION]
的引用,并修改其值,最后依赖中间件模型在await sess.commit();
步骤更新session以及set cookie.
lib/context.js, class ContextSession(有删减)
class ContextSession {
get() {
if (this.session) {
return this.session;
}
if (!this.store) {
this.initFromCookie();
}
return this.session;
}
set(val) {
// 删除该session
if (val === null) {
this.session = false;
return;
}
if (typeof val === 'object') {
// use the original `externalKey` if exists to avoid waste storage
this.create(val, this.externalKey);
return;
}
}
initFromExternal() {
// 可以理解为从cookie中获取sessionId的值
const externalKey = ctx.cookies.get(opts.key, opts);
// 获取该sessionId所对应数据,也就是{view: 1}这种
const json = await this.store.get(externalKey, opts.maxAge, { rolling: opts.rolling });
// 将json的值更新到this.session,其实也就是前面提到的this[_CONTEXT_SESSION],保证每一个请求到来,都会更新当前请求所对应的session值
this.create(json, externalKey);
// 用于记录当前session值,在该次请求结束时,会通过比较this.session.toJSON() === this.prevHash,来决定是否需要更新cookie/外部存储
this.prevHash = util.hash(this.session.toJSON());
}
initFromCookie() {
// 从cookies中拿到sessionId, if sessionId不存在,就创建一个
const cookie = ctx.cookies.get(opts.key, opts);
if (!cookie) {
this.create();
return;
}
//xxx
}
create(val, externalKey) {
if (this.store) this.externalKey = externalKey || this.opts.genid();
this.session = new Session(this, val);
}
// 通过中间件机制,在请求结束后自动更新cookie/store
async commit() {
// 就是通过对prevHash和当前session比较得到
const changed = this._shouldSaveSession() === 'changed';
await this.save(changed);
}
async save() {
let json = this.session.toJSON();
// 使用外部存储时的更新逻辑
if (externalKey) {
if (typeof maxAge === 'number') {
// ensure store expired after cookie
maxAge += 10000;
}
// 更新store中的值
await this.store.set(externalKey, json, maxAge, {
changed,
rolling: opts.rolling,
});
// 更新cookie
this.ctx.cookies.set(key, externalKey, opts);
return;
}
// 基于cookie存储时的更新逻辑
json = opts.encode(json);
this.ctx.cookies.set(key, json, opts);
}
}
可以看到业务中getctx.session
最终就是ContextSession中get()的return this.session
。 而this.session
最初又是通过create
方法添加的,this.session = new Session(this, val);
,
lib/session.js, class Session(有删减)
class Session {
constructor(sessionContext, obj) {
this._sessCtx = sessionContext;
this._ctx = sessionContext.ctx; // 外部请求的ctx
if (!obj) {
// 没有cookie值
this.isNew = true;
} else {
// 修改maxAge,保存配置
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];
}
}
}
toJSON() {
const obj = {};
Object.keys(this).forEach(key => {
if (key === 'isNew') return; // 内部使用属性
if (key[0] === '_') return; // 内部属性 _ctx, _sessCtx 等
obj[key] = this[key];
});
// 为何不直接返回传入的obj? 在修改session 时,每次传入的参数obj可能是配置的子集,内部要始终保存全量的配置
return obj;
}
}
可以看到Session主要就是维护一个配置,以及maxAge等内部逻辑。
答疑: 基于cookie的存储如何保证安全性呢?
首先存到cookie的信息都是用户可见的,即使是通过base64(或者custom encode)encode的,但仍很容易让人解析出cookie的内容,所以放到cookie中的信息务必是非保密的信息。而cookie是通过增加签名来保证安全性的,在cookie中会有一个同名的.sig的值,也就是不阻止前端查看cookie内容,但防止前端篡改cookie内容。签名算法都是不可逆的,在node中接收请求时,从cookie中解析出内容,然后再使用秘钥得到签名跟原签名.sig做比较,得到内容是否被篡改的结论。
总结
- 通过对ctx.session 设置getter方法, 每次获取/修改ctx.session.view的值时,都会保持对
this[_CONTEXT_SESSION]
的引用。 - 在每次调用ctx.session时,都会根据当期cookie中的信息去更新
this[_CONTEXT_SESSION]
的值,以保证对每个请求的状态进行记录与更新。- 基于cookie的存储方案: decode(cookie)得到session值
- 基于外部存储的方案: 从cookie获取sessionId,再从store中读取其值
- 通过中间件机制,在每次调用完成之后,判断是否需要更新cookie/externalStore