Nestjs最佳實踐教程(六): 簡化程式碼與自定義約束

pincman1988發表於2022-09-01

注意: 此處文件只起配合作用,為了您的身心愉悅,請看B站影片教程,不要直接略過影片直接看這個文件,這樣你將什麼都看不到?

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

學習目標

  • 學會抽象程式碼,減少重複工作
  • 自定義驗證約束,支援資料庫驗證

檔案結構

本節內容仍然主要聚焦於CoreModule

src/modules/core
├── constants.ts
├── constraints
│   ├── index.ts
│   ├── match.constraint.ts
│   ├── match.phone.constraint.ts
│   ├── model.exist.constraint.ts
│   ├── password.constraint.ts
│   ├── tree.unique.constraint.ts
│   ├── tree.unique.exist.constraint.ts
│   ├── unique.constraint.ts
│   └── unique.exist.constraint.ts
├── core.module.ts
├── crud
│   ├── index.ts
│   ├── repository.ts
│   ├── subscriber.ts
│   └── tree.repository.ts
├── decorators
│   ├── dto-validation.decorator.ts
│   ├── index.ts
│   └── repository.decorator.ts
├── filters
│   ├── index.ts
│   └── optional.uuid.pipe.ts
├── helpers.ts
├── providers
│   ├── app.filter.ts
│   ├── app.interceptor.ts
│   ├── app.pipe.ts
│   └── index.ts
└── types.ts

應用編碼

驗證約束

自定義驗證約束的規則請看這裡

  • IsMatch: 判斷兩個欄位的值是否相等的驗證規則
  • isMatchPhone:手機號驗證規則,必須是”區域號.手機號”的形式
  • IsPassword: 密碼複雜度驗證,提供5種規則並且可自行新增規則
  • IsModelExist: 查詢某個欄位的值的記錄是否在某張資料表中存在
  • IsUnique: 驗證某個欄位的唯一性
  • IsUniqueExist: 在更新時驗證唯一性,透過指定ignore忽略忽略的欄位
  • IsTreeUnique: 驗證樹形模型下同級別某個欄位的唯一性
  • IsTreeUniqueExist: 在更新時驗證樹形資料同級別某個欄位的唯一性,透過ignore指定忽略的欄位

自定義約束類

  • 對於需要使用容器來注入依賴的約束需要新增上@Injectable裝飾器(比如需要注入DataSource來訪問資料庫連線)
  • 對於需要非同步驗證的約束請在@ValidatorConstraint中設定asynctrue(name選項隨意填或者不填),並且在validate方法前加上async
  • validate中編寫驗證邏輯,其中value是驗證欄位的值,args是驗證引數(比如args.constraints為驗證條件陣列,args.object為當前驗證類的物件),具體屬性請檢視ValidationArguments型別,validate返回一個布林值代表是否驗證成功
  • defaultMessage方法用於定義驗證失敗後預設響應的錯誤資訊,如果在驗證屬性上傳入自定義的錯誤資訊則會覆蓋

自定義約束裝飾器

  • 構造一個裝飾器工廠函式,其引數除了最後一項必須為ValidationOptions的自定義選項外,前面的引數作為驗證條件陣列被放入args.constraints中,validationOptions用於設定驗證組和覆蓋預設錯誤資訊以及是否each等選項
  • 工廠所返回的裝飾器函式可以獲取兩個引數,object是驗證類本身,透過object.contsturctor可獲取當前驗證類的例項,繫結target屬性後會賦值給validateargs.objectpropertyName即為當前驗證屬性的名稱

一個自定義約束裝飾器的大致程式碼結構如下

@Injectable()
@ValidatorConstraint({ name: 'Demo', async: true })
export class DemoConstraint implements ValidatorConstraintInterface {
    constructor(private dataSource: DataSource) {}

    async validate(value: any, args: ValidationArguments): Promise<boolean>

    defaultMessage(args: ValidationArguments):string {
        return `default error message`;
    }
}

export function IsDemo(...params:any[],validationOptions?: ValidationOptions) {
    return (object: Record<string, any>, propertyName: string) => {
        registerDecorator({
            target: object.constructor,
            propertyName,
            options: validationOptions,
            constraints: [params],
            validator: UniqueTreeExistConstraint,
        });
    };
}

