
掌握API建模:基本概念和實踐
為了在 Node.js 中 構建 API,我們將使用 Nest.js。它是一個相當靈活的框架,建立在 Express.js 的基礎上,可以讓你在短時間內制作出 Node.js 服務,因為它集成了很多好功能(如完全的類型化支持、依賴注入、模塊管理和更多)。
為了更快地開始工作,Nest.js 附帶了一個很好的 CLI 工具,可以為我們創建項目模板。我們開始用以下幾行代碼生成我們的項目:
npm i -g @nestjs/cli
nest new project-name
更多的 Nest.js 和它的 CLI
讓我們測試一下,看看到目前為止是否一切正常:
npm run start:dev
我們將使用 TypeORM 來管理我們的數據庫架構。TypeORM 的優點是:它可以讓你通過代碼來描述數據實體模型,然后能夠應用和同步這些模型到表結構的數據庫。(這不僅適用于 PostgreSQL 數據庫,還適用于其他數據庫,可以在 TypeORM 文檔中找到支持哪些數據庫)
使用 docker 自動化設置本地 PostgreSQL 數據庫實例。
要在本地實現數據持久性,我們現在需要一個數據庫服務器和一個要連接的數據庫。一種方法是在本地機器上設置一個 PostgreSQL 數據庫服務器,但這樣做不是很好。因為這樣項目與我們的本地數據庫服務器會過于耦合。這意味著如果你和一個團隊一起做一個項目,只要切換機器就要在每臺機器上設置數據庫服務器,或者以某種方式編寫安裝指南等(當你團隊的開發同學有不同的操作系統時,事情變得更加棘手)。
那么我們如何克服這一點呢?讓這個步驟自動化!
我們使用預構建的 PostgreSQL docker 鏡像并將數據庫服務器作為 docker 進程運行。我們可以用幾行 shell 代碼編寫一個完整的設置來讓我們的服務器實例運行并準備一個空的數據庫準備連接。因為它是可復用的,并且設置代碼可以與項目代碼的其余部分一起在源代碼管理中進行管理,這使得團隊中其他開發人員的 “入門” 變得非常簡單。
下面是這個腳本的樣子:
#!/bin/bash
set -e
SERVER="my_database_server";
PW="mysecretpassword";
DB="my_database";
echo "echo stop & remove old docker [$SERVER] and starting new fresh instance of [$SERVER]"
(docker kill $SERVER || :) && \
(docker rm $SERVER || :) && \
docker run --name $SERVER -e POSTGRES_PASSWORD=$PW \
-e PGPASSWORD=$PW \
-p 5432:5432 \
-d postgres
# wait for pg to start
echo "sleep wait for pg-server [$SERVER] to start";
SLEEP 3;
# create the db
echo "CREATE DATABASE $DB ENCODING 'UTF-8';" | docker exec -i $SERVER psql -U postgres
echo "\l" | docker exec -i $SERVER psql -U postgres
讓我們將該命令添加到我們的 package.json 運行腳本中,以便我們可以輕松執行它。
"start:dev:db": "./src/scripts/start-db.sh"
現在我們有了一個可以運行的命令,它會設置數據庫服務器和一個普通的數據庫。
為了使過程更健壯,我們將為 docker 容器使用相同的名稱(腳本中的 $SERVER var),并添加一個額外的檢查:如果有同名的容器正在運行,那么將結束并刪除它以確保干凈狀態。
就像所有事情一樣,已經有一個 NPM 模塊可以幫助您將 Nest.js 項目掛鉤到您的數據庫。讓我們使用預構建的 NestJS-to-TypeORM 模塊為我們的項目添加 TypeORM 支持。
您可以像這樣添加所需的模塊:
npm install --save @nestjs/typeorm typeorm pg
我們可以在 Nest.js 中配置 TypeORM 連接到哪個數據庫服務器,方法是使用 TypeOrmModule。它有一個 forRoot
方法,我們可以傳入配置。我們知道配置在本地開發和生產環境中會有所不同。所以,這個過程在某種程度上必須是通用的,以便它可以在不同運行環境提供不同的配置。我們可以編寫以下配置服務。這個配置類的功能是在我們的 API Server main.ts 啟動之前運行。它可以從環境變量中讀取配置,然后在運行時以只讀方式提供值。為了使 dev 和 prod 靈活,我們將使用 dotenv 模塊。
npm install --save dotenv
有了這個模塊,我們可以在本地開發的項目根目錄中有一個 “.env” 文件來準備配置值,而在生產中,我們可以從生產服務器上的環境變量中讀取值。這是一種非常靈活的方法,還允許您使用一個文件輕松地與團隊中的其他開發人員共享配置。注意:我強烈建議 git 忽略此文件,因為你有可能會將生產環境的賬號密碼放入此文件中,所以你不應把配置文件提交到項目中而造成意外泄露。
這是您的 .env 文件的樣子:
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=mysecretpassword
POSTGRES_DATABASE=my_database
PORT=3000
MODE=DEV
RUN_MIGRATIONS=true
因此,我們的 ConfigService 將作為單例服務運行,在啟動時加載配置值并將它們提供給其他模塊。我們將在服務中包含一個容錯模式。這意味著如果獲取一個不存在的值,它將拋出含義完整的錯誤。這使您的設置更加健壯,因為您將在構建 / 啟動時檢測配置錯誤,而不是在運行時生命周期。這樣您將能夠在部署 / 啟動服務器時盡早地檢測到這一點,而不是在消費者使用您的 api 時才發現問題。
這是您的 ConfigService 的外觀以及我們將其添加到 Nest.js 應用程序模塊的方式:
// app.module.ts
import { Module } from'@nestjs/common';
import { TypeOrmModule } from'@nestjs/typeorm';
import { AppController } from'./app.controller';
import { AppService } from'./app.service';
import { configService } from'./config/config.service';
@Module({
imports: [
TypeOrmModule.forRoot(configService.getTypeOrmConfig())
],
controllers: [AppController],
providers: [AppService],
})
exportclass AppModule { }
// src/config/config.service.ts
import { TypeOrmModuleOptions } from'@nestjs/typeorm';
require('dotenv').config();
class ConfigService {
constructor(private env: { [k: string]: string | undefined }) { }
private getValue(key: string, throwOnMissing = true): string {
const value = this.env[key];
if (!value && throwOnMissing) {
thrownewError(config error - missing env.${key}
);
}
return value;
}
publicensureValues(keys: string[]) {
keys.forEach(k =>this.getValue(k, true));
returnthis;
}
publicgetPort() {
returnthis.getValue('PORT', true);
}
publicisProduction() {
const mode = this.getValue('MODE', false);
return mode != 'DEV';
}
public getTypeOrmConfig(): TypeOrmModuleOptions {
return {
type: 'postgres',
host: this.getValue('POSTGRES_HOST'),
port: parseInt(this.getValue('POSTGRES_PORT')),
username: this.getValue('POSTGRES_USER'),
password: this.getValue('POSTGRES_PASSWORD'),
database: this.getValue('POSTGRES_DATABASE'),
entities: ['**/*.entity{.ts,.js}'],
migrationsTableName: 'migration',
migrations: ['src/migration/*.ts'],
cli: {
migrationsDir: 'src/migration',
},
ssl: this.isProduction(),
};
}
}
const configService = new ConfigService(process.env)
.ensureValues([
'POSTGRES_HOST',
'POSTGRES_PORT',
'POSTGRES_USER',
'POSTGRES_PASSWORD',
'POSTGRES_DATABASE'
]);
export { configService };
npm i --save-dev nodemon ts-node
然后在 root 中添加一個帶有調試和 ts-node 支持的 nodemon.json 文件
{
"watch": ["src"],
"ext": "ts",
"ignore": ["src/**/*.spec.ts"],
"exec": "node --inspect=127.0\. 0.1:9223 -r ts-node/register -- src/main.ts",
"env": {}
}
最后我們將 package.json 中的 start:dev 腳本更改為:
"start:dev": "nodemon --config nodemon.json",
這樣可以通過 npm run start:dev
來啟動我們的 API-server,在啟動時它應該從 ConfigService 中獲取 .env 對應環境的 values,然后將 typeORM 連接到我們的數據庫,而且它不綁定在我的機器上。
TypeORM 支持自動加載數據模型實體。您可以簡單地將它們全部放在一個文件夾中,并在您的配置中使用一種模式加載它們 —— 我們將我們的放在 model/.entity.ts 中。(見實體的 TypeOrmModuleOptions 中的 ConfigService)
TypeORM 的另一個特性是這些實體模型支持繼承。
例如,如果您希望每個實體都擁有某些數據字段。
例如:自動生成的 uuid id 字段 和 createDateTime 字段,lastChangedDateTime 字段。
注意:這些基類應該是 abstract。
因此,在 TypeORM 中定義數據模型實體將如下所示:
// base.entity.ts
import { PrimaryGeneratedColumn, Column, UpdateDateColumn, CreateDateColumn } from'typeorm';
exportabstractclass BaseEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'boolean', default: true })
isActive: boolean;
@Column({ type: 'boolean', default: false })
isArchived: boolean;
@CreateDateColumn({ type: 'timestamptz', default: () =>'CURRENT_TIMESTAMP' })
createDateTime: Date;
@Column({ type: 'varchar', length: 300 })
createdBy: string;
@UpdateDateColumn({ type: 'timestamptz', default: () =>'CURRENT_TIMESTAMP' })
lastChangedDateTime: Date;
@Column({ type: 'varchar', length: 300 })
lastChangedBy: string;
@Column({ type: 'varchar', length: 300, nullable: true })
internalComment: string | null;
}
// item.entity.ts
import { Entity, Column } from'typeorm';
import { BaseEntity } from'./base.entity';
@Entity({ name: 'item' })
exportclass Item extends BaseEntity {
@Column({ type: 'varchar', length: 300 })
name: string;
@Column({ type: 'varchar', length: 300 })
description: string;
}
在 typeORM 文檔中查找更多支持的數據注釋。
讓我們啟動我們的 API,看看它是否有效。
npm run start:dev:db
npm run start:dev
實際上我們的數據庫并沒有立即反映我們的數據模型,TypeORM 能夠將您的數據模型同步到數據庫中的表中。數據模型自動同步很好,但也很危險。為什么?在前期開發中,您可能沒有把所有數據實體都整理清楚。因此,您在代碼中更改了實體類, typeORM 會為你自動同步字段, 但是,一旦您的數據庫中有實際數據,后期打算修改字段類型或其他操作時,TypeORM 將通過刪除并重新創建數據庫表來更改數據庫,這意味著你極有可能丟失了表內的數據。當然在生產環境中你應該避免這種意想不到情況發生。
這就是為什么我更喜歡從一開始就直接在代碼中處理數據庫遷移。
這也將幫助您和您的團隊更好地跟蹤和理解數據結構的變化,并迫使您更積極地思考這一點:怎樣做可以幫助您避免生產環境中的破壞性更改和數據丟失。
幸運的是 TypeORM 提供了一個解決方案和 CLI
命令,它為你處理生成 SQL 命令的任務。然后,您可以輕松驗證和測試這些,而無需在后臺使用任何黑魔法。
以下是如何設置 typeORM CLI 的最佳實踐。
我們已經在 ConfigService 中添加了所有必要的配置,但是 typeORM CLI 與 ormconfig.json 是同時生效的,所以我們希望與正式環境的 CLI 區分開來。添加一個腳本來編寫配置 json 文件并將其添加到我們的.gitignore -list:
import fs = require('fs');
fs.writeFileSync('ormconfig.json', JSON.stringify(configService.getTypeOrmConfig(), null, 2)
);
添加一個 npm 腳本任務來運行它以及 typeorm:migration:generate 和 typeorm:migration:run 的命令。
像這樣 ormconfig 將在運行 typeORM CLI 命令之前生成。
"pretypeorm": "(rm ormconfig.json || :) && ts-node -r tsconfig-paths/register src/scripts/write-type-orm-config.ts",
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"typeorm:migration:generate": "npm run typeorm -- migration:generate -n",
"typeorm:migration:run": "npm run typeorm -- migration:run"
現在我們可以運行這個命令來創建一個初始化遷移:
npm run typeorm:migration:generate -- my_init
這會將 typeORM 連接到您的數據庫并生成一個數據庫遷移腳本 my_init.ts(在 typescript 中)并將其放入您項目的遷移文件夾中。
注意:您應該將這些遷移腳本提交到您的源代碼管理中,并將這些文件視為只讀。
如果你想改變一些東西,想法是使用 CLI 命令在頂部添加另一個遷移。
npm run typeorm:migration:run
現在我們擁有了創建和運行遷移所需的所有工具,而無需運行 API 服務器項目,它在開發時為我們提供了很大的靈活性,我們可以隨時重新運行、重新創建和添加它們。然而,在生產或階段環境中,您實際上經常希望在部署之后 / 之后啟動 API 服務器之前自動運行遷移腳本。
為此,您只需添加一個 start.sh 腳本即可。
您還可以添加一個環境變量 RUN_MIGRATIONS=<0|1> 來控制遷移是否應該自動運行。
#!/bin/bash
設置 -e
設置 -x
如果 [ "$RUN_MIGRATIONS" ]; 然后
回顯“正在運行的遷移”;
npm run typeorm:migration:run
fi
回聲“啟動服務器”;
npm run start:prod
我們通過 API 完成同步數據庫字段工作 – 但我們的數據庫實際上反映了我們的數據模型嗎?
可以通過對 DB 運行一些 CLI 腳本查詢或使用 UI 數據庫管理工具進行快速調試來檢查這一點。
使用 PostgreSQL 數據庫時,我使用 pgAdmin。
這是一個非常強大的工具,有一個漂亮的用戶界面。但是,我建議您使用以下工作流程:
我們現在可以看到表在數據庫中創建。1. 我們在項目中定義的項目表。2. 一個遷移表,在這個表中 typeORM 跟蹤已經在這個數據庫上執行了哪個遷移。(注意:您也應該將此表視為只讀,否則 typeORM CLI 會混淆)
添加一些業務邏輯
現在讓我們添加一些業務邏輯。
為了演示,我將添加一個簡單的 endpoint,它將返回表中的數據。
我們使用 Nest.js CLI 添加一個項目控制器和一個項目服務。
nest -- generate controller item
nest -- generate service item
這將為我們生成一些模板,然后我們添加:
// item.service.ts
import { Injectable } from'@nestjs/common';
import { InjectRepository } from'@nestjs/typeorm';
import { Item } from'../model/item.entity';
import { Repository } from'typeorm';
@Injectable()
exportclass ItemService {
constructor(@InjectRepository(Item) private readonly repo: Repository<Item>) { }
publicasyncgetAll() {
returnawaitthis.repo.find();
}
}
// item.controller.ts
import { Controller, Get } from'@nestjs/common';
import { ItemService } from'./item.service';
@Controller('item')
exportclass ItemController {
constructor(private serv: ItemService) { }
@Get()
publicasyncgetAll() {
returnawaitthis.serv.getAll();
}
}
然后通過 ItemModule 連接在一起,然后在 AppModule 中導入。
// item.module.ts
import { Module } from'@nestjs/common';
import { TypeOrmModule } from'@nestjs/typeorm';
import { ItemService } from'./item.service';
import { ItemController } from'./item.controller';
import { Item } from'../model/item.entity';
@Module({
imports: [TypeOrmModule.forFeature([Item])],
providers: [ItemService],
controllers: [ItemController],
exports: []
})
exportclass ItemModule { }
啟動 API 后,curl 試試:
curl localhost:3000/item | jq
[] # << indicating no items in the DB - cool :)
不要通過您的 API 向消費者公開您在持久性上的實際數據模型。
當你用一個數據傳輸對象包裝每個數據實體時,你必須對它做序列化和反序列化。
在內部數據模型(API 到數據庫)和外部模型(API 消費者到 API)之間應該是有區別的。從長遠來看,這將幫助您解耦,令維護變得更容易。
@nestjs/swagger
、class-validator
和 class-transformer
。// item.dto.ts
import { ApiModelProperty } from'@nestjs/swagger';
import { IsString, IsUUID, } from'class-validator';
import { Item } from'../model/item.entity';
import { User } from'../user.decorator';
exportclass ItemDTO implements Readonly<ItemDTO> {
@ApiModelProperty({ required: true })
@IsUUID()
id: string;
@ApiModelProperty({ required: true })
@IsString()
name: string;
@ApiModelProperty({ required: true })
@IsString()
description: string;
publicstaticfrom(dto: Partial<ItemDTO>) {
const it = new ItemDTO();
it.id = dto.id;
it.name = dto.name;
it.description = dto.description;
return it;
}
publicstaticfromEntity(entity: Item) {
returnthis.from({
id: entity.id,
name: entity.name,
description: entity.description
});
}
publictoEntity(user: User = null) {
const it = new Item();
it.id = this.id;
it.name = this.name;
it.description = this.description;
it.createDateTime = newDate();
it.createdBy = user ? user.id : null;
it.lastChangedBy = user ? user.id : null;
return it;
}
}
現在我們可以像這樣簡單地使用 DTO:
// item.controller.ts
@Get()
publicasync getAll(): Promise<ItemDTO[]> {
returnawaitthis.serv.getAll()
}
@Post()
publicasync post(@User() user: User, @Body() dto: ItemDTO): Promise<ItemDTO> {
returnthis.serv.create(dto, user);
}
// item.service.ts
publicasync getAll(): Promise<ItemDTO[]> {
returnawaitthis.repo.find()
.then(items => items.map(e => ItemDTO.fromEntity(e)));
}
publicasync create(dto: ItemDTO, user: User): Promise<ItemDTO> {
returnthis.repo.save(dto.toEntity(user))
.then(e => ItemDTO.fromEntity(e));
}
設置 OpenAPI(Swagger)
DTO 方法還使您能夠從它們生成 API 文檔(openAPI aka swagger docs)。您只需安裝:
npm install --save @nestjs/swagger swagger-ui-express
并在 main.ts 中添加這幾行
// main.ts
asyncfunction bootstrap() {
const app = await NestFactory.create(AppModule);
if (!configService.isProduction()) {
constdocument = SwaggerModule.createDocument(app, new DocumentBuilder()
.setTitle('Item API')
.setDescription('My Item API')
.build());
SwaggerModule.setup('docs', app, document);
}
await app.listen(3000);
}
本文章轉載微信公眾號@騰訊IMWeb前端團隊