🗂

VitalRouterでの設計について、READMEから考える

2024/10/08に公開

はじめに

VitalRouterはUnity(や.NET環境)におけるメッセージライブラリの1つです。今回はUnityでのVitalRouterを用いた設計について考えてみようと思います。

https://github.com/hadashiA/VitalRouter

VitalRouterの使い方(マニュアル)

先にざっくりとVitalRouterの使い方を紹介しておきます。

メッセージ(コマンド)の定義

メッセージはICommandを継承したクラス・構造体で定義します。

public readonly struct SomeCommand : ICommand
{ 
    public readonly int Value;

    public SomeCommand(int value)
    {
        Value = value;
    }
}

参考:
https://zenn.dev/qemel/articles/3d4c8081287350

メッセージの送信

ICommandPublisherをDIすることで、Publishメソッドを使ってメッセージを送信できます。

public class SomeSender : MonoBehaviour
{
    // Commandを送信するためのPublisher
    private ICommandPublisher _commandPublisher;

    [Inject]
    public void Construct(ICommandPublisher commandPublisher)
    {
        // DI
        _commandPublisher = commandPublisher;
    }

    void Start()
    {
        // SomeCommandを送信(非同期も可)
        _commandPublisher.PublishAsync(new SomeCommand(42));
    }
}

メッセージの受信

[Routes]アトリビュートのついたpartialなクラスがDIによって自動的にメッセージを受信できるようになります。

受信するメッセージは引数の型によってフィルタリングされるので、メソッド名はOnくらいがちょうどいいと思います(公式もこのように書いています)。

// [Routes]アトリビュートをつけ、partialクラスにする
[Routes]
public partial class SomeReceiver
{
    // SomeCommandがどこかからPublishされたら自動的に呼ばれる
    public void On(SomeCommand command)
    {
        Debug.Log($"Received: {command.Value}");
    }
}

DI

最後にこれらを繋ぎこむためにDIコンテナを使います。

public class SomeLifetimeScope : LifetimeScope
{
    [SerializeField] private SomeSender _sender;

    protected override void Configure(IContainerBuilder builder)
    {
        // [Routes]アトリビュートのついたクラスを登録
        builder.RegisterVitalRouter(routing =>
        {
            // PureC#の場合
            routing.Map<SomeReciever>();

            // MonoBehaviour継承の場合(たぶん非推奨)
            // この辺りはVContainerの普通の登録と同じ感じ
            routing.MapComponent<SomeReciever>();
            routing.MapComponentInHierarchy<SomeReciever>();
            routing.MapComponentInNewPrefab<SomeReciever>();
        });

        // 発行側は普通に登録するとICommandPublisherがDIされる
        builder.RegisterComponent(_sender);
    }
}

VitalRouterの思想

さて、VitalRouterのGitHubのREADMEには、設計における思想のような記述があります。
これについて考えるのが今回のメインテーマであり、あまり自信のない内容でもあります...。

再掲:
https://github.com/hadashiA/VitalRouter

ざっくり言っていることまとめ

  • コマンドはデータ型であり、関数を持ってはいけない
    • コマンドはデータ指向の考え方
    • データ指向はシリアライズなのが大きなメリット
      • コマンドを順に保存して再生することでリプレイが実装できる、など
    • データは動的な寿命をもつ
    • 反対に機能性は静的な寿命をもつ
  • コードベースが大きい場合、Viewコンポーネント(Mono継承で表示やUnityのAPIをたたいたり、衝突などのイベントを通知するだけのクラス)はPublishするよりもイベントを通知するだけにした方がいい
    • 制御フローの責務を持っているものだけがPublishする
  • あらゆるイベントが多数のオブジェクトに影響をおよぼすゲーム開発においてpub/subを使用することは有効である
    • N:Nの関係かつ高速な作成・破棄がコードを複雑化させる
    • この原因は 命令を出す側命令を受ける側 の区別がないことに起因する
    • モダンなGUIフレームワークは一方向の制御フローを推奨している
    • ゲームでも同様に制御フローを整理することが重要
  • ゲーム開発における主な関心ごとは、ゲーム特有の見た目のコンポーネントをつくることである
    • 複雑な状態管理をみんなが見たり書いたりして多くのオブジェクトが反応してしまうようなつくりは避けるべき
    • Viewコンポーネントは内部的なフレームの動きや複雑な親子関係を含む状態をかくすべき
    • 公開できる状態こそがPublishされるべき
  • 粒度の高いコンポーネントの所有権を持つオブジェクトはさらに外部と通信する
  • MVCのコントローラーは誰にも制御されるべきではない
    • エントリポイントとして機能する
    • これはVitalRouterの[Routes]アトリビュートのついたクラスである

