用于本机 C++ 的小巧快捷的 XML 分析器
Kenny Kerr
本文讨论:
| 本文使用了以下技术: XML、C++ |
尽管 .NET Framework 不断取得成功,Microsoft 仍然认真对待本机 C++ 开发。通过引入 XmlLite(适合于用本机 C++ 编写的应用程序的高性能、低开销 XML 读取器和写入器)说明了这一点。
托管代码通过 System.Xml 命名空间广泛支持 XML,而依赖于 COM 的传统 Visual Basic
® 和 C++ 应用程序可以访问 Microsoft
® XML 核心服务 (MSXML) 中的类似功能。但是,这些并没有为需要快速精简的 XML 分析器的本机 C++ 开发人员提供富有吸引力的选项。开始使用 XmlLite 吧。
本文将探讨您可以对 XmlLite 执行的操作。但是,首先,为设定预期,我希望快速回顾一下 XmlLite 未提供的内容,至少是此初始版本中未提供的内容。对于初学者,它既未提供文档对象模型 (DOM) 实现,也未提供 XML 架构或文档类型定义 (DTD) 验证。它还缺少对高级工具的支持,例如基于光标的导航(如 XPath)、样式表和序列化。但是,通过建立在 XmlLite 之上的功能,可以根据需要填补任何空白,Microsoft .NET Framework 中的几乎所有 XML 功能同样都建立在 XmlReader 和 XmlWriter 类之上。
那么,XmlLite 提供了哪些内容?简单地说,它提供了非缓存的只进分析器(提供接收式编程模型)和非缓存的只进 XML 生成器。已证明这两者是非常有价值的功能。
为什么推出新的 XML 分析器?
开发人员日益熟悉他们每天使用的库,通过广泛使用 XML,他们肯定会询问有关新推出的 XML 分析器的一些疑难问题。要了解这一新分析器的价值,让我们首先考虑一下 XML 分析器当今的情形。
很自然地,如果应用程序已经利用 .NET Framework,则决定通常是很简单的:只需使用 System.Xml 即可。为证明这一点,XmlLite 的设计基于 .NET Framework 中 XmlReader 和 XmlWriter 类的设计。从以 C++ 编写的托管应用程序使用 XmlLite 通常没有优势。XmlLite 的功能毕竟比 XmlReader 和 XmlWriter 类提供的功能要少得多。(
图 1 中的表略述 XmlLite 中的主要类型如何映射到 .NET Framework 中的主要类型。)另一方面,如果应用程序仅使用本机代码,那么就 Microsoft 技术而言,MSXML 在传统上是所选的解决方案。
![](http://i.msdn.microsoft.com/Global/Images/clear.gif)
XmlLite | .NET Framework |
---|---|
IXmlReader 接口 | XmlReader 类 |
IXmlWriter 接口 | XmlWriter 类 |
XmlReaderProperty 枚举 | XmlReaderSettings 类 |
XmlWriterProperty 枚举 | XmlWriterSettings 类 |
CreateXmlReaderInputWithEncodingCodePage CreateXmlReaderInputWithEncodingName CreateXmlWriterOutputWithEncodingCodePage CreateXmlWriterOutputWithEncodingName | 编码类 |
XmlNodeType 枚举 | XmlNodeType 枚举 |
MSXML 提供了两个差异很大的 XML 分析器。第一个分析器是在各种情形下可用的 DOM 实现。如果使用较小的 XML 文档且需要随机访问 XML 文档进行内存中读取和写入,则 DOM 实现是一种合理的选择。MSXML 的更高版本引入了“用于 XML 的简单 API (SAX2)”的实现。它实际上是否简单是有争议的。使用 SAX2 时(甚至在开始之前),您需要实现至少两个 COM 接口:一个用于接收 XML 文档中各个节点的通知,另一个用于接收分析错误的通知。
将 SAX2 实现添加到 MSXML 的原因如下:与 DOM 实现不同,SAX2 分析器以数据流形式读取 XML 文档,并通知您何时到达各个节点。这意味着,您的应用程序的内存使用量并不随所分析文档的大小而增加。
SAX2 存在的问题以及 .NET Framework 不提供其实现的原因在于 SAX2 模型的内在复杂性。它要求实现接口或事件,并强制开发人员使用更为间接的编程模型,要求开发人员管理注定会使应用程序变得复杂的其他状态。相反,.NET Framework 中的 XmlReader 和 XmlWriter 类以及 XmlLite 的 IXmlReader 和 IXmlWriter 接口提供了简单易懂的分析器,可以直接在函数中使用,而不必管理任何外部状态或通知。
由于其设计的简明性,XmlLite 能够提供相当好的性能,即使与 MSXML SAX2 实现相比也是如此。虽然 SAX2 分析器可以比 DOM 实现更好地处理大型文档,但是与 XmlLite 相比就逊色了。
简单地说,XmlLite 优于 MSXML,且它更易于从本机 C++ 使用。MSXML 仍将是 Visual Basic 和基于 COM 的脚本语言的最可行解决方案,但是现在本机 Visual C++
® 最终具有了专门为它设计的 XML 分析器。虽然 Windows Vista™ 和更高版本中附带有 XmlLite,但是一个更新对于 Windows
® XP 和 Windows Server
® 2003 的 32 位和 64 位版本也是可用的。因为未涉及 COM 注册,所以此更新包应该不会导致 MSXML 通常造成的有关安装和版本控制的难题。
COM“Lite”
XmlLite 不仅是易记的名称;事实上,它是一个轻型 XML 分析器。XmlLite 利用了 COM 的精华,即编程规范和约定,并抛弃了复杂的和可能不必要的部分,如 COM 注册、运行时服务、代理、线程模型、封送处理等。
从 XmlLite.dll 导出的函数创建 XML 读取器和写入器。通过链接到 XmlLite.lib 并包括 Windows SDK 中的 XmlLite.h 头文件,可以访问它们。生成的 COM 样式接口使用熟悉的 IUnknown 接口方法进行生存期管理。COM IStream 接口也起到一定作用并表示存储器。除此之外,没有 COM 的依赖项;无需注册任何 COM 类或甚至调用强制性的 CoInitialize 函数。活动模板库 (ATL) CComPtr 类处理剩余的一小部分 COM。但是,您确实需要关注线程安全,因为出于单线程方案中的性能,XmlLite 不是线程安全的。
我在以下示例中使用 COM_VERIFY 宏,以便清晰地识别方法在何处返回需要检查的 HRESULT。可以将此替换为相应的错误处理 - 不管该操作引发异常还是您自己返回 HRESULT。
读取 XML
XmlLite 提供了返回 IXmlReader 接口实现的 CreateXmlReader 函数:
CComPtr<IXmlReader> reader; COM_VERIFY(::CreateXmlReader(__uuidof(IXmlReader), reinterpret_cast<void**>(&reader), 0));
虽然是可选的,但是 CComPtr 类模板确保迅速释放接口指针。
CreateXmlReader 接受接口标识符 (IID) 以及指向 void 指针的指针。这是 COM 编程中的常见模式,允许调用方指定要返回的接口指针的类型。我的示例使用 __uuidof 运算符,该运算符是 Microsoft 特定的关键字,用于提取与类型关联的 GUID。在这种情况下,它用于检索接口的 IID。CreateXmlReader 的最后一个参数接受可选的 IMalloc 实现以允许调用方控制内存分配。
创建读取器后,需要指示读取器将用作输入的存储器。IStream 接口表示存储器,这样就可以将 XmlLite 与可能设计的任何流实现一起使用:
CComPtr<IStream> stream; // Create stream object here... COM_VERIFY(reader->SetInput(stream));
(我将在本文的后面部分中讨论流。)
设置 XML 读取器的输入后,可以通过重复调用 Read 方法进行读取。Read 方法接受一个可选参数,该参数在每次成功调用时返回节点类型。Read 方法返回 S_OK 以指示已从流中成功读取下一个节点,返回 S_FALSE 以指示已到达流的结尾处。以下是如何依次枚举节点的一个示例:
HRESULT result = S_OK; XmlNodeType nodeType = XmlNodeType_None; while (S_OK == (result = reader->Read(&nodeType))) { // Get node-specific info }
要枚举当前节点的属性,请使用 MoveToFirstAttribute 和 MoveToNextAttribute 方法。如果已成功地重新定位读取器,则这两种方法都返回 S_OK;如果不存在更多的属性,则返回 S_FALSE。以下示例说明如何依次枚举给定节点的属性:
for (HRESULT result = reader->MoveToFirstAttribute(); S_OK == result; result = reader->MoveToNextAttribute()) { // Get attribute-specific info }
调用 IXmlReader 的 Read 方法时,它会将任何节点属性自动存储在内部集合中。这样,您就可以使用 MoveToAttributeByName 方法,按名称将读取器移动到特定的属性。但是,枚举属性并将其存储在应用程序特定的数据结构中,效率通常更高。请注意,您还可以使用 GetAttributeCount 方法确定当前节点中的属性数。
确定节点或属性后,获取其信息就很简单了。以下示例演示如何获取给定节点的命名空间 URI 和本地名称:
PCWSTR namespaceUri = 0; UINT namespaceUriLength = 0; COM_VERIFY(reader->GetNamespaceUri(&namespaceUri, &namespaceUriLength)); PCWSTR localName = 0; UINT localNameLength = 0; COM_VERIFY(reader->GetLocalName(&localName, &localNameLength));
返回字符串值的所有 IXmlReader 方法都遵循此模式。第一个参数接受指向宽字符指针常量的指针。第二个参数是可选的;如果它不为零,则它将返回以字符度量的字符串长度(不包括空结束符)。
以下是强调性能的另一个示例。仅在将读取器移动到其他节点或以某种其他方式(如通过设置新的输入流或释放 IXmlReader 接口)使当前节点无效之前,从 IXmlReader 方法返回的字符串指针才是有效的。换句话说,IXmlReader 不会将流的副本返回给调用方。
与其在 .NET Framework 中的对应方不同,IXmlReader 未提供读取键入内容的任何方法。例如,如果特定的元素或属性包含数字或日期,则您需要首先获取其字符串表示形式,然后根据需要自己进行转换。.NET Framework 的 XmlReader 类中存在的许多其他 helper 方法也不存在于 IXmlReader 中,但是可以作为 helper 函数编写。XmlLite 确实符合最小接口设计的 C++ 理论。
图 2 显示使用 IXmlReader 读取 XML 文档时涉及的对象和抽象。但是,请牢记,IStream 可以抽取任何存储,此处显示的文件仅仅是一个常见示例。
![](http://i.msdn.microsoft.com/cc163436.fig02%28zh-cn%29.gif)
图 2
读取器
写入 XML
XmlLite 提供了返回 IXmlWriter 接口实现的 CreateXmlWriter 函数:
CComPtr<IXmlWriter> writer; COM_VERIFY(::CreateXmlWriter(__uuidof(IXmlWriter), reinterpret_cast<void**>(&writer), 0));
创建写入器后,需要指示写入器将用作输出的存储器:
CComPtr<IStream> stream; // Create stream object here COM_VERIFY(writer->SetOutput(stream));
开始写入之前,可以修改写入器属性。XmlWriterProperty 枚举定义可用的属性。例如,您可能希望指定是否缩进 XML 输出以便于读者阅读(使用 SetProperty 方法可以做到这一点):
COM_VERIFY(writer->SetProperty(XmlWriterProperty_Indent, TRUE));
然后可以开始使用 IXmlWriter 方法写入基础流。XmlLite 支持 XML 片段。如果计划写入完整的 XML 文档,则应该从调用 WriteStartDocument 方法(它负责写入 XML 声明)开始。声明取决于所用的编码,但是默认编码为 UTF-8,它在大多数情况下都应该是合适的。(稍后将介绍文本编码。)提供了许多 WriteXxx 方法,用于写入各种节点类型、属性和值。
请考虑以下示例:
COM_VERIFY(writer->WriteStartDocument(XmlStandalone_Omit)); COM_VERIFY(writer->WriteStartElement(0, L"html", L"http://www.w3.org/1999/xhtml")); COM_VERIFY(writer->WriteStartElement(0, L"head", 0)); COM_VERIFY(writer->WriteElementString(0, L"title", 0, L"My Web Page")); COM_VERIFY(writer->WriteEndElement()); // </head> COM_VERIFY(writer->WriteStartElement(0, L"body", 0)); COM_VERIFY(writer->WriteElementString(0, L"p", 0, L"Hello world!")); COM_VERIFY(writer->WriteEndDocument());
WriteStartDocument 方法处理将 XML 声明写入流的操作。它只有一个参数,该参数接受来自 XmlStandalone 枚举的值,指示是否出现独立的文档声明,如果是这样,则指示它保存的值。写入 XML 片段时,通常省略对 WriteStartDocument 的调用。
WriteStartElement 方法接受以下三个参数:第一个参数指定元素的可选命名空间前缀,第二个参数指定元素的本地名称,第三个参数指定可选的命名空间 URI。WriteElementString 是 XmlLite 提供的非常方便的方法之一。用于写入 XHTML 文档标题的以下代码等效于上一示例中使用的 WriteElementString:
COM_VERIFY(writer->WriteStartElement(0, L"title", 0)); COM_VERIFY(writer->WriteString(L"My Web Page")); COM_VERIFY(writer->WriteEndElement());
显然,WriteElementString 方法不是绝对必要的,但它确实很有用。
最后,WriteEndDocument 方法用于关闭文档。您可能已注意到,未显式关闭 body 和 html 元素。WriteEndDocument 会自动关闭任何打开的元素。就此而言,释放写入器也会关闭任何剩余的元素。但是,如果您不小心,则未显式关闭此类元素的做法可能会导致错误,因为流的生存期和写入器的生存期通常可以不同。要说的是,如果需要确保已将所有要写入的内容写入基础流,则只需调用 IXmlWriter 的 Flush 方法即可。
图 3 显示使用 IXmlWriter 写入 XML 文档时涉及的对象和抽象流。请牢记,IStream 可以抽取任何存储,此处的文件仅仅是一个常见示例。
![](http://i.msdn.microsoft.com/cc163436.fig03%28zh-cn%29.gif)
图 3
写入器
使用流
到此为止,我对流进行的介绍并不多。与一些功能更全面的 XML 库不同,XmlLite 未提供任何支持从公共存储位置(如文件或通过网络协议)读取和向其写入的功能。正因为这一点,对于希望从其读取或向其写入的任何存储器,您都需要提供 IStream 实现。实现 IStream 接口并不复杂,但是在许多情况下,您不需要执行此操作,因为实现可能已存在。
CreateStreamOnHGlobal 函数提供由虚拟内存支持的 IStream 实现。第一个参数是使用 GlobalAlloc 函数创建的可选内存句柄。但是,只需传递零,CreateStreamOnHGlobal 即可为您创建内存对象。以下示例创建一个由系统内存支持且将根据需要动态增长的 IStream 实现:
CComPtr<IStream> stream; COM_VERIFY(::CreateStreamOnHGlobal(0, TRUE, &stream));
释放流将释放内存。
SHCreateStreamOnFile 函数提供了另一个有用的 IStream 实现。它创建由文件支持的 IStream:
CComPtr<IStream> stream; COM_VERIFY(::SHCreateStreamOnFile(L"D://Sample.xml", STGM_WRITE | STGM_SHARE_DENY_WRITE, &stream));
读取时的文本编码
虽然默认情况下 XmlLite 使用 UTF-8 进行写入,但是如果在读取时尝试检测文本编码,则可以覆盖此行为。首先,让我们看一下您将自动获取的信息。对于给定的流,IXmlReader 将通过作为 XML 前同步码的字节顺序标记来检测编码提示。IXmlReader 还将允许在 XML 声明中指定的任何编码。期望任何 XML 分析器都具有这两个特征。如果具有可能未定义任何编码信息的输入流,而且 XmlLite 无法试探性地确定正使用的编码,则可以将 IXmlReader 定向到特定的编码(如果给定了代码页或编码名称)。
可以假借 IXmlReaderInput 接口创建 XML 读取器输入对象,而不是将流直接传递到 IXmlReader。提供了两个用于创建包装输入流的输入对象的函数。CreateXmlReaderInputWithEncodingCodePage 函数接受代码页编号形式的代码。CreateXmlReaderInputWithEncodingName 函数接受使用其规范名称的编码。除此之外,这两个函数具有完全相同的签名。概括一下,通常可以对 XML 读取器的输入流进行如下设置:
CComPtr<IStream> stream; // Create stream object here COM_VERIFY(reader->SetInput(stream));
要覆盖编码,请将代码更改为:
CComPtr<IStream> stream; // Create stream object here CComPtr<IXmlReaderInput> input; COM_VERIFY(::CreateXmlReaderInputWithEncodingName(stream, 0, // default allocator L"ISO-8859-8", TRUE, // hint 0, // base URI &input)); COM_VERIFY(reader->SetInput(input));
第一个参数指示 XML 读取器将从其读取的流。第二个参数接受可选的 IMalloc 实现。如果提供的话,则它将覆盖 XML 读取器自己的实现。第三个参数指定编码名称。
msdn2.microsoft.com/ms752827.aspx 上的文档列出了本机支持的编码;要支持其他编码,可以提供 IMultiLanguage2 接口实现。下一个参数指示是否必须使用指定的编码或者它是否仅仅是一个提示。如果指定 TRUE,则指示分析器尝试使用建议的编码,但是如果它失败,则可以随意尝试试探性地确定实际的编码。如果指定 FALSE,则指示分析器尝试建议的编码;如果它与输入流不匹配,则返回错误。下一个参数接受可能用于解析外部实体的可选基本 URI。最后一个参数返回表示要传递到 SetInput 方法的输入对象的接口指针。
写入时的文本编码
XML 写入器将基于传递到 SetOutput 方法的对象确定要使用的编码。如果该对象实现 IStream 接口或者甚至实现有限的 ISequentialStream 接口,则 XML 写入器将使用 UTF-8 编码。可以创建 XML 写入器输出对象来覆盖此行为。提供了两个用于创建包装输出流的输出对象的函数。CreateXmlWriterOutputWithEncodingCodePage 函数接受代码页编号形式的编码,而 CreateXmlWriterOutputWithEncodingName 函数接受使用其规范名称的编码。除此之外,这两个函数具有完全相同的签名。通常,可以对 XML 写入器的输出流进行如下设置:
CComPtr<IStream> stream; // Create stream object here COM_VERIFY(writer->SetOutput(stream));
要覆盖默认编码,请编写以下代码:
CComPtr<IStream> stream; // Create stream object here CComPtr<IXmlWriterOutput> output; COM_VERIFY(::CreateXmlWriterOutputWithEncodingName(stream, 0, L"ISO-8859-8", &output)); COM_VERIFY(writer->SetOutput(output));
第一个参数指示 XML 写入器将写入的流。第二个参数接受可选的 IMalloc 实现。如果提供的话,则它将覆盖 XML 写入器自己的实现。第三个参数指定编码名称。最后一个参数返回表示要传递到 SetOutput 方法的输出对象的接口指针。
处理大数据值
为了在读取大数据值时限制内存使用,XML 读取器提供了按数据块读取值的机制。IXmlReader ReadValueChunk 方法读取的字符数不超过规定的最大字符数,在预料到后续调用时向前移动读取器。以下示例说明如何重复调用 ReadValueChunk 以读取大数据值:
CString value; WCHAR chunk[256] = { 0 }; HRESULT result = S_OK; UINT charsRead = 0; while (S_OK == (result = reader->ReadValueChunk(chunk, countof(chunk), &charsRead))) { value.Append(chunk, charsRead); }
当不再有数据可用时,ReadValueChunk 返回 S_FALSE。在此示例中,我要将数据块写入 CString 对象。这仅仅是为了说明如何管理数据块的长度,显然这在实际中会抵消数据分块的优势。
安全注意事项
以 XML 为中心的应用程序必须总是处理来自不可信源的 XML。XmlLite 提供了许多工具以保护应用程序免受已知漏洞和将来漏洞的攻击。
XML 文档可以包含对外部实体的引用。一些 XML 分析器自动解析这些实体。虽然此方法可能很有用,但是,如果未仔细编写 XML 解析程序以缓解各种威胁,则此方法可能会造成安全漏洞的攻击。XmlLite 既不自动解析外部实体,也不提供 XML 解析程序。要提供自己的实现(如有必要),请实现 IXmlResolver 接口并将 XmlReaderProperty_XmlResolver 属性与 IXmlReader SetProperty 方法一起使用,以指示读取器使用您的解析程序。
XML 文档可能还包含 DTD 处理说明。虽然 XmlLite 不支持文档验证(使用 XML 架构或 DTD),但是它支持 DTD 实体扩展和默认属性。由于这些 DTD 可以包含对外部实体的引用,因此它们可能会使您的应用程序受到各种攻击。默认情况下,XmlLite 禁用 DTD 处理。通过将 XmlReaderProperty_DtdProcessing 属性设置为 DtdProcessing_Parse 值,可以允许 DTD 处理。此外,还存在由 XmlReaderProperty_MaxEntityExpansion 控制的 DTD 实体扩展攻击(也称为 billion laughs 攻击)的内置缓解措施。此属性的默认值为 100,000。
攻击者可以利用使用 XML 的应用程序的另一种方法是,创建名称非常长的文档。如果未能阻止,则这可能用尽巨大的内存并允许拒绝服务攻击。我已经提示了可以执行的方法。缓解此类威胁的一种明显方法是,按数据块读取大数据值,如上一部分所述。另一种有用的方法是,提供限制内存分配的自定义 IMalloc 实现。如果输入流支持随机访问,则还可以指示 XML 读取器使用 XmlReaderProperty_RandomAccess 属性来避免缓存属性。这将减少用于读取开始元素标记的内存量,但是也可能降低分析速度,因为分析器必须来回查找以便在请求时检索各个属性值。
如果 XML 层次结构过深,则也可能快速用尽系统资源。要阻止攻击者提供层次结构过深的 XML 文档,可以使用 XmlReaderProperty_MaxElementDepth 属性限制分析器将允许的深度。此属性默认为 256。
总结
XmlLite 为本机 C++ 应用程序提供了功能强大的 XML 分析器。它着重于性能,知道它所使用的系统资源,为控制这些特征提供了很大的灵活性。XmlLite 支持所有的常见文本编码,是一种非常有用的实用工具,可以简化本机 C++ 应用程序中的 XML 使用。有关详细信息,请参阅
msdn2.microsoft.com/ms752872.aspx 上的 XmlLite 文档。