Nestjs最佳實踐教程:3模型關聯與樹形巢狀

pincman1988發表於2022-07-10

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

學習目標

這次教程在上一節的基礎上實現一個簡單的CMS系統,實現如下功能

  • 文章與分類多對多關聯
  • 文章與評論一對多關聯
  • 分類與評論的樹形無限級巢狀

檔案結構

這次的更改集中於ContentModule模組,編寫好之後的目錄結構如下

src/modules/content
├── content.module.ts
├── controllers
│   ├── category.controller.ts
│   ├── comment.controller.ts
│   ├── index.ts
│   └── post.controller.ts
├── dtos
│   ├── create-category.dto.ts
│   ├── create-comment.dto.ts
│   ├── create-post.dto.ts
│   ├── index.ts
│   ├── update-category.dto.ts
│   └── update-post.dto.ts
├── entities
│   ├── category.entity.ts
│   ├── comment.entity.ts
│   ├── index.ts
│   └── post.entity.ts
├── repositories
│   ├── category.repository.ts
│   ├── comment.repository.ts
│   ├── index.ts
│   └── post.repository.ts
└── services
    ├── category.service.ts
    ├── comment.service.ts
    ├── index.ts
    └── post.service.ts
cd src/modules/content && \
touch controllers/category.controller.ts \
controllers/comment.controller.ts \
dtos/create-category.dto.ts \
dtos/create-comment.dto.ts \
dtos/update-category.dto.ts \
entities/category.entity.ts \
entities/comment.entity.ts \
repositories/category.repository.ts \
services/category.service.ts \
services/comment.service.ts \
&& cd ../../../

應用編碼

編碼流程與上一節一樣,entity->repository->dto->service->controller,最後註冊

模型類

模型關聯

分別建立分類模型(CategoryEntity)和評論模型(CommentEntity),並和PostEntity進行關聯

分類模型

// src/modules/content/entities/category.entity.ts
@Entity('content_categories')
export class CategoryEntity extends BaseEntity {
  ...
    // 分類與文章多對多關聯
    @ManyToMany((type) => PostEntity, (post) => post.categories)
    posts!: PostEntity[];
}

評論模型

// src/modules/content/entities/comment.entity.ts
@Entity('content_comments')
export class CommentEntity extends BaseEntity {
  ...
   // 評論與文章多對一,並觸發`CASCADE`
    @ManyToOne(() => PostEntity, (post) => post.comments, {
        nullable: false,
        onDelete: 'CASCADE',
        onUpdate: 'CASCADE',
    })
    post!: PostEntity;
}

文章模型

@Entity('content_posts')
export class PostEntity extends BaseEntity {
    // 評論數量
    // 虛擬欄位,在Repository中透過QueryBuilder設定
    commentCount!: number;

    // 文章與分類反向多對多關聯
    @ManyToMany((type) => CategoryEntity, (category) => category.posts, {
        cascade: true,
    })
    @JoinTable()
    categories!: CategoryEntity[];
    // 文章與評論一對多關聯
    @OneToMany(() => CommentEntity, (comment) => comment.post, {
        cascade: true,
    })
    comments!: CommentEntity[];
}

樹形巢狀

評論模型與分類模型的樹形巢狀實現基本一致,唯一的區別在於在刪除父分類時子分類不會刪除而是提升為頂級分類,而刪除評論則連帶刪除其後代評論

typeorm有三種方案實現樹形巢狀模型,我們使用綜合來說最好用的一種,即物理路徑(Materialized Path),原因在於Adjacency list的缺點是無法一次載入整個樹,而closure則無法自動觸發Cascade

// src/modules/content/entities/category.entity.ts
@Entity('content_categories')
// 物理路徑巢狀樹需要使用`@Tree`裝飾器並以'materialized-path'作為引數傳入
@Tree('materialized-path')
export class CategoryEntity extends BaseEntity {
  ...
    // 子分類
    @TreeChildren({ cascade: true })
    children!: CategoryEntity[];
    // 父分類
    @TreeParent({ onDelete: 'SET NULL' })
    parent?: CategoryEntity | null;
}

// src/modules/content/entities/comment.entity.ts
@Entity('content_comments')
@Tree('materialized-path')
export class CommentEntity extends BaseEntity {
    ...
    @TreeChildren({ cascade: true })
    children!: CommentEntity[];
    @TreeParent({ onDelete: 'CASCADE' })
    parent?: CommentEntity | null;
}

儲存類

建立一個空的CategoryRepository用於操作CategoryEntity模型

注意:樹形的儲存類必須透過getTreeRepository獲取或者透過getCustomRepository載入一個繼承自TreeRepository的類來獲取

nestjs中注入樹形模型的儲存庫使用以下方法

  • 使用該模型的儲存庫類是繼承自TreeRepository類的自定義類,則直接注入即可
  • 如果沒有儲存庫類就需要在注入的使用TreeRepository<Entity>作為型別提示

為了簡單,CommentRepository暫時不需要建立,直接注入服務即可

// src/modules/content/repositories/category.repository.ts
@EntityRepository(CategoryEntity)
export class CategoryRepository extends TreeRepository<CategoryEntity> {}

