RxJSで"Reduxっぽいもの"を書いてみたらAngularが楽しくて仕方がない。

  • 52
    いいね
  • 0
    コメント

事の発端はこれ。

The question is: do you really need Redux if you already use Rx? Maybe not. It's not hard to re-implement Redux in Rx. Some say it's a two-liner using Rx .scan() method. It may very well be!
(訳: RxJSを使えばReduxは簡単に実装できるよ。)

from Redux documant.


「RxJSを使えばReduxは簡単に実装できるよ。」


自己紹介

ちきさん
(Tomohiro Noguchi)

Twitter/GitHub/Qiita: @ovrmrw

ただのSIer。

(前回の登壇)
「僕はどうしてAngular2をテーマに登壇することになってしまったのか」を振り返る


今日話すこと

  • "Reduxっぽいもの"をRxJSで書いてみる。
  • それをAngularと組み合わせてStateループをガンガン回してみる。

===
GitHubリポジトリ
ovrmrw/ng2-firebase-as-a-store

参考資料
Reduxの概念をRxJSとTypeScriptで理解する(初心者向け) on Qiita


結論 (1/2)

Componentから(Serviceを経由して)Actionを発行する。
(図の赤い矢印)
Firebase-as-a-Store (4).png

  • 大きい矢印は処理の流れ。
  • それぞれのクラスはDIで繋がっている。

結論 (2/2)

Storeから流れ込んでくるStateをViewに反映させる。とても簡単!
(図の赤い矢印)
Firebase-as-a-Store (8).png

デモアプリ
https://fir-as-a-store.firebaseapp.com/


留意事項


Reduxという言葉を、
npm install reduxの意味ではなく、
"Reduxの概念"とか"Reduxのコンセプト"という意味で用います。


Reducerで非同期処理を扱います。


Angularに慣れたころの僕の感想

===

確かに便利だ。
書きやすい。
読みやすい。
モダンだ。
かっこいい。
完全に把握した。


ところでデータ(State)はどうやって管理すればいいの?


Componentの中でStateを扱う。 → ViewのStateは良いがアプリケーションのStateは向かない

Serviceの中でStateを扱う。 → どこから書き換えたか追跡つらい問題

地道にComponentツリーを辿る。 → つらい

できれば全体で、少なくともNgModule単位で一つのStoreが欲しい。


Storeどうするか問題


よく見かけるFluxの図
(なんだかよくわからないけどグルグル回る感じ)
flux-diagram-white-background.png


よく見かけるReduxの図
(これもなんだかよくわからないけどグルグル回る感じ)
redux-unidir-ui-arch.jpg


Redux使ったことないけど、なんか流行ってる


(QiitaでReduxの記事書いたらやけにストックされたし)

流行っているということは、そのアーキテクチャは広く受け入れられている。

ところが…


Reducerで非同期処理が扱えない


え、Webって非同期処理だらけじゃないの?

Middlewareとかいう謎の存在に非同期処理を書かないといけない。
redux-unidir-ui-arch.jpg


肥大化するMiddleware、終わらないMiddleware戦争


つらい


そこで、
Reducerで非同期処理を扱えるReduxを書いてAngularと組み合わせてみた。
:tada: angular-rxjs-redux :tada:
Firebase-as-a-Store (2).png

(Caution)

  • そういう名前のnpmライブラリがあるわけではありません。

元ネタ
Tackling State by Victor Savkin 1-PIL3k4Uz9WSPwSwhvT71UA.jpeg

1-T9cUHK4jzfyM1iF7Yxh0ow.jpeg

AngularでFluxやりたいなら必読。


Reduxライブラリを使わずに angular-rxjs-redux を書いてみよう


データの流れ → ストリームの流れ (RxJS用語)

Stateループをnon-blockingで回したい。
Firebase-as-a-Store (3).png


687474703a2f2f692e696d6775722e636f6d2f4149696d5138432e6a7067.jpeg


下準備開始。


Stateの型(interface)を定義しておく。

store/types.ts
export interface IncrementState {
  counter: number;
}

export interface AppState {
  increment: IncrementState | Promise<IncrementState>; // ★★★
}

(Tips)

  • incrementプロパティはReducerの中ではPromiseとして扱う。
  • type-safeなコードが書ける。

