第17章 序列化

第17章 序列化

17.1 序列化概念

序列化和反序列化通常用于:

  1. 通过网络或程序边界传输对象
  2. 在文件或者数据库中保存对象
  3. 深拷贝

17.1.1 序列化引擎

下表列出了 .NET Framework 支持的 4 中序列化机制:

C7.0 核心技术指南 第7版.pdf - p737 - C7.0 核心技术指南 第 7 版-P737-20240223125808

特性 数据契约序列化器 二进制序列化器 XmlSerializer IXmlSerializable
自动化级别 ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐
类型耦合 可选 紧密 松散 松散
版本容错性 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
是否保留对象引用 可选 可选
是否可以序列化非公有字段
是否适用于互操作消息 ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
读取XML文件的灵活性 ⭐⭐ - ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
输出精简程度 ⭐⭐ ⭐⭐⭐⭐ ⭐⭐ ⭐⭐
性能 ⭐⭐ ⭐⭐⭐⭐ ⭐到⭐⭐⭐ ⭐⭐⭐

17.1.1.2 数据契约序列化器

数据契约序列化器在这三种序列化引擎中是最新的,也是用途最广的,WCF 使用的就是这个引擎。适用于如下场景:

  • 使用标准的消息协议来交换信息。
  • 需要良好的版本容错性,并且能够保留对象的引用。

17.1.1.3 二进制序列化器

优点:

  1. 易于使用

  2. 高度自动化

    仅需一个特性标注

  3. 速度快

缺点:

  1. 版本容错性差

17.1.1.4 XmlSerializer​ 类

优点:

  1. 处理比较随意的 XML 结构时有足够的灵活性。

    1. 支持选择将对象的属性序列化为 XML 元素或 XML 属性;
    2. 可以选择集合中外部元素的处理方式。
  2. 提供了较好的版本容错性

缺点:

  1. 只能产生 XML。
  2. 无法保存或恢复复杂的对象图(特别是,它无法恢复共享的对象引用)。

17.1.1.5 IXmlSerializable​ 接口

该接口可以使用 XmlReader ​ 和 XmlWriter ​ 自定义序列化的方式,可以使用它对复杂的类型进行序列化。XmlSerializer​与数据契约序列化器均支持 IXmlSerializable ​接口。

17.1.2 格式化器

  • 序列化引擎

    将实例按照一定规则转化为 数据

  • 格式化器

    序列化引擎转化后的数据转化为 特定格式 ,可以是 二进制xmljson

序列化引擎处理对象图的遍历和对象状态的提取,是序列化过程中格式无关的部分。格式化器则根据序列化引擎提供的信息,将对象状态转换为特定的数据格式。虽然不同格式化器可能共享序列化引擎的某些底层逻辑,但它们各自负责将数据转换为特定的格式。

序列化引擎的角色通常是内嵌在格式化器中。

详见格式化器和序列化引擎

17.1.3 显式和隐式序列化

  • 显式序列化

    要求开发者直接调用序列化方法,确定序列化所需的序列化引擎格式化器,提供了更高的控制度,适用于需要精细管理序列化过程的场景。

  • 隐式序列化

    通常由框架自动处理,开发者只需通过某种方式(如特性标记)指示哪些类或属性是可序列化的。这种方式简化了序列化过程,但可能在控制精度上有所牺牲。

隐式序列化常出现在:

  1. WCF

    总是使用数据契约序列化器。

  2. Remoting

    总是使用二进制序列化引擎。

  3. Web 服务

    总是使用 XmlSerializer。

17.2 数据契约(DataContract)的序列化

17.2.1 DataContractSerializer​ 与 NetDataContractSerializer

数据契约序列化有两种:

  • DataContractSerializer

    1. .NET 类型与数据契约类型** **耦合。
    2. 输出 专有, 不必 依赖特定程序集。
    3. 指定参数 保留引用特性。
  • NetDataContractSerializer

    1. .NET 类型与数据契约类型** **耦合。
    2. 输出 专有, 需要 依赖特定程序集。
    3. 自动 保留引用特性。

DataContractSerializer​​ 需要预先注册 可序列化子类型(使用 KnownType ​​ 特性等方式) ,而 NetDataContractSerializer​​ 不需要,它会把要序列化对象的类型和程序集的全名输出。这和二进制序列化引擎非常相似:

<!-- DataContractSerializer 序列化 -->
<Person xmlns="...">
    ...
</Person>
<!-- NetDataContractSerializer 序列化 -->
<Person z:Type="SerialTest.Person" z:Assembly="SerialTest, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null">
    ...
</Person>

Warn

NetDataContractSerializer​ 这种输出是专有的。为了反序列化,它也必须依赖特定程序集、特定命名空间下的特定 .NET 类型。

17.2.2 使用序列化器

显式实例化 DataContractSerializer ​和 NetDataContractSerializer

假设有如下类型:

[DataContract] public class Person
{
    [DataMember] public string Name;
    [DataMember] public int Age;
}

DataContractSerializer​ 的使用方式如下:

Person p = new Person{Name = "Stacey", Age = 30};
var ds = new DataContractSerializer(typeof(Person));
var filename = "Person.xml";
using(Stream s = File.Create(filename))
    ds.WriteObject(s, p);
  
Person p2;
using(Stream s = File.OpenRead(filename))
    p2 = (Person) ds.ReadObject(s);
p2.Dump();

NetDataContractSerializer​ 的使用方式如下:

