もがもがしいブログ

もがもがしく生きる

Node.js+Socket.IO+Express+MongoDB で実装するチャットサンプル [deprecated]

(※注:2012年当時の記事のため内容はかなり古いです。いつか書き直したい・・・)

2012年もスタートして早5日。
せっかくの心機一転のチャンスだし、何か新しい技術を覚えてみたいと思い、WebSocket の勉強をしてみることにしました。
既にご存知の方も多いと思いますが、WebSocket とはブラウザ上で Web サーバとの双方向通信を可能にする規格のことで、
HTML5 とともに、Web アプリケーションがこれから全盛期を迎えるだろう時代にあっては必須とも言えるものです。

blog.livedoor.jp

ブラウザ上で双方向通信を行うための規格としては、これまでにも Ajax や Comet といったものがありましたが、
サーバからのプッシュ通信ができないことや負荷が高いといった問題があり、
それらを簡潔な実装で解消できる技術が求められていたようです。

Ajax, Comet, WebSocket の具体的な相違点については、こちらのスライドで詳しく説明されています。

www.slideshare.net

以上のように、WebSocket はとても将来性のある技術ですが、現時点では APIプロトコルの仕様が固まっていないことや、一部の古いブラウザが対応していないということもあり、実際にアプリケーションを作る際にはその点を考慮しなければなりません。
今回は、そうしたブラウザ間の差異を吸収し、一度の実装で全ブラウザ対応を可能にしてくれる「Socket.IO」というライブラリを使用することにしました。
また、アプリケーションを動かすサーバとして、「Node.js」という、JavaScriptで動かせるサーバを使うことにしました。
作るものはリアルタイムチャットです。

ここから実装の話に入っていきますが、便宜上、OS は CentOS を使うことにします。
それから、自分自身がroot権限で作業を行ったということもあり、あまり良くはないと思いますが、念のためroot権限で行っていきます。
まず、Node.js のインストール。

# cd /usr/local/src
# wget <a href="http://nodejs.org/dist/v0.6.5/node-v0.6.5.tar.gz" style="color:#ccf;">http://nodejs.org/dist/v0.6.5/node-v0.6.5.tar.gz</a>
# tar xzvf node-v0.6.5.tar.gz
# cd node-v0.6.5
# ./configure
# make
# make install

これで /usr/local/bin/node が作成されます。
次に、Socket.IO のインストール。

# npm install socket.io
socket.io@0.8.7 ./node_modules/socket.io

npmというのは Node Package Manager、つまり Node.js 関係のパッケージ管理システムで、これを使って Socket.IO をインストールしています。
今回は Socket.IO の v0.8.7 を使いますが、Socket.IO は 0.7系から仕様が大きく変わっており、同じコードでもバージョンによって動かなくなることがあります。
(自分はこれにハマってドキュメントの大切さを思い知りました…)

この記事の内容も数ヶ月後にはもう動かなくなっているかもしれないので、ぜひ本家のドキュメントもご覧ください。
https://github.com/LearnBoost/socket.io

続いて、Express と EJS をインストールします。
Express は Node.js アプリを作るのに必要なファイルを自動生成してくれるフレームワークで、EJS は Node.js アプリの表示機能の実装をちょっと楽にしてくれるテンプレートエンジンです。

# npm install -g express ejs
# express sandbox
# cd sandbox

# sandbox 内で必要なモジュールが使えるようにします。
# 元のモジュールをグローバルオプションでインストールしているので npm link express ejs でいけるはずなのですが
# なぜがうまくいかなかったので、リソースの無駄遣いと知りつつ二回インストールしています。

# npm install express ejs

express sandbox を実行すると、sandbox というディレクトリが作成されて、その中に Node.js アプリに必要なファイル郡が作られます。
具体的には以下の通り。

sandbox
│
├ node_modules
│  │
│  ├ express
│  │  └...
│  └ jade
│     └...  // 今回は使わない
├ views
│  ├ layout.jade  // 今回は使わない
│  └ index.jade  // 今回は使わない
│
├ routes
│  └ index.js  // 今回は使わない
│
├ public
│  ├ stylesheets
│  │  └ style.css
│  ├ javascripts
│  └ images  // 今回は使わない
│
├ package.json
│
└ app.js  // サーバーサイドで動くJavaScriptファイル

この状態で

# node app.js

を実行して、http://203.0.113.0:3000(サーバのIPアドレスを203.0.113.0と仮定しています)
にアクセスすると、「Welcome to Express」という画面が表示されるかと思います。

