ゲーム開発をより効率的に! サービスロケータを使ってみよう

はじめに

こんにちは! はじめまして
副団体長、コーディング課2回生のkeyです!
初めてこういう記事を書くので拙い部分もあるかと思いますがご一読いただけるとありがたいです…!
さて、今回は私がよく使うサービスロケータについてどういうものなのか、使い方などを紹介したいと思います。前日のインタフェースについての記事を読んでおくとより理解しやすいと思うのでぜひそちらもご覧ください。
使い方 < 概念の理解 に重きを置くつもりです。

対象者

  • インタフェースを理解できている人
  • チーム開発でタスクの割り振りに苦労している人
  • 開発を高速に回したい人

サービスロケータってなに?

そもそもサービスロケータってなんなんでしょうか?
サービスロケータは、ソフトウェアのデザインパターンの1つで、必要とするサービス(機能やクラスのインスタンス)を一元管理して提供する設計手法のことを言います。簡単に言うと「何でも屋の受付窓口」みたいなイメージです。プログラムの各部品が「こういう機能にアクセスしたい!」という時に直接アクセスして参照するのではなく、この窓口に「~機能ください!」と問い合わせをして機能を受け取るという具合です。
使う場合と使わない場合では以下のような違いがあります。

サービスロケータを使わない場合
サービスロケータを使う場合

このように個々の機能の窓口を直接つなげるのではなく、間にサービスロケータを挟んで集約させる、そこからアクセス先につなげるという形を取ります。
「え、それ意味ある?」と思うかもしれません。今のままだとただ依存を別クラスに切り分けただけです。でもここからがサービスロケータの真骨頂です。

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

ということで、ここでサービスロケータ利用のメリットをいくつか挙げます。

  • どこからでもサービスを取り出せるため手軽
  • 実装がとても早い
  • ピュアクラスとMonoBehaviour依存クラスのどちらも利用する側からは同じように利用することが出来る
  • 利用する側がサービスの具体的な実装を知らなくて済む ←今回の肝です

「利用する側がサービスの具体的な実装を知らなくて済む」がサービスロケータの一番のメリットと言ってもいいでしょう。これにはサービスロケータが具象クラスのみでなく、インタフェースを登録出来るという特徴を持っていることに起因します。(インタフェースの説明については前の記事に任せます)
これにより、設計段階ではインタフェースを継承したモッククラス(ここでは設計段階のダミークラスと考えてもらえば大丈夫です)を本処理として登録することが出来ます。言葉だけだと分かりずらいと思うので、具体例を見てみましょう。

  • IScoreFacade(スコアモジュールの窓口インタフェース)
public interface IScoreFacade
{
    /// <summary>
    /// スコアを加算する
    /// </summary>
    /// <param name="value">加算するスコア量</param>
    void AddScore(int value);

    /// <summary>
    /// スコアを取得する
    /// </summary>
    /// <returns>現在のスコア</returns>
    int GetScore();
}

  • MockScoreFacade(窓口インタフェースを継承したダミークラス)
public class MockScoreFacade : IScoreFacade
{
    private int _score = 0;

    public void AddScore(int value)
    {
        _score += value;
    }

    public int GetScore()
    {
        return _score;
    }
}

実際はFacadeがスコア値の実体フィールドを持つのはあまり良くないですが、ダミーなので今回はこのように実装したとします。

  • サービスを登録(これはのちほど話します)

  • GameManager(ゲームのメインフローを担当するクラス)
public class GameManager : MonoBehaviour
{
    private IScoreFacade _scoreFacade;

    private void Awake()
    {
        _scoreFacade = ServiceLocator.Resolve<IScoreFacade>();
    }

    private void AddScore(int value)
    {
        _scoreFacade.AddScore(value);
        Debug.Log($"現在のスコア: {_scoreFacade.GetScore()}");
    }
}

このようなクラス設計をもとに考えていきましょう。チーム開発の場合、機能単位で担当を割り振ることが多いと思います。その場合、GameManagerのようなゲームの進行を管理するクラスではどうしても他クラスが完成していないとどういう関数を呼べばいいのか分からず、コーディングが出来ません。これではせっかく開発人数が多くても待ち状態になる人が生まれて効率的に開発を進めることが出来ません。
そこでインタフェースを利用して、実装してほしい関数の外枠だけを定義します。あとはこのインタフェースを継承したクラスを担当者に作ってもらえばいいわけです。

