2023年,我剛從外包跳到一家新能源車企時,對DTO、DO、VO這些概念是真懵——光聽名字就繞,更別說實際用了。后來踩了幾次坑才慢慢明白,這些東西看著復雜,核心就倆字:省事。
先說說DTO、DO、VO到底解決啥問題
剛開始寫代碼,我總喜歡一個實體類用到底:查數據庫用它,接口入參用它,返回給前端還?它。直到有一次,產品突然說要大改接口返回字段,我改著改著發現不對勁——數據庫實體(比如UserDO
)里有密碼、創建時間這些敏感字段,之前直接返回給前端了!更坑的是,因為實體類被Service、Controller、前端共用,改一個字段得全鏈路檢查,差點沒改崩。
后來才明白,DTO、DO、VO的分層,本質是給不同層劃清界限:
- DO(Data Object):和數據庫表一一對應,只在Dao層和Service層之間用,里面全是數據庫字段(比如
user_id
、password
、create_time
)。 - DTO(Data Transfer Object):前端傳給后端的參數載體,比如用戶登錄時傳的
username
和password
,只包含接口需要的字段,多余的一概不要。 - VO(View Object):后端返回給前端的結果,會根據前端需求“裁剪”DO里的字段,比如隱藏密碼,只返回
username
、nickname
這些前端需要展示的。
這么一分層,好處立馬就顯出來了。就像我之前遇到的產品大改:Service層邏輯全重寫了,查的表都換了,但因為Controller層只認DTO和VO,我只要保證入參DTO和返回VO的格式不變,前端完全不用改,Swagger文檔也沒動——這就是解耦的威力。
其實不用死記BO、PO這些細分概念,日常開發里,把實體類分成這三類基本夠用了。咱們看個簡單例子:// 前端傳參:只需要name(DTO)
public class UserDTO {
private String name;
// getter/setter
}
// 數據庫映射:包含id、name、password等(DO)
public class UserDO {
private Long id;
private String name;
private String password;
// getter/setter
}
// 返回給前端:只包含id和name(VO)
public class UserVO {
private Long id;
private String name;
// getter/setter
}
Controller接收DTO,Service把DTO轉成DO查庫,再把DO轉成VO返回——每層各司其職,改起來就不會牽一發而動全身。
對象轉換的坑:深淺拷貝
分層后繞不開一個問題:DO、DTO、VO之間總得轉換吧?比如把UserDO
轉成UserVO
,總不能手動一個個set字段(字段多了能寫哭)。最常用的就是Spring的BeanUtils.copyProperties
,但這東西有倆坑,我踩過好幾次。
先看個例子:如果UserDO
里嵌套了一個Department
對象,用BeanUtils
拷貝會咋樣?// DO里有個子對象
@Data
public class UserDO {
private String name;
private Department dept; // 部門子對象
}
@Data
public class Department {
private String deptName;
}
用BeanUtils.copyProperties(do, vo)
拷貝后,如果你改了原UserDO
里的dept.deptName
,會發現UserVO
里的dept.deptName
也跟著變了!這就是淺拷貝的問題:它只拷貝對象本身的字段,但對子對象(比如dept
)只拷貝引用,原對象和新對象的子對象其實指向同一個內存地址。
那啥是深拷貝?就是不僅拷貝主對象,連里面的子對象也一起復制一份,兩邊改了互不影響。比如用序列化的方式:把對象轉成字節流,再讀回來,相當于重新創建了一個完全獨立的對象。
自己封裝個MyBeanUtils,解決這些破事
既然Spring的BeanUtils
不夠用,不如自己封裝一個工具類,既支持淺拷貝,也能搞定深拷貝,還能批量轉換List(日常開發里轉List的場景太多了)。
1. 淺拷貝:應付簡單對象
大部分時候,對象里沒有子對象,淺拷貝就夠用了。直接封裝一個copyBean
方法,再擴展一個copyList
批量轉換:public finalclass MyBeanUtils {
private static final Logger log = LoggerFactory.getLogger(MyBeanUtils.class );
// 單個對象淺拷貝
public static <T> T copyBean(Object source, Class<T> targetClass) {
try {
T target = targetClass.newInstance(); // 新建目標對象
BeanUtils.copyProperties(source, target); // 拷貝屬性
return target;
} catch (Exception e) {
log.error("對象拷貝失敗", e);
throw new RuntimeException("對象轉換出錯");
}
}
// List批量淺拷貝(比如List<UserDO>轉List<UserVO>)
public static <T> List<T> copyList(List<?> sourceList, Class<T> targetClass) {
if (sourceList == null) return null;
List<T> targetList = new ArrayList<>();
for (Object source : sourceList) {
targetList.add(copyBean(source, targetClass));
}
return targetList;
}
}
用起來賊簡單,一行代碼搞定:// 單個對象轉換
UserVO vo = MyBeanUtils.copyBean(userDO, UserVO.class );
// List轉換
List<UserVO> voList = MyBeanUtils.copyList(doList, UserVO.class );
2. 深拷貝:處理嵌套對象
如果對象里有子對象,就得用深拷貝了。最常用的方式是序列化(要求對象實現Serializable
):public finalclass MyBeanUtils {
// 深拷貝(基于序列化)
public static <T> List<T> deepCopy(List<T> sourceList) {
try {
// 序列化:把對象轉成字節流
ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(byteOut);
out.writeObject(sourceList);
// 反序列化:把字節流轉回對象(全新對象)
ByteArrayInputStream byteIn = new ByteArrayInputStream(byteOut.toByteArray());
ObjectInputStream in = new ObjectInputStream(byteIn);
return (List<T>) in.readObject();
} catch (Exception e) {
log.error("深拷貝失敗", e);
throw new RuntimeException("深拷貝出錯");
}
}
}
或者用JSON工具(比如Jackson)也能實現深拷貝,原理差不多:把對象轉成JSON字符串,再解析成新對象,缺點是性能比序列化稍差,但勝在不用實現Serializable
:// 用Jackson做深拷貝
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(sourceList);
List<UserVO> newList = mapper.readValue(json, new TypeReference<List<UserVO>>() {});
實際開發中常見的轉換場景
除了簡單的DO轉VO,還有些場景也很常用,比如:
1. 組合多個對象到一個TO里
比如查商品列表時,需要把商品信息(ProductDO
)和對應的用戶信息(UserDO
)合并到ProductExtendsTO
里返回:public List<ProductExtendsTO> getProductList() {
// 1. 查商品列表
List<ProductDO> productList = productDao.list();
if (CollectionUtils.isEmpty(productList)) {
return Collections.emptyList();
}
// 2. 批量查商品對應的用戶(避免N+1查詢)
List<Long> userIds = productList.stream()
.map(ProductDO::getUserId)
.collect(Collectors.toList());
List<UserDO> userList = userDao.listByIds(userIds);
// 轉成Map,方便根據userId快速取用戶
Map<Long, UserDO> userMap = userList.stream()
.collect(Collectors.toMap(UserDO::getId, u -> u));
// 3. 組合數據到TO
return productList.stream().map(product -> {
ProductExtendsTO to = new ProductExtendsTO();
// 拷貝商品基本信息
MyBeanUtils.copyBean(product, to);
// 從userMap里取用戶信息,設置到TO里
UserDO user = userMap.get(product.getUserId());
if (user != null) {
to.setUserName(user.getName());
to.setUserAge(user.getAge());
}
return to;
}).collect(Collectors.toList());
}
2. 空集合處理
轉List時一定要注意空指針!如果原List是null
,直接遍歷會報錯。可以用Optional
或者先判斷空:// 安全的List轉換
public List<UserVO> getUserVos(List<UserDO> doList) {
// 用Optional避免null,空集合返回空List而非null
return Optional.ofNullable(doList)
.map(list -> list.stream()
.map(doObj -> MyBeanUtils.copyBean(doObj, UserVO.class ))
.collect(Collectors.toList()))
.orElse(Collections.emptyList());
}
最后說句大實話
其實不用糾結工具類的性能——和數據庫查詢、網絡請求比起來,對象轉換的耗時幾乎可以忽略。日常開發里,能少寫重復代碼、少踩坑才是關鍵。
如果團隊里沒有統一的轉換工具,Spring的BeanUtils
+自己封裝的copyList
就夠用了;如果字段映射復雜(比如字段名不一樣),可以試試MapStruct(編譯時生成轉換代碼,性能好還支持自定義映射)。
說到底,DTO、DO、VO這些“O”和轉換工具,都是為了讓代碼更規整、改起來更省心。剛開始可能覺得麻煩,但用熟了就會發現:真香!