Skip to content

好的,我们来探讨一个在构建 API 时非常重要,但又常常被初学者忽略的细节:序列化 (Serialization)。这就像是为你的应用配备了一位专业的“形象设计师”和“公关发言人”,确保它对外展示的信息既美观又得体。

API 的“形象设计师”:精通 NestJS 序列化技术

想象你的应用是一位顶级的秘密特工。

  • 在他的大脑里(数据库和实体 Entity,存储着关于他自己的所有信息:真实姓名、秘密代号、年龄、掌握的技能、执行过的所有任务记录、甚至还有一些不为人知的私人密码。
  • 当他需要和外界联系时(API 响应),他绝不会把所有信息都和盘托出。他需要一位“公关发言人”,根据不同的交流对象(用户角色),决定哪些信息可以说,哪些信息必须保密,以及信息该以何种形式呈现。

序列化,在 NestJS 的上下文中,主要指的就是将一个对象(通常是实体类的实例)转换为另一种格式(通常是 JSON)的过程。但它远不止是简单的格式转换,它更关乎于 控制 (Control)

  • 过滤 (Filtering):决定哪些属性应该被包含在最终的响应中。例如,绝不能把用户的密码哈希值返回给前端。
  • 转换 (Transforming):改变属性的格式或值。例如,将数据库中的日期对象转换为一个更友好的时间戳字符串。
  • 添加 (Adding):在响应中加入一些实体本身没有,但动态计算出来的属性。

NestJS 提供了强大的工具来优雅地处理序列化,其核心就是拦截器 (Interceptors) 配合 class-transformer 这个库。

1. 问题所在:不加控制的“大嘴巴”响应

让我们看看不进行序列化会发生什么。假设我们有一个 User 实体。

src/users/entities/user.entity.ts

typescript
export class User {
  id: number;
  firstName: string;
  lastName: string;
  email: string;
  password; // 糟糕!这是敏感信息!

  constructor(partial: Partial<User>) {
    Object.assign(this, partial);
  }
}

src/users/users.controller.ts

typescript
@Controller('users')
export class UsersController {
  @Get(':id')
  findOne(@Param('id') id: string): User {
    // 假设这里是从数据库获取了完整的 user 对象
    return new User({
      id: 1,
      firstName: 'John',
      lastName: 'Doe',
      email: 'john.doe@example.com',
      password: 'hashed_password_string', // 这是一个巨大的安全漏洞!
    });
  }
}

当客户端访问 /users/1 时,它会收到一个包含了 password 字段的 JSON 对象。这是绝对不能接受的!

2. 第一步:聘请“公关发言人” (ClassSerializerInterceptor)

NestJS 内置了一个专门用于序列化的拦截器:ClassSerializerInterceptor。它的工作原理就是与 class-transformer 库提供的装饰器进行联动。

如何聘请他? 最简单的方式,就是让他成为一个全局的发言人,对所有响应进行处理。

src/main.ts

typescript
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { ClassSerializerInterceptor } from '@nestjs/common';

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

  // 聘请一位全局的形象设计师/公关发言人
  app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));

  await app.listen(3000);
}
bootstrap();
  • app.get(Reflector): ClassSerializerInterceptor 需要 Reflector 的帮助来读取我们稍后会添加的装饰器元数据。

现在,发言人已经就位,但他还需要一本**“发言稿审核规则”**。

3. 第二步:编写“发言稿审核规则” (使用 class-transformer 装饰器)

这本规则就是我们直接写在实体类上的装饰器。class-transformer 提供了几个核心的“审核图章”:

  • @Exclude(): “这个属性,无论如何都不能对外说!”
  • @Expose(): “这个属性,默认是保密的,但如果我明确说了要公开,那就公开它。”
  • @Transform(): “这个属性可以说,但在说出去之前,需要先这样包装或转换一下。”

第一步:安装 class-transformer (如果之前没装过的话)

bash
npm install class-transformer

第二步:用装饰器来“审核”我们的 User 实体

src/users/entities/user.entity.ts (审核后)

typescript
import { Exclude } from 'class-transformer';

export class User {
  id: number;
  firstName: string;
  lastName: string;
  email: string;

  // 盖上“绝密”图章
  @Exclude()
  password: string;

