- 教程地址: pincman.com
- 影片地址: pincman.com/docs/courses/nestjs-pr...
- qq: 1849600177
- qq 群: 455820533
注意: 此處文件只起配合作用,為了您的身心愉悅,請看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
中設定async
為true
(name
選項隨意填或者不填),並且在validate
方法前加上async
validate
中編寫驗證邏輯,其中value
是驗證欄位的值,args
是驗證引數(比如args.constraints
為驗證條件陣列,args.object
為當前驗證類的物件),具體屬性請檢視ValidationArguments
型別,validate
返回一個布林值代表是否驗證成功defaultMessage
方法用於定義驗證失敗後預設響應的錯誤資訊,如果在驗證屬性上傳入自定義的錯誤資訊則會覆蓋
自定義約束裝飾器
- 構造一個裝飾器工廠函式,其引數除了最後一項必須為
ValidationOptions
的自定義選項外,前面的引數作為驗證條件陣列被放入args.constraints
中,validationOptions
用於設定驗證組和覆蓋預設錯誤資訊以及是否each
等選項 - 工廠所返回的裝飾器函式可以獲取兩個引數,
object
是驗證類本身,透過object.contsturctor
可獲取當前驗證類的例項,繫結target
屬性後會賦值給validate
的args.object
,propertyName
即為當前驗證屬性的名稱
一個自定義約束裝飾器的大致程式碼結構如下
@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
包含BaseRepository
的queryName
等所有屬性和方法
其餘屬性及方法列如下
如果
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);
}
}
修改應用
模型觀察者
使CategorySubscriber
和PostSubscriber
分別繼承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);
}
}
儲存類
使CategoryRepository
和CommentRepository
繼承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 協議》,轉載必須註明作者和本文連結