COLOPL Tech Blog

コロプラのエンジニアブログです

DataMapperにおけるデータの遅延一括取得の試み

こんにちは。サーバーエンジニアの佐藤です。

前回、N+1問題の解決と処理の分離にまつわる問題点についてこちらの記事で説明させていただきました。

blog.colopl.dev

今回はその続きとして、問題を解決するデータ取得を遅延させた上で一括で行う機能について解説させていただきます。

今回のお話では「N部グラフに着想を得たデータ取得メソッドの再実装」の機能を利用しています。できるだけ前回の記事を読んでいなくても理解できるように解説いたしますが、先に読んでいただけるとより深く理解できるかと思います。

blog.colopl.dev

上記の記事では、DataMapperを使用しているプロジェクトにおいて、Where句に対応するクラスとしてWhere句データクラスを実装することにより、マスターデータおよびユーザデータの両方において、キャッシュの管理を自動化しました。

また「DataMapperを利用した場合におけるN+1問題の解決と処理の分離」で紹介させていただいたように、対象のプロジェクトにはN+1問題の解決と処理分離の両立が困難であるという問題がありました。

これを、Where句データクラスによるキャッシュ機構を利用し、データを遅延一括取得する仕組みを作ることで解決したというのが今回のブログ記事の内容です。

この仕組みはN+1問題を解決するという点で、PHPフレームワークであるLaravelのオブジェクトリレーショナルマッパー(以下ORM)EloquentにおけるEager Loadingに近い機能となっております。

以降では、これらについて詳しく説明していきます。

LazyMapper

トレードオフの打破

前回の「DataMapperを利用した場合におけるN+1問題の解決と処理の分離」では、DataMapperにおけるN+1問題の解決と処理の分離がトレードオフになっており、このトレードオフを打ち壊すものが必要であるとお話しました。

このトレードオフは、Where句データを用いたデータ取得のキャッシュ処理とLazyLoadを組み合わせることで解決可能です。*1

LazyLoadとはデータの取得を実際に必要になるまで遅らせるという考え方です。

LazyLoadの中でも、データ取得メソッドの呼び出しまでクエリ実行を遅らせるValue Holderを参考に実装を行いました。また、値(value)を保持(hold)する役割はDataMapperのキャッシュが担っています。

以下が今回実装したLazyMapperと呼んでいるものを利用したデータ取得時の挙動になっています。

<?php
// QuestsテーブルのDataMapper取得
$questMapper = $this->questMapper;

// Quests.id=1のデータを取得するLazyMapper生成
$lazyQuestMapper1 = new LazyQuestMapper($questMapper, 1);
// Quests.id=2のデータを取得するLazyMapper生成
$lazyQuestMapper2 = new LazyQuestMapper($questMapper, 2);

// [SELECT * FROM Quests WHERE id IN (1,2);]を実行し、Quests.id=1のデータのみを返却
$quest1 = $lazyQuestMapper1->getModel();
// クエリは実行せず、キャッシュからQuests.id=2のデータを返却
$quest2 = $lazyQuestMapper2->getModel();

上のコードで注目していただきたいことは2つあります。

1つは、本来「SELECT * FROM WHERE id = 1」と「SELECT * FROM WHERE id = 2」で2回実行されるクエリが「SELECT * FROM WHERE id IN (1,2)」として実行されることです。

もう1つは、結果がキャッシュに乗るため以降はクエリを実行せずにデータを取得できるということです。

このように個別で書いたWhere句に用いるパラメータが裏で共有され、データ取得時にクエリを束ねて実行することにより、N+1問題の解決と処理分離の両立を達成することができます。

LazyMapper概要

LazyMapperと呼んでいるモジュールは以下の2つのクラスから成り立っています。

  • LazyMapperクラス:データの遅延取得
  • BulkMapperクラス:データの一括取得

LazyMapperと呼んでいるモジュールの中に同名のクラスであるLazyMapperが存在するというのが非常に紛らわしいところです。

