具有不可序列化部分的 Java 序列化
1. 簡介
在本教程中,我們將探討如何使用 Java 序列化來處理非序列化設計的類型。我們將介紹幾種不同的技術來處理這個問題,並了解每種技術的優點。
2.什麼是Java序列化?
Java 序列化是 Java 內建的機制,允許我們將物件序列化。我們可以將物件轉換為位元組流,然後再將這些位元組轉換回原始物件。這包含完整的類型信息,因此即使我們不在同一個 JVM 中,我們也能獲得正確的原始類型。我們只需要在類別路徑上找到正確的類別定義。
我們可以使用java.io.ObjectOutputStream
建構函式序列化物件。它包裝了另一個OutputStream
,允許我們將任何 Java 類型寫入其中:
ObjectOutputStream oos = new ObjectOutputStream(outputStream);
oos.writeObject(user);
在另一個方向上,我們使用java.io.ObjectInputStream
來讀取我們先前寫入的物件:
ObjectInputStream ois = new ObjectInputStream(bais);
Object readObject = ois.readObject();
User readUser = (User) readObject;
問題在於,這僅適用於實作了java.io.Serializable
的類型,並且要求這些物件的所有欄位也實作java.io.Serializable
。每當您嘗試寫入不可序列化的物件時,JVM 都會拋出NotSerializableException
異常。如果我們不小心,這會嚴重限制我們使用它的情況。不過,我們可以透過一些方法來克服這個限制。
3. 瞬態場
解決這個問題的一種方法是使用transient
字段。瞬態欄位是不構成物件持久狀態一部分的欄位。這意味著寫出物件並不會序列化這些欄位。因此,它們不需要實作Serializable
,系統仍然能夠序列化父物件。
我們可以將此應用於從物件中的其他狀態取得值的欄位。例如,為了快取一個昂貴的計算:
class User implements Serializable {
private String name;
private String profilePath;
private transient Path profile;
public User(String name, String profilePath) {
this.name = name;
this.profilePath = profilePath;
}
public Path getProfile() {
if (this.profile == null) {
this.profile = FileSystems.getDefault().getPath(profilePath);
}
return this.profile;
}
}
在本例中, profile
欄位表示實際個人資料圖片在檔案系統中的位置。我們在第一次需要時計算它,並從那時起儲存它。但是由於Path
實例不可序列化,因此如果我們序列化User
實例,則需要自己處理這個問題。
我們可以透過將欄位標記為transient.
這表示在寫出物件時,序列化會跳過該欄位。這也意味著,當我們反序列化物件時,該欄位將保留其預設值。對於物件字段,預設值為null
;對於原始字段,預設值為合適的預設值:
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(user);
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bais);
Object read = ois.readObject();
User readUser = (User) read;
// readUser.profile is always null at this point
這應該沒問題,因為根據定義, transient
欄位就是以這種方式使用的。在這種情況下,下次呼叫getProfile()
方法時,我們將自動再次填入該欄位。
3.1. 使用readResolve()
填充
雖然我們通常可以將transient
欄位保留其預設值,但在某些情況下,我們需要明確地填充它們。 Java 提供了readResolve()
方法來處理這種情況。
如果我們的物件定義了readResolve()
方法,它將在實例反序列化後立即被呼叫。此方法的回傳值就是反序列化後回傳的物件。這讓我們可以執行任何正確初始化物件所需的操作,例如填入標記為transient
欄位:
class User implements Serializable {
private String name;
private String profilePath;
private transient Path profile;
public User(String name, String profilePath) {
this.name = name;
this.profilePath = profilePath;
this.profile = FileSystems.getDefault().getPath(this.profilePath);
}
public Object readResolve() {
this.profile = FileSystems.getDefault().getPath(this.profilePath);
return this;
}
}
這裡我們在建構函式中填充了transient
字段,以確保始終為其提供值。然而,由於它是transient
,因此在反序列化後它將被取消填充。我們使用readResolve()
方法在反序列化物件後正確地填充它。
如果我們不想修改現有對象,我們可以從readResolve()
方法傳回不同的實例:
public Object readResolve() {
return new User(this.name, this.profilePath);
}
這裡唯一的要求是傳回的物件必須與預期類型相容。如果不相容,則會拋出ClassCastException
異常。
4. 使用readObject()
和writeObject()
進行自訂序列化
有時我們可能需要完全序列化對象,即使並非所有欄位都可序列化。我們不能總是依賴將欄位標記為瞬態,並希望產生的物件在沒有值的情況下也能正常運作。例如,如果我們的User
類別沒有將設定檔路徑儲存為單獨的字串:
class User implements Serializable {
private String name;
private Path profile;
public User(String name, String profilePath) {
this.name = name;
this.profile = FileSystems.getDefault().getPath(profilePath);
}
}
Java 為我們提供了使用writeObject()
和readObject()
方法完全控制序列化的能力。
透過實作writeObject()
方法,我們可以精確地序列化物件。 ObjectOutputStream 在序列ObjectOutputStream
物件以寫入其狀態時會自動呼叫該方法:
private void writeObject(ObjectOutputStream out) throws IOException {
out.writeObject(name);
out.writeObject(profile.toString());
}
這裡我們按原樣編寫name
字段,但我們正在寫出可以安全序列化的profile
字段的自訂形式。
與此相反的是readObject()
方法。此方法負責從提供的ObjectInputStream
中反序列化物件的狀態:
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
String nameTemp = (String) in.readObject();
String profilePathTemp = (String) in.readObject();
this.name = nameTemp;
this.profile = FileSystems.getDefault().getPath(profilePathTemp);
}
為了確保正確工作,這必須與writeObject()
完全相反。這裡我們直接讀取name
字段,但我們讀取的是為profile
字段寫入的String
,並再次使用它來建構Path
物件。
4.1. 包裝類
在某些情況下,我們需要序列化一個不可序列化且我們無法控制其原始碼的類別。例如,來自我們正在使用的依賴項的類別。如果我們無法更改類別本身,就無法使用上述任何技術對其進行序列化。
在這種情況下,我們可以選擇編寫一個我們能夠控制的新類別。這個類別可以作為我們想要序列化的類別的包裝器。因為這個新類別在我們的控制之下,所以我們可以隨意修改它-例如,透過寫writeObject()
和readObject()
方法:
static class UserWrapper implements Serializable {
private User user;
public UserWrapper(User user) {
this.user = user;
}
private void writeObject(ObjectOutputStream out) throws IOException {
out.writeObject(user.name);
out.writeObject(user.profile.toString());
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
String nameTemp = (String) in.readObject();
String profilePathTemp = (String) in.readObject();
this.user = new User(nameTemp, profilePathTemp);
}
}
在這種情況下,我們根本沒有改變User
類別。相反,我們編寫了一個純粹用於序列化的新UserWrapper
類,並且加入了與之前相同的writeObject()
和readObject()
方法。
我們仍然無法直接序列化User
對象,但現在我們可以序列化UserWrapper
物件:
ObjectOutputStream oos = new ObjectOutputStream(outputStream);
oos.writeObject(new UserWrapper(user));
相反,當我們讀取時,我們需要知道讀取包裝類,然後從中提取內部類:
ObjectInputStream ois = new ObjectInputStream(bais);
Object read = ois.readObject();
UserWrapper wrapper = (UserWrapper) read;
User readUser = wrapper.user;
這裡我們回到了原始的User
對象,儘管無法直接序列化它。
5. 總結
在本文中,我們深入探討了 Java 序列化,並探討了幾種對 JVM 認為不可序列化的物件進行序列化的方法。下次需要使用 Java 序列化時,不妨試試這些方法。
與往常一樣,本文中的所有範例都可以在 GitHub 上找到。