問題提起
(※タイトルはキャッチーなのにしましたが、Middleware全般の不要論ではありません。非同期処理において不要論です。)
Redux使うときに非同期処理はどう書きますか?
「よくわからないけどMiddleware使うらしい」と思考停止していませんか?
この記事では、Reduxは本来どのように扱うことを想定されているのかと、なぜ非同期処理の文脈でもMiddlewareが出てきたのか、そして「実はMiddleware無くても読みやすく書けるよね」という話をしていこうと思います。
Reduxでの設計を悩む人への個人的な解です。
(気になる・詳しく知りたい箇所などありましたらお気軽にコメントください)
この記事のゴール
ActionDispatcherという筆者が命名したクラスを使うことで、
- 複数の非同期処理を含むロジックでも読みやすく書ける
- ネットワーク通信などを含んでもテストがしやすい
- データの永続化やロジックの共通化など様々なケースに対応できる
- かなり型安全に書ける
を実現できる設計を見せることです。
なお、この記事は
- 型がある(flow/TypeScriptなど)
- async/await記法が使える。(最近のbabelやTypeScriptには備わっている)
を前提としています。ただしそこまで難しいことではないはずです。
Middlewareだと何が困るか
redux-saga
The mental model is that a saga is like a separate thread in your application that's solely responsible for side effects.
sagaは、非同期処理をそれぞれ別のスレッドのイベントループのように切り出して、そこに副作用(主に非同期処理)の責務を押し付けるスタイルです。
日本語の記事としてはこちらが詳しいです。
redux-sagaで非同期処理と戦う
ただ非同期処理をしたいだけなのに、複数のイベントループを扱うようなメンタルモデルを強要されるのは、難しいことを難しく解決しているように思います。
(覚えれば便利なのかもしれませんが、reduxの学習コストをいたずらに上げている気がします。)
また、処理を分割した場合に互いのやりとりはActionを通じて行うことになるため、
- plain objectでやり取りする必要がある
- 処理があちこちに飛んで流れが追いにくい
- 型チェックを活かしにくい
というところが個人的に好きではありません。(特に2番)
後ほど、上記記事に載っている応用例についても言及していこうと思います。
redux-thunk
公式のドキュメントに載っている3rd partyのライブラリです。中身はびっくりするほど薄く、このくらいです。
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
}
const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;
export default thunk;
from https://github.com/gaearon/redux-thunk/blob/master/src/index.js
Middlewareの作り方としては持ってこいの事例で、Actionが飛んで来るたびに store => next => action
を扱える関数として呼び出されます。
これはこれで便利なのですが、
- ViewのPropsにdispatchをbindしたActionCreatorが渡ってくる
- 関数一つ一つにPropsの型定義を書くのが面倒。
- MiddlewareとbindActionCreatorsについて知る必要がある。
- (bindActionCreatorsを使わずとも良いが、Viewがdispatchを知ることになる)
- redux-thunkが、Actionが関数かどうかで判別しているのはズルさを感じる。
というところが好きではありません(特に1番)
ただ、これくらいなら別に使っても良いかなと思っています。(公式でもFacebookのイベントアプリでも使われていますし)
個人的に思う理想のAPI(ActionDispacher)
さて、文句ばかり言ってきましたが、僕の理想の形はこのようなAPIです。(便宜上、SomeAPIと置きます。)
class SomeAPIClass {
syncIncrement(amount: number) {
this.dispatch({type: INCREMENT, amount: amount})
}
async asyncIncrement(amount: number): Promise<void> {
this.dispatch({type: ASYNC_START});
try {
const result = await someAyncFunction()
this.dispatch({type: INCREMENT, amount: amount})
} catch (err) {
this.dispatch({type: ASYNC_FAIL})
} finally {
this.dispatch({type: ASYNC_FINISH})
}
}
}
type Props = {
count: number;
actions: SomeAPIClass;
};
export class MyComponent extends Component<void, Props, void> {
render() {
return (
<div>
<p>{`count: ${this.props.count}`}</p>
<button onClick={() => this.props.actions.syncIncrement(3)}>Increment 3</button>
<button onClick={() => this.props.actions.asyncIncrement(2)}>async Increment 2</button>
</div>
);
}
}
何が嬉しいかというと、
- Component(View層)では上から渡ってきたSomeAPIだけ知っていれば良い。
- Propsの型定義もしやすい
- SomeAPIのところは、非同期処理を含んでいても手続き的に書けて読みやすい。
と言った形です。
特に非同期処理のところは、メソッド呼び出しから「非同期開始」、「結果の通知」、「非同期の終了」と、「失敗時の通知」がわかりやすく書けているのがわかります。
ActionDispacherの書き方
さて、上記の思想を実際のアプリケーションでどのように実装するか確認します。
サンプルとして、ボタンを押すと同期/非同期的にスコアが増えていく例を挙げています。
なお、実際に動くサンプルなどはこちらのリポジトリをご利用ください。
(ちなみに、この実装はDIから着想を得ています。
コンテナ層で必要な依存を使ってインスタンス化することで、本番では正しく動き、テストでは依存をモックすることができます。)
コード
まずView側はこのような形です。先ほどと変わらず必要最小限で済んでいますね。
// @flow
import React, {Component} from 'react';
import {CounterState} from "./Entities";
import {ActionDispatcher} from "./Actions";
type Props = {
someState: SomeState;
actions: ActionDispatcher;
};
export class Counter extends Component<void, Props, void> {
render() {
const loading = (this.props.value.loadingCount === 0) ? null : <p>loading</p>;
return (
<div>
{loading}
<p>{`score: ${this.props.value.num}`}</p>
<button onClick={() => this.props.actions.increment(3)}>Increment 3</button>
<button onClick={() => this.props.actions.asyncIncrement()}>async Increment 100</button>
</div>
);
}
}
続いてActionDispatcherはこのような形です。dispatchが渡ってくるのでそれを使って非同期処理をこなしています。
// @flow
import {JsonObject} from "./Entities";
export const INCREMENT: string = 'counter/increment';
export const FETCH_REQUEST_START = 'counter/fetch_request_start';
export const FETCH_REQUEST_FINISH = 'counter/fetch_request_finish';
export class ActionDispatcher {
dispatch: (action: any) => any;
constructor(dispatch: (action: any) => any) {
this.dispatch = dispatch
}
increment(amount: number) {
this.dispatch({type: INCREMENT, amount: amount})
}
async asyncIncrement(): Promise<void> {
this.dispatch({type: FETCH_REQUEST_START});
try {
const response: Response = await fetch('/api/count', {
method: 'GET',
headers: headers,
credentials: 'include'
});
if (response.status === 200) { //2xx
const json: JsonObject = await response.json();
this.dispatch({type: INCREMENT, amount: json.amount})
} else {
throw new Error(`illegal status code: ${response.status}`);
}
} catch (err) {
console.error(err.message);
} finally {
this.dispatch({type: FETCH_REQUEST_FINISH})
}
}
}
最後にそれらをつなぎ合わせるコンテナはこのような形です。dispatchがreduxから渡されるので、それをコンストラクタの引数で処理します。
(storeから直接dispatchを受け取っても構いません。また、redux-thunkのような getState
が欲しくなったらstoreから受け取れます。)
// @flow
import * as React from "react";
import {Counter} from "./Counter";
import {connect} from "react-redux";
import type {Dispatch} from "redux";
import {ActionDispatcher} from "./Actions";
//import store from "../Store"
const mapStateToProps = (state: any) => {
return {someState: state.someState}
}
const mapDispatchToProps = (dispatch: Dispatch<any>) => {
//最新のstateが欲しくなったらstoreも引数に加える。
//外部へのアクセスを担うクラスがある場合は、それも引数に加える
return {actions: new ActionDispatcher(dispatch, someClient)}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(Counter);
テスト
(テストはmocha/sinon/assert/enzymeを使います。)
コンテナはそれぞれの関数やクラスを繋げるだけなのでテストは不要です。
View側のテストはこのような形です。
描画して、ボタンをクリックして、想定されるAPIと引数が呼ばれていることをテストしています。非同期かどうかは考慮しないのでこれで終わりです。
it('click increment button', () => {
const spy = sinon.spy();
const actions = {increment: spy};
const state: SomeState = {};
const wrapper = shallow(<Counter value={state} actions={actions} />);
wrapper.find('button').at(0).simulate('click');
const calls = spy.getCalls();
assert(calls.length === 1);
assert(deepEqual(calls[0].args, [3]));
});
続いて、ActionDispatcherのテストはこのような形です。
通信部分をモックして、dispatchをspyにすることでテストを実現しています。(通信部分のモックは本来はリポジトリ層をInjectすべきなのですが本質ではないので省略。)
it('asyncIncrement success', async () => {
//set up
fetchMock.get('/api/count', {body: {amount: 100}, status: 200});
const spyCB:any = spy();
const actions = new ActionDispatcher(spyCB);
//run
await actions.asyncIncrement();
//assert
const calls = spyCB.getCalls();
assert(calls.length === 3);
assert(deepEqual(calls[0].args, [{ type: ASYNC_START }]));
assert(deepEqual(calls[1].args, [{ type: INCREMENT, amount: 100 }]));
assert(deepEqual(calls[2].args, [{ type: ASYNC_FINISH }]));
});
ActionDispacherは複雑なパターンにも対応できるのか?
通信以外の非同期処理とそのテスト
上記の例ではfetch処理をmockしているのですが、本来は副作用を及ぼすClientをDIするのが一般的でしょうか。
DIライブラリを使っても良いのですが、react-reduxでいうところのコンテナ層でActionDispatcherのコンストラクタに実体を喰わせることで、通常の挙動を維持しつつ、テスト時にMockに差し替えることが容易になります。
具体的にどうするかは上記の Container.js
で触れています。
複数の非同期処理の連鎖
redux-sagaの記事のこちらの例を挙げてみましょう。
以下のように、一つの手続きとして読みやすく書けます。
// @flow
export class ActionDispatcher {
async fetchUser(user, pass): Promise<void> {
this.dispatch(requestUser(id))
const { payload, error } = await API.user(id)
if (!payload && error) {
this.dispatch(failureUser(error));
return;
}
this.dispatch(successUser(payload));
// チェイン: 地域名でユーザーを検索
this.dispatch(requestSearchByLocation(id));
const { payload, error } = await API.searchByLocation(id)
if (payload && !error) {
this.dispatch(successSearchByLocation(payload));
} else {
this.dispatch(failureSearchByLocation(error));
}
}
}
あるいは、それぞれの通信部分で処理を分けたい場合は以下のように書くこともできます。一連の流れは残しつつ、テストが容易になりますね。
// @flow
export class ActionDispatcher {
async fetchUser(id): Promise<void> {
await this.findUserById(id)
await this.SearchByLocation(id)
}
async findUserById(id): Promise<void> {
this.dispatch(requestUser(id))
const { payload, error } = await API.user(id)
if (!payload && error) {
this.dispatch(failureUser(error));
return;
}
this.dispatch(successUser(payload));
}
async SearchByLocation(id): Promise<void> {
// チェイン: 地域名でユーザーを検索
this.dispatch(requestSearchByLocation(id));
const { payload, error } = await API.searchByLocation(id)
if (payload && !error) {
this.dispatch(successSearchByLocation(payload));
} else {
this.dispatch(failureSearchByLocation(error));
}
}
}
非同期処理の途中キャンセル
非同期処理に時間がかかっている間に、「やっぱりその処理しなくていいや」となるケース。オートコンプリートでの候補取得の際に、古い値での結果は不要になった場合など。
この場合、それぞれの通信処理において「どの文字列でオートコンプリート対象を探しているか」を持っておくことで、現在のTextBoxの値と比較して同じであれば結果をdispatchする、といったことができます。
現在の状況をstateに保存しておくことで、非同期処理中にどのような変化があっても getState
を呼び出すことで最新の状態を知って対応することができます。
実際に通信するんじゃなくて、sagaみたいにActionを返すほうがテストしやすくない?
sagaの例は以下のようなやつですね。
assert.deepEqual(saga.next().value, call(APIRepository.fetchUser, args));
たしかに、sagaだと実際に実行はされていないのでテストの時に楽だとは思います。
ただ、テストのためにコードの書き方を大きく変更されるのは本末転倒だと考えています。
外部へ影響を与えてしまう箇所のテストなどはサーバーサイドなどでも起こる問題ですが、DIの仕組みで解消されています。つまり、コンストラクタ引数として外とのやり取りを担う層(リポジトリ/DAO層)をInjectすることで、
//コードではこのように書き、
const actions = new ActionDispatcher(dispatch. APIRepository)
//テストではこのように書く
const spy = sinon.spy()
apiRepository.fetchUser = spy
const actions = new ActionDispatcher(dispatch. apiRepository)
//...
assert(deepEqual(spy.getCalls()[0].args, [args]));
他、なんか変なことにならない?
筆者が4人くらいの規模のプロジェクトで3ヶ月ほど運用しているところ、特に問題なく開発できています。フロントエンド初めての人もいますが理解も問題無さそうです。
なんかGCで参照消されたりしないかなとか気になりましたが、redux-thunkとほぼ同じことをしているので問題無さそうです。