BulkMapperは利用者が目にすることがないクラスになっています。そのため、このモジュールは利用者が頻繁に目にするLazyMapperクラスの名前を冠することになりました。

LazyMapperクラス

データの遅延取得を担当するLazyMapperクラスはデータ取得メソッドを備えています。

LazyMapperのインスタンスはDataMapperに対するデータ取得メソッドの1コールに対応しており、コンストラクタはDataMapperとクエリ用パラメータを必要とします。

<?php
// Quests.idが1となるレコードを取得するMapper生成
$lazyQuestMapper = new LazyQuestMapper($questMapper, 1);

LazyMapperのコンストラクタ内ではクエリ用のパラメータからWhere句データを生成します。

こちらのWhere句データは前述のブログにおけるものと全く同様のもので、Where句を表現するクラスです。これによりキャッシュ管理の自動化を行っています。

LazyMapperはメンバ変数としてDataMapperとWhere句データを持ちます。

BulkMapperクラス

データの一括取得を担当するBulkMapperクラスはシングルトンであり、全てのLazyMapperインスタンスにより生成されるWhere句データを蓄積します。

蓄積時には、複数のWhere句データを1つにまとめ上げています。

では、実際にDataMapper、LazyMapper、BulkMapperを組み合わせて、データの遅延一括取得をどのようにして実現しているのか見ていきます。

仕組み

ここでは、Questsテーブルにおいて、「SELECT * FROM Quests WHERE id = 1」と「SELECT * FROM Quests WHERE id = 2」となるデータ取得を遅延させて一括で行うフローを見ていきます。

LazyMapper生成時フロー

LazyMapperインスタンスが生成される際の内部挙動を確認します。

LazyMapper生成時フロー

①データの取得に用いるパラメータであるid:1を用いて、LazyMapperインスタンスを生成します。

②コンストラクタではWhere句データを生成し、基底クラスでBulkMapperへのWhere句データの登録を行います。

③id:2を用いてインスタンスを生成します。

④生成したWhere句データをBulkMapperへ登録します。BulkMapperは$lazyQuestMapper1の「WHERE id = 1」と$lazyQuestMapper2の「WHERE id = 2」を蓄積します。

インスタンス生成時の処理はこれで完了です。

LazyMapperからのデータ取得時フロー

生成されたLazyMapperからデータを取得する際の流れを追ってみましょう。

LazyMapperからのデータ取得時フロー1

④データを取得するためには$lazyQuestMapper1->getModel()を呼び出します。

⑤$lazyQuestMapper1はBulkMapper::fetch()を呼び出します。

⑥BulkMapperは、蓄積されたWhere句データを使い、QuestMapperのデータ取得メソッドを呼び出します。QuestMapperは$lazyQuestMapper1から取得します。

QuestMapperのデータ取得メソッドはキャッシュが存在する場合にはキャッシュから取得し、存在しない場合にはクエリを実行して取得します。

クエリを実行した場合には、取得したデータはキャッシュに保持され、以降のデータ取得においてキャッシュを使いデータを返却できるようになります。

LazyMapperからのデータ取得時フロー2

⑧BulkMapperはデータ取得に利用したWhere句データを自身から削除します。

⑨BulkMapperは、$lazyQuestMapper1からWhere句データの取得を行います。

⑩そのWhere句を用いてQuestMapperのデータ取得メソッドを呼び出します。この際には、クエリは発行されず、QuestMapperはキャッシュからデータを返却します。

⑪、⑫BulkMapperは取得したデータを$lazyQuestMapper1を通じて返します。

LazyMapperからのデータ取得時フロー3

⑬データを取得するため$lazyQuestMapper2->getModel()を呼び出します。

⑭$lazyQuestMapper2はBulkMapper::fetch()を呼び出します。

