美文网首页让前端飞
koa必备插件分析:koa-session的内部实现

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

作者: RichardBillion | 来源:发表于2019-03-17 14:33 被阅读4次

    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并没有这样做。koa-session又是怎样的内部实现,使得使用ctx.session可以如此简单,而无需用户关注是对哪个uniqueId的value进行操作呢?

    koa-session本身并没有维护一个session对象在应用中,而是抽象出了一个session中间件模型,并且定义了需要子类实现的抽象接口Store,可以很方便的支持外部store的扩展。同时默认内置了基于cookie的存储方案。

    • 默认存储到cookie

      在未使用外部存储时,koa-session也不会维护一个session对象,而是将每个session的值通过base64编码(也可以传入自定义的encode/decode方法)后放到了cookie中。对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等内部逻辑。

    总结

    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

    相关文章

      网友评论

        本文标题:koa必备插件分析:koa-session的内部实现

        本文链接:https://www.haomeiwen.com/subject/yrlumqtx.html