阅读视图

发现新文章,点击刷新页面。

第8章 Three.js入门

8.1 初始化项目

three.js 是一个基于JavaScript 的WebGL 引擎,可直接在浏览器中运行GPU 驱动的游戏与图形驱动的应用。 three.js 的库提供了大量用于在浏览器中绘制3D场景的特性与API。我们的入门就基于three.js库去调用对应的API。

需要完成的前置条件如下4点:

(1)创建一个空项目THREEJS。

(2)创建index.html和main.ts文件,用于后续编写示例代码。

(3)安装three.js库和对应声明文件。

(4)安装Vite用于启动项目。

// 安装three.js库和对应声明文件
npm i three
npm i --save-dev @types/three
// 安装Vite用于启动项目
npm i vite -D

其中index.html是作为展示3D场景界面的文件,然后需要导入main.ts文件,main.ts文件是作为编写three.js的代码文件。

// index.html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    html,
    body {
      margin: 0;
      padding: 0;
      overflow: hidden;
    }
  </style>
</head>

<body>
  <script src="./main.ts" type="module"></script>
</body>

</html>

接着到package.json文件中配置vite的启动命令,然后启动项目。

// package.json
{
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "three": "^0.182.0"
  },
  "devDependencies": {
    "@types/three": "^0.182.0",
    "vite": "^7.3.0"
  }
}

8.2 案例搭建

完成项目的初始化并启动项目后,项目界面是一片空白,因为我们还没有编写对应代码,接下来回到main.ts文件中,完成以下3点操作:

(1)引入three库。

(2)创建场景

(3)创建网格。

创建场景永远是第一件要做的事情,它是所有3D物体的容器,就像在2D Canvas中必须先获取画布上下文一样基础。接着是创建网格,three.js中的所有可见的3D物体都是基于网格去组成,网格可以有多个,网格包含几何体和材质。

几何体(Geometry):定义了物体的形状,即顶点、面等结构信息。

材质(Material):定义了物体表面的外观,例如颜色、纹理、光滑度等。

创建网格也意味着需要创建几何体和材质,然后放入网格中。几何体有多种形状,对应不同方法,填入不同参数;材质也有多种材质选择,通过不同方法去操作。

import * as THREE from 'three';

//创建场景
const scene = new THREE.Scene();

//创建几何体
const geometry = new THREE.BoxGeometry(100, 100, 100); // x,y,z三轴

//创建材质
//MeshBasicMaterial 这个材质是不受光照影响
//MeshLambertMaterial 这个材质是受光照影响 漫反射材质
//MeshPhongMaterial 这个材质是受光照影响 镜面高光
const material = new THREE.MeshBasicMaterial({color: 0x00ff00});

//网格 几何体 + 材质 可以有多个
const mesh = new THREE.Mesh(geometry, material);

//将网格添加到场景中
scene.add(mesh);

网格需要包含几何体和材质是很好理解的,几何体是物品的形状,而材质是物品的表面,网格就是将两者结合起来的“完整物体”,类似于3D建模。

创建网格属于场景的部分,而一个最简的Three.js代码结构需要包含三个核心组件:

(1)Scene(场景):是舞台。所有演员、道具、灯光都必须放在这个舞台上。

(2)Camera(相机):是摄像机。它决定了你从哪个角度、以何种视野去观看舞台。

(3)Renderer(渲染器):是负责把摄像机拍到的画面,实际绘制到屏幕画布上的“渲染引擎”。没有它,一切准备都只是数据,看不到图像。

因此创建网格并填充对应的几何体和材质意味着我们拥有了一个最简单的物品填入场景中作为被观察对象(网格需要添加到场景中),在这之后还需要创建相机和渲染器。接下来我们开始创建相机。

相机通过THREE.PerspectiveCamera()创建,需要四个参数分别是:视野角度(fov)、宽高比(aspect)、近裁剪面(near)、远裁剪面(far)。它们一起构成了一个视锥体,决定了相机能看到什么。视野角度控制可见范围的垂直开合程度,类似摄像机的镜头焦距;宽高比确保渲染不变形,通常直接使用窗口比例;远近裁剪面则定义了相机能看清的最小和最大距离,就像人眼的最近视点和最远视点。相机视角如图8-1所示。

image.png

图8-1 相机视角

在我们以下代码示例中,第一个参数 75 是垂直视野角度,类似人眼睁开的角度,值越大看到的场景越广;第二个参数 window.innerWidth / window.innerHeight 是宽高比,通常设置为渲染区域的宽除以高,以确保物体不被拉伸变形;第三个参数 0.1 是近裁剪面,表示相机能看清的最短距离,比这更近的物体将被裁剪不可见;第四个参数 1000 是远裁剪面,表示相机能看清的最远距离,比这更远的物体同样不可见,这四个参数共同划定了相机在三维空间中实际能观察到的范围。

