使用NestJS搭建服务端应用

11,887 阅读16分钟

前言

最近有个需求需要基于前端技术栈实现一套中间层API接口,用于处理由前端维护的一套JSON配置文件。

经过一番查找后,最终选择了nest.js这个框架,由于它支持AOP编程,与SpringBoot的写法较为相似,可以将SpringBoot那套架构思想应用过来,这对于我这个全干工程师(懂亿点点Java)来说就非常友好了😁

经过3天的学习与折腾,终于搭建了一套我比较满意的架构,本文就跟大家分享下我的架构方案,欢迎各位感兴趣的开发者阅读本文。

写在前面

本文所讲内容会涉及到TypeScript,如果你对它还不够理解,请先移步:TypeScript中文文档学习下,入个门🤓。

  • 本文完整项目代码移步:nest-project

  • 本文中所安装的依赖包要求你的node版本必须在14.16.0及以上。

你可以使用node版本管理控制器n来管理你的node版本,你可以使用npm install -g n来安装它。

安装完成后,你只需使用n 版本号即可安装并切换到对应版本的node了。macos下使用可能需要使用sudo n 版本号。例如:n 14.16.0

有关n的更多使用方法请移步:n-github

环境搭建

在nest官网中,它提供了三种搭建方式

  • 使用CLI安装
  • 使用Git安装
  • 手动创建

这三种安装方式都比较简单,感兴趣的开发者可自行查阅文档来了解学习。为了锻炼大家的动手能力,本文不采用上述方法来搭建项目,我们将从0开始使用yarn初始化一个空项目,然后安装nest的相关依赖包。

注意:如果你已经搭建好了环境,请跳过此章节,前往下一个章节:项目架构

初始化一个空项目

本文使用yarn来初始化项目,如果你没有安装的话需要先使用npm来安装下,命令如下:

  • npm install --global yarn

安装完成后,可以使用命令:yarn --version 来验证下是否安装成功,如果成功你会看到如下所示的输出:

image-20220111215750509

接下来,我们创建一个名为nest-project的空文件夹,在终端进入这个文件夹,使用命令:yarn init来初始化一个项目,如下所示,根据自己的需要填写即可,带括号的部分可以不填写保持默认,直接回车即可。

image-20220111222505312

随后,我们打开这个项目,文件夹中只有一个package.json文件,内容如下所示:

{
  "name": "nest-project",
  "version": "1.0.0",
  "main": "index.js", // 这个可以删除,不需要这个字段
  "author": "likai",
  "license": "MIT",
  "private": true
}

上述内容就是我们刚才在终端所选择的,因此你也可以自己创建一个空文件,创建这个json文件,写上对应的配置,达到相同的结果。

安装nest依赖包

我们打开刚才创建的package.json文件,添加如下所示的字段:

{
  "dependencies": {
    "@nestjs/common": "^8.1.1",
    "@nestjs/core": "^8.1.1",
    "@nestjs/platform-express": "^8.1.1",
    "class-transformer": "^0.5.1",
    "class-validator": "^0.13.2",
    "reflect-metadata": "^0.1.13",
    "rimraf": "^3.0.2",
    "rxjs": "^7.4.0"
  },
  "devDependencies": {
    "@nestjs/cli": "^8.1.3",
    "@nestjs/schematics": "^8.0.4",
    "@types/express": "^4.17.13",
    "@types/node": "^16.11.1",
    "supertest": "^6.1.6",
    "ts-loader": "^9.2.6",
    "ts-node": "^10.3.0",
    "tsconfig-paths": "^3.11.0",
    "tslib": "^2.3.0",
    "typescript": "^4.4.4",
    "webpack": "5.0.0"
  }
}

随后,我们打开终端,进入项目目录,执行yarn install 命令,成功后的界面如下所示:

image-20220111225541175

安装代码规范依赖包

本文采用eslint和prettier来规范代码,对此不了解的开发者请移步我的另一篇文章:独立使用ESLint+Prettier对代码进行格式校验

