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

JSON on Fire:JSON (C++) 是一个可以在低内存设备上运行的极速 JSON 库

starIconstarIconstarIconstarIconstarIcon

5.00/5 (13投票s)

2020年12月16日

MIT

28分钟阅读

viewsIcon

15373

downloadIcon

349

一个 JSON 拉取解析器和一个内存中树库,适用于现代 IoT 甚至您的 PC

JSON project

引言

更新: GitHub 仓库已更改为重写后的代码库和新的 API。关于新的、大幅改进的代码,请参见此处的对应文章。

C++ 的出色之处在于它确实可以“一次编写,到处运行”——甚至可以在 .NET 和 Java 无法到达的地方运行。有了它,你可以针对当今许多微小的 32 位 IoT 系统。

这些系统的一个特点是内存不多,因此在设计将在这些环境中运行的功能时必须小心。

JSON 无处不在,联网设备通常需要使用它,但许多 JSON 库基本上要求你处理内存中的 JSON 文档,这可能会占用相当多的 RAM。

为此,我编写了一个库,试图为你提供一种避免加载整个文档的方法,如果必须将片段加载到 RAM 中,它可以池化字符串,并过滤所需的字段以保持大小。

此外,它向你公开了所有的内存分配,因此你可以精确控制 JSON 加载到何处以及加载多长时间。分配的开销非常小,释放也是如此,所以基本上你就是分配/查询/丢弃,然后重复。

对于 Arduino 设备,有一个很棒的 `ArduinoJson` 库,但它主要只支持内存操作,而且我认为它与 Arduino SDK 绑定。然而,它可以在原始的 Arduino SDK 上编译和运行,无需像 ESP-IDF 这样的东西,这与本库不同。如果你正在为 ATmega2560 编程,请使用它。如果你正在为 Raspberry Pi 或 ESP32 等设备编程,或者只是在 C++ 应用程序中需要一个快速的 JSON 查询工具,请考虑使用此库。

构建这个大杂烩

只需使用你喜欢的编译器编译 main.cpp 即可。那是演示文件。data.json 是测试数据文件。其余是库文件。它快速而粗糙,但我正在努力。我使用 GCC 和 VS Code 作为编辑器构建了它。我还使用 Arduino IDE 和 ESP32 板管理器将 main.cpp 的变体构建为一个 ino 文件。

免责声明

尽管规范要求支持 Unicode,但此代码不支持。在内存受限的环境中,Unicode 有些邪恶,而且许多平台在它们的库中根本不支持或不完全支持 Unicode,因此任何超出 0-255 范围的 Unicode 字符串字符都将被转换为 `?`。这是我深思熟虑后做出的妥协,尽管我不喜欢它,但它是可以接受的。将来我可能会添加 Unicode 编译选项,但这将是一个相当大的代码更新。

此代码仍在试验阶段。我尚未进行我希望的那么多测试,但我希望在仍在开发的同时将其发布出去,以便人们试用,因为反馈是好的。我已尝试清除段错误等问题,但特别是当你处理非格式良好数据时,我尚未充分测试这些场景。

概念化这个混乱的局面

拉取解析器:JsonReader.h

该库的核心是 `JsonReader`,一个拉取解析器,通过一次检查文档的小窗口来读取几乎任何大小的文档。拉取解析器在内存和速度方面都非常高效,但使用它们有时会很困难。我添加了一些功能以使其更易于使用。

词法分析

您通过 `LexContext` 实例化一个 `JsonReader`,`LexContext` 是一个轻量级、只向前移动的光标,用于某种输入以及一个包含的捕获缓冲区。`JsonReader` 使用 `LexContext` 从底层源读取输入,并将相关输入捕获到缓冲区中以供后续检查。您声明一个要使用的 `LexContext` 并为其指定一个捕获缓冲区容量。该容量必须足够大以容纳您希望检查的任何单个字段或值,包括转义符和引号。1kB 可能过多,具体取决于您正在执行的操作,或者对于文档中那些冗长的 `overview` 和 `description` 字段可能不够长。一切都取决于您。您比任何人都更了解您正在使用的 JSON 源和查询,因此请相应地编写代码。我通常喜欢在具有大量 RAM 的桌面上进行性能分析,以找出我实际需要哪种空间,然后相应地设置我的容量。请注意,这不需要保存文档中最长的值,只需保存您实际需要检查的最长值即可。如果您收到 `JSON_ERROR_OUT_OF_MEMORY` 错误,您可能需要增加它(尽管还有其他原因可能导致此错误,我们稍后将探讨)。

