オニオンアーキテクチャを使って、Unityと.NETでコードを共有する


こちらは Unity Adnvent Calender 2024 その2 17日目の記事になります。

# 概要

オニオンアーキテクチャを使って、Unityと.NETでコードを共有しながらTODOアプリを作成する方法を解説します。

# アプリの説明

Unityと.NETコンソールアプリの2つのアプリのスクリーンショットです。

- Unity

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120077/8741d8a3-cb09-bfca-dfc3-f37ace94fbff.png)

- .NETコンソールアプリ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120077/ea562ca6-2e27-c01f-7baf-3b901b325e8e.png)


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` から参照しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120077/3e6282a1-cbfc-e4de-157e-04ad4a3dea0c.png)

共通コードの変更のたびにDLLを手でコピーするのは面倒なので本格的に実装する場合は、nuget配信・ソリューションごと同居・git submoduleなど別の手段での共通化も検討してください。

## ドメインモデリング

今回のTODOアプリのドメインモデリングをPlantUMLのユースケース図で書いたものです。
前述のように、以下のようなアプリの特徴があり、この部分をドメインモデリングでも表現します。

- 必ず一定時間で期限切れにする → TODO決めたら、**すぐやれ！**
- 有効なTODOは一定個数に制限する → TODOを積み続けるな！ **すぐやれ！**

以下は、ドメイン層のモデルをPlantUMLで表現した例です。

```plantuml
@startuml
skinparam actorStyle awesome
left to right direction
actor "User" as u
actor "System" as s
package TODO {
    usecase "TODO追加" as add
    usecase "TODOを見る" as view
    usecase "TODO完了" as done
    usecase "TODO内容を見る" as viewText
    usecase "TODOステータスを見る" as viewStatus
    usecase "TODO期限までの時間が見える" as viewDate

    usecase "一定時間で期限切れにする" as expire
    usecase "有効なTODOは一定個数に制限する" as limit    
    usecase "一度期限切れになったTODOは変更できない" as cannotDone
}

u --> add
u --> view
view ..> viewText : <<include>>
view ..> viewStatus : <<include>>
view ..> viewDate : <<include>>
u --> done

add ..> expire : <<extend>>
add ..> limit : <<extend>>
expire ..> cannotDone : <<extend>>
s --> expire
s --> limit
s --> cannotDone
@enduml
```

## コード

### 全体コード

全体コードはここにおいてあります。

https://github.com/soi013/LifeTodoConsole

https://github.com/soi013/LifeTodoUnity

### Domain層

ドメイン層ではアプリケーションのビジネスロジックを表現します。

下記部分の `Do()` メソッドでは 「一度期限切れになったTODOは完了にできない」というルールを表現しています。

```csharp
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を完了にする」というユースケースを実現しています。

```csharp
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さえ守っていれば、インフラ層の実装が変わっても他の階層に影響はないはずです。

```csharp
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追加」

```csharp
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追加」を実行します

```csharp
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完了」を実行します

```csharp
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生成を実行すると例外が発生することをテストしています。

```csharp
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

# 参考文献

- ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本
  - https://amazon.jp/dp/479815072X
- ドメイン駆動設計 モデリング/実装ガイド
  - https://booth.pm/ja/items/1835632