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

JSON 设计:高性能 JSON 处理器的演练

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.53/5 (5投票s)

2020年12月30日

MIT

30分钟阅读

viewsIcon

14581

downloadIcon

145

设计无限可扩展的 JSON:JSON (C++)

JSON by Design

引言

更新:已根据文章评论修复Windows编译问题。我现在没有Windows机器,所以非常感谢Randor帮助我解决了这个问题。

更新 2:重大项目更改,包括为ESP32、Arduino Mega 2560和原生机器集成PlatformIO构建。*您需要安装带有VS Code和相应软件包的PlatformIO才能使用这些板卡。由于体积过大,我没有将其包含在zip文件中。可在GitHub上获取。

我一直在撰写关于JSON(C++)演进的文章,最新一篇介绍了一个代码库,它在保持极小的内存占用的同时,性能得到了显著提升。

此外,我决定从更高的层面来阐述,让您可以参考我之前的文章来了解更具体的操作细节。上一篇文章可能让读者难以知道从何入手。我希望这次能更好地与您沟通,并向您展示这如何代表了JSON处理方面的一项架构性重大改进,从而在几乎任何设备上实现JSON的按需流式传输。

我不会用UML或充斥着各种“行业标准”设计模式的系统来淹没您。那些有其适用之处,那就是企业级软件*。我们采用的是我用于大多数项目的非常精简的方法。收集和识别功能性需求起主导作用,然后从需求出发进行设计决策,最后再进入编码阶段。诚然,这是一个非常简单的过程,您在大型专业项目上会做得更多。这对于分解单个开发者的项目,使其不至于压倒人,然后向他人解释这些项目很有帮助。

* 我在这里有点开玩笑。事实是,所有那些东西都有助于理解和实践——即使在您自己的项目中,因为它们可以提高您设计软件的能力,也能让您更好地处理大型项目、与大型或分散的开发团队协作,总之,让您成为一名更好的开发者。同时,对于独自工作的开发者来说,它们通常是过度设计的。

概念化这个混乱的局面

通常,JSON处理器会验证整个文档,并将其解析到内存树中。这在许多场景下都很有用,但不适合大数据处理,因为在这种情况下,我们需要比常见的JSON处理算法在时间和空间效率上实现数量级的改进。

关于大数据,它是一个相对的概念。一个200KB的文件在我PC上不足以提供一致的基准测试,但在8位Arduino Atmega2560上处理起来可能需要几秒钟,即使已经进行了性能优化。

要处理大数据,您需要一个专门为此而构建的JSON处理器。传统处理方式在批量加载GB数据以及在小型设备上处理“块状”在线数据时都会失效,原因相同——数据量对于传统JSON处理的机器来说太大了。有意义的是,使用相同的代码解决这两个问题,因为它们是同一个问题,并使代码能在任何设备上运行,这样才值得。

为此,我提出一种更好的JSON处理方法。通过设想真实世界的大型文档的使用场景,并从我之前的设计迭代(甚至已经编写了代码)中学习,我迭代地得出了一系列功能性需求。它并不完美,因为使用场景最好由用户驱动,而不是开发者自己,但在独自处理自己的项目时,必须做一些权衡。以下是功能性需求的非正式展示

  • *最终决定内存需求的不是引擎,而是终端开发者。这是为了在受限环境中工作,同时又能扩展到更强大的机器。
  • JSON处理器必须利用显著的硬件特性来提高性能
  • *JSON处理器必须是可移植的,甚至可以移植到8位平台。
  • *JSON处理器使用的内存不应超过成员/局部变量所需的内存,并且只包含给定查询特定请求的结果集。
  • *实际的查询及其结果必须能够容纳在4KB以下的空间,以便在Arduino上流畅运行。
  • JSON处理器必须允许开发者粒度地检索JSON元素,即使是文档深处的内容。
  • JSON处理器必须支持多种输入源,或允许您实现自己的输入源。
  • JSON处理器必须提供优化的搜索和导航功能。
  • *JSON处理器必须接受所有符合规范的文档,包括无限精度数字和大BLOB字段。
  • *JSON处理器必须允许所有标量值的往返(round tripping),但字符串转义可能会被转换为等效表示。
  • *JSON处理器可以在值空间中对数字表示施加限制,但对词法空间中的任何类型的值都不允许有任何限制,除非存在平台特定的流/文件大小限制。