Person p = new Person { Name = "Stacey", Age = 30 };
var ns = new NetDataContractSerializer();
var filename = "Person.xml";
using (Stream s = File.Create(filename))
    ns.WriteObject(s, p);

Person p2;
using (Stream s = File.OpenRead(filename))
    p2 = (Person)ns.ReadObject(s);
p2.Dump();

Notice

DataContractSerializer ​的构造器需要一个根对象类型(显式序列化的对象类型),NetDataContractSerializer​ 不需要。

两种序列化器默认使用 XML 格式化器,可以改为使用 XmlWriter​​,增强输出的 可读 性(注意,ds.WriteObject(Stream)​ ​和 ds.WriteObject(XmlWriter)​ ​不是同一个方法):

...
XmlWriterSettings settings = new XmlWriterSettings() { Indent = true };
using (var w = XmlWriter.Create(path, settings))
    ds.WriteObject(w, p);
<Person xmlns:i="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://schemas.datacontract.org/2004/07/">
  <Age>30</Age>
  <Name>Stacey</Name>
</Person>
DataContract​ 特性

DataContract​ 可以通过可选参数修改 XML 元素的信息

可选参数:​** Name **

XML 元素名称默认使用类名,通过 Name ​ 属性可以覆盖默认行为:

[DataContract (Name="Candidate")]
public class Person { ... }

可选参数:​** Namespace **

XML 命名空间对应着数据契约的命名空间,默认是 http://schemas.datacontract.org/2004/07/ 再加上.NET 类型的命名空间。我们可以用 Namespace ​ 属性重写这个值:

[DataContract (Namespace="http://oreilly.com/nutshell")]
pulic class Person { ... }

C7.0 核心技术指南 第7版.pdf - p743 - C7.0 核心技术指南 第 7 版-P743-20240224132049

DataMember​ 特性

DataMember​ 数据类型可以是:

  1. 任何基元类型

  2. DateTime​、TimeSpan​、Guid​、Uri​ 或 Enum​ 值

  3. 上述类型的可空类型版本

  4. byte[]

    在 XML 中序列化为 base64 编码

  5. 任何用 DataContract ​修饰的已知类型

  6. 任何 IEnumerable ​类型

  7. 任何被 [Serializable] ​修饰,或实现了 ISerializable ​接口的类型

  8. 任何实现了 IXmlSerializable ​接口的类型

指定二进制格式化器

DataContractSerializer​ 和 NetDataContractSerializer ​可以使用二进制序列化器:

Person person = new Person { Name = "Stacey", Age = 30 };
var ds = new DataContractSerializer(typeof(Person));

var s = new MemoryStream();
using (var w = XmlDictionaryWriter.CreateBinaryWriter(s))
    ds.WriteObject(w, person);

var s2 = new MemoryStream(s.ToArray());
Person person2;
using (XmlDictionaryReader r = XmlDictionaryReader.CreateBinaryReader(s2, XmlDictionaryReaderQuotas.Max))
    person2 = (Person)ds.ReadObject(r);
person2.Dump();

二进制格式化器的输出会比 XML 格式化器稍小一些,尤其是当类型中包含大的数组时会更加明显。

Info

更多内容,见 XmlDictionaryWriter 介绍

17.2.3 序列化子类

NetDataContractSerializer​ 序列化器会将类型的完全限定名称写入到内容中,无须做任何特殊的处理,可以自动解析。

DataContractSerializer​ 序列化器需要告知可能的子类类型,方式有二:

  1. 通过构造器传入 Type 集合
  2. 通过 KnownType 特性标注
var ds =new DataContractSerializer (typeof (Person), 
    newType[] {typeof (Student), typeof(Teacher)});
[DataContract, KnownType(typeof(Student)), KnownType(typeof(Teacher))]
public class Person

C7.0 核心技术指南 第7版.pdf - p745 - C7.0 核心技术指南 第 7 版-P745-20240224173508

17.2.4 对象引用

NetDataContractSerializer​ 总是会保留引用的相等性,​DataContractSerializer​ 需要专门指定才会保留。

未保留引用可以保持 XML 简洁并符合标注,但也有如下缺点:

  1. 造成 XML 过大
  2. 失去引用完整性
  3. 无法处理循环引用

DataContractSerializer​ 可以通过两种方式启用保留引用:

  1. 构造器指示 preserveObjectReference 参数为 true
  2. 使用 [DataContract(IsReference = true)]标注需要保留应用的类型。

不过,这样会降低互操作性。

互操作性:

指的是不同系统、应用程序或组件之间能够无障碍地交换数据的能力。这意味着一个系统序列化(即将数据结构或对象状态转换为一种格式化的、可存储或传输的形式)的数据能够被另一个系统正确地反序列化(即从该格式化形式恢复到原始的数据结构或对象状态),即使这两个系统可能使用不同的编程语言、运行在不同的操作系统上,或者是基于不同的技术平台。

17.2.5 版本容错性

数据契约序列化器按照如下规则执行序列化/反序列化

  1. 序列化:仅序列化 [DataMember] ​ 标注的成员;

  2. 反序列化:

    1. 跳过 [DataMember]​ 已标注, 流中却没有数据 的成员。
    2. 无法识别的数据,进行 丢弃

若不想丢弃无法识别的数据,可以实现 IExtensibleDataObject ​ 接口(显式),该接口对应的属性将保留未识别的数据:

