本系列文已出版成書「NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式」,感謝 iT 邦幫忙與博碩文化的協助。如果對 NestJS 有興趣、覺得這個系列文對你有幫助的話,歡迎前往購書,你的支持是我最大的寫作動力!
通常寫後端都會使用到資料庫,透過資料庫來儲存使用者相關的資料,而資料庫有非常多種,本篇將會介紹最熱門的 NoSQL 資料庫 - MongoDB 如何在 Nest 中進行互動,如果不清楚 MongoDB 或是想要在雲端建立免費的 MongoDB 服務,可以參考我去年寫的文章。那就廢話不多說,趕快開始吧!
node.js 與 MongoDB 溝通最有名的函式庫即 mongoose,它是一個採用 schema-based 的 ODM 套件。Nest 對 mongoose 進行了包裝,製作了一個 MongooseModule 讓習慣用 mongoose 的開發人員可以無痛使用,實在是太貼心啦!
安裝方式一樣是透過 npm,不過這裡需要特別注意除了安裝 Nest 製作的模組外,還需要安裝 mongoose 本身:
$ npm install @nestjs/mongoose mongoose
在安裝完相關套件後,就可以來實作連線資料庫的部分,其方法十分簡單,在 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 的基本概念,它主要是由兩大元素構成:schema、model。
MongoDB 最基本的元素為 document,也就是一筆資料,而很多個 document 所形成的集合為 collection,其概念如下:
為什麼要特別說 MongoDB 的基本概念呢?因為 schema 與這概念息息相關,每一個 schema 都對應到一個 collection,它會制定該 collection 下所有 document 的欄位與欄位規則,是最基礎的元素。
透過 schema 制定了資料結構,但無法直接透過它來存取資料庫,因為它只是制定了規則,真正執行存取的元素為 model,所有的 model 都是基於 schema 產生的,透過 model 便可以操作該 schema 所控管的 collection,並且所有建立、修改、查詢都會根據 schema 制定的欄位來操作。
在 Nest 要設計 schema 有兩種方式,一種是採用 mongoose 原生的做法,另一種則是用 Nest 設計的裝飾器,這裡會以 Nest 裝飾器為主,想了解原生作法的話可以參考我去年的文章。透過 Nest 裝飾器設計的 schema 主要是由 @Schema 與 @Prop 所構成:
@Schema 裝飾器會將一個 class 定義為 schema 的格式,並且可以接受一個參數,該參數對應到 mongoose 的 schema 選項配置,詳細內容可以參考官方文件。
這裡我們先簡單設計一個名為 Todo 的 class,並使用 @Schema 裝飾器,在 src/common/models 資料夾下建立一個名為 todo.model.ts 的檔案:
import { Schema } from '@nestjs/mongoose';
@Schema()
export class Todo {}
@Prop 裝飾器定義了 document 的欄位,其使用在 class 中的屬性,它擁有基本的型別推斷功能,讓開發人員在面對簡單的型別可以不需特別做指定,但如果是陣列或巢狀物件等複雜的型別,則需要在 @Prop 帶入參數來指定其型別,而帶入的參數其實就是 mongoose 的 SchemaType,詳細內容可以參考官方文件。
這裡我們就來修改一下 todo.model.ts 的內容,實作一次 @Prop 的使用方式,總共配置了 title、description 以及 completed 這三個欄位,其中,title 與 completed 為必填,而 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;
}
我們說 schema 是定義 document 的資料結構,而 model 是基於 schema 所產生出來的,這裡我們就可以簡單推斷出 model 的操作會返回的東西為 document,mongoose 也很貼心的提供了基本的 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 的欄位,並在該欄位配置 firstName、lastName 以及 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 的欄位,並透過 mongoose 的 ObjectId 與 User 產生關聯:
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 透過 SchemaFactory 的 createForClass 方法產生出這個 schema 的實體,這裡以 todo.model.ts 與 user.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 的實作,是不是很方便呢!
在完成 schema 以後就要來實作 model 的部分了,這裡我們先建立 UserModule、UserController 以及 UserService 來替我們的 API 做準備:
$ nest generate module features/user
$ nest generate controller features/user
$ nest generate service features/user
MongooseModule 有提供 forFeature 方法來配置 MongooseModule,並在該作用域下定義需要的 model,使用方式很簡單,給定一個陣列其內容即為 要使用的 schema 與 對應的 collection 名稱,通常我們習慣直接使用 schema 的 class 名稱作為值,其最終會對應到的 collection 為 名稱 + s,舉例來說,User 會對應到的 collection 名稱即 users。
我們修改 user.module.ts,將 MongooseModule 引入,並在 UserModule 的作用域下使用 User 的 model:
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 來將 User 的 model 注入到 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>
) {}
}
修改 user.service.ts 的內容,新增一個 create 方法,並呼叫 userModel 的 create 方法來建立一個使用者到 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 建立使用者,成功建立之結果如下圖:
修改 user.service.ts 的內容,新增一個 findById 方法,並呼叫 userModel 的 findById 方法來透過 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 來取得使用者資料,成功取得之結果如下圖:
修改 user.service.ts 的內容,新增一個 updateById 方法,並呼叫 userModel 的 findByIdAndUpdate 方法來透過 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 來更新使用者資料,成功更新之結果如下圖:
修改 user.service.ts 的內容,新增一個 removeById 方法,並呼叫 userModel 的 remove 方法來透過 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 來刪除使用者資料,成功更新之結果如下圖:
mongoose 有提供 hook 讓開發人員使用,其屬於 schema 的層級,它可以用來實作許多功能,比如說:我希望在儲存之前可以在終端機將內容印出來、我希望在儲存之前添加時間戳等,都可以透過 hook 來實現。而 hook 的註冊需要在 model 建立之前,使用 MongooseModule 的 forFeatureAsync 方法來實作工廠函式,並在工廠函式中完成 hook 的註冊即可。
這裡以 user.module.ts 為例,我們透過 UserSchema 的 pre 方法來介接 動作發生之前 的 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 來進行存取。這裡附上今天的懶人包:
mongoose 是 schema-based 的 ODM 套件。schema 用來定義特定 collection 下 document 的資料結構。schema 建立 model,並透過其存取 MongoDB。