一、什么是运行时序列化
序列化的作用就是将对象图(特定时间点的对象连接图)转换为字节流,这样这些对象图就可以在文件系统/网络进行传输。
二、序列化/反序列化快速入门
一般来说我们通过 FCL 提供的 BinaryFormatter
对象就可以将一个对象序列化为字节流进行存储,或者通过该 Formatter 将一个字节流反序列化为一个对象。
FCL 的序列化与反序列化
序列化操作:
|
|
反序列化操作:
|
|
反序列化通过 Formatter 的
Deserialize()
方法返回序列化好的对象图的根对象的一个引用。
深拷贝
通过序列化与反序列化的特性,可以实现一个深拷贝的方法,用户创建源对象的一个克隆体。
|
|
另外一种技巧就是可以将多个对象图序列化到一个流当中,即调用多次 Serialize()
方法将多个对象图序列化到流当中。如果需要反序列化的时候,按照序列化时对象图的序列化顺序反向反序列化即可。
BinaryFormatter
在序列化的时候会将类型的全名与程序集定义写入到流当中,这样在反序列化的时候,格式化器会获取这些信息,并且通过 System.Reflection.Assembly.Load()
方法将程序集加载到当前的 AppDomain
。
在程序集加载完成之后,会在该程序集搜索待反序列化的对象图类型,找不到则会抛出异常。
【注意】
某些应用程序通过
Assembly.LoadFrom()
来加载程序集,然后根据程序集中的类型来构造对象。序列化该对象是没问题的,但是反序列化的时候格式化器使用的是Assembly.Load()
方法来加载程序集,这样的话就会导致无法正确加载对象。这个时候,你可以实现一个与
System.ResolveEventHandler
签名一样的委托,并且在反序列化注册到当前AppDomain
的AssemblyResolve
事件。这样当程序集加载失败的时候,你可以在该方法内部根据传入的事件参数与程序集标识自己使用
Assembly.LoadFrom()
来构造一个Assembly
对象。记得在反序列化完成之后,马上向事件注销这个方法,否则会造成内存泄漏。
三、使类型可序列化
在设计自定义类型时,你需要显式地通过 Serializable
特性来声明你的类型是可以被序列化的。如果没有这么做,在使用格式化器进行序列化的时候,则会抛出异常。
|
|
【注意】
正因为这样,我们一般都会现将结果保存到
MemoryStream
之中,当没有抛出异常之后再将这些数据写入到文件/网络。
Serializable 特性
Serializable
特性只能用于值类型、引用类型、枚举类型(默认)、委托类型(默认),而且是不可被子类继承。
如果有一个 A 类与其派生类 B 类,那么 A 类没拥有 Serializable
特性,而子类拥有,一样的是无法进行序列化操作。
而且序列化的时候,是将所有访问级别的字段成员都进行了序列化,包括 private 级别成员。
四、简单控制序列化操作
禁止序列化某个字段
可以通过 System.NonSerializedAttribute
特性来确保某个字段在序列化时不被处理其值,例如下列代码:
|
|
在序列化之前,该自定义对象 z 字段的值为 1000,在序列化时,检测到了忽略特性,则不会写入该字段的值到流当中。并且在反序列化之后,z 的值为 0,而 x ,y 的值是 10 和 100。
序列化与反序列化的四个生命周期特性
通过 OnSerializing
、OnSerialized
、OnDeserializing
、OnDeserialized
这四个特性,我们可以在对象序列化与反序列化时进行一些自定义的控制。只需要将这四个特性分别加在四个方法上面即可,但是针对方法签名必须返回值为 void,同时也需要用有一个 StreamingContext
参数。
而且一般建议将这四个方法标识为 private ,防止其他对象误调用。
|
|
【注意】
如果 A 类型有两个版本,第 1 个版本有 5 个字段,并被序列化存储到了文件当中。后面由于业务需要,针对于 A 类型增加了 2 个新的字段,这个时候如果从文件中读取第 1 个版本的对象流信息,就会抛出异常。
我们可以通过
System.Runtime.Serialization.OptionalFieldAttribute
添加到我们新加的字段之上,这样的话在反序列化数据时就不会因为缺少字段而抛出异常。
五、格式化器的序列化原理
格式化器的核心就是 FCL 提供的 FormatterServices
的静态工具类,下列步骤体现了序列化器如何结合 FormatterServices
工具类来进行序列化操作的。
- 格式化器调用
FormatterService.GetSerializableMembers()
方法获得需要序列化的字段构成的MemberInfo
数组。 - 格式化器调用
FormatterService.GetObjectData()
方法,通过之前获取的字段MethodInfo
信息来取得每个字段存储的值数组。该数组与字段信息数组是并行的,下标一致。 - 格式化器写入类型的程序集等信息。
- 遍历两个数组,写入字段信息与其数据到流当中。
反序列化操作的步骤与上面相反。
- 首先从流头部读取程序集标识与类型信息,如果当前 AppDomain 没有加载该程序集会抛出异常。如果类型的程序集已经加载,则通过
FormatterServices.GetTypeFromAssembly()
方法来构造一个 Type 对象。 - 格式化器调用
FormatterService.GetUninitializedObject()
方法为新对象分配内存,但是 不会调用对象的构造器。 - 格式化器通过
FormatterService.GetSerializableMembers()
初始化一个MemberInfo
数组。 - 格式化器根据流中的数据创建一个 Object 数组,该数组就是字段的数据。
- 格式化器通过
FormatterService.PopulateObjectMembers()
方法,传入新分配的对象、字段信息数组、字段数据数组进行对象初始化。
六、控制序列化/反序列化的数据
一般来说通过在第四节说的那些特性控制就已经满足了大部分需求,但格式化器内部使用的是反射,反射性能开销比较大,如果你想要针对序列化/反序列化进行完全的控制,那么你可以实现 ISerializable
接口来进行控制。
该接口只提供了一个 GetObjectData()
方法,原型如下:
|
|
【注意】
使用了
ISerializable
接口的代价就是其集成类都必须实现它,而且还要保证子类必须调用基类的GetObjectData()
方法与其构造函数。一般来说密封类才使用ISerializable
,其他的类型使用特性控制即可满足。另外为了防止其他的代码调用
GetObjectData()
方法,可以通过一下特性来防止误操作:
1
[SecurityPermissionAttribute(SecurityAction.Demand,SerializationFormatter = true)]
如果格式化器检测到了类型实现了该接口,则会忽略掉原有的特性,并且将字段值传入到 SerializationInfo
之中。
通过这个 Info 我们可以被序列化的类型,因为 Info 提供了 FullTypeName
与 AssemblyName
,不过一般推荐使用该对象提供的 SetType(Type type)
方法来进行操作。
格式化器构造完成 Info 之后,则会调用 GetObjectData()
方法,这个时候将之前构造好的 Info 传入,而该方法则决定需要用哪些数据来序列化对象。这个时候我们就可以通过 Info 的 AddValue()
方法来添加一些信息用于反序列化时使用。
在反序列化的时候,需要类型提供一个特殊的构造函数,对于密封类来说,该构造函数推荐为 private ,而一般的类型推荐为 protected,这个特殊的构造函数方法签名与 GetObjectData()
一样。
因为在反序列化的时候,格式化器会调用这个特殊的构造函数。
以下代码就是一个简单实践:
|
|
该类型的对象在反序列化之后,X 的值为序列化之前的值,而 Y 的值始终都会为 20。
【注意】
如果你存储的 X 值是 Int32 ,而在获取的时候是通过 GetInt64() 进行获取。那么格式化器就会尝试使用
System.Convert
提供的方法进行转换,并且可以通过实现IConvertible
接口来自定义自己的转换。不过只有在 Get 方法转换失败的情况下才会使用上述机制。
子类与基类的 ISerializable
如果某个子类集成了基类,那么子类在其 GetObjectData()
与特殊构造器中都要调用父类的方法,这样才能够完成正确的序列化/反序列化操作。
如果基类没有实现 ISerializable
接口与特殊的构造器,那么子类就需要通过 FormatterService
来手动针对基类的字段进行赋值。
七、流上下文
流上下文 StreamingContext
只有两个属性,第一个是状态标识位,用于标识序列化/反序列化对象的来源与目的地。而第二个属性就是一个 Object 引用,该引用则是一个附加的上下文信息,由用户进行提供。
八、类型序列化为不同的类型与对象反序列化为不同的对象
在某些时候可能需要更改序列化完成之后的对象类型,这个时候只需要对象在其实现 ISerializable
接口的 GetObjectData()
方法内部通过 SerializationInfo
的 SetType()
方法变更了序列化的目标类型。
下面的代码演示了如何序列化一个单例对象:
|
|
这里通过显式实现接口的 GetObjectData()
方法来将序列化的目标类型设置为 SingletonHelper
,该类型的定义如下:
|
|
这里因为 SingletonHelper
实现了 IObjectReference
接口,当格式化器尝试进行反序列化的时候,由于在 GetObjectData()
欺骗了转换器,因此反序列化的时候检测到类型有实现该接口,所以会尝试调用其 GetRealObject()
方法来进行反序列化操作。
而以上动作完成之后,SingletonHelper
会立即变为不可达对象,等待 GC 进行回收处理。
九、序列化代理
当某些时候需要对一个第三方库对象进行序列化的时候,没有其源码,但是想要进行序列化,则可以通过序列化代理来进行序列化操作。
要实现序列化代理,需要实现 ISerializationSurrogate
接口,该接口拥有两个方法,其签名分别如下:
|
|
GetObjectData()
方法会在对象序列化时进行调用,而 SetObjectData()
会在对象反序列化时调用。
比如说我们有一个需求是希望 DateTime
类型在序列化的时候通过 UTC 时间序列化到流中,而在反序列化时则更改为本地时间。
这个时候我们就可以自己实现一个序列化代理类 UTCToLocalTimeSerializationSurrogate
:
|
|
并且在使用的时候,通过构造一个 SurrogateSelector
代理选择器,传入我们针对于 DateTime
类型的代理,并且将格式化器与代理选择器相绑定。那么在使用格式化器的时候,就会通过我们的代理类来处理 DateTime
类型对象的序列化/反序列化操作了。
|
|
而一个代理选择器允许绑定多个代理类,选择器内部维护一个哈希表,通过 Type
与 StreamingContext
作为其键来进行搜索,通过 StreamintContext
地不同可以方便地为 DateTime
类型绑定不同用途的代理类。
十、反序列化对象时重写程序集/类型
通过继承 SerializationBinder
抽象类,我们可以很方便地实现类型反序列化时转化为不同的类型,该抽象类有一个 Type BindToType(String assemblyName,String typeName)
方法。
重写该方法你就可以在对象反序列化时,通过传入的两个参数来构造自己需要返回的真实类型。第一个参数是程序集名称,第二个参数是格式化器想要反序列化时转换的类型。
编写好 Binder 类重写该方法之后,在格式化器的 Binder
属性当中绑定你的 Binder 类即可。
【注意】
抽象类还有一个
BindToName()
方法,该方法是在序列化时被调用,会传入他想要序列化的类型。