シリーズ目次
①設計を学ぶ意義
②複雑な分岐にState, 要求と実行の分離にCommand
③DIとPub/Subと疎結合 ← 今ここ
④クリーンアーキテクチャ入門
⑤保守性を高めるテスト, モジュール境界の強制
今回の目的
- 主にジュニアエンジニア向けにDIコンテナとPub/Subパターン向けライブラリの情報共有をする
- これらのメリットを実感してもらえれば目標達成
大雑把に括ると、DIやPub/Subは疎結合な実装を行うための設計手法です。
まずは「疎結合」について説明します。
疎結合について
疎結合とはどういう状態か?
同じ弾が命中したら効果音を鳴らす処理を、密結合と疎結合で書き分けてみます。
// 密結合の例
FindObjectOfType<SoundManager>().PlayHit();
// 疎結合の例
public interface IHitSFX { void PlayHit(); }
public class Bullet
{
IHitSFX _sfx;
public Bullet(IHitSFX sfx) => _sfx = sfx;
public void Fire() => _sfx.PlayHit();
}
密結合の場合はSoundManagerのクラス名・生成方法・名前空間まで知っています。
一方、疎結合の場合は「効果音を鳴らせるもの(IHitSFX)」という抽象しか知りません。
知っていることが多いほど密結合、少ないほど疎結合であると言えます。
疎結合にどんなメリットがあるか
- 密結合だと実際にどんな苦労をするのか
- 疎結合なら同じ変更をどう楽に乗り切れるのか
を比べながら、疎結合のメリットを共有していきます。
密結合だとどんな苦労をするのか
上記のスクリプトで3D音響ライブラリSpatializedSoundManagerを導入して、
音量パラメータも渡す必要が出てきたとします。
この場合、複数箇所・複数ファイルで修正を加える必要が生じます。
- クラス名の変更: SoundManagerからSpatializedSoundManagerに変更
- コンストラクタに引数を追加: Bulletも含め全ファイルで追加し忘れがないか要確認
- 名前空間の変更:
using OldAudio;
をusing NewSpatialAudio;
に修正
using NewSpatialAudio;
using UnityEngine;
public class Bullet : MonoBehaviour
{
void OnCollisionEnter(Collision _)
{
var snd = new SpatializedSoundManager(0.8f);
snd.PlayHitSpatial();
}
}
疎結合ならどう楽に乗り切れるのか
Bulletクラスへの変更は不要です。
IHitSFXを継承するクラスを新規に作成し、DIの設定を変更することで対応できます。
クラス名・コンストラクタ・名前空間の考慮を行う範囲は新規作成したクラス内に収まります。
// 3D音響用のIHitSFX継承クラスを新規作成
public class SpatializedSound : IHitSFX
{
readonly float _volume;
public SpatializedSound(float v) => _volume = v;
public void PlayHit() { /* 3D 再生処理 */ }
}
// DI設定(VContainerを例に)
builder.Register<IHitSFX>(_ => new SpatializedSound(0.8f), Lifetime.Singleton);
その他のメリット
他にも疎結合には
- 責務がはっきりしてレビューしやすくなる
- レイヤーを分けやすくなる
- テストがしやすくなる
といったメリットがあります。
レイヤーやテストについては後続の記事で共有することが出来ればと思います。
疎結合を実現するために
疎結合な実装のために使える道具と呼べるものがいくつか存在します。
- C#のevent, Unity Event
- Scriptable Object
- R3(UniRx後継)などのReactive Extensions
- DIコンテナ
- MessagePipeなどのPub/Subパターン向けのライブラリ
今回はDIコンテナとPub/Subパターン向けのライブラリを取り上げます。
DIについて
DI (Dependency Injection) とは何か
依存性注入と呼ばれる設計手法です。
クラスが依存先(別クラス)を自分でインスタンス生成しない代わりに、外部から注入してもらいます。
クラスの責務は何を使うかを宣言するだけになり、どう作るかは外に追い出されます。
// 従来 ― 依存を自分で探す
public class Bullet : MonoBehaviour
{
void OnCollisionEnter(Collision _)
{
FindAnyObjectByType<SpatializedSFX>().PlayHit();
}
}
// DI ― 依存を外からもらう
public interface IHitSFX { void PlayHit(); }
public class Bullet : MonoBehaviour
{
readonly IHitSFX _sfx;
// ☆☆☆依存性注入
// 外部(別クラス)からBulletを生成するとき
// new Bullet(new SpatializedSFX())のように依存を注入してもらう
public Bullet(IHitSFX sfx) => _sfx = sfx;
void OnCollisionEnter(Collision _) => _sfx.PlayHit();
}
DIのメリット
依存性注入を行うクラスとその依存先を疎結合にすることが出来ます。
結果、先述した疎結合の恩恵を受けることが出来ます。
DIコンテナとは?
手動で外部から依存性を注入するとき、どこかのクラスでnew Bullet(new SpatializedSFX())のように書く必要があります。
そうではなく自動で依存性注入をやってくれるフレームワークをDIコンテナと言います。
代表的なDIコンテナは以下です。
- VContainer
- Extenject
DIコンテナを用いた例
VContainerを用いた例を挙げます。
// 1. 具体実装:SpatializedSFX
public class SpatializedSFX : MonoBehaviour, IHitSFX
{
readonly float _volume;
public SpatializedSFX(float volume) => _volume = volume;
public void Play() { /* 3D 音再生 */ }
}
// 2. LifetimeScopeでマッピング
public class GameScope : LifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
builder.Register<IHitSFX>(_ => new SpatializedSFX(0.8f), Lifetime.Singleton);
builder.RegisterComponentInHierarchy<Bullet>();
}
}
// 3. Bulletでは[Inject]をつけたメソッドを用意して依存性注入してもらう
// Bullet以外にもIHitSFXを使うクラスで同じことが出来る
public class Bullet : MonoBehaviour
{
IHitSFX _sfx;
[Inject]
void Construct(IHitSFX sfx) => _sfx = sfx;
void OnCollisionEnter(Collision _) => _sfx.PlayHit();
}
こちらはほんの一例で、実際にはDIコンテナの機能には幅があります。
ドキュメント・記事・書籍などを基に学んでいけるとよいかと思います。
Pub/Subパターンについて
ここからはアイテムを拾ったらUIと効果音を動かす実装を扱いながら、
Pub/Subパターンとその実装向けのライブラリを説明します。
Pub/Subパターンとは?
アプリ内部にメッセージ伝達手のクラス(Broker)を用意した上で、
- Publisher: 「アイテム拾った」などのメッセージ(DTO, データ転送オブジェクト)を投げる
- Subscriber: そのメッセージを登録しておいた処理で受け取る
というモデルを実装するデザインパターンです。
送り手と受け手はお互いのクラス名や生成方法などを知らずにメッセージのやり取りができます。
Pub/Subパターンのメリット
- Publisher側はPublish(DTO)するのみなので、Subscriberが増えても変更は少なく済みます
- 相手を知らずにメッセージのやり取りをするため、送信側と受信側が疎結合となります
イベントとの違い
似たようなものとして、C#のeventが挙げられます。
ただ、eventの場合はどのクラスがeventを持っているかを参照しなければ発行・購読が出来ません。
public class ItemEventDispatcher
{
public event Action<Item> OnPicked;
}
// 発行側: ItemEventDispatcherを知っている
ItemEventDispatcher.OnPicked?.Invoke(item);
// 受信側: ItemEventDispatcherを知っている
ItemEventDispatcher.OnPicked += item => Handle(item);
Pub/Subパターンを用いると、
DTO(上記例だとItemのようなもの)のみ知っていればよいので、
より疎結合な実装をすることが出来ます。
一方でPub/Subパターンに必要な実装はeventよりも多くなることがあります。
そのため、画面内のUIなど小規模な範囲内の通知はeventを用いて、
複数レイヤ・複数シーンをまたぐような通知の場合はPub/Subパターンを用いるのが良いかと思います。
MessagePipeとは何か?
Cysharp社が開発したPub/Subパターンの実装を提供するライブラリです。
高性能で、後述の実装例以外にも機能に幅があります。
MessagePipeを用いた実装例
MessagePipeを用いて、アイテムを拾ったらUIと効果音を動かす実装をしてみます。
DTO: イベントの中身を型で宣言
public struct ItemPicked
{
public readonly string ItemId;
public ItemPicked(string id) => ItemId = id;
}
Publisher: プレイヤーが拾ったらPublish
public class PlayerItemCollector : MonoBehaviour
{
IPublisher<ItemPicked> _pub;
[Inject]
void Construct(IPublisher<ItemPicked> pub) => _pub = pub;
void OnTriggerEnter(Collider other)
{
if (other.TryGetComponent<Item>(out var item))
{
_pub.Publish(new ItemPicked(item.Id));
}
}
}
Subscriber: UIを更新し、効果音も別クラスで購読
// UIを更新
public class UIInventory : MonoBehaviour
{
ISubscriber<ItemPicked> _sub;
[Inject] void Construct(ISubscriber<ItemPicked> sub) => _sub = sub;
void Start()
{
_sub.Subscribe(e => AddIcon(e.ItemId))
.AddTo(this.GetCancellationTokenOnDestroy());
}
void AddIcon(string id) { /* アイコン追加 */ }
}
// 効果音を再生
public class SFXPlayer : MonoBehaviour
{
[SerializeField] AudioClip pickupClip;
ISubscriber<ItemPicked> _sub;
[Inject] void Construct(ISubscriber<ItemPicked> sub) => _sub = sub;
void Start()
{
_sub.Subscribe(_ => AudioSource.PlayClipAtPoint(pickupClip, transform.position))
.AddTo(this.GetCancellationTokenOnDestroy());
}
}
DIでセットアップ
public class GameLifetimeScope : LifetimeScope
{
[SerializeField] UIInventory uiPrefab;
[SerializeField] SFXPlayer sfxPrefab;
protected override void Configure(IContainerBuilder builder)
{
// MessagePipe 基本設定
var options = builder.RegisterMessagePipe();
// このシーンでやり取りするDTOを登録
builder.RegisterMessageBroker<ItemPicked>(options);
// PlayerItemCollectorをEntryPointとして起動
builder.RegisterEntryPoint<PlayerItemCollector>(Lifetime.Singleton);
// UIと効果音オブジェクトをDI付きで生成
builder.RegisterBuildCallback(resolver =>
{
resolver.Instantiate(uiPrefab);
resolver.Instantiate(sfxPrefab);
});
}
}
まとめ
疎結合
- 疎結合とはコードが互いを関知しない状態を指す
- 疎結合だと変更が入っても影響範囲が小さくて済む
- 実現するための道具と言える例としてevent, Scriptable Object, Reactive Extensions, DIコンテナ, Pub/Subパターン向けのライブラリなどが挙げられる
DI
- DI(Dependency Injection)は依存オブジェクトの生成をクラス外へ委ね、クラス間を疎結合にする設計手法
- DIコンテナを導入すると、依存解決とライフサイクル管理が自動化され、実装コストを削減できる
- 代表的なものとして、VContainer, Extenjectが挙げられる
Pub/Subパターン
- Pub/Subパターンで実装するとメッセージ仲介(Broker)経由で通信するため、送信側と受信側が互いを知らずに済み、疎結合を推進することが出来る
- 実装するための代表的なライブラリとして、MessagePipeが挙げられる
次回はクリーンアーキテクチャを取りあげます。