接下来我们将从一个请求开始说起,如何一步一步构架一个 Web 服务

本教程的完整示例见 hello-world

# 先理解 Nest 的 Module

Nest 默认提供了 Module 模块的概念,在继续本教程之前,请务必理解 Nest 的 Module 其核心概念有:

  • providers 由 Nest 注入器实例化的提供者,并且可以至少在整个模块中共享
  • controllers 必须创建的一组控制器
  • imports 导入模块的列表,这些模块导出了此模块中所需提供者
  • exports 由本模块提供并应在其他模块中可用的提供者的子集。

假设一个场景,我们系统里定义两个 Module,UserModule 和 OrderModule. 在 UserModule 中我们定义了一个 UsersService

users.module.ts

@Module({
  imports: [
    TypegooseModule.forFeature([User], 'core'),
  ],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService]
})
export class UsersModule {
}

如果我们要在 OrderModule 里使用 User 这个 UsersService 应该如何做呢? 前提是 UserModule 里必须把 UsersService 导出,然后在 OrderModule 里导入

order.module.ts

@Module({
  imports: [
    UserModule,
    TypegooseModule.forFeature([Order], 'core'),
  ],
  controllers: [OrderController],
  providers: [OrderService]
})
export class OrderModule {
}

然后在 OrderService 中使用

TODO

# 定义一个路由

Nest 使用注解式路由

cats.controller.ts

import { Controller, Get } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Get()
  findAll(): string {
    return 'This action returns all cats';
  }
}

请求参数则通过 @Query @Body 等方式获取,具体见 Nest文档

# 参数校验 DTO

在 Nest 框架中,最好使用 DTO 对象来完成参数的校验.

例如在一个注册 register 接口中,我们将会定义一个 UserDto 对象表示注册需要的参数。

src/users/users.dto.ts

import { IsNotEmpty } from 'class-validator'
import { BaseResponse } from '@kalengo/web'

export class UserDto {
  @IsNotEmpty()
  readonly name!: string
  @IsNotEmpty()
  readonly phone!: string
}

而且我们还在具体的属性上加了 @IsNotEmpty() 的注解,这样就可以通过 Nest 的中间件自动帮你完成参数校验。

使用 DTO 的另一个好处: DTO 作为一个 class 定义的,可以方便地继承和重用。

接下来讲讲如何配置 Validate 中间件。

使用Nest 内置的 ValidationPipe,使用全局配置的方式


import { Module, ValidationPipe } from '@nestjs/common'
import { APP_PIPE } from '@nestjs/core'

@Module({
  providers: [
    {
      provide: APP_PIPE,
      useClass: ValidationPipe
    }
  ]
})
export class ApplicationModule {}

但是这个 ValidationPipe 不会输出校验的错误明细信息,它只会抛出一个 BadRequestException,而且里面没有任何错误提示信息。 查看了 ValidationPipe 源码,应该是一个 BUG,ValidationPipe 抛出 error 的时候把 error 丢失了,这个时候前端收到的内容是

{
  message: 'Bad Request Exception',
  code: 400,
  url: '/api/v1/users/register'
}

一个笼统的错误提示。

我们可以通过修改 ValidationPipe 来实现输出错误信息。


import { Injectable, ValidationPipe, ValidationError } from '@nestjs/common'
import { HttpErrorByCode } from '@nestjs/common/utils/http-error-by-code.util'

@Injectable()
export class ParamsValidationPipe extends ValidationPipe {
  public createExceptionFactory() {
    return (validationErrors: ValidationError[] = []) => {
      if (this.isDetailedOutputDisabled) {
        return new HttpErrorByCode[this.errorHttpStatusCode]()
      }
      // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
      // @ts-ignore
      const errors = this.flattenValidationErrors(validationErrors)
      return new HttpErrorByCode[this.errorHttpStatusCode](errors.join(';')) // 把错误信息转为 string,这样就能抛出正确的 error
    }
  }
}

改用我们修改过的中间件

import { Module } from '@nestjs/common'
import { APP_PIPE } from '@nestjs/core'
import { ParamsValidationPipe } from '@kalengo/web'

@Module({
  providers: [
    {
      provide: APP_PIPE,
      useClass: ParamsValidationPipe
    }
  ]
})
export class ApplicationModule {}

测试一下,返回内容变为

{
  message: 'phone should not be empty',
  code: 400,
  url: '/api/v1/users/register'
}

这个修改版中间件将由 @kalengo/web 库提供

更多配置方式和细节请看 Nest 官方文档 类验证器

# 集合参数(TODO)

在之前的开发经验中,为了方便处理参数,我们希望把 query body 的参数集合到一个对象 parameters 中, 我们可以通过一个中间件来实现

# Request Log

Nest 默认不提供 Request Log 的中间件,我们可以使用开源的 express 中间件 morgan,在 main.ts 中挂载即可

