《CLR Via C#》读书笔记:24.运行时序列化

一、什么是运行时序列化

序列化的作用就是将对象图(特定时间点的对象连接图)转换为字节流,这样这些对象图就可以在文件系统/网络进行传输。

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

一般来说我们通过 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() 方法,该方法是在序列化时被调用,会传入他想要序列化的类型。