发布于 2026-01-06 1 阅读
0

使用 NestJS 构建强大的后端服务器🚧

使用 NestJS 构建强大的后端服务器🚧

是时候谈谈这件事了……不 下一个也不 纽克斯特但是Nest

序言

前段时间,我挑战自己用 JavaScript 构建一个强大的后端解决方案。很多开发者认为 JavaScript 不够安全、效率低下,或者组织结构不够完善,无法通过 API 管理数据。我会用事实证明我的看法是错误的……

在这个新项目中,我选择了新兴技术,特别是PostgreSQL,尤其是NestJS。因此,我放弃了我一直使用的 MongoDB 数据库,转而选择了一种更传统的数据库。

:我一开始有点生疏,但回归关系型数据库真是个好主意;即使只是为了能一次调用从多个表中检索数据,也比 NoSQL 好得多,NoSQL 需要调用与要查询的文档数量一样多的次数……最后,SQL 就像骑自行车一样,让人难以忘怀😉

NestJS 是一个用于创建高可扩展性服务器应用程序(基于 NodeJS)的框架。它基于 TypeScript,并结合了三种开发范式:

  • 面向对象编程# OOP
  • 功能编程# FP
  • 功能反应编程# FRP

NestJS 的创立源于一个观察:Web 框架(尤其是面向组件的框架)的结构不够完善……但有一个例外:Angular!因此,NestJS 正是受到 Angular 的启发,旨在提供一个完整的后端开发库。

:Angular 的组织结构确实类似,但与 NestJS 不同的是,我认为它的结构比其他任何框架都更具限制性……我更喜欢 React、Vue(以及 Svelte)等框架,它们在前端应用设计方面提供了更大的灵活性。不过,我相信这种“厚重”的架构对于前端开发新手来说可能大有裨益。

最后,NestJS 嵌入了ExpressJS引擎或Fastify引擎(可选),以提供一个强大而稳定的 HTTP 服务器。所以,你可以把它看作是一个框架中的框架(元框架!?🤔)。在这个项目中,我选择了 Fastify,它看起来非常有前景……

初始化

是时候开始编写代码了!🧑‍💻 首先,我们来安装 NestJS 的主要依赖项,然后初始化一个新项目:hello-community

npm i -g @nestjs/cli
nest new hello-community
Enter fullscreen mode Exit fullscreen mode

所以,你看出它和 Angular 的相似之处了吗?🙃 使用 NestJS 命令行界面,你会得到一个“模块-控制器-服务”的架构:

  • 控制器负责暴露应用程序路由
  • 服务处理数据源之间的通信
  • 模块将服务与控制器连接起来。

注意:除了初始化项目之外,NestJS CLI 还允许您单独生成这些相同的文件(nest generate <module|controller|service>)。非常实用!

现在,我们来仔细看看这个main.ts文件,也就是应用程序的入口点。默认情况下,NestJS 会挂载一个基于 ExpressJS 的 HTTP 服务器。但是,就我的情况而言,我需要使用 Fastify,它(我们都知道)比 ExpressJS 更快更轻量级。

为此,需要将@nestjs/platform-fastify依赖项添加到项目中,然后修改main.ts文件代码。

import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter()
  );
  await app.listen(3000);
}

bootstrap();
Enter fullscreen mode Exit fullscreen mode
main.ts

瞧!只需前往[此处插入链接] http://localhost:3000,即可查看您的第一张Hello World!(或者Hello Community!如果您一直好奇的话)👏

注意:通过将该0.0.0.0字符串指定为listen()函数的第二个参数,您将能够访问同一本地网络内的端点。

在简单的调用中,很难看出 ExpressJS 和 Fastify 之间的明显区别。不过……我在加载时注意到了一些细微的差别。根据 Google Chrome 的显示,ExpressJSHello World!首次加载需要 239 字节,而Fastify仅需 176 字节(Mozilla Firefox 也证实了这一点)。显然,这个框架(至少)比它的前身更轻量级。

增删改查

让我们通过实现你的第一个 RESTful API 来让事情变得更有趣/更具体。为此,你可以使用 CLI 工具逐个创建文件(模块/控制器/服务),或者直接生成整套文件(即一个新的 CRUD 资源)。

nest g resource命令会自动为您完成此操作。只需输入资源名称(users)并指定它是 REST API,即可快速获得一个可直接使用的控制器和服务。

这种运行模式的优势之一是,NestJS 还会生成一些开发模式,包括开发者熟知的DTO数据迁移对象)。#面向对象编程

