NestJS 开发环境搭建指南

前言

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

初始化项目

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

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

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

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

目录简介

src 目录

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

1
2
3
4
5
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

1
2
3
4
5
# 代码检查
npm run lint

# 格式化代码
npm run format

nest-cli.json

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

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"$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文件。

1
2
3
4
5
6
7
8
{
"compilerOptions": {
//...其他配置省略...
"paths": {
"@/*": ["src/*"]
}
}
}

数据库的连接

说明以及安装

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

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

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

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

1
2
3
4
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 文件

1
2
3
4
5
6
7
8
9
10
generator client {
provider = "prisma-client-js"
}

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

创建数据库

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

1
2
3
4
5
6
// ... 其他的配置
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
}

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

1
npx prisma migrate dev --name init

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

Nestjs 上使用

安装客户端

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

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

该目录下新建 prisma.service.ts

1
2
3
4
5
6
7
8
9
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

1
2
3
4
5
6
7
8
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

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

之后在users.module.ts中导入

1
2
3
4
5
6
7
8
9
10
11
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 文件夹。

然后新建如下文件

1
2
3
src\\users\\users.controller.ts
src\\users\\users.module.ts
src\\users\\users.service.ts

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

users.service.ts

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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 类型的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 层。

1
2
3
4
5
6
7
8
9
10
11
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

1
2
3
4
5
6
7
8
9
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

1
2
3
4
export class UsersDto {
public user: string;
public email: string;
}

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

1
2
3
4
5
6
7
8
9
10
11
......
@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

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

下面的需要安装包

1
pnpm add class-validator class-transformer

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

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

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

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

1
2
3
4
5
6
7
8
9
10
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 层中使用它的相关装饰器来校验参数了

1
2
3
4
5
6
7
8
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 方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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 告知客户端错误原因
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 返回给调用者的视图结构
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层来使用我们创建好的工具类了,示例代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 层

1
2
3
4
5
6
7
8
9
10
11
@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 },
});
}
}

返回的结果就会变成

1
2
3
4
5
6
7
8
9
10
{
"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关键字来声明一个接口,那么上述例子转换为代码后就如下所示:

1
2
3
4
5
6
7
8
typescript复制代码;
export interface AppInterface {
getTitle(): string;
getName(): string;
getAge(): string;
setName(): string;
setTitle(): string;
}

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

1
2
3
4
5
6
7
8
9
10
11
12
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 层的服务在这里一一引入即可。
1
2
3
4
5
6
7
8
9
10
11
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