示例(以IsUnique為例)

// src/modules/core/constraints/unique.constraint.ts
@ValidatorConstraint({ name: 'entityItemUnique', async: true })
@Injectable()
export class UniqueConstraint implements ValidatorConstraintInterface {
    constructor(private dataSource: DataSource) {}

    async validate(value: any, args: ValidationArguments) {
        // 獲取要驗證的模型和欄位
        const config: Omit<Condition, 'entity'> = {
            property: args.property,
        };
        const condition = ('entity' in args.constraints[0]
            ? merge(config, args.constraints[0])
            : {
                  ...config,
                  entity: args.constraints[0],
              }) as unknown as Required<Condition>;
        if (!condition.entity) return false;
        try {
            // 查詢是否存在資料,如果已經存在則驗證失敗
            const repo = this.dataSource.getRepository(condition.entity);
            return isNil(await repo.findOne({ where: { [condition.property]: value } }));
        } catch (err) {
            // 如果資料庫操作異常則驗證失敗
            return false;
        }
    }

    defaultMessage(args: ValidationArguments) {
        const { entity, property } = args.constraints[0];
        const queryProperty = property ?? args.property;
        if (!(args.object as any).getManager) {
            return 'getManager function not been found!';
        }
        if (!entity) {
            return 'Model not been specified!';
        }
        return `${queryProperty} of ${entity.name} must been unique!`;
    }
}

export function IsUnique(
    params: ObjectType<any> | Condition,
    validationOptions?: ValidationOptions,
) {
    return (object: Record<string, any>, propertyName: string) => {
        registerDecorator({
            target: object.constructor,
            propertyName,
            options: validationOptions,
            constraints: [params],
            validator: UniqueConstraint,
        });
    };
}

如果是有依賴注入的提供者約束,需要在CoreModule中註冊

   // src/modules/core/core.module.ts
    public static forRoot(options?: TypeOrmModuleOptions): DynamicModule {
        // ...
        const providers: ModuleMetadata['providers'] = [
            ModelExistConstraint,
            UniqueConstraint,
            UniqueExistContraint,
            UniqueTreeConstraint,
            UniqueTreeExistConstraint,
        ];
        return {
            global: true,
            imports,
            providers,
            module: CoreModule,
        };
    }

抽象基類

為了簡化程式碼以及後續課程中實現自定義CRUD庫,需要編寫一些基礎類

BaseRepository

這是一個通用的基礎儲存類,在實現此類之前先新增如下型別和常量

// src/modules/core/constants.ts
/**
 * 排序方式
 */
export enum OrderType {
    ASC = 'ASC',
    DESC = 'DESC',
}

// src/modules/core/types.ts
/**
 * 排序型別,{欄位名稱: 排序方法}
 * 如果多個值則傳入陣列即可
 * 排序方法不設定,預設DESC
 */
export type OrderQueryType =
    | string
    | { name: string; order: `${OrderType}` }
    | Array<{ name: string; order: `${OrderType}` } | string>;

此類繼承自自帶的Repository

  • queryName屬性是一個抽象屬性,在子類中設定,用於在構建查詢時提供預設模型的查詢名稱
  • orderBy屬性用於設定預設排序規則,可以透過每個方法的orderBy選項進行覆蓋
  • buildBaseQuery方法用於構建基礎查詢
  • getQueryName方法用於獲取queryName
  • getOrderByQuery根據orderBy屬性生成排序的querybuilder,如果傳入orderBy則覆蓋this.orderBy屬性
// src/core/base/repository.ts
export abstract class BaseRepository<E extends ObjectLiteral> extends Repository<E> {
    protected abstract qbName: string;
    protected orderBy?: string | { name: string; order: `${OrderType}` };
    buildBaseQuery(): SelectQueryBuilder<E>
    getQBName()
    protected getOrderByQuery(qb: SelectQueryBuilder<E>, orderBy?: OrderQueryType): SelectQueryBuilder<E>
}

TreeRepository

預設的TreeRepository基類的方法如findRoots等無法在QueryBuilder中實現排序,自定義query函式等,所以建立一個繼承自預設基類的新的TreeRepository來實現

在實現此類之前先新增如下型別

// src/core/types.ts
/**
 * 樹形資料表查詢引數
 */
