YAMADA TAISHI’s diary

ゲームについてとか私の日記とか

UniRxはもう古い?使い勝手の良さそうなMessagePipeを勉強してみた

こんにちは、やまだたいし( やまだ たいし (@OrotiYamatano) / Twitter )です。
イベントが使えるUnityのライブラリが使いたくて色々調べていました。
Unityには標準でもイベントが扱えるものがありますが、寿命管理や使い勝手が少々微妙です。

他にはUniRxもありますが、Linq前提なのと使いこなせる方が意外と現場に少ない&グローバルなスコープには向いてないという現実です。
(一応UniRxにもMessageBrokerがあるんですけどね……)
そこで私はMessagePipeを勉強することにしました。

今回はその時の知見を共有したいと思い記事化しました。

目次


なぜ?イベントが使いたいのか?


そもそもなぜイベントが使いたいのかという話をさせていただきます。
それはゲーム自体が疎結合に作るのが難しいというのがあります。
結合性を下げるためにMessageを使えば良いのではないだろうかと思ったからです。

MessageやEventは昔からありつつも実装が見直されてきている感じがします。
そして現在主流になりつつあるあるのを感じます。
最近ではEvent中心の設計をイベント駆動型プログラミングと読んだりします。
UEなんかでも用いられます。

一般的に、グラフィカルユーザインタフェース (GUI) を使用するオペレーティングシステムアプリケーションソフトウェアでは、イベント駆動型プログラミングを利用している。マウス操作やキーボード操作といったユーザーからの入力や、システム状態の変化・変更といった各イベントに対する処理を統一的に記述することができます。(Wiki引用)

しかしながら安直なイベントはデメリットが有る


イベントは疎結合に利用できますが、どこから呼ばれているか分かりづらくしてしまうというデメリットもあります。
UniRxが基本クローズドなイベントに適した形になっているのもこれが理由だと思います。(しらんけど)

Unityの公式にも引用されている、ScriptableObjectを使った不思議なイベントシステムもありますが、特にコチラ↓はどこから呼ばれているか分からなくしている代表的な例だと思います。

unity.com

UnityAtomsというライブラリが上記のScriptableObjectを改良したものだったのですが、
私が使ってみたところ

スタックトレースをさせるために内部でC#のStackTraceの処理を呼び出していて(実機実行時は大丈夫だが)デバッグ時にイベントが走るだけで数msかかってしまう(場合によっては10msかかってしまう)
・使用するイベントが多くなるたびにエディタ上での参照が多くなって管理がし辛くなる(コーディングが苦手な人には向いてるかも?)
・ぶっちゃけ、UnityEventの上に載ってるので、そこまで早くなく、UniRxのMessageBrokerの方が良さそう

という印象でした。

MessagePipeは良いのか?


github.com

ある程度習熟度がある方なら良いと個人的には思います。
MessagePipeが解説されている記事が少ないですが、ソレには理由があると考えます。

ライブラリ前提で組まれている

MessagePipeはDIライブラリ前提で組まれています。
DIについては割愛しますが、MessagePipeがDIライブラリを使うのには理由があります。
通常パブリックにイベントを使う場合、どこから呼ばれているかわからないというデメリットが生じます。
そこで、DIを使って依存性が注入されるタイミングや寿命を管理してしまうことで期間的にも制限が加わるので多少はスコープが大きくなりすぎてしまうデメリットを軽減できます。

しかし、DI自体を理解しないまま実装するとスコープも大きくなってしまう可能性もあり、
更にインスタンスは増大する可能性もあります。
そうなってしまうと正直良いことがありません。

さらにUniTaskというライブラリも導入が必須です。
正直導入ハードルが高く、解説をするほどユーザー数は少ないのだと思います。

また、DIを高度に利用できているようなユーザーは説明を受けなくても理解できてしまう。
しかもDIライブラリ、Zenjectなどにもイベント機能が入っており、そちらの利用でも十分だったりします。

なので、MessagePipeが解説されている記事が少ないんだと思います。
とはいえ、現状Unityでイベント駆動をするならこのライブラリが最強だと言わざるを得ないでしょう。

(Unity公式のイベントもっと頑張れよ!!!!!小声)

さっそくインストールしてみる


使用ツール


Unity2021.3.1f1
MessagePipe:Ver1.7.4
VContainer: v1.11.1
UniTask:Ver2.3.1

次に基本の実装をしていこうと思います


それぞれライブラリのインストールが済んだら、基本の形になるサンプルを実装してみようと思います。
ちなみに今回は一緒につかうライブラリのVContainerやUniTaskの解説は行いません。

MessagePipeで使える種類


メッセージにも様々なものがあります。
細かいオプションはありますが主に3つ。
どんなものがあるのか一つずつ簡単に説明していきます。

1.送り手と受け取り手(Publish/Subscribe)


送る方と受け取る方を定義して、間をつなぐのをDIが行います。

お互いがつながったところで、
送る方が条件を満たしたらDIされたオブジェクトへPublishします。
(Event発行はライブラリによって呼び方様々ですよね。SendだったりPublishだったりBroadcastだったり)

受け取る方はDIされたオブジェクトから送り手からの通知を読んで、反応をします。
終わり。

この形をPub/Subと呼びます。

で、DIがお互いをつなぐ条件は何かというとですが、Publishする型です。
しかし、コレでは頻繁にPublishされた際にどれがどれに対応しているか不明です。
そこでキー付きにできます。Guidをつけることで簡単に見分けられる!

2.イベントファクトリー


Pus/Subで通知が出来るってのはわかる……。
スゲーよくわかる送信と受信だからな……。
だが、Publishの条件が『型』っていうのはどういう事だぁ~~~~っ!?
いくらクラスがつくれるからってよ、intとか同じ型で別のメッセージやり取りしたい、型ごとにグループ化されていないことって沢山あるじゃねーか!!
それごとにクラスやキーを作れるかつーのよ――――ッ!
ナメやがって!!超イラつくぜェ~~~ッ!!チクショ――ッ!!
ってギアッチョ並にブチ切れた人が居るかもしれない。

安心して欲しい。
イベントファクトリーというものがある。

通常のPub/Subでは、DIにより、型でワイヤリングされていますが、
EventFactoryからイベントの個別のインスタンスを作り出すことが出来る。

言うなればC#のEventと似た機能らしい。
じゃあ、C#のEventでいいじゃんと思うかもしれないが、C#のEventより多機能。
一つ気をつけないといけないことは必ずDisposeしなければならないということ。
無視するとリークする。

コードの書き手によるので、うっかり破棄が漏れることもある。
その辺はアナライザがあるのでソチラを活用してほしいとのこと。

3.リクエスト/レスポンス/オール


個人的に一番オススメしたい機能。

オブザーバー デザイン パターンだけでなくメディエーターパターンで作りたいなら通知に対する返信が欲しくなる。
そこで利用するとよいのがリクエスト/レスポンス/オールだ!

Mediator(仲介者)を介してメッセージのやり取りを行う機能。
これであればいろんなところから参照したい場合にMediatorを起点(エントリーポイント)に考えやすい。
特にリクエスト/レスポンスは1対1なので、追いやすい。(レスポンスは早いものがち?)

2のイベントファクトリーでもエントリーポイントは作成できたが、投げっぱなしで、
現在どこで何の処理が行われているか見失いがちになる気がする。
そこで処理に対する結果を工夫して返却することで安心して通知処理出来るということだ。
(なんというか、イベントファクトリーとかがUDPだとするとリクエスト/レスポンス/オールはTCP的に組めるよね)

オールは1対多の通知を行う。
敵キャラに対して全滅処理を行うなどの実装が楽そうだ。

実際に書いてみる


(ネームスペースの有る無しは適当なので見逃してください)

