搭建基础的 NestJS 项目框架,包括以下内容:

  • 初始化 NestJS 项目
  • 配置环境变量
  • 配置数据库连接
  • 配置 Swagger 文档
  • 设置基础中间件
  • 配置日志系统

1. 初始化 NestJS 项目

1.1. 安装 NestJS CLI 工具

全局安装 NestJS 的 CLI 工具:

1
yarn global add @nestjs/cli

确保全局安装的包路径已被添加到环境变量 PATH 中,否则无法在终端中使用 nest 命令。

可以使用以下命令查看 Yarn 的全局包路径:

1
yarn global bin

将输出的路径添加到 PATH 中。

1.2. 创建新的 NestJS 项目

执行以下命令,创建一个名为 shopping-nest-server 的新项目:

1
nest new shopping-nest-server

运行后,NestJS CLI 会提示选择包管理工具,选择 Yarn,等待其自动安装所需的依赖。

项目生成后,NestJS CLI 默认会在根目录下生成一个 test 目录和一些单元测试文件(.spec.ts)。我暂时不需要测试,所以删去了这些内容。

NestJS CLI 创建的 NestJS 项目会自带 ESLint 和 Prettier,我们只需要将上一章配置好的 .prettierrc 复制过来、确保前端后端都遵循相同的代码规范即可:

1
2
3
4
5
6
7
8
9
10
11
{
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "none",
"bracketSpacing":true,
"bracketSameLine": true,
"arrowParens": "avoid"
}

2. 配置环境变量和配置文件

在实际配置前,我们先来考虑一下,什么是需要配置的:

  1. 应用基本配置

    • APP_PORT:定义应用监听的端口号
    • APP_ENV:表示当前的应用环境。应用将根据环境做出一些环境特定的设置,比方说日志的详细级别
  2. 核心数据库配置

    数据库是应用的核心部分,用于存储应用的持久化数据。

    • DB_TYPE:数据库的类型。我选择使用 mysql 或者 postgres
    • DB_HOSTDB_PORTDB_USERDB_PASSWORDDB_NAME:这些参数确保应用能连接到正确的数据库实例。不同环境中的数据库配置往往不尽相同,开发环境中可能连接到本地数据库、生产环境中可能连接到远程数据库
  3. 缓存配置

    缓存系统是提升应用性能的重要手段。

    Redis 是一个高效的内存缓存数据库,常用于缓存频繁访问的数据,从而减轻数据库负载、提升响应速度。

    • REDIS_HOSTREDIS_PORT:让应用访问正确的 Redis 实例
    • REDIS_PASSWORD部分 Redis 服务需要密码验证,通过密码可以保障缓存数据的安全
    • REDIS_DB:指定 Redis 数据库编号,有助于在同一 Redis 实例中隔离不同用途的数据
  4. Elasticsearch 配置

    Elasticsearch 是一个分布式搜索和分析引擎,适用于海量数据的全文检索和分析。

    • ELASTICSEARCH_HOSTELASTICSEARCH_PORT:定义了 Elasticsearch 服务的位置和端口
    • ELASTICSEARCH_USERNAMEELASTICSEARCH_PASSWORD:通过用户名和密码的方式实现对 Elasticsearch 集群的访问控制
    • ELASTICSEARCH_INDEX:定义应用使用的索引。索引类似于数据库中的表,是 Elasticsearch 存储和查询数据的基本单位
  5. 文件存储配置

    对于需要上传文件(如用户头像、商品图片等)的应用来说,文件存储服务是不可或缺的。

    很多应用会选择云存储服务(如 Amazon S3、Aliyun OSS)或者本地可用的 MinIO 来满足存储需求。

    • STORAGE_ENDPOINT:指定文件存储服务的 API 端点,便于与远程存储服务连接
    • STORAGE_ACCESS_KEYSTORAGE_SECRET_KEY:用于认证的密钥对,保障文件存储服务的安全访问
    • STORAGE_BUCKETSTORAGE_REGION:定义存储的目标存储桶和区域位置,以便更合理地管理文件资源,减少延迟
    • STORAGE_USE_SSL:配置是否启用 HTTPS,以增强数据传输的安全性
  6. JWT 配置

    JWT 用于实现用户身份验证和会话管理,是一种轻量的认证方式。

    在过去的 React + NestJS + SocketIO 教程文章中,我们已经讲过了 JWT,感兴趣的可以看看。

    • JWT_SECRET:用于签发和验证 JWT 的密钥,确保身份认证的安全性。设计一个足够强度的密钥并保持其私密性至关重要
    • JWT_TOKEN_AUDIENCE:指定 JWT 的受众,即令牌面向的服务或应用。设置受众可以帮助确保令牌仅被指定应用使用,提高认证的安全性
    • JWT_TOKEN_ISSUER:用于声明 JWT 的发布者,一般设置为认证服务器的标识,确保 JWT 的来源是可信的
    • JWT_ACCESS_TOKEN_TTL:JWT 访问令牌的有效时间,单位为秒。合理设置过期时间既能提升安全性(防止会话过长导致会话劫持风险),又可避免用户频繁重新登录带来的不便

