読者です 読者をやめる 読者になる 読者になる

Reduxのテストファースト開発(第七回 サーバー・アプリケーションとの接続)

React.js Redux テストファースト

前回

uraway.hatenablog.com

Setting Up The Socket.io Client

クライアントのReduxアプリと、サーバーのReduxアプリをどのようにつなげるのか。

サーバーはすでにsocket接続を受信し、voting stateを放出する準備がある。他方、クライアントはRedux storeを持ち、簡単にデータを発送(dispatch)することができる。ここでは、これらをつなげる。

まずはインフラの導入からはじめよう。ブラウザからサーバーへのSocket.io接続を行うため、socket.io-clientを用いる。

npm install --save socket.io-client

このライブラリはSocket.ioサーバーとの接続を行うためのio関数を提供する。クライントと同じホストで、サーバーを走らせる8090ポートで接続しよう。

src/index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const socket = io(`${location.protocol}//${location.hostname}:8090`);

const routes = <Route component={App}>
  <Route path="/results" component={ResultsContainer} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

サーバーを実行してクライアントアプリを開いて、ネットワークトラフィックを調べれば、WebSocket接続が行われていることがわかるはずだ。

Receiving Actions From The Server

サーバーはstateイベントを送っている。SET_STATE actionをstoreに発送し、このイベントをlistenしよう。

src/index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);

const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch({type: 'SET_STATE', state})
);

const routes = <Route component={App}>
  <Route path="/results" component={ResultsContainer} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

ハードコーディングされたSET_STATE dispatchを取り除いていることに注意する。

UIを見てみよう。投票エントリーはサーバーで定義したエントリーのペアが表示されていれば、サーバーとクライアントは接続されている。

Dispatching Actions From React Components

UIからactionを発送(dispatch)する方法を考えよう。UIをビルドしていた時、Votingコンポーネントは、値がコールバック関数であるvoteプロパティを受け取ることを想定した。このコンポーネントはユーザーがボタンをクリックした時に、コールバック関数を発火する。しかし、ユニットテストを除いて、コールバック関数を実装していない。

ユーザーが投票すれば何が起こるのか?投票はサーバに送信されるだけではなく、クライアントサイドのあるロジックを発火する: hasVotedプロパティがコンポーネントにセットされて、ユーザーが同じペアに投票ができなくなる。

このクライアントサイドのRedux actionをVOTEと呼ぶ。 state MapのhasVotedプロパティをもつエントリーにデータを入れる。

test/reducer_spec.js

it('handles VOTE by setting hasVoted', () => {
  const state = fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  });
  const action = {type: 'VOTE', entry: 'Trainspotting'};
  const nextState = reducer(state, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    },
    hasVoted: 'Trainspotting'
  }));
});

投票エントリーでないエントリーにVOTE actionがあれば、hasVotedをセットしないようにテストする。

test/reducer_spec.js

it('does not set hasVoted for VOTE on invalid entry', () => {
  const state = fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  });
  const action = {type: 'VOTE', entry: 'Sunshine'};
  const nextState = reducer(state, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  }));
});

reducerにVOTEを追加しよう。

src/reducer.js

import {Map} from 'immutable';

function setState(state, newState) {
  return state.merge(newState);
}

function vote(state, entry) {
  const currentPair = state.getIn(['vote', 'pair']);
  if (currentPair && currentPair.includes(entry)) {
    return state.set('hasVoted', entry);
  } else {
    return state;
  }
}

export default function(state = Map(), action) {
  switch (action.type) {
  case 'SET_STATE':
    return setState(state, action.state);
  case 'VOTE':
    return vote(state, action.entry);
  }
  return state;
}

hasVotedエントリーはstateにとどまらず、次のペアに移った時にリセットすべきだ。このロジックをSET_STATEで扱う。

test/reducer_spec.js

it('removes hasVoted on SET_STATE if pair changes', () => {
  const initialState = fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    },
    hasVoted: 'Trainspotting'
  });
  const action = {
    type: 'SET_STATE',
    state: {
      vote: {
        pair: ['Sunshine', 'Slumdog Millionaire']
      }
    }
  };
  const nextState = reducer(initialState, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Sunshine', 'Slumdog Millionaire']
    }
  }));
});

SET_STATE actionハンドラにresetVoteと呼ばれる関数を追加することで実装する。

src/reducer.js

import {List, Map} from 'immutable';

function setState(state, newState) {
  return state.merge(newState);
}

function vote(state, entry) {
  const currentPair = state.getIn(['vote', 'pair']);
  if (currentPair && currentPair.includes(entry)) {
    return state.set('hasVoted', entry);
  } else {
    return state;
  }
}

function resetVote(state) {
  const hasVoted = state.get('hasVoted');
  const currentPair = state.getIn(['vote', 'pair'], List());
  if (hasVoted && !currentPair.includes(hasVoted)) {
    return state.remove('hasVoted');
  } else {
    return state;
  }
}

