LoginSignup
34
22

More than 3 years have passed since last update.

CleanArchitectureでひとつ『上』 のコードを目指す:実装編

Last updated at Posted at 2019-09-06

前回の記事(概念編)からの続きです。是非こちらもご覧ください。
CleanArchitectureを適用したBMI計算アプリの実装について解説します。
言語はC#、実行環境はUnityです。

依存

プログラムにおける依存とは主に「クラスAがクラスBに依存している」といった表現で使われます。この場合、「クラスBがクラスAの一部になっている」「クラスAはクラスBの中身を知っている」ということを表し、「クラスAはクラスBの上位モジュールである(=クラスBはクラスAの下位モジュールである)」わけです。

依存

上位/下位という言葉を使うと上位は下位より偉いと捉えられますが、一概にそうとは言えません。クラスAがいないとクラスBの存在理由が無いのはそうなのですが、クラスAもクラスB無しでは自分の一部を失うことになります。「代わりはいくらでもいる」とはならないのです。

抽象

「代わりはいくらでもいる」とクラスAがクラスBに対して強く言えるようになる方法に 抽象化 があります。クラスBの機能を整理して、インタフェースBに抽象化します。クラスAはそのインタフェースBに依存するようにすれば、クラスAはクラスBに対して「うちはインタフェースBを実現しているクラスなら誰でも良いんだ。代わりはいくらでもいる。」といえるようになります。

抽象

抽象化を実現する方向には interfaceabstract class を使う2つの方法がありますが、C#ではクラスの多重継承ができないので、interfaceを使う方が多いと思います。実装も共通化させたいなどの事情があればabstract classを使ってください。

さて、CleanArchitecture以前に設計の原則として 依存性逆転の原則 1というものがあります。
抽象化前はクラスAからクラスBの右向きの矢印一本しかなかったですが、抽象化によってクラスBからクラスA(インタフェースB)に向かう左向きの矢印が出来ました。これが依存性逆転です。
今までは一方的にクラスAに使われる側だったクラスBもインタフェースBを 実現してやってる 上位の立場になったわけです。(クラスAに見捨てられても「私はインタフェースBを実現したクラスです」と宣伝すれば別のクラスに使ってもらえるかもしれません。)

境界を超える

CleanArchitectureのルールとして、外側の層が内側の層に依存する というものがあります。
画像左側の3本の並んだ矢印がそれを表しています。

CleanArchitecture
外側が内側を知るのはいいが、内側が外側を知るのはダメということです。

前の記事でUseCaseについて

数字の入力をPresenter経由で受け、BMI計算を呼び出し、結果をPresenterに返す実装をUseCaseで行います。

という説明をしました。これではあたかも

BMIUseCase.cs
class BMIUseCase {
  BMIPresenter presenter = new BMIPresenter();
  ...
}

の様に BMIUseCaseBMIPresenter に依存した作りになっていると想像されたかもしれません。

BMI依存

これは、「外から内に依存する(内から外に依存してはいけない)」という決まりに反しています。しかし、BMIを計算するというUseCaseをBMIUseCaseとして実装するためには数値入力のViewと接続したBMIPresenterが必要になります。この内側から外側への依存を回避するために依存性逆転というトリックを使います

IBMIPresenterというinterfaceを定義します。そして、BMIUseCaseからはIBMIPresenterを参照し、それをBMIPresenterで実現するようにします。
BMI抽象

ここで留意すべきはinterfaceのIBMIPresenterは緑のInterface Adaptersではなく赤のApplication Business Rulesに属すると言う所です。2
理由はIBMIPresenterを決定するのはPresenterやViewではなく、UseCaseだからです。BMIの計算と表示を行うUseCaseで使うPresenterの定義がIBMIPresenterに現れていて、BMIPresenterはそれを実現したに過ぎないという訳です。

これにより内側は外側を知らず、外側が内側の定義を知っているだけになり、「外から内に依存する(内から外に依存してはいけない)」というルールを守ることができます。

このように、CleanArchitectureで境界を超えるときは抽象化による依存性逆転が便利な場面が多いです。
前記事のDomain/UseCase/Presenter/Viewをすべて表すと以下の図になります。
image.png

各層の外側から内側に矢印が向くように作ってあります。Domainにて抽象化を行っていない点が恣意的ですが、この層は変更が行われることが少ないことや、Domainは単にUseCaseから利用される層なのでこのようにしています。必要性を感じるなら抽象化を行ってください。