2.1. 安装必要依赖

首先安装 @nestjs/configdotenv 以及数据库驱动和 TypeORM,以支持加载环境变量、进行数据库连接和配置。

1
2
3
yarn add @nestjs/config
yarn add dotenv -D
yarn add @nestjs/typeorm typeorm mysql2 # 替换 mysql2 为 pg 以使用 PostgreSQL
  • @nestjs/config 是 NestJS 提供的官方配置模块,专为加载、管理和验证环境变量而设计
  • dotenv 是配置模块的底层依赖,通过 .env 文件加载环境变量
  • TypeORM 是一个 TypeScript 支持的 ORM(对象关系映射),能够与关系型数据库集成

2.2. 创建环境变量文件

在项目根目录下创建 .env 文件:

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
# 应用
APP_PORT=4000
APP_ENV=development

# 核心数据库
DB_TYPE=mysql
DB_HOST=localhost
DB_PORT=3306
DB_USER=用户名
DB_PASSWORD=密码
DB_NAME=数据库名称

# Redis 缓存
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=密码
REDIS_DB=0

# Elasticsearch
ELASTICSEARCH_HOST=localhost
ELASTICSEARCH_PORT=9200
ELASTICSEARCH_USERNAME=用户名
ELASTICSEARCH_PASSWORD=密码
ELASTICSEARCH_INDEX=products

# 文件存储
STORAGE_ENDPOINT=
STORAGE_ACCESS_KEY=access_key_id
STORAGE_SECRET_KEY=secret_access_key
STORAGE_BUCKET=桶名
STORAGE_REGION=
STORAGE_USE_SSL=true

# JWT
JWT_SECRET=secret
JWT_TOKEN_AUDIENCE=localhost:4000
JWT_TOKEN_ISSUER=localhost:4000
JWT_ACCESS_TOKEN_TTL=3600

这里的环境变量覆盖了不同模块所需的配置项,确保各模块配置的灵活性。

2.3. 引入配置模块和验证

AppModule 中配置 @nestjs/config,使用 Joi 对环境变量进行验证,确保每个变量都满足格式要求:

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
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { DatabaseModule } from './database/database.module';
import * as Joi from 'joi';

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // 让 ConfigModule 全局可用
validationSchema: Joi.object({
APP_PORT: Joi.number().default(4000),
APP_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
// 核心数据库
DB_TYPE: Joi.string().valid('mysql', 'postgres').default('mysql'),
DB_HOST: Joi.string().required(),
DB_PORT: Joi.number().default(3306),
DB_USER: Joi.string().required(),
DB_PASSWORD: Joi.string().required(),
DB_NAME: Joi.string().required(),
// Redis 缓存
REDIS_HOST: Joi.string().required(),
REDIS_PORT: Joi.number().default(6379),
REDIS_PASSWORD: Joi.string().allow(''),
REDIS_DB: Joi.number().default(0),
// Elasticsearch
ELASTICSEARCH_HOST: Joi.string().required(),
ELASTICSEARCH_PORT: Joi.number().default(9200),
ELASTICSEARCH_USER: Joi.string().required(),
ELASTICSEARCH_PASSWORD: Joi.string().required(),
ELASTICSEARCH_INDEX: Joi.string().required(),
// 文件存储
STORAGE_ENDPOINT: Joi.string().allow(''),
STORAGE_ACCESS_KEY: Joi.string().required(),
STORAGE_SECRET_KEY: Joi.string().required(),
STORAGE_BUCKET: Joi.string().required(),
STORAGE_REGION: Joi.string().allow(''),
STORAGE_USE_SSL: Joi.boolean().default(true),
// JWT
JWT_SECRET: Joi.string().required(),
JWT_TOKEN_AUDIENCE: Joi.string().required(),
JWT_TOKEN_ISSUER: Joi.string().required(),
JWT_ACCESS_TOKEN_TTL: Joi.number().default(3600)
})
}),
DatabaseModule
],
controllers: [AppController],
providers: [AppService]
})
export class AppModule {}
  • isGlobal:将配置模块设为全局模块,避免在其他模块中重复引入
  • validationSchema:通过 Joi 验证环境变量的值,确保值类型与业务需求匹配;例如 DB_HOST 需要是字符串,APP_PORT 应为数值,且数据库和JWT密钥都必须存在