接着我们需要设置相机放置的位置,就和现实一样,拍摄所在的位置决定了画面的叙事视角、视觉重点和情感基调。将相机靠近物体并采用低角度,能像电影特写一样赋予主体压迫感和权威性,常用于突出核心元素或营造紧张氛围;反之,将相机拉远并提升高度,则形成俯瞰式的宏观视角,适合展现场景全貌、空间关系或个体的渺小感。通过精确控制相机与主体的距离、高度和角度,能够决定整个场景是通过一个“第一人称”的沉浸式窗口呈现,还是作为一个“上帝视角”的客观全景被观察。

最后,我们需要将相机加入场景中,正如前面所说的所有演员、道具、灯光都必须放在这个舞台(场景)上。

// 创建相机
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
// 设置相机放置的位置
camera.position.set(0, 0, 400);
// 将相机加入场景
scene.add(camera);

最后,我们需要创建渲染器,将摄像机拍到的画面,实际绘制到屏幕画布。

// 创建WebGL渲染器实例,这是Three.js用来绘制3D场景的核心工具
const renderer = new THREE.WebGLRenderer();
// 设置渲染器输出画布的尺寸为整个浏览器窗口的宽度和高度
renderer.setSize(window.innerWidth, window.innerHeight);
// 将渲染器自动生成的<canvas>画布DOM元素添加到网页的<body>中,这样画面才能显示出来
document.body.appendChild(renderer.domElement);
// 执行一次性的渲染操作:命令渲染器从指定相机(camera)的视角,将场景(scene)中的所有物体绘制到画布上
renderer.render(scene, camera);

实际完整Demo代码如下:

如果创建材质选择MeshPhongMaterial这种受光照影响的要素,那么需要添加光照,否则看不见。如果你的画面看不到物体的话,你需要考虑去看下代码部分中的材质是否受光照影响。

import * as THREE from 'three';

//创建场景
const scene = new THREE.Scene();

//创建几何体
const geometry = new THREE.BoxGeometry(100, 100, 100); // x,y,z三轴

//创建材质
//MeshBasicMaterial 这个材质是不受光照影响
//MeshLambertMaterial 这个材质是受光照影响 漫反射材质
//MeshPhongMaterial 这个材质是受光照影响 镜面高光
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });

//网格 几何体 + 材质 可以有多个
const mesh = new THREE.Mesh(geometry, material);

//将网格添加到场景中
scene.add(mesh);


// 创建相机
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
// 设置相机放置的位置
camera.position.set(0, 0, 400);
// 将相机加入场景
scene.add(camera);

// 创建WebGL渲染器实例,这是Three.js用来绘制3D场景的核心工具
const renderer = new THREE.WebGLRenderer();
// 设置渲染器输出画布的尺寸为整个浏览器窗口的宽度和高度
renderer.setSize(window.innerWidth, window.innerHeight);
// 将渲染器自动生成的<canvas>画布DOM元素添加到网页的<body>中,这样画面才能显示出来
document.body.appendChild(renderer.domElement);
// 执行一次性的渲染操作:命令渲染器从指定相机(camera)的视角,将场景(scene)中的所有物体绘制到画布上
renderer.render(scene, camera);

Three.js创建的Demo画面如图8-2所示。

image-20251218205238843

图8-2 Three.js创建Demo画面

目前我们场景内只有一个正方块物体,正被相机拍摄着,但看着就像2D的画面。因此我们可以通过引入OrbitControls(轨道控制器)来实现拖动效果,从而实现3D效果。

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
//创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);

const animate = () => {
    requestAnimationFrame(animate);
    controls.update();
    renderer.render(scene, camera);
}
animate();

轨道控制器拖动效果如图8-3所示。由于目前正方体是没有边缘线的,因此静止的时候看起来更像是不规则的平面物体,我稍微添加了几条红色线条(不太规范)来辅助理解,大致能看出这是一个正方体。

image-20251218205731942

图8-3 轨道控制器拖动效果

以上是Three.js入门的一个简单案例。在创建轨道控制器的时候,我们添加了一个定时器,并且使用了递归,但不会出现死循环导致爆栈的情况。因为使用的是浏览器原生API requestAnimationFrame 实现的动画循环,它并不是传统意义上的递归死循环。

代码步骤思路为以下2步:

(1)requestAnimationFrame(animate):向浏览器“预约”下一帧,告诉浏览器:“在下次屏幕刷新绘制时,请调用animate函数”。它不会立即、连续地调用自身。

(2)浏览器控制节奏:浏览器会以屏幕刷新率(通常是60FPS,即每秒约60次) 的节奏来回调animate函数。当页面隐藏或最小化时,浏览器会自动暂停这些回调以节省资源。

所以通过requestAnimationFrame执行循环,每帧执行完后会释放主线程,等待浏览器下一次绘制时机(执行时机在DOM回流和重绘之前),浏览器牢牢掌控住绘制的运行时间间隔,甚至决定了什么时候会暂停,所以自然不会出现死循环的情况。

这种非阻塞的协作式循环在性能优化(与屏幕刷新同步,避免不必要的重复渲染),节能(页面不可见时自动暂停)和流畅动画(动画更新与屏幕刷新率一致)方面都很不错。这种技术被称为RAF技术。

const animate = () => {
  // 递归调用animate
  requestAnimationFrame(animate);
  controls.update();
  renderer.render(scene, camera);
}
animate();

