1. 介紹
自動序列化就像飛機上的自動駕駛:一切照著計畫進行時,它運作得很好。但一旦出現特殊情況,就會發現僅靠這個簡單機制已不夠。試想需要保存一個物件,但並非所有欄位都該被保存:有些資料是暫時性的,有些則過於敏感,不適合寫入檔案。或者反過來——保存時需要加入自己的資訊:例如版本或校驗和。有時在寫入或讀取資料之前,還需要先做檢查或轉換。更複雜的情況是:當類別結構隨時間變動時,仍需與舊版本保持相容。
在這些情境下就能體會到:僅靠預設序列化是不夠的。你得親自掌控流程。
序列化的特殊方法:writeObject 與 readObject
在 Java 中,有兩個特殊方法可讓你完全掌控物件的序列化與反序列化流程:
private void writeObject(ObjectOutputStream out) throws IOException
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException
重要!
- 方法必須是 private(不是 public、不是 protected、也不是 package-private)。
- 簽名必須與上面所示完全一致。
- 如果這些方法在你的類別中宣告了,JVM 將呼叫它們,而不是使用預設的序列化/反序列化。
運作原理?
當你呼叫 ObjectOutputStream.writeObject(obj) 時,JVM 會先在 obj 的類別中尋找方法 private void writeObject(ObjectOutputStream)。若存在,就會呼叫該方法。類似地,在反序列化時會呼叫 private void readObject(ObjectInputStream)。
若未宣告這些方法,將使用預設的序列化。
writeObject 與 readObject 的結構
方法簽名
private void writeObject(ObjectOutputStream out) throws IOException
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException
在這些方法內你必須呼叫:
- out.defaultWriteObject(); — 用於序列化父類別與目前類別的標準(非 transient)欄位。
- in.defaultReadObject(); — 用於反序列化標準欄位。
如果不呼叫這些方法,標準欄位將不會被序列化——反序列化後物件會是「空的」。這就像出門忘了把護照放進行李:人雖到,但無法證明身分。
2. 範例:在序列化時加入校驗和
來看一個實際例子。假設我們有一個使用者類別,希望在序列化時把校驗和附加到物件,方便在反序列化時驗證資料完整性。
import java.io.*;
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
// transient 欄位——不序列化它
private transient int checksum;
public User(String name, int age) {
this.name = name;
this.age = age;
this.checksum = calculateChecksum();
}
private int calculateChecksum() {
return (name != null ? name.hashCode() : 0) + age;
}
// 自訂序列化
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject(); // 保存標準欄位
int sum = calculateChecksum();
out.writeInt(sum); // 寫入校驗和
System.out.println("[LOG] 序列化 User: checksum=" + sum);
}
// 自訂反序列化
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject(); // 還原標準欄位
int sum = in.readInt(); // 讀取校驗和
int actual = calculateChecksum();
System.out.println("[LOG] 反序列化 User: checksum=" + sum + ", actual=" + actual);
if (sum != actual) {
throw new IOException("資料已損壞!校驗和不相符。");
}
this.checksum = actual;
}
@Override
public String toString() {
return "User{name='" + name + "', age=" + age + ", checksum=" + checksum + "}";
}
}
使用範例:
// 儲存物件
User user = new User("Alice", 42);
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("user.bin"))) {
out.writeObject(user);
}
// 載入物件
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("user.bin"))) {
User loaded = (User) in.readObject();
System.out.println("還原的物件:" + loaded);
}
發生了什麼?
- 序列化時會呼叫 writeObject,保存標準欄位加上校驗和。
- 反序列化時會呼叫 readObject,復原欄位並檢查校驗和。
- 主控台會出現日誌;若有任何異常,將拋出例外。
3. 從序列化中排除敏感資料
有時需要讓某些欄位不要被序列化(例如密碼)。可用關鍵字 transient(詳細內容會在下一講),或者在自行實作 writeObject 時手動略過該欄位。
範例:
public class Account implements Serializable {
private static final long serialVersionUID = 1L;
private String username;
private transient String password; // transient——不會被序列化
// 也可以這麼做:
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
// 不寫入 password!
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
// password 會保持為 null
}
}
注意:
如果只想序列化物件的一部分——就不要把多餘的欄位寫入串流。
4. 呼叫父類別方法:defaultWriteObject 與 defaultReadObject
在你的 writeObject 與 readObject 方法中,幾乎總是需要呼叫 defaultWriteObject() 與 defaultReadObject()。這有點像在加上自己的備註之前先按下「儲存草稿」。
這些方法負責將目前類別與其父類別中所有非 transient、非 static 的欄位依預設方式序列化。若不呼叫,這些欄位將不會被序列化,反序列化後會是空的。
錯誤示範:
private void writeObject(ObjectOutputStream out) throws IOException {
// out.defaultWriteObject(); // 忘了呼叫!
out.writeInt(123); // 自行寫入一些資料
}
在此情況下,標準欄位根本不會被保存!
5. 實作:為序列化流程加上日誌
讓我們在使用者類別中加入日誌,以便觀察何時發生序列化與反序列化。
public class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
private void writeObject(ObjectOutputStream out) throws IOException {
System.out.println("[LOG] 序列化 Person: " + name + ", 年齡 " + age);
out.defaultWriteObject();
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
System.out.println("[LOG] 反序列化 Person: " + name + ", 年齡 " + age);
}
}
使用方式:
Person p = new Person("Bob", 30);
// 存到檔案
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("person.bin"))) {
out.writeObject(p);
}
// 從檔案載入
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("person.bin"))) {
Person loaded = (Person) in.readObject();
}
結果:
你會在主控台看到物件正在序列化與反序列化的訊息。
6. 使用 writeObject/readObject 時的常見錯誤
錯誤 №1: 未呼叫 defaultWriteObject/defaultReadObject。 如果忘了呼叫這些方法,標準欄位不會被序列化,而物件在反序列化後會是空的或不正確。
錯誤 №2: 方法簽名不正確。 方法必須嚴格是 private void writeObject(ObjectOutputStream) 與 private void readObject(ObjectInputStream)。若將它們設為 public/protected 或更改參數——就不會被自動呼叫。
錯誤 №3: 方法中拋出了例外。 如果在 writeObject 或 readObject 中發生例外,序列化或反序列化會被中斷,物件將無法正確保存/載入。
錯誤 №4: 忘了序列化/反序列化父類別。 如果你的類別繼承自其他可序列化的類別,務必呼叫 defaultWriteObject/defaultReadObject,否則父類別的欄位不會被保存。
錯誤 №5: 序列化了敏感資料。 若忘了排除密碼或其他私密資料,它們會被寫入序列化檔案。請使用 transient 或在手動邏輯中略過它們。
GO TO FULL VERSION