  constructor(partial: Partial<User>) {
    Object.assign(this, partial);
  }
}

现在,当你再次启动应用并访问 /users/1 时,奇迹发生了!

响应结果:

json
{
  "id": 1,
  "firstName": "John",
  "lastName": "Doe",
  "email": "john.doe@example.com"
}

password 字段被自动、干净地移除了!ClassSerializerInterceptor 看到了 @Exclude() 图章,并忠实地执行了保密指令。

4. “形象设计”进阶技巧

技巧一:白名单模式 (@Exclude() + @Expose())

默认情况下,ClassSerializerInterceptor 会包含所有没有@Exclude() 标记的属性。有时候,我们希望反过来:默认所有属性都保密,只公开那些被明确标记的。这种“白名单”模式更安全。

typescript
import { Exclude, Expose } from 'class-transformer';

@Exclude() // 1. 在类上使用 @Exclude(),将所有属性默认设为排除
export class User {
  @Expose() // 2. 只在希望公开的属性上使用 @Expose()
  id: number;

  @Expose()
  firstName: string;

  @Expose()
  lastName: string;

  @Expose()
  email: string;

  password: string; // 这个没有 @Expose(),所以它会被排除

  constructor(partial: Partial<User>) {
    Object.assign(this, partial);
  }
}

技巧二:动态添加属性 (@Expose() getter)

有时我们需要返回一个由其他属性组合而成的新属性。我们可以通过 getter 方法和 @Expose() 来实现。

typescript
@Exclude()
export class User {
  @Expose() id: number;
  @Expose() firstName: string;
  @Expose() lastName: string;

  // ...

  // 使用 getter 定义一个新属性
  @Expose()
  get fullName(): string {
    return `${this.firstName} ${this.lastName}`;
  }

  constructor(partial: Partial<User>) {
    Object.assign(this, partial);
  }
}

响应结果将包含:

json
{
  "id": 1,
  "firstName": "John",
  "lastName": "Doe",
  "fullName": "John Doe"
}

技巧三:转换属性 (@Transform())

@Transform() 装饰器给予了我们完全的控制权,可以对属性值进行任意转换。

typescript
import { Transform } from 'class-transformer';

@Exclude()
export class User {
  // ...

  // 假设我们有一个角色属性,数据库存的是数字,但希望返回字符串
  @Expose()
  @Transform(({ value }) => {
    if (value === 1) return 'admin';
    if (value === 2) return 'moderator';
    return 'member';
  })
  role: number; // 实体内部是 number

  // ...
}```
如果一个用户的 `role` 是 `1`,那么最终的 JSON 响应中,`role` 字段的值将会是 `"admin"`。

**重要前提:确保返回的是类的实例**

`ClassSerializerInterceptor` 只能对一个**类的实例**起作用,因为它需要读取实例所属类上的装饰器。如果你从 Controller 返回一个普通的 JavaScript 对象 ` { ... } `,序列化将不会生效。

```typescript
// 错误示范 ❌
@Get(':id')
findOne(@Param('id') id: string) {
  // 这是一个普通对象,不是 User 类的实例
  return { id: 1, firstName: 'John', password: '...' };
}

// 正确示范 ✅
@Get(':id')
findOne(@Param('id') id: string): User {
  const userDataFromDb = { id: 1, firstName: 'John', password: '...' };
  // 必须将其转换为类的实例
  return new User(userDataFromDb);
}

如果你使用 TypeORM 等 ORM,从数据库查询出的结果通常已经是实体类的实例,所以这一步是自动完成的。

总结

序列化是你 API 开发工具箱中必不可少的一环,它负责你应用对外的“门面”。

  1. 全局拦截 (ClassSerializerInterceptor):在 main.ts 中全局启用 ClassSerializerInterceptor,为你应用的所有响应聘请一位“形象设计师”。
  2. 定义规则 (Decorators):在你的实体或 DTO 类上,使用 @Exclude(), @Expose(), @Transform()class-transformer 提供的装饰器,来精确地定义哪些数据可以展示,以及如何展示。
  3. 返回实例 (Return Class Instance):确保你的 Controller 始终返回类的实例,而不是普通 JavaScript 对象,这样序列化才能生效。

掌握了序列化,你就能构建出更安全、更专业、API 响应更清晰的 NestJS 应用程序。