* 这些特性区分了此JSON处理器与其他大多数产品。

上面我使用了词法空间值空间这两个术语。简而言之,这些概念与数据的表示方式有关。如果我们把数字看作一串字符,那么我们就是在词法空间中观察它。如果我们把数字看作其数值表示(例如,用intdouble存储),那么这就是值空间。出于本文的考虑,以及在使用处理器时,理解这些概念非常有帮助,因为它们在您可以做什么方面差异很大。应该指出的是,JSON是一个词法规范。尽管它包含数字,但它没有为这些数字指定值空间表示。例如,它没有指示必须适合64位。事实上,JSON中数字的唯一限制是它符合正则表达式(\-?)(0|[1-9][0-9]*)((\.[0-9]+)?([Ee][\+\-]?[0-9]+)?)

处理任意长度的值

这意味着为了接受所有文档,我们必须接受任意长度的数字。但这并不意味着我们必须在值空间中表示它们。这个JSON处理器可以产生任意长度数字的完美词法表示,并且它会尽最大努力在值空间中忠实地表示它。然而,大值可能会溢出。JSON规范允许处理器对数字施加平台限制,但由于此JSON处理器旨在跨平台,因此如果可避免,不同环境下的限制不应有显著差异,这也是我们允许完美词法表示的原因。不过,我们无法避免基于平台的值空间表示的差异化限制。这种在词法空间中无限精度的能力也为往返数字提供了基础。如果您想往返数字,请将其获取为词法空间。您可以根据需要获取词法空间或值空间,或两者兼有。

当然,我们也必须接受其他任意长度的标量字段,这意味着我们也不能对字符串值施加长度限制。

表面上看,这与之前提到的项目内存使用要求直接矛盾。20MB的字符串字段或包含1000个零的数字怎么办?为了解决这些值的内存需求,我们可以将任何值分块流式传输,JSON处理器会自动这样做。目前,字段名的长度有时会受到开发者设置的限制,但即使是这个限制,也可以通过正确的查询来避免。“捕获缓冲区”用于保存字段名或值块(在代码中称为“值部分”),其长度可以小到5字节——4字节用于UTF-8中最长的Unicode码点,1字节用于空终止符,但只要有RAM,它就可以任意大。只有当值长度超过可用捕获缓冲区时,才会对其进行分块。分块足够高效,即使进行大量分块也不会对性能产生太大影响。因此,除非您需要检索非常长的字段名,否则很少有理由分配大量捕获缓冲区空间,因为它们无法流式传输/分块。底线是,您的捕获缓冲区大小的唯一限制是5字节,或您需要检索的最长字段名长度加上四个字节,以较大者为准。这就引出了内存管理。

处理内存

为了满足项目相关的内存要求,我们使用了两种内存方案。一种是附加在输入源上的专用捕获缓冲区,我们在前面已经提到过。其大小由开发者设置。另一种方案称为“内存池”(由MemoryPool派生类实现),它是一个通用的微型堆,其大小也由开发者设置。它支持快速分配,但不支持单个项目删除。因此,性能是一致的,因为没有碎片。从内存池中分配和删除非常高效,几乎是免费的。所有数据必须一次性从内存池中释放,但您可以使用任意数量的不同内存池来执行查询的不同部分。不过,一个内存池几乎总是够用的。内存池被传递给任何需要分配RAM来完成其操作的方法。通常,您会在操作完成后并使用检索到的数据后释放该内存池。在单个查询过程中,这通常意味着多次释放内存池。例如,您可能会收到几“行”结果,但您只需要逐行打印到控制台,因此在检索到每“行”后,您会打印它然后释放内存池,因为您不再需要该行的数据。这里,“行”只是一个熟悉的结果集单个结果的术语。在可能的情况下,系统会尝试根据您分配给它的内存量进行调整,但这并非总是可能。有时,查询所需的内存量会超过您分配给内存池的内存量。在这种情况下,通常可以通过重新设计查询来尽量避免(如果可能的话)依赖内存中的结果,但这通常意味着编写更复杂的代码。