接下来,我们打开前面所创建的package.json文件,在devDependencies对象中添加下述代码:

{
  "devDependencies": {
    "eslint": "^7.0.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-prettier": "^3.3.1",
    "prettier": "^2.2.1",
    "@typescript-eslint/eslint-plugin": "^4.18.0",
    "@typescript-eslint/parser": "^4.18.0",
  }
}

添加完成后,执行yarn install就完成了依赖包的引入。

添加启动命令

安装完所有依赖后,接下来我们在package.json中添加6个运行脚本,用于项目的启动与打包构建,如下所示:

  • prebuild 移除dist目录
  • build 打包项目
  • start 启动项目
  • start:dev 启动项目(支持热更新)
  • start:debug 以debugger模式启动项目(支持断点调试)
  • start:prod 启动打包后的项目
{
  "scripts": {
    "prebuild": "rimraf dist",
    "build": "nest build",
    "start": "nest start",
    "start:dev": "nest start --watch",
    "start:debug": "nest start --debug --watch",
    "start:prod": "node dist/main"
  }
}

配置路径别名

项目内引入文件的时候,我们都会使用@来指向src目录。我们需要修改webpack.config.jstsconfig.json文件。

首先,在webpack.config.js文件中添加:

module.exports = {
  // ...其他配置省略...
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src")
    }
  }
}

在 tsconfig.json文件中添加:

{
  "compilerOptions": {
    	//...其他配置省略...
    "paths": {
      "@/*": ["./src/*"]
    }    
  }
}  

添加配置文件

接下来,我们还需要在项目根目录添加nest、eslint、prettier等配置文件,如下所示:

具体的文件内容,点击上方蓝色字体可直接跳转到GitHub中对应的文件。

项目架构

本章节将跟大家下分享我的项目架构,首先在项目根目录创建src文件夹,所有项目代码将存放在此目录下。

本章节节对应的的完整项目代码移步:nest-project

控制层

这一层用于处理客户端传入的请求以及向客户端返回响应,所有的请求映射都会在这一层来实现。每个请求会对应一个控制器,一个控制器中可以有多个子方法用于处理同类型的不同操作。

举例说明

接下来,我们在src目录下创建controller文件夹,在其目录下新建一个AppController.ts文件。

我们从一个例子入手:

  1. 处理/home/setTitlepost请求,它的参数在http body中
  2. 处理/home/getTitleget请求,它的参数在请求url中

实现代码

翻阅官方文档后,我们就可以写出如下所示的代码:

import { Body, Controller, Get, Query, Post } from "@nestjs/common";

@Controller("home")
export class AppController {
  @Post("setTitle")
  setTitle(@Body() data: { id: number; title: string }): {
    code: number;
    data: null | string;
    msg: string;
  } {
    // 客户端传入的数据
    console.log(data);

    // 返回给客户端的数据
    return { code: 0, data: null, msg: "post方法调用成功" };
  }

  @Get("getTitle")
  getTitle(@Query("id") id: number): {
    code: number;
    data: string;
    msg: string;
  } {
    console.log("客户端传入的数据", id);
    return { code: 0, data: null, msg: "get方法调用成功" };
  }
}

我们来看下上述代码中各个装饰器的作用:

  • @Controller 用于标识此文件是一个控制器,它接受一个参数,此处我写了home,代表所有/home的请求都会进到这里。
  • @Post 用于处理post格式的请求,它也接受一个参数,此处我写了setTitle,代表/home/setTitle的post请求会进到这里。
  • @Body用于获取http body中的数据
  • @Query用于获取请求url中的数据

在nest文档中,它提供的装饰器还有很多,可以应付各种开发场景,详情请移步:控制器- request

服务层

服务层用于处理具体的业务逻辑,当我们收到客户端的请求后,取出参数编写具体的业务代码。

举例说明

接下来,我们在src目录下创建service文件夹,在其目录下新建一个AppService.ts文件。