※ Domain=CleanArchitectureの図で言う所のEntitiesです。本記事ではDomainという言葉を使っています。

黒幕は誰だ?

UseCaseを中心に考えて実装を行うと

BMIUseCase.cs
class BMIUseCase {
  IBMIPresenter presenter;
  public BMIUseCase(IBMIPresenter presenter) {
    this.presenter = presenter;
  }
  ...
}
interface IBMIPresenter {
  ...
}
BMIPresenter.cs
class BMIPresenter : IBMIPresenter {
  IBMIView view;
  public BMIPresenter(IBMIView view) {
    this.view = view;
  }
  ...
}
interface IBMIView {
  ...
}
BMIView.cs
class BMIView : IBMIView {
  ...
}

のような構造になります。
BMIUseCaseIBMIPresenterに依存し、コンストラクタで外からIBMIPresenterを実装したクラスのインスタンスを受け取れるようになっています。
interfaceなどで抽象化した要素に具体的なインスタンスを渡して抽象を解決する処理を依存性の注入(Dependency injection)といいます。依存性の注入を行う人物が裏で糸を引いて、各層の実装を引き合わせることでBMIを求めるアプリが完成するのです。正に黒幕です。

money_yami_soshiki.png

実際に依存性の注入を行う場合ですが、プログラムの起動時にMainでまとめて行う場合もあれば、DIライブラリを使って行う場合などがあります。

main.cs
// mainで依存性の注入を行う例
int main(string[] args) {
  var view = new BMIView();
  var presenter = new BMIPresenter(view); // presetnerにはviewが必要
  var usecase = new BMIUseCase(presenter); // usecaseにはpresenterが必要
  ...
}

Unityでの実装

Unityのバージョンは2019.1.14です。

画面イメージ

image.png

身長と体重を入力できる InputField とBMIの結果を表示する Text を配置します。
名前/年齢/性別の入力は何となく付けました。

View

UIによるユーザへの入出力を行う部分です。Unityの都合による影響をモロに受ける部分でもあります。

using UnityEngine;
using UnityEngine.UI;

namespace BMIApp {
    public interface IView { }
    public interface IBMIView : IView {
        InputField NameInput { get; }
        InputField HeightInput { get; }
        InputField WeightInput { get; }
        InputField AgeInput { get; }
        Toggle GenderMaleToggle { get; }
        Toggle GenderFemaleToggle { get; }
        Text BMIText { get; }
        Button SaveButton { get; }
    }
    public class BMIView : MonoBehaviour, IBMIView {
        [SerializeField] InputField nameInput = default;
        [SerializeField] InputField heightInput = default;
        [SerializeField] InputField weightInput = default;
        [SerializeField] InputField ageInput = default;
        [SerializeField] Toggle genderMaleToggle = default;
        [SerializeField] Toggle genderFemaleToggle = default;
        [SerializeField] Text bmiText = default;

        public InputField NameInput => nameInput;
        public InputField HeightInput => heightInput;
        public InputField WeightInput => weightInput;
        public InputField AgeInput => ageInput;
        public Toggle GenderMaleToggle => genderMaleToggle;
        public Toggle GenderFemaleToggle => genderFemaleToggle;
        public Text BMIText => bmiText;
    }
}

IView

CleanArchitectureに則った構成であることを示すために定義したinterfaceです。別に定義しなくても実装できない訳ではありません。

IBMIView

BMIの入力・出力を行うUIの要素群を定義したinterfaceです。Interface Adapters層の住人になります。

BMIView

IBMIViewを実現した具体的なクラスです。Unityで動かす都合上MonoBehaviourを継承しします。Frameworks & Drivers層の住人になります。

Presenter

ViewとUseCaseを繋ぐ層です。

  • UseCase のUIのイベントを通知する
  • UseCase から の情報をUIに反映する

という双方向のデータ処理を実現する必要があります。UseCaseへの通知を今回はUniRxを導入し、IReadOnlyReactivePropertyを使って実現しました。

using System;
//using UnityEngine.UI; 禁止!!
using UniRx;

