こんにちは、fluctの@nekoyaです。
今日は現在開発に携わっている、俗に言う「管理画面」のWebアプリケーションのアーキテクチャをご紹介します。
このアプリケーションはReactとRxJSを軸として作られており、コードはTypeScriptを使って書いています。
アプリケーションを流れるデータと状態の管理について、Write StackとRead Stackという考え方を取り入れたところ、いろいろなメリットが得られたので、そのあたりを軸に掘り下げてみます。
全体の大まかな構成
各Stackの前に、まずはアプリケーション全体の構成をざっくりと見ておきます。
流れとしては、DispatcherからWrite Stack, Read Stackを通ってStateが生成され、それをViewが受け取るという構成になっています。
全体の流れとしてはFluxっぽい何かのひとつのあり方なのですが、Stateを生成する部分をWrite StackとRead Stackに分けて考えているところに特徴があります。
また、サンプルとしてURLで指定したidのissueを表示するアプリケーションを用意しました。簡易的なものですが雰囲気はつかめるかと。
cloneして
make
とだけ実行すれば、ビルドからテスト用のnginxコンテナの起動まで一気にやるようにしています。http://localhost:18888/
からアクセスできます。
nginxコンテナがそのままコンソールに居続けるので、終了する時はCTRL+Cで止めてください。
Node.jsとyarnが動く環境が必要ですが、そのあたりは各自適当にやってください。
CQRSに基いてデータフローを組み立てる
このWrite Stack, Read Stackという考え方はAlmin.jsからの引用で、その背景にはCQRSの概念があります。
CQRSについては「複雑を増すだけだ」という考えもあるかもしれませんが、少なくとも我々が業務で取り扱っている領域において「データの更新」という副作用を局所化できるメリットは大きいと捉えています。
Alminそのものを採用しなかった理由としては、
- 当時のAlminがTypeScriptをサポートしていなかった
- RxJSを組み合わせるには独自に実装した方が都合がよかった
といったところがメインですが、Alminの根本思想として「考えながら作る」ということが掲げられており、アーキテクチャへの理解を深めるためには自身の実装を持つのは悪いことではないと考えた部分もあります。
それぞれのStackについて掘り下げる前に、全体をもう少し把握するために各要素について簡単に解説します。
Dispatcher
Viewの操作などに起因する状態の更新を受け取り、一連のデータフローの起点となるオブジェクトです。
ひとつひとつのドメインイベントをRxJSのSubjectとして切り出すようなイメージです。
それを呼び出す部分は実際にはもう一枚wrapper層を設けたりするのですが、今回のサンプルでは省略しています。
Epic
Dispatcherのイベントを受けて、最終的にRepositoryを更新するための処理を定義するレイヤです。
特徴的な部分としては、EpicはRxJSのデータフローを定義するとこまでを仕事としている点でしょうか。
データが流れてくる度にEpicが仕事をするのではなく、あくまでDispatcherとRepositoryをつなぐだけの役割を持たせています。
Repository
何らかの値を格納する場で、Write StackとRead Stackの橋渡しをする存在となります。Write Stackの終点であり、Read Stackの始点であるとも言えます。
Repositoryは自身が初期値を持つようにBehaviorSubjectを使っています。こうすることでWrite Stackが未実装の段階でもViewの開発を進めることが可能となり、チームでの並行作業なども進めやすくなります。
Store
DispatcherおよびRepositoryを入力として、単一のState Objectを生成するのがStoreです。
Epicと同様、StoreそのものはRxJSのデータフローを定義することを責務とします。
実装としては各種入力をcombineLatestでひとまとめにするだけです。
State
Viewを生成するためのアプリケーションの状態を集約した単一のオブジェクトです。
実装としては単にinterfaceをひとつ置いているだけです。
View
Stateから一連のDOM Treeを生成するReact Component群です。
ここで扱うReact Componentは全てReact0.14から導入されたStateless Functional Component(とは最近は言わなくて単にFunctionalなComponentというっぽい)としています。
そうすることで、React Componentから状態を排除してDOM Treeの生成のみを任せることができ、Viewのレイヤに複雑さを持ち込まないようにしています。
先の図ではViewがDispatcherを直接操作するように見えますが、実際にはここはもう一段抽象化しています(本エントリはWrite StackとRead Stackの解説がメインのため省略)。
全てのComponentが状態を持たないという前提を敷くことで、Componentの分割はその状態を考えることなく、表示の都合だけで切り分けられるため自由度が高くなります。
また、状態をReact Componentに持たせるとRxJSを使ったデータフローとの接合部分が煩雑になりがちなため、切り離すことでView領域の責務を軽くできるというメリットも得られます。
Write StackとRead Stack
さて、本題に戻ってWrite StackとRead Stackの分割について改めて考えてみましょう。
Write Stack
Dispatcherを入力として、Repositoryに何らかの値を保存するところまでをWrite Stackと呼びます。
CQRSで言うところのCommandの系にあたり、アプリケーションの状態を更新するための副作用をこの部分に局所化することで状態を管理しやすくする狙いがあります。
具体的には何らかのWebAPIを通じてデータを取得したり、更新したりといった操作を非同期におこなうなど、外界とのやり取りをWrite Stackに持たせます。
Read Stack
DispatcherおよびRepositoryを入力として、ViewのためのStateを生成する一連の流れをRead Stackと位置付けます。Read StackはCQRSで言うQueryにあたり、Repositoryに変更を加えることはありません。
外界とのやり取りはWrite Stackの段階で全て済んでいるため、Read Stackの実装は全て自身のアプリケーション内で完結できます。
フロントエンドの開発においては、サーバサイドとの連携部分がシステム内に分散していると何かの問題が起きた際の調査が煩雑になったり、機能追加・更新で地雷を踏む場面が増えます。
このアーキテクチャではWrite Stack以外での外界との接触を禁止することで、複雑さを局所化しています。
データフローを単純化するという意味ではDispatcherからStoreへの流れは許可せずに、全てRepositoryを通した方が簡単にはなります。
とは言え、何の操作も伴わないただの値の受け渡し(URLに含まれるパラメータをそのまま描画する場合など)に
- その値を格納するRepositoryを用意する
- Dispatcherの値をそのままRepositoryに流す経路をEpicに作る
という実装を毎回用意するのは無駄が多いため、DispatcherからStoreへの直結ルートを用意しています。
このアプリケーション構成ではViewに何らかの変更を起こすためには、必ずDispatcherに値を流してデータのフローを回す必要があります。
この直結ルートがあることで「面倒だからViewの中だけで完結させよう」という邪念を封じる効果が得られます。
何がうれしいのか
文中にも書いていますが、改めてこの構成によって得られたメリットをまとめます。
Viewから複雑さを排除できる
React Componentに状態を持たせず、Viewの再描画には常にStateの再生成を要求することでViewの責務を軽くしています。
こうすることでRead Stackをアプリケーション的に参照透明なものとして扱うことができ、Viewの見通しが良くなります。
また、将来的にもしReact以外の何かを使いたくなった場合もViewの責務が軽いので乗り換えやすくなることが期待できます。
副作用あるいは外部への依存を局所化できる
近年のフロントエンド開発においては、外部のリソース(WebAPIなど)との非同期通信とその結果を受けての処理がアプリケーションが複雑化する主な要因であると考えています。
この構成では、その複雑さを受け入れる場所をWrite Stackとして明確に定めることで「あちこちでコールバックが発生して収集がつかない」といった状況を避けることを目指しています。
これが効果を発揮したかが本当の意味で分かるのは数年先のことかもしれませんが、思考の整理のしやすさという恩恵はプロジェクトの初期段階から得られている実感はあります。
懸念される課題
あらゆる状態変化に対してViewが再構築される
React Componentに状態を持たせないということは、何らかの状態の変更に対して常にデータフローを一巡させ、Stateを再生成し、Viewを再構築することを意味します。
高いパフォーマンスが求められるケースでは不利になる場合もありますが、我々の業務ドメインにおいては構成がシンプルになるメリットの方が大きいため、ここには目をつぶっています。
少なくとも現時点ではReactのVirtualDOMに全て任せてしまって問題のないレベルのパフォーマンスが得られています。
初期学習コストが高くなる
例えば公開期間が限定されているようなキャンペーンサイトであれば、このようなStackを分けた構成は必要ないかもしれません。
我々が現在取り組んでいる「管理画面」というものは何年にも渡って使われる(もちろん会社やビジネスが存続するという前提はありますが)ことを念頭に置く必要があるため、初期学習コストを下げることはそこまで重要視していません。
コード量が増える
アプリケーションを構成する要素が増えるため、実際のところコード量は増加する傾向にあります。
この課題については、TypeScriptを採用していることでIDEのサポートも得やすくなっているため、コードの絶対量に対して人間がやるべきことを抑えられているため許容可能であると感じています。
まとめ
フロントエンドの状態管理には様々なアプローチがあり、問題領域によっても適切な手法は違ってくるでしょう。
「状態をどのように扱うか」という問いはアプリケーションの本質的な課題であり、そのあり方については常に向き合い続けるべきであると考えます。
そんなひとつのあり方として、今回ご紹介した手法が何かのきっかけになれば幸いです。