CodeGym /課程 /JAVA 25 SELF /序列化中的相容性與向後相容性(backward compatibility)

序列化中的相容性與向後相容性(backward compatibility)

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

1. 相容性問題

想像一下:你發布了應用程式的第一個版本,使用者開始儲存資料(例如使用者檔案或設定)。一個月後,你發現在類別 UserProfile 中少了欄位 email,於是把它加上。看起來一切都很順利……直到你嘗試載入舊檔。最好情況是新欄位為空,最糟則會拋出例外,讓使用者不悅。

序列化相容性是指程式能正確讀取由較舊版本的類別序列化的資料,反之亦然。在 Java(特別是透過 Serializable 進行的二進位序列化)中,這一點格外重要,因為 JVM 對類別結構的變更非常敏感。

常見會出問題的情境:

  • 你在類別中新增了欄位。
  • 你刪除了舊的欄位。
  • 你變更了欄位型別(例如,從 int 改為 String)。
  • 你重新命名了類別或把它移到其他套件。
  • 你更新了負責序列化物件的函式庫或框架。

在上述情況下,舊的序列化資料可能對新版程式來說變得「無法讀取」。

2. serialVersionUID:序列化類別的版本識別碼

在 Java 中,每個可序列化的類別(也就是實作介面 Serializable)都有一個唯一的版本識別碼——serialVersionUID。JVM 會使用這個欄位來檢查是否能用當前類別來反序列化物件。如果識別碼不一致——就會拋出 InvalidClassException

private static final long serialVersionUID = 1L;

如果你沒有顯式宣告這個欄位,Java 會根據類別的結構(欄位、方法、修飾子等)自動產生它。但只要你之後修改類別(即使很細微),自動產生的 serialVersionUID 也會改變,舊資料就會變得不相容。

檢查是怎麼運作的?

當物件被序列化時,serialVersionUID 的值會連同資料一起寫入串流;在反序列化時,JVM 會將這個識別碼與當前類別中宣告的值比對。如果相符——物件就能正常還原。但如果不同,處理會立刻以錯誤終止:JVM 認為類別已經有了重大變更,舊資料不再適用。

為什麼要顯式宣告 serialVersionUID?

如果由你自行設定 serialVersionUID,就能控制哪些變更被視為「可接受」。例如,你新增了一個欄位,但希望仍可載入舊物件?只要保持識別碼不變——反序列化就能順利進行。若仰賴自動生成,可能會被驚嚇:哪怕是極小的修改,都會導致舊的存檔無法開啟。

範例:

public class Person implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
    private int age;
    // ... getter 與 setter
}

現在你可以放心地加入新欄位(只要它們不是必填),舊物件的反序列化也不會壞掉。

3. 當類別發生變更時會發生什麼?

新增欄位

舊的序列化物件 → 含有額外欄位的新類別

  • 新欄位會得到預設值(null0false)。
  • 其餘部分會正確反序列化。

範例:

// 變更前:
public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
}

// 變更後:
public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
    private String email; // 新增欄位
}

結果: 舊物件可載入,email == null

刪除欄位

舊的序列化物件包含某欄位,但新類別已移除該欄位

  • 反序列化時會直接忽略這個欄位。
  • 重點——不要更改 serialVersionUID

變更欄位型別

例如,原本是 int age,後來改成 String age

  • 這屬於不相容的變更。嘗試反序列化時會出錯(通常是 InvalidClassExceptionClassCastException)。
  • 最好避免此類變更,或透過自訂序列化來維持相容性(見下文)。

重新命名類別或套件

這裡相當嚴格:如果你變更類別或套件名稱,反序列化就不會成功。序列化串流中保存了類別的完整名稱,而 JVM 期望看到完全相同的名稱。因此任何重新命名都被視為重大變更。若確實需要調整專案結構,就免不了進行手動資料移轉。

4. transient 與 static:哪些會被序列化,哪些不會?

  • static 欄位完全不會被序列化——它們屬於類別,而非物件。
  • transient 欄位表示這是暫時性資料,不應被序列化(例如快取、臨時權杖)。

範例:

public class Session implements Serializable {
    private static final long serialVersionUID = 1L;
    private String user;
    private transient String sessionToken; // 不會被序列化
}

在反序列化時,sessionToken 會是 null,就算在序列化前它有填值。

5. 自訂序列化:writeObject/readObject

若你需要更複雜的相容性邏輯(例如把舊欄位轉換成新欄位、處理已變更的型別),可以實作以下特殊方法:

private void writeObject(ObjectOutputStream out) throws IOException {
    out.defaultWriteObject();
    // 如有需要,可加入其他邏輯
}

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    in.defaultReadObject();
    // 其他邏輯,例如依據舊資料填入新欄位
}

