V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
lichnow
V2EX  ›  Node.js

Nestjs 最佳实践教程:3 模型关联与树形嵌套

  •  
  •   lichnow · 2022-07-10 08:19:35 +08:00 · 3599 次点击
    这是一个创建于 867 天前的主题,其中的信息可能已经有所发展或是发生改变。

    另,本人在找工作中,希望能有远程工作匹配(无法去外地),有需要的老板可以看一下我的个人介绍: https://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 {}
    
    5 条回复    2022-08-15 09:56:09 +08:00
    lovepocky
        1
    lovepocky  
       2022-07-10 10:07:36 +08:00 via iPhone
    这目录结构是哪学的? spring 吗?
    lichnow
        2
    lichnow  
    OP
       2022-07-10 10:18:46 +08:00
    @lovepocky symfony,laravel 不都这个吗?我 php 转的 node ,没用过 java
    lovepocky
        3
    lovepocky  
       2022-07-10 10:28:43 +08:00 via iPhone
    @lichnow 多看看官方文档
    lichnow
        4
    lichnow  
    OP
       2022-07-10 10:39:48 +08:00
    @lovepocky 好的,官方文档基本看了几十遍了。。。这目录结构不标准吗?
    oppddd
        5
    oppddd  
       2022-08-15 09:56:09 +08:00
    官方应该是一个 module 一个文件夹,对应的 controller 还有 service 在一起;你这个是把不同的业务放在了一起;
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   958 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 23ms · UTC 21:13 · PVG 05:13 · LAX 13:13 · JFK 16:13
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.