目前我们有4张表(集合),User是最基础的,其它3张表都有个userId字段表示与它的关联。
我们可以使用deno_mongo_schema的virtual功能进行优化。
代码
posts.schema.ts
src/posts/posts.schema.ts,新增:
typescript
import { Comment } from "../comments/comments.schema.ts";
import {
BaseSchema,
Prop,
Schema,
SchemaFactory,
VirtualTypeOptions,
} from "deno_mongo_schema";
import { User, UserInfo } from "../user/user.schema.ts";
export const PostSchema = SchemaFactory.createForClass(Post);
const userVirtual: VirtualTypeOptions = {
ref: User,
localField: "userId", //本表字段
foreignField: "_id", //user表中字段
isTransformLocalFieldToObjectID: true,
justOne: true,
};
PostSchema.virtual("author", userVirtual);
const commentCountVirtual: VirtualTypeOptions = {
ref: Comment,
localField: "_id", //本表字段
foreignField: "postId", //comment表中字段
isTransformLocalFieldToString: true,
count: true,
};
PostSchema.virtual("commentsCount", commentCountVirtual);
VirtualTypeOptions的几个属性也容易理解:
- ref指向的是关联表。
- localField是使用本表哪个字段进行关联。
- foreignField是被关联表的哪个字段。
- isTransformLocalFieldToObjectID表示是否把本表的字段转换成mongodb的id进行关联。_id是mongodb的默认id,格式是一个复杂对象ObjectId,并不是纯粹的字符串,如果不进行转换,二者是关联不上的。相应的,如果要把本表的ObjectId转换为字符串,设置属性isTransformLocalFieldToString。
- justOne,关联是一对多的关系,默认会返回数组,如果justOne,则只取第一个。
- count,指的是统计关联的数量,性能比取到所有数据要好,如果只用数量不用具体数据,推荐使用。这里的commentsCount就用到了count属性,只会统计与博客相关联的留言数量。
PostSchema.virtual的第一个参数表示关联后会挂载到博客对象上的哪个字段。
为了性能考虑,deno_mongo_schema设定为这里只是将关联信息注册,真正是否使用这些字段进行关联,仍要在具体方法里配置使用,后文会介绍。
session.schema.ts
修改src/session/session.schema.ts,新增以下代码:
typescript
import {
BaseSchema,
Prop,
Schema,
SchemaFactory,
VirtualTypeOptions,
} from "deno_mongo_schema";
import { User } from "../user/user.schema.ts";
export const SessionSchema = SchemaFactory.createForClass(Session);
const userVirtual: VirtualTypeOptions = {
ref: User,
localField: "userId", //本表字段
foreignField: "_id", //user表中字段
isTransformLocalFieldToObjectID: true,
justOne: true,
};
SessionSchema.virtual("user", userVirtual);
comments.schema.ts
src/comments/comments.schema.ts新增:
typescript
import { User, UserInfo } from "../user/user.schema.ts";
import {
BaseSchema,
Prop,
Schema,
SchemaFactory,
VirtualTypeOptions,
} from "deno_mongo_schema";
export const CommentSchema = SchemaFactory.createForClass(Comment);
const userVirtual: VirtualTypeOptions = {
ref: User,
localField: "userId", //本表字段
foreignField: "_id", //user表中字段
isTransformLocalFieldToObjectID: true,
justOne: true,
};
CommentSchema.virtual("author", userVirtual);
comments.service.ts
src/comments/comments.service.ts修改findByPostId方法:
typescript
async findByPostId(postId: string) {
const arr = await this.model.findMany({
postId,
}, {
populates: {
author: true,
},
});
arr.forEach((comment) => {
comment.createdAt = format(comment.createTime, "zh_CN");
const html = Marked.parse(comment.content).content;
comment.contentHtml = html;
});
return arr;
}
从下图对比看,删掉了获取author信息的3行代码: 这样,本篇中引入的userService就没用了,也可以删掉了。
session.service.ts
src/session/session.service.ts与之类似:
posts.service.ts
src/posts/posts.service.ts稍微复杂些。 先给PopulateOptions接口添加一个属性isWithCommentsCount:
typescript
interface PopulateOptions {
isWithUserInfo?: boolean;
isWithComments?: boolean;
isWithCommentsCount?: boolean; // 新增
isIncrementPv?: boolean;
}
为什么呢?上文提过,只获取数量时性能高些,比如我们首页时,并不展示具体的留言信息,只展示留言数量,这时没有必要把关联的留言都取到。所以,我们增加一个字段,表明二者目的的不同。
在开发时,需要时刻铭记一点,我们的应用与数据库的交互是昂贵的,所以一要尽可能减少交互的次数,比如不允许在循环中调用交互方法,二要尽可能减少数据库返回的数据大小,降低网络传输造成的损耗。
typescript
export class PostsService {
async findById(id: string, options: PopulateOptions = {}) {
const post = await this.model.findById(id, {
populates: this.getPopulates(options),
});
if (!post) {
return;
}
if (options.isWithComments) {
post.comments = await this.commentsService.findByPostId(id);
post.commentsCount = post.comments.length;
}
// 增加浏览次数
if (options.isIncrementPv) {
this.incrementPvById(id).catch(this.logger.error);
}
this.format(post);
return post;
}
private incrementPvById(id: string) {
return this.model.findByIdAndUpdate(id, {
$inc: {
pv: 1,
},
});
}
private incrementPvByIds(ids: string[]) {
return this.model.updateMany({
_id: { // 注意此处是_id。底层虽然默认将_id转换为id让我们使用,但这种批量处理仍是需要使用_id,因为并没有禁止Schema中定义id字段的情况。
$in: ids,
},
}, {
$inc: { // $inc是自增的处理
pv: 1,
},
});
}
private getPopulates(options?: PopulateOptions) {
if (!options) {
return;
}
const populates: Record<string, boolean> = {};
if (options.isWithUserInfo) {
populates["author"] = true;
}
if (options.isWithCommentsCount) {
populates["commentsCount"] = true;
}
return populates;
}
async findAll(options: PopulateOptions = {}) {
const posts = await this.model.findMany({}, {
populates: this.getPopulates(options),
sort: {
createTime: -1,
},
});
this.formatPosts(posts, options);
return posts;
}
private formatPosts(
posts: Required<Post>[],
options: PopulateOptions = {},
) {
// 增加浏览次数
if (options.isIncrementPv) {
this.incrementPvByIds(posts.map((post) => post.id)).catch(
this.logger.error,
);
}
posts.forEach((post) => {
this.format(post);
});
}
async findByUserId(userId: string, options: PopulateOptions = {}) {
const posts = await this.model.findMany({
userId,
}, {
populates: this.getPopulates(options),
sort: {
createTime: -1,
},
});
this.formatPosts(posts, options);
return posts;
}
}
从代码对比能看出,formatPosts的代码删除掉很多:
findMany参数
要使用上面的表关联,重点是增加populates这个参数,它的key值必须与virtual的第一个参数保持一致,否则将被忽略。
另一个需要注意的是还有个sort参数,它可以在数据库底层进行排序,-1表示倒序,新的博客排在前面。
incrementPvByIds
incrementPvByIds是批量将数据的pv字段,使用$inc操作符在原来基础上加1,这样我们就不必拿到原来的pv再设置,也避免了并发错误的发生。比如原来的逻辑,假设pv是1,一个用户正在访问页面,我们进行更新,pv变成2,但更新完成前又有一个用户访问,拿到的pv就还是1,上一个更新完成(pv变成2)后,我们新的更新开始了,本来pv应该变成3,但仍设置成2了。
你可能也观察到了,调用incrementPvByIds和incrementPvById这两个方法的地方前面并没有加await,而是使用catch保障其稳定。这是因为增加浏览量这个业务逻辑与查询博客并没有直接的关系,不必等待它完成,应该尽快把数据返回给页面。同样的场景如增加日志、记录热值等运维数据记录,都应该同样处理。
findById
你可能会问,为什么findById里不同样使用表关联把comments的数据关联出来,仍要调用commentsService呢?
原因是我们展示的留言里,用到了对应的用户信息:
表关联只是把comments关联出来,并没有更深一级关联用户。所以不如调用commentsService,在它里面关联用户方便些。
如果是单纯的接口开发,博客的信息和留言的信息将是两个接口,千万别整合成一个。由于我们做的服务端渲染,所以稍微复杂些。
posts.controller.ts
src/posts/posts.controller.ts
将getAll中isWithComments修改isWithCommentsCount。
注意一点是,我并没有在findPostById中增加isWithCommentsCount属性,这样会浪费一次查找,因为我们没有对留言做分页处理。后期你添加分页时需要考虑这点。
验证
请在页面上自行验证。
作业
这次使用MongoDB重构就告一段落了。是不是很简单?deno_mongo_schema的API也是语义化的,在开发过程中使用TypeScript的类型推断查看参数即可。
下一节,我们将学习如何对我们的项目写测试。