COLOPL Tech Blog

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

再利用可能かつ効率的なデータ取得を持つ条件判定モジュールの開発

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

前回、データの遅延一括取得を可能とするLazyMapperについてお話いたしました。
blog.colopl.dev

この記事の最後でLazyMapperを利用して再利用可能な条件判定モジュールについて触れさせていただきました。

今回はそちらのお話をします。


ゲームにおいては「特定クエストクリア」や「特定アイテム所持」といった条件判定を行うことがよくあります。担当するプロジェクトではその条件判定が機能単位で独自に実装されていました。

判定ロジックは同一であっても、その実装は機能により異なり、他の機能で同様の判定が必要になった際にも都度実装していました。

これは非常に手間ですし、都度の実装によりミスも発生します。

また、判定が機能と密に結合しており、メンテナンスが困難になった機能も存在しました。

これらの問題を解決するため、判定ごとにクラスを用意し、機能ごとの関心と条件判定を分離するように設計することで多くの機能で利用可能な条件判定モジュールを実現することに成功しました。

以下では、こちらを具体的に説明していきます。

問題

対象となるプロジェクトでは数多くの機能において条件判定を行っていました。

例えば「イベントの表示非表示」、「交換可能なアイテムの表示」、「特定コンテンツでの表示される文言の変更」、「表示するアセット*1の切り替え」といったものです。

このような機能は定期的に生み出されており、自身でも何度も実装を行いました。

これらの機能は条件の継ぎ足しに耐えられる設計になっていないことも多く、複雑なロジックを持つようになり、リファクタリングを行うこともありました。

これらの経験からより楽に実装および保守を行うことができないかと考え、解決すべき問題を列挙しました。

  • 再利用性のなさ
  • 画一性のない条件のパース処理
  • 適切に分離されない条件判定処理
  • 条件判定と問題解決の結合

これらについて具体的に説明します。

再利用性のなさ

同種の条件判定がすでに存在していても、機能ごとに実装が必要であり、非常に手間がかかります。

また、都度の実装によりバグが発生する可能性もあります。

画一性のない条件のパース処理

条件判定に必要なパラメータはマスターデータ*2に設定する必要がありますが、そのパラメータのフォーマットはテーブルによってマチマチです。

複数のカラムにデータを入れているものもあれば、カンマ区切りの文字列として入れているものもあります。

フォーマットにブレがあるため、都度パースし、パラメータとして取り出す処理を書かなくてはなりません。

既存のものに関しては修正は難しいですが、新機能においては共通のフォーマットを利用するようにして、都度のパーサーの実装を不要とし、データ設定を行いやすくしたいところです。

また、パースと条件判定が密に結合しており、再利用できない一因となっていました。

適切に分離されない条件判定処理

N+1問題により、データの取得効率が落ちるのを避けることで、条件判定に必要な一連の処理を条件判定の種類ごとのクラスに収めることが困難になっていました。

このことが原因で、コードの可読性が低下し、保守や不具合の特定が困難になっていました。

N+1問題の解決と条件判定処理を1つのクラスへ収めることを両立させる必要があります。

条件判定と問題解決の結合

条件判定と「条件と満たすもの1つを取得」や「条件を満たすもの全て取得」といった機能ごとの問題の解決が結合してしまっていました。

これにより、AND条件やOR条件といった再帰的な条件判定を作ることが難しく、複雑な処理になってしまいます。

対象とする機能が解決したい問題が複雑な場合には、それを解決する処理を記述することも難しくなります。

これらの問題をCheckConditionStrategyというモジュールを作ることにより解決することができました。

CheckConditionStrategy

CheckConditionStrategyは複数の役割の異なるクラスにより構成されており、条件による切り替え機能の新規追加や既存機能の合流を可能としています。

以下はその構成図です。

CheckConditionStrategy構成図

責任を細かく分離しているため構成要素が非常に多くなっています。

ここで注目してほしいのは、今回の話の中心となり、単一の条件判定に責任を持つStrategyです。

Strategyはマスターデータ*3であるMasterから得た条件判定用のパラメータParamを使い対象のユーザデータを取得し、判定を行います。

Judgeableは、StrategyInstantiater*4を使ってMasterが持つ条件判定種別であるEnumからStrategyを生成します。生成したStrategyを使って条件判定を行い、条件を満たした場合にMasterから必要なデータを返します。

ResolverはJudgeableから受け取ったデータをまとめて処理します。例えば、ある機能では「初めに条件を満たしたデータを返す」、また別の機能では「条件判定を満たしたJudgeableの持つデータを集計して返す」といった具合です。

このパターンに当てはめることで、誰でも簡潔な機能実装を行うことができます。

以降では、一部の構成要素について具体的に説明していきます。

Strategy

Strategy

Strategyは単一の条件判定に責任を持ち、対象の条件判定に必要となるパラメータの取得、そのパラメータを用いたデータの取得、データを利用した条件判定を行います。

「特定クエストをクリアする」という条件判定を行うStrategyでは、以下のような処理を行います。

<?php
class QuestClearStrategy extends Strategy 
{
    // データ遅延一括取得用のLazyMapper(※後ほど説明)
    private LazyUserQuestClearStatusMapper $lazyUserQuestClearStatusMapper;