需要注意的是,虽然我提供了 `FileLexContext`/`StaticFileLexContext` 用于从文件中读取,以及 `ArduinoLexContext`(如果适用)用于从 Arduino `Stream` 接口读取,但您可以轻松地为其他源(例如网络套接字)实现自己的。您所要做的就是从 `LexContext` 派生,提供您选择的初始化方法,例如 `open()` 或 `attach()`,并实现 `int16_t read()`,该方法将游标前进一位并返回下一个字符,如果源已超出末尾则返回 `LexContext::EndOfInput`,如果关闭或发生 I/O 错误则返回 `LexContext::Closed`。完成此操作后,您可以创建从该类和 `StaticLexContext` 派生的模板类以完成您的定义。有关完整示例,请参阅 FileLexContext.hpp。这实际上非常容易。

读取

主要的解析方法是 `read()`,它执行解析的一个步骤。你可以在 `while` 循环中调用此方法,以一次检索一个元素的所有数据。在每一点上,你都可以查询解析器以获取其信息。

查询解析器

在任何节点上,您可以调用各种 getter 方法来获取有关解析状态的信息,例如文档当前所在的节点类型,或逻辑文档游标下的当前值。

  • `nodeType()`:获取解析器正在检查的节点类型。可以是 `JsonReader::Initial`、`JsonReader::Value`、`JsonReader::Field`、`JsonReader::Array`、`JsonReader::EndArray`、`JsonReader::Object`、`JsonReader::EndObject`、`JsonReader::EndDocument` 或 `JsonReader::Error`。
  • `value()`:将节点值作为以 null 结尾的字符串获取。这仅对节点类型 `JsonReader::Value` 有效,其中它指示标量字段或数组元素的字符串值;`JsonReader::Field` 有效,其中它指示字段名称;以及 `JsonReader::Error` 有效,其中它指示错误消息。
  • `valueType()`:获取游标下值的内在类型。这仅对具有 `value()` 的节点有效。对于其他任何内容,它将是 `JsonReader::Unknown`。有效类型为 `JsonReader::Null` 表示 `null`,`JsonReader::Boolean` 表示 `true` 或 `false`,`JsonReader::String` 表示字符串,或 `JsonReader::Number` 表示数值。
  • `isIntegerValue()` 指示游标下的值(如果存在)是否可以解释为整数。整数不是 JSON 规范的一部分,但是如果我们需要为所有内容使用浮点数,我们可能会失去精度,这对于像 `id` 这样的值是不利的。此方法将扫描一个数字以查看它是否符合模式 `\-?[0-9]+`,也就是说,它是否只是一个可选的连字符后跟一个或多个数字。如果此方法返回 true,则最好将此值视为整数而不是浮点数。
  • `booleanValue()`、`value()`、`numericValue()` 和(规范扩展的)`integerValue()` 可分别用于检索布尔值、字符串、实数和整数的值。
  • `undecorate()` 对于 `JsonReader::String` 值来说是一个重要的方法,因为它进行就地删除引号和转义字符到其实际值的转换。由于它是就地操作,因此会产生副作用。一旦调用此值,除了 `value()` 之外,您不应调用 `valueType()`、`isIntegerValue()` 或其他值访问方法,因为它们不会崩溃,但也不可靠。
  • `context()` 返回附加的 `LexContext`,这样你就可以获取物理游标位置等信息,该位置总是略微领先于 `read()` 报告的位置。你通常不需要它,但它在调试查询时会有帮助。

导航

你通常不会使用 `read()`,除非作为推进游标或读取单个值的辅助设施。有用于实际导航的方法,可以使你的生活轻松得多。

  • `skipToField()`:此方法沿给定轴查找文档中具有给定名称的下一个字段。它不会检查当前字段——只检查后续字段。这样你就可以在循环中调用它。它支持 `JsonReader::Siblings` 轴(从当前位置查找同级字段)、`JsonReader::Descendants`(从当前位置查找后代字段)和 `JsonReader::All`(一个快速、向前搜索的轴,不关心层级)。这是一种快速强制性地查找文档中具有特定名称的所有字段的绝佳方法。
  • `skipToIndex()`:跳到数组中给定索引处
  • `skipSubtree()`:跳过游标下的当前子树。这对于检查文档中与当前层级(同级)处于同一级别的下一个元素很有用。
  • `skipToEndObject()`:跳到当前对象的末尾
  • `skipToEndArray()`:跳到当前数组的末尾

除了易用性之外,使用这些方法的另一个优点是它们比等效的 `read()` 调用更快,并且使用的 RAM 更少。我费尽心思优化了这些调用,使它们在扫描过程中进行最少的规范化和不进行任何分配。它们也不总是必然进行格式良好性检查。我决定这个权衡是值得的,特别是考虑到 JSON 文档通常是机器生成的。缺点是如果文档格式不正确,如果您使用跳过方法,它可能不会检测到它,或者会在扫描后期出错。同样,我认为为了性能,这个权衡是值得的。

我这样做的原因是,这是查询过程的第一阶段——导航到您希望检查的文档的通用部分。熟悉这些方法,特别是 `skipToField()`,因为这是在文档中前进的首选方法。我们稍后将探讨示例。

错误处理

