關於 Java 物件序列化您不知道的 5 件事

developerworks發表於2015-06-16

數年前,當和一個軟體團隊一起用 Java 語言編寫一個應用程式時,我體會到比一般程式設計師多知道一點關於 Java 物件序列化的知識所帶來的好處。

關於本系列

您覺得自己懂 Java 程式設計?事實上,大多數程式設計師對於 Java 平臺都是淺嘗則止,只學習了足以完成手頭上任務的知識而已。在本 系列 中,Ted Neward 深入挖掘 Java 平臺的核心功能,揭示一些鮮為人知的事實,幫助您解決最棘手的程式設計挑戰。

大約一年前,一個負責管理應用程式所有使用者設定的開發人員,決定將使用者設定儲存在一個 Hashtable中,然後將這個 Hashtable 序列化到磁碟,以便持久化。當使用者更改設定時,便重新將 Hashtable 寫到磁碟。

這是一個優雅的、開放式的設定系統,但是,當團隊決定從 Hashtable 遷移到 Java Collections 庫中的HashMap 時,這個系統便面臨崩潰。

Hashtable 和 HashMap 在磁碟上的格式是不相同、不相容的。除非對每個持久化的使用者設定執行某種型別的資料轉換實用程式(極其龐大的任務),否則以後似乎只能一直用Hashtable 作為應用程式的儲存格式。

團隊感到陷入僵局,但這只是因為他們不知道關於 Java 序列化的一個重要事實:Java 序列化允許隨著時間的推移而改變型別。當我向他們展示如何自動進行序列化替換後,他們終於按計劃完成了向 HashMap 的轉變。

本文是本系列的第一篇文章,這個系列專門揭示關於 Java 平臺的一些有用的小知識 — 這些小知識不易理解,但對於解決 Java 程式設計挑戰遲早有用。

將 Java 物件序列化 API 作為開端是一個不錯的選擇,因為它從一開始就存在於 JDK 1.1 中。本文介紹的關於序列化的 5 件事情將說服您重新審視那些標準 Java API。

Java 序列化簡介

Java 物件序列化是 JDK 1.1 中引入的一組開創性特性之一,用於作為一種將 Java 物件的狀態轉換為位元組陣列,以便儲存或傳輸的機制,以後,仍可以將位元組陣列轉換回 Java 物件原有的狀態。

實際上,序列化的思想是 “凍結” 物件狀態,傳輸物件狀態(寫到磁碟、通過網路傳輸等等),然後 “解凍” 狀態,重新獲得可用的 Java 物件。所有這些事情的發生有點像是魔術,這要歸功於 ObjectInputStream/ObjectOutputStream 類、完全保真的後設資料以及程式設計師願意用Serializable 標識介面標記他們的類,從而 “參與” 這個過程。

清單 1 顯示一個實現 Serializable 的 Person 類。

清單 1. Serializable Person
package com.tedneward;

public class Person
    implements java.io.Serializable
{
    public Person(String fn, String ln, int a)
    {
        this.firstName = fn; this.lastName = ln; this.age = a;
    }

    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
    public Person getSpouse() { return spouse; }

    public void setFirstName(String value) { firstName = value; }
    public void setLastName(String value) { lastName = value; }
    public void setAge(int value) { age = value; }
    public void setSpouse(Person value) { spouse = value; }

    public String toString()
    {
        return "[Person: firstName=" + firstName + 
            " lastName=" + lastName +
            " age=" + age +
            " spouse=" + spouse.getFirstName() +
            "]";
    }    

    private String firstName;
    private String lastName;
    private int age;
    private Person spouse;

}

將 Person 序列化後,很容易將物件狀態寫到磁碟,然後重新讀出它,下面的 JUnit 4 單元測試對此做了演示。

