koa必备插件:koa-session的内部实现

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中

    1. 在context上定义了Symbol变量来保存的contextSession的引用: 没有直接挂到session变量, 确保了contextSession的唯一性,保证不会被其他代码逻辑覆盖。
    2. 通过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做比较,得到内容是否被篡改的结论。

总结

  1. 通过对ctx.session 设置getter方法, 每次获取/修改ctx.session.view的值时,都会保持对this[_CONTEXT_SESSION]的引用。
  2. 在每次调用ctx.session时,都会根据当期cookie中的信息去更新this[_CONTEXT_SESSION]的值,以保证对每个请求的状态进行记录与更新。
    • 基于cookie的存储方案: decode(cookie)得到session值
    • 基于外部存储的方案: 从cookie获取sessionId,再从store中读取其值
  3. 通过中间件机制,在每次调用完成之后,判断是否需要更新cookie/externalStore
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容