大多数方法返回一个 `bool` 指示成功,但失败并不总是意味着发生了错误。例如,如果你调用 `skipToField("foo",JsonReader::Siblings)` 并且 `"foo"` 不存在,该方法将返回 `false`,但这并不是一个错误——只是一个结果。错误由解析器的 `nodeState()` 为 `JsonReader::Error` 表示。然后可以调用 `lastError()` 来获取错误代码——`JSON_ERROR_XXXX` 常量之一,或 `JSON_ERROR_NO_ERROR`,表示没有错误。错误消息通常可以通过获取错误节点的 `value()` 来检索,但错误报告很复杂,而且目前并不总是可靠。

内存中 JSON 树:JsonTree.h

这是库的可选部分。您可能永远不需要它,但对于更复杂的查询,拉取解析器可能会变得非常困难,您可能有一天需要适度的内存树提供的帮助才能完成任务。

我们的内存树非常简单。没有哈希,所以所有查找都是线性的。理由是,如果你加载足够的 JSON 到内存中,哈希能给你带来任何性能提升,那么你可能用错了库。不要为了获取其中两个字段而将包含 12 个字段的对象加载到内存中!我们会找到一种更好的方法来处理这个问题。

JSON 元素

一个元素是 JSON 文档的可组合单元。它可以表示一个对象、一个数组或一个标量值。我们可以用这些来构建文档。在代码中,所有元素,无论它们代表什么,都由 `JsonElement` 实现,`JsonElement` 是一种变体,可以是任何类型的元素。需要注意的是,由于所使用的内存方案,`JsonElement` 对象编辑效率不高。您不应尝试将它们用作某种 DOM。将您需要的数据加载到其中,使用它们,然后丢弃它们。不要加载它们,然后反复编辑它们。

一个元素具有 `type()` 和一个值,该值可以是 `isUndefined()`,或 `null()`、`boolean()`、`real()`、`integer()`、`string()`、`pobject()` 或 `parray()`。

`type()` 指示 `JsonElement::Undefined`、`JsonElement::Null`、`JsonElement::Boolean`、`JsonElement::Real`、`JsonElement::Integer`、`JsonElement::String`、`JsonElement::Object` 或 `JsonElement::Array`。

您已经看到上面用于从元素获取类型化值的 get 访问器方法。每个 set 访问器都是一个同名方法,接受一个参数,该参数几乎总是所需类型的值。例如,`myElement.boolean(true)` 会将元素 `myElement` 设置为 `JsonElement::Boolean` 类型并赋予其 `true` 值。例外是 `pobject(dummy)` 和 `parray(dummy)`,它们接受一个虚拟的 null 并只是发出信号以设置适当的 `type()`。

构建对象和数组稍微复杂一些。您必须调用 `myElement.pobject(nullptr)`,然后重复调用 `myElement.addField(...)` 以向其添加字段。数组也一样,只不过是 `parray(nullptr)` 和 `addItem(...)`。字段和数组项在内部使用 `JsonFieldEntry` 和 `JsonArrayEntry` 结构以链表形式存储。您可以通过 `pobject()` 和 `parray()` get 访问器方法访问列表的头部。

一旦你有一个对象,操作符[] getter 重载适用于 `JsonElement::Array` 和 `JsonElement::Object` 类型,或者你可以调用 `toString()`,它会分配 JSON(迷你化)的字符串表示,并返回一个指向它的 `char* sz` 指针。

管理内存

某些与 `JsonElement` 相关的方法会接受一个 `MemoryPool& pool` 参数。这些方法需要内存分配。池就是它们将从中分配的地方。我们使用这些 `MemoryPool` 对象直接控制我们的 JSON 在何处以及如何分配,以及何时释放。这种程度的控制是一把双刃剑,但考虑到它主要针对低内存环境,稍微复杂一点是值得的。这种复杂性也由于这些池中极高性能的内存分配而物有所值。

当我们进行分配时,我们从这些池而不是原始堆中分配,正如我上面提到的。这些池中的任何内容都不会被单独删除,但这些池可以整体回收,一次性使池中的所有数据失效。这意味着,如果您使用 `JsonElement` 的 `setString()` 方法,每次都会分配一个新的字符串,而旧的字符串不会被删除。这就是我为什么说编辑效率低下的原因。

我们通常在堆上创建我们的池,但你也可以在栈上创建它们。`JsonElement` 可以很好地引用其他池中的 `JsonElement`,但这通常不是你想要做的方式,因为你可能会用 `freeAll()` 使一个池失效,而另一个池可能仍然有对象引用其中的数据。我们通常的做法是进行大量快速分配,然后尽快在操作完成后释放整个池。然后我们可以回收该池以供将来的操作使用。

加载内存中文档

