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

前言:
刚学 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内部 |
如果这篇文章对您有帮助的话,还请留下一个免费的小心心
关注我,我们下期再见!