65.9K
CodeProject 正在变化。 阅读更多。
Home

精简 JSON 和可乐:探索极其高效的 JSON 处理

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.73/5 (8投票s)

2020年12月23日

MIT

54分钟阅读

viewsIcon

8314

downloadIcon

133

在任何平台上使用新的 JSON 范例,实现对 JSON 数据源的高效访问和选择性批量加载 JSON。

data.json

引言

JSON 库通常使用内存模型来存储数据,但当您处理大量数据、内存极少或两者兼有时,这种方法很快就会崩溃。

在这里,我们将努力让 JSON “瘦身”,并流式传输所有内容,以便我们可以在任何机器上处理大量数据,无论其内存大小如何。现在,您可以“超大份”您的 JSON,而无需感到内疚。

该库提供了一些创新功能——流式搜索和检索,称为“提取”。这允许您预加载文档中相关字段、数组元素或值的多个请求,并将它们排队以便通过一次高效的操作进行检索。它不是事务原子性的,因为对于纯粹的仅向前流式拉取解析器来说这是不可能的,但它已经尽可能接近了。执行提取时,读取器会自动知道如何跳过不需要的数据而无需将其加载到 RAM 中,因此使用这些提取可以严格控制您接收和不接收的内容,而无需将字段名或值加载到 RAM 中进行比较或跳过。您只需保留足够的空间来存放您实际想要查看的内容。

我上一个 JSON 产品发布不久,但我决定重写它以进行清理,改变从源中检索数据的方式,并确保它可以针对 ATmega2560 等 8 位处理器。我不再支持白名单和黑名单“过滤器”,而是使用一种不同的机制进行高效检索,同样称为“提取”。

这个新的改进库还具有更具可读性的代码和更多示例代码,并修复了许多错误。

它可用于处理几乎任何大小的批量 JSON 数据,高效地搜索和选择性地提取数据,并且应该在任何提供 C++ 的平台上编译。

早期发布说明

目前,此库不支持 UTF-8 Unicode,仅支持 ASCII。UTF-8 正在添加中,并将很快更新。

概念化这个混乱的局面

忘记内存中的树。当您处理总共 8KB 内存和/或数千兆字节数据时,内存中的树不会让您走得太远。

这个库是围绕拉取解析器构建的。拉取解析器是一种一次一小步读取输入的解析器。您在一个循环中调用它的 read() 方法,直到它返回 false,然后对于每次迭代,您可以查询解析器以获取有关您正在检查的文档中当前节点的各种信息。

拉取解析器的优点是它们非常节省时间/空间,这意味着它们速度快且不占用大量内存。主要缺点是它们可能难以使用。但是,我采取了一些措施来降低其使用难度,因为您可以“编程”它一次性从文档中的各个路径提取数据,并且它将忠实地执行您的提取,然后将解析器返回到已知状态/可预测位置。

因此,您不必实际使用多少(如果有的话)基本拉取解析器读取函数。您主要可以依赖 skipToField()/skipToFieldValue()skipToIndex()extract()

对于下面的每个部分,我将为您提供概述,然后介绍重要方法。稍后,我们将探讨一些实际代码。

本文档中用到的一些术语

  • 元素 - JSON 数据的逻辑单元。它可以是数组、对象或某种标量值。一个*文档*在逻辑上由这些组成。某些元素——即数组和对象,有子元素。子元素是每个字段的值或数组中的项。
  • 节点 - 某种 JSON 的标记。一个*文档*或多或少地“物理地”由这些组成。例如,JsonReader::Object 节点类型表示我们刚刚通过了文档中的 {,而 JsonReader::EndObject 节点类型表示我们刚刚通过了文档中的 }。*元素*是自包含的单元,而*节点*不是。例如,要使文档格式良好,每个开始节点(如 JsonReader::ArrayJsonReader::Object)都必须有一个对应的结束节点(JsonReader::EndArrayJsonReader::EndObject)。*元素*没有对应的“结束元素”——一个 JSON *元素*及其逻辑后代跨越几个“物理”*节点*。
  • 文档 - 指示整个格式良好的 JSON 数据源(例如文件)的逻辑构造。一个*文档*始于 JsonReader::Initial 节点,结束于 JsonReader::EndDocument 节点。一个*文档*有一个单一的根*元素* - 无论是数组、对象还是标量值。任何其他*元素*只能存在于根*元素*或其后代之下。

初始化和重置

让我们从如何创建一个输入源的读取器以及如何向其提供新的输入源以重新开始的相关方法开始。

JsonReader()

参数
  • lexContext - 要使用的输入源,类型为 LexContext

这将使读取器处于初始状态。

构造函数接受一个参数 - 一个 LexContext 派生类的实例,它用作输入源和用于保存从文件中提取的相关数据的捕获缓冲区。不同的 LexContext 派生类用于从不同类型的输入中读取,尽管 ArduinoLexContext 接受一个 Arduino Stream,这使得它非常通用,因为文件、网络连接和串行端口都从中派生 - 至少在 Arduino 平台上是这样。一旦您选择并声明了适当的 LexContext,您可以将一个实例传递给 JsonReader 构造函数。

reset()

参数
  • lexContext - 要使用的输入源,类型为 LexContext

重置读取器也会使其返回到初始状态,此时,您可以向其传递一个新的 LexContext。这基本上就像再次调用构造函数一样。

检索关联的 LexContext

有时,您可能想获取读取器附加的 LexContext。一个特别好的原因是您可以从中获取当前的物理光标位置,包括行和列信息。这可以帮助您调试查询。

context()

检索与此读取器关联的词法上下文信息。

Returns
  • 一个引用当前附加输入源和捕获缓冲区的 LexContext&

主要用于信息目的,帮助您在查询期间跟踪读取器。它包含位置信息和捕获缓冲区,有时会很有用,尽管您通常可以通过调用读取器的 value() 方法来获取捕获缓冲区 - 参见下文。

光标和位置信息

