はじめに
IssueHub で使っている自作のライブラリ ReduxSharp。
github.com
www.nuget.org
すでに GitHub でソースコード、NuGet でパッケージを公開していたんだけど、
紹介記事を書いていなかった。
IssueHub のソースコード公開を機に紹介したいと思う。
ReduxSharp について
JavaScript には Redux というライブラリがある。
React で Single Page Application を開発した事がある人は使ったことがあるんじゃないだろうか。
redux.js.org
Redux がどういうものかは公式のドキュメントを読んでもらうとして、
ReduxSharp は名前の通り、その C# 版。
Redux の .NET 移植版としては先に Redux.NET というのが存在していた。
GitHub のスター数も Redux.NET の方が圧倒的に多い。
github.com
モチベーション
Redux.NET が既に存在するのに、それでも ReduxSharp を作ったのは、
Redux.NET に不満があったから。
どんな不満かというと、まずは Redux.NET が Rx.NET に依存していること。
内部で ReplaySubject<T>
使っちゃっている。
ライブラリ自体は小さいのに、Rx.NET という大きいライブラリに依存していて、
インストールするともれなく付いてくるのが嫌だった。
たとえ一緒に使うことが多いとしても、ね。
もう 1 つが、アクションを発行する Dispatch メソッドの引数が object なところ。
頻繁に発行されるアクションは構造体で定義したいのに、これだとボックス化してしまう。
以上の 2 点が気に入らなかった。
ReduxSharp の使い方
- NuGet から ReduxSharp のパッケージをインストールする
Install-Package ReduxSharp
- アプリのステートを表すクラスを定義
- アクションを定義
- Reducer を定義
IReducer<TState>
インタフェースを実装する
Store<TState>
のインスタンスを作成する
- Reducer のインスタンスを渡す必要がある
- ステートの初期値を渡すことができる
- 変更通知を受け取りたいクラスに
IObserver<T>
を実装する
Store<TState>.Subscribe(IObserver<TState>)
で変更通知を受け取るインスタンスを登録する
- 任意のアクションをストアに Dispatch する
サンプルコードは次の通り。
using System;
using ReduxSharp;
class Program : IObserver<AppState>
{
static void Main(string[] args)
{
var store = new Store<AppState>(
new AppReducer(),
new AppState(0));
var p = new Program();
using (store.Subscribe(p))
{
store.Dispatch(new Increment());
store.Dispatch(new Increment());
store.Dispatch(new Decrement());
store.Dispatch(new Increment());
}
Console.ReadLine();
}
public void OnNext(AppState value) =>
Console.WriteLine(value.Count);
public void OnCompleted() { }
public void OnError(Exception error) { }
}
public class AppState
{
public AppState(int count) => Count = count;
public int Count { get; }
}
public readonly struct Increment { }
public readonly struct Decrement { }
public class AppReducer : IReducer<AppState>
{
public AppState Invoke<TAction>(AppState state, TAction action)
{
switch (action)
{
case Increment _:
return new AppState(state.Count + 1);
case Decrement _:
return new AppState(state.Count - 1);
default:
return state;
}
}
}
ReduxSharp でもミドルウェアをサポートしている。
IMiddleware<TState>
インタフェース実装したクラスのインスタンスを、
Store<TState>
のコンストラクタで渡せば使える。
using Newtonsoft.Json;
using ReduxSharp;
public class ConsoleLoggingMiddleware<TState> : IMiddleware<TState>
{
public void Invoke<TAction>(IStore<TState> store, IDispatcher next, TAction action)
{
Console.WriteLine(JsonConvert.SerializeObject(store.State));
next.Invoke(action);
Console.WriteLine(JsonConvert.SerializeObject(store.State));
}
}
Reducer の Invoke が呼ばれる前後に任意の処理を挿入できる。
アクションとステートを記録するも良し、ロギングを挟むも良し。
非同期処理
今のところ非同期処理はサポートしていない。
非同期処理を行いたい場合、redux-thunk みたいなミドルウェアを書いてもいいが、
下記の DispatchAsync
みたいな拡張メソッドを実装して、
非同期アクションを実行できるようにする方が使いやすい。
IssueHubではそれで対応している。
using System;
using System.Threading.Tasks;
using ReduxSharp;
public delegate ValueTask AsyncActionCreator<TState>(IStore<TState> store);
public static class StoreEx
{
public static async ValueTask DispatchAsync<TState>(
this IStore<TState> store,
AsyncActionCreator<TState> asyncActionCreator)
{
if (asyncActionCreator == null) throw new ArgumentNullException(nameof(asyncActionCreator));
await asyncActionCreator(store).ConfigureAwait(false);
}
}
本体に組み込んでもいいけど、これくらいならまぁ別に毎回書いてもいいか、
と思って組み込んでいない。
ReduxSharp の実装について
Redux だと結構な頻度でアクションを Dispatch するので、アクションをクラスで定義すると、
頻繁にインスタンスを生成してはすぐ破棄して効率悪い。
頻繁に使うアクションは構造体にしたいところ。
また、アクションは作ったら変更がほぼ無いので readonly struct にできる。
そんな構造体で定義したアクションをボックス化せずに使えるよう、
アクションを Dispatch するための Store のメソッドはジェネリックな Store.Dispatch<TAction>(TAction)
にした。
それに合わせて、Reducer がアクションを処理するためのメソッドもジェネリクスな IRedicer<TState>.Invoke<TAction>(TState, TAction)
にした。
Reducer の中ではアクションの型をチェックして処理を分岐するが、
C# なら switch や is で型をマッチさせて分岐できるので自然に書ける。
ストア自体は IObservable<T>
を実装していて、
そこは Redux.NET と一緒。
ただ、中では Rx.NET を使わず愚直に実装している。
使っていないので Rx.NET に依存していない。
.NET Standard 2.0 に対応していて、サードパーティのライブラリへの依存はない。
IObservable<T>
を実装しているので、Rx.NET と組み合わせて使える。
実際、IssueHub でも組み合わせて使っている。
ベンチマークも取っていて、Redux.NETと比較して、
単純なアクションの Dispatch なら ReduxSharp の方が高速。
構造体で定義したアクションならゼロアロケーション。
ただ、ミドルウェアを使った場合はまだ Redux.NET の方が速いので、そこは今後の課題だ。
良いアイデアがないものか。
余談
C# には ValueTask と async/await があるので、
最初は Reducer の中で非同期処理を書けるようにしようと考えていた。
しかし、いざ実装してベンチマークをとってみたら激遅。
ReduxSharp を使うアプリでやる非同期処理というと、WebAPI 呼び出しとかファイル IO とか。
同期処理になるケースがまず無いので、ValueTaskにしたところで大した効果は無かった。
むしろ全部 ValueTask にしちゃうことで非同期でないアクションにも影響が出てしまった。
Redux.NET と比べて桁違いに遅かったので、非同期 Reducer はスパッと断念。
せめて、ミドルウェアくらいは非同期にしたかったが、構造体で定義したアクションのこと考えたら、やっぱり見送り。
おわりに
Xamarin で使うために作ったライブラリではあるけど、WPF や WinForms でも使える。
Blazor で使えるかどうかは試してないが、おそらく一手間かかりそう。
機能的には完成していて、今後やるとしたらパフォーマンスチューニングくらいだ。
特にミドルウェアを組み込んだとき Redux.NET に負けているのはなんとかしたい。