export type TreeQueryParams<E extends ObjectLiteral> = FindTreeOptions & QueryParams<E>;

TreeRepository包含BaseRepositoryqueryName等所有屬性和方法

其餘屬性及方法列如下

如果params中不傳orderBy則使用this.orderBy屬性

  • findTrees: 過載方法,為樹查詢更改查詢引數型別(如新增排序等)
  • findRoots: 過載方法,為頂級查詢更改查詢引數型別(如新增排序和分頁等)
  • findDescendants: 過載方法,為後代列表查詢更改查詢引數型別(如新增排序等)
  • findDescendantsTree:過載方法,為後代樹查詢更改查詢引數型別(如新增排序等)
  • countDescendants: 過載方法,為後代數量查詢更改查詢引數型別(如後續課程的軟刪除等)
  • createDtsQueryBuilder: 為createDescendantsQueryBuilder新增條件引數
  • findAncestors等祖先查詢方法與後代你查詢的方法類似,都是為對應的原方法新增條件查詢引數
  • toFlatTrees: 打平並展開樹
// src/modules/core/crud/tree.repository.ts
export class BaseTreeRepository<E extends ObjectLiteral> extends TreeRepository<E> {
    protected qbName = 'treeEntity';
    protected orderBy?: string | { name: string; order: `${OrderType}` };
    constructor(target: EntityTarget<E>, manager: EntityManager, queryRunner?: QueryRunner)
    buildBaseQuery(): SelectQueryBuilder<E>
    getQBName()
    protected getOrderByQuery(qb: SelectQueryBuilder<E>, orderBy?: OrderQueryType)
    async findTrees(params: TreeQueryParams<E> = {}): Promise<E[]>
    findRoots(params: TreeQueryParams<E> = {}): Promise<E[]>
    findDescendants(entity: E, params: TreeQueryParams<E> = {}): Promise<E[]>
    async findDescendantsTree(entity: E, params: TreeQueryParams<E> = {}): Promise<E>
    countDescendants(entity: E, params: TreeQueryParams<E> = {}): Promise<number>
    createDtsQueryBuilder(
        closureTableAlias: string,
        entity: E,
        params: TreeQueryParams<E> = {},
    ): SelectQueryBuilder<E>
    findAncestors(entity: E, params: TreeQueryParams<E> = {}): Promise<E[]>
    async findAncestorsTree(entity: E, params: TreeQueryParams<E> = {}): Promise<E>
    countAncestors(entity: E, params: TreeQueryParams<E> = {}): Promise<number>
    createAtsQueryBuilder(
        closureTableAlias: string,
        entity: E,
        params: TreeQueryParams<E> = {},
    ): SelectQueryBuilder<E>
    async toFlatTrees(trees: E[], level = 0): Promise<E[]>
}

BaseSubscriber

這是一個基礎的模型觀察者,在其中新增一些屬性和方法可以減少在編寫觀察者時的額外程式碼

新增一個SubcriberSetting型別用於設定一些必要的屬性(這節課程只用於設定是否為樹形模型)

// src/modules/core/types.ts
export type SubcriberSetting = {
    tree?: boolean;
};

在建構函式中根據傳入的引數設定連線,並在連線中加入當前訂閱者,以及構建預設的repository

這個類比較簡單,直接列出程式碼結構

實現如下

