xsolve-pl/associate

该库允许通过关联收集对象和值,并为Doctrine ORM提供一些实体获取优化,以解决N+1查询问题。

v1.0.2 2023-01-19 13:49 UTC

README

Build Status Scrutinizer Code Quality Latest Stable Version Total Downloads Monthly Downloads License

目录

简介

该库允许通过关联收集对象和值,并为Doctrine ORM提供一些实体获取优化,以解决N+1查询问题。

它可以与来自 webonyx/graphql-phpDeferred 实现配合使用,从而显著减少数据库查询次数。

许可

此包受MIT许可协议保护。请参阅 LICENSE 文件中的完整许可协议。

入门

使用以下方式将此包包含到您的项目中(假设已全局安装Composer)

$ composer require xsolve-pl/associate

有关Composer的更多信息,请参阅其简介

要获取基本收集器,您可以使用库提供的门面

<?php
$facade = new \Xsolve\Associate\Facade();
$basicCollector = $facade->getBasicCollector();

如果您想使用针对Doctrine ORM优化的收集器,在实例化门面时提供适当的实体管理器,并获取专用收集器

<?php
$facade = new \Xsolve\Associate\Facade($entityManager);
$doctrineOrmCollector = $facade->getDoctrineOrmCollector();

您还可以使用此库提供的构建块创建自己的收集器。还可以用框架使用的DI容器的一些配置替换提供的门面。

这就完了 - 现在您可以开始使用了!

使用示例

收集关联对象和值

此库提供的第一项功能是允许从一些基本对象开始检索通过指定关联可以到达的所有对象。

让我们假设我们定义了以下类

<?php

class Car
{
    /**
     * @var Engine|null
     */
    protected $engine;

    /**
     * @param Engine $engine
     */
    public function __construct(Engine $engine = null)
    {
        $this->engine = $engine;
    }

    /**
     * @return Engine|null
     */
    public function getEngine()
    {
        return $this->engine;
    }
}

class Engine
{
    /**
     * @var Part[]
     */
    public $parts;

    /**
     * @param Part[] $parts
     */
    public function __construct(array $parts)
    {
        $this->parts = $parts;
    }
}

class Part
{
    /**
     * @var string
     */
    protected $name;

    /**
     * @param string $name
     */
    public function __construct(string $name)
    {
        $this->name = $name;
    }

    /**
     * @return string
     */
    public function getName(): string
    {
        return $this->name;
    }

    /**
     * @return string[]
     */
    public function getNameAsWords(): array
    {
        return explode(' ', $this->name);
    }

    /**
     * @return array
     */
    public function getNameStats(): array
    {
        return ['wordCount' => count($this->getNameAsWords())];
    }
}

现在假设在 $cars 数组中有一些 Car 类的实例以及一些关联对象

<?php
$cars = [
    $sportCar = new Car(
        $fastEngine = new Engine([
            $valve = new Part('valve'),
            $cylinder= new Part('cylinder'),
        ])
    ),
    $sedan = new Car(
        $turboEngine = new Engine([
            $valve,
            $sparkPlug = new Part('nano spark plug'),
            $smartCylinder = new Part('smart cylinder'),
        ])
    ),
    $suv = new Car(),
];

现在我们想收集与 $cars 相关的所有 Engine 实例。就像这样简单

<?php
$engines = $basicCollector->collect($cars, ['engine']);
// $engines ~= [$fastEngine, $turboEngine]; - order is not guaranteed.

重要!请注意,$engines 的顺序没有保证。这是因为内部使用了 \SplObjectStorage 来确保收集对象的唯一性。

我们可以更进一步,通过这样做来收集距离 $cars 两个关联的对象

<?php
$parts = $basicCollector->collect($cars, ['engine', 'parts']);
// $parts ~= [$valve, $cylinder, $sparkPlug, $smartCylinder]; - order is not guaranteed.

请注意,只有 $valve 会被包括一次,因为它会被检测到与 $fastEngine$turboEngine 关联的是同一个对象。

还可以收集标量值,但在此情况下不会对它们施加唯一性约束

<?php
$names = $basicCollector->collect($cars, ['engine', 'parts', 'name']);
// $names ~= ['valve', 'cylinder', 'spark plug', 'smart cylinder']; - order is not guaranteed.

如果给定的关联产生一个从 0 开始的具有顺序数字索引的数组,则自动假定它是一个对象或标量的集合(即关联将给定对象链接到多个对象或标量)。因此可以编写

<?php
$words = $basicCollector->collect($cars, ['engine', 'parts', 'nameAsWords']);
// $words ~= [
//     'valve', 'cylinder', 'nano', 'spark', 'plug', 'smart', 'cylinder'
// ]; - order is not guaranteed.

这次,cylinder 出现了两次,因为它是一个标量值,且没有施加唯一性约束。

但是,如果数组是关联的,则在收集值时可以更进一步

<?php
$wordCounts = $basicCollector->collect($cars, ['engine', 'parts', 'nameStats', '[wordCount]']);
// $wordCounts ~= [1, 1, 3, 2]; - order is not guaranteed.

