🐷

Fluxパターンと.NETでの実装とか

2024/03/17に公開

Fluxパターンを.NET(C#、F#)で実装してみたので、それを記していこうと思います。

Fluxとは

Dispatcher、Store、Action、Viewによる一方向データフローを用いたアプリケーションの設計(アーキテクチャ)パターンです。世に出てもう10年くらいになります。

https://github.com/facebookarchive/flux/tree/main/examples/flux-concepts

The diagram of data flow from "flux-concepts"

既存のアーキテクチャ(MVCなど)と比較している記事は、検索するといろいろ出てくるので、気になる方は探してみてください。

今回の実装

上のデータフロー図で言うところのViewをC#(WPF)で、その他のDispatcherActionStoreをF#で作っていきます。
ActionをDispatcher経由でStoreに渡して、Storeの変更をViewに通知するという作りなので、Observerパターン(または、Pub/Subパターン)を使っていきます。

ソリューション/プロジェクト構成

FluxPattern.sln
├─FluxPattern.Logic.fsproj (F#)
│ ・Action、Dispatcher、Storeを実装
└─FluxPattern.View.csproj (C#)
  ・Viewを実装

こんな感じの構成にします。お試しで作るのは、チュートリアルな流れでよくある(?)足し算と引き算ができるアプリです。

お時間のない方向け

GitHubにリポジトリがあるので、そちらを眺めていただいてもいいかもしれません。

https://github.com/Gab-km/FluxPattern

F#側の実装

F#側ではDispatcher、Action、Storeを実装します。他に必要なものもここで定義しています。

Observable.fs

Observerパターンを適用するために、インタフェースを定義します。

namespace FluxPattern.Logic

type ISubscriber =
    abstract Update: context: string -> unit

type IPublisher =
    abstract Subscribe: s: ISubscriber -> unit
    abstract Unsubscribe: s: ISubscriber -> unit
    abstract NotifySubscribers: context: string -> unit

IPublisherが観察される側であり通知する側、ISubscriberが観察する側であり通知される側を表します。

Action.fs

次に、Actionを作ります。足し算/引き算をできるようにしたいので、AddSubtractというアクションを作ります。せっかくのF#なので、判別共用体でいきます。

namespace FluxPattern.Logic

type Action =
    | Add of int
    | Subtract of int
    override this.ToString() =
        match this with
        | Add _ -> "Add"
        | Subtract _ -> "Subtract"

Add、Subtractともにint型の値を持ちます。これは加算及び減算する値となります。
ToStringメソッドもオーバーライドしているんですが、これは後で説明します。

さて、このAction.fsにActionCreatorを追加します。こっちはFluxよりもその派生コンセプトに当たるReduxの使い方に近いです。具体的には、所定のActionを作って返す関数です。

module ActionCreator =
    [<CompiledName "CreateAdd">]
    let createAdd (value: int) = Add value
    [<CompiledName "CreateSub">]
    let createSub (value: int) = Subtract value

これはC#側から使用されることを想定しているので、CompiledNameアトリビュートをつけてPascalCaseにしています。(F#の関数はcamelCaseが標準)

Store.fs(準備)

F#側のメインロジックに当たる、Storeの実装です。IStore<'T>インタフェースを定義します。

namespace FluxPattern.Logic

type internal IStore<'T> =
    inherit IPublisher
    abstract GetState: 'T with get
    abstract Update: action: Action -> unit

このインタフェースはIPublisherを実装しており、変更をViewに通知する役目を持ちます。
状態はジェネリック型引数'Tで表現しており、getterであるGetStateプロパティを持ちますが、setterは提供しません。
基本的にはUpdateメソッドを実行し、引数として渡したActionに従い状態を更新し、それがViewに通知されます。

IStore<`T>インタフェースを実装する前に、もうひとつインタフェースを定義します。

type internal IReducer<'T> =
    abstract Reduce: action: Action -> 'T -> 'T

IReducer<'T>インタフェースはActionと現在の状態を受け取り、'T型の値、つまり新しい状態を返します。この考え方もFluxではなくReduxのものですが、せっかく関数型のコンセプトがあるので、F#でやるならこちらを採用したいと思いました。

さて、IReducer<'T>をこのように実装しました:

type internal Reducer() =
    interface IReducer<int> with
        member this.Reduce (action: Action) (value: int) =
            match action with
            | Add a -> value + a
            | Subtract s -> value - s

このアプリではint型の状態を持つので、Addが来たら加算結果を、Subtractが来たら減算結果を返します。

Store.fs(実装)

準備ができたので、Storeを実装していきます。

type internal StoreClass() =
    let mutable state = 0
    let reducer: IReducer<int> = Reducer()
    let mutable subscribers: ISubscriber list = []

    interface IStore<int> with
        member this.GetState = state

        member this.Update(action: Action) =
            let reduced = reducer.Reduce action state
            if (state <> reduced) then
                state <- reduced
                (this :> IPublisher).NotifySubscribers(action.ToString())

        member this.NotifySubscribers(context: string) =
            subscribers
            |> List.iter (fun sub -> sub.Update(context))

        member this.Subscribe(s: ISubscriber) = subscribers <- s :: subscribers

        member this.Unsubscribe(s: ISubscriber) =
            let filtered =
                subscribers
                |> List.fold (fun acc elem -> if elem = s then acc else elem :: acc) []

            subscribers <- List.rev filtered

プライベートなフィールドとして、statereducersubscribersを定義しました。

stateは、状態のバッキングフィールドです。基本的にF#は参照透過性を重視して、値の変更をさせないようにしているんですが、mutableをつけて宣言すると、その変数は変更可能になります。とはいえ何でもかんでもつけちゃうとバグの温床になるので、本当に変更が必要なものだけにして、さらに変更できる操作も制限するのが良いです。

reducerは先ほど定義したReducerクラスのインスタンスです。

subscribersは、StoreがPublisherとなるので、それを購読するSubscriberを登録しておくためのリストです。こちらも要素の追加や削除が発生するのでmutableとしています。

IStore<'T>インタフェースの実装ですが、GetStateプロパティはstateを返すだけ。Updateメソッドでは、もらったActionをreducerのReduceメソッドに状態と一緒に渡して、状態に変更があったら登録済みのSubscriberに通知します。ソースを眺めてもらうと分かるんですが、stateを更新しているのはこのUpdateメソッドのみです。
通知メソッド(NotifySubscribers)にActionのToStringメソッドを渡しており、メソッド本体ではsubscribersの全要素に対してUpdateメソッドをこの引数を渡して実行します。これはView(今回のSubscriber)にどのアクションで変更があったかを知らせるためです。ViewはDispatcher経由でStoreに変更を依頼しますが、どの操作を行ったかは持たず、Storeからの変更通知を契機に画面を更新します。(View側の実装で説明します)
SubscribeUnsubscribeはそれぞれ購読開始と終了を行うメソッドです。

さて、Storeを実装しましたが、F#側とC#側で同じStoreを相手にしないと意味がないですね。なので、シングルトンで使うためにモジュールを用意します。

module Store =
    let private singleton: IStore<int> = StoreClass()

    let internal update = singleton.Update

    [<CompiledName "GetState">]
    let state () = singleton.GetState

    [<CompiledName "Subscribe">]
    let subscribe (s: ISubscriber) = singleton.Subscribe s

    [<CompiledName "Unsubscribe">]
    let unsubscribe (s: ISubscriber) = singleton.Unsubscribe s

基本的には上で実装したStoreClassをシングルトンで使えるようにしただけで、C#側に公開する関数(C#側からはstaticなメソッドに見える)にCompiledNameアトリビュートをつけています。

Dispatcher.fs

F#側の最後にDispatcherを実装します。

namespace FluxPattern.Logic

module Dispatcher =
    [<CompiledName "Dispatch">]
    let dispatch (action: Action) = Store.update action

と言っても、大したことはしていません。渡されたActionを、シングルトンなStoreのupdate関数に渡しているだけです。これもC#側から使われるので、CompiledNameでPascalCaseな名前を設定しています。

C#側の実装

C#側ではViewを実装します。Fluxの言葉でViewと言っていますが、WPFアプリケーションとして作ろうと思っており、MVVMパターンでやっていきます。

MainWindow.xaml

メイン画面にして唯一の画面です。現在値(変更不可)と加算値、減算値があって、「たす」ボタンを押すと加算値を足し、「ひく」ボタンを押すと減算値を引く、というアプリです。

<Window x:Class="FluxPattern.View.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:local="clr-namespace:FluxPattern.View.Views"
        xmlns:vm="clr-namespace:FluxPattern.View.ViewModels"
        mc:Ignorable="d"
        d:DataContext="{d:DesignInstance vm:MainWindowViewModel}"
        Title="Fluxパターンお試し" Height="130" Width="200"
        ResizeMode="NoResize">
    <Grid>
        <Label Margin="10,10,0,0" VerticalAlignment="Top" HorizontalAlignment="Left" Content="現在値" />
        <TextBox x:Name="txtState" Margin="60,15,0,0" Width="70" VerticalAlignment="Top" HorizontalAlignment="Left" IsReadOnly="True" Text="{Binding StateValue}" TextAlignment="Right" />
        <Label Margin="10,30,0,0" VerticalAlignment="Top" HorizontalAlignment="Left" Content="加算値" />
        <TextBox x:Name="txtAdd" Margin="60,35,0,0" Width="70" VerticalAlignment="Top" HorizontalAlignment="Left" Text="{Binding AddValue}" TextAlignment="Right" />
        <Button x:Name="btnAdd" Margin="140,35,0,0" Width="30" VerticalAlignment="Top" HorizontalAlignment="Left" Content="たす">
            <b:Interaction.Triggers>
                <b:EventTrigger EventName="Click" SourceObject="{Binding ElementName=btnAdd}">
                    <b:CallMethodAction TargetObject="{Binding}" MethodName="Add" />
                </b:EventTrigger>
            </b:Interaction.Triggers>
        </Button>
        <Label Margin="10,50,0,0" VerticalAlignment="Top" HorizontalAlignment="Left" Content="減算値" />
        <TextBox x:Name="txtSub" Margin="60,55,0,0" Width="70" VerticalAlignment="Top" HorizontalAlignment="Left" Text="{Binding SubtractValue}" TextAlignment="Right" />
        <Button x:Name="btnSub" Margin="140,55,0,0" Width="30" VerticalAlignment="Top" HorizontalAlignment="Left" Content="ひく">
            <b:Interaction.Triggers>
                <b:EventTrigger EventName="Click" SourceObject="{Binding ElementName=btnSub}">
                    <b:CallMethodAction TargetObject="{Binding}" MethodName="Subtract" />
                </b:EventTrigger>
            </b:Interaction.Triggers>
        </Button>
    </Grid>
</Window>

Xamlをベタッと貼りましたが、内容は今回の主目的ではないので、「こんなもんかな」という程度でお願いします。
コードビハインドも今回不要なので、ソースは割愛します。

MainWindowViewModel.cs

上のMainWindow.xamlのDataContextになるビューモデルです。

using System;
using System.Diagnostics;
using System.Windows;
using FluxPattern.Logic;

namespace FluxPattern.View.ViewModels
{
    public class MainWindowViewModel : ViewModelBase, ISubscriber
    {
        #region StateValue変更通知プロパティ
        private int _StateValue;
        public int StateValue
        {
            get { return _StateValue; }
            set
            {
                if (_StateValue == value)
                    return;
                _StateValue = value;
                RaisePropertyChanged();
            }
        }
        #endregion

        #region AddValue変更通知プロパティ
        private int _AddValue;
        public int AddValue
        {
            get { return _AddValue; }
            set
            {
                if (_AddValue == value)
                    return;
                _AddValue = value;
                RaisePropertyChanged();
            }
        }
        #endregion

        #region SubtractValue変更通知プロパティ
        private int _SubtractValue;
        public int SubtractValue
        {
            get { return _SubtractValue; }
            set
            {
                if (_SubtractValue == value)
                    return;
                _SubtractValue = value;
                RaisePropertyChanged();
            }
        }
        #endregion

        public MainWindowViewModel()
        {
            // StateValue = 0;
            Store.Subscribe(this);
            StateValue = Store.GetState();
        }

        public void Add()
        {
            // StateValue += AddValue;
            var action = ActionCreator.CreateAdd(AddValue);
            Dispatcher.Dispatch(action);
        }

        public void Subtract()
        {
            // StateValue -= SubtractValue;
            var action = ActionCreator.CreateSub(SubtractValue);
            Dispatcher.Dispatch(action);
        }

        public void Update(string context)
        {
            StateValue = Store.GetState();
        }

        protected override void Dispose(bool disposing)
        {
            Store.Unsubscribe(this);
            base.Dispose(disposing);
        }
    }
}

こちらも、どんなMVVMインフラストラクチャーを使ったか、は今回の主目的ではないので、「何か上手くやっている」と思ってください。
このクラスで大事なのが、ISubscriberインタフェースを実装している点です。つまり、Storeの変更が通知されてきます。

コンストラクタでは、Store(=IPublisher)に自身を登録(Subscribe、購読)し、現在値にバインドしているStateValueにStoreの状態を設定します。F#側の実装を思い出していただくと、状態を表すstateの初期値に0を設定していたので、ここでは0が返ってきます。

「たす」ボタンや「ひく」ボタンを押すと、それぞれAddメソッドやSubtractメソッドが呼ばれます。どちらも対応するActionをActionCreatorの関数を使って作成し、それをDispatcherに渡しています。

Storeから変更があると、Updateメソッドが呼ばれます。今回はどの操作かというのは重要ではないので、引数(context)は使っておらず、変更された状態をGetStateメソッドで取得し、StateValueプロパティに設定して現在値を更新します。

できあがり

アプリのスクリーンショット

まとめとか

Fluxパターンを適用すると、データの向きが一方向になるので、アプリケーションとしての状態が思わぬところで更新されるということを防ぐことができ、バグの少ないプログラムに繋がります。
そして.NETの世界でも、そんなに難しい作り込みをせずに実装できるので、ある程度以上の規模になりそうなアプリでは十分採用できると思います。

もちろん、いつでもFluxパターンを適用すべき、とは思ってなくて。例えば上のコードだと、MainWindowViewModelクラスでコメントアウトで残した処理がいくつかありますが、このくらいのアプリだと「一方向で状態管理だー」とか頑張らなくても、ViewModelで閉じた処理にしても問題ない場合もあります。設計って、何かと何かのトレードオフなので、引き出しは用意しつつ、その中から適したものを使っていくのが良いですね。

Fluxパターン(一部エッセンスにRedux要素)って、JavaScript界隈では基本知識と思いますが、.NET開発者の皆さんはもしかしたら馴染みがないかな?と思ったので、何かの参考になればと取り上げてみました。
今回は簡単なアプリだったので、もう少し効き目のある別のサンプルでやってみたら良かったかもしれません。

先行研究

今回のサンプルをある程度しあげて、さて文字に起こすか……となったところで、こんな発表資料を見つけました。

https://speakerdeck.com/shinpeim/vue-dot-jsdeshi-xian-surumvvmpatan-fluxakitekutiyatofalseju-li

この資料の途中(67ページ目くらい)から、FluxとMVVMについての共通点を見出していて、「あれ?私のやろうとしてること、n番煎じだった?」なのかとドキドキしました。
今回の実装もMVVMにおけるVとVM(View)に対し、その他(Action、Dispatcher、Store)はすべてM、という作りをしています。
もちろん尾上さんの記事は、これまで何度読んできたか分からないくらいに概念が頭に染み付いているので、そりゃこういう作り方にもなるよね、と。。。
まぁ細かいところは異なる(上記の資料は、VがViewとAction、VMがDispatcher、MがStore)ことと、.NETでの話という訳でもなかったので、私の記事でも少しは意味があるかなと思って、投稿してみました。

Discussion