🎼

【Unity】ScenarioFlowによるシナリオ実装#1-7(IReflectableとDI Container)

2023/04/27に公開

はじめに

こんにちは.伊都アキラです.
前回は,ScenarioMethodに対する説明(description)の登録や,不特定多数の要素を引数をもつScenarioMethodの追加といった,ScenarioMethodの発展的な内容について解説をしました.

今回は,#1最終回です.
最後は何について解説するのかと言うと,IReflectableインターフェースの活用についてです.

IReflectableの存在意義

ScenarioMethodを追加して使用するためには,ScenarioMethodを宣言しているクラスがIReflectableインターフェースを実装している必要がありました.
これを,ScenarioMethodSearcherのコンストラクタにIReflectableの配列として渡すことで,追加したScenarioMethodが使用できるようになるのでした.

GameManager.cs
        //ScenarioMethodをExcelScenarioPublisherに提供する
        IScenarioMethodSearcher scenarioMethodSearcher =
            new ScenarioMethodSeacher(
                new IReflectable[]
                {
                    //使用したいScenarioMethodとDecoderを宣言している
                    //クラスのインスタンス
                    new MessageLogger(),
                    new PrimitiveDecoder(),
                });

一見,IReflectableは無意味に見えるかもしれません.なぜなら,IReflectableは何もメンバーを持たない空のインターフェースだからです.
ここから,なぜScenarioFlowが各クラスにIReflectableを要求するのか,その理由について解説します.

まず前提として,ScenarioFlowは,リフレクションを利用してScenarioMethodを直接呼び出します.
ですから,ScenarioMethodSearcherに対して呼び出すScenarioMethodが宣言されているクラスのインスタンスを渡すのは,リフレクションによるメソッド呼び出しを行うためです.

ただ,リフレクションによるメソッド呼び出しに必要なのは,メソッドの情報クラスのインスタンスです.さらに言えば,そのインスタンスはobject型で構いません.
単に呼び出しを行うだけであれば,IReflectableなどと言うインターフェースを実装させる必要は全くないのです.

それでもScenarioFlowがIReflectableを要求するのは,ScenarioFlowをDI-friendlyにするためです.

すなわち,ScenarioFlowはIReflectableのおかげで,DI Containerとの相性が良くなります.そして,DI Containerと組み合わせることで,ScenarioFlowがさらに便利になります.

具体的には,ScenarioMethodを追加する手順がさらに簡略化されます.
ここからは,実際の例を見ていきましょう.

Extenjectのインポートとセットアップ

DI Containerとして,Extenjectを使用することにします.これはアセットストアからインポート可能です(現時点で最新バージョンは9.2.0).

Extenjectをインポートできたら,次の手順に沿って作業を進めてください.

  • ProjectウィンドウのCreate/Zenject/Mono Installerから,GameInstaller.csを作成
  • HierarchyウィンドウのZenject/Scene ContextでGameContextオブジェクトを作成
  • HierarchyウィンドウのCreate EmptyでGameInstallerオブジェクトを作成
  • GameInstaller.csGameInstallerオブジェクトにアタッチ
  • GameContextオブジェクトのMono InstallersにGameInstallerオブジェクトを追加


Hierarchyウィンドウ


GameContextオブジェクト

DI Containerを利用しないScenarioMethodの追加

前回までは,DI Containerを用いずにScenarioFlowを使用していました.
まずは,DI Containerを使わずにScenarioMethodを追加するときの手順について確認しましょう.
そうすることで,DI Containerを使用しない場合の問題点を指摘します.

ちなみに,ScenarioMethodを実行するためのベースとなるコードは,前回までにも使っていた,GameManager.csです.