コマンドとデータ型について

VitalRouterはデータ型を使ってメッセージを扱うことを推奨しています。これは、

  • データ型はシリアライズ可能
  • データと機能を分離できる

というメリットを持っているからです。

シリアライズが可能だと、データの入出力が簡単にできるので、それを利用した機能の実装も容易ですし、デバッグ等での出力も簡単にできます。

また、データと機能を分離することについては、データは動的な寿命を持つのに対して、機能は静的な寿命を持つという点が強力であるとされています。

両方がごちゃまぜになって両方が動的に見えるような世界観では、あらゆるオブジェクトがどこからでもどこにでも影響を与えることができるため、コードが複雑化してしまうということでしょう。

そうではなく、データだけが目まぐるしく動くような設計にすれば、コードはシンプルになるということです。

処理の方向性について

ゲーム開発では、制御フロー(命令を出す・受ける・加工する等)を整理することが重要であるとしています。

例えば、Viewコンポーネントは、MonoBehaviour継承で表示やUnityのAPIをたたいたり、衝突などのイベントを通知するだけのクラスであるべきで、制御フローの責務を持っているものだけがPublishするべきであるとされています。

その例で行くと、先ほどのSomeSenderは(大規模開発には)不適切ということになります。

public class SomeSender : MonoBehaviour
{
    // Commandを送信するためのPublisher
    private ICommandPublisher _commandPublisher;

    [Inject]
    public void Construct(ICommandPublisher commandPublisher)
    {
        _commandPublisher = commandPublisher;
    }

    void Start()
    {
        _commandPublisher.PublishAsync(new SomeCommand(42));
    }
}

これは恐らく、MonoBehaviourを継承するクラスに[Inject]するべきではないという思想にもつながってくると思います。

https://vcontainer.hadashikick.jp/ja/resolving/gameobject-injection#あらゆる-monobehaviour-へ自動的にインジェクトが行われないのはなぜ-

となると、以下のようにでもすべきなのでしょうか...?(正直自信ない)

using R3;

public class SomeView : MonoBehaviour
{
    // 公開情報の通知
    public Observable<SomeCommand> OnSomeCommand => _onSomeCommand;
    private readonly Subject<SomeCommand> _onSomeCommand = new();

    void Awake()
    {
        _onSomeCommand.AddTo(this);
    }

    void Start()
    {
        _onSomeCommand.OnNext(new SomeCommand(42));
    }
}

public class SomeSender : IInitializable
{
    private readonly SomeView _view;
    private readonly ICommandPublisher _commandPublisher;

    public SomeSender(SomeView view, ICommandPublisher commandPublisher)
    {
        _view = view;
        _commandPublisher = commandPublisher;
    }

    public void Initialize()
    {
        _view.OnSomeCommand.Subscribe(command =>
        {
            _commandPublisher.PublishAsync(command);
        });
    }
}

命令をする側・受ける側の区別について

また、命令をする側と命令を受ける側の区別の大切さについても書かれています。

これも含めて考えると先ほどのSomeSenderが不適切である理由がわかるかもしれません。

SomeSenderPublishAsyncをしている、つまり命令をする側になってしまっていますが、それはViewコンポーネントの責務からは外れているからです。

Viewはあくまで表示やイベントの通知だけを行い、制御フロー(命令を出す・受ける・加工する等)は別のクラスに委譲するべきであるということです。

エントリポイントについて

MVCのContollerの例がありました。これはエントリポイント(自分で勝手に動いて、誰にも参照されないクラス)として機能するべきであるとされています。

それと同様に、VitalRouterでは[Routes]アトリビュートのついたクラスが、エントリポイントとして機能するべきであるとされています。

こうすることで、特定の所有者に依存するような関係を作ることなしにシステムを機能させることが出来ます。

実際、VitalRouterの[Routes]アトリビュートのついたクラスは、誰に所有されることもなく、コマンドを引数にしてイベントを処理することしかできないよう設計することが出来ます。

そしてこのシステム、つまり静的な機能を、動的なデータによって動かしていく、というイメージだと思います。

まとめ

今回はVitalRouterのREADMEが濃厚だったのでその話を自分なりに解釈し、設計について考えてみました。
正直あまり自信がないので、自分もいろいろ試しながら理解を深めていきたいと思います。

Discussion