NestJS全栈实战笔记:优雅处理 Entity 与 DTO 的映射与字段过滤
在 NestJS + TypeORM 开发中,我发现经常面临一个两难困境:数据库实体 (Entity) 包含所有字段(密码、角色、审计时间),但 API 接口往往只需要部分字段。
如果直接返回 Entity,会泄露敏感信息;如果手动写无数个 DTO,又会产生大量重复代码,在Service层繁复的手动脱敏又不够优雅,本文记录了如何利用 Mapped Types 和 序列化 (Serialization) 优雅解决这一问题,希望可以帮助到正在使用Nestjs开发全栈项目且对代码质量和可维护性有要求的开发者。
问题缘起
在项目开发过程中,我定义了一个包含 password、userRole、isActive 等敏感字段的 User 实体。 但在编写 RESTful API 时,出现了以下痛点:
- 数据冗余与泄露:普通用户查询接口不应返回
userRole或password,而管理员接口又需要这些信息。 - 类型不匹配:我尝试用
OmitType派生一个"阉割版"Entity 在 Service 层流转,导致 Service 内部逻辑(如鉴权)无法获取到被切除的字段。 - 手动脱敏繁琐:在 Controller 或 Service 中手动写
user.password = ''既不优雅也容易遗漏,而且心智负担极其昂贵,不符合DRY 原则(Don't Repeat Yourself)。
这个问题的核心矛盾在于,Service层需要"全量数据"处理业务,但Controller层需要"按需数据"响应客户端。
方案搜索与整合
为了优雅的解决这个问题,我打算从DTO和ORM映射两方面入手,利用AI工具查找和梳理了方案。
Nest.js的映射类型
映射类型是Nestjs内部实现的一套工具函数,实现了类型转换,使这项任务更加便捷。包含Paticial,Pick,Omit等。
1 PartialType (部分类型)
场景 :用于更新操作(Patch),将基类所有属性变为可选。
typescript
import { PartialType } from '@nestjs/swagger';
import { CreateCatDto } from './create-cat.dto';
// UpdateCatDto 拥有 CreateCatDto 的所有属性,但都是可选的
export class UpdateCatDto extends PartialType(CreateCatDto) {}
2 PickType (挑选类型)
场景:只需要基类中的某几个字段。
typescript
import { PickType } from '@nestjs/swagger';
import { CreateCatDto } from './create-cat.dto';
// 只包含 'name' 和 'age' 字段
export class CatAgeDto extends PickType(CreateCatDto, ['name', 'age'] as const) {}
3 OmitType (忽略类型)
场景:排除基类中的敏感字段或不需要的字段。
typescript
import { OmitType } from '@nestjs/swagger';
import { CreateUserDto } from './create-user.dto';
// 排除 'password' 字段,保留其他所有字段
export class UserResponseDto extends OmitType(CreateUserDto, ['password'] as const) {}
4 IntersectionType (交叉类型)
场景:合并两个不同的 DTO。
typescript
import { IntersectionType } from '@nestjs/swagger';
// 合并 CreateCatDto 和 AdditionalCatInfo 的属性
export class CombinedCatDto extends IntersectionType(
CreateCatDto,
AdditionalCatInfo,
) {}
5. 类型组合 (Composition)
这些映射类型可以相互嵌套组合,以构建更复杂的 DTO 逻辑。
示例 :创建一个用于更新的 DTO,但排除掉 id 字段,且其余字段可选。
typescript
import { PartialType, OmitType } from '@nestjs/swagger';
// 1. 先排除 'id'
// 2. 再将剩余属性变为可选
export class UpdateCatDto extends PartialType(
OmitType(CreateCatDto, ['id'] as const),
) {}
class-transformer @Exclude
可以利用class-transformer的装饰器来实现跳过特定字段
有时候你想在转换过程中跳过一些字段. 可以使用 @Exclude 装饰器:
ts
import { Exclude } from 'class-transformer';
export class User {
id: number;
email: string;
@Exclude()
password: string;
}
现在当你转换User时, password 字段就会被跳过并不再出现在转换后的结果里。
敲定方案
解决该问题的核心在于 关注点分离 (Separation of Concerns) 和 单一数据源 (Single Source of Truth)。
- Entity 不变 :Entity 类应完整映射数据库结构,不应为了迁就某个API接口而修改
Entity的定义。 - Mapped Types 减少样板代码 :利用 Swagger 的映射类型,我们可以基于
Entity自动生成 DTO,当Entity字段变更(如修改 Swagger 文档描述)时,DTO 自动同步。 - DTO 决定响应结构:DTO (Data Transfer Object) 负责定义"给前端看什么"。
- 序列化负责清洗数据 :TypeScript 的类型检查是编译时的,运行时的对象往往包含多余属性。必须使用序列化工具(如
class-transformer)在运行时剔除 DTO 中未定义的字段。
解决步骤与代码实现
1. 定义全量 Entity (配合 class-transformer)
TypeORM中的Entity是直接映射到数据库的表的,因此我们必须始终保持Entity的完整性。
对于那些全局敏感 字段(如密码),可以在 Entity 层面使用 @Exclude 进行兜底保护,确保在序列化的过程中不会意外泄露。
typescript
// entities/user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { Exclude } from 'class-transformer';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
// 默认序列化时永远排除,防止意外泄露
@Exclude()
@Column()
password: string;
@ApiProperty({ description: '角色' })
@Column({ default: 'user' })
userRole: string; // 业务逻辑需要,但普通API不需要
@Column()
isActive: boolean;
@Column()
createdAt: Date;
}
2. 利用 Mapped Types 派生场景化 DTO
针对不同接口需求,从 Entity 中"挑选 "或"排除"字段生成 DTO,通过一次或多次映射类型的操作实现按需输入和输出,免去了手动复制粘贴属性的工作,且Swagger API文档保持同步。
typescript
// dto/user.dto.ts
import { PickType } from '@nestjs/swagger';
import { User } from '../entities/user.entity';
// 场景A:普通用户视图(只看基本信息)
// PickType 会自动继承 User 中对应字段的 @ApiProperty 描述
export class PublicUserDto extends PickType(User, [
'id',
'username'
] as const) {}
// 场景B:管理员视图(排除密码,保留其他所有)
// 即使 User 类有 password 字段,因为加了 @Exclude,这里其实双重保险
export class AdminUserDto extends PickType(User, [
'id', 'username', 'userRole', 'isActive', 'createdAt'
] as const) {}
3. Controller 层进行转换与清理
Service 层返回完整的 User 实体,Controller 层使用 plainToInstance方法将其转换为特定的 DTO,并剔除多余字段。
需要配置 excludeExtraneousValues: true,这意味着"DTO 里没定义的字段,全部丢弃"。
typescript
// user.controller.ts
import { Controller, Get } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { PublicUserDto } from './dto/user.dto';
import { UserService } from './user.service';
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get('list')
async findAll(): Promise<PublicUserDto[]> {
// 1. Service 返回的是完整的 User[] (包含 userRole, isActive, createdAt)
const users = await this.userService.findAll();
// 2. 转换为 PublicUserDto
// excludeExtraneousValues: true 确保只保留 PublicUserDto 中定义的 'id' 和 'username'
// 任何多余字段(如 userRole)都会被剔除
return plainToInstance(PublicUserDto, users, {
excludeExtraneousValues: true,
});
}
}
总结
- Service 层 :只处理
Entity,确保业务逻辑拥有完整数据。 - DTO 定义 :使用
PickType/OmitType基于Entity生成,避免重复定义。 - Controller 层 :使用
plainToInstance+excludeExtraneousValues清洗数据,实现接口的字段过滤。
这种模式既保证了内部逻辑的灵活性,又确保了对外接口的安全性和整洁性。 希望对您开发工作有帮助,欢迎点赞收藏评论。