CodeGym /課程 /JAVA 25 SELF /序列化資料的遷移與版本控管

序列化資料的遷移與版本控管

JAVA 25 SELF
等級 45 , 課堂 3
開放

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. 資料遷移策略

其中一個方便的方法是所謂的「延遲」遷移。意思是你不會一次轉換所有舊資料,而是在物件第一次被讀取時逐步進行。

例如,如果你新增了欄位,反序列化舊物件時,它會自動拿到預設值—0nullfalse,取決於型別。若刪除了欄位,反序列化會直接忽略它。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 已轉為字串。
  • 新欄位 emailnull
  • 不會出現 InvalidClassException,因為 serialVersionUID 相符,且我們手動處理了型別不一致。

如果不處理不匹配會怎樣?

若只是改變欄位型別而未實作 readObject,在反序列化時會得到錯誤:

java.io.InvalidClassException: User; incompatible types for field age

6. 序列化資料遷移中的常見錯誤

錯誤 1:未宣告 serialVersionUID—只要稍微變動類別就會得到 InvalidClassException,即使只是些微變更。

錯誤 2:變更了欄位型別卻未在 readObject 中處理—會得到型別不相容的錯誤。

錯誤 3:刪除了欄位,但舊資料仍包含它—Java 會直接忽略該欄位,但若它很關鍵,資料就會遺失。

錯誤 4:嘗試手動遷移所有資料而未經充分測試—可能遺失部分資訊或得到不一致的物件。

錯誤 5:沒有更新所有序列化/反序列化的位置—部分程式碼使用新版本,部分使用舊版本,導致「幽靈」錯誤。

錯誤 6:沒有為大量資料設計遷移策略—在「延遲」遷移下,使用者可能在首次存取舊資料時遇到意外錯誤。

錯誤 7:遷移前未做備份—在更新前務必為序列化資料做備份!

1
任務
JAVA 25 SELF, 等級 45, 課堂 3
上鎖
動物記錄系統:在不丟失舊資料的情況下新增顏色欄位
動物記錄系統:在不丟失舊資料的情況下新增顏色欄位
1
任務
JAVA 25 SELF, 等級 45, 課堂 3
上鎖
使用者個人檔案更新:智慧化的年齡遷移
使用者個人檔案更新:智慧化的年齡遷移
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION