原则27:总是使你的类型可序列化

优质
小牛编辑
131浏览
2023-12-01

持久化是类型的核心特征。没有人会注意到除非你没有支持它。如果你的类型没有支持恰当支持序列化,你就会给想要使用你的类作为成员或基类的开发者增加工作。当你的类没有支持序列化,它们必须围着它添加自己对这个标准特征的实现。当你的类有不能访问的 private 细节时,就不可能正确的实现序列化。因此,如果你不提供序列化机制,使用者是很难甚至是不可能实现这个机制。

相反,实践中要为你的类添加序列化。对于那些除了不展示 UI 组件,窗口或表单的所有类是很有实践意义的。感觉到有额外工作量是没理由的。.NET 序列化支持是那么简单的让你没有任何理由去支持它。大多数情况下,添加 Serializable 特性就足够了:

[Serializable] 
public class MyType 
{
    private string label; 
    private int value;
}

添加 Serializable 特性就可以工作是因为这个类的所有成员都是可序列化的:string 和 int 都支持 .NET 序列化。这就是为什么都要支持序列化的原因,当你添加自定义类的域是就会变得更加明显:

[Serializable] 
public class MyType 
{
    private string label; 
    private int value;
    private OtherClass otherThing; 
}

这里的 Serializable 特性只有当 OtherClass 类支持 .NET 序列化才有效。如果 OtherClass 不可序列化,会报一个运行时错误,所以你就不得不自己写代码序列化 MyType 和内部的 OtherClass 对象。如果没有掌握 OtherClass 内部定义是不可能。

.NET 序列化会将对象的所有成员保存到输出流中。此外,.NET 序列化支持任意的对象关系图:即使对象有环引用,序列化和反序列化方法都只会保存和存储每个实际对象一次。当 web 对象反序列化时, .NET 的序列化框架同时也会重创建 web 对象的引用。当对象关系图被反序列化,任何 web 相关的对象都会被正确创建。最后重要一点是 Serializable 特性同时支持二进制和 SOAP 序列化。这个原则里介绍所有技术都支持这两种序列化机制。但是记住只有对象关系图中所有类型都支持序列化机制才有用。那就是为什么在所有类型支持序列化重要的原因。一旦你遗漏一个,你就在对象关系图中留了一个孔,就使得使用你的类的其他人很难轻易支持序列化。不久以后,每个人都会发现不得不自己写实现序列化的代码。

添加 Serializable 特性是序列化对象的最简单技术。但是最简单的解决方法不总是最合适的解决方案。有时,你希望序列化对象的所有成员:有些成员可能只会存在长期操作的缓存中。其他成员可能持有只会在内存操作的运行时资源。你同样可以使用特性可以管理所有可能。添加 [NonSerialized] 特性给任意数据成员就不会作为对象状态的一部分保存。这就它们变得不可序列化:

[Serializable] 
public class MyType 
{
    private string label;
    [NonSerialized]
    private int cachedValue;
    private OtherClass otherThing; 
}

不可序列化成员会增加你一点工作量。序列化 API 在反序列化时是不会初始化不可序列化成员。任何构造函数会被调用,成员变量的初始化也不会被执行。当你使用序列化特性,不可序列化成员会获得系统默认初始化值:0或 null 。如果默认0初始化不正确,你需要实现 IDeserializationCallBack 接口来初始化这些不可序列化成员。 IDeserializationCallBack 包含一个方法: ONDeserialization 。框架当整个对象关系图都反序列化之后会调用这个方法。 你使用这个方法初始化对象的不可序列化成员。因为整个关系图已经被读取,你调用类型上任何函数或序列化成员是安全的。不幸的是,它不是万无一失的。在整个对象关系图都读取之后,框架会调用每个实现了 IDeserializationCallBack 对象的 OnDeserialization 方法。在执行 OnDeserialization 可以调用关系图中的任何其他对象的 public 成员。如果它们先执行了,你对象的非序列化成员会是 null 或0。调用顺序是无法保证的,所以你必须保证所有 public 方法都要处理不可序列化成员没有被初始化的情况。

