この記事はTech KAYAC Advent Calendar 2019の5日目の記事です。
こんにちは。カヤック オルタナティ部の藤澤覚司です。
普段はUnityエンジニアをやったり、広島カープの布教活動に日々勤しんでいます。今回はUnityでClean Architectureを適用させる方法についてお話します。
目次
はじめに
Unityでソースコードを書いてると、
- 何でもかんでもmonobehaviour継承させる
- 一つのソースコードに色んな処理書いちゃう
- 参照まわりがめんどくさくなったらsingletonにしちゃう
みたいなことやりがちですよね?私も気を抜くとすぐやっちゃいます。
例えば、ハッカソンやちょっとアイデアプロトのようなスピード感重視のプロジェクトなら良いのですが、関わる人数が多かったりじっくり開発するような規模の大きいプロジェクトの場合、このような実装だと確実に問題を起こします。テストを書けなかったり、依存関係がめちゃくちゃになったりして、開発後期にはレビュアーが地獄を見、納期に間に合わず、プロジェクトが壊れ、広島カープの試合を見ることができなくなってしい、やがて引退を迎える…
そこで、広島カープファンとしての生活を守る為、そして精神衛生面上よろしくない事にならない為の方法を調べてみたところ、Clean Architectureというアーキテクチャに出会いました。そこで今回はUnityでClean Architectureを使う方法を解説したいと思います。
Clean Architectureについて
Clean ArchitectureはビジネスロジックやUIとの役割を分離して、それぞれのクラスが一つの処理に集中する事で、単一責任の法則(classを変更する理由は一つでなければならない)や依存性逆転の法則(下位モジュールは上位モジュールに依存してはいけない)の実現が期待できるアーキテクチャだそうです。
koutalouさんなど、説明をしてくださっている記事がたくさんあるので、勉強したいと思います。(まだ全然理解しきれていない)
CAFUについて
CAFUはTetsuya Mori(@monry)さんが開発された、Clean ArchitectureをUnityで実現するためのアーキテクチャフレームワークです。「かふー」と呼ぶそうです。CAFU…CAPU…CARP…広島カープみたいで最高ですね!(ありがとうございます!)
また汎用的なEntityやDataStoreなどを提供してくれるcafu_genericやCAFUに適したテンプレートソースコードを作成してくれるcfu_generatorなど、Clean Architectureを実現するためのサポートをしてくれるモジュールをmonryさんはご提供されています。
CAFUの公開されている最新のバージョンはv3.0.0なのですが、以下の理由で今回はv2を使っています。
- v3.0.0を触る前にv2がどんな構成なのか理解したかった。
- v3.0.0では対応していないcafu_generatorを使ってみたかった。
- CAFU v2系を使った記事がいくつか存在していたので、取っ掛かりやすそうだったから。
以上の理由から今回は以下のモジュールを利用して開発をしました。
cafu_core 2.7.3 (多分) cafu_generator cafu_generic
環境構築
フレンズひらたソフトさんがcafu_core、cafu_generic、cafu_generatorの導入方法をご紹介していたので、参考にさせて頂きました。ありがとうございます。
friends-hirata-soft.com
触ってみた(CAFUで作ってみた)
今回はサンプルアプリとして「広島カープの選手の情報を知る事ができるアプリ」を作ってみました!
機能要件はこんな感じです。
- 検索フィルタをかけたい内容ごとにボタンがあり、押すと検索処理が走る
- 検索結果はテキストとして、InputFieldに表示される
- 表示するデータ数はユーザーが指定できる
選手のデータは、プロ野球Freakさんを参考に作成させて頂きました。ありがとうございます。
baseball-data.com
広島カープに栄光あれ!!結果、以下のようなものができました。
クラス図はこのような感じになりました。小さくて見づらいし、関係間違えているかもしれませんが、ご了承ください。
今回CAFU v2で実装するにあたって、青木とと(@lycoris102)さんの記事をめちゃくちゃ参考にさせて頂きました。ありがとうございます。
qiita.com
それでは、以下からは各レイヤーごとに解説したいと思います。
1. Data
CAFU v2ではEntityとDataStoreが属している層です。データの構造の定義(Entity)やそのデータの取り扱い(参照/保存)など(DataStore)を担当しています。
1.1. Entity
データ構造が定義されているレイヤーです。ScriptableObjectのような静的データや、サーバーから受け取ったキャッシュデータなどを保持する役目を持っています。今回作成したアプリではcafu_genericsを使ってScriptableObjectGenericEntityListを使ってGenericDataStoreで管理できるScriptableObjectを作成しています。このScriptableObjectの中に検索をかけたい選手に関するデータが定義されています。
using System; using CAFU.Core.Data.Entity; using CAFU.Generics.Data.Entity; using UnityEngine; namespace Data.Entity { [Serializable] public class CarpEntityList : ScriptableObjectGenericEntityList<CarpEntityPair> { } [Serializable] public class CarpEntityPair : GenericPairEntity<int, CarpEntity> { } public interface ICarpEntity : IEntity { //名前 string Name { get; set; } //守備位置 string Position { get; set; } //誕生日 string Birthday { get; set; } //血液型 string BloodType { get; set; } //投打 string Throw { get; set; } //出身地 string Birthplace { get; set; } //年俸 int AnnualSalary { get; set; } //年数 int Years { get; set; } //年齢 int Age { get; set; } //身長 int Height { get; set; } //体重 int Weight { get; set; } } [Serializable] public class CarpEntity : ICarpEntity { [SerializeField] private string _name; public string Name { set { _name = value; } get { return _name; } } [SerializeField] private string _position; public string Position { set { _position = value; } get { return _position; } } [SerializeField] private string _birthday; public string Birthday { set { _birthday = value; } get { return _birthday; } } [SerializeField] private string _bloodType; public string BloodType { set { _bloodType = value; } get { return _bloodType; } } [SerializeField] private string _throw; public string Throw { set { _throw = value; } get { return _throw; } } [SerializeField] private string _birthplace; public string Birthplace { set { _birthplace = value; } get { return _birthplace; } } [SerializeField] private int _annualSalary; public int AnnualSalary { set { _annualSalary = value; } get { return _annualSalary; } } [SerializeField] private int _years; public int Years { set { _years = value; } get { return _years; } } [SerializeField] private int _age; public int Age { set { _age = value; } get { return _age; } } [SerializeField] private int _height; public int Height { set { _height = value; } get { return _height; } } [SerializeField] private int _weight; public int Weight { set { _weight = value; } get { return _weight; } } } }
1.2. DataStore
Entitiyで定義したデータを取り扱うことが役割のレイヤーです。例えば、何かのデータベースと通信してデータを取得。そのデータをEntityに変換して保持しておく、のような処理などをここで行います。また保持しているEntityはRepositoryを介してUseCaseに提供する流れになります。
今回は、cafu_genericsのGenericDataStoreを使うことで、DataStoreを実現しています。GenericDataStoreを使えば、GenericReopsitoryからDataStoreを参照できるようにしてくれるので、DataStoreとRepositoryをコーディングなしで実装することができます。便利だ。
2. Domain
CAFU v2ではRepository、Translator、Model、UseCaseレイヤーが属している層です。ビジネスロジック(UseCase)、DataStoreからのEntityの受け取り(Repository)、Viewで取り扱いやすいデータ構造の定義(Model)、EntityからModelへの変換処理などを担当しています。
2.1. Repository
UseCaseからのEntity取得要請を受けて、DataStoreからEntityを参照してくるレイヤーです。上記で述べたように、今回はcafu_genericのGenericRepositoryを使用するため、hierarchyにGenericDataStoreコンポーネントを持つオブジェクトが存在すれば、GenericRepositoryからGenericDataStoreに登録されたEntityを参照することができます。
2.2. Model
Entityのデータを元に、システムの中で使用しやすいデータ構造を定義することが目的のレイヤーです。model用のinterfaceが用意されており、これを継承することで、適切なModelを実装します。今回のシステムの場合、Entityにない"そのデータの年"と"各データを数珠つなぎした文字列"を追加してModelを実装しています。
using CAFU.Core.Domain.Model; namespace Domain.Model { public interface IPlayerModel : IModel { //名前 string Name { get; } //守備位置 string Position { get; } //年齢 int Age { get; } //身長 int Height { get; } //体重 int Weight { get; } //年俸 int AnnualSalary { get; } } public class PlayerLogModel : IPlayerModel { public string Name { get; } public string Position { get; } public int Age { get; } public int Height { get; } public int Weight { get; } public int AnnualSalary { get; } //データの年 public int DataYear { get; } public string DataFullPath { get; } public PlayerLogModel( string _name, string _position, int _age, int _height, int _weight, int _annualSalary, int _dataYear) { this.Name = _name; this.Position = _position; this.Age = _age; this.Height = _height; this.Weight = _weight; this.DataYear = _dataYear; this.AnnualSalary = _annualSalary; this.DataFullPath = this.Name + "(" + this.DataYear + ")" + "_" + this.Position + "_" + this.Age + "歳_" + this.Height + "cm_" + this.Weight + "kg_" + this.AnnualSalary + "万円_"; } } }
2.3. Translator
EntityとModel間の相互変換を担当するレイヤーです。TranslatorはUseCaseレイヤーから呼び出されています。今回の場合は、Entity->Modelという変換しか行なっていませんが、システム内でデータの書き換えなどを行うことがある場合、Model->Entityという方向で変換することもあるようです。
using CAFU.Core.Domain.Translator; using Domain.Model; using Data.Entity; namespace Domain.Translator { public class PlayerModelTranslator : IModelTranslator<int,ICarpEntity,IPlayerModel> { public IPlayerModel Translate(int _dataYear, ICarpEntity _entity) { return new PlayerLogModel( _entity.Name, _entity.Position, _entity.Age, _entity.Height, _entity.Weight, _entity.AnnualSalary, _dataYear); } } }
2.4. UseCase
ビジネスロジックのような機能の実装を行うレイヤーです。行う処理の内容単位でUseCaseを分けて、UseCaseを作成するためのinterfaceを継承して実装します。またRepository(Entityの取得)やTranslator(EntityからModelへの変換)のインスタンスを保持しているのもUseCaseであり、処理に必要なデータを取得します。
今回の場合、
- 指定された方法でソートをかけて、必要数のデータを取得する
- 表示するデータの数を保持、更新、提供する
という処理をUseCaseにしました。
まず指定された方法でソートをかけて、必要数のデータを取得するUseCaseからです。
using CAFU.Core.Domain.UseCase; using System; using System.Collections.Generic; using CAFU.Core.Domain.Translator; using CAFU.Generics.Domain.Repository; using UniRx; using Data.Entity; using Domain.Model; using Domain.Translator; using System.Linq; namespace Domain.UseCase { public interface ISearchPlayerUseCase : IUseCase { IObservable<SerachType> OnSearchAsObservable(); IObservable<List<IPlayerModel>> OnSerachedAsObserable(); void ExecuteSerach(SerachType type); void Serach(SerachType type,int range); } public class SearchPlayerUseCase : ISearchPlayerUseCase { //Entityを参照するためのRepository private IGenericRepository<CarpEntityList> carpPlayerRepository { get; set; } //Modelに変換した後に保持しておくlist private List<IPlayerModel> playerModels { get; set; } //modelに変換するためのTranslator private PlayerModelTranslator playerModelTranslator { get; set; } //検索処理を実行するときにOnNextされるSubject private Subject<SerachType> serachSubject; //検索処理が完了したらOnNextされるSubject private Subject<List<IPlayerModel>> serachedSubject; public class Factory : DefaultUseCaseFactory<SearchPlayerUseCase> { protected override void Initialize(SearchPlayerUseCase instance) { base.Initialize(instance); instance.serachSubject = new Subject<SerachType>(); instance.serachedSubject = new Subject<List<IPlayerModel>>(); instance.playerModelTranslator = new DefaultTranslatorFactory<PlayerModelTranslator>().Create(); instance.carpPlayerRepository = new GenericRepository<CarpEntityList>.Factory().Create(); instance.playerModels = new List<IPlayerModel>(); instance.Initialize(); } } private void Initialize() { foreach (var playerEntityPair in this.carpPlayerRepository.GetEntity().List) { var playerModel = this.playerModelTranslator.Translate(playerEntityPair.Key, playerEntityPair.Value); playerModels.Add(playerModel); } } //PresenterにSubjectを提供するためのメソッド public IObservable<SerachType> OnSearchAsObservable() { return this.serachSubject.AsObservable(); } public IObservable<List<IPlayerModel>> OnSerachedAsObserable() { return this.serachedSubject.AsObservable(); } //View側から検索処理を受け付けるためのメソッド public void ExecuteSerach(SerachType type) { serachSubject.OnNext(type); } //検索の処理部分 public void Serach(SerachType type,int range) { List<IPlayerModel> res = new List<IPlayerModel>(); switch (type) { case SerachType.All: res = playerModels; break; case SerachType.Picher: res = playerModels.FindAll(model => model.Position.Equals("投手")); break; case SerachType.HeightPlayer: res = playerModels.OrderByDescending(model=>model.Height).ToList(); break; case SerachType.HeavyPlayer: res = playerModels.OrderByDescending(model=>model.Weight).ToList(); break; case SerachType.RichPlayer: res = playerModels.OrderByDescending(model=>model.AnnualSalary).ToList(); break; } if(range >= res.Count) this.serachedSubject.OnNext(res); else this.serachedSubject.OnNext(res.GetRange(0,range)); } } }
次に表示するデータの数を保持、更新、提供するUseCaseです。
using CAFU.Core.Domain.UseCase; using System; using System.Collections.Generic; using CAFU.Core.Domain.Translator; using CAFU.Generics.Domain.Repository; using UniRx; using Data.Entity; using Domain.Model; using Domain.Translator; namespace Domain.UseCase { public interface IRangeUseCase : IUseCase { int GetRange(); void UpdateRange(string value); IObservable<int> OnChangeRangeAsObservable(); } public class SerachRangeUseCase : IRangeUseCase { private ReactiveProperty<int> range{get;set;} public class Factory : DefaultUseCaseFactory<SerachRangeUseCase> { protected override void Initialize(SerachRangeUseCase instance) { base.Initialize(instance); instance.range = new ReactiveProperty<int>(100); } } public int GetRange() { return range.Value; } public void UpdateRange(string value) { int res=range.Value; if(int.TryParse(value,out res)) range.Value = res; } public IObservable<int> OnChangeRangeAsObservable() { return range; } } }
3. Presentetion
CAFU v2ではPresenter、Controller、Viewレイヤーが属している層です。描画や入力の定義(View)、シーン全体で発生するイベントやPresenterの初期化(Controller)、ViewとUseCaseのつなぎこみ(Presenter)などを担当しています。
3.1. Presenter
ViewとUseCaseのつなぎこみを担当しているレイヤーです。このレイヤーでは状態管理やロジックなどの処理は行わず、ViewとUseCaseのつなぎこみだけに専念します。
今回の場合、検索処理UseCase(ISearchPlayerUseCase)と表示データ数UseCase(IRangeUseCase)のインスタンスを保持しており、そこからメソッドを呼びだす流れになっています。
using CAFU.Core.Presentation.Presenter; using System; using System.Collections.Generic; using Domain.UseCase; using Domain.Model; using IPresenter = CAFU.Core.Presentation.Presenter.IPresenter; namespace Presentation.Presenter { public class SampleScenePresenter : IPresenter { private ISearchPlayerUseCase serach { get; set; } private IRangeUseCase range { get; set; } public class Factory : DefaultPresenterFactory<SampleScenePresenter> { protected override void Initialize(SampleScenePresenter instance) { base.Initialize(instance); instance.serach = new SearchPlayerUseCase.Factory().Create(); instance.range = new SerachRangeUseCase.Factory().Create(); } } //検索の実行 public void ExecuteSerach(SerachType type) { serach.ExecuteSerach(type); } //検索処理 public void Serach(SerachType type, int range) { serach.Serach(type, range); } //検索実行時に走らせたい処理を登録するためのObservableを取得 public IObservable<SerachType> OnSearchAsObservable() { return serach.OnSearchAsObservable(); } //検索実行後に走らせたい処理を登録するためのObservable public IObservable<List<IPlayerModel>> OnSerachedAsObservable() { return serach.OnSerachedAsObserable(); } public int GetRange() { return range.GetRange(); } public void UpdateRange(string value) { range.UpdateRange(value); } //表示するデータ数を変えたタイミングでに走らせたい処理を登録するためのObservable public IObservable<int> OnChangeRangeAsObservable() { return range.OnChangeRangeAsObservable(); } } }
3.2. View(Controller)
View全体に発生するイベントを管理したり、Presenterの初期化や、提供を行うレイヤーです。各ViewはControllerで生成された、staticなインスタンスを参照することで、Presenterに命令を飛ばします。
今回の場合でも、Presenterを初期化しています。また全てのボタンViewでクリック時に表示するデータ数を検索メソッドの引数に投げる処理が必要だったため、Controllerでつなぎこみを行なっています。
using CAFU.Core.Presentation.View; using Presentation.Presenter; using UniRx; namespace Presentation.View.SampleScene { public class Controller : Controller<Controller, SampleScenePresenter, SampleScenePresenter.Factory> { protected override void OnStart() { base.OnStart(); this.GetPresenter().OnSearchAsObservable() .Subscribe(_type => this.GetPresenter().Serach(_type,this.GetPresenter().GetRange())) .AddTo(this); } } public static class ViewExtension { public static SampleScenePresenter GetPresenter(this IView view) { return Controller.Instance.Presenter; } } }
3.3. View
描画や入力の定義を行うレイヤーです。ビジネスロジックはUseCaseに全て投げているので、Viewはビジュアルの操作に専念することができます。
またボタンなどの入力に関するViewは、PresenterからUseCaseのObservableを参照することで、イベントをSubscribleします。
今回の場合、画面に存在するUIごとにViewを定義しています。以下のコードはボタンと結果表示のViewから抜粋しています。
using CAFU.Core.Presentation.View; using UniRx; using UnityEngine; using UnityEngine.UI; namespace Presentation.View.SampleScene { [RequireComponent(typeof(Button))] public class SerachAllButton : MonoBehaviour, IView { private Button serachButton; private void Awake() { this.serachButton = this.GetComponent<Button>(); } private void Start() { serachButton.OnClickAsObservable() .Subscribe(_ => this.GetPresenter().ExecuteSerach(SerachType.All)) .AddTo(this); } } }
using CAFU.Core.Presentation.View; using UniRx; using UnityEngine; using UnityEngine.UI; using Domain.Model; namespace Presentation.View.SampleScene { [RequireComponent(typeof(InputField))] public class ResultField : MonoBehaviour, IView { private InputField resultField; private void Awake() { resultField = this.GetComponent<InputField>(); } private void Start() { this.GetPresenter().OnSerachedAsObservable() .Subscribe(_res => { resultField.text = ""; foreach(PlayerLogModel res in _res) { resultField.text+=res.DataFullPath; resultField.text+=" \n"; } }) .AddTo(this); } } }
4. でげだ
動くものができました!疎結合?なので、例えば表示方法を変えるみたいな仕様変更がおきてもViewレイヤーだけいじれば簡単に変更できます。気持ちいい。
触ってみた感想
・今までアーキテクチャを適用するためのモジュールは使ったことがありませんでしたが、自然とプロジェクトの中にルールが構築されて精神衛生面上にもいい感じに設計、開発ができた気がします。
・また今回はcafu_core以外にもcafu_generatorやcafu_genericを使用してみましたが、とても便利でした。
・一方でcafu_genericのGenericDataStoreでエディタ実行時にDataStoreが見つからないという感じのエラーを吐いてたので(一度DataStoreを持ったオブジェクトを消して改めて作ると直る)、今後の更新を踏まえてもCAFU v3を次は使おうと思いました。
・CAFU v3ではUniRX以外にもZenejct(今はExtenject?)が使われていたり、レイヤーの見直しが行われていたりとかなり変化があるようなので、早く触っておきたいと思いました。
・疎結合ならテストコードも書きやすいはずなので、シミュレーションしてみたいと思いました。
最後に
疎結合最高!広島カープに栄光あれ!