Joi 是一个 JavaScript 数据验证库,通常用来确保应用中的数据符合特定的规则或格式。

2.3.1. 基本用法

Joi 提供了一个简单的链式 API 来定义验证规则。验证的流程一般是:定义 schema(验证规则) -> 验证数据 -> 获取结果或错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const Joi = require('joi');

// 定义 schema
const schema = Joi.object({
name: Joi.string().min(3).max(30).required(),
age: Joi.number().integer().min(0).max(120),
email: Joi.string().email()
});

// 验证数据
const result = schema.validate({ name: 'Alice', age: 25, email: 'alice@example.com' });

// 检查结果
if (result.error) {
console.log(result.error.details);
} else {
console.log(result.value);
}

2.3.2. 基本类型验证

Joi 支持的基本类型包括:stringnumberbooleanarrayobject 等。每种类型可以组合其他规则,如最小/最大值、必填/选填、格式限制等。

  1. 字符串验证
1
2
3
4
5
Joi.string()  // 定义为字符串类型
Joi.string().min(3) // 最小长度
Joi.string().max(30) // 最大长度
Joi.string().email() // 必须是电子邮箱格式
Joi.string().regex(/^[a-zA-Z0-9]{3,30}$/) // 使用正则表达式验证格式
  1. 数字验证
1
2
3
4
5
6
Joi.number()  // 定义为数字类型
Joi.number().integer() // 必须是整数
Joi.number().min(0) // 最小值
Joi.number().max(100) // 最大值
Joi.number().positive() // 必须是正数
Joi.number().negative() // 必须是负数
  1. 布尔值验证
1
Joi.boolean()  // 定义为布尔类型
  1. 数组验证
1
2
3
4
Joi.array()  // 定义为数组类型
Joi.array().items(Joi.number()) // 数组中每项都必须是数字
Joi.array().min(1).max(5) // 数组长度限制
Joi.array().unique() // 数组中的每个元素必须唯一
  1. 对象验证
1
2
3
4
Joi.object({
username: Joi.string().required(),
password: Joi.string().min(8).required(),
})

2.3.3. 条件验证

条件验证允许定义复杂的规则,如基于字段值的条件或逻辑分支。

when 条件验证:

1
2
3
4
5
6
7
8
const schema = Joi.object({
password: Joi.string().min(8).required(),
confirmPassword: Joi.string().valid(Joi.ref('password')).when('password', {
is: Joi.exist(), // 如果 password 存在……
then: Joi.required(), // ……confirmPassword 也是必填
otherwise: Joi.forbidden() // 否则不允许出现 confirmPassword
})
});

2.3.4. 嵌套对象和数组验证

Joi 允许定义嵌套结构,如对象嵌套和数组嵌套。

  1. 嵌套对象
1
2
3
4
5
6
const schema = Joi.object({
user: Joi.object({
name: Joi.string().required(),
age: Joi.number().min(0)
}).required()
});
  1. 嵌套数组
1
2
3
4
5
6
const schema = Joi.array().items(
Joi.object({
id: Joi.number().required(),
name: Joi.string().required()
})
);

2.3.5. 自定义验证器

Joi 支持自定义验证函数,用于更复杂的场景。

1
2
3
4
5
6
const schema = Joi.string().custom((value, helpers) => {
if (!/^[a-zA-Z]+$/.test(value)) {
return helpers.error('any.invalid'); // 返回一个自定义错误
}
return value; // 验证通过
}, 'Custom alphabet validation');

2.3.6. 配合 NestJS 使用

在 NestJS 中,可以结合 @nestjs/config 模块来使用 Joi 验证配置文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import * as Joi from 'joi';

@Module({
imports: [
ConfigModule.forRoot({
validationSchema: Joi.object({
// 假设有核心数据库的配置
DB_HOST: Joi.string().required(),
DB_PORT: Joi.number().default(5432),
})
})
],
})
export class AppModule {}