通过以上思路,需要先有整体的场景,然后往场景里添加被观察对象(演员、道具、灯光等),接着是观察对象(相机),最后用渲染器将相机拍到的画面渲染出来。在这里场景是我们(导演)的视角,而摄像机才是观众的视角。相机所拍摄的部分才是我们想展现的部分。我们应该从实际摄影所带来的经验中去思考如何拍摄。

8.3 添加灯光

接下来,我们修改材质,将其设置为MeshPhongMaterial这种受光照影响的材质,然后加入灯光,看效果如何。

const material = new THREE.MeshPhongMaterial({ color: 0x00ff00 });

// 添加平行光源(模拟太阳光)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(1, 1, 1).normalize();
scene.add(directionalLight);

模拟太阳光对材质进行照射如图8-4所示。可见物体呈现了不一样的质感。像太阳光属于平行光的一种,只能照射到正方体的正面,因此正方体背面还是不可见的。

image-20251218211611489

图8-4 模拟太阳光

在Three.js中,灯光是塑造三维空间体积感、材质属性和场景氛围的核心工具,本质上是通过模拟光线与物体材质的交互来定义视觉层次。主要分为四种基本类型:

(1)环境光提供均匀无方向的基底照明,如同阴天的漫射光,用于消除纯黑阴影;

(2)平行光模拟无限远处的光源(如太阳),发出平行光线,产生方向明确的阴影,适合户外场景;

(3)点光源从一个点向所有方向均匀辐射光线,像灯泡或蜡烛,能营造真实的衰减和柔和的明暗过渡;

(4)聚光灯则形成锥形的光束,像手电筒或舞台追光,带有清晰的照射范围和边缘衰减,常用于突出特定物体或制造戏剧性焦点。

实际应用中,通常需要组合多种灯光——例如用环境光奠定基调,再用平行光或点光源刻画主次和投影——才能构建出既有层次又自然可信的三维视觉空间。

第6章 Postgres数据库安装

PostgreSQL是世界上最先进的开源对象关系型数据库,比MySQL会更好用一些。

下载地址:PostgreSQL: Downloads,从该页面选择适合自己电脑系统的下载包,有Linux、macOS、Windows,BSD和Solaris五个选项。

对于Windows系统而言,下载地址为:EDB: Open-Source, Enterprise Postgres Database Management。选择PostgreSQL Version里最前列的版本(最新版本),在我下载时,最新版本为18.1,实际最新版本以你下载时为准,选择Windows x86-64选项进行下载。如图6-1所示。

image-20251214194715316

图6-1 PostgreSQL安装包下载地址

下载安装包之后,不断点击Next(下一步)就行。安装过程会确定以下11点步骤:

(1)Welcome to the PostgreSQL Setup Wizard(欢迎来到PostgreSQL安装向导),点击Next。

(2)安装目录(默认/自定义),通常默认就行。

(3)选择需要安装的组件(Select Components),默认勾选PostgreSQL Server、pgAdmin 4、Stack Builder和Command Line Tools四个选项,直接点击Next。

(4)创建存放数据的目录(Data Directory),就默认安装目录,继续点击Next。

(5)Password,输入登录密码(自定义),通常推荐123456,免得自己也忘了,然后点击Next。

(6)Post,分配给PostgreSQL的端口,默认分配端口可能每个人都不一样,但大概率是5432,直接点击Next。

(7)Advanced Options(高级选项),默认选择,点击Next。

(8)Pre Installation Summary(安装前的总结),直接点击Next。

(9)Ready to Install(准备安装),点击Next。

(10)等待安装结束,弹出Completing the PostgreSQL Setup Wizard(完成PostgreSQL安装向导),勾选Stack Builder may be used to download... 用于补充其他工具。

(11)弹出Stack Builder安装界面继续不断的Next就行,中途要输密码输密码,要选择安装应用程序就选择EDB Language Pack(EDB语言安装包)就行。

安装结束之后,我们需要确认是否安装成功。打开vscode或者cursor等编辑器,下载插件Database Client(数据可视化工具),然后使用。

PostgreSQL可视化工具如图6-2所示。服务类型选择PostgreSQL,输入密码123456。

image-20251214200916239

图6-2 PostgreSQL可视化工具

PostgreSQL安装成功如图6-3所示。当连接PostgreSQL数据库成功,说明PostgreSQL安装成功。

image-20251214201124048

图6-3 PostgreSQL安装成功

第4章 Nest.js业务合并

第4章 Nest.js业务合并

在实际项目中,不同的业务操作需要明确的反馈信息。例如:

  • 登录操作返回消息为「登录成功」,状态码为 1。
  • 注册操作返回消息为「注册成功」,状态码为 2。

状态码 作为业务逻辑判断的操作标识,是一个数字或字符串标识符。不同数值代表不同的业务含义(如1表示登录成功、2表示注册成功),前端或调用方可根据具体数值执行相应的逻辑分支。业务描述 则面向用户或开发者,用自然语言清晰传达操作结果,提供直观的反馈信息,辅助理解状态码对应的具体业务场景。

在全局拦截器的使用中,message 和 code 这两个参数需要根据业务需求进行自定义。下面将介绍如何在 Nest.js 中对这两个参数,以及更多同类参数进行规范化管理。

