MEAN Stack チュートリアルを元に Sails.js で実装してみた

IBM developerWorks の MEAN Stack で構成されたチュートリアルの記事を参考に、Sails.jsの実装方法で再構成してみました。

Node.js、Express、AngularJS、および MongoDB を使用してリアルタイム・アンケート・アプリケーションを作成する
http://www.ibm.com/developerworks/jp/web/library/wa-nodejs-polling-app/

アプリに必要なもの

  • Node.js
  • Node モジュール: Sails.js
  • Node モジュール: waterlineモジュール (sails-redis / sails-mongo / sails-postgresql など)
  • JavaScript フレームワーク: AngularJS
  • データベース: MongoDBなど
※ MongoDBを使わなくても可能

動作環境

  • Node.js v0.10.26
  • Sails.js v0.10.5
ソースコード
https://github.com/rustichearts/sails-mean-sample

ステップ 1. 基本的な Sails.js バックエンドを作成する

# View template jade
$ sails new sails-mean --viewEngine jade
info: Created a new Sails app `sails-mean`!

# View template ejs(デフォルト)
$ sails new sails-mean-ejs
info: Created a new Sails app `sails-mean-ejs`!

Jade をインストール

$ cd sails-mean
$ npm install --save jade

Sails.js アプリを実行する

# Run Sails.js
$ sails lift --verbose

http://localhost:1337 にアクセスすると Sailsが準備しているページが表示されます。

この時、表示されているページは view/layout.jade + view/homepage.jadeで、
ルーティングの設定は config/routes.js
となっています。

基本的なフロントエンドを構成する

Sails.jsではControllerを介さず、Viewファイル指定出来るのでこのままでも良いのですが、サンプルでは コントローラーロジックで title プロパティを設定しているので、同様に設定します。

ControllerとVewを設定します。

# Create Controller
$ sails generate controller root index
info: Created a new controller ("root") at api/controllers/RootController.js!

# When use CoffeeScript     
$ sails generate controller root index —coffee
info: Created a new controller ("root") at api/controllers/RootController.coffee!

// # See api/controllers//RootController.js

/**
 * RootController
 *
 * @description :: Server-side logic for managing roots
 * @help        :: See http://links.sailsjs.org/docs/controllers
 */

module.exports = {
/**
   * `RootController.index()`
   */
  index: function (req, res) {
    return res.json({
      todo: 'index() is not implemented yet!'
    });
  }
};

この状態で http://localhost:1337/root/ にアクセスすると コントローラーで res.json を返しているので、Json形式で出力されています。

次にView ファイルと割り当てます

// # Modify api/controllers//RootController.js

module.exports = {
/**
   * `RootController.index()`
   */
  index: function (req, res) {
    return res.view({
      title: 'Polls'
    });
  }
};

ここでViewファイルのパスを指定していませんが api/root(Controller)/index ので、res.view() では自動的に views/root/index.jade を読み込んでいます。

意図的に(もしくは別のコントローラーなどから)指定するなら

  index: function (req, res) {
    return res.view("root/index",{
      title: 'Polls'
    });
  }
};

となります。

// # Create views/root/index.jade

// Modify doctype 5 -> doctype html
// See: https://github.com/strongloop/express/issues/1880

doctype html
html(lang='en')
  head
    meta(charset='utf-8')
    meta(name='viewport', content='width=device-width, nitial-scale=1, user-scalable=no')
    title= title
    link(rel='stylesheet', href='//netdna.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css')

    // STYLES
    // STYLES END

    // SCRIPTS
    // SCRIPTS END

  body
    nav.navbar.navbar-inverse.navbar-fixed-top(role='navigation')
      div.navbar-header
        a.navbar-brand(href='#/polls')= title
    div.container
      div

// STYLES と // SCRIPTS のコメントはSails.jsの独自機能 でファイルの自動include(+圧縮)機能です。
あと、doctype宣言は修正しています。

サンプルではルート(/)URLでのアクセスなのでルーティング設定合わせましょう

// # Modify config/routes.js
 '/': {
    controller: 'RootController',
    action: "index"
  }

こんどは http://localhost:1337/ で表示されました。