以下ではこの app.js やテンプレートファイルを書き換えてチャットアプリを作っていきます。

まず、必要なロジックとして挙げられるのは

  • クライアント端末からサーバにメッセージを送信する機能(クライアント側で実装)
  • サーバが受信したメッセージを全てのクライアント端末に対して送信する機能(サーバ側で実装)
  • クライアント端末が受信したメッセージを画面に表示する機能(クライアント側で実装)

の3つです。
(以下、クライアントからサーバに送信するメッセージの名前を 'msg send', サーバからクライアントに送信するメッセージの名前を 'msg push' とします。)

これに表示用のテンプレートファイルを加えた結果、ファイル構成は

sandbox
│
├ node_modules  // 各種モジュール
│  │
│  ├ express
│  │  └...
│  └ ejs
│    └...
├ views
│  └ index.ejs  // 表示用テンプレート
│
├ public    // クライアント側で使用するファイル群
│  │
│  ├ stylesheets
│  │  └ style.css
│  └ javascripts
│     └ client.js  // クライアント側のJavaScriptファイル
│
├ package.json
│
└ app.js  // サーバーサイドで動くJavaScriptファイル

となります。まずはクライアントの表示用ファイルから。

[index.ejs]

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <link rel="stylesheet" type="text/css" href="/stylesheets/style.css" />
  </head>
  <body>
    <script type="text/javascript" src="https://www.google.com/jsapi"></script>
    <script type="text/javascript">google.load("jquery", "1.4.4");</script>
    <script type="text/javascript" src="/socket.io/socket.io.js"></script>
    <script type="text/javascript">
      var port = <%= port %>;
    </script>
    <script type="text/javascript" src="/javascripts/client.js"></script>
    <h1><%= title %></h1>
    <form id="form1">
      <input type="text" id="message" />
      <input type="submit" value="送信" />
    </form>
    <ul></ul>
  </body>
</html>


必要なファイルの読み込みと、簡単なタグだけです。<%= port %> と <%= title %> のところには、後で [app.js] で定義する値が入ります。

続いてクライアントのJavaScript


[client.js]

$(function() {
  var socket = new io.connect('http://203.0.113.0:'+port);

  // ▼ チャットフォーム ▼
  $('#form1').submit(function() {
    msg = $('#message').val();
    msg = sanitize(msg);
    socket.emit('msg send', msg);
    $('#message').val('');
      return false;
    });
    // ▲ チャットフォーム ▲
    
    // メッセージ受信
    socket.on('msg push', function(msg, date) {
      $('ul').prepend('<li>'+msg+' ('+date+')</li>');
    });
});

function sanitize(str) {
  str = str.replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
  return str;
}

サーバ(203.0.113.0:3000)に接続した後、フォームのsubmitボタンを押すと
サーバに対して 'msg send' という名前のメッセージを、変数 msg とともに送信します。
メッセージはサーバ側で処理された後、'msg push' という名前のメッセージを、
変数 msg, date とともに返すものとしています。

最後にサーバ側のJavaScript


[app.js]

var express = require('express')
  , ejs = require('ejs')
  , io  = require('socket.io')

var app = module.exports = express.createServer();

var title = 'チャットテスト';
var port = 3000;

app.configure(function(){
  app.set('views', __dirname + '/views');
  app.set('view engine', 'ejs');
  app.set('view options', { layout: false });
  app.use(express.static(__dirname + '/public'));
});
app.get('/views', function(req, res) {
  res.render('index', { locals: { port:port, title:title } });
});

app.listen(port);
console.log("Express server listening on port %d", app.address().port);

var socket = io.listen(app);

// ▼ 接続時実行 ▼
socket.sockets.on('connection', function(client) {

  // ▼ クライアントからサーバへメッセージ送信 ▼
  client.on('msg send', function(msg) {
    date = getDateAndTime();
    if (msg == '') return;
    client.emit('msg push', msg, date);
    client.broadcast.emit('msg push', msg, date);
  });
  // ▲ クライアントからサーバへメッセージ送信 ▲
});
// ▲ 接続時実行 ▲