// src/interceptor/interceptor.interceptor.ts
return {
  timestmap: new Date().toISOString(),
  data: transformBigInt(data),
  path: request.url,
  message: 'success',//业务逻辑自定义
  code: 200,//业务逻辑自定义
  success: true,
};

要对全局拦截器的统一返回数据格式中的 message 和 code 进行自定义,应从专门的业务定义文件中引入自定义的业务状态码和描述信息,然后传递给 message 和 code 参数。

在自定义参数时,有一个重要原则:message 和 code 的自定义数据需要与业务逻辑层分离。这些数据应作为纯粹的配置信息使用,类似于 JSON 配置文件。业务层则建立在统一的业务规范基础上进行进一步封装。

首先创建一个响应格式模块(response module)和服务(response service),用于统一处理业务响应。通过 Nest CLI 命令生成的 response.service.ts 文件会自动关联到 response.module.ts 文件中。

nest g mo response
nest g s response

在 src 目录下创建 business 文件夹,并在其中创建 index.ts 文件。该文件专门存放自定义的业务状态码和描述信息,我们只关心每个业务操作对应的状态码和消息内容。后续新增业务需求时,只需在此文件中添加相应的状态码和消息。

注意:业务字段命名通常采用大写字母和下划线组合的格式,即「功能_状态」的表达方式,如 LOGIN_SUCCESS。

// src/business/index.ts
export const business = {
  LOGIN_SUCCESS: {
    code: 1,
    message: '登录成功'
  },
  LOGIN_ERROR: {
    code: 2,
    message: '登录失败'
  },
  REGISTER_SUCCESS: {
    code: 2,
    message: '注册成功'
  },
}

在 response 目录的 response.service.ts 文件中编写具体的响应格式逻辑,定义操作成功和操作失败时需要返回的数据结构。

// src/response/response.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class ResponseService {
  success(data: any = null, message: string = '操作成功', code: number = 200) {
    return {
      data,
      message,
      code
    }
  }
  error(message: string = '操作失败', code: number = 500) {
    return {
      message,
      code
    }
  }
}

如果我们想要使用自定义的业务状态码,要如何使用?假设要在user模块的业务层中使用。

// src/user/user.service.ts(业务层)
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

@Injectable()
export class UserService {
  create(createUserDto: CreateUserDto) {
    return 'This action adds a new user';
  }

  findAll() {
    return `This action returns all user`;
  }
 // 省略...
}

假设需要在 user 模块的业务层中使用自定义业务状态码,操作步骤如下:

(1)引入依赖文件:同时引入业务定义文件和响应格式文件。

(2)依赖注入:在 UserService 类中注入 ResponseService 类。

(3)使用响应格式:在 findAll() 方法中使用 ResponseService 类,按照 data、message 和 code 的顺序传入数据。

(4)引用业务定义:message 和 code 从 business 业务文件中读取,本次「登录成功操作」使用业务字段 LOGIN_SUCCESS。

// src/user/user.service.ts(业务层)
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { ResponseService } from 'src/response/response.service';
import { business } from 'src/business';
@Injectable()
export class UserService {
  constructor(private readonly responseService: ResponseService) { }
  create(createUserDto: CreateUserDto) {
    return 'This action adds a new user';
  }

  findAll() {
    // 登录成功的消息内容和业务码
    const message = business.LOGIN_SUCCESS.message;
    const code = business.LOGIN_SUCCESS.code;
    return this.responseService.success('This action returns all user', message, code);
  }
}

此时启动项目,访问 localhost:3000/user 路由应返回登录成功的业务状态码。但可能出现以下错误:

RROR [ExceptionHandler] UnknownDependenciesException [Error]: Nest can't resolve dependencies of the UserService (?). Please make sure that the argument ResponseService at index [0] is available in the UserModule context.

错误信息表明 Nest 无法解析 UserService 的依赖,需要确保 ResponseService 在 UserModule 上下文中可用。这是因为 ResponseService 未被正确导出和导入。

解决上述问题需要如下3个步骤:

(1)导出服务:在 response.module.ts 文件中将 ResponseService 添加到 exports 数组中。

(2)导入模块:在 user.module.ts 文件中导入 ResponseModule 模块。

(3)完成注入:此时 UserModule 可以读取到 ResponseModule 导出的 ResponseService,UserService 才能正常使用 ResponseService。

// src/response/response.module.ts
import { Module } from '@nestjs/common';
import { ResponseService } from './response.service';

@Module({
  providers: [ResponseService],
  exports: [ResponseService]
})
export class ResponseModule { }

在user.module.ts 文件中导入 ResponseModule 模块,完成导入模块。