export default function(state = Map(), action) {
  switch (action.type) {
  case 'SET_STATE':
    return resetVote(setState(state, action.state));
  case 'VOTE':
    return vote(state, action.entry);
  }
  return state;
}

hasVotedエントリーとVotingのプロパティをつなげる。

src/components/Voting.jsx

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    hasVoted: state.get('hasVoted'),
    winner: state.get('winner')
  };
}

クライアントサイドの残りのactionのためのaction creatorを定義する。

src/action_creators.js

export function setState(state) {
  return {
    type: 'SET_STATE',
    state
  };
}

export function vote(entry) {
  return {
    type: 'VOTE',
    entry
  };
}

index.jsxで、setState action creatorをSocket.ioのイベントハンドラで使用する。

src/index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import {setState} from './action_creators';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);

const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch(setState(state))
);

const routes = <Route component={App}>
  <Route path="/results" component={ResultsContainer} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

Votingプロパティのvoteコールバック関数とvote action creatorがある。同じ名前で、投票中のエントリーを引数に取るという同じ関数の特徴を持つ。action creatorをconnect関数のふたつ目の引数に与える。

src/components/Voting.jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {connect} from 'react-redux';
import Winner from './Winner';
import Vote from './Vote';
import * as actionCreators from '../action_creators';

export const Voting = React.createClass({
  mixins: [PureRenderMixin],
  render: function() {
    return <div>
      {this.props.winner ?
        <Winner ref="winner" winner={this.props.winner} /> :
        <Vote {...this.props} />}
    </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    hasVoted: state.get('hasVoted'),
    winner: state.get('winner')
  };
}

export const VotingContainer = connect(
  mapStateToProps,
  actionCreators
)(Voting);

これでvoteプロパティがVotingに渡される。このプロパティはvote action creatorを使用するactionを作る関数で、Redux Storeにそのactionを発送する。したがって、今投票ボタンをクリックすると、actionが発送される。投票すると、ボタンがdisabledになることを確認しよう。

Sending Actions To The Server Using Redux Middleware

最後に、ユーザーのサーバーに対するアクションの結果を取得しよう。ユーザーが投票するときだけでなく、投票管理者が結果スクリーンで"NEXT"ボタンをクリックした時にも発生する。

まずはVotingコンポーネントからはじめよう。次のことを実装してきた。

  • ユーザーが投票するときにVOTE actionが作成され、クライアントサイドのRedux Storeに発送される。
  • VOTE actionはhasVoted stateをセットすることでクライアントサイドのreducerによって操作される。
  • サーバーはaction Socket.ioイベントを通してクライアントからのactionsを受け取って、サーバーサイドのRedux Storeに発送する。
  • VOTE actionは投票を登録し、得票結果を更新することで、サーバーサイドのreducerによって操作される。

すべてが揃っているように見えるが、ひとつ欠けているのは、クライアントサイドのVOTE actionをサーバーに送信することだ。

Middlewareは、actionがreducerとstoreに届く前に、呼び出される関数。ここでは、クライアントサイドのactionをサーバーに送信するために使用する。

Reduxのmiddlewareとlistenerの違いには注意しよう。middlewareはactionがstoreを叩く前に呼びだされ、actionに影響を与える。listenerはactionが発送を終えてから呼び出され、actionには影響を与えない。

ここで作成するのは、もともとのstoreだけでなく、"remote"(離れた) storeにactionを発送する"remote action middleware"である。

まずはこのmiddlewareの骨組みを設定しよう。Redux storeを取る関数であり、"next"コールバック関数を取る別の関数を返す。この関数はRedux actionを取る、第三の関数を返す。この内的な関数はmiddlewareが実装するところである。

src/remote_action_middleware.js

export default store => next => action => {

}

上記の関数は少し奇異に見えるかもしれないが、実際には次の表現法を簡潔にしたまでである。

export default function(store) {
  return function(next) {
    return function(action) {

    }
  }
}

こうした一つの引数を取る関数のネストをcurrying と呼ぶ。この場合、middlewareは設定しやすくなる。もし、すべての引数がひとつの関数(function(store, next, action) { })にまとまっていれば、middlewareが使用されるたびにすべての引数を提供しなければならなくなる。

next引数は、middlewareがその仕事を終えて、actionがstore(あるいは別のmiddleware)に送られた時にmiddlewareが呼び出すコールバック関数である。

src/remote_action_middleware.js

export default store => next => action => {
  return next(action);
}

実際に呼びだされているかどうかを、ログで確かめよう。

src/remote_action_middleware.js

export default store => next => action => {
  console.log('in middleware', action);
  return next(action);
}

middlewareはapplyMiddleware関数を使って、アクティベートされる。登録したいmiddlewareを取り、createStore関数が引数に取る関数を返す。

src/components/index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore, applyMiddleware} from 'redux';
import {Provider} from 'react-redux';
import io from 'socket.io-client';
import reducer from './reducer';
import {setState} from './action_creators';
import remoteActionMiddleware from './remote_action_middleware';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const createStoreWithMiddleware = applyMiddleware(
  remoteActionMiddleware
)(createStore);
const store = createStoreWithMiddleware(reducer);

