カテゴリー
プログラミング

Node.js (Server API) と React + Flux (Client) でカウントアップアプリ作成

はじめに

JS によるクライアントサイドのフレームワーク界隈では、Backbone.js や AngularJS、vue.js などいろいろと選択することができます。どのフレームワークを選択するかでにぎやかな界隈です。

今回は React を使って、Flux アーキテクチャに沿って簡単なカウントアップアプリを作ってみます。また、API と通信する部分も実装してみて、構成を確認したいと思います。

具体的なアプリの見た目は次の図のようなシンブルなものです。

図1. 作成するアプリケーションの外観

次の要件を満たすようなアプリケーションを作ります。

  • ボタン「Countup」をクリックすれば数字が 1 つ大きくなる
  • ボタン「Reset」をクリックすれば 0 に戻る
  • 複数のクライアントでカウントを共有できる

また、Heroku へのデプロイも実際にやってみます。この投稿の内容で作成したアプリは http://ssdn-heroku-nodereactflux.herokuapp.com/ で確認できます。

Flux アーキテクチャ

Flux というワードは Facebook がつけた名前で、データの流れが「ユーザー入力」→「アクション発生」→「ディスパッチ」→「処理」→「UIへ反映」の流れに沿うよう実装するものを指します。この流れ自体はよくあるものかと思います。ここにサーバー API とのやりとりも加えると次の図のようになります。

図2. アーキテクチャ

矢印はデータの流れを表します。特に、Flux で重要な部分は青い矢印にしています。サーバー側のAPI を実装するにあたり、今回は Node.js を使います。

開発環境の準備

今回の開発で利用するものは下記のとおりです。それぞれ、インストールの手順をまとめた投稿にリンクしておきます。

ひな型を clone する

まず、Heroku の Node.js アプリ向けのひな型を clone して、ここで開発を進めましょう。

$ mkdir /path/to/workspace/
$ cd /path/to/workspace/
$ git clone https://github.com/heroku/node-js-getting-started.git <heroku-app-name>
$ cd <heroku-app-name>

ディレクトリ・ファイル構成は下記のようになっていると思います。

.
|-- Procfile
|-- README.md
|-- app.json
|-- index.js
|-- package.json
|-- public
|   |-- lang-logo.png
|   |-- node.svg
|   `-- stylesheets
|       `-- main.css
`-- views
    |-- page
    |   |-- db.ejs
    |   `-- index.ejs
    `-- partials
        |-- header.ejs
        `-- nav.ejs 

この状態で一度、ローカルで実行してみましょう。

$ npm install
$ heroku local web --port 10080
(「Ctrl + c」で終了できます。)

うまく起動できたでしょうか?http://localhost:10080/ にアクセスして、「Getting Started」の画面が表示されれば OK です。

ひな型の整理

クライアント側のアプリは ./public/ 以下に格納します。サーバー側の API 向けに ./routes/ と ./models/ を新たに作成しておきます。また、Node.js では API のみ提供するため、views が不要となりますので、ここで削除して整理しておきましょう。