清單 2. 對 Person 進行反序列化
public class SerTest
{
    @Test public void serializeToDisk()
    {
        try
        {
            com.tedneward.Person ted = new com.tedneward.Person("Ted", "Neward", 39);
            com.tedneward.Person charl = new com.tedneward.Person("Charlotte",
                "Neward", 38);

            ted.setSpouse(charl); charl.setSpouse(ted);

            FileOutputStream fos = new FileOutputStream("tempdata.ser");
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(ted);
            oos.close();
        }
        catch (Exception ex)
        {
            fail("Exception thrown during test: " + ex.toString());
        }

        try
        {
            FileInputStream fis = new FileInputStream("tempdata.ser");
            ObjectInputStream ois = new ObjectInputStream(fis);
            com.tedneward.Person ted = (com.tedneward.Person) ois.readObject();
            ois.close();

            assertEquals(ted.getFirstName(), "Ted");
            assertEquals(ted.getSpouse().getFirstName(), "Charlotte");

            // Clean up the file
            new File("tempdata.ser").delete();
        }
        catch (Exception ex)
        {
            fail("Exception thrown during test: " + ex.toString());
        }
    }
}

到現在為止,還沒有看到什麼新鮮的或令人興奮的事情,但是這是一個很好的出發點。我們將使用 Person 來發現您可能 知道的關於 Java 物件序列化 的 5 件事。

1. 序列化允許重構

序列化允許一定數量的類變種,甚至重構之後也是如此,ObjectInputStream 仍可以很好地將其讀出來。

Java Object Serialization 規範可以自動管理的關鍵任務是:

  • 將新欄位新增到類中
  • 將欄位從 static 改為非 static
  • 將欄位從 transient 改為非 transient

取決於所需的向後相容程度,轉換欄位形式(從非 static 轉換為 static 或從非 transient 轉換為 transient)或者刪除欄位需要額外的訊息傳遞。

重構序列化類

既然已經知道序列化允許重構,我們來看看當把新欄位新增到 Person 類中時,會發生什麼事情。

如清單 3 所示,PersonV2 在原先 Person 類的基礎上引入一個表示性別的新欄位。

清單 3. 將新欄位新增到序列化的 Person 中
enum Gender
{
    MALE, FEMALE
}

public class Person
    implements java.io.Serializable
{
    public Person(String fn, String ln, int a, Gender g)
    {
        this.firstName = fn; this.lastName = ln; this.age = a; this.gender = g;
    }

    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public Gender getGender() { return gender; }
    public int getAge() { return age; }
    public Person getSpouse() { return spouse; }

    public void setFirstName(String value) { firstName = value; }
    public void setLastName(String value) { lastName = value; }
    public void setGender(Gender value) { gender = value; }
    public void setAge(int value) { age = value; }
    public void setSpouse(Person value) { spouse = value; }

    public String toString()
    {
        return "[Person: firstName=" + firstName + 
            " lastName=" + lastName +
            " gender=" + gender +
            " age=" + age +
            " spouse=" + spouse.getFirstName() +
            "]";
    }    

    private String firstName;
    private String lastName;
    private int age;
    private Person spouse;
    private Gender gender;
}

序列化使用一個 hash,該 hash 是根據給定原始檔中幾乎所有東西 — 方法名稱、欄位名稱、欄位型別、訪問修改方法等 — 計算出來的,序列化將該 hash 值與序列化流中的 hash 值相比較。

為了使 Java 執行時相信兩種型別實際上是一樣的,第二版和隨後版本的 Person 必須與第一版有相同的序列化版本 hash(儲存為 private static final serialVersionUID 欄位)。因此,我們需要 serialVersionUID 欄位,它是通過對原始(或 V1)版本的 Person 類執行 JDK serialver命令計算出的。

一旦有了 Person 的 serialVersionUID,不僅可以從原始物件 Person 的序列化資料建立 PersonV2 物件(當出現新欄位時,新欄位被設為預設值,最常見的是“null”),還可以反過來做:即從 PersonV2 的資料通過反序列化得到 Person,這毫不奇怪。

