C#12 技术手册(八)
原文:
zh.annas-archive.org/md5/e2c84fd09097e50aedbc4e5989f32a85译者:飞龙
第十章:LINQ to XML
.NET 提供了多个用于处理 XML 数据的 API。用于通用 XML 文档处理的主要选择是 LINQ to XML。LINQ to XML 包括一个轻量级、与 LINQ 兼容的 XML 文档对象模型(DOM),以及一组补充查询运算符。
在本章中,我们完全专注于 LINQ to XML。在 第十一章 中,我们涵盖了仅向前 XML 读取器/写入器,而在 在线补充 中,我们涵盖了用于处理模式和样式表的类型。.NET 还包括基于传统 XmlDocument 的 DOM,但我们不予讨论。
注意
LINQ to XML DOM 设计非常精良且性能非常高。即使没有 LINQ,LINQ to XML DOM 作为低级 XmlReader 和 XmlWriter 类的轻量级外观也是非常有价值的。
所有 LINQ to XML 类型都定义在 System.Xml.Linq 命名空间中。
架构概述
本节从对 DOM 概念的非常简要介绍开始,然后解释了 LINQ to XML 的 DOM 背后的基本原理。
什么是 DOM?
考虑以下 XML 文件:
<?xml version="1.0" encoding="utf-8"?>
<customer id="123" status="archived">
<firstname>Joe</firstname>
<lastname>Bloggs</lastname>
</customer>
与所有 XML 文件一样,我们从一个 声明 开始,然后是一个名为 customer 的根 元素。customer 元素具有两个 属性,每个属性都有一个名称(id 和 status)和一个值("123" 和 "archived")。在 customer 内部,有两个子元素,firstname 和 lastname,每个都有简单的文本内容("Joe" 和 "Bloggs")。
这些构造——声明、元素、属性、值和文本内容——都可以用类表示。如果这些类具有用于存储子内容的集合属性,我们可以组装一个对象树来完整描述文档。这称为 文档对象模型,或 DOM。
LINQ to XML DOM
LINQ to XML 包括两个主要内容:
-
XML DOM,我们称之为 X-DOM
-
大约有 10 个补充查询运算符的集合
正如您可能期望的那样,X-DOM 包括诸如 XDocument、XElement 和 XAttribute 之类的类型。有趣的是,X-DOM 类型并不依赖于 LINQ——您可以加载、实例化、更新和保存 X-DOM,而无需编写 LINQ 查询。
相反,您可以使用 LINQ 查询旧的符合 W3C 标准的 DOM。然而,这将是令人沮丧和受限制的。X-DOM 的显著特点是它对 LINQ 友好,这意味着:
-
它具有生成有用的
IEnumerable序列的方法,您可以对其进行查询。 -
它的构造函数设计得使您能够通过 LINQ 投影构建 X-DOM 树。
X-DOM 概述
图 10-1 显示了核心 X-DOM 类型。其中最常用的是 XElement 类型。XObject 是继承层次结构的根;XElement 和 XDocument 是容器层次结构的根。
图 10-1. 核心 X-DOM 类型
图 10-2 显示了从以下代码创建的 X-DOM 树:
string xml = @"<customer id='123' status='archived'>
<firstname>Joe</firstname>
<lastname>Bloggs<!--nice name--></lastname>
</customer>";
XElement customer = XElement.Parse (xml);
图 10-2. 一个简单的 X-DOM 树
XObject 是所有 XML 内容的抽象基类。它定义了与容器树中的 Parent 元素的链接以及一个可选的 XDocument。
XNode 是大多数 XML 内容的基类,不包括属性。XNode 的显著特点是它可以位于混合类型 XNode 的有序集合中。例如,考虑以下 XML:
<data>
Hello world
<subelement1/>
<!--comment-->
<subelement2/>
</data>
在父元素 <data> 中,首先是一个 XText 节点(Hello world),然后是一个 XElement 节点,接着是一个 XComment 节点,然后是第二个 XElement 节点。相比之下,XAttribute 仅容忍其他 XAttribute 作为对等体。
虽然 XNode 可以访问其父 XElement,但它没有 子 节点的概念:这是其子类 XContainer 的工作。XContainer 定义了处理子节点的成员,并且是 XElement 和 XDocument 的抽象基类。
XElement 引入了用于管理属性的成员——以及 Name 和 Value。在元素只有一个 XText 子节点的情况下(这是相当常见的情况),XElement 上的 Value 属性封装了此子节点的内容,用于获取和设置操作,减少了不必要的导航。由于 Value,您大多数情况下可以避免直接使用 XText 节点。
XDocument 表示 XML 树的根。更确切地说,它 包装 了根 XElement,添加了 XDeclaration、处理指令和其他根级别的“fluff”。与 W3C DOM 不同的是,它的使用是可选的:您可以加载、操作和保存 X-DOM,而无需创建 XDocument!不依赖 XDocument 也意味着您可以高效且轻松地将节点子树移动到另一个 X-DOM 层次结构中。
加载和解析
XElement 和 XDocument 都提供静态的 Load 和 Parse 方法,从现有源构建 X-DOM 树:
-
Load从文件、URI、Stream、TextReader或XmlReader构建 X-DOM。 -
Parse从字符串构建 X-DOM。
例如:
XDocument fromWeb = XDocument.Load ("http://albahari.com/sample.xml");
XElement fromFile = XElement.Load (@"e:\media\somefile.xml");
XElement config = XElement.Parse (
@"<configuration>
<client enabled='true'>
<timeout>30</timeout>
</client>
</configuration>");
在后面的章节中,我们将描述如何遍历和更新 X-DOM。作为快速预览,这是如何操作我们刚刚填充的 config 元素的方法:
foreach (XElement child in config.Elements())
Console.WriteLine (child.Name); // client
XElement client = config.Element ("client");
bool enabled = (bool) client.Attribute ("enabled"); // Read attribute
Console.WriteLine (enabled); // True
client.Attribute ("enabled").SetValue (!enabled); // Update attribute
int timeout = (int) client.Element ("timeout"); // Read element
Console.WriteLine (timeout); // 30
client.Element ("timeout").SetValue (timeout * 2); // Update element
client.Add (new XElement ("retries", 3)); // Add new element
Console.WriteLine (config); // Implicitly call config.ToString()
这是最后一个 Console.WriteLine 的结果:
<configuration>
<client enabled="false">
<timeout>60</timeout>
<retries>3</retries>
</client>
</configuration>
注意
XNode 还提供了一个静态的 ReadFrom 方法,从 XmlReader 实例化和填充任何类型的节点。与 Load 不同,它在读取一个(完整的)节点后停止,因此您可以继续手动从 XmlReader 中读取。
您还可以反向使用 XmlReader 或 XmlWriter 通过其 CreateReader 和 CreateWriter 方法读取或写入 XNode。
我们描述了 XML 读取器和写入器以及如何在 第十一章 中与 X-DOM 一起使用它们。
保存和序列化
在任何节点上调用ToString将其内容转换为 XML 字符串——格式化为我们刚刚看到的带有换行和缩进的形式。(在调用ToString时,可以通过指定SaveOptions.DisableFormatting来禁用换行和缩进。)
XElement和XDocument还提供了一个Save方法,用于将 X-DOM 写入文件、Stream、TextWriter或XmlWriter。如果指定了一个文件,将自动写入 XML 声明。XNode类中还定义了一个WriteTo方法,只接受一个XmlWriter。
我们将在“文档和声明”中更详细地描述保存时处理 XML 声明的方法。
实例化 X-DOM
不要使用Load或Parse方法,可以通过手动实例化对象并通过XContainer的Add方法将它们添加到父对象来构建 X-DOM 树。
要构造XElement和XAttribute,只需提供一个名称和值:
XElement lastName = new XElement ("lastname", "Bloggs");
lastName.Add (new XComment ("nice name"));
XElement customer = new XElement ("customer");
customer.Add (new XAttribute ("id", 123));
customer.Add (new XElement ("firstname", "Joe"));
customer.Add (lastName);
Console.WriteLine (customer.ToString());
这是结果:
<customer id="123">
<firstname>Joe</firstname>
<lastname>Bloggs<!--nice name--></lastname>
</customer>
在构造XElement时,值是可选的——您可以只提供元素名称,稍后再添加内容。请注意,当我们提供值时,一个简单的字符串就足够了——我们不需要显式创建和添加XText子节点。X-DOM 会自动完成这项工作,因此您可以简单地处理“值”。
函数式构造
在我们之前的示例中,很难从代码中获取 XML 结构。X-DOM 支持另一种实例化模式,称为函数式构造(来自函数式编程)。使用函数式构造,您可以在单个表达式中构建整个树:
XElement customer =
new XElement ("customer", new XAttribute ("id", 123),
new XElement ("firstname", "joe"),
new XElement ("lastname", "bloggs",
new XComment ("nice name")
)
);
这有两个好处。首先,代码类似于 XML 的结构。其次,它可以并入 LINQ 查询的select子句中。例如,以下查询将从 EF Core 实体类投影到 X-DOM 中:
XElement query =
new XElement ("customers",
from c in dbContext.Customers.AsEnumerable()
select
new XElement ("customer", new XAttribute ("id", c.ID),
new XElement ("firstname", c.FirstName),
new XElement ("lastname", c.LastName,
new XComment ("nice name")
)
)
);
我们将在本章后面更深入地讨论这一点,见“投影到 X-DOM 中”。
指定内容
函数式构造是可能的,因为XElement(和XDocument)的构造函数被重载以接受一个params对象数组:
public XElement (XName name, params object[] content)
对于XContainer中的Add方法也是如此:
public void Add (params object[] content)
因此,在构建或附加 X-DOM 时,您可以指定任意数量的任何类型的子对象。这是因为任何东西都被视为合法内容。要了解具体情况,我们需要检查每个内容对象在内部是如何处理的。以下是XContainer做出的决定:
-
如果对象为
null,则会被忽略。 -
如果对象基于
XNode或XStreamingElement,它将按原样添加到Nodes集合中。 -
如果对象是
XAttribute,它将被添加到Attributes集合中。 -
如果对象是一个
string,它将被包装在一个XText节点中并添加到Nodes中。¹ -
如果对象实现了
IEnumerable,则会被枚举,并且相同的规则将应用于每个元素。 -
否则,对象将被转换为字符串,包装在
XText节点中,然后添加到Nodes中。²
一切最终都会进入两个桶之一:Nodes或Attributes。此外,任何对象都是有效内容,因为它始终可以最终调用ToString并将其视为XText节点。
注
在对任意类型调用ToString之前,XContainer首先测试它是否是以下类型之一:
float, double, decimal, bool,
DateTime, DateTimeOffset, TimeSpan
如果是这样,它将在XmlConvert助手类上调用适当类型化的ToString方法,而不是在对象本身上调用ToString。这确保数据是可往返的,并符合标准 XML 格式化规则。
自动深度克隆
当通过功能性构建或Add方法向元素添加节点或属性时,该节点或属性的Parent属性将设置为该元素。节点只能有一个父元素:如果将已有父节点的节点添加到第二个父节点中,则节点将自动进行深度克隆。在以下示例中,每个客户都有一个单独的address副本:
var address = new XElement ("address",
new XElement ("street", "Lawley St"),
new XElement ("town", "North Beach")
);
var customer1 = new XElement ("customer1", address);
var customer2 = new XElement ("customer2", address);
customer1.Element ("address").Element ("street").Value = "Another St";
Console.WriteLine (
customer2.Element ("address").Element ("street").Value); // Lawley St
此自动复制保持 X-DOM 对象实例化不受副作用的影响——这是函数式编程的又一标志。
导航和查询
正如您所预期的那样,XNode和XContainer类定义了遍历 X-DOM 树的方法和属性。但与传统的 DOM 不同,这些函数不会返回实现IList<T>的集合。相反,它们返回单个值或实现IEnumerable<T>的序列——您随后期望使用 LINQ 查询(或使用foreach枚举)。这不仅允许进行高级查询,还可以执行简单的导航任务——使用熟悉的 LINQ 查询语法。
注
X-DOM 中的元素和属性名称区分大小写,就像 XML 中一样。
子节点导航
| 返回类型 | 成员 | 作用于 |
|---|---|---|
XNode | FirstNode { get; } | XContainer |
LastNode { get; } | XContainer | |
IEnumerable<XNode> | Nodes() | XContainer* |
DescendantNodes() | XContainer* | |
DescendantNodesAndSelf() | XElement* | |
XElement | Element (XName) | XContainer |
IEnumerable<XElement> | Elements() | XContainer* |
Elements (XName) | XContainer* | |
Descendants() | XContainer* | |
Descendants (XName) | XContainer* | |
DescendantsAndSelf() | XElement* | |
DescendantsAndSelf (XName) | XElement* | |
bool | HasElements { get; } | XElement |
注
此表及其他表中第三列标有星号的功能也适用于同一类型的序列。例如,您可以在XContainer或XContainer对象序列上调用Nodes。这是因为System.Xml.Linq中定义的扩展方法——我们在概述中讨论的补充查询运算符使这成为可能。
FirstNode、LastNode 和 Nodes
FirstNode和LastNode为您提供对第一个或最后一个子节点的直接访问;Nodes将所有子节点作为序列返回。这三个函数仅考虑直接子代:
var bench = new XElement ("bench",
new XElement ("toolbox",
new XElement ("handtool", "Hammer"),
new XElement ("handtool", "Rasp")
),
new XElement ("toolbox",
new XElement ("handtool", "Saw"),
new XElement ("powertool", "Nailgun")
),
new XComment ("Be careful with the nailgun")
);
foreach (XNode node in bench.Nodes())
Console.WriteLine (node.ToString (SaveOptions.DisableFormatting) + ".");
这是输出:
<toolbox><handtool>Hammer</handtool><handtool>Rasp</handtool></toolbox>.
<toolbox><handtool>Saw</handtool><powertool>Nailgun</powertool></toolbox>.
<!--Be careful with the nailgun-->.
检索元素
Elements 方法仅返回类型为 XElement 的子节点:
foreach (XElement e in bench.Elements())
Console.WriteLine (e.Name + "=" + e.Value); // toolbox=HammerRasp
// toolbox=SawNailgun
以下 LINQ 查询找到带有钉枪的工具箱:
IEnumerable<string> query =
from toolbox in bench.Elements()
where toolbox.Elements().Any (tool => tool.Value == "Nailgun")
select toolbox.Value;
RESULT: { "SawNailgun" }
下一个示例使用 SelectMany 查询检索所有工具箱中的手工工具:
IEnumerable<string> query =
from toolbox in bench.Elements()
from tool in toolbox.Elements()
where tool.Name == "handtool"
select tool.Value;
RESULT: { "Hammer", "Rasp", "Saw" }
注意
Elements 本身相当于对 Nodes 的 LINQ 查询。我们之前的查询可以如下开始:
from toolbox in bench.Nodes().OfType<XElement>()
where ...
Elements 也可以返回给定名称的元素:
int x = bench.Elements ("toolbox").Count(); // 2
这相当于以下内容:
int x = bench.Elements().Where (e => e.Name == "toolbox").Count(); // 2
Elements 也被定义为接受 IEnumerable<XContainer> 或更精确地说是该类型参数的扩展方法:
IEnumerable<T> where T : XContainer
这使其也能处理元素序列。使用此方法,我们可以重写在所有工具箱中查找手工工具的查询如下:
from tool in bench.Elements ("toolbox").Elements ("handtool")
select tool.Value;
第一次调用 Elements 绑定到 XContainer 的实例方法;第二次调用 Elements 绑定到扩展方法。
检索单个元素
方法 Element(单数)返回给定名称的第一个匹配元素。 Element 用于简单的导航,如下所示:
XElement settings = XElement.Load ("databaseSettings.xml");
string cx = settings.Element ("database").Element ("connectString").Value;
Element 等同于调用 Elements() 然后应用 LINQ 的 FirstOrDefault 查询运算符进行名称匹配的谓词。如果请求的元素不存在,Element 返回 null。
注意
如果元素 xyz 不存在,Element("xyz").Value 将抛出 NullReferenceException。如果你宁愿得到 null 而不是异常,可以使用空值条件操作符——Element("xyz")?.Value——或者将 XElement 强制转换为 string 而不是查询其 Value 属性。换句话说:
string xyz = (string) settings.Element ("xyz");
这样可以正常工作,因为 XElement 为此目的定义了显式的 string 转换!
检索后代
XContainer 还提供了 Descendants 和 DescendantNodes 方法,返回子元素或节点及其所有子级(整个树)。 Descendants 可接受一个可选的元素名称。回到我们的早期示例,我们可以使用 Descendants 查找所有手工工具:
Console.WriteLine (bench.Descendants ("handtool").Count()); // 3
父节点和叶节点均包括在内,如下例所示:
foreach (XNode node in bench.DescendantNodes())
Console.WriteLine (node.ToString (SaveOptions.DisableFormatting));
这是输出:
<toolbox><handtool>Hammer</handtool><handtool>Rasp</handtool></toolbox>
<handtool>Hammer</handtool>
Hammer
<handtool>Rasp</handtool>
Rasp
<toolbox><handtool>Saw</handtool><powertool>Nailgun</powertool></toolbox>
<handtool>Saw</handtool>
Saw
<powertool>Nailgun</powertool>
Nailgun
<!--Be careful with the nailgun-->
下一个查询从 X-DOM 中的任何位置提取包含单词“careful”的所有评论:
IEnumerable<string> query =
from c in bench.DescendantNodes().OfType<XComment>()
where c.Value.Contains ("careful")
orderby c.Value
select c.Value;
父导航
所有 XNode 都有一个 Parent 属性和 Ancestor*XXX* 方法用于父导航。父始终是一个 XElement:
| 返回类型 | 成员 | 适用于 |
|---|---|---|
XElement | Parent { get; } | XNode |
Enumerable<XElement> | Ancestors() | XNode |
Ancestors (XName) | XNode | |
AncestorsAndSelf() | XElement | |
AncestorsAndSelf (XName) | XElement |
如果 x 是 XElement,以下内容始终打印 true:
foreach (XNode child in x.Nodes())
Console.WriteLine (child.Parent == x);
然而,如果 x 是 XDocument,情况则不同。XDocument 很特别:它可以有子节点但永远不可能是任何人的父节点!要访问 XDocument,你可以使用 Document 属性;这适用于 X-DOM 树中的任何对象。
Ancestors 返回一个序列,其第一个元素是 Parent,下一个元素是 Parent.Parent,依此类推,直到根元素。
注意
您可以使用 LINQ 查询 AncestorsAndSelf().Last() 导航到根元素。
另一种实现相同功能的方法是调用 Document.Root,尽管这仅在存在 XDocument 时有效。
对等节点导航
| 返回类型 | 成员 | 定义在 |
|---|---|---|
bool | IsBefore (XNode node) | XNode |
IsAfter (XNode node) | XNode | |
XNode | PreviousNode { get; } | XNode |
NextNode { get; } | XNode | |
IEnumerable<XNode> | NodesBeforeSelf() | XNode |
NodesAfterSelf() | XNode | |
IEnumerable<XElement> | ElementsBeforeSelf() | XNode |
ElementsBeforeSelf (XName name) | XNode | |
ElementsAfterSelf() | XNode | |
ElementsAfterSelf (XName name) | XNode |
使用 PreviousNode 和 NextNode(以及 FirstNode/LastNode),您可以遍历具有链表感觉的节点。这并非巧合:内部节点存储在链表中。
注意
XNode 内部使用单向链表,因此 PreviousNode 不具备性能。
属性导航
| 返回类型 | 成员 | 定义在 |
|---|---|---|
bool | HasAttributes { get; } | XElement |
XAttribute | Attribute (XName name) | XElement |
FirstAttribute { get; } | XElement | |
LastAttribute { get; } | XElement | |
IEnumerable<XAttribute> | Attributes() | XElement |
Attributes (XName name) | XElement |
此外,XAttribute 定义了 PreviousAttribute 和 NextAttribute 属性以及 Parent。
接受名称的 Attributes 方法返回一个序列,其中包含零个或一个元素;XML 元素不能具有重复的属性名称。
更新 X-DOM
您可以通过以下方式更新元素和属性:
-
调用
SetValue或重新分配Value属性。 -
调用
SetElementValue或SetAttributeValue。 -
调用其中一个
Remove*XXX*方法。 -
调用
Add*XXX*或Replace*XXX*方法之一,指定新内容。
您还可以在 XElement 对象上重新分配 Name 属性。
简单值更新
| 成员 | 适用于 |
|---|---|
SetValue (object value) | XElement, XAttribute |
Value { get; set } | XElement, XAttribute |
SetValue 方法用简单值替换元素或属性的内容。设置 Value 属性也是如此,但仅接受字符串数据。我们稍后在“处理值”中详细描述这两个函数。调用 SetValue(或重新分配 Value)的一个效果是替换所有子节点:
XElement settings = new XElement ("settings",
new XElement ("timeout", 30)
);
settings.SetValue ("blah");
Console.WriteLine (settings.ToString()); // <settings>blah</settings>
更新子节点和属性
| 类别 | 成员 | 适用于 |
|---|---|---|
| 添加 | Add (params object[] content) | XContainer |
AddFirst (params object[] content) | XContainer | |
| 移除 | RemoveNodes() | XContainer |
RemoveAttributes() | XElement | |
RemoveAll() | XElement | |
| 更新 | ReplaceNodes (params object[] content) | XContainer |
ReplaceAttributes (params object[] content) | XElement | |
ReplaceAll (params object[] content | XElement | |
SetElementValue (XName name, object value) | XElement | |
SetAttributeValue (XName name, object value) | XElement |
此组中最方便的方法是最后两个:SetElementValue 和 SetAttributeValue。它们作为快捷方式,用于实例化 XElement 或 XAttribute,然后将其添加到父级,并替换同名的现有元素或属性:
XElement settings = new XElement ("settings");
settings.SetElementValue ("timeout", 30); // Adds child node
settings.SetElementValue ("timeout", 60); // Update it to 60
Add 将子节点附加到元素或文档。AddFirst 与其类似,但插入到集合的开头而不是结尾。
您可以使用 RemoveNodes 或 RemoveAttributes 一次性删除所有子节点或属性。RemoveAll 等同于同时调用这两种方法。
Replace*XXX* 方法等效于 Remove 然后 Add。它们对输入进行了快照,因此 e.ReplaceNodes(e.Nodes()) 能够正常工作。
通过父级更新
| 成员 | 适用对象 |
|---|---|
AddBeforeSelf (params object[] content) | XNode |
AddAfterSelf (params object[] content) | XNode |
Remove() | XNode, XAttribute |
ReplaceWith (params object[] content) | XNode |
方法 AddBeforeSelf、AddAfterSelf、Remove 和 ReplaceWith 不操作节点的子节点。而是操作节点所在的集合。这要求节点必须有一个父元素,否则会抛出异常。AddBeforeSelf 和 AddAfterSelf 适用于将节点插入到任意位置:
XElement items = new XElement ("items",
new XElement ("one"),
new XElement ("three")
);
items.FirstNode.AddAfterSelf (new XElement ("two"));
这是结果:
<items><one /><two /><three /></items>
在长序列中的任意位置插入元素是高效的,因为节点在内部以链表形式存储。
Remove 方法从其父节点中移除当前节点。ReplaceWith 也是如此,并在相同位置插入其他内容:
XElement items = XElement.Parse ("<items><one/><two/><three/></items>");
items.FirstNode.ReplaceWith (new XComment ("One was here"));
这是结果:
<items><!--one was here--><two /><three /></items>
删除一系列节点或属性
多亏了 System.Xml.Linq 中的扩展方法,您还可以对节点或属性的序列调用 Remove。考虑这个 X-DOM:
XElement contacts = XElement.Parse (
@"<contacts>
<customer name='Mary'/>
<customer name='Chris' archived='true'/>
<supplier name='Susan'>
<phone archived='true'>012345678<!--confidential--></phone>
</supplier>
</contacts>");
下面的示例删除了所有客户:
contacts.Elements ("customer").Remove();
下面的示例删除了所有存档的联系人(因此 Chris 消失了):
contacts.Elements().Where (e => (bool?) e.Attribute ("archived") == true)
.Remove();
如果我们将 Elements() 替换为 Descendants(),整个 DOM 中的所有存档元素都将消失,得到如下结果:
<contacts>
<customer name="Mary" />
<supplier name="Susan" />
</contacts>
下一个示例删除了任何位置包含树中任何地方注释“confidential”的所有联系人:
contacts.Elements().Where (e => e.DescendantNodes()
.OfType<XComment>()
.Any (c => c.Value == "confidential")
).Remove();
这是结果:
<contacts>
<customer name="Mary" />
<customer name="Chris" archived="true" />
</contacts>
与下面更简单的查询相比,它从树中删除所有注释节点:
contacts.DescendantNodes().OfType<XComment>().Remove();
注意
内部实现中,Remove 方法首先将所有匹配的元素读入临时列表,然后枚举临时列表以执行删除操作。这样可以避免同时删除和查询可能导致的错误。
使用值
XElement 和 XAttribute 都有 Value 属性,类型为 string。如果一个元素有一个单独的 XText 子节点,XElement 的 Value 属性作为方便的快捷方式,用于访问该节点的内容。对于 XAttribute,Value 属性就是属性的值。
尽管存储方式不同,X-DOM 为处理元素和属性值提供了一致的操作集。
设置数值
有两种方法可以赋值:调用 SetValue 或分配 Value 属性。SetValue 更灵活,因为它不仅接受字符串,还接受其他简单的数据类型:
var e = new XElement ("date", DateTime.Now);
e.SetValue (DateTime.Now.AddDays(1));
Console.Write (e.Value); // 2019-10-02T16:39:10.734375+09:00
相反,我们本可以直接设置元素的 Value 属性,但这意味着必须手动将 DateTime 转换为字符串。这比调用 ToString 更复杂 —— 它需要使用 XmlConvert 来获得符合 XML 标准的结果。
当您将 值 传递给 XElement 或 XAttribute 的构造函数时,对于非字符串类型也会发生相同的自动转换。这确保了 DateTime 被正确格式化;true 以小写形式写入,double.NegativeInfinity 写为 “-INF”。
获取数值
要反向操作并将 Value 解析回基本类型,只需将 XElement 或 XAttribute 强制转换为所需类型。听起来好像不应该起作用,但它确实可以!例如:
XElement e = new XElement ("now", DateTime.Now);
DateTime dt = (DateTime) e;
XAttribute a = new XAttribute ("resolution", 1.234);
double res = (double) a;
元素或属性本身并不原生存储 DateTime 或数字 —— 它们始终以文本形式存储,然后根据需要进行解析。它也不会 “记住” 原始类型,因此必须正确地进行强制转换,以避免运行时错误。为了使您的代码健壮,可以将强制转换放入 try/catch 块中,捕获 FormatException。
XElement 和 XAttribute 上的显式转换可以解析为以下类型:
-
所有标准数值类型
-
string、bool、DateTime、DateTimeOffset、TimeSpan和Guid -
上述值类型的
Nullable<>版本
在与 Element 和 Attribute 方法结合使用时,将类型强制转换为可空类型很有用,因为即使请求的名称不存在,强制转换仍然有效。例如,如果 x 没有 timeout 元素,第一行将生成运行时错误,而第二行不会:
int timeout = (int) x.Element ("timeout"); // Error
int? timeout = (int?) x.Element ("timeout"); // OK; timeout is null.
您可以通过 ?? 运算符在最终结果中消除可空类型。如果 resolution 属性不存在,则以下计算结果为 1.0:
double resolution = (double?) x.Attribute ("resolution") ?? 1.0;
不过,将类型强制转换为可空类型并不能解决问题,如果元素或属性 存在 并且具有空(或格式不正确)的值。对此,必须捕获 FormatException。
您还可以在 LINQ 查询中使用转换。以下返回“John”:
var data = XElement.Parse (
@"<data>
<customer id='1' name='Mary' credit='100' />
<customer id='2' name='John' credit='150' />
<customer id='3' name='Anne' />
</data>");
IEnumerable<string> query = from cust in data.Elements()
where (int?) cust.Attribute ("credit") > 100
select cust.Attribute ("name").Value;
将可空 int 强制转换可以防止 NullReferenceException 的发生,例如,如果 Anne 没有 credit 属性。另一种解决方法是在 where 子句中添加谓词:
where cust.Attributes ("credit").Any() && (int) cust.Attribute...
查询元素值时也适用相同原则。
值和混合内容节点
鉴于 Value 的值,您可能想知道何时需要直接处理 XText 节点。答案是当您有混合内容时,例如:
<summary>An XAttribute is <bold>not</bold> an XNode</summary>
一个简单的 Value 属性无法捕捉 summary 的内容。summary 元素包含三个子元素:一个 XText 节点,后跟一个 XElement,再跟一个 XText 节点。以下是构造它的方法:
XElement summary = new XElement ("summary",
new XText ("An XAttribute is "),
new XElement ("bold", "not"),
new XText (" an XNode")
);
有趣的是,我们仍然可以查询 summary 的 Value —— 而不会抛出异常。相反,我们得到的是每个子元素值的连接:
An XAttribute is not an XNode
重新分配 summary 的 Value 也是合法的,代价是用一个新的单个 XText 节点替换所有先前的子元素。
自动 XText 连接
当向 XElement 添加简单内容时,X-DOM 会追加到现有的 XText 子元素,而不是创建新的。在以下示例中,e1 和 e2 最终只有一个子 XText 元素,其值为 HelloWorld:
var e1 = new XElement ("test", "Hello"); e1.Add ("World");
var e2 = new XElement ("test", "Hello", "World");
然而,如果您明确创建 XText 节点,则最终会有多个子元素:
var e = new XElement ("test", new XText ("Hello"), new XText ("World"));
Console.WriteLine (e.Value); // HelloWorld
Console.WriteLine (e.Nodes().Count()); // 2
XElement 不会连接这两个 XText 节点,因此节点的对象标识被保留。
文档和声明
XDocument
正如前面所说,XDocument 包装了一个根 XElement,允许您添加 XDeclaration、处理指令、文档类型和根级注释。XDocument 是可选的,可以被忽略或省略:与 W3C DOM 不同,它不起粘合剂作用以使所有内容保持在一起。
XDocument 提供与 XElement 相同的功能构造函数。因为它基于 XContainer,所以还支持 Add*XXX*、Remove*XXX* 和 Replace*XXX* 方法。然而,与 XElement 不同的是,XDocument 只能接受有限的内容:
-
一个单独的
XElement对象(“根”) -
一个单独的
XDeclaration对象 -
一个单独的
XDocumentType对象(用于引用文档类型定义 [DTD]) -
任意数量的
XProcessingInstruction对象 -
任意数量的
XComment对象
注意
其中,仅根 XElement 是强制性的,以确保具有有效的 XDocument。XDeclaration 是可选的——如果省略,则在序列化期间应用默认设置。
最简单的有效 XDocument 只有一个根元素:
var doc = new XDocument (
new XElement ("test", "data")
);
注意,我们没有包含 XDeclaration 对象。然而,通过调用 doc.Save 生成的文件仍将包含一个 XML 声明,因为默认情况下会生成一个。
下一个示例生成了一个简单但正确的 XHTML 文件,展示了 XDocument 可以接受的所有结构:
var styleInstruction = new XProcessingInstruction (
"xml-stylesheet", "href='styles.css' type='text/css'");
var docType = new XDocumentType ("html",
"-//W3C//DTD XHTML 1.0 Strict//EN",
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd", null);
XNamespace ns = "http://www.w3.org/1999/xhtml";
var root =
new XElement (ns + "html",
new XElement (ns + "head",
new XElement (ns + "title", "An XHTML page")),
new XElement (ns + "body",
new XElement (ns + "p", "This is the content"))
);
var doc =
new XDocument (
new XDeclaration ("1.0", "utf-8", "no"),
new XComment ("Reference a stylesheet"),
styleInstruction,
docType,
root);
doc.Save ("test.html");
结果文件 test.html 的内容如下:
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<!--Reference a stylesheet-->
<?xml-stylesheet href='styles.css' type='text/css'?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html >
<head>
<title>An XHTML page</title>
</head>
<body>
<p>This is the content</p>
</body>
</html>
XDocument 具有 Root 属性,用作访问文档的单个 XElement 的快捷方式。反向链接由 XObject 的 Document 属性提供,适用于树中的所有对象:
Console.WriteLine (doc.Root.Name.LocalName); // html
XElement bodyNode = doc.Root.Element (ns + "body");
Console.WriteLine (bodyNode.Document == doc); // True
请记住,文档的子元素没有 Parent:
Console.WriteLine (doc.Root.Parent == null); // True
foreach (XNode node in doc.Nodes())
Console.Write (node.Parent == null); // TrueTrueTrueTrue
注意
XDeclaration 不是 XNode,也不会出现在文档的 Nodes 集合中——不像注释、处理指令和根元素。相反,它被分配到一个名为 Declaration 的专用属性。这就是为什么最后一个例子中的“True”重复四次而不是五次。
XML 声明
标准的 XML 文件以如下声明开头:
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
XML 声明确保文件将被解析器正确解析和理解。XElement 和 XDocument 遵循这些规则来发出 XML 声明:
-
使用文件名调用
Save总是会写入声明。 -
使用
XmlWriter调用Save写入声明,除非显式告知XmlWriter否则不要这样做。 -
ToString方法从不输出 XML 声明。
注意
当构造 XmlWriter 时,可以通过设置 XmlWriterSettings 对象的 OmitXmlDeclaration 和 ConformanceLevel 属性来指示 XmlWriter 不生成声明。我们在第十一章中描述了这一点。
不存在 XDeclaration 对象的存在或不存在不会影响是否写入 XML 声明。XDeclaration 的目的是 提示 XML 序列化 的两种方式:
-
使用的文本编码
-
XML 声明的
encoding和standalone属性应该放什么(是否应该写入声明)
XDeclaration 的构造函数接受三个参数,分别对应于属性 version、encoding 和 standalone。在下面的例子中,test.xml 使用 UTF-16 编码:
var doc = new XDocument (
new XDeclaration ("1.0", "utf-16", "yes"),
new XElement ("test", "data")
);
doc.Save ("test.xml");
注意
无论您为 XML 版本指定什么,XML 写入器都会忽略它:它总是写入 "1.0"。
编码必须使用像 XML 声明中出现的 IETF 代码 "utf-16"。
将声明写入字符串
假设我们想将 XDocument 序列化为 string,包括 XML 声明。因为 ToString 不会写入声明,我们需要使用 XmlWriter:
var doc = new XDocument (
new XDeclaration ("1.0", "utf-8", "yes"),
new XElement ("test", "data")
);
var output = new StringBuilder();
var settings = new XmlWriterSettings { Indent = true };
using (XmlWriter xw = XmlWriter.Create (output, settings))
doc.Save (xw);
Console.WriteLine (output.ToString());
这是结果:
<?xml version="1.0" encoding="utf-16" standalone="yes"?>
<test>data</test>
注意,尽管我们在 XDeclaration 中显式请求了 UTF-8,但输出中却有 UTF-16!这看起来可能像一个错误,但事实上,XmlWriter 的处理非常智能。因为我们写入的是一个 string 而不是文件或流,所以除了 UTF-16(字符串内部存储的格式)外,不可能应用任何其他编码。因此,XmlWriter 写入 "utf-16" 以免造成误导。
这也解释了为什么 ToString 方法不会输出 XML 声明。想象一下,如果不调用 Save 而是像以下这样将 XDocument 写入文件:
File.WriteAllText ("data.xml", doc.ToString());
如其现状,data.xml 将缺少 XML 声明,使其不完整但仍可解析(可以推断文本编码)。但如果 ToString() 输出了 XML 声明,data.xml 实际上将包含一个错误的声明(encoding="utf-16"),这可能导致完全无法读取,因为 WriteAllText 使用 UTF-8 进行编码。
名称和命名空间
就像.NET 类型可以有命名空间一样,XML 元素和属性也可以有命名空间。
XML 命名空间实现了两个功能。首先,类似于 C#中的命名空间,它们有助于防止命名冲突。当你将一个 XML 文件的数据合并到另一个 XML 文件中时,这可能会成为一个问题。其次,命名空间为名称分配了绝对的含义。例如,名称“nil”可以表示任何东西。然而,在*www.w3.org/2001/xmlsch… C#中的null,并且具有特定的应用规则。
由于 XML 命名空间是一个重要的混淆源,我们首先总结一般命名空间,然后再讨论它们在 LINQ to XML 中的使用。
XML 中的命名空间
假设我们想要在命名空间OReilly.Nutshell.CSharp中定义一个customer元素。有两种方法可以进行。第一种是使用xmlns属性:
<customer />
**xmlns是一个特殊的保留属性。当以这种方式使用时,它执行两个功能:
-
它为相关元素指定了命名空间。
-
它为所有后代元素指定了默认命名空间。
这意味着在以下示例中,address和postcode隐含地位于OReilly.Nutshell.CSharp命名空间中:
<customer >
<address>
<postcode>02138</postcode>
</address>
</customer>
如果我们希望address和postcode没有命名空间,我们需要这样做:
<customer >
<address xmlns="">
<postcode>02138</postcode> <!-- postcode now inherits empty ns -->
</address>
</customer>
前缀
指定命名空间的另一种方法是使用前缀。前缀是你为命名空间分配的别名,以节省输入。使用前缀有两个步骤—定义前缀和使用前缀。你可以同时进行:
<nut:customer xmlns:nut="OReilly.Nutshell.CSharp"/>
此处发生了两件不同的事情。右侧定义了一个名为nut的前缀,并使其在该元素及其所有后代中可用。左侧的nut:customer将新分配的前缀分配给了customer元素。
带有前缀的元素不会为后代元素定义默认命名空间。在以下 XML 中,firstname具有空命名空间:
<nut:customer >
<firstname>Joe</firstname>
</customer>
要给firstname分配OReilly.Nutshell.CSharp前缀,你必须这样做:
<nut:customer >
<nut:firstname>Joe</firstname>
</customer>
你还可以为你的后代方便地定义一个或多个前缀,而不将它们分配给父元素本身。以下定义了两个前缀,i和z,同时将customer元素本身保留为空命名空间:
<customer
>
...
</customer>
如果这是根节点,则整个文档都可以使用i和z前缀。当元素需要从多个命名空间中获取信息时,前缀非常方便。
注意,此示例中的两个命名空间都是 URI。使用 URI(你拥有的)是标准做法:它确保命名空间的唯一性。因此,在实际情况中,我们的customer元素更可能是这样的:
<customer />
或者:
<nut:customer />
属性
你也可以为属性分配命名空间。主要区别在于,属性始终需要一个前缀。例如:
<customer nut:id="123" />
另一个不同之处在于,未限定的属性始终具有空命名空间:它从不从父元素继承默认命名空间。
属性通常不需要命名空间,因为它们的含义通常局限于元素本身。一个例外是像 W3C 定义的 nil 属性这样的通用或元数据属性:
<customer >
<firstname>Joe</firstname>
<lastname xsi:nil="true"/>
</customer>
这明确表示 lastname 是 nil(在 C# 中是 null),而不是空字符串。因为我们使用了标准命名空间,通用的解析工具可以确切地了解我们的意图。** **## 在 X-DOM 中指定命名空间
到目前为止,在本章中,我们仅使用简单的字符串作为 XElement 和 XAttribute 的名称。一个简单的字符串对应于一个具有空命名空间的 XML 名称,类似于在全局命名空间中定义的 .NET 类型。
有几种指定 XML 命名空间的方法。第一种是在本地名称之前用大括号括起来:
var e = new XElement ("{http://domain.com/xmlspace}customer", "Bloggs");
Console.WriteLine (e.ToString());
这将产生如下的 XML:
<customer >Bloggs</customer>
第二种(更高效)方法是使用 XNamespace 和 XName 类型。以下是它们的定义:
public sealed class XNamespace
{
public string NamespaceName { get; }
}
public sealed class XName // A local name with optional namespace
{
public string LocalName { get; }
public XNamespace Namespace { get; } // Optional
}
两种类型都定义了从 string 的隐式转换,因此以下操作是合法的:
XNamespace ns = "http://domain.com/xmlspace";
XName localName = "customer";
XName fullName = "{http://domain.com/xmlspace}customer";
XNamespace 还重载了 + 运算符,允许你将命名空间和名称组合成一个 XName 而无需使用大括号:
XNamespace ns = "http://domain.com/xmlspace";
XName fullName = ns + "customer";
Console.WriteLine (fullName); // {http://domain.com/xmlspace}customer
X-DOM 中所有接受元素或属性名称的构造函数和方法实际上接受的是一个 XName 对象而不是一个 string。你之所以可以像我们到目前为止的所有示例那样替换一个字符串,是因为存在隐式转换。
无论是对元素还是属性,指定命名空间的方式都是相同的:
XNamespace ns = "http://domain.com/xmlspace";
var data = new XElement (ns + "data",
new XAttribute (ns + "id", 123)
);
X-DOM 和默认命名空间
X-DOM 在构造子 XElement 时忽略了默认命名空间的概念;如果需要,你必须显式为其指定命名空间;它不会从父元素继承:
XNamespace ns = "http://domain.com/xmlspace";
var data = new XElement (ns + "data",
new XElement (ns + "customer", "Bloggs"),
new XElement (ns + "purchase", "Bicycle")
);
直到实际输出 XML 时,X-DOM 才会应用默认命名空间:
Console.WriteLine (data.ToString());
OUTPUT:
<data >
<customer>Bloggs</customer>
<purchase>Bicycle</purchase>
</data>
Console.WriteLine (data.Element (ns + "customer").ToString());
OUTPUT:
<customer >Bloggs</customer>
如果在构造 XElement 子元素时没有指定命名空间,换句话说
XNamespace ns = "http://domain.com/xmlspace";
var data = new XElement (ns + "data",
new XElement ("customer", "Bloggs"),
new XElement ("purchase", "Bicycle")
);
Console.WriteLine (data.ToString());
你会得到以下结果:
<data >
<customer xmlns="">Bloggs</customer>
<purchase xmlns="">Bicycle</purchase>
</data>
另一个陷阱是在导航 X-DOM 时未包括命名空间:
XNamespace ns = "http://domain.com/xmlspace";
var data = new XElement (ns + "data",
new XElement (ns + "customer", "Bloggs"),
new XElement (ns + "purchase", "Bicycle")
);
XElement x = data.Element (ns + "customer"); // ok
XElement y = data.Element ("customer"); // null
如果在构建 X-DOM 树时未指定命名空间,你随后可以将每个元素分配到一个单一的命名空间,如下所示:
foreach (XElement e in data.DescendantsAndSelf())
if (e.Name.Namespace == "")
e.Name = ns + e.Name.LocalName;
前缀
X-DOM 对待前缀和命名空间的方式与其对待序列化功能相同。这意味着你可以选择完全忽略前缀的问题,并且顺利进行!唯一可能希望做出不同选择的原因是在将 XML 输出到文件时提高效率。例如,考虑下面这个:
XNamespace ns1 = "http://domain.com/space1";
XNamespace ns2 = "http://domain.com/space2";
var mix = new XElement (ns1 + "data",
new XElement (ns2 + "element", "value"),
new XElement (ns2 + "element", "value"),
new XElement (ns2 + "element", "value")
);
默认情况下,XElement 将按如下方式序列化:
<data >
<element >value</element>
<element >value</element>
<element >value</element>
</data>
正如你所看到的,存在一些不必要的重复。解决方案不是改变构造 X-DOM 的方式,而是在写入 XML 之前提示序列化器。通过添加定义你想要应用的前缀的属性来实现。这通常在根元素上完成:
mix.SetAttributeValue (XNamespace.Xmlns + "ns1", ns1);
mix.SetAttributeValue (XNamespace.Xmlns + "ns2", ns2);
这将前缀ns1分配给我们的XNamespace变量ns1,并将ns2分配给ns2。当序列化时,X-DOM 自动获取这些属性,并用它们来压缩生成的 XML。现在,在mix上调用ToString的结果如下:
<ns1:data
>
<ns2:element>value</ns2:element>
<ns2:element>value</ns2:element>
<ns2:element>value</ns2:element>
</ns1:data>
前缀不会改变您构造、查询或更新 X-DOM 的方式——对于这些活动,您忽略前缀的存在并继续使用完整名称。前缀仅在转换为和从 XML 文件或流中时才会起作用。
前缀在序列化属性时也会受到尊重。在以下示例中,我们记录了客户的出生日期和信用为"nil",并使用了 W3C 标准属性。突出显示的行确保前缀在序列化时不会重复命名空间:
XNamespace xsi = "http://www.w3.org/2001/XMLSchema-instance";
var nil = new XAttribute (xsi + "nil", true);
var cust = new XElement ("customers",
new XAttribute (XNamespace.Xmlns + "xsi", xsi),
new XElement ("customer",
new XElement ("lastname", "Bloggs"),
new XElement ("dob", nil),
new XElement ("credit", nil)
)
);
这是其 XML:
<customers >
<customer>
<lastname>Bloggs</lastname>
<dob xsi:nil="true" />
<credit xsi:nil="true" />
</customer>
</customers>
为了简洁起见,我们预先声明了 nil XAttribute,以便在构建 DOM 时可以使用它两次。您允许引用相同的属性两次,因为根据需要会自动复制它。** **# 注释
您可以使用注释将自定义数据附加到任何XObject上。注释专为您自己的私人使用而设计,并且在 X-DOM 中被视为黑匣子。如果您曾经在 Windows Forms 或 Windows Presentation Foundation (WPF) 控件的 Tag 属性上使用过,您可能已经熟悉这个概念——不同之处在于您有多个注释,并且您的注释可以是私有作用域。您可以创建其他类型甚至看不到——更不用说覆盖的注释。
XObject 上述方法用于添加和移除注释:
public void AddAnnotation (object annotation)
public void RemoveAnnotations<T>() where T : class
以下方法检索注释:
public T Annotation<T>() where T : class
public IEnumerable<T> Annotations<T>() where T : class
每个注释都以其类型作为键,必须是引用类型。以下是添加然后检索string注释的操作:
XElement e = new XElement ("test");
e.AddAnnotation ("Hello");
Console.WriteLine (e.Annotation<string>()); // Hello
您可以添加多个相同类型的注释,然后使用Annotations方法检索匹配序列。
诸如string之类的公共类型并不是一个很好的键,因为其他类型中的代码可能会干扰您的注释。更好的方法是使用内部或(嵌套的)私有类:
class X
{
class CustomData { internal string Message; } // Private nested type
static void Test()
{
XElement e = new XElement ("test");
e.AddAnnotation (new CustomData { Message = "Hello" } );
Console.Write (e.Annotations<CustomData>().First().Message); // Hello
}
}
要删除注释,您还必须访问键的类型:
e.RemoveAnnotations<CustomData>();
投射到 X-DOM 中
到目前为止,我们已经展示了如何使用 LINQ 从 X-DOM 中获取数据*。您还可以使用 LINQ 查询来投射*到 X-DOM。源可以是 LINQ 可查询的任何内容,例如以下内容:
-
EF Core 实体类
-
本地集合
-
另一个 X-DOM
无论来源如何,使用 LINQ 发出 X-DOM 的策略是相同的:首先编写一个功能构造表达式,以生成所需的 X-DOM 结构,然后围绕表达式构建 LINQ 查询。
例如,假设我们想要从数据库中检索以下 XML 的客户信息:
<customers>
<customer id="1">
<name>Sue</name>
<buys>3</buys>
</customer>
...
</customers>
我们从编写 X-DOM 的功能构造表达式开始,使用简单字面量:
var customers =
new XElement ("customers",
new XElement ("customer", new XAttribute ("id", 1),
new XElement ("name", "Sue"),
new XElement ("buys", 3)
)
);
然后我们将其转换为投射,并围绕其构建 LINQ 查询:
var customers =
new XElement ("customers",
// We must call AsEnumerable() due to a bug in EF Core.
from c in dbContext.Customers.AsEnumerable()
select
new XElement ("customer", new XAttribute ("id", c.ID),
new XElement ("name", c.Name),
new XElement ("buys", c.Purchases.Count)
)
);
注意
由于 EF Core 中的一个错误(计划在后续版本中修复),需要调用 AsEnumerable 方法。修复后,删除对 AsEnumerable 的调用将通过防止每次调用 c.Purchases.Count 都进行往返而提高效率。
这是结果:
<customers>
<customer id="1">
<name>Tom</name>
<buys>3</buys>
</customer>
<customer id="2">
<name>Harry</name>
<buys>2</buys>
</customer>
...
</customers>
通过两步构造相同的查询,我们可以更清晰地看到其工作原理。首先:
IEnumerable<XElement> sqlQuery =
from c in dbContext.Customers.AsEnumerable()
select
new XElement ("customer", new XAttribute ("id", c.ID),
new XElement ("name", c.Name),
new XElement ("buys", c.Purchases.Count)
);
这部分是一个正常的 LINQ 查询,将投影到 XElement 中。这是第二步:
var customers = new XElement ("customers", sqlQuery);
这构造了根 XElement。唯一不同寻常的是内容 sqlQuery 不是单个 XElement,而是实现了 IEnumerable<XElement> 的 IQueryable<XElement>。请记住,在处理 XML 内容时,集合会自动被枚举。因此,每个 XElement 都会作为子节点添加。
消除空元素
假设在前面的示例中,我们还想包含客户最近一次高价值购买的详细信息。我们可以这样做:
var customers =
new XElement ("customers",
// The AsEnumerable call can be removed when the EF Core bug is fixed.
from c in dbContext.Customers.AsEnumerable()
let lastBigBuy = (from p in c.Purchases
where p.Price > 1000
orderby p.Date descending
select p).FirstOrDefault()
select
new XElement ("customer", new XAttribute ("id", c.ID),
new XElement ("name", c.Name),
new XElement ("buys", c.Purchases.Count),
new XElement ("lastBigBuy",
new XElement ("description", lastBigBuy?.Description),
new XElement ("price", lastBigBuy?.Price ?? 0m)
)
)
);
这会发出空元素,但对于没有高价值购买的客户来说,不会发出 null。如果这是一个本地查询而不是数据库查询,它会抛出 NullReferenceException。在这种情况下,最好完全省略 lastBigBuy 节点。我们可以通过将 lastBigBuy 元素的构造函数包装在条件运算符中来实现:
select
new XElement ("customer", new XAttribute ("id", c.ID),
new XElement ("name", c.Name),
new XElement ("buys", c.Purchases.Count),
lastBigBuy == null ? null :
new XElement ("lastBigBuy",
new XElement ("description", lastBigBuy.Description),
new XElement ("price", lastBigBuy.Price)
对于没有 lastBigBuy 的客户,会发出 null 而不是空的 XElement。这正是我们想要的,因为 null 内容会被简单地忽略。
流式投影
如果你只是为了 Save(或对其调用 ToString)而将 X-DOM 投影,则可以通过 XStreamingElement 提高内存效率。XStreamingElement 是 XElement 的简化版本,将 延迟加载 语义应用于其子内容。要使用它,只需将外部的 XElement 替换为 XStreamingElement:
var customers =
new XStreamingElement ("customers",
from c in dbContext.Customers
select
new XStreamingElement ("customer", new XAttribute ("id", c.ID),
new XElement ("name", c.Name),
new XElement ("buys", c.Purchases.Count)
)
);
customers.Save ("data.xml");
XStreamingElement 构造函数中传递的查询,在调用元素的 Save、ToString 或 WriteTo 方法之前不会被枚举;这可以防止一次性将整个 X-DOM 加载到内存中。反之,重新 Save 时,查询会重新评估。此外,你无法遍历 XStreamingElement 的子内容—它不会公开诸如 Elements 或 Attributes 的方法。
XStreamingElement 不基于 XObject—或任何其他类—因为它具有非常有限的成员集。除了 Save、ToString 和 WriteTo 外,它唯一拥有的成员是以下几个:
-
Add方法,接受与构造函数类似的内容 -
Name属性
XStreamingElement 不允许以流式方式 读取 内容—为此,必须将 XmlReader 与 X-DOM 结合使用。我们在 “使用 XmlReader/XmlWriter 的模式” 中描述了如何做到这一点。
¹ X-DOM 实际上通过将简单文本内容存储在字符串中,在内部优化了这一步骤。直到在XContainer上调用Nodes( )时,XTEXT节点才会真正创建。
² 请参阅脚注 1。
第十一章:其他 XML 和 JSON 技术
在第十章中,我们介绍了 LINQ-to-XML API 和 XML 的一般概念。在本章中,我们将探索低级别的 XmlReader/XmlWriter 类以及处理 JavaScript 对象表示法(JSON)的相关类型,后者已成为 XML 的流行替代方案。
在在线补充中,我们描述了处理 XML 模式和样式表的工具。
XmlReader
XmlReader 是一种高性能的类,以逐个向前的方式读取 XML 流。
考虑以下 XML 文件,customer.xml:
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<customer id="123" status="archived">
<firstname>Jim</firstname>
<lastname>Bo</lastname>
</customer>
要实例化一个 XmlReader,您可以调用静态方法 XmlReader.Create,并传入一个 Stream、TextReader 或 URI 字符串:
using XmlReader reader = XmlReader.Create ("customer.xml");
...
注意
因为 XmlReader 允许从潜在缓慢的来源(Stream 和 URI)读取,它提供了其大多数方法的异步版本,使您能够轻松编写非阻塞代码。我们在第十四章中详细介绍了异步处理。
要构造一个从字符串读取的 XmlReader:
using XmlReader reader = XmlReader.Create (
new System.IO.StringReader (myString));
您还可以传入一个 XmlReaderSettings 对象来控制解析和验证选项。XmlReaderSettings 上的以下三个属性特别适用于跳过多余的内容:
bool IgnoreComments // Skip over comment nodes?
bool IgnoreProcessingInstructions // Skip over processing instructions?
bool IgnoreWhitespace // Skip over whitespace?
在下面的示例中,我们指示阅读器不要输出空白节点,因为在典型场景中它们会造成干扰:
XmlReaderSettings settings = new XmlReaderSettings();
settings.IgnoreWhitespace = true;
using XmlReader reader = XmlReader.Create ("customer.xml", settings);
...
XmlReaderSettings 的另一个有用属性是 ConformanceLevel。其默认值 Document 指示阅读器假定一个带有单个根节点的有效 XML 文档。如果要读取仅包含多个节点的 XML 内部部分,则会遇到问题:
<firstname>Jim</firstname>
<lastname>Bo</lastname>
要在不抛出异常的情况下读取此内容,必须将 ConformanceLevel 设置为 Fragment。
XmlReaderSettings 还有一个名为 CloseInput 的属性,指示在关闭阅读器时是否关闭底层流(XmlWriterSettings 上也有类似的 CloseOutput 属性)。CloseInput 和 CloseOutput 的默认值均为 false。
读取节点
XML 流的单位是 XML 节点。阅读器以文本方式(深度优先)遍历流。阅读器的 Depth 属性返回光标当前的深度。
从 XmlReader 中读取 XML 的最基本方法是调用 Read。它前进到 XML 流中的下一个节点,类似于 IEnumerator 中的 MoveNext。首次调用 Read 会将光标定位在第一个节点上。当 Read 返回 false 时,意味着光标已经超过了最后一个节点,在这种情况下应关闭并丢弃 XmlReader。
XmlReader 上的两个 string 属性提供对节点内容的访问:Name 和 Value。根据节点类型,Name 或 Value(或两者)将被填充。
在此示例中,我们逐个读取 XML 流中的每个节点,并输出每个节点的类型:
XmlReaderSettings settings = new XmlReaderSettings();
settings.IgnoreWhitespace = true;
using XmlReader reader = XmlReader.Create ("customer.xml", settings);
while (reader.Read())
{
Console.Write (new string (' ', reader.Depth * 2)); // Write indentation
Console.Write (reader.NodeType.ToString());
if (reader.NodeType == XmlNodeType.Element ||
reader.NodeType == XmlNodeType.EndElement)
{
Console.Write (" Name=" + reader.Name);
}
else if (reader.NodeType == XmlNodeType.Text)
{
Console.Write (" Value=" + reader.Value);
}
Console.WriteLine ();
}
输出如下:
XmlDeclaration
Element Name=customer
Element Name=firstname
Text Value=Jim
EndElement Name=firstname
Element Name=lastname
Text Value=Bo
EndElement Name=lastname
EndElement Name=customer
注意
属性不包括在基于 Read 的遍历中(参见“读取属性”)。
NodeType 的类型是 XmlNodeType,它是一个枚举,包含以下成员:
| None XmlDeclaration
Element
EndElement
Text
Attribute | Comment Entity
EndEntity
EntityReference
ProcessingInstruction
CDATA | Document DocumentType
DocumentFragment
Notation
Whitespace
SignificantWhitespace |
读取元素
通常,您已经知道正在读取的 XML 文档的结构。为了帮助处理,XmlReader 提供了一系列方法,这些方法在 假定 特定结构的同时读取内容。这样做不仅简化了您的代码,还同时进行了一些验证。
注意
如果验证失败,XmlReader 会抛出 XmlException。XmlException 具有 LineNumber 和 LinePosition 属性,指示错误发生的位置——如果 XML 文件很大,记录此信息至关重要!
ReadStartElement 验证当前的 NodeType 是 Element,然后调用 Read。如果指定了名称,则验证其是否与当前元素的名称匹配。
ReadEndElement 验证当前的 NodeType 是 EndElement,然后调用 Read。
例如,我们可以读取
<firstname>Jim</firstname>
如下所示:
reader.ReadStartElement ("firstname");
Console.WriteLine (reader.Value);
reader.Read();
reader.ReadEndElement();
ReadElementContentAsString 方法一次完成所有操作。它读取起始元素、文本节点和结束元素,将内容作为字符串返回:
string firstName = reader.ReadElementContentAsString ("firstname", "");
第二个参数是指命名空间,在本例中为空白。此方法还有类型化版本,如 ReadElementContentAsInt,用于解析结果。返回到我们的原始 XML 文档:
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<customer id="123" status="archived">
<firstname>Jim</firstname>
<lastname>Bo</lastname>
<creditlimit>500.00</creditlimit> <!-- OK, we sneaked this in! -->
</customer>
我们可以按以下方式读取它:
XmlReaderSettings settings = new XmlReaderSettings();
settings.IgnoreWhitespace = true;
using XmlReader r = XmlReader.Create ("customer.xml", settings);
r.MoveToContent(); // Skip over the XML declaration
r.ReadStartElement ("customer");
string firstName = r.ReadElementContentAsString ("firstname", "");
string lastName = r.ReadElementContentAsString ("lastname", "");
decimal creditLimit = r.ReadElementContentAsDecimal ("creditlimit", "");
r.MoveToContent(); // Skip over that pesky comment
r.ReadEndElement(); // Read the closing customer tag
注意
MoveToContent 方法非常有用。它跳过所有的冗余内容:XML 声明、空白、注释和处理指令。您还可以通过 XmlReaderSettings 上的属性自动指示阅读器执行大部分此操作。
可选元素
在前面的示例中,假设 <lastname> 是可选的。解决方法很简单:
r.ReadStartElement ("customer");
string firstName = r. ReadElementContentAsString ("firstname", "");
string lastName = r.Name == "lastname"
? r.ReadElementContentAsString() : null;
decimal creditLimit = r.ReadElementContentAsDecimal ("creditlimit", "");
随机元素顺序
本节的示例依赖于 XML 文件中元素按顺序出现的顺序。如果需要处理元素以任意顺序出现的情况,最简单的解决方案是将 XML 的该部分读入 X-DOM。我们稍后在“使用 XmlReader/XmlWriter 的模式”中描述如何做到这一点。
空元素
XmlReader 处理空元素的方式可能会导致严重问题。考虑以下元素:
<customerList></customerList>
在 XML 中,这相当于以下内容:
<customerList/>
然而,XmlReader 对这两种情况的处理方式不同。在第一种情况下,以下代码按预期工作:
reader.ReadStartElement ("customerList");
reader.ReadEndElement();
在第二种情况下,由于在 XmlReader 看来不存在单独的“结束元素”,因此 ReadEndElement 抛出异常。解决方法是检查空元素:
bool isEmpty = reader.IsEmptyElement;
reader.ReadStartElement ("customerList");
if (!isEmpty) reader.ReadEndElement();
实际上,这只有在所讨论的元素可能包含子元素(如客户列表)时才是一个麻烦。对于包含简单文本的元素(如firstname),你可以通过调用诸如ReadElementContentAsString的方法避免整个问题。ReadElement*XXX*方法可以正确处理这两种类型的空元素。
Other ReadXXX methods
Table 11-1 总结了XmlReader中所有Read*XXX*方法。其中大多数设计用于处理元素。粗体显示的示例 XML 片段是所述方法读取的部分。
表 11-1。读取方法
| Members | Works on NodeType | Sample XML fragment | Input parameters | Data returned |
|---|---|---|---|---|
ReadContentAs*XXX* | Text | <a>**x**</a> | x | |
ReadElementContentAs*XXX* | Element | **<a>x</a>** | x | |
ReadInnerXml | Element | **<a>x</a>** | x | |
ReadOuterXml | Element | **<a>x</a>** | <a>x</a> | |
ReadStartElement | Element | **<a>**x</a> | ||
ReadEndElement | Element | <a>x**</a>** | ||
ReadSubtree | Element | **<a>x</a>** | <a>x</a> | |
ReadToDescendant | Element | **<a>x**<b></b></a> | "b" | |
ReadToFollowing | Element | **<a>x**<b></b></a> | "b" | |
ReadToNextSibling | Element | **<a>x</a**><b></b> | "b" | |
ReadAttributeValue | Attribute | 参见“读取属性” |
ReadContentAs*XXX*方法将文本节点解析为类型*XXX*。在内部,XmlConvert类执行字符串到类型的转换。文本节点可以位于元素或属性内。
ReadElementContentAs*XXX*方法是相应的ReadContentAs*XXX*方法的包装。它们适用于元素节点,而不是包含在元素中的文本节点。
ReadInnerXml通常适用于元素,它会读取并返回元素及其所有后代。当应用于属性时,它会返回属性的值。ReadOuterXml与之类似,但它包括光标位置处的元素,而不是排除它。
ReadSubtree返回一个代理阅读器,提供对当前元素(及其后代)的视图。必须在可以安全再次读取原始阅读器之前关闭代理阅读器。关闭代理阅读器时,原始阅读器的光标位置移动到子树的末尾。
ReadToDescendant将光标移动到具有指定名称/命名空间的第一个后代节点的开头。ReadToFollowing将光标移动到具有指定名称/命名空间的第一个节点的开头,无论深度如何。ReadToNextSibling将光标移动到具有指定名称/命名空间的第一个同级节点的开头。
还有两个传统方法:ReadString 和 ReadElementString 的行为类似于 ReadContentAsString 和 ReadElementContentAsString,但如果元素包含多个 单一 文本节点,则会抛出异常。应避免使用这些方法,因为如果元素包含注释,它们会抛出异常。
读取属性
XmlReader 提供了一个索引器,让您可以直接(随机)访问元素的属性——按名称或位置。使用索引器等同于调用 GetAttribute。
给定 XML 片段
<customer id="123" status="archived"/>
我们可以读取其属性,如下所示:
Console.WriteLine (reader ["id"]); // 123
Console.WriteLine (reader ["status"]); // archived
Console.WriteLine (reader ["bogus"] == null); // True
警告
XmlReader 必须定位在 起始元素 上才能读取属性。在调用 ReadStartElement 后,属性将永远消失!
尽管属性顺序在语义上无关紧要,但可以通过其序数位置访问属性。我们可以将前述示例重写如下:
Console.WriteLine (reader [0]); // 123
Console.WriteLine (reader [1]); // archived
索引器还允许您指定属性的命名空间(如果有)。
AttributeCount 返回当前节点的属性数量。
属性节点
要显式遍历属性节点,必须从仅调用 Read 的正常路径进行特殊分流。这样做的一个好理由是,如果你想将属性值解析为其他类型,可以通过 ReadContentAs*XXX* 方法。
操作必须从 起始元素 开始。为了简化工作,在属性遍历期间放宽了单向规则:通过调用 MoveToAttribute,你可以跳转到任何属性(向前或向后)。
注意
MoveToElement 从属性节点转到 start 元素。
回到我们之前的例子:
<customer id="123" status="archived"/>
我们可以这样做:
reader.MoveToAttribute ("status");
string status = reader.ReadContentAsString();
reader.MoveToAttribute ("id");
int id = reader.ReadContentAsInt();
如果指定的属性不存在,MoveToAttribute 返回 false。
您还可以通过调用 MoveToFirstAttribute 然后调用 MoveToNextAttribute 方法按顺序遍历每个属性:
if (reader.MoveToFirstAttribute())
do { Console.WriteLine (reader.Name + "=" + reader.Value); }
while (reader.MoveToNextAttribute());
// OUTPUT:
id=123
status=archived
命名空间和前缀
XmlReader 提供了两个并行系统来引用元素和属性名称:
-
Name -
NamespaceURI和LocalName
每当读取一个元素的 Name 属性或调用接受单个 name 参数的方法时,你正在使用第一个系统。如果没有命名空间或前缀,这种方式非常有效;否则,它会以一种粗糙和字面的方式工作。命名空间被忽略,前缀被包含在其原样写入的位置;例如:
| 示例片段 | 名称 |
|---|---|
<**customer** ...> | customer |
<**customer** xmlns='blah' ...> | customer |
<**x:customer** ...> | x:customer |
下面的代码适用于前两种情况:
reader.ReadStartElement ("customer");
处理第三种情况需要以下操作:
reader.ReadStartElement ("x:customer");
第二个系统通过两个 命名空间感知 属性工作:NamespaceURI 和 LocalName。这些属性考虑了由父元素定义的前缀和默认命名空间。前缀会自动扩展。这意味着 NamespaceURI 总是反映当前元素的语义上正确的命名空间,而 LocalName 总是不带前缀的。
当您将两个名称参数传递给诸如 ReadStartElement 的方法时,您正在使用相同的系统。例如,考虑以下 XML:
<customer >
<address>
<other:city>
...
我们可以按以下方式读取它:
reader.ReadStartElement ("customer", "DefaultNamespace");
reader.ReadStartElement ("address", "DefaultNamespace");
reader.ReadStartElement ("city", "OtherNamespace");
抽象化掉前缀通常正是您想要的。如果需要,您可以通过调用 LookupNamespace 查看使用的前缀,并将其转换为命名空间。
XmlWriter
XmlWriter 是 XML 流的单向写入器。XmlWriter 的设计与 XmlReader 对称。
与 XmlTextReader 一样,您通过调用 Create(带有可选的 settings 对象)来构造 XmlWriter。在下面的示例中,我们启用缩进以使输出更易读,并写入一个简单的 XML 文件:
XmlWriterSettings settings = new XmlWriterSettings();
settings.Indent = true;
using XmlWriter writer = XmlWriter.Create ("foo.xml", settings);
writer.WriteStartElement ("customer");
writer.WriteElementString ("firstname", "Jim");
writer.WriteElementString ("lastname", "Bo");
writer.WriteEndElement();
这将产生以下文档(与我们在 XmlReader 的第一个示例中读取的文件相同):
<?xml version="1.0" encoding="utf-8"?>
<customer>
<firstname>Jim</firstname>
<lastname>Bo</lastname>
</customer>
XmlWriter 自动在顶部写入声明,除非在 XmlWriterSettings 中设置 OmitXmlDeclaration 为 true 或 ConformanceLevel 设置为 Fragment 以外。后者还允许写入多个根节点——否则会引发异常。
WriteValue 方法写入单个文本节点。它接受字符串和非字符串类型,如 bool 和 DateTime,在内部调用 XmlConvert 执行符合 XML 标准的字符串转换:
writer.WriteStartElement ("birthdate");
writer.WriteValue (DateTime.Now);
writer.WriteEndElement();
相反,如果我们调用
WriteElementString ("birthdate", DateTime.Now.ToString());
结果会既不符合 XML 标准,也容易受到错误解析的影响。
WriteString 等同于使用字符串调用 WriteValue。XmlWriter 自动转义否则在属性或元素中非法的字符,如 &、< > 和扩展的 Unicode 字符。
写入属性
您可以在写入 start 元素后立即写入属性:
writer.WriteStartElement ("customer");
writer.WriteAttributeString ("id", "1");
writer.WriteAttributeString ("status", "archived");
要写入非字符串值,请调用 WriteStartAttribute、WriteValue,然后 WriteEndAttribute。
写入其他节点类型
XmlWriter 还定义了用于写入其他类型节点的以下方法:
WriteBase64 // for binary data
WriteBinHex // for binary data
WriteCData
WriteComment
WriteDocType
WriteEntityRef
WriteProcessingInstruction
WriteRaw
WriteWhitespace
WriteRaw 直接将字符串注入输出流。还有一个接受 XmlReader 的 WriteNode 方法,从给定的 XmlReader 中回显所有内容。
命名空间和前缀
Write* 方法的重载允许您将元素或属性与命名空间关联起来。让我们重写前面示例中 XML 文件的内容。这次,我们将所有元素与 oreilly.com 命名空间关联起来,在 customer 元素处声明前缀 o:
writer.WriteStartElement ("o", "customer", "http://oreilly.com");
writer.WriteElementString ("o", "firstname", "http://oreilly.com", "Jim");
writer.WriteElementString ("o", "lastname", "http://oreilly.com", "Bo");
writer.WriteEndElement();
输出现在如下所示:
<?xml version="1.0" encoding="utf-8"?>
<o:customer xmlns:o='http://oreilly.com'>
<o:firstname>Jim</o:firstname>
<o:lastname>Bo</o:lastname>
</o:customer>
注意,为了简洁起见,当父元素已经声明了子元素的命名空间时,XmlWriter会省略子元素的命名空间声明。
使用 XmlReader/XmlWriter 的模式
处理层次数据
考虑以下类:
public class Contacts
{
public IList<Customer> Customers = new List<Customer>();
public IList<Supplier> Suppliers = new List<Supplier>();
}
public class Customer { public string FirstName, LastName; }
public class Supplier { public string Name; }
假设您希望使用XmlReader和XmlWriter将Contacts对象序列化为 XML,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<contacts>
<customer id="1">
<firstname>Jay</firstname>
<lastname>Dee</lastname>
</customer>
<customer> <!-- we'll assume id is optional -->
<firstname>Kay</firstname>
<lastname>Gee</lastname>
</customer>
<supplier>
<name>X Technologies Ltd</name>
</supplier>
</contacts>
最好的方法不是编写一个大方法,而是在Customer和Supplier类型本身中封装 XML 功能,通过编写这些类型的ReadXml和WriteXml方法来实现。这种模式很简单:
-
ReadXml和WriteXml在退出时将读者/写者保持在相同的深度。 -
ReadXml读取外部元素,而WriteXml仅写入其内部内容。
下面是如何编写Customer类型的方法:
public class Customer
{
public const string XmlName = "customer";
public int? ID;
public string FirstName, LastName;
public Customer () { }
public Customer (XmlReader r) { ReadXml (r); }
public void ReadXml (XmlReader r)
{
if (r.MoveToAttribute ("id")) ID = r.ReadContentAsInt();
r.ReadStartElement();
FirstName = r.ReadElementContentAsString ("firstname", "");
LastName = r.ReadElementContentAsString ("lastname", "");
r.ReadEndElement();
}
public void WriteXml (XmlWriter w)
{
if (ID.HasValue) w.WriteAttributeString ("id", "", ID.ToString());
w.WriteElementString ("firstname", FirstName);
w.WriteElementString ("lastname", LastName);
}
}
注意,ReadXml读取外部的起始和结束元素节点。如果它的调用者执行这个工作,Customer无法读取自己的属性。不在WriteXml中对称处理的原因有两个:
-
调用者可能需要选择外部元素的命名方式。
-
调用者可能需要写入额外的 XML 属性,例如元素的subtype(然后可以用于在读回元素时决定实例化哪个类)。
遵循这种模式的另一个好处是,它使您的实现与IXmlSerializable兼容(我们在在线补充材料的“序列化”中介绍了这一点,网址为http://www.albahari.com/nutshell)。
Supplier类类似于Customer:
public class Supplier
{
public const string XmlName = "supplier";
public string Name;
public Supplier () { }
public Supplier (XmlReader r) { ReadXml (r); }
public void ReadXml (XmlReader r)
{
r.ReadStartElement();
Name = r.ReadElementContentAsString ("name", "");
r.ReadEndElement();
}
public void WriteXml (XmlWriter w) =>
w.WriteElementString ("name", Name);
}
对于Contacts类,我们必须在ReadXml中枚举customers元素,检查每个子元素是客户还是供应商。我们还需要编写代码来处理空元素的陷阱:
public void ReadXml (XmlReader r)
{
bool isEmpty = r.IsEmptyElement; // This ensures we don't get
r.ReadStartElement(); // snookered by an empty
if (isEmpty) return; // <contacts/> element!
while (r.NodeType == XmlNodeType.Element)
{
if (r.Name == Customer.XmlName) Customers.Add (new Customer (r));
else if (r.Name == Supplier.XmlName) Suppliers.Add (new Supplier (r));
else
throw new XmlException ("Unexpected node: " + r.Name);
}
r.ReadEndElement();
}
public void WriteXml (XmlWriter w)
{
foreach (Customer c in Customers)
{
w.WriteStartElement (Customer.XmlName);
c.WriteXml (w);
w.WriteEndElement();
}
foreach (Supplier s in Suppliers)
{
w.WriteStartElement (Supplier.XmlName);
s.WriteXml (w);
w.WriteEndElement();
}
}
下面是如何将填充了客户和供应商的Contacts对象序列化为 XML 文件:
var settings = new XmlWriterSettings();
settings.Indent = true; // To make visual inspection easier
using XmlWriter writer = XmlWriter.Create ("contacts.xml", settings);
var cts = new Contacts()
// Add Customers and Suppliers...
writer.WriteStartElement ("contacts");
cts.WriteXml (writer);
writer.WriteEndElement();
以下是如何从同一文件反序列化的方法:
var settings = new XmlReaderSettings();
settings.IgnoreWhitespace = true;
settings.IgnoreComments = true;
settings.IgnoreProcessingInstructions = true;
using XmlReader reader = XmlReader.Create("contacts.xml", settings);
reader.MoveToContent();
var cts = new Contacts();
cts.ReadXml(reader);
将 XmlReader/XmlWriter 与 X-DOM 混合使用
在 XML 树的任何点,当XmlReader或XmlWriter变得过于笨重时,您可以在 X-DOM 中飞行。使用 X-DOM 处理内部元素是将 X-DOM 的易用性与XmlReader和XmlWriter的低内存占用结合起来的绝佳方式。
使用 XmlReader 与 XElement
要将当前元素读入 X-DOM,您可以调用XNode.ReadFrom,将XmlReader传递给它。与XElement.Load不同,此方法不是“贪婪”的,它只读取当前子树的末尾。
例如,假设我们有一个结构化如下的 XML 日志文件:
<log>
<logentry id="1">
<date>...</date>
<source>...</source>
...
</logentry>
...
</log>
如果有一百万个logentry元素,将整个内容读入 X-DOM 将浪费内存。更好的解决方案是使用XmlReader遍历每个logentry,然后使用XElement逐个处理元素:
XmlReaderSettings settings = new XmlReaderSettings();
settings.IgnoreWhitespace = true;
using XmlReader r = XmlReader.Create ("logfile.xml", settings);
r.ReadStartElement ("log");
while (r.Name == "logentry")
{
XElement logEntry = (XElement) XNode.ReadFrom (r);
int id = (int) logEntry.Attribute ("id");
DateTime date = (DateTime) logEntry.Element ("date");
string source = (string) logEntry.Element ("source");
...
}
r.ReadEndElement();
如果您遵循前一节描述的模式,您可以将XElement插入到自定义类型的ReadXml或WriteXml方法中,而调用方不会知道您曾经欺骗过!例如,我们可以重新编写Customer的ReadXml方法,如下所示:
public void ReadXml (XmlReader r)
{
XElement x = (XElement) XNode.ReadFrom (r);
ID = (int) x.Attribute ("id");
FirstName = (string) x.Element ("firstname");
LastName = (string) x.Element ("lastname");
}
XElement与XmlReader合作,确保命名空间保持完整,并且前缀正确扩展——即使在外部级别定义。因此,如果我们的 XML 文件如下所示:
<log >
<logentry id="1">
...
我们在logentry级别构造的XElement将正确继承外部命名空间。
使用XElement和XmlWriter
您可以仅使用XElement将内部元素写入XmlWriter以及如何使用XElement将一百万logentry元素写入 XML 文件——而无需将整个元素存储在内存中:
using XmlWriter w = XmlWriter.Create ("logfile.xml");
w.WriteStartElement ("log");
for (int i = 0; i < 1000000; i++)
{
XElement e = new XElement ("logentry",
new XAttribute ("id", i),
new XElement ("date", DateTime.Today.AddDays (-1)),
new XElement ("source", "test"));
e.WriteTo (w);
}
w.WriteEndElement ();
使用XElement会产生最小的执行开销。如果我们在整个示例中使用XmlWriter进行修改,执行时间没有明显差异。
使用 JSON 工作
JSON 已成为 XML 的流行替代方案。虽然缺乏 XML 的高级功能(如命名空间、前缀和模式),但它简单、清晰,并且其格式类似于将 JavaScript 对象转换为字符串的格式。
历史上,.NET 没有内置对 JSON 的支持,您必须依赖第三方库,主要是 Json.NET。尽管现在情况不再如此,但 Json.NET 库因多种原因仍然很受欢迎:
-
它已经存在自 2011 年以来。
-
同一 API 也可在较旧的.NET 平台上运行。
-
在过去至少被认为更为功能性(在这一部分中)比微软的 JSON API。
微软的 JSON API 具有从头设计为简单和极其高效的优势。此外,从.NET 6 开始,它们的功能已经与 Json.NET 非常接近。
在这一部分中,我们涵盖了以下内容:
-
前向只读读取器和写入器(
Utf8JsonReader和Utf8JsonWriter) -
JsonDocument只读 DOM 读取器 -
JsonNode读写 DOM 读取器/写入器
在http://www.albahari.com/nutshell的在线补充部分中的“序列化”中,我们介绍了JsonSerializer,它可以自动将 JSON 序列化和反序列化为类。
Utf8JsonReader
System.Text.Json.Utf8JsonReader是针对 UTF-8 编码的 JSON 文本的优化前向只读器。在概念上,它类似于本章前面介绍的XmlReader,并且使用方式大致相同。
考虑以下名为people.json的 JSON 文件:
{
"FirstName":"Sara",
"LastName":"Wells",
"Age":35,
"Friends":["Dylan","Ian"]
}
大括号表示JSON 对象(其中包含"FirstName"和"LastName"等属性),而方括号表示JSON 数组(其中包含重复元素)。在本例中,重复元素是字符串,但它们可以是对象(或其他数组)。
下面的代码通过枚举其 JSON 令牌 解析文件。令牌可以是对象的开始或结束,数组的开始或结束,属性的名称,或者数组或属性的值(字符串、数字、true、false 或 null):
byte[] data = File.ReadAllBytes ("people.json");
Utf8JsonReader reader = new Utf8JsonReader (data);
while (reader.Read())
{
switch (reader.TokenType)
{
case JsonTokenType.StartObject:
Console.WriteLine ($"Start of object");
break;
case JsonTokenType.EndObject:
Console.WriteLine ($"End of object");
break;
case JsonTokenType.StartArray:
Console.WriteLine();
Console.WriteLine ($"Start of array");
break;
case JsonTokenType.EndArray:
Console.WriteLine ($"End of array");
break;
case JsonTokenType.PropertyName:
Console.Write ($"Property: {reader.GetString()}");
break;
case JsonTokenType.String:
Console.WriteLine ($" Value: {reader.GetString()}");
break;
case JsonTokenType.Number:
Console.WriteLine ($" Value: {reader.GetInt32()}");
break;
default:
Console.WriteLine ($"No support for {reader.TokenType}");
break;
}
}
这是输出:
Start of object
Property: FirstName Value: Sara
Property: LastName Value: Wells
Property: Age Value: 35
Property: Friends
Start of array
Value: Dylan
Value: Ian
End of array
End of object
因为 Utf8JsonReader 直接使用 UTF-8,所以它在遍历令牌时无需先将输入转换为 UTF-16(.NET 字符串的格式)。只有在调用 GetString() 等方法时才会进行 UTF-16 转换。
有趣的是,Utf8JsonReader 的构造函数不接受字节数组,而是接受 ReadOnlySpan<byte>(因此,Utf8JsonReader 被定义为 ref struct)。你可以传入一个字节数组,因为从 T[] 到 ReadOnlySpan<T> 有隐式转换。在 第二十三章 中,我们描述了 span 的工作原理以及如何通过减少内存分配来提高性能。
JsonReaderOptions
默认情况下,Utf8JsonReader 要求 JSON 严格符合 JSON RFC 8259 标准。您可以通过向 Utf8JsonReader 构造函数传递 JsonReaderOptions 实例来指示读取器更加宽容。选项允许以下操作:
C 风格的注释
默认情况下,JSON 中的注释会导致 JsonException 异常被抛出。将 CommentHandling 属性设置为 JsonCommentHandling.Skip 可以忽略注释,而 JsonCommentHandling.Allow 则使读取器识别它们,并在遇到时发出 JsonTokenType.Comment 令牌。注释不能出现在其他令牌中间。
尾随逗号
根据标准,对象的最后一个属性和数组的最后一个元素不能有尾随逗号。将 AllowTrailingCommas 属性设置为 e 可以放宽此限制。
控制最大嵌套深度
默认情况下,对象和数组可以嵌套到 64 层。将 MaxDepth 设置为其他数字会覆盖此设置。
Utf8JsonWriter
System.Text.Json.Utf8JsonWriter 是一个顺序写入的 JSON 写入器。它支持以下类型:
-
String和DateTime(格式化为 JSON 字符串) -
数值类型
Int32、UInt32、Int64、UInt64、Single、Double和Decimal(这些类型被格式化为 JSON 数字) -
bool(格式化为 JSON 的 true/false 字面值) -
JSON null
-
数组
您可以按照 JSON 标准将这些数据类型组织成对象。它还允许您写入注释,尽管注释不是 JSON 标准的一部分,但实际上 JSON 解析器通常支持。
以下代码演示了其用法:
var options = new JsonWriterOptions { Indented = true };
using (var stream = File.Create ("MyFile.json"))
using (var writer = new Utf8JsonWriter (stream, options))
{
writer.WriteStartObject();
// Property name and value specified in one call
writer.WriteString ("FirstName", "Dylan");
writer.WriteString ("LastName", "Lockwood");
// Property name and value specified in separate calls
writer.WritePropertyName ("Age");
writer.WriteNumberValue (46);
writer.WriteCommentValue ("This is a (non-standard) comment");
writer.WriteEndObject();
}
这会生成以下输出文件:
{
"FirstName": "Dylan",
"LastName": "Lockwood",
"Age": 46
/*This is a (non-standard) comment*/
}
从 .NET 6 开始,Utf8JsonWriter 具有 WriteRawValue 方法,用于直接将字符串或字节数组写入 JSON 流。这在特殊情况下非常有用,例如,如果希望始终包含小数点(1.0 而不是 1)。
在这个例子中,我们将 JsonWriterOptions 上的 Indented 属性设置为 true 以提高可读性。如果没有这样做,输出将如下所示:
{"FirstName":"Dylan","LastName":"Lockwood","Age":46...}
JsonWriterOptions 还具有 Encoder 属性以控制字符串的转义,以及 SkipValidation 属性以允许跳过结构验证检查(从而允许发出无效的输出 JSON)。
JsonDocument
System.Text.Json.JsonDocument 将 JSON 数据解析为只读 DOM,由按需生成的 JsonElement 实例组成。与 Utf8JsonReader 不同,JsonDocument 允许您随机访问元素。
JsonDocument 是两种基于 DOM 的用于处理 JSON 的 API 之一,另一种是 JsonNode(我们将在下一节中介绍)。JsonNode 在 .NET 6 中引入,主要是为了满足对可写 DOM 的需求。然而,在只读场景中它也很适用,并且提供了一种更为流畅的接口,支持传统的使用类表示 JSON 值、数组和对象的 DOM。相比之下,JsonDocument 极其轻量,仅包含一个值得注意的类 (JsonDocument) 和两个轻量级结构体 (JsonElement 和 JsonProperty),它们按需解析底层数据。差异可见于 Figure 11-1。
注意
在大多数实际场景中,JsonDocument 相对于 JsonNode 的性能优势微乎其微,因此如果您更喜欢学习单一的 API,可以直接跳转到 JsonNode。
图 11-1. JSON DOM APIs
警告
JsonDocument 还通过使用池化内存来最小化垃圾回收以提高其效率。这意味着您必须在使用后释放 JsonDocument;否则,其内存将不会返回到池中。因此,当一个类在字段中存储 JsonDocument 时,还必须实现 IDisposable 接口。如果这样做很繁琐,请考虑改用 JsonNode。
静态的 Parse 方法从流、字符串或内存缓冲区实例化 JsonDocument:
using JsonDocument document = JsonDocument.Parse (jsonString);
...
在调用 Parse 时,您可以选择提供 JsonDocumentOptions 对象以控制处理尾随逗号、注释和最大嵌套深度(有关这些选项的工作方式,请参见 “JsonReaderOptions”)。
然后,您可以通过 RootElement 属性访问 DOM:
using JsonDocument document = JsonDocument.Parse ("123");
JsonElement root = document.RootElement;
Console.WriteLine (root.ValueKind); // Number
JsonElement 可以表示 JSON 值(字符串、数字、true/false、null)、数组或对象;ValueKind 属性指示其类型。
注意
在接下来的几节中描述的方法中,如果元素不是预期的类型,则会抛出异常。如果不确定 JSON 文件的结构,可以通过先检查 ValueKind(或使用 TryGet* 方法)来避免此类异常。
JsonElement 还提供了适用于任何类型元素的两种方法:GetRawText() 返回内部的 JSON 数据,WriteTo 将该元素写入 Utf8JsonWriter。
读取简单值
如果元素表示 JSON 值,则可以通过调用 GetString、GetInt32、GetBoolean 等方法获取其值。
using JsonDocument document = JsonDocument.Parse ("123");
int number = document.RootElement.GetInt32();
JsonElement 还提供了将 JSON 字符串解析为其他常用 CLR 类型(如 DateTime 和甚至 base-64 二进制)的方法。还有 TryGet* 版本,如果解析失败,则不会抛出异常。
读取 JSON 数组
如果 JsonElement 表示一个数组,则可以调用以下方法:
EnumerateArray()
枚举 JSON 数组的所有子项(作为 JsonElement)。
GetArrayLength()
返回数组中的元素数量。
您还可以使用索引器返回特定位置的元素:
using JsonDocument document = JsonDocument.Parse (@"[1, 2, 3, 4, 5]");
int length = document.RootElement.GetArrayLength(); // 5
int value = document.RootElement[3].GetInt32(); // 4
读取 JSON 对象
如果元素表示 JSON 对象,则可以调用以下方法:
EnumerateObject()
枚举对象的所有属性名和值。
GetProperty (string propertyName)
通过名称获取属性(返回另一个 JsonElement)。如果名称不存在,则抛出异常。
TryGetProperty (string propertyName, out JsonElement value)
如果存在对象的属性,则返回该属性。
例如:
using JsonDocument document = JsonDocument.Parse (@"{ ""Age"": 32}");
JsonElement root = document.RootElement;
int age = root.GetProperty ("Age").GetInt32();
以下是我们如何“发现”Age属性的方式:
JsonProperty ageProp = root.EnumerateObject().First();
string name = ageProp.Name; // Age
JsonElement value = ageProp.Value;
Console.WriteLine (value.ValueKind); // Number
Console.WriteLine (value.GetInt32()); // 32
JsonDocument 和 LINQ
JsonDocument 非常适合于 LINQ。给定以下 JSON 文件:
[
{
"FirstName":"Sara",
"LastName":"Wells",
"Age":35,
"Friends":["Ian"]
},
{
"FirstName":"Ian",
"LastName":"Weems",
"Age":42,
"Friends":["Joe","Eric","Li"]
},
{
"FirstName":"Dylan",
"LastName":"Lockwood",
"Age":46,
"Friends":["Sara","Ian"]
}
]
我们可以使用 JsonDocument 和 LINQ 进行查询,如下所示:
using var stream = File.OpenRead (jsonPath);
using JsonDocument document = JsonDocument.Parse (json);
var query =
from person in document.RootElement.EnumerateArray()
select new
{
FirstName = person.GetProperty ("FirstName").GetString(),
Age = person.GetProperty ("Age").GetInt32(),
Friends =
from friend in person.GetProperty ("Friends").EnumerateArray()
select friend.GetString()
};
因为 LINQ 查询是惰性评估的,所以在文档超出范围和由 using 语句隐式处理的 JsonDocument 被释放之前,枚举查询是非常重要的。
使用 JSON writer 进行更新
虽然 JsonDocument 是只读的,但可以使用 WriteTo 方法将 JsonElement 的内容发送到 Utf8JsonWriter 中。这提供了一种机制,用于生成一个修改后的 JSON 版本。以下是如何从前面的示例中获取 JSON 并将其写入一个新的 JSON 文件,该文件只包含具有两个或更多朋友的人:
using var json = File.OpenRead (jsonPath);
using JsonDocument document = JsonDocument.Parse (json);
var options = new JsonWriterOptions { Indented = true };
using (var outputStream = File.Create ("NewFile.json"))
using (var writer = new Utf8JsonWriter (outputStream, options))
{
writer.WriteStartArray();
foreach (var person in document.RootElement.EnumerateArray())
{
int friendCount = person.GetProperty ("Friends").GetArrayLength();
if (friendCount >= 2)
person.WriteTo (writer);
}
}
然而,如果您需要更新 DOM 的能力,JsonNode 是一个更好的解决方案。
JsonNode
JsonNode(位于 System.Text.Json.Nodes 中)在 .NET 6 中引入,主要是为了满足可写 DOM 的需求。然而,在只读场景中它也很合适,并且提供了一个较为流畅的接口,支持传统的基于类的 DOM,用于表示 JSON 值、数组和对象(参见图 11-1)。作为类,它们会产生垃圾回收的开销,但在大多数实际场景中这可能是可以忽略不计的。JsonNode 仍然高度优化,并且在重复读取相同节点时实际上可能比 JsonDocument 更快(因为 JsonNode 虽然是惰性的,但缓存了解析结果)。
静态的 Parse 方法从流、字符串、内存缓冲区或 Utf8JsonReader 创建一个 JsonNode:
JsonNode node = JsonNode.Parse (jsonString);
在调用 Parse 方法时,您可以选择提供一个 JsonDocumentOptions 对象来控制尾随逗号、注释和最大嵌套深度的处理(关于这些选项的工作原理,请参见“JsonReaderOptions”)。与 JsonDocument 不同,JsonNode 不需要释放。
注意
在 JsonNode 上调用 ToString() 方法将返回一个人类可读的(缩进的)JSON 字符串。还有一个 ToJsonString() 方法,返回一个紧凑的 JSON 字符串。
从 .NET 8 开始,JsonNode 包含一个静态的 DeepEquals 方法,因此您可以在不先将其展开为 JSON 字符串的情况下比较两个 JsonNode 对象。从 .NET 8 还有一个 DeepClone 方法。
Parse 方法返回 JsonNode 的子类型,可能是 JsonValue、JsonObject 或 JsonArray。为了避免类型转换的混乱,JsonNode 提供了名为 AsValue()、AsObject() 和 AsArray() 的辅助方法:
var node = JsonNode.Parse ("123"); // Parses to a JsonValue
int number = node.AsValue().GetValue<int>();
// Shortcut for ((JsonValue)node).GetValue<int>();
然而,通常情况下您不需要调用这些方法,因为 JsonNode 类本身公开了最常用的成员:
var node = JsonNode.Parse ("123");
int number = node.GetValue<int>();
// Shortcut for node.AsValue().GetValue<int>();
读取简单值
我们刚刚看到,您可以通过使用类型参数调用 GetValue 方法来提取或解析简单值。为了使这更加简单,JsonNode 重载了 C# 的显式转换运算符,从而实现了以下快捷方式:
var node = JsonNode.Parse ("123");
int number = (int) node;
此功能适用于标准数值类型:char、bool、DateTime、DateTimeOffset 和 Guid(及其可空版本),以及 string。
如果不确定解析是否成功,需要使用以下代码:
if (node.AsValue().TryGetValue<int> (out var number))
Console.WriteLine (number);
从 .NET 8 开始,调用 node.GetValueKind() 将告诉您节点是字符串、数字、数组、对象还是 true/false。
注意
从 JSON 文本解析出来的节点在内部由 JsonElement 支持(它是 JsonDocument 只读 JSON API 的一部分)。您可以按以下方式提取底层的 JsonElement:
JsonElement je = node.GetValue<JsonElement>();
然而,当节点是显式实例化时(例如在更新 DOM 时),这种方法不起作用。这些节点不是由 JsonElement 支持,而是由实际解析的值支持(请参见“使用 JsonNode 进行更新”)。
读取 JSON 数组
表示 JSON 数组的 JsonNode 将是 JsonArray 类型。
JsonArray 实现了 IList<JsonNode> 接口,因此可以枚举它并像数组或列表一样访问元素:
var node = JsonNode.Parse (@"[1, 2, 3, 4, 5]");
Console.WriteLine (node.AsArray().Count); // 5
foreach (JsonNode child in node.AsArray())
{ ... }
作为快捷方式,您可以直接从 JsonNode 类中访问索引器:
Console.WriteLine ((int)node[0]); // 1
从 .NET 8 开始,还可以调用 GetValues<T> 方法将数据作为 IEnumerable<T> 返回:
int[] values = node.AsArray().GetValues<int>().ToArray();
读取 JSON 对象
表示 JSON 对象的 JsonNode 将是 JsonObject 类型。
JsonObject 实现了 IDictionary<string, JsonNode> 接口,因此可以通过索引器访问成员,并枚举字典的键/值对。
与 JsonArray 类似,您也可以直接从 JsonNode 类中访问索引器:
var node = JsonNode.Parse (@"{ ""Name"":""Alice"", ""Age"": 32}");
string name = (string) node ["Name"]; // Alice
int age = (int) node ["Age"]; // 32
以下是我们如何“发现”Name 和 Age 属性的方式:
// Enumerate over the dictionary’s key/value pairs:
foreach (KeyValuePair<string,JsonNode> keyValuePair in node.AsObject())
{
string propertyName = keyValuePair.Key; // "Name" (then "Age")
JsonNode value = keyValuePair.Value;
}
如果你不确定某个属性是否已经定义,以下模式也适用:
if (node.AsObject().TryGetPropertyValue ("Name", out JsonNode nameNode))
{ ... }
流畅遍历和 LINQ
你可以仅通过索引器深入到层次结构中。例如,给定以下 JSON 文件:
[
{
"FirstName":"Sara",
"LastName":"Wells",
"Age":35,
"Friends":["Ian"]
},
{
"FirstName":"Ian",
"LastName":"Weems",
"Age":42,
"Friends":["Joe","Eric","Li"]
},
{
"FirstName":"Dylan",
"LastName":"Lockwood",
"Age":46,
"Friends":["Sara","Ian"]
}
]
我们可以按以下方式提取第二个人的第三个朋友:
string li = (string) node[1]["Friends"][2];
通过 LINQ,对这样的文件进行查询也很容易:
JsonNode node = JsonNode.Parse (File.ReadAllText (jsonPath));
var query =
from person in node.AsArray()
select new
{
FirstName = (string) person ["FirstName"],
Age = (int) person ["Age"],
Friends =
from friend in person ["Friends"].AsArray()
select (string) friend
};
不像JsonDocument,JsonNode是不可释放的,所以我们不必担心在惰性枚举期间可能的释放问题。
使用 JsonNode 进行更新
JsonObject和JsonArray是可变的,因此你可以更新它们的内容。
用索引器来替换或添加JsonObject的属性是最简单的方法。在下面的示例中,我们将 Color 属性的值从“Red”改为“White”,并添加了一个名为“Valid”的新属性:
var node = JsonNode.Parse ("{ \"Color\": \"Red\" }");
node ["Color"] = "White";
node ["Valid"] = true;
Console.WriteLine (node.ToJsonString()); // {"Color":"White","Valid":true}
上述示例的第二行是以下代码的简写形式:
node ["Color"] = JsonValue.Create ("White");
与其为属性分配一个简单的值,你可以将其分配为JsonArray或JsonObject。(我们将在下一节中展示如何构建JsonArray和JsonObject实例。)
要删除一个属性,首先要转换为JsonObject(或调用AsObject),然后调用Remove方法:
node.AsObject().Remove ("Valid");
(JsonObject还公开了一个Add方法,如果属性已存在则会抛出异常。)
JsonArray也允许你使用索引器来替换项:
var node = JsonNode.Parse ("[1, 2, 3]");
node[0] = 10;
调用AsArray公开了Add/Insert/Remove/RemoveAt方法。在下面的示例中,我们移除数组中的第一个元素并在末尾添加一个元素:
var arrayNode = JsonNode.Parse ("[1, 2, 3]");
arrayNode.AsArray().RemoveAt(0);
arrayNode.AsArray().Add (4);
Console.WriteLine (arrayNode.ToJsonString()); // [2,3,4]
从.NET 8 开始,你还可以通过调用ReplaceWith来更新JsonNode:
var node = JsonNode.Parse ("{ \"Color\": \"Red\" }");
var color = node["Color"];
color.ReplaceWith ("Blue");
以编程方式构建 JsonNode DOM
JsonArray和JsonObject具有支持对象初始化语法的构造函数,这允许你在一个表达式中构建整个JsonNode DOM:
var node = new JsonArray
{
new JsonObject {
["Name"] = "Tracy",
["Age"] = 30,
["Friends"] = new JsonArray ("Lisa", "Joe")
},
new JsonObject {
["Name"] = "Jordyn",
["Age"] = 25,
["Friends"] = new JsonArray ("Tracy", "Li")
}
};
这将计算为以下 JSON:
[
{
"Name": "Tracy",
"Age": 30,
"Friends": ["Lisa", "Joe"]
},
{
"Name": "Jordyn",
"Age": 25,
"Friends": ["Tracy","Li"]
}
]