Walts - Angular 2向けFluxライブラリを作った

  • 87
    Like
  • 0
    Comment

@armorik83です。FluxライブラリWaltsを開発したので発表します。

walts.png


Walts

この度、Waltsというライブラリを開発した。ウォルツとも読めるが、ここはワルツと呼んでもらいたい。View -> Action -> Store、この三角の動きを三拍子に見立てて名付けたものだ。

数々の検証や他のライブラリの知見を経て開発に着手したのが、Angular 2用を意識して設計したFluxライブラリ"Walts"である。他のライブラリの知見や昨今のFlux事情については前日の記事にて綴ってある。

これは2016年4月に開発を始めており、それまでに私が経験してきたフロントエンドの難点や当時の案件の問題点、反省点などを数多く活かしたものとなっている。Almin.jsとも開発時期が近いようだが、全くあずかり知らぬところで開発しており、結果的にはAlmin.js側が先出しになったのでそちらも参考にしている。

DDD, CQRS, Redux, RxJS, Savkin's Flux, redux-observable…、昨今のアーキテクチャ、デザインパターン、ライブラリを調べては実践し、信頼している技術者にレビューを依頼しては開発を進めてきた。

Waltsの特徴

Waltsは「Angular 2上にFluxアーキテクチャを導入する」ことを最大のモチベーションとして設計している。また、他のFluxライブラリとは異なり、Angular 2であること、TypeScriptであることを常に念頭に置いている。

推奨実装例として、今後チュートリアル記事を準備する予定にしている。

Waltsの記述例

TodoMVCを用意しているので、ご覧いただきたい。なおng2-redux版との比較を目的に実装している。

Stateの記述例

app.state.ts
import { State } from 'walts'

import { TodosRepository, FilterType } from './todos.repository'

export interface AppState extends State {
  todos?: TodosRepository
  filter?: FilterType
}

Waltsを採り入れたAngularアプリケーションを開発する上で、最初に定義するのがこのStateである。
StateはAppState extends Stateとしてinterfaceとして宣言している。この名前はなんでもよい。

TypeScriptを前提としているため、AppStateには型のみを宣言する。

Viewの記述例

つぎにViewからのユーザ操作の入力だ。そのためには、FluxでいうAction CreatorsDispatcherを用いる。Waltsでは基本的に語を変えずにそのままにしているが、Action CreatorsのみActionsと呼ぶことにした。

todo-item.component.ts
import { Component, Input } from '@angular/core'

import { Todo } from './todo'
import { AppDispatcher } from './app.dispatcher'
import { AppActions } from './app.actions'

@Component({
  selector: 'ex-todo-item',
  template: `...`
})
export class TodoItemComponent {
  @Input() todo: Todo

  private editing: boolean

  constructor(private dispatcher: AppDispatcher,
              private actions: AppActions) {}

  ngOnInit() {
    this.editing = false
  }

  onClickDestroy() {
    const id = this.todo.id
    this.dispatcher.emit(this.actions.deleteTodo(id))
  }
}

これはAngular 2のComponentである。削除ボタンを例として取り上げよう。

onClickDestroy() {
  const id = this.todo.id
  this.dispatcher.emit(this.actions.deleteTodo(id))
}

this.dispatcherthis.actionsはComponentのconstructorに記述することで、それぞれDIして受け取っている。これらはapp.dispatcher.tsapp.actions.tsとして定義する。

this.actions.deleteTodo(id)の戻り値はあくまでも「処理を行うための関数」でしかないので、これをthis.dispatcher.emit()に渡すことで初めて実行される。

Dispatcherの記述例

app.dispatcher.ts
import { Injectable } from '@angular/core'
import { Dispatcher } from 'walts'

import { AppState } from './app.state'

@Injectable()
export class AppDispatcher extends Dispatcher<AppState> {}

Dispatcherはこれが全てである。名前はなんでもいいのだが、Waltsの提供するDispatcherからのextendsが必須なのでAppDispatcherという名前にしている。実装は何もないが、TypeScriptのGenericsを通す意味がある。

Actionsの記述例

app.actions.ts
import { Injectable } from '@angular/core'
import { Actions, Action } from 'walts'

import { AppState } from './app.state'
import { MAP_FILTERS } from './todos.repository'

@Injectable()
export class AppActions extends Actions<AppState> {
  addTodo(text: string): Action<AppState> {
    return (state) => {
      state.todos.addTodo(text)
      return state
    }
  }

  clearCompletedAction(): Action<AppState> {
    return (state) => {
      state.todos.clearCompleted()
      return state
    }
  }