// 現在の日時を YYYY/MM/DD hh:mm:dd 形式で返す関数
function getDateAndTime() {
  dd = new Date();
  year = (dd.getYear() < 2000 ? dd.getYear()+1900 : dd.getYear() );
  month = (dd.getMonth() < 9 ? "0" + (dd.getMonth()+1) : dd.getMonth()+1 );
  day = (dd.getDate() < 10 ? "0" + dd.getDate() : dd.getDate() );
  hour = (dd.getHours() < 10 ? "0" + dd.getHours() : dd.getHours() );
  minute = (dd.getMinutes() < 10 ? "0" + dd.getMinutes() : dd.getMinutes() );
  second = (dd.getSeconds() < 10 ? "0" + dd.getSeconds() : dd.getSeconds() );
  return year + "/" + month + "/" + day + " " + hour + ":" + minute + ":" + second;
}

ここで、

# node app.js

を実行してから http://203.0.113.0:3000/views にアクセスすると、socket.sockets.on の中身が実行され、'msg send' というメッセージの受信待ち状態になります。(client.on の部分)
メッセージを受信すると、

client.emit('msg push', msg, date);

でそのメッセージの送信者に対して 'msg push' という名前のメッセージを返信するほか、

client.broadcast.emit('msg push', msg, date);

で接続中の残り全てのクライアントに対して同じメッセージを送信します。

ブラウザでメッセージを送信すると、他のブラウザにも瞬時に反映されることが確認できます。

以上で最低限の機能の実装は終わりです。
この状態だと、入力したメッセージはその時チャットを開いていたブラウザに表示されるだけでそのまま消えてしまうので、会話が成立するためにはお互いが同じタイミングで居合わせる必要があります。
このままでは実用性に乏しいため、MongoDB というデータベースを使ってログの保存・読み込み機能を実装します。
MongoDB の概要、Node.js との相性の良さについてはこちらのスライドが詳しいです。

www.slideshare.net

インストール作業、やはりroot権限でやっています。
まずは yumリポジトリを追加。こちらのブログを参考にしました。

http://memo.yomukaku.net/entries/tiSGwUw

# vi /etc/yum.repos.d/10gen.repo
[10gen] 
name=10gen Repository 
baseurl=<a href="http://downloads-distro.mongodb.org/repo/redhat/os/x86_64" style="color:#ccf;">http://downloads-distro.mongodb.org/repo/redhat/os/x86_64</a>
gpgcheck=0
enabled=0

# yum search mongo --enablerepo=10gen
=========================== Matched: mongo ===========================
libuninum.x86_64 : Library for converting unicode strings to numbers
mongo-10gen.x86_64 : mongo client shell and tools
mongo-10gen-server.x86_64 : mongo server, sharding server, and support
                          : scripts
mongo18-10gen.x86_64 : mongo client shell and tools
mongo18-10gen-server.x86_64 : mongo server, sharding server, and
                            : support scripts

# yum install mongo-10gen-server.x86_64 --enablerepo=10gen
# mongod --version
db version v2.0.2, pdfile version 4.5
# npm install mongodb		// sandbox ディレクトリ直下で実行

/usr/bin に MongoDB とその関連モジュールがインストールされました。
起動スクリプトは /etc/rc.d/init.d/mongod に、設定ファイルは /etc/mongod.conf にあります。

せっかくなので MongoDB がどんなものなのか使ってみましょう。

# mongo
MongoDB shell version: 2.0.2
connecting to: test
> show dbs
admin   (empty)
local   (empty)
> db.things.insert({"name": "rice ball", "price": 105})
> db.things.insert({"name": "cup noodle", "price": 188})
> db.things.insert({"name": "water", "price": 98})
> db.things.find()
{ "_id" : ObjectId("4f044fb116df0fb8470aae4d"), "name" : "rice ball", "price" : 105 }
{ "_id" : ObjectId("4f04504916df0fb8470aae4e"), "name" : "cup noodle", "price" : 188 }
{ "_id" : ObjectId("4f04505e16df0fb8470aae4f"), "name" : "water", "price" : 98 }</div>

test というデータベース配下の things というコレクションに 3件のドキュメントを追加しています。
コレクションはリレーショナルデータベースではテーブルに相当するもので、ドキュメントは行に相当するものです。
データ構造を決めずにいきなりデータを追加できるという手軽さが面白いです。

ドキュメントを探す時は

> db.things.find({"name": "rice ball"})
{ "_id" : ObjectId("4f044fb116df0fb8470aae4d"), "name" : "rice ball", "price" : 105 }

のようにできます。

