【Unity】OSSのハイパフォーマンスメッセージングシステム「Vital Router」の使い方まとめ

Unityで使えるメッセージングシステムVital Routerの使い方についてまとめました。

Vital Router 0.9.2

Vital Routerの概要と本記事の範囲

Vital Routerは.NETで使えるハイパフォーマンスなメッセージングシステムです。

github.com

メッセージングシステムとは、オブジェクト間でデータをやり取りする仕組みです。
発行者(Publisher)は任意の型のコマンド(Vital Routerではメッセージのことをコマンドと呼びます)を発行でき、購読者(Subscriber)もまた任意の型のコマンドを購読できます。
発行されたコマンドは Router に送られ、Routerがコマンドの型を判別して適切な購読者にコマンドを送ります。
以下はキャラクターがジャンプした時の「ジャンプ開始」コマンドと着地時の「ジャンプ終了」コマンドを発行し、エフェクトやサウンドのコントローラがそれらを購読している図です。

Vital Routerの概念図

これはごく簡単な例ですが、実際のゲームではより多くのオブジェクトが複雑に絡み合っており、そのようなケースでメッセージングシステムは威力を発揮します。
一方で直接参照するより処理が追いづらくなるというデメリットがあるので、メリットとデメリットを天秤にかけてどこに使うかを判断する必要はあります。

本記事ではこのようなメッセージングシステムの一つである Vital Router の使い方についてまとめます。
.NETプロジェクトでも使えますが、本記事ではUnityでの使用を前提とし、さらにDIと組み合わせると便利なのでその前提でまとめます。

Unity で Vital Router の DI をするには、同じ方が開発されている DIライブラリである VContainer を使うのが便利です。
VContainer については以下の記事にまとめていますので必要に応じて参照してください。

light11.hatenadiary.com

なお、メッセージングシステム関連の用語は統一されておらずいろんなパターンがありますが、本記事では混乱を避けるため、極力 Vital Router で使われているものに統一します。

セットアップ

前提として、Vital Router は Source Generator を使う関係で Unity 2022.2以上が必要なので、対応しているバージョンのUnityで作業します。

Unityを開いたら関連するライブラリをインストールします。
UniTaskと、今回は上述の通りVContainerを使うのでこれもインストールします。
manifest.json に以下の記述を追加することでインストールできます。(他のインストール方法を使いたい場合は各ライブラリの公式ページを参照してください)

"com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask",
"jp.hadashikick.vcontainer": "https://github.com/hadashiA/VContainer.git?path=VContainer/Assets/VContainer",

同様に、VitalRouterもインストールします。

 "jp.hadashikick.vitalrouter": "https://github.com/hadashiA/VitalRouter.git?path=/src/VitalRouter.Unity/Assets/VitalRouter",

以上でセットアップは完了です。

基本的な使い方

次にVital Routerの基本的な使い方についてまとめます。

コマンドを定義する

まず発行するためのコマンドを定義する必要があります。
コマンドにはICommandインターフェースを実装します。
クラス、構造体、レコードなんでもコマンドにすることができます。

以下はジャンプ開始を伝えるコマンドの例です。

using UnityEngine;
using VitalRouter;

public readonly struct JumpStartCommand : ICommand
{
    public JumpStartCommand(Character owner, Vector3 position)
    {
        Owner = owner;
        Position = position;
    }

    public Character Owner { get; }
    public Vector3 Position { get; }
    
    public override string ToString()
    {
        return $"JumpStartCommand: {Owner.Id} {Position}";
    }
}
コマンドを発行する

次に前節のコマンドを発行する Character クラスを以下のように作成します。

using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using VContainer;
using VitalRouter;

public sealed class Character : MonoBehaviour
{
    [SerializeField] private Button jumpButton;
    [SerializeField] private string id = "001";

    private ICommandPublisher _publisher;
    public string Id => id;

    // ICommandPublisherを注入
    [Inject]
    public void Initialize(ICommandPublisher publisher)
    {
        _publisher = publisher;
    }
    
    private void Start()
    {
        jumpButton.onClick.AddListener(() =>
        {
            // JumpStartCommandを発行
            _publisher.PublishAsync(new JumpStartCommand(this, transform.position)).Forget();
        });
    }
}

コマンドを発行するクラスには ICommandPublisher をDIで渡し、これの PublishAsync メソッドを呼ぶことでコマンドを発行できます。