// src/user/user.module.ts
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { ResponseModule } from 'src/response/response.module';
@Module({
  imports: [ResponseModule],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}

完成以上配置后,重新启动项目访问 http://localhost:3000/user,可见 data、message 和 code 三个业务字段的信息都成功输出。

image-20251213233534395

图4-1 业务字段信息插入

接下来只需从全局拦截器中重新统一数据格式即可。

// src/interceptor/interceptor.interceptor.ts
return {
  timestmap: new Date().toISOString(),
  data: transformBigInt(data.data) ?? null,
  path: request.url,
  message: data.message ?? 'success',//业务逻辑自定义
  code: data.code ?? 200,//业务逻辑自定义
  success: true,
};

统一业务字段格式如图4-2所示。

image-20251213234031967

图4-2 统一业务字段格式

通过以上步骤,Nest.js 的业务状态码和业务状态描述已成功应用到对应的 user 模块接口中。但在实现过程中,我们发现每个使用 ResponseService 的模块都需要:

(1)在提供模块中导出服务。

(2)在使用模块中导入模块。

如果项目有十几个模块都需要使用,这种重复导入导出的操作会变得繁琐。为此,可以将 response 模块注册为全局模块,这样在整个项目中都可以直接使用,无需在每个使用模块的 module.ts 文件中重复导入。

将 response 模块注册为全局模块的方法为以下2步:

(1)从 @nestjs/common 中导入 Global 装饰器

(2)将 @Global() 装饰器应用到 ResponseModule 上

注册为全局模块后,ResponseService 可以在任何模块中直接使用,无需在各使用模块中导入 ResponseModule。

// src/response/response.module.ts
import { Module, Global } from '@nestjs/common';
import { ResponseService } from './response.service';

@Global()
@Module({
  providers: [ResponseService],
  exports: [ResponseService]
})
export class ResponseModule { }

此时如果我们回到user.module.ts文件中,将刚才注册的ResponseModule删除,项目也不会报错。

对于全局模块的使用,若我想在xiaoyu模块中使用ResponseService,主要步骤如下,无需在XiaoyuModule中导入ResponseModule:

(1)在 xiaoyu.service.ts 文件中导入 ResponseService 服务类和 business 业务常量。

(2)在 XiaoyuService 的构造函数中注入 ResponseService,并使用其方法按照业务数据格式规范处理数据。

(3)在 xiaoyu.controller.ts 文件的控制器中注入 XiaoyuService,并在路由处理器中调用其业务方法返回处理结果。

注意:虽然 ResponseService 是全局模块无需导入,但 XiaoyuModule 仍需要在自身的 providers 中注册 XiaoyuService,在 controllers 中注册 XiaoyuController。

// src/xiaoyu/xiaoyu.service.ts
import { Injectable } from '@nestjs/common';
import { ResponseService } from 'src/response/response.service';
import { business } from 'src/business';
@Injectable()
export class XiaoyuService {
  constructor(private readonly responseService: ResponseService) {}
  getHello(): any {
    const message = business.LOGIN_SUCCESS.message;
    const code = business.LOGIN_SUCCESS.code;
    return this.responseService.success('This action returns all user', message, code);
  }
}
// src/xiaoyu/xiaoyu.controller.ts
import { Controller, Get } from '@nestjs/common';
import { XiaoyuService } from './xiaoyu.service';

@Controller('xiaoyu')
export class XiaoyuController {
  // 依赖注入
  constructor(private readonly xiaoyuService: XiaoyuService) {}
  @Get()
  getHello(): any {
    return this.xiaoyuService.getHello();
  }
}

xiaoyu模块业务合并如图4-3所示。

image-20251214004117303

图4-3 xiaoyu模块业务合并

以上就是Nest.js业务层的所有内容,我们回顾一下,Nest.js 的业务处理分为 business 和 response 两个部分:

(1)business 文件夹:集中管理业务状态码和描述信息。

(2)response 模块:专门用于构建统一的响应格式。

在实际项目中,这两者的变化频率不同:响应格式通常保持稳定,而业务状态码会随着业务发展不断新增或调整。因此需要将两者分离管理。response 模块的功能与全局拦截器中统一返回客户端响应格式的功能是一致的,区别在于我们将自定义部分拆分出来,提高了灵活性和可维护性,更符合实际项目的需求变化。

这种架构设计的优势在于:业务状态定义与响应格式构建职责明确(单一职责);业务状态变化不影响响应格式,响应格式调整不影响业务逻辑;统一的响应格式可跨模块、跨项目使用;新增业务状态只需在 business 文件中添加,不影响现有结构。通过这种规范化管理,可以构建出清晰、可维护、可扩展的业务层架构,适应各种复杂的业务场景需求。

除了message和code这两个字段,我们还可以有权限与安全控制(例如IP白名单、频率限制、黑白名单)、数据持久化(创建、读取、更新、删除业务数据)、业务流程(多级审批、会签、或签逻辑)、第三方集成(支付宝、微信支付回调处理)、监控与统计(接口响应时间、成功率)等多方面基于实际业务需求去增添。

第3章 Nest.js拦截器

3.1 拦截器介绍

Nest.js的拦截器和axios的拦截器是类似的,可以在网络请求处理的前后去执行额外的逻辑。拦截器从字面意思理解就是拦截,假设有流程A->B,拦截器要做的是A到B的过程中,将内容拦截下来处理后再丢给B,变成了A->拦截器->B。

在网络请求的逻辑中,拦截器的拦截位置如下:

  • 客户端请求->拦截器(前置逻辑)->路由处理器->拦截器(后置逻辑)->客户端响应。

Nest.js拦截器效果如图3-1所示。

image-20251212195404071

图3-1 Nest.js拦截器

Nest.js拦截器主要的用途有以下5点:

(1)统一响应格式:将返回数据包装成统一格式。

(2)日志记录:记录请求耗时、请求参数等信息。

(3)缓存处理:对响应数据进行缓存。

(4)异常映射:转换异常类型。

(5)数据转换:对响应数据进行序列化/转换。

在英语AI项目中,主要使用到第5点数据转换,因此我们主要学习这一点。

3.2 拦截器创建

如表1-2所示,可以通过nest g itc interceptor快速创建一个拦截器(interceptor可以替换为任何你想取的拦截器名称)。通过该命令会在src文件夹下创建interceptor文件夹,而interceptor文件夹下存放interceptor.interceptor.ts文件。

根据命令的生成规则,我们知道文件夹和文件的名称取决于我们命令对拦截器的名称,从而生成xxx文件夹和xxx.interceptor.ts文件。并且在这唯一的文件中,会提前生成好对应的Demo示例。

//src/interceptor/interceptor.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle();
  }
}

