リアルタイム付箋アプリの中身

先日リアルタイム付箋アプリを公開したところ、結構好評だったので、ちょこちょこ
アップデートしています。

リアルタイム付箋アプリ

さてさて、このブログは一応技術系のブログなので中身について書いていきます。
主に中身はNode.jsとMongoDBです。

鼻血がでるほど相性が良いと言われた組み合わせですね。
実際に使ってみると、相性の良さは抜群。鼻血どころか目からも何か出るくらい相性が良かったです。

付箋がドラッグされたらその度に付箋位置をMongo上で更新するようにしたのですが、まったくレスポンスの低下を感じさせなかったです。

これはNode Ninjaのサーバーのおかげもあると思いますが、JavaScriptというシンタックスとかなり相性が良いのも確かです。

サーバー側でやっていることを解説していきます。
入れているライブラリは下記のとおりです。

・Express (Webレンダリング用)
・Jade (簡易HTML)
・Mongoose (MongoDBアクセッサー)
・Socket.io (WebSocketエンジン)

それぞれnpmで入れて使います。
※Node Ninjaにデプロイする場合は、npm でinstallする際に作られる
node_modulesはデプロイされないようにする必要があります。

サーバー側のソースコードは以下の通りです。
server.js

/**
 * Module dependencies.
 */

var express = require('express')
  , routes = require('./routes')
  , io = require('socket.io')
  , mongoose = require('mongoose')

var app = module.exports = express.createServer();
var counter = 0;
var mongoUri = 'mongodb://127.0.0.1/test_ninja';
var Schema = mongoose.Schema;
var commentSchema = new Schema({
    slideno :Number,
    message :String,
    slideKey:String,
    x       :Number,
    y       :Number
});
var slideKey = 'default';
var socketIds = new Array();
var slideMap = new Array();


// Configuration

app.configure(function(){
  app.set('views', __dirname + '/views');
  app.set('view engine', 'jade');
  app.use(express.bodyParser());
  app.use(express.methodOverride());                                                                                  
  app.use(app.router);
  app.use(express.static(__dirname + '/public'));
  mongoose.connect(mongoUri);
});
var Comment = mongoose.model('Comment', commentSchema);

app.configure('development', function(){
  app.use(express.errorHandler({ dumpExceptions: true, showStack: true })); 
});

app.configure('production', function(){
  app.use(express.errorHandler()); 
});

// Routes

// Faviconはひとまず無視
app.get('/favicon.ico', function(req, res){
  res.render('favicon.ico', {});
});

// URLの後ろはidとして考える
app.get('/:id?', function(req, res){
  console.log(req.params.id);
  if (!req.params.id) {
    slideKey = 'default';
  } else {
    slideKey = req.params.id;
  }
  var counter = slideMap[slideKey];
  if (!counter) {
    slideMap[slideKey] = 0;
  }
  if(slideKey != 'default') {
    res.render(slideKey, { slideId: slideKey });
  } else {
    res.render('index', { slideId: 'default' });
  }
});

app.listen(process.env.PORT || 3000);
console.log("Express server listening on port %d in %s mode", app.address().port, app.settings.env);