ステップ 2. AngularJS を使用してフロントエンドのユーザー・エクスペリエンスを作成する

Angular.jsを使用するので、それぞれ設定していきます。

Angular.js を追加

bower を利用してもよいと思いますが、今回はサンプルに近づけます。

あわせて、HTML/BODY/DIVタグの変更します。

// # Modify HTML Tag "html(lang='en’)" 
html(lang='en', ng-app='polls')
//…
     // # Add angular.js Before "// SCRIPTS"
     script(src='//ajax.googleapis.com/ajax/libs/angularjs/1.0.8/angular.min.js')
     script(src='//ajax.googleapis.com/ajax/libs/angularjs/1.0.8/angular-resource.min.js')

//...

  // # Mod BODY Tag  
  body(ng-controller='PollListCtrl')

//…

      //# Mod DIV Tag at last line div
      div(ng-view)

最終的にはこうなりますね。

// Modify doctype 5 -> doctype html
// See: https://github.com/strongloop/express/issues/1880

doctype html
html(lang='en', ng-app='polls')
  head
    meta(charset='utf-8')
    meta(name='viewport', content='width=device-width, nitial-scale=1, user-scalable=no')
    title= title
    link(rel='stylesheet', href='//netdna.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css')

    // STYLES
    // STYLES END
    
    script(src='//ajax.googleapis.com/ajax/libs/angularjs/1.0.8/angular.min.js')
    script(src='//ajax.googleapis.com/ajax/libs/angularjs/1.0.8/angular-resource.min.js')

    // SCRIPTS
    // SCRIPTS END

  body(ng-controller='PollListCtrl')
    nav.navbar.navbar-inverse.navbar-fixed-top(role='navigation')
      div.navbar-header
        a.navbar-brand(href='#/polls')= title
    div.container
      div(ng-view)

Angular モジュールを作成する

Angularのコントローラーとスコープ定義します。
また、Sails.js の仕組みに合わせて、templateURLも一部変更します。

// # Create assets/js/app.js

angular.module('polls', [])
  .config(['$routeProvider', function($routeProvider) {
    $routeProvider.
      when('/polls', { templateUrl: 'templates/list.html', controller: PollListCtrl }).
      when('/poll/:pollId', { templateUrl: 'templates/item.html', controller: PollItemCtrl }).
      when('/new', { templateUrl: 'templates/new.html', controller: PollNewCtrl }).
      otherwise({ redirectTo: '/polls' });
  }]);
// # Create assets/js/dependencies/controllers.js

// Managing the poll list
function PollListCtrl($scope) {
  $scope.polls = [];
}
// Voting / viewing poll results
function PollItemCtrl($scope, $routeParams) {
  $scope.poll = {};
  $scope.vote = function() {};
}
// Creating a new poll
function PollNewCtrl($scope) {
  $scope.poll = {
    question: '',
    choices: [{ text: '' }, { text: '' }, { text: '' }]
  };
  $scope.addChoice = function() {
    $scope.poll.choices.push({ text: '' });
  };
  $scope.createPoll = function() {};
}

HTML 部分テンプレートを作成する

Angular.js で使用するテンプレートファイルを設定します。

