luminouslabs / json-machine
高效、易用且快速的JSON提取解析器
Requires
- php: >=8.0
Requires (Dev)
- ext-json: *
- friendsofphp/php-cs-fixer: ^3.0
- phpunit/phpunit: ^8.0
Suggests
- ext-json: To run JSON Machine out of the box without custom decoders.
- guzzlehttp/guzzle: To run example with GuzzleHttp
This package is auto-updated.
Last update: 2024-09-16 12:23:03 UTC
README
(README与代码同步)
非常易于使用且内存效率高,是PHP >=8.0中不高效的迭代大JSON文件或流的便捷替换方案。见TL;DR。除了可选的ext-json
外,生产环境中无其他依赖。
TL;DR
<?php use \JsonMachine\Items; // this often causes Allowed Memory Size Exhausted - $users = json_decode(file_get_contents('500MB-users.json')); // this usually takes few kB of memory no matter the file size + $users = Items::fromFile('500MB-users.json'); foreach ($users as $id => $user) { // just process $user as usual var_dump($user->name); }
如$users[42]
这样的随机访问目前还不可能。使用上面提到的foreach
找到项目或使用JSON Pointer。
通过iterator_count($users)
计算项目数量。请记住,它仍然需要内部迭代整个内容以获取计数,因此将花费相同的时间。
如果直接使用,需要ext-json
。见解码器。
遵循变更日志。
简介
JSON Machine是基于生成器开发的,用于不可预测地长的JSON流或文档的,一个高效、易用且快速的JSON流/提取/增量/懒(无论你叫什么)解析器。主要特性包括
- 对于不可预测地大的JSON文档,具有恒定的内存占用。
- 易用性。只需使用
foreach
迭代任何大小的JSON。无事件和回调。 - 对文档的任何子树进行高效迭代,指定为JSON Pointer
- 速度。性能关键代码不包含不必要的函数调用、正则表达式,并默认使用本机的
json_decode
解码JSON文档项。见解码器。 - 不仅解析流,还解析任何产生JSON块的可迭代对象。
- 经过彻底测试。超过200个测试和1000个断言。
解析JSON文档
解析文档
假设fruits.json
包含以下巨大的JSON文档
// fruits.json { "apple": { "color": "red" }, "pear": { "color": "yellow" } }
可以这样解析
<?php use \JsonMachine\Items; $fruits = Items::fromFile('fruits.json'); foreach ($fruits as $name => $data) { // 1st iteration: $name === "apple" and $data->color === "red" // 2nd iteration: $name === "pear" and $data->color === "yellow" }
解析json数组而不是json对象遵循相同的逻辑。foreach中的键是项目的数字索引。
如果您希望JSON Machine返回数组而不是对象,请使用解码器new ExtJsonDecoder(true)
。
<?php use JsonMachine\JsonDecoder\ExtJsonDecoder; use JsonMachine\Items; $objects = Items::fromFile('path/to.json', ['decoder' => new ExtJsonDecoder(true)]);
解析子树
如果您只想迭代此fruits.json
中的results
子树
// fruits.json { "results": { "apple": { "color": "red" }, "pear": { "color": "yellow" } } }
使用JSON Pointer /results
作为pointer
选项
<?php use \JsonMachine\Items; $fruits = Items::fromFile('fruits.json', ['pointer' => '/results']); foreach ($fruits as $name => $data) { // The same as above, which means: // 1st iteration: $name === "apple" and $data->color === "red" // 2nd iteration: $name === "pear" and $data->color === "yellow" }
注意
results
的值不会一次性加载到内存中,而是一次只加载一个results
中的项目。在您当前迭代的级别/子树中,始终只有一个项目在内存中。因此,内存消耗是恒定的。
解析数组中的嵌套值
JSON指针规范还允许使用连字符(-
)来代替特定的数组索引。JSON Machine将其解释为通配符,可以匹配任何数组索引(而不是任何对象键)。这使您能够在不加载整个项目的情况下迭代数组中的嵌套值。
示例
// fruitsArray.json { "results": [ { "name": "apple", "color": "red" }, { "name": "pear", "color": "yellow" } ] }
要迭代所有水果的颜色,请使用JSON指针"/results/-/color"
。
<?php use \JsonMachine\Items; $fruits = Items::fromFile('fruitsArray.json', ['pointer' => '/results/-/color']); foreach ($fruits as $key => $value) { // 1st iteration: $key == 'color'; $value == 'red'; $fruits->getMatchedJsonPointer() == '/results/-/color'; $fruits->getCurrentJsonPointer() == '/results/0/color'; // 2nd iteration: $key == 'color'; $value == 'yellow'; $fruits->getMatchedJsonPointer() == '/results/-/color'; $fruits->getCurrentJsonPointer() == '/results/1/color'; }
解析单个标量值
您可以通过与集合相同的方式解析文档中的单个标量值。考虑以下示例
// fruits.json { "lastModified": "2012-12-12", "apple": { "color": "red" }, "pear": { "color": "yellow" }, // ... gigabytes follow ... }
获取lastModified
键的标量值如下
<?php use \JsonMachine\Items; $fruits = Items::fromFile('fruits.json', ['pointer' => '/lastModified']); foreach ($fruits as $key => $value) { // 1st and final iteration: // $key === 'lastModified' // $value === '2012-12-12' }
当解析器找到值并将其返回给您时,它会停止解析。因此,当单个标量值位于数GB大小的文件或流的开始处时,它只需在极短的时间内从开始处获取值,并且几乎不消耗内存。
显然的快捷方式是
<?php use \JsonMachine\Items; $fruits = Items::fromFile('fruits.json', ['pointer' => '/lastModified']); $lastModified = iterator_to_array($fruits)['lastModified'];
单个标量值访问也支持JSON指针中的数组索引。
解析多个子树
还可以使用多个JSON指针解析多个子树。考虑以下示例
// fruits.json { "lastModified": "2012-12-12", "berries": [ { "name": "strawberry", // not a berry, but whatever ... "color": "red" }, { "name": "raspberry", // the same ... "color": "red" } ], "citruses": [ { "name": "orange", "color": "orange" }, { "name": "lime", "color": "green" } ] }
要迭代所有浆果和柑橘类水果,请使用JSON指针["/berries", "/citrus"]
。指针的顺序无关紧要。项目将按照在文档中出现的顺序迭代。
<?php use \JsonMachine\Items; $fruits = Items::fromFile('fruits.json', [ 'pointer' => ['/berries', '/citruses'] ]); foreach ($fruits as $key => $value) { // 1st iteration: $value == ["name" => "strawberry", "color" => "red"]; $fruits->getCurrentJsonPointer() == '/berries'; // 2nd iteration: $value == ["name" => "raspberry", "color" => "red"]; $fruits->getCurrentJsonPointer() == '/berries'; // 3rd iteration: $value == ["name" => "orange", "color" => "orange"]; $fruits->getCurrentJsonPointer() == '/citruses'; // 4th iteration: $value == ["name" => "lime", "color" => "green"]; $fruits->getCurrentJsonPointer() == '/citruses'; }
那么JSON Pointer到底是什么呢?
这是在JSON文档中指定一个项的方法。请参阅JSON指针RFC 6901。它非常方便,因为有时JSON结构更深,您想要迭代子树而不是主层。所以您只需要指定要迭代的JSON数组或对象(甚至是标量值)的指针,然后就可以开始了。当解析器遇到指定的集合时,迭代开始。您可以通过所有Items::from*
函数中的pointer
选项传递它。如果您指定了文档中不存在的位置的指针,将抛出异常。它可以用来访问标量值。 JSON指针本身必须是一个有效的JSON字符串。将引用令牌(斜杠之间的部分)与JSON文档的键/成员名称进行字面比较。
以下是一些示例
选项
选项可能会改变JSON的解析方式。Items::from*
函数的所有参数的第二个参数是选项数组。可用的选项包括
pointer
- 一个表示您想要迭代的文档哪一部分的JSON指针字符串。decoder
-ItemDecoder
接口的实例。debug
-true
或false
,用于启用或禁用调试模式。当启用调试模式时,在解析或异常期间可以获取有关行、列和文档中的位置的数据。禁用调试模式会略微提高性能。
解析来自JSON API的流式响应
流API响应或任何其他JSON流都是按与文件完全相同的方式进行解析的。唯一的区别是,您使用Items::fromStream($streamResource)
进行解析,其中$streamResource
是包含JSON文档的流资源。其余部分与解析文件相同。以下是支持流响应的流行http客户端的一些示例
GuzzleHttp
Guzzle使用自己的流,但可以通过调用\GuzzleHttp\Psr7\StreamWrapper::getResource()
将它们转换回PHP流。将此函数的结果传递给Items::fromStream
函数,然后您就可以设置了。请参阅工作GuzzleHttp示例。
Symfony HttpClient
Symfony HttpClient的流响应作为迭代器工作。由于JSON Machine基于迭代器,因此与Symfony HttpClient的集成非常简单。请参阅HttpClient示例。
跟踪进度(启用debug
)
大型文档解析可能需要一些时间。在foreach
循环中调用Items::getPosition()
以获取从开始处处理过的字节数。然后,计算百分比很简单,即position / total * 100
。要找出文档的总字节数,你可能需要检查
- 如果解析字符串,请检查
strlen($document)
- 如果解析文件,请检查
filesize($file)
- 如果解析HTTP流响应,请检查HTTP头中的
Content-Length
- ...你明白了
如果debug
被禁用,getPosition()
总是返回0
。
<?php use JsonMachine\Items; $fileSize = filesize('fruits.json'); $fruits = Items::fromFile('fruits.json', ['debug' => true]); foreach ($fruits as $name => $data) { echo 'Progress: ' . intval($fruits->getPosition() / $fileSize * 100) . ' %'; }
解码器
Items::from*
函数还接受decoder
选项。它必须是JsonMachine\JsonDecoder\ItemDecoder
的实例。如果没有指定,默认使用ExtJsonDecoder
。它需要ext-json
PHP扩展存在,因为它使用json_decode
。当json_decode
不按你的意愿工作时,实现JsonMachine\JsonDecoder\ItemDecoder
并创建自己的。
可用的解码器
-
ExtJsonDecoder
- 默认。使用json_decode
来解码键和值。构造函数与json_decode
具有相同的参数。 -
PassThruDecoder
- 不进行解码。键和值都以纯JSON字符串的形式产生。当你想在foreach中直接用其他东西解析JSON项,而不想实现JsonMachine\JsonDecoder\ItemDecoder
时很有用。自1.0.0
版起不再使用json_decode
。
示例
<?php use JsonMachine\JsonDecoder\PassThruDecoder; use JsonMachine\Items; $items = Items::fromFile('path/to.json', ['decoder' => new PassThruDecoder]);
ErrorWrappingDecoder
- 一个装饰器,它将解码错误包裹在DecodingError
对象中,从而允许你跳过格式不正确的项,而不是在SyntaxError
异常上崩溃。示例
<?php use JsonMachine\Items; use JsonMachine\JsonDecoder\DecodingError; use JsonMachine\JsonDecoder\ErrorWrappingDecoder; use JsonMachine\JsonDecoder\ExtJsonDecoder; $items = Items::fromFile('path/to.json', ['decoder' => new ErrorWrappingDecoder(new ExtJsonDecoder())]); foreach ($items as $key => $item) { if ($key instanceof DecodingError || $item instanceof DecodingError) { // handle error of this malformed json item continue; } var_dump($key, $item); }
错误处理
从0.4.0版本开始,每个异常都扩展了JsonMachineException
,因此你可以捕获它来过滤JSON Machine库中的任何错误。
跳过格式不正确的项
如果json流中任何地方出现错误,将抛出SyntaxError
异常。这非常不方便,因为如果JSON项内部有一个错误,你将无法解析文档的其余部分,因为有一个格式不正确的项。ErrorWrappingDecoder
是一个解码器装饰器,可以帮助你解决这个问题。用这个装饰器包装解码器,你将在foreach中通过DecodingError
收到所有格式不正确的项。这样,你可以跳过它们,并继续处理文档。请参见可用解码器中的示例。尽管如此,迭代项之间的JSON流结构中的语法错误仍然会抛出SyntaxError
异常。
解析器效率
时间复杂度总是O(n)
流/文件
TL;DR:内存复杂度是O(2)
JSON Machine一次读取一个流(或一个文件)中的1个JSON项,并一次生成对应的1个PHP项。这是最有效的方法,因为如果你在JSON文件中有10,000个用户,并且想使用json_decode(file_get_contents('big.json'))
来解析它,你将同时将整个字符串和所有10,000个PHP结构加载到内存中。下表显示了差异
这意味着,JSON Machine对于任何大小的处理JSON都是持续有效的。100 GB没问题。
内存中的JSON字符串
TL;DR:内存复杂度是O(n+1)
还有一个方法Items::fromString()
。如果你被迫解析一个大字符串,并且流不可用,JSON Machine可能比json_decode
更好。原因是与json_decode
不同,JSON Machine仍然一次遍历JSON字符串中的一个项,并且不会一次性将所有生成的PHP结构加载到内存中。
让我们继续以10,000个用户为例。这次它们都存储在内存中的字符串中。当使用 json_decode
解析该字符串时,会在内存中创建10,000个数组(对象),然后返回结果。另一方面,JSON Machine为字符串中的每个找到的项目创建单个结构,并将其返回给你。当你处理该项目并迭代到下一个项目时,将创建另一个单个结构。这与处理流/文件的行为相同。以下表格将此概念进行了说明。
实际上,情况甚至更好。Items::fromString
比使用 json_decode
消耗大约 5倍更少的内存。原因在于PHP结构比其对应的JSON表示占用更多的内存。
故障排除
"我仍在得到允许的内存大小 ...耗尽"
可能的原因之一是你想要迭代的项位于某个子键中,例如 "results"
,但你忘记了指定JSON指针。请参阅解析子树。
"那没有帮助"
另一个可能的原因是,你迭代的其中一个项本身非常大,无法一次性解码。例如,你正在迭代用户,其中一个用户有数千个“朋友”对象。可以使用 PassThruDecoder
,它不会解码项,获取用户的json字符串,然后使用 Items::fromString()
逐个迭代解析。
<?php use JsonMachine\Items; use JsonMachine\JsonDecoder\PassThruDecoder; $users = Items::fromFile('users.json', ['decoder' => new PassThruDecoder]); foreach ($users as $user) { foreach (Items::fromString($user, ['pointer' => "/friends"]) as $friend) { // process friends one by one } }
"我还是很不幸"
这可能意味着JSON字符串 $user
本身或其中一些朋友太大,无法放入内存中。然而,你可以尝试递归地这样做。使用 PassThruDecoder
解析 "/friends"
,一次获取一个 $friend
json字符串,然后使用 Items::fromString()
解析它... 如果这仍然不起作用,那么可能还没有通过JSON Machine解决这个问题。计划了一个功能,这将使你能够完全递归地迭代任何结构,并且字符串将被作为流提供。
安装
使用Composer
composer require halaxa/json-machine
不使用Composer
克隆或下载此仓库,并在您的引导文件中添加以下内容
spl_autoload_register(require '/path/to/json-machine/src/autoloader.php');
开发
克隆此仓库。此库支持两种开发方法
- 非容器化(您的机器上已安装PHP和Composer)
- 容器化(您的机器上安装了Docker)
非容器化
在项目目录中运行 composer run -l
,以查看可用的开发脚本。这样,你可以运行构建过程的一些步骤,例如测试。
容器化
安装Docker,并在您的宿主机项目目录中运行 make
,以查看可用的开发工具/命令。您还可以单独运行构建过程的每个步骤,以及一次性运行整个构建过程。Make基本上在后台容器中运行composer开发脚本。
make build
:运行完整构建。相同的命令通过GitHub Actions CI运行。
灵感来源于
: https://github.com/halaxa/json-machine/tree/master#guzzlehttp