// Process
io = io.listen(app);
io.sockets.on('connection', function (socket) {
  // アクセスしたらsocketにslideIdをセットして、カウントアップする。
  socket.on('count up',function(data) {
    socket.set('slideId', data.slideId, function(){
      if (socketIds.indexOf(socket.id) < 0) {
        socketIds.push(socket.id);
        console.log(socket.id);
        var count = slideMap[data.slideId];
        count++;
        slideMap[data.slideId] = count;
        io.sockets.emit('counter', {count : count, slideId: data.slideId});
      }
    });
  });
  // 接続が切れたらsocketからslideIdを取ってきて、カウントダウンする。
  socket.on('disconnect', function () {
    console.log('disconnect');
    var index = socketIds.indexOf(socket.id);
    socketIds.splice(index, 1);
    socket.get('slideId', function (err, slideId){
      if (!err) {
      console.log("slideId disconnect" + slideId);
      var count = slideMap[slideId];
      count--;
      slideMap[slideId] = count;
      io.sockets.emit('counter', {count : count, slideId: slideId});
      } else {
        console.log(err);
      }
    });
  });
  // slideKey(slideId)ごとの全コメントを送信する。
  Comment.find({slideKey: slideKey}, function(err,docs){ 
        if(!err) {
            for (var i = 0; i < docs.length; i++ ) {
                console.log(docs[i]);
		if (docs[i].message) {
			socket.emit('loaded', docs[i]);
                } else {
			Comment.findById(docs[i].id, function (err, comment) {
			if (!err) {
            			comment.remove();
        		} else {
          			console.log(err);
        		}
      			});
		}
            }
        } else {
	    console.log(err);
	}

  });
  // 誰かが付箋を貼ったらdocumentを作成する。
  socket.on('create', function (data) {

    console.log(data);
    if (data) {
      console.log("Data : %s", data.message);
      console.log("Data : %s", data.slideno);

      var comment = new Comment();
      comment.slideno = data.slideno;
      comment.x = data.x;
      comment.y = data.y;
      comment.slideKey = slideKey;
      console.log(comment);
      comment.save(function(err, doc) {
	console.log('saved: %s', doc.id);
        if (!err) { 
          socket.emit('created', {id: doc.id, slideno: doc.slideno, x: doc.x, y:doc.y, slideKey:doc.slideKey});
          socket.broadcast.emit('created by other', {id: doc.id, slideno: doc.slideno, x: doc.x, y:doc.y, slideKey:doc.slideKey});
        } else {
          console.log(err);
        }
      });
    }
  });
  // テキスト編集されたらdocumentを更新する。
  socket.on('text edit', function (data) {
    if (data && data.message) {
      Comment.findById(data.id, function (err, comment) {
        if (!err) {
          if (data.message != null) {
            comment.message = data.message;
          }
          comment.save(function(err){
            if (!err) {
              socket.emit('text edited', {id: comment.id, slideno: comment.slideno, x: comment.x, y: comment.y, message: comment.message, slideKey:comment.slideKey});
              socket.broadcast.emit('text edited', {id: comment.id, slideno: comment.slideno, x: comment.x, y: comment.y, message: comment.message, slideKey:comment.slideKey});
            } else {
              console.log(err);
            }
          });
        }
      });
    }
  });
  // 削除されたらdocumentを削除する。
  socket.on('delete', function (data) {
    console.log(data);
    if (data) {
      Comment.findById(data.id, function (err, comment) {
	if (!err && comment) {
            comment.remove();
            socket.emit('deleted', {id: data.id});
            socket.broadcast.emit('deleted', {id: data.id});
        } else {
          console.log(err);
        }
      });
    }
  });
  // テキスト編集をキャンセルしたら一旦作ったdocumentを削除する。
  socket.on('cancel', function(data) {
    if (data) {
      Comment.findById(data.id, function (err, comment) {
	if (!err && comment) {
            comment.remove();
            socket.broadcast.emit('cancelled', {id: data.id});
        } else {
          console.log(err);
        }
      });

    }
  });
  // ドラッグで移動したらdocument位置を更新する。
  socket.on('update', function(data) {
     if (data) {
       Comment.findById(data.id, function(err, comment) {
         if(!err && comment) {
         comment.x = data.x;
         comment.y = data.y;
         comment.save(function(err){
           if (!err) {
             socket.emit('updated', {id: comment.id, x: comment.x, y: comment.y});
             socket.broadcast.emit('updated', {id: comment.id, x: comment.x, y: comment.y});
           } else {
             console.log(err);
           }
         });
         } else {
           console.log(err);
         }
       });
     }
  });
});

socket.io v0.7からの新機能であるsocket.set, socket.getを使って
クライアントとSlideのIDを紐付けしています。
これでスライドごとに人をカウントする機能を実現しています。

また、socket.emitで自分に対してデータ送信、socket.broadcast.emitで自分以外の人に対して送信なので
それをうまく使って、付箋を編集中の場合に自分以外の他の人には
"someone writing"というメッセージを出力するようにしています。
将来的には書いた文字がその場で更新されていくようなGoogle Waveライクな更新スタイルにも対応したいと思います。

Socket.ioの使い方は下記の記事が参考になりました。
Socket.IO API 解説 - Block Rockin’ Codes

MongoDBネイティブも使えるみたいですが、Mongooseの直感的なメソッドがお気に入りなので、
Mongooseを使っています。
Mongooseの使い方は下記の記事が参考になりました。
node.js から MongoDB にアクセス (Mongoose の紹介) - KrdLabの不定期日記
手前味噌ですが、自分も前にMongooseの使い方をちょこっと書いています。
herokuでNode.jsを使ってchatアプリ その3(MongoDBを利用して、メッセージを永続化) - from scratch

サーバー側の実装はこんな感じですね。
Node.js側よりもクライアント側のほうが大変だったなぁ。不慣れなこともあって
微妙な実装で、まだバグが多そうだ。

詳細は以下のリポジトリに公開されています。
yosuke-furukawa/nodeslide · GitHub

でも作っている間はかなり楽しい!やっぱNode.js実装は楽しいですね。
今度勉強会でこのプレゼン使って発表してみようかなぁと思います。