Nestjs最佳實踐教程:2基本資料操作

pincman1988發表於2022-07-10

另,本人在找工作中,希望能有遠端工作匹配(無法去外地),有需要的老闆可以看一下我的個人介紹:pincman.com/about

學習目標

  • 簡單地整合nestjs框架與typeorm
  • 實現基本的CRUD資料操作
  • 使用class-validator驗證請求資料
  • 更換更加快速的fastify介面卡
  • 使用Thunder Client對測試介面

安裝Mysql

實際生產環境中建議使用PostgreSQL,因為教程以學習為主,所以直接使用相對來說比較通用和簡單的Mysql

使用以下命令安裝Mysql

如果本機不是使用linux(比如使用wsl2),請到mysql官網點選download按鈕下載安裝包後在chrome檢視下載地址,然後在開發機用wget下載

如果本機使用MacOS,使用brew install mysql,如果本機使用Arch系列,使用sudo pacman -Syy mysql

# 下載映象包
cd /usr/local/src
sudo wget sudo wget https://repo.mysql.com/mysql-apt-config_0.8.22-1_all.deb
# 新增映象(其它選項不用管,直接OK就可以)
sudo apt-get install ./mysql-apt-config_0.8.22-1_all.deb
# 升級包列表
sudo apt-get update
# 開始安裝,輸入密碼後,有一個密碼驗證方式,因為是開發用,所以選擇第二個弱驗證即可
sudo apt-get install mysql-server 
# 初始化,在是否載入驗證元件時選擇No,在是否禁用遠端登入時也選擇No
sudo mysql_secure_installation
# 因為是遠端SSH連線開發所以需要開啟遠端資料庫連結,如果是本地或者wsl2則不需要開啟
mysql -u root -p 
CREATE USER 'root'@'%' IDENTIFIED BY '密碼';
GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;

接著使用Navicat等客戶端就可以連線了

預裝依賴

~ pnpm add class-transformer \
  @nestjs/platform-fastify \
  class-validator \
  lodash \
  @nestjs/swagger \
  fastify-swagger \
  mysql2 \
  typeorm \
  @nestjs/typeorm

 ~ pnpm add @types/lodash cross-env @types/node typescript -D

生命週期

要合理的編寫應用必須事先了解清楚整個程式的訪問流程,本教程會講解如何一步步演進每一次訪問流,作為第一步課時,我們的訪問流非常簡單,可以參考下圖

檔案結構

我們透過整合typeorm來連線mysql實現一個基本的CRUD應用,首先我們需要建立一下檔案結構

建議初學者手動建立,沒必要使用CLI去建立,這樣目錄和檔案更加清晰

  1. 建立模組
  2. 編寫模型
  3. 編寫Repository(如果有需要的話)
  4. 編寫資料驗證的DTO
  5. 編寫服務
  6. 編寫控制器
  7. 在每個以上程式碼各自的目錄下建立一個index.ts並匯出它們
  8. 在各自的Module裡進行註冊提供者,匯出等
  9. AppModule中匯入這兩個模組

編寫好之後的目錄結構如下

.
├── app.module.ts                           # 引導模組           
├── config                                  # 配置檔案目錄
│   ├── database.config.ts                  # 資料庫配置
│   └── index.ts
├── main.ts                                 # 應用啟動器
├── modules
    ├── content                             # 內容模組目錄
    │   ├── content.module.ts               # 內容模組
    │   ├── controllers                     # 控制器
    │   ├── dtos                            # DTO訪問資料驗證
    │   ├── entities                        # 資料實體模型
    |   ├── index.ts              
    │   ├── repositories                    # 自定義Repository
    │   ├── services                        # 服務
    └──  core
        ├── constants.ts                    # 常量
        ├── core.module.ts                  # 核心模組
        ├── decorators                      # 裝飾器
        └── types.ts                        # 公共型別

應用編碼

在開始編碼之前需要先更改一下package.jsonnestjs-cli.json兩個檔案

package.json中修改一下啟動命令,以便每次啟動可以自動配置執行環境併相容windows環境

"prebuild": "cross-env rimraf dist",
"start": "cross-env NODE_ENV=development nest start",
"start:dev": "cross-env NODE_ENV=development nest start --watch",
"start:debug": "cross-env NODE_ENV=development nest start --debug --watch",
"start:prod": "cross-env NODE_ENV=production node dist/main",

為了在每次重新編譯前自動刪除上次的產出,在nestjs-cli.json中配置"deleteOutDir": true

main.ts

把介面卡由express換成更快的fastify,並把監聽的IP改成0.0.0.0方便外部訪問.為了在使用class-validatorDTO類中也可以注入nestjs容器的依賴,需要新增useContainer

