Typescript express 新手教程 S6 express应用MongoDB(木地板)极限简洁建立 Document之间的联系
太长不看
- 木地板规定了不同库之间的数据引用关系可以是 1v1,1vn,nvn ,以下就是具体示范细节
建立木地板文档之间的联系
木地板是非关系型数据库,还是文档数据库,Document
木地板不是关系型数据库,但这不代表不能进行表之间的联系,木地板提供了名字不同但功能类似的API来完成这种联系,
比如通过_id或者直接嵌入文档(embedding Document) 来进行 引用 reference。
那么什么是引用?这只是一个术语-jargon,比如我们之前建立post的时候,在其中加入了userId这个属性(user库),也就是在每一条post里记录了作者的_id,
那么这个id就是一种引用。
const post = await this.post.create({ ...content, id: req._userId });
可以想象post的数据结构 就会包含了id这一个成员。
因为这个id它
- 是post当中的成员
- 但是id的值指向(引用)了user表当中的内容,所以叫做引用
使用这种引用就能在两张表之间建立联系
1对1
Model 代表的数据被称为entity 实体
当类型A的实体中的1个成员与类型B的实体中的一个成员通过引用建立联系, 在木地板中有 1对1关系 。
上文提到了,直接嵌入文档,可以通过下面的code段来了解具体含义
import * as mongoose from 'mongoose';
import User from './user.interface';
const addressSchema = new mongoose.Schema({
city: String,
street: String,
});
const userSchema = new mongoose.Schema({
address: addressSchema,
email: String,
name: String,
password: String,
});
const userModel = mongoose.model<User & mongoose.Document>('User', userSchema);
export default userModel;
注意 uesrSchema的address属性, 比如一个用户有一个住址,
- 在user库里,一个user只有一个address,而反过来,address库里的这条address只属于一个user,因为这种1对1的关系,可以直接嵌入文档
- address库会按照上述Schema自动生成,且会被添加自增的_id
1对多
有了1对1的基础,1对多就简单很多了,还是有实体A和B,
当实体A中的一个成员与实体B中的多个成员有关系,但是反过来B中的一个成员只能与A中的一个成员有关系
这种关系可以使用user和post来举例,比如一个user作为author可以发多个post,但每个post只能有一个user作为 author,下面的code说明了具体实现
首先要把 Post接口和dto 改一下,
# 接口
import { Types } from "mongoose";
/**
* Post接口
*/
export default interface IPost {
title: string;
author: Types.ObjectId;
content: string;
}
# dto
import { Types } from "mongoose";
// 使用class-validator提供的装饰器,会把验证规则装到一个全局store里,调用validate方法的时候,会到容器里去找对应的验证规则
/**
* 发帖需要作者 标题和内容
*/
class CreatePostDto implements IPost {
@IsString()
public author: Types.ObjectId;
@IsString()
public content: string;
@IsString()
public title: string;
}
...
上面的code在接口和dto中Types.ObjectId改写了 string。
然后回到 postSchema定义中,丰富author的 配置
import { UserEntityName } from "controller/user/user.model";
import mongoose, { Schema } from "mongoose";
import IPost from "./post.interface";
const PostEntityName = "Post";
// https://mongoosejs.com/docs/typescript.html
// 1. Imports an interface representing a document in MongoDB.
// 2. Create a Schema corresponding to the document interface.
const postSchema = new Schema<IPost>({
content: { type: String, required: true },
author: {
ref: UserEntityName,
type: Schema.Types.ObjectId,
required: true,
},
title: { type: String, required: true },
});
// You as the developer are responsible for ensuring that your document interface lines up with your Mongoose
// schema. For example, Mongoose won't report an error if email is required in your Mongoose schema but
// optional in your document interface.
const postModel = mongoose.model<IPost & mongoose.Document>(
PostEntityName,
postSchema
);
/**
* Post 表 的控制器
*/
export { PostEntityName };
export default postModel;
注意上文的 author 的type 没有使用 mongoose.Schema.Types.ObjectId ,而是使用了Schema.Types.ObjectId,这是官方文档要求的内容。如下
在Typescript中使用mongoose
前文中使用的引用方式是直接嵌入文档,这里使用的引用方式 是 ref
,
也就是指定一个库/表/实体,也就是建立用schmea创建Model的时候,传入Model的(表)的名字,注意和ref一起写入的配置 type,mongoose.Schema.Types.ObjectId其实就是 _id
的类型。
mongoose.model<User & mongoose.Document>('User', userSchema);
通过上述配置,通过引用User作为author,就在User和Post两个库之间形成了 1对多的关系。
注意,下方的create操作是建立在 上面1对多配置基础之上的。
populate (就是join)
我们之前使用 ref的形式 创建了两个表之间的数据的关系,mongoose 提供了populta的 API 来把ref替换成实际数据,
比如原始的数据:
author: "这里是一个 _id",
content:"never mind ,i'll find ..."
替换后的数据就是
author:{_id:"这是一个_id",name:"adele",email:"adele@21.com",password:"一个秘密的蛤希"},
content:"never mind ,i'll find ..."
具体操作如下:
private createPost = async (request: RequestWithUser, response: express.Response) => {
const postData: CreatePostDto = request.body;
const createdPost = new this.post({
...postData,
author: request.user._id,
});
const savedPost = await createdPost.save();
await savedPost.populate('author').execPopulate();
response.send(savedPost);
}
populate 函数可以通过传入配置
- 来选择什么需要被populte
- 或者什么需要被忽略
这个配置很重要,比如敏感信息 password,用户隐私 email 这些都不应该被填充到查询结果中(populate)。
另外populate在Document实例上和Query实例上的使用不同
# 在Document 实例上
savedPost.populate('author').execPopulate()
# 在Query 实例上
this.post.find().populate('author', '-password').exec()
对Query实例,exec实际上是一个then方法,对Document实际上是一个普通链式调用的函数 (存疑)
引用的方向
我们已经在user和post之间建立的1对多的关系,具体是在每条post中 加入了user_id,
那么反过来是否可以?在每个user里边 加入post_id,理论上完全可行,但是一般不会这么做,所以决定引用的 方向的时候,下面几点需要被考虑
- 引用方向是否会导致单条Document太大?
- 最常用的query操作是什么?
还是用user和post举例,假设我们选择在user里引用帖子,那形式上就要在user里保存一个数组,数组保存多个post 的_id或者是内嵌文档,但是一条木地板Document的大小上限默认是16MB,这样做 一条user的 空间占用很快会超出木地板的要求上限, 所以 这个时候就要在post里保存 user的id,因为user create了 post, 所以这被称为 parent-referencing
另外,因为我们已经在每条post里存储了user_id,那么给出任意一条post,很容易就能知道其作者是谁,但是反过来,给出一个user_id,很那查询到其发表过的所有帖子(因为要遍历)
import * as express from 'express';
import NotAuthorizedException from '../exceptions/NotAuthorizedException';
import Controller from '../interfaces/controller.interface';
import RequestWithUser from '../interfaces/requestWithUser.interface';
import authMiddleware from '../middleware/auth.middleware';
import postModel from '../post/post.model';
class UserController implements Controller {
public path = '/users';
public router = express.Router();
private post = postModel;
constructor() {
this.initializeRoutes();
}
private initializeRoutes() {
this.router.get(`${this.path}/:id/posts`, authMiddleware, this.getAllPostsOfUser);
}
private getAllPostsOfUser = async (request: RequestWithUser, response: express.Response, next: express.NextFunction) => {
const userId = request.params.id;
if (userId === request.user._id.toString()) {
const posts = await this.post.find({ author: userId });
response.send(posts);
}
next(new NotAuthorizedException());
}
}
export default UserController;
上面是一种实现 ,只有登录成功的user可以获取其发表过的所有post(操作X),
这个时候,相对于 在post中获取对应的user(操作Y),Y的使用频次很明显低于X, 所以,优先Y操作对应的引用方向。
嵌入 VS 引用
虽然说形式上嵌入也是引用(直接),但是直接嵌入就是字面意思上的嵌入,可以在一个实体里获取到填充的信息,而不需要横跨多个实体,当然缺点是虽然获得了填充信息,但是不能把这些信息当成独立的实体来进行操作。
而_id引用的形式不同, 他需要横跨多个实体,需要更多的query,所以相对来说慢一些。
基本上,内嵌文档适用于 子文档(内嵌的文档称作子文档)size小,且不容易发生变化。
ref_id适用于 子文档较大 或者经常发生变化,或者常常被(populate)exclude在查询结果之外的的情况
多对多
有了前面的基础,很容易想到多对多的情况,在A实体中,有一个doc和B实体中多个doc有关系,同时,在B实体中,有一个doc和A实体中多个doc有关系。
还是用user和post举例。
现在规定,一个user可以有多个post,一个post还能有多个作者(联合发表,就像论文的多个作者)
一种实现是把author:{ref} 改成 authors[ {ref}]
const postSchema = new mongoose.Schema({
authors: [
{
ref: 'User',
type: mongoose.Schema.Types.ObjectId,
}
],
content: String,
title: String,
});
对比之前的author配置,authors 可以传入多个 对象包含对 user_id的ref的{},也就是告诉木地板,这是个"多"引用。
双向引用 two-way referencing
双向引用是对 1对多 和 多对多关系常见的一种操作,在建立关系的双方实体中都存储 ref,还是用post和user举例
在 post中,保存对user的引用,这样可以通过查询post了解 post作者
const postSchema = new mongoose.Schema({
authors: [
{
ref: 'User',
type: mongoose.Schema.Types.ObjectId,
},
],
content: String,
title: String,
});
在user中,保存对post的引用,这样可以通过user查询user发过的post
const userSchema = new mongoose.Schema({
address: addressSchema,
email: String,
name: String,
password: String,
posts: [
{
ref: 'Post',
type: mongoose.Schema.Types.ObjectId,
},
],
});
这种双向引用的优势很明显,上节提到的考虑最常用查询故意忽略了 给定user查询post的情况,双向引用后,给定user再查询post轻而易举。
劣势在于,每次创建doc,就要刻意考虑数据一致性,也就是更新 post和user的引用。
比如某user发了一个新的post,就要在user中更新posts,
反过来某个user的_id改了,就要在post的很多doc中更新author (真的是这样吗?简直是一个噩梦啊)
private createPost = async (request: RequestWithUser, response: express.Response) => {
const postData: CreatePostDto = request.body;
const createdPost = new this.post({
...postData,
authors: [request.user._id],
});
const user = await this.user.findById(request.user._id);
user.posts = [...user.posts, createdPost._id];
await user.save();
const savedPost = await createdPost.save();
await savedPost.populate('authors', '-password').execPopulate();
response.send(savedPost);
}
上面的实现是一个思路,但是 注意到authors中实际只传入了一个_id,如果有多个 _id,那么下面的 逻辑就要更复杂。
为了系统的稳定性,如非必要,一对多是较好的实践,双向引用是较冗余的实践。