[NestJS 带你飞!] DAY22 - MongoDB

通常写後端都会使用到资料库,透过资料库来储存使用者相关的资料,而资料库有非常多种,本篇将会介绍最热门的 NoSQL 资料库 - MongoDB 如何在 Nest 中进行互动,如果不清楚 MongoDB 或是想要在云端建立免费的 MongoDB 服务,可以参考我去年写的文章。那就废话不多说,赶快开始吧!

安装 mongoose

node.js 与 MongoDB 沟通最有名的函式库即 mongoose,它是一个采用 schema-based 的 ODM 套件。Nest 对 mongoose 进行了包装,制作了一个 MongooseModule 让习惯用 mongoose 的开发人员可以无痛使用,实在是太贴心啦!

安装方式一样是透过 npm,不过这里需要特别注意除了安装 Nest 制作的模组外,还需要安装 mongoose 本身:

$ npm install @nestjs/mongoose mongoose

连线 MongoDB

在安装完相关套件後,就可以来实作连线资料库的部分,其方法十分简单,在 AppModule 下汇入 MongooseModule 并使用 forRoot 方法来进行连线,它的效果等同於 mongoose 中的 connect 方法。这里以 app.module.ts 为例,定义一个常数 MONGO 来放置连线相关资讯,并在 MongooseModule.forRoot 中调用 getUrl 方法:

注意:通常不会把敏感资讯写在程序码里面,会将其抽离至环境变数中。

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AppController } from './app.controller';
import { AppService } from './app.service';

const MONGO = {
  username: '<Example>',
  password: encodeURIComponent('<YOUR_PASSWORD>'),
  getUrl: function () {
    return `mongodb+srv://${this.username}:${this.password}@<YOUR_DB>`
  }
};