import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './interfaces/user.interface';
import { generateUuid } from '../utils';

@Injectable()
export class UsersService {
  users: User[] = [];

  findAll(): User[] {
    return this.users;
  }

  findOne(id: string): User {
    const user = this.users.find(user => user.id === id);
    if (user) return user;
    throw new HttpException('No User Found', HttpStatus.NOT_FOUND);
  }

  findOneByEmail(email: string): User {
    const user = this.users.find(user => user.email === email);
    if (user) return user;
    throw new HttpException('No User Found', HttpStatus.NOT_FOUND);
  }

  create(createUserDto: CreateUserDto): { createdId: string } {
    const uuid = generateUuid();
    this.users = [
      ...this.users,
      {
        id: uuid,
        ...createUserDto
      }
    ];
    return { createdId: uuid };
  }

  update(id: string, updateUserDto: UpdateUserDto): { updatedId: string } {
    let founded = false;
    this.users = this.users.map(user => {
      if (user.id === id) {
        founded = true;
        return { ...user, ...updateUserDto };
      }
      return user;
    });
    if (founded) return { updatedId: id };
    throw new HttpException('No User Found', HttpStatus.NOT_FOUND);
  }

  remove(id: string): { removedId: string } {
    const length = this.users.length;
    this.users = this.users.filter(user => user.id !== id);
    if (length !== this.users.length) return { removedId: id };
    throw new HttpException('No User Found', HttpStatus.NOT_FOUND);
  }
}
Enter fullscreen mode Exit fullscreen mode
users.controller.ts

对于第一个资源,我建议您保持控制器不变(users.controller.ts),但调整服务代码(users.service.ts),以便模拟数据源。

:出于实际考虑(防止文章过长),我选择展示数据类型。实际应用中,我建议您将其放置在正确的位置,例如 `<filename>` 和 `<filename>` interfacesenums或简称 `<filename> models`)文件夹中。

请确保您已users.module.ts在主模块级别注册了您的模块(app.module.ts),然后启动应用程序(npm run start)。您应该能够通过 REST 协议(当然还有Postman 😎)开始使用您的 API。

安全

严重的事情从这里开始……如果你快速回顾一下,你会发现所有端点的安全级别都一样(也就是说,根本没有安全措施)。此外,用户的密码直接保存在数据库中……所以,必须加强所有这些方面的安全!

第一步:密码混淆。NestJS 支持数据加密(使用 NodeJS 的crypto模块),也支持数据哈希。这里,我选择使用bcrypt库来哈希我的字符串,以确保单向(且最佳)安全性!👌

让我们安装依赖项(npm i --save bcrypt && npm i --save-dev @types/bcrypt)并修改用户创建服务以保护其密码。

import * as bcrypt from 'bcrypt';

@Injectable()
export class UsersService {
  // ...

  async create(createUserDto: CreateUserDto): Promise<{ createdId: string }> {
    const uuid = generateUuid();

    const salt = await bcrypt.genSalt();
    const hash = await bcrypt.hash(password, salt);

    this.users = [
      ...this.users,
      {
        id: uuid,
        password: hash,
        ...createUserDto
      }
    ];
    return { createdId: uuid };
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode
users.service.ts

第二步:使用身份验证协议保护调用安全。这项任务更为复杂,因为它需要实现一种或多种身份验证策略。幸运的是,NestJS 已经提供了“守卫”的概念,允许对应用程序的路由进行“验证/失效”操作。开始吧!

npm install --save @nestjs/passport passport passport-local passport-jwt
npm install --save-dev @types/passport-local @types/passport-jwt
Enter fullscreen mode Exit fullscreen mode

使用 NodeJS 实现此类操作的最佳方法是使用Passport。在设计方面,我需要创建一个新的身份验证路由,该路由将提供一个令牌,用于验证/users/:id端点(GET、PATCH 和 DELETE)。因此,我(如上所述)提出了两种策略:

  • 本地策略允许简单的身份验证(基于/对)usernamepassword
  • JWT策略基于令牌随时间的有效性。

同样,您需要使用命令行界面来实例化模块、控制器和服务nest g <module|controller|service> auth。此外,您还需要创建两个分别对应于 Passport 策略的新文件。

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { User } from '../users/interfaces/user.interface';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super({
      usernameField: 'email'
    });
  }