⑮このときBulkMapperのWhere句データは空になっています。空の場合には、BulkMapperは全ての生成済みLazyMapperインスタンスに対応するデータのキャッシュが完了していると判断し、$lazyQuestMapper2からWhere句データを取得します。

⑯Where句データに対応するデータのみをQuestMapperから取得します。

⑰、⑱BulkMapperは取得したデータを$lazyQuestMapper2を通じて返します。

かなり複雑な内部フローではありますが、LazyMapperのインスタンスを事前に生成することでクエリをまとめることができます。

実装と次の問題

LazyMapperにより取得されるモデルには単体か複数の2種類があり、それぞれに基底クラスを用意しており、これを継承するように実装を行います。

必要とする実装は、コンストラクタで対応するWhere句データを生成するだけです。

これでデータを遅延して一括取得する仕組みができました。

しかし、まだ問題は残っています。それはLazyMapperからリレーションするテーブルでのデータ取得です。

つまりQuestsテーブルからMissionsテーブルへのリレーションがある場合、QuestsをLazyMapperで遅延一括取得できても、Missionsは遅延一括取得できません。

これを行えるようにしたのがRelationalLazyMapperです。

RelationalLazyMapper

概要

RelationalLazyMapperはリレーションシップを持つテーブルのLazyMapperを包含する形を取っています。

LazyMapperとRelationalLazyMapperの関係

RelationalLazyMapperは、リレーション元となるLazyMapper1が一括データ取得を行った際に通知を受け取り、自身の内部にLazyMapper2を生成します。

これにはObserverパターンを用いており、LazyMapperはSubject、RelationalLazyMapperはObserverとなります。

仕組み

LazyMapper、RelationalLazyMapperの生成フロー

LazyMapperとそれに紐付けられたRelationalLazyMapperのインスタンスが生成される流れを見ていきましょう。

LazyMapper、RelationalLazyMapper生成フロー

①LazyMapperを生成します。

②LazyMapperのコンストラクタで、BulkMapperにWhere句データを登録します。

③LazyMapperを引数としてRelationalLazyMapperを生成します。

④RelationalLazyMapperのコンストラクタでは、LazyMapperのデータ取得時に通知を受け取るため、LazyMapperに自身を登録します。

インスタンス生成時の処理は以上になります。

LazyMapperに対するデータ取得フロー

LazyMapperに対するデータ取得時の内部フローを確認します。

LazyMapperに対するデータ取得フロー

①LazyMapperに対してデータ取得メソッドを呼びます。

②BulkMapperのデータ取得メソッドを呼び出し、BulkMapperは蓄積したWhere句データを使いDataMapperからデータ取得を行います。

③BulkMapperはLazyMapperに対応するデータ蓄積したWhere句データから取得したデータの2つを返します。

④LazyMapperが蓄積したWhere句データから取得したデータを受け取った場合には、そのLazyMapperクラスに登録された全てのRelationalLazyMapperインスタンスに対してデータを送ります。こちらの処理は最も複雑になっていますので、後ほど詳細に説明します。

⑤RelatinalLazyMapperは受け取ったデータからLazyMapperを生成します。

⑥RelationalLazyMapper内部のLazyMapperはコンストラクタでBulkMapperにWhere句データを登録します。

⑦データ取得メソッドを呼び出されたLazyMapperはデータを返却します。

この手順によってリレーション先の全てのRelationalLazyMapperインスタンスに対してLazyMapperインスタンスを生成することができます。

RelationalLazyMapper対するデータ取得フロー

残されたRelationalLazyMapperに対するデータ取得時フローを確認します。

RelationalLazyMapperからのデータ取得フロー

その後のRelationalLazyMapperにおけるデータ取得は非常に簡単です。

RelationalLazyMapperが包含するLazyMapperに対してメソッドを呼び、取得したデータを返却するだけとなっています。

また、RelationalLazyMapperのテーブルがさらなるリレーションを持つことも可能です。