举个例子:

  • 写一个方法,根据id来做一些事情,做完后返回操作结果。

实现代码

查阅文档后,我们知道了需要使用@Injectable()来装饰这个类,代码如下所示:

import { Injectable } from "@nestjs/common";

@Injectable()
export class AppService {
  public setTitle(id: string): {
    code: number;
    data: null | string;
    msg: string;
  } {
    // 根据id做一些事情,此处省略
    console.log(id);
    // 返回操作结果
    return { code: 0, data: null, msg: "设置成功" };
  }
}

做完上述操作后,我们还需要改造下AppController,在constructor中引入我们刚才创建好的service,部分代码如下所示:

export class TextAttributeController {
  constructor(private readonly appService: AppService) {}
  @Post("setTitle")
  setTitle(){
    // 此处省略了较多代码,这里的重点是演示如何调用我们刚才写好的方法
    return this.appService.setTitle();
  }
}

一个service类中会有很多方法,我们会根据控制层的映射建立与之对应的处理方法,这样就可以让控制层更专心的处理它的分内之事,提升代码可读性。

接口层

这一层用于声明每个service类中都有哪些方法,可以很大程度提升代码的可读性。如果没有这一层,当service中的方法越来越多时,代码也会特别长,想快速找到某个方法,将会变得很费时。

举例说明

接下来我们在src目录下创建interface文件夹,在其目录下新建一个AppInterface.ts文件。

举个例子,我们需要在声明5个方法,分别如下所示:

  1. getTitle
  2. getName
  3. getAge
  4. setName
  5. setTitle

实现代码

在TypeScript中用interface关键字来声明一个接口,那么上述例子转换为代码后就如下所示:

export interface AppInterface {
  getTitle(): string;
  getName(): string;
  getAge(): string;
  setName(): string;
  setTitle(): string;
}

做完上述操作后,我们还需要改造下service层的代码,让其实现这个接口,部分代码如下所示:

@Injectable()
export class AppService implements AppInterface {
  getAge(): string {
    return "";
  }

  getName(): string {
    return "";
  }
  
  // 	其他方法省略
}

在TypeScript中,我们使用implements关键字来实现一个接口。

模块层

这一层是使用@Module() 装饰器的类,它提供了元数据,Nest 用它来组织应用程序结构。我们有了控制层和服务层后,它们还无法运行,因为它们缺少一个组织。

实现代码

接下来,我们在src目录下创建module文件夹,在其目录下创建AppModule.ts文件,代码如下所示:

  • controllers 是一个数组类型的数据,我们把controller层的控制器在这里一一引入即可。
  • providers 也是一个数组类型的数据,我们把service层的服务在这里一一引入即可。
import { Module } from "@nestjs/common";
import { AppController } from "../controller/AppController";
import { AppService } from "../service/AppService";

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService]
})
export class AppModule {}

有关controllers与providers的详细介绍,请移步:Nest-@module

配置入口文件

接下来,我们在src目录下,创建main.ts文件,它的代码如下所示:

  • 导入AppModule,使用NestFactory来创建实例
  • 将3000端口设为本项目的监听
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./module/AppModule";

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

最后,我们运行package.json中的start:dev命令,在浏览器访问http://127.0.0.1:3000即可访问该项目。

image-20220114230042606

验证控制层创建的控制器

接下来,我们来验证下前面在AppController.ts中写的两个方法是否能正常运行。

验证Get方法

我们先来验证下get请求的访问情况,在浏览器访问http://127.0.0.1:3000/home/getTitle?id=12,客户端的界面如下所示:

image-20220114230439191

服务端同样也会输出客户端在地址栏所传的id,如下所示:

image-20220114230550220

验证Post方法

我们需要使用postman来测试post方法能否正常访问,假设你已经安装好了postman,我们新建一个请求,写入地址http://127.0.0.1:3000/home/setTitle,访问结果如下所示:

image-20220114230935445

同样的,服务端也会收到我们在http body中所传的json数据,如下所示:

image-20220114231123801

DTO层(处理客户端参数)

在前面的例子中,我们获取客户端的参数都是直接写在控制器内每个方法的参数中的,这样做引发的问题有:

  1. 会降低代码的可读性,一大串参数写在方法里很不优雅。
  2. 当很多方法都都需要传入相同参数时,要写很多重复代码,可维护性大大降低。
  3. 参数的有效性验证需要写在控制器内的方法中,会产生冗余代码。

DTO层的作用就是解决上述问题的,我们用class来处理客户端传入的参数。

实现代码

我们在src目录下创建DTO文件夹,在其目录下创建AppDto.ts文件,代码如下所示:

export class AppDto {
  public id: string;
  public title: string;
  public name: string;
}

export class GetNameDto extends AppDto {
  public type: string;
}

随后,我们在AppController.ts中的方法里使用即可,代码如下所示:

import { AppDto, GetNameDto } from "../dto/AppDto";

@Controller("home")
export class AppController {
  @Post("setTitle")
  setTitle(@Body() data: AppDto): {
    code: number;
    data: null | string;
    msg: string;
  } {
    // 其他代码省略
  }
  
  @Get("getName")
  getName(@Body() data: GetNameDto): {
    code: number;
    data: null | string;
    msg: string;
  } {
    // 其他代码省略
  }
}

完成上述操作后,我们就成功解决了1,2问题。由于参数的接收是采用类实现的,因此我们可以利用继承来避免冗余代码。

使用管道验证参数的有效性

接下来,我们使用管道来解决第3个问题,在nest官网中,它提供了8个开箱即用的内置管道,此处我们需要用它的ValidationPipe管道来验证参数。

根据文档所述,在使用前我们需要先绑定管道,官网给出了两种方法:

  • 绑在 controller 或是其方法上,我们使用 @UsePipes() 装饰器并创建一个管道实例,并将其传递给 Joi 验证。
  • 在入口处将其设置为全局作用域的管道,用于整个应用程序中的每个路由处理器。

此处我们使用全局作用域的管道,修改main.ts文件,代码如下所示:

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./module/AppModule";
import { ValidationPipe } from "@nestjs/common";

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

有关管道的具体原理请移步:nest-绑定管道

随后,我们即可在dto层中使用它的相关装饰器来校验参数了,AppDto.ts的部分代码如下所示:

import { IsString, MinLength } from "class-validator";

export class AppDto {
  @MinLength(5)
  @IsString()
  public id!: string;
  @IsString()
  public title!: string;
  @IsString()
  public name!: string;
}

export class GetNameDto extends AppDto {
  @IsString()
  public type!: string;
}

最后,我们使用postman来测试下是否生效,如下所示:

  • 传入了一个number类型的id
  • 没传name参数

服务端返回了400错误,并告知了错误原因。

image-20220116221632391

因为我们将参数的非空验证交给了装饰器,我们在dto类中,就需要用!:操作符来断言某个参数一定有值。

我们从class-validator'包中引入了string类型的验证装饰器,它还能验证其它类型,感兴趣的开发者请移步:class-validator#usage

VO层(返回给客户端的视图)

通常情况下,我们返回给客户端的字段是固定的,在本文前面的controller层中,两个方法我们都返回了codedatamsg这三个字段,只是数据不同。那么我们就应该把它封装起来,将数据作为参数传入,这样就大大的提高了代码的可维护性,也就是我们所说的VO层。

封装工具类

我们在src目录下创建VO文件夹,在其目录下创建ResultVO.ts文件,代码如下所示:

  • 简单创建了一个类,添加了三个字段
  • 为每个字段写了get和set方法
export class ResultVO<T> {
  private code!: number;
  private msg!: string;
  private data!: T | null;

  public getCode(): number {
    return this.code;
  }

  public setCode(value: number): void {
    this.code = value;
  }

  public getMsg(): string {
    return this.msg;
  }

  public setMsg(value: string): void {
    this.msg = value;
  }