2.3.7. 错误处理与自定义错误消息

Joi 会在验证失败时返回详细的错误信息,可以自定义错误消息。

1
2
3
4
5
6
7
8
const schema = Joi.object({
name: Joi.string().min(3).required().messages({
'string.base': `"name" should be a type of 'text'`,
'string.empty': `"name" cannot be an empty field`,
'string.min': `"name" should have a minimum length of {#limit}`,
'any.required': `"name" is a required field`
})
});

2.4. 使用配置

作为一个使用 ConfigService 的例子,我们将从 .env 中读取 APP_PORT 的值,并将其作为应用的启动端口。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';

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

const configService = app.get(ConfigService);
const port = configService.get<number>('APP_PORT') || 4000;

await app.listen(port);
}
bootstrap();

运行 nest start 来查看是否有任何问题。

3. 设置数据库连接

首先,确保自己的本机环境中有安装 MySQL 或者 PostgreSQL。

安装教程请自行在网上搜索,本篇文档将只会使用 8.0.40 MySQL Community

挖坑:未来可能会添加支持其他数据库的功能。

3.1. 配置 MySQL

  1. 打开 MySQL CLI(或者使用 mysql -u root -p 来进行连接)

  2. 创建数据库:

    1
    CREATE DATABASE 数据库名称;

    数据库名称要匹配 .env 中的:

    1
    DB_NAME=数据库名称
  3. 创建用户:

    1
    CREATE USER '用户名'@'localhost' IDENTIFIED BY '密码';

    这里的内容要匹配 .env 中的这一段内容:

    1
    2
    DB_USER=用户名
    DB_PASSWORD=密码
  4. 设置权限:

    1
    2
    GRANT ALL PRIVILEGES ON 数据库名称.* TO '用户名'@'localhost';
    FLUSH PRIVILEGES;
  5. 设置完后可以测试一下:

    1
    mysql -u 用户名 -p -h localhost -P 3306 数据库名称

3.2. 创建 DatabaseModule

在 NestJS 项目中,集中管理数据库连接的配置非常重要,尤其是在需要支持多种环境(如开发、测试、生产)时。

创建 DatabaseModule 能让我们将数据库的配置代码分离出来,以便在不同的环境中灵活调整配置,比如使用 ConfigService 来获取环境变量。

通过 TypeOrmModule.forRootAsync 方法,我们可以使用异步的方式配置 TypeORM。这样可以确保数据库配置在应用初始化时依赖于环境变量,如 DB_HOSTDB_USERDB_PASSWORD 等,从而增强配置的灵活性和安全性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
imports: [
ConfigModule,
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: configService.get<'mysql' | 'postgres'>('DB_TYPE'),
host: configService.get<string>('DB_HOST'),
port: configService.get<number>('DB_PORT'),
username: configService.get<string>('DB_USER'),
password: configService.get<string>('DB_PASSWORD'),
database: configService.get<string>('DB_NAME'),
autoLoadEntities: true,
synchronize: configService.get<string>('APP_ENV') === 'development'
})
})
]
})
export class DatabaseModule {}
  • ConfigService:用于从环境变量获取配置,确保 DB_TYPE 等参数的灵活性
  • forRootAsync:动态配置 TypeOrmModule,适用于需要依赖环境变量初始化的模块
  • autoLoadEntities: true:TypeORM 会自动加载应用中定义的所有实体。这让我们可以在项目中自由地添加新的实体,而不需要每次手动导入
  • synchronize:将其设置为 true 会在开发环境中自动同步数据库表结构,以便在本地开发时快速响应数据结构的修改。但在生产环境中,建议关闭 synchronize,以防止意外数据丢失或表结构破坏

4. 配置 Swagger 文档

在现代 Web 开发中,API 文档对于开发人员和用户来说都是至关重要的,特别是在团队协作中,清晰的 API 文档可以大大提高开发效率。

NestJS 提供了内置的 Swagger 支持,允许我们快速生成符合 OpenAPI 标准的文档,为用户提供更好的接口可视化。

OpenAPI 是一种用于描述 RESTful API 的规范,它提供了一种标准化的格式,用于定义 API 的端点、请求、响应、认证等内容。

它的前身是 Swagger 规范,因此你可能听过 Swagger 和 OpenAPI 这两个词被混用。

OpenAPI 的主要目标是使 API 设计、文档、测试和集成过程更为高效和一致。

4.1. 安装依赖