その場合には、リレーション元のRelationalLazyMapperへの登録時に、リレーション先のRelationalLazyMapperの参照を持っておき、LazyMapperインスタンス生成時に登録を行うようにします。これでRelationalLazyMapperをリレーションし続けることが可能です。

BulkMapperから一括取得データのRelationalLazyMapperへの転送

先程、LazyMapperにおいて、BulkMapperに蓄積されたWhere句データを使って一括取得したデータを登録されたRelationalLazyMapperに振り分けることについて触れました。

LazyMapperへのRelationalLazyMapper登録処理では、LazyMapperクラスごとに、LazyMapperインスタンスとそのWhere句データ、リレーション先のRelationalLazyMapperのセットを登録しています。

これによりLazyMapperクラスが対応するインスタンスのリレーションを管理することができます。

登録によりインスタンスのリレーション関係を把握するLazyMapperクラス

一括取得されたデータはWhere句データと比較することにより、どのLazyMapperインスタンスによって取得されたものであるか、どのRelationalLazyMapperに渡すべきなのかがわかります。これにより対象データの振り分けが可能になります。

リレーションにおけるレコード数の関係性

LazyMapperには取得するモデルが単数であるLazySingleModelMapperと複数のLazyModelListMapperが存在することは先ほど説明いたしました。

RelationalLazyMapperにも同様に単数の場合と複数の場合が存在します。

より具体的には、リレーション元のLazyMapperで得られるモデル数とリレーション先のRelationalLazyMapperで得られるモデル数の組み合わせが「1対1」、「1対多」、「多対多」の3種類あり、「1対1」が単数、「1対多」と「多対多」が複数となります。

プロジェクトでは、この3種類に対応するRelationalLazyMapperの基底クラスを用意しています。

実例

実装したRelationalLazyMapperを用いて、ユーザのクエストクリア状況からレスポンス用モデルを生成する処理は以下のようになります。

<?php
// UserQuestClearStatusはユーザの単一クエストクリア状況
private function createQuestResponseModel(
    UserQuestClearStatus $userQuestClearStatus
): QuestResponseModel {
    $questMapper = $this->questMapper;
    $subMissionMapper = $this->subMissionMapper;

    // 単一Questsに対するLazyMapper生成
    $lazyQuestMapper = new LazyQuestMapper($questMapper, $userQuestClearStatus->getQuestId());
    // 単一Questsに対する複数SubMissionsを取得するLazyMapper生成
    $relationalLazySubMissionsMapper = new RelationalLazySubMissionsMapper($subMissionMapper, $lazyQuestMapper);

    // レスポンス生成メソッドを持つレスポンスモデル生成
    return new QuestResponseModel(
        $userQuestClearStatus,
        $lazyQuestMapper,
        $relationalLazySubMissionsMapper
    );
}

このQuestResponseModelクラスはレスポンス生成メソッドを持ち、メソッド内でLazyMapperからデータを取得します。

createQuestResponseModel()で全てのクエストクリア状況のレスポンス用モデルを生成した上で、QuestResponseModelからレスポンス生成メソッドを呼び出すことで、QuestsとSubMissionsに対するクエリを計2回にまとめることができます。

まとめ

個別に処理を書いても裏でクエリが束ねられることにより、N+1問題の解決と処理の分離が容易になりました。

ただし、インスタンスを事前に生成しきらなくてはならないという制約はあります。

これにより、「特定クエストのクリア」や「特定アイテムの入手」といった条件判定を1つのクラスにまとめ、凝縮度を上げることで可読性や再利用性を高めることが可能となっています。

N+1問題が原因としてデータベースへのアクセスが増え、パフォーマンスが下がる問題が頻繁に起きていましたが、LazyMapperを利用するようになってから見られなくなりました。

このLazyMapperを利用して再利用可能な条件判定モジュールの開発も行っています。機会があればお話できるかもしれません。

以上で、今回のLazyMapperの話とさせていただきます。

2つの記事に跨り非常に長くなりましたが、お読みいただきありがとうございました!