GameManager.cs
using ScenarioFlow;
using ScenarioFlow.ExcelFlow;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    //読み込むシナリオのソースファイル
    [SerializeField]
    private ExcelAsset excelAsset;

    private void Start()
    {
        //ScenarioMethodをExcelScenarioPublisherに提供する
        IScenarioMethodSearcher scenarioMethodSearcher =
            new ScenarioMethodSeacher(
                new IReflectable[]
                {
                    //使用したいScenarioMethodとDecoderを宣言している
                    //クラスのインスタンス
                    new PrimitiveDecoder(),
                });
        //ExcelAssetをScenarioBookに変換する
        IScenarioPublisher<ExcelAsset> excelScenarioPublisher = 
            new ExcelScenarioPublisher(scenarioMethodSearcher);
        //ExcelAsset -> ScenarioBook
        IScenarioBook scenarioBook = excelScenarioPublisher.Publish(excelAsset);
        //ScenarioMethodをすべて実行
        ReadScenarioBook(scenarioBook);
    }

    //ScenarioBook中のScenarioMethodをすべて実行
    private void ReadScenarioBook(IScenarioBook scenarioBook)
    {
        foreach(IScenarioPage scenarioPage in scenarioBook.ReadAll())
        {
            foreach(IScenarioSentence scenarioSentence in scenarioPage.ReadAll())
            {
                scenarioSentence.OnRead();
            }
        }
    }
}

対象のソースファイルをScenarioBookに変換し,すべてのScenarioMethodを呼び出すだけの単純なコードです.ここで,使いたいScenarioMethod(とDecoder)を宣言しているクラスのインスタンスをScenarioMethodSearcherに渡さなければならない点に注意です.
現時点では,string用とint用のDecoderを実装しているPrimitiveDecoderのみを渡しています.これで,ScenarioMethodの引数としてstring型とint型が使えるようになるのでしたね.

PrimitiveDecoder.cs
using ScenarioFlow;
using System;

public class PrimitiveDecoder : IReflectable
{
    [Decoder]
    public string StringDecoder(string source)
    {
        return source;
    }

    [Decoder]
    public int IntDecoder(string source)
    {
        return int.TryParse(source, out int result) ?
            result :
            throw new ArgumentException($"Failed to decode {source}");
    }
}

さて,ここに挨拶をするScenarioMethod,"greet"を追加してみます.
そのために,まずは以下のようなコードを記述します.

Greeter
using ScenarioFlow;
using UnityEngine;

public class Greeter : IReflectable
{
    [ScenarioMethod("greet")]
    public void Greet(string name)
    {
        Debug.Log($"Hello, {name}!");
    }
}

ScenarioMethod属性をメソッドに付加し,それを宣言しているクラスはIReflectableインターフェースを実装している必要があるのでした.

そして,ScenarioMethodSearcherにこのクラスのインスタンスを渡します.

GameManager.cs
//前略
public class GameManager : MonoBehaviour
{
    //中略
    private void Start()
    {
        //ScenarioMethodをExcelScenarioPublisherに提供する
        IScenarioMethodSearcher scenarioMethodSearcher =
            new ScenarioMethodSeacher(
                new IReflectable[]
                {
                    //使用したいScenarioMethodとDecoderを宣言している
                    //クラスのインスタンス
                    new PrimitiveDecoder(),
                    //追加
                    new Greeter(),
                });
        //中略
    }
    
    //後略
}

これで,ScenarioMethod,"greet"が使えるようになりました.


ソースファイル


GameManager.csの実行結果

問題はここからです.
「足し算をする」ScenarioMethodを追加したいとなった場合,どうでしょうか.

「足し算をする」処理は,「挨拶をする」処理とは関係がなさそうですから,この場合はGreeter以外に新たなクラスを実装するのが良いでしょう.
よって,以下のようにコードを記述します.

Calculator.cs
using ScenarioFlow;
using UnityEngine;

public class Calculator : IReflectable
{
    [ScenarioMethod("add")]
    public void Add(int n1, int n2)
    {
        Debug.Log($"{n1} + {n2} = {n1 + n2}");
    }
}

そして,このクラスのインスタンスをScenarioMethodSearcherに渡す必要があります.