assets/templates/*.html として配置します。

Sails.jsでは、JST(JavaScript Template)用に templates フォルダに .html を配置します。
templates フォルダの *.html は 1ファイルにまとめられ、jst.js に自動生成されます。
これにより JavaScript からも jst.js を使ってインクルードすることが出来ます。

// JavaScriptからの使用例
$(#list).append(window["JST"]["assets/templates/list.html”](data))

※JSTとして利用する場合は クライアント側に lodash.js or underscore.js が必要になります。

ng-repeat ループ内 poll._idpoll.idを変更する以外は、サンプルと同様にして設定しましょう。

// # Create assets/templates/list.html
 
<div class="page-header">
  <h1>Poll List</h1>
</div>
<div class="row">
  <div class="col-xs-5">
    <a href="#/new" class="btn btn-default"><span class="glyphicon
glyphicon-plus"></span> New Poll</a>
  </div>
  <div class="col-xs-7">
    <input type="text" class="form-control" ng-model="query"
           placeholder="Search for a poll">
  </div>
</div>
<div class="row"><div class="col-xs-12">
  <hr></div></div>
<div class="row" ng-switch on="polls.length">
  <ul ng-switch-when="0">
    <li><em>No polls in database. Would you like to
      <a href="#/new">create one</a>?</em></li>
  </ul>
  <ul ng-switch-default>
    <li ng-repeat="poll in polls | filter:query">
      <a href="#/poll/{{poll.id}}">{{poll.question}}</a>
    </li>
  </ul>
</div>
<p>&nbsp;</p>

こちらは、choice._id choice.id に変更。

//# Create assets/templates/item.html
 
<div class="page-header">
  <h1>View Poll</h1>
</div>
<div class="well well-lg">
  <strong>Question</strong><br>{{poll.question}}
</div>
<div ng-hide="poll.userVoted">
  <p class="lead">Please select one of the following options.</p>
  <form role="form" ng-submit="vote()">
    <div ng-repeat="choice in poll.choices" class="radio">
      <label>
        <input type="radio" name="choice" ng-model="poll.userVote"
               value="{{choice.id}}">
        {{choice.text}}
      </label>
    </div>
    <p><hr></p>
    <div class="row">
      <div class="col-xs-6">
        <a href="#/polls" class="btn btn-default" role="button"><span
          class="glyphicon glyphicon-arrow-left"></span> Back to Poll</a>
      </div>
      <div class="col-xs-6">
        <button class="btn btn-primary pull-right" type="submit">
          Vote &raquo;</button>
      </div>
    </div>
  </form>
</div>
<div ng-show="poll.userVoted">
  <table class="result-table">
    <tbody>
    <tr ng-repeat="choice in poll.choices">
      <td>{{choice.text}}</td>
      <td>
        <table style="width: {{choice.votes.length/poll.totalVotes*100}}%;">
          <tr><td>{{choice.votes.length}}</td></tr>
        </table>
      </td>
    </tr>
    </tbody>
  </table>
  <p><em>{{poll.totalVotes}} votes counted so far. <span
    ng-show="poll.userChoice">You voted for <strong>{{poll.userChoice.text}}
  </strong>.</span></em></p>
  <p><hr></p>
  <p><a href="#/polls" class="btn btn-default" role="button">
    <span class="glyphicon glyphicon-arrow-left"></span> Back to
    Poll List</a></p>
</div>
<p>&nbsp;</p>
//# create assets/templates/new.html
 
<div class="page-header">
  <h1>Create New Poll</h1>
</div>
<form role="form" ng-submit="createPoll()">
  <div class="form-group">
    <label for="pollQuestion">Question</label>
    <input type="text" ng-model="poll.question" class="form-control"
           id="pollQuestion" placeholder="Enter poll question">
  </div>
  <div class="form-group">
    <label>Choices</label>
    <div ng-repeat="choice in poll.choices">
      <input type="text" ng-model="choice.text" class="form-control"
             placeholder="Enter choice {{$index+1}} text"><br>
    </div>
  </div>
  <div class="row">
    <div class="col-xs-12">
      <button type="button" class="btn btn-default" ng-click=
        "addChoice()"><span class="glyphicon glyphicon-plus">
</span> Add another</button>
    </div>
  </div>
  <p><hr></p>
  <div class="row">
    <div class="col-xs-6">
      <a href="#/polls" class="btn btn-default" role="button">
        <span class="glyphicon glyphicon-arrow-left"></span>
        Back to Poll List</a>
    </div>
    <div class="col-xs-6">
      <button class="btn btn-primary pull-right" type="submit">
        Create Poll &raquo;</button>
    </div>
  </div>
  <p>&nbsp;</p>
</form>

CSSファイルを assets/styles/style.css に作成します。

// # Create assets/style/include/style.css

body { padding-top: 50px; }
.result-table {
  margin: 20px 0;
  width: 100%;
  border-collapse: collapse;
}
.result-table td { padding: 8px; }
.result-table > tbody > tr > td:first-child {
  width: 25%;
  max-width: 300px;
  text-align: right;
}
.result-table td table {
  background-color: lightblue;
  text-align: right;
}

ステップ 3. sails-mongoを使用して MongoDB にデータを保存する

Sails.jsでは waterlineモジュールをORMとして使用していて、接続するDBを意識せずデータを扱うことが出来ます。

waterline
https://github.com/balderdashy/waterline

サンプルに合わせて sails-mongo を利用しますが、sails-disk (開発用)でもソースは変わりません。

waterline モジュールをダウンロードします。

npm install --save sails-mongo

DB接続設定を変更します

config/connection.js に定義しているmongodb の設定をそのまま使うようにしてみます。

// # See config/connection.js

//...
  someMongodbServer: {
    adapter: 'sails-mongo',
    host: 'localhost',
    port: 27017,
    // user: 'username',
    // password: 'password',
    // database: 'your_mongo_db_name_here'
  },
//…
//# Modify config/models.js

module.exports.models = {

  /***************************************************************************
  *                                                                          *
  * Your app's default connection. i.e. the name of one of your app's        *
  * connections (see `config/connections.js`)                                *
  *                                                                          *
  ***************************************************************************/
  //connection: 'localDiskDb’
  connection: ‘someMongodbServer'

}

