前言

NestJS 是一个高效、可扩展的 Node.js 框架,它提供了一套成熟的模式和功能,帮助开发者构建可维护和可扩展的服务端应用。本教程将引导你使用 NestJS 搭建一个服务端应用,并介绍如何对接 PostgreSQL 数据库、配置环境变量以及实现用户密码加密。

初始化项目

请确保你的操作系统上安装了 Node.js(version ≥ 16)

我们采用官网提供的 Nest CLI 方式设置项目。

npm i -g @nestjs/cli
nest new project-name --strict
## 进入安装的时候会让你选择包管理器,这里选择pnpm

要使用 TypeScript 更严格的功能集创建新项目,请将 --strict 标志传递给 nest new 命令。

目录简介

src 目录

这里是主要代码,初始化的项目打开会看到

src\\app.controller.spec.ts // 控制器的单元测试。
src\\app.controller.ts // 具有单一路线的基本控制器。(控制器)
src\\app.module.ts // 应用程序的根模块。这里是module层,主要是引入,抛出,表明控制层
src\\app.service.ts // 具有单一方法的基本服务。这里编写了服务层代码
src\\main.ts // 使用核心函数 NestFactory 创建Nest应用程序实例的应用程序的入口文件。这里配置了端口等信息

之后运行 pnpm run start:dev 。就可以访问http://localhost:3000/ 会出现 hello world 的字眼。

package.json

这个文件前端开发都熟悉,不过多解释。主要是了解初始化的项目已经安装了什么。

分别安装了主要的代码检测和格式化插件 eslintprettier

# 代码检查
npm run lint

# 格式化代码
npm run format

nest-cli.json

这个文件主要是配置插件和库以及项目的配置,例如你需要 @nestjs/graphql 插件或者自定义插件。以下是简单的教程,基本上可以不用修改文件,仅做了解。

{
  "$schema": "<https://json.schemastore.org/nest-cli>",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true,
    "plugins": ["@nestjs/graphql"],

    //自定义
    "typeFileNameSuffix": [".input.ts", ".args.ts"], // GraphQL 类型文件后缀
    "introspectComments": true //如果设置为 true,插件将根据注释生成属性描述
  }
}

项目文件别名

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

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

数据库的连接

说明以及安装

在连接数据库之前,我们需要了解数据库和 orm 之间的关系,orm 是一种数据关系映射的工具,它用作编写纯 SQL 或使用其他数据库访问工具。这样的话,我们不用关心多种数据库的连接,改变数据库版本之后要修改代码等等问题。

pnpm install prisma --save-dev
npx prisma // 获取命令
npx prisma init // 初始化prisma

初始化之后会得到 prisma 的文件夹(如果你初始化失败,八成是你的网络连不上国外的网络)。

新建 .env 文件,这个是本地的环境变量设置

DATABASE_URL="file:./dev.db" // SQLite 数据库是简单的文件,所以你建立一个dev.db的文件就好了
DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public" // postgresql的数据库连接
DATABASE_URL="mysql://USER:PASSWORD@HOST:PORT/DATABASE" // mysql的数据库连接
// 上面的3个选择你自己的数据库就可以了

prisma 配置

上面新建的环境变量文件,那么就需要 prisma 来连接数据库了。

打开 prisma\\schema.prisma 文件

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql" // 如果你是MySQL/SQLite 对应的字段为"mysql"、"sqlite"
  url      = env("DATABASE_URL")
}
// 上面的就是postgresql的连接
// 如果你使用了第三方的postgresql数据库,那么配置上的修改安装他们的提示

创建数据库

添加如下的模型到 prisma\\schema.prisma 文件中

// ... 其他的配置
model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
}

之后做如下操作,同步本地的表结构到数据库中。

npx prisma migrate dev --name init

后续数据库有更新,用 npx prisma db push/pull 来操作。

Nestjs 上使用

安装客户端

pnpm install @prisma/client ## 执行的同时会运行 prisma generate 。表结构有更新手动执行npx prisma generate 生成新的客户端关系

安装完之后为了让项目结构更加清晰,这里新建 src/prismac 文件夹,用来存放客户端创建 prisma 的操作。

该目录下新建 prisma.service.ts

import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }
}

之后再在该目录下新建 prisma.module.ts

import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

之后在 users.module.ts中导入

import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UserService } from './users.service';
import { PrismaService } from '@/prismac/prisma.service';

@Module({
  imports: [],
  controllers: [UsersController],
  providers: [UserService, PrismaService],
})
export class UsersModule {}

prisma 命令说明

命令 说明
init 在应用中初始化 Prisma
generate 主要用来生成 Prisma Client
db 管理数据库的模式和生命周期
migrate 迁移数据库
studio 启动一个 Web 端的工作台来管理数据
validate 检查 Prisma 的模式文件的语法是否正确
format 格式化 Prisma 的模式文件,默认就是 prisma/schema.prisma

项目架构

这里我通过我的一个项目来分享大致的项目架构是什么样子的,我这里写一个 users 的接口。

在 src 文件夹下新建 users 文件夹。

然后新建如下文件

src\\users\\users.controller.ts
src\\users\\users.module.ts
src\\users\\users.service.ts

因为代码都是要使用到服务层,所以我们先写服务层代码

users.service.ts