GameManager.cs
//前略
public class GameManager : MonoBehaviour
{
    //中略
    private void Start()
    {
        //ScenarioMethodをExcelScenarioPublisherに提供する
        IScenarioMethodSearcher scenarioMethodSearcher =
            new ScenarioMethodSeacher(
                new IReflectable[]
                {
                    //使用したいScenarioMethodとDecoderを宣言している
                    //クラスのインスタンス
                    new PrimitiveDecoder(),
                    new Greeter,
                    //追加
                    new Calculator(),
                });
        //中略
    }
    //後略
}

問題はここにあります.
すなわち,ScenarioMethodを宣言する新たなクラスを実装した場合,そのたびにScenarioMethodSearcherにそのインスタンスを渡す必要があるということです.

この作業は少々面倒にも思えますし,「インスタンスの渡し忘れ」も起こってしまうかと思います.
細かいことですが,DI Containerを利用することで,このような作業を一部省略することができます.

次の節では,その方法を見ていきましょう.

Auto RegistrationによるScenarioMethodの追加

まずは,今までに書いたコードをDI Containerを利用した形に書き換えましょう.
今まではオブジェクトのマッピングと,そのオブジェクトの使用をGameManager.csに一任していましたが,それらをGameInstaller.csGameManager.csの2つに分けて書きます.

GameManager.cs
using ScenarioFlow;
using ScenarioFlow.ExcelFlow;
using UnityEngine;
using Zenject;
using System;

public class GameManager : MonoBehaviour
{
    //読み込むシナリオのソースファイル
    [SerializeField]
    private ExcelAsset excelAsset;

    private IScenarioPublisher<ExcelAsset> excelScenarioPublisher;

    [Inject]
    private void Inject(IScenarioPublisher<ExcelAsset> excelScenarioPublisher)
    {
        this.excelScenarioPublisher = excelScenarioPublisher ??
            throw new ArgumentNullException(nameof(excelScenarioPublisher));
    }

    private void Start()
    {
        //ExcelAsset -> ScenarioBook
        IScenarioBook scenarioBook = excelScenarioPublisher.Publish(excelAsset);
        //ScenarioMethodをすべて実行
        ReadScenarioBook(scenarioBook);
    }

    //ScenarioBook中のScenarioMethodをすべて実行
    private void ReadScenarioBook(IScenarioBook scenarioBook)
    {
        foreach(IScenarioPage scenarioPage in scenarioBook.ReadAll())
        {
            foreach(IScenarioSentence scenarioSentence in scenarioPage.ReadAll())
            {
                scenarioSentence.OnRead();
            }
        }
    }
}
GameInstaller.cs
using ScenarioFlow;
using ScenarioFlow.ExcelFlow;
using Zenject;

public class GameInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        //Decoderを登録
        Container.Bind<IReflectable>().To<PrimitiveDecoder>().AsSingle();
        //Greeterを登録
        Container.Bind<IReflectable>().To<Greeter>().AsSingle();
        //Calculatorを登録
        //この書き方でも登録できる
        Container.BindInterfacesTo<Calculator>().AsSingle();
        //ScenarioPublisherを構築
        Container.Bind<IScenarioMethodSearcher>().To<ScenarioMethodSeacher>().AsSingle();
        Container.Bind<IScenarioPublisher<ExcelAsset>>().To<ExcelScenarioPublisher>().AsSingle();
    }
}

このとき,以下のソースファイルを実行すると,GreeterCalculatorに属するScenarioMethodが登録されていることが分かります.


ソースファイル


実行結果

DI Containerを利用することで,コードの見通しが良くなったような気がします.

ただ,まだ問題は解決していません.
この時も,ScenarioMethodを追加しようとすると次のようなコードを追加しなければならないからです.

        //追加したいScenarioMethodが属するクラスを登録
        Container.Bind<IReflectable>().To<SomeClass>().AsSingle();
        //この書き方でもOK
        Container.BindInterfacesTo<SomeClass>().AsSingle();