namespace BMIApp {
    public interface IPresenter { }
    public interface IBMIPresenter : IPresenter {
        IReadOnlyReactiveProperty<string> NameInput { get; }
        IReadOnlyReactiveProperty<string> HeightInput { get; }
        IReadOnlyReactiveProperty<string> WeightInput { get; }
        IReadOnlyReactiveProperty<string> AgeInput { get; }
        IReadOnlyReactiveProperty<bool> GenderMaleSelect { get; }
        IReadOnlyReactiveProperty<bool> GenderFemaleSelect { get; }
        void Bind();
        void SetBMIResult(string result);
    }
    public class BMIPresenter : IBMIPresenter {
        readonly IBMIView view;
        public BMIPresenter(IBMIView view) { this.view = view; }

        public IReadOnlyReactiveProperty<string> NameInput { private set; get; }
        public IReadOnlyReactiveProperty<string> HeightInput { private set; get; }
        public IReadOnlyReactiveProperty<string> WeightInput { private set; get; }
        public IReadOnlyReactiveProperty<string> AgeInput { private set; get; }
        public IReadOnlyReactiveProperty<bool> GenderMaleSelect { private set; get; }
        public IReadOnlyReactiveProperty<bool> GenderFemaleSelect { private set; get; }

        public void Bind() {
            NameInput = view.NameInput.OnEndEditAsObservable().ToReadOnlyReactiveProperty();
            HeightInput = view.HeightInput.OnEndEditAsObservable().ToReadOnlyReactiveProperty();
            WeightInput = view.WeightInput.OnEndEditAsObservable().ToReadOnlyReactiveProperty();
            AgeInput = view.AgeInput.OnEndEditAsObservable().ToReadOnlyReactiveProperty();
            GenderMaleSelect = view.GenderMaleToggle.OnValueChangedAsObservable().ToReadOnlyReactiveProperty();
            GenderFemaleSelect = view.GenderFemaleToggle.OnValueChangedAsObservable().ToReadOnlyReactiveProperty();
        }

        public void SetBMIResult(string result) {
            view.BMIText.text = result;
        }
    }
}

IPresenter

CleanArchitectureに則った構成であることを示すために定義したinterfaceです。無駄だと思うなら不要です。

PresenterでViewの要素(Buttonとか)を公開するプロパティを持ってしまうとUseCaseがViewの要素にアクセスできてしまいPresenterの層が意味を為しません。ここでのusing UnityEngine.UIは禁止するのが吉です

IBMIPresenter

BMI計算アプリUIのin/outを定義したinterfaceです。Application Business Rules層の住人になります。

BMIPresenter

IBMIPresenterを実現した具体的なクラス。IBMIViewをコンストラクタで受け取り、そいつからのイベントを外部に公開するのと同時に、そいつへのデータの反映も行います。Interface Adapters層の住人です。

Domain

業務ロジックの層です。BMI計算ロジックが含まれます。

namespace BMIApp {
    public interface IBMIDomainObject {
        float Height { set; get; } // [cm]
        float Weight { set; get; } // [m]
    }
    public class BMIDomain {
        public float CalcBMI(IBMIDomainObject domainObject) {
            if (domainObject.Height <= 0.0F || domainObject.Weight <= 0.0F) {
                return 0.0F;
            }
            var h = domainObject.Height / 100.0F; // cm -> m
            return domainObject.Weight / (h * h);
        }
    }
}

UseCase

アプリケーションの挙動の実装を行います。Viewを直接参照せずにPresetnerから操作を行ったり、BMI計算処理などはDomainに任せたりするなどして、アプリケーションの挙動に集中させます。

using System;
using UnityEngine;
using UniRx;

