LoginSignup
3
4

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

Last updated at Posted at 2024-04-16

はじめに

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

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

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

1章 設計説明編
2章 AppUI編 
3章 MVVM実装編
4章 アプリ実装編
5章 Middleware編

Middleware

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

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

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

実装

セットアップ

まずは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に以下を追記してください。

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()に変わったのみとなります。

- _storeService.Store.Dispatch(Actions.SetChatMessage, ChatMessage);

+ await next(Actions.SetChatMessageAction.Invoke(messages), token);

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

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

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

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する場合

store.Dispatch("ActionType",Payload);

としなければなりませんが、ActionCreatorをあらかじめstaticに持っておくことで、

store.Dispatch(ActionCreator.Invoke(Payload));

と書くことができます。
便利。

Actionのフィルタリング

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

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()を呼ぶ形に。

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

Store → MiddlewareStoreへの変更

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

- 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などに書きたいのですができない....)

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へ書いていたロジックを消していきましょう。

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を使用することはできません。

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

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と紐づけます。

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が流れるようになります。

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

おわりに

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

それでは。

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4