在第 中,我们介绍了 LINQ to-XML API 以及一般的 XML。在本章中,我们将探讨低级 XmlReader / XmlWriter 类以及使用 JavaScript 对象表示法 (JSON) 的类型,它已成为 XML 的流行替代品。
在中,我们描述了用于处理 XML 架构和样式表的工具。
XmlReader
XmlReader 是一个高性能类,用于以低级别、只进的方式读取 XML 流。
请考虑以下 XML 文件,客户.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 允许您从可能较慢的源(流 s 和 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 上的另一个有用属性是 一致性级别 。其默认值“文档”指示读取器假定具有单个根节点的有效 XML 文档。如果您只想读取包含多个节点的 XML 内部部分,则会出现此问题:
<firstname>Jim</firstname>
<lastname>Bo</lastname>
若要在不引发异常的情况下阅读此内容,必须将“一致性级别”设置为“片段”。
XmlReaderSettings 还有一个名为 CloseInput 的属性,它指示在读取器关闭时是否关闭基础流(XmlWriterSettings 上有一个类似的属性称为 CloseOutput )。“关闭输入”和“关闭输出”的默认值为 false。
读取节点
XML 流的单元是 。读取器按文本(深度优先)顺序遍历流。读取器的 Depth 属性返回光标的当前深度。
从 XmlReader 读取的最原始方法是调用 Read 。它前进到 XML 流中的下一个节点,就像 IEnumerator 中的 MoveNext 一样。对 Read 的第一次调用将光标定位在第一个节点上。当 Read 返回 false 时,表示游标已最后一个节点,此时 XmlReader 应关闭并放弃。
XmlReader 上的两个字符串属性提供对节点内容的访问:“名称”和“值”。根据节点类型,将填充“名称”或“值”(或两者)。
在此示例中,我们读取 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
注意
属性不包括在基于读取的遍历中(请参阅)。
NodeType 的类型是 XmlNodeType ,它是一个包含以下成员的枚举:
无 XML声明元素 结束元素文本属性 | 注释实体结束实体引用处理指令 CDATA | 文档 文档类型 文档片段表示法 空格 显著空格 |
阅读元素
通常,您已经知道正在阅读的 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 上的属性自动执行大部分操作。
可选元素
在前面的示例中,假设<姓氏>是可选的。解决方案很简单:
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();
在第二种情况下,ReadEndElement 会引发异常,因为就 XmlReader 而言,没有单独的“结束元素”。解决方法是检查空元素:
bool isEmpty = reader.IsEmptyElement;
reader.ReadStartElement ("customerList");
if (!isEmpty) reader.ReadEndElement();
实际上,仅当相关元素可能包含子元素(例如客户列表)时,这才令人讨厌。使用包装简单文本的元素(如名字),可以通过调用诸如 ReadElementContentAsString 之类的方法来避免整个问题。这些方法可以正确处理这两种空元素。ReadElementXXX
其他读取方法
总结了 XmlReader 中的所有方法。其中大多数都旨在处理元素。以粗体显示的示例 XML 片段是所述方法读取的部分。ReadXXX
读取方法 | ||||
成员 | 适用于节点类型 | 示例 XML 片段 | 输入参数 | 返回的数据 |
ReadContentAsXXX | 发短信 | <a>x</a> | x | |
ReadElementCon?tent?AsXXX | 元素 | <a>x</a> | x | |
ReadInnerXml | 元素 | <a>x</a> | x | |
ReadOuterXml | 元素 | <a>x</a> | <a>x</a> | |
读取启动元素 | 元素 | <a>x</a> | ||
读取端元素 | 元素 | <a>x</a> | ||
读取子树 | 元素 | <a>x</a> | <a>x</a> | |
读取到后代 | 元素 | <a>x<b></b></a> | “乙” | |
阅读到以下 | 元素 | <a>x<b></b></a> | “乙” | |
阅读到下一个兄弟姐妹 | 元素 | <a>x</a><b></b> | “乙” | |
读取属性值 | 属性 | 请参阅 |
这些方法将文本节点解析为类型 。在内部,类执行字符串到类型的转换。文本节点可以位于元素或属性中。ReadContentAsXXXXXX
这些方法是相应方法的包装器。它们适用于元素节点,而不是所包围节点。ReadElementContentAsXXXReadContentAsXXX
ReadInnerXml 通常应用于元素,它读取并返回元素及其所有后代。应用于属性时,它返回属性的值。ReadOuterXml 是相同的,只是它包含而不是排除光标位置的元素。
ReadSubtree 返回一个代理读取器,该读取器仅提供当前元素(及其后代)的视图。必须先关闭代理读取器,然后才能安全地再次读取原始读取器。当代理读取器关闭时,原始读取器的光标位置移动到子树的末尾。
ReadToDescendant 将光标移动到具有指定名称/命名空间的第一个后代节点的开头。ReadToFollow 将光标移动到具有指定名称/命名空间的第一个节点的开头(无论深度如何)。ReadToNext同级将光标移动到具有指定名称/命名空间的第一个同级节点的开头。
还有两种遗留方法: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 的正常路径进行特殊转移。这样做的一个很好的理由是,如果要通过方法将属性值解析为其他类型。ReadContentAsXXX
转移必须从开始。为了使工作更轻松,在属性遍历期间放宽了只进规则:您可以通过调用 MoveToAttribute 跳转到任何属性(向前或向后)。
注意
MoveToElement 从属性节点转移中的任何位置返回到起始元素。
回到我们之前的示例
<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 提供了两个并行系统来引用元素和属性名称:
- 名字
- 命名空间 URI 和本地名称
每当读取元素的 Name 属性或调用接受单个名称参数的方法时,您使用的是第一个系统。如果不存在命名空间或前缀,这很有效;否则,它以粗略和字面的方式行事。命名空间将被忽略,前缀将完全按照写入时的方式包含在内;例如:
样品片段 | 名字 |
<customer ...> | 客户 |
<customer xmlns='blah' ...> | 客户 |
<x:customer ...> | X:客户 |
以下代码适用于前两种情况:
reader.ReadStartElement ("customer");
处理第三种情况需要满足以下条件:
reader.ReadStartElement ("x:customer");
第二个系统通过两个命名空间感知属性工作: URI 和 LocalName。这些属性考虑了父元素定义的前缀和默认命名空间。前缀会自动展开。这意味着 NamespaceURI 始终反映当前元素的语义正确的命名空间,并且 LocalName 始终没有前缀。
当您将两个名称参数传递到诸如 ReadStartElement 之类的方法中时,您使用的是同一个系统。例如,请考虑以下 XML:
<customer xmlns="DefaultNamespace" xmlns:other="OtherNamespace">
<address>
<other:city>
...
我们可以这样解读:
reader.ReadStartElement ("customer", "DefaultNamespace");
reader.ReadStartElement ("address", "DefaultNamespace");
reader.ReadStartElement ("city", "OtherNamespace");
抽象掉前缀通常正是您想要的。如有必要,可以通过 Prefix 属性查看使用了哪个前缀,并通过调用 LookupNamespace 将其转换为命名空间。
XmlWriter
XmlWriter 是 XML 流的只进编写器。XmlWriter的设计与XmlReader对称。
与 XmlTextReader 一样,您可以通过使用可选设置对象调用 Create 来构造 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 或将一致性级别设置为“片段”来另行指示。后者还允许写入多个根节点,否则会引发异常。
方法写入单个文本节点。它接受字符串和非字符串类型,如布尔值和日期时间,在内部调用XmlConvert来执行符合XML的字符串转换:
writer.WriteStartElement ("birthdate");
writer.WriteValue (DateTime.Now);
writer.WriteEndElement();
相反,如果我们打电话
WriteElementString ("birthdate", DateTime.Now.ToString());
结果将不符合 XML,并且容易受到错误分析的影响。
WriteString 等效于用字符串调用 WriteValue。XmlWriter 会自动转义属性或元素中原本是非法的字符,例如 & 、< > 和扩展 Unicode 字符。
写入属性
您可以在编写开始元素后立即写入属性:
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 直接将字符串注入输出流中。还有一个 WriteNode 方法接受 XmlReader ,回显给定的 XmlReader 中的所有内容。
命名空间和前缀
Write* 方法的重载允许您将元素或属性与命名空间相关联。让我们重写前面示例中 XML 文件的内容。这次我们将所有元素与 命名空间相关联,在 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 将联系人对象序列化为 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>
最好的方法不是编写一个大方法,而是通过在客户和供应商类型上编写 ReadXml 和 WriteXml 方法,将 XML 功能封装在这些类型本身中。这样做的模式很简单:
- ReadXml 和 WriteXml 在退出时将读取器/写入器留在相同的深度。
- ReadXml 读取外部元素,而 WriteXml 只写入其内部内容。
以下是我们编写客户类型的方式:
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 读取外部开始和结束元素节点。如果其调用方改为执行此工作,则客户无法读取其自己的属性。在这方面,不使 WriteXml 对称的原因有两个:
- 调用方可能需要选择外部元素的命名方式。
- 调用方可能需要编写额外的 XML 属性,例如元素的(然后可用于决定在读回元素时要实例化哪个类)。
遵循此模式的另一个好处是,它使您的实现与 IXmlSerializable 兼容(我们在 在线补充的“序列化”中对此进行了介绍)。
供应商类类似:
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);
}
使用联系人类,我们必须枚举 客户元素 ReadXML ,检查每个子元素是客户还是供应商。我们还需要围绕空元素陷阱进行编码:
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();
}
}
下面介绍如何将填充有客户和供应商的联系人对象序列化为 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>
如果有一百万个日志条目元素,将整个内容读取到 X-DOM 中会浪费内存。更好的解决方案是使用 XmlReader 遍历每个日志条目,然后使用 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 方法中,而调用方永远不会知道您作弊了!例如,我们可以重写客户的 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 xmlns="http://loggingspace">
<logentry id="1">
...
我们在日志条目级别构建的 XElements 将正确继承外部命名空间。
将 XmlWriter 与 XElement 一起使用
您可以使用 XElement 将内部元素写入 XmlWriter。下面的代码使用 XElement 将一百万个日志条目元素写入 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 平台上运行。
- 它被认为比Microsoft JSON API 功能更强大(至少在过去)。
Microsoft JSON API 的优势在于从头开始设计,简单且非常高效。此外,从 .NET 6 开始,它们的功能已非常接近 Json.NET。
在本节中,我们将介绍以下内容:
- 只进读取器和写入器(Utf8JsonReader 和 Utf8JsonWriter)
- JsonDocument 只读文档对象模型 (DOM) 读取器
- JsonNode 读/写 DOM 读取器/写入器
在 在线增刊的“序列化”中,我们介绍了 JsonSerializer,它会自动将 JSON 序列化和反序列化为类。
Utf8JsonReader
是针对 UTF-8 编码的 JSON 文本优化的只进阅读器。从概念上讲,它类似于本章前面介绍的 XmlReader,并且使用方式大致相同。
请考虑以下名为 的 JSON 文件:
{
"FirstName":"Sara",
"LastName":"Wells",
"Age":35,
"Friends":["Dylan","Ian"]
}
大括号表示 JSON (包含“名字”和“姓氏”等),而方括号表示 (包含重复元素)。在这种情况下,重复元素是字符串,但它们可以是对象(或其他数组)。
以下代码通过枚举其 JSON 来分析文件。标记是对象的开头或结尾、数组的开头或结尾、属性的名称或数组或属性值(字符串、数字、真、假或空)。
byte[] data = File.ReadAllBytes ("people.json");
Utf8JsonReader reader = new Utf8JsonReader (data);
while (reader.Read())
{
switch (reader.TokenType)
{
case JsonTokenType.StartObject:
Console.WriteLine (#34;Start of object");
break;
case JsonTokenType.EndObject:
Console.WriteLine (#34;End of object");
break;
case JsonTokenType.StartArray:
Console.WriteLine();
Console.WriteLine (#34;Start of array");
break;
case JsonTokenType.EndArray:
Console.WriteLine (#34;End of array");
break;
case JsonTokenType.PropertyName:
Console.Write (#34;Property: {reader.GetString()}");
break;
case JsonTokenType.String:
Console.WriteLine (#34; Value: {reader.GetString()}");
break;
case JsonTokenType.Number:
Console.WriteLine (#34; Value: {reader.GetInt32()}");
break;
default:
Console.WriteLine (#34;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 被定义为 )。你可以传入一个字节数组,因为有一个从 T[] 到 ReadOnlySpan<T 的隐式转换> 。中,我们描述了跨度的工作原理,以及如何通过最小化内存分配来使用它们来提高性能。
JsonReaderOptions
默认情况下,Utf8JsonReader 要求 JSON 严格遵守 JSON RFC 8259 标准。您可以通过将 JsonReaderOptions 的实例传递给 Utf8JsonReader 构造函数来指示读取器更加宽容。这些选项允许执行以下操作:
C 样式注释
默认情况下,JSON 中的注释会导致引发 JsonException。将 CommentHandling 属性设置为 JsonCommentHandling.Skip 会导致忽略注释,而 JsonCommentHandling.Allow 会导致读者识别它们并在遇到注释时发出 JsonTokenType.Comment 令牌。注释不能出现在其他令牌的中间。
尾随逗号
根据标准,对象的最后一个属性和数组的最后一个元素不得具有尾随逗号。将 AllowTrailingCommmas 属性设置为 e 可放宽此限制。
控制最大嵌套深度
默认情况下,对象和数组可以嵌套到 64 个级别。将“最大深度”设置为其他数字将覆盖此设置。
Utf8JsonWriter
是一个只进的 JSON 编写器。它支持以下类型:
- 字符串和日期时间(格式为 JSON 字符串)
- 数字类型 Int32 、UInt32 、 Int64 、UInt64、Single 、Double 、Decimal (格式化为 JSON 数字)
- 布尔值(格式化为 JSON 真/假文字)
- JSON 空
- 阵 列
您可以根据 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 上的缩进属性设置为 true 以提高可读性。如果我们没有这样做,输出将如下所示:
{"FirstName":"Dylan","LastName":"Lockwood","Age":46...}
JsonWriterOptions 还有一个 Encoder 属性来控制字符串的转义,以及 SkipValidation 属性,用于允许绕过结构验证检查(允许发出无效的输出 JSON)。
JsonDocument
将 JSON 数据解析为由按需生成的 JsonElement 实例组成的只读文档对象模型 (DOM)。与Utf8JsonReader不同,JsonDocument允许您随机访问元素。
JsonDocument 是用于处理 JSON 的两个基于 DOM 的 API 之一,另一个是 JsonNode(我们将在下一节中介绍)。JsonNode是在.NET 6中引入的,主要是为了满足对可写DOM的需求。但是,它也适用于只读方案,并公开了一个更流畅的接口,该接口由使用类作为 JSON 值、数组和对象的传统 DOM 提供支持。相比之下,JsonDocument 是非常轻量级的,只包含一个注释类(JsonDocument)和两个轻量级结构(JsonElement 和 JsonProperty ),它们按需解析底层数据。 所示。
注意
在大多数实际场景中,JsonDocument 相对于 JsonNode 的性能优势可以忽略不计,因此,如果您只想学习一个 API,可以跳到 JsonNode。
JSON DOM API
警告
JsonDocument 通过使用池内存来最大程度地减少垃圾回收,从而进一步提高了其效率。这意味着您必须在使用后处置 JsonDocument;否则,其内存将不会返回到池中。因此,当类在字段中存储 JsonDocument 时,它还必须实现 IDisposable 。如果这很麻烦,请考虑改用 JsonNode。
静态 Parse 方法从流、字符串或内存缓冲区实例化 JsonDocument:
using JsonDocument document = JsonDocument.Parse (jsonString);
...
调用 Parse 时,可以选择提供一个 JsonDocumentOptions 对象来控制尾随逗号、注释和最大嵌套深度的处理(有关这些选项如何工作的讨论,请参阅)。
从那里,您可以通过 RootElement 属性访问 DOM:
using JsonDocument document = JsonDocument.Parse ("123");
JsonElement root = document.RootElement;
Console.WriteLine (root.ValueKind); // Number
JsonElement 可以表示 JSON 值(字符串、数字、真/假、空)、数组或对象;属性指示哪个。
注意
我们在以下部分中介绍的方法在元素不属于预期的类型时引发异常。如果不确定 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 表示数组,则可以调用以下方法:
枚举数组()
枚举 JSON 数组的所有子项(作为 JsonElement s)。
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 对象,则可以调用以下方法:
枚举对象()
枚举对象的所有属性名称和值。
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 查询是延迟计算的,因此在文档超出范围之前枚举查询非常重要,并且 JsonDocument 通过 using 语句隐式释放。
使用 JSON 编写器进行更新
尽管 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的需求。但是,它也适用于只读方案,并公开了一个更流畅的接口,该接口由使用 JSON 值、数组和对象的类的传统 DOM 提供支持(参见)。作为类,它们会产生垃圾收集成本,但这在大多数现实世界的场景中可能可以忽略不计。JsonNode 仍然高度优化,当重复读取相同的节点时,实际上可以比 JsonDocument 更快(因为 JsonNode 虽然懒惰,但会缓存解析的结果)。
静态 Parse 方法从流、字符串、内存缓冲区或 Utf8JsonReader 创建一个 JsonNode:
JsonNode node = JsonNode.Parse (jsonString);
调用 Parse 时,可以选择提供一个 JsonDocumentOptions 对象来控制尾随逗号、注释和最大嵌套深度的处理(有关这些选项如何工作的讨论,请参阅)。与JsonDocument不同,JsonNode不需要处置。
注意
在 JsonNode 上调用 ToString() 会返回一个人类可读(缩进)的 JSON 字符串。还有一个ToJsonString()方法,它返回一个紧凑的JSON字符串。
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(及其可为空的版本),以及字符串。
如果不确定分析是否会成功,则需要以下代码:
if (node.AsValue().TryGetValue<int> (out var number))
Console.WriteLine (number);
注意
从 JSON 文本解析的节点在内部由 JsonElement(JsonDocument 只读 JSON API 的一部分)提供支持。您可以按如下方式提取底层 JsonElement:
JsonElement je = node.GetValue<JsonElement>();
但是,当显式实例化节点时,这不起作用(就像我们更新 DOM 时一样)。这些节点不是由 JsonElement 支持,而是由实际解析的值支持(参见)。
读取 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
读取 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
以下是我们如何“发现”名称和年龄属性:
// 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 属性的值从“红色”更改为“白色”,并添加一个名为 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]
以编程方式构造 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"]
}
]
本文暂时没有评论,来添加一个吧(●'◡'●)