建議11:養成良好習慣,顯示宣告UID
我們編寫一個實現了Serializable介面(序列化標誌介面)的類,Eclipse馬上就會給一個黃色警告:需要新增一個Serial Version ID。為什麼要增加?他是怎麼計算出來的?有什麼用?下面就來解釋該問題。
類實現Serializable介面的目的是為了可持久化,比如網路傳輸或本地儲存,為系統的分佈和異構部署提供先決條件支援。若沒有序列化,現在我們熟悉的遠端呼叫、物件資料庫都不可能存在,我們來看一個簡單的序列化類:
1 import java.io.Serializable; 2 public class Person implements Serializable { 3 private String name; 4 5 public String getName() { 6 return name; 7 } 8 9 public void setName(String name) { 10 this.name = name; 11 } 12 13 }
這是一個簡單的JavaBean,實現了Serializable介面,可以在網路上傳輸,也可以在本地儲存然後讀取。這裡我們以java訊息服務(Java Message Service)方式傳遞物件(即通過網路傳遞一個物件),定義在訊息佇列中的資料型別為ObjectMessage,首先定義一個訊息的生產者(Producer),程式碼如下:
1 public class Producer { 2 public static void main(String[] args) { 3 Person p = new Person(); 4 p.setName("混世魔王"); 5 // 序列化,儲存到磁碟上 6 SerializationUtils.writeObject(p); 7 } 8 }
這裡引入了一個工具類SerializationUtils,其作用是對一個類進行序列化和反序列化,並儲存到硬碟上(模擬網路傳輸),其程式碼如下:
1 import java.io.FileInputStream; 2 import java.io.FileNotFoundException; 3 import java.io.FileOutputStream; 4 import java.io.IOException; 5 import java.io.ObjectInputStream; 6 import java.io.ObjectOutputStream; 7 import java.io.Serializable; 8 9 public class SerializationUtils { 10 private static String FILE_NAME = "c:/obj.bin"; 11 //序列化 12 public static void writeObject(Serializable s) { 13 try { 14 ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(FILE_NAME)); 15 oos.writeObject(s); 16 oos.close(); 17 } catch (FileNotFoundException e) { 18 e.printStackTrace(); 19 } catch (IOException e) { 20 e.printStackTrace(); 21 } 22 } 23 //反序列化 24 public static Object readObject() { 25 Object obj = null; 26 try { 27 ObjectInputStream input = new ObjectInputStream(new FileInputStream(FILE_NAME)); 28 obj=input.readObject(); 29 input.close(); 30 } catch (FileNotFoundException e) { 31 e.printStackTrace(); 32 } catch (IOException e) { 33 e.printStackTrace(); 34 } catch (ClassNotFoundException e) { 35 e.printStackTrace(); 36 } 37 return obj; 38 } 39 }
通過物件序列化過程,把一個記憶體塊轉化為可傳輸的資料流,然後通過網路傳送到訊息消費者(Customer)哪裡,進行反序列化,生成實驗物件,程式碼如下:
1 public class Customer { 2 public static void main(String[] args) { 3 //反序列化 4 Person p=(Person) SerializationUtils.readObject(); 5 System.out.println(p.getName()); 6 } 7 }
這是一個反序列化的過程,也就是物件資料流轉換為一個例項的過程,其執行後的輸出結果為“混世魔王”。這太easy了,是的,這就是序列化和反序列化的典型Demo。但此處藏著一個問題:如果訊息的生產者和訊息的消費者(Person類)有差異,會出現何種神奇事件呢?比如:訊息生產者中的Person類新增一個年齡屬性,而消費者沒有增加該屬性。為啥沒有增加?因為這個是分散式部署的應用,你甚至不知道這個應用部署在何處,特別是通過廣播方式發訊息的情況,漏掉一兩個訂閱者也是很正常的。
這中序列化和反序列化的類在不一致的情況下,反序列化時會報一個InalidClassException異常,原因是序列化和反序列化所對應的類版本發生了變化,JVM不能把資料流轉換為例項物件。刨根問底:JVM是根據什麼來判斷一個類的版本呢?
好問題,通過SerializableUID,也叫做流識別符號(Stream Unique Identifier),即類的版本定義的,它可以顯示宣告也可以隱式宣告。顯示宣告格式如下:
private static final long serialVersionUID = 1867341609628930239L;
而隱式宣告則是我不宣告,你編譯器在編譯的時候幫我生成。生成的依據是通過包名、類名、繼承關係、非私有的方法和屬性,以及引數、返回值等諸多因子算出來的,極度複雜,基本上計算出來的這個值是唯一的。
serialVersionUID如何生成已經說明了,我們再來看看serialVersionUID的作用。JVM在反序列化時,會比較資料流中的serialVersionUID與類的serialVersionUID是否相同,如果相同,則認為類沒有改變,可以把資料load為例項相同;如果不相同,對不起,我JVM不幹了,拋個異常InviladClassException給你瞧瞧。這是一個非常好的校驗機制,可以保證一個物件即使在網路或磁碟中“滾過”一次,仍能做到“出淤泥而不染”,完美的實現了類的一致性。
但是,有時候我們需要一點特例場景,例如我的類改變不大,JVM是否可以把我以前的物件反序列化回來?就是依據顯示宣告的serialVersionUID,向JVM撒謊說"我的類版本沒有變化",如此我買你編寫的類就實現了向上相容,我們修改Person類,裡面新增private static final long serialVersionUID = 1867341609628930239L;
剛開始生產者和消費者持有的Person類一致,都是V1.0,某天生產者的Person類變更了,增加了一個“年齡”屬性,升級為V2.0,由於種種原因(比如程式設計師疏忽,升級時間視窗不同等)消費端的Person類還是V1.0版本,新增的程式碼為 priavte int age;以及對應的setter和getter方法。
此時雖然生產這和消費者對應的類版本不同,但是顯示宣告的serialVersionUID相同,序列化也是可以執行的,所帶來的業務問題就是消費端不能讀取到新增的業務屬性(age屬性而已)。通過此例,我們反序列化也實現了版本向上相容的功能,使用V1.0版本的應用訪問了一個V2.0的物件,這無疑提高了程式碼的健壯性。我們在編寫序列化類程式碼時隨手新增一個serialVersionUID欄位,也不會帶來太多的工作量,但它卻可以在關鍵時候發揮異乎尋常的作用。
顯示宣告serialVersionUID可以避免物件的不一致,但儘量不要以這種方式向JVM撒謊。
建議12:避免用序列化類在建構函式中為不變數賦值
我們知道帶有final標識的屬性是不變數,也就是隻能賦值一次,不能重複賦值,但是在序列化類中就有點複雜了,比如這個類:
1 public class Person implements Serializable { 2 private static final long serialVersionUID = 1867341609628930239L; 3 public final String perName="程咬金"; 4 }
這個Peson類(此時V1.0版本)被序列化,然後儲存在磁碟上,在反序列化時perName屬性會重新計算其值(這與static變數不同,static變數壓根就沒有儲存到資料流中)比如perName屬性修改成了"秦叔寶"(版本升級為V2.0),那麼反序列化的perName值就是"秦叔寶"。保持新舊物件的final變數相同,有利於程式碼業務邏輯統一,這是序列化的基本原則之一,也就是說,如果final屬性是一個直接量,在反序列化時就會重新計算。對於基本原則不多說,現在說一下final變數的另一種賦值方式:通過建構函式賦值。程式碼如下:
public class Person implements Serializable { private static final long serialVersionUID = 1867341609628930239L; public final String perName; public Person() { perName = "程咬金"; } }
這也是我們常用的一種賦值方式,可以把Person類定義為版本V1.0,然後進行序列化,看看序列化後有什麼問題,序列化程式碼如下:
public class Serialize { public static void main(String[] args) { //序列化以持久保持 SerializationUtils.writeObject(new Person()); } }
Person的實習物件儲存到了磁碟上,它時一個貧血物件(承載業務屬性定義,但不包含其行為定義),我們做一個簡單的模擬,修改一下PerName值代表變更,要注意的是serialVersionUID不變,修改後的程式碼如下:
public class Person implements Serializable { private static final long serialVersionUID = 1867341609628930239L; public final String perName; public Person() { perName = "秦叔寶"; } }
此時Person類的版本時V2.0但serialVersionUID沒有改變,仍然可以反序列化,程式碼如下:
public class Deserialize { public static void main(String[] args) { Person p = (Person) SerializationUtils.readObject(); System.out.println(p.perName); } }
現在問題出來了,列印出來的結果是"程咬金" 還是"秦叔寶"?答案是:"程咬金"。final型別的變數不是會重新計算嘛,列印出來的應該是秦叔寶才對呀。為什麼會是程咬金?這是因為這裡觸及到了反序列化的兩一個原則:反序列化時建構函式不會執行.
反序列化的執行過程是這樣的:JVM從資料流中獲取一個Object物件,然後根據資料流中的類檔案描述資訊(在序列化時,儲存到磁碟的物件檔案中包含了類描述資訊,注意是描述資訊,不是類)檢視,發現是final變數,需要重新計算,於是引用Person類中的perName值,而此時JVM又發現perName竟沒有賦值,不能引用,於是它很聰明的不再初始化,保持原值狀態,所以結果就是"程咬金"了。
注意:在序列化類中不使用建構函式為final變數賦值.
建議13:避免為final變數複雜賦值
為final變數賦值還有另外一種方式:通過方法賦值,及直接在宣告時通過方法的返回值賦值,還是以Person類為例來說明,程式碼如下:
public class Person implements Serializable { private static final long serialVersionUID = 1867341609628930239L; //通過方法返回值為final變數賦值 public final String pName = initName(); public String initName() { return "程咬金"; } }
pName屬性是通過initName方法的返回值賦值的,這在複雜的類中經常用到,這比使用建構函式賦值更簡潔,易修改,那麼如此用法在序列化時會不會有問題呢?我們一起看看。Person類寫好了(定義為V1.0版本),先把它序列化,儲存到本地檔案,其程式碼與之前相同,不在贅述。現在Person類的程式碼需要修改,initName的返回值改為"秦叔寶".那麼我們之前儲存在磁碟上的的例項載入上來,pName的會是什麼呢?
現在,Person類的程式碼需要修改,initName的返回值也改變了,程式碼如下:
public class Person implements Serializable { private static final long serialVersionUID = 1867341609628930239L; //通過方法返回值為final變數賦值 public final String pName = initName(); public String initName() { return "秦叔寶"; } }
上段程式碼僅僅修改了initName的返回值(Person類為V2.0版本)也就是通過new生成的物件的final變數的值都是"秦叔寶",那麼我們把之前儲存在磁碟上的例項載入上來,pName的值會是什麼呢?
結果是"程咬金",很詫異,上一建議說過final變數會被重新賦值,但是這個例子又沒有重新賦值,為什麼?
上個建議說的重新賦值,其中的"值"指的是簡單物件。簡單物件包括:8個基本型別,以及陣列、字串(字串情況複雜,不通過new關鍵字生成的String物件的情況下,final變數的賦值與基本型別相同),但是不能方法賦值。
其中的原理是這樣的,儲存到磁碟上(或網路傳輸)的物件檔案包括兩部分:
(1).類描述資訊:包括類路徑、繼承關係、訪問許可權、變數描述、變數訪問許可權、方法簽名、返回值、以及變數的關聯類資訊。要注意一點是,它並不是class檔案的翻版,它不記錄方法、建構函式、static變數等的具體實現。之所以類描述會被儲存,很簡單,是因為能去也能回嘛,這保證反序列化的健壯執行。
(2).非瞬態(transient關鍵字)和非靜態(static關鍵字)的實體變數值
注意,這裡的值如果是一個基本型別,好說,就是一個簡單值儲存下來;如果是複雜物件,也簡單,連該物件和關聯類資訊一起儲存,並且持續遞迴下去(關聯類也必須實現Serializable介面,否則會出現序列化異常),也就是遞迴到最後,還是基本資料型別的儲存。
正是因為這兩個原因,一個持久化的物件檔案會比一個class類檔案大很多,有興趣的讀者可以自己測試一下,體積確實膨脹了不少。
總結一下:反序列化時final變數在以下情況下不會被重新賦值:
- 通過建構函式為final變數賦值
- 通過方法返回值為final變數賦值
- final修飾的屬性不是基本型別
建議14:使用序列化類的私有方法巧妙解決部分屬性持久化問題
部分屬性持久化問題看似很簡單,只要把不需要持久化的屬性加上瞬態關鍵字(transient關鍵字)即可。這是一種解決方案,但有時候行不通。例如一個計稅系統和一個HR系統,通過RMI(Remote Method Invocation,遠端方法呼叫)對接,計稅系統需要從HR系統獲得人員的姓名和基本工資,以作為納稅的依據,而HR系統的工資分為兩部分:基本工資和績效工資,基本工資沒什麼祕密,績效工資是保密的,不能洩露到外系統,這明顯是連個相互關聯的類,先看看薪水類Salary的程式碼:
1 public class Salary implements Serializable { 2 private static final long serialVersionUID = 2706085398747859680L; 3 // 基本工資 4 private int basePay; 5 // 績效工資 6 private int bonus; 7 8 public Salary(int _basepay, int _bonus) { 9 this.basePay = _basepay; 10 this.bonus = _bonus; 11 } 12 //Setter和Getter方法略 13 14 }
Person類和Salary類是關聯關係,程式碼如下:
1 public class Person implements Serializable { 2 3 private static final long serialVersionUID = 9146176880143026279L; 4 5 private String name; 6 7 private Salary salary; 8 9 public Person(String _name, Salary _salary) { 10 this.name = _name; 11 this.salary = _salary; 12 } 13 14 //Setter和Getter方法略 15 16 }
這是兩個簡單的JavaBean,都實現了Serializable介面,具備了序列化的條件。首先計稅系統請求HR系統對一個Person物件進行序列化,把人員資訊和工資資訊傳遞到計稅系統中,程式碼如下:
1 public class Serialize { 2 public static void main(String[] args) { 3 // 基本工資1000元,績效工資2500元 4 Salary salary = new Salary(1000, 2500); 5 // 記錄人員資訊 6 Person person = new Person("張三", salary); 7 // HR系統持久化,並傳遞到計稅系統 8 SerializationUtils.writeObject(person); 9 } 10 }
在通過網路傳輸到計稅系統後,進行反序列化,程式碼如下:
1 public class Deserialize { 2 public static void main(String[] args) { 3 Person p = (Person) SerializationUtils.readObject(); 4 StringBuffer buf = new StringBuffer(); 5 buf.append("姓名: "+p.getName()); 6 buf.append("\t基本工資: "+p.getSalary().getBasePay()); 7 buf.append("\t績效工資: "+p.getSalary().getBonus()); 8 System.out.println(buf); 9 } 10 }
列印出的結果為:姓名: 張三 基本工資: 1000 績效工資: 2500
但是這不符合需求,因為計稅系統只能從HR系統中獲取人員姓名和基本工資,而績效工資是不能獲得的,這是個保密資料,不允許發生洩漏。怎麼解決這個問題呢?你可能會想到以下四種方案:
- 在bonus前加上關鍵字transient:這是一個方法,但不是一個好方法,加上transient關鍵字就標誌著Salary失去了分散式部署的功能,它可能是HR系統核心的類了,一旦遭遇效能瓶頸,再想實現分散式部署就可能了,此方案否定;
- 新增業務物件:增加一個Person4Tax類,完全為計稅系統服務,就是說它只有兩個屬性:姓名和基本工資。符合開閉原則,而且對原系統也沒有侵入性,只是增加了工作量而已。但是這個方法不是最優方法;
- 請求端過濾:在計稅系統獲得Person物件後,過濾掉Salary的bonus屬性,方案可行但不符合規矩,因為HR系統中的Salary類安全性竟然然外系統(計稅系統來承擔),設計嚴重失職;
- 變更傳輸契約:例如改用XML傳輸,或者重建一個WebSerive服務,可以做但成本很高。
下面展示一個優秀的方案,其中實現了Serializable介面的類可以實現兩個私有方法:writeObject和readObject,以影響和控制序列化和反序列化的過程。我們把Person類稍作修改,看看如何控制序列化和反序列化,程式碼如下:
1 public class Person implements Serializable { 2 3 private static final long serialVersionUID = 9146176880143026279L; 4 5 private String name; 6 7 private transient Salary salary; 8 9 public Person(String _name, Salary _salary) { 10 this.name = _name; 11 this.salary = _salary; 12 } 13 //序列化委託方法 14 private void writeObject(ObjectOutputStream oos) throws IOException { 15 oos.defaultWriteObject(); 16 oos.writeInt(salary.getBasePay()); 17 } 18 //反序列化委託方法 19 private void readObject(ObjectInputStream input)throws ClassNotFoundException, IOException { 20 input.defaultReadObject(); 21 salary = new Salary(input.readInt(), 0); 22 } 23 }
其它程式碼不做任何改動,執行之後結果為:姓名: 張三 基本工資: 1000 績效工資: 0
在Person類中增加了writeObject和readObject兩個方法,並且訪問許可權都是私有級別,為什麼會改變程式的執行結果呢?其實這裡用了序列化的獨有機制:序列化回撥。Java呼叫ObjectOutputStream類把一個物件轉換成資料流時,會通過反射(Refection)檢查被序列化的類是否有writeObject方法,並且檢查其是否符合私有,無返回值的特性,若有,則會委託該方法進行物件序列化,若沒有,則由ObjectOutputStream按照預設規則繼續序列化。同樣,在從流資料恢復成例項物件時,也會檢查是否有一個私有的readObject方法,如果有,則會通過該方法讀取屬性值,此處有幾個關鍵點需要說明:
- oos.defaultWriteObject():告知JVM按照預設的規則寫入物件,慣例的寫法是寫在第一行。
- input.defaultReadObject():告知JVM按照預設規則讀入物件,慣例的寫法是寫在第一行。
- oos.writeXX和input.readXX
分別是寫入和讀出相應的值,類似一個佇列,先進先出,如果此處有複雜的資料邏輯,建議按封裝Collection物件處理。大家可能注意到上面的方式也是Person失去了分散式部署的能了,確實是,但是HR系統的難點和重點是薪水的計算,特別是績效工資,它所依賴的引數很複雜(僅從數量上說就有上百甚至上千種),計算公式也不簡單(一般是引入指令碼語言,個性化公式定製)而相對來說Person類基本上都是靜態屬性,計算的可能性不大,所以即使為效能考慮,Person類為分散式部署的意義也不大。
建議15:break萬萬不可忘
我們經常會寫一些轉換類,比如貨幣轉換,日期轉換,編碼轉換等,在金融領域裡用到的最多的要數中文數字轉換了,比如把"1"轉換為"壹" ,不過開源工具是不會提供此工具類的,因為它太貼近中國文化了,需要自己編寫:
1 public class Client15 { 2 public static void main(String[] args) { 3 System.out.println(toChineseNuberCase(0)); 4 } 5 6 public static String toChineseNuberCase(int n) { 7 String chineseNumber = ""; 8 switch (n) { 9 case 0: 10 chineseNumber = "零"; 11 case 1: 12 chineseNumber = "壹"; 13 case 2: 14 chineseNumber = "貳"; 15 case 3: 16 chineseNumber = "叄"; 17 case 4: 18 chineseNumber = "肆"; 19 case 5: 20 chineseNumber = "伍"; 21 case 6: 22 chineseNumber = "陸"; 23 case 7: 24 chineseNumber = "柒"; 25 case 8: 26 chineseNumber = "捌"; 27 case 9: 28 chineseNumber = "玖"; 29 } 30 return chineseNumber; 31 } 32 }
這是一個簡單的程式碼,但執行結果卻是"玖",這個很簡單,可能大家在剛接觸語法時都學過,但雖簡單,如果程式設計師漏寫了,簡單的問題會造成很大的後果,甚至經濟上的損失。所以在用switch語句上記得加上break,養成良好的習慣。對於此類問題,除了平常小心之外,可以使用單元測試來避免,但大家都曉得,專案緊的時候,可能但單元測試都覆蓋不了。所以對於此類問題,一個最簡單的辦法就是:修改IDE的警告級別,例如在Eclipse中,可以依次點選PerFormaces-->Java-->Compiler-->Errors/Warings-->Potential Programming problems,然後修改'switch' case fall-through為Errors級別,如果你膽敢不在case語句中加入break,那Eclipse直接就報個紅叉給你看,這樣可以避免該問題的發生了。但還是囉嗦一句,養成良好習慣更重要!