如果您不确定读取器在文档中的位置,您可以检查此处。请记住,物理光标始终位于*下一个*节点的开头,这意味着它只有在通过 { 标记之后才会注册 JsonReader::ObjectnodeType()。因此,要跟踪您的逻辑位置,只需查看物理光标当前位置后面一个节点。如果您位于对象中第一个字段的开头,您是 JsonReader::Object,而不是 JsonReader::Field。一旦您通过了字段名和“:”,您将位于 JsonReader::Field。一旦您通过了标量值,您将位于 JsonReader::Value。物理光标位置不会直接从 JsonReader 报告,原因很简单,它与 nodeType()value() 等反映的逻辑光标位置不匹配。虽然可以跟踪每个逻辑节点的开始和结束位置,但这需要更多的成员变量,因此需要更多的堆栈来运行。我觉得这不值得,因为这种功能更适合高亮编辑器等。

词法分析和自定义输入源

LexContext 是所有词法输入源的基类,无论它们是来自文件、内存字符串、网络套接字还是其他来源。

一个 LexContext 管理两件事——一个输入源和一个捕获缓冲区。它们在同一个类中的原因是它们相互操作,并且词法分析和捕获这两个任务紧密交织。例如,您可以使用 tryReadUntil('\"','\\',false) 来读取 C 风格字符串引号之间的所有内容,同时尊重(但不解码)转义字符。在执行此操作时,LexContext 正在做两件事——它从输入源读取,并且在读取时,它正在捕获到捕获缓冲区。如果捕获和读取的这些关注点被分离到不同的类中,将大大复杂化使用和扩展这些设施与更多的 tryReadXXXX() 方法。

目前 FileLexContext (在 *FileLexContext.hpp* 中) 和 SZLexContext (在 *LexContext.hpp* 中) 实现了两个自定义输入源。请注意,它们也是抽象的,因为它们没有实现捕获设施。

您通常不需要自己实现捕获功能。您可以创建一个派生自 StaticLexContext<TCapacity> 的模板类,并将模板参数(size_t TCapacity)传递给 StaticLexContext<TCapacity> 模板实例化。您可以在 *FileLexContext.hpp* 中查找同名类,然后查找 StaticFileLexContext<TCapacity> 模板类,以获取将两个功能区域组合到最终类中的示例。

您可以看到 FileLexContext 类派生自 LexContext,然后重写了 read(),并提供了自己的 open()attach()close() 方法。当您创建自定义的 SocketLexContext 或其他类似的东西时,您也会做类似的事情。同时,实现 read() 非常简单。只需将光标向前移动一个位置并返回该位置的字符,如果超出数据末尾则返回 LexContext::EndOfInput,如果已关闭或发生错误则返回 LexContext::Closed

此读取操作没有进行缓冲,因此如果您需要缓冲,则必须自行完成。我避免了它,因为这个库本身已经使用了足够的 RAM,而且在我的测试中,即使将整个 200KB 文档加载到内存中并从中搜索,性能也没有比我的硬盘更好,这让我认为硬盘控制器在此情况下提供的缓冲已经足够了。您的体验可能会有所不同。如果出现问题,请进行性能分析,然后像往常一样决定需要改进的地方。

导航

高效查询的第一步是导航到代表要从中提取数据的逻辑元素开头的节点。虽然您可以相当高效地从根中提取数据,但 extract() 总是将读取器移动到您查询的对象或数组的末尾,因为这样它就可以从您期望的位置继续,并且这很一致。但是,这意味着如果您从根中 extract(),您将不得不扫描整个文档,因为它会一直扫描到根对象的末尾。尽管这相对高效,因为它不会规范化数据,但它不如获取所需内容然后提前中止高效,尤其是在网络 I/O 方面。

底线是先导航,然后提取。我们将在这里介绍导航,然后稍后介绍提取。

为了高效导航,我们有几个 skipXXXX() 方法,允许您逻辑地前进到特定的对象字段或数组项,或者以各种方式跳过当前位置。

这些方法只进行最少的验证,并且不规范化/加载值,除了 skipToFieldValue() 可能会导致标量值被加载。因此,它们非常高效,但并非总能捕获文档格式错误。之所以如此决定,是因为替代方案需要更多的内存和 CPU 周期。

事实上,我们煞费苦心地将 RAM 使用量保持在极低水平,包括直接从流中逐字符进行所有字符串比较,而不是先将它们加载到内存中。代码还在不将整个字符串加载到内存中的情况下内联地“去装饰”字符串(删除引号并转换转义字符)。因此,当您使用此引擎时,它只为实际检索到的值分配空间。如果您从未检索过某个值,它将永远不会被加载到 RAM 中。这使得此解析器在 JSON 解析器中独树一帜,并允许它即使在内存极少的情况下也能执行复杂的查询。

导航到您需要去的地方几乎总是您的首要选择和考虑,因为使用导航方法最有效。

skipToField()

此方法使用频率很高。它是主要的导航方式之一。它基本上是跳到指定名称和指定轴上的字段。

参数
  • field - 指示要跳到的字段的名称。
  • axis - 指示要使用的搜索轴。这告诉解析器搜索的“方向”以及何时停止。您可以向前扫描文档,忽略层次结构,仅扫描当前对象的成员字段(同级),或分别使用 JsonReader::ForwardJsonReader::SiblingsJsonReader::Descendants 扫描其后代。
  • pdepth - 指向“深度跟踪 cookie”的指针,用于在使用 JsonReader::Descendants 轴后将读取器返回到层次结构的当前级别。指定该轴时,此参数是必需的。否则,它将被忽略。请注意,您只需声明一个 unsigned long int 并传入其地址即可。您无需读取或写入它。它用于标记后代调用序列何时开始,而不是像 skipToFirstDescendant()skipToNextDescendant() 这样一对方法,我觉得那样会更丑陋、更难使用,并且只稍微容易理解一些。
Returns
  • 一个值,表示成功则为 true,否则表示未找到或错误则为 false。您可以检查 hasError() 来区分 false 结果是错误情况还是仅仅未找到字段。未找到字段不是错误,只是一个结果。

考虑到参数,它的工作原理非常简单。根据您使用的轴,您可能需要在循环中调用它,例如 while(jsonReader.skipToField(...)),这对于 JsonReader::Siblings 以外的轴尤其有用。如果您在 JsonReader::Object 节点或调用时在 JsonReader::Field 节点上且字段存在,则成功。否则不成功。请注意,光标下的字段不会被检查。下一个字段会被检查。这是为了方便在循环中正确调用它。换句话说,skipToField() 所做的第一件事是在检查任何内容之前移动光标。另请注意,当您成功跳到某个字段时,您无法从读取器的 value() 方法中检索字段名,该方法将为空。这是为了让读取器不必在 RAM 中为字段名保留空间,也不必将字符串复制到捕获缓冲区。理由是,如果您成功跳到某个字段,您已经知道它的名称,并且可以从您获取 field 参数的同一位置检索它。

关于 skipToField() 和 JSON 规范的警告

在 JSON 中,字段可以是任意顺序。您不能也绝不能依赖字段按已知顺序排列。第一次查询源时字段的顺序可能与下次查询时不同,这是完全可以接受的——您必须处理这种情况。您不能也绝不能依赖字段顺序。

问题在于当您尝试将 skipToField()JsonReader::Siblings 一起使用时。如果您想从同一个对象中检索多个字段,比如 nameid,您应该首先查询哪个?如果您查询错误,您最终会跳过另一个,并且您提前不知道它们的顺序。

因此,您不能使用 skipToField() 从同一个对象中检索多个字段。解决方法是使用您声明的提取,然后将其传递给读取器的 extract() 方法。在提取中,您命名多个字段,然后读取器将一次性获取所有这些字段,无论它们的顺序如何。我们将在本文后面讨论这个问题。

skipToFieldValue()

此方法也使用频率很高,但它基本上是一个便利方法,调用 skipToField() 后跟 read()。它只是一个捷径,避免您自己执行该步骤,因为这非常常见。请注意,如果值是标量值(如字符串或数字),read() 将把整个值读入缓冲区。因此,请勿将此方法用于您不打算检索其值的字段,否则您的查询效率会降低。

参数
  • field - 指示您要跳到的字段值的名称。
  • axis - 指示要使用的搜索轴。这告诉解析器搜索的“方向”以及何时停止。您可以向前扫描文档,忽略层次结构,仅扫描当前对象的成员字段(同级),或分别使用 JsonReader::ForwardJsonReader::SiblingsJsonReader::Descendants 扫描其后代。
  • pdepth - 指向“深度跟踪 cookie”的指针,用于在使用 JsonReader::Descendants 轴后将读取器返回到层次结构的当前级别。指定该轴时,此参数是必需的。否则,它将被忽略。
Returns
  • 一个值,表示成功则为 true,否则表示未找到或错误则为 false。您可以检查 hasError() 来区分 false 结果是错误情况还是仅仅未找到字段。未找到字段不是错误,只是一个结果。

这与 skipToField() 的工作原理完全相同,只是它还会读取字段的值。请注意,它会导致标量值加载到内存中。

skipToIndex()

您可以使用 skipToIndex() 前进到数组中的特定项。

参数
  • index - 您希望检索的数组元素的零基索引。
Returns
  • 如果元素已检索,则为 true,如果未找到或发生错误,则为 false。使用 hasError() 来区分负面结果。

调用时,读取器必须位于 JsonReader::Array 节点才能成功,并且数组中必须存在具有指定索引的项。如果不是,此方法将返回 false。请注意,您不能重复调用 skipToIndex() 来从数组中检索多个值,因为它只在从数组开头开始时才有效。这不应在循环中调用。要检索不同索引的多个值,您可以对第一个要移动到的索引使用 skipToIndex(),然后对每个项使用 read()skipSubtree() 以进一步前进数组。

skipSubtree()

Returns
  • 如果跳过子树,则为 true,如果没有子树或发生错误,则为 false。使用 hasError() 来区分负面结果。

此方法跳过当前位置的逻辑元素(包括其所有子元素),并跳到其下一个同级元素(如果存在),否则跳到对象或数组的末尾。它对于跳过元素同时保持在层次结构中的相同深度很有用。

skipToEndObject()

Returns
  • 如果找到并移动到当前对象的末尾,则为 true,否则如果没有或发生错误,则为 false。使用 hasError() 来区分负面结果。

当您从对象中的字段读取数据然后完成时,此方法很有用,并且您需要找到它的结尾。

skipToEndArray()

Returns
  • 如果找到并移动到当前数组的末尾,则为 true,否则如果没有或发生错误,则为 false。使用 hasError() 来区分负面结果。

这是前面方法的推论,只是针对数组。

内存预留、跟踪和管理

JSON 读取器使用两个独立的内存缓冲区。第一个用于在从输入源扫描时捕获数据。它的长度必须是您实际希望检查的最长字段名或标量值的长度,包括末尾的空终止符。例如,如果您只查看 name 字段,您可能只需要一个 256 字节的捕获缓冲区,但如果您想检索 overview 字段的值,您可能需要 2KB 甚至更多的长字段。我倾向于一开始就分配比我需要的更多的空间,然后在进行性能分析后减少它。最终,我将提供一种流式传输标量值(实际上是字符串)的方法,但目前它必须一次性加载整个标量值,这可能是它目前最大的限制,除了缺乏 Unicode 之外。

除了捕获缓冲区,某些操作,例如构建内存中的结果,需要一个额外的缓冲区,称为 MemoryPool。这些是预留的固定长度内存块,支持非常快速的分配。它们不支持单个项目的删除,但整个池可以一次性回收/释放。

基本上,要使用此读取器执行几乎任何操作,您都需要声明一个具有特定字节容量的 LexContext 派生类,并且您可能还需要一个具有特定字节容量的 MemoryPool 派生类。

虽然我告诉过您如何计算最长的 LexContext 捕获缓冲区,即您将检查的最长值的长度(不包括引号或转义字符),但通过一些性能分析,为您的提取计算 MemoryPool 大小也同样简单。它是检索到的值大小的总和。对于标量值计算,正如我所说,它很简单。另一方面,如果您持有非标量元素(对象或数组),为了存储字段名等信息并将所有这些数据链接在一起,还会产生开销。此外,由于指针大小不同,不同的机器可能需要不同的 RAM 量来完成相同的查询。好消息是,通常情况下,您需要的空间量主要由检索到的数据的总长度决定。在大多数情况下,相同的查询在同一台机器上将占用相同量的 RAM,除了由于检索到的值的大小(如字符串长度)而产生的差异。因此,一旦您进行性能分析,很容易进行估算,然后分配一些额外的空间,以防数据比您的测试数据长。

当您声明这些时,您必须预先拥有容量,通常在编译时。出于性能原因,它们无法在运行时扩展大小。在效率方面,这种权衡是值得的,但它确实需要一些初步的工作来确定要插入的值。当然,您可以将它们声明为每个 2KB,在大多数情况下您都会很安全,但这总共使用了 4KB 的 RAM,坦白地说,您的内存池可能至少小得多——根据您的查询,四分之一 KB 通常绰绰有余。对于 PC 来说,这可能无关紧要,但对于只有 8KB SRAM 的小型 ATMega2560 来说,您最好相信它很重要。幸运的是,由于它的编写方式,8KB 的 RAM 对此来说已经足够了。对于这个小型 8 位怪物来说,更令人担忧的是它有多慢!但是,对此也无能为力。它是一个小型 8 位处理器,具有大量的 I/O,但马力不足。

通常,您对 LexContext 派生类所做的是将其声明为全局变量并为其指定大小,例如 StaticFileLexContext<512> 声明了一个基于文件源的 LexContext,带有 512 字节的捕获缓冲区。请注意,该大小包含一个空终止符,因此对于上述情况,您实际上将拥有 511 字节的字符串空间,然后是一个空终止符。如果您需要文件或字符串源以外的源,您可以轻松派生自己的源并重载 read()

如果您需要 MemoryPool,您也会做同样的事情。如果您要进行提取或解析,您将需要一个,否则您可能根本不会使用一个。您会知道您需要一个,因为某些函数会接受 MemoryPool& pool 参数。一个示例声明看起来像 StaticMemoryPool<1024>,它声明了一个编译时已知的容量为 1KB 的内存池。

您通常不会直接使用 LexContext 成员或 MemoryPool 成员。您只需将它们作为参数传递给各种方法。

由于它们不直接属于 JSON API,我们在此处不介绍它们的各个成员。

如果您想了解有关内存池的更多信息,请参阅本文。此代码库已更新,但概念和大部分功能保持不变。

如果您想跟踪内存使用量,您可以使用来自 *ProfilingLexContext.h* 的分析 LexContext,然后获取 used() 值,该值跟踪最大分配大小,并且可以通过 resetUsed() 重置。内存池已经有一个 used() 访问器方法,因此您可以直接检查它,当然,内存池的 freeAll() 方法在释放/使内存无效时会重置 used() 值。

关于这些,我在这里就介绍这么多,因为它们再次超出了本文涵盖的 JSON 功能本身的范围。

解析、内存树和 JsonElement

在某些情况下,您可能需要少量 JSON 驻留在内存中。一个很好的理由可能是读取器正在进行提取操作,该操作在一次运行中从文档返回多个值,并且需要将它们保留下来以便能够将它们返回给您。

另一个原因可能是……嗯,真的没有——至少没有一个好的理由。是的,您可以使用 parseSubtree() 从文档中解析对象,并且如果内存允许,您可以在根上解析并将整个文档加载到内存中,然后对其进行操作。

不要这样做。

我发现的开销是文件大小的四分之一。我的 190KB+ 文件在内存中占用了超过 240KB,这是由于维护链表之类的东西。仅仅因为你能做某事并不意味着你应该做。这个库的全部意义在于高效。与拉取解析器相比,这些内存中的树并不高效。如果你想要一个内存库,有很多功能更强大的产品比这个库在内存树方面表现更好*。当然,这些库会占用更多的 RAM。几乎所有其他库都是内存库,这就是我编写这个库的原因。使用这个库需要一种与你可能习惯的不同方式来思考你的 JSON 查询,但它会带来回报。

* 内存库通常可以执行 JSONPath 等操作,而拉取解析器无法(至少是完全)实现。由于内存需求,此库不包含其内存树的 JSONPath。此库也不进行字符串哈希或池化,因此字段查找和存储对于内存树而言效率不高。

好的,既然我已经说了,少量直接相关的内存标量 JSON 元素是没问题的,这正是这个库旨在为您提供的。

当您收到 JSON 元素时,它们由 JsonElement 表示,它是一种变体,可以容纳任何类型的 JSON 元素,无论是数组、对象还是某种标量值。JsonElement 数组和对象包含其他 JsonElement,这就是树的形成方式。例如,一个 JSON 对象可以包含其他对象的字段。

一个 JsonElement 有一个类型,然后根据类型有几种不同的值。您在决定使用哪个访问器(例如 string()integer())之前检查 type()

你可以编辑 JsonElement,但你几乎总是希望将它们视为只读。编辑它们效率不高,这个库也不是为了构建 DOM 而设计的。创建/编辑方法之所以存在,是因为我觉得我不能完全证明将它们设为半私有/友元方法的合理性,仅仅因为我想不出手动构建树的用例。所以你可以这样做。

现在让我们探索 JsonElement

JsonElement()

此构造函数只是用未定义的值初始化一个 JsonElement

JsonElement() (多个重载)

参数
  • value(各种类型) - 用于初始化 JsonElement 的值。这些永远不会导致分配。

type()

Returns
  • 一个 int8_t,表示 JsonElement 的类型。可能的值包括:JsonElement::Undefined 表示未定义的值,JsonElement::Null 表示空值,JsonElement::String 表示字符串值,JsonElement::Real 表示浮点值,JsonElement::Integer 表示整数值*,JsonElement::Boolean 表示布尔值,JsonElement::Array 表示数组,以及 JsonElement::Object 表示对象。

* 整数不属于 JSON 规范的一部分。然而,在能力较弱的处理器上有限的浮点精度迫使我们,我们不能冒险将整数 id 等存储在 4 字节浮点数中,因为我们有丢失精度从而损坏 id 的风险。因此,读取器和内存树都检测并支持整数以及浮点值。

由于 JsonElement 是一个变体,它可以是多种类型之一。此访问器指示我们正在处理的元素的类型。您几乎总是会在决定调用任何 getter 方法之前检查此值。

简单值访问器方法

每个访问器方法对都包含一个不带值并返回特定类型的方法,以及一个同名方法,它接受相同类型的一个参数并返回 void。这些方法都不会导致内存分配,这就是我称它们为简单的原因。例如,如果您使用 string() 设置字符串,则不会创建字符串的副本。而是持有对现有字符串的引用。但是,标量值会被复制。这与构造函数重载的行为相同。每种类型都有一个访问器对,除了 undefined() 只有 getter。每个方法的名称对应于它所代表的类型名称。设置值会自动设置适当的类型。不支持类型转换,因此如果您尝试从不同类型的元素获取特定类型的值,则结果是未定义的。请注意,少数设置访问器,特别是 null()pobject()parray() 设置访问器,接受一个虚拟的 nullptr 值,只是为了将它们与获取访问器区分开来。使用 nullptr 调用这些方法将把类型设置为适当的值,这是它们在此情况下的主要目的。

以下是按名称列出的访问器

  • undefined() (无设置访问器)
  • null()
  • string()
  • real()
  • integer()
  • boolean()
  • parray()
  • pobject()

导航树

有两个索引器 operator[] 重载,一个用于对象的字符串,一个用于数组的 size_t(某种整数)。您可以使用它们来导航对象和数组。请记住它们返回指针,如果请求的字段或元素不存在,这些指针将为空。

分配设置器

分配设置器是访问器方法的替代方案,它们在设置值时实际分配内存。在两种情况下——数组和对象类型的元素,这些方法是添加值的唯一方式。所有这些设置器都需要一个 MemoryPool。其中一些设置器名称中带有“Pooled”,这些方法用于字符串池。然而,我目前已移除字符串池功能,因为它未能达到我的预期——它经常增加内存使用量,也没有使任何东西更快。如果我能以某种方式改进它,我可能会重新添加它,但目前请勿使用这些方法,因为它们基本上要求字符串指针已分配给用于存储字符串的专用 MemoryPool

再次注意,您真的不应该在代码中构建/编辑这些对象。这只是为了完整性而提供。我不鼓励这种用法。

allocString()

此方法将 JsonElement 的值设置为传入字符串的新分配副本。

参数
  • pool - 要分配到的 MemoryPool。您可以分配到任何您喜欢的池,但通常您会对所有内容使用同一个池。如果池中内存不足,函数将返回 false 而不进行分配。
  • sz - 要分配副本的以 null 结尾的字符串。如果此值为 null,函数将返回 false 而不进行分配。
Returns
  • 一个 bool 值,指示成功或失败。如果内存池中空间不足或参数无效,则会发生失败。

addField() (各种重载)

这些方法向对象类型添加字段。

参数
  • pool - 要分配的 MemoryPool
  • name - 字段的以 null 结尾的名称 - 分配名称的副本
  • value/pvalue - 字段的值 - 分配值的副本
Returns
  • 一个 bool 值,指示成功或失败。如果内存池中空间不足或参数无效,则会发生失败。

字段存储在 JsonFieldEntry 结构的链表中,通过 pobject() 访问器访问。要使此方法成功,必须已在元素上调用 pobject(nullptr)

addItem() (各种重载)

这些方法向数组添加项目。

参数
  • pool - 要分配的 MemoryPool
  • value/pvalue - 项的值 - 分配值的副本
Returns
  • 一个 bool 值,指示成功或失败。如果内存池中空间不足或参数无效,则会发生失败。

数组存储在 JsonArrayEntry 结构的链表中,通过 parray() 访问器访问。要使此方法成功,必须已在元素上调用 parray(nullptr)

获取字符串表示

toString()

此方法分配一个字符串,其中包含 JsonElement 表示的 JSON 的压缩副本,并返回该字符串。

Returns
  • 一个包含压缩 JSON 数据的以 null 结尾的字符串。

此方法主要用于查询调试,因为此库主要用于查询 JSON,而不是编写 JSON。如果它是用于编写的,我将包含流式写入功能,但这样做需要为 Arduino 提供与标准 C 和 C++ 库不同的代码。您可以将其用于显示或调试,但它并非真正为效率而设计。如果必须使用它,最好为其分配一个专用内存池,以便您可以在每次分配后回收内存池。另一种略微危险的选项是在显示字符串后立即对内存池使用 unalloc()。您不会像您想象的那样经常需要它。我自己从不使用它。

提取数据

提取数据是除了原始导航之外,从 JSON 流中获取数据的主要方式。一旦我们导航,我们就会构建一个“提取”,它是一系列嵌套的提取器结构,告诉读取器要检索哪些值,相对于我们当前位置。最后一点很重要,也很有用,因为它意味着我们可以对数组的每个元素重用相同的提取器,例如,只需在遍历数组时为每个项目调用 extract()

JsonExtractor 是我们的主要结构,它可以以三种不同的方式操作

  1. 作为对象提取器,它跟踪一个或多个对象的字段。
  2. 作为数组提取器,它跟踪数组的一个或多个索引元素。
  3. 作为值提取器,它将当前元素作为值检索。

为了有用,形式 #3 中至少一个 JsonExtractor 是必需的。否则,将不会检索任何值。这里的想法是前两种形式基本上是您的导航——它们将您的光标移动到您需要的位置——指向正确的值,然后您使用值提取器提取该值。

要组合提取,您基本上使用指向 JsonExtractor 结构数组的 pchildren 成员构建路径。例如,根级别可能有一个 created_by 字段,它将从读取器当前位置的对象中获取该字段。它可能有一个对应的 pchildren 条目,用于获取索引零,该条目本身有一个 pchildren 条目,用于获取字段 name,最后获取值。这种混乱的 JSONPath 将是 $.created_by[0].name。请记住,每个提取器可以指定多个字段或索引。

JsonExtractor 构造函数有三种基本形式

  1. 一个接受字段和子元素
  2. 一个接受索引和子元素
  3. 一个接受指向 JsonElement 的指针

这些对应于三种提取器类型。由于构造函数接受子节点,您必须预先构建子节点,这意味着您从叶子开始,自下而上地构建您的提取,直到根节点。

这实际上是件好事,因为有值提取器。它们接受指向您的 JsonElement 的指针,这意味着您必须首先声明这些元素。然后,每当调用读取器或内存树的 extract() 方法时,提取都会填充这些值。通常,我首先声明所有 JsonElement,然后构建填充它们的提取。

通过提取,您只能返回已知数量的值。如果您必须返回列表,请尝试实际返回您想要从中构建列表的数组,或者更好的是,重新设计您的查询以使用读取器导航数组,然后对每个项位置进行 extract()

所以它的工作原理是这样的

  1. 决定您想要哪些值
  2. 声明与 #1 对应的 JsonElement 变量
  3. 声明您的值提取器(类型 3)以指向 #2 中的那些元素
  4. 使用类型 1 和类型 2 提取器声明您的路径提取器,从叶子 (#3) 到根,它将是一个单一的 JsonExtractor
  5. 将那个单一的根提取器传递给一个 extract() 方法,通常是在读取器上。

JsonElementJsonReader 都支持 extract() 方法。这些方法的工作方式相同,但 JsonReader 版本需要分配,因此也需要向其传递一个 MemoryPool,而 JsonElement 版本已经处理内存中的对象,因此不需要进一步的内存分配。

请记住,每次调用 extract() 都会发生一到三件事,具体取决于您是从 JsonReader 还是从 JsonElement 提取:

  • 从内存池中分配内存以存储结果(仅适用于读取器)
  • 您之前在 #2 中声明的 JsonElement 值将填充 extract() 的最新结果
  • 如果使用读取器,读取器将移动到当前值、对象或数组的末尾,具体取决于根的提取器类型。

如果找不到值,这并不表示失败。如果找不到值,它们将简单地是 JsonElement::Undefined 类型。如果内存池中没有足够的可用空间,或者传递了无效参数,或者发生了某种扫描/解析错误,则会发生失败。

这有很多样板代码,它迫切需要某种查询代码生成应用程序,但我需要设计自己的查询语言,或者以某种方式找出一个可行的 JSONPath 子集。我正在考虑这个想法。

我不想在此之上搭建运行时查询引擎,尽管您可能认为它非常需要一个。问题是内存使用量增加,以及在较慢的处理器上将查询转换为提取器树时的延迟。另一个问题是提取器可以同时导航对象或数组中的多个值,但我不确定如何在 JSONPath 中表示这一点。

extract()

从当前位置提取值

参数
  • pool - 用于分配的 MemoryPool(仅限 JsonReader
  • extraction - 提取的根 JsonExtractor
Returns
  • 一个 bool 值,指示成功或错误。如果成功,JsonExtractor 引用的任何 JsonElement(如果有)将填充找到和提取的值。

错误处理

此库不使用 C++ 异常,因为它所针对的平台并非总是支持它。在读取器上,您可以查看 nodeType() 以查看它是否是 JsonReader::Error,或者您可以查看 hasError()。如果 hasError() 为 true,lastError() 将报告错误代码,value() 将报告错误消息的文本。有时,方法在错误或失败时返回 false——这是两种不同的情况。例如,如果未找到字段,skipToField() 可以返回 false,但这并不*必然*表示发生了错误。

我曾尝试让解析器在“可恢复错误”上进行恢复——也就是说,在发生错误后,您应该能够再次调用大多数方法以重新开始解析,但这可能不起作用。解析中的智能错误恢复可能令人发狂,因为所有边缘情况都很难找到,因此此库需要成熟才能有所改进。某些错误是不可恢复的,通常是因为读取器由于未终止的字符串、数组或对象而到达文档末尾。

hasError()

指示读取器是否遇到错误。

Returns
  • 一个 bool 值,指示读取器是否处于错误状态。

lastError()

指示发生的最后一个错误代码。

Returns
  • 一个 JSON_ERROR_XXXX 错误代码,如果没有则为 JSON_ERROR_NO_ERROR

如果出现错误,请再次检查 value() 以获取错误消息。

其余部分:读取和解析

请注意,我希望劝退在其他方法可行的情况下使用这些方法,因为通常它们较慢且倾向于占用更多内存。

读取是解析的完整一步,它将规范化任何遇到的数据,并将其加载到 LexContext 中。这包括您可能想要跳过的大字段值。读取有时对推进解析器一步很有用,但您应该谨慎使用它,因为尽管它仍然相对较快,但就原始效率而言,它根本无法与用于导航的其他方法相比。另一方面,它也会最快地发现格式错误的数据,因为它进行全面检查,不像 skipXXXX() 方法甚至 extract()

read()

读取解析的一个步骤

Returns
  • 一个 bool,指示是否还有更多数据。如果返回 false,可能是由于错误。您可以调用 hasError() 来区分。

您可以循环调用 read(),直到它返回 false,以读取文档的每个节点。一旦您读取了解析的一个步骤,您就可以查询解析器以获取有关解析的各种信息。

nodeType()

返回一个值,指示文档当前所在的节点类型。它从 JsonReader::Initial 开始,并遍历各种类型,直到遇到 JsonReader::EndDocumentJsonReader::Error

Returns
  • 一个 int8_t 值,它将是 JsonReader::Initial 表示初始节点,JsonReader::Value 表示标量值节点,JsonReader::Field 表示字段,JsonReader::Object 表示对象的开头,JsonReader::EndObject 表示对象的结尾,JsonReader::Array 表示数组的开头,JsonReader::EndArray 表示数组的结尾,JsonReader::EndDocument 表示文档的结尾,以及 JsonReader::Error 表示错误。请注意,JsonReader::InitialJsonReader::EndDocumentread() 为 true 时永远不会出现,也就是说,您永远不会在 while(read()) 循环中实际看到它们被报告,该循环只报告这些节点之间的节点。这些节点实际上是“虚拟的”,不对应于数据中的实际位置。

objectDepth()

表示作为当前位置父级存在的嵌套对象的数量。

Returns

一个 unsigned long int 值,指示对象深度。它在 JsonReader::Initial 节点上从 0 开始,如果存在根对象,则在第一次读取时变为 1。在文档末尾,它将为 0。

检索标量值

检索值取决于您正在检查的值的类型。有几种方法可以从读取器中获取类型化数据,或者您可以使用 value() 以词法空间(与文档中完全相同)获取值,但 JsonReader::String 类型除外,它们在读取时会被去装饰,因此它们的原始词法流(包括转义和引号)会被丢弃以节省 RAM 并提高性能。以下方法对值进行操作。

value()

将节点的 JSON 值作为字符串获取。

Returns
  • 一个以 null 结尾的字符串,指示节点的值。字符串值和字段名已去装饰,这意味着所有引号和转义字符已被删除或替换为其实际值。

这仅适用于 JsonReader::ValueJsonReader::FieldJsonReader::Error 节点类型。所有其他节点将为此返回 null。除了字符串(被去装饰)之外,该值直接来自文档,因此如果它是一个数值,例如,value() 将让您有机会准确地查看它在文档中的书写方式,因此如果您需要区分“1.0”和“1.00”,您可以使用此访问器。

valueType()

指示节点下值的类型。

Returns
  • 一个 int8_t 值,指示节点下值的类型,可以是 JsonReader::Undefined(如果节点没有值),JsonReader::Null(表示 null),JsonReader::String(表示字符串),JsonReader::Real(表示浮点数),JsonReader::Integer(表示整数*),或 JsonReader::Boolean(表示布尔值)。

* 再次强调,整数不属于 JSON 规范,但读取器支持它们,因为在小型平台上,双精度浮点数有时精度不高。有损浮点数不适用于整数 id 等情况。在词法空间中,它们之所以有效,是因为词法空间中几乎是完全精确的,但在二进制空间中它们并不完全适用,因为根据平台的不同,您只有 4 或 8 字节,而整数本身可以是 4 字节。显然,JSON 更多的是词法规范,二进制考量并不是其中的一部分。在 32 位平台上,您可能大部分情况下可以使用 64 位的双精度浮点数。在 8 位平台上,情况就没那么乐观了。

realValue()

指示节点下的浮点值。

Returns
  • 一个 double 值,如果当前节点没有数字,则为 NAN。实际精度取决于平台。如果您需要完美的精度或往返转换,请使用 value() 获取词法空间中的数字。

integerValue()

指示节点下的整数值。

Returns
  • 一个 long long int 值,如果当前节点没有数字,则为 0。如果数字是浮点数,它将被四舍五入。它实际有多大取决于平台。有时,它可能不足以容纳 JSON 中的整数,因为不支持任意大的整数。如果您需要完美的表示,请使用 value() 在词法空间中获取它。

booleanValue()

指示节点下的布尔值。

Returns
  • Abool值,如果值不是布尔值则为 false

将元素解析为内存树

您可以使用 parseSubtree() 将元素解析到内存树中,我差点就没有公开这个方法。我建议您不要使用它,因为它直接这样做效率低下。请考虑,十有八九,一旦您解析了那个内存对象,您就会想遍历它。那么,当您可以直接调用 extract() 时,为什么还要将其加载到内存中进行遍历并从中提取值呢?提取器使您能够**只解析您需要的内容**,这就是重点。在内存中这样做并不容易。我决定公开它的唯一原因是我一个人无法确定这个项目的全部用例,所以其中一些我将留给您自己判断,亲爱的读者。请记住,使用一个超低内存影响的库,然后用它加载一个巨大的内存树是毫无意义的!

parseSubtree()

将光标下的元素及其所有后代解析为内存树。

参数
  • pool - 用于分配的 MemoryPool
  • pelement - 指向您想要填充的 JsonElement 的指针。该元素将填充文档当前位置的元素数据。
Returns
  • 一个 bool 值,指示成功则为 true,失败则为 false。如果失败,请检查 lastError()

可能最好不要使用此方法。有更好的方法,例如 extract()。不过,如果我错了,我也会很高兴,所以如果您有什么疯狂巧妙的想法需要它,请尽管使用。只是不要说我没有警告您。

下一步?

我们已经涵盖了这么多内容,我不指望您已经全部吸收。请将以上内容视为参考资料。我们现在将开始编写代码,所有那些抽象的东西最终都会变得具体。

编写这个混乱的程序

我们现在将在 PC 上进行此操作。我已经使用 Linux、gcc、PlatformIO 和 Arduino IDE 对其进行了测试,但您的体验可能会有所不同。我还没有来得及使用 Microsoft 编译器或您在苹果设备上使用的任何编译器进行测试。不过,我非常有信心它们会工作,如果您必须进行更改,那将是非常微小的。我喜欢在有大量空间的 PC 上开始,并使用它们进行性能分析,以确定我**实际**需要多少空间。

请注意,我还随项目附带了一个 *json.ino* 文件。这用于在 Arduino IDE 中编译针对 Arduino 设备的代码。代码与 *main.cpp* 中的代码基本相同,尽管它是一个快速而粗糙的移植。编译 *main.cpp* 以针对 Arduino 以外的平台。

安装

我们可能首先要做的是声明一个读取器可以使用的适当的 LexContext,并且可能,或者更确切地说,一个 MemoryPool,以便我们可以用它进行提取。我喜欢在构建查询之前从较大的值开始,这样我就不会遇到内存不足错误,从而使我的生活复杂化。一旦我调整了查询,我就会进行性能分析并将其大幅削减,特别是如果我想让它在小的 8 位 Arduino 等设备上运行。

以下是一些全局范围的代码

// this lex context is suitable for profiling
// and implements a fixed size capture buffer and file input source
// 2kB is usually enough to handle things like overviews or descriptions
// Keep in mind we don't need to examine every field. If we only care about
// names for example, this can be significantly smaller. data.json's max 
// field length is something like 2033 not including quotes or escapes.
ProfilingStaticFileLexContext<2048> fileLC;

// make it 1MB - HUGE! for testing, as long as we're on a PC.
// We can always profile and trim it.
StaticMemoryPool<1024*1024> pool;

你并不真的需要那么大的内存池。我只是讨厌内存不足错误。有这么大的空间,你不太可能遇到它们。我用于本文的整个测试文档只有大约 190KB,作为内存树大约 240KB,所以要用掉 1MB 需要做一些工作。我只是随便设置了一个荒谬的值。在典型的查询中,每个(缓冲区/池)可能只有 128 字节,甚至更小。我通常喜欢大约 2KB,其中大部分用于 LexContext,但这适用于简单的提取。分析,分析,再分析!

然而,请仔细考虑您的最终值。您希望它尽可能大,但不要超出您平台的限制。这样,它就可以优雅地处理比您分析的更长的值。这既需要一点艺术,也需要一点经验,再加上测试才能获得最佳值。问题是,即使在小型 8 位怪物上,它们也不必是最佳的,只要足够好,如果说有什么倾向,那就是倾向于太大。

data.json: 我们的测试数据

下载项目并解压 *data.json* 可能会有所帮助,以便您可以跟随。它将近 200KB 的数据,所以我不会在这里详细介绍。如果您要在 Arduino 上运行它,您需要将文件放到 SD 卡的根目录并将其重命名为 data.jso,因为某些 Arduino SD 实现只支持 8.3 格式的文件名。

*data.json* 文件包含来自 tmdb.com 的几个合并转储,这些转储已合并到一个大文件中进行测试。它包含有关美国电视剧“火线警告”的全面信息,包括其 7 季、111 集和特别节目的信息。

许多在线 JSON 仓库,例如通常由 MongoDB 及类似数据库支持的仓库,虽然不会一次返回 200KB 的数据,但每次 HTTP 请求往往会返回大量 JSON 数据。这最终是为了性能,这样就不需要发出多个请求来获取相关数据,因为它们已经包含在内。然而,这些数据对于小型设备来说可能难以处理。处理对于您的设备来说过大的数据正是这个库的亮点,无论是服务器搜索和批量上传海量 JSON 数据转储,还是物联网设备查询 TMDb 服务等“大块头”在线网络服务。

事实上,这个库的设计初衷,比任何其他目的都更重要,就是连接到那些倾倒大量 JSON 数据的源,然后从中筛选出您需要的内容,并尽可能高效地完成。请将 *data.json* 视为此类数据转储的一个示例。

“大数据”是相对的。显然,对于笔记本电脑或更大尺寸的设备来说,这个文件很容易处理,但对于 ATmega2560 甚至 ESP32 来说,这可是一项壮举。文件的大小我认为非常适合测试——不大到在台式机上解析需要 2 分钟而拖慢开发周期,但又足够大,可以在物联网设备上真正考验它的实力,看看它在能力较弱的设备上的表现如何。

无论如何,在我们讨论其余部分之前,请注意这些查询是针对此文件编写的。请查看一下。

探索代码中的导航

让我们从一些简单的导航开始。假设我们想要节目名称,它恰好在文件的第 45 行。我们甚至不需要提取器,它将是 JSONPath $.name

fileLC.resetUsed(); // reset the used counter for profiling
// open the file
if (!fileLC.open("./data.json")) {
  printf("Json file not found\r\n");
  return;
}

// create the reader
JsonReader jsonReader(fileLC);

// time it, for fun.
milliseconds start = duration_cast<milliseconds>(system_clock::now().time_since_epoch());

// JSONPath would be $.name
// we start on nodeType() Initial, but that's sort of a pass through node. skipToFieldValue()
// simply reads through when it finds it, so our first level is actually the root object
// look for the field named "name" on this level of the heirarchy and read its value
if (jsonReader.skipToFieldValue("name",JsonReader::Siblings)) {
  printf("%s\r\n",jsonReader.value());
} else if(JsonReader::Error==jsonReader.nodeType()) {
  // if we error out, print it
  printf("\r\nError (%d): %s\r\n\r\n",jsonReader.lastError(),jsonReader.value());
  return;
}

// stop the "timer"
milliseconds end = duration_cast<milliseconds>(system_clock::now().time_since_epoch());

// print the results
printf("Scanned %llu characters in %d milliseconds using %d bytes of LexContext\r\n",
       fileLC.position()+1,(int)(end.count()-start.count()),(int)fileLC.used());

// close the file
fileLC.close();

它周围有很多冗余代码,但核心代码只是 jsonReader.skipToFieldValue("name",JsonReader::Siblings)

这很快。在我破旧的台式机上甚至不到 1 毫秒。它一读取 name 的值就停止扫描,因为没有理由再往下。这正是它应该有的样子。在我的 ESP32 上,它需要更长的时间。

Burn Notice
Scanned 1118 characters in 15 milliseconds using 12 bytes

从 SD 卡上读取需要 15 毫秒,只用了 12 字节,因为“Burn Notice”是 11 个字符加上空终止符。

现在让我们做一些更复杂的事情——导航到第 2 季第 2 集,并检索名称,就像 JSONPath 表达式 $.seasons[2].episodes[2].name 一样。

fileLC.resetUsed();
// open the file
if (!fileLC.open("./data.json")) {
  printf("Json file not found\r\n");
  return;
}
JsonReader jsonReader(fileLC);
milliseconds start = duration_cast<milliseconds>(system_clock::now().time_since_epoch());
// JSONPath would be $.seasons[2].episodes[2].name
if(jsonReader.skipToFieldValue("seasons",JsonReader::Siblings) ) {
  if(jsonReader.skipToIndex(2)) {
    if(jsonReader.skipToFieldValue("episodes",JsonReader::Siblings) ) {
      if(jsonReader.skipToIndex(2)) {
        if(jsonReader.skipToFieldValue("name",JsonReader::Siblings)) {
          printf("%s\r\n",jsonReader.value());
          milliseconds end = duration_cast<milliseconds>
                                      (system_clock::now().time_since_epoch());
          printf("Scanned %llu characters in %d milliseconds 
                           using %d bytes of LexContext\r\n",fileLC.position()+1,
                           (int)(end.count()-start.count()),(int)fileLC.used());
        }
      }
    }
  }
}
if(jsonReader.hasError()) {
  printf("Error: (%d) %s\r\n",(int)jsonReader.lastError(),jsonReader.value());
}
fileLC.close();

这在我的桌面上输出的结果是

Trust Me
Scanned 42491 characters in 1 milliseconds using 9 bytes of LexContext

现在我们将尝试搜索后代。我们将获取节目制作公司的名称。等效的 JSONPath 将是 $.production_companies..name

fileLC.resetUsed();
// open the file
if (!fileLC.open("./data.json")) {
  printf("Json file not found\r\n");
  return;
}
// create the reader
JsonReader jsonReader(fileLC);
// keep track of the number we find
size_t count = 0;
// time it for fun
milliseconds start = duration_cast<milliseconds>(system_clock::now().time_since_epoch());
// JSONPath would be $.production_companies..name
// find the production_companies field on this level of the hierarchy, than read to its value
if (jsonReader.skipToFieldValue("production_companies",JsonReader::Siblings)) {
  // we want only the names that are under the current element. 
   // we have to pass a depth tracker
  // to tell the reader where we are.
  unsigned long int depth=0;
  while (jsonReader.skipToFieldValue("name", JsonReader::Descendants,&depth)) {
    ++count;
    printf("%s\r\n",jsonReader.value());
  }
} 
// stop the "timer"
milliseconds end = duration_cast<milliseconds>(system_clock::now().time_since_epoch());
// dump the results
printf("Scanned %llu characters and found %d companies in %d milliseconds using %d bytes 
       of LexContext\r\n",fileLC.position()+1,(int)count, 
       (int)(end.count()-start.count()),(int)fileLC.used());

// always close the file
fileLC.close();

请注意,当我们使用 JsonReader::Descendants 时,我们如何将 unsigned long int depth 变量的地址传递给 skipToFieldValue()。这是必需的。我们无需对其进行任何操作,但它应该与我们的 while 循环(如果正在使用)位于同一作用域内。所有这些都只是标记我们在文档中的位置,以便我们可以返回到层次结构的同一级别。它是一个 cookie。它的值对您无用,也不应写入。将其视为不透明。

更进一步:使用简单提取进行导航

在这里,我们将执行与以下 JSONPath 大致等效的操作:$..episodes[*].name,season_number,episode_number,除了我们所有的字段值都将作为同一行上的元组的一部分,而不是像 JSONPath 那样每个结果有 3 行。

我们不想要这样:(JSONPath 结果)

[
 "The Fall of Sam Axe",
 0,
 1,
 "Burn Notice",
 1,
 1,
 "Identity",
 1,
 2,
...

我们想要的是这样的

0,1,"The Fall of Sam Axe"
1,1,"Burn Notice"
1,2,"Identity"

数据相同,但排列方式更好,每个结果一行而不是三行。我还将季和集数移到了开头。

对于我们返回的每一行,我们基本上将执行 printf("S%02dE%02d %s\r\n",season_number,episode_number,name),这将给我们类似“S06E18 Game Change”的结果,然后我们将前进到下一行。

如果我们将其分解为简单的步骤,这将更容易。首先,创建您的 JsonElement 变量来保存您的数据。我们需要 seasonNumberepisodeNumbername

JsonElement seasonNumber;
JsonElement episodeNumber;
JsonElement name;

如前所述,我们有三种提取器类型,具体取决于它们的构造方式。我们有对象提取器、数组提取器和值提取器。

现在让我们创建我们的对象提取器

const char* fields[] = {
  "season_number",
 "episode_number",
 "name"
};

JsonExtractor children[] = {
  JsonExtractor(&seasonNumber), // value extractors
 JsonExtractor(&episodeNumber),// ...
 JsonExtractor(&name)          // ...
};
// object extractor:
JsonExtractor extraction(fields,3,children);

请记住,对象提取器是任何已用字段数组初始化的 JsonExtractor。数组提取器是已用索引数组初始化的提取器,值提取器是已用指向单个 JsonElement 的指针初始化的提取器。

基本上,这只是说从对象中获取三个命名字段,并将其关联的值填充到 seasonNumberepisodeNumbername 中。

现在我们已经有了,我们只需要遍历文档寻找 episodes——我们为此使用 JsonReader::Forward 轴,因为从根开始它相当于 $..episodes 并且速度很快。唯一的缺点是它会一直前进到文档末尾。除了找到匹配项之外,没有其他停止条件。在这种情况下,这是可以接受的,因为在最后一个 episodes 条目之后,文档实际上没有剩下多少内容。

接下来,当我们找到 episodes 时,我们假设它包含一个像 "episodes": [ ... ] 这样的数组。

while(jsonReader.skipToFieldValue("episodes",JsonReader::Forward)) {
  while(!jsonReader.hasError() && JsonReader::EndArray!=jsonReader.nodeType()) {
    // read the next array element
    if(!jsonReader.read())
      break;
...

一旦我们定位到数组元素,我们只需执行提取。

if(!jsonReader.extract(pool,extraction))
  break;

一旦我们的提取运行完毕,我们就可以访问 seasonNumberepisodeNumbername 中的值。现在我们可以格式化并打印它们。

printf("S%02dE%02d %s\r\n",
  (int)seasonNumber.integer(),
  (int)episodeNumber.integer(),
  name.string());

我在这里还没有提到的一点是 pool。我们在 extract() 中使用了它,但如果我们不在每次 extract() 调用之后(或之前)释放它,我们将把所有结果添加到内存池中,即使我们不再需要它们。

基本上,我们只需要值足够长时间来打印它们。一旦打印,我们就可以释放它们的内存。如果不这样做,我们很快就会用完整个内存池。这说明的道理是,只要您不再需要数据,就务必对内存池执行 freeAll()。不要等到出现内存不足错误!

整合所有内容

pool.freeAll();
fileLC.resetUsed();
// open the file
if (!fileLC.open("./data.json")) {
    printf("Json file not found\r\n");
    return;
}
JsonElement seasonNumber;
JsonElement episodeNumber;
JsonElement name;
const char* fields[] = {
   "season_number",
   "episode_number",
   "name"
};
JsonExtractor children[] = {
   JsonExtractor(&seasonNumber),
   JsonExtractor(&episodeNumber),
   JsonExtractor(&name)
};
JsonExtractor extraction(fields,3,children);
JsonReader jsonReader(fileLC);
size_t maxUsedPool = 0;
size_t episodes = 0;
milliseconds start = duration_cast<milliseconds>(system_clock::now().time_since_epoch());
// JSONPath would be $..episodes[*].name,season_number,episode_number
while(jsonReader.skipToFieldValue("episodes",JsonReader::Forward)) {
    while(!jsonReader.hasError() && JsonReader::EndArray!=jsonReader.nodeType()) {
        // read the next array element
        if(!jsonReader.read()) 
            break;
        // we keep track of the max pool we use
        if(pool.used()>maxUsedPool)
            maxUsedPool = pool.used();
        // we don't need to keep the pool data between calls here
        pool.freeAll();
        if(!jsonReader.extract(pool,extraction))
            break;
        ++episodes;
        printf("S%02dE%02d %s\r\n",
            (int)seasonNumber.integer(),
            (int)episodeNumber.integer(),
            name.string());
    }
}
if(jsonReader.hasError()) {
    // report the error
    printf("\r\nError (%d): %s\r\n\r\n",jsonReader.lastError(),jsonReader.value());
    return;
} else {
    // stop the "timer"
    milliseconds end = duration_cast<milliseconds>(system_clock::now().time_since_epoch());
    // report the statistics
    printf("Scanned %d episodes and %llu characters in %d milliseconds 
           using %d bytes of LexContext and %d bytes of the pool\r\n",
           (int)episodes,fileLC.position()+1,(int)(end.count()-start.count()),
           (int)fileLC.used(),(int)maxUsedPool);
}
// always close the file
fileLC.close();

请注意,尽管我们正在处理一个数组,但我们没有使用数组提取器。问题在于提取器只能处理提取中的值与您的 JsonElement 变量之间的一对一映射。要返回不确定数量的值,您将需要不确定数量的变量!通常,解决方案是……使用数组!但在这里,我们试图**减少**内存并处理非常长的数据,所以我不想使用这个库存储数组。如果您想将提取器与数组一起使用,它必须具有一个或多个固定索引来映射,从而产生确定数量的元素。换句话说,没有办法将索引设置为“*”或“all”,也永远不会有。

因此,您必须自己导航任何您想从中提取任意数量值的数组。

同样的限制也适用于对象,但这通常不会引起注意,除非您特别缺少字段名的通配符匹配,因为这是与数组相同问题出现的唯一方式。

这是设计使然。思考一下您的流程。您是在流式传输吗?如果您将结果集存储在内存中并在此基础上工作,那您就不是在流式传输。如果您存储一个数组而不是遍历它,那也是一样的,也不是流式传输。处理单个结果,丢弃它,然后移到下一个。底线是这意味着不要将您的结果存储在数组中。

现在,您完全可以使用值提取器提取整个 JSON 数组,并将其作为 JsonElement parray() 获取,但同样,如果您以一种需要比其设计内存更多的 RAM 的方式使用它,那么使用一个内存占用极小的库是毫无意义的。这样使用它会适得其反。如果您发现自己正在提取非标量值(如数组和对象),您可能需要重新考虑您的查询方式。更多地依赖读取器,更少地依赖内存中的元素。

更复杂的提取

我们将从根对象中获取多个字段。因此,我们需要对根对象进行提取。通常,我们更倾向于避免这种情况,因为从根对象提取需要扫描到文档的末尾。然而,由于我之前提出的关于 JSON 规范和不确定字段顺序的问题,您没有更好的选择。幸运的是,即使我们必须扫描整个文档,它仍然相当高效,因为至少我们没有对其进行全部规范化。

我们还将从对象下方的字段中获取字段值和数组元素,这需要读取器更深入地探查层次结构以挖掘内容。这就是我们无法再编写等效的 JSONPath 表达式的地方。此查询没有相应的 JSONPath,因为 JSONPath 无法直接支持此类提取——它实际上是几个 JSONPath 查询

  • $.id
  • $.name
  • $.number_of_episodes
  • $.last_episode_to_air.name
  • $.created_by[0].name
  • $.created_by[0].profile_path

请注意,下面我们实际上是从右到左创建上述查询的,因为我们必须嵌套结构。

// we want name and credit_id
// from the created_by array's 
// objects
JsonElement creditName;
JsonElement creditProfilePath;
// we also want id, name, and 
// number_of_episodes to air 
// from the root object
JsonElement id;
JsonElement name;
JsonElement numberOfEpisodes;
// we want the last episode to air's name.
JsonElement lastEpisodeToAirName;

// create nested the extraction
// query for $.created_by[0].name 
// and $.created_by[0].profile_path
const char* createdByFields[] = {
   "name",
   "profile_path"
};
JsonExtractor createdByExtractions[] = {
   JsonExtractor(&creditName),
   JsonExtractor(&creditProfilePath)
};
JsonExtractor createdByExtraction(
   createdByFields,
   2,
   createdByExtractions
 );

// we want the first index of the 
// created_by array: $.created_by[0]
JsonExtractor createdByArrayExtractions[] {
   createdByExtraction
};
size_t createdByArrayIndices[] = {0};
JsonExtractor createdByArrayExtraction(
   createdByArrayIndices,
   1,
   createdByArrayExtractions
 );

// we want the name off of 
// last_episode_to_air, 
// like $.last_episode_to_air.name
const char* lastEpisodeFields[] = {"name"};
JsonExtractor lastEpisodeExtractions[] = {
   JsonExtractor(&lastEpisodeToAirName)
};
JsonExtractor lastEpisodeExtraction(
   lastEpisodeFields,
   1,
   lastEpisodeExtractions
 );

// we want id, name, and 
// created by from the root
// $.id, $.name, 
// $.created_by, 
// $.number_of_episodes and 
// $.last_episode_to_air
const char* showFields[] = {
   "id",
   "name",
   "created_by",
   "number_of_episodes",
   "last_episode_to_air"
};
JsonExtractor showExtractions[] = {
    JsonExtractor(&id),
    JsonExtractor(&name),
    createdByArrayExtraction,
    JsonExtractor(&numberOfEpisodes),
    lastEpisodeExtraction
};
JsonExtractor showExtraction(
   showFields,
   5,
   showExtractions
 );

现在从根对象开始,您可以调用 jsonReader.extract(showExtraction),它将填充所有 JsonElement 变量。

对我来说,这样做会产生

id: 2919
name: "Burn Notice"
created_by: [0]: name: "Matt Nix"
  profile_path: "/qvfbD7kc7nU3RklhFZDx9owIyrY.jpg"
number_of_episodes: 111
last_episode_to_air: name: "Reckoning"
Parsed 191288 characters in 4 milliseconds using 33 bytes of LexContext and 64 bytes of the pool.

main.cpp 中的 dumpExtraction() 例程打印得不是非常美观,但所有数据都在那里。

请记住,提取是相对于它们所执行的当前文档位置而言的。这次提取是相对于根对象进行的,只是因为当我们调用 extract() 时,光标位于根对象上。这很有用,因为就像我们之前处理剧集一样,您可以通过简单地导航到所需位置、提取,然后再次导航并再次执行来从文档中的多个位置重新执行相同的提取。

希望嵌套结构不会过于令人困惑。我很有可能会制作某种代码生成工具来为您创建提取代码,因为它非常规律,但我目前还没有这样做。

这些提取的优点在于,它们有效地“编程”读取器跳过文档中不相关的部分,并导航到相关的部分,为您检索所有值。这些结构可能看起来很复杂,但使用 extract() 比手动驱动读取器要简单得多(也更高效)。它还可以避免潜在地加载和规范化您不需要的数据。

结论

与 JSON 库通常解析和处理数据的方式相比,使用此库需要显著的范式转变。希望这是值得的,因为它可以在任何机器上实现极其高效的选择性批量加载,甚至在性能较差的 CPU 和内存极小的系统上也能实现高效查询。

在这种范式中,您构建一个“提取”,导航,然后使用您的提取执行 extract()。然后,您将结果从内存中释放,并以流式、只进的方式移动到文档中希望提取的下一部分,并重复 extract(),继续此过程,直到您获得所有结果。

通过适当地设计您的查询(包括导航和提取),您可以获得令人难以置信的性能优势,并且几乎可以在任何地方运行,即使您的输入数据以千兆字节计量。

我希望您能花一些时间使用这个库,并逐渐欣赏这种范式。我认为它是一种特别有效的 JSON 处理方法。

限制

  • 此库目前不支持 Unicode,但将来会通过 UTF-8 支持 Unicode。
  • 目前无法从文档中流式传输大的标量值,例如 base64 编码的字符串 BLOB。通常,这意味着您将不得不跳过它们,因为您没有足够的 RAM 来一次性加载整个 BLOB。我计划最终添加此功能。
  • 由于它所做的跳过和扫描类型,此库处理格式不佳的文档不如其他库。这是有意为之的。替代方案是更多的规范化、更多的 RAM 使用和更低的效率。如果您想制作一个验证器,您可以使用此库,使用 read() 和一个堆栈,但您会失去性能。JSON 几乎总是机器生成的,因此与 HTML 不同,格式良好的错误在野外并不常见。最好的情况是代码立即发现错误。最坏的情况是此代码会完全遗漏一些错误。通常的情况是它稍后才会发现错误,然后由于不平衡节点的级联效应,它可能会返回一个奇怪的错误。
  • 我工作并有有限的时间来为此创建完整的测试套件。

历史

  • 2020 年 12 月 22 日 - 首次提交
© . All rights reserved.