import {NestFactory} from '@nestjs/core'
import * as morgan from 'morgan'
import {ApplicationModule} from './app.module'

async function bootstrap () {
  const app = await NestFactory.create(ApplicationModule)
  // request log
  app.use(morgan('tiny'))
  await app.listen(process.env.PORT || 3000)
  console.log(`Application(${ process.env.NODE_ENV }) is running on: ${ await app.getUrl() }`)
}

如果你有更复杂的日志需求,例如 log 存储分割等问题,请查阅本脚手架的日志说明文档

# Response Format

实际业务中,我们需要统一接口的返回值,Nest 默认不提供此类中间件,我们可以自己实现一个,如何编写中间件,请看 Nest文档

transform.interceptor.ts

import {Injectable, NestInterceptor, CallHandler, ExecutionContext} from '@nestjs/common'
import {map} from 'rxjs/operators'
import {Observable} from 'rxjs'

interface Response<T> {
  data: T
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept (context: ExecutionContext, next: CallHandler<T>): Observable<Response<T>> {
    return next.handle().pipe(
      map(data => {
        return {
          data,
          code: 0,
          message: 'success'
        }
      })
    )
  }
}

中间件写好了之后,在 app.module.ts 中全局挂载即可

src/app.module.ts

import { Module } from '@nestjs/common'
import { UsersModule } from './users/users.module'
import { APP_INTERCEPTOR } from '@nestjs/core'
import { TransformInterceptor } from '@kalengo/web'

@Module({
  imports: [UsersModule],
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: TransformInterceptor
    }
  ]
})
export class ApplicationModule {}

注意 此类通用的中间件,我们会放入 @klg/web 包中

# 统一的异常处理

在 AppModule 注册全局的异常拦截器

import { Module } from '@nestjs/common'
import { APP_FILTER } from '@nestjs/core'
import { HttpExceptionFilter } from '@kalengo/web'

@Module({
  imports: [UsersModule],
  providers: [
    {
      provide: APP_FILTER,
      useClass: HttpExceptionFilter
    }
  ]
})
export class ApplicationModule {}

注意这种写法是和下面的 useGlobalFilters 效果一致的,不过 useClass 方式可以注入其他实例。

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  await app.listen(3000);
}
bootstrap();

HttpExceptionFilter 默认拦截所有异常,并返回如下结构的 json 内容

{
"message": "Business Error",
"code": 1,
"url": "/api/v1/xxx/xxx"
}

配置好了拦截器,我们测试一下异常的情况,在 Controller 中抛出异常

import { Controller, Get } from '@nestjs/common'
import { BusinessException } from '@kalengo/web'

@Controller('users')
export class UsersController {

  @Get('/err')
  async err(): Promise<string> {
    throw new BusinessException()
  }
}

@kalengo/web 为大家提供了一个 BusinessException 对象,实际上是继承与 Nest 的 HttpException, 这里抛出的异常将会被我们配置的拦截器拦截并处理。

我们可以根据业务需要编写更多的自定义异常。

更多详细内容请查阅 Nest文档

# 接口文档 Swagger

Nest 对 Swagger 有着非常好的集成,我们可以用注解的方式,直接在代码上编写接口文档。

还是以注册接口为例,看看如何编写接口文档。

src/users/users.controller.ts


@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post('/register')
  @ApiOkResponse({
    description: 'find one account',
    type: RegisterRes
  })
  async register(@Body() createUserDto: UserDto) {
    return await this.usersService.register(createUserDto)
  }
}

在 Controller 中,我们声明了 register 所需要的参数是 UserDto,返回值是 ApiOkResponse 里声明的 RegisterRes

这两个类型实际定义在 DTO 文件中

src/users/users.dto.ts

import { IsNotEmpty } from 'class-validator'
import { ApiProperty } from '@nestjs/swagger'
import { BaseResponse } from '@kalengo/web'

export class UserDto {
  @IsNotEmpty()
  @ApiProperty()
  readonly name!: string
  @IsNotEmpty()
  @ApiProperty()
  readonly phone!: string
}

export class RegisterRes extends BaseResponse {
  @ApiProperty({ type: UserDto })
  readonly data!: UserDto
}

使用了 @ApiProperty 注解声明了 DTO 的字段,这些声明将会用于生成 Swagger 文档.

注意,DTO 每个字段都需要注释还是比较麻烦的,Nest Swagger 为了解决这个问题,提供了自动扫描插件, 该插件会尝试读取 typescript 信息,自动生成 API 文档需要的信息,详情见 插件文档

最终,生成好的接口文档长这样:

可以看到,参数类型和返回值类型都被准确声明了。

后端开发可以先写好接口设计,然后使用这份文档和前端沟通

更多细节请看 Nest 官方文档 OpenAPI (Swagger)