2618 字
约 8 分钟
17
S18-X10 · 领域对象与数据对象分离:DTO/VO/PO/Entity 的边界

一张 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. 自测

  1. 一个"Entity"只有 getter/setter,它和 PO 的区别是什么?
  2. Controller 直接接收 PO,会打开哪一类漏洞?
  3. BeanUtils.copyProperties 的浅拷贝在什么结构下会出问题?
  4. MapStruct 的 unmappedTargetPolicy 默认值是什么,为什么建议改?
  5. 为什么说"VO 里没有这个字段"比 @JsonIgnore 更可靠?
  6. 判断"该不该再加一个对象"的标准是什么?
S18-X10 · 领域对象与数据对象分离:DTO/VO/PO/Entity 的边界
https://www.clxhxhhr.top/posts/5811/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。