Stateの初期値を作っておく。

store/initial-state.ts
export const defaultAppState: AppState = {
  increment: {
    counter: 0
  }
};

(Tips)

  • ReduxにおいてStateは一つのオブジェクトツリーで表現されなければならない。
  • incrementプロパティはPromiseオブジェクトでも良い。

ActionをClassとして定義しておく。

store/actions.ts
export class IncrementAction { // ★★★
  constructor() { }
}

export class DecrementAction { // ★★★
  constructor() { }
}

export type Action = IncrementAction | DecrementAction;

(Tips)

  • Classとして定義することでtype-safeなコードが書ける。

主な登場人物
Dispatcher (Subject)
Provider (BehaviorSubject)
ReducerContainer (Observable)
Firebase-as-a-Store (9).png


DispatcherとProviderを定義しておく。(実体はSubject)

angular-rxjs-redux/common.ts
@Injectable()
export class Dispatcher<T> extends Subject<T> {
  constructor() { super(); }
  next(action: T) { super.next(action); }
}

export class Provider<T> extends Subject<T> {
  constructor() { super(); }
  next(newState: T) { super.next(newState); }
}

(Tips)

  • .next()をオーバーライドして引数を取ることを強制する。

Reducerの型を定義しておく。

angular-rxjs-redux/common.ts
export interface StateReducer<T> {
  (initState: T, dispatcher: Dispatcher<any>): Observable<T>;
}

(Tips)

  • StateとDispatcherを引数に取り、Observableで新しいStateを返す関数。
  • 要するに (state, action) => state みたいなもの。
  • type-safeなコードが書ける。

ヘルパー関数promisify()を用意しておく。

angular-rxjs-redux/common.ts
export function promisify<T>(state: T | Promise<T> | Observable<T>, withInnerResolve: boolean = false): Promise<T> {
  const _state = withInnerResolve ? resolveInnerAsyncStates<T>(state) : state;
  if (_state instanceof Observable) {
    return _state.take(1).toPromise();
  } else if (_state instanceof Promise) {
    return _state;
  } else {
    return Promise.resolve<T>(_state);
  }
}

(Tips)

  • 引数stateの型がT | Promise<T> | Observable<T>のいずれであってもPromise<T>にして返す関数。
  • 要するに (state) => Promise(state) ということ。

下準備終了。

  • Stateの型(interface)を定義した。
  • Stateの初期値を用意した。
  • ActionをClassとして定義した。
  • DispatcherとProviderを定義した。(実体はSubject)
  • Reducerの型を定義した。
  • ヘルパー関数promisify()等を用意した。

ストリームの流れ (1/5)

ComponentからServiceの関数を実行
Firebase-as-a-Store (4).png

(Tips)

  • ServiceクラスをComponentクラスにInjectしている。

ComponentからServiceの関数を実行する。Componentはその関数(ActionCreator)の実装を知らない。

page1/page1.component.ts
@Component({
  selector: 'my-page1',
  template: '(省略)'
})
export class Page1Component {
  constructor(
    private service: Page1Service, // ★★★
    private state: State,
  ) { }

  // ★★★
  increment(): void {
    this.service.increment();
  }

  get counter() { return this.state.getState().map(s => s.increment.counter); }
}

(Motivation)

  • Componentは放っておいてもfatになりやすい存在なのでViewの描画に必要無いものはなるべく追い出したい。

ストリームの流れ (2/5)

ServiceからDispatcherにActionを送る
Firebase-as-a-Store (5).png

(Tips)

  • DispatcherクラスをServiceクラスにInjectしている。

dispatcher$.next()の引数にActionを代入することで、どこかへActionを流そうとしている。

page1/page1.service.ts
@Injectable()
export class Page1Service {
  constructor(
    private dispatcher$: Dispatcher<Action> // ★★★
  ) { }

  // ★★★
  increment(): void {
    this.dispatcher$.next(new IncrementAction());
  }
}

(Tips)

  • Flux的に言えばActionCreatorとなる場所。
  • WebAPIを叩く場合はここでやって、Actionの引数にPromiseとかObservableを代入する。

Question

dispatcher$.next(new IncrementAction())は一体どこへ流れるのか?

Answer