如果您在 `JsonReader.h` 之上包含了 JsonTree.h,您将能够从文档中 `parseSubtree()`。您可以在文档中的大多数位置调用 `JsonReader` 的 `parseSubtree()` 方法,以从中获取 `JsonElement` 对象。这对于进行涉及对象中多个字段或必须整理子对象或兄弟节点数据的复杂查询非常有用。仅仅使用拉式解析器尝试这样做会很困难。相信我,我尝试过。这就是我编写项目这一部分的原因。然而,虽然您可以简单地打开一个 JSON 文档并在根目录上 `parseSubtree()`,但这会使用比您需要使用的更多的内存,而且我并不是为此设计的。例如,如果我这样做,我将对对象字段进行哈希处理以加快查找速度。让我们谈谈一种更好的方法。

使用过滤器加载内存文档

你几乎从不需要整个文档。你需要的是特定对象中的某些字段,对吗?那么为什么要全部加载呢?它浪费时间,浪费 RAM,而且编码不一定更简单。

`parseSubtree()` 可以接受一个 `JsonParseFilter` 结构,该结构可用于黑名单或白名单字段。白名单尤其强大,可能是 80% 的情况,因为它允许您选择已知字段并排除其余字段。黑名单主要用于您想列出您事先不知道存在的字段,但想排除您知道会很大的某些字段,例如 `description` 或 base64 编码的 blob。

您使用要包含或排除的字段、它们的计数和过滤器类型来构造 `JsonParseFilter`。

类型为 `JsonParseFilter::WhiteList` 的 `JsonParseFilter` 还可以使用长度为 `fieldCount` 的数组填充 `pvalues` 成员,以保存每个找到字段的返回值为。我的意思是,如果您用数组填充此成员,则该数组将填充一个 `JsonElement*` 对象,该对象表示每个匹配字段的值。这意味着在大多数情况下,您的过滤器可以为您提供所需的查询结果,从而省去任何额外的步骤。这几乎没有额外的性能开销,因此这是运行查询的好方法。

白名单过滤器还可以包含子过滤器。基本上,对于每个列入白名单的字段,您可以为该字段应用一个额外的过滤器,依此类推。黑名单过滤器可以是子过滤器,但不能自己使用子过滤器——它们的 `pfilters` 成员会被忽略。要使用子过滤器,您必须使用与 `fields`/`fieldCount` 长度相同的数组填充 `pfilters` 成员,其中包含指向额外 `JsonParseFilter` 结构的指针,这些结构本身也可以有子过滤器。如果白名单中的某个条目不需要过滤器,则该数组元素可以为 null。数组不计入层级,因此如果您的对象有一个包含子对象数组的字段,您可以为该字段设置过滤器,它将应用于该数组中的对象。

学习并使用它。这是使用此库执行复杂查询的重要部分。我曾考虑公开 JSONPath,但这只会占用更多内存,并且在它与 `skipToField()` 之间,这已经处理了如此多的用例,以至于增加额外的资源需求和精力似乎不值得。

使用字符串池加载内存文档

通常,JSON 数据可能包含许多重复的字段。虽然最好是首先使用过滤来减少加载的字段数量,但有时您确实需要大量数据。当具有相同名称的字段重复出现时,会浪费 RAM。为了解决这个问题,第二个专用的 `MemoryPool` 可以用作字符串池,并传递给 `parseSubtree()`。如果传递了,将在池中创建一个补救性的“几乎”哈希表,并将字符串分配到该表中。重复的字符串将始终返回相同的指针。如果传递了此第二个池,`parseSubtree()` 将字段名分配到此池中,如果指定了 `poolValues`,则字符串值也将被池化,尽管这通常没有帮助,因此默认情况下它是关闭的。请注意,此第二个池绝不能用于字符串池之外的任何其他用途,尽管如果需要,您可以将相同的池用于多个不同的文档。

整合:使用内存树执行复杂查询

这涉及几个步骤。首先,您需要分配具有有效容量的 `MemoryPool`(s)和 `LexContext`。我通常从较大的容量值开始,然后进行性能分析后减小它。

拥有这些之后,使用适当的初始化方法(例如 `StaticFileLexContext<TCapacity>` 上的 `open()`)准备 `LexContext`。

现在设置你的过滤器。你正在使用过滤器,对吧?你几乎应该总是使用(白名单)过滤器。通过使用 `pvalues` 成员(如前所述),这使查询文档变得更容易(且效率更高!)。实现它。创建一个过滤器,它将从你的对象和子对象中获取所需的字段。这个过滤器将相对于我们在下一步中导航的位置。

现在,用该 `LexContext` 初始化一个 `JsonReader`,并使用该读取器导航到感兴趣的区域。你可能(很可能)需要设置它,使其在循环中导航到其中几个区域。我们假设你在这里循环。你通常会使用 `while(skipToField())` 来实现此操作,然后接着调用 `read()` 来加载字段的值。

