14、服务端实战:基础大综合

前言


在上一章节中,我们使用 CLI 与 PNPM 得到了一套基础的项目工程。

Nest 为开发者提供了非常多的内置或配套的功能例如高速缓存、日志拦截、过滤器、微服务等多种模块,方便开发者根据自身的业务需求定制适合当前业务的工程,但 CLI 提供的基础框架并没有内置这些服务,需要开发自己配置。

本章将根据业务需求选择对应的模块搭建出一个符合要求的通用性脚手架。

Fastify & Express


在上一本《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();
  }

14、服务端实战:基础大综合_第1张图片

配置完毕之后从上图可以看到,只有携带了版本号的请求 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 可以获取到如下的返回值:

14、服务端实战:基础大综合_第2张图片

全局返回参数


在配置版本的过程中,也不断地测试了很多次接口,不难发现返回的接口数据非常的不标准,在一个正常的项目中不太合适用这种数据结构返回,毕竟这样对前端不友好,也不利于前端做统一的拦截与取值,所以需要格式化请求参数,输出统一的接口规范。

一般正常项目的返回参数应该包括如下的内容:

{
    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());

然后我们再次访问之前的请求,就能获取到标准格式的接口返回值了:

14、服务端实战:基础大综合_第3张图片

全局异常拦截


处理完正常的返回参数格式之后,对于异常处理也应该做一层标准的封装,这样利于开发前端的同学统一处理这类异常错误。

第一步:新建 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 ,此时可以对比自定义与原生的异常返回参数区别。

14、服务端实战:基础大综合_第4张图片

验证完 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 ,此时可以看到原生与自定义返回的异常错误存在一定的区别了。

14、服务端实战:基础大综合_第5张图片

除了全局异常拦截处理之外,我们需要再新建一个 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 ,可以看到能够返回我们预期的错误了。

14、服务端实战:基础大综合_第6张图片

自定义业务异常的优点在于,当你的业务逻辑复杂到一定的地步,在任意的一处出现可预知的错误,此时可以直接抛出异常让用户感知,不需要写很多冗余的返回代码。

异常拦截、全局返回参数修改以及替换 Fastify 框架的代码已上传 demo/v2, 需要的同学可以自取。

环境配置


一般在项目开发中,至少会经历过 Dev -> Test -> Prod 三个环境。如果再富余一点的话,还会再多一个 Pre 环境。甚至在不差钱的情况下,每个环境可能都会有多套配置。那么对应的使用的数据库、Redis 或者其他的配置项都会随着环境的变换而改变,所以在实际项目开发中,多环境的配置非常必要。

自带环境配置

NestJS 本身也自带了多环境配置方法

  1. 安装 @nestjs/config
$ pnpm add @nestjs/config -w
  1. 安装完毕之后,在 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

自定义 YAML

虽然 Nest 自带了环境配置的功能,使用的 dotenv 来作为默认解析,但默认配置项看起来并不是非常清爽,我们接下来使用结构更加清晰的 YAML 来覆盖默认配置。

想要了解 YAML 更多细节的同学可以点击链接看下,如果使用过 GitLab CICD 的同学,应该对 .yml 文件比较熟悉了,这里我就不对 YAML 配置文件做过多阐述了。

  1. 在使用自定义 YAML 配置文件之前,先要修改 app.module.ts 中 ConfigModule 的配置项 ignoreEnvFile,禁用默认读取 .env 的规则:
ConfigModule.forRoot({ ignoreEnvFile: true, });
  1. 然后再安装 YAML 的 Node 库 yaml
$ pnpm add yaml -w
  1. 安装完毕之后,在根目录新建 .config 文件夹,并创建对应环境的 yaml 文件,如下图所示:

14、服务端实战:基础大综合_第7张图片

  1. 新建 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;
};
  1. 最后添加在 app.module.ts 自定义配置项即可正常使用环境变量:
+ import { getConfig } from './utils';
    ConfigModule.forRoot({
      ignoreEnvFile: true,
+     isGlobal: true,
+     load: [getConfig]
    }),

注意:load 方法中传入的 getConfig 是一个函数,并不是直接 JSON 格式的配置对象,直接添加变量会报错。

使用自定义配置

完成之前的配置后,就可以使用 cross-env 指定运行环境来使用对应环境的配置变量。

  1. 添加 cross-env 依赖:
$ pnpm add cross-env -w
  1. 修改启动命令:
"start:lowcode": "cross-env RUNNING_ENV=dev nest start --watch",
"start:devops": "cross-env RUNNING_ENV=dev nest start devops --watch",
  1. 添加 .dev.yaml 配置:
TEST_VALUE:
  name: cookie

注意 yaml 配置的规则,缩进以及冒号 : 后的空格是经常容易出错的地方

  1. 在 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 能看到已经能够根据环境变量拿到对应的值:

14、服务端实战:基础大综合_第8张图片

这里应该注意到,我们并没有注册 ConfigModule。这是因为在 app.module 中添加 isGlobal 属性,开启 Config 全局注册,如果 isGlobal 没有添加的话,则需要先在对应的 module 文件中注册后才能正常使用 ConfigService

文档


作为一个后端服务,API 文档是必不可少的,除了接口描述、参数描述之外,自测也十分方便。NestJS 自带了 Swagger 文档,集成非常简单,接下来进行文档的配置部分。

  1. 安装以下依赖:
$ pnpm add @nestjs/swagger -w
  1. 依赖安装完毕之后,先创建 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
  }
}
  1. 在 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 已经将我们的前面写好的接口信息收集起来了。

14、服务端实战:基础大综合_第9张图片

从上图可以看出,Swagger 会默认收集我们的接口信息,但是没有描述与分类,使用上很不方便,由于使用过程中的细节较多,具体的配置细节可以从官网文档获取。

热重载


!!!! 注意热重载这个请使用 NestJS 直接创建的项目使用,同时会有不少缓存的问题,不建议在 MonoRepo 的项目中使用,这里只做了简单的介绍。

NestJS 的 dev 模式是将 TS 代码编译成 JS 再启动,这样每次我们修改代码都会重复经历一次编译的过程。在项目开发初期,业务模块体量不大的情况下,性能开销并不会有很大的影响,但是在业务模块增加到一定数量时,每一次更新代码导致的重新编译就会异常痛苦。为了避免这个情况,NestJS 也提供了热重载的功能,借助 Webpack 的 HMR,使得每次更新只需要替换更新的内容,减少编译的时间与过程。

注意:Webpack并不会自动将(例如 graphql 文件)复制到 dist 文件夹中。同理,Webpack 与静态路径(例如 TypeOrmModule 中的 entities 属性)不兼容。所以如果有同学跳过本章,直接配置了 TypeOrmModule 中的 entities,反过来再直接配置热重载会导致启动失败。

由于我们是使用 CLI 插件安装的工程模板,可以直接使用 HotModuleReplacementPlugin 创建配置,减少工作量。

  1. 照例安装所需依赖:
$ yarn add webpack-node-externals run-script-webpack-plugin webpack
  1. 根目录新建 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 }),
    ],
  };
};
  1. 修改 main.ts,开启 HMR 功能:
declare const module: any;

async function bootstrap() {
  if (module.hot) {
    module.hot.accept();
    module.hot.dispose(() => app.close());
  }
}
bootstrap();
  1. 修改启动脚本启动命令即可:
"start:hotdev": "cross-env RUNNING_ENV=dev nest build --webpack --webpackPath webpack-hmr.config.js --watch"

然后修改一段简单的代码(随意修改即可),测试一下热更新的是否正常生效:

如上图所示,我们已经开启了 HMR 功能,具体什么时候使用可以根据自己的项目以及喜好开启,如果没有使用 CLI 创建的工程模板,但也想开启 HMR 功能的话,可以根据文档 自行配置。

热更新的功能看自己的需求再开启,有的时候存在缓存的情况出现,另外,在使用热更新的时候,数据库章节中实体类需要手动注册,不能自动注册,所以如果项目不大的啥情况,使用 NestJS 自带的项目启动脚本即可。

写在最后


本章主要介绍了,对 CLI 创建的标准工程模板进行一些常规项目必备的功能配置,例如替换底层 HTTP 框架、环境变量配置等等内容。

添加了上述通用性基础配置后的工程模板能基本满足一个小型的业务需求,如果还有其他要求的话可以增减功能或者修改某些配置来适配,总体还是看团队自身的业务需求来定制,比如团队中有统一权限控制的插件或者构建服务的脚本都可以放在工程模板中,方便其他同学开箱即用。

至此相信我们已经对 NestJS 有了初步了解。下一章,我们将学习数据库的相关内容。

如果你有什么疑问,欢迎在评论区提出。

14 服务端实战:基础大综合

你可能感兴趣的:(从,0,打造通用型低代码产品,从,0,打造通用型低代码产品)