ちなみに PublishAsync には非ジェネリックなバージョンも用意されています。

object cmd = new JumpStartCommand(this, transform.position);
_publisher.PublishAsync(typeof(JumpStartCommand), cmd).Forget();
コマンドを購読する

次にコマンドを購読するクラスを作成します。
このクラスには Routes アトリビュートをつける必要があります。
また Vital Router では、購読に関する処理が部分クラスとして自動生成される仕組みになっているため、クラスは partial 型にしておきます。

using UnityEngine;
using VitalRouter;

// コマンドを購読するクラスにはRoutesアトリビュートをつけて、partialクラスにする
[Routes]
public partial class EffectController : MonoBehaviour
{
    // ジャンプ開始時のコマンド
    // コマンドの型で受け取るコマンドが判別されるので、メソッド名は任意
    public void OnJumpStarted(JumpStartCommand cmd)
    {
        Debug.Log(cmd);
    }
}

これを保存するとコンパイルが走ったときに EffectController.g.cs に部分クラスと必要な処理が自動生成されます。(透過的に使えるため中身を意識する必要はありません。)

依存性注入する

コマンドを発行する側と購読する側を作ったので、あとはこれをDIを使って繋げるだけです。

using VContainer;
using VContainer.Unity;
using VitalRouter.VContainer;

public sealed class CompositionRoot : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        // Vital RouterをDIコンテナに登録
        builder.RegisterVitalRouter(routing =>
        {
            // 購読側はこのように登録する(MonoBehaviourの場合)
            // EffectControllerが破棄されたり、VContainerのLifetimeScopeが破棄されたらイベントの購読も自動的に解除(Unmap)される
            routing.MapComponentInHierarchy<EffectController>();
        });
        
            // 発行側は普通にVContainerを使うときと同様に登録すればOK
        builder.RegisterComponentInHierarchy<Character>();
    }
}

VContainerの使い方についてはこちらの記事を参照してください。
Vital Router としてのポイントは MapComponentInHierarchy している部分です。
これにより購読者を、コマンドを管理する Router に登録(マッピング)しています。

なおマッピングの方法には以下の種類があります。

// 型を指定してマッピング
// ピュアクラスの場合はインスタンスを生成し、それをマッピング
// MonoBehaviorの場合はそれをアタッチしたGameObjectを生成し、それをマッピング
routing.Map<EffectController>();

// インスタンスを渡すと生成済みのインスタンスをマッピング
routing.Map(new EffectController());

// Prefabを生成してそれをマッピングすることもできる
routing.MapComponentInNewPrefab(prefab);

// シーンにあるオブジェクトを型指定してマッピング
// (すでに紹介した方法)
routing.MapComponentInHierarchy<EffectController>();
シーン構築と実行確認

ここまでできたら以下の手順で実行確認できます。

  1. CharacterをアタッチしたGameObjectをシーン上に置いてInspectorからボタンを設定
  2. EffectControllerをアタッチしたGameObjectをシーン上に配置
  3. CompositionRootをアタッチしたGameObjectをシーン上に配置
  4. 再生
  5. ボタンを押してログ出力を確認
VContainerと組み合わせるメリットについて

さて既に説明した通り、DIを使ってRouterへのマッピング(購読の登録)を行うと、アンマッピングまで自動的にやってくれます。
イベントで情報をやりとりするシステム全般に言えることですが、登録した購読を適切なタイミングで解除する処理は重要ですがややこしく、発行者と購読者の数や依存関係が多くなればなるほど複雑になりがちです。

VContainerとVital Routerを組み合わせると、このあたりの複雑さを気にすることなくコマンドを発行するまでの処理とコマンドを受け取った後の処理という本質的な処理に集中し、見通しの良いコードを書くことができます。
(但し、DI全般に言えることですが、闇雲に使うと依存関係を追いづらい見通しの悪いコードになります。)

もしVContainerを使わない場合は自分でRouterを作ってそれに対して以下のようにマッピング、アンマッピングの処理を書く必要があります。

// Routerを生成
// Router.Defaultで取得できるデフォルトのRouterを使用することもできる
var router = new Router();

// 購読者をマッピング
var effectController = GetComponent<EffectController>();
var subscription = effectController.MapTo(router);

