CocaColf

庭前桃李满,院外小径芳

走马观Nest.js

2021-07-02


TL;DR

从19年夏天偶然了解 Nest.js 至今,我已经使用它构建了三个应用,不过应用的规模都不大。每个应用之间都相隔半年以上,但每一次它给我的开发体验都是十分舒服的,它已成为目前我构建后端应用的首选框架。最近在开发代码质量检测工具的服务端时我依然选择它开发。这篇文章对 Nest.js 进行了简单的介绍,通过此文能够基本了解 Nest.js 的使用和开发。


初次接触 Nest.js,可能会觉得它的概念特别多,引入了 ProvidersPipes 等概念。我个人觉得这些概念的出现是很自然的,而且这恰好是它最大的特点,一个后端应用从请求到达至响应的每一个环节都有具体的执行者,这种“专人专事”的方式使得项目非常清晰。

控制器 (Controllers)

在 Nest.js 中,每一个控制器是一个类,我们使用 @Controller 来标识它,配合 @Get/Post 等 method 标识,即可完成一个控制器。

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

@Controller('test')
export class TestController {
	@Get('list')
	getList (@Query('name') name: string) {}
}

而且框架内置了非常多的 HttpStatus 和 HttpExceptions,极方便的辅助开发。

提供者 (Providers)

Providers 是一个很抽象的概念,其几乎可以是任何一个类,通过依赖注入到不同的类中,让被注入对象的创建工作委托给 Nest.js 运行时,使得不同模块/类之间的使用变得非常简单。使用 @Injectable 修饰一个类,即可将其变成 Providers。

比如我们上面有了控制器,根据 MVC 思想,一般我们的业务处理会放在 M 层去处理。

// testService.ts
import {Injectable} from '@nestjs/common'

@Injectable()
export class TestService {
	hanleList () {
	
	}
}

那么在我们的其他模块,便可以通过依赖注入使用这个 Providers,你不需要去做诸如 new TestService() 的事情。

import { Controller, Get } from '@nestjs/common'
import { TestService } from 'testService.ts'

@Controller('test')
export class TestController {
	constructor (
		private readonly testService: TestService
	) {}
	
	@Get('list')
	getList () {
		testService.hanleList();
	}
}

模块

从外部看,模块则是一个具体功能的所有系统的集合。Nest.js 应用是由不同的模块组成,就像是前端开发中的组件,由根组件出发,由各个子组件组成。模块由 @Module 进行修饰,其内部定义模块的各个元素:

// testModule.ts

import { Module } from '@nestjs/common';
import { TestController } from 'testController.ts'
import { TestService } from 'testService.ts'

@Module({
	controllers: [TestController],
	providers: [TestService]
})
export class TestModule {}

那么在其他模块,如果想使用某一个模块,就像是前端开发中使用某一个组件一样,只需要在父模块中引入即可:

// rootModule.ts

import { Module } from '@nestjs/common'
import { TestModule } from 'testModule.ts'

@Module({
	import: [
		TestModule
	]
})
export class AppModule {}

当了解了这几个概念后,不考虑工程化的情况下,已经足够我们去编写应用。所以其他概念的引入,则是将开发中各个相似的处理抽离出来。

中间件

中间件是在路由处理之前调用的函数,可以使用它在路由请求处理前做一些事情,譬如对请求进行修改。Nest.js 中使用中间件很简单,大致分为两类,应用中间件和全局中间件。

应用中间件

应用中间件即只用在某个模块上,它的使用一般是在模块文件中,比如下面这个中间件 LoggerMiddleware 则提供给 /test/xxx 路由使用


// testModule.ts
import { Module, MiddlewareConsumer } from '@nestjs/common';
import LoggerMiddleware from './LoggerMiddleware.ts';

@Module({
	// ...
})
export class TestModule {
    configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('test');
  }
}

全局中间件

全局中间件则是对整个应用使用,我们需要在 Nest.js 构建的应用的启动文件里 use 即可:

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './appModule.ts';
import { logger } form './loggerMiddleware.ts';

async function bootstrap () {
    const app = await NestFactory.create(AppModule);
    app.use(logger);
    await app.listen(80);
}

异常过滤器

程序出现异常很正常,但是对于用户侧,我们并不希望用户看到具体的异常报错,而是提供给其清晰地友好的信息。

一般来说,我们常在逻辑中这么抛出异常:

// testService.ts

// do something...
if (xxx) {
    throw new HttpException({
        status: HttpStatus.FORBIDDEN,
        error: '这里是给用户看的错误信息',
      }, HttpStatus.FORBIDDEN);
}

