iT邦幫忙

2021 iThome 鐵人賽

DAY 22
0
Modern Web

NestJS 帶你飛!系列 第 22

[NestJS 帶你飛!] DAY22 - MongoDB

本系列文已出版成書「NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式」,感謝 iT 邦幫忙與博碩文化的協助。如果對 NestJS 有興趣、覺得這個系列文對你有幫助的話,歡迎前往購書,你的支持是我最大的寫作動力!

通常寫後端都會使用到資料庫,透過資料庫來儲存使用者相關的資料,而資料庫有非常多種,本篇將會介紹最熱門的 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": "test@example.com"
}

小結

資料庫可以說是後端不可或缺的一環,而本篇以 MongoDB 為例,並採用 Nest 包裝的 mongoose 來進行存取。這裡附上今天的懶人包:

  1. mongooseschema-based 的 ODM 套件。
  2. schema 用來定義特定 collectiondocument 的資料結構。
  3. 透過 schema 建立 model,並透過其存取 MongoDB。
  4. 善用 hook 來實現各式插件,如:儲存前添加時間戳等。

上一篇
[NestJS 帶你飛!] DAY21 - HTTP Module
下一篇
[NestJS 帶你飛!] DAY23 - Authentication (上)
系列文
NestJS 帶你飛!32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言