はじめに
最近はVRMmodの人と認識されるようになった@yoship1639です。
久々の投稿となります。
皆様、MessagePipeというライブラリを聞いたことはありますでしょうか。
多分ほとんどの方が聞いた事が無いと思います。なぜならリリースからまだ1週間しか経っていませんので…(2021/5/3現在)
このMessagePipe、ポテンシャルがやばいので是非いろんな方に知っていただきたくて早めに記事にしました。
個人的にはUniRxやUniTaskレベルで使えるのではと思っています。
※ ちなみに私が触った所感なので、本記事で述べるMessagePipeのメリットや使い方が正しいかどうかはまだ不明です
MessagePipeについては既に以下の記事が投稿されておりますので、どのような機能があるかはこちらを参考にしていただければと思います。
本記事では細かい機能は述べません。
まずは、簡単にどんなライブラリか説明します。
MessagePipeとは
MessagePipeは、C#大統一理論でお馴染みneuecc先生が開発し2021/4/26にリリースした.Net, Unity用メッセージングパイプラインライブラリです。
嚙み砕いて言うと、「C#標準のeventよりも高速な多機能イベント・分散メッセージング用ライブラリ」となります。
主な用途として、Readmeには以下の様に記載されています。
- Pub/Sub
- CQRSのMediatorパターン
- PrismのEventAggregator(V-VM decoupling)
- etc
Unityの場合、オブジェクト同士のメッセージングや通信の仲介役として使われる想定ですね。
MessagePipeがどの位パフォーマンスがあるかというと
この位高速らしいです。RxのSubjectやC#標準eventよりも高速なのは目を引きます。
MessagePipeのメリット
そんなMessagePipeですが、どうしてポテンシャルがあると私が思ったかというと
ずばり、オブジェクト同士が互いを認識することなく最小限のインターフェースで超高速にやり取りできるからです。
この一言でこのライブラリの凄さに気づいた方は鋭いです。
Unityでは今までオブジェクト同士がお互いが参照しあったり、あるマネージャが他のマネージャのメソッドを呼んだりなど密になった状態が当たり前となっていました。これはUnityでゲームを作っている方には避けられない道であるかと思います。イベント処理だとUniRxを使う方も多いですが、UniRxでもイベントの仲介者はサポートしていないため、オブジェクトのどちらか片方がもう片方の実態を認識しメッセージを送る必要がありました。
class Enemy
{
public int Hp { get; set; }
}
class Player
{
public int Hp { get; set; }
public int Atk { get; set; }
// Player は Enemy というクラスを知っていなくてはならない
public void Attack(Enemy enemy)
{
// 超適当な敵への攻撃
enemy.Hp -= Atk;
}
}
これを回避するには、インターフェースを作成しそれを仲介させることでお互いの依存度を下げる対処を行う必要がありました。これは、有効な手段ですがUnityだと煩雑になりかねないのでインターフェースを記述せずに直接メッセージングを行ったりする事も多いのではないかと思います。
interface IDamageable
{
int Hp { get; set; }
}
class Enemy : IDamageable
{
public int Hp { get; set; }
}
class Player
{
public int Hp { get; set; }
public int Atk { get; set; }
// Player は IDamageable に対してダメージを与える
// この場合、プレイヤーはEnemyの存在を知らなくてもよいが、インターフェースを定義していなければならない
public void Attack(IDamageable damageable)
{
damageable.Hp -= Atk;
}
}
これ以外にもAttackManagerを実装してAttackManagerがプレイヤーをとエネミーの攻撃処理を取りまとめる事もできますが、結局AttackManager依存となるため根本的な問題は解決しません。
ですが、MessagePipeはこれらの厄介な問題を一気に解決してくれます。
オブジェクトが互いを認識しなくても、AマネージャがBマネージャを認識しなくても、仲介役のインターフェースを記述しなくても他のオブジェクトにメッセージを送ることができるのです。詳しい例は次の章でご紹介します。
互いを認識せずともメッセージを送る事ができて一体どんなメリットがあるのか、それは最大限の疎結合化・モジュール化の手段となる事です。
分野にもよりますが、Unityを使ったゲーム開発であれば、これは設計においてエンジニアが目指すべき領域です。
疎結合化やモジュール化のメリットはググるとたくさん出てきますので、これらのメリットが解らない方はぜひ調べてみてください。
※ 因みにここで指すモジュール化はオブジェクト指向の型によるモジュール化です。手続き型のモジュール化とは違います。
では、実際にMessagePipeの使用例を見てみましょう。
MessagePipeの使用例
先ほどの例で考えます。例えば3DアクションでプレイヤーがエネミーにZキーでダメージを与えたい場合、MessagePipeを使うとどのように記述できるのかをご紹介します。
まず、どんなメッセージがあればプレイヤーがエネミーにダメージを与えることができるのかを考えます。
using UnityEngine;
public class PlayerAttackData
{
public Vector3 position; // どの位置で
public float radius; // どの範囲で
public int value; // どの位のダメージで
}
一先ず上のデータがあれば最低限のダメージデータとしては成り立ちそうです。
次に、AttackDataをMessagePipeで使うために登録します。
MessagePipeはver1.2.0時点ではDIコンテナを使う必要がありますので、今回は「Zenject」を使います。
using MessagePipe;
using UnityEngine;
using Zenject;
public class ZenjectInstaller : MonoInstaller
{
public override void InstallBindings()
{
// MessagePipeをZenjectContainerにバインド
var option = Container.BindMessagePipe();
// PlayerAttackDataのイベントを登録
Container.BindMessageBroker<PlayerAttackData>(option);
}
}
これで、プレイヤーからエネミーに行くダメージイベントの登録が完了しました。
次に攻撃イベント発する機能をもつプレイヤークラスを記述します。
Zキーで攻撃イベントを発します。
using MessagePipe;
using UnityEngine;
using Zenject;
public class Player : MonoBehaviour
{
// Zenjectによって自動的に攻撃イベントが注入される
[Inject] IPublisher<PlayerAttackData> AttackEvent { get; set; }
[SerializeField] private int hp;
[SerializeField] private int atk;
void Update()
{
if (Input.GetKeyDown(KeyCode.Z))
{
Debug.Log("プレイヤーの攻撃");
// Zキーで攻撃。攻撃データを発するだけ
AttackEvent.Publish(new PlayerAttackData()
{
position = transform.position + transform.forward, // プレイヤーの前方
radius = 1.0f, // 半径1m
value = atk, // ダメージ量
});
}
}
}
上のコードを見ていただければ分かりますが、Playerは攻撃データを発する事しかしていません。そこにはEnemyもIDamageableもありません。
IPublisherがMessagePipeのイベント機能です。名前の通りイベントをPublishします。
ここにはZenjectから勝手にInjectされるので個別にIPublisherを代入する必要はありません。
最後に攻撃イベントを受けるエネミークラスを記述します。
using System;
using MessagePipe;
using UnityEngine;
using Zenject;
public class Enemy : MonoBehaviour
{
// Zenjectによって自動的に攻撃イベントが注入される
[Inject] ISubscriber<PlayerAttackData> OnAttacked { get; set; }
[SerializeField] private int hp;
private IDisposable disposable;
void Awake()
{
var d = DisposableBag.CreateBuilder();
OnAttacked.Subscribe(attack =>
{
if (Vector3.Distance(transform.position, attack.position) <= attack.radius)
{
// プレイヤーの攻撃範囲内の場合、ダメージを受ける
hp -= attack.value;
Debug.Log("エネミーはダメージを受けた");
if (hp <= 0)
{
Debug.Log("エネミーは倒れた");
Destroy(gameObject);
}
}
}).AddTo(d);
disposable = d.Build();
}
void OnDestroy()
{
// 破棄されるタイミングでOnAttackイベントの購読をやめる
disposable.Dispose();
}
}
どこかしらから来たプレイヤーの攻撃イベントを受け取って、攻撃を受ける範囲内の場合にダメージを受ける処理を記述しています。
Enemyクラスを見ればわかりますがそこにはPlayerの存在がありません、MessagePipeを通して来た攻撃イベントを元にダメージを受ける処理を記述しているだけです。
これで、Zキーを押すたびに前方にいるエネミーにダメージを与えられます。
ZenjectInstaller、Player、Enemyすべてのクラスでお互いの実態を認識していません。そこにはAttackDataという攻撃のデータが行き来するだけです。
これがMessagePipeの凄いところです。
Unityでの使いどころ
使用例を見ただけではMessagePipeをどのようなところで使えばいいのか分からないと思うので、簡単にご紹介します。
モジュール同士のような広いスコープでのイベント処理
まず、使うべき場所はモジュール同士のような広いスコープでのイベント処理です。
イベントといえばUniRxも柔軟にイベントを処理できるライブラリですが、どちらか片方が相手の実態を認識していなければなりません。もし、UniRxを広いスコープで利用するとどうなるかというと、全く関係のないオブジェクト同士が密に繋がってしまい疎結合化やモジュール化ができなくなります。
しかし、広いスコープではMessagePipeを使えばこれを回避できます。モジュール同士が結合度を高めてしまう事をほぼ完全に防げるのです。
関係性が薄いオブジェクト同士がイベントのやり取りをしなければならない時
次に、関係性が薄いオブジェクト同士のイベント処理です。
プレイヤーとエネミーは互いに独立していた方が良いのはもちろんですが、どうしてもそれらをつながなければならない場合があります。先ほどの攻撃処理がそうですね。この場合はMessagePipeを使った方が良いです。C#標準のeventやUniRxでも同じような処理はできますが、先ほども述べたように、どちらかがどちらかの実態を認識する必要があります。これを回避できるのはありがたいですね。
逆を言えば関連性のあるオブジェクト同士には今まで通りUniRxやeventを使った方が良いです。わざわざMessagePipeを介すだけ無駄なので。
ボトムアップ型の設計をしたUnityプロジェクト
最後に、ボトムアップ型の設計をしたUnityプロジェクトです。
これを聞いてピンとこない方も多いかと思いますが、設計にはボトムアップ型とトップダウン型の2種類の設計があります。簡単に説明すると、○○マネージャが○○オブジェクトを操作、管理する手法がトップダウン設計で、逆に個々のオブジェクトが自発的にふるまいをしてプロジェクト全体を成り立たせるのがボトムアップ型の設計です。ざっくりいうと前者は手続き指向で後者はオブジェクト指向だと思ってください。細かい話はここではしないので各自調べていただければと思います。
どちらの設計が良いかは作ろうとしているプロジェクトによりますが、ゲーム開発において私がお勧めしているのはボトムアップ型の設計です。最速の処理速度を目指すなら手続き型が最適解になりますが、機能追加等の仕様変更や柔軟性に強いプロジェクトを目指すならボトムアップ型の方が良いです。
MessagePipeはオブジェクト同士がやり取りするためのライブラリなので、上から管理しているトップダウン型のプロジェクトにはあまり向きません。なぜなら、上が下の実態を認識しておりメッセージの仲介を上が行っているので疎なメッセージングを行う必要性が薄いからです。
しかし、ボトムアップ型だと効果は絶大です。MessagePipeはオブジェクト指向が目指している関心の分離、疎結合化を解決する最適な手段になり得ます。
おわりに
まだMessagePipeはリリースされたばかりで文献が全くない状態なので本記事の内容が本当に正しいかは分かりません、ただ、いち早く触ってみた私の所感は記述した通りで大方まとめたつもりです。UniRxやZenjectといった前提知識が求められるライブラリで学習には少しハードルが高いかもしれませんが、MessagePipeはUniRxやUniTaskなどと並び得るライブラリだと個人的には思っています。それだけポテンシャルを持っています。
勘違いしてはならないのが、MessagePipeを使えば疎結合化が簡単に実現できる訳では無いという事です。これはどのようなライブラリにも言えますが、使い方を間違えると逆に悪化してしまう可能性があることを念頭に置いてください。特にUniRxやUniTask等がまだうまく使えないUnity初心者の方には全くお勧めできないライブラリです。UnRx、UniTask、ZenjectやVContainerといったライブラリの使い方を知っている、メリットも分かっている方には非常にお勧めです。
MessagePipeを試してみたいけど一々準備するのが面倒な方や、どう記述できるのか学習したい方向けにGithubにMessagePipeTestというプロジェクトをあげているので、軽く見てみたい方はぜひクローンしてみてください、必要なパッケージは自動でインポートされます。できれば更新してくださいお願いしますお願いします。。。
https://github.com/yoship1639/MessagePipeTest
この記事がMessagePipeを導入しようとするきっかけになっていただければ幸いです!
結合度に関する追記(21/05/04)
勘違いされないためにも一応追記しておきます。
MessagePipeを使って結合度を下げる事は保守や変更に対して強くなるので良い事ではありますが、やり過ぎは禁物です。Pub/Subだらけにするなど行き過ぎた疎結合化はプロジェクト全体像を把握する事ができなくなります。すると逆にプロジェクトが保守できなくなる可能性もあります。これでは導入した意味がなくなってしまうので、適材適所に使うようにしてください。