1.送り手と受け取り手(Publish/Subscribe)(キーなし)


まずはスタンダードなPublish/Subscribe。
ぶっちゃけコレしか使わないならMessagePipe使うメリットはあまり感じない。

スクリプトアタッチ情報

コード

Test1Publish .cs

using MessagePipe;
using UnityEngine;
using VContainer;

public class Test1Publish : MonoBehaviour
{
    [Inject] private IPublisher<int> _publisher;
    private int _num;

    void Start()
    {
        _num = 0;
    }

    void Update()
    {
        if (!Input.GetKeyDown(KeyCode.Z))
        {
            return;
        }
        _num++;
        _publisher.Publish(_num);  //Zを押すとパブリッシュされる
    }
}

Test1Subscribe.cs

using System;
using MessagePipe;
using UnityEngine;
using VContainer;

public class Test1Subscribe : MonoBehaviour
{
    [Inject] private ISubscriber<int> _subscriber;
    private IDisposable _disposable;


    private void Start()
    {
        var d = DisposableBag.CreateBuilder();
        _subscriber.Subscribe(num =>
        {
            Debug.Log(num + "Subscribe");  //Subscribeに成功するとDebugLogに表示
        }).AddTo(d);
        _disposable = d.Build();
    }
    
    void OnDestroy()
    {
        // 破棄されるタイミングでOnAttackイベントの購読をやめる
        _disposable.Dispose();
    }
}

Test1LifetimeScope.cs

using MessagePipe;
using VContainer;
using VContainer.Unity;

namespace Scenes.Test1
{
    public class Test1LifetimeScope : LifetimeScope
    {
        protected override void Configure(IContainerBuilder builder)
        {
            var options = builder.RegisterMessagePipe(/* configure option */);
            builder.RegisterMessageBroker<int>(options);
        }
    }
}

結果

2.イベントファクトリー


多分モノビヘイビアに書いても使えるんだろうけどピュアスクリプト側に持たせてみた。
Unity側の依存を減らせるのは嬉しい。
正直これだけだとPublish/Subscribeと変わらなないように見えるが、
Test2Publish で生成されている eventFactory に紐づいて定義されているので、
Test3Publish などを作った場合も別々に通知される。
+=, -= で購読を管理出来るようですが、やり方が微妙にわからなかったので誰か教えて。

スクリプトアタッチ情報

コード

Test2Publish.cs

using System;
using MessagePipe;
using UnityEngine;

public class Test2Publish : VContainer.Unity.IStartable,IDisposable,VContainer.Unity.ITickable
{
    IDisposablePublisher<int> tickPublisher;

    public ISubscriber<int> OnTick { get; }

    public Test2Publish(EventFactory eventFactory)
    {
        (tickPublisher, OnTick) = eventFactory.CreateEvent<int>();
    }

    int count;
    public void Dispose()
    {
        // You can unsubscribe all from Publisher.
        tickPublisher.Dispose();
    }

    public void Start()
    {
        OnTick.Subscribe(x =>
        {
            Debug.Log(x + "Subscribe");
        });
    }

    public void Tick()
    {
        tickPublisher.Publish(count++);
    }
}

Test2LifetimeScope.cs

using MessagePipe;
using VContainer;
using VContainer.Unity;

namespace Scenes.Test2
{
    public class Test2LifetimeScope : LifetimeScope
    {
        protected override void Configure(IContainerBuilder builder)
        {
            var options = builder.RegisterMessagePipe();
            builder.RegisterMessageBroker<int>(options);
            builder.RegisterEntryPoint<Test2Publish>(Lifetime.Singleton);
        }
    }
}

結果

3.リクエスト/レスポンス/オール

さっき(の2で)はピュアスクリプトにもたせたので今回はモノビヘイビアにもたせてみる。


スクリプトアタッチ情報

コード

Test3Presenter.cs

using System;
using MessagePipe;
using UnityEngine;

