一张 t_user 表加了个 last_login_ip 字段,两周后前端页面莫名其妙多出一个地址栏位;一次接口返回里带了 password,安全扫描直接打回来。这类事故的共同点不是"没分层",而是同一张表结构被当成了接口契约——改表就是改接口,改接口就是改表。
对象分离要解决的就是这件事:让数据库结构、领域行为、跨层传输、前端展示各有各的形状,任何一边变化都不牵动另一边。但分离本身有成本,分不含也会翻车。
1. 四种对象各自管什么
| 对象 | 对应什么 | 变谁驱动 | 典型内容 |
|---|---|---|---|
| PO(Persistent Object) | 一张表或一个视图 | 表结构变更 | 与列一一对应的字段,无业务方法 |
| Entity(领域对象) | 一个业务概念 | 业务规则变更 | 状态、行为方法、不变量校验 |
| DTO(Data Transfer Object) | 一次跨层调用的入参/出参 | 接口契约变更 | 扁平字段、校验注解、无持久化痕迹 |
| VO(View Object) | 一次页面渲染 | 展示需求变更 | 裁剪后的字段、格式化后的文本 |
// PO:只映射表结构,不放业务方法
public class UserPO {
private Long id;
private String loginName;
private String passwordHash;
private String salt;
private Integer status; // 0 正常 1 冻结
private LocalDateTime createdAt;
// getter/setter 省略
}
// Entity:承载行为,不暴露给外层
public class User {
private final Long id;
private String passwordHash;
private UserStatus status;
public void freeze(String reason) {
if (this.status == UserStatus.FROZEN) {
throw new IllegalStateException("already frozen");
}
this.status = UserStatus.FROZEN;
}
}
差别在方法:PO 只有数据,Entity 有"能不能冻结""冻结后该做什么"。如果一个"Entity"只有 getter/setter,那它就是 PO 换了个名字,分离没发生。
2. 混用的三种具体后果
| 混用方式 | 直接后果 | 事故形态 |
|---|---|---|
| Controller 直接返回 PO | 表字段即接口字段 | 加字段自动暴露;password/salt 泄漏 |
| Controller 直接接收 PO | 前端可写任意列 | 越权赋值(改 role、改 balance) |
| Entity 上加序列化注解 | 持久层被展示逻辑污染 | 换序列化框架时行为不一致 |
| Entity 被缓存/跨层传递 | 懒加载失效、循环引用 | StackOverflowError |
第二行是最容易被低估的。前端 POST 一个 JSON,框架按字段名反射填充,攻击者多加一个 "status": 1 就能把冻结账号解冻——这类"批量赋值"漏洞的根因就是入参对象与持久化对象是同一个。
第三行的机制:@JsonIgnore 只对 Jackson 生效,同一个类若还走 Hessian、Kryo、或作为 MyBatis 结果映射目标,注解不起作用;反过来,为了 JSON 输出而加的 @JsonProperty("pwd") 会让 ORM 的列映射变复杂。
3. 三层各自的出入参
| 层 | 入参 | 出参 | 不该出现的东西 |
|---|---|---|---|
| Controller | XxxRequest(DTO) |
XxxResponse / XxxVO |
PO、Entity、@Transactional |
| Service | DTO 或领域命令对象 | DTO 或领域对象 | HttpServletRequest、Jackson 注解 |
| Repository / Mapper | PO 或查询条件对象 | PO | 业务校验、展示格式化 |
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public UserDetailVO detail(@PathVariable Long id) {
return userService.detail(id); // Service 返回 VO,Controller 不再加工
}
@PostMapping
public Long create(@Valid @RequestBody UserCreateRequest req) {
return userService.create(req); // 入参是 DTO,不是 PO
}
}
关键约束只有一条:Controller 的方法签名里不出现 PO 和 Entity。这条守住了,越权赋值和字段泄漏就同时被堵住。Service 内部是否用 Entity 是设计自由,只要不越过 Controller 边界。
4. 转换放在哪,成本有多大
| 转换方式 | 机制 | 成本 | 风险 |
|---|---|---|---|
| 手工 setter | 逐字段赋值 | 代码量最大 | 漏字段,改字段要同步改 |
| MapStruct | 编译期生成实现 | 需要额外注解与配置 | 映射不全默认只告警 |
BeanUtils.copyProperties |
运行期反射 + 属性描述符缓存 | 单次调用开销高于直接赋值 | 浅拷贝、字段名不一致即静默丢失 |
| 构造器 / record | 显式传参 | 字段多时签名很长 | 无 |
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface UserConverter {
UserDetailVO toVO(UserPO po);
UserPO toPO(UserCreateRequest req);
}
unmappedTargetPolicy = ERROR 是这段配置里最重要的一行。默认是 WARN,加了字段忘了映射,编译照样过,运行时那个字段永远是 null。
BeanUtils.copyProperties 的问题在两个地方:一是浅拷贝,PO 里若有个 List<Address>,拷完两边指向同一个对象,改一个影响另一个;二是静默失败,源对象 userName 对目标 username,不报错,直接是 null。它在小对象、字段完全同名时可用,不适合当主力。
转换的位置:在 Service 边界做。Controller 只收 DTO、只发 VO;Repository 只认 PO。把转换写在 Controller 里,Service 就会被 DTO 绑住;写在 Repository 里,持久层就依赖了展示结构。
5. 命名与序列化的两层差异
| 层 | 约定 | 配置项 |
|---|---|---|
| 数据库列 | 下划线 created_at |
— |
| Java 字段 | 驼峰 createdAt |
MyBatis map-underscore-to-camel-case=true |
| JSON 字段 | 驼峰或下划线(按前端约定) | spring.jackson.property-naming-strategy |
mybatis:
configuration:
map-underscore-to-camel-case: true
spring:
jackson:
property-naming-strategy: SNAKE_CASE
default-property-inclusion: non_null
三层的命名规则要对齐一次,否则会出现"数据库有、Java 有、JSON 里没有"的三方不一致。用 PO 直接序列化时,这个问题会以最难受的方式暴露:列名改动会连带改变 API 响应字段。
Java 17 之后,DTO/VO 用 record 是个好选择——不可变、构造即校验、没有空 setter 可被反射填充,天然免疫批量赋值:
public record UserDetailVO(Long id, String loginName, String displayName, String statusText) {}
6. 不该分离的场景,与分离过度的代价
| 场景 | 建议 | 理由 |
|---|---|---|
| 内部运维小工具、一次性脚本 | 不分离 | 生命周期短,维护成本高于收益 |
| 纯 CRUD 原型、字段与表 1:1 | 可合并 PO/VO | 没有展示裁剪需求 |
| 对外 API、有敏感字段 | 必须分离 | 字段泄漏代价不可逆 |
| 领域规则复杂(状态机、金额计算) | 必须分离 Entity | 行为无处安放就会散落到 Service |
| 多端(App/Web/开放平台)共用一套接口 | 必须分离 VO | 各端字段需求不同 |
| 只有一张表、两个接口 | 不必四层全上 | 对象爆炸 |
分离过度的典型症状:一个"用户详情"功能出现 UserPO、UserDO、UserEntity、UserDTO、UserQueryDTO、UserDetailVO、UserListVO、UserConverter、UserAssembler 九个类,其中转换代码比业务代码长。判断标准很简单:每多一个对象,要能说出它挡住了哪一种变化。说不出来就是冗余。
7. 敏感字段:裁剪优于注解
两种做法,优先级不同:
// 做法一(推荐):VO 里根本没有这个字段
public record UserDetailVO(Long id, String loginName, String displayName) {}
// 做法二:序列化层屏蔽 —— 只能在"没法加 VO"时用
public class UserPO {
@JsonIgnore
private String passwordHash;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String salt;
}
做法一的可靠性来自"不存在"——没有字段就没有泄漏路径,也不依赖某个序列化框架是否尊重注解。做法二的问题是会扩散:一旦在 PO 上加了展示层注解,后续任何复用这个 PO 的场景都要重新判断注解是否合适。
还有个中间态:用 @JsonView 在同一 DTO 上区分"列表视图"与"详情视图"。它比 @JsonIgnore 灵活,但视图定义会散落在多个类上,字段一多就难维护。
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 接口响应突然多出字段 | Controller 返回 PO,表加了列 | 改返回 VO,VO 不引用 PO |
| 前端能改不该改的字段 | 入参是 PO,批量赋值 | 换 DTO,只声明允许的字段 |
| JSON 里字段名是下划线,前端不认 | PO 直接序列化 + 命名策略 | 中间加 VO,或统一命名策略 |
列表接口返回 StackOverflowError |
Entity 双向引用被序列化 | DTO 断开引用,或加 @JsonIgnore 于反向字段(仅序列化层) |
| MapStruct 转换后字段为 null | 字段名不一致,默认只告警 | 设 unmappedTargetPolicy=ERROR |
copyProperties 后两边数据联动 |
浅拷贝共享引用 | 深拷贝或换 MapStruct |
| 接口字段随表结构漂移 | 无 VO 层 | 建立 VO 与表的解耦,加契约测试 |
9. 边界
| 场景 | 建议 | 理由 |
|---|---|---|
| 对外接口 | Controller 只经手 DTO/VO | 契约稳定,字段可控 |
| 领域规则复杂 | Entity 承担行为,PO 只做映射 | 行为不散落 |
| 高频查询、字段极多 | 允许"查询专用 DTO"直接映射结果集 | 避免为性能造一堆中间对象 |
| 报表/导出 | 单独 ExportDTO | 展示口径与接口口径不同 |
| 单体小项目 | 至少分 PO 与 VO | 挡住字段泄漏这一条就够 |
| 微服务间调用 | DTO + 版本化 | 跨服务契约不能跟着表变 |
一句话:分离的判据是"变更方向不同"。表结构、业务规则、接口契约、展示需求四条变更线各自独立时,对象就该分开;四条线永远同步变时,分开只是多写代码。
10. 自测
- 一个"Entity"只有 getter/setter,它和 PO 的区别是什么?
- Controller 直接接收 PO,会打开哪一类漏洞?
BeanUtils.copyProperties的浅拷贝在什么结构下会出问题?- MapStruct 的
unmappedTargetPolicy默认值是什么,为什么建议改? - 为什么说"VO 里没有这个字段"比
@JsonIgnore更可靠? - 判断"该不该再加一个对象"的标准是什么?