演進範例:

public class User implements Serializable {
    private static final long serialVersionUID = 2L;
    private String name;
    private int age; // 以前是 String birthYear

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        // 若存在欄位 birthYear,將其轉換成 age
        // (若你將 birthYear 標記為 transient,可在此加入示例程式碼)
    }
}

6. XML 與 JSON 的相容性:文字格式的彈性

與二進位序列化不同,XML 與 JSON 對類別結構的變更通常寬容得多。

XML(JAXB)與 JSON(Jackson、Gson)

在處理 XML 或 JSON 時,反序列化會寬鬆許多。若資料中出現你類別裡沒有的欄位,會被忽略;而類別中的新欄位如果在原始資料裡沒有,通常會取得預設值——對物件是 null,對數值是 0。元素或鍵的順序不影響解析,因此即使重新排列標籤或鍵,仍能正確解析。

註解提供了完整的控制:你可以指定在檔案中使用的名稱、哪些欄位為必填、哪些可省略,甚至調整格式。例如,在 JAXB 中,類別 User 可以長這樣:

public class User {
    @XmlElement(required = true)
    private String name;

    @XmlElement
    private String email; // 新增欄位,非必填
}

對於使用 Jackson 或 Gson 的 JSON,大致如下:

public class User {
    @JsonProperty("name")
    private String name;

    @JsonProperty("email")
    private String email; // 新增欄位
}

好處是顯而易見:舊的 JSON 或 XML 檔可以輕鬆載入;新欄位會得到 null,而資料中多餘的欄位會被忽略。你可以放心調整類別結構,而不必擔心破壞舊的儲存資料。

什麼時候需要更嚴格的控制?

當你把某欄位設為必填時,控制尤為重要。如果舊資料沒有該欄位,反序列化將會失敗。型別變更也一樣:如果先前欄位是字串,而你改成數值,舊資料可能無法通過解析。因此在做這類變更之前,務必評估其對既有儲存的影響,並在必要時準備資料移轉或設定合適的預設值。

7. 確保相容性的策略

  • 明確宣告 serialVersionUID。這是控制二進位序列化相容性的主要手段。
  • 僅新增非必填欄位。 新欄位應為 null 或具有預設值。
  • 使用 transient 來標示暫時或不重要的資料。 這些欄位不會被序列化,也不會在類別演進時造成問題。
  • 記錄類別的變更。 在類別的註解中說明哪些欄位被新增/移除,以及自哪個版本開始。
  • 遇到複雜情況—— writeObject/readObject。可在「載入時」實作資料移轉。
  • 對關鍵資料使用結構定義(XML Schema、JSON Schema) 這有助於明確描述資料結構並在載入時驗證。

8. 實作示範:不相容與演進

演示 serialVersionUID 不一致時的錯誤

// 先用某個類別版本序列化物件
public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
}

// 接著把 serialVersionUID 改掉(例如改成 2L),重新編譯,並嘗試載入舊檔
public class User implements Serializable {
    private static final long serialVersionUID = 2L;
    private String name;
}

結果:

java.io.InvalidClassException: User; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 2

成功演進類別的範例

public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
    // 新增欄位
    private String email;
}

如果先序列化舊物件(沒有 email),之後新增欄位且不改 serialVersionUID,反序列化會成功,email 會是 null

9. 處理序列化相容性時的常見錯誤

錯誤一:未宣告 serialVersionUID。 如果沒有顯式宣告 serialVersionUID,JVM 會自動產生。即使是極小的類別變更(例如新增方法或變更欄位修飾子)都會導致 serialVersionUID 改變,進而無法反序列化舊資料。這是破壞向後相容性的典型方式。

錯誤二:變更欄位型別。 把欄位型別改掉(例如從 int 變成 String)——會得到例外或不正確的資料。這類變更需要特別謹慎,最好配合 writeObject/readObject 進行手動移轉。

錯誤三:刪除或重新命名類別/套件。 重新命名類別或變更套件會使舊物件無法反序列化。類別名稱與套件會被寫入序列化串流,而 JVM 無法將其對應起來。

錯誤四:濫用 transient。 如果把重要欄位(例如使用者 id)標記為 transient,它就不會被序列化,而在還原物件時該值會遺失。

錯誤五:不一致地變更集合。 新增新的集合欄位或改變集合型別(例如,從 List 變為 Set)——舊資料可能會反序列化不正確或拋出錯誤。

錯誤六:在 XML/JSON 中設定過於嚴格的限制。 如果在 XML/JSON 結構中將欄位標為必填(required = true),而舊資料沒有該欄位,載入就會失敗。請謹慎使用註解與結構定義!

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION