6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Unity+ZenjectでCQRS実装メモ

Last updated at Posted at 2019-12-22

はじめに

DDD(ドメイン駆動開発)で良く用いられるCQRS (Command Query Responsibility Separation)ですが、Unity+Zenjectを使ったわかりやすい実装例が見当たらなかったので、自分で作ってみました。筆者自身CQRSやDDDは初心者なので、もっと良い方法、例があればコメントいただけるとありがたいです。

参考資料

  1. [CQRS – example of implementation] (https://fildev.net/2018/08/27/cqrs-example-of-implementation/) by Paweł Filipek
  2. .NETのエンタープライズアプリケーションアーキテクチャ第2版 by ディノ エスポシト, アンドレア サルタレロ
  3. CQRS by Convey
  4. A Simple CQRS Pattern Using C# in .NET by JAMES STILL

実装環境

Unity2019.3.0f1とExtenject 9.1.0を使ってます。

CQRSとは?

参考資料2の第10章によると、

DDDの早期導入者が最も苦労したのは、ドメインのすべての側面に対処する単一のモデルを設計することでした。一般的に言えば、ソフトウェアシステムで実行されるアクションはどれも、クエリかコマンドのどちらかに分類されます。この場合のクエリは、システムの状態をいかなる方法でも変更せず、データを返すだけです。これに対してコマンドは、システムの状態を実際に変更します。そして、ステータスコードか確認応答を返すことがあったとしても、それ以外のデータは返しません。クエリとコマンドという2つのアクショングループが同じドメインモデルを使用することを強いられた場合、それらの間に存在する論理的な境界はぼやけてしまいます。このような理由により、ここ数年の間にコマンド/クエリ責務分離(CQRS)と呼ばれる新しいサポートアーキテクチャが登場しました。

平たく言えば、データの読み込みと書き込みを別の処理系にするということです。

CQRSでは、以下のようにドメイン層を2つに分けます(参考資料2 図10-1右)
image.png

つまり、コマンドでは4層構造を維持する一方、クエリではDTO(データ転送オブジェクト)を使い、アプリケーション層とドメイン層は使用しません。

コマンドに用いるインターフェースの準備

ここでは、参考資料1に従ってコマンドを実装してみます。最初に、インターフェースICommandを定義します。ここでは、下のように中身が空のマーカーインターフェースで定義します。

ICommand.cs
public interface ICommand
{
}

マーカーインタフェースは、コマンドの意図、つまりドメインモデルの中での役割を示すために用いられます。

次に、コマンドハンドラを定義します。

ICommandHandler.cs
public interface ICommandHandler
{
}

public interface ICommandHandler<T> : ICommandHandler where T : ICommand
{
    void Handle(T command);
}

この中で、一つのコマンドに対しては一つのハンドラだけを定義することに気を付けてください。

次に定義するのがコマンドバスです。 コマンドバスはプレゼンテーション層からのリクエストに応じて、必要なコマンドハンドラを選択する(通常は)シングルトンのクラスです。

ICommandBus.cs
public interface ICommandBus
{
    void Send<T>(T Command) where T : ICommand;
}

実装

これでインターフェースの準備はできたので、次は実装です。まず、コマンドバスからです。参考資料1をUnity+Zenject用に修整しました。

CommandBus.cs
using Zenject;

public class CommandsBus : ICommandBus
{
    private readonly DiContainer container;
    
    public CommandsBus(DiContainer container)
    {
        this.container = container;
    }

    public void Send<T>(T command) where T : ICommand
    {
        var handler = container.Resolve<ICommandHandler<T>>();
        handler.Handle(command);
    }
}

DiContainerを直接使うのはよろしくないとZenject公式こちらの記事も述べているのですが、今回はこの書き方しか思いつかなかったので、このままにします。

次はコマンドです。コマンドを実装する場合は、以下のことを守ります(参考資料3)

 - メンバ変数はimmutable
 - コマンドの名前は命令文にする

SetAvatarCommand.cs
public class SetAvatarCommand : ICommand
{
    public string Name { get; }

    public SetAvatarCommand(string name)
    {
        Name = name;
    }
}

そして、このコマンドに対応するコマンドハンドラを実装します。

SetAvatarCommandHandler.cs
using UnityEngine;
public class SetAvatarCommandHandler : ICommandHandler<SetAvatarCommand>
{
    public void Handle(SetAvatarCommand command)
    {
        Debug.Log("Set Avatar done:"+command.Name);
    }
}

最後に、ZenjectでコマンドハンドラとコマンドバスのBindを行って、依存関係を解決します。

CommandInstaller.cs
using Zenject;

public class CommandInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<ICommandHandler<SetAvatarCommand>>().To<SetAvatarCommandHandler>().AsCached();
        Container.Bind<ICommandBus>().To<CommandsBus>().AsCached();
    }
}