首先,我们需要安装 @nestjs/swaggerswagger-ui-express 两个模块。

  • @nestjs/swagger 提供了 NestJS 对 Swagger 的支持
  • swagger-ui-express 是 Swagger UI 的依赖包,用于在浏览器中显示 API 文档

在项目根目录下运行以下命令来安装它们:

1
yarn add @nestjs/swagger swagger-ui-express

4.2. 配置 Swagger

打开 main.ts 并添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ...
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

async function bootstrap() {
// ...
const swaggerConfig = new DocumentBuilder()
.setTitle('API 文档')
.setDescription('Shopping-Nest 的 API 文档')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api-docs', app, document);
// ...
}

在这里,我们使用 DocumentBuilder 来创建 Swagger 文档的基本信息。常见的配置项有:

  • .setTitle(): 设置 API 文档的标题
  • .setDescription(): 提供 API 的描述信息
  • .setVersion(): 指定 API 的版本号
  • .addBearerAuth(): 如果 API 需要 JWT 认证(通常用于保护 API),可以添加 Bearer 认证支持

SwaggerModule.setup() 方法将 Swagger UI 绑定到指定的路由路径(这里是/api-docs),之后,我们可以通过访问 http://localhost:APP_PORT/api-docs 来查看生成的文档。

4.3. 如何使用 Swagger

在我们完成 Swagger 的基础配置后,接下来的步骤将详细介绍如何利用 Swagger 注释来生成清晰的 API 文档。这一部分将涵盖如何为控制器、DTO(数据传输对象)和请求参数添加 Swagger 装饰器,以便 Swagger 能够生成准确且全面的 API 文档。

4.3.1. 为控制器添加注释

在 NestJS 中,控制器负责处理客户端请求并返回响应。我们可以使用 Swagger 提供的装饰器为控制器中的每个方法添加注释,以描述其功能、请求参数和返回结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Controller, Get, Post, Body } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { CreateUserDto } from './dto/create-user.dto';

@ApiTags('Users') // 给控制器添加标签
@Controller('users')
export class UserController {

@Post()
@ApiOperation({ summary: 'Create a new user' }) // 描述此 API 的作用
@ApiResponse({ status: 201, description: 'The user has been successfully created.' }) // 201 状态响应
@ApiResponse({ status: 400, description: 'Invalid input data.' }) // 400 状态响应
create(@Body() createUserDto: CreateUserDto) {
return 'This action adds a new user';
}

@Get()
@ApiOperation({ summary: 'Retrieve a list of users' }) // 描述此 API 的作用
@ApiResponse({ status: 200, description: 'A list of users.' }) // 200 状态响应
getAllUsers() {
return [{ id: 1, name: 'John Doe' }];
}
}

4.3.2. 为 DTO 添加注释

DTO(数据传输对象)用于定义请求和响应的结构。使用 Swagger 的 @ApiProperty 装饰器,可以清晰地说明每个字段的含义和要求。

1
2
3
4
5
6
7
8
9
10
11
12
import { ApiProperty } from '@nestjs/swagger';

export class CreateUserDto {
@ApiProperty({ description: 'The name of the user' }) // 描述 name 字段
name: string;

@ApiProperty({ description: 'The age of the user', minimum: 1 }) // 描述 age 字段并设置最小值
age: number;

@ApiProperty({ description: 'The email of the user', required: true }) // 描述 email 字段
email: string;
}

在上面的例子中,CreateUserDto 包含了三个属性:nameageemail。每个属性都使用了 @ApiProperty 装饰器来提供详细描述,并且可以设置字段的其他约束(如是否必填、类型等)。

4.3.3. 为请求参数添加注释

如果你的 API 需要接受路径参数、查询参数或请求体中的数据,Swagger 也提供了相关的装饰器来帮助描述这些参数。

1
2
3
4
5
6
7
8
9
10
11
12
import { Controller, Get, Param } from '@nestjs/common';
import { ApiParam } from '@nestjs/swagger';

@Controller('users')
export class UserController {
@Get(':id')
@ApiOperation({ summary: 'Retrieve a user by ID' }) // 描述此 API 的作用
@ApiParam({ name: 'id', required: true, description: 'The ID of the user to retrieve', type: Number }) // 描述路径参数
getUserById(@Param('id') id: number) {
return { id, name: 'John Doe' }; // 示例返回
}
}

在这个示例中,@ApiParam 用于描述路径参数id。它帮助用户理解这个参数是必须的,且应该是一个数字。

5. 设置基础中间件