[DataContract]
public class Person : IExtensibleDataObject
{
    [DataMember] public string Name;
    [DataMember] public int Age;
    ExtensionDataObject IExtensibleDataObject.ExtensionData { get; set; }
}

版本容错性指可以添加或删除数据成员,而不破坏向前和向后兼容性。

必备的成员

如果某个成员是必须的,可以使用 IsRequired ​进行限制。该成员不存在,反序列化时会 抛出异常

[DataMember(IsRequired = true)] public int ID;

17.2.6 成员顺序

数据契约序列化器对数据成员的顺序要求极为苛刻。反序列化器会跳过任何判定为序列之外的成员。

在序列化时,成员会按照如下的顺序进行书写:

  1. 类型到 类型
  2. Order 值到 Order 值(限被 Order 修饰的数据成员)
  3. 字母表 顺序,使用 字符串 的序数(ordinal)进行排序

如果不需要进行如此严格的互操作,则最容易的办法就是不指定成员的 Order 而单纯使用字母表排序法。这样,当序列化的类型出现新添的或者删除的成员时就不会出现任何差异。

但在基类和子类中移动成员时则会出现紊乱(这也是唯一会出现问题的情况)。例如如下代码,第一段代码先序列化 A ,第二段代码先序列化 B ,即使使用 Order 进行标注,也不会改变 A、B 序列化顺序:

[DataContract]
public class Base
{
    [DataMember]
    public int A { get; set; } = 10;
}

[DataContract]
public class Derived : Base
{
    [DataMember]
    public int B { get; set; } = 20;
}
[DataContract]
public class Base
{
    [DataMember]
    public int B { get; set; } = 10;
}

[DataContract]
public class Derived : Base
{
    [DataMember]
    public int A { get; set; } = 20;
}

17.2.7 null 和 空值

当成员的值为 null 或 空值(即默认值),可以在序列化过程中进行忽略,以节省空间。方式如下:

[DataContract] public class Person
{
    [DataMember(EmitDefaultValue = false)] public string Name;
    [DataMember(EmitDefaultValue = false)] public int Age;
}

使用 [DataMember(EmitDefaultValue = false)]​ 修饰的成员,有:

  • 引用 类型和 可空 类型

值为 null 时将不进行序列化

  • 类型

值为默认值时将不进行序列化

17.3 数据契约与集合

数据契约在序列化集合时, 不包含 任何与集合有关的信息,这意味着 List<int>​​ ​和 int[] ​​ ​序列化得到的数据结构完全一致:

[DataContract]
public class EnumeratorClass
{
    [DataMember]
    public int[] MyArray { get; set; } 
        = { 1, 2, 3};

    [DataMember]
    public List<int> MyList { get; set; } 
        = new List<int> { 4, 5, 6 };
}
<MyArray xmlns:d2p1="http://schemas.microsoft.com/2003/10/Serialization/Arrays">
  <d2p1:int>1</d2p1:int>
  <d2p1:int>2</d2p1:int>
  <d2p1:int>3</d2p1:int>
</MyArray>
<MyList xmlns:d2p1="http://schemas.microsoft.com/2003/10/Serialization/Arrays">
  <d2p1:int>4</d2p1:int>
  <d2p1:int>5</d2p1:int>
  <d2p1:int>6</d2p1:int>
</MyList>

这种形式对接口不友好,若将 MyList 类型改为 IList<int>​,反序列化器将默认转化为 最简单的数组 ,而非 List<int>​。

17.3.2 自定义集合与元素名称

对于集合的派生类,可以使用 CollectionDataContract ​ ​进行标注:

  • 元素名称:

    1. ItemName​​:标注每个 Item ​的 XML 名称:
    2. KeyName​​:字典的 Key​ 名称
    3. ValueName​​:字典的 Value​ 名称
    [CollectionDataContract(ItemName="Entry", 
                            KeyName="Kind",
                            ValueName="Number")]
    public class PhoneNumberList : Dictionary<string, string> { }
    
    [DataContract] public class Person
    {
        [DataMember]
        public PhoneNumberList PhoneNumbers;
    }
    
    <PhoneNumbers>
      <Entry>
        <Kind>Home</Kind>
        <Number>08 1234 5678</Number>
      </Entry>
      <Entry>
        <Kind>Mobile</Kind>
        <Number>040 8765 4321</Number>
      </Entry>
    </PhoneNumbers>
    
  • Namespace

  • Name

17.4 扩展数据契约

17.4.1 序列化与反序列化钩子

如果要在序列化之前或者序列化之后执行一个自定义方法,则可以在该方法上标记以下特性:

  • 序列化

    • [OnSerializing]​:在序列化之前调用这个方法。
    • [OnSerialized]​:在序列化之后调用这个方法。
  • 反序列化

    • [OnDeserializing]​:在反序列化之前调用这个方法。
    • [OnDeserialized]​:在反序列化之后调用这个方法。

这些方法只能包含一个 StreamingContext ​​ 类型的参数,该参数数据契约序列化器并不使用,仅用于保证和二进制引擎的兼容性。

用这四个特性修饰的方法 可以 是私有的。如果子类型需要参与其中,那么它们可以使用相同的特性来标记自己的方法,这些方法同样会得到执行

Notice

上述方法并不需要 SerializationInfo​ 参数,仅当类型实现 ISerializable​ 接口,其构造器、GetObjectData​ 方法需要该参数。

Info

另可见2 对数据协定序列化的支持

17.4.2 与 [Serializable] 的互操作