拦截器有两种使用方式:

(1)局部使用。

(2)全局使用。

当想局部使用时,例如只想在src文件夹下的user模块使用,我们只需要注册到user模块中。那怎么注册?有3种注册方式,在user.module.ts、user.controller.ts以及user.controller.ts都可以注册,最主要的区别在于局部作用范围不同。Nest.js拦截器局部注册如表3-1所示。

表3-1 Nest.js拦截器局部注册

注册方式 作用范围 代码位置 优点 缺点
模块级别 整个模块所有控制器 user.module.ts 统一管理,自动应用到所有路由 无法灵活排除某些路由
控制器级别 单个控制器所有路由 user.controller.ts 控制器粒度控制 需在每个控制器添加装饰器
路由级别 单个路由方法 user.controller.ts 最精细的控制 代码重复,管理复杂

局部使用的具体代码不演示,可通过AI或者官方文档学习使用。

3.3 全局拦截器使用

在英语AI项目中会使用到全局使用,我们这里学习具体如何全局使用。步骤为以下2步:

(1)使用nest g itc <拦截器名称>快速创建一个拦截器。

(2)将拦截器注册到main.ts文件中,即在main.ts文件中导入刚创建的拦截器,并且使用Nest应用程序实例方法useGlobalInterceptors()。

// main.ts文件
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { InterceptorInterceptor } from './interceptor/interceptor.interceptor';
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(new InterceptorInterceptor());
  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

当然由于InterceptorInterceptor拦截器是一个类,所以我们需要使用new运算符创建拦截器的实例以供使用。到这里,InterceptorInterceptor拦截器就是全局使用,即每一个接口都会经过该拦截器。Nest.js全局注册的官方文档如图3-2所示。

image-20251212212343423

图3-2 Nest.js拦截器全局注册

此时来编写InterceptorInterceptor拦截器内的逻辑,可见引入了来自rxjs的Observable类,rxjs是Nest.js内部自带的,主要用于处理流的,使用频率不高。通常获取数据需要区分同步与异步,同步直接获取,而异步通过Promise的then或者catch方法获取。如果此时有rxjs,就不需要我们去关注获取的数据是同步或者异步的问题,减少心智负担。rxjs会将这些数据统一转成一个数据流,然后通过管道(pipe)去接收,接收到之后可由我们处理该数据格式,无论是通过map遍历处理还是filter过滤等等,最终将处理好的数据格式返回就行。

以上是rxjs的核心理念,除此之外,它还可以同时处理多个异步,而then或者catch方法每次只能处理一个。

像InterceptorInterceptor拦截器中的所返回的next.handle()就是一个Observable(数据流),所以我们需要通过pipe(管道)去接收数据然后使用rxjs的map方法对数据处理之后再返回数据。

我们将原始数据包裹在一个标准响应结构中,添加了时间戳、请求路径、业务状态码、成功标志和自定义消息。这样确保了所有经过此拦截器的HTTP响应都遵循统一的JSON格式,包括 { timestamp, data, path, message, code, success } 等标准化字段,前端可以统一处理和错误追踪。

// src/interceptor/interceptor.interceptor.ts文件
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Request } from 'express';

@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 将通用的执行上下文切换到HTTP特定的上下文
    const ctx = context.switchToHttp();
    // 获取当前HTTP请求的详细对象,包含了请求方法、URL、请求头、参数、主体等所有信息。
    const request = ctx.getRequest<Request>();
    return next.handle().pipe(map((data) => {
      return {
        timestmap: new Date().toISOString(),
        data: data,
        path: request.url,
        message: 'success',//业务逻辑自定义
        code: 200,//业务逻辑自定义
        success: true,
      };
    }));
  }
}

此时在浏览器的URL输入http://localhost:3000/user/123,访问带参数的get请求,get请求拦截效果如图3-3所示。在这里体现的是:路由处理器->拦截器(后置逻辑)->客户端响应。

image-20251212220853053

