こちらは Unity Adnvent Calender 2024 その2 17日目の記事になります。
概要
オニオンアーキテクチャを使って、Unityと.NETでコードを共有しながらTODOアプリを作成する方法を解説します。
アプリの説明
Unityと.NETコンソールアプリの2つのアプリのスクリーンショットです。
- Unity
- .NETコンソールアプリ
TODOアプリとしてのベーシックな機能があります。
- 文字列を入力したらTODOメモが追加される
- 完了したら
Done
になる
加えてアプリの特徴として、以下のようにユーザーを甘やかさない厳し目のTODOアプリになっています
- 必ず一定時間で期限切れにする
- 一度期限切れになったら、完了にはできない
→ TODO決めたら、すぐやれ! JUST DO IT!
(アドベントカレンダーの締め切り間に合わなくて申し訳ありません。)
本文
オニオンアーキテクチャとは
オニオンアーキテクチャは、システムを複数のレイヤーに分割し、依存性を内側のレイヤーに向けるアーキテクチャです。以下の特徴があります。
- ドメイン中心設計: アプリケーションのビジネスルールを中心に設計
- 依存性の方向: 外側のレイヤーが内側のレイヤーに依存するが、逆はない
- テスト容易性: ドメイン層が他のレイヤーから独立しているため、ユニットテストが容易になる
主要なレイヤーは以下です。
- ドメイン層: ビジネスルールやエンティティ
- ユースケース層: ユースケースやアプリケーションロジック
- インフラ層: データベースや外部サービスとの連携
- UI層: ユーザーインターフェース
TODOアプリの設計
TODOアプリでは、以下の要素を設計します:
-
ドメイン層:
Todo
エンティティや TODOの制限ルール - ユースケース層: TODOリストの追加・削除・一覧取得のユースケース
- インフラ層: 永続化のためのデータアクセス
-
UI層: ユーザーが直接ふれるユーザーインターフェース部分
- ここはプラットフォーム固有になるので、Unityと.NETで個別に実装
プロジェクト構成
コンソールアプリのソリューション内に共通コードを別csprojとして配置します。
/LifeTodoConsole
/Domain
- LifeTodo.Domain.csproj
- Todo.cs
...
/UseCase
- LifeTodo.UseCase.csproj
- TodoAppService.cs
...
/Infra
- LifeTodo.Infra.csproj
- InMemoryTodoRepository.cs
...
/ConsoleApp
- LifeTodo.ConsoleApp.csproj
- Program.cs
...
/Test
- LifeTodo.Test.csproj
- Todo_Test.cs
...
/LifeTodoUnity
/Assets/Scripts
/Views
- Views.asmdef
- TodoAddView.cs
...
/Installers
- Installers.asmdef
- MyInstaller.cs
- LifeTodoConsole: コンソールアプリと共通コードを含んだソリューション
- LifeTodoUnity: Unityアプリ。
コード共通化の方法
今回は LifeTodoConsole
内の共通コードを含んだDLLを LifeTodoUnity
から参照しています。
共通コードの変更のたびにDLLを手でコピーするのは面倒なので本格的に実装する場合は、nuget配信・ソリューションごと同居・git submoduleなど別の手段での共通化も検討してください。
ドメインモデリング
今回のTODOアプリのドメインモデリングをPlantUMLのユースケース図で書いたものです。
前述のように、以下のようなアプリの特徴があり、この部分をドメインモデリングでも表現します。
- 必ず一定時間で期限切れにする → TODO決めたら、すぐやれ!
- 有効なTODOは一定個数に制限する → TODOを積み続けるな! すぐやれ!
以下は、ドメイン層のモデルをPlantUMLで表現した例です。
コード
全体コード
全体コードはここにおいてあります。
Domain層
ドメイン層ではアプリケーションのビジネスロジックを表現します。
下記部分の Do()
メソッドでは 「一度期限切れになったTODOは完了にできない」というルールを表現しています。
public class Todo
{
public TodoId Id { get; init; }
public string Text { get; init; }
public DateTime CreatedDate { get; init; }
public TodoStatus Status { get; private set; }
...
public void Do()
{
if (Status == TodoStatus.Active)
{
Status = TodoStatus.Done;
}
}
...
}
ユースケース層
ユースケース層ではアプリケーションとして成り立たせるために必要な機能が実装されます。
実装の詳細はinterfaceを経由してインフラ層に移譲する場合もあります。
下記部分では、「内容を指定してTODOを追加する」、「指定したTODOを完了にする」というユースケースを実現しています。
public class TodoAppService
{
...
public void AddTodo(string? todoTextNew)
{
var todoNew = new Todo(todoTextNew!);
todos.Add(todoNew);
}
public void DoTodo(TodoDto item)
{
todos.DoTodo(item.Id);
}
public List<TodoDto> GetActiveTodos()
{
return todos.GetActiveTodos().Select(t => new TodoDto(t)).ToList();
}
...
インフラ層
インフラ層ではドメイン層・ユースケース層におくべきでない実装の詳細が書かれています。
下記部分ではドメイン層の ITodoRepository
を継承した、インメモリなレポジトリ実装が示されています。
TODOが外部から追加されたら、フィールドの List<Todo>
にそのまま追加します。
TODOの量が肥大化してDBで管理したくなった場合も、interfaceさえ守っていれば、インフラ層の実装が変わっても他の階層に影響はないはずです。
public class InMemoryTodoRepository : ITodoRepository
{
private List<Todo> todos = new();
...
public void Add(Todo todoNew)
{
...
todos.Add(todoNew);
}
...
}
UI層 - コンソールアプリ
UI層はプラットフォームごとに異なります。
コンソールアプリでは Program.cs
に直接書いています。
下記部分ではコンソール入力された内容に応じて、ユースケース層の appService
のメソッドを呼び出しています。
- 数字が入力されたら、指定した番号の既存のTODOに対して「TODO完了」
- それ以外の文だったら、「TODO追加」
class Program
{
...
static void Main()
{
...
while (true)
{
bool isFinished = Update();
if (isFinished)
{
break;
}
}
...
}
private static bool Update()
{
string? todoTextNew = RecieveText();
if (int.TryParse(todoTextNew, out int indexDone))
{
TodoDto itemDone = currentActiveTodos.ElementAt(indexDone);
appService.DoTodo(itemDone);
}
else
{
...
appService.AddTodo(todoTextNew);
...
}
private static string? RecieveText()
{
Console.Write("> ");
string? todoTextNew = Console.ReadLine();
...
}
...
}
UI層 -Unityアプリ
UI層はプラットフォームごとに異なります。
Unityアプリでは複数の MonoBehaviour
継承したスクリプトを使用します。
下記部分ではボタン操作に応じて、ユースケース層の appService
のメソッドを呼び出しています。
TodoAddView
では button
が押されたら、title
から文字を取得して、「TODO追加」を実行します
public class TodoAddView : MonoBehaviour
{
[SerializeField]
private Button button;
[SerializeField]
private TMP_Text title;
...
private void Start()
{
button.onClick.AddListener(OnAdd);
}
private void OnAdd()
{
appService.AddTodo(title.text);
title.text = string.Empty;
}
}
TodoCellView
では button
が押されたら、ローカル変数の todo
に対して「TODO完了」を実行します
public class TodoCellView : MonoBehaviour
{
[SerializeField]
private Button button;
...
private TodoDto todo;
private int index;
...
public void SetTodo(int indexList, TodoDto todoDto)
{
this.todo = todoDto;
...
button.onClick.AddListener(OnDone);
}
private void OnDone()
{
appService.DoTodo(todo);
}
...
}
Test
コンソールアプリ側で共通コードのユニットテストができます。
UnityEditorが必要ないため、高速にテスト実行が可能です。
下記部分では空文字など無効な内容のTodo生成を実行すると例外が発生することをテストしています。
public class Todo_Test
{
...
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Todo_CreateInvalidTextTodo_Fail(string textTodo)
{
var funcTodo = () => new Todo(textTodo) { CreatedDate = new DateTime(2020, 12, 3) };
funcTodo.Should().Throw<ArgumentException>();
}
}
まとめ・メリット
オニオンアーキテクチャを使って、Unityと.NETでコードを共有しながらTODOアプリを作成する方法について説明しました。
この方法のメリットとして以下が挙げられます。
- Unityへの依存を減らす
- View層以外はUnityに依存しなくなります
- これにより、他のプラットフォームへの移植性が高まります
- テスト容易性の向上
- Unityの実行環境を必要としない単体テストが可能になります
- UnityEditorのロードが必要ないので、コード修正→テスト実行の待ち時間が減少します
環境
- UnityEditor: Unity 2022.3.22
- VisualStudio: 2022 17.12.3
参考文献
- ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本
- ドメイン駆動設計 モデリング/実装ガイド