こちらは Unity Adnvent Calender 2024 その2 17日目の記事になります。
概要
オニオンアーキテクチャを使って、Unityと.NETでコードを共有しながらTODOアプリを作成する方法を解説します。
アプリの説明
Unityと.NETコンソールアプリの2つのアプリのスクリーンショットです。
TODOアプリとしてのベーシックな機能があります。
- 文字列を入力したらTODOメモが追加される
- 完了したら
Done
になる
加えてアプリの特徴として、以下のようにユーザーを甘やかさない厳し目のTODOアプリになっています
- 必ず一定時間で期限切れにする → TODO決めたら、すぐやれ!
- 有効なTODOは一定個数に制限する → TODOを積み続けるな! すぐやれ!
(アドベントカレンダーの締め切り間に合わなくて申し訳ありません。)
本文
オニオンアーキテクチャとは
オニオンアーキテクチャは、システムを複数のレイヤーに分割し、依存性を内側のレイヤーに向けるアーキテクチャです。以下の特徴があります。
- ドメイン中心設計: アプリケーションのビジネスルールを中心に設計
- 依存性の方向: 外側のレイヤーが内側のレイヤーに依存するが、逆はない
- テスト容易性: ドメイン層が他のレイヤーから独立しているため、ユニットテストが容易になる
主要なレイヤーは以下です。
- ドメイン層: ビジネスルールやエンティティ
- ユースケース層: ユースケースやアプリケーションロジック
- インフラ層: データベースや外部サービスとの連携
- UI層: ユーザーインターフェース
TODOアプリの設計
TODOアプリでは、以下の要素を設計します:
-
ドメイン層:
Todo
エンティティや TODOの制限ルール - ユースケース層: TODOリストの追加・削除・一覧取得のユースケース
- インフラ層: 永続化のためのデータアクセス
-
UI層: ユーザーが直接ふれるユーザーインターフェース部分
- ここはプラットフォーム固有になるので、Unityと.NETで個別に実装
プロジェクト構成
コンソールアプリのソリューション内に共通コードを別csprojとして配置します。
/LifeTodoConsole
/Domain
- LifeTodo.Domain.csproj
/UseCase
- LifeTodo.UseCase.csproj
/Infra
- LifeTodo.Infra.csproj
/ConsoleApp
- LifeTodo.ConsoleApp.csproj
/Test
- LifeTodo.Test.csproj
/LifeTodoUnity
/Assets/Scripts
/Views
- Views.asmdef
/Installers
- Installers.asmdef
- 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を追加できること、有効なTODO(=未完了)だけを抜き出す機能が実装されています。
public class TodoAppService
{
...
public void AddTodo(string? todoTextNew)
{
var todoNew = new Todo(todoTextNew!);
todos.Add(todoNew);
}
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
に直接書いています。
下記部分ではコンソールに数字が入力されたら、指定した番号の既存の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
継承したスクリプトを使用します。
下記部分ではPrefabを実体化させて、Todoの内容を反映しています。
public class TodoPanelView : MonoBehaviour
{
[SerializeField]
private GameObject todoPrefab;
...
private void CreateTodoUi(int index, TodoDto todoDto)
{
GameObject todoGo = Instantiate(todoPrefab, transform.position, Quaternion.identity, transform);
TodoCellView todoView = todoGo.GetComponent<TodoCellView>();
todoView.SetTodo(index, todoDto);
}
...
}
public class TodoCellView : MonoBehaviour
{
[SerializeField]
private TMP_Text title;
[SerializeField]
private TMP_Text status;
...
private TodoDto todo;
private int index;
public void SetTodo(int indexList, TodoDto todoDto)
{
this.todo = todoDto;
this.index = indexList;
UpdateTodoView();
...
}
private void UpdateTodoView()
{
TimeSpan remainTime = expireService.CalcRemainTime(todo.CreatedDate);
string statusText = todo.Status == TodoStatus.Active
? $"残り{remainTime:dd}日"
: todo.Status.ToString();
...
title.text = $" {index}:\t{todo.Text}";
status.text = statusText;
}
...
}
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
参考文献
- ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本
- ドメイン駆動設計 モデリング/実装ガイド