内部使用 symfony/property-access 来跟踪关联,因此它们可以通过不同的方式访问 - 例如作为公共属性或通过getter方法。请参阅其文档以了解可能的选项。

高效加载关联实体并解决N+1查询问题

假设我们正在使用 doctrine/orm 来构建一个电子商务网站。我们可能会遇到的一个问题是 N+1 查询问题,它发生在我们从数据库中获取一些实体并尝试通过获取器遍历它们的关联时。

例如,我们可以有一些产品。每个产品都有一些变体,这些变体又有一个属性来存储可用的库存数量。现在我们想找出哪些产品可供销售,并且我们已经从数据库中加载了 Product 实例(例如,在考虑用户应用的某些过滤器之后)。我们可以使用以下代码

<?php
$availableProducts = array_filter(
    $products,
    function(Product $product) {
        foreach ($product->getVariants() as $variant) {
            if ($variant->getInventoryQuantity() > 0) {
                return true;
            }
        }

        return false;
    }
);

虽然这样会工作得很好,但每次我们第一次在给定的 Variant 实例上调用 getVariants 方法时,都会执行一个 SELECT 查询。因此,如果我们想检查 100 个产品的可用性,我们将最终执行 101 个数据库查询。

您可以在 Benjamin Eberlei 撰写的 5 Doctrine ORM Performance Traps You Should Avoid 中了解更多关于这个问题,请参阅标题为 Lazy-Loading and N+1 Queries 的部分。那里指出了四种处理此问题的方法。

在许多情况下,预加载(解决方案 3)可能是最简单的方法,但它可能过于严格。可能的情况是,我们并不希望始终加载特定的关联,而只是在某些情况下加载。

其他解决方案更灵活,例如使用专门的 DQL 查询(解决方案 1)或收集实体标识符后触发实体的预加载(解决方案 2)。

然而,这些解决方案会导致代码变得复杂,并且必须根据给定的关联是 -to-one 还是 -to-many 类型以及实体是否在关联的相反或拥有方进行调整。此外,如果已经初始化了一些 \Doctrine\Common\Persistence\Proxy 实例或 \Doctrine\ORM\PersistentCollection 实例,则可以应用一些小的优化。

这个库试图以干净和封装的方式实现解决方案 1 和 2 中提出的建议。感谢它,加载关联实体变得简单且易于应用。在上述示例中,只需在之前给出的代码之前添加以下内容

<?php
$facade = new \Xsolve\Associate\Facade($entityManager);
$doctrineOrmCollector = $facade->getDoctrineOrmCollector();
$doctrineOrmCollector->collect($products, ['variants']);

执行此代码片段后,给定产品的所有变体将使用单个 SELECT 查询加载,并且调用 getVariants 不会导致任何额外的查询。

如果产品或关联实体的数量很高,它们将被分成块,并且每个块的关联将分别加载。块大小默认为 1000,但您可以自由更改它或将它设置为 null 以禁用分块。

也可以用这种方式收集属性值。如果每个变体都有一个包含其价格的属性,并且我们想收集所有给定产品的所有变体的价格,我们可以执行以下代码

<?php
$facade = new \Xsolve\Associate\Facade($entityManager);
$doctrineOrmCollector = $facade->getDoctrineOrmCollector();
$prices = $doctrineOrmCollector->collect($products, ['variants', 'price']);

就这么简单!

重要! 从反向开始,您无法减少一对一关联的查询数量 - Doctrine ORM 默认为每个实体发出一个单独的 SELECT。您可以考虑将此类关联更改为一对一(然后使用收集器)或尽可能使用可嵌入的(在这种情况下,嵌入实体将与包含它们的实体一起加载)。

延迟关联遍历以批量加载实体

如果您正在使用Doctrine ORM进行项目开发并提供了GraphQL API,那么这个库可以很好地与webonyx/graphql-php提供的Deferred类协同工作。您可以在其文档的解决N+1问题部分了解更多关于这种方法的总体思路。

假设我们需要实现一个返回Product实例的Variant实例的resolve函数。基本实现可能如下所示

<?php
$resolve = function(Product $product) {
    return $product->getVariants();
};

但使用这种方法,我们又会执行N+1个查询。为了解决这个问题并有效地加载这些对象,我们可以使用BufferedCollector的实例,如下所示

<?php
$facade = new \Xsolve\Associate\Facade($entityManager);
$bufferedCollector = $facade->getBufferedDoctrineOrmCollector();

$resolve = function(Product $product) use ($bufferedCollector) {
    $bufferedCollectClosure = $bufferedCollector->createCollectClosure([$product], ['variants']);

    return new \GraphQL\Deferred(function() use ($bufferedCollectClosure) {
        return $bufferedCollectClosure();
    });
};

Et voilà!BufferedCollector将累积所有收集任务,在查询结果构建的过程中按顺序进行。当GraphQL库尝试解析在resolve函数中返回的Deferred时,收集器将根据基础对象类和关联路径将之前存储的类似工作分组,并将所有这些工作批量加载,只发出1个SELECT查询(或1个查询块,如果基础实体数量很高,如上所述)。因此,我们最终只会执行2个查询,而不是101个。