在现代 Web 开发中,处理安全性、请求速率限制、响应压缩以及自定义日志记录是打造可靠、高效应用的基础。

NestJS 提供了简单灵活的中间件配置支持,通过整合 helmet@nestjs/throttlercompression 等库,开发者可以轻松地实现这些功能。

5.1. 添加 CORS 支持

首先,我们需要确保应用支持跨域请求(CORS),特别是在前后端分离的情况下。以下是启用和配置 CORS 的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// main.ts

async function bootstrap() {
// ...
const corsOrigin = configService.get<string>('CORS_ORIGIN');

app.enableCors({
origin: corsOrigin,
methods: 'GET,POST',
credentials: true,
});

// ...
}

.env 中添加:

1
CORS_ORIGIN=http://localhost:3000

使用 http://localhost:3000 是因为 React 本地环境默认运行在 localhost:3000

5.2. 增强安全性

helmet 是一组帮助设置安全 HTTP 头的中间件,能够防范常见的 Web 攻击(例如,XSS 攻击和点击劫持)。

安装 helmet 库:

1
yarn add helmet

开启 helmet 保护:

1
2
3
4
5
6
7
8
9
10
11
// main.ts

import helmet from 'helmet';

async function bootstrap() {
// ...

app.use(helmet());

// ...
}

通过这段简单的代码,helmet 会自动添加一组常用的安全头(以下信息来自于 NPM):

  • Content-Security-Policy:一个强大的允许清单,控制页面上可以发生的操作,有助于缓解多种攻击
  • Cross-Origin-Opener-Policy:帮助页面实现进程隔离
  • Cross-Origin-Resource-Policy:阻止其他网站跨域加载您的资源
  • Origin-Agent-Cluster:将进程隔离改为基于源的方式
  • Referrer-Policy:控制 Referer 请求头
  • Strict-Transport-Security:告知浏览器优先使用 HTTPS
  • X-Content-Type-Options:避免 MIME 类型嗅探
  • X-DNS-Prefetch-Control:控制 DNS 预取
  • X-Download-Options:强制将下载的文件保存到本地(仅适用于 Internet Explorer)
  • X-Frame-Options:传统的标头,用于防范点击劫持攻击
  • X-Permitted-Cross-Domain-Policies:控制 Adobe 产品(如 Acrobat)的跨域行为
  • X-Powered-By:关于 Web 服务器的信息,已移除,以防止简单攻击利用该信息
  • X-XSS-Protection:传统的标头,旨在防止 XSS 攻击,但通常效果不佳,因此 Helmet 将其禁用

5.3. 压缩响应

压缩响应能够有效减少传输的数据量,提升页面加载速度。

安装 compression 库:

1
yarn add compression

配置压缩的级别和触发条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// main.ts

import * as compression from 'compression';

async function bootstrap() {
// ...

const compressionLevel = configService.get<number>('COMPRESSION_LEVEL') || 6;
const compressionThreshold = configService.get<number>('COMPRESSION_THRESHOLD') || 1024;
app.use(
compression({
level: compressionLevel,
threshold: compressionThreshold,
})
);

// ...
}

.env 中添加:

1
2
3
# 响应压缩
COMPRESSION_LEVEL=6
COMPRESSION_THRESHOLD=1024

在上面的代码中,level 设置了压缩级别(范围从0-9,数字越大压缩越强,但 CPU 负荷越高),而 threshold 设置了触发压缩的响应体积阈值(单位为字节)。

5.4. 限制请求速率

防止滥用 API 资源是每个 Web 应用的核心需求之一。我们可以使用 @nestjs/throttler 模块对请求速率进行限制,确保服务不会被大量请求淹没。

1
yarn add @nestjs/throttler

我们在 AppModule 中通过 ThrottlerModule.forRootAsync 配置速率限制。利用 ConfigService.env 文件中获取 ttl(时间窗口)和 limit(最大请求数)参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// app.module.ts

import { ThrottlerModule, ThrottlerModuleOptions } from '@nestjs/throttler';

@Module({
imports: [
// ...
ThrottlerModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService): ThrottlerModuleOptions => [
{
ttl: configService.get<number>('THROTTLE_TTL') || 60,
limit: configService.get<number>('THROTTLE_LIMIT') || 10
}
]
}),
],
})

.env 中添加:

1
2
3
# 速率限制
THROTTLE_TTL=60
THROTTLE_LIMIT=10

5.4.1. 使用例子