到目前为止,你已经知道为什么要对所有类型添加序列化:非不可序列化类型在序列化类型中使用会带来更多工作。你已经学会使用特性的最简单序列化方法,包括怎么初始化不可序列化成员。

序列化数据会在成员的不同版本存在。给你的类型加上序列化意味着将来你需要读取旧的版本。反序列化时如果发现类有域被添加或移除,就会抛出异常。当你需要支持多个版本你就需要更多控制序列化进程,实现 ISerializable 接口。这接口定义一些 hook 用于自己定义类的序列化机制。 ISerializable 接口的方法和存储和默认序列化的方法和存储是一致的。这说明当你创建类时可以继续使用序列化特性。如果你觉得需要提供自己的扩展时,你可以添加 ISerializable 接口的支持。

例如,考虑你如何来支持 MyType 的第2个版本,也就是添加了另一个域到类中时。简单的添加一个域都会产生一个新的类型,而这与先前已经存在磁盘上的版本是不兼容的:

[Serializable]
public class MyType 
{
    private MyType(SerializationInfo info, StreamingContext cntxt) 
    { 
    }
    private string label;
    [NonSerialized] 
    private int value;
    private OtherClass otherThing;
    // Added in version 2 
    // The runtime throws Exceptions 
    // with it finds this field missing in version 1.0 
    // files.
    private int value2; 
}

你可以添加 ISerializable 来解决这个行为。 ISerializable 接口定义一个方法,但是你不得不实现两个。 ISerializable 定义 GetObjectData() 方法写数据到流中。此外,你必须提供一个序列化构造器从流中 初始化对象:

private MyType(SerializationInfo info, StreamingContext cntxt)

下面的序列化构造函数演示了如何从先前的版本中读取数据,以及读取当前版本中的数据和默认添加的Serializable特性生成的序列化保持一致:

using global::System.Runtime.Serialization; 
using global::System.Security.Permissions; 
[Serializable] 
public sealed class MyType : ISerializable 
{
    private string label;
    [NonSerialized] 
    private int value;
    private OtherClass otherThing;
    private const int DEFAULT_VALUE = 5; 
    private int value2;
    // public constructors elided.
    // Private constructor used only 
    // by the Serialization framework. 
    private MyType(SerializationInfo info, StreamingContext cntxt) 
    {
        label = info.GetString("label"); 
        otherThing = (OtherClass)info.GetValue("otherThing",typeof(OtherClass)); 
        try 
        {
            value2 = info.GetInt32("value2"); 
        } 
        catch (SerializationException) 
        {
            // Found version 1\. 
            value2 = DEFAULT_VALUE;
        } 
    }
    [SecurityPermissionAttribute(SecurityAction.Demand, SerializationFormatter = true)] 
    void ISerializable.GetObjectData(SerializationInfo inf,StreamingContext cxt) 
    {
        inf.AddValue("label", label); 
        inf.AddValue("otherThing", otherThing); 
        inf.AddValue("value2", value2);
    } 
}

序列化流把每项当做 key/value 对存储。默认特性生成的代码是使用变量名作为键值存储值。当你添加 ISerializable 接口,你必须匹配键名和变量的顺序。这个顺序就是类中声明它们的顺序。(顺便说下,这个实际说明类中变量声明顺序变了或者重命名了就破坏了已经存储的序列化文件的兼容性。)

同时,我已经要求 SerializationFormatter 的安全许可。如果没有恰当的保护, GetObjectData 可能是一个安全漏洞。恶意代码可以创建一个 StreamingContext ,并且使用 GetObjectData 获得对象中的值,或者续断修改 SerializationInfo 的版本,或者重写组织修改的对象。这可能运行恶意开发者访问你对象的内部状态,在流中修改,并且返回给你。要求 SerializationFormatter 安全许可进而就封闭了这个潜在的漏洞。这就确保只有被信任的代码才能访问对象的内部状态。

