サービスロケータを用いたUnityでの活用事例

はじめに

本記事では、プログラムを特定の実装に依存せずに動作させる実装手法の1つである、サービスロケータ(Service Locator)についての説明とサムザップのUnityプロジェクトでの活用事例と紹介します。

サービスロケータはサービスの処理を直接呼び出さずに動作させたい時に用いる実装手法です。 これまでサービスの処理を静的クラスやシングルトンで直接呼び出してきた読者や、サービスロケータでの実際の活用事例を見てみたい方を想定した記事となっています。

サービスロケータ

サービスロケータとは

サービスロケータとは、いろんなクラスから呼び出される共通的なプログラムであるサービス(例:サウンド再生・サーバー通信・ログの出力・端末データの保存と読み込み処理など)の処理を直接呼び出さずに動作させたい時に用いる実装手法です。

サービスロケータを紹介する上での背景

シングルトンなどでシステムの処理を実装しておき、グローバルなアクセスとして提供することでその処理をどこでも呼びだせるようにすることがあると思います。 ただ、シングルトンでは実際のサービスの処理がシングルトン内に記載している関係で、サービスの処理が依存する形となります。

例えば、端末に保存されてるデータを読み込んでその値によって処理を変えたい場合、処理に依存しているが故に色んなパターンを試すために毎度保存データを更新して確認することになります。これだと非常に動作の確認が厄介です。

もし、サービス内の処理を柔軟に差し替えることができたら(例えば、デバッグコマンドで入力した値を取得させるなどができたら)、既存の処理も残しつつ値の変更自体が簡単になるので動作確認がしやすい状態になります。

サービスロケータでは直接呼び出しをせず、サービスの処理自体を差し替えることができるため動作確認の厄介さを解消することができます。

サービスロケータの役割

サービスの処理を柔軟に差し替えるためにサービスロケータでは、システムを具体化させた処理(インスタンス)を抽象化された情報(インタフェース)とセットにして登録をすることで、サービスの具体的な実装とインスタンスの生成方法を分離させるといったことができます。 インタフェースに対してインスタンスを差し替えることができ、状況に応じてサービスの内容を変えることができるといった形です。

ただし、サービスロケータでは、「紐づけられたインタフェースとインスタンスの管理」役割だけを担っています。インタフェースとインスタンスの紐付けの役割は別にあります。

より分かりやすく理解をしてもらうために、先ほど例に出した端末保存の呼び出しの事例を元に、図を用いてロケータの処理を説明したいと思います。

【よくあるサービスの呼び方】

よくあるサービスの呼び方のイメージ

よくあるのがサービスの呼び方として、上記のように端末保存のデータを読み込む処理をそのまま使い手側が呼び出してデータを取得する方法です。

シングルトンで実装すると下記のような処理になります。

public class SaveDataManager : SingletonMonoBehaviour
{
    public int LoadUserLevel()
    {
        // ユーザーのレベルを返す
    }
}
var userLevel = SaveDataManager.Instance.LoadUserLevel();

これは端末保存のデータを読み込む処理を使い手側が直接呼んでいるため、もしデバッグコマンドの値を元に動作を確認したいとなると以下のような対応が発生します。

  • 端末保存のデータの処理を直接書き換える
    • SaveDataManager.Instance.LoadUserLevel()の処理の中身を直接変更をする
  • デバッグコマンドの値を取得する処理を作って、使い手が端末保存のデータを読み込む処理を消した上で、処理を差し替える
    • var saveData = DebugLoadUserLevel();みたいに別の処理を用意して代入後の処理を差し替える

なので、テストをするために既存の処理である端末保存のデータ処理自体に影響を与えるか、使い手側の処理にも本来想定しない処理を入れることになるので可能であればこの状況を避けたいです。

これをサービスロケータを使って避けていきます。

【サービスロケータのイメージ】

ロケータによるサービス差し替えのイメージ

上記のイメージからわかるように、具体的なサービスの処理は使い手側は知ってはおらず、ロケータだけが具体的なサービスの処理を知っている状態を作ります。具体的なサービスに関してはロケータ経由で使い手に提供します。

そして、サービスの登録者はロケータにインタフェースとサービスの紐付けのみをしています。

コードとしては以下です。

public interface ISaveDataManager
{
    public int LoadUserLevel();
}
#if ENV_PRD
// 本番環境用の保存データのマネージャーを登録
Locator.Register<ISaveDataManager, ProductionSaveDataManager>();
#endif