配置后,系统会自动为所有API路由设置速率限制。也可以在特定控制器或路由中通过 @Throttle 装饰器进行覆盖:

1
2
3
4
5
6
7
8
9
10
11
import { Controller, Get } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';

@Controller('test')
export class TestController {
@Throttle(5, 10) // 每 10 秒最多 5 个请求
@Get()
testRoute() {
return "Testing rate limiting";
}
}

5.5. 自定义日志记录

为了记录请求信息,我们可以实现一个简单的 LoggerMiddleware,并在 AppModule 中配置它:

1
2
3
4
5
6
7
8
9
10
11
12
// logger.middleware.ts

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log(`Request... ${req.method} ${req.url}`);
next();
}
}

AppModule 中使用 configure 方法应用此中间件:

1
2
3
4
5
6
7
8
9
10
11
12
13
// app.module.ts

import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { LoggerMiddleware } from './middlewares/logger.middleware';

@Module({
imports: [/* 其他模块 */],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes('*');
}
}

这样,每次请求都会在控制台输出请求方法和 URL 路径,帮助我们跟踪请求流向和响应情况。

启动 NestJS 项目,在浏览器中访问 localhost:APP_PORT(或者默认的 localhost:4000),就能在终端中看到 Request... GET / 的字眼。

5.6. 设置全局错误处理

为了统一错误响应格式,可以创建自定义异常过滤器来捕获异常,并返回标准化的错误信息。

我们在项目中定义 HttpExceptionFilter 类,并将其注册为全局过滤器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// http-exception.filter.ts

import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;

const errorResponse = {
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: exception instanceof HttpException ? exception.getResponse() : 'Internal server error'
};

response.status(status).json(errorResponse);
}
}

接着在 main.ts 中注册过滤器:

1
2
3
4
5
6
7
8
9
10
11
// main.ts

import { HttpExceptionFilter } from './filters/http-exception.filter';

async function bootstrap() {
// ...

app.useGlobalFilters(new HttpExceptionFilter());

// ...
}

通过此过滤器,所有未处理的异常都会以标准格式返回。

6. 配置日志系统

在开发和运维中,日志记录是至关重要的一部分。通过详细的日志记录,我们可以更好地了解应用的运行状态、排查错误,甚至帮助团队进行性能优化。

在开发或生产环境中,可能会遇到以下问题:

  • 控制台日志输出过于混乱:控制台日志输出没有明显的视觉区分,开发者难以快速找到关键信息
  • 文件日志管理不当:日志文件没有分目录管理,日志存储时间不固定,且文件体积容易过大
  • 日志轮转:没有对日志文件进行按日期轮换,容易导致单个日志文件过大,不利于维护

6.1. 安装依赖

我们首先需要安装 chalknest-winstonwinstonwinston-daily-rotate-file 这些依赖。

1
yarn add chalk@^4 nest-winston winston winston-daily-rotate-file

注意:由于我的 NestJS 项目使用的是 CommonJS 模块系统,和使用 ESM 的 chalk@5 不兼容,所以我采用的最简单直接的方法就是降级到 chalk@4

6.2. 配置 winston 日志文件

在 NestJS 项目中创建一个 winston.logger.ts 文件,用于配置 winston 的日志记录选项,包括日志等级、日志格式、文件轮转等。

6.2.1. 配置日志目录

我们将设置不同的日志目录来分别存储错误日志、警告日志和常规应用日志。

使用 fs 来检查目录是否存在,不存在时自动创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import * as fs from 'fs';
import * as path from 'path';

const logDirectories = ['logs/errors', 'logs/warnings', 'logs/app'];

logDirectories.forEach(dir => {
const fullPath = path.join(__dirname, '..', '..', dir);
try {
if (!fs.existsSync(fullPath)) {
console.log(`Creating directory at: ${fullPath}`);
fs.mkdirSync(fullPath, { recursive: true });
}
} catch (error) {
console.error(`Error creating directory ${fullPath}:`, error);
}
});

这段代码创建了 logs/errorslogs/warningslogs/app 三个目录,用于分别保存错误、警告和常规日志。

6.2.2. 定义日志颜色

接下来,通过 chalk 为不同的日志级别定义颜色。这样在控制台输出时,不同的日志级别会有明显的颜色区分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import * as chalk from 'chalk';

const getChalkColor = (level: string): chalk.Chalk => {
switch (level) {
case 'error':
return chalk.red;
case 'warn':
return chalk.yellow;
case 'info':
return chalk.green;
case 'debug':
return chalk.blue;
case 'verbose':
return chalk.cyan;
default:
return chalk.white;
}
};