对于使用了 [Serializable] 特性、 ISerializable ​ 接口的类,数据契约提供了兼容性。这些类无需使用 [DataMember] 标注,可直接进行序列化。

如下代码演示了标记为 [Serializable] 的类序列化的效果:

[DataContract] public class Person {
    [DataMember] public Address MailingAddress;
}
[Serializable] public class Address {
    public string Postcode, Street;
    [NonSerialized] public string State;
}
<Person ...>
  <MailingAddress>
    <Postcode>6020</Postcode>
    <Street>Odo st</Street>
  </MailingAddress>
</Person>

如下代是实现了 ISerializable ​的类序列化效果,效率较低:

<MailingAddress>
  <Street xmlns:d3p1="http://www.w3.org/2001/xMLSchema"
    i:type="d3p1:string xmlns="">str</Street>
  <Postcode xmlns:d3p1="http://www.w3.org/2001/xMLSchema"
    i:type="d3p1:string xmlns="">pcode</Postcode>
<MailingAddress>

数据契约内部对一些类型(如 string​、DateTime​)进行了额外的处理,其规则与二进制引擎不相同,以进行兼容。

C7.0 核心技术指南 第7版.pdf - p755 - C7.0 核心技术指南 第 7 版-P755-20240226132904

17.4.3 与 IXmlSerializable​ 的互操作

数据契约序列化器无法控制 XML 的结果,如有控制结构的需求,需借助 IXmlSerializable ​接口。

17.5 二进制序列化器

令类型支持二进制序列化的方式有两种:

  1. 基于特性标记的

相对简单
2. 实现 ISerializable 接口

更灵活

一般来说,实现 ISerializable​ 的目的有两个:

  1. 动态控制序列化的内容
  2. 让可序列化的类型能更加友好地被第三方继承

[Serializable]​ 的使用

[Serializable]​ 特性将令序列化器序列化类型中的 所有 字段( 有、 有), 但不包含 属性。每一个字段本身都必须可序列化,否则将 抛出异常
.NET 的基本类型(如 string​​ 和 int​​)都支持序列化。

[Serializable] public sealed class Person
{
    public string Name;
    public int Age;
}

C7.0 核心技术指南 第7版.pdf - p757 - C7.0 核心技术指南 第 7 版-P757-20240226131341

二进制引擎

有两个二进制引擎可用:

  1. BinaryFormatter​:该格式化器的效率较高,可以用更短的时间产生更小的输出。
  2. SoapFormatter​:该序列化器在和 Remoting 配合使用时可以生成基本的 SOAP 样式的消息

C7.0 核心技术指南 第7版.pdf - p757 - C7.0 核心技术指南 第 7 版-P757-20240226131801

使用方式如下:

Person p = new Person() { Name = "George", Age = 25 };
IFormatter formatter = new BinaryFormatter();
using(FileStream s = File.Create("serialized.bin"))
    formatter.Serialize(s, p);

using(FileStream s = File.OpenRead("serialized.bin"))
{
    Person p2 = (Person) formatter.Deserialize(s);
    p2.Dump();
}

Eureka

C7.0 核心技术指南 第7版.pdf - p758 - C7.0 核心技术指南 第 7 版-P758-20240226132509

DataContract​ 序列化引擎无需无参构造器便能实例化,也是借助 FormatterServices.GetUninitializedObject​ 方法实现的。

BinaryFormatter​ 序列化的特点

  1. 序列化数据包含 程序集 的全部信息;

    试图在另一个程序集中反序列化将 产生一个错误

  2. 反序列化会完全恢复对象引用的初始状态;

    集合同样如此。

C7.0 核心技术指南 第7版.pdf - p758 - C7.0 核心技术指南 第 7 版-P758-20240226173230

17.6 BinaryFormatter​ 序列化特性(Attribute)

17.6.1 [NonSerialized]

对于 [Serializable]​ 标注的类型,其字段可以使用 [NonSerialized] ​ 标注,序列化时将排除在外:

[DataContract] public class Person {
    [DataMember] public Address MailingAddress;
}
[Serializable] public class Address {
    public string Postcode, Street;
    [NonSerialized] public string State;
}
<Person ...>
  <MailingAddress>
    <Postcode>6020</Postcode>
    <Street>Odo st</Street>
  </MailingAddress>
</Person>

17.6.2 [OnDeserializing]​ 和 [OnDeserialized]

17.4.1 序列化与反序列化钩子 相似,二进制引擎可以使用 [OnDeserializing]​ 和 [OnDeserialized]​ 定义一个所需的反序列化构造器、反序列化后的计算方法:

public sealed class Person
{
    public string Name;
    public DateTime DateOfBirth;
    [NonSerialized] public int Age;
    [NonSerialized] public bool Valid = true;

    public Person() { Valid = true ;}
}
[OnDeserializing]
void OnDeserializing(StreamingContext context)
{
    Valid = true;
}
[OnDeserialized]
void OnDeserialized(StreamingContext context){
    var ts = DateTime.Now - DateOfBirth;
    Age = ts.Days / 365;
}

17.6.3 [OnSerializing]​ 和 [OnSerialized]

二进制引擎也支持 [OnSerializing]​ 和 [OnSerialized]​ 特性。这两个特性用以标记序列化前后调用的方法。

SOAP 格式化器不支持泛型,因此我们可以借助 [OnSerializing] ​ 和 [OnDeserialized] ​ 将数据转为其他类型:

[Serializable]
public sealed class Team
{
    public string Name;
    Person[] _playersToSerialize;
    [NonSerialized] public List<Person> Players= new List<Person>();
}
[OnSerializing]
void OnSerializing(StreamingContext context)
{
    _playersToSerialize = Players.ToArray();
}
[OnSerialized]
void OnSerialized(StreamingContext context)
{
    //Allow it to be freed from memory
    _playersToSerialize = null; 
}
[OnDeserialized]
void OnDeserialized(StreamingContext context)
{
    Players = new List<Person>(_playersToSerialize);
}

17.6.4 [OptionalField] ​ 特性和版本

为了保持向后兼容性,可以在新增字段上附加 [OptionalField] ​ 特性:

// Version 1
[Serializable] public sealed class Person
{
    public string Name;
}
// Version 2
[Serializable] public sealed class Person
{
    public string Name;
    [OptionalField(VersionAdded = 2)] public DateTime DateOfBirth;
}

C7.0 核心技术指南 第7版.pdf - p761 - C7.0 核心技术指南 第 7 版-P761-20240227123019

对于双向通信,还需关注向前兼容性的问题:即序列化流中出现了一个额外的字段,但是序列化器不知道如何处理该字段的情况。 BinaryFormatter ​ 会自动丢弃这些字段;而 SoapFormatter ​ 会抛出异常。因此,若要求双向版本健壮性,最好使用 BinaryFormatter ​,否则就需要通过实现 ISerializable ​接口来手动控制序列化的过程。

17.7 使用 ISerializable​ 接口进行二进制序列化

通过 ISerializable​ 接口和形为 T(SerializationInfo si, StreamingContext sc) ​ 的构造器可以完全控制二进制序列化和反序列化的过程。ISerializable​ 接口定义如下:

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

GetObjectData​ 方法在序列化时触发,其任务是将序列化的所有字段放入 SerializationInfo ​(一个 键值对字典 )对象中。

T(SerializationInfo si, StreamingContext sc) ​ 构造器则在反序列化时触发,解析 SerializationInfo ​ 中的内容。

示例如下:

[Serializable]
public sealed class Person : ISerializable {
    public string Name;
    [OptionalField(VersionAdded = 2)] public DateTime DateOfBirth;

    public void GetObjectData(SerializationInfo info, StreamingContext context) {
        info.AddValue(nameof(Name), Name);
        info.AddValue(nameof(DateOfBirth), DateOfBirth);
    }
}
[Serializable]
public class Team : ISerializable {
    public string Name;
    public List<Person> Players;
  
    // 序列化使用
    public virtual void GetObjectData(SerializationInfo info, StreamingContext context) {
        info.AddValue(nameof(Name), Name);
        // 此处使用数组,以兼容 SoapFormatter
        info.AddValue(nameof(Players), Players.ToArray());
    }
  
    public Team() {}

    // 反序列化使用
    protected Team(SerializationInfo si, StreamingContext sc) {
        Name = si.GetString(nameof(Name));

        var array = (Person[]) si.GetValue(nameof(Players), typeof(Person[]));
        Players = new List<Person>(array);
    }
}

其中,反序列化构造器的访问修饰符可以是 任何 级别,对于非密封类,推荐使用 protected 级别,便于 子类调用

Notice

如果类型并非 sealed​,则应尽量将 GetobjectData​ 声明为 virtual ​。这样子类可以扩展序列化的功能而无须重新实现接口。

StreamingContext​ 参数提供了关于序列化流的源和目标的附加上下文信息。这个上下文信息用于在序列化或反序列化对象时,允许对象了解它正在被序列化或反序列化的环境和目的,从而可以根据需要进行适当的调整或优化。

StreamingContext​ 类包含两个主要部分:

  1. State:一个 StreamingContextStates​ 枚举值,指示序列化流的来源或目标。这包括文件、内存、数据库、远程调用等多种情况。例如,如果对象被序列化以便进行远程调用,State​ 属性可能指示该对象正被序列化用于跨进程或跨机器通信。
  2. Additional Context:一个对象,提供有关序列化操作的额外信息。虽然在.NET 框架的标准序列化过程中这个属性不常用,但它为开发者提供了一种机制,通过它可以传递在序列化过程中可能需要的任何自定义数据或状态信息。

通过检查 StreamingContext​ 参数的 State​ 属性,序列化逻辑可以灵活地调整其行为,以适应特定的序列化场景。例如,一个对象可能只希望在进行持久化到磁盘时包含某些数据,而在进行网络传输时排除这些数据以节省带宽。

这种机制特别有用于那些需要在不同序列化场景下表现不同行为的复杂对象。它允许开发者编写更为灵活和健壮的序列化逻辑,以适应各种不同的需求和约束。

二进制反序列化时的异常处理

SerializationInfo.Get​ 方法用于反序列化,当它通过名称无法正常获取数据会抛出异常(常出现在版本冲突时)。有两种方法可以解决该问题:

  • 添加 异常处理代码

  • 实现 自己的版本编号系统

    例如:

    public string MyNewField;
    
    public virtual void GetObjectData(SerializationInfo info, StreamingContext context){
        info.AddValue("_version", 2);
        info.AddValue(nameof(MyNewField), MyNewField);
    }
    
    protected Team(SerializationInfo info, StreamingContext context){
        int version = info.GetInt32("_version");
        if(version >= 2) MyNewField = info.GetString(nameof(MyNewField));
        ...
    }
    

继承可序列化类

