网站首页 > 技术文章 正文
刚接维护项目时,我见过最离谱的代码:一个User实体类从 Dao 层查到数据库,到 Service 层处理逻辑,再到 Controller 层返回前端,全程 “一用到底”。直到有次前端反馈:“为什么我能拿到用户的密码?”—— 打开代码才发现,User类里的password字段没做任何隐藏,直接随接口返回了。更坑的是后来产品改接口字段,我改了User类的一个属性,结果数据库查询 SQL、Service 层校验逻辑、前端渲染全崩了 —— 这就是没分清 TO/DO/VO 的典型坑。
今天从实战角度聊聊,TO/DO/VO 该怎么用、怎么转,把踩过的坑和封装好的工具直接给你,看完就能落地。
一、先搞懂:TO/DO/VO 到底是啥?别再混着用
很多人觉得这些 “O” 绕,其实核心就一个目的:给不同层划清边界,避免 “牵一发而动全身”。日常开发里,不用死记 BO、PO、QO 这些细分概念,把 TO/DO/VO 用明白,80% 的场景都能覆盖。
1. DO(Data Object):数据库的 “影子”
- 定位:和数据库表一一对应,是数据库字段的直接映射,只在Dao 层和 Service 层之间流转。
- 核心特点:包含数据库所有字段(比如user_id、password、create_time、delete_flag),哪怕是敏感字段或逻辑删除标记,只要表中有,DO 里就有。
- 实战例子:
java
// 对应数据库user表,字段完全匹配
@Data
public class UserDO {
// 数据库主键,和表中user_id字段对应
private Long userId;
// 用户名,表中username字段
private String username;
// 敏感字段:密码,表中password字段(加密存储)
private String password;
// 逻辑删除标记,表中is_deleted字段
private Integer isDeleted;
// 创建时间,表中create_time字段
private LocalDateTime createTime;
}
- 禁忌:绝对不能把 DO 传到 Controller 层或前端!否则敏感字段泄露、表结构变动会直接影响外层。
2. DTO(Data Transfer Object):前端传参的 “快递盒”
- 定位:前端传给后端的参数载体,只在Controller 层和 Service 层之间作为入参流转,里面只装接口需要的字段,多余的一概不装。
- 核心特点:字段按需定义,比如登录接口只需要username和password,那 DTO 就只有这两个字段,避免传无用参数浪费带宽。
- 实战例子:
java
// 用户登录接口的入参DTO,只包含需要的字段
@Data
public class UserLoginDTO {
// 前端传的用户名
@NotBlank(message = "用户名不能为空") // 校验注解直接加在DTO上
private String username;
// 前端传的密码
@NotBlank(message = "密码不能为空")
private String password;
}
// 用户注册接口的入参DTO,字段更多但仍按需定义
@Data
public class UserRegisterDTO {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 20, message = "密码长度6-20位")
private String password;
@NotBlank(message = "昵称不能为空")
private String nickname;
}
- 优势:参数校验(比如@NotBlank)直接在 DTO 上做,不用在 Service 层重复写;前端改传参字段,只动 DTO,不影响 Service 和 Dao 层。
3. VO(View Object):后端给前端的 “定制礼包”
- 定位:后端返回给前端的结果,只在Service 层和 Controller 层之间作为出参流转,会根据前端展示需求 “裁剪” DO 的字段。
- 核心特点:隐藏敏感字段(比如password、isDeleted),只保留前端需要展示的内容(比如username、nickname、createTime),甚至可以做字段重命名(比如 DO 的userId在 VO 里叫id,更符合前端习惯)。
- 实战例子:
java
// 前端用户列表展示用的VO,无敏感字段
@Data
public class UserVO {
// 前端习惯叫id,对应DO的userId
private Long id;
// 用户名,直接取自DO的username
private String username;
// 昵称,取自DO的nickname
private String nickname;
// 创建时间,格式化为字符串给前端(避免前端处理时间格式)
private String createTime; // 比如返回"2024-08-29 10:30:00"
}
- 关键:VO 是 “定制化” 的,不同前端页面可能需要不同 VO。比如用户列表 VO 不含详细信息,用户详情 VO 则需要补充phone、email等字段。
4. TO(Transfer Object):跨层传递的 “复合包裹”
很多人分不清 DTO 和 TO,简单说:DTO 多是单一接口的入参,TO 是跨层传递的复合数据。比如查商品列表时,需要把 “商品信息 + 对应的用户信息” 一起传给前端,这时候就需要一个ProductExtendsTO,把两个 DO 的字段组合起来。
- 实战例子:
java
// 商品列表展示用的复合TO,组合商品和用户信息
@Data
public class ProductExtendsTO {
// 商品相关字段(来自ProductDO)
private Long productId;
private String productName;
private BigDecimal price;
// 用户相关字段(来自UserDO,供前端展示“创建人”)
private String createUserName; // 商品创建者的用户名
private String createUserNickname; // 商品创建者的昵称
}
二、分层后绕不开的坑:对象转换的 “深浅拷贝”
把 TO/DO/VO 分层后,绕不开一个问题:怎么把UserDO转成UserVO?把UserLoginDTO转成UserDO?总不能字段多了就手动set(10 个字段写 10 行set,累还容易错)。
最常用的是 Spring 的BeanUtils.copyProperties,但这东西有个大坑 ——浅拷贝,我踩过好几次。
1. 坑:浅拷贝导致的 “连锁修改”
如果 DO 里嵌套了子对象(比如UserDO里有个Department部门对象),用BeanUtils拷贝后,修改原 DO 的子对象,VO 的子对象也会跟着变!
看例子:
java
// 嵌套的部门类
@Data
public class Department {
private String deptName; // 部门名称
}
// 包含子对象的UserDO
@Data
public class UserDO {
private String username;
private Department dept; // 嵌套子对象
}
// 对应的UserVO(结构和DO一致)
@Data
public class UserVO {
private String username;
private Department dept;
}
// 测试浅拷贝问题
public class CopyTest {
public static void main(String[] args) {
// 1. 构建原UserDO
UserDO userDO = new UserDO();
userDO.setUsername("张三");
Department dept = new Department();
dept.setDeptName("研发部");
userDO.setDept(dept);
// 2. 用BeanUtils浅拷贝到UserVO
UserVO userVO = new UserVO();
BeanUtils.copyProperties(userDO, userVO);
// 3. 修改原DO的子对象deptName
userDO.getDept().setDeptName("产品部");
// 4. 打印VO的deptName——结果变成了“产品部”!
System.out.println(userVO.getDept().getDeptName());
}
}
原因:浅拷贝只拷贝对象的 “表层字段”,对子对象(比如dept)只拷贝 “引用地址”—— 原 DO 和新 VO 的dept指向同一个内存地址,改一个就会影响另一个。
2. 解:按需选择 “浅拷贝” 或 “深拷贝”
- 浅拷贝:适合没有嵌套子对象的简单场景(比如UserDO只有基本类型字段),效率高。
- 深拷贝:适合有嵌套子对象的场景,会把主对象和子对象一起复制,两边修改互不影响。
3. 实战:封装自己的转换工具类 MyBeanUtils
既然 Spring 的BeanUtils不够用,不如自己封装一个工具类,同时支持浅拷贝、深拷贝、批量 List 转换,直接在项目里用。
(1)浅拷贝:处理简单对象和 List
java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class MyBeanUtils {
private static final Logger log = LoggerFactory.getLogger(MyBeanUtils.class);
// 私有构造,避免被实例化
private MyBeanUtils() {}
/**
* 单个对象浅拷贝
* @param source 源对象(比如UserDO)
* @param targetClass 目标对象类型(比如UserVO.class)
* @return 目标对象
*/
public static <T> T copyBean(Object source, Class<T> targetClass) {
if (source == null) {
return null;
}
try {
// 1. 新建目标对象实例
T target = targetClass.getDeclaredConstructor().newInstance();
// 2. 浅拷贝属性
BeanUtils.copyProperties(source, target);
return target;
} catch (Exception e) {
log.error("浅拷贝对象失败,源对象类型:{},目标对象类型:{}",
source.getClass().getName(), targetClass.getName(), e);
throw new RuntimeException("对象转换出错");
}
}
/**
* List批量浅拷贝(比如List<UserDO>转List<UserVO>)
* @param sourceList 源List
* @param targetClass 目标对象类型
* @return 目标List(避免返回null,空集合返回Collections.emptyList())
*/
public static <T> List<T> copyList(List<?> sourceList, Class<T> targetClass) {
// 用Optional避免null,空集合返回空List
return Optional.ofNullable(sourceList)
.map(list -> list.stream()
.map(item -> copyBean(item, targetClass))
.collect(Collectors.toList()))
.orElse(Collections.emptyList());
}
}
用法:一行代码搞定转换,不用写一堆set:
java
// 1. 单个对象转换:UserDO -> UserVO
UserVO userVO = MyBeanUtils.copyBean(userDO, UserVO.class);
// 2. List转换:List<UserDO> -> List<UserVO>
List<UserDO> userDOList = userDao.list();
List<UserVO> userVOList = MyBeanUtils.copyList(userDOList, UserVO.class);
(2)深拷贝:处理嵌套对象
深拷贝有两种常用方式,按需选择:
方式 1:基于序列化(需实现 Serializable)
要求所有嵌套对象都实现Serializable接口,优点是效率高,缺点是需要加接口。
java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.List;
public final class MyBeanUtils {
// 省略上面的浅拷贝方法...
/**
* List批量深拷贝(基于序列化,需实现Serializable)
* @param sourceList 源List(元素需实现Serializable)
* @return 目标List(完全独立,修改不影响原List)
*/
@SuppressWarnings("unchecked")
public static <T extends Serializable> List<T> deepCopyBySerialize(List<T> sourceList) {
if (sourceList == null) {
return Collections.emptyList();
}
try (
// 1. 序列化:把对象转成字节流
ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(byteOut);
// 2. 反序列化:把字节流转回对象(全新实例)
ByteArrayInputStream byteIn = new ByteArrayInputStream(byteOut.toByteArray());
ObjectInputStream in = new ObjectInputStream(byteIn)
) {
out.writeObject(sourceList);
return (List<T>) in.readObject();
} catch (Exception e) {
log.error("深拷贝(序列化)失败", e);
throw new RuntimeException("深拷贝出错");
}
}
}
用法:先让 DO 和嵌套类实现Serializable:
java
// 嵌套类实现Serializable
@Data
public class Department implements Serializable {
private static final long serialVersionUID = 1L; // 建议加序列化ID
private String deptName;
}
// DO实现Serializable
@Data
public class UserDO implements Serializable {
private static final long serialVersionUID = 1L;
private String username;
private Department dept;
}
// 深拷贝测试
List<UserDO> userDOList = ...;
List<UserDO> newList = MyBeanUtils.deepCopyBySerialize(userDOList);
// 修改newList的子对象,原userDOList不受影响
newList.get(0).getDept().setDeptName("测试部");
方式 2:基于 Jackson(无需实现接口)
如果不想加Serializable接口,可以用 Jackson 把对象转成 JSON 字符串,再解析成新对象。优点是不用改实体类,缺点是性能比序列化稍差(日常场景完全够用)。
java
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
public final class MyBeanUtils {
// 省略其他方法...
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* List批量深拷贝(基于Jackson,无需实现Serializable)
* @param sourceList 源List
* @param typeReference 目标类型引用(比如new TypeReference<List<UserVO>>() {})
* @return 目标List
*/
public static <T> List<T> deepCopyByJackson(List<?> sourceList, TypeReference<List<T>> typeReference) {
if (sourceList == null) {
return Collections.emptyList();
}
try {
// 1. 转成JSON字符串
String json = objectMapper.writeValueAsString(sourceList);
// 2. 解析成目标List
return objectMapper.readValue(json, typeReference);
} catch (Exception e) {
log.error("深拷贝(Jackson)失败", e);
throw new RuntimeException("深拷贝出错");
}
}
}
用法:不用改实体类,直接指定目标类型:
java
List<UserDO> userDOList = ...;
// 深拷贝:List<UserDO> -> List<UserVO>
List<UserVO> userVOList = MyBeanUtils.deepCopyByJackson(
userDOList,
new TypeReference<List<UserVO>>() {}
);
三、3 个高频实战场景:从代码到思路
光懂理论不够,结合实际业务场景才能真正用起来。分享 3 个日常开发中最常见的场景,附完整代码和思路。
场景 1:多对象组合成 TO(避免 N+1 查询)
比如 “查商品列表”,前端需要展示 “商品信息 + 创建人昵称”,这时候需要把ProductDO和UserDO组合成ProductExtendsTO。
关键思路:批量查询用户,避免 N+1 查询(查 100 个商品,再查 100 次用户)。
java
@Service
public class ProductService {
@Autowired
private ProductDao productDao;
@Autowired
private UserDao userDao;
/**
* 查商品列表,返回包含创建人信息的TO
*/
public List<ProductExtendsTO> getProductList() {
// 1. 第一步:查所有商品(1次数据库查询)
List<ProductDO> productDOList = productDao.list();
if (productDOList.isEmpty()) {
return Collections.emptyList();
}
// 2. 第二步:批量查商品对应的用户(1次数据库查询,避免N+1)
// 提取所有商品的创建人ID
List<Long> createUserIds = productDOList.stream()
.map(ProductDO::getCreateUserId)
.distinct() // 去重,减少查询量
.collect(Collectors.toList());
// 批量查用户,转成Map(userId -> UserDO),方便快速获取
Map<Long, UserDO> userMap = userDao.listByIds(createUserIds).stream()
.collect(Collectors.toMap(UserDO::getUserId, user -> user));
// 3. 第三步:组合数据到ProductExtendsTO
return productDOList.stream()
.map(productDO -> {
// 3.1 拷贝商品基本信息到TO
ProductExtendsTO to = MyBeanUtils.copyBean(productDO, ProductExtendsTO.class);
// 3.2 从userMap中获取创建人信息,设置到TO
UserDO createUser = userMap.get(productDO.getCreateUserId());
if (createUser != null) {
to.setCreateUserName(createUser.getUsername());
to.setCreateUserNickname(createUser.getNickname());
} else {
// 兜底:没有找到用户时设默认值,避免前端显示null
to.setCreateUserName("未知用户");
to.setCreateUserNickname("未知用户");
}
return to;
})
.collect(Collectors.toList());
}
}
场景 2:DTO 转 DO 时的字段适配
比如用户注册接口,前端传的UserRegisterDTO里有password明文,需要加密后再存到UserDO的password字段。
关键思路:转换时做字段加工,不能直接无脑拷贝。
java
@Service
public class UserService {
@Autowired
private UserDao userDao;
// 密码加密工具(比如BCrypt)
@Autowired
private PasswordEncoder passwordEncoder;
/**
* 用户注册:DTO -> DO(密码加密处理)
*/
public void register(UserRegisterDTO registerDTO) {
// 1. DTO转DO,但不直接拷贝password
UserDO userDO = MyBeanUtils.copyBean(registerDTO, UserDO.class);
// 2. 对password做加密处理(关键步骤)
String encryptedPwd = passwordEncoder.encode(registerDTO.getPassword());
userDO.setPassword(encryptedPwd);
// 3. 设置默认值(DO里的公共字段)
userDO.setIsDeleted(0); // 未删除
userDO.setCreateTime(LocalDateTime.now());
// 4. 存数据库
userDao.insert(userDO);
}
}
场景 3:VO 字段格式化(前端友好)
比如UserDO的createTime是LocalDateTime类型,前端需要字符串格式(比如2024-08-29 10:30:00),直接返回会导致前端解析异常。
关键思路:在 VO 里做字段格式化,或转换时处理。
java
@Service
public class UserService {
@Autowired
private UserDao userDao;
// 时间格式化工具
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 查用户详情:DO -> VO(时间格式化)
*/
public UserVO getUserDetail(Long userId) {
// 1. 查DO
UserDO userDO = userDao.getById(userId);
if (userDO == null || userDO.getIsDeleted() == 1) {
throw new RuntimeException("用户不存在或已删除");
}
// 2. DO转VO
UserVO userVO = MyBeanUtils.copyBean(userDO, UserVO.class);
// 3. 时间格式化:LocalDateTime -> String
userVO.setCreateTime(userDO.getCreateTime().format(FORMATTER));
// 4. 隐藏敏感字段(即使DO里有,VO里没定义,也不会返回)
return userVO;
}
}
四、最后:不用纠结 “完美”,实用就好
很多人刚开始用 TO/DO/VO 会觉得 “麻烦”,但用熟了会发现:前期多写一个类,后期少改十处代码。最后给几个实用建议:
- 不用追求所有 “O”:日常开发 TO/DO/VO 足够,BO(业务对象)、PO(持久化对象)这些细分概念,除非团队有强制规范,否则不用刻意加。
- 工具选择看场景:简单场景用MyBeanUtils(自己封装的),字段名不一致、复杂映射用MapStruct(编译时生成代码,性能好)。
- 核心是 “解耦”:只要保证 “Dao 层只传 DO,Controller 层只收 DTO、返 VO”,就算字段多写几个set,也比 “一个类用到底” 强。
其实这些 “O” 和转换工具,本质都是为了让代码 “好维护”—— 毕竟没人想改一个字段,就要全链路翻代码。现在把工具类抄到项目里,下次写接口就能少踩坑了。
感谢关注【AI码力】,获取更多Java秘籍!
猜你喜欢
- 2025-09-13 十万字前端面试问题总结 - 第4部分
- 2025-09-13 DeepSeek 写接口文档总乱码?这个工具让 API 规范度拉满
- 2025-09-13 Spring Boot 集成 SeetaFace6:手把手打造工业级人脸识别接口
- 2025-09-13 索引失效?MySQL 隐式转换搞的鬼!3 步解决,提速 10 倍
- 2025-09-13 Agent杂谈:Agent的能力上下限及「Agent构建」核心技术栈调研分享~
- 2025-09-13 使用 Scrapy 轻松抓取网页_scrapy爬取多个网页
- 2025-09-13 sql中常用的字符串函数详解_sql中常用的字符串函数详解有哪些
- 2025-09-13 揭秘JavaScript数组神器slice():不修改原数组的5个实用技巧
- 2025-09-13 使用 StringUtils.split 的坑_string split -1
- 2025-09-13 JavaScript:字符串的相关方法_js 字符串
你 发表评论:
欢迎- 最近发表
- 标签列表
-
- 前端设计模式 (75)
- 前端性能优化 (51)
- 前端模板 (66)
- 前端跨域 (52)
- 前端缓存 (63)
- 前端aes加密 (58)
- 前端脚手架 (56)
- 前端md5加密 (54)
- 前端路由 (61)
- 前端数组 (73)
- 前端js面试题 (50)
- 前端定时器 (59)
- Oracle RAC (76)
- oracle恢复 (77)
- oracle 删除表 (52)
- oracle 用户名 (80)
- oracle 工具 (55)
- oracle 内存 (55)
- oracle 导出表 (62)
- oracle约束 (54)
- oracle 中文 (51)
- oracle链接 (54)
- oracle的函数 (58)
- oracle面试 (55)
- 前端调试 (52)
本文暂时没有评论,来添加一个吧(●'◡'●)