《CLR Via C#》讀書筆記:24.執行時序列化

myzony發表於2018-11-04

一、什麼是執行時序列化

序列化的作用就是將物件圖(特定時間點的物件連線圖)轉換為位元組流,這樣這些物件圖就可以在檔案系統/網路進行傳輸。

二、序列化/反序列化快速入門

一般來說我們通過 FCL 提供的 BinaryFormatter 物件就可以將一個物件序列化為位元組流進行儲存,或者通過該 Formatter 將一個位元組流反序列化為一個物件。

FCL 的序列化與反序列化

序列化操作:

public MemoryStream SerializeObj(object sourceObj)
{
    var memStream = new MemoryStream();
    var formatter = new BinaryFormatter();

    formatter.Serialize(memStream, sourceObj);

    return memStream;
}

反序列化操作:

public object DeserializeFromStream(MemoryStream stream)
{
    var formatter = new BinaryFormatter();
    stream.Position = 0;
    return formatter.Deserialize(stream);
}

反序列化通過 Formatter 的 Deserialize() 方法返回序列化好的物件圖的根物件的一個引用。

深拷貝

通過序列化與反序列化的特性,可以實現一個深拷貝的方法,使用者建立源物件的一個克隆體。

public object DeepClone(object originalObj)
{
    using (var memoryStream = new MemoryStream())
    {
        var formatter = new BinaryFormatter();
        formatter.Serialize(memoryStream, originalObj);

        // 表明物件是被克隆的,可以安全的訪問其他託管資源
        formatter.Context = new StreamingContext(StreamingContextStates.Clone);

        memoryStream.Position = 0;
        return formatter.Deserialize(memoryStream);
    }
}

另外一種技巧就是可以將多個物件圖序列化到一個流當中,即呼叫多次 Serialize() 方法將多個物件圖序列化到流當中。如果需要反序列化的時候,按照序列化時物件圖的序列化順序反向反序列化即可。

BinaryFormatter 在序列化的時候會將型別的全名與程式集定義寫入到流當中,這樣在反序列化的時候,格式化器會獲取這些資訊,並且通過 System.Reflection.Assembly.Load() 方法將程式集載入到當前的 AppDomain

在程式集載入完成之後,會在該程式集搜尋待反序列化的物件圖型別,找不到則會丟擲異常。

【注意】

某些應用程式通過 Assembly.LoadFrom() 來載入程式集,然後根據程式集中的型別來構造物件。序列化該物件是沒問題的,但是反序列化的時候格式化器使用的是 Assembly.Load() 方法來載入程式集,這樣的話就會導致無法正確載入物件。

這個時候,你可以實現一個與 System.ResolveEventHandler 簽名一樣的委託,並且在反序列化註冊到當前 AppDomainAssemblyResolve 事件。

這樣當程式集載入失敗的時候,你可以在該方法內部根據傳入的事件引數與程式集標識自己使用 Assembly.LoadFrom() 來構造一個 Assembly 物件。

記得在反序列化完成之後,馬上向事件登出這個方法,否則會造成記憶體洩漏。

三、使型別可序列化

在設計自定義型別時,你需要顯式地通過 Serializable 特性來宣告你的型別是可以被序列化的。如果沒有這麼做,在使用格式化器進行序列化的時候,則會丟擲異常。

[Serializable]
public class DIYClass
{
    public int x { get; set; }
    public int y { get; set; }
}

【注意】

正因為這樣,我們一般都會現將結果儲存到 MemoryStream 之中,當沒有丟擲異常之後再將這些資料寫入到檔案/網路。

Serializable 特性

Serializable 特性只能用於值型別、引用型別、列舉型別(預設)、委託型別(預設),而且是不可被子類繼承。

如果有一個 A 類與其派生類 B 類,那麼 A 類沒擁有 Serializable 特性,而子類擁有,一樣的是無法進行序列化操作。

而且序列化的時候,是將所有訪問級別的欄位成員都進行了序列化,包括 private 級別成員。

四、簡單控制序列化操作

禁止序列化某個欄位

可以通過 System.NonSerializedAttribute 特性來確保某個欄位在序列化時不被處理其值,例如下列程式碼:

[Serializable]
public class DIYClass
{
    public DIYClass()
    {
        x = 10;
        y = 100;
        z = 1000;
    }

    public int x { get; set; }
    public int y { get; set; }

    [NonSerialized]
    public int z;
}

在序列化之前,該自定義物件 z 欄位的值為 1000,在序列化時,檢測到了忽略特性,則不會寫入該欄位的值到流當中。並且在反序列化之後,z 的值為 0,而 x ,y 的值是 10 和 100。

序列化與反序列化的四個生命週期特性

通過 OnSerializingOnSerializedOnDeserializingOnDeserialized 這四個特性,我們可以在物件序列化與反序列化時進行一些自定義的控制。只需要將這四個特性分別加在四個方法上面即可,但是針對方法簽名必須返回值為 void,同時也需要用有一個 StreamingContext 引數。

而且一般建議將這四個方法標識為 private ,防止其他物件誤呼叫。

[Serializable]
public class DIYClass
{
    [OnDeserializing]
    private void OnDeserializing(StreamingContext context)
    {
        Console.WriteLine("反序列化的時候,會呼叫本方法.");
    }

    [OnDeserialized]
    private void OnDeserialized(StreamingContext context)
    {
        Console.WriteLine("反序列化完成的時候,會呼叫本方法.");
    }

    [OnSerializing]
    public void OnSerializing(StreamingContext context)
    {
        Console.WriteLine("序列化的時候,會呼叫本方法.");
    }

    [OnSerialized]
    public void OnSerialized(StreamingContext context)
    {
        Console.WriteLine("序列化完成的時候,會呼叫本方法.");
    }
}

【注意】

如果 A 型別有兩個版本,第 1 個版本有 5 個欄位,並被序列化儲存到了檔案當中。後面由於業務需要,針對於 A 型別增加了 2 個新的欄位,這個時候如果從檔案中讀取第 1 個版本的物件流資訊,就會丟擲異常。

我們可以通過 System.Runtime.Serialization.OptionalFieldAttribute 新增到我們新加的欄位之上,這樣的話在反序列化資料時就不會因為缺少欄位而丟擲異常。

五、格式化器的序列化原理

格式化器的核心就是 FCL 提供的 FormatterServices 的靜態工具類,下列步驟體現了序列化器如何結合 FormatterServices 工具類來進行序列化操作的。

  1. 格式化器呼叫 FormatterService.GetSerializableMembers() 方法獲得需要序列化的欄位構成的 MemberInfo 陣列。
  2. 格式化器呼叫 FormatterService.GetObjectData() 方法,通過之前獲取的欄位 MethodInfo 資訊來取得每個欄位儲存的值陣列。該陣列與欄位資訊陣列是並行的,下標一致。
  3. 格式化器寫入型別的程式集等資訊。
  4. 遍歷兩個陣列,寫入欄位資訊與其資料到流當中。

反序列化操作的步驟與上面相反。

  1. 首先從流頭部讀取程式集標識與型別資訊,如果當前 AppDomain 沒有載入該程式集會丟擲異常。如果型別的程式集已經載入,則通過 FormatterServices.GetTypeFromAssembly() 方法來構造一個 Type 物件。
  2. 格式化器呼叫 FormatterService.GetUninitializedObject() 方法為新物件分配記憶體,但是 不會呼叫物件的構造器
  3. 格式化器通過 FormatterService.GetSerializableMembers() 初始化一個 MemberInfo 陣列。
  4. 格式化器根據流中的資料建立一個 Object 陣列,該陣列就是欄位的資料。
  5. 格式化器通過 FormatterService.PopulateObjectMembers() 方法,傳入新分配的物件、欄位資訊陣列、欄位資料陣列進行物件初始化。

六、控制序列化/反序列化的資料

一般來說通過在第四節說的那些特性控制就已經滿足了大部分需求,但格式化器內部使用的是反射,反射效能開銷比較大,如果你想要針對序列化/反序列化進行完全的控制,那麼你可以實現 ISerializable 介面來進行控制。

該介面只提供了一個 GetObjectData() 方法,原型如下:

public interface ISerializable{
    void GetObjectData(SerializationInfo info,StreamingContext context);
}

【注意】

使用了 ISerializable 介面的代價就是其整合類都必須實現它,而且還要保證子類必須呼叫基類的 GetObjectData() 方法與其建構函式。一般來說密封類才使用 ISerializable ,其他的型別使用特性控制即可滿足。

另外為了防止其他的程式碼呼叫 GetObjectData() 方法,可以通過一下特性來防止誤操作:

[SecurityPermissionAttribute(SecurityAction.Demand,SerializationFormatter = true)]

如果格式化器檢測到了型別實現了該介面,則會忽略掉原有的特性,並且將欄位值傳入到 SerializationInfo 之中。

通過這個 Info 我們可以被序列化的型別,因為 Info 提供了 FullTypeNameAssemblyName,不過一般推薦使用該物件提供的 SetType(Type type) 方法來進行操作。

格式化器構造完成 Info 之後,則會呼叫 GetObjectData() 方法,這個時候將之前構造好的 Info 傳入,而該方法則決定需要用哪些資料來序列化物件。這個時候我們就可以通過 Info 的 AddValue() 方法來新增一些資訊用於反序列化時使用。

在反序列化的時候,需要型別提供一個特殊的建構函式,對於密封類來說,該建構函式推薦為 private ,而一般的型別推薦為 protected,這個特殊的建構函式方法簽名與 GetObjectData() 一樣。

因為在反序列化的時候,格式化器會呼叫這個特殊的建構函式。

以下程式碼就是一個簡單實踐:

public class DIYClass : ISerializable
{
    public int X { get; set; }
    public int Y { get; set; }

    public DIYClass() { }

    protected DIYClass(SerializationInfo info, StreamingContext context)
    {
        X = info.GetInt32("X");
        Y = 20;
    }

    public void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        info.AddValue("X", 10);
    }
}

該型別的物件在反序列化之後,X 的值為序列化之前的值,而 Y 的值始終都會為 20。

【注意】

如果你儲存的 X 值是 Int32 ,而在獲取的時候是通過 GetInt64() 進行獲取。那麼格式化器就會嘗試使用 System.Convert 提供的方法進行轉換,並且可以通過實現 IConvertible 介面來自定義自己的轉換。

不過只有在 Get 方法轉換失敗的情況下才會使用上述機制。

子類與基類的 ISerializable

如果某個子類整合了基類,那麼子類在其 GetObjectData() 與特殊構造器中都要呼叫父類的方法,這樣才能夠完成正確的序列化/反序列化操作。

如果基類沒有實現 ISerializable 介面與特殊的構造器,那麼子類就需要通過 FormatterService 來手動針對基類的欄位進行賦值。

七、流上下文

流上下文 StreamingContext 只有兩個屬性,第一個是狀態標識位,用於標識序列化/反序列化物件的來源與目的地。而第二個屬性就是一個 Object 引用,該引用則是一個附加的上下文資訊,由使用者進行提供。

八、型別序列化為不同的型別與物件反序列化為不同的物件

在某些時候可能需要更改序列化完成之後的物件型別,這個時候只需要物件在其實現 ISerializable 介面的 GetObjectData() 方法內部通過 SerializationInfoSetType() 方法變更了序列化的目標型別。

下面的程式碼演示瞭如何序列化一個單例物件:

[Serializable]
public sealed class Singleton : ISerializable
{
    private static readonly Singleton _instance = new Singleton();

    private Singleton() { }

    public static Singleton GetSingleton() { return _instance; }

    [SecurityPermissionAttribute(SecurityAction.Demand,SerializationFormatter =true)]
    void ISerializable.GetObjectData(SerializationInfo info, StreamingContext context)
    {
        info.SetType(typeof(SingletonHelper));
    }
}

這裡通過顯式實現介面的 GetObjectData() 方法來將序列化的目標型別設定為 SingletonHelper ,該型別的定義如下:

[Serializable]
public class SingletonHelper : IObjectReference
{
    public object GetRealObject(StreamingContext context)
    {
        return Singleton.GetSingleton();
    }
}

這裡因為 SingletonHelper 實現了 IObjectReference 介面,當格式化器嘗試進行反序列化的時候,由於在 GetObjectData() 欺騙了轉換器,因此反序列化的時候檢測到型別有實現該介面,所以會嘗試呼叫其 GetRealObject() 方法來進行反序列化操作。

而以上動作完成之後,SingletonHelper 會立即變為不可達物件,等待 GC 進行回收處理。

九、序列化代理

當某些時候需要對一個第三方庫物件進行序列化的時候,沒有其原始碼,但是想要進行序列化,則可以通過序列化代理來進行序列化操作。

要實現序列化代理,需要實現 ISerializationSurrogate 介面,該介面擁有兩個方法,其簽名分別如下:

void GetObjectData(Object obj,SerializationInfo info,StreamingContext context);
void SetObjectData(Object obj,SerializationInfo info,StreamingContext context,ISurrogateSelector selector);

GetObjectData() 方法會在物件序列化時進行呼叫,而 SetObjectData() 會在物件反序列化時呼叫。

比如說我們有一個需求是希望 DateTime 型別在序列化的時候通過 UTC 時間序列化到流中,而在反序列化時則更改為本地時間。

這個時候我們就可以自己實現一個序列化代理類 UTCToLocalTimeSerializationSurrogate

public sealed class UTCToLocalTimeSerializationSurrogate : ISerializationSurrogate
{
    public void GetObjectData(object obj, SerializationInfo info, StreamingContext context)
    {
        info.AddValue("Date", ((DateTime)obj).ToUniversalTime().ToString("u"));
    }

    public object SetObjectData(object obj, SerializationInfo info, StreamingContext context, ISurrogateSelector selector)
    {
        return DateTime.ParseExact(info.GetString("Date"), "u", null).ToLocalTime();
    }
}

並且在使用的時候,通過構造一個 SurrogateSelector 代理選擇器,傳入我們針對於 DateTime 型別的代理,並且將格式化器與代理選擇器相繫結。那麼在使用格式化器的時候,就會通過我們的代理類來處理 DateTime 型別物件的序列化/反序列化操作了。

static void Main(string[] args)
{
    using (var stream = new MemoryStream())
    {
        var formatter = new BinaryFormatter();

        // 建立一個代理選擇器
        var ss = new SurrogateSelector();

        // 告訴代理選擇器,針對於 DateTime 型別採用 UTCToLocal 代理類進行序列化/反序列化代理
        ss.AddSurrogate(typeof(DateTime), formatter.Context, new UTCToLocalTimeSerializationSurrogate());

        // 繫結代理選擇器
        formatter.SurrogateSelector = ss;

        formatter.Serialize(stream,DateTime.Now);
        stream.Position = 0;
        var oldValue = new StreamReader(stream).ReadToEnd();

        stream.Position = 0;
        var newValue = (DateTime)formatter.Deserialize(stream);

        Console.WriteLine(oldValue);
        Console.WriteLine(newValue);
    }

    Console.ReadLine();
}

而一個代理選擇器允許繫結多個代理類,選擇器內部維護一個雜湊表,通過 TypeStreamingContext 作為其鍵來進行搜尋,通過 StreamintContext 地不同可以方便地為 DateTime 型別繫結不同用途的代理類。

十、反序列化物件時重寫程式集/型別

通過繼承 SerializationBinder 抽象類,我們可以很方便地實現型別反序列化時轉化為不同的型別,該抽象類有一個 Type BindToType(String assemblyName,String typeName) 方法。

重寫該方法你就可以在物件反序列化時,通過傳入的兩個引數來構造自己需要返回的真實型別。第一個引數是程式集名稱,第二個引數是格式化器想要反序列化時轉換的型別。

編寫好 Binder 類重寫該方法之後,在格式化器的 Binder 屬性當中繫結你的 Binder 類即可。

【注意】

抽象類還有一個 BindToName() 方法,該方法是在序列化時被呼叫,會傳入他想要序列化的型別。

相關文章