【Unity】AppUI×ClaudiaでMVVMを意識してチャットアプリをつくる 5章 Middleware編

# はじめに
この記事は4章の続きです。
まだ読んでいない人は4章から読まれることを推奨します。

この章では3章4章で実装したチャットアプリに関してMiddlewareを利用してViewModelに実装したロジックをドメイン層に持っていこうという話です。

記事を通じて疑問点があれば、ぜひフィードバックをください。

1章 [設計説明編](https://qiita.com/garume/items/d3edba72548f51108e7c)
2章 [AppUI編](https://qiita.com/garume/items/9e83eb7a3ce7b8c12594)　
3章 [MVVM実装編](https://qiita.com/garume/items/21840bc9edd70f40503b)
4章 [アプリ実装編](https://qiita.com/garume/items/5a26324cbbbeb2d32dc0)
5章 [Middleware編](https://qiita.com/garume/items/3f4c2f4b2de04b602e58)

# Middleware

3章 4章で実装したロジックをMiddlewareに記述していきます。
MiddlewareはAppUIには実装されていませんが、自作ライブラリ`UnityReduxMiddleware`を利用することで実現可能になります。今回はこれを利用することとします。

https://github.com/Garume/Unity-Redux-Middleware

Middlewareに関する解説は以下の記事でしています。

https://qiita.com/garume/items/2a6678005574a721699a

また、UnityReduxMiddlewareを用いたソースコードは以下のリポジトリにあります。

https://github.com/Garume/ClaudiaApp/tree/use-redux-middleware

## 実装
### セットアップ
まずは`UnityReduxMiddleware`を導入しましょう。
1. Window > Package Manager を選択
2. 「+」ボタン > Add package from git URL を選択
3. 以下の URL を入力する
```
https://github.com/Garume/Unity-Redux-Middleware.git?path=/Assets/UnityReducMiddleware
```

### Middlewareの作成
次に実際にロジックをMiddlewareに書いていきます。
`StoreService.cs`に以下を追記してください。

```cs:StoreService.cs
public MiddlewareDelegate RequestMessageMiddleware()
{
    return store => next => async (action, token) =>
    {
        var state = store.GetState<AppState>(Actions.SliceName);
        var anthropic = state.Anthropic;
        var messages = state.ChatMessages;
        messages.Add(new Message { Role = Roles.User, Content = state.inputMessage });
        var stream = anthropic.Messages.CreateStreamAsync(
            new MessageRequest
            {
                Model = Claudia.Models.Claude3Opus,
                MaxTokens = 1024,
                Temperature = state.temperatureValue,
                System = string.IsNullOrEmpty(state.systemString) ? null : state.systemString,
                Messages = state.ChatMessages.ToArray()
            }, cancellationToken: token);
        var currentMessage = new Message
        {
            Role = Roles.Assistant,
            Content = ""
        };
        messages.Add(currentMessage);
        await foreach (var messageStreamEvent in stream)
            if (messageStreamEvent is ContentBlockDelta content)
            {
                currentMessage.Content[0].Text += content.Delta.Text;
                await next(Actions.SetChatMessageAction.Invoke(messages), token);
            }
    };
}
```

もともとの`MainViewModel.cs`のコードとの差分としては、`dispatch()`が`next()`に変わったのみとなります。

```diff_c_sharp
- _storeService.Store.Dispatch(Actions.SetChatMessage, ChatMessage);
↓
+ await next(Actions.SetChatMessageAction.Invoke(messages), token);
```

Middleware内では`next()`を呼ぶことでdispatchを行えます。

:::note warn
今回はStoreService.csに追記しましたが、実際の開発では新たにファイルを作成してそこに記述するようにしてください。
:::

また、メッセージを更新するAction、通信をリクエストするActionTypeを追加で作成します。`Actions.cs`に変更を加えましょう。

```diff_c_sharp:Action.cs
﻿using System.Collections.Generic;
using Claudia;
using Unity.AppUI.Redux;

namespace ClaudiaApp.Models
{
    public static class Actions
    {
         public const string SliceName = "app";
         public const string SetTemperature = SliceName + "/SetTemparature";
         public const string SetInputMessage = SliceName + "/SetInputMessage";
         public const string SetSystemString = SliceName + "/SetSystemString";
         public const string SetChatMessage = SliceName + "/SetChatMessage";
+        public const string RequestMessage = SliceName + "/RequestMessage";

+        public static readonly ActionCreator<List<Message>> SetChatMessageAction =
+            Store.CreateAction<List<Message>>(SetChatMessage);
    }
}
```

`SetChatMessageAction`は文字列のままでもよいのですが可読性を上げる目的でここに持っておきます。
payload付きでdispatchする場合
```cs
store.Dispatch("ActionType",Payload);
```
としなければなりませんが、`ActionCreator`をあらかじめstaticに持っておくことで、
```cs
store.Dispatch(ActionCreator.Invoke(Payload));
```
と書くことができます。
便利。

#### Actionのフィルタリング
さて、Middlewareを書きはしましたが、この実装ではすべてのActionに対して実行されてしまいます。
リクエスト専用のActionが流れてきたときにのみ発火させたいところです。
そこでif文で条件分岐します。

```diff_c_sharp:StoreService.cs
public MiddlewareDelegate RequestMessageMiddleware()
{
    return store => next => async (action, token) =>
    {
+        if (action.type == Actions.RequestMessage)
+        {
            var state = store.GetState<AppState>(Actions.SliceName);
            var anthropic = state.Anthropic;
            var messages = state.ChatMessages;
            messages.Add(new Message { Role = Roles.User, Content = state.inputMessage });
            var stream = anthropic.Messages.CreateStreamAsync(
                new MessageRequest
                {
                    Model = Claudia.Models.Claude3Opus,
                    MaxTokens = 1024,
                    Temperature = state.temperatureValue,
                    System = string.IsNullOrEmpty(state.systemString) ? null : state.systemString,
                    Messages = state.ChatMessages.ToArray()
                }, cancellationToken: token);
            var currentMessage = new Message
            {
                Role = Roles.Assistant,
                Content = ""
            };
            messages.Add(currentMessage);
            await foreach (var messageStreamEvent in stream)
                if (messageStreamEvent is ContentBlockDelta content)
                {
                    currentMessage.Content[0].Text += content.Delta.Text;
                    await next(Actions.SetChatMessageAction.Invoke(messages), token);
                }
+        }
+        else
+        {
+            await next(action, token);
+        }
    };
}
```

`Actions.RequestMessage`が流れてきたときのみ実行するようにしました。
それ以外の場合はそのままnext()を呼ぶ形に。

:::note warn
必ずnext()が呼ばれるようにしてください。
バグのもとです。
:::

### Store → MiddlewareStoreへの変更

UnityReduxMiddlewareを使うにあたり、StoreではなくMiddlewareStoreを使用する必要があります。
まずは、`IStoreService.cs`を修正していきます。

```diff_c_sharp
- ﻿using Unity.AppUI.Redux;
+ ﻿using UnityReduxMiddleware;

namespace ClaudiaApp.Services
{
    public interface IStoreService
    {
-        Store Store { get; }
+        MiddlewareStore Store { get; }
    }
}
```

次に`StoreService.cs`に変更を加えていきます。
前回まではCreateStoreの処理は`MainViewModel.cs`に書いていましたがこれも`StoreService.cs`に移していきましょう。
(本当は`CaludiaApp.cs`などに書きたいのですができない....)

```cs:StoreService.cs
namespace ClaudiaApp.Services
{
    public class StoreService : IStoreService
    {
        public StoreService(ILocalStorageService localService)
        {
            Store = new MiddlewareStore();
            Store.AddMiddleware(ExceptionMiddleware.Create());
            Store.AddMiddleware(RequestMessageMiddleware());
            
            var initialState = localService.GetValue(Actions.SliceName, new AppState());
            Store.CreateSlice(Actions.SliceName, initialState, builder =>
            {
                builder
                    .Add<float>(Actions.SetTemperature, Reducers.SetTemperatureValueReducer)
                    .Add<List<Message>>(Actions.SetChatMessage, Reducers.SetChatMessage)
                    .Add<string>(Actions.SetInputMessage, Reducers.SetInputMessageReducer)
                    .Add<string>(Actions.SetSystemString, Reducers.SetSystemStringReducer);
            });
        }
        
        public MiddlewareStore Store { get; }
    ...
}
```

`ExceptionMiddleware.Create()`はUnityReduxMiddlewareが提供する例外を処理してくれるものです。

最後に`MainViewModel.cs`へ書いていたロジックを消していきましょう。

```diff_c_sharp:MainViewModel.cs
namespace ClaudiaApp.ViewModels
{
    public class MainViewModel : ObservableObject, IMainViewModel
    {
        private readonly ILocalStorageService _localStorageService;
        private readonly IStoreService _storeService;
        private readonly Unsubscriber _unSubscriber;
        private List<Message> _chatMessage;

        private bool _running;

        public MainViewModel(IStoreService storeService, ILocalStorageService localService)
        {
            _storeService = storeService;
            _localStorageService = localService;

            SetTemperatureCommand = new RelayCommand<float>(SetTemperature);
            SetSystemMessageCommand = new RelayCommand<string>(SetSystemMessage);
            SetInputMessageCommand = new RelayCommand<string>(SetInputMessage);
            SendMessageCommand = new AsyncRelayCommand(SendMessage);

-            var initialState = localService.GetValue(Actions.SliceName, new AppState());
-            _storeService.Store.CreateSlice(Actions.SliceName, initialState, builder =>
-            {
-                builder
-                    .Add<float>(Actions.SetTemperature, Reducers.SetTemperatureValueReducer)
-                    .Add<List<Message>>(Actions.SetChatMessage, Reducers.SetChatMessage)
-                    .Add<string>(Actions.SetInputMessage, Reducers.SetInputMessageReducer)
-                    .Add<string>(Actions.SetSystemString, Reducers.SetSystemStringReducer);
-            });
-
-            _chatMessage = initialState.ChatMessages;

+           _chatMessage = storeService.Store.GetState<AppState>(Actions.SliceName).ChatMessages;

            _unSubscriber = storeService.Store.Subscribe<AppState>(Actions.SliceName, OnStateChanged);
            App.shuttingDown += OnShuttingDown;
        }

        public List<Message> ChatMessage
        {
            get => _chatMessage;
            set
            {
                _chatMessage = value;
                OnPropertyChanged();
            }
        }

        public RelayCommand<float> SetTemperatureCommand { get; }
        public RelayCommand<string> SetSystemMessageCommand { get; }
        public RelayCommand<string> SetInputMessageCommand { get; }
        public AsyncRelayCommand SendMessageCommand { get; }

        private void OnStateChanged(AppState state)
        {
            ChatMessage = state.ChatMessages;
        }

        private async Task SendMessage(CancellationToken token)
        {
            if (_running) return;

            _running = true;

            try
            {
-                var state = _storeService.Store.GetState<AppState>(Actions.SliceName);
-                var anthropic = state.Anthropic;
-                ChatMessage.Add(new Message { Role = Roles.User, Content = state.inputMessage });
-                var stream = anthropic.Messages.CreateStreamAsync(new MessageRequest
-                {
-                    Model = Claudia.Models.Claude3Opus,
-                    MaxTokens = 1024,
-                    Temperature = state.temperatureValue,
-                    System = string.IsNullOrEmpty(state.systemString) ? null : state.systemString,
-                    Messages = state.ChatMessages.ToArray()
-                }, cancellationToken: token);
-
-                var currentMessage = new Message
-                {
-                    Role = Roles.Assistant,
-                    Content = ""
-                };
-
-                ChatMessage.Add(currentMessage);
-
-                await foreach (var messageStreamEvent in stream)
-                    if (messageStreamEvent is ContentBlockDelta content)
-                    {
-                        currentMessage.Content[0].Text += content.Delta.Text;
-                        _storeService.Store.Dispatch(Actions.SetChatMessage, ChatMessage);
-                    }
                
+                await _storeService.Store.DispatchAsync(Actions.RequestMessage, token);
            }
            catch (Exception e)
            {
                Debug.Log(e);
            }
            finally
            {
                _running = false;
            }
        }

        private void SetInputMessage(string message)
        {
            _storeService.Store.Dispatch(Actions.SetInputMessage, message);
        }

        private void SetSystemMessage(string message)
        {
            _storeService.Store.Dispatch(Actions.SetSystemString, message);
        }

        private void SetTemperature(float temperature)
        {
            _storeService.Store.Dispatch(Actions.SetTemperature, temperature);
        }

        private void OnShuttingDown()
        {
            _localStorageService.SetValue(Actions.SliceName, _storeService.Store.GetState<AppState>(Actions.SliceName));
            App.shuttingDown -= OnShuttingDown;
            _unSubscriber?.Invoke();
        }
    }
}
```

かなりすっきりした(?)と思います。

こうすることでViewModelの責務であった
- ViewとModelをつなぐ 
- Viewに使うデータを保持する

に専念することができます。
ViewModelの肥大化も比較的抑えられるでしょう。

---

これで修正はおしまいです。
実際にプレイしてみて動作を確認しましょう。

次にRxに書けるEpicを用いた変更もしていきます。
目的はもう果たしているので興味がある方のみ読んでください・

# Epic

UnityReduxMiddlewareはR3と連携することでRxに書ける`Epic`という機能を提供しています。
これを使用することでもっと楽にロジックを記述することができます。
(Actionが流れてくるという仕組み上Push挙動のObservableと相性がいいはず。)

では実際に`RequestMessageMiddleware`と同等の機能をもつEpicを作成していきましょう

## 実装
### Epicの作成

作成の前に`R3`が導入済みであることを確認してください。
`R3`が導入されていないとEpicを使用することはできません。

https://github.com/Cysharp/R3?tab=readme-ov-file#unity

導入できたら早速作成していきましょう。

```cs:StoreService.cs
public Epic<AppState> RequestMessageEpic()
{
    Message currentMessage = null;
    List<Message> messages = null;

    return (action, state) => action
        .OfAction(Actions.RequestMessage) // Actions.RequestMessageのみを通す
        .SelectMany((_, _) =>
        {
            var stateValue = state.CurrentValue;
            var anthropic = stateValue.Anthropic;
            messages = stateValue.ChatMessages;
            messages.Add(new Message { Role = Roles.User, Content = stateValue.inputMessage });
            var response = anthropic.Messages.CreateStreamAsync(
                new MessageRequest
                {
                    Model = Claudia.Models.Claude3Opus,
                    MaxTokens = 1024,
                    Temperature = stateValue.temperatureValue,
                    System = string.IsNullOrEmpty(stateValue.systemString) ? null : stateValue.systemString,
                    Messages = stateValue.ChatMessages.ToArray()
                });
            currentMessage = new Message
            {
                Role = Roles.Assistant,
                Content = ""
            };
            messages.Add(currentMessage);
            return response.ToObservable(); // IAsyncEnumerableをObservableに変換
        })
        .Where(x => x is ContentBlockDelta)
        .Select(x =>
        {
            currentMessage.Content[0].Text += ((ContentBlockDelta)x).Delta.Text;
            return Actions.SetChatMessageAction.Invoke(messages) as Action;
        });
}
```

これだけです！
(Select内で副作用起こすな等あると思いますがここは見逃してください。)

つぎに作成したEpicを適用していきましょう。

### Epicの適用

`StoreService.cs`を修正しましょう。
`EpicMiddleware`を追加して、作成したEpicと紐づけます。

```diff_c_sharp:StoreService.cs
public class StoreService : IStoreService
{
    public StoreService(ILocalStorageService localService)
    {
         Store = new MiddlewareStore();
         Store.AddMiddleware(ExceptionMiddleware.Create());
-        Store.AddMiddleware(RequestMessageMiddleware());
        
         var initialState = localService.GetValue(Actions.SliceName, new AppState());
         Store.CreateSlice(Actions.SliceName, initialState, builder =>
         {
             builder
                 .Add<float>(Actions.SetTemperature, Reducers.SetTemperatureValueReducer)
                 .Add<List<Message>>(Actions.SetChatMessage, Reducers.SetChatMessage)
                 .Add<string>(Actions.SetInputMessage, Reducers.SetInputMessageReducer)
                 .Add<string>(Actions.SetSystemString, Reducers.SetSystemStringReducer);
         });

+        var epicMiddleware = EpicMiddleware.Default<AppState>();
+        Store.AddMiddleware(epicMiddleware.Create());
        
+        epicMiddleware.Run(RequestMessageEpic());
    }
    ...
}
```

こうすることで`RequestMessageEpic`にObservable<Action>が流れるようになります。

実際にプレイしてみて動くことを確認してみましょう。

# おわりに

UnityReduxMiddlewareを用いてロジックをViewModelから分離しました。
AppUIがどうなるかはわかりませんが、本シリーズはこれにて完結です。
ここまで読んでいただきありがとうございました。
何かの役に立てばうれしいです。
また、何か間違いがありましたらすみません。コメント等でご指定いただけると幸いです、

それでは。