📐

どうして Walts を開発したのか - そして昨今の Flux

2021/07/04に公開

@armorik83 です。Flux ライブラリWaltsを開発したので、その開発に至るまでのモチベーションと昨今の Flux をまとめたポエムを記しておきます。

<img width="200px" alt="walts.png" src="https://qiita-image-store.s3.amazonaws.com/0/17959/745f55b1-ace7-c8b5-d4b1-c56a0a8cea02.png">


Flux

昨今の Flux

まずFluxとはなんだろうか。Flux の解説はすでに多数掲載されているが、ここでは「データフローを一方向としたアーキテクチャ」と定義したい。

そもそも、Flux というのはObserver パターンにちょっとした規則を設けて、かっこいい名前を与えたに過ぎないのだが、現代のフロントエンドはこの Flux を見事に受け容れた。なぜか。それは開発者が秩序を求めたからである。

これは、拡大し続けるフロントエンド・サイドの開発規模に対して、従来の MVC、正確には複数の View と複数の Controller が相互にデータを受け渡し合うアーキテクチャがスケールしなくなったことに起因する。(ここでは MVC を厳密に定義していない。GUI アーキテクチャについてなのかバックエンド・アーキテクチャについてなのか判然とさせないまま、俗語的に用いている)

シングルトンという名でごまかした巨大なグローバル神オブジェクトを至る所で書き換えあう状態、参照の共有と副作用の組み合わせが「たまたま」アプリケーションの体裁を保てていただけで、これは終盤のジェンガのようにいつ崩れてもおかしくない。

2014/5 Facebook の決断:MVC はスケールしない。ならば Flux だ。
https://www.infoq.com/jp/news/2014/05/facebook-mvc-flux

そこで Facebook は Flux を提唱した。Flux はライブラリ名ではなく、あくまでもデザインパターンである。ただ、Facebook 自身もそのパターンを体現したライブラリ "facebook/flux" を発表している。ここからはもう雨後の筍のように Flux を体現するライブラリが世界中で生まれたわけだが、日本国内だと @azu 氏の「10 分で実装する Flux」にて掲載されているmini-fluxが本質を突いていると感じる。

Flux の本質

Observer パターン自体にデータフローの方向性や規則はないため、Observer パターンにレイヤーごとのデータの方向性を規則として設けたのが Flux の本質といえる。これはたぶん世界中で既に何人も考えついているだろうが、このパターンを Flux と名付けて一斉に広めることに成功したのが Facebook の功罪だ。

レイヤーというのは、大きく分けて View と Store の二つとなる。View は GUI、つまりあらゆるユーザの操作が行われる UI レイヤーで、Store は状態・値を保持するアプリケーションレイヤーである。

ここを繋ぐのが Action Creators だ。Action Creators は View からの操作・引数を伴って、イベントを通じて Store に「なんらかの処理を経て」結果を格納する。ただ、このなんらかの処理の実装箇所について、Facebook の提唱には特に指針がない。

updateText: function(id, text) {
  AppDispatcher.dispatch({
    actionType: TodoConstants.TODO_UPDATE_TEXT,
    id: id,
    text: text
  });
}

facebook/fluxでは Action Creators はただのイベントドリブンなので、actionTypeというイベント名に必要な値をくっつけて投げているだけに過ぎない。このイベントは Store で subscribe され、actionTypeの値(ただの文字列)を switch 文で分岐し、その先に必要な処理を記述するスタイルを取っている。

つまり、Action Creators はイベント定義のみ、Dispatcher は何も処理を持たず、すべての処理は Store の switch 文内に書かれる。このとき API などの非同期処理をどう扱うかが抜け落ちがちだが、facebook/flux では View から直接呼んだり、Action Creators 内で呼んだりと、いまいち整合性がない。

Redux