@Module({
  imports: [
    MongooseModule.forRoot(MONGO.getUrl())
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

使用环境变数

MongooseModule 提供了 forRootAsync 方法,透过这个方法可以把依赖项目注入进来,使 MongooseModule 在建立时可以使用依赖项目来赋予值。运用这个特性将 ConfigModule 引入,并注入 ConfigService 进而取出我们要的环境变数来配置 MongoDB 的来源。

我们先在专案目录下新增 .env 档,并将 MongoDB 的相关配置写进来:

MONGO_USERNAME=YOUR_USERNAME
MONGO_PASSWORD=YOUR_PASSWORD
MONGO_RESOURCE=YOUR_RESOURCE

接着,运用前面学到的命名空间技巧,将 MongoDB 相关的环境变数归类在 mongo 底下,并针对环境变数进行处理,我们在 src/config 资料夹下新增 mongo.config.ts 来实作该工厂函式:

import { registerAs } from '@nestjs/config';

export default registerAs('mongo', () => {
  const username = process.env.MONGO_USERNAME;
  const password = encodeURIComponent(process.env.MONGO_PASSWORD);
  const resource = process.env.MONGO_RESOURCE;
  const uri = `mongodb+srv://${username}:${password}@${resource}?retryWrites=true&w=majority`;
  return { username, password, resource, uri };
});

修改 app.module.ts,配置 ConfigModule 以及 MongooseModule,在 forRootAsync 方法中带入依赖,并用 useFactory 返回从 ConfigService 取到的值:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';

import { AppController } from './app.controller';
import { AppService } from './app.service';

import MongoConfigFactory from './config/mongo.config';

@Module({
  imports: [
    ConfigModule.forRoot({
      load: [MongoConfigFactory]
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri')
      })
    })
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

如此一来,就成功用环境变数去设定 MongooseModule 的配置啦!

mongoose 概念

在开始存取资料库之前,还是来讲解一下 mongoose 的基本概念,它主要是由两大元素构成:schemamodel

Schema

MongoDB 最基本的元素为 document,也就是一笔资料,而很多个 document 所形成的集合为 collection,其概念如下:
https://ithelp.ithome.com.tw/upload/images/20210616/2011933844J6IMQGcd.png

为什麽要特别说 MongoDB 的基本概念呢?因为 schema 与这概念息息相关,每一个 schema 都对应到一个 collection,它会制定该 collection 下所有 document 的栏位与栏位规则,是最基础的元素。

Model

透过 schema 制定了资料结构,但无法直接透过它来存取资料库,因为它只是制定了规则,真正执行存取的元素为 model,所有的 model 都是基於 schema 产生的,透过 model 便可以操作该 schema 所控管的 collection,并且所有建立、修改、查询都会根据 schema 制定的栏位来操作。

Schema 设计

在 Nest 要设计 schema 有两种方式,一种是采用 mongoose 原生的做法,另一种则是用 Nest 设计的装饰器,这里会以 Nest 装饰器为主,想了解原生作法的话可以参考我去年的文章。透过 Nest 装饰器设计的 schema 主要是由 @Schema@Prop 所构成:

Schema 装饰器

@Schema 装饰器会将一个 class 定义为 schema 的格式,并且可以接受一个参数,该参数对应到 mongooseschema 选项配置,详细内容可以参考官方文件

这里我们先简单设计一个名为 Todoclass,并使用 @Schema 装饰器,在 src/common/models 资料夹下建立一个名为 todo.model.ts 的档案:

import { Schema } from '@nestjs/mongoose';

@Schema()
export class Todo {}

Prop 装饰器

@Prop 装饰器定义了 document 的栏位,其使用在 class 中的属性,它拥有基本的型别推断功能,让开发人员在面对简单的型别可以不需特别做指定,但如果是阵列或巢状物件等复杂的型别,则需要在 @Prop 带入参数来指定其型别,而带入的参数其实就是 mongooseSchemaType,详细内容可以参考官方文件

这里我们就来修改一下 todo.model.ts 的内容,实作一次 @Prop 的使用方式,总共配置了 titledescription 以及 completed 这三个栏位,其中,titlecompleted 为必填,而 title 最多接受 20 个字,description 最多接受 200 字:

import { Prop, Schema } from '@nestjs/mongoose';

@Schema()
export class Todo {

  @Prop({ required: true, maxlength: 20 })
  title: string;

  @Prop({ maxlength: 200 })
  description: string;

  @Prop({ required: true })
  completed: boolean;

}

制作 Document 型别

我们说 schema 是定义 document 的资料结构,而 model 是基於 schema 所产生出来的,这里我们就可以简单推断出 model 的操作会返回的东西为 documentmongoose 也很贴心的提供了基本的 Document 型别,但因为 document 会根据 schema 定义的资料结构而有所不同,故我们需要设计一个 type 来让之後 model 可以顺利拿到 schema 定义的栏位。

修改 todo.model.ts 的内容,定义了 TodoDocument 的型别:

import { Prop, Schema } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type TodoDocument = Todo & Document;

@Schema()
export class Todo {

  @Prop({ required: true, maxlength: 20 })
  title: string;

  @Prop({ maxlength: 200 })
  description: string;

  @Prop({ required: true })
  completed: boolean;

}

巢状物件型别

如果碰上某个栏位是巢状物件的型别该怎麽处理呢?这时候可以使用一个非常实用的函式来达成该目的,其名为 raw,它可以让开发人员在不额外建立 class 的情况下,用 mongoose 原生的写法来定义该栏位的型别与规则。

上面的叙述可能有点难理解,我们实作一次会比较清楚,这边在 src/common/models 底下建立一个 user.model.ts 的档案并设计其 schema,我们定义一个名为 name 的栏位,并在该栏位配置 firstNamelastName 以及 fullName 的栏位,形成一个巢状物件的型别:

import { Prop, raw, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type UserDocument = User & Document;

@Schema()
export class User {

  @Prop(
    raw({
      firstName: { type: String },
      lastName: { type: String },
      fullName: { type: String }
    })
  )
  name: Record<string, any>;
  
  @Prop({ required: true })
  email: string;

}

产生关联

mongoose 可以将多个相关 collection 的资料产生关联,透过 populate 的方法让别的 collection 资料能够被找到且带入。

修改一下 todo.model.ts 的内容,添加一个 owner 的栏位,并透过 mongooseObjectIdUser 产生关联:

import { Prop, Schema } from '@nestjs/mongoose';
import { Document, Types } from 'mongoose';
import { User } from './user.model';

export type TodoDocument = Todo & Document;

@Schema()
export class Todo {

  @Prop({ required: true, maxlength: 20 })
  title: string;

  @Prop({ maxlength: 200 })
  description: string;

  @Prop({ required: true })
  completed: boolean;

  @Prop({ type: Types.ObjectId, ref: 'User' })
  owner: User;

}

产生 Schema

在设计好 schema 之後,就要将 schema 透过 SchemaFactorycreateForClass 方法产生出这个 schema 的实体,这里以 todo.model.tsuser.model.ts 为例:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, Types } from 'mongoose';
import { User } from './user.model';

export type TodoDocument = Todo & Document;

@Schema()
export class Todo {

  @Prop({ required: true, maxlength: 20 })
  title: string;

  @Prop({ maxlength: 200 })
  description: string;

  @Prop({ required: true })
  completed: boolean;

  @Prop({ type: Types.ObjectId, ref: 'User' })
  owner: User;

}

export const TodoSchema = SchemaFactory.createForClass(Todo);
import { Prop, raw, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type UserDocument = User & Document;

@Schema()
export class User {

  @Prop(
    raw({
      firstName: { type: String },
      lastName: { type: String },
      fullName: { type: String }
    })
  )
  name: Record<string, any>;
  
  @Prop({ required: true })
  email: string;

}

export const UserSchema = SchemaFactory.createForClass(User);

如此一来,便完成了 schema 的实作,是不是很方便呢!

使用 Model

在完成 schema 以後就要来实作 model 的部分了,这里我们先建立 UserModuleUserController 以及 UserService 来替我们的 API 做准备:

$ nest generate module features/user
$ nest generate controller features/user
$ nest generate service features/user

MongooseModule 有提供 forFeature 方法来配置 MongooseModule,并在该作用域下定义需要的 model,使用方式很简单,给定一个阵列其内容即为 要使用的 schema对应的 collection 名称,通常我们习惯直接使用 schemaclass 名称作为值,其最终会对应到的 collection名称 + s,举例来说,User 会对应到的 collection 名称即 users

我们修改 user.module.ts,将 MongooseModule 引入,并在 UserModule 的作用域下使用 Usermodel

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';

import { User, UserSchema } from '../../common/models/user.model';
import { UserController } from './user.controller';
import { UserService } from './user.service';

@Module({
  imports: [
    MongooseModule.forFeature([
      { name: User.name, schema: UserSchema }
    ])
  ],
  controllers: [UserController],
  providers: [UserService]
})
export class UserModule {}

在定义好之後,就可以透过 @InjectModel 来将 Usermodel 注入到 UserService 中,并给定型别 UserDocument

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';

import { Model } from 'mongoose';

import { User, UserDocument } from '../../common/models/user.model';

@Injectable()
export class UserService {

  constructor(
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>
  ) {}

}

建立 (Create)

修改 user.service.ts 的内容,新增一个 create 方法,并呼叫 userModelcreate 方法来建立一个使用者到 users 这个 collection 里面:

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';

import { Model } from 'mongoose';

import { User, UserDocument } from '../../common/models/user.model';

@Injectable()
export class UserService {

  constructor(
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>
  ) {}

  create(user: any) {
    return this.userModel.create(user);
  }

}

修改 user.controller.ts 的内容,设计一个 POST 方法来建立使用者,并且返回 UserDocument 到客户端:

import { Body, Controller, Post } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('users')
export class UserController {
  
  constructor(
    private readonly userService: UserService
  ) {}

  @Post()
  create(@Body() body: any) {
    return this.userService.create(body);
  }

}

透过 Postman 建立使用者,成功建立之结果如下图:
https://ithelp.ithome.com.tw/upload/images/20210622/20119338E2KkCY1Vwl.png

读取 (Read)

修改 user.service.ts 的内容,新增一个 findById 方法,并呼叫 userModelfindById 方法来透过 id 取得使用者资料:

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';

import { Model } from 'mongoose';

import { User, UserDocument } from '../../common/models/user.model';

@Injectable()
export class UserService {

  constructor(
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>
  ) {}

  findById(id: string) {
    return this.userModel.findById(id);
  }

}

修改 user.controller.ts 的内容,设计一个 GET 方法来取得使用者,并且返回 UserDocument 到客户端:

import { Body, Controller, Get, Param } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('users')
export class UserController {
  
  constructor(
    private readonly userService: UserService
  ) {}

  @Get(':id')
  findById(@Param('id') id: string) {
    return this.userService.findById(id);
  }

}

透过 Postman 来取得使用者资料,成功取得之结果如下图:
https://ithelp.ithome.com.tw/upload/images/20210623/20119338ssCqVew9qM.png

更新 (Update)

修改 user.service.ts 的内容,新增一个 updateById 方法,并呼叫 userModelfindByIdAndUpdate 方法来透过 id 更新使用者资料:

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';

import { Model } from 'mongoose';

import { User, UserDocument } from '../../common/models/user.model';

@Injectable()
export class UserService {

  constructor(
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>
  ) {}

  updateById(id: string, data: any) {
    return this.userModel.findByIdAndUpdate(id, data, { new: true });
  }

}

注意:上方的 new 参数是让 mongoose 回传更新後的结果,预设为 false

修改 user.controller.ts 的内容,设计一个 PATCH 方法来更新使用者资料,并且返回 UserDocument 到客户端:

import { Body, Controller, Param, Patch } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('users')
export class UserController {
  
  constructor(
    private readonly userService: UserService
  ) {}

  @Patch(':id')
  updateById(
    @Param('id') id: string,
    @Body() body: any
  ) {
    return this.userService.updateById(id, body);
  }

}

透过 Postman 来更新使用者资料,成功更新之结果如下图:
https://ithelp.ithome.com.tw/upload/images/20210623/20119338liFSn2Fbgj.png

删除 (Delete)

修改 user.service.ts 的内容,新增一个 removeById 方法,并呼叫 userModelremove 方法来透过 id 删除使用者资料:

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';

import { Model } from 'mongoose';

import { User, UserDocument } from '../../common/models/user.model';

@Injectable()
export class UserService {

  constructor(
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>
  ) {}

  removeById(id: string) {
    return this.userModel.remove({ _id: id });
  }

}

修改 user.controller.ts 的内容,设计一个 DELETE 方法来删除使用者资料,并且返回删除的相关资讯到客户端:

import { Controller, Delete, Param } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('users')
export class UserController {
  
  constructor(
    private readonly userService: UserService
  ) {}

  @Delete(':id')
  removeById(@Param('id') id: string) {
    return this.userService.removeById(id);
  }

}

透过 Postman 来删除使用者资料,成功更新之结果如下图:
https://ithelp.ithome.com.tw/upload/images/20210623/20119338F8s6Pb6H6N.png

Hook 功能

mongoose 有提供 hook 让开发人员使用,其属於 schema 的层级,它可以用来实作许多功能,比如说:我希望在储存之前可以在终端机将内容印出来、我希望在储存之前添加时间戳等,都可以透过 hook 来实现。而 hook 的注册需要在 model 建立之前,使用 MongooseModuleforFeatureAsync 方法来实作工厂函式,并在工厂函式中完成 hook 的注册即可。

这里以 user.module.ts 为例,我们透过 UserSchemapre 方法来介接 动作发生之前 的 hook,这里以 save 为例,其触发时间点为储存之前:

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';

import { User, UserDocument, UserSchema } from '../../common/models/user.model';
import { UserController } from './user.controller';
import { UserService } from './user.service';

@Module({
  imports: [
    MongooseModule.forFeatureAsync([
      {
        name: User.name,
        useFactory: () => {
          UserSchema.pre('save', function(this: UserDocument, next) {
            console.log(this);
            next();
          });
          return UserSchema;
        }
      }
    ])
  ],
  controllers: [UserController],
  providers: [UserService]
})
export class UserModule {}

透过 Postman 建立一个使用者,会在终端机看到下方资讯,表示 hook 顺利执行了:

{
  "_id": "60db07078d5c2c53e49e6cf0",
  "name": {
    "firstName": "Hao",
    "lastName": "Hsieh",
    "fullName": "Hao Hsieh"
  },
  "email": "[email protected]"
}

小结

资料库可以说是後端不可或缺的一环,而本篇以 MongoDB 为例,并采用 Nest 包装的 mongoose 来进行存取。这里附上今天的懒人包:

  1. mongooseschema-based 的 ODM 套件。
  2. schema 用来定义特定 collectiondocument 的资料结构。
  3. 透过 schema 建立 model,并透过其存取 MongoDB。
  4. 善用 hook 来实现各式插件,如:储存前添加时间戳等。

<<:  Day 27 CSS3 < 动画 animation>

>>:  [Day 23] Reactive Programming - Spring WebFlux(Handler)

Day01-为什麽我要学Vue/Vue简介

我的梦想就是带这一台笔电走遍全世界,「成为一个工程师似乎可以完成这个梦想」,於是在去年底毅然决然的投...

使用 Quick Reply 改善 Line Bot 互动

昨天了解各种 Line 的 Message Type 後,今天就运用其中的格式来优化验证码小帮手的互...

mostly:functional 第二十八章的试炼: Applicative 的证明

小测验 我们在上一章的最开始,示范了元组上的 <*>,其中有一条是这样写的: pure ...

[从0到1] C#小乳牛 练成基础程序逻辑 正确打开方式 - 观看SOP

🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄🐄 Day 01 - 认识C++++ 乳牛与程序...

虚拟机 Vagrant

第30天了,来聊聊 vm/虚拟机 ... 如果只是想练习Linux的CLI,一定要在GCP上开ins...