图3-3 Nest.js全局拦截器-get请求拦截效果

message和code字段属于业务逻辑的部分,后续完成英语AI项目时,会根据业务实际逻辑去自定义设置。

3.4 优化全局拦截器

但此时全局拦截器还有一个很大的Bug,假如接口返回一个很大的数据,我们通过BigInt数据类型去处理返回,那么在通过全局拦截器时就会出现报错情况,全局拦截器处理BigInt类型报错如图3-4所示。

image-20251212222211323

图3-4 全局拦截器处理BigInt类型报错

报错是error TS2322: Type 'bigint' is not assignable to type 'string'。即bigint类型无法赋值给string类型,这是很正常的。因为全局拦截器的这些参数都是通过JavaScript标准内置对象JSON.stringify()进行格式化的,而JSON.stringify()是没办法处理BigInt值的。在MDN文档中是这样表述这一异常情况:当尝试去转换 BigInt类型的值会抛出TypeError("BigInt value can't be serialized in JSON")(BigInt 值不能 JSON 序列化)。

// src/app.service.ts文件
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello() {
    return BigInt(123456789123456789123456789)
  }
}

所以我们需要针对BigInt类型的值去处理,通过编写transformBigInt方法去单独处理这一情况,主要处理的事情是当遇到BigInt类型的值就将它转成一个字符串。

const transformBigInt = (data: any) => {
  if (typeof data === 'bigint') {
    return data.toString();
  }
  return data;
};

此时将接口(get请求)返回给用户的data数据放入transformBigInt方法中即可。

// src/interceptor/interceptor.interceptor.ts文件
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Request } from 'express';

const transformBigInt = (data: any) => {
  if (typeof data === 'bigint') {
    return data.toString();
  }
  return data;
};

@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const ctx = context.switchToHttp();
    const request = ctx.getRequest<Request>();
    return next.handle().pipe(map((data) => {
      return {
        timestmap: new Date().toISOString(),
        data: transformBigInt(data),
        path: request.url,
        message: 'success',//业务逻辑自定义
        code: 200,//业务逻辑自定义
        success: true,
      };
    }));
  }
}

但此时还会报错同样的问题(Type 'bigint' is not assignable to type 'string'),这是很正常的。我们来梳理下流程:

(1)接口返回数据给前端。

(2)全局拦截器拦截接口返回的数据进行处理。

(3)全局处理后的数据返回给前端。

我们已经在全局拦截器中处理好类型转换问题(BigInt转String),如果还有问题,就只能在第一步的接口返回数据给前端的步骤中。前端访问的是接口,而接口是体现形式是路由,路由层从业务层获取数据返回给前端。因此在业务层的数据是BigInt类型,则路由层所拿到的数据也会是BigInt类型。由于Nest.js是强制使用TypeScript的,所以我们需要到app.controller.ts文件中将get默认请求所返回的类型限制从string改成any类型或者string和bigint的联合类型。此时就能正常运行代码。

// src/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) { }

  @Get()
  getHello(): string | bigint {
    return this.appService.getHello();
  }
}

出于严谨的考虑,我们需要处理相应的边界判断,假如BigInt类型在数组里,在对象里呢?原有的处理方式就又解析不了了。

return [BigInt(123456789123456789123456789)];
return { a: BigInt(123456789123456789123456789) };

所以需要进一步强化transformBigInt方法,对数组遍历处理内部可能存在的BigInt类型,而对象则通过Object.entries()静态方法将对象切换成保存键值对的二维数组后,遍历键值对并针对其中的value值处理可能存在的BigInt类型,最后通过Object.fromEntries()静态方法将键值对形式的二维数组重新转换回原始对象。

  • 对象打印效果:{ foo: "bar", baz: 42 }。

  • 将可迭代对象切成二维数组:[ ['foo', 'bar'], ['baz', 42] ]。

将对象切成二维数组更方便找到键值对的值并进行遍历操作。

const transformBigInt = (data: any) => {
  if (typeof data === 'bigint') {
    return data.toString();
  }
  if(Array.isArray(data)){
    return data.map(transformBigInt);
  }
  if(typeof data === 'object' && data !== null){
    return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, transformBigInt(value)]));
  }
  return data;
};

做完以上的优化后,我们会发现接口要返回Date日期没办法正常返回给前端了,因为我们把对象全部都处理了,而JavaScript标准内置对象Date的使用是通过new运算符调用的实例对象,实例对象也是对象,也会被transformBigInt方法一并处理,所以在判断对象的内部逻辑中还需要判断是否是Date类型,若为Date类型则直接原路返回,不处理。

if(typeof data === 'object' && data !== null){
  if(data instanceof Date){
    return data
  }
  return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, transformBigInt(value)]));
}

完整的全局拦截器如下代码所示,后续英语AI项目中,会将该全局拦截器直接拿过去使用。

// src/interceptor/interceptor.interceptor.ts文件
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Request } from 'express';
//同步 异步 then catch ->数据流->pipe -> map filter -> 返回