Reducer内部へ流れてObservableの.scan()または.map()を発火する。


ストリームの流れ (3/5)

DispatcherがReducer内部で発火
Firebase-as-a-Store (6).png

(Tips)

  • DispatcherクラスをStoreクラスにInjectしている。

Dispatcher発火の様子 (1/2)
Dispatcherの図 (1).png

.next()するまでは何も起きない。

(Tips)

  • Dispatcherの実体はただのSubject。

Dispatcher発火の様子 (2/2)
Dispatcherの図.png

.next()すると紐付いている(購読している)場所にデータが流れ込んで発火!

(Tips)

  • 扱うStateの数が増えるほど図の下半分は増えていく。

Reduxの心臓部、Reducer。

Reducerの仕事は流れてきたDispatcherからActionを取り出してStateを更新すること。そして次に流すこと。

store/reducers.ts
// ★★★
export const incrementStateReducer: StateReducer<Promise<IncrementState>> =
  (initState: Promise<IncrementState>, dispatcher$: Dispatcher<Action>): Observable<Promise<IncrementState>> =>
    dispatcher$.scan<Promise<IncrementState>>((state, action) => { // ★★★
      if (action instanceof IncrementAction) {
        return new Promise<IncrementState>(resolve => {
          setTimeout(() => state.then(s => resolve({ counter: s.counter + 1 })), 500);
        });
      } else if (action instanceof DecrementAction) {
        return new Promise<IncrementState>(resolve => {
          setTimeout(() => state.then(s => resolve({ counter: s.counter - 1 })), 500);
        });
      } else {
        return state;
      }
    }, initState);


/* この場合は型を書かない方が読みやすいかも。 */
export const incrementStateReducer =
  (initState, dispatcher$) =>
    dispatcher$.scan((state, action) => { // ★★★
      if (action instanceof IncrementAction) {
        return new Promise(resolve => {
          setTimeout(() => state.then(s => resolve({ counter: s.counter + 1 })), 500);
        });
      } else if (action instanceof DecrementAction) {
        return new Promise(resolve => {
          setTimeout(() => state.then(s => resolve({ counter: s.counter - 1 })), 500);
        });
      } else {
        return state;
      }
    }, initState);


/* 極限まで簡潔にしたもの。(stateが非同期ではない場合) */
export const incrementStateReducer =
  (initState, dispatcher$) =>
    dispatcher$.scan((state, action) => { // ★★★
      if (action instanceof IncrementAction) {
        return { 
          counter: state.counter + 1
        };
      } else { // actionの型がIncrementActionではない場合
        return state;
      }
    }, initState);

Reducerの心構え

  • 1つのStateを更新していいのは1つのReducerだけ。
  • あるStateが想定外の値になっているとしたら、対応する1つのReducerだけを確認すれば良い。
  • Stateの更新処理を単一のReducerに閉じ込めることで秩序が保たれる。

Reducerの戻り値は.zip()でまとめられて次に流れる。
その後のprovider$.next()"更新後のState"をどこかへ流そうとしている。

store/store.ts
/* 抜粋 */
  ReducerContainer /* = Observable */
    .zip<AppState>(...[
      // ★★★ Reducer
      incrementStateReducer(promisify(this.initialState.increment), this.dispatcher$),

      (increment): AppState => { /* projection */
        return Object.assign<{}, AppState, {}>({}, this.initialState, { increment });
      }
    ])
    .subscribe(newState => {
      this.provider$.next(newState); // ★★★ Provider = BehaviorSubject
    });

(Tips)

  • incrementReducer()は第一引数の型をPromiseと定めているため、promisify()を使って型をPromiseに合わせている。
  • ReducerContainerの実体はObservable。
angular-rxjs-redux/common.ts
export class ReducerContainer<T> extends Observable<T> {
  constructor() { super(); }
}

Question

provider$.next(newData)は一体どこへ流れるのか?

Answer

StateCreatorで型を整えた後、Componentへ流れる。


ストリームの流れ (4/5)

StateCreator(Stateクラス)でStateの型を整える
Firebase-as-a-Store (7).png

(Tips)

  • StoreクラスをStateクラスにInjectしている。

Observable<T>Observable<Promise<T>>Observable<Observable<T>>
のいずれかの型で流れてくるStateを
Observable<T>の型に統一する。
(非同期処理はここで全て解決させておく)

