Java深度歷險(十)——Java物件序列化與RMI
對於一個存在於Java虛擬機器中的物件來說,其內部的狀態只保持在記憶體中。JVM停止之後,這些狀態就丟失了。在很多情況下,物件的內部狀態是需要被持久化下來的。提到持久化,最直接的做法是儲存到檔案系統或是資料庫之中。這種做法一般涉及到自定義儲存格式以及繁瑣的資料轉換。物件關係對映(Object-relational mapping)是一種典型的用關係資料庫來持久化物件的方式,也存在很多直接儲存物件的物件資料庫。物件序列化機制(object serialization)是Java語言內建的一種物件持久化方式,可以很容易的在JVM中的活動物件和位元組陣列(流)之間進行轉換。除了可以很簡單的實現持久化之外,序列化機制的另外一個重要用途是在遠端方法呼叫中,用來對開發人員遮蔽底層實現細節。
基本的物件序列化
由於Java提供了良好的預設支援,實現基本的物件序列化是件比較簡單的事。待序列化的Java類只需要實現Serializable介面即可。Serializable僅是一個標記介面,並不包含任何需要實現的具體方法。實現該介面只是為了宣告該Java類的物件是可以被序列化的。實際的序列化和反序列化工作是通過ObjectOuputStream和ObjectInputStream來完成的。ObjectOutputStream的writeObject方法可以把一個Java物件寫入到流中,ObjectInputStream的readObject方法可以從流中讀取一個Java物件。在寫入和讀取的時候,雖然用的引數或返回值是單個物件,但實際上操縱的是一個物件圖,包括該物件所引用的其它物件,以及這些物件所引用的另外的物件。Java會自動幫你遍歷物件圖並逐個序列化。除了物件之外,Java中的基本型別和陣列也是可以通過 ObjectOutputStream和ObjectInputStream來序列化的。
try { User user = new User("Alex", "Cheng"); ObjectOutputStream output = new ObjectOutputStream(new FileOutputStream("user.bin")); output.writeObject(user); output.close(); } catch (IOException e) { e.printStackTrace(); } try { ObjectInputStream input = new ObjectInputStream(new FileInputStream("user.bin")); User user = (User) input.readObject(); System.out.println(user); } catch (Exception e) { e.printStackTrace(); }
上面的程式碼給出了典型的把Java物件序列化之後儲存到磁碟上,以及從磁碟上讀取的基本方式。 User類只是聲明瞭實現Serializable介面。
在預設的序列化實現中,Java物件中的非靜態和非瞬時域都會被包括進來,而與域的可見性宣告沒有關係。這可能會導致某些不應該出現的域被包含在序列化之後的位元組陣列中,比如密碼等隱私資訊。由於Java物件序列化之後的格式是固定的,其它人可以很容易的從中分析出其中的各種資訊。對於這種情況,一種解決辦法是把域宣告為瞬時的,即使用transient關鍵詞。另外一種做法是新增一個serialPersistentFields? 域來宣告序列化時要包含的域。從這裡可以看到在Java序列化機制中的這種僅在書面層次上定義的契約。宣告序列化的域必須使用固定的名稱和型別。在後面還可以看到其它類似這樣的契約。雖然Serializable只是一個標記介面,但它其實是包含有不少隱含的要求。下面的程式碼給出了 serialPersistentFields的宣告示例,即只有firstName這個域是要被序列化的。
private static final ObjectStreamField[] serialPersistentFields = {
new ObjectStreamField("firstName", String.class)
};
自定義物件序列化
基本的物件序列化機制讓開發人員可以在包含哪些域上進行定製。如果想對序列化的過程進行更加細粒度的控制,就需要在類中新增writeObject和對應的 readObject方法。這兩個方法屬於前面提到的序列化機制的隱含契約的一部分。在通過ObjectOutputStream的 writeObject方法寫入物件的時候,如果這個物件的類中定義了writeObject方法,就會呼叫該方法,並把當前 ObjectOutputStream物件作為引數傳遞進去。writeObject方法中一般會包含自定義的序列化邏輯,比如在寫入之前修改域的值,或是寫入額外的資料等。對於writeObject中新增的邏輯,在對應的readObject中都需要反轉過來,與之對應。
在新增自己的邏輯之前,推薦的做法是先呼叫Java的預設實現。在writeObject方法中通過ObjectOutputStream的defaultWriteObject來完成,在readObject方法則通過ObjectInputStream的defaultReadObject來實現。下面的程式碼在物件的序列化流中寫入了一個額外的字串。private void writeObject(ObjectOutputStream output) throws IOException {
output.defaultWriteObject();
output.writeUTF("Hello World");
}
private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException {
input.defaultReadObject();
String value = input.readUTF();
System.out.println(value);
}
序列化時的物件替換
在有些情況下,可能會希望在序列化的時候使用另外一個物件來代替當前物件。其中的動機可能是當前物件中包含了一些不希望被序列化的域,比如這些域都是從另外一個域派生而來的;也可能是希望隱藏實際的類層次結構;還有可能是新增自定義的物件管理邏輯,如保證某個類在JVM中只有一個例項。相對於把無關的域都設成transient來說,使用物件替換是一個更好的選擇,提供了更多的靈活性。替換物件的作用類似於Java EE中會使用到的傳輸物件(Transfer Object)。
考慮下面的例子,一個訂單系統中需要把訂單的相關資訊序列化之後,通過網路來傳輸。訂單類Order引用了客戶類Customer。在預設序列化的情況下,Order類物件被序列化的時候,其引用的Customer類物件也會被序列化,這可能會造成使用者資訊的洩露。對於這種情況,可以建立一個另外的物件來在序列化的時候替換當前的Order類的物件,並把使用者資訊隱藏起來。
private static class OrderReplace implements Serializable {
private static final long serialVersionUID = 4654546423735192613L;
private String orderId;
public OrderReplace(Order order) {
this.orderId = order.getId();
}
private Object readResolve() throws ObjectStreamException {
//根據orderId查詢Order物件並返回
}
}
這個替換物件類OrderReplace只儲存了Order的ID。在Order類的writeReplace方法中返回了一個OrderReplace物件。這個物件會被作為替代寫入到流中。同樣的,需要在OrderReplace類中定義一個readResolve方法,用來在讀取的時候再轉換回 Order類物件。這樣對呼叫者來說,替換物件的存在就是透明的。
private Object writeReplace() throws ObjectStreamException {
return new OrderReplace(this);
}
序列化與物件建立
在通過ObjectInputStream的readObject方法讀取到一個物件之後,這個物件是一個新的例項,但是其構造方法是沒有被呼叫的,其中的域的初始化程式碼也沒有被執行。對於那些沒有被序列化的域,在新創建出來的物件中的值都是預設的。也就是說,這個物件從某種角度上來說是不完備的。這有可能會造成一些隱含的錯誤。呼叫者並不知道物件是通過一般的new操作符來建立的,還是通過反序列化所得到的。解決的辦法就是在類的readObject方法裡面,再執行所需的物件初始化邏輯。對於一般的Java類來說,構造方法中包含了初始化的邏輯。可以把這些邏輯提取到一個方法中,在readObject方法中呼叫此方法。
版本更新
把一個Java物件序列化之後,所得到的位元組陣列一般會儲存在磁碟或資料庫之中。在儲存完成之後,有可能原來的Java類有了更新,比如添加了額外的域。這個時候從相容性的角度出發,要求仍然能夠讀取舊版本的序列化資料。在讀取的過程中,當ObjectInputStream發現一個物件的定義的時候,會嘗試在當前JVM中查詢其Java類定義。這個查詢過程不能僅根據Java類的全名來判斷,因為當前JVM中可能存在名稱相同,但是含義完全不同的Java 類。這個對應關係是通過一個全域性惟一識別符號serialVersionUID來實現的。通過在實現了Serializable介面的類中定義該域,就聲明瞭該Java類的一個惟一的序列化版本號。JVM會比對從位元組陣列中得出的類的版本號,與JVM中查詢到的類的版本號是否一致,來決定兩個類是否是相容的。對於開發人員來說,需要記得的就是在實現了Serializable介面的類中定義這樣的一個域,並在版本更新過程中保持該值不變。當然,如果不希望維持這種向後相容性,換一個版本號即可。該域的值一般是綜合Java類的各個特性而計算出來的一個雜湊值,可以通過Java提供的serialver命令來生成。在Eclipse中,如果Java類實現了Serializable介面,Eclipse會提示並幫你生成這個serialVersionUID。
在類版本更新的過程中,某些操作會破壞向後相容性。如果希望維持這種向後相容性,就需要格外的注意。一般來說,在新的版本中新增東西不會產生什麼問題,而去掉一些域則是不行的。
序列化安全性
前面提到,Java物件序列化之後的內容格式是公開的。所以可以很容易的從中提取出各種資訊。從實現的角度來說,可以從不同的層次來加強序列化的安全性。
- 實現自己的writeObject和readObject方法,在呼叫defaultWriteObject之前,先對要序列化的域的值進行加密處理。
- 使用一個SignedObject或SealedObject來封裝當前物件,用SignedObject或SealedObject進行序列化。
- 在從流中進行反序列化的時候,可以通過ObjectInputStream的registerValidation方法新增ObjectInputValidation介面的實現,用來驗證反序列化之後得到的物件是否合法。
RMI
RMI(Remote Method Invocation)是Java中的遠端過程呼叫(Remote Procedure Call,RPC)實現,是一種分散式Java應用的實現方式。它的目的在於對開發人員遮蔽橫跨不同JVM和網路連線等細節,使得分佈在不同JVM上的物件像是存在於一個統一的JVM中一樣,可以很方便的互相通訊。之所以在介紹物件序列化之後來介紹RMI,主要是因為物件序列化機制使得RMI非常簡單。呼叫一個遠端伺服器上的方法並不是一件困難的事情。開發人員可以基於Apache MINA或是Netty這樣的框架來寫自己的網路伺服器,亦或是可以採用REST架構風格來編寫HTTP服務。但這些解決方案中,不可迴避的一個部分就是資料的編排和解排(marshal/unmarshal)。需要在Java物件和傳輸格式之間進行互相轉換,而且這一部分邏輯是開發人員無法迴避的。RMI的優勢在於依靠Java序列化機制,對開發人員遮蔽了資料編排和解排的細節,要做的事情非常少。JDK 5之後,RMI通過動態代理機制去掉了早期版本中需要通過工具進行程式碼生成的繁瑣方式,使用起來更加簡單。
RMI採用的是典型的客戶端-伺服器端架構。首先需要定義的是伺服器端的遠端介面,這一步是設計好伺服器端需要提供什麼樣的服務。對遠端介面的要求很簡單,只需要繼承自RMI中的Remote介面即可。Remote和Serializable一樣,也是標記介面。遠端介面中的方法需要丟擲RemoteException。定義好遠端介面之後,實現該介面即可。如下面的Calculator是一個簡單的遠端介面。
public interface Calculator extends Remote {
String calculate(String expr) throws RemoteException;
}
實現了遠端介面的類的例項稱為遠端物件。創建出遠端物件之後,需要把它註冊到一個登錄檔之中。這是為了客戶端能夠找到該遠端物件並呼叫。
public class CalculatorServer implements Calculator {
public String calculate(String expr) throws RemoteException {
return expr;
}
public void start() throws RemoteException, AlreadyBoundException {
Calculator stub = (Calculator) UnicastRemoteObject.exportObject(this, 0);
Registry registry = LocateRegistry.getRegistry();
registry.rebind("Calculator", stub);
}
}
CalculatorServer是遠端物件的Java類。在它的start方法中通過UnicastRemoteObject的exportObject把當前物件暴露出來,使得它可以接收來自客戶端的呼叫請求。再通過Registry的rebind方法進行註冊,使得客戶端可以查詢到。
客戶端的實現就是首先從登錄檔中查詢到遠端介面的實現物件,再呼叫相應的方法即可。實際的呼叫雖然是在伺服器端完成的,但是在客戶端看來,這個介面中的方法就好像是在當前JVM中一樣。這就是RMI的強大之處。
public class CalculatorClient {
public void calculate(String expr) {
try {
Registry registry = LocateRegistry.getRegistry("localhost");
Calculator calculator = (Calculator) registry.lookup("Calculator");
String result = calculator.calculate(expr);
System.out.println(result);
} catch (Exception e) {
e.printStackTrace();
}
}
}
在執行的時候,需要首先通過rmiregistry命令來啟動RMI中用到的登錄檔伺服器。
為了通過Java的序列化機制來進行傳輸,遠端介面中的方法的引數和返回值,要麼是Java的基本型別,要麼是遠端物件,要麼是實現了 Serializable介面的Java類。當客戶端通過RMI登錄檔找到一個遠端介面的時候,所得到的其實是遠端介面的一個動態代理物件。當客戶端呼叫其中的方法的時候,方法的引數物件會在序列化之後,傳輸到伺服器端。伺服器端接收到之後,進行反序列化得到引數物件。並使用這些引數物件,在伺服器端呼叫實際的方法。呼叫的返回值Java物件經過序列化之後,再發送回客戶端。客戶端再經過反序列化之後得到Java物件,返回給呼叫者。這中間的序列化過程對於使用者來說是透明的,由動態代理物件自動完成。除了序列化之外,RMI還使用了動態類載入技術。當需要進行反序列化的時候,如果該物件的類定義在當前JVM中沒有找到,RMI會嘗試從遠端下載所需的類檔案定義。可以在RMI程式啟動的時候,通過JVM引數java.rmi.server.codebase來指定動態下載Java類檔案的URL。