// main.ts
import { NestFactory } from '@nestjs/core';
import {
  FastifyAdapter,
  NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { useContainer } from 'class-validator';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter()
  );
  useContainer(app.select(AppModule), { fallbackOnErrors: true });
  await app.listen(3000,'0.0.0.0');
}
bootstrap();

連線配置

建立一個src/config/database.config.ts檔案

export const database: () => TypeOrmModuleOptions = () => ({
    // ...
    // 此處entites設定為空即可,我們直接透過在模組內部使用`forFeature`來註冊模型
    // 後續魔改框架的時候,我們會透過自定義的模組建立函式來重置entities,以便給自己編寫的CLI使用
    // 所以這個配置後面會刪除
    entities: [], 
    // 自動載入模組中註冊的entity
    autoLoadEntities: true,
    // 可以在開發環境下同步entity的資料結構到資料庫
    // 後面教程會使用自定義的遷移命令來代替,以便在生產環境中使用,所以以後這個選項會永久false
    synchronize: process.env.NODE_ENV !== 'production',
});

CoreModule

核心模組用於掛載一些全域性類服務,比如整合typeorm的``TypeormModule`

注意: 這裡不要使用@Global()裝飾器來構建全域性模組,因為後面在CoreModule類中新增一些其它方法

返回值中新增global: true來註冊全域性模組,並匯出metadata.

// src/core/core.module.ts
export class CoreModule {
    public static forRoot(options?: TypeOrmModuleOptions) {
        const imports: ModuleMetadata['imports'] = [TypeOrmModule.forRoot(options)];
        return {
            global: true,
            imports,
            module: CoreModule,
        };
    }
}

AppModule匯入該模組,並註冊資料庫連線

// src/app.module.ts
@Module({
    imports: [CoreModule.forRoot(database())],
  ...
})
export class AppModule {}

自定義儲存類

由於原來用於自定義Repository的@EntityRepositorytypeorm0.3版本後已經不可用,特別不方便,所以根據這裡的示例來自定義一個CustomRepository裝飾器

// src/modules/core/constants.ts
// 傳入裝飾器的metadata資料標識
export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA';

// src/modules/core/decorators/repository.decorator.ts
// 定義裝飾器
import { CUSTOM_REPOSITORY_METADATA } from '../constants';
export const CustomRepository = <T>(entity: ObjectType<T>): ClassDecorator =>
    SetMetadata(CUSTOM_REPOSITORY_METADATA, entity);

// src/modules/core/decorators/index.ts
export * from './repository.decorator';

定義靜態方法用於註冊自定義Repository

 public static forRepository<T extends Type<any>>(
        repositories: T[],
        dataSourceName?: string,
    ): DynamicModule {
        const providers: Provider[] = [];

        for (const Repo of repositories) {
            const entity = Reflect.getMetadata(CUSTOM_REPOSITORY_METADATA, Repo);

            if (!entity) {
                continue;
            }

            providers.push({
                inject: [getDataSourceToken(dataSourceName)],
                provide: Repo,
                useFactory: (dataSource: DataSource): typeof Repo => {
                    const base = dataSource.getRepository<ObjectType<any>>(entity);
                    return new Repo(base.target, base.manager, base.queryRunner);
                },
            });
        }

        return {
            exports: providers,
            module: CoreModule,
            providers,
        };
    }

ContentModule

內容模組用於存放CRUD操作的邏輯程式碼

// src/modules/content/content.module.ts
@Module({})
export class ContentModule {}

AppModule中註冊

// src/app.module.ts
@Module({
    imports: [CoreModule.forRoot(database()),ContentModule],
  ...
})
export class AppModule {}

實體模型

建立一個PostEntity用於文章資料表

PostEntity繼承``BaseEntity,這樣做是為了我們可以進行ActiveRecord操作,例如PostEntity.save(post),因為純DataMapper`的方式有時候程式碼會顯得囉嗦,具體請檢視此處

@CreateDateColumn@UpdateDateColumn是自動欄位,會根據建立和更新資料的時間自動產生,寫入後不必關注

// src/modules/content/entities/post.entity.ts
// 'content_posts'是表名稱
@Entity('content_posts')
export class PostEntity extends BaseEntity {
...
    @CreateDateColumn({
        comment: '建立時間',
    })
    createdAt!: Date;

    @UpdateDateColumn({
        comment: '更新時間',
    })
    updatedAt!: Date;
}

儲存類

本節儲存類是一個空類,後面會新增各種操作方法

這裡用到我們前面定義的自定義CustomRepository裝飾器

