luminouslabs/json-machine

高效、易用且快速的JSON提取解析器

dev-main 2023-08-08 11:46 UTC

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 - truefalse,用于启用或禁用调试模式。当启用调试模式时,在解析或异常期间可以获取有关行、列和文档中的位置的数据。禁用调试模式会略微提高性能。

解析来自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-jsonPHP扩展存在,因为它使用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');

开发

克隆此仓库。此库支持两种开发方法

  1. 非容器化(您的机器上已安装PHP和Composer)
  2. 容器化(您的机器上安装了Docker)

非容器化

在项目目录中运行 composer run -l,以查看可用的开发脚本。这样,你可以运行构建过程的一些步骤,例如测试。

容器化

安装Docker,并在您的宿主机项目目录中运行 make,以查看可用的开发工具/命令。您还可以单独运行构建过程的每个步骤,以及一次性运行整个构建过程。Make基本上在后台容器中运行composer开发脚本。

make build:运行完整构建。相同的命令通过GitHub Actions CI运行。

灵感来源于 : https://github.com/halaxa/json-machine/tree/master#guzzlehttp