从koa-session源码解读session本质

47次阅读

共计 7161 个字符,预计需要花费 18 分钟才能阅读完成。

前言
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 方式。另外关于登录还有单点登录,第三方登录等。如果大家有兴趣,可以在后面的文章继续给大家剖析。

正文完
 0