store/state-creator.ts
@Injectable()
export class State {
  constructor(
    private store: Store // ★★★
  ) { }

  // ★★★ 
  /* Observable<AppState> --(mutation)--> Observable<ResolvedAppState> */
  getState(): Observable<ResolvedAppState> {
    return connect(takeLatest<AppState>(this.store.provider$, true)) as Observable<ResolvedAppState>;
  }


  /* 型を書かない方がわかりやすいかも。 */
  getState() {
    return connect(takeLatest(this.store.provider$, true));
  }
}

(Tips)

  • 毎回同じようなコードを書くことになるのでconnect()takeLatest()のようなヘルパー関数を用意しておく。詳しくはソースコードで。
  • takeLatest()の中で全ての非同期処理は解決している。

Question

なぜStateクラスで型を整える必要があるのか?

Answer

  • ComponentはStateがどのような型(同期or非同期)で流れてきたかを知る必要がないから。
  • 型が統一されていれば記述がシンプルになるから。

ストリームの流れ (5/5)

ComponentでStateを受け取ってViewを更新する
Firebase-as-a-Store (8).png

(Tips)

  • StateクラスをComponentクラスにInjectしている。

(下準備)
自前のPipeを用意しておく。(標準のAsyncPipeではObservable<Promise<T>>に対応できないため)

angular-rxjs-redux/common.ts
@Pipe({
  name: 'asyncState',
  pure: false
})
export class AsyncStatePipe<T> implements PipeTransform, OnDestroy {
  private subscription: Subscription;
  private latestValue: T | null = null;

  constructor(private cd: ChangeDetectorRef) { }

  ngOnDestroy() {
    if (this.subscription) { this.subscription.unsubscribe(); }
  }

  transform(observable: Observable<T>): T | null {
    if (!this.subscription) {
      /* should pass here only for the first-time. */     
      this.subscription = observable
        .subscribe(state => { // ★★★
          this.latestValue = state;
          this.cd.markForCheck();
        });
    }
    return this.latestValue;
  }
}

(Tips)

  • ここの.subscribe()がProviderから始まるストリームの終点となる。
  • .transform()は何度も実行される関数なのでSubscriptionが何回も生成されないように気を付けること。

ComponentでStateを受け取ってPipeに流す。
流れてきたStateが元々は非同期(Promise)だったこともComponentは知らない。

page1/page1.component.ts
@Component({
  selector: 'my-page1',
  template: `
    <h4>Counter</h4>
    <h5>{{counter | asyncState}}</h5> <!--- ★★★ -->
    <button (click)="increment()">+</button>
    <button (click)="decrement()">-</button>
  `
})
export class Page1Component {
  constructor(
    private service: Page1Service,
    private state: State, // ★★★
  ) { }

  increment(): void { this.service.increment(); }

  decrement(): void { this.service.decrement(); }

  // ★★★
  get counter(): Observable<number> { 
    return this.state.getState().map(s => s.increment.counter); 
  }
}

デモアプリ

https://fir-as-a-store.firebaseapp.com/

  • Incrementボタンを押すとカウントアップする。(ついでにMathStateを更新する)
  • Decrementボタンを押すとカウントダウンする。(ついでにTimeStateを更新する)

  • Actionが発行される順番とViewが更新される順番は関係しない。非同期処理が解決した順にViewに反映される。


Storeクラスの全容。これがReduxの概念だ!

(Middleware ≠ Effector)

store/store.ts
@Injectable()
export class Store {
  readonly provider$: Provider<AppState>; /* = Subject */

  constructor(
    private dispatcher$: Dispatcher<Action>,
    @Inject(InitialState) private initialState: AppState,
  ) {
    /* function createStore() { */
    this.provider$ = new BehaviorSubject(initialState);
    this.combineReducers();
    this.applyEffectors();
    /* } */
  }

  combineReducers(): void {
    ReducerContainer /* = Observable */
      .zip<AppState>(...[
        incrementStateReducer(promisify(this.initialState.increment), this.dispatcher$), /* Reducer */

        (increment): AppState => { /* projection */
          return Object.assign<{}, AppState, {}>({}, this.initialState, { increment });
        }
      ])
      .subscribe(newState => {
        this.provider$.next(newState);
        this.effectAfterReduced(newState);
      });
  }