在主池上调用 `freeAll()`,但不要在字符串池上调用。这将释放和回收池中分配的任何内存,我们希望在每次迭代之前执行此操作。如果我们使用字符串池,我们会在调用之间保留字符串池,因为它最终会使我们受益,但通常当我们已经过滤到几个唯一的字段时,字符串池不值得。如果您以这种方式执行查询,您几乎永远不会实际使用它,除非您在此步骤中需要加载大量数据——在这种情况下,也许您应该重新设计您的查询?

现在调用 `parseSubtree()` 并确保结果不是 `nullptr`,这表示失败。

现在,暂时忘记 `parseSubtree()` 的返回值,转而检查你的过滤器结构的 `pvalues`。它们应该包含你查询到的字段的值,假设这些字段存在。如果它们不存在,该字段的值将为 `nullptr`。如果你使用嵌套白名单,你也可以在这里检查这些嵌套过滤器结构。请注意,这对于数组不起作用,因为 `pvalues` 元素中没有地方返回多个值。

就这样大功告成了。现在,在那一步,你可以从 `pvalues` 中取出每个 `JsonElement*` 值,尽情使用。

现在让我们通过实际代码来演示。

编写这个混乱的程序

我们将对从 tmdb.com 返回的关于美国电视剧《火线警告》的 JSON 数据执行各种查询。该系列 7 季 111 集的完整信息以 JSON 形式呈现,数据量约为 190KB+,经过美化打印,包含大量嵌套数组、对象和字段,这使其成为一个非常好的测试集——您可以在项目随附的 data.json 中找到它。

...
  "homepage": "http://usanetwork.com/burnnotice",
  "id": 2919,
  "in_production": false,
  "languages": [
      "en"
    ],
  "last_air_date": "2013-09-12",
  "last_episode_to_air": {
      "air_date": "2013-09-12",
      "episode_number": 13,
      "id": 224759,
      "name": "Reckoning",
      "overview": "Series Finale. Michael must regain the trust of those 
                   closest to him in order to finish what he started.",
      "production_code": null,
      "season_number": 7,
      "show_id": 2919,
      "still_path": "/lGdhFXEi29e2HeXMzgA9bvvIIJU.jpg",
      "vote_average": 0,
      "vote_count": 0
    },
  "name": "Burn Notice",
  "next_episode_to_air": null,
  "networks": [
      {
        "name": "USA Network",
        "id": 30,
        "logo_path": "/g1e0H0Ka97IG5SyInMXdJkHGKiH.png",
        "origin_country": "US"
      }
    ],
  "number_of_episodes": 111,
  "number_of_seasons": 7,
...

使用 skipToField()

我们将从使用 `skipToField()` 开始,因为它绝对至关重要。

bool skipToField(const char* field, int8_t searchAxis,unsigned long int *pdepth=nullptr);
  • `field` 是您想要跳到的字段名。目前,没有通配符匹配——您需要知道您正在寻找什么。
  • `searchAxis` 指示搜索的方向/路径,可以是 `JsonReader::Siblings`、`JsonReader::Descendants` 或 `JsonReader::All`。这些与 XPath 和 JSONPath 中的轴概念相似,但在细节方面略有不同。
  • 对于 `searchAxis=JsonReader::Descendants`,`pdepth` 是必需的,对于所有其他轴则忽略。它是一种用于跟踪搜索开始时您在层次结构中的位置的 cookie。您声明一个 `unsigned long int depth = 0;` 参数,并简单地将其地址传递给每次连续的相关 `skipToField()` 调用。这是为了它能够跟踪您从哪里开始。您除了在操作期间(您的 while 循环)保持其作用域之外,无需对该变量做任何事情。我本可以创建单独的方法,如 `skipToFirstDescendantField()`、`skipToNextDescendantField()`,但这似乎是一种更轻量级的方式来实现相同的功能。

如果找到匹配字段,则返回 `true`,如果未找到,则返回 `false`,即使未找到的原因是发生了错误。由于这种潜在的歧义,当您收到 `false` 时,您应该检查 `nodeType()` 是否为 `JsonReader::Error` 以确定其未能成功的原因。如果不是错误,则表示它找不到匹配的字段。请注意,这再次是一个仅向前解析器。对于 `JsonReader::All` 轴,如果找不到字段,您将一直扫描直到到达末尾,然后您将被困在那里,因此请小心使用。

现在让我们看一些例子

// we only even need 14 bytes for this!:
// "Burn Notice" plus the null terminator
// In the real world, give it some breathing room
// for Arduino this code is slightly different than
// this which uses standard stdio file op, 
// unavailable in the base Arduino SDK
// you'd instead use ArduinoLexContext<14> 
// (always static implied) and you'd use it below 
// with a Stream derived class like File
StaticFileLexContext<14> lexContext; 
...
// open the file
if (!lexContext.open("./data.json")) {
  printf("Json file not found\r\n");
  return;
}

// create the reader
JsonReader jsonReader(lexContext);

