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,理论上完全可行,但是一般不会这么做,所以决定引用的 方向的时候,下面几点需要被考虑

  1. 引用方向是否会导致单条Document太大?
  2. 最常用的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,那么下面的 逻辑就要更复杂。

为了系统的稳定性,如非必要,一对多是较好的实践,双向引用是较冗余的实践。

posted @ 2022-02-23 19:07  刘老六  阅读(134)  评论(0编辑  收藏  举报