编写服务代码,主要这层就是操作 orm,来实现数据库的操作。

import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { User, Prisma } from '@prisma/client';

@Injectable()
export class UserService {
  constructor(private prisma: PrismaService) {}

  async user(
    userWhereUniqueInput: Prisma.UserWhereUniqueInput,
  ): Promise<User | null> {
    return this.prisma.user.findUnique({
      where: userWhereUniqueInput,
    });
  }

  async users(params: {
    skip?: number;
    take?: number;
    cursor?: Prisma.UserWhereUniqueInput;
    where?: Prisma.UserWhereInput;
    orderBy?: Prisma.UserOrderByWithRelationInput;
  }): Promise<User[]> {
    const { skip, take, cursor, where, orderBy } = params;
    return this.prisma.user.findMany({
      skip,
      take,
      cursor,
      where,
      orderBy,
    });
  }

  async createUser(data: Prisma.UserCreateInput): Promise<User> {
    return this.prisma.user.create({
      data,
    });
  }

  async updateUser(params: {
    where: Prisma.UserWhereUniqueInput;
    data: Prisma.UserUpdateInput;
  }): Promise<User> {
    const { where, data } = params;
    return this.prisma.user.update({
      data,
      where,
    });
  }

  async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
    return this.prisma.user.delete({
      where,
    });
  }
}

users.controller.ts

这里就是调用前面写的 service 层的函数,这里主要负责请求的参数解析等,请求的连接定义等,我这里的就是/users/sign post 类型的接口

import { Body, Controller, Post } from '@nestjs/common';
import { UserService } from './users.service';
import { User as UserModel } from '@prisma/client';

@Controller('/users')
export class UsersController {
  constructor(private readonly userService: UserService) {}

  @Post('sign')
  async signupUser(
    @Body() userData: { name?: string; email: string },
  ): Promise<UserModel> {
    return this.userService.createUser(userData);
  }
}

users.modules.ts

这里主要是负责注册或者抛出模块,,因为 cotroller 层需要 PrismaService ,所以注册进来。让后表明 controller 层。

import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UserService } from './users.service';
import { PrismaService } from '@/prismac/prisma.service';

@Module({
  imports: [],
  controllers: [UsersController],
  providers: [UserService, PrismaService],
})
export class UsersModule {}

最后在 app.module.ts 中导入这个 UsersModule

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

import { UsersModule } from './users/users.module';
@Module({
  imports: [UsersModule],
  controllers: [],
  providers: [],
})
export class AppModule {}

以上就是最基础的操作,为了项目的规范化,下面还有其他层。

Dto 层

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

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

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

新建 src/users/users.dto.ts

export class UsersDto {
  public user: string;
  public email: string;
}

之后在 src/users/users.controller.ts中使用

......
@Controller('/users')
export class UsersController {
  constructor(private readonly userService: UserService) {}

  @Post('user')
  async signupUser(@Body() userData: UsersDto): Promise<UserModel> {
    return this.userService.createUser(userData);
  }
}
//这样就解决了问题1和问题2

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

下面的需要安装包

pnpm add class-validator class-transformer

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

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

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

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

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

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

随后,我们即可在 dto 层中使用它的相关装饰器来校验参数了

import { IsString, MinLength } from "class-validator";
export class UsersDto {
  @MinLength(2)
  @IsString()
  public user: string;
  @IsString()
  public email: string;
}

之后你测试 127.0.0.1:3000/users/user 接口,输入名字就 1 个字的时候,接口会返回提示。

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

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

封装工具类

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

  • 简单创建了一个类,添加了三个字段
  • 为每个字段写了 get 和 set 方法
typescript复制代码;
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层来使用我们创建好的工具类了,示例代码如下所示:

export class UserService{
......
import { VOUtils } from '@/utils/voUtils';
async updateUser(params: {
  where: Prisma.UserWhereUniqueInput;
  data: Prisma.UserUpdateInput;
}): Promise<VOUtils> {
  const { where, data } = params;
  let result = await this.prisma.user.update({
    data,
    where,
  });
  return VOUtils.success(result);
}

然后修改一些 Controller 层

@Controller('/users')
export class UsersController{

  @Post('update')
  async updateUser(@Body() userData: UsersDto): Promise<VOUtils> {
    return this.userService.updateUser({
      where: { id: Number.parseInt(userData.id) },
      data: { name: userData.name, email: userData.email },
    });
  }
}

返回的结果就会变成

{
    "code": 0,
    "msg": "接口调用成功",
    "data": {
        "id": 1,
        "email": "test1@qq.com",
        "name": "testChange",
        "age": null
    }
}

接口层

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

举例说明

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

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

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

实现代码

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

typescript复制代码;
export interface AppInterface {
  getTitle(): string;
  getName(): string;
  getAge(): string;
  setName(): string;
  setTitle(): string;
}

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

typescript复制代码
@Injectable()
export class AppService implements AppInterface {
  getAge(): string {
    return "";
  }

  getName(): string {
    return "";
  }

  // 	其他方法省略}

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

模块层

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

实现代码

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

  • controllers 是一个数组类型的数据,我们把 controller 层的控制器在这里一一引入即可。
  • providers 也是一个数组类型的数据,我们把 service 层的服务在这里一一引入即可。
typescript复制代码;
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