使用 NestJS 构建模块化和可测试的后端应用程序

使用 NestJS 构建模块化和可测试的后端应用程序

引言 🎯

大家好,欢迎来到今天的讲座!今天我们要一起探讨如何使用 NestJS 构建一个模块化、可测试的后端应用程序。NestJS 是一个基于 TypeScript 的框架,它不仅帮助我们构建高效、可扩展的应用程序,还提供了强大的工具来确保代码的可维护性和可测试性。

在接下来的时间里,我们将一步步深入 NestJS 的核心概念,学习如何设计模块化的架构,编写高效的业务逻辑,并通过单元测试和集成测试来确保代码的可靠性。如果你已经对 NestJS 有一定的了解,那么今天的内容将帮助你进一步提升技能;如果你是新手,别担心,我们会从基础开始,逐步深入。

准备好了吗?让我们开始吧!😊

什么是 NestJS? 🤔

1. 简介

NestJS 是一个用于构建高效、可扩展的 Node.js 服务器端应用程序的框架。它基于 Express(也可以与 Fastify 一起使用),并结合了现代 JavaScript 的特性,如装饰器、依赖注入等。NestJS 的设计理念深受 Angular 框架的影响,因此它也被称为“服务端的 Angular”。

NestJS 的主要特点包括:

  • 模块化:NestJS 采用模块化架构,允许我们将应用程序拆分为独立的功能模块,每个模块负责特定的业务逻辑。
  • 依赖注入:NestJS 提供了内置的依赖注入机制,使得组件之间的耦合度降低,代码更易于测试和维护。
  • 装饰器:NestJS 广泛使用装饰器来简化代码编写,例如控制器、服务、管道、守卫等都可以通过装饰器来定义。
  • 异步支持:NestJS 原生支持异步操作,如 Promise 和 async/await,使得处理 I/O 操作更加简单。
  • 丰富的生态系统:NestJS 提供了丰富的官方模块和第三方库,涵盖了常见的开发需求,如数据库连接、身份验证、GraphQL 等。

2. 为什么选择 NestJS?

在众多的 Node.js 框架中,NestJS 为何脱颖而出呢?以下是几个关键原因:

  • TypeScript 支持:NestJS 完全基于 TypeScript,这意味着我们可以享受静态类型检查、智能提示、自动补全等功能,大大提高了开发效率和代码质量。
  • 模块化架构:NestJS 的模块化设计使得我们可以轻松地将应用程序拆分为多个独立的模块,每个模块都可以独立开发、测试和部署。
  • 依赖注入:依赖注入机制使得我们可以轻松管理应用程序中的依赖关系,避免了硬编码的依赖,使得代码更加灵活和可测试。
  • 社区活跃:NestJS 拥有一个非常活跃的社区,提供了大量的文档、教程和开源项目,开发者可以轻松找到解决问题的方法。
  • 性能优异:NestJS 基于 Express 或 Fastify,这两个框架都以其高性能著称,因此 NestJS 也能提供出色的性能表现。

快速入门 🚀

在正式进入模块化和可测试性的讨论之前,我们先来创建一个简单的 NestJS 应用程序,熟悉一下基本的开发流程。

1. 安装 NestJS CLI

首先,我们需要安装 NestJS 的命令行工具(CLI),它可以帮助我们快速生成项目结构和代码模板。

npm install -g @nestjs/cli

2. 创建新项目

接下来,使用 NestJS CLI 创建一个新的项目:

nest new my-app

CLI 会询问你是否要使用 TypeScript(默认选项)或纯 JavaScript。我们选择 TypeScript,因为它是 NestJS 的推荐语言。

创建完成后,进入项目目录:

cd my-app

3. 启动应用程序

现在,我们可以启动应用程序,看看它是否正常工作:

npm run start

打开浏览器,访问 http://localhost:3000,你应该会看到一个欢迎页面,显示 "Hello World!"。

4. 项目结构

NestJS 项目的默认结构如下:

my-app/
├── src/
│   ├── app.controller.ts
│   ├── app.module.ts
│   ├── app.service.ts
│   └── main.ts
├── test/
│   └── app.e2e-spec.ts
├── .env
├── .gitignore
├── jest.config.js
├── nest-cli.json
├── package.json
└── tsconfig.json
  • src/:存放应用程序的源代码。
  • app.module.ts:应用程序的根模块,负责引导整个应用程序。
  • app.controller.ts:默认的控制器,处理 HTTP 请求。
  • app.service.ts:默认的服务,封装业务逻辑。
  • main.ts:应用程序的入口文件,负责启动服务器。
  • test/:存放测试文件,NestJS 默认集成了 Jest 测试框架。

