シリーズ目次
①設計を学ぶ意義
②複雑な分岐にState, 要求と実行の分離にCommand
③DIとPub/Subと疎結合
④クリーンアーキテクチャ入門 ← 今ここ
⑤保守性を高めるテスト, モジュール境界の強制
今回の目的
- 主にジュニアエンジニア向けにクリーンアーキテクチャの解説をする
アーキテクチャとは何か?
ソフトウェア開発において、アーキテクチャという言葉は二通りに使われます。
- 実務的な意味 – サーバ・クライアント・ネットワークなどを含むシステム全体の構造・配置図
- 概念的な意味 – 依存の向きや責務の分け方などを示す設計の原理原則・思想
本記事が扱うのは2の概念的アーキテクチャです。
デザインパターンが再利用できる小さな設計定型を示すのに対し、
概念的なアーキテクチャは守るべき原理原則を指し示す大枠のガイドラインです。
そのため、実態や細部はプロジェクトの文脈に合わせて作られるものだと思って頂きたいです。
いくつかのアーキテクチャを組み合わせて開発することもあります。
概念的なアーキテクチャの例
今回はUnityでのアプリの長期開発を想定し、クリーンアーキテクチャを取りあげます。
他にも様々なアーキテクチャがあるので、文脈に合わせて使うのが理想かと思います。
クリーンアーキテクチャ
コア部分(=核心のルール)を守る発想を第一とする。
コア部分を複数の層(=機能的なまとまり)で覆う。
層の順はドメインとの関係や変更頻度で決まり、依存は常に外から内の一方向へのみ流す。
マイクロカーネルアーキテクチャ
長期間進化させる前提のソフトを最小コアとプラグインで構築し、
コアを安定させつつ、外部機能をホットスワップできるようにする。
etc...
クリーンアーキテクチャの長所・短所
クリーンアーキテクチャの概要は先ほどのアーキテクチャの例示で触れました。
以下、長所・短所です。
長所
- View(見た目)や外部APIを変更しても中心部は無傷
- 中心部は純C#なので、テストが容易
短所
- 初期の設計にコストがかかる
- 小規模開発やプロトタイプの場合、設計のコストが保守性のリターンを上回ってしまう
- その場合はMVP/MVVMなど軽量パターンで実装するのが良い
クリーンアーキテクチャの実装例
まず、実装に必要な知識を説明していきます。
クリーンアーキテクチャを学校のグループ課題にたとえる
各レイヤーを説明しますが、学校のグループ課題にたとえて説明させてください。
1つのグループの中での話をします。
レイヤ | 実装例 | たとえ | 補足 |
---|---|---|---|
Presentation | ・ボタン押下を検知 ・TextやImageを更新 |
発表メンバー | ・メンバーは誰でもOK ・変えても他への影響は無 |
Application | ・スコア取得→計算 →保存の手順を担う |
段取り係 | ・発表までの段取りを変更 ・新しい作業を追加する |
Domain | スコア上限等 アプリの核 |
テーマ・評価基準・ 提出形式等のルール |
・一貫して守るもの ・ここがブレるのは極力NG |
Infrastructure | PlayerPrefs/Httpsなど 外部I/Oとモデル変換 |
図書館 PC/プリンタ |
・内外のリソースを指す ・どのように代替してもOK |
各レイヤーはたとえに近い役割を担っています。
実装の際は属しているレイヤーを踏まえて
「ここで実装すべきか・他のレイヤーで実装すべきか」を考えながら行う必要があります。
また、核となるドメインを守るために
依存は外から内への一方向(Infrastructure, Presentation → Application → Domainなど)
である必要があります。
この関係を図示化すると、有名な画像のようになります。
なお、本記事ではUnityでの実装がイメージしやすいという理由から、
クリーンアーキテクチャの具体化の1つであるオニオンアーキテクチャの名称に近い
「Domain/Application/Presentation/Infrastructure」を採用します。
Clean Architecture原典の図と対応付けると、Domain=Entities, Application=Use Cases、Presentation=Interface Adapters, Infrastructure=Frameworks & Driversとなります。
クリーンアーキテクチャとオニオンアーキテクチャの関係の詳細は以下をご覧いただければと思います。
https://zenn.dev/streamwest1629/articles/no-clean_like-clean_its-onion-solid
なぜ依存は外から内への一方向である必要があるのか
依存の向きとは
クラスAのコード内でクラスBの名前を書いている時、A→Bの方向で依存しています。
このとき、Bを修正すればAが影響を受ける可能性が高いことになります。
依存が内から外になると何が問題なのか
Domain → Infrastructureの方向に依存してしまった場合の悪い例を取り上げます。
まずはドメインです。
public class Score
{
const string Key = "Score";
public int Add(int gain)
{
// Infrastructureに依存
var v = PlayerPrefs.GetInt(Key, 0);
v += gain;
// Infrastructureに依存
PlayerPrefs.SetInt(Key, v);
return v;
}
}
この時、保存先をPlayerPrefsからサーバに変更することとなったとします。
ドメインは本質だけ他から分離して実装されるべきにもかかわらず、大幅に変更する必要が生じてしまいます。
ドメインに依存しているクラスの処理にも影響が出てきます。
public class ScoreCounter
{
readonly HttpClient _http = new();
const string Url = "https://api.example.com/score";
public async Task<int> AddAsync(int gain)
{
var v = int.Parse(await _http.GetStringAsync(Url));
v += gain;
await _http.PutAsync(Url, new StringContent(v.ToString()));
return v;
}
}
そのため、依存はこの場合Infrastructure → Domainの方向の必要があります。
また、アプリケーションレイヤーはドメインと関係を持つ制御役のレイヤーです。
このレイヤーはドメインの次に不変である必要があります。
こうして、連鎖的にレイヤーを考えると、依存が外から内への一方向である必要性が理解できます。
実装例
シンプルなクイズアプリを実装します。
正解のボタンを押すと10点加点され、値はローカルに保存されます。
実はクリーンアーキテクチャを導入するにはシンプルすぎるアプリになります。
ただ、説明したいのは後からサーバ同期などを足しても、
ドメインのクラスは一行も触ることがないということです。
Domainレイヤー
グループ課題のたとえ: テーマ・評価基準・提出形式等のルール
変更すべきでない重要事項のみを書きます。
public class Score
{
public int Value { get; }
public Score(int rawValue)
{
Value = Math.Clamp(rawValue, 0, 10_000);
}
public Score Add(int inc)
{
return new Score(Value + inc);
}
}
なお、この層でInterfaceを作成します。
目的は依存を内側への一方向にすることや、拡張性のため他レイヤーと疎結合にすることです。
後ほどInfrastructure層で使うことになります。
// 永続化リポジトリ
public interface IScoreRepository
{
UniTask<int> LoadAsync();
UniTask SaveAsync(int score);
}
Applicationレイヤー
グループ課題のたとえ: 段取り係
Domainを使って、ユーザーの目的を達成する手順を定義します。
// DTO
public struct AddScoreRequest { public int Gain; }
public struct AddScoreUpdated { public int Total; }
// 具体
public class AddScoreInteractor : IStartable, IDisposable
{
private IScoreRepository repository;
private IPublisher<AddScoreUpdated> publisher;
private ISubscriber<AddScoreRequest> addScoreRequested;
private IDisposable subscription;
private Score current;
public AddScoreInteractor(
IScoreRepository repository,
IPublisher<AddScoreUpdated> publisher,
ISubscriber<AddScoreRequest> addScoreRequested
)
{
this.repository = repository;
this.publisher = publisher;
this.addScoreRequested = addScoreRequested;
}
public void Start()
{
InitializeAsync().Forget();
subscription = addScoreRequested.Subscribe(req => HandleAsync(req).Forget());
}
public void Dispose() => subscription?.Dispose();
async UniTask InitializeAsync()
{
int raw = await repository.LoadAsync();
current = new Score(raw);
publisher.Publish(new AddScoreUpdated { Total = current.Value });
}
async UniTask HandleAsync(AddScoreRequest req)
{
current = current.Add(req.Gain);
await repository.SaveAsync(current.Value);
publisher.Publish(new AddScoreUpdated { Total = current.Value });
}
}
ただし、説明のために省きましたが、ここでScoreをnewするのではなく、
Factoryのようなクラスがあっても良いかもしれません。
Presentationレイヤー
グループ課題のたとえ: 発表メンバー
UIなど見た目部分は変更頻度が高く、Applicationレイヤーより外となります。
// Presenter
public class AddScorePresenter : IStartable, IDisposable
{
private ScoreView view;
private IPublisher<AddScoreRequest> publisher;
private ISubscriber<AddScoreUpdated> scoreUpdated;
private IDisposable subscription;
public AddScorePresenter(
ScoreView view,
IPublisher<AddScoreRequest> publisher,
ISubscriber<AddScoreUpdated> scoreUpdated
)
{
this.view = view;
this.publisher = publisher;
this.scoreUpdated = scoreUpdated;
}
public void Start()
{
// ボタン押下 → リクエスト発行
view.CorrectAnswerClicked += OnCorrectAnswer;
// スコア更新通知を購読
subscription = scoreUpdated.Subscribe(res => Present(res));
}
public void Dispose()
{
view.CorrectAnswerClicked -= OnCorrectAnswer;
subscription?.Dispose();
}
private void OnCorrectAnswer()
{
publisher.Publish(new AddScoreRequest { Gain = 10 });
}
public void Present(AddScoreUpdated res)
{
view.Render(res.Total);
}
}
// View
public class ScoreView : MonoBehaviour
{
[SerializeField] private TMP_Text scoreText;
public event Action CorrectAnswerClicked;
public void OnCorrectAnswerButton() => CorrectAnswerClicked?.Invoke();
public void Render(int total) => scoreText.text = $"{total} Point";
}
Infrastructureレイヤー
グループ課題のたとえ: 図書館・PC/プリンタ
PlayerPrefsで実装していますが、サーバ保存になることもあります。
変わりやすいレイヤーである前提で実装しましょう。
public class PlayerPrefsScoreRepository : IScoreRepository
{
private const string Key = "Score";
public UniTask<int> LoadAsync()
{
return UniTask.FromResult(PlayerPrefs.GetInt(Key, 0));
}
public UniTask SaveAsync(int score)
{
PlayerPrefs.SetInt(Key, score);
PlayerPrefs.Save();
return UniTask.CompletedTask;
}
}
DIコンテナの設定
最後に前回の記事で言及したVContainerを使って、依存性注入を行います。
public class QuizLifetimeScope : LifetimeScope
{
[SerializeField]
private ScoreView scoreView;
protected override void Configure(IContainerBuilder builder)
{
// MessagePipe
var options = builder.RegisterMessagePipe();
builder.RegisterMessageBroker<AddScoreRequest>(options);
builder.RegisterMessageBroker<AddScoreUpdated>(options);
// Infrastructure
builder.Register<IScoreRepository, PlayerPrefsScoreRepository>(Lifetime.Singleton);
// Presentation
builder.RegisterComponent<ScoreView>(scoreView);
builder.RegisterEntryPoint<AddScorePresenter>(Lifetime.Scoped);
// Application
builder.Register<AddScoreInteractor>(Lifetime.Singleton).As<IStartable>();
}
}
このようにして核を守り、層で覆って、
依存を内側一方向にするクリーンアーキテクチャを実装することができます。
複数ドメインがある時に起こりうる問題について
例で扱ったのは1つのドメインのみでした。
実際の開発では複数のドメインを扱うことが普通です。
この複数のドメインをどう扱うかは状況次第ですが、
以下のような問題が発生する可能性があります。
問題の発生例: 相互newが発生する場合
ドメインが複数あるということは
アプリケーションレイヤーの実装も複数存在します。
この例では互いにnewしてしまった場合を取り上げます。
// アプリケーションレイヤーA スコア加算
public class ScoreInteractor
{
private readonly AchievementInteractor achievement;
// 違うドメインのアプリケーションレイヤーのクラスを参照
public ScoreInteractor(AchievementInteractor ach) => achievement = ach;
public void Add(int gain)
{
total += gain;
achievement.OnScoreUpdated(total);
}
private int total;
}
// アプリケーションレイヤーB 実績解除
public class AchievementInteractor
{
private readonly ScoreInteractor score;
// 違うドメインのアプリケーションレイヤーのクラスを参照
public AchievementInteractor(ScoreInteractor s) => score = s;
public void OnScoreUpdated(int total)
{
if (miss) score.Add(-5);
}
private bool miss = false;
}
この場合、手動で依存性注入しようとしても出来ません。
var a = new ScoreInteractor( /* Achievement が要る */ );
var b = new AchievementInteractor(a); // ここでBを作るにはAが要る
a = new ScoreInteractor(b); // Aを作り直すにはBが要る
同じ理由でDIコンテナを使ってもエラーとなります。
問題例の解決方法
この場合は前回の記事で解説したMessagePipeを使えば解決します。
互いの名前を知らない状態でイベントを送ることが出来るためです。
イベントDTOを定義
public readonly struct ScoreChanged
{
public int Delta;
}
ScoreInteractorで発行を定義
pub.Publish(new ScoreChanged { Delta = diff });
AchievementInteractorで購読
sub.Subscribe(async e =>
{
/** 処理 **/
}).AddTo(disposable);
VContainerでMessagePipelineの設定
protected override void Configure(IContainerBuilder b)
{
var mp = b.RegisterMessagePipe();
b.RegisterMessageBroker<ScoreChanged>(mp);
b.Register<ScoreInteractor>(Lifetime.Singleton);
b.Register<AchievementInteractor>(Lifetime.Singleton).As<IStartable>();
}
他にも複数ドメインを使うことによる問題やその解決方法が存在すると思います。
都度対処していくことが出来れば良いかと思います。
まとめ
- アーキテクチャに正解はない:プロジェクトに合わせて選択・組み合わせる
- クリーンアーキテクチャは「核を守り」「層で覆い」「依存を内向きに一方向」
- 複数ドメインの時に問題が発生した場合、Pub/Subパターンなどで対処する
次はアーキテクチャを守るためのテスト・CIです。