DTO、VO、PO、BO 到底该怎么区分?

🌈个人主页 :一条泥憨鱼 (欢迎各位大佬莅临)

🎬精选专栏:数据结构与算法Java,AI与Agent

前言:

刚学 Java 那会儿,我对着项目里的 UserDTO、UserVO、UserPO、UserBO 看了半天------字段几乎一样,换来换去不嫌烦吗?

后来踩了几次坑才明白,不是闲得慌,是每一层有每一层的风险。一个典型的例子:有人直接把 PO 返回给前端,结果把加密后的密码也带出去了。虽然一般人解不开,但这本身就说明设计有问题。

四个对象,四种场景

PO(Persistent Object,持久化对象) --- 就是数据库那张表

你用 MyBatis 或 JPA,查出来的数据映射到一个对象上,这个对象就是 PO。表里有什么列,它就有什么字段,一个不多一个不少。

java 复制代码
@TableName("user")
public class UserPO {
    private Long id;
    private String username;
    private String password;      // 数据库里有,但绝不能往外传
    private Integer status;       // 0 或 1,存原始值
    private LocalDateTime createTime;
}

它就一个作用:和数据库对齐。

DTO (Data Transfer Object,数据传输对象)--- 前端传进来的数据

前端发什么字段,DTO 就接什么字段。多一个不要,少了就报错。

java 复制代码
public class UserRegisterDTO {
    @NotBlank(message = "用户名不能为空")
    private String username;

    @Length(min = 6, max = 20)
    private String password;

    @Email
    private String email;
    // 没有 id,没有 createTime------前端不应该传这些
}

这时就会人问:为什么不用 PO 直接接?你想想,注册接口要是允许前端传 id,人家传个 1,说不定就把管理员账号覆盖了。DTO 其实就是一层白名单,不声明的字段一律不收。

VO(View Object,视图对象) --- 返回给前端看的

VO 负责把数据"打扮"好再出门。敏感字段删掉,状态码转成文字,时间戳格式化成字符串。

java 复制代码
public class UserVO {
    private Long id;
    private String username;
    private String email;
    private String statusDesc;    // "正常" 而不是 0
    private String createTime;    // "2024-01-15 14:30:00" 而不是 LocalDateTime
    // 绝对没有 password
}

DTO 和 VO 最容易搞混,记一个方向就行:DTO 是进门(前端 → 后端),VO 是出门(后端 → 前端)。

BO(Bussiness Object,业务对象) --- 只在 Service 内部用,不是必需品

当你需要同时操作多张表、中间还要算来算去的时候,可以把这些数据塞到一个 BO 里。项目简单的话完全可以不用。

java 复制代码
// 比如算订单总价,涉及用户、订单明细、优惠券三张表
public class OrderCalculateBO {
    private UserPO user;
    private List<OrderItemPO> items;
    private CouponPO coupon;
    private BigDecimal totalPrice;
}

数据怎么走的,一张图就够了:

java 复制代码
前端请求
    ↓  DTO(只收该收的字段)
Controller
    ↓  转成 PO,复杂场景用 BO
Service
    ↓  操作完,转成 VO
Controller
    ↓  VO(只给该给的字段)
前端

一个完整的例子

java 复制代码
@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    @PostMapping("/register")
    public Result<Void> register(@RequestBody @Valid UserRegisterDTO dto) {
        userService.register(dto);
        return Result.success();
    }

    @GetMapping("/{id}")
    public Result<UserVO> getUser(@PathVariable Long id) {
        return Result.success(userService.getUserById(id));
    }
}

转换逻辑全部放 Service,Controller 只负责收发:

java 复制代码
@Service
public class UserServiceImpl implements UserService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public void register(UserRegisterDTO dto) {
        UserPO user = new UserPO();
        user.setUsername(dto.getUsername());
        user.setPassword(BCrypt.hashpw(dto.getPassword())); // 明文进来,密文存
        user.setEmail(dto.getEmail());
        user.setStatus(1);  // 新用户默认启用
        userMapper.insert(user);
    }

    @Override
    public UserVO getUserById(Long id) {
        UserPO user = userMapper.selectById(id);
        if (user == null) {
            throw new BusinessException("用户不存在");
        }
        UserVO vo = new UserVO();
        vo.setId(user.getId());
        vo.setUsername(user.getUsername());
        vo.setEmail(user.getEmail());
        vo.setStatusDesc(user.getStatus() == 1 ? "正常" : "禁用");
        vo.setCreateTime(user.getCreateTime()
            .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
        return vo;  // password 没设,前端拿不到
    }
}

手动 set 太烦怎么办

字段少还好,字段一多就变体力活。两个方案:

BeanUtils.copyProperties ---

简单场景够用,但只能复制同名同类型的字段,status → statusDesc 这种情况还是得自己写。

java 复制代码
UserVO vo = new UserVO();
BeanUtils.copyProperties(user, vo);
vo.setStatusDesc(user.getStatus() == 1 ? "正常" : "禁用");

MapStruct ---

稍微正规点的项目都用这个。写个接口,编译期自动生成实现,零反射,性能跟手写差不多。

java 复制代码
@Mapper(componentModel = "spring")
public interface UserConverter {
    UserVO toVO(UserPO po);
    UserPO toEntity(UserRegisterDTO dto);
}

容易踩的坑

把 PO 直接返回给前端 --- 密码、手机号、内部状态码全出去了。说出来你可能不信,这种事情在 Code Review 里见得太多了。

用同一个 DTO 接所有接口 --- 注册和编辑的参数差很多,硬塞到一个类里只会越来越臃肿,校验注解也越写越拧巴。一个场景一个 DTO,多写几个类不丢人哈。

VO 和 DTO 互用 --- 省一时的事,等后面改起来哭都来不及。哪天需求让加个字段,都不知道这字段是该进门还是该出门。

一表总结

|-----|--------------|------------|
| 对象 | 干什么的 | 方向 |
| PO | 跟数据库对齐 | 数据库<一>后端 |
| DTO | 收前端参数 | 前端<一>后端 |
| VO | 返回给前端看 | 后端<一>前端 |
| BO | Service内部算数据 | Service内部 |

如果这篇文章对您有帮助的话,还请留下一个免费的小心心

关注我,我们下期再见!

相关推荐
Database_Cool_5 分钟前
阿里云 Tair(企业级内存数据库)vs 腾讯云 Redis 云缓存深度对比:性能/数据结构/成本全维度 Benchmark
数据库·阿里云·缓存
学计算机的计算基12 分钟前
操作系统内存管理全解:虚拟内存、页表、COW、malloc、OOM一篇搞定
java·笔记·算法
流星白龙13 分钟前
【Redis】1.Redis特性
数据库·redis·缓存
奶糖 肥晨16 分钟前
DBeaver 安装与 MySQL 连接配置教程
数据库·mysql
tachibana217 分钟前
hot100 前 K 个高频元素(347)
java·数据结构·算法·leetcode
woshihuanglaoshi34 分钟前
数据迁移与版本管理 - Flutter在鸿蒙平台实现数据库升级策略
数据库·学习·flutter·华为·harmonyos·鸿蒙·鸿蒙系统
万亿少女的梦16835 分钟前
基于Spring Boot、Java与MySQL的网络订餐系统设计与实现
java·spring boot·mysql·系统设计·网络订餐
java_logo3 小时前
Apache Doris Docker 部署指南:实时分析数据库实战
数据库·docker·apache·doris·apache doris·轩辕镜像·docker部署doris
咩咩啃树皮9 小时前
第40篇:Vue3组件化开发精讲——组件拆分、复用、父子通信、工程化架构
java·前端·架构
鱟鲥鳚10 小时前
Spring Boot 集成 LangChain4j:从模型调用到 Tool Calling(Demo版)
java·spring boot