  effectAfterReduced(newState: AppState): void {
    /* Do something after reduced. */
    console.log(newState);
  }

  applyEffectors(): void {
    /* Do something with Side-Effectors. */
  }
}

(Tips)

  • effectAfterReduced(newState)によりReducer処理後のNewStateに対して何らかのSideEffectを実行できる。

(再掲)主な登場人物
Dispatcher (Subject)
Provider (BehaviorSubject)
ReducerContainer (Observable)
Firebase-as-a-Store (9).png


こんな感じでストリームが流れる。
Dispatcher → ReducerContainer → Provider
Firebase-as-a-Store (10).png


図を重ねてみる。
Component → Service → Dispatcher → Reducers → ReducerContainer → Provider → State → Component
Firebase-as-a-Store (11).png

グルグル回る感じが作れた。


Reduxライブラリを使わずに angular-rxjs-redux を書けた!


Reduxの概念を一枚のコードで表現したのがこれ。

Cut2016_0924_1615_34.jpg

http://qiita.com/ovrmrw/items/89c79fae4a2acd8159fc

初心者でも簡単にReduxを理解できますので復習用にどうぞ。


Appendix

(ここから先は時間が余ったら話す感じで)


実は、先程のデモアプリはEffectorの部分にFirebaseとの連携を組み込んでいました。
Firebase-as-a-Store.png

(Tips)

  • Effectorは最初Middlewareという名前にしていましたが、ReduxのMiddlewareとは何か違う気がしたので副作用担当ということでEffectorとしました。

デモアプリのState初期値。
Component到達時には全てStableになっているので、Promiseが混入していても大丈夫。

store/initial-state.ts
export const defaultAppState: AppState = {
  increment: Promise.resolve<IncrementState>({
    counter: 0
  }),
  restore: false,
  uuid: generateUuid(),
  time: Promise.resolve<TimeState>({
    serial: 0
  }),
  actionName: '',
  math: {
    addition: Promise.resolve<number>(0),
    subtraction: Promise.resolve<number>(100),
    multiplication: Promise.resolve<number>(2)
  },
  nest: {
    a: Promise.resolve({
      b: Promise.resolve({
        c: true
      })
    })
  },
};

起動時と"自分以外がStateを変更したとき"には、Firebaseから取得したデータでStateを更新する。
Firebase-as-a-Store (1).png

(Tips)

  • Firebaseから取得したデータのuuidと、ローカルで生成されたuuidを比較している。

"自分がStateを変更したとき"には、Viewを更新すると同時にFirebaseのデータも更新する。
Firebase-as-a-Store (15).png

(Tips)

  • Effectorへの流れはStoreクラスのeffectAfterReduced()から始まる。
  • Storeが保持しているStateはPromiseの混在を許容するが、FirebaseはStableな値でないと書き込みできない。そのためStateが持つ全てのPromiseを解決させた上でFirebaseに書き込みする。

AさんがStateを更新するとFirebaseを経由してBさんのViewも更新される。

BさんがStateを更新するとFirebaseを経由してAさんのViewも更新される。

AさんがStateを更新すると.....

BさんがStateを更新すると.....

Aさんが.....

Bさんが.....

A.....

B.....


楽しいですね!


GitHubリポジトリ

ovrmrw/ng2-firebase-as-a-store

FirebaseEffectorを組み込んだStoreクラスはいきなり読むと難しいかもしれませんが、このスライドの内容を踏まえた上で読んでもらえればそう難しくはないと思います。


まとめ

(cons)

  • FluxとかReduxのような仕組みを構築するのは手間がかかる。
  • TypeScriptの型サポートを受けようとするとさらに手間がかかる。

(pros)

  • しかしその結果得られる秩序は非常に強固でtype-safeである。
  • 自分なりのアーキテクチャを持っていると心に余裕が持てる。(Stateがオブジェクトツリーで表現できる限り破綻しないという自信)
  • Reduxをマスターしているとフロントエンジニア同士の会話でそれ系の話になったときについていける。

===
割に合うかどうかは規模次第、本人次第。


ご清聴ありがとうございました。