本系列文已出版成書「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。