// we start on nodeType() Initial, but that's sort of a pass through node. skipToField()
// 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 hierarchy.
// then read its value which comes right after it.
if ( jsonReader.skipToField("name",JsonReader::Siblings) && jsonReader.read()) {
  // deescape the value
  jsonReader.undecorate();
  // print it
  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;
}
// close the file
lexContext.close();

那是一个简单的兄弟字段搜索。注意它找到了正确的 `name` 字段,尽管文档中前面有几个 `name` 字段。这是层次结构根对象级别上唯一的 `name` 字段,这就是它选择该字段的原因——由于 `JsonReader::Siblings`。如果我们使用了 `JsonReader::All`,它将报告“Matt Nix”而不是“Burn Notice”,因为那是文档中出现的第一个 `name` 字段的值,即使它嵌套在子对象下。在这种情况下,使用 `JsonReader::All` 和 `JsonReader::Descendants` 会得到相同的结果,因为 `name` 在文档顶部的后代对象中与“Matt Nix”一起出现。

看起来 `skipToField("myField",JsonReader::Siblings)` 是一种很好的导航方式,但是它有一个严重的缺陷,这与 JSON 规范的工作方式有关。根据规范,字段可以是任何顺序。这意味着像 tmdb.com 这样的公司可能会决定重新排列字段,使它们不再按字母顺序排列,而是按访问频率最高排列。您的代码必须能够处理这种情况——基本上字段的到来顺序您无法依赖。因此,如果您需要检索多个字段,例如同一对象上的 `episode_number`、`name` 和 `season_number`,您不能可靠地使用 `skipToField()`。您应该首先跳到哪个字段?由于规范的原因,这是一个您无法可靠回答的问题。由于这个症结,您会希望为此类操作使用过滤后的内存树。这样,您可以一次性加载所需对象的所有字段,并将它们作为一个单元进行检查,我们稍后将这样做,但在那之前,让我们看看如何使用 `JsonReader::Descendants` 轴。我们将获取节目中每个制作公司的名称。`production_companies` 在根对象之下。

...
  "production_companies": [
      {
        "id": 6981,
        "logo_path": null,
        "name": "Fuse Entertainment",
        "origin_country": ""
      },
      {
        "id": 6529,
        "logo_path": null,
        "name": "Fox Television Studios",
        "origin_country": ""
      }
    ],
...

这是它的代码

// find the production_companies field on this level of the hierarchy, than read to its value
if (jsonReader.skipToField("production_companies",JsonReader::Siblings) && jsonReader.read()) {
  // we want only the names that are under the current object. we have to pass a depth tracker
  // to tell the reader where we are.
  unsigned long int depth=0;
  while (jsonReader.skipToField("name", JsonReader::Descendants,&depth) && jsonReader.read()) {
    undecorate(); // take the quotes and escapes out
    printf("%s\r\n",jsonReader.value());
  }
}

请注意我们如何在此处传递了“深度跟踪器”cookie。这再次用于标记我们当前所在的位置,以便在后续调用中我们知道要读取回到我们层次结构中的级别并停止。如果您不传递,调用将因 `JSON_ERROR_INVALID_ARGUMENT` 而失败。您可以嵌套这些循环调用,但如果这样做,您需要为每个调用使用不同的深度跟踪器 cookie。其思想是将深度跟踪器 cookie 的作用域与您的 `while` 循环的生命周期绑定。这将使您免于麻烦,或者至少避免陷入正确的麻烦。

使用 read()

现在我们已经讨论了这一点,让我们看看如何使用 `read()` 并查询解析器。如前所述,`read()` 执行解析的一个步骤。然后你可以使用各种方法获取有关解析状态的信息,例如你所在节点的类型或其值。与其解释整个流程,不如直接演示它更容易,而且代码更能说明问题,因此这里是用于将文件的每个元素转储到控制台的代码:

// open the file
if (!lexContext.open("./data.json")) {
    printf("Json file not found\r\n");
    return;
}
JsonReader jsonReader(lexContext);
// pull parsers return portions of the parse which you retrieve
// by calling their parse/read method in a loop.
while (jsonReader.read())
{
    // what kind of JSON element are we on?
    switch (jsonReader.nodeType())
    {
    case JsonReader::Value: // we're on a scalar value
        printf("Value ");
        switch (jsonReader.valueType())
        {                        // what type of value?
        case JsonReader::String: // a string!
            printf("String: ");
            jsonReader.undecorate(); // remove all the nonsense
            printf("%s\r\n", jsonReader.value()); // print it
            break;
        case JsonReader::Number: // a number!
            printf("Number: %f\r\n", jsonReader.numericValue()); // print it
            break;
        case JsonReader::Boolean: // a boolean!
            printf("Boolean: %s\r\n", jsonReader.booleanValue() ? "true" : "false");
            break;
        case JsonReader::Null: // a null!
            printf("Null: (null)\r\n");
            break;
        }
        break;
    case JsonReader::Field: // this is a field
        printf("Field %s\r\n", jsonReader.value());
        break;
    case JsonReader::Object: // an object start {
        printf("Object (Start)\r\n");
        break;
    case JsonReader::EndObject: // an object end }
        printf("Object (End)\r\n");
        break;
    case JsonReader::Array: // an array start [
        printf("Array (Start)\r\n");
        break;
    case JsonReader::EndArray: // an array end ]
        printf("Array (End)\r\n");
        break;
    case JsonReader::Error: // a bad thing
        // maybe we ran out of memory, or the document was poorly formed
        printf("Error: (%d) %s\r\n", jsonReader.lastError(), jsonReader.value());
        break;
    }
}
// always close the file
lexContext.close();