输入源的灵活性

C++标准并未规定用于HTTPS通信等事的便携式函数。我没有提供所有可用I/O的实现,而是创建了一个名为LexSource的基类,它实现了对某些输入的专用仅向前游标。这满足了JSON处理器支持自定义输入源的要求。我没有使用C++迭代器模型,是因为出于性能优化的原因,它有一个迭代器接口不存在的重要特殊化,但我的类必须存在。我没有使用它,是因为LexSource有一个集成的捕获缓冲区,其逻辑与读取操作交织在一起以提高效率。迭代器上不存在这样的接口。我也没有使用它,是因为它往往会使您依赖STL,而我出于便携性和目标平台合规性的原因希望避免这一点。稍后我会详细介绍。但是,std::istream接口或迭代器可以很容易地插入到这个库中。事实上,任何输入源都可以。您需要做的就是派生自LexSource并实现read(),该函数向前移动一个字节,返回流(UTF-8或ASCII)中的下一个字节,或者在输入不可用时返回几个负的失败条件之一。如果底层输入源支持查找一组字符的优化方法,您可以实现skipToAny()以使搜索和跳过速度更快,但它是可选的,实现起来可能有点棘手,并非所有源都能很好地利用它。因此,提供了一个默认实现,它只调用read()。对于内存映射文件,skipToAny()非常有效。缓冲网络I/O可能是另一个可能受益的领域。JSON处理器使用LexSource作为其输入源。我已经实现了几个,包括两种不同的文件实现,一个基于Arduino Stream的实现,以及一个基于空终止字符串(ASCII或UTF-8)的实现。如果您需要创建一个自定义的,请参考它们作为示例。

解决便携性问题

该库是用C++11的头文件库编写的。它可以在Arduino、Linux、Windows上编译和运行,并且应该也能在Apple和Raspberry设备以及ESP32等各种IoT设备上进行很少或无需修改即可运行。如果有人在编译时遇到问题,请留言,因为我还没有能在所有平台上进行测试。

便携性带来了某些设计上的考虑。例如,该库避免使用CPU特定的SIMD指令,而是依赖于C标准库中的优化,而C标准库通常会利用这些指令。我们还在该库中避免使用泛型编程,以避免代码膨胀,它倾向于引入,并且通常强迫依赖STL实现,而STL在Arduino上几乎不存在。此外,某些STL实现不幸地偏离了标准,并且彼此之间也存在差异(尽管近年来,一些更不规范的实现已被更标准的实现所取代)。

内存映射文件等某些功能在某些平台上不可用。这些功能被分解为可以在需要时包含,但没有任何东西依赖于它们。这样,我们就可以在满足竞争性能的功能性需求的同时,保持相对的便携性。在可能的情况下,为多个平台实现了特定于平台的特性。例如,Windows和Linux都支持内存映射文件。

我们努力支持Arduino设备,因为它们的代码库比较特殊(支持某些C++功能而不支持其他功能),以便能够针对流行的8位处理器。我还没有用ATmega2560以外的8位处理器进行测试,但只要它们能够处理32位值(即使没有专门的指令)并且具有一定的浮点支持,它们应该能够处理它……虽然速度较慢。当然,您没有内存映射文件I/O或大多数LexSource实现。但StaticArduinoLexSource<TCapacity>接受Arduino用于文件、网络连接和串行通信的Stream,允许您使用其中任何一个。最棒的是,您的查询代码仍然可以正常工作,因为JSON处理器在完整大小的平台上暴露的所有功能都以相同的方式进行。

提高性能

