Doctrine\ORMでの巨大なオブジェクトの集合の扱い

  • 8
    いいね
  • 0
    コメント

今年はあまりSymfonyに触れなかったのですが、年末になり再びSymfonyのプロジェクトに関わる事になり、その際表題の点を改善したのでご紹介します。

※ORMの話です。ODMは対象外ですのでご了承下さい。

おさらい

Doctrine\ORMではDBAL\Statementの結果セットを特定のクラスのインスタンスにマップする事を "Hydration" と呼びます。
一度オブジェクトがHydrateされると、EntityManagerはその参照を(#detach($object)、あるいは#clear()するまで)保持し続けます。当然、Hydrateされるオブジェクトが増えるとその分メモリ使用量も増加するので注意が必要です。

普通のHTTPリクエスト上では〜数十個、多くてもせいぜい百数個のオブジェクトしか扱わないので気にする必要はありませんが、バッチ処理等時間に余裕があるケースでは数千、あるいは数万のオブジェクトを扱う事があります。(そんな処理にORM使う事の是非についてはここでは問いません)
従って、どうメモリを管理していくかが重要なのですが、これはドキュメントにも書いてある通り、ORM\Query#iterate()を使う方法で概ね問題ないです。このメソッドは#execute()#getResult()のように結果セットの全てを一度にHydrateする代わりに、トラバース毎にHydrateするイテレータを作ります。

実際の使用例はこんな感じです:

$usersQuery = $em->createQuery('SELECT u FROM Acme\User u');

foreach ($usersQuery->iterate() as $counter => list($user)) { // 渡ってくる値はarrayでラップされているのでアンラップする必要がある
    /** @var Acme\User $user */

    // ...

    if (($counter + 1) % 1000 === 0) {
        $em->clear(); // 1000回トラバースする毎にEntityManagerのオブジェクトを開放する
    }
}

これにより、ユーザーの数がどんなに多くなっても安心です。便利ですね。因みに、detachは重い上に将来削除されるかもしれないので特別な理由がなければclearの方が無難です。

陥いるアンチパターン

以下は、Query#iterate()の強力さを味わってしまった僕が実際にやってしまったアンチパターンの例です:

class UserRepository
{
    /** @var EntityManagerInterface */
    private $em;

    // ...

    public function createFindQueryBy($criteria): Query
    {
        $qb = $this->em->createQueryBuilder()->from(Acme\User::class, 'u')->select('u');

        // $criteriaの内容に応じて$qbを加工するコードがある

        return $qb->getQuery();
    }
}

検索条件である $criteria にマッチするユーザーを取得するクエリを取得するメソッドです。
もう名前からして糞メソッド感が漂ってます。全然リポジトリ感がありません。

では何故このような実装にしたのでしょうか。このメソッドを作るにあたって、次のような背景がありました:

  • 取得したリストはページングして一部分だけ処理する事がある
    • 但し条件に一致した全体の件数は取りたい
  • ページングはせず、一度に全件処理する事もある
  • 同じロジック(検索機能)を使いまわせる機能が複数あった

技術的にもう少し噛み砕いてみると次のようになります:

  • ページングが必要なケースでは、範囲を指定しての取得と、全体の件数をカウントできる必要がある
    • 範囲指定はQuery#setFirstResult(int)Query#setMaxResults(int)を使う事で実現が可能
    • 件数のカウントははQueryが生成したSQLを加工してCOUNTクエリを作成する事で実現が可能
      • 実際には後述するPaginatorクラスを使用して実現
  • 全件処理対象の場合はHydrateする件数が不明確なので、Query#iterate()を使う必要がある

つまり、どちらもQueryを使う事によって満たす事ができるのです。特に、範囲指定は毎回したい訳ではないので、Queryを直接返した方が汎用性が高いと考えました。

以上が、上記のようなゴミメソッドを作ってしまった原因です。僕が未熟なだけだったんですが^^;そもそもEntityRepositoryがQueryに関するメソッドを外部に公開しているのが悪い

どのように解決したか

その前に、今一度メソッドの問題点を考えてみます。

原点に帰ると、本当にやりたかった事は、「検索条件にマッチするユーザーのリストが欲しい」と言う事になります。なので、メソッドのシグネチャは次のような物が適切と言えるでしょう:

/**
 * @param $criteria
 * @return Acme\User[]
 */
public function findBy($criteria): iterable;

従って、Queryにやらせていた事は全てメソッドの中で完結させます。まず、何件のユーザーが対象になるか分からないので、Hydrateはオンデマンドで行います:

public function findBy($criteria): iterable
{
    $qb = $this->em->createQueryBuilder()->from(Acme\User::class, 'u')->select('u');

    // $criteriaの内容に応じて$qbを加工するコードがある

    foreach ($qb->getQuery()->iterate() as $counter => list($user)) {
        yield $user;
    }
}

次に、必要に応じてページングできるようするにするには、Countable、かつslice可能である必要があるので、次のようなイテレータを作ってみました。

<?php

declare(strict_types=1);

namespace Acme\Doctrine\ORM;

use Doctrine\ORM\Query;
use Doctrine\ORM\Tools\Pagination\Paginator;

class SliceableQueryResult implements \Countable, \IteratorAggregate
{
    /**
     * @var Paginator
     */
    protected $paginator;

    public function __construct(Query $query)
    {
        $this->paginator = new Paginator($query);
    }

    /**
     * {@inheritdoc}
     */
    public function count()
    {
        return $this->paginator->count();
    }

    /**
     * Returns the sliced result.
     *
     * @param int $offset
     * @param int $length
     *
     * @return iterable
     */
    public function slice(int $offset, int $length): iterable
    {
        $this->paginator->getQuery()
            ->setFirstResult($offset)
            ->setMaxResults($length)
        ;

        return $this->paginator->getIterator();
    }

    /**
     * {@inheritdoc}
     */
    public function getIterator()
    {
        $query = $this->paginator->getQuery()
            ->setFirstResult(null)
            ->setMaxResults(null)
        ;

        foreach ($query->iterate() as list($object)) {
            yield $object;
        }
    }
}

PaginatorはORM標準のページングユーティリティで、CountableかつTraversableです。しかも、countに必要な複雑なクエリ(toManyな結合があっても正しい件数が取れる)も勝手に作って実行してくれる優れものです。
※Queryは都度cloneして使ったほうが良さ気に見えますが、clone毎にパラメータとヒントがリセットされる仕様な為やむなくこうしています。(実際にはPaginator側でパラメータとヒントごとcloneされるので問題はありません)

尚、コンストラクタで渡すQueryのDQLでtoManyを結合している場合は、それをSELECTしていたり、DISTINCTを明示していない場合は#iterate()例外を投げるので、そのようなQueryを渡す事はできません。
まぁ、巨大なコレクションでtoManyをEAGERロードする事自体間違ってるのでそこら辺は割り切っています。

これを#findBy($criteria)に組み込むとこのようになります:

/**
 * @param $criteria
 * @return Acme\User[]|SliceableQueryResult
 */
public function findBy($criteria): iterable
{
    $qb = $this->em->createQueryBuilder()->from(Acme\User::class, 'u')->select('u');

    // $criteriaの内容に応じて$qbを加工するコードがある
    // 必要に応じて$qb->distinct(true);をする

    return new SliceableQueryResult($qb->getQuery());
}

メソッドの呼び出しは次のようになります:

// ユーザー数は1000件とする

$users = $userRepository->findBy($criteria);
count($users); // =1000

$counter = 0;
foreach ($allUsers as $user) {
    // ...

    // 100件毎にメモリが解放される
    if (++$counter % 100 === 0) {
        $em->clear();
    }
}

// Sliceして使う場合
$slicedUsers = $userRepository->findBy($criteria)->slice(900, 100); // OFFSET 900 LIMIT 100 を取得

以上です。これにより、リポジトリのメソッドが適切なシグネチャを持ちつつ再利用性も確保する事ができました。

勿論全てのケースで柔軟に使える訳ではないのでまだまだ改良の余地がありますが(例:#slice()の戻り値がSliceableQueryResultでない等)、参考になれば幸いです。
尚、上記の例ではPaginatorのfetchJoinCollectionuseOutputWalkerについて考慮していませんが、必要に応じて追加してみて下さい。

それでは皆さんMerry Christmas Eve!🎅🎄🎁✨
良いお年を。

この投稿は Symfony Advent Calendar 201624日目の記事です。