5. 添加自定义路由

为了更好地理解 NestJS 的路由机制,我们来添加一个自定义的控制器。假设我们要创建一个简单的 API 来获取用户信息。

首先,使用 CLI 生成一个新的控制器:

nest generate controller users

这将在 src/ 目录下生成一个 users.controller.ts 文件。打开该文件,添加以下代码:

import { Controller, Get } from '@nestjs/common';

@Controller('users')
export class UsersController {
  @Get()
  findAll(): string {
    return 'This action returns all users';
  }
}

这段代码定义了一个名为 UsersController 的控制器,并为 /users 路径添加了一个 GET 方法。现在,访问 http://localhost:3000/users,你应该会看到返回的字符串。

6. 添加服务

为了让控制器更具模块化,我们可以将业务逻辑提取到一个服务中。使用 CLI 生成一个新的服务:

nest generate service users

这将在 src/ 目录下生成一个 users.service.ts 文件。打开该文件,添加以下代码:

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

@Injectable()
export class UsersService {
  getUsers() {
    return ['John', 'Jane', 'Alice'];
  }
}

接下来,修改 UsersController,使其调用 UsersService

import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() {
    return this.usersService.getUsers();
  }
}

现在,当我们访问 http://localhost:3000/users 时,API 将返回一个用户列表。

7. 注册模块

为了让 NestJS 知道我们创建的 UsersModule,我们需要将其注册到根模块 AppModule 中。打开 app.module.ts,添加以下代码:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module'; // 导入 UsersModule

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

现在,UsersModule 已经被成功注册到应用程序中,我们可以继续为其添加更多的功能。

模块化设计 🧩

模块化是 NestJS 的核心设计理念之一。通过将应用程序拆分为多个独立的模块,我们可以实现更好的代码组织、复用和维护。每个模块都可以包含控制器、服务、管道、守卫等组件,并且可以与其他模块进行通信。

1. 什么是模块?

在 NestJS 中,模块是一个类,通常以 @Module 装饰器进行标注。模块的主要作用是:

  • 声明组件:模块可以声明控制器、服务、管道等组件,并将它们注册到容器中。
  • 导入其他模块:模块可以导入其他模块,从而使用它们提供的功能。
  • 导出组件:模块可以导出某些组件,以便其他模块可以使用它们。

2. 创建模块

我们已经通过 CLI 创建了一个 UsersModule,现在让我们来看看它的结构:

import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  controllers: [UsersController], // 声明控制器
  providers: [UsersService],      // 声明服务
  exports: [UsersService],        // 导出服务
})
export class UsersModule {}

在这个模块中:

  • controllers:声明了 UsersController,它负责处理 HTTP 请求。
  • providers:声明了 UsersService,它封装了业务逻辑。
  • exports:导出了 UsersService,使得其他模块可以使用它。

3. 模块之间的依赖

模块之间可以通过 imports 属性来建立依赖关系。例如,如果我们有一个 AuthModule,它需要使用 UsersService 来验证用户身份,我们可以在 AuthModule 中导入 UsersModule

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module'; // 导入 UsersModule

@Module({
  imports: [UsersModule], // 导入 UsersModule
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}

由于 UsersModule 导出了 UsersServiceAuthModule 可以直接使用它而不需要重新声明。

4. 共享模块

有时,我们希望某些模块可以在多个地方使用。为了避免重复导入,我们可以创建一个共享模块。共享模块通常不包含控制器,只提供服务或其他可重用的组件。

例如,我们可以创建一个 CommonModule,其中包含一些常用的工具函数或配置:

import { Module } from '@nestjs/common';
import { ConfigService } from './config.service';

@Module({
  providers: [ConfigService],
  exports: [ConfigService],
})
export class CommonModule {}

然后,在需要的地方导入 CommonModule

import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { CommonModule } from './common/common.module';

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

5. 动态模块

动态模块允许我们在运行时根据不同的配置来创建模块。这对于需要根据不同环境或配置加载不同功能的场景非常有用。

例如,我们可以创建一个动态的 DatabaseModule,它可以根据传入的配置来连接不同的数据库:

import { Module, DynamicModule } from '@nestjs/common';
import { DatabaseService } from './database.service';

@Module({})
export class DatabaseModule {
  static forRoot(options: any): DynamicModule {
    return {
      module: DatabaseModule,
      providers: [
        {
          provide: 'DATABASE_OPTIONS',
          useValue: options,
        },
        DatabaseService,
      ],
      exports: [DatabaseService],
    };
  }
}

然后,在 AppModule 中使用 forRoot 方法来创建 DatabaseModule

import { Module } from '@nestjs/common';
import { DatabaseModule } from './database/database.module';

@Module({
  imports: [DatabaseModule.forRoot({ type: 'mongodb', url: 'mongodb://localhost:27017' })],
  controllers: [],
  providers: [],
})
export class AppModule {}

依赖注入 🛠️

依赖注入(Dependency Injection,简称 DI)是 NestJS 的另一个重要特性。通过依赖注入,我们可以将组件之间的依赖关系交给框架来管理,而不是手动创建实例。这不仅使得代码更加简洁,还提高了可测试性和灵活性。

1. 什么是依赖注入?

依赖注入是一种设计模式,它允许我们将对象的依赖项(如服务、库等)作为参数传递给构造函数或方法,而不是在类内部直接创建这些依赖项。这样做的好处是:

  • 解耦:组件之间的依赖关系变得松散,便于替换和扩展。
  • 可测试:我们可以轻松地为组件提供模拟的依赖项,从而编写单元测试。
  • 灵活性:依赖项可以在运行时动态配置,而不必硬编码。

2. 使用依赖注入

在 NestJS 中,依赖注入非常简单。我们只需要在类的构造函数中声明依赖项,NestJS 会自动为我们创建并注入这些依赖项。

例如,我们在 UsersController 中注入了 UsersService

import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() {
    return this.usersService.getUsers();
  }
}

在这里,UsersServiceUsersController 的依赖项。NestJS 会自动查找并注入 UsersService 的实例,而我们不需要手动创建它。

3. 自定义提供者

除了注入服务,我们还可以注入其他类型的提供者,例如常量、工厂函数等。NestJS 提供了多种方式来自定义提供者。

3.1 使用 useClass

useClass 是最常见的方式,它告诉 NestJS 使用某个类作为提供者的实现。例如,我们可以将 UsersService 注册为一个提供者:

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [
    {
      provide: 'USER_SERVICE',
      useClass: UsersService,
    },
  ],
  exports: ['USER_SERVICE'],
})
export class UsersModule {}

然后,在其他地方使用 @Inject 装饰器来注入这个提供者:

import { Injectable, Inject } from '@nestjs/common';

@Injectable()
export class AuthService {
  constructor(@Inject('USER_SERVICE') private readonly userService: UsersService) {}
}

3.2 使用 useFactory

useFactory 允许我们通过工厂函数来创建提供者。这对于需要在创建提供者时执行某些逻辑的场景非常有用。例如,我们可以创建一个数据库连接池:

import { Module, FactoryProvider } from '@nestjs/common';
import { createPool } from 'mysql2/promise';

@Module({
  providers: [
    {
      provide: 'DB_CONNECTION',
      useFactory: async () => {
        const pool = await createPool({
          host: 'localhost',
          user: 'root',
          password: 'password',
          database: 'mydb',
        });
        return pool;
      },
    },
  ],
  exports: ['DB_CONNECTION'],
})
export class DatabaseModule {}

然后,在其他地方注入这个提供者:

import { Injectable, Inject } from '@nestjs/common';

@Injectable()
export class UsersService {
  constructor(@Inject('DB_CONNECTION') private readonly db: any) {}
}

3.3 使用 useValue

useValue 允许我们直接注入一个常量值。这对于配置项或全局变量非常有用。例如,我们可以注入一个 API 密钥:

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

@Module({
  providers: [
    {
      provide: 'API_KEY',
      useValue: 'your-api-key-here',
    },
  ],
  exports: ['API_KEY'],
})
export class ConfigModule {}

然后,在其他地方注入这个常量:

import { Injectable, Inject } from '@nestjs/common';

@Injectable()
export class AuthService {
  constructor(@Inject('API_KEY') private readonly apiKey: string) {}
}

4. Scope 作用域