`read()` 很快,但它会进行格式检查和完整解析,因此它是最慢的导航方式。除非您打算编写文档验证器,否则在有其他选项时请使用它们。

使用 parseSubtree()

我把这个留到最后,因为它实际上包含了比名称所暗示的更多的内容。这个函数隐藏着强大的查询和过滤功能,一旦我们使用 `skipToField()` 导航到数据的大致范围,它就是我们的秘密武器。

使用它需要额外的设置,因为它创建的对象使用一种名为 `MemoryPool` 的专用分配器。您必须在使用此函数之前声明一个,并且您必须进行性能分析以找出您需要的大小,因为它取决于查询。幸运的是,相同的查询应该始终大致相同的大小,考虑到实际字段值的长度不同,因为字段总是已知的且相同的,至少如果您使用白名单过滤器。将其设置得比您需要的更大,进行性能分析,然后修剪以适应(始终留出一些喘息空间,因为数据长度可能会有所不同)。

幸运的是,这并不难做到。声明一个就像声明一个 `LexContext` 一样简单。选择你需要的那个——通常是 `StaticMemoryPool<TCapacity>`,并声明它,通常作为全局变量,但很多时候,查询运行所需的内存不到半 KB,因此它可能可以存在于栈上。如果你真的想要一个局部作用域的堆分配池,尝试 `DynamicMemoryPool()`,但我不推荐这样做,因为它会在进出作用域时导致多次系统堆分配和释放,从而抵消了使用这些池本应带来的大部分性能提升。

StaticMemoryPool<1024> pool; // 1kB of data. That's often plenty. 
                             // Profile, you might need a quarter of it.

你需要的字节数最大的决定因素是你需要的字段数量,所以在选择白名单过滤器时要考虑到这一点。显然,这同样适用于嵌套对象,所以要嵌套你的过滤器!

在以下代码中,我们跳到文档中的每个 `episodes` 字段(类似于 JSONPath 中的 `$..episodes`),一旦找到,我们对每个字段运行一个过滤后的 `parseSubtree()`,它会给我们 `season_number`、`episode_number` 和 `name`。然后我们使用从过滤器返回的这 3 个字段值来填充格式字符串,以 `S##E## Name` 格式给出剧集,我选择这种格式是因为它是媒体文件名的一种常见格式。这是代码:

LexContext<256> lexContext;
StaticMemoryPool<256> pool;
...
// open the file
if (!lexContext.open("./data.json")) {
    printf("Json file not found\r\n");
    return;
}

// create a JSON reader
JsonReader jsonReader(lexContext);

// prepare a simple filter
const char* fields[] = {"season_number","episode_number","name"};
JsonParseFilter filter(fields,3,JsonParseFilter::WhiteList);
// create some room to hold our return values
JsonElement* values[3];
filter.pvalues=values;

// for tracking 
size_t max = 0;
size_t episodes=0;

// yeah, we're timing it for fun
milliseconds start = duration_cast<milliseconds>(system_clock::now().time_since_epoch());

// skip to each field in the document named "episodes" regardless of where. 
// JsonReader:All cuts through the hierarchy so running it repeatedly starting 
// from the root is similar to doing $..episodes in JSONPath:
// (jsonReader.read() advances to the field's value, in this case an array)
while (jsonReader.skipToField("episodes", JsonReader::All) && jsonReader.read() ) {
    // if it's an array let's move to the first element using read():
    if(JsonReader::Array==jsonReader.nodeType() && jsonReader.read()) {
        // while we still have more array elements...
        while(JsonReader::EndArray!=jsonReader.nodeType()) {     
            // now parse that array element (an episode)
            // parse with a filter in place. 
            // We only want season_number,episode_number and name:
            JsonElement *pr= jsonReader.parseSubtree(pool,&filter);
            if(nullptr==pr) {
                printf("\r\nError (%d): %s\r\n\r\n",jsonReader.lastError(),jsonReader.value());
                return;
            } else {
                // since we've whitelisted, the API filled in the values for us. 
                // It's easy to retrieve them
                // normally you'd want to do checking here top.
                printf("S%02dE%02d %s\r\n",
                    nullptr!=filter.pvalues[0]?(int)filter.pvalues[0]->integer():0,
                    nullptr!=filter.pvalues[1]?(int)filter.pvalues[1]->integer():0,
                    nullptr!=filter.pvalues[2]?filter.pvalues[2]->string():"(not found)");
                // increase the episode count
                ++episodes;
            }
            // track the max amount of data we end up using at one time
            if(pool.used()>max)
                max=pool.used();
            // recycle this for the next query
            pool.freeAll();
        }
    
    } else if(JsonReader::Error==jsonReader.nodeType()) {
        break;
    }
}
if(JsonReader::Error==jsonReader.nodeType()) {
    // report the error
    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());