  public getData(): T | null {
    return this.data;
  }

  public setData(value: T | null): void {
    this.data = value;
  }
}

随后,我们在src目录下创建utils文件夹,在其目录下创建VOUtils.ts文件,封装常用方法,便于其他层直接调用,代码如下所示:

  • 我们封装了successerror方法
  • 成功时,传入data进来
  • 失败时,传入code与msg告知客户端错误原因
// 返回给调用者的视图结构
import { ResultVO } from "../VO/ResultVO";

export class VOUtils {
  public static success<T>(data?: T): ResultVO<T> {
    const resultVo = new ResultVO<T>();
    resultVo.setCode(0);
    resultVo.setMsg("接口调用成功");
    resultVo.setData(data || null);
    return resultVo;
  }

  public static error(code: number, msg: string): ResultVO<null> {
    const resultVo = new ResultVO<null>();
    resultVo.setCode(code);
    resultVo.setMsg(msg);
    return resultVo;
  }
}

注意:success方法支持传入的参数是任意类型的,实际的业务需求中,data这一层会很复杂,你在实际使用时,可以根据具体的业务需求创建对应业务的vo类,然后对其进行实例化,为每个字段赋值。最后在调用success方法时将你实例化后的对象传入即可。

在业务代码中使用

随后,我们就可以在service层来使用我们创建好的工具类了,示例代码如下所示:

import { VOUtils } from "../utils/VOUtils";

@Injectable()
export class AppService implements AppInterface {
  // 其它代码省略
  setTitle(): VOUtils {
    return VOUtils.success("标题设置成功");
  }
}

接口调用结果如下所示:

image-20220116231739210

类型层

我们在写业务代码时,会碰到许许多多的Object类型的数据,通常情况下我们会给每个字段定义具体的类型,此时我们就需要将所有的类型放在一起,方便维护,此处我的做法是在src目录下创建type文件夹,将所有的类型定义都放在这个文件夹里,代码如下所示:

  • 创建了一个type文件夹
  • type文件夹下创建了AppDataType.ts文件,用于存放所有类型
export type book = {
  title: string;
  author: string;
  time: string;
  updateTime: string;
};

export interface specialBook extends book {
  id: number;
  createTime: string;
}


注意:所有的类型定义我们都用type关键词来定义,使用的时候直接导入即可,当我们要继承某个类型时,就必须要使用interface关键词了。

枚举层

我们写业务代码时,肯定会遇到各种异常状况,当服务端发生异常时,我们就需要在VO层返回错误信息与状态码,如果我们直接将数据写在方法里,后期需要修改时,将会是一件很头痛的事情。那么,当我们把这些数据统一在枚举层进行定义,在业务代码中直接使用我们定义好的枚举,这个问题就迎刃而解了。

我们在src目录下创建enum文件夹,在其文件夹下创建AppEnum.ts文件,代码如下所示:

  • NOTFOUND 表示错误码
  • NOTFOUND_DESCRIPTION 表示错误码的描述信息
export enum AppEnum {
  NOTFOUND = -1,
  NOTFOUND_DESCRIPTION = "未找到相关人物"
}

随后,我们在业务代码使用即可,如下所示:

@Injectable()
export class AppService implements AppInterface {
  // 其它代码省略
  getName(): VOUtils {
    return VOUtils.error(AppEnum.NOTFOUND, AppEnum.NOTFOUND_DESCRIPTION);
  }

}

注意:typescript中的枚举不能像Java一样在定义的时候就设置相关的描述信息,所以此处只能选择曲线救国的方式在定义错误吗的时候多定义一个以__DESCRIPTION结尾的枚举。

项目代码

本文所使用的完整代码,请移步项目的GitHub仓库:nest-project

写在最后

至此,文章就分享完毕了。

我是神奇的程序员,一位前端开发工程师。

如果你对我感兴趣,请移步我的个人网站,进一步了解。

  • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊
  • 本文首发于神奇的程序员公众号,未经许可禁止转载💌