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

Nestjs 最佳实践教程:2 基本数据操作

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

    学习目标

    • 简单地整合nestjs框架与typeorm
    • 实现基本的CRUD数据操作
    • 使用class-validator验证请求数据
    • 更换更加快速的fastify适配器
    • 使用 Thunder Client 对测试接口

    安装 Mysql

    实际生产环境中建议使用 PostgreSQL,因为教程以学习为主,所以直接使用相对来说比较通用和简单的 Mysql

    使用以下命令安装 Mysql

    如果本机不是使用 linux(比如使用 wsl2),请到mysql官网点击 download 按钮下载安装包后在 chrome 查看下载地址,然后在开发机用wget下载

    如果本机使用 MacOS,使用brew install mysql,如果本机使用 Arch 系列,使用sudo pacman -Syy mysql

    # 下载镜像包
    cd /usr/local/src
    sudo wget sudo wget https://repo.mysql.com/mysql-apt-config_0.8.22-1_all.deb
    # 添加镜像(其它选项不用管,直接 OK 就可以)
    sudo apt-get install ./mysql-apt-config_0.8.22-1_all.deb
    # 升级包列表
    sudo apt-get update
    # 开始安装,输入密码后,有一个密码验证方式,因为是开发用,所以选择第二个弱验证即可
    sudo apt-get install mysql-server 
    # 初始化,在是否加载验证组件时选择 No,在是否禁用远程登录时也选择 No
    sudo mysql_secure_installation
    # 因为是远程 SSH 连接开发所以需要开启远程数据库链接,如果是本地或者 wsl2 则不需要开启
    mysql -u root -p 
    CREATE USER 'root'@'%' IDENTIFIED BY '密码';
    GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;
    FLUSH PRIVILEGES;
    

    接着使用 Navicat 等客户端就可以连接了

    预装依赖

    ~ pnpm add class-transformer \
      @nestjs/platform-fastify \
      class-validator \
      lodash \
      @nestjs/swagger \
      fastify-swagger \
      mysql2 \
      typeorm \
      @nestjs/typeorm
    
     ~ pnpm add @types/lodash cross-env @types/node typescript -D
    

    生命周期

    要合理的编写应用必须事先了解清楚整个程序的访问流程,本教程会讲解如何一步步演进每一次访问流,作为第一步课时,我们的访问流非常简单,可以参考下图

    文件结构

    我们通过整合typeorm来连接 mysql 实现一个基本的 CRUD 应用,首先我们需要创建一下文件结构

    建议初学者手动创建,没必要使用 CLI 去创建,这样目录和文件更加清晰

    1. 创建模块
    2. 编写模型
    3. 编写 Repository(如果有需要的话)
    4. 编写数据验证的 DTO
    5. 编写服务
    6. 编写控制器
    7. 在每个以上代码各自的目录下建立一个index.ts并导出它们
    8. 在各自的Module里进行注册提供者,导出等
    9. AppModule中导入这两个模块

    编写好之后的目录结构如下

    .
    ├── app.module.ts                           # 引导模块           
    ├── config                                  # 配置文件目录
    │   ├── database.config.ts                  # 数据库配置
    │   └── index.ts
    ├── main.ts                                 # 应用启动器
    ├── modules
        ├── content                             # 内容模块目录
        │   ├── content.module.ts               # 内容模块
        │   ├── controllers                     # 控制器
        │   ├── dtos                            # DTO 访问数据验证
        │   ├── entities                        # 数据实体模型
        |   ├── index.ts              
        │   ├── repositories                    # 自定义 Repository
        │   ├── services                        # 服务
        └──  core
            ├── constants.ts                    # 常量
            ├── core.module.ts                  # 核心模块
            ├── decorators                      # 装饰器
            └── types.ts                        # 公共类型
    

    应用编码

    在开始编码之前需要先更改一下package.jsonnestjs-cli.json两个文件

    package.json中修改一下启动命令,以便每次启动可以自动配置运行环境并兼容windows环境

    "prebuild": "cross-env rimraf dist",
    "start": "cross-env NODE_ENV=development nest start",
    "start:dev": "cross-env NODE_ENV=development nest start --watch",
    "start:debug": "cross-env NODE_ENV=development nest start --debug --watch",
    "start:prod": "cross-env NODE_ENV=production node dist/main",
    

    为了在每次重新编译前自动删除上次的产出,在nestjs-cli.json中配置 "deleteOutDir": true

    main.ts

    把适配器由express换成更快的fastify,并把监听的 IP 改成0.0.0.0方便外部访问.为了在使用class-validatorDTO类中也可以注入 nestjs 容器的依赖,需要添加useContainer

    // main.ts
    import { NestFactory } from '@nestjs/core';
    import {
      FastifyAdapter,
      NestFastifyApplication,
    } from '@nestjs/platform-fastify';
    import { useContainer } from 'class-validator';
    import { AppModule } from './app.module';
    
    async function bootstrap() {
      const app = await NestFactory.create<NestFastifyApplication>(
        AppModule,
        new FastifyAdapter()
      );
      useContainer(app.select(AppModule), { fallbackOnErrors: true });
      await app.listen(3000,'0.0.0.0');
    }
    bootstrap();
    

    连接配置

    创建一个src/config/database.config.ts文件

    export const database: () => TypeOrmModuleOptions = () => ({
        // ...
        // 此处 entites 设置为空即可,我们直接通过在模块内部使用`forFeature`来注册模型
        // 后续魔改框架的时候,我们会通过自定义的模块创建函数来重置 entities,以便给自己编写的 CLI 使用
        // 所以这个配置后面会删除
        entities: [], 
        // 自动加载模块中注册的 entity
        autoLoadEntities: true,
        // 可以在开发环境下同步 entity 的数据结构到数据库
        // 后面教程会使用自定义的迁移命令来代替,以便在生产环境中使用,所以以后这个选项会永久 false
        synchronize: process.env.NODE_ENV !== 'production',
    });
    
    

    CoreModule

    核心模块用于挂载一些全局类服务,比如整合typeorm的``TypeormModule`

    注意: 这里不要使用@Global()装饰器来构建全局模块,因为后面在CoreModule类中添加一些其它方法

    返回值中添加global: true来注册全局模块,并导出metadata.

    // src/core/core.module.ts
    export class CoreModule {
        public static forRoot(options?: TypeOrmModuleOptions) {
            const imports: ModuleMetadata['imports'] = [TypeOrmModule.forRoot(options)];
            return {
                global: true,
                imports,
                module: CoreModule,
            };
        }
    }
    

    AppModule导入该模块,并注册数据库连接

    // src/app.module.ts
    @Module({
        imports: [CoreModule.forRoot(database())],
      ...
    })
    export class AppModule {}
    

    自定义存储类

    由于原来用于自定义 Repository 的@EntityRepositorytypeorm0.3 版本后已经不可用,特别不方便,所以根据这里的示例来自定义一个CustomRepository装饰器

    // src/modules/core/constants.ts
    // 传入装饰器的 metadata 数据标识
    export const CUSTOM_REPOSITORY_METADATA = 'CUSTOM_REPOSITORY_METADATA';
    
    // src/modules/core/decorators/repository.decorator.ts
    // 定义装饰器
    import { CUSTOM_REPOSITORY_METADATA } from '../constants';
    export const CustomRepository = <T>(entity: ObjectType<T>): ClassDecorator =>
        SetMetadata(CUSTOM_REPOSITORY_METADATA, entity);
    
    // src/modules/core/decorators/index.ts
    export * from './repository.decorator';
    

    定义静态方法用于注册自定义 Repository

     public static forRepository<T extends Type<any>>(
            repositories: T[],
            dataSourceName?: string,
        ): DynamicModule {
            const providers: Provider[] = [];
    
            for (const Repo of repositories) {
                const entity = Reflect.getMetadata(CUSTOM_REPOSITORY_METADATA, Repo);
    
                if (!entity) {
                    continue;
                }
    
                providers.push({
                    inject: [getDataSourceToken(dataSourceName)],
                    provide: Repo,
                    useFactory: (dataSource: DataSource): typeof Repo => {
                        const base = dataSource.getRepository<ObjectType<any>>(entity);
                        return new Repo(base.target, base.manager, base.queryRunner);
                    },
                });
            }
    
            return {
                exports: providers,
                module: CoreModule,
                providers,
            };
        }
    

    ContentModule

    内容模块用于存放CRUD操作的逻辑代码

    // src/modules/content/content.module.ts
    @Module({})
    export class ContentModule {}
    

    AppModule中注册

    // src/app.module.ts
    @Module({
        imports: [CoreModule.forRoot(database()),ContentModule],
      ...
    })
    export class AppModule {}
    

    实体模型

    创建一个PostEntity用于文章数据表

    PostEntity继承``BaseEntity,这样做是为了我们可以进行ActiveRecord操作,例如PostEntity.save(post),因为纯DataMapper`的方式有时候代码会显得啰嗦,具体请查看此处

    @CreateDateColumn @UpdateDateColumn是自动字段,会根据创建和更新数据的时间自动产生,写入后不必关注

    // src/modules/content/entities/post.entity.ts
    // 'content_posts'是表名称
    @Entity('content_posts')
    export class PostEntity extends BaseEntity {
    ...
        @CreateDateColumn({
            comment: '创建时间',
        })
        createdAt!: Date;
    
        @UpdateDateColumn({
            comment: '更新时间',
        })
        updatedAt!: Date;
    }
    

    存储类

    本节存储类是一个空类,后面会添加各种操作方法

    这里用到我们前面定义的自定义 CustomRepository 装饰器

    // src/modules/content/repositories/post.repository.ts
    @CustomRepository(PostEntity)
    export class PostRepository extends Repository<PostEntity> {}
    

    注册模型和存储类

    在编写好entityrepository之后我们还需要通过Typeorm.forFeature这个静态方法进行注册,并把存储类导出为提供者以便在其它模块注入

    // src/modules/content/content.module.ts
    @Module({
        imports: [
            TypeOrmModule.forFeature([PostEntity]),
            // 注册自定义 Repository
            CoreModule.forRepository([PostRepository]),
        ],
         exports: [
            // 导出自定义 Repository,以供其它模块使用
            CoreModule.forRepository([PostRepository]),
        ],
    })
    export class ContentModule {}
    

    DTO 验证

    DTO配合管道(PIPE)用于控制器的数据验证,验证器则使用class-validator

    class-validator 是基于 validator.js 的封装,所以一些规则可以通过 validator.js 的文档查找,后面教程中我们会编写大量的自定义的验证规则,这节先尝试基本的用法

    其基本的使用方法就是给DTO类的属性添加一个验证装饰器,如下

    groups选项用于配置验证组

    // src/modules/content/dtos/create-post.dto.ts
    @Injectable()
    export class CreatePostDto {
        @MaxLength(255, {
            always: true,
            message: '文章标题长度最大为$constraint1',
        })
        @IsNotEmpty({ groups: ['create'], message: '文章标题必须填写' })
        @IsOptional({ groups: ['update'] })
        title!: string;
        ...
    }
    
    

    更新验证类UpdatePostDto继承自CreatePostDto,为了使CreatePostDto中的属性变成可选,需要使用[@nestjs/swagger][]包中的PartialType方法,请查阅此处文档

    // src/modules/content/dtos/update-post.dto.ts
    @Injectable()
    export class UpdatePostDto extends PartialType(CreatePostDto) {
        @IsUUID(undefined, { groups: ['update'], message: '文章 ID 格式错误' })
        @IsDefined({ groups: ['update'], message: '文章 ID 必须指定' })
        id!: string;
    }
    
    

    服务类

    服务一共包括 5 个简单的方法,通过调用PostRepository来操作数据

    // src/modules/content/services/post.service.ts
    @Injectable()
    export class PostService {
        // 此处需要注入`PostRepository`的依赖
        constructor(private postRepository: PostRepository) {}
        // 查询文章列表
        async findList() 
        // 查询一篇文章的详细信息
        async findOne(id: string)
        // 添加文章
        async create(data: CreatePostDto)
        // 更新文章
        async update(data: UpdatePostDto)
        // 删除文章
        async delete(id: string)
    }
    
    

    控制器

    控制器的方法通过@GET,@POST,@PUT,@PATCH,@Delete等装饰器对外提供接口,并且通过注入PostService服务来操作数据.在控制器的方法上使用框架自带的ValidationPipe管道来验证请求中的body数据,ParseUUIDPipe来验证params数据

    // 控制器 URL 的前缀
    @Controller('posts')
    export class PostController {
        constructor(protected postService: PostService) {}
    
        ...
       // 其它方法请自行查看源码
        @Get(':post')
        async show(@Param('post', new ParseUUIDPipe()) post: string) {
            return this.postService.findOne(post);
        }
    
        @Post()
        async store(
            @Body(
                new ValidationPipe({
                    transform: true,
                    forbidUnknownValues: true,
                    // 不在错误中暴露 target
                    validationError: { target: false },
                    groups: ['create'],
                }),
            )
            data: CreatePostDto,
        ) {
            return this.postService.create(data);
        }
    }
    

    注册控制器等

    • 为了后面``DTO中可能会导入服务,需要把DTO,同样注册为提供者并且改造一下main.ts,把容器加入到class-containter`中
    • PostService服务可能后续会被UserModule等其它模块使用,所以此处我们也直接导出
    // src/modules/content/content.module.ts
    @Module({
        imports: [
            TypeOrmModule.forFeature([PostEntity]),
            // 注册自定义 Repository
            CoreModule.forRepository([PostRepository]),
        ],
        providers: [PostService, CreatePostDto, UpdatePostDto],
        controllers: [PostController],
        exports: [
            PostService,
            // 导出自定义 Repository,以供其它模块使用
            CoreModule.forRepository([PostRepository]),
        ],
    })
    export class ContentModule {}
    
    // src/main.ts
    ...
    async function bootstrap() {
        const app = await NestFactory.create<NestFastifyApplication>(
            AppModule,
            new FastifyAdapter(),
        );
        useContainer(app.select(AppModule), { fallbackOnErrors: true });
        await app.listen(3000, '0.0.0.0');
    }
    

    最后启动应用在Thunder Client中测试接口

    1 条回复    2023-01-06 08:59:47 +08:00
    pincmancc
        1
    pincmancc  
       2023-01-06 08:59:47 +08:00
    test
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   3297 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 27ms · UTC 12:21 · PVG 20:21 · LAX 05:21 · JFK 08:21
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.