// JumpStartCommandを発行
router.PublishAsync(new JumpStartCommand(this, transform.position)).Forget();

// 不要になったら自分でアンマッピング
effectController.UnmapRoutes();
// 以下でもアンマッピング可能
//subscription.Dispose();

本記事は VContainer を使う前提なのでこの部分は詳しく説明しないので、必要に応じて公式ドキュメントを参照してください。

ちなみに VContainer を使う場合にはこの Router を Vital Router 側で登録してくれているということになるので、以下のコメント部分のようにDIコンテナから Router を取得してそれにマッピング、みたいなこともできます(やるべきかはおいといて)。

using UnityEngine;
using VContainer;
using VContainer.Unity;
using VitalRouter;
using VitalRouter.VContainer;

public sealed class CompositionRoot : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        builder.RegisterComponentInHierarchy<Character>();
        builder.RegisterVitalRouter(routing =>
        {
            routing.MapComponentInHierarchy<EffectController>();
        });
        
        builder.RegisterBuildCallback(container =>
        {
            // DIコンテナにRouterが含まれている(Vital Routerが登録してくれてる)ので解決可能
            // このRouterに対して手動でマッピングすることもできる
            var router = container.Resolve<Router>();
            Debug.Log(router);
        });
    }
}

非同期な購読処理とその実行順

購読処理は非同期メソッドにすることができます。

using Cysharp.Threading.Tasks;
using UnityEngine;
using VitalRouter;

[Routes]
public partial class EffectController : MonoBehaviour
{
    // 非同期な購読処理
    public async UniTask OnJumpStarted(JumpStartCommand cmd)
    {
        Debug.Log("Effect Start");
        await UniTask.Delay(1000);
        Debug.Log("Effect End");
    }
}

以下のように PublishAsyncawait すると、登録されているすべての購読処理が完了するまで待つことができます。

await _publisher.PublishAsync(new JumpStartCommand(this, transform.position));

なお、購読処理が二つ以上登録されている場合、それらは並列で実行されます。

また、以下のように書くとコマンドの完了を待ってから次のコマンドを発行する(直列で発行する)ことができます。

await _publisher.PublishAsync(new JumpStartCommand(this, transform.position));
// 前のコマンドの完了を待ってからコマンド発行
await _publisher.PublishAsync(new JumpStartCommand(this, transform.position));

発行側が同一であればこのようにできるのですが、実際にはいろんな発行者が存在していて、それぞれが発行したコマンドを直列で処理したいケースがあると思います。
そのような場合には以下のように routing.OrderingSequential に設定します。

using VContainer;
using VContainer.Unity;
using VitalRouter;
using VitalRouter.VContainer;

public sealed class CompositionRoot : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        builder.RegisterComponentInHierarchy<Character>();
        builder.RegisterVitalRouter(routing =>
        {
            // コマンドが複数発行された場合に直列で順番に実行する
            routing.Ordering = CommandOrdering.Sequential;
            
            routing.MapComponentInHierarchy<EffectController>();
        });
    }
}

こうすると、コマンドを連続で発行したとしても、前に発行したコマンドの購読側の処理すべてが完了するのを待ってから次のコマンドが処理されます。

購読側の処理の前後に処理を挟むInterceptor

ICommandInterceptorを使うと購読側の処理の前後に処理を挟んだり、処理自体を条件に応じてスキップすることができます。
例として、購読側の処理の前後にログ出力をするInterceptorを作成します。

using Cysharp.Threading.Tasks;
using UnityEngine;
using VitalRouter;

public sealed class LoggingInterceptor : ICommandInterceptor
{
    public async UniTask InvokeAsync<T>(
        T cmd,
        PublishContext context,
        PublishContinuation<T> next
    ) where T : ICommand
    {
        // 前処理
        Debug.Log($"Start {cmd}");
        
        // コマンド実行
        await next(cmd, context);
        
        // 後処理
        Debug.Log($"End   {cmd}");
    }
}

これを適用するには購読側のクラスやメソッドにFilterアトリビュートをつけます。

using Cysharp.Threading.Tasks;
using UnityEngine;
using VitalRouter;