  completeAll(): Action<AppState> {
    return (state) => {
      state.todos.completeAll()
      return state
    }
  }

  setFilter(filter: string): Action<AppState> {
    return (state) => ({
      filter: MAP_FILTERS[filter]
    })
  }
}

facebook/fluxReduxと大きく異るのは「Actionsに処理を書く」点である。他のFluxライブラリではActionsはイベント用トークンをactionTypeなどで記述して値と共にイベントとして発火するだけで、実際の処理はStoreのswitch文内に書いていた。Savkin's Fluxではこれをクラスのインスタンスにすることで、分岐をinstanceofで行っていた。

これに対してWaltsでは、意図的にActionsに処理を集約させるよう設計している。戻り値Action<AppState>は「AppStateを受け取りAppStateを返す関数」のことである。イベント駆動の考え方では、関数そのものをトークンとして投げ、その関数をそのまま実行するというのは、依存の結合上好まれないかもしれない、という点は把握している。トリガとハンドラを、イベント名文字列とディスパッチャによって疎にするという前提があるからだ。

ただし実際に開発していると、この懸念点はAngular 2の場合、DI機構をベースにした依存の逆転ですでに解決できており、それよりもIDEなどでView側のトリガからすぐに処理を追える点を重視すべきだと判断した。(すぐに処理を追えるとは、Actionsのソースを開くことで処理を読める、イベント名をプロジェクト内全検索にかける必要がない、ということ)

Actionsの例の解説に戻ろう。この例ではstate.todosはRepositoryとして実装しているので、ここへの操作はRepository内の副作用に期待しているが、CQRSの考え方を適用し書き込みと読み込みは分けることを推奨する。この例ではActions内では書き込みの処理のみを記述している。一方で、後述のStore内では読み込みの処理を記述する。Waltsではこういったstateのプロパティに対する副作用の期待は、やむを得ないものであると認識しており、引数state、戻り値stateの関数が維持されるならば、その中では従来のような手続き的な処理を書くこともあり得ると想定している。

このActionについてテストを実践する場合、Angular 2ではそもそもDIによるモックテストが前提となっているため、このRepositoryをモックに置き換え、直接Actionの関数を検証するだけで済む。

Actionsの名称は、この例ではAppActionsとしているが、規模に応じて分割しFooActions extends Actions<AppState>BarActions extends Actions<AppState>など複数作ることは構わない。

return (state) => ({
  filter: MAP_FILTERS[filter]
})

上記のように、stateの部分的なプロパティのみ返しても、すべてのStateは結合される(これはReactのsetState()を参考にした)

Storeの記述例

最後にStoreの記述例である。

app.store.ts
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { Store } from 'walts'

import { AppState } from './app.state'
import { AppDispatcher } from './app.dispatcher'
import { TodosRepository, FilterType } from './todos.repository'
import { Todo } from './todo'

const INIT_STATE: AppState = {
  todos: void 0,
  filter: 'showAll'
}

@Injectable()
export class AppStore extends Store<AppState> {
  constructor(protected dispatcher: AppDispatcher,
              private todosRepository: TodosRepository) {
    super((() => {
      INIT_STATE.todos = todosRepository
      return INIT_STATE;
    })(), dispatcher)
  }

  getAllTodos(): Observable<Todo[]> {
    return this.observable.map((state) => {
      return state.todos.getAll()
    })
  }

  getFilteredTodos(): Observable<Todo[]> {
    return this.observable.map((state) => {
      const todos = state.todos.getAll()
      if (state.filter === 'showAll') {
        return todos
      }
      if (state.filter === 'showActive') {
        return todos.filter((todo) => !todo.completed)
      }
      if (state.filter === 'showCompleted') {
        return todos.filter((todo) => todo.completed)
      }
      console.error('The unknown filter type has given.')
    })
  }

  getCompletedCount(): Observable<number> {
    return this.observable.map((state) => {
      return state.todos.completedCount()
    })
  }

  getFilter(): Observable<FilterType> {
    return this.observable.map((state) => {
      return state.filter
    })
  }
}

Storeも同じようにwalts/StoreをextendsしAppStoreとして用いる。

@Injectable()
export class AppStore extends Store<AppState> {
  // ...
}

const INIT_STATEにはアプリケーション起動直後の初期値を定義している。型はAppStateである。この初期値はあくまでもアプリケーションが起動した瞬間に使用されるものであり、即座に通信して値を取得し、更新することはまったく問題ない。

