ReduxSharp – 単方向データフローを C# で実践するためのライブラリ

はじめに

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 の使い方

  1. NuGet から ReduxSharp のパッケージをインストールする
    • Install-Package ReduxSharp
  2. アプリのステートを表すクラスを定義
  3. アクションを定義
  4. Reducer を定義
    • IReducer<TState> インタフェースを実装する
  5. Store<TState>インスタンスを作成する
    • Reducer のインスタンスを渡す必要がある
    • ステートの初期値を渡すことができる
  6. 変更通知を受け取りたいクラスに IObserver<T> を実装する
  7. Store<TState>.Subscribe(IObserver<TState>) で変更通知を受け取るインスタンスを登録する
  8. 任意のアクションをストアに Dispatch する

サンプルコードは次の通り。

using System;
using ReduxSharp;

// 6. 変更通知を受け取りたいクラスに IObserver<T> を実装する
class Program : IObserver<AppState>
{
    static void Main(string[] args)
    {
        // 5. Store<TState> のインスタンスを作成する
        var store = new Store<AppState>(
            new AppReducer(),
            new AppState(0));

        // 7. Store<TState>.Subscribe(IObserver<TState>) で変更通知を受け取るインスタンスを登録する
        var p = new Program();
        using (store.Subscribe(p))
        {
            // 8. 任意のアクションをストアに Dispatch
            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) { }
}

// 2. アプリのステートを表すクラスを定義
public class AppState
{
    public AppState(int count) => Count = count;

    public int Count { get; }
}

// 3. アクションを定義
public readonly struct Increment { }

public readonly struct Decrement { }

// 4. Reducer を定義
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 の方が高速。 構造体で定義したアクションならゼロアロケーション

f:id:griefworker:20200121111612p:plain

ただ、ミドルウェアを使った場合はまだ Redux.NET の方が速いので、そこは今後の課題だ。 良いアイデアがないものか。

余談

C# には ValueTask と async/await があるので、 最初は Reducer の中で非同期処理を書けるようにしようと考えていた。 しかし、いざ実装してベンチマークをとってみたら激遅。

ReduxSharp を使うアプリでやる非同期処理というと、WebAPI 呼び出しとかファイル IO とか。 同期処理になるケースがまず無いので、ValueTaskにしたところで大した効果は無かった。 むしろ全部 ValueTask にしちゃうことで非同期でないアクションにも影響が出てしまった。

Redux.NET と比べて桁違いに遅かったので、非同期 Reducer はスパッと断念。 せめて、ミドルウェアくらいは非同期にしたかったが、構造体で定義したアクションのこと考えたら、やっぱり見送り。

おわりに

Xamarin で使うために作ったライブラリではあるけど、WPF や WinForms でも使える。 Blazor で使えるかどうかは試してないが、おそらく一手間かかりそう。 機能的には完成していて、今後やるとしたらパフォーマンスチューニングくらいだ。 特にミドルウェアを組み込んだとき Redux.NET に負けているのはなんとかしたい。