4、层间通信:DTO/VO/PO/DO的概念与转换,避免层间耦合

层间通信,说白了就是不同层之间怎么传数据。很多项目写着写着就乱成一锅粥,根源往往就在这儿。我见过最夸张的,一个Controller直接把数据库的Entity扔给前端,结果前端看到了不该看的字段,比如密码哈希值。嗯,这显然不行。

要解决这个问题,得先搞清楚几个概念:DTO、VO、PO、DO。它们各自有各自的职责,混在一起就是灾难。

4.1 先搞清楚这几个“O”是什么

我习惯这么理解:

  • PO(Persistent Object):持久化对象。跟数据库表结构一一对应。比如User表有id、name、password_hash、created_at,那PO里就原样照搬。
  • DO(Domain Object):领域对象。承载业务逻辑,可能包含多个PO的组合。比如一个“订单”DO,里面既有订单PO,又有商品PO、用户PO。
  • DTO(Data Transfer Object):数据传输对象。专门用于层间通信,比如Controller传给Service,或者Service返回给Controller。它只包含需要传输的字段。
  • VO(View Object):视图对象。专门给前端用的,可能只展示部分字段,或者把多个字段拼成一个展示字段。比如“用户姓名”可能是lastName + firstName拼出来的。

核心原则:每一层只关心自己该关心的数据。PO只待在DAO层,DO只待在Service层,DTO在Service和Controller之间穿梭,VO只出现在Controller返回给前端的那一刻。

4.2 为什么不能直接传PO?

你想想看,如果把PO直接暴露给上层,会有什么后果?

  • 安全风险:PO里可能有敏感字段,比如密码、内部ID、删除标记。前端一旦拿到,要么泄露,要么被恶意利用。
  • 耦合问题:数据库表结构一改,所有上层代码都得跟着改。比如把字段名从user_name改成name,前端、Controller、Service全得动。
  • 性能浪费:PO可能包含大量字段,但前端只需要其中两三个。传一堆没用的数据,网络开销和序列化成本都上去了。

我在项目中遇到过一件事:有个同事图省事,直接把PO返回给前端。后来数据库加了个字段叫is_deleted,前端页面莫名其妙显示了一堆“true”和“false”。排查了半天才发现是PO直接暴露了。从那以后,我定了个规矩——PO永远不能出DAO层

4.3 DTO/VO/PO/DO的转换策略

转换是绕不开的活。我建议用以下几种方式:

4.3.1 手动转换(适合小项目)

写个转换方法,比如:

public class UserConverter {
    public static UserDTO toDTO(UserPO po) {
        UserDTO dto = new UserDTO();
        dto.setId(po.getId());
        dto.setName(po.getName());
        // 注意:不设置passwordHash
        return dto;
    }
}

这种方式简单直接,但字段多了会写到手软。我一般只在项目初期用。

4.3.2 工具类转换(推荐)

用MapStruct或者BeanUtils。我个人偏爱MapStruct,因为它是在编译期生成代码,性能好,而且能处理字段名不一致的情况。

@Mapper
public interface UserMapper {
    UserDTO toDTO(UserPO po);
    UserPO toPO(UserDTO dto);
}

你只需要定义接口,MapStruct自动帮你生成实现类。字段名不一样?加个@Mapping注解就行。

小技巧:如果字段名完全一致,MapStruct会自动匹配。不一致时用@Mapping(source = "userName", target = "name")。这样数据库字段改了,只需要改Mapper接口,不用动业务代码。

4.3.3 分层转换(最佳实践)

我建议在每一层的边界做转换:

  • DAO层:PO ↔ DO。DAO只返回DO,不返回PO。
  • Service层:DO ↔ DTO。Service接收DTO,处理业务后返回DTO。
  • Controller层:DTO ↔ VO。Controller把DTO转成VO返回给前端。

这样做的好处是:每一层只依赖自己该依赖的东西。数据库改了,只影响DAO层;前端需求变了,只影响Controller层。

4.4 避坑指南

我曾经踩过一个坑:有个项目用了Lombok的@Builder,直接在PO上加了Builder模式。结果有个同事在Service里new了一个PO,然后set了部分字段,其他字段全是null。存到数据库后,那些null字段覆盖了原来的值。从那以后,我规定PO只能用setter/getter,不允许用Builder,避免误操作。

另外,要注意不要滥用DTO。有些团队每个方法都定义一个DTO,结果DTO数量爆炸。我建议:

  • 同一个业务场景的DTO可以复用。比如“用户查询”和“用户详情”如果字段差不多,用一个DTO就行。
  • 如果字段差异很大,再拆成不同的DTO。别为了省事硬塞在一起,前端会骂人的。

4.5 总结一下

层间通信的核心就一句话:各层只传自己该传的数据。PO留在DAO层,DO在Service层内部流转,DTO在Service和Controller之间传递,VO只给前端看。

转换虽然麻烦,但这是解耦的代价。你想想看,如果哪天数据库表结构大改,你只需要改DAO层和对应的Mapper,上层代码完全不用动。这种爽感,值得你多写几行转换代码。

嗯,最后提醒一句:别偷懒。我见过太多项目因为图省事直接传PO,最后重构时哭都哭不出来。规矩定在前面,后面才能睡得安稳。