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

  • 217
    Like
  • 0
    Comment
More than 1 year has passed since last update.

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

walts.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解説編を公開しました。

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