React
redux
redux-saga
SAGA

redux-sagaで検索機能の実装を写経してCSPのパワーを感じる

この記事は、@inuscriptさんによる「redux-observableで検索機能の実装を写経してRxJSのパワーを感じる」をRedux-Sagaで書きなおしたものです。

本記事の目的

実用コードにおける非同期処理では以下のような問題が頻出する。これらについて元記事におけるRedux-obserbable(あるいはAngularが提供する)RxJSを用いた解決方法に対して、Redux-sagaによる解決方法を比較する。

(元記事よりの引用)

この時、大きく以下3つの問題に当たる

1: キーストローク毎にリクエストするのはよろしくない
2: 同じリクエストで済むのに無意味にリクエスト飛ばすのはよろしくない
foo -> fooo -> foo -> fooo -> foo みたいな入力をした場合に不必要にデータ飛んでしまう可能性
3: 複数リクエストを発火した場合の処理
A -> Bの順番にリクエストしたら B -> Aの順で返ってきちゃって表示がおかしくなる場合
Anglar を扱ったサンプルの記事

redux-sagaの説明

redux-sagaとは何か

reduxのミドルウェアとして実装されたコンカレント処理エンジン(プロセスマネージャ)。コンカレント処理モデルはCSP1に基づいている。並行性の単位は協調型マルチタスク(非プリエンプティブ)で、これらはコルーチンを使用して実現される。generator functionがコルーチン処理を実装するために使用されている。エフェクト(作用)をデータとして扱い、副作用の分離2を可能とすることが特徴である。

generator functionでコルーチンってどういうこと?

こちらなどを参照。

ジェネレータが作る「値を次々に返すモノ」は、味付けこそイテレータ風ですが、 本質的には コルーチン(coroutine) です。 コルーチンとは、「実行の途中でリターンでき、次回コール時にはそこから処理を再開することが出来るモノ」で*2、 「メインとサブ」という関係を持たないルーチンを示すします

ジェネレータ関数/コルーチンでコンカレント処理ってどういうこと?

readux-sagaランタイムの処理を想像するに、以下のようなことをしていると思われる。

  • ジェネレータ関数は一連のエフェクトを返すイテレータです。rootSagaなどのジェネレータ関数(saga)はそれぞれ一つのイテレータに対応します。
  • takeエフェクトの処理
    • イテレータが特定のアクションをtakeするエフェクトを返したとき、アクションとイテレータの対応を保存しておきます。
    • ReduxアプリケーションがReduxアクションをdispatchしたとき、redux-sagaミドルウェアはその対応を検索し、該当アクションでブロックしているイテレータに対してnext()を呼び出します(このときdispatchの引数を与えてnextを呼び出す。その値がyieldされる値となる)。
  • callエフェクトの処理
    • Promiseにつないだthen中でそのイテレータにnextします(Proimseの結果値がyieldされる値となる)。

他、タスク間の親子関係の管理、それに従うキャンセル処理、レースコンディション(race)の処理なども行なう。

redux-sagaはテストコードを書きやすくするものって聞いたんだけど

そういう効用はあるでしょうが、本質ではない。それが証拠に、仮にテストをしなかったとしてredux-sagaの効用は無くならない。複雑な非同期処理のハンドリングに本質的な価値がある。

大まかな意味での比較

ReactiveXもredux-sagaもいずれもイベントの繰り返しをイテレータに類する機能でハンドリングする。ただし前者は内部イテレータ(プッシュ型)、後者は外部イテレータ(プル型)風味である。

コード比較

(比較対象として)Redux-observableの場合(こちらの「完成品 & 最終形」からの引用)

// epic.js
const searchEpic = (action$) => (
  action$.ofType(CHANGE_INPUT)
    .map( ({payload}) => payload )
    .debounceTime(400)
    .distinctUntilChanged()
    .switchMap( (payload) => searchApi(payload) )
    .map( result => loadResult(result) )
)

export const epics = combineEpics(
  searchEpic,
)

Redux-sagaで書きなおしたコード

// sagas.js
let lastWord;
function* changeInput() {
  yield delay(400);
  const word = yield select(state => { return state.word; });
  if (lastWord === word) {
    return;
  }
  lastWord = word;
  const json = yield call(searchApi, word);
  yield put(loadResult(json));
}

export default function* rootSaga() {
  yield takeLatest(CHANGE_INPUT, changeInput);
}

上記とミドルウェアの組み込みのところ以外はRedux-observable版と全く同じ。

redux-saga版ソースコード全体

redux-saga版のコード全体はこちら

redux-saga版のデモ

デモはこちら

redux-saga版のコードの解説

rootSaga saga全体

export default function* rootSaga() {
  yield takeLatest(CHANGE_INPUT, changeInput);
}

rootSaga一行目

export default function* rootSaga() {
  • redux-sagaにおけるsaga3はエフェクトをyieldするgenerator functionとして定義する(エフェクトについては後述)。
  • sagaが記述している実行単位はタスクと呼ばれる。sagaをforkするとタスクが起動する。
  • sagaによるタスク記述というのはワークフローエンジンにおけるワークフローシナリオと思えば良い。「こうなったらこうする」といったイベントベースの処理フローを、JavaScriptのif文やwhileループで書いていく。ただし、処理の各ステップは、「エフェクトをyieldする」という形式に統一する必要がある。すると複数のタスクがコンカレントに処理されたり、副作用を伴う処理をランタイムに任せたりすることになる。
  • rootSagaでは、エントリポイントとしてページ全体で一回だけ最初にfork起動されるタスクを記述する。

rootSaga二行目〜

  yield takeLatest(CHANGE_INPUT, changeInput);
}
  • takeLatestは、「Reduxアクションが発生したら指定したsagaからタスクをfork起動する、そしてすでに同じアクションに対して別のタスクが起動していたら、その先行するタスクはキャンセルする」という意味のエフェクトである。
  • エフェクトは単なるデータである。エフェクトを解釈するのはredux-sagaランタイムであり、sagaは副作用を持たない純粋関数である。(だからテストがしやすい)
  • エフェクトには、reduxアクションを待つtake、reduxアクションを発行するput、プロミスの完了を待つcallや、タスクを起動するfork、エフェクトを組合わせる各種エフェクト(race, takeLatest, ...)などがある。(参考)
  • 上記では、「CHANGE_INPUTアクション」が発行されたとき、changeInput sagaをtakeLatestで起動している。これで元問題の「複数リクエストを発火した場合の処理」を解決している(先行するリクエストの結果は無視される)。

changeInput saga全体

let lastWord;
function* changeInput() {
  yield delay(400);
  const word = yield select(state => { return state.word; });
  if (lastWord === word) {
    return;
  }
  lastWord = word;
  const json = yield call(searchApi, word);
  console.log(json);
  yield put(loadResult(json));
}

changeInput 1行目〜

let lastWord;
function* changeInput() {
    :
  const word = yield select(state => { return state.word; });
  if (lastWord === word) {
    return;
  }
  lastWord = word;
  • 「distinctUntilChanged」に対応する処理。前回実行した検索時のwordと一致していたら検索を行わずにリターンする4。これで「同じリクエストで済むのに無意味にリクエスト飛ばすのはよろしくない」問題を解決している。

  • changeInput 2行目

function* changeInput() {
  • chageInput sagaの定義である。前述のように、各段階でエフェクトをyieldするgenerator関数として定義されている。

changeInput 3行目

  yield delay(400);
  • delayは、指定したミリセカンド数遅延してからresolveするProimseを返す関数をcallするエフェクトを返すユーティリティ関数。意味は「400ms delayする」ただそれだけの話。
  • これで元問題の「キーストローク毎にリクエストするのはよろしくない」問題が解決される。takeLatestと合せ技で、400ms以内に発行された先行タスクはキャンセルされるため。

changeInput 4行目

  const word = yield select(state => { return state.word; });
  • selectエフェクトは、stateから値を取り出す(たぶん同期的)。

changeInput 9行目

  const json = yield call(searchApi, word);
  • callエフェクトはPromiseを返す関数の呼び出し。ここではsearchApi API関数を呼んでいる。awaitと同様に同期的に書ける。

changeInput 10行目〜

  yield put(loadResult(json));
}
  • putはreduxアクションを発行するエフェクトである。ここでは検索APIの呼び出し結果jsonをresultに設定するReduxアクションを発行する。

主観としての感想

RxJSは便利なオペレータが準備されていれば良し。ただしその大量のオペレータの正確な意味を覚え続けていられるか、他メンバーや未来の自分自身と共有できるかという問題がある。さらに上記コードにはないが、ストリームが分岐したり合流したりしたとき、極めて分かりにくいコードになる。

redux-sagaは「ベッタベタ」のコードとなり、数個のエフェクトの意味さえ押えれば読み書きができる。使いこなすにはES2015のジェネレータ関数のシンタックスとセマンティクスについてある程度熟知しておく必要はある。

要するに、「FRPの語彙」と、「ifやwhileや関数呼び出しなどの古典的命令的プログラミングの語彙」のいずれを好むのか、が両者の差異の核心である。



  1. CSPはConcurrent Sequencial Processingの略。redux-sagaはtake/putに関してredux action型を持つ単一のチャンネルを扱うCSPと見做せる5。redux-sagaを典型的なCSPとよぶべきかはわからないが、語呂が良いのでこういうタイトルにした。コルーチンとかワークフローエンジンとか副作用の分離などで置き換えても良いと思う。 

  2. 副作用のデータ化と分離は、Elm Architectureにインスパイアされていると感じられる。 

  3. redux-sagaのsagaという名は、長命分散トランザクションにおけるエラーハンドリングのsagaパターンから来ている。転じて、プロセスマネージャを意味する場合もあるようで、redux-sagaはこの意味でのsagaである。ただし、後者はそもそも誤用に近い用法である気がする。 

  4. 複数のsagaが並行実行される場合も考えると、共有変数で重複をチェックするこの方法は少々ナイーブである。ちゃんとやるならtakeLastestを使わずに明示的にtakeで回して、lastWordはそのループで参照するローカル変数にするのかな(changeInputを分離せずにrootSagaに展開)。もしくはreduxのstore変数にするのだが、それをやりたくないからsagaを使う面もある。 

  5. redux-sagaでは複数チャンネルも扱える。