新年新氣象,小貓也是踏上了新年新征程,自從小貓按照老貓給的建議【系統梳理大法】完完整整地梳理完畢系統之后,小貓對整個系統的把控可謂又是上到了一個新的高度。開工一周,事情還不是很多,寥寥幾個需求,小貓分分鐘搞定。
類似于開放平臺的老六接到客戶的需求,需要在查詢訂單新增一個下單時間的返回值,然后這就需要提供底層服務的小貓在接口層給出這個字段,然后老六通過包裝之后給客戶。由于需求比較簡單,所以加完字段之后,老六和小貓也就直接上線了。
上線之后事兒來了,對面客戶研發一直詢問為什么還是沒有下單時間,總是空的。老六于是直接找到了小貓,可是小貓經過了一系列的自測發現返回值都是有的,后來排查到在老六封裝之后值不見了。經過仔細排查,終于找到了問題,雖然沒有造成太大的影響,但是總歸給客戶研發的心里留下了一個不好的印象。
雖然下單時間老六和小貓定義的都是orderTime這樣一個字段,但是字段類型小貓用的是Date類型而老六用的是LocalDate,恰巧老六在進行對象賦值的時候偷了個懶直接用了spring的BeanUtils.copyProperties工具類,于是導致日期類型的值并沒有被賦值過去,踩坑了。
老六這才回想起前段時間架構師在群里@ALL的一段話,“大家用BeanUtils拷貝對象的時候注意點,有坑啊,大家盡量用get,set方法啊”。當時的老六不以為意,想著,“切,這得多麻煩,一個個set不花時間啊,有工具類不用”。現在想來是真踩到BeanUtils的坑了。
老六一邊改著代碼一邊叨叨:“這也沒說坑在哪里啊......”
相信很多小伙伴在日常開發的過程中都用過BeanUtils.copyProperties。因為我們日常開發中,經常涉及到DO、DTO、VO對象屬性拷貝賦值。很多開發為了省去繁瑣而又無聊的set方法往往都會用到這樣的工具類進行值拷貝,但是看似簡單的拷貝程序,其實往往暗藏坑點,這不上面的老六就踩雷了么。
下面咱們一起來盤點一下這個拷貝存在哪些坑點吧。見下圖。
盤點
這里主要說的是從老對象進行屬性拷貝到新對象之后,新對象的屬性值不是所期待的。這里分為兩種。
我們來重放一下老六和小貓遇到坑。代碼如下:
/** * 公眾號:程序員老貓 **/public class BeanCopyHelper { public static void main(String[] args) { Origin a = new Origin(); a.setOrderTime(new Date()); Target b = new Target(); BeanUtils.copyProperties(a,b); System.out.println(a.getOrderTime()); System.out.println(b.getOrderTime()); }}@Dataclass Origin { private Date orderTime;}@Dataclass Target { private LocalDate orderTime;}
輸出結果:
Sun Feb 25 21:52:22 CST 2024null
我看看到兩個對象的命名雖然是一致的,但是一個是Date另外一個是LocaDate,這樣導致值并沒有被賦值過去。
這種拷貝不成功的原因很多時候是由于研發人員粗心,沒有校對好導致的。例如下面兩個類:
@Dataclass Origin { private Date ordertime;}@Dataclass Target { private Date orderTime;}
這種顯而易見是無法賦值成功的,因為仔細看來兩個屬性名稱不一致。當然不會賦值成功了。
這種情況是比較極端的,在用loombook和不用loombook的情況下是不一樣的。我們看一下下面例子。當我們不用loombook的時候,如下代碼:
public class BeanCopyHelper { public static void main(String[] args) { Origin origin = new Origin(); origin.setOrderTime(true); Target target = new Target(); BeanUtils.copyProperties(origin,target); System.out.println(origin.getOrderTime()); System.out.println(target.isOrderTime()); }}class Origin { private Boolean isOrderTime; public Boolean getOrderTime() { return isOrderTime; } public void setOrderTime(Boolean orderTime) { isOrderTime = orderTime; }}class Target { private boolean isOrderTime; public boolean isOrderTime() { return isOrderTime; } public void setOrderTime(boolean orderTime) { isOrderTime = orderTime; }}
上面的代碼中,我們看到基礎屬性的類型分別是包裝類還有一個是非包裝類,屬性的命名都是一致的。其最終的輸出結果,我們看到兩者是一致的:
truetrue
如果我們使用loombook的時候,問題就來了,我們看一下loombook改造之后的代碼:
public class BeanCopyHelper { public static void main(String[] args) { Origin origin = new Origin(); origin.setIsOrderTime(true); Target target = new Target(); BeanUtils.copyProperties(origin,target); System.out.println(origin.getIsOrderTime()); System.out.println(target.isOrderTime()); }}@Dataclass Origin { private Boolean isOrderTime;}@Dataclass Target { private boolean isOrderTime;}
最后的輸出結果為:
truefalse
那么這是為什么呢?老貓在這里簡單分享一下,BeanUtils.copyProperties用戶在兩個對象之間進行屬性的復制,底層基于JavaBean的內省機制,通過內省得到拷貝源對象和目的對象屬性的讀方法和寫方法,然后調用對應的方法進行屬性的復制。
所以在進行拷貝時,如果手動生成get和set那么方法分別為:getOrderTime()以及setOrderTime()。我們再來看一下如果采用LoomBook的時候,那么對應的get和set的方法分別為:getIsOrderTime()以及setOrderTime(),拋開set和get本身關鍵字不看,那么后面的肯定是對應不起來了。
這里我們再發散一下,如果說對應的兩個類其屬性壓根連get和set方法都沒有設置,那么兩個對象能夠被拷貝成功嗎?答案是顯而易見的,無法被拷貝成功。所以這里也是用這個拷貝方法的時候的一個坑點。
看標題還是比較抽象的,我們一起來看一下下面的代碼實現:
public class BeanCopyHelper { public static void main(String[] args) { Origin test1 = new Origin(); test1.outerName = "程序員老貓"; Origin.InnerClass innerClass = new Origin.InnerClass(); innerClass.InnerName = "程序員老貓 內部類"; test1.innerClass = innerClass; System.out.println(test1); Target test2 = new Target(); BeanUtils.copyProperties(test1, test2); System.out.println(test2); }}@Dataclass Origin { public String outerName; public Origin.InnerClass innerClass; @Data public static class InnerClass { public String InnerName; }}@Dataclass Target { public String outerName; public Target.InnerClass innerClass; @Data public static class InnerClass { public String InnerName; }}
輸出最終結果如下:
Origin(outerName=程序員老貓, innerClass=Origin.InnerClass(InnerName=程序員老貓 內部類))Target(outerName=程序員老貓, innerClass=null)
最終我們發現其內部內的屬性并沒有被賦值過去。
BeanUtils.copyProperties其實同命名的方法存在于兩個不同的包中,一個是spring的另外一個是apache的,如果不注意的話,很容易就會有問題。如下代碼:
//org.springframework.beans.BeanUtils(源對象在左邊,目標對象在右邊)public static void copyProperties(Object source, Object target) throws BeansException //org.apache.commons.beanutils.BeanUtils(源對象在右邊,目標對象在左邊)public static void copyProperties(Object dest, Object orig) throws IllegalAccessException, InvocationTargetException
位于org.springframework.beans包下。其copyProperties方法實現原理和Apache BeanUtils.copyProperties原理類似,默認實現淺拷貝 區別在于對PropertyDescriptor(內省機制相關)的處理結果做了緩存來提升性能。這里大家有興趣可以自行去查閱一下源代碼。
當我們在排查問題的時候,或者在熟悉業務的過程中,常常會想要看一個整個屬性值的調用鏈路,從而來跟蹤其設值源頭。如果我想看當前的這個屬性是什么時候被設值值的時候,老貓的做法通常是找到當前的那個屬性的set方法,然后使用idea中的“Find Usages”或者快捷鍵ALT+F7。得到需要屬性值被設置的地方。如下圖,就能清晰看到在哪里設值了。
調用鏈路
但是,如果用了工具類進行拷貝的話,那么在代碼復雜的情況下,我們就很難定位其在什么時候被調用的了。
在這里,咱們要回憶一下什么時候淺拷貝,什么是深拷貝。淺拷貝:淺拷貝是指創建一個新對象,然后將原始對象的內容逐個復制到新對象中。在淺拷貝中,只有最外層對象被復制,而內部的嵌套對象只是引用而已,沒有被遞歸復制。這意味著原始對象和淺拷貝對象之間共享內部對象,修改其中一個對象的內部對象會影響到另一個對象。如下示意圖:
淺拷貝
深拷貝:深拷貝是指在進行復制操作時,創建一個完全獨立的新對象,并遞歸地復制原始對象及其所有子對象。換句話說,深拷貝會復制對象的所有層級,包括對象的屬性、嵌套對象、引用等。因此,原始對象和復制對象是完全獨立的,修改其中一個對象不會影響另一個對象。
深拷貝
根據上面的描述,我們通過代碼來重現一下坑點,具體如下:
public class Address { private String city; ...}public class Person { private String name; private Address address; ...}public class TestMain { public static void main(String[] args) { Person sourcePerson = new Person(); sourcePerson.setName("老六"); Address address = new Address(); address.setCity("上海 徐匯"); sourcePerson.setAddress(address); Person targetPerson = new Person(); BeanUtils.copyProperties(sourcePerson, targetPerson); System.out.println(targetPerson.getAddress().getCity()); sourcePerson.getAddress().setCity("上海 黃埔"); System.out.println(targetPerson.getAddress().getCity()); }}
輸出結果為:
上海 徐匯上海 黃埔
我們很明顯地看到操作原始屬性的地址,直接影響到了新對象的屬性的地址。所以這個坑大家也要當心。當然由于淺拷貝的原因導致拷貝出現問題還涉及集合類進行拷貝。例如我們需要對List或者Map進行拷貝的時候也不能直接去拷貝list以及map。
由于BeanUtils.copyProperties其實底層是通過反射實現的,所以其程序執行的效率還是比較低的。我們看一下下面的對比代碼:
public class BeanCopyHelper { public static void main(String[] args) { Origin test1 = new Origin(); test1.outerName = "公眾號:程序員老貓"; Target test2 = new Target(); long beginTime = System.currentTimeMillis(); for (int i = 0; i < 100000; i++) { //循環10萬次 test2.setOuterName(test1.getOuterName()); } System.out.println(test2); System.out.println("common setter time:" + (System.currentTimeMillis() - beginTime)); long beginTime2 = System.currentTimeMillis(); for (int i = 0; i < 100000; i++) { //循環10萬次 BeanUtils.copyProperties(test1, test2); } System.out.println(test2); System.out.println("common setter time:" + (System.currentTimeMillis() - beginTime2)); }}@Dataclass Origin { public String outerName;}@Dataclass Target { public String outerName;}
輸出結果如下:
Target(outerName=公眾號:程序員老貓)common setter time:14Target(outerName=公眾號:程序員老貓)common setter time:291
上述結果,很好地證明了這個結論。有小伙伴肯定會說,這種場景應該比較少吧,太極端了。那么極端嗎?大家回憶一下上面老貓提到的,如果用這個工具復制List或者Map這種集合的時候,其實如果把List和Map當做整個對象來復制往往是失敗的。相信如果不是小白的話一般都會知道這個坑點,為了解決這個問題,很多小伙伴可能會選擇在List或者Map等集合內部進行循環一一遍歷去進行單個對象的拷貝賦值,那么這樣的場景下,性能是不是就受到了影響呢?
既然說了bean拷貝工具類這么多的壞話,那么我們如何去替換這種寫法呢?第一種:當然是直接采用原始的get以及set方法了。這種方式好像除了代碼長了一些之外好像也沒有什么缺點了。有小伙伴可能會跳出來說,這不擼起來麻煩么。不著急,idea這款強大的工具不是已經給我們提供插件了么。如下圖:
插件示意圖
第二種:使用映射工具庫,如MapStruct、ModelMapper等,它們可以自動生成屬性映射的代碼。這些工具庫可以減少手動編寫setter方法的工作量,并提供更好的性能。如下使用代碼:
/** * 公眾號:程序員老貓 **/ @Mapper public interface SourceTargetMapper { SourceTargetMapper INSTANCE = Mappers.getMapper(SourceTargetMapper.class); @Mapping(source = "name", target = "name") @Mapping(source = "age", target = "age") Target mapToTarget(Source source); } //使用 Target target = SourceTargetMapper.INSTANCE.mapToTarget(source);
上述這兩種替換方案,說真的作為開發者而言,老貓更喜歡第一種,簡單方便,而且不需要依賴第三方maven依賴。第二種個人感覺用起來反而比較繁瑣,上述當然純屬個人偏好。
上述小貓和老六的案例中,其實存在的問題需要我們思考的。
即使再小再簡單的需求,作為研發開發完畢之后,我們可以直接上線么?其實很多時候事故往往就是由于“不以為意”發生的。事故的發生往往也遵循“墨菲定律”,這就要求我們更要敬畏生產,再小的需求點都需要經過嚴格的測試驗證才能上線。
說了那么多BeanUtils.copyProperties的壞話,那么這種拷貝方式是不是真的就一無是處呢?其實不是的,所謂存在即合理。很多時候使用的時候踩坑說白了我們沒有理解好這個拷貝工具的特性。很多時候大家在使用使用一個技術的時候都是囫圇吞棗,為了使用而去使用,壓根就沒有深入了解這個技術的特性以及使用注意點。所以在我們使用第三方工具的時候,我們需要更好地了解其特性,知其所以然才能更好更正確地使用。小伙伴們你們覺得呢?
本文鏈接:http://www.www897cc.com/showinfo-26-75349-0.html都說了別用BeanUtils.copyProperties,這不翻車了吧
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。郵件:2376512515@qq.com
下一篇: 16 個 CSS @ 規則,一網打盡!