[Routes]
// Filterアトリビュート
[Filter(typeof(LoggingInterceptor))]
public partial class EffectController : MonoBehaviour
{
    // メソッドごとに設定することもできる
    //[Filter(typeof(LoggingInterceptor))]
    public async UniTask OnJumpStarted(JumpStartCommand cmd)
    {
        Debug.Log("Effect Start");
        await UniTask.Delay(1000);
        Debug.Log("Effect End");
    }
}

すべての処理に対して適用する場合は以下のようにDIするときにフィルタを追加します。

using VContainer;
using VContainer.Unity;
using VitalRouter.VContainer;

public sealed class CompositionRoot : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        builder.RegisterComponentInHierarchy<Character>();
        builder.RegisterVitalRouter(routing =>
        {
            // Interceptorを登録
            routing.Filters.Add<LoggingInterceptor>();

            routing.MapComponentInHierarchy<EffectController>();
        });
    }
}

VContainerで子のスコープ専用のRouterを作る

VContainer では LifetimeScope に親子関係を設定できます。
このとき、子の LifetimeScope でも上述の方法と同様に Vital Router 関連の DIを行うことができます。

using VContainer;
using VContainer.Unity;
using VitalRouter.VContainer;

public sealed class ChildLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        builder.RegisterComponentInHierarchy<Enemy>();
        builder.RegisterVitalRouter(routing =>
        {
            routing.Map<SoundController>();
        });
    }
}

上記の実装の場合、親子のスコープに関係なく、すべてのコマンドがすべての購読者に届きます。

もし、LifetimeScope の範囲内でだけコマンドをやり取りしたい場合には以下のようにします。

using VContainer;
using VContainer.Unity;
using VitalRouter.VContainer;

public sealed class ChildLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        builder.RegisterComponentInHierarchy<Enemy>();
        builder.RegisterVitalRouter(routing =>
        {
            // ChildLifetimeScopeのIsolatedをTrueにすると、
            // 親は親の範囲内でだけコマンドをやり取りし、子は子の範囲内でだけコマンドをやり取りする
            routing.Isolated = true;
            routing.Map<SoundController>();
        });
    }
}

コマンドをプーリングする

Commandを構造体にする場合はVital Routerがいい感じにボックス化を避けてくれます。
しかしクラスにする場合はボックス化してしまうので、パフォーマンスの低下を防ぐためにコマンドをプーリングする仕組みが用意されています。

プーリング可能なコマンドを作るには IPoolableCommand を実装します。

using System;
using UnityEngine;
using VitalRouter;

public sealed class JumpStartCommand : IPoolableCommand
{
    public Character Owner { get; set; }
    public Vector3 Position { get; set; }

    public void OnReturnToPool()
    {
        // プールに返却された時の処理
        Owner = null;
        Position = Vector3.zero;
    }

    public override string ToString()
    {
        return $"JumpStartCommand: {Owner?.Id} {Position}";
    }
}

プールからコマンドを取得するには以下のようにします。

using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using VContainer;
using VitalRouter;

public sealed class Character : MonoBehaviour
{
    [SerializeField] private Button jumpButton;
    [SerializeField] private string id = "001";

    private ICommandPublisher _publisher;
    public string Id => id;

    private void Start()
    {
        jumpButton.onClick.AddListener(() =>
        {
            // プールからコマンドを取得
            var cmd = CommandPool<JumpStartCommand>.Shared.Rent(() => new JumpStartCommand());
            // 引数を渡すこともできる
            //var extArg = 1;
            //var cmd = CommandPool<JumpStartCommand>.Shared.Rent(arg1 => new JumpStartCommand(arg1), extArg);
            
            // コマンドをセットアップ
            cmd.Owner = this;
            cmd.Position = transform.position;
            
            // Publish
            _publisher.PublishAsync(typeof(JumpStartCommand), cmd).Forget();
        });
    }

    [Inject]
    public void Initialize(ICommandPublisher publisher)
    {
        _publisher = publisher;
    }
}

プールへの返却については、CommandPooling Inceptorを登録しておくことで自動的に行われます。

using VContainer;
using VContainer.Unity;
using VitalRouter;
using VitalRouter.VContainer;

public sealed class CompositionRoot : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        builder.RegisterComponentInHierarchy<Character>();
        builder.RegisterVitalRouter(routing =>
        {
            // CommandPooling Inspectorを追加
            routing.Filters.Add<CommandPooling>();
            
            routing.Map<EffectController>();
        });
    }
}

関連

light11.hatenadiary.com

参考

github.com