但是实现 ISerializable 接口有一个弊端。你可以看到,我之前使得 MyType 为 sealed 。就强制它是一个叶节点类。在基类实现 ISerializable 接口就要复杂到考虑所有子类。实现 ISerializable 意味着每个子类都必须创建一个 protected 构造方法用于反序列化。另外,为了支持非封闭类,你需要在 GetObjectData 方法中创建 hook ,让子类可以添加自己的数据到流中。编译器不会捕获出现的错误。当从流中读取子类时,如果没有恰当的构造函数会抛出运行时异常。缺少 GetObjectData() 的钩子意味着子类派生的数据不会被保存到文件中。没有错误抛出。我很想建议地说“在叶节点实现实现可序列化”。但我没有那样说因为那不能正常工作。你的基类必须为子类实现可序列化。修改 MyType 使得它成为一个可序列化基类,莫修改序列化构造函数为 protected 并且创建一个虚方法子类可以重载自己的版本存储数据:

[Serializable] 
public class MyType : ISerializable 
{
    private string label;
    [NonSerialized] 
    private int value;
    private OtherClass otherThing;
    private const int DEFAULT_VALUE = 5; 
    private int value2;
    // public constructors elided.
    // Protected constructor used only by the 
    // Serialization framework. 
    protected MyType(SerializationInfo info, StreamingContext cntxt) 
    {
        label = info.GetString("label"); 
        otherThing = (OtherClass)info.GetValue("otherThing",typeof(OtherClass)); 
        try 
        {
            value2 = info.GetInt32("value2"); 
        } 
        catch (SerializationException e) 
        {
        // Found version 1\. 
        value2 = DEFAULT_VALUE;
        } 
    }
    [SecurityPermissionAttribute(SecurityAction.Demand,SerializationFormatter = true)] 
    void ISerializable.GetObjectData(SerializationInfo inf, StreamingContext cxt)
    {
        inf.AddValue("label", label); 
        inf.AddValue("otherThing", otherThing); 
        inf.AddValue("value2", value2);
        WriteObjectData(inf, cxt); 
    }
    // Overridden in derived classes to write 
    // derived class data:
    protected virtual void
    WriteObjectData(SerializationInfo inf,StreamingContext cxt)
    {
        // Should be an abstract method,
        // if MyType should be an abstract class.
    } 
}

子类可以提供自己的序列化构造函数和重载 WriteObjectData 方法:

public class DerivedType : MyType 
{
    private int derivedVal;
    private DerivedType(SerializationInfo info,
    StreamingContext cntxt) : base(info, cntxt)
    {
        derivedVal = info.GetInt32("_DerivedVal"); 
    }
    protected override void WriteObjectData(SerializationInfo inf, StreamingContext cxt)
    {
        inf.AddValue("_DerivedVal", derivedVal); 
    }
}

从序列化流中写入和检索值的顺序必须保持一致。我首先对基类的值进行读和写因为我相信它是最简单的。如果你不按照继承关系中正确的顺序去读和写,序列化代码就失败。

本原则的例子代码中都没有使用自动(隐式)属性。这就是设计。自动属性使用编译器产生的支持域来存储。你不能访问支持域,因为域的名字是无效的 C# 记号(它是一个有效的 CLR 符号)。这就使得二进制序列化对使用自动属性的类而言非常脆弱。你不能写自己的序列化构造函数,或 GetObjectData 方法访问那些支持域。这样只能对简单类型有效,任何子类和增加域都会失败。随着时间推移,你会发现这个问题,并且你不能修复这个问题。任何时候你给你的类型添加 Serializable 特性,你必须使用自己的支持域存储具体地实现属性。

.NET 框架提供简单,标准算法序列化对象。如果你的类型需要持久化,你应该遵从标准的实现。如果你的类型没有支持序列化,使用这个类的其他类同样也不支持序列化。尽量为你的客户端支持这个机制。尽可能的使用默认序列化特性,并且在默认的特性不满足时要实现 ISerializable 接口。

小结:

虽然序列化的实现机制很简单,但是细节还是有很多讲究的。累死了,打了这么多字,跑步去,加油!

欢迎各种不爽,各种喷,写这个纯属个人爱好,秉持”分享“之德!