いくつもの Flux ライブラリが出ては消えを繰り返すなか、ある時 Redux が飛び抜けて注目を集めた。正確にいつ頃から注目を集め始めたか定かではないが、2015 年夏前頃から騒がしくなった記憶がある。ひとつには既存の有名 Flux ライブラリ(例1, 2)が「今後はメンテナンスを行わないので、代わりに Redux を使え」とアナウンスしたことも要因に含まれるだろう。

Redux の優れていた点は「stateから新しいstateを返す関数」である Reducer を軸に据えた点だ。facebook/flux の複数 Store 運用時のwaitFor()の扱いといった、直感的でない部分を統一的 State で管理することで解決させている。

Redux の 3 つの原則

Redux には 3 つの原則がある。

https://github.com/reactjs/redux/blob/master/docs/introduction/ThreePrinciples.md

  • Single source of truth
  • State is read-only
  • Changes are made with pure functions

すべて Facebook の提唱した「データフローを一方向としたアーキテクチャ」であるところの Flux に乗った上で、さらに開発時の不用意なバグ混入を防ぐ方向に倒している。

ただ、個人的にはここで一つ大きな誤りが起きたと感じている。"Changes are made with pure functions"についてだ。

非同期処理と middleware

Redux は徹底された関数指向ゆえに、"pure functions"にこだわりすぎてしまった嫌いがある。これは副作用を期待した非同期処理との相性が悪いため、Redux の Reducer 自体は非同期処理を前提としていない。

そのため、この界隈では middleware を用いてこの問題を解決するのが常となっている。例えばredux-thunkredux-sagaなどを用いるとされる。これは Redux に対するロックインに他ならず、中立的だった View ライブラリとしての React に対して、Store ライブラリが複数の関係で依存しあう状態になってしまう。AngularJS を捨てたいのに UI パーツから他言語対応から、何から何まで AngularJS のサードパーティ・モジュールにロックインしてしまい肥大化が止まらない、いつぞやの状況を思い出す。

ロックインに対する危機感や価値観はエンジニアそれぞれだろうが、私が感じているのは、ひとつの問題を解決するために Redux があるところ、そこでの問題を解決するためにさらに middleware を入れる必要があるのは滑稽ではないか?という点である。個人的には、そこまでして導入する Redux は解決したい問題を不用意に拡げている気がしてならない。

CQRS の概念と DDD

昨日発表された複雑な JavaScript アプリケーションを考えながら作る話はとてもいい資料なので、まずは読んでもらいたい。

ここである種衝撃的だったのが、フロントエンドに CQRS の概念を持ち込むという点だった。CQRS は上記資料にある通り Command Query Responsibility Segregation(コマンドクエリ責務分離)の略称で、コマンド(書き込み)とクエリ(読み込み)を分けるというアーキテクチャであり、データフローをただ一方向にするという Flux とはまた異なるものである。もともとサーバサイド、特にインフラ方面で用いられる設計概念として広まっていたと感じており、フロントエンドのコンテキストで CQRS を聞くことはあまりなかった。なぜなら、フロントエンドのアプリケーションがそこまで複雑でなかったからである。

進むフロントエンドの複雑化に立ち向かうべく生まれたアーキテクチャが Flux であるが、更に複雑・大規模化を続ける場合にどうやってスケールさせていくかを考えたとき、もうひとつ設計概念として挙げられるのが DDD(ドメイン駆動設計)である。DDD は、全てを厳密に遵守しようとすると時間コスト・人的コストの双方が膨らみ採算に合わないが、だからといって DDD の考え方を一切採り入れないのは、とても勿体ない。DDD の中途半端な採用は勧められないが、スケーリングさせていく上での考慮点をいくつも学べるのがエリック・エヴァンスの DDDヴァーン・ヴァーノンの実践 DDDである。一言でいうと、大規模化のためのノウハウが詰まっている。

サーバサイドでの複雑さを解決するために用いられる CQRS、ドメインモデリングを中心にレイヤーごとのアプリケーション構築を進める DDD。これらも、とうとう複雑さを極めるフロントエンドに持ち込まれ始めたのだ。