支持 ISerializable​ 接口的类应考虑:

  • 声明为 sealed,禁止派生。
  • 若未密封,应将 ISerializable.GetObjectData 标注为 virtual。

具体原因见原文。

ISerializable​ 序列化杂谈

ISerializable​ 接口最初是设计来支持二进制序列化的,其目的是提供一种机制,让对象可以控制自己的序列化和反序列化过程。通过实现 ISerializable​ 接口,一个类可以精确地指定哪些数据需要被序列化以及如何进行序列化。

随后,在 .NET Framework 中引入了数据契约序列化(特别是通过 DataContractSerializer​),这个新的序列化机制为了提高兼容性和灵活性,也支持了对 ISerializable​ 接口的兼容。这意味着实现了 ISerializable​ 接口的对象,除了可以使用二进制序列化外,也可以通过数据契约序列化进行 XML 序列化或 JSON 序列化。

数据契约序列化通过识别类是否实现了 ISerializable​ 接口,来决定采用自定义序列化逻辑(即由 GetObjectData​ 方法提供的逻辑)还是标准的数据契约逻辑(基于 [DataContract]​ 和 [DataMember]​ 特性)。这种方式为旧的代码提供了向前兼容的路径,使得原本只能进行二进制序列化的类也能够被序列化为 XML 或 JSON 格式,增强了 .NET 序列化机制的灵活性和应用范围。

然而,随着 .NET 的发展,特别是在 .NET Core 和 .NET 5/6/7 等新版 .NET 中,推荐的序列化方法有所变化。System.Text.Json​ 成为了处理 JSON 数据的首选方式,而对于 XML 数据,则主要推荐使用 XmlSerializer​ 或 DataContractSerializer​。尽管 ISerializable​ 仍然被支持,但在新的开发工作中,通常会推荐使用更现代的序列化框架和模式。

17.8 XML 序列化

XmlSerializer​ 用于将 .NET 类型序列化为 XML 文件。和 ISerializable ​相似,可以用两种方式使用 XML 序列化引擎:

  1. 使用 特性标记。
  2. 实现 IXmlSerializable接口。
XML序列化特性标记类型XmlRootXmlInclude成员XmlAttributeXmlElementOrder参数TypeXmlIgnore集合成员XmlArrayXmlArrayItemIXmlSerializable 接口ReadXmlXmlReaderWriteXmlXmlWriter

17.8.1 基于特性的序列化入门

XmlSerializer​ 序列化有如下特点:

  1. 可以序列化没有标记任何特性的类型

    默认地,它会序列化类型中的所有 有的 字段属性 。若不希望序列化某些成员,则可以标记 [XmlIgnore] ​ 特性来排除它们。

  2. 需要 无参构造器 辅助完成反序列化。

  3. 可以使用 XmlAttribute​​​、XmlElement​​​ 特性标注。

它可以序列化:

  1. 基元类型
  2. DateTime​​
  3. TimeSpan​​
  4. Guid​​
  5. 上述类型的可空类型
  6. byte[]​​
  7. IXmlSerializable​​ 类型
  8. 任何集合类型

使用方式如下:

Person p = new Person() {Name = "Stacey", Age = 30 };

// 序列化
XmlSerializer xs = new XmlSerializer(typeof(Person));
using(Stream s = File.Create("person.xml"))
    xs.Serialize(s, p);

// 反序列化
Person p2;
using(Stream s = File.OpenRead("person.xml"))
    p2 = (Person) xs.Deserialize(s);
p2.Dump();

public class Person {
    public string Name;
    public int Age;
    [XmlIgnore] public DateTime DateOfBirth;
}

17.8.1.1 属性、名称和命名空间

  • [XmlAttribute]

    应用于 字段属性 成员,将其序列化为 XML 属性

  • [XmlElement]

    应用于 字段属性 成员,将其序列化为 XML 元素

  • [XmlRoot]

    应用于 类型

上述三者都可以指定 NameNamespace ,使用方式如下:

[XmlRoot("Candidate", Namespace = "http://mynamespace/test/")]
public class Person
{
    [XmlElement("FirstName", Namespace = "http://mynamespace/test2/")] public string Name;
    [XmlAttribute("RoughAge", Namespace = "http://mynamespace/test3/")] public int Age;
}
<?xml version="1.0" encoding="utf-8"?>
<Candidate xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:xsd="http://www.w3.org/2001/XMLSchema"
  p3:RoughAge="30" xmlns:p3="http://mynamespace/test3/"
  xmlns="http://mynamespace/test/">
  <FirstName xmlns="http://mynamespace/test2/">Stacey</FirstName>
</Candidate>

17.8.1.2 XML 元素顺序

XmlSerializer​ 会按照 成员定义 顺序进行序列化,可以通过 XmlElement ​特性的 Order ​参数调整:

public class Person
{
    [XmlElement(Order = 2)] public string Name;
    [XmlElement(Order = 1)] public int Age;
}

一旦使用了 Order​ 参数,则所有需要序列化的成员 都需要使用该参数

反序列化器并不关心元素的顺序,不论元素以何种顺序出现都会被恰当地反序列化

17.8.2 子类和子对象

17.8.2.1 派生类的标注

对于派生类,可以在父类上使用 [XmlInclude] ​​ 特性进行标注,或将 Type[] ​​ 传入 XmlSerializer​​ 构造器:

// 原代码,无法兼容 Student 和 Teacher 类
public void SerializePerson(Person p, string path){
    XmlSerializer xs = new XmlSerializer(typeof(Person));
    using(Stream s = File.Create(path))
        xs.Serialize(s, p);
}

