Christoph Schittko
适用于:
Microsoft ®Visual Studio ®.NET
摘要:Christoph Schittko 讨论了各种相关技巧,以便诊断在使用 .NET 框架中的 XML 序列化技术将 XML 转换为对象(以及反向转换)时发生的常见问题。
本页内容
简介 | |
XmlSerializer 的内部工作方式 | |
序列化错误 | |
声明序列化类型 | |
反序列化 XML 时发生的问题 | |
来自构造函数的异常 | |
小结 | |
致谢 |
简介
.NET 框架中的 XmlSerializer 是一种很棒的工具,它将高度结构化的 XML 数据映射到 .NET 对象。XmlSerializer 在程序中通过单个 API 调用来执行 XML 文档和对象之间的转换。转换的映射规则在 .NET 类中通过元数据属性来表示。这一编程模型带有自己的错误类别,开发人员需要了解如何诊断这些错误。例如,元数据属性必须描述序列化程序可以处理的 XML 格式的所有变体。本文研究了在使用 XmlSerializer 构建基于 XML 的解决方案时可能发生的各种错误,并且讨论了用来诊断这些错误的技巧和工具。
XmlSerializer 的内部工作方式
为了有效地解决在 XML 序列化过程中出现的问题,需要了解一下在非常简单的 XmlSerializer 接口的内部发生了什么事情。与传统的分析范型相反,.NET 框架中 System.Xml.Serialization 命名空间的 XmlSerializer 将 XML 文档绑定到 .NET 类的实例。程序员不再需要编写 DOM 或 SAX 分析代码,而是通过直接在这些类中附加 .NET 元数据属性来声明性地设置绑定规则。因为所有分析规则都是通过属性表示的,所以 XmlSerializer 的接口非常简单。它主要由两个方法组成:Serialize() 用于从对象实例生成 XML;Deserialize() 用于将 XML 文档分析成对象图。
在使用强类型的、能够完美地映射到编程对象的结构严谨的 XML 格式时,这种方法非常有效。如果格式由 W3C架构定义,并且该架构由不带混合型内容或且不过度使用通配符(xs:any 和 xs;anyAttribute)的 complexType 组成,则 XML 序列化是处理该数据的好方法。
面 向消息的应用程序就是一个很好的例子,这些应用程序之间的交换格式已预先定义。因为许多消息驱动的企业应用程序都具有非常高的吞吐量要求,所以 Serialize() 和 Deserialize() 方法被设计为具有很高的执行速度。实际上,正是 XmlSerializer 为 System.Messaging 命名空间中的具有高度可伸缩性的库、ASP.NET Web 服务和 BizTalk Server 2004 提供了动力。
为获得 XmlSerializer 的高性能,需要付出双重代价。首先是与给定 XmlSerializer 可以处理的 XML 格式有关的灵活性,其次是实例的构造需要进行大量的处理。
当您实例化 XmlSerializer 时,必须传递您试图通过该序列化程序实例进行序列化和反序列化的对象的类型。序列化程序将检查该类型的所有公共字段和属性,以了解一个实例在运行时引用哪些类型。接下来,它将为一组类创建 C# 代码,以便使用 System.CodeDOM 命名空间中的类处理序列化和反序列化。在此过程中,XmlSerializer 将检查 XML 序列化属性的反射类型,以便根据 XML 格式定义来自定义所创建的类。这些类随后被编译为临时程序集,并由 Serialize() 和 Deserialize() 方法调用以执行 XML 到对象的转换。
这个设置 XmlSerializer 的精巧过程和声明性编程模型导致了三类错误,其中一些错误可能很难解决:
• | 所生成的序列化类期望被序列化的对象完全符合元数据属性所定义的类型结构。如果 XmlSerializer 遇到未声明(显式声明或者是通过 XML 序列化属性声明)的类型,则对象将无法序列化。 |
• | XML 文档在以下情况下无法反序列化:该文档的根元素不能映射对象类型;该文档的格式不正确,例如包含 XML 规范中定义为非法的字符;该文档违反基础架构的限制(在某些情形下)。 |
• | 最后,序列化类的创建及其随后的编译可能由于多种不同的原因而失败。当传递给构造函数的类型或者由该类型引用的类型实现了不受支持的接口或者不能满足 XmlSerializer 施加的限制时,类的创建可能会失败。 当附加的属性生成无法编译的 C# 代码时,编译步骤可能会失败。编译步骤也可能由于与安全有关的原因而失败。 |
下面各个部分将更深入地研究这些情况,并提供有关如何解决这些问题的指导和建议。
序列化错误
我们要研究的第一类错误发生在 Serialize() 方法中。当在运行时传递给该方法的对象图中的类型与在设计时在类中声明的类型不匹配时,将发生此类错误。您可以通过字段或属性的类型定义来隐式声明类型,也可以通过附加序列化属性来显式声明类型。
图 1. 对象图中的类型声明
这里需要指出的是,依靠继承是不够的。开发人员必须通过将 XmlInclude 属性附加到基类,或者通过将 XmlElement 属性附加到字段(这些字段可以容纳从所声明的类型派生的类型的对象),来声明 XmlSerializer 的派生类型。
例如,请看一下以下类层次结构:
public class Base
{
public string Field;
}
public class Derived
{
public string AnotherField;
}
public class Container
{
public Base MyField;
}
如果您依赖继承并且编写了与下面类似的序列化代码:
Container obj = new Container();
obj.MyField = new Derived(); // legal assignment in the
//.NET type system
// ...
XmlSerializer serializer = new XmlSerializer( typeof( Container ) );
serializer.Serialize( writer, obj ); // Kaboom!
您将得到发自 Serialize() 方法的异常,这是因为没有 XmlSerializer 的显式类型声明。
发自 XmlSerializer 的异常
诊断这些问题的根源在开始时可能比较困难,这是因为来自 XmlSerializer 的异常看起来并没有提供有关其产生原因的大量信息;至少,它们没有在开发人员通常会查看的地点提供信息。
在大多数情况下,当发生错误时,Serialize、Deserialize 甚至 XmlSerializer 构造函数都会引发一个相当普通的 System.InvalidOperationException。该异常类型可以在 .NET 框架中的许多地方出现;它根本不是 XmlSerializer 所特有的。更糟糕的是,该异常的 Message 属性也仅产生非常普通的信息。在上述示例中,Serialize() 方法会引发带有以下消息的异常:
There was an error generating the XML document.
该消息最多也就是令人讨厌的,因为当您看到 XmlSerializer 引发异常时,就已经猜到了这一点。现在,您只好无奈地发现该异常的 Message 无法帮助您解决问题。
奇 怪的异常消息和非描述性的异常类型反映了本文前面介绍的 XmlSerializer 内部工作方式。Serialize() 方法会捕获序列化类中引发的所有异常,将它们包装到 InvalidOperationException 中,然后将该异常包沿着堆栈向上传递。
读取异常消息
得 到“实际”的异常信息的窍门是检查该异常的 InnerException 属性。InnerException 引用了从序列化类内部引发的实际异常。它包含有关该问题及其发生地点的非常详细的信息。您在运行上述示例时捕获的异常将包含带有以下消息的 InnerException:
The type Derived was not expected. Use the XmlInclude or SoapInclude
attribute to specify types that are not known statically.
您可以通过直接检查 InnerException 或者通过调用该异常的 ToString() 方法来得到此消息。下面的代码片段演示了一个异常处理程序,它写出了在反序列化对象的过程中发生的所有异常中的信息:
public void SerializeContainer( XmlWriter writer, Container obj )
{
try
{
// Make sure even the construsctor runs inside a
// try-catch block
XmlSerializer ser = new XmlSerializer( typeof(Container));
ser.Serialize( writer, obj );
}
catch( Exception ex )
{
DumpException( ex );
}
}
public static void DumpException( Exception ex )
{
Console.WriteLine( "--------- Outer Exception Data ---------" );
WriteExceptionInfo( ex );
ex = ex.InnerException;
if( null != ex )
{
Console.WriteLine( "--------- Inner Exception Data ---------" );
WriteExceptionInfo( ex.InnerException );
ex = ex.InnerException;
}
}
public static void WriteExceptionInfo( Exception ex )
{
Console.WriteLine( "Message: {0}", ex.Message );
Console.WriteLine( "Exception Type: {0}", ex.GetType().FullName );
Console.WriteLine( "Source: {0}", ex.Source );
Console.WriteLine( "StrackTrace: {0}", ex.StackTrace );
Console.WriteLine( "TargetSite: {0}", ex.TargetSite );
}
声明序列化类型
要 解决上述示例中的问题,您只需读取 InnerException 的消息并实现建议的解决方案。传递给 Serialize 方法的对象图中的一个字段引用了一个类型为 Derived 的对象,但并未将该字段声明为序列化 Derived 类型的对象。尽管该对象图在 .NET 类型系统中完全合法,但 XmlSerializer 的构造函数在遍历容器类型的字段时,并不知道为 Derived 类型的对象创建了序列化代码,这是因为它没有找到对 Derived 类型的引用。
要向 XmlSerializer 声明其他字段和属性类型,您拥有多种选择。您可以通过 XmlInclude 属性(由异常消息提示)声明基类上的派生类型,如下所示:
[System.Xml.Serialization.XmlInclude( typeof( Derived ) )]
public class Base
{
// ...
}
通过附加 XmlInclude 属性,可以让 XmlSerializer 在字段或属性被定义为 Base 类型时序列化引用 Derived 类型对象的字段。
或 者,您还可以仅在单个字段或属性上声明有效类型,而不是在基类上声明派生类型。您可以将 XmlElement、XmlAttribute 或 XmlArrayItem 属性附加到字段,并且声明该字段或属性可以引用的类型。然后,XmlSerializer 的构造函数会将序列化和反序列化这些类型所需的代码添加到序列化类中。
读取 StackTrace
InnerException 的 Message 属性并不是唯一包含有价值信息的属性。StackTrace 属性传达了更多有关错误根源的详细信息。在堆栈跟踪的最顶端,您可以找到首先引发异常的方法的名称。临时程序集中的方法名称对于序列化类遵循格式 Write_,对于反序列化类则遵循格式 Read_。在具有上述错误命名空间的示例中,您可以看到异常源自名为 Read1_MyClass 的方法。稍后,我将向您说明如何使用 Visual Studio 调试器设置断点并单步执行此方法。不过,首先让我们看一下围绕反序列化 XML 文档发生的常见问题。
反序列化 XML 时发生的问题
将 XML 文档反序列化为对象图不像将对象图序列化为 XML 那样容易出错。当对象不十分匹配类型定义时,XmlSerializer 会非常敏感,但如果反序列化的 XML 文档不十分匹配对象,则它会非常宽容。对于与反序列化对象中的字段或属性不对应的 XML 元素,XmlSerializer 不再引发异常,而只是简单地引发事件。如果您需要跟踪反序列化的 XML 文档与 XML 格式之间的匹配程度,则可以注册这些事件的处理程序。然而,您不需要向 XmlSerializer 注册事件处理程序以正确处理未映射的 XML 节点。
在反序列化过程中,只有几种错误条件会导致异常。最常见的条件有:
• | 根元素的名称或其命名空间不匹配期望的名称。 |
• | 枚举数据类型呈现未定义的值。 |
• | 文档包含非法 XML。 |
就像序列化的情形一样,每当发生问题时,Deserialize() 方法都会引发带有以下消息的 InvalidOperation 异常
There is an error in XML document (, ).
该异常通常在 InnerException 属性中包含真正的异常。InnerException 的类型随读取 XML 文档时发生的实际错误而有所不同。如果序列化程序无法用传递给构造函数的类型、通过 XmlInclude 属性指定的类型或者在传递给 XmlSerializer 构造函数的某个更为复杂的重载的 Type[] 中指定的类型来匹配文档的根元素,则 InnerException 为 InvalidCastException。请记住,XmlSerializer 将查看 Qname(即元素的名称)和命名空间,以确定要将文档反序列化为哪个类。它们都必须匹配 .NET 类中的声明,以便 XmlSerializer 正确标识与文档的根元素相对应的类型。
让我们看一个示例:
[XmlRoot( Namespace="urn:my-namespace" )]
public class MyClass
{
public string MyField;
}
反序列化以下 XML 文档将导致异常,因为 MyClass 元素的 XML 命名空间并不像通过 .NET 类上的 XmlRoot 属性所声明的那样是 urn:my-namespace。
<MyClass> <MyField>Hello, World</MyField> </MyClass>
让我们更进一步地观察一下该异常。异常 Message 比您从 Serialize() 方法中捕获的消息更具描述性;至少它引用了文档中导致 Deserialize() 失败的位置。尽管如此,当您处理大型 XML 文档时,查看文档并确定错误可能不会如此简单。InnerException 又一次提供了更好的信息。这一次,它显示:
<MyClass xmlns=''> was not expected.
该消息仍然有一些模糊,但它的确向您指明了导致问题的元素。您可以回头仔细检查一下 MyClass 类,并将元素名称和 XML 命名空间与 .NET 类中的 XML 序列化属性进行比较。
反序列化无效的 XML
另一个经常报告的问题是无法反序列化无效的 XML 文档。XML 规范禁止在 XML 文档中使用某些控制字符。 然而,有时您仍然会收到包含这些字符的 XML 文档。正如您猜想的那样,问题暴露在 InvalidOperationException 中。尽管如此,在这种特殊情况下,InnerException 的类型是 XmlException。InnerException 的消息正中要害:
hexadecimal value <value>, is an invalid character
如果您通过将其 Normalization 属性设置为 true 的 XmlTextReader 进行反序列化,则可以避免此问题。遗憾的是,ASP.NET Web 服务在内部使用的 XmlTextReader 将其 Normalization 属性设置为 false;也就是说,它将不会反序列化包含这些无效字符的 SOAP 消息。
来自构造函数的异常
本文讨论的最后一类问题发生在 XmlSerializer 的构造函数对传入的类型进行分析的时候。请记住,构造函数将递归检查类型层次结构中的每个公共字段和属性,以便创建用来处理序列化和反序列化的类。然后,它将即时编译这些类,并加载得到的程序集。
在这一复杂的过程中,可能会发生许多不同的问题:
• | 根元素的声明类型或者由属性或字段引用的类型不提供默认的构造函数。 |
• | 层次结构中的某个类型实现了集合接口 Idictionary。 |
• | 执行对象图中某个类型的构造函数或属性访问器时,需要提升安全权限。 |
• | 生成的序列化类的代码无法编译。 |
试 图向 XmlSerializer 构造函数传递不可序列化的类型也会导致 InvalidOperationException,但这一次该异常不会包装其他异常。Message 属性包含对构造函数拒绝传入“类型”的原因的充分解释。试图序列化未实现不带参数的构造函数(默认构造函数)的类的实例时,将产生带有以下 Message 的异常:
Test.NonSerializable cannot be serialized because it does not have a default public constructor.
另一方面,解决编译错误是非常复杂的。这些问题暴露在带有以下消息的 FileNotFoundException 中:
File or assembly name abcdef.dll, or one of its dependencies, was not found. File name: "abcdef.dll"
at System.Reflection.Assembly.nLoad( ... )
at System.Reflection.Assembly.InternalLoad( ... )
at System.Reflection.Assembly.Load(...)
at System.CodeDom.Compiler.CompilerResults.get_CompiledAssembly()
....
您可能不知道“找不到文件”异常与实例化序列化程序对象之间有什么关系,但请记住:构造函数写入 C# 文件并试图编译这些文件。该异常的调用堆栈提供了一些有用的信息,为这种怀疑提供了依据。当 XmlSerializer 试图加载由调用 System.Reflection.Assembly.Load 方法的 CodeDOM 生成的程序集时,发生了该异常。该异常没有提供有关 XmlSerializer 根据推测要创建的程序集不存在的原因的解释。通常,该程序集不存在的原因是编译失败,这是由于序列化属性生成了 C# 编译器无法编译的代码,但这种情况很少出现。
注 当 XmlSerializer 运行时所属的帐户或安全环境无法访问 temp 目录时,也会发生该错误。
XmlSerializer 所引发的任何异常错误消息都不包含实际的编译错误,甚至连 InnerException 也不包含实际的编译错误。这使得解决这些异常变得非常困难,直到 Chris Sells 发布了他的 XmlSerializerPrecompiler 工具。
XmlSerializerPreCompiler
XmlSerializer PreCompiler 是一个命令行程序,它执行与 XmlSerializer 的构造函数相同的步骤。它可分析类型,生成序列化类,并编译这些类 — 因为它被纯粹设计为故障排除工具,所以它可以安全地向控制台写入任何编译错误。
该工具使用起来非常方便。您只需使该工具指向包含导致异常的类型的程序集,并指定要预编译的类型。让我们看一个示例。当您将 XmlElement 或 XmlArrayItem 属性附加到定义为交错数组的字段时,会发生一个经常报告的问题,如下面的示例所示:
namespace Test
{
public class StringArray
{
[XmlElement( "arrayElement", typeof( string ) )]
public string [][] strings;
}
}
在为类型 Test.StringArray 实例化 XmlSerializer 对象时,XmlSerializer 构造函数会引发 FileNotFoundException。如果您编译该类并试图序列化该类的实例,将得到 FileNotFoundException,但不会得到有关该问题实质的线索。XmlSerializerPreCompiler 可以为您提供缺少的信息。在我的示例中,StringArray 类被编译为名为 XmlSer.exe 的程序集,并且我必须用下面的命令行运行该工具:
XmlSerializerPreCompiler.exe XmlSer.exe Test.StringArray
第一个命令行参数指定了程序集,第二个参数定义了该程序集中要预编译的类。该工具会向命令窗口写入大量信息。
图 2. XmlSerializerPreCompiler 命令窗口输出
需要查看的重要代码行是具有编译错误的代码行以及两个与以下内容类似的代码行:
XmlSerializer-produced source:
C:\DOCUME~1\\LOCALS~1\Temp\.cs
现在,XmlSerializerPreCompiler 为我们提供了编译错误以及含有无法编译的代码的源文件的位置。
调试序列化代码
通常情况下,XmlSerializer 会在不再需要序列化类的 C# 源文件时将其删除。然而,有一个未经证实的诊断开关,可用来指示 XmlSerializer 将这些文件保留在硬盘上。您可以在应用程序的 .config 文件中设置此开关:
<?xml version="1.0" encoding="utf-8" ?> <configuration> <system.diagnostics> <switches> <add name="XmlSerialization.Compilation" value="4" /> </switches> </system.diagnostics> </configuration>
若此开关出现在 .config 文件中,C# 源文件将保留在 temp 目录中。如果您使用的计算机运行 Windows 2000 或更高版本,则 temp 目录的默认位置是 \Documents and Settings\\LocalSettings\Temp 或 \Temp(对于在 ASP.NET 帐户下运行的 Web 应用程序)。这些 C# 文件很容易丢失,因为它们的文件名看起来非常奇怪并且是随机生成的,例如:bdz6lq-t.0.cs。 XmlSerializerPreCompiler 可设置该诊断开关,因此您可以在记事本或 Visual Studio 中打开这些文件,以检查 XmlSerializerPreCompiler 对其报告编译错误的代码行。
您甚至可以逐句执行这些临时序列化类,因为诊断开关还可以 将含有调试符号的 .pdb 文件保留在硬盘上。如果您需要在序列化类中设置断点,则可以在 Visual Studio 调试器下运行应用程序。一旦您在输出窗口中看到相关消息,表明应用程序已经从 temp 目录中加载了具有这些奇特名称的程序集,就可以打开具有相应名称的 C# 文件,然后像在您自己的代码中一样设置断点。
图 3. 来自诊断开关的编译错误输出
在序列化类中设置断点之后,您需要执行代码以调用 XmlSerializer 对象上的 Serialize() 或 Deserialize() 方法。
注 您只能调试序列化和反序列化,而不能调试在构造函数中运行的代码生成过程。
通 过在序列化类中单步执行,您能够查明每个序列化问题。如果您要单步执行 SOAP 消息的反序列化,则可以使用上述技巧,这是因为 ASP.NET Web 服务和 Web 服务代理是在 XmlSerializer 之上构建的。您需要做的只是将诊断开关添加到您的 config 文件中,然后在反序列化消息的类中设置断点。如果 WSDL 在生成代理类时没有准确地反映消息格式,则我偶尔会使用上述技巧来判断正确的序列化属性集。
小结
这 些提示应该可以帮助您诊断 XmlSerializer 中的序列化问题。您遇到的大多数问题都源自 XML 序列化属性的错误组合,或源自与要反序列化的类型不匹配的 XML。序列化属性控制序列化类的代码生成,并且可能导致编译错误或运行时异常。通过仔细地检查由 XmlSerializer 引发的异常,可帮助您识别运行时异常的根源。如果您需要进一步诊断问题,则可以使用 XmlSerializerPreCompiler 工具来帮助您查找编译错误。如果任一种方法都不能帮助您找到问题的根源,则可以检查自动创建的序列化类的代码,并在调试器中逐句执行这些代码。
致谢
在此感谢 Dare Obasanjo 和 Daniel Cazzulino 对本文提供了反馈和编辑建议。