public class Test3Presenter : VContainer.Unity.IStartable
{
    IRequestHandler<int, bool> requestHandler;

    public Test3Presenter(IRequestHandler<int, bool> eventFactory)
    {
        this.requestHandler = eventFactory;
    }

    public void Start()
    {
        var pong = this.requestHandler.Invoke(1);
        Debug.Log("PONG" + pong);
    }

}

Test3RequestHandler.cs

using MessagePipe;
using UnityEngine;

namespace Scenes.Test3
{
    public class Test3RequestHandler: MonoBehaviour,IRequestHandler<int, bool>
    {
        public bool Invoke(int request)
        {
            Debug.Log("PONG" + request);
            return true;
        }
    }
}

Test3LifetimeScope.cs

using MessagePipe;
using VContainer;
using VContainer.Unity;

namespace Scenes.Test3
{
    public class Test3LifetimeScope : LifetimeScope
    {
        protected override void Configure(IContainerBuilder builder)
        {
            var options = builder.RegisterMessagePipe();
            builder.RegisterRequestHandler<int,bool,Test3RequestHandler>(options); //モノビヘイビアオブジェクトNewするとWarningで怒られちゃうけど、いいんかな?
            builder.RegisterEntryPoint<Test3Presenter>(Lifetime.Scoped);
        }
    }
}

結果

モノビヘイビアを持つクラスをnewするなと怒られてしまったが、成功。
(Warning回避する方法ってあったりするのだろうか……?)

今回説明を割愛したもの


簡単に一言で説明します。

ISingleton***、IScoped***


寿命やスコープが管理できる機能。
あると便利だよね

Buffered


最後にPublishしたものをSubscribeで取得できる仕組み。
通常のメッセージとは違う使い方でRx的な使い方だったので割愛した。

Subscribe Extensions


メッセージにフィルター機能をつけて、Publishを避けたり
メッセージの変換処理を作ったり出来る。
上級者向け機能だったので解説は割愛。

Filter


メッセージの呼び出しにフックしてログを出したり出来るらしい。
よくしらんけど。

Managing Subscription and Diagnostics


デバッグ用機能。
呼び出された回数などを監視できる。

IDistributedPubSub / MessagePipe.Redis,InterprocessPubSub、IRemoteAsyncRequest / MessagePipe.Interprocess


正直よーわからんかった。
ちゃんと読めば分かると思うがネットワークに紐づく機能っぽかったので割愛。

MessagePipeOptions


MessagePipe 自体の動作の構成オプション設定。
メッセージのライフタイムの設定とかが出来たり、DI側の色々設定。
ISingleton***、IScoped***とも紐づいてくるし様々だったので割愛。

使い込むなら公式読んだほうが良いかも。

まとめ

とこんな感じで様々な形でイベントを通知出来る仕組みが存在する。
グローバルも存在するが、正直グローバルで使うのはコントローラーのインスタンスをInjectするぐらいだろうので説明は割愛した。(正直グローバルはあまり使わないほうが良いとは思うし)
個人的にはEventFactoryで作ったインスタンスをDIでInjectを出来るようになって欲しい。(やり方あんのかな?よく分からん)
またリクエスト/レスポンス/オールのEventFactoryも欲しいと思った。
まぁソレが出来てしまうと一見どこにどのインスタンスが紐づいているのかコード上からソースを追うのが難しくなってしまう側面もあり、
実際に実装されても特定のパターンでしか使わないと思うので、単純に私のワガママに過ぎないのかも知れない。

まだまだ拡張できそうな実装だが、シンプルに実装する分には分かりやすく、
レイアリングには適したライブラリだと思います。
メッセージングライブラリで個人的に一番しっくり来たライブラリです。

みなさんが使い始め、利用法が色々広がれば、きっとUnityでの利用改善も進んで行くと思うのでぜひ皆さん使ってみてください。
以上、やまだたいしでした。