malef/associate

此库允许通过关联收集对象,并为 Doctrine ORM 提供实体加载优化,以解决 N+1 查询问题。

v0.2.0 2020-06-20 16:02 UTC

This package is auto-updated.

Last update: 2024-09-21 01:47:34 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 malef/associate

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

要获取实体管理器的 EntityLoader 实例,您可以使用库提供的门面。对于更复杂的情况,您还需要 AssociationTreeBuilder 实例,可以使用 new 或门面进行实例化。

use Malef\Associate\DoctrineOrm\Facade;
use Malef\Associate\DoctrineOrm\Association\AssociationTreeBuilder;

$facade = new Facade($entityManager);

$entityLoader = $facade->createEntityLoader();

$associationTreeBuilder1 = $facade->createAssociationTreeBuilder();
$associationTreeBuilder2 = new AssociationTreeBuilder();

您还可以使用这些类来定义适用于您所选择的框架的 DI 容器的服务。

这就完成了!现在您可以开始了!

使用示例

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

原因

假设我们正在使用 doctrine/orm 构建一个电子商务网站。我们将遇到的一个问题是 N+1 查询问题。它发生在我们从数据库中获取一些实体,然后尝试通过获取器遍历它们的关联(例如,在序列化期间)。

以一个例子来说明,我们可能有一些需要列出的产品。每个产品都有一些变体,我们还需要显示这些变体。如果我们简单地将这组产品提供给我们的模板(或序列化器,如果我们提供某些 API),则在第一次尝试访问由 Doctrine ORM 管理的相应 PersistentCollection 时,将分别获取每个产品的变体。虽然这可以正常工作,但它将产生每个 Product 实例的一个 SELECT 查询。因此,如果我们以这种方式列出 100 个产品,我们将执行 101 个数据库查询,并且如果需要跟踪更多关系,这个数字还会增加。

一些 ORM 正在解决这个问题的某些基本案例。例如,在 Eloquent ORM 中,您可以使用 延迟 eager 加载。尽管如此,它仍然仅限于一次遍历一个关系。遗憾的是,Doctrine ORM 并没有提供类似的助手。

您可以在 Benjamin Eberlei 撰写的 5 Doctrine ORM 性能陷阱,您应该避免 中了解更多关于此问题的信息 - 请参阅标题为 Lazy-Loading 和 N+1 查询 的部分。其中指出了四种解决此问题的方法。

预加载(解决方案 3)可能是最简单的方法,但在许多情况下我们发现它太僵硬了。问题在于,它将始终加载相关实体,而通常我们只需要在少数特定情况下访问它们。

其他解决方案更加灵活,例如使用专门的DQL查询(方案1)或收集实体标识符后触发实体预加载(方案2)。然而,这些方案会导致代码繁琐,并且需要根据给定的关联是“一对一”还是“一对多”类型,以及已初始化的实体位于关联的逆向或拥有方进行调整。此外,我们有时需要联合其他实体进行过滤,我们不能简单地在一个查询中获取所有所需内容,因为需要由Doctrine ORM进行填充的结果集会变得过大。最后,如果已经初始化了一些\Doctrine\Common\Persistence\Proxy实例或\Doctrine\ORM\PersistentCollection实例,则可以应用一些微小的优化,从而跳过它们。

这个库试图以干净和封装的方式实现方案1和2,使其容易在多个场景中使用。

基本用法

在上面的示例中,只需在之前给出的代码之前添加以下内容

use Malef\Associate\DoctrineOrm\Facade;

$facade = new Facade($entityManager);

$entityLoader = $facade->createEntityLoader();
$entityLoader->load($products, 'variants', Product::class);

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

可能的输入参数

