2.6 Schema属性参数

91 阅读3分钟

上节最后我们提到:

思考下,目前我们的session还有什么缺陷?应该怎么优化?

不提分布式等高端问题,当你关闭浏览器,再次打开页面,会发现原来登陆状态没有了,因为cookie中session-id只会保留在当前会话期间。

另一方面,我们后端的session并没有过时清理。也就是说只要cookie中的session-id一直在,用户状态永远不会过期,这在业务上一般是不行的。而用户删除了cookie后我们又会生成一个新的session,原来的session就成为了脏数据,永远保存在我们服务器了。

cookie过期

cookie的时效很容易解决,只需要修改src/session/session.middleware.ts中设置cookie的地方,添加第3个参数:

await context.cookies.set(SESSION_KEY, sessionId, {
  httpOnly: true, // 不会被前端js代码获取到
  secure: false, // 如果服务部署成https则设置为true
  maxAge: 60 * 60 * 24 * 7, // 过期时间,单位是秒,这里设7天
  sameSite: "strict", // 同源策略
});

这样即使浏览器关闭了,只要不手动清理,再次打开时,这个cookie仍会存在,直到7天后过期。

session清理

针对session脏数据的情况,当然是我们的DAO层(model.ts)帮我们处理最合适了。

schema.ts

修改src/schema.ts中SchemaType,增加几个属性信息。这几项也是我们常用的。

export interface SchemaType {
  /**
   * 是否必须的字段
   */
  required?: boolean | [required: boolean, errorMsg: string];

  /**
   * 默认值,可能是函数,也可以是具体值。其中如果是Date或Date.now,则是当前时间,相当于new Date()。
   */
  default?: any;

  /**
   * 过期时间,单位是秒。
   */
  expires?: number; 

  /**
   * 全局唯一
   */
  unique?: boolean;
}

model.ts

修改src/model.ts,在新增时进行参数校验,对上面的SchemaType做下处理:

  1. 如果配置了required,但没有传递,就报错
  2. 如果配置了default,则赋给默认值。
  3. 如果配置了unique,则先查找一个有没有同样的数据,有的话就报错。
  4. 如果配置了expires,则相对麻烦些,我们要维护一个过期数据的数据结构,它是定时轮循进行清理。
export class Model<T extends object, U = T & { id: string }> {
  name: string;
  schema: Constructor | undefined;
  expiredKey: string;
  constructor(name: string, schema?: Constructor) {
    this.name = name;
    this.schema = schema;
    this.expiredKey = name + "_expired";
    setInterval(this.clearExpired.bind(this), 1000 * 5); // 轮循,5秒一次
  }

  private getExpiredMap(): Record<string, number> {
    return getData(this.expiredKey) || {};
  }

  private async clearExpired() {
    const expiredMap = this.getExpiredMap();
    if (Object.keys(expiredMap).length === 0) {
      return;
    }
    const now = Date.now();
    await Promise.all(
      Object.keys(expiredMap).map((key) => {
        if (expiredMap[key] <= now) {
          console.debug("clear expired", this.expiredKey, key, expiredMap[key]);
          delete expiredMap[key];
          return this.findByIdAndDelete(key);
        }
      }),
    );
    setData(this.expiredKey, expiredMap);
  }

  /** 校验参数 */
  private async checkProps(doc: T, id: string) {
    const newdoc = {} as T;
    if (this.schema) {
      const meta = getSchemaMetadata(this.schema);
      await Promise.all(
        Object.keys(meta).map(async (key) => {
          const k = key as keyof T;
          const options: SchemaType = meta[key];
          if (options.default && doc[k] === undefined) {
            newdoc[k] = typeof options.default === "function"
              ? ((options.default === Date || options.default === Date.now)
                ? new Date()
                : options.default())
              : options.default;
          } else if (doc[k] !== undefined) {
            newdoc[k] = doc[k];
          }
          if (options.required) {
            if (doc[k] === undefined) {
              if (Array.isArray(options.required) && options.required[0]) {
                throw new Error(options.required[1]);
              } else {
                throw new Error(`${key} is required`);
              }
            }
          }
          if (options.unique) {
            const exist = await this.findOne(
              { [k]: doc[k] } as unknown as Partial<U>,
            );
            if (exist) {
              throw new Error(`${key} is unique`);
            }
          }
          if (options.expires) {
            const expiredMap = this.getExpiredMap();
            const expired = Date.now() + options.expires * 1000;
            expiredMap[id] = expired;
            setData(this.expiredKey, expiredMap);
          }
        }),
      );
    }
    // console.log(newdoc);
    return newdoc;
  }

  /** 增加一个文档 */
  async insertOne(doc: T): Promise<string> {
    const id = nanoid();
    const newdoc = await this.checkProps(doc, id);
    setData(id, {
      id,
      ...newdoc,
    });
    this.addToIds(id);
    return id;
  }

  async findMany(options: Partial<U>) {
    const docs = await this.findAll();
    return docs.filter((doc: any) => {
      return Object.keys(options).every((key) => {
        return doc[key] === (options as any)[key];
      });
    });
  }

  async findOne(options: Partial<U>) {
    const docs = await this.findAll();
    return docs.find((doc: any) => {
      return Object.keys(options).every((key) => {
        return doc[key] === (options as any)[key];
      });
    });
  }
  
  ... 其它代码不变
}

session.schema.ts

修改src/session/session.schema.ts,增加一个字段expired。

image.png

这里为了测试,我们把过期时间设置为60秒,正式使用时当然要长一些,一般以天为单位,比如7天(60*60*24*7),千万不要写成604800,想想为什么?

验证

  1. 清理所有数据(localStorage)。
  2. 访问http://localhost:8000/posts,这时会有新的session创建出来,可在网络中观察响应的cookie:

image.png

  1. 1分钟之后,看vscode控制台是否有一条打印信息
clear expired session_expired aGn55ENaIfa4pFuJ3xLat 1655452606367
  1. 再刷新页面,这时会有新的session-id。
  2. http://localhost:8000/signup注册成功后,可以看到右上角的信息是已登陆状态,但1分钟之后,再刷新会变成未登陆的。

作业

  1. 修改src/user/user.schema.ts的User,给name加上unique属性,验证下是否成功。
  2. 注册功能就先告一段落了。我们下一步是登陆页面和登出功能的实现,你可以先尝试着实现。