    public function __construct(
        int $userId,  // ユーザ固有のID
        Master $master // 対象マスターデータ
    )  {
        // 条件値のパースをQuestIdParamに委ねる(※後ほど説明)
        $conditionParam = new QuestIdParam($master);

        // 遅延一括データ取得用のLazyMapper生成(ユーザのクエストクリア情報取得)
        $this->lazyUserQuestClearStatusMapper = new LazyUserQuestClearStatusMapper(
            $this->userQuestClearStatusMapper, // mapper取得処理は都合上省略します
            $userId,
            $conditionParam->getQuestId(); // QuestIdParamからクエストID取得
        );
    }

    /**
     * 条件判定メソッド
     */
    public function canMeetCondition(): bool
    {
        // LazyMapperからユーザデータ取得
        $userQuestClearStatus = $this->lazyUserQuestClearStatusMapper->getModel();
        return $userQuestClearStatus !== null;  // 対応データが存在すればクリア済み
    }
}

コード内に登場するLazyUserQuestClearStatus、QuestIdParamはそれぞれLazyMapperとParamに対応しています。こちらは後ほど説明します。

Param

Param

Paramは条件判定のために必要なデータのパースに責任があります。

Paramはフォーマットが異なる既存機能でStrategyを利用するために用意されたクラスです。

「特定クエストクリア」といった条件判定には、対象のクエストのIDが必要になります。ParamはこういったパラメータをMasterから取り出します。

Paramの内部では、対象の機能のMasterごとに利用するパース処理が書かれており、フォーマットが異なる既存機能でCheckConditionStrategyを利用する際にはパース処理を追加します。

また、パース画一化のための標準的なパース処理も用意しています。新機能ではこちらを利用します。

Paramは条件ごとにクラスが用意されているため、ある機能で条件ごとにフォーマットが大きく異なるケースにおいても、その違いを吸収することができます。

LazyMapper

LazyMapper

LazyMapperはデータを遅延一括取得することに責任を持ちます。

Paramから取得したパラメータを使って、判定に必要なデータを取得します。

LazyMapperインスタンスは1つのクエリに対応しており、インスタンスからデータを取得する際には、生成済みの全インスタンスのクエリを裏で束ねて実行することによりN+1問題を解決します。

これにより、条件判定をStrategyクラスに分離しながら、N+1問題を解決可能です。

Judgeable

Judgeable

1レコードに対応する条件判定と判定後対応に責任を持ちます。

内部にはStrategyとMasterを持っており、条件判定はStrategy、その後の処理はMasterを使って行います。

その後の処理は大抵がMasterに設定されたデータを返すといったものです。

Resolver

Resolver

Resolverは複数のJudgeable間での問題解決を行います。

ここでいう問題解決とは「条件を最初に満たしているものを選ぶ」や「条件を満たしているもの全ての中から抽選で1つ選ぶ」といったものです。

これらの問題は機能によって異なります。

具体的には、Resolver内にJudgeableを使って問題を解く処理を実装します。

例えば、「条件を最初に満たしたもののデータを取得する」Resolverの処理は以下のように書くことができます。

<?php
class SampleResolver 
{
    // Judgeable生成処理は省略

    public getData()
    {
         foreach ($this->judgeableMasters as $judgeableMaster) {
             if ($judgeableMaster->canMeetCondition()) {
                  return $judgeableMaster->getData(); // 対象データ返却
             }
         }
         return null;
     }
}

問題の解決

CheckConditionStrategyによって、それぞれの問題がどのように解決されたのか見ていきます。

再利用性のなさ

機能を跨ぐ条件判定の再利用が可能となり、保守すべきロジックは1つになりました。

ある機能で既存の判定処理を利用したい場合には、対象の条件判定のEnumを返す対応を行うだけです。

CheckConditionStrategyに対応していない機能もJudgeable、Resolverをうまく構成することにより比較的簡単に合流可能です。

実装済みのStrategyを利用した派生Strategyを作ることも可能です。実際にプロジェクトでは、あるStrategyを利用して、その否定の条件となるStrategyを作るなどしています。

画一性のないパース

新機能に画一化されたパース処理を提供することができるようになりました。

Paramが条件判定とマスターデータとの間に入ることによって、独自のパース処理を必要とする既存の条件値に関しても、変換し、Strategyに渡すことが可能です。

凝縮度の低さ

Strategy内に条件値の取得、対応データの取得、データを利用した判定が1つのクラスに収められました。条件判定の可読性、信頼性が上がり、テストも容易になります。

また、LazyMapperにより、条件判定に必要なデータは一括で取得できます。

判定が必要となるまで、データ取得を遅らせるため、判定しなかった条件のデータ取得は省略され、パフォーマンスも向上する効果が得られました。

条件判定と問題解決の結合

条件判定とその機能が解決したい問題を分離することにより、機能の実装者は問題解決処理のみをResolverとJudgeableを使って記述すれば良くなりました。

この分離により、ステートマシンといった少々複雑な問題解決も容易に実装することができています。

まとめ

特定の条件判定に必要な一連の処理を1つのクラス内に収め、機能ごとの問題解決と分離することにより、再利用可能な条件判定モジュールを開発することができました。

あるイベントの開発では6つの機能で同じ条件判定を実装しなくてはなりませんでしたが、全ての機能をCheckConditionStrategyを使うように切り替え、条件判定の実装を1つで済ませることで大幅に工数が削減できています。

このモジュールができてから多くの機能の移行が進み、着実に実装が楽になっていっています。

以上が今回のお話となります。

お読みいただきありがとうございました!

*1:画像や3Dモデル、サウンドなどのこと

*2:全ユーザ共通のデータ

*3:このマスターデータには条件とその条件を満たした場合の処理が含まれる

*4:StrategyInstantiaterはEnumからStrategyを生成するシンプルなFactoryクラス