于是客户端便收到了如下的响应:

{
    "status": 403,
  	"error": "这里是给用户看的错误信息"
}

上面的 HttpExceptionHttpStatus 分别是 Nest.js 内置的类和枚举器。

但是并不是所有的异常我们都希望在每个地方这么处理,有时候我们想统一处理异常,并做一些诸如日志记录的事情,这时候就要自己实现异常过滤器。下面是一个示例:

// allExceptionFilter.ts

import { 
    ArgumentsHost, 
    Catch, 
    ExceptionFilter, 
    HttpException, 
    HttpStatus, 
    Injectable, 
    Logger 
} from "@nestjs/common";

@Injectable()
@Catch()
export class AllExceptionFilter implements ExceptionFilter {
    catch(exception: unknown, host: ArgumentsHost) {
        const ctx = host.switchToHttp();
        const response = ctx.getResponse();

        const status =
        exception instanceof HttpException
            ? exception.getStatus()
            : HttpStatus.INTERNAL_SERVER_ERROR;

        const msg = exception['response'] && exception['response']['msg'] || exception['message'] || '服务端错误';
        Logger.error(`捕获异常,${msg}`, 'exception');
        
        response.status(200).json({
            statusCode: 200,
            success: 0,
            msg
        });
    }
}

可以看到,异常过滤器需要继承 ExceptionFilter,并重载 catch 方法。异常过滤器也是一个 Provider。

要使用这个异常过滤器,我们只需要在目标 controller 上用 UseFilters 装饰器声明即可:

// testController.ts
import { Controller, UseFilters } from '@nest/common';
import { AllExceptionFilter } from './allExceptionFilter';

@Controller('test')
@UseFilters(new AllExceptionFilter())
export class TestController {}

管道

从客户端传到路由控制器的数据,会经过管道,所以管道是数据传输的管子,在这里我们可以对数据进行:

如果在管道层抛出异常,则请求并不会到达路由层。

假设我们已经存在一个叫做 testPipe 的管道,我们要使用它对 /test/report 接口的数据进行验证或转换,只需要:

// testController.ts

@Controller('test')
export class TestController {
    
    @Post('report')
    @UsePipes(new TestPipe(testPipeSchema))
    handleReport (@Body() reportData: reportData) {
        // ...
    }
}

很显然,管道内部则是对数据进行处理。为了再加深印象理解管道,我们简单实现 testPipe 这个管道。

一个管道需要实现 PipeTransform, 而 transform 方法中 value 变量为当前处理的参数,即我们路由中收到的参数; metaData 中包含有请求上下文的信息。

// testPipe.ts
import { 
    Injectable, 
    PipeTransform, 
    ArgumentMetadata, 
    BadRequestException, 
    HttpStatus 
} from "@nestjs/common";

@Injectable()
export class TestPipe implements PipeTransform {
    transform (value: reportData, metaData: ArgumentMetadata) {
        if (!Array.isarray(value)) {
            throw new BadRequestException({
               status:  HttpStatus.BAD_REQUEST,
               message: '参数错误,xxx必须是数组'
            });
        }
    }
    
    return value;
}

Nest.js 中也内置了许多有用的管道,对于许多场景都可以开箱即用。而且我们当然也可以绑定全局的管道,在 main.tsuse 即可:

// ...
app.useGlobalPipes(new ValidationPipe());
// ...

守卫

守卫从名字也看得出来,它的工作就是询问“来者何人”——鉴权。当一个操作请求到达,一般来说我们的应用针对它需要做两件事情:

Nest.js 的设计也正是如此,下面我们从这两个角度来看看 Nest.js 里 守卫是怎么样的。先假设我们构建了一个叫做 RolesGuard 的守卫。

定义条件

// testController.ts

import { Controller, UseGuards, Post, SetMetadata } from '@nestjs/common';
import { RolesGuard } from './rolesGuard.ts';

@Controller('test')
@UseGuards(RolesGuard)
export class TestController {
    @Post('deleteValue')
    @SetMetadata('roles', ['admin', 'admin2'])
    async deleteValue (@Body() deleteItem: string) {
        // ....
    }
}

解释一下这段代码:

验证条件

下面我们来实现这个守卫,来看看守卫的原理。

// rolesGuard.ts

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const roles = this.reflector.get<string[]>('roles', context.getHandler());
    if (!roles) {
      return false;
    }
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    return roles.includes(user.roles);
  }
}

怎么样,这种依赖注入解耦后,整个结构是不是赏心悦目。

拦截器

当我们想要在函数或整个应用执行前/后做一些事情,但是又不想对其有侵入性时,便可以使用拦截器。比如说:

