专业编程教程与实战项目分享平台

网站首页 > 技术文章 正文

Java 实战:TO/DO/VO 避坑指南,转换工具直接抄!

ins518 2025-09-13 01:01:33 技术文章 2 ℃ 0 评论

刚接维护项目时,我见过最离谱的代码:一个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 会觉得 “麻烦”,但用熟了会发现:前期多写一个类,后期少改十处代码。最后给几个实用建议:

  1. 不用追求所有 “O”:日常开发 TO/DO/VO 足够,BO(业务对象)、PO(持久化对象)这些细分概念,除非团队有强制规范,否则不用刻意加。
  2. 工具选择看场景:简单场景用MyBeanUtils(自己封装的),字段名不一致、复杂映射用MapStruct(编译时生成代码,性能好)。
  3. 核心是 “解耦”:只要保证 “Dao 层只传 DO,Controller 层只收 DTO、返 VO”,就算字段多写几个set,也比 “一个类用到底” 强。

其实这些 “O” 和转换工具,本质都是为了让代码 “好维护”—— 毕竟没人想改一个字段,就要全链路翻代码。现在把工具类抄到项目里,下次写接口就能少踩坑了。


感谢关注【AI码力】,获取更多Java秘籍!

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表