#if ENV_DEV
// 開発環境用の保存データのマネージャーを登録
Locator.Register<ISaveDataManager, DevelopmentSaveDataManager>();
#endif
// 登録時のクラスを元に本番環境用か開発環境用のデータを取得
var userLevel = Locator.Resolve<ISaveDataManager>().LoadUserLevel();

このように、取得する側は環境を意識せずに同じ処理を呼び出し、登録する側でどれを登録するのかを決めるイメージです。

これらによって、使い手側はロケータから呼び出す処理を変える必要はないですし、差し替えたいサービスの処理が増えたとしても登録者がサービスを差し替えるので、サービスの処理を変えるといった影響が発生しません。

次はサービスロケータの実装例について紹介します。

サービスロケータの実装例

サービスロケータの実装本体では、型とインスタンスを辞書型配列で登録をし、生成されたインスタンスを型の情報を元に取り出すことが可能です。

以下のコードは、弊社Unityエンジニアの尾崎がGistで公開しているサービスロケータの実装例になります。

こちらの実装では、事前に生成されたインスタンスを登録することも可能ですし、サービスを取得するタイミングでインスタンスを生成するパターンどちらも対応されています。

gist.github.com

サービスロケータのメリットとデメリット

メリット

サービスロケータのメリットは以下のようなものがメリットとして挙げられます。

  • 複数の実装を切り替えることができるので、柔軟性のあるプログラムになる

  • インスタンスに対してのアクセスが容易

  • DI(Dependency Injection)コンテナよりシンプルで高速

デメリット

サービスロケータのメリットは以下のようなものがデメリットとして挙げられます。

  • サービスロケータクラスへの依存度が増えるので依存関係が分かりづらい状態になり、大規模なプロジェクトになればなるほど依存関係の追跡がしにくくなる
    • なので、一般的にアンチパターンと呼ばれており、DIコンテナの使用が推奨されています

上記のようなデメリットは存在しますが、プロジェクトの決め事として登録の場所を固定することや呼び出し場所を限定するなどの対策を入れることで、ある程度のデメリットは緩和されると考えています。

活用事例

ここまでは、サービスロケータについて説明をしてきましたが、ここからはサービスロケータの活用事例について紹介していきます。

サムザップの一部プロジェクトでは、サービスロケータを用いて通信APIのレスポンスをモックデータとして扱った事例があるので、今回の記事ではその事例を用いて重点的に説明をしていきます。

通信APIのレスポンス差し替え

例えば、「抽選したアイテムがもらえるログインボーナス(以降、抽選ログインボーナス)」といったログインボーナスを表示する実装をするといった状況を想定して以降の話を進めます。

データを差し替えずにそのままサーバーのデータを受け取って処理をする例

はじめに、先述したログインボーナスの構造をサーバーのデータとして受け取る処理を記載していきます。

※以降で例示するコードはサービスロケータの流れを説明をしやすくするために、かなり簡略化したものになります。実際のプロジェクトで使用している実装コードとは異なりますので念頭においた上で見ていただけると幸いです。

/// <summary>
/// 抽選ログインボーナスのデータクラス
/// </summary>
public class LotteryLoginBonusResponseDto : ResponseDtoBase
{
    /// <summary>
    /// マスターID
    /// </summary>
    public int master_id;

    /// <summary>
    /// 抽選結果
    /// (1等賞:5% / 2等賞:15% / 3等賞:30% / 4等賞:50%)
    /// </summary>
    public string result;
}

そして、実際にログインボーナスを受け取る処理は以下のような方法で受け取れるとします。

/// <summary>
/// 抽選ログインボーナスの処理
/// </summary>
public async UniTask LotteryLoginBonus()
{
    // ログインボーナスの情報をサーバーから受け取る
    LotteryLoginBonusResponseDto lotteryLoginBonus = await RequestFactory.LotteryLoginBonus().Request();

    // 抽選ログインボーナスを表示すべきか確認
    if (lotteryLoginBonus != null)
    {
        // 抽選ログインボーナスの画面に遷移する
        await ShowLotteryLoginBonus(lotteryLoginBonus);
    }
}

上記のコードでいうと、

await RequestFactory.LotteryLoginBonus().Request();

で実際にサーバーからユーザーの抽選ログインボーナス情報を受け取る処理になります。

大まかな流れとして、

  1. 抽選ログインボーナスに関する情報をサーバーから受け取る

  2. もし、抽選ログインボーナスのデータがあれば、抽選ログインボーナス用の画面に遷移

になります。