// report the statistics
printf("Max used bytes of pool: %d\r\nScanned %d episodes 
        and %llu characters in %d milliseconds\r\n",(int)max,(int)episodes,
        lexContext.position()+1,(int)(end.count()-start.count()));

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

代码有点仓促,但可用。你可以看到我们甚至在计时整个过程。在我的老旧台式机(带 HDD)上,我得到了以下输出:

...
S07E03 Down Range
S07E04 Brothers in Arms
S07E05 Exit Plan
S07E06 All or Nothing
S07E07 Psychological Warfare
S07E08 Nature of the Beast
S07E09 Bitter Pill
S07E10 Things Unseen
S07E11 Tipping Point
S07E12 Sea Change
S07E13 Reckoning
Max used bytes of pool: 193
Scanned 112 episodes and 191288 characters in 4 milliseconds

你的现代机器可能表现更好。我的 ESP32 物联网设备表现明显更差,但它使用的是半速 SPI SD 卡读卡器和默认的 SD 库,所以大部分开销可能都在那里。毕竟这是 200KB 的数据。

Scanned 112 episodes and 191288 characters in 2429 milliseconds

缓存 `LexContext` 可能会以增加内存为代价来改进这一点,但我需要调查以找出大部分时间花在哪里。再次,我敢打赌是 SD 卡的 I/O。这里的重要之处是 256 容量中的 193 字节。这就是执行此查询所需的全部池空间,当您处理小型设备时,这非常出色。它不包括 `LexContext` 大小(256 字节)、`JsonReader` 上的成员变量或用于进行调用的本地堆栈空间。也许有一天我会研究这些,但那是一个深入的探索。就我们所知,执行此操作大约需要半 KB,而且至少在 PC 上它启动速度非常快。

我还没有真正向你展示如何使用字符串池,但老实说,我添加它只是为了能够以某种效率加载具有大量重复数据的大型文档。它适用于特殊情况,而不是常规用途。几乎总是,解决方案是首先加载更少的数据——也许将更多的查询转移到内存文档之外,并将其搬运到读取器上。

话虽如此,这应该足以让你入门。我希望你喜欢这段代码。

未来方向

我正在考虑添加 JSONPath 机制来查询内存文档。我不想这样做的主要原因是它鼓励内存查询,而这正是本库旨在减少的,那么为了什么呢?更多的代码?我能想到的一个很好的理由是,它可能会增加进行比过滤器允许的更复杂数据提取的机会。另一个原因是更容易地从层次结构的不同级别整理数据。主要缺点是许多查询需要大量的内存来存储结果集,然后对这些结果集进行反复查询,令人厌烦。它不适合小型机器。我的一部分仍然认为,应该由最终开发人员来设计他们的 JSONPath 查询,以尽可能少地使用内存,所以真正的问题是,如果我做了,会有人好好使用它吗?还是会普遍被滥用?

我正在考虑将 `StaticLexContext` 的内存移动到使用 `MemoryPool`,但这会稍微复杂化 `LexContext` 的使用,而且该池必须是专用的。它不能同时用于其他任何事情。这样做的一个很好的理由是方便更简单的性能分析。目前,尽管规则很简单,但很难知道 `LexContext` 实际执行操作需要多少空间。这有点像试错。例如,在上面的代码中,我知道 `LexContext` 运行该查询所需的内存少于 256 字节,但我不知道少了多少,除非我尝试一下。可能 64 字节就足够了。请记住,它是您需要检查的最长值的长度(包括引号和转义符)加上空终止符。这很容易理解,但很难实际弄清楚。

我最终可能会尝试让它在运行基本 Arduino SDK 的小型 8 位和 16 位处理器上工作,这些处理器没有像 <cinttypes> 这样的东西。我还没有这样做的主要原因是 ArduinoJSON 已经覆盖了这些设备,即使它主要是一个内存树库,但我可能有一天会做到,尽管我不想想到我完成了 80% 的工作,却发现我无法在 ATmega2560 8 位怪兽上编译它。

总的来说,此代码仍在开发中,所以请继续关注我的 GitHub。

历史

  • 2020年12月16日 - 初次提交
  • 2020年12月22日 - 大规模更新 - 新的 GitHub 仓库和新文章(文章于23日发布)
© . All rights reserved.