2. 序列化並不安全

讓 Java 開發人員詫異並感到不快的是,序列化二進位制格式完全編寫在文件中,並且完全可逆。實際上,只需將二進位制序列化流的內容轉儲到控制檯,就足以看清類是什麼樣子,以及它包含什麼內容。

這對於安全性有著不良影響。例如,當通過 RMI 進行遠端方法呼叫時,通過連線傳送的物件中的任何 private 欄位幾乎都是以明文的方式出現在套接字流中,這顯然容易招致哪怕最簡單的安全問題。

幸運的是,序列化允許 “hook” 序列化過程,並在序列化之前和反序列化之後保護(或模糊化)欄位資料。可以通過在 Serializable 物件上提供一個 writeObject 方法來做到這一點。

模糊化序列化資料

假設 Person 類中的敏感資料是 age 欄位。畢竟,女士忌談年齡。 我們可以在序列化之前模糊化該資料,將數位迴圈左移一位,然後在反序列化之後復位。(您可以開發更安全的演算法,當前這個演算法只是作為一個例子。)

為了 “hook” 序列化過程,我們將在 Person 上實現一個 writeObject 方法;為了 “hook” 反序列化過程,我們將在同一個類上實現一個readObject 方法。重要的是這兩個方法的細節要正確 — 如果訪問修改方法、引數或名稱不同於清單 4 中的內容,那麼程式碼將不被察覺地失敗,Person 的 age 將暴露。

清單 4. 模糊化序列化資料
public class Person
    implements java.io.Serializable
{
    public Person(String fn, String ln, int a)
    {
        this.firstName = fn; this.lastName = ln; this.age = a;
    }

    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
    public Person getSpouse() { return spouse; }

    public void setFirstName(String value) { firstName = value; }
    public void setLastName(String value) { lastName = value; }
    public void setAge(int value) { age = value; }
    public void setSpouse(Person value) { spouse = value; }

    private void writeObject(java.io.ObjectOutputStream stream)
        throws java.io.IOException
    {
        // "Encrypt"/obscure the sensitive data
        age = age << 2;
        stream.defaultWriteObject();
    }

    private void readObject(java.io.ObjectInputStream stream)
        throws java.io.IOException, ClassNotFoundException
    {
        stream.defaultReadObject();

        // "Decrypt"/de-obscure the sensitive data
        age = age << 2;
    }

    public String toString()
    {
        return "[Person: firstName=" + firstName + 
            " lastName=" + lastName +
            " age=" + age +
            " spouse=" + (spouse!=null ? spouse.getFirstName() : "[null]") +
            "]";
    }      

    private String firstName;
    private String lastName;
    private int age;
    private Person spouse;
}

如果需要檢視被模糊化的資料,總是可以檢視序列化資料流/檔案。而且,由於該格式被完全文件化,即使不能訪問類本身,也仍可以讀取序列化流中的內容。

3. 序列化的資料可以被簽名和密封

上一個技巧假設您想模糊化序列化資料,而不是對其加密或者確保它不被修改。當然,通過使用 writeObject 和 readObject 可以實現密碼加密和簽名管理,但其實還有更好的方式。

如果需要對整個物件進行加密和簽名,最簡單的是將它放在一個 javax.crypto.SealedObject 和/或 java.security.SignedObject 包裝器中。兩者都是可序列化的,所以將物件包裝在 SealedObject 中可以圍繞原物件建立一種 “包裝盒”。必須有對稱金鑰才能解密,而且金鑰必須單獨管理。同樣,也可以將 SignedObject 用於資料驗證,並且對稱金鑰也必須單獨管理。

結合使用這兩種物件,便可以輕鬆地對序列化資料進行密封和簽名,而不必強調關於數字簽名驗證或加密的細節。很簡潔,是吧?

4. 序列化允許將代理放在流中

