前言
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 方式. 另外关于登录还有单点登录, 第三方登录等. 如果大家有兴趣, 可以在后面的文章继续给大家剖析.
来源: https://www.cnblogs.com/woodk/p/10129836.html