const transformBigInt = (data: any) => {
  if (typeof data === 'bigint') {
    return data.toString();
  }
  if(Array.isArray(data)){
    return data.map(transformBigInt);
  }
  if(typeof data === 'object' && data !== null){
    if(data instanceof Date){
      return data
    }
    return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, transformBigInt(value)]));
  }
  return data;
};

@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const ctx = context.switchToHttp();
    const request = ctx.getRequest<Request>();
    return next.handle().pipe(map((data) => {
      return {
        timestmap: new Date().toISOString(),
        data: transformBigInt(data),
        path: request.url,
        message: 'success',//业务逻辑自定义
        code: 200,//业务逻辑自定义
        success: true,
      };
    }));
  }
}

接下来对异常也格式化统一处理一下,逻辑思路与全局拦截器类似。当前端发起不符合规范和要求的网络请求,后端就会返回异常信息,方便前端去统一处理。

image-20251212235546507

图3-5 异常情况的处理

此时我们需要总结nest命令的表1-2,找到filter命令来生成一个过滤器。命令是:nest g f <过滤器名称>,我们就通过nest g f exceptionFilter来生成一份过滤器吧。成功在src文件夹下创建exception-filter文件夹和exception-filter文件夹下的exception-filter.filter.ts文件,这些生成文件的命名规则都是一致的,不再赘述。

// src/exception-filter/exception-filter.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';

@Catch()
export class ExceptionFilterFilter<T> implements ExceptionFilter {
  catch(exception: T, host: ArgumentsHost) {}
}

通过以上exception-filter.filter.ts文件的代码,我们发现异常处理@Catch()装饰器是空的,空的表示处理所有的异常操作,包括非HTTP请求都会处理,但我希望这个业务只处理和HTTP相关的异常就可以了。所以我们需要从@nestjs/common中引入一个HttpException类,然后让@Catch()装饰器去继承HttpException类就可以了。

// src/exception-filter/exception-filter.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';

@Catch(HttpException)
export class ExceptionFilterFilter<T extends HttpException> implements ExceptionFilter {
  catch(exception: T, host: ArgumentsHost) {}
}

在这里我们可以看到这个很有意思的设计理念,通过Nest命令生成的内容,它希望我们都能用得上,这种思想和TypeScript所想表达的含义是一致的,只写用得上且必要的部分。因此在通过Nest CLI 在生成过滤器模板时,会会默认使用 @Catch()(不带任何参数),示例性地展示如何捕获所有异常。但它只是一个类模板,需要我们手动把它注册为全局过滤器,或者在控制器上使用。

只有当我们明确在@Catch()中指定具体的异常类型(如 @Catch(HttpException) 或 @Catch(WsException)),过滤器才会从“捕获所有异常”转变为“仅处理特定类型的异常”。如图3-6所示的官方文档也说明了不同协议层(HTTP 与 WebSocket)对应的异常类型不同,因此需要在 @Catch() 中明确指定对应的异常类型。

image-20251212235819873

图3-6 HTTP异常过滤层的说明

接下来我们来对异常处理情况进行统一的格式化处理。这里的code(异常状态码)就不采用我们自定义的,而是使用exception内部定义的状态码,因为Nest内置的HttpException已经为所有常见错误定义了标准化的状态码(如 400、401、403、404、500 等),这些状态码符合 HTTP 协议本身的语义。直接使用exception.getStatus()可以确保服务端返回的错误信息在网络层面是可预测和通用的。Nest.js内置异常处理层说明如图3-7所示。

image-20251213000123007

图3-7 Nest.js内置异常处理层说明

当token过期了,exception.getStatus()会自动识别并设置成401状态码,没有权限则403状态码。因此exception.getStatus()会自动化的根据实际情况去调整,非常方便。对应的详细讲解可阅读Nest.js的官方文档:Exception filters | NestJS - A progressive Node.js framework

如果再自定义一套error code,就等于需要维护两套错误体系:HTTP 状态码 + 我们自己额外设计的业务错误码,这会造成重复劳动、文档负担加重以及维护难度上升。而直接使用 HttpException 内部的状态码可以保持异常捕获逻辑与框架一致,不需要额外重复造轮子。

// src/exception-filter/exception-filter.filter.ts文件
import { ArgumentsHost, Catch, ExceptionFilter,HttpException } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class ExceptionFilterFilter<T extends HttpException> implements ExceptionFilter {
  catch(exception: T, host: ArgumentsHost) {
    const ctx = host.switchToHttp()
    const request = ctx.getRequest<Request>()
    const response = ctx.getResponse<Response>()
    return response.status(exception.getStatus()).json({
      timestamp: new Date().toISOString(),
      path: request.url,
      message: exception.message,
      code: exception.getStatus(),
      success: false,
    })
  }
}

最后,过滤器和拦截器一样,在main.ts文件中全局注册一下,则可以作用于整个项目的异常情况处理。

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { InterceptorInterceptor } from './interceptor/interceptor.interceptor';
import { ExceptionFilterFilter } from './exception-filter/exception-filter.filter';
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(new InterceptorInterceptor());
  app.useGlobalFilters(new ExceptionFilterFilter());
  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

全局异常情况的过滤处理效果如图3-8所示。

image-20251212235417767

图3-8 全局异常情况的过滤处理效果

❌