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など
動作環境
- 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._id
をpoll.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> </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 »</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> </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 »</button>
</div>
</div>
<p> </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');
}
};
}
最終的な成果物の動作を表示する
アンケートを設定し、それぞれのアンケートへの回答、同じアンケートであれば、回答時他のブラウザも内容が更新されていれば慣性です。