public class Student : Person { }
public class Teacher : Person { }

public class Person
{
    public string Name;
}
public class Student : Person { }
public class Teacher : Person { }
// 方法1,使用 XmlInclude 标注可能的子类
[XmlInclude(typeof(Student))]
[XmlInclude(typeof(Teacher))]
public class Person
{
    public string Name;
}
// 方法2,构造函数传入派生类型
public void SerializePerson(Person p, string path)
{
    XmlSerializer xs = new XmlSerializer(typeof(Person), 
        new Type[] { typeof(Student), typeof(Teacher) });
    using(Stream s = File.Create(path))
        xs.Serialize(s, p);
}

两种方式序列化器都会使用 type 属性 来记录子类的类型(就像数据契约格式化器那样),反序列化器可以根据 type 属性 匹配对应的类型:

<Person xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:xsd="http://www.w3.org/2001/XMLSchema"
  xsi:type="Teacher">
  <Name>John</Name>
</Person>

C7.0 核心技术指南 第7版.pdf - p768 - C7.0 核心技术指南 第 7 版-P768-20240228132004

17.8.2.2 序列化引用类型成员

XmlSerializer​ 会自动递归对象引用,例如:

public class Personn{
    public string Name;
    public Address HomeAddress = new Address();
}

public class Address {
    public string Street;
    public string PostCode;
}
<Person ... ">
  <Name>Stacey</Name>
  <HomeAddress>
    <Street>Odo St</Street>
    <PostCode>6020</PostCode>
  </HomeAddress>
</Person>

C7.0 核心技术指南 第7版.pdf - p769 - C7.0 核心技术指南 第 7 版-P769-20240228173145

17.8.2.3 引用类型成员的派生类

我们在17.8.2.1 派生类的标注提到可以用 [XmlInclude]​ 在 类上标记 派生 类:

public class Student : Person { }
public class Teacher : Person { }
// 方法1,使用 XmlInclude 标注可能的子类
[XmlInclude(typeof(Student))]
[XmlInclude(typeof(Teacher))]
public class Person
{
    public string Name;
}

还可以通过 [XmlElement] ​ 特性在成员上标注 派生 类型:

public class Person
{
    public string Name;
    [XmlElement("Address", typeof(Address))]
    [XmlElement("AUAddress", typeof(AUAddress))]
    [XmlElement("USAddress", typeof(USAddress))]
    public Address HomeAddress = new USAddress();
}
<Person ... >
  <Name>Stacey</Name>
  <USAddress>
    <Street>Odo St</Street>
    <PostCode>6020</PostCode>
  </USAddress>
</Person>

每一个 [XmlElement] ​会将一个类型映射为一个元素名称。使用了该方式后将不再需要 [XmlInclude]​ 指定每一种 Address 子类类型(但即使使用了,也不会影响序列化的结果)。

C7.0 核心技术指南 第7版.pdf - p770 - C7.0 核心技术指南 第 7 版-P770-20240228180100

17.8.3 序列化集合

XmlSerializer​ 可以自动识别和序列化具体的集合类型:

public class Person {
    public string Name;
    public List<Address> Addresses = new List<Address>();

    // 等效于
    // [XmlElement("Address")]
    // public List<Address> Addresses = ...
}

public class Address {
    public string Street;
    public string PostCode;
}
<Person ...>
  <Name>Stacey</Name>
  <Addresses>
    <Address>
      <Street>Odo St</Street>
      <PostCode>6020</PostCode>
    </Address>
    <Address>
      <Street>Odo St</Street>
      <PostCode>6021</PostCode>
    </Address>
  </Addresses>
</Person>

对于集合,可以使用 [XmlArray] ​、 [XmlArrayItem] ​ 修改 XML 中的集合名称、Item 名称、Namespace:

public class Person
{
    public string Name;
    [XmlArray("PreviousAddresses")]
    [XmlArrayItem("Location")]
    public List<Address> Addresses = new List<Address>();
}
<Person ...>
  <Name>Stacey</Name>
  <PreviousAddresses>
    <Location>
      <Street>Odo St</Street>
      <PostCode>6020</PostCode>
    </Location>
    <Location>
      <Street>Odo St</Street>
      <PostCode>6021</PostCode>
    </Location>
  </PreviousAddresses>
</Person>

序列化子类集合元素

我们在17.8.2.3 引用类型成员的派生类提到可以用 [XmlInclude] ​ 和 [XmlElement] ​ 告知序列化器该类型的子类。除这两个特性外,集合还可以使用 [XmlArrayItem] ​ 进行标注:

public class Person
{
    public string Name;
    public List<Address> Addresses = new List<Address>();
}

[XmlInclude(typeof(AUAddress))]
[XmlInclude(typeof(USAddress))]
[XmlInclude(typeof(Address))]
public class Address
{
    public string Street;
    public string PostCode;
}
public class AUAddress : Address { }
public class USAddress : Address { }
public class Person {
    public string Name;

    [XmlElement(nameof(Address), typeof(Address))]
    [XmlElement(nameof(AUAddress), typeof(AUAddress))]
    [XmlElement(nameof(USAddress), typeof(USAddress))]
    public List<Address> Addresses = new List<Address>();
}
public class Person {
    public string Name;

    [XmlArrayItem(nameof(Address), typeof(Address))]
    [XmlArrayItem(nameof(AUAddress), typeof(AUAddress))]
    [XmlArrayItem(nameof(USAddress), typeof(USAddress))]
    public List<Address> Addresses = new List<Address>();
}