Sails@modelを作成する

$ sails generate model poll
info: Created a new model ("Poll") at api/models/Poll.js!

$ sails generate model choice
info: Created a new model (“Choice") at api/models/Choice.js!

$ sails generate model vote
info: Created a new model (“Vote") at api/models/Vote.js! 

それぞれの model が生成されるのでSchemeを定義します。

// # Modify api/models/Poll.js
module.exports = {

  attributes: {
    question: {
      type: "STRING",
      required: true
    },

    choices: {
      collection: "choice",
      via: "poll",
      defaultsTo:[]
    }
  }
};

// # Modify api/models/Choice.js 

module.exports = {

  attributes: {
    text: "STRING",
    poll: {
      model: "poll"
    },
    votes: {
      collection: "vote",
      via: "choice",
      defaultsTo: []
    }
  }
};


// # Modify api/models/Vote.js 


module.exports = {

  attributes: {
    ip: "STRING",
    choice: {
      model: "choice"
    }
  }
};

v0.10.5ではモデルを持っている場合に限り起動時、migrate に関してどうするかを毎回聞いて来ます。
起動時毎回出力されてしまい、煩雑になるので設定を変更します。

-----------------------------------------------------------------

 Excuse my interruption, but it looks like this app
 does not have a project-wide "migrate" setting configured yet.
 (perhaps this is the first time you're lifting it with models?)
 
 In short, this setting controls whether/how Sails will attempt to automatically
 rebuild the tables/collections/sets/etc. in your database schema.
 You can read more about the "migrate" setting here:
 http://sailsjs.org/#/documentation/concepts/ORM/model-settings.html?q=migrate

 In a production environment (NODE_ENV==="production") Sails always uses
 migrate:"safe" to protect inadvertent deletion of your data.
 However during development, you have a few other options for convenience:

 1. safe  - never auto-migrate my database(s). I will do it myself (by hand)
 2. alter - auto-migrate, but attempt to keep my existing data (experimental)
 3. drop  - wipe/drop ALL my data and rebuild models every time I lift Sails

What would you like Sails to do?

info: To skip this prompt in the future, set `sails.config.models.migrate`.
info: (conventionally, this is done in `config/models.js`)

warn: ** DO NOT CHOOSE "2" or "3" IF YOU ARE WORKING WITH PRODUCTION DATA **

prompt: ?: 
// # Modify config/model.js

module.exports.models = {

  //migrate: 'allter'
  migrate: 'allter'

}; 

データ保存用 API routes を定義する

Sails.jsはデフォルトでCRUDを使えるのですが、今回はその標準CRUDを使いません。
別途 config/routes.js で設定します。

// # Modify config/routes.js

'/': {
  controller: 'RootController',
  action: "index"
},
"GET /polls/polls":{
  controller: 'PollsController',
  action: "list"
},
"GET /polls/:id":{
  controller: 'PollsController',
  action: "poll"
},
"POST /polls":{
  controller: 'PollsController',
  action: "create"
}

それぞれのRoutingに対応するPollController側の処理を記述します。

$ sails generate controller polls list poll create
info: Created a new controller ("polls") at api/controllers/PollsController.js!
// # Modify api/controllers/PollsController.js

/**
 * PollsController
 *
 * @description :: Server-side logic for managing polls
 * @help        :: See http://links.sailsjs.org/docs/controllers
 */

module.exports = {

  /**
   * `PollsController.list()`
   */
  list: function (req, res) {
    Poll.find().populate('choices').exec(function(err, polls){
      if(err) return res.serverError(err);
      return res.json(polls);
    });
  },

  /**
   * `PollsController.poll()`
   */
  poll: function (req, res) {
    var pollId = req.params.id;
    Poll.findOne({id: pollId}).populate('choices').exec(function(err, poll) {

      if(err) return res.serverError(err);
      if(!poll) return res.notFound(err);

      var userVoted = false,
        userChoice,
        totalVotes = 0;

      for(c in (poll.choices)){
        var choice = poll.choices[c];
        for(v in choice.votes) {
          var vote = choice.votes[v];
          totalVotes++;
          if(vote.ip === (req.header('x-forwarded-for') || req.ip)) {
            userVoted = true;
            userChoice = {id: choice.id, text: choice.text };
          }
        }
      }
      poll.userVoted = userVoted;
      poll.userChoice = userChoice;
      poll.totalVotes = totalVotes;
      res.json(poll);

    });
  },


  /**
   * `PollsController.create()`
   */
  create: function (req, res) {
    var reqBody = req.body,
      choices = reqBody.choices.filter(function(v) { return v.text != ''; }),
      pollObj = {question: reqBody.question, choices: choices};

    Poll.create(pollObj).exec(function(err, doc) {
      if(err || !doc) {
        res.serverError(err);
      } else {
        res.json(doc);
      }
    });
  }
};

Angular サービスを使用してデータをフロントエンドにバインドする

// # Create assets/js/dependencies/services.js

angular.module('pollServices', ['ngResource']).
  factory('Poll', function($resource) {
    return $resource('polls/:pollId', {}, {
      query: { method: 'GET', params: { pollId: 'polls' }, isArray: true }
    })
  });

Angular.js のサービスを設定して、反映します。

// # Modify assets/js/app.js

angular.module('polls', ['pollServices'])

Poll Modelのデータ一括取得

// # Modify assets/js/dependencies/controllers.js

// Managing the poll list
function PollListCtrl($scope, Poll) {
  $scope.polls = Poll.query();
}

IDによるPoll Modelのデータ取得

// Voting / viewing poll results
function PollItemCtrl($scope, $routeParams, Poll) {
  $scope.poll = Poll.get({pollId: $routeParams.pollId});
  $scope.vote = function() {};
}

アンケート内容の設定と登録処理

// Creating a new poll
function PollNewCtrl($scope, $location, Poll) {
  $scope.poll = {
    question: '',
    choices: [ { text: '' }, { text: '' }, { text: '' }]
  };
  $scope.addChoice = function() {
    $scope.poll.choices.push({ text: '' });
  };
  $scope.createPoll = function() {
    var poll = $scope.poll;
    if(poll.question.length > 0) {
      var choiceCount = 0;
      for(var i = 0, ln = poll.choices.length; i < ln; i++) {
        var choice = poll.choices[i];
        if(choice.text.length > 0) {
          choiceCount++
        }
      }
      if(choiceCount > 1) {
        var newPoll = new Poll(poll);
        newPoll.$save(function(p, resp) {
          if(!p.error) {
            $location.path('polls');
          } else {
            alert('Could not create poll');
          }
        });
      } else {
        alert('You must enter at least two choices');
      }
    } else {
      alert('You must enter a question');
    }
  };
}

アプリを実行する

ここまでの実装で、アンケートの表示と検索、新規アンケートの作成、個々のアンケートに対する回答選択肢の表示が可能となっています。
あとは、アンケートへの回答を送信した時の処理を作成します。

ステップ 4. Socket.io を使用したリアルタイムでの回答

Socket.io を使ってアンケートへの回答をリアルタイムで送信し、回答画面を閲覧しているブラウザへのアンケート結果もリロードすることなく結果を更新します。

Sails.jsでは デフォルトでSails.io(Socket.ioをラップしたモジュール)を利用する事ができます。

クライアント側の準備は特に必要なく、views/root/index.jade//SCRIPTS//SCRIPTS END の間で assets/js/dependencies/sails.io.js が自動的に読み込まれていると思います。

サーバ側では Socekt.ioのコネクションと接続した時の処理を、Sails.ioのconfig、sockets.js内の onConnect プロパティで設定することが出来ます。

onConnet 内で使っている lodash も合わせてダウンロードします。

$ npm install --save lodash
// # Modify config/sockets.js

onConnect: function(session, socket) {

  // vote sockets.
  socket.on('send:vote', function(data) {

    var ip = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address.address;
    Poll.findOne({id: data.poll_id}).populate("choices").exec(function(err, poll) {

      var _ = require("lodash");
      var choice = _.find(poll.choices, function(choice) {
        return choice.id == data.choice;
      });

      //投稿保存
      choice.votes.add({ ip: ip });
      choice.save(function(){

        //配信用データ
        var theDoc = {
          _id: poll.id + "",
          question: poll.question,
          choices: _.cloneDeep(poll.choices),
          totalVotes: 0
        };

        var async = require("async");
        var ids = [];
        theDoc.choices.forEach(function(choice){
          ids.push(choice.id);
        });

        Choice.find({id:ids}).populate("votes").exec(function(err,choices) {

          if (err) return console.log(err);

          choices.forEach(function(choice,i){

            theDoc.choices[i].votes = choice.votes;
            theDoc.totalVotes += choice.votes.length;

          })

          //他ブラウザの更新
          socket.broadcast.emit('vote', theDoc);

          theDoc.userChoice = {id: choice.id, text: choice.text};
          theDoc.userVoted = true;

          //自ブラウザの更新
          socket.emit('myvote', theDoc);

        });
      });
    });
  });
}, 

WebSocket にデータを送信する Angular サービスを追加する

Socket.io の送信/受信(emit/on)を処理するサービスを定義します。

// # Modify assets/js/dependencies/services.js

angular.module('pollServices', ['ngResource']).
  factory('Poll', function($resource) {
    return $resource('polls/:pollId', {}, {
      query: { method: 'GET', params: { pollId: 'polls' }, isArray: true }
    })
  }).
  factory('socket', function($rootScope) {
    var socket = io.connect();
    return {
      on: function (eventName, callback) {
        socket.on(eventName, function () {
          var args = arguments;
          $rootScope.$apply(function () {
            callback.apply(socket, args);
          });
        });
      },
      emit: function (eventName, data, callback) {
        socket.emit(eventName, data, function () {
          var args = arguments;
          $rootScope.$apply(function () {
            if (callback) {
              callback.apply(socket, args);
            }
          });
        })
      }
    };
  });

アンケートへの回答をsocekt メッセージ経由で自ブラウザの更新処理と他ブラウザの更新処理の実装と、”Vote” ボタンが押された時の処理を追加します。

// # Modify assets/js/dependencies/controllers.js

// Voting / viewing poll results
function PollItemCtrl($scope, $routeParams, socket, Poll) {
  $scope.poll = Poll.get({pollId: $routeParams.pollId});
  socket.on('myvote', function(data) {
    console.dir(data);
    if(data._id === $routeParams.pollId) {
      $scope.poll = data;
    }
  });
  socket.on('vote', function(data) {
    console.dir(data);
    if(data._id === $routeParams.pollId) {
      $scope.poll.choices = data.choices;
      $scope.poll.totalVotes = data.totalVotes;
    }
  });
  $scope.vote = function() {
    var pollId = $scope.poll.id,
      choiceId = $scope.poll.userVote;
    if(choiceId) {
      var voteObj = { poll_id: pollId, choice: choiceId };
      socket.emit('send:vote', voteObj);
    } else {
      alert('You must select an option to vote for');
    }
  };
}

最終的な成果物の動作を表示する

アンケートを設定し、それぞれのアンケートへの回答、同じアンケートであれば、回答時他のブラウザも内容が更新されていれば慣性です。