NestJS 提供了三种作用域(Scope)来控制提供者的生命周期:

  • Singleton:默认情况下,所有提供者都是单例的,即在整个应用程序中只有一个实例。
  • Request:每次请求都会创建一个新的提供者实例,适用于需要在每次请求中保持独立状态的场景。
  • Transient:每次注入都会创建一个新的提供者实例,适用于需要在不同地方使用不同实例的场景。

例如,我们可以将 UserService 设置为 Request 作用域:

import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.REQUEST })
export class UserService {
  constructor() {
    console.log('New instance of UserService created');
  }

  getUsers() {
    return ['John', 'Jane', 'Alice'];
  }
}

现在,每次请求都会创建一个新的 UserService 实例。

可测试性 ✅

编写可测试的代码是构建高质量应用程序的关键。NestJS 提供了强大的测试工具和框架支持,使得我们可以轻松编写单元测试、集成测试和端到端测试。

1. 单元测试

单元测试是对单个组件(如服务、控制器等)进行测试,确保其行为符合预期。NestJS 默认集成了 Jest 测试框架,我们可以使用它来编写单元测试。

1.1 测试服务

假设我们要测试 UsersService,我们可以编写一个简单的单元测试来验证 getUsers 方法的行为。

首先,创建一个测试文件 users.service.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';

describe('UsersService', () => {
  let service: UsersService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [UsersService],
    }).compile();

    service = module.get<UsersService>(UsersService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  it('should return a list of users', () => {
    expect(service.getUsers()).toEqual(['John', 'Jane', 'Alice']);
  });
});

在这个测试中:

  • Test.createTestingModule 用于创建一个测试模块,模拟真实的模块环境。
  • module.get 用于从模块中获取 UsersService 的实例。
  • expect 用于断言测试结果。

1.2 测试控制器

我们还可以编写单元测试来验证控制器的行为。例如,测试 UsersControllerfindAll 方法:

import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

describe('UsersController', () => {
  let controller: UsersController;
  let service: UsersService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        {
          provide: UsersService,
          useValue: {
            getUsers: jest.fn().mockReturnValue(['John', 'Jane', 'Alice']),
          },
        },
      ],
    }).compile();

    controller = module.get<UsersController>(UsersController);
    service = module.get<UsersService>(UsersService);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });

  it('should return a list of users', async () => {
    const result = await controller.findAll();
    expect(result).toEqual(['John', 'Jane', 'Alice']);
    expect(service.getUsers).toHaveBeenCalled();
  });
});

在这个测试中,我们使用 jest.fn() 来模拟 UsersServicegetUsers 方法,并验证控制器是否正确调用了该方法。

2. 集成测试

集成测试是对多个组件之间的交互进行测试,确保它们能够协同工作。我们可以使用 Supertest 来编写集成测试,测试整个 HTTP 请求的处理过程。

首先,安装 Supertest:

npm install --save-dev supertest

然后,创建一个集成测试文件 users.e2e-spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { UsersModule } from '../src/users/users.module';

describe('UsersModule (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [UsersModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  it('/users (GET)', () => {
    return request(app.getHttpServer())
      .get('/users')
      .expect(200)
      .expect(['John', 'Jane', 'Alice']);
  });
});

在这个测试中,我们使用 Supertest 发送 HTTP 请求,并验证响应的状态码和数据。

3. 端到端测试

端到端测试是对整个应用程序进行测试,模拟真实用户的操作。我们可以使用 Cypress 或 Playwright 等工具来编写端到端测试,但这里我们仍然使用 Supertest 来演示如何测试整个应用程序。

首先,创建一个端到端测试文件 app.e2e-spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

describe('AppModule (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('Hello World!');
  });

  it('/users (GET)', () => {
    return request(app.getHttpServer())
      .get('/users')
      .expect(200)
      .expect(['John', 'Jane', 'Alice']);
  });
});

在这个测试中,我们同时测试了根路径和用户路径,确保整个应用程序的行为符合预期。

总结 🎉

通过今天的讲座,我们深入了解了如何使用 NestJS 构建模块化和可测试的后端应用程序。我们从基础的项目创建开始,逐步学习了模块化设计、依赖注入和可测试性的相关知识。希望这些内容能够帮助你在实际开发中更好地应用 NestJS,构建高效、可靠的应用程序。

当然,NestJS 还有许多其他强大的功能和特性,比如中间件、管道、守卫、异常过滤器等,留待大家在后续的学习中继续探索。如果你有任何问题或想法,欢迎随时交流!

谢谢大家的聆听,祝你们 coding 快乐!💻✨

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注