ただ、本番でしか動かさない想定であればこのような処理のままで問題ないですが、開発中で以下の状況が考えられないでしょうか?

  • 受け取るデータ構造は完了しているが、肝心のサーバーの処理が適切に動いてない状態(サーバーにリクエストを送っても適切なデータが返ってこない状態など)
  • 抽選ログインボーナスの結果は抽選なので、確率の低い結果を試すのが困難
    • もちろんサーバー内で日にちを変更させることで毎回試すのは可能かもしれないが、確認をするために毎度サーバーの日にちを偽装するといった作業が必要になってくる
    • しかも、確率が低いとなるといつその結果が出てくるのかわからない

今の処理だと、サーバーから受け取ったデータに依存しているので、サーバーのデータがなければ動作確認もできないですし、実装者が確認したいデータで柔軟に確認することができません

なので、本来サーバーからの受け取るデータでの動きに依存させるのはあまり良くなく、モックデータとして実装者が確認したいデータに差し替えることができるのが理想です。

データを柔軟に差し替えることができれば、実装者はサーバーの影響を受けずに確認したいデータの組み合わせを作ってテストすることが可能になります。

その差し替えをするために本記事ではサービスロケータを用いてデータを差し替える方法を紹介します。 サービスロケータを用いることで、実装者はログインボーナス取得のタイミングで条件分岐(サーバーを使用するかモックデータを使用するかの分岐)を書かずに、中身を柔軟に差し替えることができます。


先ほどまで例示したコードを元に、サービスロケータを用いてログインボーナスの受け取りデータをどのように差し替えていくのかを説明していきます。

サービスロケータを用いて差し替える例

まず、差し替えるためのインタフェースを用意します。

今回はログインボーナスのデータサービスの中身を変更するのでILotteryLoginBonusDataServiceといった名前で用意しています。

/// <summary>
/// 抽選ログインボーナスデータサービス
/// </summary>
public interface ILotteryLoginBonusDataService
{
    /// <summary>
    /// 抽選ログインボーナス情報の取得
    /// </summary>
    /// <returns>LotteryLoginBonusの情報を構成するデータ</returns>
    UniTask<LotteryLoginBonusResponseDto> Get();
}

ILotteryLoginDataServiceを実装したクラスLotteryLoginBonusDataServiceを作成します。

/// <summary>
/// サーバー通信版ログインボーナスデータサービス(本番想定のデータ)
/// </summary>
public class LotteryLoginBonusDataService : ILotteryLoginBonusDataService
{
    public UniTask<LotteryLoginBonusResponseDto> Get()
    {
        // サーバーに対してリクエストする
        return RequestFactory.LotteryLoginBonus().Request();
    }
}

ゲーム起動の初期化処理のタイミングで、以下のようにロケータを登録する処理を入れます。

/// <summary>
/// プロジェクトの初期化
/// </summary>
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Initialize()
{
    // ログインボーナスの通信データを扱えるように、ロケータにサーバー通信を扱うクラスを登録する
    Locator.Register<ILotteryLoginBonusDataService, LotteryLoginBonusDataService>();
}

そして、ログインボーナスを表示する処理で以下のように実装を変更します。

/// <summary>
/// 抽選ログインボーナスの処理
/// </summary>
public async UniTask LotteryLoginBonus()
{
    // ログインボーナスの情報を受け取る
    var lotteryLoginBonus = await Locator.Resolve<ILotteryLoginBonusDataService>().Get();

    // 抽選ログインボーナスを表示すべきか確認
    if (lotteryLoginBonus != null)
    {
        // 抽選ログインボーナスの画面に遷移する
        await ShowLotteryLoginBonus(lotteryLoginBonus);
    }
}

今の状態は ILotteryLoginBonusDataService を実装したクラスにて、Getメソッドで抽選ログインボーナスの情報を取得する通信の処理を用意して、ゲームの初期化時にロケータでインタフェースと通信処理の部分を紐づけています。

抽選ログインボーナスの処理の部分でロケータを呼び出す処理をしているので、結果的にはロケータにする前と同じ処理となっていますが、通信処理を直接呼んでいるわけではないので呼び出し側は、実際の中の処理を気にせずデータを扱うことができます。

ここからは、中身を柔軟に差し替えれるモックデータのクラスを用意してどのように差し替えるのかを説明していきます。


以下のように、モック用の抽選ログインボーナスのデータサービスを用意します。