// src/modules/content/repositories/post.repository.ts
@CustomRepository(PostEntity)
export class PostRepository extends Repository<PostEntity> {}

註冊模型和儲存類

在編寫好entityrepository之後我們還需要透過Typeorm.forFeature這個靜態方法進行註冊,並把儲存類匯出為提供者以便在其它模組注入

// src/modules/content/content.module.ts
@Module({
    imports: [
        TypeOrmModule.forFeature([PostEntity]),
        // 註冊自定義Repository
        CoreModule.forRepository([PostRepository]),
    ],
     exports: [
        // 匯出自定義Repository,以供其它模組使用
        CoreModule.forRepository([PostRepository]),
    ],
})
export class ContentModule {}

DTO驗證

DTO配合管道(PIPE)用於控制器的資料驗證,驗證器則使用class-validator

class-validator是基於validator.js的封裝,所以一些規則可以透過validator.js的文件查詢,後面教程中我們會編寫大量的自定義的驗證規則,這節先嚐試基本的用法

其基本的使用方法就是給DTO類的屬性新增一個驗證裝飾器,如下

groups選項用於配置驗證組

// src/modules/content/dtos/create-post.dto.ts
@Injectable()
export class CreatePostDto {
    @MaxLength(255, {
        always: true,
        message: '文章標題長度最大為$constraint1',
    })
    @IsNotEmpty({ groups: ['create'], message: '文章標題必須填寫' })
    @IsOptional({ groups: ['update'] })
    title!: string;
    ...
}

更新驗證類UpdatePostDto繼承自CreatePostDto,為了使CreatePostDto中的屬性變成可選,需要使用[@nestjs/swagger][]包中的PartialType方法,請查閱此處文件

// src/modules/content/dtos/update-post.dto.ts
@Injectable()
export class UpdatePostDto extends PartialType(CreatePostDto) {
    @IsUUID(undefined, { groups: ['update'], message: '文章ID格式錯誤' })
    @IsDefined({ groups: ['update'], message: '文章ID必須指定' })
    id!: string;
}

服務類

服務一共包括5個簡單的方法,透過呼叫PostRepository來運算元據

// src/modules/content/services/post.service.ts
@Injectable()
export class PostService {
    // 此處需要注入`PostRepository`的依賴
    constructor(private postRepository: PostRepository) {}
    // 查詢文章列表
    async findList() 
    // 查詢一篇文章的詳細資訊
    async findOne(id: string)
    // 新增文章
    async create(data: CreatePostDto)
    // 更新文章
    async update(data: UpdatePostDto)
    // 刪除文章
    async delete(id: string)
}

控制器

控制器的方法透過@GET,@POST,@PUT,@PATCH,@Delete等裝飾器對外提供介面,並且透過注入PostService服務來運算元據.在控制器的方法上使用框架自帶的ValidationPipe管道來驗證請求中的body資料,ParseUUIDPipe來驗證params資料

// 控制器URL的字首
@Controller('posts')
export class PostController {
    constructor(protected postService: PostService) {}

    ...
   // 其它方法請自行檢視原始碼
    @Get(':post')
    async show(@Param('post', new ParseUUIDPipe()) post: string) {
        return this.postService.findOne(post);
    }

    @Post()
    async store(
        @Body(
            new ValidationPipe({
                transform: true,
                forbidUnknownValues: true,
                // 不在錯誤中暴露target
                validationError: { target: false },
                groups: ['create'],
            }),
        )
        data: CreatePostDto,
    ) {
        return this.postService.create(data);
    }
}

註冊控制器等

  • 為了後面``DTO中可能會匯入服務,需要把DTO,同樣註冊為提供者並且改造一下main.ts,把容器加入到class-containter`中
  • PostService服務可能後續會被UserModule等其它模組使用,所以此處我們也直接匯出
// src/modules/content/content.module.ts
@Module({
    imports: [
        TypeOrmModule.forFeature([PostEntity]),
        // 註冊自定義Repository
        CoreModule.forRepository([PostRepository]),
    ],
    providers: [PostService, CreatePostDto, UpdatePostDto],
    controllers: [PostController],
    exports: [
        PostService,
        // 匯出自定義Repository,以供其它模組使用
        CoreModule.forRepository([PostRepository]),
    ],
})
export class ContentModule {}
// src/main.ts
...
async function bootstrap() {
    const app = await NestFactory.create<NestFastifyApplication>(
        AppModule,
        new FastifyAdapter(),
    );
    useContainer(app.select(AppModule), { fallbackOnErrors: true });
    await app.listen(3000, '0.0.0.0');
}

最後啟動應用在Thunder Client中測試介面

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章