通过fgetc()甚至fread()检索数据的标准解析无法提供现代JSON处理器所能达到的吞吐量。为了使该软件包与其他产品具有竞争力,它包含许多优化,其中一些对于该库来说是相对独特的。

  1. 拉取解析 - 该库使用拉取解析器,以避免递归、节省内存并提供高效的流式支持。
  2. 部分解析 - 除非您要求,否则该库不会解析整个文档。它会对文档中的键标记进行快速匹配以找到您想要的内容。虽然它特别适合机器生成的JSON,但它不像完全验证的解析器那样健壮地报告格式错误的错误。这是一个为了性能而做出的设计决策。该软件没有要求拒绝所有无效文档。要求仅仅是它接受所有有效的JSON文档。它通常会检测到错误,只是有时不如其他产品那样及早。
  3. 非规范化搜索 - 该库不加载或以其他方式规范化和存储任何未明确请求的数据。字符串在“磁盘”/输入源上直接以流式方式进行解码和/或搜索,而不是加载到RAM中。这限制了您检查字符串的次数,并节省了内存。
  4. 内存映射I/O - 这是该库在某种程度上特定于平台的领域。并非所有平台都具有此功能,但主流操作系统都有。在具有该功能的平台上,您可以使用此库将速度提高5-6倍。通过这种方式,您可以在现代工作站(指非我老古董的计算机)上达到甚至超过每秒1GB的处理JSON速度。这与使用优化的strpbrk()函数相结合,满足了利用显著硬件特性来提高性能的要求,因为strpbrk()几乎总是经过高度优化。
  5. 快速DFA流式值解析。为了支持任意长度的数字,此实现使用手工构建的DFA状态机按需、渐进地解析数字甚至文字,仅在请求时进行。大多数JSON处理器必须将整个数字加载到RAM中才能将其解析为double或integer。该库没有这个要求,这意味着它可以流式传输任意大小的值,可以在此类流式传输操作的中间中止,并且可以逐个字符地延迟解析为值空间。它在分块时迭代解析。

在我这台古老的PC上,处理一个200KB的漂亮格式化的JSON文档,我得到了以下结果

Extract episodes - JSONPath equivalent: $..episodes[*].season_number,episode_number,name
    Extracted 112 episodes using 23 bytes of the pool
    Memory: 324.022283MB/s
    Extracted 112 episodes using 23 bytes of the pool
    Memory Mapped: 331.680991MB/s
    Extracted 112 episodes using 23 bytes of the pool
    File: 94.471541MB/s

Episode count - JSONPath equivalent: $..episodes[*].length()
    Found 112 episodes
    Memory: 518.251549MB/s
    Found 112 episodes
    Memory Mapped: 510.993124MB/s
    Found 112 episodes
    File: 99.413921MB/s

Read id fields - JSONPath equivalent: $..id
    Read 433 id fields
    Memory: 404.489014MB/s
    Read 433 id fields
    Memory Mapped: 382.441395MB/s
    Read 433 id fields
    File: 95.012784MB/s

Skip to season/episode by index - JSONPath equivalent $.seasons[7].episodes[0].name
    Found "New Deal"
    Memory: 502.656078MB/s
    Found "New Deal"
    Memory Mapped: 495.413195MB/s
    Found "New Deal"
    File: 97.786336MB/s

Read the entire document
    Read 10905 nodes
    Memory: 76.005061MB/s
    Read 10905 nodes
    Memory Mapped: 76.837467MB/s
    Read 10905 nodes
    File: 65.852761MB/s

Structured skip of entire document
    Memory: 533.405103MB/s
    Memory Mapped: 516.783414MB/s
    File: 100.731389MB/s

Episode parsing - JSONPath equivalent: $..episodes[*]
    Parsed 112 episodes using 5171 bytes of the pool
    Memory: 69.252903MB/s
    Parsed 112 episodes using 5171 bytes of the pool
    Memory Mapped: 68.221595MB/s
    Parsed 112 episodes using 5171 bytes of the pool
    File: 58.409269MB/s

Read status - JSONPath equivalent: $.status
    status: Canceled
    Memory: 546.021376MB/s
    status: Canceled
    Memory Mapped: 528.611999MB/s
    status: Canceled
    File: 86.431820MB/s

Extract root - JSONPath equivalent: $.id,name,created_by[0].name,
               created_by[0].profile_path,number_of_episodes,last_episode_to_air.name
    Used 64 bytes of the pool
    Memory: 527.238570MB/s
    Used 64 bytes of the pool
    Memory Mapped: 510.993124MB/s
    Used 64 bytes of the pool
    File: 100.123241MB/s

我选择这个而不是20MB的批量文档用于基准测试,因为我可以将较小的版本随项目一起分发,而您则需要自己生成20MB的文档。这通常能提高大型文件的吞吐量,至少在PC上是这样。您的数据越密集,吞吐量计数就越低,尽管“有效”速度实际上是相同的,因为它不计算前进的死空间。

在所有这些基准测试中,我比较了三种不同的访问方法:第一种是使用200KB内存中的空终止字符串。第二种是使用内存映射文件。最后一种方法是使用标准文件,底层使用fgetc()。我曾经也有一个使用fread()的方法,但令人惊讶的是,性能并没有更好,所以我删除了它,因为额外的缓冲只需要额外的内存。

作为参考,我的ESP32 IoT设备平均以约73KB/s的速度运行这些基准测试,而可怜的8位Arduino则以平均23KB/s的速度运行。我想出去闯荡!不过,它仍然运行,而且您不必担心输入的大小。它应该能处理您扔给它的几乎任何东西,即使是稍微不合理的要求。

提供导航和搜索

这些都由JsonReader上的skipXXXX()方法覆盖。使用这些方法可以实现高速、高度选择性的文档导航。您可以沿着三个轴之一移动到特定字段,可以跳到数组的特定索引,还可以极快地跳过整个子树。这些是穿越大量JSON文本的最快方式。skip方法从不将数据加载到RAM,也从不启动完整的解析,除了skipToFieldValue()方便方法,它在找到字段后调用skipToField()然后为您调用一次read()。总而言之,这些方法满足了高效搜索和导航功能的要求。

粒度提取数据

当然,您最终会在某个时候需要从文档中提取数据,虽然在许多情况下您可以使用skipXXXX()read(),但还有另一种处理方法,即“提取”。提取是预先编码的导航,可以在一次高效的操作中检索多个值。使用我们已经探索过的原始读取器函数通常更灵活、更高效,但用它进行导航也很困难。此外,它有一个常见的操作,既不能高效也不能正确地处理:从同一个对象中检索多个字段的值。您可能会认为可以使用skipToField()循环,每次传递不同的字段名,但问题是JSON字段是无序的,这意味着您不能假定它们在文档中出现的顺序。读取器不能回退,所以假设您想要一个id和一个name。您可以调用skipToField("id", JsonReader::Siblings)然后是skipToField("name",JsonReader::Siblings),但这只在id在文档中出现在name之前时才有效——根据规范,您不能依赖这一点!如果您想搜索多个字段名,您可以遍历所有字段使用read()并选择您想要的内容,但 wherever possible,您应该使用提取。read()在时间和空间要求方面更昂贵。

提取允许您在一次操作中快速获取多个字段值或数组项以及子项。它们的主要限制是它们必须返回固定数量的值。您不能运行一个返回任意长度结果列表的“查询”来执行提取。您必须使用读取器来导航,从文档的较小部分提取固定数量的值,再次导航并重复。最终,我将生产一个查询引擎或代码生成引擎,以促进构建复杂的查询,可能使用JSONPath的非回溯子集。提取本身不是查询。查询包括导航/搜索和提取。提取只是查询的构建块。有时,查询甚至不需要提取,例如,如果您只想从对象中检索单个值。

提取目前只能检索值空间中的数值,而不是词法空间,因此您目前无法通过提取来实现往返。这将来可能会改变,但可能需要注意,这可能会导致RAM使用量激增。提取的值必须完全加载到内存中,因为它们无法流式传输。您必须使用读取器上的read()来获取词法空间中的数值。

提取满足了粒度数据检索的要求,并且与导航/搜索结合起来,满足了能够从文档深处精确查找数据的要求。它们也有助于满足使查询舒适地容纳在4KB RAM中的要求,因为尽管它们比原始读取使用更多的RAM,但通过使用可以在每个结果行处理后释放的内存池,以及能够仅提取所需的标量值,它非常保守地使用它确实使用的内存。

提取由一系列JsonExtractor结构组成,它们嵌套在一起,有一个根。有三种类型的提取器可用,具体取决于调用的构造函数。

  1. 对象提取器从对象元素中提取字段值
  2. 数组提取器从数组中提取特定索引的项
  3. 值提取器提取当前位置的单个值

您基本上是组合这些。前两个是导航,第三个进行实际的提取。前两个一起用于构成文档中的一种复合路径(实际上是多条并行路径),在路径的末端,您将有值提取器来获取您刚刚指向的数据。

如前所述,数组和对象提取器可以同时检索多个字段或索引,因此,正如我所说的,您可以同时导航多条路径。另一方面,值提取器链接到您提供的变量,该变量将在提取时被设置。

您可以通过声明将用于保存数据的变量,然后声明嵌套结构来使用它们。然后,您可以获取根结构并将其传递给extract(),以用相对于文档当前位置的数据填充您的变量。最后这一点很重要,因为它允许您在文档的不同位置重复使用提取器。这对于提取任意长度的结果列表非常有用,因为对于每一行,您都使用读取器进行导航,然后每次都使用相同的提取器调用extract()。我们在基准测试中从大型JSON数据转储中提取电视节目数据时就是这样做的。

现在我们已经讨论了很多关于这些内容,稍后我们会详细介绍。

内存树

这本身并不直接满足功能性需求,但它有助于提取,因为我们需要将JSON数据保存在内存中才能执行提取。我可以为提取器设置一个限制,这样您只能提取标量JSON值,而不能使用值提取器提取对象或数组值,但这并不会使代码变得更简单。它甚至可能使代码更复杂。

因此,我们可以将任何JSON元素保存在内存中,通常使用MemoryPool,尽管如果您自己构建JSON树(不推荐),则不要求使用内存池——您的内存可以来自任何地方。

JsonElement是一种变体,可以表示任何类型的JSON元素,无论是对象、数组还是标量值,如字符串或整数。数据以值空间形式保存。没有保留值的词法表示的设施,这在某些情况下会使RAM使用量过高,因为它们不是流式的。通常,您可以查询其持有的值的type(),然后使用适当的访问器方法,如integer()real()string()来获取标量值。对象和数组保存在链表中,其头节点可在pobject()parray()处获得,并使用字段名或索引通过operator[]进行访问。我不会在此详细介绍手动构建树,因为这并非为此设计,编辑对象、数组或字符串也不高效。请注意,字段名不是哈希的。不要以为您可以通过这种方式高效地从大型内存对象中检索字段。这并非该库的设计目的。大多数其他JSON库这样设计的。

JsonElement的主要用途是保存由值提取器提取的值。您可以将JsonElement变量链接到值提取器,它将被您要求的数据填充。

您还可以对JsonElement本身调用extract(),这样您就可以像处理读取器一样从内存树中提取数据。这仅仅是为了让您可以将任意长度的查询数据列表保存在内存中作为JSON数组或对象,然后在事后从中提取相关数据。基本上,这是为了让您可以将整个结果集作为JSON数组保存在内存中,然后在此基础上进行工作,但我不会提供包装器使这种技术易于使用,因为它违背了该库的RAM要求。如果您发现自己以这种方式使用此代码,那么您可能最好选择其他JSON处理器。

我们现在已经涵盖了该JSON处理器的功能性需求和总体设计元素。现在是时候深入代码了。

编写这个混乱的程序

入门

我提供了一个项目,可以在VSCode中使用PlatformIO与ATmega2560或GCC编译的Linux工作站一起工作。如果您要在PlatformIO中使用它,则需要修改您的板卡设置以匹配您的设备。如前所述,它目前设置为ATMega2560,但在同一个platformio.ini文件中也注释掉了ESP32板卡的设置。如果您使用GCC以外的编译器和/或GDB以外的调试器,您需要自己向tasks.json添加其他构建任务,并按照您想要的方式配置launch.json。该项目可以修改为使用一个INO文件,该文件简单地#includemain.cpp,这样ArduinoIDE就可以构建它。不要在您的INO中使用main.cpp,因为预处理器定义容易使IDE混淆,并且它会损坏生成后的代码,即使它能构建。

生成批量数据

我在项目中提供了BulkTemplate.txt,它提供了一个模板,用于生成20MB的漂亮格式化的JSON,可与基准测试应用程序中的查询一起使用。您可以使用此网站 https://www.json-generator.com/ 来生成,尽管它涉及滥用剪贴板和您(最不)喜欢的文本编辑器。该网站不会太喜欢您,但我们不是来交朋友的。我不能保证您的IoT设备能处理,因为我还没有等待它以22KB/s的速度解析。它的设计目的是这样,我能想到的任何直接阻止它的事情,除了可能的整数溢出。例如,我试图将其设计成,如果位置溢出,当它变为负数时不会破坏解析。我们将看看这些措施在实践中效果如何。

探索代码

基准测试代码涵盖了该库的所有主要功能,但因此篇幅相当长。

这不会充斥代码,但我们会涵盖一些核心功能。让我们从提取文档中的剧集信息开始,因为这说明了执行查询、搜索/导航和提取的所有主要组件。

// we don't need nearly this much. See the profile info output 
StaticMemoryPool<256> pool;
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 jr(fls);
unsigned long long maxUsedPool = 0;
int episodes = 0;
while(jr.skipToFieldValue("episodes",JsonReader::Forward)) {
    if(!jr.read())
        break;
    while(!jr.hasError() && JsonReader::EndArray!=jr.nodeType()) {
        
        // we keep track of the max pool we use
        if(pool.used()&gt;maxUsedPool)
            maxUsedPool = pool.used();
        // we don't need to keep the pool data between calls here
        pool.freeAll();
        
        ++episodes;
        // read and extract from the next array element
        if(!jr.extract(pool,extraction)) {
          print("\t\t");
          print(episodes);
          print(". ");
          print("Extraction failed");
          println();
          break;
        }
            
        if(!silent) {
            print("\t\t");
            print(episodes);
            print(". ");
            printSEFmt((int)seasonNumber.integer(),(int)episodeNumber.integer());
            print(" ");
            print(name.string());
            println();
        }
    }
}
if(jr.hasError()) {
    // report the error
    print("\tError (");
    print((int)jr.error());
    print("): ");
    print(jr.value());
    println();
    return;

} else {
    print("\tExtracted ");
    print(episodes);
    print(" episodes using ");
    print((long long)maxUsedPool);
    print(" bytes of the pool");
    println();
}

首先,我必须为不使用cout甚至printf()而道歉,因为Arduino对此有限制,它们没有这些功能,或者实现不完整。我不得不改用这些print()函数。在Arduino上,它输出到第一个Serial端口。在其他任何设备上,它都写入stdout

我们做的第一件事是分配一个内存池,因为extract()需要它来保存您的结果数据。我分配了惊人的256字节,远超所需。在data.json中,我们可以用它的1/10左右的空间就足够了,但更大的内存池让我们有更多的空间来保存比该文件中更长的剧集名称。此外,extract()内部会使用一些额外的字节作为临时空间,但它们不会被报告,所以您总是需要留出一些空间。这是一个典型场景。您不需要预留大量空间,因为查询的RAM效率极高,但即使如此,为了安全起见,您通常仍会预留比实际使用量多得多的空间。

接下来,我们声明JsonElement变量,它们将在每次调用extract()时保存结果。我们需要三个变量来保存我们正在提取的三个字段。

现在我们声明一个字段名数组,然后是一个JsonExtract(&var)值提取器数组,我们将它们作为子项传递给我们接下来声明的对象提取器。这样就完成了我们的根提取器。

JsonExtractor extraction(fields,3,children);

请注意,您的字段数组的大小、子项数组的大小以及计数都必须匹配。如果不匹配(想象一下不祥的音乐),结果是未定义的。这是委婉的说法,意思是您很可能会崩溃。

现在我们已经构建了提取器,但还不能使用它。提取总是相对于您在文档中的当前位置。如果我们现在运行extract(),名称将是“Burn Notice”,而季和集的数字将是<No Result>/<Undefined>,因为它们不存在于根对象中。

所以,在使用提取之前,我们必须导航。因此,我们创建一个JsonReader,然后基本上这样导航:$..episodes

诀窍在于这行代码

while(jr.skipToFieldValue("episodes",JsonReader::Forward))

这将快速地在文档中搜索任何名为episodes的字段,然后移动到该值。它不考虑字段在层次结构中的位置。这对于我们的目的来说已经足够了,而且速度很快。另一种方法可能是使用$.seasons[*].episodes,但这更复杂。

每当找到一个时,我们就会处于其Array节点上。从那里开始,我们开始遍历每个数组值。我们必须通过导航而不是使用提取来实现这一点,因为提取无法返回任意长度的结果集。一个变量映射到一个元素的检索。要提取一个未知数量的数组的所有项,唯一的方法是使用值提取器提取整个数组并将其保存在内存中。这不被推荐,这个引擎也不是为此设计的。另一种方法是这样做,即依次导航到数组的每个元素,并从该位置调用extract()

while(!jr.hasError() && JsonReader::EndArray!=jr.nodeType()) {
    
    // 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();
    
    ++episodes;
    if(!jr.extract(pool,extraction)) {
      print("\t\t");
      print(episodes);
      print(". ");
      print("Extraction failed");
      println();

      break;
    }
        
    if(!silent) {
        print("\t\t");
        print(episodes);
        print(". ");
        printSEFmt((int)seasonNumber.integer(),(int)episodeNumber.integer());
        print(" ");
        print(name.string());
        println();
    }
}

一旦我们移除了所有冗余部分,事情就很简单了。只要数组中还有元素,并且没有错误,我们就释放任何先前数据的内存池,因为我们不再需要它,并递增一个我们实际上不需要的剧集计数——我们只用它来统计。这里最主要的是下一行,我们将调用extract()并传入我们的提取器。每次调用成功,我们之前声明并链接到值提取器的JsonElement变量都会被我们导航到的当前位置(数组中的对象)的提取结果填充。如果失败,则表示文档解析出现某种错误,请检查错误代码。找不到值并不表示失败。如果找不到值,其关联变量的类型将是Undefined。请注意我们随后打印值的方式。当我们到达数组末尾时,外层循环将把我们带到文档中的下一个episodes字段,然后过程重复。

值得注意的是,extract()操作的是您当前的逻辑位置,然后将光标移过该位置的元素。例如,如果您处于一个对象上,当extract()完成后,它将已读取该对象,并将读取器定位到其后的任何内容。数组和标量值也是如此。提取实际上“消耗”了它所定位的元素。我们在上面充分利用了这一点,因为简单地调用extract()就会将我们移到数组中的下一个项。

上面,我们只对一次检查单个对象感兴趣,但您不限于此。您可以创建钻取到层次结构的深处并从中提取值的提取器。

//JSONPath is roughly $.id,name,created_by[0].name,created_by[0].profile_path,
//number_of_episodes,last_episode_to_air.name
DynamicMemoryPool pool(256);
if(0==pool.capacity()) {
  print("\tNot enough memory to perform the operation");
  println();
  return;
}
// create nested the extraction query

// 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.name and $.created_by.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 jr(ls);

if(jr.extract(pool,showExtraction)) {
    if(!silent)
      printExtraction(pool,showExtraction,0);
    print("\tUsed ");
    print((int)pool.used());
    print(" bytes of the pool");
    println();
} else {
    print("extract() failed.");
    println();
}

在这里,我们创建了几个嵌套的对象提取器,甚至还加入了一个数组提取器。查询大致翻译为以下JSONPath

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

我不确定这在JSONPath中是否语法正确,但这个想法应该能让人理解。由于我们从根开始,所以我们直接从那里extract()

目前此查询设置有一个重大限制,即无法执行某些非回溯查询,例如当您想要获取对象上的多个字段并递归地无限深入其某个字段时。我将添加一种回调提取器,它将在提取到达时通知您,以便您可以检索任意一系列值。

结论

main.cpp文件中包含大量代码可供您入门。希望本文演示了我最初设定的目标——通过一个可伸缩的JSON处理器进行设计。这里的功能性需求收集被缩减和非正式化,并产生了一系列同样非正式的设计决策,但足以产生满足其目标软件。一点前期规划就能走很远。

历史

  • 2020年12月30日 - 首次提交
  • 2020年12月31日 - 更新 1 - 修复Windows + MSVC上的编译问题
  • 2020年1月1日 - 更新 2 - 向GitHub项目添加了PlatformIO构建
© . All rights reserved.