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内部 |

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

关注我,我们下期再见!

相关推荐
青石路2 小时前
记一次多JDK版本问题的排查,一坑套一坑,差点没爬上来
java
像我这样帅的人丶你还5 小时前
Java 后端详解(五):Redis 缓存
java·后端·全栈
plainGeekDev7 小时前
GreenDAO → Room
android·java·kotlin
jiayou647 小时前
KingbaseES 表级与列级加密完全指南
数据库·后端
亦暖筑序12 小时前
Java 8老系统AI Workflow实战:把一次性AI对话升级成可恢复工作流
java·后端
敲代码的彭于晏12 小时前
Bean 生命周期完全图解:前端同学也能看懂的 Spring 核心机制
java·前端·后端
plainGeekDev13 小时前
ButterKnife → ViewBinding
android·java·kotlin
GBASE1 天前
G术时刻 |GBase 8s数据库事务并发控制之封锁技术介绍(下)
数据库
像我这样帅的人丶你还1 天前
Java 后端详解(四):分页与搜索
java·javascript·后端
她的男孩1 天前
数据权限为什么不能只靠注解?Forge 的 Mapper 层 SQL 改写源码拆解
java·后端·架构