专业的编程技术博客社区

网站首页 > 博客文章 正文

c# 10 教程:11 其他 XML 和 JSON 技术

baijin 2024-09-05 11:38:29 博客文章 4 ℃ 0 评论


在第 中,我们介绍了 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"]
  }
]

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表