// src/core/base/subscriber.ts
@EventSubscriber()
export abstract class BaseSubscriber<E extends ObjectLiteral>
    implements EntitySubscriberInterface<E>
{
    /**
     * @description 資料庫連線
     * @protected
     * @type {Connection}
     */
    protected dataSource: DataSource;

    /**
     * @description EntityManager
     * @protected
     * @type {EntityManager}
     */
    protected em!: EntityManager;

    /**
     * @description 監聽的模型
     * @protected
     * @abstract
     * @type {ObjectType<E>}
     */
    protected abstract entity: ObjectType<E>;

    /**
     * @description 自定義儲存類
     * @protected
     * @type {Type<SubscriberRepo<E>>}
     */
    protected repository?: SubscriberRepo<E>;

    /**
     * @description 一些相關的設定
     * @protected
     * @type {SubcriberSetting}
     */
    protected setting!: SubcriberSetting;

    constructor(dataSource: DataSource, repository?: SubscriberRepo<E>) {
        this.dataSource = dataSource;
        this.dataSource.subscribers.push(this);
        this.setRepository(repository);
        if (!this.setting) this.setting = {};
    }

    listenTo() {
        return this.entity;
    }

    async afterLoad(entity: any) {
        // 是否啟用樹形
        if (this.setting.tree && isNil(entity.level)) entity.level = 0;
    }

    protected setRepository(repository?: SubscriberRepo<E>) {
        this.repository = isNil(repository)
            ? this.dataSource.getRepository(this.entity)
            : repository;
    }

    /**
     * @description 判斷某個屬性是否被更新
     * @protected
     * @param {keyof E} cloumn
     * @param {UpdateEvent<E>} event
     */
    protected isUpdated(cloumn: keyof E, event: UpdateEvent<E>) {
        return !!event.updatedColumns.find((item) => item.propertyName === cloumn);
    }
}

修改應用

模型觀察者

使CategorySubscriberPostSubscriber分別繼承BaseSubscriber,以CategorySubscriber為例,如下

CategoryEntity是一個樹形模型,所以需要在設定中新增tree

// src/modules/content/subscribers/category.subscriber.ts
@EventSubscriber()
export class CategorySubscriber extends BaseSubscriber<CategoryEntity> {
    protected entity = CategoryEntity;

    protected setting: SubcriberSetting = {
        tree: true,
    };

    constructor(
        protected dataSource: DataSource,
        protected categoryRepository: CategoryRepository,
    ) {
        super(dataSource, categoryRepository);
    }
}

儲存類

使CategoryRepositoryCommentRepository繼承BaseTreeRepository,使PostRepository繼承BaseRepository,並按需更改程式碼,以CommentRepository為例,如下

// src/modules/content/repositories/comment.repository.ts
@CustomRepository(CommentEntity)
export class CommentRepository extends BaseTreeRepository<CommentEntity> {
    protected qbName = 'comment';

    protected orderBy = 'createdAt';

    buildBaseQuery(): SelectQueryBuilder<CommentEntity> {
        return this.createQueryBuilder(this.qbName)
            .leftJoinAndSelect(`${this.getQBName()}.parent`, 'parent')
            .leftJoinAndSelect(`${this.qbName}.post`, 'post');
    }

    async findTrees(
        params: TreeQueryParams<CommentEntity> & { post?: string } = {},
    ): Promise<CommentEntity[]> {
        return super.findTrees({
            ...params,
            addQuery: (qb) => {
                return isNil(params.post) ? qb : qb.where('post.id = :id', { id: params.post });
            },
        });
    }
}

新增約束

為了程式碼清晰,需要拆分原本的post.dto.ts,category.dto.ts以及comment.dto.ts等,按各自功能每個檔案對應一個類,並新增上我們的自定義約束裝飾器

CreateCategoryDto為例

//     src/modules/content/dtos/create-category.dto.ts
@Injectable()
@DtoValidation({ groups: ['create'] })
export class CreateCategoryDto {
    @IsTreeUnique(
        { entity: CategoryEntity },
        {
            groups: ['create'],
            message: '分類名稱重複',
        },
    )
    @IsTreeUniqueExist(
        { entity: CategoryEntity },
        {
            groups: ['update'],
            message: '分類名稱重複',
        },
    )
    @MaxLength(25, {
        always: true,
        message: '分類名稱長度不得超過$constraint1',
    })
    @IsNotEmpty({ groups: ['create'], message: '分類名稱不得為空' })
    @IsOptional({ groups: ['update'] })
    name!: string;

    @IsModelExist(CategoryEntity, { always: true, message: '父分類不存在' })
    @IsUUID(undefined, { always: true, message: '父分類ID格式不正確' })
    @ValidateIf((value) => value.parent !== null && value.parent)
    @IsOptional({ always: true })
    @Transform(({ value }) => (value === 'null' ? null : value))
    parent?: string;

    @Transform(({ value }) => tNumber(value))
    @IsNumber(undefined, { message: '排序必須為整數' })
    @IsOptional({ always: true })
    customOrder?: number;
}

最後在dtos/index.ts中重新匯入拆分後的檔案

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

相關文章