実現したいことは,IReflectableを実装したクラスを作成するだけで,自動的にそのクラスに属するScenarioMethodが登録されることです.

これは,Auto Registrationという技術を使うことで達成されます.

Auto Registrationとは,ある慣習に則ってクラス設計を行うことによって,自動的にDI Containerにマッピングを行うテクニックです.そして,ここでの慣習とは,ScenarioMethodもしくはDecoderが属するクラスはIReflectableを実装するということですね.

これを利用して,Extenjectでは例えば以下のようにAuto Registrationを行うことができます.

GameInstaller.cs
using ScenarioFlow;
using ScenarioFlow.ExcelFlow;
using Zenject;
using System.Linq;

public class GameInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        foreach(var type in
            //PrimitiveDecoderが属するアセンブリの全ての型の中で
            typeof(PrimitiveDecoder).Assembly.GetTypes()
            //抽象クラスではなく
            .Where(type => !type.IsAbstract)
            //IReflectableを実装していて
            .Where(type => type.GetInterface(typeof(IReflectable).Name) != null)
            //名前空間を持たない
            .Where(type => type.Namespace == null))
        {
            //全ての条件に合致するクラスをマッピング
            Container.BindInterfacesTo(type).AsSingle();
        }

        //ScenarioPublisherを構築
        Container.Bind<IScenarioMethodSearcher>().To<ScenarioMethodSeacher>().AsSingle();
        Container.Bind<IScenarioPublisher<ExcelAsset>>().To<ExcelScenarioPublisher>().AsSingle();
    }
}

この時,Unityの実行結果が先ほどと全く同じになることを確認してください.

注目すべきは,コードの中にはPrimitiveDecoderのことしか書いていないことです.
それにも関わらず,"greet"や"add"といったScenarioMethodが使用できています.

さて,また一つ,ScenarioMethodを追加してみましょう.

FooLogger.cs
using ScenarioFlow;
using UnityEngine;

public class FooLogger : IReflectable
{
    [ScenarioMethod("foo")]
    public void LogFoo()
    {
        Debug.Log("Foo!!!");
    }
}

この時点で,ScenarioMethod,"foo"が使えるようになります.

以下のソースファイルを実行してみましょう.


ソースファイル


実行結果

何とFooLoggerを定義しただけで,他のコードを一切書くことなく,新たなScenarioMethodが使えるようになりました.

これも,FooLogger慣習に従っているからです.
以下の規則に従う限り,そのクラスは自動的にマッピングされ,そのクラスに属するScenarioMethodが使えるようになります.

  • PrimitiveDecoderと同じアセンブリに属すること(この場合はAssembly-CSharp)
  • 抽象クラスでないこと
  • IReflectableを実装していること
  • 名前空間を持たないこと

もちろん,自動的にマッピングを行いたくないクラスもあるでしょう.ただ,その場合は設定した慣習を厳しくするか,クラスをわざと慣習から外すだけです.
上の例だと,自動的にマッピングをするクラスについて,クラス名で制限をつけるか,自動的にマッピングを行いたくないクラスを別の名前空間に入れる,などといったところでしょうか.

コードを実装するにあたって,あらかじめ上手な規則を設けておくことによってScenarioMethodの登録が自動化され,非常に楽になります.

おわりに

DI Containerを活用することで,ScenarioMethodを追加する手順がさらに簡略化されました.
Auto Registrationにより,究極的にはScenarioMethodを定義するだけで登録が行われ,すぐに使えるようになります.

そして,この技術を利用することができるのも,ScenarioMethodが属するクラスにIReflectableインターフェースを実装させるように強制しているからです.IReflectableは空のインターフェースですが,決して無意味なものではないということがお分かりいただけたかと思います.

長くなってしまいましたが,今回の記事はこれで終わりです.
最後までお読みいただき,ありがとうございました.

Discussion