CommandInstaller.csは適当なゲームオブジェクトにアタッチし、Scene Contextと関連付けることを忘れずに。

image.png

これでコマンドに関する実装はできたので、以下のように確認します。これも、適当なゲームオブジェクトにアタッチしてください。

Program.cs
using UnityEngine;
using Zenject;

public class Program : MonoBehaviour
{
    [Inject] ICommandBus commandBus;

    void Start()
     {
        commandBus.Send(new SetAvatarCommand("Miku"));
    }
}

実行すると、以下のようにコンソールに表示されます。
image.png

イベントの処理

コマンドを実行すると、何らかの形でシステムの状態が変化するので、それを通知する手段としてイベントを用います(下図: CQRS and Event Sourcing as an antidote for problems with retrieving application statesより)。
image.png

この図にあるように、イベントの処理には、イベント、イベントハンドラ、イベントバスが用いられ、コマンドハンドラからイベントがイベントバスに送られて処理が行われます。

イベントのインターフェースはコマンドと似た形で作ります(参考資料1参照)。

IEvents.cs
public interface IEvents
{
}

IEventsHandler.cs
public interface IEventsHandler
{
}
public interface IEventsHandler<T> : IEventsHandler
    where T : IEvents
{
    void Handle(T events);
}
IEventsBus.cs
public interface IEventsBus
{
    void Publish<T>(T events) where T : IEvents;
}

イベントの実装もコマンドと似ていますが、バスの作り方が少し違います。一つのイベントに対して複数のハンドラが対応する可能性があるので、ResolveAllを使ってます。

EventBus.cs
using Zenject;

public class EventsBus : IEventsBus
{
    private readonly DiContainer container;

    public EventsBus(DiContainer container)
    {
        this.container = container;
    }

    public void Publish<T>(T events) where T : IEvents
    {
        var handlers = container.ResolveAll<IEventsHandler<T>>();
        handlers.ForEach(h => h.Handle(events));
    }
}

その動作確認のために、1つのイベントに対してイベントハンドラを2つ定義してみます

AvatarCreated.cs
public class AvatarCreated : IEvents
{
    public string Name { get; }
    public AvatarCreated(string name)
    {
        Name = name;
    }
}
EventHandlers.cs
using UnityEngine;
public class AvatarCreatedEventHandler : IEventsHandler<AvatarCreated>
{
    public void Handle(AvatarCreated command)
    {
        Debug.Log("avatar created:" + command.Name);
    }
}

public class AvatarIsMikuEventHandler : IEventsHandler<AvatarCreated>
{
    public void Handle(AvatarCreated command)
    {
        Debug.Log("created avatar was definitely miku");
    }
}

このAvatarCreatedイベントは、コマンドハンドラから起こすようSetAvatarCommandHandlerを変更します。

SetAvatarCommandHandler.cs
using UnityEngine;
public class SetAvatarCommandHandler : ICommandHandler<SetAvatarCommand>
{

    private readonly IEventsBus eventBus;
    public SetAvatarCommandHandler(IEventsBus eventBus)
    {
        this.eventBus = eventBus;
    }

    public void Handle(SetAvatarCommand command)
    {
        Debug.Log("Set Avatar done:" + command.Name);
        eventBus.Publish(new AvatarCreated(command.Name));
    }
}

イベントの依存関係もコマンドと同様に解決します。

EventsInstaller.cs
using Zenject;

public class EventsInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<IEventsHandler<AvatarCreated>>().To<AvatarCreatedEventHandler>().AsCached();
        Container.Bind<IEventsHandler<AvatarCreated>>().To<AvatarIsMikuEventHandler>().AsCached();
        Container.Bind<IEventsBus>().To<EventsBus>().AsCached();
    }
}

最後に、Scene ContextにEventsInstallerを追加します。
image.png

これで実行すると、コンソールの表示は以下のようになります。

image.png

一つのイベントで複数のハンドラが動作しているのが確認できます。

クエリの実装

クエリは、バスを経由せず、ハンドラだけで実装します。以下は参考資料4を基にした例です。とりあえず、データベースとして以下を用意します。

AvatarDatabase.cs
using System.Collections;
using System.Collections.Generic;

public static class AvatarDatabase
{
    public static List<Avatar> Avatars { get; }

    static AvatarDatabase()
    {
        Avatars = new List<Avatar>()
        {
            new Avatar {ID = 1, Name = "Miku"},
            new Avatar {ID = 2, Name = "Megu"},
        };
    }
}

このデータベースのエントリーであるAvatarは以下のように定義しておきます。

Avatar.cs
public class Avatar
{
    public int ID { get; set; }
    public string Name { get; set; }
}