> db.things.update({name: "rice ball"}, {$set: {price: 210}})
> db.things.find({"name": "rice ball"})
{ "_id" : ObjectId("4f044fb116df0fb8470aae4d"), "name" : "rice ball", "price" : 210 }

値上がりさせてみました。

それでは実際に MongoDB を使って、先程作った [client.js], [app.js] を書き換えていきます。( index.ejs はそのままです)

[client.js]

$(function() {
  var socket = new io.connect('http://203.0.113.0:'+port);

  // ▼ チャットフォーム ▼
  $('#form1').submit(function() {
    msg = $('#message').val();
    msg = sanitize(msg);
    socket.emit('msg send', msg);
    $('#message').val('');
    return false;
  });
  // ▲ チャットフォーム ▲
  
  // メッセージ受信
  socket.on('msg push', function(msg, date) {
    $('ul').prepend('<li>'+msg+' ('+date+')</li>');
  });
  
  // ログの読み込み
  socket.on('msg load', function(msg, date) {
    $('ul').append('<li>'+msg+' ('+date+')</li>');
  });
});

function sanitize(str) {
  str = str.replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
  return str;
}

チャットにアクセスした時に、サーバから 'msg load' という名前のメッセージを
クライアントが受け取り、表示する機能を加えています。


[app.js]

var express = require('express')
  , ejs = require('ejs')
  , io  = require('socket.io')
  , mongo = require('mongodb')
  , db = new mongo.Db('test', new mongo.Server('203.0.113.0', 27017, {}), {});

var app = module.exports = express.createServer();

var title = 'チャットテスト';
var port = 3000;

app.configure(function(){
  app.set('views', __dirname + '/views');
  app.set('view engine', 'ejs');
  app.set('view options', { layout: false });
  app.use(express.static(__dirname + '/public'));
});

app.get('/views', function(req, res) {
  res.render('index', { locals: { port:port, title:title } });
});

app.listen(port);
console.log("Express server listening on port %d", app.address().port);

var socket = io.listen(app);

// ▼ 接続時実行 ▼
socket.sockets.on('connection', function(client) {
  
  // ▼ 初回アクセス時 最新20件のログを取得して送信 ▼
  db.open(function(){
    db.createCollection("comments", function(err, collection) {
      cursor = collection.find({}, {"limit":20, "sort":[['unixtime','desc']] });
      cursor.each(function(err, doc) {
        if (doc != null)
          client.emit('msg load', doc.message, doc.date);
      });
    });
  });
  // ▲ 初回アクセス時 最新20件のログを取得して送信 ▲
  
  // ▼ クライアントからサーバへメッセージ送信 ▼
  client.on('msg send', function(msg) {
    date = getDateAndTime();
    unixtime = new Date()/1000;
    if (msg == '') return;
    // comments コレクションに保存
    db.open(function() {
      db.collection("comments", function(err, collection) {
        collection.insert({"message":msg, "date":date, "unixtime":unixtime});
        console.log('*** DB insert ***');
      });
    });
    client.emit('msg push', msg, date);
    client.broadcast.emit('msg push', msg, date);
  });
  // ▲ クライアントからサーバへメッセージ送信 ▲
});
// ▲ 接続時実行 ▲

// 現在の日時を YYYY/MM/DD hh:mm:dd 形式で返す関数
function getDateAndTime() {
  dd = new Date();
  year = (dd.getYear() < 2000 ? dd.getYear()+1900 : dd.getYear() );
  month = (dd.getMonth() < 9 ? "0" + (dd.getMonth()+1) : dd.getMonth()+1 );
  day = (dd.getDate() < 10 ? "0" + dd.getDate() : dd.getDate() );
  hour = (dd.getHours() < 10 ? "0" + dd.getHours() : dd.getHours() );
  minute = (dd.getMinutes() < 10 ? "0" + dd.getMinutes() : dd.getMinutes() );
  second = (dd.getSeconds() < 10 ? "0" + dd.getSeconds() : dd.getSeconds() );
  return year + "/" + month + "/" + day + " " + hour + ":" + minute + ":" + second;
}

これでログの保存・読み込み機能ができました。

初めての記事にしてはだいぶ長くなってしまいましたが、ひとまずこれで終了します。

今回は Node.js から MongoDB にアクセスするドライバとして、

# npm install mongodb

でインストールされる node-mongodb-native を使用しましたが、この後色々チャットを改造していてコールバック地獄を味わったというのもあり、mongoose にも興味を持ちました。
また時間のあるときに試したいと思います。