这样做的好处是,可以根据日志等级设置不同颜色,从而在控制台中快速识别重要的日志信息。

6.2.3. 配置 winston 日志选项

接下来,我们配置 winston 的核心功能,包括日志格式、日志文件轮转和控制台输出:

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
import { createLogger, format, transports } from 'winston';
import * as DailyRotateFile from 'winston-daily-rotate-file';

const winstonLogger = createLogger({
format: format.combine(format.timestamp(), format.errors({ stack: true }), format.splat(), format.json()),
defaultMeta: { service: 'log-service' },
transports: [
new DailyRotateFile({
filename: path.join(__dirname, '..', '..', 'logs/errors/error-%DATE%.log'),
datePattern: 'YYYY-MM-DD-HH-mm-ss',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
level: 'error'
}),
new DailyRotateFile({
filename: path.join(__dirname, '..', '..', 'logs/warnings/warning-%DATE%.log'),
datePattern: 'YYYY-MM-DD-HH-mm-ss',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
level: 'warn'
}),
new DailyRotateFile({
filename: path.join(__dirname, '..', '..', 'logs/app/app-%DATE%.log'),
datePattern: 'YYYY-MM-DD-HH-mm-ss',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d'
}),
new transports.Console({
format: format.combine(
format.colorize(),
format.simple(),
format.printf(info => {
const level = info.level.toLowerCase();
const chalkColor = getChalkColor(level);
return `${chalkColor(`${info.timestamp} - ${info.level}:`)} ${info.message}`;
})
),
level: 'debug'
})
]
});

export default winstonLogger;

这段配置实现了以下几个功能:

  • DailyRotateFile:设置了日志文件的轮转,每个日志类型(错误、警告、应用)都将按日期命名并存储
  • 控制台输出:控制台输出带有颜色区分,并包含时间戳和日志级别,便于快速读取
  • 日志格式:定义了 json 格式日志输出,并包含 timestampstack 信息

6.3. 引入日志配置

AppModule 中通过 WinstonModule 引入 winstonLogger,使日志系统能够全局生效:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { WinstonModule } from 'nest-winston';
import winstonLogger from './loggers/winston.logger';

@Module({
imports: [
// ...
WinstonModule.forRoot({
transports: winstonLogger.transports,
format: winstonLogger.format,
defaultMeta: winstonLogger.defaultMeta,
exitOnError: false
})
]
});

通过 WinstonModule.forRoot() 配置,我们将之前定义的 winstonLogger 作为全局日志管理器,使 NestJS 自动将应用日志转发到 winston

6.4. 应用日志系统

为了启用配置的日志系统,我们需要在 main.ts 中将其应用到应用程序:

1
2
3
4
5
6
7
8
9
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';

async function bootstrap() {
// ...

app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));

// ...
}

这样就将 winston 配置的日志记录器注入到应用中,使日志管理器可以通过 NestJS 的日志 API 来记录日志。

6.5. 修改 LoggerMiddleware

最后,我们来修改一下 LoggerMiddleware,将其记录下每个请求的详细信息,包括:

  • 请求方法
  • URL
  • IP
  • HTTP 版本
  • 状态码
  • 响应时间等

这在调试和性能分析时尤为有用。

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
import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as dayjs from 'dayjs';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
private logger = new Logger();
use(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
const { method, originalUrl, ip, httpVersion, headers } = req;
const { statusCode } = res;

res.on('finish', () => {
const end = Date.now();
const duration = end - start;
const logFormat = `${dayjs().valueOf()} ${method} ${originalUrl} HTTP/${httpVersion} ${ip} ${statusCode} ${duration}ms ${headers['user-agent']}`;

if (statusCode >= 500) {
this.logger.error(logFormat, originalUrl);
} else if (statusCode >= 400) {
this.logger.warn(logFormat, originalUrl);
} else {
this.logger.log(logFormat, originalUrl);
}
});

next();
}
}
  • 事件监听:使用 finish 事件来确保所有响应数据都已发送
  • 日志格式:每个请求记录的格式包括请求时间、请求方法、URL、IP、状态码、响应耗时等信息
  • 自动区分日志级别:根据响应的状态码自动设置日志等级
    • 错误状态码(500+)记录为 error
    • 客户端错误(400+)记录为 warn
    • 其他成功请求则都记录为 log