Storeも希望に応じて分割してよいが、注意点としてFooStore extends AppStoreFooStore extends Store<AppState>としてはいけない。なぜならStoreはシングルトンであり、複数のStoreを作成してしまうと、処理がStoreの数だけ走ってしまうからだ。(Storeを二つ生成すると、値が常に二倍になってしまう)

もしStoreをドメインごとに複数扱いたいときは、次のようにする。

@Injectable()
class AppStore extends Store<AppState> {
  ...
}

@Injectable()
class FooStore {
  constructor(store: AppStore) {}
}

@Injectable()
class BarStore {
  constructor(store: AppStore) {}
}

このように大本のStore自体は一つで、サブStoreは大本のStoreをDIして扱えばよい。これで作成されるStoreは一つとなる。Storeとそこに乗るStateは常に一つであるべきという考え方はReduxの影響を受けている。

Storeに記述する処理についてだが、CQRSに則ると読み込み中心となる。

getAllTodos(): Observable<Todo[]> {
  return this.observable.map((state) => {
    return state.todos.getAll()
  })
}

Storeのメソッドの戻り型は規則では縛っていないが、Observableを返すことを推奨する。なぜなら、ViewからStoreに対して明示的にget()してしまうと、かつての神オブジェクトを取り合っていた時代に逆戻りしてしまうからだ。FluxとはObserverパターンなので、Viewは値の変更がやってくるまで、ただ黙ってsubscribeしていればよい。

非同期処理

非同期処理はもちろん考慮に含めている。middlewareが必要なんてこともない。WaltsではActions#delayed()というメソッドを用意している。

fetchAllProducts(): Action<AppState> {
  return (state) => {
    return this.delayed((apply) => {
      this.api.getAllProducts().then((products) => {
        apply((state) => ({
          products
        }))
      })
    })
  }
}

別のアプリケーションからの引用だが、this.delayed((apply) => {})の箇所がWaltsでの非同期処理を扱う仕組みである。

なぜこのように分かれているかというと、処理を呼んだときのstateと非同期処理が終わった瞬間のstateは異なる可能性があるからだ。APIをコールする際にstateの値が必要になり、そのAPIのレスポンスをstateに格納しなければならない状況などで、これは役に立つ。

なお通常のPromiseはサポートしていないが、this.delayed()の実態はただのPromiseの型定義ラッパーに名前を付けたものなので、型定義さえ一致していればPromiseも使用できる。(あまり推奨はしない)

Waltsの始め方

Angular 2の始め方については公式のドキュメント拙記事を参照してもらいたい。

npm install --save walts

必要となるファイルは次の通りだ。ファイル名は任意であるが下記を推奨しておく。ファイルの作成場所はアプリケーションに応じて決めればよい。

touch app.state.ts app.store.ts app.dispatcher.ts app.actions.ts

各ファイルは次のような体裁を推奨している。

app.state.ts
import {State} from 'walts';

export interface AppState extends State {
  //
}
app.store.ts
import {Injectable} from '@angular/core';
import {Observable} from 'rxjs';
import {Store} from 'walts';

import {AppState} from './app.state';
import {AppDispatcher} from './app.dispatcher';

const INIT_STATE: AppState = {
  //
};

@Injectable()
export class AppStore extends Store<AppState> {
  constructor(protected dispatcher: AppDispatcher) {
    super(INIT_STATE, dispatcher);
  }
}
app.dispatcher.ts
import {Injectable} from '@angular/core';
import {Dispatcher} from 'walts';

import {AppState} from './app.state';

@Injectable()
export class AppDispatcher extends Dispatcher<AppState> {
}
app.actions
import {Injectable} from '@angular/core';
import {Actions, Action} from 'walts';

import {AppState} from './app.state';

@Injectable()
export class AppActions extends Actions<AppState> {

  actionName(): Action<AppState> {
    return (state) => state;
  }

}

Waltsのサンプル集

前節の基礎的な記述を元に、いくつかのサンプル・アプリケーションを用意している。

サポート体制

今回はロゴも作った、かなり本気だ。この内容は英訳し、英語のドキュメントと共に早期にサイトとして整備し、世界に向けてアプローチしていく予定で活動を続ける。少しでも賛同者を集め、開発やフィードバックに協力していただけると幸いである。

結び

Fluxは登場から約2年となり、おおよそ広まった感はあるが、Reduxの界隈を見ているとまだまだ試行錯誤や成長が窺える。Angularと共にフロントエンドのスケーリング、様々な設計概念をこれからも追求していき、Waltsを育てていきたい。


よろしくおねがいします。

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