namespace BMIApp {
    public interface IUseCase {
        void Begin();
    }
    public class BMIUseCase : IUseCase {
        readonly IBMIPresenter bmiPresenter;
        readonly Component disposableComponent;
        readonly BMIDomain bmiDomain;
        public BMIUseCase(IBMIPresenter bmiPresenter, Component disposableComponent) {
            this.bmiPresenter = bmiPresenter;
            this.disposableComponent = disposableComponent;
            this.bmiDomain = new BMIDomain();
        }
        public void Begin() {
            var dto = new BMIDataTransferObject();
            bmiPresenter.Bind();
            bmiPresenter
                .NameInput
                .Subscribe(x => {
                    dto.Name = x;
                })
                .AddTo(disposableComponent);
            bmiPresenter
                .HeightInput
                .Subscribe(x => {
                    if (float.TryParse(x, out var val)) {
                        dto.Height = val;
                        dto.BMI = bmiDomain.CalcBMI(dto);
                        bmiPresenter.SetBMIResult($"{dto.BMI:F1}");
                    }
                })
                .AddTo(disposableComponent);
            bmiPresenter
                .WeightInput
                .Subscribe(x => {
                    if (float.TryParse(x, out var val)) {
                        dto.Weight = val;
                        dto.BMI = bmiDomain.CalcBMI(dto);
                        bmiPresenter.SetBMIResult($"{dto.BMI:F1}");
                    }
                })
                .AddTo(disposableComponent);
            bmiPresenter
                .AgeInput
                .Subscribe(x => {
                    if (int.TryParse(x, out var val)) {
                        dto.Age = val;
                    }
                })
                .AddTo(disposableComponent);
            Observable
                .CombineLatest(bmiPresenter.GenderMaleSelect,  bmiPresenter.GenderFemaleSelect)
                .Subscribe(x => {
                    if (x[0]) {
                        dto.Gender = Gender.Male;
                    } else if (x[1]) {
                        dto.Gender = Gender.Female;
                    } else {
                        dto.Gender = Gender.None;
                    }
                })
                .AddTo(disposableComponent);
        }
    }
    public enum Gender { None = 0, Male, Female }
    public class BMIDataTransferObject : IBMIDomainObject {
        public string Name { set; get; }
        public int Age { set; get; }
        public Gender Gender { set; get; }
        public float BMI { set; get; }
    }
}

コンストラクタでIBMIPresenterを受け取り、Begin() にてIBMIPresenterが公開しているIReadOnlyReactivePropertyをすべて購読(Subscribe())します。UIの入力に応じてBMI計算ロジックを呼び出して画面に結果を反映させるなど、アプリの処理がすべてBegin()に記述されています。UseCaseには難しいことはさせず、子供でもできることだけを記述するのがいいでしょう。例えば入力のバリデーションをしたくなった場合にもBMIUseCaseにべた書きするのではなく、専用のDomainやUseCaseを新たに追加することを考えてください。

Main

今まで出てきたView/Presenter/UseCaseは全てコンストラクタで要素を受け取って依存性の注入ができるようになっています。必要なインスタンスを各自に渡して依存性の注入を行い、アプリの挙動を決定・開始させる役目をMainに任せます。
Unityの場合、MainにはMonoBehaviourを継承させてシーンに配置することで以下を行えるようにします。

  • [SerializeField]などでシーン上のUnityのコンポーネント(ここではView)を受け取る
  • Awake()でPresenter/UseCaseを作成
  • Start()でUseCaseを実行
using UnityEngine;

namespace BMIApp {
    public class BMISceneMain : MonoBehaviour {
        [SerializeField] BMIView bmiView = default; // from inspector
        IUseCase bmiUseCase;

        void Awake() {
            bmiUseCase = new BMIUseCase(new BMIPresenter(bmiView), this);
        }

        void Start() {
            bmiUseCase.Begin();
        }
    }
}

Mainに書くのはこれだけです(シンプルですね)。アプリのロジックは全てUseCaseを中心に構成されています。

クラス図

各クラスの関係を表したクラス図です。UseCaseは抽象化されたPresenterしか知らず、Presenterの先にView(MonoBehaviourなどUnityの世界)があることなど知りえないのです。Mainを全てを知る立場に添えて、裏で各要素を繋げてアプリを走らせる役割を任せています。

image.png

まとめ

他のCleanArchitectureの記事にある実装例とは合致していないかもしれませんが、私なりに腑に落ちるように噛み砕いてUnityの実装まで落とし込みました。作るScriptのファイルが多くなるのでいざ作ると大変ではありますが、MainやUseCaseに実装する処理が明確でシンプルになった手ごたえを感じています。また、各層が抽象化されているのでUseCaseなどをテストする場合も簡単にできそうです。

GitHub

naninunenoy/UnityViewPatterns/BMIApp
色々(シーン跨ぐ場合や複数UseCaseがある場合など)試したので、今は仮ログインや履歴表示の機能などが盛り込まれてます。

image.png


  1. Unity開発で使える設計の話+Zenjectの紹介 素晴らしい資料なので是非読んでください 

  2. umm/cafu_core レイヤー定義 大いに参考にさせてもらいました 

34
22
1

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
34
22