その CQRS をフロントエンドで体現したライブラリがAlmin.jsであり、DDD ではかとじゅんの技術日誌 Flux と DDD の統合方法が例として挙げられる。

Angular 2

Angualr 2 とは

先日とうとう stable がリリースされた Angular 2 である。正確には Angular と呼び、バージョン番号 2.0.0 を添えるべきだが、まだ時期からして Angular 2 と表記する方が印象として効果的であるため、こちらを用いる。

Angular 2 は Angular 1 とは異なり、かなり Web 標準に寄り添った形でリニューアルを遂げた。そのため、大部分の API、module 群は差し替え可能である。そういう意味では中立に寄っている。ただ、Router, i18n, Animation などの機能は Angular 2 周辺ライブラリとして充実しており、やはりフルスタック・フレームワークの地位は譲らない。

ただしかつてあったような、ページ内のフォームなどの一部分のみを Angular 1 で動かすといったことは 2 では不向きになっている。どちらかといえば、複数のコンポーネントと複数のサービスを組み合わせて Web アプリケーションやモバイルアプリケーションを開発する用途に向いた。

となると、Angular 2 でアプリケーションを構築する上では、どのような指針で開発すればよいだろうか。

React では Facebook が Flux を提唱したが、Angular に対して Google が特に名の付いたアーキテクチャを提唱している訳ではない。このままでは DI (Dependency Injection) とシングルトン・サービスを組み合わせた開発者それぞれに委ねられたアーキテクチャ、ということになってしまう。

Angular に Flux

Flux は一方向にデータを流すと同時に、View でもルートが受け取り子コンポーネントにバケツリレーしていくという手法が取られた。これは React のデータバインディングが仮想 DOM によるもので比較的高速だったため、富豪的に頻繁に処理できたためで、逆に Angular 1 では Dirty Checking による変更検知が足枷となるため Flux との組み合わせは難しいのではないか、とされた。

実際に私が取り組んだところ、ある程度の規模ならば許容できる遅延だったが、検知対象が数百を超える辺りから、やはり差がついてしまった。

これに対して Angular 2 では、変更検知を Change Detector によっておこない非常に高速となっている。つまりデータバインディングのパフォーマンス・コストを気にしてアーキテクチャを選択するような時代ではなくなった。好きなアーキテクチャを採用することができる。そうなれば Flux の一方向のデータフローという秩序は開発する上で魅力的だ。Angular 2 でも、気を抜くとすぐに複数の Component が複数の Service を書き換え合うことになる。これでは、かつて神オブジェクトを書き換えあった頃と何も変わらない。我々は秩序が無い現場ではすぐに縦横無尽にデータを渡し合ってしまう。

さて、Flux を Angular 2 でも採用するという案は当然思い付かれており、その名もng2-reduxという、まさに Redux の Angular 2 版がある。実際、処理の中核は Redux 自身に依存しており、ng2-redux は Angular 2 とのアダプタの役目を果たしている。

ng2-redux は Angular 2 の良さを何ひとつ活かせていない

過激な見出しにしたが、本当に何ひとつ活かせていなかったので、まず実際に ng2-redux を使って感じた問題点をまとめよう。

このソースは、Redux の TodoMVCを私自身で ng2-redux に移植したものである。可能な限り元の Redux TodoMVC に似せて作ったため、そちらと比較してもらうと、むしろ React と Angular 2 の差が読めて面白いかもしれない。

では ng2-redux の何が不満だったかを挙げていく。あくまでもこれらの不満点は ng2-redux に向けたものであって、Redux 自体を否定しているわけではないと断っておく。

Output を活かせない関数のバケツリレー

Angular 2 では@Outputという子 Component のイベント発火を親でハンドリングできる API が備わっている。逆に@Inputは値を親から子へ渡すための API だ。Angular 2 way としては文字列、数値、ブール値やオブジェクトなどは@Inputを通じて渡す。ところが、関数は子に渡して呼ばせるのではなく、子が@Output経由でイベントを発火して親でハンドリングするのが美しいとされる。

