1. 問題:當序列化的類別被修改時會發生什麼事?
在實際專案中,物件經常會被序列化—保存到檔案、資料庫或快取,之後再還原。但如果你修改了類別,新增或移除欄位,或更改型別,而生產環境中已經存在舊的序列化物件,會發生什麼事?
例如,生產環境裡有個保存了 User 類別物件的檔案。你釋出新版本應用程式,在 User 中新增一個欄位或改變既有欄位的型別。當程式嘗試反序列化舊資料時,通常會以 InvalidClassException 之類的錯誤收場,或造成資料遺失,因為物件結構已不再符合 JVM 的預期。
因此,事先思考類別與序列化資料之間的相容性非常重要。在生產環境中不能直接「清除」舊檔案—你必須要嘛維持向後相容,要嘛實作資料遷移,讓新版本類別能正確處理已保存的物件。
2. 使用 serialVersionUID 的解法
什麼是 serialVersionUID?
這是一個用來定義可序列化類別「版本」的特殊欄位。
private static final long serialVersionUID = 1L;
- 如果未宣告該欄位,Java 會根據類別結構自動計算它。
- 在反序列化時,會比較類別中的 serialVersionUID 與序列化資料中的值。
- 若不相符—會拋出 InvalidClassException。
自動生成與手動控管
自動:若未明確指定,編譯器會根據類別結構(名稱、欄位、方法等)自行計算。
手動控管:建議在可序列化的類別中一律明確宣告 serialVersionUID,以便掌控相容性。
範例:
public class User implements Serializable {
private static final long serialVersionUID = 1L;
// ...
}
什麼時候要變更,什麼時候保持不變?
- 保持不變:如果變更不破壞相容性(例如新增可用預設值初始化的新欄位)。
- 應變更:如果刪除了欄位、改變欄位型別、改變類別繼承階層,或做了其他不相容的變更。
規則:
- 若希望新版本類別能讀取舊的序列化物件—不要變更 serialVersionUID。
- 若不相容性很嚴重(寧願得到錯誤,也不要取得「歪掉」的資料)—就提高 serialVersionUID。
3. 資料遷移策略
其中一個方便的方法是所謂的「延遲」遷移。意思是你不會一次轉換所有舊資料,而是在物件第一次被讀取時逐步進行。
例如,如果你新增了欄位,反序列化舊物件時,它會自動拿到預設值—0、null 或 false,取決於型別。若刪除了欄位,反序列化會直接忽略它。JVM 會依名稱與型別對應欄位,因此許多變更可以「自動通過」。
較棘手的是變更欄位型別,例如當它先前是 int,而後改成 String。標準反序列化已無法處理。解法—實作自訂的 readObject 方法,手動處理轉換:
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
ObjectInputStream.GetField fields = in.readFields();
// 舊欄位: int age
int age = fields.get("age", -1);
// 新欄位: String ageStr
this.ageStr = String.valueOf(age);
}
如此一來,舊物件會在第一次讀取時正確地適配到新版本類別。
「就地轉換」(in-place conversion)模式
這個方法不同於延遲遷移,它會一次轉換所有資料。概念很簡單:你遍歷檔案或資料庫中的每個序列化物件—以舊版類別讀取它,建立新版物件,然後以更新後的格式寫回。
當無法依賴「延遲」遷移時,這種方法就很實用。例如資料量很大,或者物件很少被讀取,而你需要所有物件都預先準備好以供新版本應用程式使用。實務上常透過額外的腳本或工具來完成。例如流程可能如下:
// 簡單的 in-place 轉換範例
List<File> files = getSerializedFiles(); // 含有舊物件的檔案清單
for (File file : files) {
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file))) {
OldUser oldUser = (OldUser) ois.readObject(); // 讀取舊物件
NewUser newUser = new NewUser(oldUser); // 根據舊物件建立新物件
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file))) {
oos.writeObject(newUser); // 以新版本覆寫檔案
}
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
}
如此一來,所有物件會立刻被轉換成新版本,並且在生產環境中可安全使用。
4. 處理舊版本:進階技巧
ObjectInputStream.readClassDescriptor() 與 readFields()
- readClassDescriptor() — 允許攔截讀取類別中繼資料的流程,必要時可以「欺騙」序列化機制。
- readFields() — 允許依名稱讀取欄位,即使類別結構已發生變化。
範例:
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
ObjectInputStream.GetField fields = in.readFields();
String name = (String) fields.get("name", "unknown");
int age = fields.defaulted("age") ? 0 : fields.get("age", 0);
// ... 初始化新欄位
}
5. 實作:兩個版本的類別、序列化與遷移
步驟 1:舊版類別
// OldUser.java
import java.io.Serializable;
public class OldUser implements Serializable {
private static final long serialVersionUID = 1L;
public String name;
public int age;
public OldUser(String name, int age) {
this.name = name;
this.age = age;
}
}
步驟 2:序列化舊版物件
OldUser user = new OldUser("Vasya", 30);
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("user.dat"))) {
out.writeObject(user);
}
步驟 3:新版類別(新增欄位 email,變更了 age 的型別)
// User.java
import java.io.*;
public class User implements Serializable {
private static final long serialVersionUID = 1L;
public String name;
public String age; // 型別已變更!
public String email; // 新欄位
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
ObjectInputStream.GetField fields = in.readFields();
this.name = (String) fields.get("name", "unknown");
// 將舊欄位 age(int)轉成字串
if (!fields.defaulted("age")) {
int oldAge = fields.get("age", 0);
this.age = String.valueOf(oldAge);
} else {
this.age = "unknown";
}
// 新欄位 email — 預設為 null
this.email = (String) fields.get("email", null);
}
}
步驟 4:用新類別反序列化舊物件
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("user.dat"))) {
User user = (User) in.readObject();
System.out.println(user.name + ", " + user.age + ", " + user.email);
}
結果:
- 舊欄位 age 已轉為字串。
- 新欄位 email — null。
- 不會出現 InvalidClassException,因為 serialVersionUID 相符,且我們手動處理了型別不一致。
如果不處理不匹配會怎樣?
若只是改變欄位型別而未實作 readObject,在反序列化時會得到錯誤:
java.io.InvalidClassException: User; incompatible types for field age
6. 序列化資料遷移中的常見錯誤
錯誤 1:未宣告 serialVersionUID—只要稍微變動類別就會得到 InvalidClassException,即使只是些微變更。
錯誤 2:變更了欄位型別卻未在 readObject 中處理—會得到型別不相容的錯誤。
錯誤 3:刪除了欄位,但舊資料仍包含它—Java 會直接忽略該欄位,但若它很關鍵,資料就會遺失。
錯誤 4:嘗試手動遷移所有資料而未經充分測試—可能遺失部分資訊或得到不一致的物件。
錯誤 5:沒有更新所有序列化/反序列化的位置—部分程式碼使用新版本,部分使用舊版本,導致「幽靈」錯誤。
錯誤 6:沒有為大量資料設計遷移策略—在「延遲」遷移下,使用者可能在首次存取舊資料時遇到意外錯誤。
錯誤 7:遷移前未做備份—在更新前務必為序列化資料做備份!
GO TO FULL VERSION