Notice

必须进行标注,否则序列化将抛出 InvalidOperationException

17.8.4 IXmlSerializable​ 接口

Info

更多内容见11.3 XmlReader/XmlWriter 的使用模式

基于特性的 XML 序列化较为灵活,但也有局限性(无法添加钩子、无法序列化公有成员)。我们可以通过实现 IXmlSerializable​ 接口改善。接口定义如下:

public interface IXmlSerializable
{
    XmlSchema GetSchema();
    void ReadXml(XmlReader reader);
    void WriteXml(XmlWriter writer);
}

IXmlSerializable​ 实现原则如下

  1. ReadXml​ 应当:

    1. 读取 最外层起始元素
    2. 读取 内容
    3. 读取 最外层结束元素

    读取最外层元素会检查节点是否正确,保证了 XML 的逻辑结构正确。

  2. WriteXml​​ 应当 只写入内容

实现方式如下:

public class Address : IXmlSerializable
{
    public string Street;
    public string PostCode;
  
    public XmlSchema GetSchema() => null;

    public void ReadXml(XmlReader reader)
    {
        reader.ReadStartElement();
        Street = reader.ReadElementContentAsString(nameof(Street), "");
        PostCode = reader.ReadElementContentAsString(nameof(PostCode), "");
        reader.ReadEndElement();
    }

    public void WriteXml(XmlWriter writer)
    {
        writer.WriteElementString(nameof(Street), Street);
        writer.WriteElementString(nameof(PostCode), PostCode);
    }
}

ReadStartElement()​​ 和 ReadEndElement()​​ 方法

  • ReadStartElement()方法:当 XmlReader​ 遇到一个元素的开始标签时,调用此方法会验证当前节点是否为元素(Element)节点,并且(如果提供了参数)这个元素的名称是否与期望相匹配。如果一切符合预期,XmlReader​ 移动到下一个节点,准备读取元素内部的数据或子元素。如果不符合预期(比如元素名不匹配或当前节点不是元素节点),则会抛出异常。无参数的 ReadStartElement()​ 方法简单地执行检查并向前移动,不指定元素名称。
  • ReadEndElement()方法:调用此方法会验证当前节点是否为元素的结束标签。这在处理嵌套元素时尤其有用,因为它可以确保 XML 文档结构的正确性。如果当前节点确实是结束标签,XmlReader​ 将移动到下一个节点;如果不是,则抛出异常。这有助于确保序列化或反序列化的逻辑正确地处理了 XML 元素的开始和结束,避免了数据丢失或格式错误。

检查作用

  • 有参调用:当提供参数(即元素名)给 ReadStartElement​ 或 ReadEndElement​ 时,这些方法除了读取元素外,还会检查当前元素是否与提供的名称匹配。这为 XML 文档的结构正确性提供了一层额外的验证。
  • 无参调用:即使是无参的 ReadStartElement()​ 和 ReadEndElement()​,也会执行基本的检查,确保当前操作符合 XML 结构的要求(例如,确保当前确实处于元素的开始或结束)。这种方式提供了一种保证 XML 读取逻辑结构正确性的机制,尽管它不检查特定的元素名。

通过这样的机制,IXmlSerializable​ 的实现确保了在序列化和反序列化过程中,XML 文档的结构得到了正确处理,有效避免了数据错误或格式不一致的问题。这些方法提供了对 XML 文档读写过程中错误检查和控制的能力,是实现自定义 XML 序列化逻辑的重要部分。

GetSchema()​ 方法

GetSchema​ 方法允许开发者定义和控制自定义对象序列化到 XML 时的准确结构。这对于确保 XML 文档符合特定的 schema 非常有用,特别是在需要与其他系统交换数据时,这些系统可能依赖于特定的 schema 来正确解析和处理数据。

使用场景

  • 互操作性:当你的.NET 应用需要与其他系统(可能是基于不同技术栈的)交换数据时,GetSchema​ 方法提供的 schema 可以帮助确保生成的 XML 文档符合预期的格式和标准,从而提高系统间的互操作性。
  • 数据验证:通过提供一个 schema,可以在创建或消费 XML 文档时进行验证,确保数据的完整性和准确性。

注意事项

在.NET Framework 中,IXmlSerializable.GetSchema​ 方法的默认实现通常返回 null,表示没有 schema。这意味着,除非你需要提供特定的 schema 来约束你的 XML 结构,否则你不必实现这个方法。在实际使用中,许多开发者选择让这个方法返回 null,并通过其他方式(如文档或外部的 XML Schema 定义)来共享他们的数据结构。

示例代码

public class MyCustomType : IXmlSerializable
{
    public XmlSchema GetSchema()
    {
        // 返回 null,表示没有 schema
        return null;
    }

    public void ReadXml(XmlReader reader)
    {
        // 实现反序列化逻辑
    }

    public void WriteXml(XmlWriter writer)
    {
        // 实现序列化逻辑
    }
}

总的来说,IXmlSerializable.GetSchema​ 方法提供了一种机制来描述自定义类型的 XML 表示形式的 schema,但在实际应用中,直接返回 null 也是一种常见的做法,特别是当 schema 定义在其他地方或者不需要强制约束 XML 结构时。

Info

上述内容另见第11章 其他 XML 技术

posted @   hihaojie  阅读(8)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
点击右上角即可分享
微信分享提示