$ rm -rf public/* views/
$ mkdir -p routes models

結果、下記のような構成になります。

.
|-- Procfile
|-- README.md
|-- app.json
|-- index.js
|-- package.json
|-- models
|-- public
`-- routes

このままでは / にアクセスしたときエラーとなりますので、下記のように編集します。

./index.js

var express = require('express');
var app = express();

app.set('port', (process.env.PORT || 5000));

app.use(express.static(__dirname + '/public'));

app.listen(app.get('port'), function() {
  console.log('Node app is running on port', app.get('port'));
});

/ にアクセスして、./public/index.html が表示できるように、./public/index.html を作成して動作確認をしましょう。

./public/index.html

<!DOCTYPE html>
<html>
  <head>
    <title>Learning Node.js + React + Flux</title>
    <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
  </head>
  <body>
    <div>You access to /.</div>
  </body>
</html>

無事、「You access to /.」と表示されたでしょうか?ひとまず、これで準備が整いました。

API 関連を除いたクライアント部分の実装

はじめに、サーバー API なしで動作するよう、API 関連を除いたクライアント部分(下図のグレーアウトしていない部分)を実装します。

図3. コンポーネント作成 ステップ1

図3 を参考に、クライアント向けのアプリのディレクトリ(./public/ 以下) を構成します。

> mkdir -p public/js \
           public/src/actions \
           public/src/apiutils \
           public/src/components \
           public/src/constants \
           public/src/dispatcher \
           public/src/stores

下記のような構成になります。

.
|-- Procfile
|-- README.md
|-- app.json
|-- index.js
|-- package.json
|-- models
|-- public
|   |-- js
|   `-- src
|       |-- actions
|       |-- apiutils
|       |-- components
|       |-- constants
|       |-- dispatcher
|       `-- stores
`-- routes

./public/index.html 実装

index.html はクライアント側アプリの入り口となります。ここに、React で実装したアプリを読み込む script タグを追加します。

./public/index.html

<!DOCTYPE html>
<html>
  <head>
    <title>Learning Node.js + React + Flux</title>
    <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
  </head>
  <body>
    <div id="app"></div>
    <script src="./js/app.js"></script>
  </body>
</html>

Note: ./public/src/ と ./public/js/ の使い分け
 script タグ には “./js/app.js” = ./public/js/app.js と記述しましたが、実装をすすめるファイルは ./public/src/app.js になります。React では JSX 形式のソースコードを記述していきますが、ブラウザで動作確認する際は ./public/src/ 以下の js ファイルを JS 形式に変換してから 1 つに結合したものを ./public/js/app.js として保存し、これを読み込ませます。
 リリースするときは ./public/js/ のみデプロイします。

./public/src/app.js 実装

React のベースとなるソースファイルです。中身はコンポーネント「CountupApp」を描画するだけのものです。

./public/src/app.js

var React = require('react');

var CountupApp = require('./components/CountupApp.react');

React.render(
    <CountupApp />,
    document.getElementById('app')
);

コンポーネント「CountupApp」 実装

CountupApp はトップレベルのコンポーネントです。このコンポーネントには下記の役割があります。

  • 初回の描画時に、ストアから値を取得する。
  • ストア「CountupStore」からデータ更新の通知を受け取ったときに、このコンポーネントが再描画するための関数を登録・解除する。
  • 子コンポーネント「CountupAppControlPanel」を描画する。
  • 子コンポーネント「CountupAppDisplayPanel」を描画する。また、子コンポーネントで描画に必要な値を渡す。

./public/src/components/CountupApp.react.js

var React = require('react')
  , CountupAppControlPanel = require('./CountupAppControlPanel.react')
  , CountupAppDisplayPanel = require('./CountupAppDisplayPanel.react')
  , CountupStore = require('../stores/CountupStore');

function getStateFromStore () {
    return {
        count: CountupStore.getCount()
    }
}

var CountupApp = React.createClass({
    getInitialState: function () {
        return getStateFromStore();
    },
    componentDidMount: function () {
        CountupStore.addChangeListener(this._onUpdate);
    },
    componentWillUnmount: function () {
        CountupStore.removeChangeListener(this._onUpdate);
    },
    render: function () {
        return (
            <div>
                <CountupAppControlPanel />
                <CountupAppDisplayPanel count={this.state.count} />
            </div>
        );
    },
    _onUpdate: function () {
        this.setState(getStateFromStore());
    }
});

module.exports = CountupApp;

このコンポーネントには下記の役割がありません。

  • ボタン「Countup」がクリックされたとき
  • ボタン「Reset」がクリックされたとき

これらの役割は子コンポーネント「CountupAppControlPanel」に任せています。

コンポーネント「CountupAppControlPanel」 実装

このコンポーネントには下記の役割があります。

  • ボタン「Countup」がクリックされたときの処理を開始する。
  • ボタン「Reset」がクリックされたときの処理を開始する。

./public/src/components/CountupAppControlPanel.react.js

var React = require('react')
  , CountupLocalActions = require('../actions/CountupLocalActions');

var CountupAppControlPanel = React.createClass({
    render: function () {
        return (
            <div>
                <button onClick={this._onCountup}>Countup</button>
                <button onClick={this._onReset}>Reset</button>
            </div>
        );
    },
    _onCountup: function () {
        CountupLocalActions.countup();
    },
    _onReset: function () {
        CountupLocalActions.reset();
    }
});

module.exports = CountupAppControlPanel;

処理を開始する、といっても、このコンポーネントが再描画までの処理をすべてやってしまうわけではなく、アクション「CountupLocalActions」を呼び出すだけです。あとの処理はアクションに任せます。

コンポーネント「CountupAppDisplayPanel」 実装

このコンポーネントは親コンポーネント「CountupApp」から受け取った値を描画するだけです。

./public/src/components/CountupAppDisplayPanel.react.js

var React = require('react');

var CountupAppDisplayPanel = React.createClass({
    propTypes: {
        count: React.PropTypes.number.isRequired
    },
    render: function () {
        return (
            <div>
                { this.props.count }
            </div>
        );
    }
});

module.exports = CountupAppDisplayPanel;

アクション「CountupLocalActions」 実装

アクションの役割はアクション固有の actionType とともに(ここにはありませんが必要であれば)オブジェクトなどを添えた アクションオブジェクトをディスパッチャーに送ります。

ディスパッチャーから先の送り先は、目的の actionType を監視しているストアです。が、アクションはアクションオブジェクトをディスパッチャーに送るまでが担当です。

./public/src/actions/CountupLocalActions.js

var AppDispatcher = require('../dispatcher/AppDispatcher')
  , CountupConstants = require('../constants/CountupConstants');

module.exports = {
    countup: function () {
        AppDispatcher.dispatch({
            actionType: CountupConstants.COUNTUP
        });
    },
    reset: function () {
        AppDispatcher.dispatch({
            actionType: CountupConstants.RESET
        });
    }
};

ディスパッチャー「AppDispatcher」 実装

ディスパッチャーはアクションから送られたアクションオブジェクトを、対象のストアに送ります。

厳密にいうと、目的の actionType を持つアクションオブジェクトを処理するための関数をストアが予めディスパッチャーに登録しておき、ディスパッチャーはアクションオブジェクトが届くたびに、登録された関数にアクションオブジェクトを渡して実行します。

./public/src/dispatcher/AppDispatcher.js

var flux = require('flux');

module.exports = new flux.Dispatcher();

ストアにより登録された関数を管理したり、アクションオブジェクトを受け取ったときに関数を実行したりする部分は、flux モジュールを利用して、flux.Dispatcher に任せます。

ストア「CountupStore」 実装

このストアには下記の役割があります。

  • カウント数 _count を管理する。
  • ディスパッチャーに actionType「COUNTUP」とactionType「RESET」のアクションオブジェクトが届いたときに実行してもらう関数を登録する。
  • データ更新があったときに通知するコンポーネントを、登録・解除する。
  • その他、コンポーネントに公開する getter を定義する。

ストアはコンポーネント向けに setter を準備してはいけません。Flux ではデータの流れはストアからコンポーネントへ一方通行で設計する必要があります。

./public/src/stores/CountupStore.js

var assign = require('object-assign')
  , EventEmitter = require('events').EventEmitter
  , AppDispatcher = require('../dispatcher/AppDispatcher')
  , CountupConstants = require('../constants/CountupConstants');

var CHANGE_EVENT = 'change';

var _count = 0;

var CountupStore = assign({}, EventEmitter.prototype, {
    getCount: function () {
        return _count;
    },
    addChangeListener: function (callback) {
        this.on(CHANGE_EVENT, callback);
    },
    removeChangeListener: function (callback) {
        this.removeListener(CHANGE_EVENT, callback);
    },
    emitChange: function () {
        this.emit(CHANGE_EVENT);
    }
});

CountupStore.dispatchToken = AppDispatcher.register(function (action) {
    switch (action.actionType) {
        case CountupConstants.COUNTUP:
            _count += 1;
            CountupStore.emitChange();
            break;
        case CountupConstants.RESET:
            _count = 0;
            CountupStore.emitChange();
            break;
        default:
            // no op.
    }
});

module.exports = CountupStore;

ここまで作成してきたプログラムをみるとおおまかな流れが理解できるかと思いますが、ディスパッチャーからのアクションオブジェクトを受けて、ストア内のデータに変更があったときに自身にイベント CHANGE_EVENT を発行して、このストアのデータ更新をリッスンしているコンポーネントに通知します。

通知されたコンポーネントはストアに最新のデータを取得しに来たり、また別のアクションを起こしたりするかもしれません。何にせよ、ストアの役割はコンポーネントに通知したところで完了です。

定数「CountupConstants」 実装

actionType を一意に決めるための値を設定しているだけです。

./public/src/constants/CountupConstants.js

module.exports =  {
    COUNTUP: 'COUNTUP',
    RESET: 'RESET'
};

ディスパッチャーはこの値でアクションとストアを関連付けています。もし複数のファイルにまたがって定数を定義しているときは、互いに値が重複しないように注意する必要があります。

クライアント側アプリのビルドの準備

./public/src/ 以下のコードを1つにまとめて、./public/js/app.js に出力します。これには gulp browserify を使用します。

まずは、下記のように ./public/package.json を作成します。

./public/package.json

{
  "name": "countup-reactflux",
  "version": "0.0.0",
  "description": "",
  "main": "gulpfile.js",
  "dependencies": {
    "browserify": "~11.2.0",
    "flux": "~2.1.1",
    "gulp": "~3.9.0",
    "object-assign": "~4.0.1",
    "react": "~0.13.3",
    "reactify": "~1.1.1",
    "vinyl-source-stream": "~1.1.0",
    "events": "~1.1.0",
    "es6-promise": "~3.0.2"
  },
  "devDependencies": {},
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "MIT"
}

./package.json と ./public/package.json の違い
 ここで作成する ./public/package.json はあくまでクライアント側アプリの JS を 1 つにまとめるためだけに使用するものです。本番環境へデプロイするときは ./public/ 以下では ./public/index.html と ./public/js/app.js があれば十分です。
 一方で、./package.json は Heroku 側で Node.js 環境の準備する際に必要になります。

次に、./public/gulpfile.js を作成します。これはタスクランナー gulp 向けの設定ファイルを作成します。

./public/gulpfile.js

var gulp = require('gulp')
  , browserify = require('browserify')
  , source = require("vinyl-source-stream")
  , reactify = require('reactify');

gulp.task('browserify', function () {
    var b = browserify({
        entries: ['./src/app.js'],
        transform: [reactify]
    });
    return b.bundle()
        .pipe(source('app.js'))
        .pipe(gulp.dest('./js'));
});

内容をざっくり解説すると、./public/src/app.js をもとに js ファイルをたどっていって、これらを JSX から JS に変換します。加えて、1つの JS ファイルに結合したものを ./public/js/app.js に出力します。

ここまでの作業で、下記のようなディレクトリ・ファイル構成になっていると思います。(node_module 以下は除外)

.
|-- Procfile
|-- README.md
|-- app.json
|-- index.js
|-- package.json
|-- models
|-- public
|   |-- gulpfile.js
|   |-- index.html
|   |-- js
|   |-- package.json
|   `-- src
|       |-- actions
|       |   `-- CountupLocalActions.js
|       |-- apiutils
|       |-- app.js
|       |-- components
|       |   |-- CountupApp.react.js
|       |   |-- CountupAppControlPanel.react.js
|       |   `-- CountupAppDisplayPanel.react.js
|       |-- constants
|       |   `-- CountupConstants.js
|       |-- dispatcher
|       |   `-- AppDispatcher.js
|       `-- stores
|           `-- CountupStore.js
`-- routes

クライアント側アプリのビルドと動作確認

これでクライアント側アプリのビルドの準備が整いました。下記コマンドを実行しましょう。

$ cd ./public/
$ npm install
$ ./node_modules/gulp/bin/gulp.js browserify
$ cd ..
$ heroku local web --port 10080

無事、動作しましたか? 3 つあった要件のうち、下記の部分を実装できました。

  • ボタン「Countup」をクリックすれば数字が 1 つ大きくなる
  • ボタン「Reset」をクリックすれば 0 に戻る

API の実装

クライアントのローカル内で動作するアプリができましたので、次はサーバー側の API を実装しましょう。下図の左端のブロックです。

図4. コンポーネント作成 ステップ2

サーバー API は GET で現在のカウントを取得、POST でカウントを 1 つ加算、DELETE でカウントを 0 に戻す、という動作を割り当てます。すべて同じパス (/counter/) で済むようにしてみました。

./index.js の改修

カウントを永続的に格納するために MongoDB を使用します。Node.js から MongoDB に接続するために、mongoose モジュールを利用します。

また、パス /counter/ が対応するメソッド get、post、delete をルーティングする 3 行を追加しました。/今回、カウンターを扱うロジックを ./router/counter.js に実装することにします。

./index.js

var express = require('express')
  , mongoose = require('mongoose');

var app = express();

app.set('port', (process.env.PORT || 5000));
mongoose.connect(process.env.MONGOLAB_URI);

app.use(express.static(__dirname + '/public'));

var counter = require('./routes/counter');
app.get('/counter', counter.get);
app.post('/counter', counter.post);
app.delete('/counter', counter.delete);

app.listen(app.get('port'), function() {
  console.log('Node app is running on port', app.get('port'));
});

MongoDB へ接続する際の URI として環境変数 MONGOLAB_URI を指定していますが、これは Heroku 向けプログラム特有の値です。ローカルでデバッグするときは ./.env で値を設定しておきます。

./.env 追加

ローカルの開発環境向けの環境変数を定義します。

./.env

MONGOLAB_URI="mongodb://localhost:27017/devel-heroku-nodereactflux"

ローカルで稼働する MongoDB サーバーは事前にデータベース「devel-heroku-nodereactflux」を定義しておかなくても初回利用時に自動的に作成されます。

モデルの実装

カウントを格納するモデル Counter を実装します。数値 1 つを格納するだけの単純なものです。

./models/counter.js

var mongoose = require('mongoose')
  , Schema = mongoose.Schema;

var counterSchema = new Schema({
  count: { type: Number }
});

module.exports = mongoose.model('Counter', counterSchema);

API の実装

./index.js に記述したとおり、/counter/ にきたリクエストを処理する部分を実装します。

./routes/counter.js

// This route handles singleton resource counter.

var Counter = require('../models/counter');

// create document if not exists.
Counter.findOne({}, function (error, result) {
  if ( result === null ) {
    var counter = new Counter({
      count: 0
    });
    counter.save(function (error, result) {});
  }
});

// main proccesses.
module.exports = {
  get: function (request, response) {
    Counter.findOne({}, function (error, result) {
      response.json({
        id: result['_id'],
        count: result['count']
      });
    });
  },
  post: function (request, response) {
    Counter.findOne({}, function (error, result) {
      var counter = result;
      counter.count += 1;
      counter.save(function (error, result) {
        response.json({
          id: result['_id'],
          count: result['count']
        });
      });
    });
  },
  delete: function (request, response) {
    Counter.findOne({}, function (error, result) {
      var counter = result;
      counter.count = 0;
      counter.save(function (error, result) {
        response.json({
          id: result['_id'],
          count: result['count']
        });
      });
    });
  }
};

./package.json 修正

mongoose を利用していますので、依存パッケージとして ./package.json に追加しておきます。

./package.json

{
  "name": "node-js-getting-started",
  "version": "0.1.5",
  "description": "A sample Node.js app using Express 4",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "ejs": "2.3.3",
    "express": "4.13.3",
    "mongoose": "~4.1.10",
    "kerberos": "0.0.9"
  },
  "engines": {
    "node": "0.12.7"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/heroku/node-js-getting-started"
  },
  "keywords": [
    "node",
    "heroku",
    "express"
  ],
  "license": "MIT"
}

kerberos 0.0.9 の指定について
 本来、kerberos は追加する必要はありません。
 2015/10/07 現在、私の開発環境 CentOS7 において、依存パッケージ kerberos はデフォルトでバージョン 0.0.15 をインストールしようとするのですが、これが失敗します。0.0.9 を指定すればこれを回避できるため、ここで明示的に指定しています。

動作確認

ローカル環境で動作確認してみましょう。curl コマンドを利用して簡単に動作確認ができます。

$ npm install
$ heroku local web --port 10080

(下記、別の端末で実行してください)
$ curl -L http://localhost:10080/counter/ -X 'GET'
{"_id":"5617261617ad9ef527381620","count":0} ← 初回は 0 が取得できる。
$ curl -L http://localhost:10080/counter/ -X 'POST'
{"_id":"5617261617ad9ef527381620","count":1} ← 1 つカウントアップして 1 が取得できる。
$ curl -L http://localhost:10080/counter/ -X 'POST'
{"_id":"5617261617ad9ef527381620","count":2} ← 1 つカウントアップして 2 が取得できる。
$ curl -L http://localhost:10080/counter/ -X 'GET'
{"_id":"5617261617ad9ef527381620","count":2} ← 現在の値 2 が取得できる。
$ curl -L http://localhost:10080/counter/ -X 'DELETE'
{"_id":"5617261617ad9ef527381620","count":0} ← リセットして 0 が取得できる。
$ curl -L http://localhost:10080/counter/ -X 'GET'
{"_id":"5617261617ad9ef527381620","count":0} ← 現在の値 0 が取得できる。

うまく動作しましたか?

API 関連のクライアント部分の実装

サーバー側の API が完成しました。先に作っておいたクライアント側アプリに、API 関連の機能を実装しましょう。

図5. コンポーネント作成 ステップ3

API と直接やりとりする、APIUtils というコンポーネントを追加します。APIUtils はアクション「CountupLocalActions」から直接依頼を受けてサーバー API とやりとりして、結果を受け取るとアクション「CountupAPIActions」を起こします。

ディスパッチャーから見ると、ユーザーが起こしたローカルのアクション「CountupLocalActions」のアクションオブジェクトと、API からデータを取得すると発生するアクション「CountupAPIActions」のアクションオブジェクト、これら 2 種類のアクションオブジェクトを扱うことになります。

コンポーネント「CountupApp」改修

いままでアプリ起動時に初期値としてストアから値を取得していましたが、今後はサーバーにある現在の値を取得するためにアクション「CountupLocalActions.reload」も起こす部分を追加しました。

これで、このコンポーネントは生成直後はストアから値を取り出して描画しますが、続けてサーバーにある値を要求するアクションを起こして、サーバーの値を描画するようになります。

./public/src/components/CountupApp.react.js

var React = require('react')
  , CountupAppControlPanel = require('./CountupAppControlPanel.react')
  , CountupAppDisplayPanel = require('./CountupAppDisplayPanel.react')
  , CountupLocalActions = require('../actions/CountupLocalActions')
  , CountupStore = require('../stores/CountupStore');

function getStateFromStore () {
    return {
        count: CountupStore.getCount()
    }
}

var CountupApp = React.createClass({
    getInitialState: function () {
        return getStateFromStore();
    },
    componentDidMount: function () {
        CountupStore.addChangeListener(this._onUpdate);
        CountupLocalActions.reload();
    },
    componentWillUnmount: function () {
        CountupStore.removeChangeListener(this._onUpdate);
    },
    render: function () {
        return (
            <div>
                <CountupAppControlPanel />
                <CountupAppDisplayPanel count={this.state.count} />
            </div>
        );
    },
    _onUpdate: function () {
        this.setState(getStateFromStore());
    }
});

module.exports = CountupApp;

アクション「CountupLocalActions」改修

ローカルにあるストア向けにアクションを作成するのに加えて サーバーのデータを取得、更新するために APIUtils を呼び出す部分を追加します。

./public/src/actions/CountupLocalActions.js

var AppDispatcher = require('../dispatcher/AppDispatcher')
  , CountupConstants = require('../constants/CountupConstants')
  , CountupAPIUtils = require('../apiutils/CountupAPIUtils');

module.exports = {
    reload: function () {
        AppDispatcher.dispatch({
            actionType: CountupConstants.RELOAD
        });
        CountupAPIUtils.reload();
    },
    countup: function () {
        AppDispatcher.dispatch({
            actionType: CountupConstants.COUNTUP
        });
        CountupAPIUtils.countup();
    },
    reset: function () {
        AppDispatcher.dispatch({
            actionType: CountupConstants.RESET
        });
        CountupAPIUtils.reset();
    }
};

APIUtils「CountupAPIUtils」実装

AJAX でサーバーAPI にリクエストを送り、レスポンスを受け取ったときに、レスポンスに含まれるデータを添えてアクションを起こします。

./public/src/apiutils/CountupAPIUtils.js

var CountupAPIActions = require('../actions/CountupAPIActions');

module.exports = {
    reload: function () {
        $.ajax({
            url: '/counter/',
            type: 'GET'
        }).done( function (result) {
            CountupAPIActions.apisynced(result.count);
        }).fail( function (result) {
            console.log(result);
        });
    },
    countup: function () {
        $.ajax({
            url: '/counter/',
            type: 'POST'
        }).done( function (result) {
            CountupAPIActions.apisynced(result.count);
        }).fail( function (result) {
            console.log(result);
        });
    },
    reset: function () {
        $.ajax({
            url: '/counter/',
            type: 'DELETE'
        }).done( function (result) {
            CountupAPIActions.apisynced(result.count);
        }).fail( function (result) {
            console.log(result);
        });
    }
};

アクション「CountupAPIActions」実装

APIUtils「CountupAPIUtils」が起こすアクションを定義します。

./public/src/actions/CountupAPIActions.js

var AppDispatcher = require('../dispatcher/AppDispatcher')
  , CountupConstants = require('../constants/CountupConstants');

module.exports =  {
    apisynced: function (count) {
        AppDispatcher.dispatch({
            actionType: CountupConstants.APISYNCED,
            count: count
        });
    }
};

ストア「CountupStore」改修

actionType「APISYNCED」に対応する処理を新しく追加しました。このアクションオブジェクトを受け取って、ストアが管理する _count へ上書きすることで同期をとります。

./public/src/stores/CountupStore.js

var assign = require('object-assign')
  , EventEmitter = require('events').EventEmitter
  , AppDispatcher = require('../dispatcher/AppDispatcher')
  , CountupConstants = require('../constants/CountupConstants');

var CHANGE_EVENT = 'change';

var _count = 0;

var CountupStore = assign({}, EventEmitter.prototype, {
    getCount: function () {
        return _count;
    },
    addChangeListener: function (callback) {
        this.on(CHANGE_EVENT, callback);
    },
    removeChangeListener: function (callback) {
        this.removeListener(CHANGE_EVENT, callback);
    },
    emitChange: function () {
        this.emit(CHANGE_EVENT);
    }
});

CountupStore.dispatchToken = AppDispatcher.register(function (action) {
    switch (action.actionType) {
        case CountupConstants.COUNTUP:
            _count += 1;
            CountupStore.emitChange();
            break;
        case CountupConstants.RESET:
            _count = 0;
            CountupStore.emitChange();
            break;
        case CountupConstants.APISYNCED:
            _count = action.count;
            CountupStore.emitChange();
            break;
        default:
            // no op.
    }
});

module.exports = CountupStore;

コンポーネントの描画は React が担当していますので、ここで大胆に値を上書きしても、画面上で変更がなければ再描画されません。また、もしストアが大きなオブジェクトを管理していても、変更箇所のみ再描画されるため、やはりここで大胆にオブジェクトごと上書きしても大きな問題にはならないはずです。

なお、ディスパッチャーに登録しておく関数の内容について、「値やオブジェクトに変化がなければ通知しない」といった判定はしない方がいいでしょう。具体例をあげると、CountupStore.emitChange() の前段に判定文を設ける、などです。React の機能と重複して冗長になります。

dispatchToken について
 あるアクションオブジェクトが複数のストアに処理されるようなとき、ディスパッチャーでどのストアから順番に実行してもらうか指定できるよう、このような書き方をしています。
 今回はストアが1つしかないため、dispatchToken は他のストアで使用されていません。もし、親子関係にあるようなストアがある場合、「親の処理が終わるのを待ってから、子(自分)の処理を実行する」といった書き方をするときに使用します。
  詳しくは「flux waitFor」などで調査してみてください。

定数「CountupConstants」改修

actionType「RELOAD」とactionType「APISYNCED」を新しく追加しました。

./public/src/constants/CountupConstants.js

module.exports =  {
    RELOAD: 'RELOAD',
    COUNTUP: 'COUNTUP',
    RESET: 'RESET',
    APISYNCED: 'APISYNCED'
};

動作確認

これで、一通りの実装が完了しました。ビルドしなおして、ボタンをクリックしてきちんと動作するか、再読み込みしてみて最後のカウントがきちんと表示されているかをテストしてみてください。

(再ビルド)
$ cd ./public; ./node_modules/gulp/bin/gulp.js browserify; cd ..
(ローカルサーバー起動)
$ heroku local web --port 10080

他にも、API レベルでのチェック、mongo コマンドを使って MongoDB を直接チェックするなどしてもいいでしょう。

(API 経由で確認)
$ curl -L http://localhost:10080/counter/ -X 'GET'

(mongo コマンドを使って直接確認)
$ mongo devel-heroku-nodereactflux
> db.counters.find()

本番環境 Heroku へのデプロイ

完成したアプリを Heroku にデプロイして公開してみます。Heroku で Node.js アプリを新規作成してください。今回、MongoDB (MongoLab) を使用していますので、先に MongoLab の準備を済ませてから、アプリをデプロイします。

ローカルリポジトリにコミット

$ git add models/ public/index.html public/js/ routes/
$ git commit -am "v0.0.1"

MongoLab の準備

MongoLab を Heroku の Node.js アプリから使えるようにするのは簡単です。Heroku の Web サイトでアプリのダッシュボードに進み、「Resources」を開きます。ここから MongoLab を検索して選択し、有効にできます。

Node.js と MongoLab との接続については、すでにこれを考慮してプログラミングしてありますので、プログラムに手を入れる必要はありません。

ただ、今回コレクション「counters」を使うようプログラミングしましたので、MongoLab 上でこのコレクションだけ作成しておいてください。

アプリのデプロイ

いよいよアプリのデプロイです。作成した Heroku の Node.js アプリに push できるようにローカルの git リポジトリを設定して、実際に push してみましょう。push が完了したタイミングでデプロイが自動的に開始されます。

$ heroku login
$ heroku git:remote -a <heroku-app-name>
$ git push heroku master

動作確認

http://<heroku-app-name>.herokuapp.com/ にアクセスしてください。無事、動作しているでしょうか?
 このアプリにはまだ CSRF 対策を施していません。次回は CSRF 対策を実装してみます。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください