上节最后我们提到:
思考下,目前我们的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做下处理:
- 如果配置了required,但没有传递,就报错
- 如果配置了default,则赋给默认值。
- 如果配置了unique,则先查找一个有没有同样的数据,有的话就报错。
- 如果配置了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。
这里为了测试,我们把过期时间设置为60秒,正式使用时当然要长一些,一般以天为单位,比如7天(60*60*24*7),千万不要写成604800,想想为什么?
验证
- 清理所有数据(localStorage)。
- 访问http://localhost:8000/posts,这时会有新的session创建出来,可在网络中观察响应的cookie:
- 1分钟之后,看vscode控制台是否有一条打印信息
clear expired session_expired aGn55ENaIfa4pFuJ3xLat 1655452606367
- 再刷新页面,这时会有新的session-id。
- 在http://localhost:8000/signup注册成功后,可以看到右上角的信息是已登陆状态,但1分钟之后,再刷新会变成未登陆的。
作业
- 修改src/user/user.schema.ts的User,给name加上
unique属性,验证下是否成功。 - 注册功能就先告一段落了。我们下一步是登陆页面和登出功能的实现,你可以先尝试着实现。