Nestjs最佳實踐教程(八): CRUD抽象化框架構建

pincman1988發表於2022-09-01

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

檢視上一節的程式碼我們會發現有很多重複性的CRUD方法在複製貼上,比如CategoryServicePostServiceCategoryControllerPostController等。所以本節的目標是對這些CRUD類進行抽象化

  • 實現CRUD的Service基類
  • 實現CRUD的Controller基類

核心模組

服務基類

  • repository屬性為預設的儲存類,透過子類的constructor注入來賦值,必須繼承自BaseRepositoryBaseTreeRepository
  • enabl_trash屬性用於確定是否包含軟刪除操作
  • list用於獲取資料列表
  • paginate用於獲取分頁資料
  • detail用於獲取資料詳情
  • creatte用於建立資料(注意:此方法由子類實現,子類不實現則呼叫時丟擲403
  • update方法與create一樣由子類實現
  • delete用於刪除單條資料
  • deleteList用於批次刪除資料
  • deletePaginatedeleteList的區別在於刪除資料後返回的列表是分頁資料
  • restore,restoreList,restorePaginate用於恢復資料,返回的列表與刪除雷同
  • buildItemQuery用於構建單條資料的查詢器
  • buildListQuery用於構建資料列表的查詢器
// src/modules/core/crud/service.ts
export abstract class BaseService<
    E extends ObjectLiteral,
    R extends BaseRepository<E> | BaseTreeRepository<E>,
    P extends QueryListParams<E> = QueryListParams<E>,
    M extends IPaginationMeta = IPaginationMeta,
> {
    protected repository: R;
    protected enable_trash = false;

    constructor(repository: R) {
        this.repository = repository;
        if (
            !(
                this.repository instanceof BaseRepository ||
                this.repository instanceof BaseTreeRepository
            )
        ) {
            throw new Error(
                'Repository must instance of BaseRepository or BaseTreeRepository in DataService!',
            );
        }
    }

    async list(params?: P, callback?: QueryHook<E>): Promise<E[]>

    async paginate(
        options: PaginateDto<M> & P,
        callback?: QueryHook<E>,
    ): Promise<Pagination<E, M>>


    async detail(id: string, trashed?: boolean, callback?: QueryHook<E>): Promise<E>


    create(data: any): Promise<E>


    update(data: any): Promise<E> 

    async delete(id: string, trash = true)

    async deleteList(data: string[], params?: P, trash?: boolean, callback?: QueryHook<E>)

    async deletePaginate(
        data: string[],
        options: PaginateDto<M> & P,
        trash?: boolean,
        callback?: QueryHook<E>,
    )

    async restore(id: string, callback?: QueryHook<E>)

    async restoreList(data: string[], params?: P, callback?: QueryHook<E>)

    async restorePaginate(data: string[], options: PaginateDto<M> & P, callback?: QueryHook<E>)

    protected async buildItemQuery(query: SelectQueryBuilder<E>, callback?: QueryHook<E>)

    protected async buildListQuery(qb: SelectQueryBuilder<E>, options: P, callback?: QueryHook<E>)
}

控制器

控制器基類

// src/modules/core/crud/controller.ts
export abstract class BaseController<
    S,
    P extends QueryListParams<any> = QueryListParams<any>,
    M extends IPaginationMeta = IPaginationMeta,
> {
    protected service: S;

    constructor(service: S) {
        this.setService(service);
    }

    private setService(service: S) {
        this.service = service;
    }

    @Get()
    async list(@Query() options: PaginateDto<M> & P & TrashedDto, ...args: any[]) {
        return (this.service as any).paginate(options);
    }

    @Get(':item')
    async detail(
        @Query() { trashed }: QueryDetailDto,
        @Param('item', new ParseUUIDPipe())
        item: string,
        ...args: any[]
    ) {
        return (this.service as any).detail(item, trashed);
    }

    @Post()
    async store(
        @Body()
        data: any,
        ...args: any[]
    ) {
        return (this.service as any).create(data);
    }

    @Patch()
    async update(
        @Body()
        data: any,
        ...args: any[]
    ) {
        return (this.service as any).update(data);
    }

    @Delete(':item')
    async delete(
        @Param('item', new ParseUUIDPipe())
        item: string,
        @Body()
        { trash }: DeleteDto,
        ...args: any[]
    ) {
        return (this.service as any).delete(item, trash);
    }

    @Delete()
    async deleteMulti(
        @Query()
        options: PaginateDto<M> & TrashedDto & P,
        @Body()
        { trash, items }: DeleteMultiDto,
        ...args: any[]
    ) {
        return (this.service as any).deletePaginate(items, options, trash);
    }

    @Patch('restore/:item')
    async restore(
        @Param('item', new ParseUUIDPipe())
        item: string,
        ...args: any[]
    ) {
        return (this.service as any).restore(item);
    }

    @Patch('restore')
    async restoreMulti(
        @Query()
        options: PaginateDto<M> & TrashedDto & P,
        @Body()
        { items }: DeleteRestoreDto,
        ...args: any[]
    ) {
        return (this.service as any).restorePaginate(items, options);
    }
}

由於控制器的方法和引數中用到一些裝飾器,比如繫結DTO的@Query,序列化資料的@SerializeOptions等等,所以新增一個裝飾器,並使用metadata來儲存傳入這些裝飾器的資料

我們把這個命名為Crud,其作用如下

  • 為控制器的方法新增上DTO
  • 為方法的響應新增上序列化選項
  • 對於沒有在enabled啟用的方法丟擲404

在編寫裝飾器之前需要先定義一下要用到的型別

// src/modules/core/types.ts

/**
 * CURD控制器方法列表
 */
export type CurdMethod =
    | 'detail'
    | 'delete'
    | 'restore'
    | 'list'
    | 'store'
    | 'update'
    | 'deleteMulti'
    | 'restoreMulti';

/**
 * CRUD裝飾器的方法選項
 */
export interface CrudMethodOption {
    /**
     * 該方法是否允許匿名訪問
     */
    allowGuest?: boolean;
    /**
     * 序列化選項,如果為`noGroup`則不傳引數,否則根據`id`+方法匹配來傳參
     */
    serialize?: ClassTransformOptions | 'noGroup';
}
/**
 * 每個啟用方法的配置
 */
export interface CurdItem {
    name: CurdMethod;
    option?: CrudMethodOption;
}

/**
 * CRUD裝飾器選項
 */
export interface CurdOptions {
    id: string;
    // 需要啟用的方法
    enabled: Array<CurdMethod | CurdItem>;
    // 一些方法要使用到的自定義DTO
    dtos: {
        [key in 'query' | 'create' | 'update']?: Type<any>;
    };
}

裝飾器程式碼

// src/modules/core/decorators/crud.decorator.ts
export const Crud =
    (options: CurdOptions) =>
    <T extends BaseController<any>>(Target: Type<T>) => {
        Reflect.defineMetadata(CRUD_OPTIONS, options, Target);
        const { id, enabled, dtos } = Reflect.getMetadata(CRUD_OPTIONS, Target) as CurdOptions;
        const changed: Array<CurdMethod> = [];
        // 新增驗證DTO類
        for (const value of enabled) {
           // 新增驗證DTO類
        }
        for (const key of changed) {
             // 新增序列化選項以及是否允許匿名訪問等metadata
        }

        const fixedProperties = ['constructor', 'service', 'setService'];
        for (const key of Object.getOwnPropertyNames(BaseController.prototype)) {
            // 對於不啟用的方法返回404
        }
        return Target;
    };

內容模組

現在更改一下內容模組中的服務類和控制器即可,程式碼會變得異常簡潔,以CategoryServiceCategoryController為例

// src/modules/content/services/category.service.ts
@Injectable()
export class CategoryService extends BaseService<CategoryEntity, CategoryRepository> {
    constructor(protected categoryRepository: CategoryRepository) {
        super(categoryRepository);
    }

    protected enable_trash = true;

    async findTrees() {
        return this.repository.findTrees();
    }

    async create(data: CreateCategoryDto) {
       // ...
    }

    async update(data: UpdateCategoryDto) {
       // ...
    }

    protected async getParent(id?: string) {
        // ...
    }
}

// src/modules/content/controllers/category.controller.ts
@Crud({
    id: 'category',
    enabled: [
        'list',
        'detail',
        'store',
        'update',
        'delete',
        'restore',
        'deleteMulti',
        'restoreMulti',
    ],
    dtos: {
        query: QueryCategoryDto,
        create: CreateCategoryDto,
        update: UpdateCategoryDto,
    },
})
@Controller('categories')
export class CategoryController extends BaseController<CategoryService> {
    constructor(protected categoryService: CategoryService) {
        super(categoryService);
    }

    @Get('tree')
    @SerializeOptions({ groups: ['category-tree'] })
    async index() {
        this.service;
        return this.service.findTrees();
    }
}
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章