じゃあGameManagerを担当する人はどうすればいいのか?
GameManagerの実装者はそのインタフェースを継承したダミークラス(例で言うところにMockScoreFacade)を作成してサービスロケータに登録します。ダミークラスは複雑なロジックの実装をせずに、シンプルな実装に留めておきます(複雑なロジックを組んでいたらせっかく担当者に割り振ったのが意味を成さなくなってしまうので)。これにより、担当者がクラスを完成させるまではモックを登録しておき、完成次第モックを本番用に切り替えて登録という流れをとることで、待ち状態の人を作らずに開発を進めることが出来ます。

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

しかし、サービスロケータはいいことばかりではありません。

  • 依存関係が不明確になりやすい
  • どこからでもアクセスできるが故、バグの原因になりやすい
  • ユニットテストがしずらい
  • シーン切り替えで破綻しやすい

サービスロケータは本来SerializeFieldで明確に依存関係を示すところを完全に外部に切り分けて管理します。それゆえ、依存関係が利用側から隠れて見えなくなってしまい、依存関係が不明確になってしまいがちです。
また、基本的にサービスロケータはグローバルなアクセスポイントなので、利用範囲を制限することが出来ず、どこからでもアクセス可能になってしまいます。そのため、思いもよらぬところで利用され、バグを引き起こす要因になる可能性があります。
また、サービスロケータは外部で使われる前提の設計思想のため、他クラスがあって初めて動作します。これにより、単体テスト(ユニットテスト)が行いずらいというデメリットもあります。(対策は可能)

そして、Unityゆえ起こることでサービス内部にMonoBehaviour依存がある場合、当然シーン遷移でその依存関係は破綻します。そのため、シーン遷移時にはサービスの登録を解除することが別途必要になってきます。

実装方法と使い方

サービスロケータには色々な実装方法がありますが、ここでは私個人が使っているデメリットを出来る限りなくしたサービスロケータの実装方法を紹介します。ServiceLocator本体とサービスを登録するクラス、実際に使うクラスの3つを挙げます。

  • ServiceLocator
using System;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// サービスロケータクラス
/// </summary>
public static class ServiceLocator
{
    // サービスを保持しておく辞書
    private static readonly Dictionary<Type, object> _container;

    static ServiceLocator() => _container = new Dictionary<Type, object>();

    /// <summary>
    /// インスタンスを取得する
    /// </summary>
    public static T Resolve<T>() => (T)_container[typeof(T)];

    /// <summary>
    /// インスタンスを登録する
    /// </summary>
    public static void Register<T>(T instance) => _container[typeof(T)] = instance;

    /// <summary>
    /// インスタンスを登録解除する
    /// </summary>
    public static void UnRegister<T>()
    {
        if (_container.ContainsKey(typeof(T))) _container.Remove(typeof(T));
        else Debug.Log($"{nameof(T)}は登録されていません");
    }
}

ポイントとしてはTypeをキー、実体をバリューとした辞書型を利用していることです。また、クラスは静的クラスとしています。staticコンストラクタはあまり見ないかと思いますが、これはこのクラスが参照された初めてのタイミングで1度だけ呼ばれます。

  • ServiceRegisterer(各シーンごとに作成します)
using UnityEngine;

public class ServiceRegisterer : MonoBehaviour
{
    void Awake()
    {
        ServiceLocator.Register(FindAnyObjectByType<PlayerFacade>());
        ServiceLocator.Register<IScoreFacade>(new ScoreFacade());
        // ServiceLocator.Register<IScoreFacade>(new MockFacade());
    }

    void OnDestroy()
    {
        ServiceLocator.UnRegister<PlayerFacade>();
        ServiceLocator.UnRegister<IScoreFacade>();
    }
}

ServiceRegistererは各シーンごとに作ります。「クラス名被っちゃダメじゃん」と思うかもしれませんが、安心してください。異なる名前空間(namespace)で括れば問題ありません。一応その場合についても書いておきます。
例としてTitleシーンとStageSelectシーンの2つのシーンでServiceRegistererを使いたい場合、このようにすれば名前空間が異なるので別物として扱われます。

  • Titleシーンに配置するServiceRegisterer
using UnityEngine;

/// <summary>
/// Titleシーンの名前空間
/// </summary>
namespace Title 
{
    public class ServiceRegisterer
    {
        void Awake()
        {
            // サービスを登録する
        }

        void OnDestroy()
        {
            // サービス登録を解除する
        }
    }
}
  • StageSelectシーンに配置するServiceRegisterer
using UnityEngine;

/// <summary>
/// StageSelectシーンの名前空間
/// </summary>
namespace StageSelect
{
    public class ServiceRegisterer
    {
        void Awake()
        {
            // サービスを登録する
        }

