09 · DTO 数据对象与分层传输
09 · DTO 数据对象与分层传输
本篇专门讲清楚一个后端项目里「数据是怎么在层与层之间流动的」:为什么 Controller 不直接返回数据库实体,为什么要有 Request / Response 两种对象,校验是怎么自动发生的,以及实体和 DTO 之间怎么转换。读完你应该能回答:
- 为什么查出来的
SysUser不能直接return给前端? UserCreateRequest上的@NotBlank、@Email是什么,谁去执行它?record写出来的 DTO 和普通的class有什么区别?- 本项目的 DTO 转换为什么用「手工 new」而不是 MapStruct 之类的库?
9.1 什么是 DTO,以及为什么需要它
DTO(Data Transfer Object,数据传输对象) 顾名思义,是专门用来在「网络/层与层之间」传输数据的对象。它和「实体(Entity)」最大的区别是:
| 维度 | 实体 Entity(如 SysUser) | DTO(如 UserResponse) |
|---|---|---|
| 存在的意义 | 映射数据库表,承载持久化逻辑 | 映射一次 API 调用的进出参数 |
| 字段 | 和数据库列一一对应(含 passwordHash、salt 等敏感字段) | 只含前端「该看到」的字段 |
| 生命周期 | 贯穿 Service / Mapper,最终落到数据库 | 活在 Controller 入参/返回值这一层 |
| 变化频率 | 由表结构决定,改动成本高 | 由接口需求决定,可自由增删 |
概念:本项目用「分层架构」—— Controller 接请求、Service 写业务、Mapper 查库、Entity 映射表。DTO 是这一层与那一层之间的「信封」,而不是业务本身。
为什么绝不能直接把实体返回给前端
打开 SysUser(在 entity/ 包下)。它是一个 JPA 风格的持久化对象,里面通常有:
passwordHash(密码哈希)——绝不能出现在任何 HTTP 响应里salt/ 加密盐——同上- 各种内部状态字段、外键 ID——前端不关心,暴露出去反而泄漏表结构
如果 Controller 直接 return sysUser;,Spring 的 JSON 序列化(Jackson)会把所有字段都转成 JSON 发到浏览器。这意味着密码哈希会明文躺在响应体里。这就是为什么要有 UserResponse——它是一份「白名单」,只列出允许对外暴露的字段。
// 危险写法(本项目没有这种代码,仅作反例)
@GetMapping("/{id}")
public SysUser getUser(@PathVariable Long id) { // ❌ 把实体直接返回
return userMapper.selectById(id); // 密码哈希等敏感字段全部泄漏
}本项目正确的写法是返回一个 DTO:
// backend/src/main/java/com/yang/backend/controller/UserController.java:55
@GetMapping("/{id}")
public UserResponse getUser(@PathVariable Long id) {
permissionGuard.require("user:view");
return userService.getUser(id); // ✅ 返回的是 UserResponse,不是 SysUser
}9.2 本项目的 DTO 命名约定
本项目所有 DTO 都放在 backend/src/main/java/com/yang/backend/dto/ 包下,按「用途 + 方向」命名,一眼能看出它是进还是出:
| 命名后缀 | 方向 | 含义 | 例子 |
|---|---|---|---|
XxxRequest | 入参(前端 → 后端) | 接收创建 / 更新等写操作的参数 | UserCreateRequest、LoginRequest、CartAddRequest |
XxxResponse | 出参(后端 → 前端) | 返回读操作的结果 | UserResponse、OrderResponse、AuthResponse |
XxxResponse(嵌套) | 出参组合 | 作为其它 Response 的子结构 | RoleResponse、OrderItemResponse、PermissionResponse |
PageResult<T> | 出参(泛型) | 分页查询的统一封装 | PageResult<UserResponse> |
规律:凡是
@PostMapping/@PutMapping的@RequestBody,基本都是XxxRequest;凡是方法返回值,基本都是XxxResponse(或PageResult<XxxResponse>)。项目里 23 个 DTO 文件全部遵循这个约定,看名字就知道用在哪。
9.3 DTO 用 record 实现(不可变值对象)
本项目所有 DTO 都用 Java 17 的 record 语法,而不是普通 class:
// backend/src/main/java/com/yang/backend/dto/UserResponse.java:7
public record UserResponse(
Long id,
String username,
String displayName,
String email,
Boolean enabled,
List<RoleResponse> roles,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}record 相比 class 自动帮你做了三件事:
- 自动生成私有
final字段 + 全参构造器 + 每个字段的getter(方法名就是字段名,例如user.id(),不是user.getId())。 - 自动生成
equals()/hashCode()/toString()。 - 字段全部
final——不可变。一旦构造出来就无法被修改,非常适合「传输数据」这种场景:数据在 Controller 之间传递时不会被意外改掉。
概念:DTO 是「值对象」(Value Object),强调「它是什么」而非「它是谁」。不可变能避免多线程/多层传递中被人偷偷改字段的隐患。
注意 getter 的命名差异——这是 record 和 Lombok/class 最容易踩混的地方:
UserResponse u = ...;
u.id(); // ✅ record 的 getter(无 get 前缀)
u.getId(); // ❌ 本项目 DTO 没有这个方法,会编译报错9.4 入参校验:@Valid + jakarta.validation 注解
XxxRequest 不只是装数据,它还自带约束注解,把「参数合法性」写在类型定义里,而不是散落在 Service 的业务代码里。
// backend/src/main/java/com/yang/backend/dto/UserCreateRequest.java:10
public record UserCreateRequest(
@NotBlank @Size(max = 64) String username,
@NotBlank @Size(max = 100) String displayName,
@Email @Size(max = 160) String email,
@NotBlank @Size(min = 6, max = 100) String password,
Boolean enabled,
List<Long> roleIds
) {
}常见校验注解(来自 jakarta.validation.constraints):
| 注解 | 作用 | 本项目用法 |
|---|---|---|
@NotBlank | 非 null 且去除首尾空格后非空(用于 String) | 用户名、密码必填 |
@Size(min, max) | 字符串长度 / 集合大小区间 | 密码 6–100 位、用户名最长 64 |
@Email | 必须是合法邮箱格式 | 用户邮箱 |
@NotNull | 引用非 null(注意和 @NotBlank 区别:后者只针对文本) | (按需使用) |
谁来执行这些注解?@Valid
光在 DTO 上写注解不会自动生效。必须在 Controller 方法的入参前加 @Valid,Spring 才会在绑定请求体时触发校验:
// backend/src/main/java/com/yang/backend/controller/UserController.java:65
@PostMapping
public UserResponse createUser(@Valid @RequestBody UserCreateRequest request) {
permissionGuard.require("user:create");
return userService.createUser(request);
}流程是:
HTTP POST /api/users { "username":"", "password":"1" }
│
└─ Spring MVC 用 Jackson 把 JSON 反序列化成 UserCreateRequest
│
└─ 发现 @Valid → 跑 jakarta.validation 校验
│
├─ 校验通过 → 进入 createUser(...)
│
└─ 校验失败 → 抛出 MethodArgumentNotValidException
│
└─ 被 GlobalExceptionHandler 捕获(见 06)→ 返回 400 + 字段级错误关联:校验失败后的统一错误响应,由
exception/GlobalExceptionHandler.java处理,返回形如{ "code": 400, "message": "username 不能为空" }的结构。这正是 06 统一异常处理 篇里MethodArgumentNotValidException分支的落点。
一个常被忽略的细节:@Valid 不会递归校验嵌套对象
如果 DTO 里嵌了另一个对象/集合,@Valid 默认只校验当前层。本项目的 DTO 大多是「扁平字段 + 简单类型集合(List<Long>)」,所以问题不大;但如果你以后加了「对象里套对象」的 DTO(比如 OrderRequest 里套 List<OrderItemRequest>),记得在嵌套字段上也加 @Valid,否则内层约束不会触发:
record OrderRequest(
@Valid List<OrderItemRequest> items // ✅ 加 @Valid 才会递归校验每一项
) {}9.5 出参 DTO 的「字段裁剪」与组合
XxxResponse 体现的是「我愿意给你看什么」。对比 UserResponse(9.3)和实体的差异:Response 没有 passwordHash、没有内部盐值,反而多了一个 roles 列表——这是把「关联查询的结果」拼装进去的。
DTO 之间可以嵌套组合,本项目大量使用这种结构:
// backend/src/main/java/com/yang/backend/dto/AuthResponse.java:7
public record AuthResponse(
String token, // JWT 令牌
UserResponse user, // ✅ 直接内嵌一个 UserResponse
List<String> permissions,// 权限标识字符串列表
LocalDateTime expiresAt,
LocalDateTime idleExpiresAt
) {}// backend/src/main/java/com/yang/backend/dto/OrderResponse.java:8
public record OrderResponse(
Long id,
BigDecimal totalAmount,
String status,
List<OrderItemResponse> items, // ✅ 内嵌订单项列表
LocalDateTime createdAt
) {}好处:
- 一次请求返回完整结构,前端不用再发 N 次请求去拼数据。
- 关联关系显式建模:
OrderResponse含OrderItemResponse,对应「订单 1─N 订单项」的业务关系。 - 序列化时 Jackson 会把这些嵌套对象递归转成 JSON 树,前端直接拿到层级数据。
注意:这种「按需拼装」是 Service 层的工作,不是数据库直接吐出来的。详见 9.6 的转换逻辑。
9.6 实体 ↔ DTO 的转换(Mapping)
DTO 和实体字段往往不完全一样(Response 多 roles,Request 没有 id)。需要一个「转换层」把实体变成 DTO、把 Request 变成实体。本项目采用手工转换,集中在 service/RbacMapper.java:
// backend/src/main/java/com/yang/backend/service/RbacMapper.java:21
static UserResponse toUserResponse(SysUser user, List<RoleResponse> roles) {
return new UserResponse(
user.getId(),
user.getUsername(),
user.getDisplayName(),
user.getEmail(),
user.getEnabled(),
roles,
user.getCreatedAt(),
user.getUpdatedAt()
);
}为什么写成 static 方法、且构造器私有(private RbacMapper() {})?因为它是个工具类,不需要实例化,只暴露一组纯转换函数。Service 里调用时:
// backend/src/main/java/com/yang/backend/service/UserService.java:170
private UserResponse toUserResponse(SysUser user) {
// 先关联查出该用户的角色,再把角色逐个转成 RoleResponse
List<RoleResponse> roles = userRoleMapper.selectRolesByUserId(user.getId()).stream()
.map(this::toRoleResponse)
.toList();
return RbacMapper.toUserResponse(user, roles);
}可以看到转换做了两件实体本身不会做的事:
- 触发关联查询(查角色的 SQL),把结果拼进
roles字段。 - 只搬运白名单字段,密码哈希等永远不会进入 DTO。
反向:Request → 实体
创建用户时,把 UserCreateRequest 的字段写进一个新 SysUser 实体(敏感字段如密码要哈希,而不是原样存):
// backend/src/main/java/com/yang/backend/service/UserService.java:150(节选)
user.setEnabled(request.enabled() == null || request.enabled());
user.setUpdatedAt(LocalDateTime.now());
// 仅当传入非空密码时才重新哈希并更新
if (StringUtils.hasText(request.password())) {
user.setPasswordHash(passwordHasher.hash(request.password())); // ✅ 哈希后再存
}
userMapper.updateById(user);为什么不引入 MapStruct / ModelMapper?
很多项目会用 MapStruct(编译期生成转换代码)或 ModelMapper(运行时反射)来省掉手工 new。本项目刻意用手工转换,原因是:
- 转换往往不是纯字段拷贝(要查关联、要哈希密码、要裁剪字段),自动映射工具反而表达不清。
- 手工
new一目了然,调试时能看到每一行在搬哪个字段。 - DTO 数量不多(23 个),手工成本可接受。
经验:当转换「只是同名同类型字段互拷」时,MapStruct 很香;一旦涉及关联查询/加密/字段裁剪,手工转换更可控。本项目属于后者。
9.7 泛型分页 DTO:PageResult<T>
列表接口需要同时返回「数据」和「分页元信息(总数、页码、每页大小)」。本项目用一个泛型 DTO 统一承载:
// backend/src/main/java/com/yang/backend/dto/PageResult.java:6
public record PageResult<T>(
List<T> records, // 当前页的数据(T 可以是 UserResponse、ProductResponse...)
long total, // 总记录数
long page, // 当前页码
long size // 每页大小
) {}使用时指定具体类型,Controller 返回值类型就带上了泛型:
// backend/src/main/java/com/yang/backend/controller/UserController.java:43
public PageResult<UserResponse> listUsers(@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") long page,
@RequestParam(defaultValue = "10") long size) {
permissionGuard.require("user:view");
return userService.listUsers(keyword, page, size);
}PageResult<UserResponse> 序列化后形如:
{
"records": [ { "id": 1, "username": "alice", "roles": [...] } ],
"total": 57,
"page": 1,
"size": 10
}概念:泛型
<T>让「分页外壳」只写一次,可以套任意一种 Response,避免为每个列表接口都重复写total/page/size四个字段。分页的具体实现(MyBatis-Plus 的Page)见 02。
9.8 一张图看懂 DTO 在请求链路中的位置
浏览器/前端
│ POST /api/users { username, password, ... } ←── 这是 UserCreateRequest 的 JSON 形态
▼
Controller ── @Valid @RequestBody UserCreateRequest
│ │ 校验失败 → 06 的全局异常处理 → 400
│ ▼ 校验通过
│ 把 Request 的字段写入 SysUser 实体(密码哈希)
▼
Service ── 调 Mapper 落库 / 查库
│
│ 查出的 SysUser(+ 关联查询 roles)→ 用 RbacMapper 转成 UserResponse
▼
Controller ── return UserResponse(或 PageResult<UserResponse>)
│
▼
Jackson 把 UserResponse 序列化成 JSON 发回前端 ←── 只有白名单字段,无密码哈希记忆要点:Request 进、Entity 落库;Entity 查、Response 出。
9.9 常见坑
用实体当返回值,泄漏敏感字段。
最经典的事故。一旦return sysUser;,passwordHash/salt全进响应体。务必走XxxResponse白名单。忘了在 Controller 加
@Valid。
DTO 上的@NotBlank/@Email不会自动生效,必须方法参数前带@Valid,否则非法请求直接进了 Service。混淆
record的 getter 命名。
本项目的user.id()是对的,user.getId()编译不过。record没有get前缀。嵌套 DTO 漏加
@Valid。List<XxxRequest>这种集合字段,外层@Valid不会递归校验内部元素;需要在集合字段上也加@Valid。在 DTO 里放业务逻辑 / 持久化注解。
DTO 应该是纯数据壳。不要把@Table、@OneToMany这类 JPA 注解写到 DTO 上,也不要在 DTO 里写业务方法。Response 里塞了不该返回的字段(过度暴露)。
比如把内部status枚举的原始值、内部错误码、调试信息也返回了。Response 字段遵循「最小必要」原则。转换时 N+1 查询。
toUserResponse里对每个用户去查一次角色,若在外层循环里对每个用户都调用,会变成 N+1 条 SQL。本项目是在单个用户详情里查,量可控;若做「批量用户列表 + 各自角色」,应先一次性查出全部角色再内存分组。Request 接受明文密码却不分场景。
本项目LoginRequest.password是明文传输(靠 HTTPS 保护),但绝不会把它原样存库——落库前一定过passwordHasher.hash(...)。Request 里的密码字段只存在于「传输这一瞬」,进了 Service 马上哈希。
9.10 小结
- DTO 是层与层之间的「信封」:
XxxRequest进、XxxResponse出,实体只活在 Service/Mapper 内部。 - 本项目用
record写 DTO,不可变、自带 getter、零样板代码。 - 入参校验靠
jakarta.validation注解 + Controller 的@Valid,失败由统一异常处理兜底。 - 实体↔DTO 转换用手工
new(RbacMapper+ Service 私有方法),便于处理关联查询与密码哈希。 PageResult<T>用泛型统一所有分页列表的返回结构。
下一步:DTO 描述「接口长什么样」,而接口本身怎么暴露、怎么生成在线文档,见
middleware-springdoc.md(SpringDoc / OpenAPI)。