更に、必要に応じてクエリに対するレスポンスの型も指定しておきます。

CommandResponse.cs
public class CommandResponse
{
    public bool Success { get; set; }
}

次に、クエリとクエリハンドラを定義します。

IQuery.cs
public interface IQuery<out T> { }
IQueryHandler.cs
public interface IQueryHandler<in TIn, out TOut> where TIn : IQuery<TOut>
{
    TOut Get();
}

これでインターフェースは準備できたので、ここからは実装です。クエリで名前を与えて、データベースから名前の一致するアバターを一つとりだすクエリを行うとします。クエリは以下の通りです。

AvatarSingleMatch.cs
public class AvatarSingleMatch : IQuery<Avatar> {

    public string name;

    public AvatarSingleMatch(string name)
    {
        this.name = name;
    }
}

ハンドラは以下の通りになります。コンストラクタを作るときにクエリを保存して、Getでデータベースでの検索を実行するイメージです。

AvatarSingleMatchHandler.cs
public class AvatarSingleMatchHandler : IQueryHandler<AvatarSingleMatch, Avatar>
{
     private readonly AvatarSingleMatch query;

    public AvatarSingleMatchHandler(AvatarSingleMatch query)
    {
        this.query = query;
    }

     public Avatar Get()
    {
        return AvatarDatabase.Avatars.Find(n => n.Name == query.name);
    }
}

クエリからハンドラを作るのは、Factoryクラスに任せます

QueryHandlerFactory.cs
public class QueryHandlerFactory
{
    public IQueryHandler<AvatarSingleMatch, Avatar> Build(AvatarSingleMatch query)
    {
        return new AvatarSingleMatchHandler(query);
    }
}

QueryHandlerFactoryへの参照をZenjectで解決するためにMonoInstallerを作ります

QueryInstaller.cs
using Zenject;

public class QueryInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<QueryHandlerFactory>().AsCached();
    }
}

QueryInstaller.csをScene Contextに追加するのを忘れずに(やり方はCommandInstallerなどと同じなので省略)。

これで準備は完了したので、あとは実際にクエリを行うだけです。こちらのスクリプトをゲームオブジェクトにアタッチして実行すると、「Miku」を検索して「1」が返ってきます。

GetAvatar.cs
using UnityEngine;
using Zenject;

public class GetAvatar : MonoBehaviour
{
    [Inject] QueryHandlerFactory queryHandlerFactory;

    void Start()
    {
        var query = new AvatarSingleMatch("Miku");
        var handler = queryHandlerFactory.Build(query);
        Debug.Log(handler.Get().ID);
    }
}

これで検索はできますが、今のままだとデータベースに名前の無い場合の処理ができないので、そのチェックのためのクエリとハンドラも追加します。

TryAvatarSingleMatch.cs
public class TryAvatarSingleMatch : IQuery<CommandResponse> {

    public string name;

    public TryAvatarSingleMatch(string name)
    {
        this.name = name;
    }
}
TryAvatarSingleMatchHandler.cs
public class TryAvatarSingleMatchHandler : IQueryHandler<TryAvatarSingleMatch, CommandResponse>
{
     private readonly TryAvatarSingleMatch query;

    public TryAvatarSingleMatchHandler(TryAvatarSingleMatch query)
    {
        this.query = query;
    }

     public CommandResponse Get()
    {
        var commandResponse = new CommandResponse();
        commandResponse.Success = AvatarDatabase.Avatars.Exists(n => n.Name == query.name);
        return commandResponse;
    }
}

Factoryにも追記します。

QueryHandlerFactory.cs
public class QueryHandlerFactory
{
    public IQueryHandler<AvatarSingleMatch, Avatar> Build(AvatarSingleMatch query)
    {
        return new AvatarSingleMatchHandler(query);
    }
    public IQueryHandler<TryAvatarSingleMatch, CommandResponse> Build(TryAvatarSingleMatch query)
    {
        return new TryAvatarSingleMatchHandler(query);
    }
}

これを用いて、「Nao」というデータベースに存在しない名前を検索してみましょう。

GetAvatar.cs
using UnityEngine;
using Zenject;

public class GetAvatar : MonoBehaviour
{
    [Inject] QueryHandlerFactory queryHandlerFactory;

    void Start()
    {
        if (queryHandlerFactory.Build(new TryAvatarSingleMatch("Nao")).Get().Success)
        {
            var handler = queryHandlerFactory.Build(new AvatarSingleMatch("Nao"));
            Debug.Log(handler.Get().ID);
        }
        else
        {
            Debug.Log("not found");
        }
    }
}

「Nao」はデータベースに無いので"not found"とコンソールに出るはずです。

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?