在上一章节中,我们使用 CLI 与 PNPM 得到了一套基础的项目工程。
Nest 为开发者提供了非常多的内置或配套的功能例如高速缓存、日志拦截、过滤器、微服务等多种模块,方便开发者根据自身的业务需求定制适合当前业务的工程,但 CLI 提供的基础框架并没有内置这些服务,需要开发自己配置。
本章将根据业务需求选择对应的模块搭建出一个符合要求的通用性脚手架。
在上一本《NesJS 实战》的实战项目网关系统中为了追求性能,我们使用了 Fastify 作为底层框架,但对于一个需要商业化的项目,稳定性、开发效率会有一定要求,并不能太过于追求技术实现,所以 Express 显然是一种更好的选择。
Express 作为老牌的框架,它经历了非常多的大型项目实战的考验以及长期的迭代,同时社区生态非常的丰富,遇到大部分的问题都可以快速找到解决方案,对于新手会更加友好,而使用 Fastify 有些模块需要自己重新实现一下,开发效率会有所降低。
所以这次的低代码相关的项目将采用 Express 作为底层框架。
之前学习过 DevOps 小册的同学,应该对 GitLab OpenApi 比较熟悉,肯定也使用过这样的请求 gitlab.example.com/api/v4/proj… ,可以看出链接上面是带 v4 版本的。
第一步:在 main.ts
启用版本配置:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { VersioningType } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 接口版本化管理
app.enableVersioning({
type: VersioningType.URI,
});
await app.listen(3000);
}
bootstrap();
第二步:启用版本配置之后再在 Controller
中请求方法添加对应的版本号装饰器:
import { Controller, Version } from '@nestjs/common';
@Get()
@Version('1')
findAll() {
return this.userService.findAll();
}
配置完毕之后从上图可以看到,只有携带了版本号的请求 http://localhost:3000/v1 能正常返回数据,而之前未携带版本号的请求 http://localhost:3000 返回了 404 错误。
除了针对某一个请求添加版本之外,同样也可以添加全局以及整个 Controller
的版本,除了 URL 上携带之外,还可以放在 Header 中或者使用 MEDIA_TYPE。
app.enableVersioning({
- type: VersioningType.URI,
+ type: VersioningType.HEADER,
});
app.enableVersioning({
- type: VersioningType.URI,
+ type: VersioningType..MEDIA_TYPE,
+ key: 'v='
});
具体的版本配置规则可以根据自己的实际需求进行取舍,更多的详细用法可以参考官方文档。
第一步:修改 enableVersioning
配置项:
app.enableVersioning({
+ defaultVersion: '1',
type: VersioningType.URI,
});
第二步:修改 Controller
的配置,在 Controller
装饰器中添加 version
属性:
- @Get()
- @Version('1')
+ @Controller({
+ path: 'user',
+ version: '1',
+ })
完成上述的操作就可以对一整个 Controller
进行版本控制。但有的时候,我们需要做针对一些接口做兼容性的更新,而其他的请求是不需要携带版本,又或者请求有多个版本的时候,而默认请求想指定一个版本的话,我们可以在 enableVersioning
添加 defaultVersion
参数达到上述的要求:
+ import { VersioningType, VERSION_NEUTRAL } from '@nestjs/common';
app.enableVersioning({
- defaultVersion: '1',
+ defaultVersion: [VERSION_NEUTRAL, '1', '2']
});
@Get()
@Version([VERSION_NEUTRAL, '1'])
findAll() {
return 'i am old one';
}
@Get()
@Version('2')
findAll2() {
return 'i am new one';
}
接下来分别访问对应的请求http://localhost:3000/user 与 http://localhost:3000/v2/user 可以获取到如下的返回值:
在配置版本的过程中,也不断地测试了很多次接口,不难发现返回的接口数据非常的不标准,在一个正常的项目中不太合适用这种数据结构返回,毕竟这样对前端不友好,也不利于前端做统一的拦截与取值,所以需要格式化请求参数,输出统一的接口规范。
一般正常项目的返回参数应该包括如下的内容:
{
data, // 数据
status: 0, // 接口状态值
extra: {}, // 拓展信息
message: 'success', // 异常信息
success:true // 接口业务返回状态
}
想要输出上述标准的返回参数格式的话:
第一步:新建 src/common/interceptors/transform.interceptor.ts
文件:
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
interface Response {
data: T;
}
@Injectable()
export class TransformInterceptor
implements NestInterceptor>
{
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable> {
return next.handle().pipe(
map((data) => ({
data,
status: 0,
extra: {},
message: 'success',
success: true,
})),
);
}
}
第二步:修改 main.ts
文件,添加 useGlobalInterceptors
全局拦截器,处理返回值
+ import { TransformInterceptor } from './common/interceptors/transform.interceptor';
// 统一响应体格式
+ app.useGlobalInterceptors(new TransformInterceptor());
然后我们再次访问之前的请求,就能获取到标准格式的接口返回值了:
处理完正常的返回参数格式之后,对于异常处理也应该做一层标准的封装,这样利于开发前端的同学统一处理这类异常错误。
第一步:新建 src/common/exceptions/base.exception.filter.ts
与 http.exception.filter.ts
两个文件,从命名中可以看出它们分别处理统一异常与 HTTP
类型的接口相关异常。
base.exception.filter
=> Catch
的参数为空时,默认捕获所有异常
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpStatus,
ServiceUnavailableException,
} from '@nestjs/common';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: Error, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
// 非 HTTP 标准异常的处理。
response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
statusCode: HttpStatus.SERVICE_UNAVAILABLE,
timestamp: new Date().toISOString(),
path: request.url,
message: new ServiceUnavailableException().getResponse(),
});
}
}
http.exception.filter.ts
=> Catch
的参数为 HttpException
将只捕获 HTTP
相关的异常错误
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
} from '@nestjs/common';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status = exception.getStatus();
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: exception.getResponse(),
});
}
}
第二步:在 main.ts
文件中添加 useGlobalFilters
全局过滤器:
+ import { AllExceptionsFilter } from './common/exceptions/base.exception.filter';
+ import { HttpExceptionFilter } from './common/exceptions/http.exception.filter';
// 异常过滤器
+ app.useGlobalFilters(new AllExceptionsFilter(), new HttpExceptionFilter());
这里一定要注意引入自定义异常的先后顺序,不然异常捕获逻辑会出现混乱。
完成上述操作之后开始检验是否配置正常。首先访问一个不存在的接口 http://localhost:3000/test ,此时可以对比自定义与原生的异常返回参数区别。
验证完 HTTP
异常之后,我们接着在 UserController
中伪造一个程序运行异常的接口,来验证常规异常是否能被正常捕获:
@Get('findError')
@Version([VERSION_NEUTRAL, '1'])
findError() {
const a: any = {}
console.log(a.b.c)
return this.appService.getHello();
}
再次访问 http://localhost:3000/findError ,此时可以看到原生与自定义返回的异常错误存在一定的区别了。
除了全局异常拦截处理之外,我们需要再新建一个 business.exception.ts
来处理业务运行中预知且主动抛出的异常:
import { HttpException, HttpStatus } from '@nestjs/common';
import { BUSINESS_ERROR_CODE } from './business.error.codes';
type BusinessError = {
code: number;
message: string;
};
export class BusinessException extends HttpException {
constructor(err: BusinessError | string) {
if (typeof err === 'string') {
err = {
code: BUSINESS_ERROR_CODE.COMMON,
message: err,
};
}
super(err, HttpStatus.OK);
}
static throwForbidden() {
throw new BusinessException({
code: BUSINESS_ERROR_CODE.ACCESS_FORBIDDEN,
message: '抱歉哦,您无此权限!',
});
}
}
export const BUSINESS_ERROR_CODE = {
// 公共错误码
COMMON: 10001,
// 特殊错误码
TOKEN_INVALID: 10002,
// 禁止访问
ACCESS_FORBIDDEN: 10003,
// 权限已禁用
PERMISSION_DISABLED: 10003,
// 用户已冻结
USER_DISABLED: 10004,
};
简单改造一下 HttpExceptionFilter
,在处理 HTTP
异常返回之前先处理业务异常:
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { BusinessException } from './business.exception';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status = exception.getStatus();
// 处理业务异常
if (exception instanceof BusinessException) {
const error = exception.getResponse();
response.status(HttpStatus.OK).json({
data: null,
status: error['code'],
extra: {},
message: error['message'],
success: false,
});
return;
}
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: exception.getResponse(),
});
}
}
完成配置之后,我们继续在 AppController
中重新伪造一个业务异常的场景:
+ import { BusinessException } from 'src/common/exceptions/business.exception';
@Get('findBusinessError')
@Version([VERSION_NEUTRAL, '1'])
findBusinessError() {
const a: any = {}
try {
console.log(a.b.c)
} catch (error) {
throw new BusinessException('你这个参数错了')
}
return this.userService.findAll();
}
访问接口 http://localhost:3000/findBusinessError ,可以看到能够返回我们预期的错误了。
自定义业务异常的优点在于,当你的业务逻辑复杂到一定的地步,在任意的一处出现可预知的错误,此时可以直接抛出异常让用户感知,不需要写很多冗余的返回代码。
异常拦截、全局返回参数修改以及替换 Fastify
框架的代码已上传 demo/v2, 需要的同学可以自取。
一般在项目开发中,至少会经历过 Dev
-> Test
-> Prod
三个环境。如果再富余一点的话,还会再多一个 Pre
环境。甚至在不差钱的情况下,每个环境可能都会有多套配置。那么对应的使用的数据库、Redis
或者其他的配置项都会随着环境的变换而改变,所以在实际项目开发中,多环境的配置非常必要。
NestJS
本身也自带了多环境配置方法
@nestjs/config
$ pnpm add @nestjs/config -w
app.module.ts
中添加 ConfigModule
模块import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [ConfigModule.forRoot()],
controllers: [AppController],
providers: [AppService],
})
export class AppModule { }
@nestjs/config
默认会从项目根目录载入并解析一个 .env
文件,从 .env
文件和 process.env
合并环境变量键值对,并将结果存储到一个可以通过 ConfigService
访问的私有结构。
forRoot()
方法注册了 ConfigService
提供者,后者提供了一个 get()
方法来读取这些解析/合并的配置变量。
当一个键同时作为环境变量(例如,通过操作系统终端如
export DATABASE_USER=test
导出)存在于运行环境中以及.env
文件中时,以运行环境变量优先。
默认的 .env
文件变量定义如下所示,配置后会默认读取此文件:
DATABASE_USER=test
DATABASE_PASSWORD=test
虽然 Nest
自带了环境配置的功能,使用的 dotenv 来作为默认解析,但默认配置项看起来并不是非常清爽,我们接下来使用结构更加清晰的 YAML
来覆盖默认配置。
想要了解
YAML
更多细节的同学可以点击链接看下,如果使用过GitLab CICD
的同学,应该对.yml
文件比较熟悉了,这里我就不对YAML
配置文件做过多阐述了。
YAML
配置文件之前,先要修改 app.module.ts
中 ConfigModule
的配置项 ignoreEnvFile
,禁用默认读取 .env
的规则:ConfigModule.forRoot({ ignoreEnvFile: true, });
YAML
的 Node
库 yaml
:$ pnpm add yaml -w
.config
文件夹,并创建对应环境的 yaml
文件,如下图所示:utils/index.ts
文件,添加读取 YAML
配置文件的方法:import { parse } from 'yaml';
import * as path from 'path';
import * as fs from 'fs';
// 获取项目运行环境
export const getEnv = () => {
return process.env.RUNNING_ENV;
};
// 读取项目配置
export const getConfig = () => {
const environment = getEnv();
const yamlPath = path.join(process.cwd(), `./.config/.${environment}.yaml`);
const file = fs.readFileSync(yamlPath, 'utf8');
const config = parse(file);
return config;
};
app.module.ts
自定义配置项即可正常使用环境变量:+ import { getConfig } from './utils';
ConfigModule.forRoot({
ignoreEnvFile: true,
+ isGlobal: true,
+ load: [getConfig]
}),
注意:
load
方法中传入的getConfig
是一个函数,并不是直接 JSON 格式的配置对象,直接添加变量会报错。
完成之前的配置后,就可以使用 cross-env
指定运行环境来使用对应环境的配置变量。
$ pnpm add cross-env -w
"start:lowcode": "cross-env RUNNING_ENV=dev nest start --watch",
"start:devops": "cross-env RUNNING_ENV=dev nest start devops --watch",
.dev.yaml
配置:TEST_VALUE:
name: cookie
注意
yaml
配置的规则,缩进以及冒号 : 后的空格是经常容易出错的地方
AppController
中添加 ConfigService
以及新的请求:+ import { ConfigService } from '@nestjs/config';
export class AppController {
constructor(
private readonly appService: AppService,
+ private readonly configService: ConfigService
) { }
+ @Get('getTestName')
+ getTestName() {
+ return this.configService.get('TEST_VALUE').name;
+ }
}
完成上述所有步骤之后,重启项目,接下来访问 http://localhost:3000/getTestName 能看到已经能够根据环境变量拿到对应的值:
这里应该注意到,我们并没有注册
ConfigModule
。这是因为在app.module
中添加isGlobal
属性,开启Config
全局注册,如果isGlobal
没有添加的话,则需要先在对应的module
文件中注册后才能正常使用ConfigService
。
作为一个后端服务,API 文档是必不可少的,除了接口描述、参数描述之外,自测也十分方便。NestJS
自带了 Swagger
文档,集成非常简单,接下来进行文档的配置部分。
$ pnpm add @nestjs/swagger -w
src/doc.ts
文件:import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import * as packageConfig from '../package.json'
export const generateDocument = (app) => {
const options = new DocumentBuilder()
.setTitle(packageConfig.name)
.setDescription(packageConfig.description)
.setVersion(packageConfig.version)
.build();
const document = SwaggerModule.createDocument(app, options);
SwaggerModule.setup('/api/doc', app, document);
}
为了节约配置项,
Swagger
的配置信息全部取自package.json
,有额外需求的话可以自己维护配置信息的文件。
默认情况下,在 TS
开发的项目中是没办法导入 .json
后缀的模块,所以可以在 tsconfig.json
中新增 resolveJsonModule
配置即可。
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "es2017",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
+ "resolveJsonModule": true
}
}
main.ts
中引入方法即可:import { VersioningType, VERSION_NEUTRAL } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/exceptions/base.exception.filter';
import { HttpExceptionFilter } from './common/exceptions/http.exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
+ import { generateDocument } from './doc';
declare const module: any;
async function bootstrap() {
const app = await NestFactory.create(
AppModule,
new FastifyAdapter(),
);
// 统一响应体格式
app.useGlobalInterceptors(new TransformInterceptor());
// 异常过滤器
app.useGlobalFilters(new AllExceptionsFilter(), new HttpExceptionFilter());
// 接口版本化管理
app.enableVersioning({
defaultVersion: [VERSION_NEUTRAL, '1', '2'],
type: VersioningType.URI,
});
+ // 创建文档
+ generateDocument(app)
// 添加热更新
if (module.hot) {
module.hot.accept();
module.hot.dispose(() => app.close());
}
await app.listen(3000);
}
bootstrap();
完成上述内容之后,浏览器打开 http://localhost:3000/api/doc 就能看到 Swagger
已经将我们的前面写好的接口信息收集起来了。
从上图可以看出,
Swagger
会默认收集我们的接口信息,但是没有描述与分类,使用上很不方便,由于使用过程中的细节较多,具体的配置细节可以从官网文档获取。
!!!! 注意热重载这个请使用 NestJS 直接创建的项目使用,同时会有不少缓存的问题,不建议在 MonoRepo 的项目中使用,这里只做了简单的介绍。
NestJS
的 dev
模式是将 TS
代码编译成 JS
再启动,这样每次我们修改代码都会重复经历一次编译的过程。在项目开发初期,业务模块体量不大的情况下,性能开销并不会有很大的影响,但是在业务模块增加到一定数量时,每一次更新代码导致的重新编译就会异常痛苦。为了避免这个情况,NestJS
也提供了热重载的功能,借助 Webpack
的 HMR
,使得每次更新只需要替换更新的内容,减少编译的时间与过程。
注意:
Webpack
并不会自动将(例如graphql
文件)复制到dist
文件夹中。同理,Webpack
与静态路径(例如TypeOrmModule
中的entities
属性)不兼容。所以如果有同学跳过本章,直接配置了TypeOrmModule
中的entities
,反过来再直接配置热重载会导致启动失败。
由于我们是使用 CLI
插件安装的工程模板,可以直接使用 HotModuleReplacementPlugin
创建配置,减少工作量。
$ yarn add webpack-node-externals run-script-webpack-plugin webpack
webpack-hmr.config.js
文件,复制下述代码:const nodeExternals = require('webpack-node-externals');
const { RunScriptWebpackPlugin } = require('run-script-webpack-plugin');
module.exports = function (options, webpack) {
return {
...options,
entry: ['webpack/hot/poll?100', options.entry],
externals: [
nodeExternals({
allowlist: ['webpack/hot/poll?100'],
}),
],
plugins: [
...options.plugins,
new webpack.HotModuleReplacementPlugin(),
new webpack.WatchIgnorePlugin({
paths: [/.js$/, /.d.ts$/],
}),
new RunScriptWebpackPlugin({ name: options.output.filename }),
],
};
};
main.ts
,开启 HMR
功能:declare const module: any;
async function bootstrap() {
if (module.hot) {
module.hot.accept();
module.hot.dispose(() => app.close());
}
}
bootstrap();
"start:hotdev": "cross-env RUNNING_ENV=dev nest build --webpack --webpackPath webpack-hmr.config.js --watch"
然后修改一段简单的代码(随意修改即可),测试一下热更新的是否正常生效:
如上图所示,我们已经开启了 HMR
功能,具体什么时候使用可以根据自己的项目以及喜好开启,如果没有使用 CLI
创建的工程模板,但也想开启 HMR
功能的话,可以根据文档 自行配置。
热更新的功能看自己的需求再开启,有的时候存在缓存的情况出现,另外,在使用热更新的时候,数据库章节中实体类需要手动注册,不能自动注册,所以如果项目不大的啥情况,使用 NestJS 自带的项目启动脚本即可。
本章主要介绍了,对 CLI
创建的标准工程模板进行一些常规项目必备的功能配置,例如替换底层 HTTP
框架、环境变量配置等等内容。
添加了上述通用性基础配置后的工程模板能基本满足一个小型的业务需求,如果还有其他要求的话可以增减功能或者修改某些配置来适配,总体还是看团队自身的业务需求来定制,比如团队中有统一权限控制的插件
或者构建服务的脚本
都可以放在工程模板中,方便其他同学开箱即用。
至此相信我们已经对 NestJS
有了初步了解。下一章,我们将学习数据库的相关内容。
如果你有什么疑问,欢迎在评论区提出。
14 服务端实战:基础大综合