修改PostRepository新增buildBaseQuery用於服務查詢,程式碼如下

// src/modules/content/repositories/post.repository.ts
buildBaseQuery() {
        return this.createQueryBuilder('post')
            // 加入分類關聯
            .leftJoinAndSelect('post.categories', 'categories')
            // 建立子查詢用於查詢評論數量
            .addSelect((subQuery) => {
                return subQuery
                    .select('COUNT(c.id)', 'count')
                    .from(CommentEntity, 'c')
                    .where('c.post.id = post.id');
            }, 'commentCount')
            // 把評論數量賦值給虛擬欄位commentCount
            .loadRelationCountAndMap('post.commentCount', 'post.comments');
    }

DTO驗證

DTO類與前面的CreatePostDtoUpdatePostDto寫法是一樣的

評論無需更新所以沒有update的DTO

  • create-category.dto.ts用於新建分類
  • update-category.dto.ts用於更新分類
  • create-comment.dto.ts用於新增評論

在程式碼中可以看到我這裡對分類和評論的DTO新增了一個parent欄位用於在建立和更新時設定他們的父級

@Transform裝飾器是用於轉換資料的,基於class-transformer這個類庫實現,此處的作用在於把請求中傳入的值為null字串的parent的值轉換成真實的null型別

@ValidateIf的作用在於只在請求的parent欄位不為null且存在值的時候進行驗證,這樣做的目的在於如果在更新時設定parentnull把當前分類設定為頂級分類,如果不傳值則不改變

// src/modules/content/dtos/create-category.dto.ts
    @IsUUID(undefined, { always: true, message: '父分類ID格式不正確' })
    @ValidateIf((p) => p.parent !== null && p.parent)
    @IsOptional({ always: true })
    @Transform(({ value }) => (value === 'null' ? null : value))
    parent?: string;

CreatePostDto中新增分類IDS驗證

// src/modules/content/dtos/create-post.dto.ts
   @IsUUID(undefined, { each: true, always: true, message: '分類ID格式錯誤' })
   @IsOptional({ always: true })
   categories?: string[];

CreateCommentDto中新增一個文章ID驗證

// src/modules/content/dtos/create-comment.dto.ts
    @IsUUID(undefined, { message: '文章ID格式錯誤' })
    @IsDefined({ message: '評論文章ID必須指定' })
    post!: string;

服務類

Category/Comment

服務的編寫基本與PostService一致,我們新增了以下幾個服務

  • CategoryService用於分類操作
  • CommentService用於評論操作

分類服務透過TreeRepository自帶的findTrees方法可直接查詢出樹形結構的資料,但是此方法無法新增查詢條件和排序等,所以後續章節我們需要自己新增這些

// src/modules/content/services/category.service.ts
export class CategoryService {
    constructor(
        private entityManager: EntityManager,
        private categoryRepository: CategoryRepository,
    ) {}

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

getParent方法用於根據請求的parent欄位的ID值獲取分類和評論下的父級

protected async getParent(id?: string) {
        let parent: CommentEntity | undefined;
        if (id !== undefined) {
            if (id === null) return null;
            parent = await this.commentRepository.findOne(id);
            if (!parent) {
                throw new NotFoundException(`Parent comment ${id} not exists!`);
            }
        }
        return parent;
    }

PostService

現在為了讀取和操作文章與分類和評論的關聯,使用QueryBuilder來構建查詢器

在此之前,在core/types(新增)中定義一個用於額外傳入查詢回撥引數的方法型別

// src/core/types.ts

/**
 * 為query新增查詢的回撥函式介面
 */
export type QueryHook<Entity> = (
    hookQuery: SelectQueryBuilder<Entity>,
) => Promise<SelectQueryBuilder<Entity>>;

PostService更改

對於評論的巢狀展示在後續教程會重新定義一個新的專用介面來實現

  • create時透過findByIds為新增文章出查詢關聯的分類
  • update時透過addAndRemove更新文章關聯的分類
  • 查詢時透過.buildBaseQuery().leftJoinAndSelect為文章資料新增上關聯的評論

控制器

新增兩個控制器,分別用於處理分類和評論的請求操作

CategoryContoller

方法與PostController一樣,index,show,store,update,destory

暫時直接用findTrees查詢出樹形列表即可

export class CategoryController {
  ...
    @Get()
    async index() {
        return this.categoryService.findTrees();
    }
}

CommentController

目前評論控制器只有兩個方法storedestory,分別用於新增和刪除評論

註冊程式碼

分別在entities,repositories,dtos,services,controllers等目錄的index.ts檔案中匯出新增程式碼以給ContentModule進行註冊

const entities = Object.values(EntityMaps);
const repositories = Object.values(RepositoryMaps);
const dtos = Object.values(DtoMaps);
const services = Object.values(ServiceMaps);
const controllers = Object.values(ControllerMaps);
@Module({
    imports: [
        TypeOrmModule.forFeature(entities),
        // 註冊自定義Repository
        CoreModule.forRepository(repositories),
    ],
    controllers,
    providers: [...dtos, ...services],
    exports: [
        // 匯出自定義Repository,以供其它模組使用
        CoreModule.forRepository(repositories),
        ...services,
    ],
})
export class ContentModule {}
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章