这种不入侵原程序来影响原程序的思想便是面向切面编程(AOP)。下面看看 Nest.js 中的拦截器,先假设我们定义了叫 ResponeInterceptor 的拦截器,它将请求的响应转成统一格式。

使用拦截器

只需要在控制器或者方法上使用 UseInterceptors 装饰器即可。

// testController.ts
import { Controller, UseInterceptors, Get } from '@nest/common';

@Controller('test')
@UseInterceptors(ResponeInterceptor)
export class TestController {
    @Get('getValue')
    async getValue (@Query() id: string) {
        return '这是获取的数据';
    }
}

拦截器实现

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

@Injectable()
export class RequestInterceptor implements NestInterceptor {
    intercept (context: ExecutionContext, next: CallHandler): Observable<any> {
        return next
            .handle()
            .pipe(map(data => {
                const baseResponse = {
                    success: 1,
                    data: null,
                    msg: ''
                };
                
                return Object.assign(baseResponse, data);
            }));
    }
}

这段拦截器的功能则是将每个路由处理中返回的数据以 data 字段返回给客户端。拦截器的几个要素:

顺带一提,我们甚至还可以在拦截器里处理异常。

// ...
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next
      .handle()
      .pipe(
        catchError(err => throwError(new BadGatewayException())),
      );
  }

一切准备就绪后,还需要在 Module 里声明拦截器:

import { APP_INTERCEPTOR } from '@nestjs/core';
import { RequestInterceptor } from './requestInterceptor.ts';

@Module({
    // ...
    providers: [
        {
            provide: APP_INTERCEPTOR,
            useClass: RequestInterceptor
        }
    ],
    // ...
})

其他

使用 Express 生态

Nest.js 底层基于 Express.js,因此可以使用 Express 的周边生态。

静态资源、跨域、body大小限制

// main.ts

import * as serverStatic from 'serve-static';
import { AppModule } from './appModule.ts';
import { json, urlencoded } from 'body-parser';

const bodyLimit = '50mb';
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.enableCors();
  app.use(json({limit: bodyLimit}));
  app.use(urlencoded({limit: bodyLimit, extended: true}));
  app.use('/', serverStatic(path.join(__dirname, '../public'), {
    maxAge: '1d'
  }));

  await app.listen(80);
}

bootstrap();

数据库

以 MongoDB 为例,我们数据存储在 test 这个数据库里。

安装依赖

npm i @nestjs/mongoose mongoose

建立数据库连接

在根模块里(app.module.ts):

// app.module.ts

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';

@Module({
    imports: [
        // ...
        MongooseModule.forRoot('mongodb://xxx:27017/test')
    ]
})
export class AppModule {}

创建数据表 schema 和文档接口

下面指定了 People 这个数据表的字段和类型:

// peopleSchema.ts

import * as mongoose from 'mongoose';

export const peopleSchema = new mongoose.Schema({
    name: String,
    address: String,
    telephone: String
    // ...
});

// peopleDocument.ts

import { Document } from 'mongoose';

export interface PeopleDocument extent Document {
    name: string,
    address: string,
    telephone: string
    // ...
};

在某个业务模块里使用表

// testModule.ts

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { people } from './peopleSchema.ts';

@Module({
    imports: [
        MongooseModule.forFeature([
            {
                name: 'People',    // 表名
                schema: peopleSchema
            }
        ])
    ]
})
export class BcodeModule {}

业务中注入模型

// testService.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { peopleDocument } from './peopleDocument.ts';

@Injectable()
export class testService {
    constructor (
    	@InjectModel('People') private readonly peopleModel: Model<peopleDocument>
    ) {}
    
    // 就可以在业务中操作数据表了
    async findAll () {
        return await this.peopleModel.find({});
    }
}

官方文档有很多非常详细的[技术介绍](Database | NestJS - A progressive Node.js framework)


上述基本上简单但是又较全面的涵盖了 Nest.js 的内容,了解这些已经可以开始使用它构建一般性应用了。经过上面的走马观花,对 Nest.js 高度解耦的特点有了很直观的感受,除此之外,良好的开发体验还来自于非常完善的类型提示所带来的 TypeScript 编码舒适度;cli 快速创建控制器、模型等文件。不过也不得不承认,相比于其他 Node.js 框架,Nest.js 比较重,于是有一种论调是:既然都选择了 Nest.js,为何不干脆上 Java?很显然,作为前端开发工程师,选择上自然会更倾向于 JavaScript。

如果你感觉 Nest.js 有让你使用的欲望,那不妨开始构建你的第一个 Nest.js 应用。

Comments: