node.js + express + haml.js + mongoose で MongoDB を使った Web アプリ開発

以前(id:fits:20110306)に Sinatra で作成したサンプルの node.js 版を作成してみました。

環境は以下の通りで、Sinatra 版と同等の構成になっています。

サンプルのソースは http://github.com/fits/try_samples/tree/master/blog/20110409/

事前準備

まず、今回使用するパッケージを npm でインストールしておきます。

インストール例
> npm install express
> npm install hamljs
> npm install mongoose

Express 用の Haml パッケージは他にもあるようですが、今回は Haml.js を使いました。

Mongoose によるモデル定義

Mongoose を使ったモデル定義は以下の通りです。
Ruby の MongoMapper とかに比べると多少面倒です。

なお、MongoMapper とかの belongs_to に該当する機能が無さそうだったので、virtual を使った仮想的な属性定義と method を使ったメソッド追加でとりあえず代用してみました。(ただし、処理内容的には全くの別物です)

models/book_models.js (Mongoose モデル定義)
var mongoose = require('mongoose');

//Userスキーマ定義
var UserSchema = new mongoose.Schema({
    name: String
});

//Commentスキーマ定義
var CommentSchema = new mongoose.Schema({
    content: String,
    created_date: Date,
    user_id: mongoose.Schema.ObjectId
});

//user 仮想属性の定義
CommentSchema.virtual('user')
    .get(function() {
        return this['userobj'];
    })
    .set(function(u) {
        this.set('user_id', u._id)
    });

//Book スキーマ定義
var BookSchema = new mongoose.Schema({
    title: String,
    isbn: String,
    //Comment の組み込みドキュメント(コンポジション)定義
    comments: [CommentSchema]
});

//Book へのメソッド追加
BookSchema.method({
    //Comment の関連 User を復元する処理
    restoreUser: function(userList) {
        var ulist = {};

        userList.forEach(function(u) {
            ulist[u._id] = u;
        });

        this.get('comments').forEach(function(c) {
            c['userobj'] = ulist[c.user_id];
        });
    }
});

//User モデルの定義
mongoose.model('User', UserSchema);
//Book モデルの定義
mongoose.model('Book', BookSchema);

//接続メソッドの定義
exports.createConnection = function(url) {
    return mongoose.createConnection(url);
};

上記で定義したモデルを使うには、model('モデル名') を実行して変数に割り当てる必要があります。(今回は Express 側の処理で実施しています)

Express による Web サーバー処理

Express による Web サーバー側の処理は以下の通りです。

  • POST データを request.body から取得するために app.use(express.bodyDecoder()) を実行
  • Haml.js を使うために app.register('.haml', require('hamljs')) を実行
  • render 時の拡張子指定を不要にするために app.set('view engine', 'haml') を実行
  • Haml.js のテンプレート内で使用する変数は locals に設定する

bodyDecoder を設定しておかないと、POST データを request.body で取得できない点にやられました。

site.js (Express 処理定義)
var express = require('express');
var app = express.createServer();

/*
 *  post でパラメータを取得するのに必要な設定
 *  bodyDecoder を設定しないと request.body が undefined になり、
 *  post された値が request.body や param() 等で取得できない
 */
app.use(express.bodyDecoder());

//haml.js の設定
app.register('.haml', require('hamljs'));
//view engine に haml を設定することで render で index.haml のように
//拡張子まで書かなくてもよくなります
app.set('view engine', 'haml');

//Book, User モデルクラス定義の呼び出し
var mongoModel = require('./models/book_models');
//MongoDB への接続設定
var db = mongoModel.createConnection('mongodb://127.0.0.1/book_review');

//Book モデルを取得
var Book = db.model('Book');
//User モデルを取得
var User = db.model('User');

//Top ページ表示
app.get('/', function(req, res) {
    User.find().asc('name').find(function(err, userList) {
        Book.find().asc('title').find(function(err, bookList) {

            bookList.forEach(function(b) {
                //Comment の関連 User を復元
                b.restoreUser(userList);
            });

            res.render('index', {
                locals: {
                    books: bookList,
                    users: userList,
                    action: '/comments'
                }
            });
        });
    });
});

//Book ページ表示
app.get('/books', function(req, res) {
    Book.find().asc('title').find(function(err, list) {
        res.render('book', {
            locals: {
                books: list,
                action: '/books'
            }
        });
    });
});

//Book 追加処理
app.post('/books', function(req, res) {
    new Book(req.body).save(function(err) {
        res.redirect('/books');
    });
});

//Comment 追加処理
app.post('/comments', function(req, res) {
    User.findById(req.body.user_id, function(err, u) {
        Book.findById(req.body.book_id, function(err, b) {
            //Comment 追加
            b.comments.push({
                content: req.body.content,
                created_date: Date.now(),
                user: u
            });
            //保存
            b.save(function(err) {
                res.redirect('/');
            });
        });
    });
});
・・・
//ポート 8081 で Web サーバーを起動
app.listen(8081);

Book や User モデルで引数無しの find を実行していますが、これは asc() を使うためです。(ソート順を find で指定する方法もあります)

find は以下のようなちょっと分かり難い動作になっています。

  • find に callback が指定されていれば検索が実行され callback が呼び出されるが、指定されていなければ検索せずに Query を返す

Haml.js によるページの定義

Haml.js の特徴は以下の通りです。

  • = で HTML エスケープされる
  • != で HTML エスケープされない
  • - each で繰り返し処理を定義できる

レイアウト定義は以下のようになります。
body 変数に各々のページ内容が設定されます。(例えば index の場合、index.hamlレンダリング結果が body 変数に格納される事になります)

views/layout.haml (レイアウト定義)
!!! 5
%html
  %title Express + Haml.js Sample
  %body
    != body

body の内容は HTML エスケープさせずにそのまま出力させたいので != を使っています。

Top ページは以下の通りです。

views/index.haml (Topページ定義)
.menu Menu
%ul
  %li
    %a{ href: "/books" } Books List
  %li
    %a{ href: "/users" } Users List

.list Book Comments
%form.post{ action: action, method: 'post' }
  %select{ name: 'user_id' }
    - each u in users
      %option{ value: u._id }
        = u.name
  
  %select{ name: 'book_id' }
    - each b in books
      %option{ value: b._id }
        = b.title
  
  %input{ name: 'content', type: 'text' }
  %input{ type: 'submit', value: 'Add' }

- each b in books
  %ul
    %li= b.title
    %ul
      - each c in b.comments
        %li= c.content + ' : ' + c.user.name + " , " + c.created_date

実行

まず、MongoDB を実行しておきます。

MongoDB 実行例
> mongod -dbpath db

次に、node コマンドで site.js を実行します。

Express 実行例
$ node site.js

http://localhost:8081/ に接続すると Sinatra 版のサンプル(id:fits:20110306)とほとんど同じ画面が表示されます。
ちなみに、表示された HTML は以下のようになります。

Top ページの表示 HTML の例
<!DOCTYPE html>
<html>
<title>Express + Haml.js Sample</title>
<body>
<div class="menu">Menu</div>
<ul>
    <li><a href="/books">Books List</a></li>
    <li><a href="/users">Users List</a></li>
</ul>
<div class="list">Book Comments</div>
<form action="/comments" method="post" class="post">
    <select name="user_id">
        <option value="4d99c38946098c7409000001">fits</option>
        <option value="4d99c38e46098c7409000003">テスター</option>
    </select>
    <select name="book_id">
        <option value="4d99c3dc46098c7409000008">プログラミングScala</option>
        <option value="4d99c40346098c740900000a">プログラミングWindowsAzure</option>
    </select>
    <input name="content" type="text"/>
    <input type="submit" value="Add"/>
</form>
<ul>
    <li>プログラミングScala</li>
    <ul>
        <li>アプリケーション開発の実践的な内容もあり : fits , Mon Apr 04 2011 13:16:00 GMT+0000 (   )</li>
        <li>テストコメント : テスター , Mon Apr 04 2011 13:21:31 GMT+0000 (   )</li>
    </ul>
</ul>
<ul>
    <li>プログラミングWindowsAzure</li>
    <ul>
        <li>cspack を使ったパッケージングなどIDE前提じゃない説明がなかなかよい : fits , Mon Apr 04 2011 13:20:45 GMT+0000 (   )</li>
    </ul>
</ul>
</body>
</html>