        void OnDestroy()
        {
            // サービス登録を解除する
        }
    }
}

最後に使うときの書き方です。

using UnityEngine;

public class GameManager : MonoBehaviour
{
    // 取得するサービスを格納するフィールド
    private IScoreFacade _scoreFacade;
    private PlayerFacade _playerFacade;

    private void Awake()
    {
        // 登録しているサービスから欲しいサービスを取得する
        _scoreFacade = ServiceLocator.Resolve<IScoreFacade>();
        _playerFacade = ServiceLocator.Resolve<PlayerFacade>();
        
        _scoreFacade.AddScore(10);
    }
}

これらを実装することで、サービスロケータを比較的安全に使うことが出来ます。ServiceRegistererでサービスの登録をまとめて行うことで、どこで登録したか分からなくなることもなくなり、オブジェクト破棄時に登録を解除するようにもしているので、シーン遷移後に前のシーンのサービスが残っていることもなくなります。「じゃあ、これで終わり!」としたいところですが、これではうまく動かないときがあるんです。

注意点

ここで1つとても重要な設定事項について述べます。前述したとおり、この実装方法だけではうまく動かないことがあります。
よくプログラムを見返すと、ServiceRegistererでサービスの登録を行っているのはAwake関数です。GameManagerでサービスを取得しているのも同じくAwake関数です。
…勘の鋭い方はもうお分かりでしょう。
これでは、サービスが登録されるよりも前にサービスの取得が行われる可能性があります。実行順によってはこれが起こりえるんです。

そのため、確実にうまく動作させるためにはAwakeの実行優先度を変える必要があります
これは以下の2つの手法で変更することが出来ます。

  • 該当クラスにDefaultExecutionOrder属性を付与する(スクリプトからの変更)
  • Project SettingsのScript Execution Orderから変更する(Unityエディタから変更)

どちらの手法にせよ、この変更でAwakeだけなく、他のMonoBehaviour依存の関数の実行順序も変わるので注意です。
それぞれ詳しく見ていきましょう。

該当クラスにDefaultExecutionOrder属性を付与する

これはプログラムからの変更方法です。具体的にはServiceRegistererクラスにDefaultExecusionOrder(-1)という属性を付与します。この属性を付与していないクラスはデフォルトで0が割り当てられており、この数値が小さいものから順に呼ばれます。また、同じオーダ内での実行順序は不定であることが保証されていません。

それでは実際の書き方を紹介します。

using UnityEngine;

[DefaultExecutionOrder(-1)]
public class ServiceRegisterer : MonoBehaviour
{
}

これだけです。

この手法のメリットとデメリットを挙げると以下のものがあります。

  • スクリプト内で直接指定できるため、柔軟性が高い
  • 他のプロジェクトでも使いまわしがしやすい
  • GUIで一覧を見ることが出来ない

Project SettingsのScript Execution Orderから変更する

これはUnityエディタからの変更方法です。具体的にはEdit->Project SettingsからScript Execution Orderタブを選択します。すると以下のような画面が出てくると思います。(Unity 6.0の画面です)

「Default Time」がオーダ0であり、何も適応していないスクリプトが割り当てられるものになります。これよりもオーダを上に設定することで、実行順序を先行させることが出来ます。

右下の「+」を押すとUnity関連のクラスを継承しているクラスの一覧が表示されます。ここからServiceRegistererを選択し、オーダを「-1」に設定しましょう。

これで完了です。(上の画面で言うInGame.ServiceRegistererが適応後のものになります)

この手法のメリットとデメリットを挙げると以下のものがあります。

  • GUIから一覧が確認できるため視覚的にわかりやすい
  • スクリプトに変更を加える必要がない
  • 毎回GUIから設定する必要があり、手間がかかる
  • プロジェクト間で共有が出来ない

どちらの手法もメリット、デメリットがあるので適したものを選択して使いましょう。

最後に

今回はゲーム開発を楽にそして開発速度を上げるための1つの手法であるサービスロケータについて紹介しました。サービスロケータはアンチパターンとよく言われていますが、正直中規模程度のゲームであればこれで十分だと思います。設計を極めたいという方は最近ではDI(Dependency Injection)がメジャーになってきているので、VContainerやZenjectといったライブラリに手を出してみるのもいいかもしれません。今回紹介したサービスロケータはDIに近い形での利用方法なので、ライブラリに触れる前の前座として使ってみるのもいいと思います。

チーム開発で特に効果を発揮するのでぜひ使ってみてください!

それではまた!

コメントを残す

CAPTCHA