しかし ng2-redux では React + Redux のコンテキストをそのまま用いているため、actionsを親から子へバケツリレーし、子がそれを呼ぶというスタイルになっている。この時点でそもそも Angular 2 way からは外れてしまっている。

Redux を TypeScript で記述する際の冗長さ

TypeScript との相性もあまり良くはない。次のソースは reducers の抜粋である。

export default function todos(
  state: Todo[] = initialState,
  action: Action
): Todo[] {
  switch (action.type) {
    case ADD_TODO:
      const addTodoAction = action as AddTodoAction;
      return [
        {
          id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
          completed: false,
          text: addTodoAction.text,
        },
        ...state,
      ];

    case DELETE_TODO:
      const deleteTodoAction = action as DeleteTodoAction;
      return state.filter((todo) => todo.id !== deleteTodoAction.id);

    case EDIT_TODO:
      const editTodoAction = action as EditTodoAction;
      return state.map((todo) =>
        todo.id === editTodoAction.id
          ? Object.assign({}, todo, { text: editTodoAction.text })
          : todo
      );

    default:
      return state;
  }
}

actionのもつプロパティがaction.typeによって変わるため、型を付け直すasと変数の再格納が行われる点が直感的ではない。Tagged union typesによって多少軽減はされるかもしれないが、型定義を逐一書く必要がある。

DI を採り入れられない

ng2-redux の中でも、Actions から先の Reducer は完全に Redux の世界である。Angular の世界で便利に使えた DI も、この中では一切使えないことになる。工夫をすれば全く無理なわけではないが、処理の記述箇所と DI 元が離れ、注入したサービスをどこまでも引数経由で持ち運ぶ必要があるなど、スマートではない。

副作用の取扱いについてやはり悩む必要がある

そこは Redux なので、redux-thunkredux-sagaといった middleware を検討せねばならない。せっかく非同期処理も難なくこなせるフルスタック Angular 2 を使っているのに、わざわざ Redux 用の middleware まで入れなければならない。これなら React をやったほうがマシではないか?

Tackling State

Angular 2 で Flux を実践するにあたって、Google エンジニアであり Angular の開発者であるVictor Savkinによって書かれた上記の記事は見逃せない。RxJSを用いて、より短く Flux 的なデザインパターンを実現するとどうなるかをまとめた記事だ。

class AddTodoAction {
  constructor(public todoId: number, public text: string) {}
}

class ToggleTodoAction {
  constructor(public id: number) {}
}

class SetVisibilityFilter {
  constructor(public filter: string) {}
}

type Action = AddTodoAction | ToggleTodoAction | SetVisibilityFilter;

Angular 2 が TypeScript を前提に設計されているため、さすがに型がある状態でのデザインパターン構築がうまい。イベントをactionType文字列と付随するプロパティ群という扱いにせず、class FooActionとしてinstanceofで判別させてしまった辺りは秀逸である。

function todos(
  initState: Todo[],
  actions: Observable<action>
): Observable<todo> {
  return actions.scan((state, action) => {
    if (action instanceof AddTodoAction) {
      const newTodo = {
        id: action.todoId,
        text: action.text,
        completed: false,
      };
      return [...state, newTodo];
    } else {
      return state.map((t) => updateTodo(t, action));
    }
  }, initState);
}

データの流れは全て RxJS のObservableで組まれておりscan()によって単独のstateを回しながらイベントとして送られてくるactionを受け取っている。

だがこれが Angular 2 での Flux を構築する上で最善なのだろうか?この Savkin's Flux の本質とは何か?Angular 2 であり Flux である理由を実現するためには何ができるだろうか?

そうして生まれたのが Walts である。


Walts 解説編は明日公開します。Walts 解説編を公開しました。

もう少し丁寧に解説した記事も書きました。

Discussion