まず、このバトルに関わるには、JavaScriptとReduxの知識が必要です。知識がない人には、このブログ記事は向いていません(話についていけませんから)。残りの人は、一緒に難題を解決していきましょう。
Reduxには、副作用を取り扱うための所定の方法がありません。そう、これは祝福であると同時に呪いでもあります。最善策はこれだ、という結論がまだ出ていないので、かなりの数の選択肢が流布しています。SlackやNetflixのような大きな会社がRxJSとRedux-Observableを選んだのに対して、Reactネイティブの開発者たちの間で人気が高いのはRedux Sagaです。
うわべを取り繕った記事を書くつもりはなく、道理をわきまえるつもりもありません。私は、この対決を「戦い」に持ち込むつもりなのです。thunkで満足していて、好意的な意見を期待している人は、読まない方がいいでしょう。この記事は、Githubのスターや有名人にはできないものを目指します。Redux-Observable EpicとRedux-Sagaという2つの方法が自分たちのニーズに合っているのか評価しなければなりません。
さあ皆さん、戦いの火蓋が切られました。
赤コーナー
Redux-Observable
Redux Observable Epicは、Reactiveプログラミングと呼ばれる深い歴史から生まれました。JavaScriptでは、RxJS通してReactiveプログラミングを使います。RxJSと、そのObservableストリームパターンについて知らない人は、ステロイドの効果の約束(Promise)のようなものだと思ってください。普通、Promiseの結果は1つですが、RxJSのObservableは、時系列に沿った結果の「ストリーム」を返します。
詳細:
RxJS ― ReactiveプログラミングをJavaScriptにポーティングするためのライブラリです。Reactiveプログラミングは、ポーティング先の言語によって名前が異なります。複数のプラットフォームで自分のスキルを活用するには良い方法です。
注釈:
言語
プラットフォームとフレームワーク用のReactiveX
Reactiveライブラリの言語と名前の一覧
そもそもObservableとは何かわからない、という人は、Jeremy Fairbankの動画を見て追いついてきてください。
Redux-Observable ― RxJSのObservableフローをReduxに応用して、ミドルウェアで非同期動作を取り扱います。アクションイン→アクションアウト形式のワークフローです。
Epic ― Redux上でRxJSからRedux-Observableを通していろいろなReactiveAPIを使う、観測対象の(オブザーブされる)アクションです。
青コーナー
Redux-Saga
Sagaの概念は、プロセスを取り扱う方法としてプリンストン大学から発表されました。Sagaプロセスは、フロー制御と非同期処理やキャッシュ処理などの副作用のために使用されます。Sagaプロセスをミドルウェアに入れて、所与の時刻に起動する一種の「プロセス」として使用します。Sagaはジェネレータ関数を利用して、Redux由来のイベントのワークフローに制御を譲ります。
詳細:
ES6 Generators ― 「チャンク単位で実行可能な」JavaScriptコードです。このおかげで、Sagaは、必要に応じてどこでも実行したり停止したりすることができます。
Saga ― アプリケーションフローの論理をクリーンな状態に保つことができるように、非同期、時限的、その他の副作用のためにプロセスマネージャを使用します。
警告:グラフィックコンテンツ
単純なthunkやasync/awaitに実装するのは、やり過ぎじゃないかと思われるかもしれませんね。そのとおりです。Redux-Sagaのシステムは汎用性が高く、強力で、アプリケーションを複雑なビジネスロジックに拡張するために役立ちます。Infinite Redでは、開発したどのアプリにもこれを使いましたが、使用期間が短いアプリや、これを使う利点が少なそうなアプリへの使用はお勧めしません。
両者ともに、集中型作用とフロー処理について、依存性の注入を使用する優れた手法があるようです。どちらでも、Reduxの副作用のための良いコアドライバになるでしょう。
第1ラウンド:機能性
さあ皆さん、まずは機能性をめぐる戦いです。両者とも、装備を整え、技を見せようと待ち構えています。リングの真ん中で対決です。結果はどうなるでしょうか。
おおこれは! Epicが繰り出したディスプレイです! switchMap
、mapMerge
、subscribe
などなど、これまで聞いたこともないメソッドの数々。どこもかもObservableだらけです! とどめの技をリプレイしてみましょう。
import Rx from 'rxjs' | |
export const getSomeInfo = (action$, store, {someAPI, otherDependenciesInjected}) => | |
action$.ofType('UNLEASH_THE_FURY') | |
.filter(action => action.someProps.important !== null) | |
.switchMap(action => { | |
Rx.Observable.fromPromise(otherDependenciesInjected.init()) | |
.map(result => { | |
const {data} = result | |
return SomeOtherActionCreator(data) | |
}) | |
.catch(error => Rx.Observable.of(SomeActionCreator())) | |
}) |
処理の連鎖に注目してください! Epicは、あるアクションがaction$
ストリームにあるか探すことから始まり、次に、特定の属性をもつアクションのみを選んでswitchMapに入ります。
Netflixの呼び出しがこのライブラリをlodashと比較する理由は明白です。Sagaは用語を学んだばかりのように見えます。Epicが疲れ切っていないことを願いましょう。Sagaは、かろうじて、まだ倒れていませんから。
Sagaは攻撃の機会をうかがっていたようですが、Epicは隙を見せませんでした。Sagaの戦略を見てみましょう。
import { call, put, takeEvery, takeLatest } from 'redux-saga/effects' | |
import Api from './PunchyAPI' | |
function * punchEnemy(action) { | |
try { | |
const user = yield call(Api.punchEnemy, action.payload.userId); | |
yield put({type: "ENEMY_PUNCH_SUCCEEDED", user: user}); | |
} catch (e) { | |
yield put({type: "ENEMY_PUNCH_FAILED", message: e.message}); | |
} | |
} | |
function * mySaga() { | |
yield takeEvery('ENEMY_DROPS_GUARD', punchEnemy); | |
} |
Epicほどは洗練されていません。処理を連鎖させるのではなく、独立した部分に分けています。言葉遣いのトレードオフは五分五分のようです。takeEvery
は優れたReadableなのに、put
のようなディスパッチ用の新しい用語に効果を削がれています。Sagaには相変わらずasync/await
の気配が漂っていて、人は、yield/*
という奇妙でありながら似たジェネレータスタイルの中を連れ回されます 。Sagaにはライブラリの一部として、有用な関数のずっと小さなリストが付いているのにも関わらず。これは自由か、それとも死か? 時間が教えてくれるでしょう。
歴史的な戦いとなった第1ラウンドは、機能性ではEpicが圧倒的に勝利したようです。EpicがReactiveプログラミングから生まれたことを考えると驚くにはあたりません。Sagaが敵の力量に対抗できるようになるには、もっと鍛錬しなければなりません。
第2ラウンド:使い勝手
さて、第2ラウンドに入りました。Epicは相手をぶちのめすつもりです。第1ラウンドを終えたばかりのEpicは、comboをひっさげて登場しました。1つのEpicから2つのアクションをディスパッチします!
export const comboEpic = (action$, store, {Punchy}) => | |
action$.ofType('START_ROUND') | |
.switchMap(action => Rx.Observable.of( | |
Rx.Observable.of(Punchy.keepGuardUp()), | |
Rx.Observable.fromPromise(Punchy.uppercut()) | |
) |
なんということでしょうか! 完全なミスです。どうしたEpic? リプレイしてみましょう。
これです! 3行目にミスがあります。いたるところにObservable.of
がありますが、今回は遅すぎました。
注釈:
この演算子は一般に、mergeM
に対する安全なデフォルトと考えられます。
この、安全というメリットを生かすためにswitchMap
を使ったのですが。
3行目では、Rx.Observable.merge
を使うべきだったのですが、致命的なエラーはありませんでした。現在はプロダクションに入っています。これは、Sagaに反撃のチャンスを与えることになります。*
反撃が始まりました!
import { call, put, takeEvery, takeLatest } from 'redux-saga/effects' | |
import Api from './PunchyAPI' | |
function * punchEnemy(action) { | |
try { | |
yield put({type: 'PUNCH_TO_THE_FACE'}) | |
const user = yield call(Api.superPunch, action.payload.userId) | |
yield put({type: 'ENEMY_PUNCH_SUCCEEDED', user: user}) | |
yield put({type: 'JAB'}) | |
yield put({type: 'JAB'}) | |
yield put({type: 'UPPERCUT'}) | |
} catch (e) { | |
yield put({type: 'ENEMY_PUNCH_FAILED', message: e.message}) | |
} | |
} | |
function * retort() { | |
yield takeEvery('ENEMY_DROPS_GUARD', punchEnemy) | |
} |
素晴らしい! ディスパッチングが簡単です。5つものディスパッチのコンボに注目してください。Epicはダウン寸前です。気の利いた攻撃にも、隠れた欠陥がありました。Sagaは、地味な手堅い手法でEpicのミスを突いてきたのです。
第2ラウンドは終わりに近づき、血まみれのEpicはコーナーにアドバイスを求めています。先ほどredux-observable docsからのpingEpic
の例を見ましたが、Epicには、実際にここで効果のある方策が必要です。Netflixのぼやけたスライドで見た驚くべきコードのどれかかも知れません。でも赤コーナーからは何の返事もありません。ここには先進の戦略は形づくられておらず、それこそが、Reactiveプログラミングが繁栄する土壌なのです。
でも、青コーナーから見れば、ストーリーは別の姿に見えます。Sagaのコーチのレシピhttps://redux-saga.js.org/docs/recipes/ を見てください。Sagaの見た目は美しくありませんが、いやというほどハウツーを蓄えているのは確かです。Epicがダウンして、第2ラウンドの終了ベルが鳴り響いたのですから。
第2ラウンドは見応えがありました! 両者ともに強さを持っています。Epicにはリーチとスピードがありますが、第2ラウンドはSagaの堅実な判断と容赦ない攻撃の勝利でした。次にはSagaの着実さが勝つのか、Epicが主導権を奪い返すのか。最終的な勝敗は第3ラウンドで決まります。
第3ラウンド:テスタビリティ
最終ラウンド
ここまでは素晴らしい戦いが繰り広げられていますね。第3ラウンドはテスト対決です。正直、何が起こるか全くわかりません。
Epicsはテストを始める機会をうかがっているように見えます。しかし、どのようにストリームをテストするのでしょう。ストリームが“完了”することはありませんよね。テストのやり方はEpicsが、Stu Kennedyや試験者のコミュニティと共に鍛練を重ね、驚くべき戦略を考え出した領域です。
const testEpic = (epic, count, action, callback, state = {}, dependencies = {}) => { | |
const actions = new Subject() | |
const actions$ = new ActionsObservable(actions) | |
const store = { getState: () => state } | |
epic(actions$, store, dependencies) | |
.take(count) | |
.toArray() | |
.subscribe(callback) | |
if (action.length) { | |
action.map(act => actions.next(act)) | |
} else { | |
actions.next(action) | |
} | |
} |
このヘルパー関数を使うことで、期待されたディスパッチングと共にEpicを呼び出すことができます。また、6行目のコマンドtake
によって、ストリームが終了し、チェックができるのです。なんと素晴らしい関数でしょうか。Stuとこの関数を洗練させた人々に称賛を送りましょう。
Epicsは攻撃の準備が整ったようです。これはReact Nativeなので、人気のJestでの攻撃となるでしょう。
test('performs combo punch', () => { | |
const comboAction = {type: 'COMBO_PUNCH'} | |
const expectedActions = [ | |
{type: 'LEFT_HOOK'}, | |
{type: 'RIGHT_UPPERCUT'} | |
] | |
testEpic(comboPunchEpic, expectedActions.length, comboAction, (actions) => { | |
expect(actions).toEqual(expectedActions) | |
}, {}, {punchy: PunchMock.alwaysSucceed}) | |
}) |
テストに青信号が出ました。しかし、おかしいですね。実際にディスパッチされたのは1つのアクションだけです。何てことでしょう。皆さん、私は自分の目が信じられません。テストに青信号は出ていますが、これは赤信号のはずです。何が起きているのか、わかる人はいますか。記者席にいる専門家、Testy McTestfaceに話を聞いてみましょう。
こちらTesty McTestfaceです。ご覧のとおり、全体的にミスが生じました。デフォルトのJestはアサーションを強制しません。つまり“`testEpic“`によるコールバックを行っても、“`take(2)“`を見たあとにしか起動しません。そのため9行目のexpect命令文は決して起動しないことになります。ストリームのテストが複雑だからなのか、Jestの実行中にファイリングすべきだったのかはわかりません。しかし正しい攻撃は、Stuの最新の関数テストで確認されたように、Mochaの“`done()“`コールバックを使うことと、以下のようにJestの“`expect.hasAssertions()“`を使うことでしょう。
// testEpic | |
export default (epic, count, action, callback, state = {}, dependencies = {}) => { | |
expect.hasAssertions() | |
const actions = new Subject() | |
const actions$ = new ActionsObservable(actions) | |
const store = { getState: () => state, dispatch: (mockAction) => mockAction } | |
epic(actions$, store, dependencies) | |
.take(count) | |
.toArray() | |
.subscribe(callback) | |
if (action.length) { | |
action.map(act => actions.next(act)) | |
} else { | |
actions.next(action) | |
} | |
} | |
// correct | |
test('performs combo punch', (done) => { | |
const comboAction = {type: 'COMBO_PUNCH'} | |
const expectedActions = [ | |
{type: 'LEFT_HOOK'}, | |
{type: 'RIGHT_UPPERCUT'} | |
] | |
testEpic(comboPunchEpic, expectedActions.length, comboAction, (actions) => { | |
expect(actions).toEqual(expectedActions) | |
done() | |
}, {}, {punchy: PunchMock.alwaysSucceed}) | |
}) |
ありがとうございます、McTestface。Epicの失敗を目の当たりにしたと言っても過言ではありません。別の地雷によってEpicは力を発揮できなくなり、Sagaは反撃のチャンスを得ました。Sagaの攻撃を見てみましょう。
/* WRITING A SAGA TEST - HOW TO | |
We test every yield as a step using the stepper function | |
We can control the path tested, by modifying each yeild's return | |
// testing this | |
function * punchEnemy(action) { | |
const punchResponse = yield call(Api.punchEnemy, action.payload.userId); | |
if (punchResponse.ok) { | |
yield put({type: "ENEMY_PUNCH_SUCCEEDED", punchResponse}); | |
} catch (e) { | |
yield put({type: "ENEMY_PUNCH_FAILED"}); | |
} | |
} | |
*/ | |
// helper function | |
const stepper = (fn) => (mock) => fn.next(mock).value | |
const api = API | |
const saga = punchSaga(api) | |
test('punch success path', t => { | |
const response = {ok: true} | |
// every step call can be passed the result from previous yield | |
const step = stepper(saga.punchEnemy()) | |
expect(step()).toEqual(call(api.punchEnemy)) | |
expect(step(response)).toEqual(call(FinishHim, response.data)) | |
expect(step({})).toEqual({type: 'ENEMY_PUNCH_SUCCEEDED'}) | |
}) | |
test('punch failure path', t => { | |
const response = {ok: false, status: 'WRONG'} | |
const step = stepper(saga.punchEnemy()) | |
// Step over previously tested yields | |
step() // punchEnemy step over | |
expect(step(response)).toEqual(put({type: 'ENEMY_PUNCH_FAILED'})) | |
}) |
やはり、優雅さはゼロです。ジェネレータ関数は単純に荒くて汚れています。このSagaのテストは汚物にまみれています。ご覧ください。ジェネレータにnext
を呼び出すためにStepper関数が必要で、“この値を前の部分で得たことを忘れない”戦法としてレスポンスを投入する奇妙で後進的なロジックを使わなければいけません。しかし、これでうまくいくようです。
魔法ではありません。命令に手を加えずチェックしているだけです。APIを呼び出すわけではないのでAPIを欺く必要はありません。命令で確実に呼び出すように確認するだけです。ジェネレータに関して私たちは後進的ですが、フォールスポジティブ(誤検知)はありません。成功も失敗もあり得る道を進み、ひどい結果となりました。しかしSagaはカバレッジを執拗なほど何度も見せてきます。Epicsになす術はありません。Sagaが攻撃を繰り返し、Epicsは再び窮地に立たされています。
カン、カン、カン! ゴングが鳴り響き、第3ラウンド終了です。素晴らしい熱戦でした。
皆さん、この戦いの勝者は…
判定は2対3。予想外の結果となりました。勝者はRedux-Sagasです。
なんと、驚きの大番狂わせです。Redux-observable(Epics)は大企業が支持する人気のツールですが、適応力と相まったSagaの実際の力が強すぎたのです。Sagaに美しさはありませんが、勝利を手にして帰ることとなります。
Epicは墓穴を掘ることが多かったですね。講習が開かれ、親しみやすいサービス「Gitter」に優秀な人々が集まっていますが、規模が小さすぎますし、遅すぎました。
**最高の戦いが幕を下ろします
いや、まだ続くのでしょうか?**
免責事項
私は“皆が勝者”となるブログ記事を好みません。そのため、両方のツールを使った自分の経験に基づき、公平に勝者を選びました。両者ともに素晴らしいライブラリです。経験、時間、エネルギー、コードを活用して、この2つのライブラリに基づく議論を交わしましょう。ぜひ、意見を聞かせてください。ツイートするか、私をフォローしてください。チャットをしましょう。
Gantについて
Gant LabordeはInfinite Red社のChief Technology Strategistで、書籍の著者、助教授、講演者、トレーニングのマッドサイエンティストです。GantとRed Shift publicationで働く彼の同僚が書いた著書を読んでください。オタク系の技術について議論を持ちかければ彼は熱心に聞きますし、カンファレンスがあれば喜んで講演します。
半分はウィットに富み、半分は文句で満たされる@GantLabordeのツイートをツイッターでご覧いただき、MediumやGitHubで彼をフォローしてください。彼の次の講演場所が気になる場合は、http://GantLaborde.com/で確認できます。
Jamon Holmgren、Steve Kellock、Derek Greenberg、Shawni Dannerに感謝の意を表します。