const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch(setState(state))
);

const routes = <Route component={App}>
  <Route path="/results" component={ResultsContainer} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

アプリをリロードすれば、middlewareがactionをログ表示していることが確認できる。最初はSET_STATE、投票からはVOTEが発送されている。

middlewareが実際に行っていることは、次のmiddlewareだけでなく、Socket.ioにactionを送っている。これは送り先であるSocket.ioとの接続が必要となるが、すでにindex.jsxで実装しており、これにmiddlewareがアクセスできるようにすればよい。これを実現するためにはmiddlewareの定義をひとつ変更する。最も外側の関数にSocket.io socketを与える。

src/remote_action_middleware.js

export default socket => store => next => action {
  console.log('in middleware', action);
  return next(action);
}

index.jsxからsocketを渡すことができる。

src/index.jsx

const socket = io(`${location.protocol}//${location.hostname}:8090`);
socket.on('state', state =>
  store.dispatch(setState(state))
);

const createStoreWithMiddleware = applyMiddleware(
  remoteActionMiddleware(socket)
)(createStore);
const store = createStoreWithMiddleware(reducer);

middlewareからactionイベントをemitする。

src/remote_action_middleware.js

export default socket => store => next => action => {
  socket.emit('action', action);
  return next(action);
}

サーバーからstateの更新を取得し、SET_STATE actionを発送するとき、それをサーバーにも返してしまうので、リスナーがトリガーされ、新しいSET_STATEを呼び出す、無限ループに陥る。これを解決しよう。

middlewareがそれぞれのactionすべてをサーバーに送ってしまうのはよくない。いくつかのaction、例えばSET_STATE等はクライアント内でローカルに完結すべきだ。したがって、特定のactionのみをサーバーに送るように修正しよう。具体的には{meta: {remote: true}}プロパティを持つactionのみを送信するようにする。

src/remote_action_middleware.js

export default socket => store => next => action => {
  if (action.meta && action.meta.remote) {
    socket.emit('action', action);
  }
  return next(action);
}

VOTEのaction creatorにこのプロパティを持たせる。

src/action_creators.js

export function setState(state) {
  return {
    type: 'SET_STATE',
    state
  };
}

export function vote(entry) {
  return {
    meta: {remote: true},
    type: 'VOTE',
    entry
  };
}

ここでは何が起こるのか、復習しよう。

  • ユーザーがボタンをクリックする。VOTE actionが発送される。
  • remote action middlewareがSocket.ioコネクションを超えてactionを送信する。
  • クライアントサイドのRedux Storeがactionを扱い、ローカルなhasVoted stateをセットする。
  • サーバーにメッセージが届くと、サーバーサイドのRedux Storeがactionを扱い、得票数を更新する。
  • サーバーサイドのRedux Storeのリスナーがすべての接続されたクライアントにstateのスナップショットを送信する。
  • SET_STATE actionが接続されたクライアントにそれぞれ発送される。
  • すべての接続されたクライアントのRedux Storeがサーバーからの更新されたstateをもとにSET_STATE actionを扱う。

アプリケーションを完了させるために、"NEXT"ボタンを作成しよう。投票と同じく、サーバーはすでにロジックを備えている。

NEXTのaction creatorはremote actionの正しいタイプを必要とする。

src/action_creators.js

export function setState(state) {
  return {
    type: 'SET_STATE',
    state
  };
}

export function vote(entry) {
  return {
    meta: {remote: true},
    type: 'VOTE',
    entry
  };
}

export function next() {
  return {
    meta: {remote: true},
    type: 'NEXT'
  };
}

ResultsContainerコンポーネントはaction creatorと接続する。

src/components/Results.jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {connect} from 'react-redux';
import Winner from './Winner';
import * as actionCreators from '../action_creators';

export const Results = React.createClass({
  mixins: [PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return this.props.winner ?
      <Winner ref="winner" winner={this.props.winner} /> :
      <div className="results">
        <div className="tally">
          {this.getPair().map(entry =>
            <div key={entry} className="entry">
              <h1>{entry}</h1>
              <div className="voteCount">
                {this.getVotes(entry)}
              </div>
            </div>
          )}
        </div>
        <div className="management">
          <button ref="next"
                   className="next"
                   onClick={this.props.next}>
            Next
          </button>
        </div>
      </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    tally: state.getIn(['vote', 'tally']),
    winner: state.get('winner')
  }
}

export const ResultsContainer = connect(
  mapStateToProps,
  actionCreators
)(Results);

これで終了だ。コンピューターで結果スクリーンを開いて、モバイルデバイスで投票スクリーンを開き、動作を確認しよう。投票ボタンをクリックすると、すぐに結果スクリーンの表示が更新される。"NEXT"ボタンをクリックすると投票デバイスでも次の投票に進む。

(終)

ソースコード: 記事と違い、クライアントはES6で記述しています。

voting-client

voting-server