很多情況下,類中包含一個核心資料元素,通過它可以派生或找到類中的其他欄位。在此情況下,沒有必要序列化整個物件。可以將欄位標記為 transient,但是每當有方法訪問一個欄位時,類仍然必須顯式地產生程式碼來檢查它是否被初始化。

如果首要問題是序列化,那麼最好指定一個 flyweight 或代理放在流中。為原始 Person 提供一個 writeReplace 方法,可以序列化不同型別的物件來代替它。類似地,如果反序列化期間發現一個 readResolve 方法,那麼將呼叫該方法,將替代物件提供給呼叫者。

打包和解包代理

writeReplace 和 readResolve 方法使 Person 類可以將它的所有資料(或其中的核心資料)打包到一個 PersonProxy 中,將它放入到一個流中,然後在反序列化時再進行解包。

清單 5. 你完整了我,我代替了你
class PersonProxy
    implements java.io.Serializable
{
    public PersonProxy(Person orig)
    {
        data = orig.getFirstName() + "," + orig.getLastName() + "," + orig.getAge();
        if (orig.getSpouse() != null)
        {
            Person spouse = orig.getSpouse();
            data = data + "," + spouse.getFirstName() + "," + spouse.getLastName() + ","  
              + spouse.getAge();
        }
    }

    public String data;
    private Object readResolve()
        throws java.io.ObjectStreamException
    {
        String[] pieces = data.split(",");
        Person result = new Person(pieces[0], pieces[1], Integer.parseInt(pieces[2]));
        if (pieces.length > 3)
        {
            result.setSpouse(new Person(pieces[3], pieces[4], Integer.parseInt
              (pieces[5])));
            result.getSpouse().setSpouse(result);
        }
        return result;
    }
}

public class Person
    implements java.io.Serializable
{
    public Person(String fn, String ln, int a)
    {
        this.firstName = fn; this.lastName = ln; this.age = a;
    }

    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
    public Person getSpouse() { return spouse; }

    private Object writeReplace()
        throws java.io.ObjectStreamException
    {
        return new PersonProxy(this);
    }

    public void setFirstName(String value) { firstName = value; }
    public void setLastName(String value) { lastName = value; }
    public void setAge(int value) { age = value; }
    public void setSpouse(Person value) { spouse = value; }   

    public String toString()
    {
        return "[Person: firstName=" + firstName + 
            " lastName=" + lastName +
            " age=" + age +
            " spouse=" + spouse.getFirstName() +
            "]";
    }    

    private String firstName;
    private String lastName;
    private int age;
    private Person spouse;
}

注意,PersonProxy 必須跟蹤 Person 的所有資料。這通常意味著代理需要是 Person 的一個內部類,以便能訪問 private 欄位。有時候,代理還需要追蹤其他物件引用並手動序列化它們,例如 Person 的 spouse。

這種技巧是少數幾種不需要讀/寫平衡的技巧之一。例如,一個類被重構成另一種型別後的版本可以提供一個 readResolve 方法,以便靜默地將被序列化的物件轉換成新型別。類似地,它可以採用 writeReplace 方法將舊類序列化成新版本。

5. 信任,但要驗證

認為序列化流中的資料總是與最初寫到流中的資料一致,這沒有問題。但是,正如一位美國前總統所說的,“信任,但要驗證”。

對於序列化的物件,這意味著驗證欄位,以確保在反序列化之後它們仍具有正確的值,“以防萬一”。為此,可以實現 ObjectInputValidation介面,並覆蓋 validateObject() 方法。如果呼叫該方法時發現某處有錯誤,則丟擲一個 InvalidObjectException

結束語

Java 物件序列化比大多數 Java 開發人員想象的更靈活,這使我們有更多的機會解決棘手的情況。

幸運的是,像這樣的程式設計妙招在 JVM 中隨處可見。關鍵是要知道它們,在遇到難題的時候能用上它們。

5 件事 系列下期預告:Java Collections。在此之前,好好享受按自己的想法調整序列化吧!

相關文章