Malef\Associate\DoctrineOrm\Loader\EntityLoader::load方法接受以下参数

  • $entities - 包含根实体的iterable实例,加载器应为其加载关联;例如,可以是普通的array或Doctrine的Collection

  • $associations - 包含一个或多个按顺序连接的关系名称的点分隔字符串(例如'profile''order.item.product.variant');或包含一个或多个关系名称的array(例如['profile']['order', 'item', 'product', 'variant']);或Malef\Associate\DoctrineOrm\Association\AssociationTree的实例(有关如何使用它的详细信息,请参阅下一节);只有最后一种情况支持在跟随关系时向多个方向分支;

  • $entityClass - 可选;包含包含在第一个数组中的实体的类名;如果没有给出,则实体加载器将尝试自动检测它;所有实体都需要共享单个实体类(或使用Doctrine继承功能的情况下的超类);

类似于EntityLoader::load的方法的输入参数(如Malef\Associate\DoctrineOrm\Loader\DeferredEntityLoader::createDeferredMalef\Associate\DoctrineOrm\Loader\DeferredEntityLoaderFactory::create)接受类似的参数。

加载多个关系

如果使用点分隔的字符串或字符串数组作为$associations参数,则可以按顺序加载多个关联的实体。假设我们有一个Product实体,它有许多Variant,这些变体又有多个可用的Offer,我们可以使用以下值作为$associations

  • 'variants.offers',

  • ['variants', 'offers'],

  • 按如下方式构建的Malef\Associate\DoctrineOrm\Association\AssociationTree的实例

    $associationTree = $associationTreeBuilder
        ->associate('variants')
        ->associate('offers')
        ->create();

这将允许我们稍后使用如$product->getVariants()$product->getVariants()->getOffers()等方法,而不会产生任何额外的查询。

如果我们想跟随多个非顺序关联(即它们在某些方面分支成多个路径),我们唯一的选项是使用Malef\Associate\DoctrineOrm\Association\AssociationTree。假设对于Offer实体,我们有一个Seller和多个Bidder,我们可以使用以下代码

$associationTree = $associationTreeBuilder
    ->associate('variants')
    ->associate('offers')
    ->diverge()
        ->associate('seller')
    ->endDiverge()
    ->diverge()
        ->associate('bidders')
    ->endDiverge()
    ->create();

这样我们也可以调用$product->getVariants()->getOffers()->getSeller()$product->getVariants()->getOffers()->getBidders(),而不会产生任何额外的查询。

分块

如果产品或相关实体的数量很多,则它们将被分块处理,并且每个块的关系将单独加载。块大小默认设置为 1000,但您可以自由更改它,或者将其设置为 null 以禁用分块。

限制

重要! 从反向开始时,不可能减少一对一关联的查询数量 - Doctrine ORM 默认为每个实体发出单独的 SELECT 来加载它们。为了解决这个问题,您可以考虑将此类关联更改为一对一(并在之后使用此库),或者如果可能,使用可嵌入的关联(在这种情况下,嵌入的实体将与包含它们的实体使用相同的查询加载)。

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

如果您正在使用 Doctrine ORM 的项目并为其提供 GraphQL API,则此库可以很好地与 webonyx/graphql-php 提供的 Deferred 类一起使用。您可以在其文档的 解决 N+1 问题 部分了解更多关于此方法背后的基本概念。

假设我们需要实现一个 resolve 函数,该函数将返回 Variant 实例以用于 Product 实例。基本实现可能如下所示

$resolve = function(Product $product) {
    return $product->getVariants()->getValues();
};

但是,使用这种方法,我们最终仍然会对数据库执行 N+1 次查询。为了减轻这个问题,并有效地加载这些对象,我们可以使用 DeferredEntityLoader 的实例,如下所示

use Malef\Associate\DoctrineOrm\Facade;

$facade = new Facade($entityManager);

$deferredEntityLoader = $facade
    ->createDeferredEntityLoaderFactory()
    ->create('variants', Product::class);

$resolve = function(Product $product) use ($deferredEntityLoader) {
    return $deferredEntityLoader->createDeferred(
        [$product],
        function() use ($product) {
            return $product->getVariants()->getValues();
        }
    );
};

Et voilà!在此处,DeferredEntityLoader 将在 GraphQL 查询结果构建的同时累积所有实体。当 GraphQL 库尝试解决我们在 resolve 函数中返回的 Deferred 时,收集器将使用 EntityLoader 根据之前提供的关联树尽可能有效地加载所有实体。