/// <summary>
/// モック版抽選ログインボーナスデータサービス
/// </summary>
public class MockLotteryLoginBonusDataService : ILotteryLoginBonusDataService
{
    public UniTask<LoginbonusResponseDto> Get()
    {
        // モックデータのJsonファイルを取得
        TextAsset mockJson = Resources.Load<TextAsset>("MockData/MockLotteryLoginBonus");

        // Jsonファイルから抽選ログインボーナスのモックデータを作成
        return JsonUtility.FromJson<LotteryLoginBonusResponseDto>(mockJson.text);
    }
}

上記のコードでは、Resources/MockDataのフォルダに入ってるMockLotteryLoginBonus.jsonのファイルを読み込んで、それをJsonUtilityを使ってJsonをレスポンスデータに変換する処理になっています。

/// <summary>
/// プロジェクトの初期化
/// </summary>
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Initialize()
{
    // モックデータに差し替えるか
    if (MockDataSettings.UseMockData)
    {
        // モックデータを使えるようにDataServiceをモックデータ用のクラスに上書きで登録する
        Locator.Register<ILotteryLoginBonusDataService, MockLotteryLoginBonusDataService>();
    }
    else
    {
        // ログインボーナスの通信データを扱えるように、ロケータにサーバー通信を扱うクラスを登録する
        Locator.Register<ILotteryLoginBonusDataService, LotteryLoginBonusDataService>();
    }
}

また、ロケータにサーバー通信の登録処理を登録していたゲーム初期化タイミングで、モックデータを使用するかどうかのパラメータを元にモックデータの差し替えの処理を入れます。

差し替え処理を入れたことによって、以下の実行処理がモックデータに差し替える設定を入れれば「モックデータの結果」、そうでなければ「サーバーから取得したデータ」が取得されるようになります。

var lotteryLoginBonus = await Locator.Resolve<ILotteryLoginBonusDataService>().Get();

なので、レスポンス想定のJsonデータであるMockLotteryLoginBonus.jsonを自分で作成 / 編集すれば、実際にサーバー通信を行わなくても自分が試してみたいデータを差し替えて偽装することができます。

例えば、以下のようなJsonの構造であれば、抽選ログインボーナスのデータがモックデータとして差し替えることができます。

MockLotteryLoginBonus.json

{
    "lottery_login_bonus": 
    {
        "master_id": 20200,
        "result": "3等賞"
    }
}

もし、抽選ログインボーナスで低確率の結果を確認したいのであれば、resultの値を”1等賞”といった低確率でしか出ない結果に差し替えることで、100%の確率で1等賞を出すことができます。

今回はJsonを用いてデータの差し替え事例を紹介しましたが、指定したJsonをコード上で指定して毎回ロードするのはデータの差し替えやすさが損なわれてしまうので以下のような方法でデータの差し替えやすさや加工のしやすさを向上させることができます。

以下は向上させるための方法の一例です。

  • ScriptableObjectを使ってTextAssetをSerializeFieldで用意し、インスペクター上でJsonのテキストデータを指定する
    • インスペクター上で指定することで実行中でもJsonデータの差し替えが可能になる
  • Serializableなクラスを用意して、インスペクター上でMockのデータ自体を書き換えやすくする
    • Json形式ではなくパラメータをインスペクターで用意することでデータ加工の柔軟性を上げることが可能になる

長くなってしまいましたが、以上がサービスロケータを用いた通信APIレスポンスの差し替え事例です。

その他のデータの差し替え事例

通信APIレスポンスの差し替え事例を紹介してきましたが、他の場面でもデータや処理を差し替えた方が良い事例はいくつかあります。

  • プラットフォームによる処理の差し替え

  • 課金テストによる処理の差し替え(本番課金とダミー課金を差し替え)

これらの場面では、データや処理の方法を差し替え可能なようにするだけで、開発や確認が便利になってきます。

最後に

サービスロケータの活用事例を実際のコード例を用いて説明しました。 サービスロケータではサービスの処理を柔軟に差し替えができるので単体でのテストが比較的容易になり、開発の効率化が見込めます。

また、使い方によっては規模の大きいプロジェクトでも効果が発揮できると思います。

ただし、サービスロケータにはデメリットも潜んでいるため、扱い方やプロジェクトの大きさを考慮した上で採用する必要はあると思います。

考慮した上で採用することで、開発に対して大きな効果が発揮できるのかなと個人的には思っています。


福田 晃久

株式会社サムザップ Unityエンジニア
2022年新卒でサイバーエージェント入社、サムザップ配属
『この素晴らしい世界に祝福を!ファンタスティックデイズ』の開発に従事


尾崎 真也

株式会社サムザップ Unityエンジニア
『この素晴らしい世界に祝福を!ファンタスティックデイズ』
『呪術廻戦ファントムパレード』などの開発に従事