  async validate(email: string, password: string): Promise<User> {
    const user = await this.authService.validateUser(email, password);
    if (user) return user;
    throw new UnauthorizedException();
  }
}
Enter fullscreen mode Exit fullscreen mode
local.strategy.ts

注意:在这里,你会注意到我替换了LOCAL策略的默认行为,以使用email/password对。

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

interface Payload {
  id: string;
  email: string;
  iat: string;
  exp: string;
}

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.JWT_SECRET
    });
  }

  async validate(payload: Payload) {
    return { userId: payload.id, userEmail: payload.email };
  }
}
Enter fullscreen mode Exit fullscreen mode
jwt.strategy.ts

注意:在实际应用中,我建议不要暴露 JWT 密钥!最好通过其他方式获取,例如通过文件.env@nestjs/config这样模块就能派上用场了)或其他进程……

import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { User } from '../users/interfaces/user.interface';
import { UsersService } from '../users/users.service';

type UserOrNull = User | null;

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService, private jwtService: JwtService) {}

  async validateUser(email: string, password: string): Promise<UserOrNull> {
    const user = await this.usersService.findOneByEmail(email);
    const isMatch = await bcrypt.compare(password, user.password);

    if (user && isMatch) {
      return user;
    }
    return null;
  }

  async login(user: { id: string; email: string; }) {
    const payload = { userId: user.id, userEmail: user.email };

    return {
      access_token: this.jwtService.sign(payload)
    };
  }
}
Enter fullscreen mode Exit fullscreen mode
auth.service.ts
import { Controller, Post, Request, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @UseGuards(AuthGuard('local'))
  @Post('login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }
}
Enter fullscreen mode Exit fullscreen mode
auth.controller.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { LocalStrategy } from './local.strategy';

@Module({
  imports: [
    PassportModule,
    JwtModule.register({
      secret: 'Hello_Community',
      signOptions: { expiresIn: '300s' }
    }),
    UsersModule
  ],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService]
})
export class AuthModule {}
Enter fullscreen mode Exit fullscreen mode
auth.module.ts

综上所述,一些解释是必不可少的。我们先从控制器开始……

调用端点时/login,用户将以/格式“ POST ”其凭据emailpassword

然后,NestJS 将通过本地守卫”保护路由,该守卫会负责检查数据库中是否存在该用户。如果找到具有唯一电子邮件地址的用户,则会使用bcrypt ( auth.service.ts) 验证密码。

如果一切顺利,该login()函数将使用与用户相关的信息(特别是唯一标识符和电子邮件)来生成300s有效期为 5 分钟的令牌。

最后,在端点返回时,您应该检索一个 JWT 令牌(access_token),该令牌将用于验证您的 REST API 的其他调用。为此,还需要完成以下两件事:

  • 在端点上添加JWT保护/users/:id( users.controller.ts)
  • Bearer <token>在调用头中添​​加

棱镜

现在我们来谈谈数据部分。从现在开始,您需要连接到您的数据库(之前已安装在您的机器上sudo apt install postgresql),然后以 RESTful 的方式查询它,即使用 GET、POST、PATCH、DELETE 等方法。

最后一次,我们需要做出一个艰难的选择:

  • 数据库驱动程序(底层查询)
  • 查询构建器”(例如KnexJS
  • ORM——对象关系映射高级查询

:例如,我以前使用 Mongoose(一个对象文档映射器)开发 MongoDB 时,会用它查询NoSQL数据。既然我选择了使用 Postgres 的 SQL,那么使用一个新的库就势在必行了……

别慌!NestJS 来了🎉 它已经为一些库提供了“连接器”,包括:Sequelize、TypeORM 甚至 Prisma。我选择将 Sequelize 从候选名单中排除(尽管它很流行),因为我希望将来能够复用“选定的库”进行 NoSQL 开发(我离不开 MongoDB 😅)。事实上,唯一的选择就是Prisma。顺便说一句,它的理念和直观的 API 让我惊喜不已!现在是时候安装这个新的依赖项,然后初始化数据库模式了。

npm install prisma --save-dev
npx prisma init
Enter fullscreen mode Exit fullscreen mode

执行 Prisma 的init命令后,您应该会在项目根目录下看到一个新文件夹,其中包含模式的初始草稿。请按以下步骤完善它:

datasource db {
  provider = "postgresql"
  url      = "postgresql://<username>:<password>@<host>:<post>/hello-community?schema=public"
}

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

enum Gender {
  X
  Y
}

model User {
  id        String  @id @default(uuid())
  email     String  @unique
  password  String
  firstName String?
  lastName  String?
  gender    Gender
}
Enter fullscreen mode Exit fullscreen mode
schema.prisma

数据库模式描述非常清晰,它允许您初始化 PostgreSQL 数据库。显然,执行最后一条migrate命令后,Prisma 会将上述信息转换为 SQL 脚本,创建表(包含正确的字段和类型)并添加关系(主键和外键)。最终,它会将查询直接执行到数据库中。

npx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

注意:至此,您的数据库就创建好了。您可以连接到它并查看结果;或者查看migration.sql文件中(prisma文件夹中)的 SQL 查询。

npm i @prisma/client
nest g module prisma
nest g service prisma
Enter fullscreen mode Exit fullscreen mode

数据库已正确创建,但您仍需建立 PostgreSQL(通过 Prisma)和 NestJS 之间的连接。使用 NestJS CLI 工具(如上所示),我生成了两个新文件:

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

@Module({
  providers: [PrismaService],
  exports: [PrismaService]
})
export class PrismaModule {}
Enter fullscreen mode Exit fullscreen mode
prisma.module.ts
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

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

  async onModuleDestroy() {
    await this.$disconnect();
  }
}
Enter fullscreen mode Exit fullscreen mode
prisma.service.ts

最后一步:实际应用!还记得上面的 CRUD 操作吗?让我们来升级一下!现在,无需再模拟数据库,直接请求即可。Prisma API 在这方面简直太神奇了!它(得益于数据库模式)会生成相应的类型models,并提供直观的函数来查询 SQL 数据库。不妨亲自体验一下创建和检索用户的函数,看看效果如何。

注意:请务必prisma.module.ts在模块导入中注册您的新模块()users,否则将无法请求您的数据源……

import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { User, Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';

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

  // ...

  async findOne(id: string): Promise<User> {
    const user = await this.prisma.user.findUnique({ where: { id } });
    if (user) return user;
    throw new HttpException('No User Found', HttpStatus.NOT_FOUND);
  }

  // ...

  async create({ password, ...userCreateInput }: Prisma.UserCreateInput): Promise<{ createdId: string }> {
    const salt = await bcrypt.genSalt();
    const hash = await bcrypt.hash(password, salt);

    const user = await this.prisma.user.create({
      data: {
        password: hash,
        ...userCreateInput
      }
    });

    return { createdId: user.id };
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode
users.service.ts

注意:出于实际原因(防止此帖子篇幅过长),我在这里只展示了服务中的两个函数,但您可以在GitHub上查看其余代码。

尾声

NestJS 真是 Web 开发领域的一颗璀璨明珠!我用 TypeScript 开发这个全新的后端解决方案,感觉非常棒。它的架构非常清晰。顺便一提,它的 CLI 在搭建 REST 资源的全部或部分组件时非常实用。

总的来说,它的组织结构让我惊喜,这让我想起了 SpringBoot(Java 框架)或 Django(Python 框架)。文件存放位置井然有序,你很快就能找到所需的文件。这正好说明了 MVC 模式始终是一个稳妥的选择……

:为了更客观地看待这个问题,我询问了另一位专门从事 Java 开发的开发人员的意见,我可以向你保证,他完全可以沉浸在代码中(这要归功于 TypeScript)。

说到安全性,NestJS 履行了其职责,无需重复造轮子,因为它主要依赖 Passport 来整合其端点。它还可以通过集成 Helmet 库提供的中间件来定义 HTTP 标头安全性。加密和数据哈希也是如此,这些概念并不新鲜,其他后端框架中也存在。

我之前没有提到单元测试,但这个项目里确实有。NestJS 也做出了一个明智的选择,它放弃了Karma/Jasmine 组合,转而使用 Jest。这样一来,模拟 Prisma 数据源、专注于控制器调用或服务的功能就变得非常容易了。

凭借强大的概念和完善的文档,NestJS 在后端 JavaScript 开发领域脱颖而出,成为行业标杆。它是一个可定制的框架(可以通过采用ExpressJS引擎或切换到Fastify引擎来实现),同时又非常完善,拥有丰富的插件,例如可以通过 Swagger 添加 OpenAPI 文档,或者为 RESTful 调用添加 GraphQL 抽象等等。

以后我选择NodeJS的技术基础架构时将不再犹豫,我会直接选择NestJS 👍

源代码

文章来源:https://dev.to/dmnchzl/building-a-robust-backend-server-with-nestjs-29bf