こんにちは、渕上です。

前回の記事では、
ローカルPCのビデオカメラで撮影している動画を画像にキャプチャして、サーバに送信、
サーバで処理を行ってローカルPCに送信するデモを作成しました。

今回は一旦動画をはなれて、かんたんなテキストチャットを実装したいと思います。

(前回の記事内で、今回を最終回としたいようなことを書いたのですが、
力及ばず、延長戦に突入しそうです。)

テキストチャットの仕様

今回は、下記を満たすものをつくります。

  • node.js + socket.ioで実装
  • 名前を入力してチャットルームに入室する
  • 入室できる定員は2名までとし、上限を超えた場合はキックされる
  • チャットルームはとりあえず一つ
  • ブラウザのウインドウ(タブ)を閉じると退出する
  • 退出ボタンを押しても退出する

動作デモ

ブラウザのウインドウを3枚並べて、動作テストを行いました。
実際のコードと内容は、以下に記載しています。

コード

クライアント側

index.html

<!DOCTYPE html>
<html lang="jp">
<head>
<meta charset="utf-8">
<script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"></script>
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
<script>
var ENTER_KEY = 13;
var socket, myId;

function disconnect(){
  socket.disconnect();
}

window.addEventListener('beforeunload', disconnect);

$(function(){
  socket = io();

  $('input[name="username"]').on('keydown', function(e){

    if (!socket.connected) {
      socket.connect();
    }

    if (ENTER_KEY == e.keyCode && $(this).val().length) {
      var myName = $(this).val();
      socket.emit('try-enter', { name: myName });
      $(this).val('');
    }
  });

  $('input[name="message"]').on('keydown', function(e){
    if (ENTER_KEY == e.keyCode) {
      socket.emit('post-message', { message: $(this).val() });
      $(this).val('');
    }
  });

  $('button.btn-exit').on('click', function(){
    disconnect();
  });

  socket.on('connected', function(data){
    if (data.status) {
      myId = data.id;
      $('#layer1').fadeOut();
      $('#layer2').fadeIn();
    }
    else {
      alert('定員オーバー');
    }
  });

  socket.on('admin-message', function(data){
    if (typeof myId != 'undefined') {
      var $message = $('#tmpl-admin').clone().removeAttr('id');
      $('> div', $message).html(data.message);
      $('#layer2 > .chatline').prepend($message);
      $message.fadeIn();
    }
  });

  socket.on('user-message', function(data){
    if (typeof myId != 'undefined') {
      var $message;

      if (data.userId == myId) {
        $message = $('#tmpl-mine').clone().removeAttr('id');
      }
      else {
        $message = $('#tmpl-partner').clone().removeAttr('id');
      }

      $('> div', $message).html('<strong>'+data.userName+'</strong><br>'+data.message);
      $('#layer2 > .chatline').prepend($message);
      $message.fadeIn();
    }
  });

  socket.on('disconnect', function(){
    myId = undefined;
    $('#layer2 > .chatline').children().remove();
    $('#layer2').fadeOut();
    $('#layer1').fadeIn();
  });

});

</script>

<style>
div.layer {
  position: absolute;
  width: 100%;
  height: 100%;
  left: 0;
  top: 0;
  background-color: #FFF;
}

#layer1 {
  z-index: 1;
}

#layer2 {
  z-index: 0;
}

div.control {
  margin-top:10px;
  margin-bottom:10px;
}

div.control-foot {
  position:fixed;
  bottom: 0;
}

</style>
</head>
<body>
  <div id="layer1" class="layer container">
    <div class="row">
      <div class="col-xs-12">
        <h1>入室する</h1>
    <p>名前を入力してEnterを押してください</p>
        <div class="input-group">
          <span class="input-group-addon">@</span>
          <input type="text" class="form-control" name="username" placeholder="ユーザーネーム">
        </div>
      </div>
    </div>
  </div>
  <div id="layer2" class="layer container" style="display:none;">
    <div class="row control">
      <div class="col-xs-12">
        <div class="input-group">
          <span class="input-group-addon">@</span>
          <input type="text" class="form-control" name="message" placeholder="発言を入力">
        </div>
      </div>
    </div>
    <div class="row chatline">
    </div>
    <div class="row control control-foot">
      <div class="col-xs-12">
        <div class="btn-group btn-group-justified">
          <div class="btn-group">
            <button type="button" class="btn btn-primary btn-exit">退出する</button>
          </div>
        </div>
      </div>
    </div>
  </div>
  <div id="tmpl-admin" class="col-xs-12" style="display:none;"><div class="well text-center"></div></div>
  <div id="tmpl-partner" class="col-xs-12" style="display:none;"><div class="alert-warning alert text-right"></div></div>
  <div id="tmpl-mine" class="col-xs-12" style="display:none;"><div class="alert-info alert"></div></div>
</body>
</html>

サーバ側

config.js

exports.IP           = '192.168.33.10';
exports.PORT         = '8080';
exports.TEMPLATE_DIR = './template';
exports.STATIC_DIR   = './static';
exports.MIME_TYPES   = {
  '.js'  : 'text/javasript',
  '.html': 'text/html',
  '.png' : 'image/png'
}

index.js

var http      = require("http");
var fs        = require("fs");
var path      = require("path");
var config    = require('./config.js');
var socketio  = require('socket.io');

var server = http.createServer();

server.on('request', function(request, response){
  var basename = path.basename(decodeURI(request.url)) || 'index.html';
  var file     = config.STATIC_DIR+'/'+basename;

  fs.exists(file, function(exists){
    var headers = {"Content-Type": path.extname[file]};

    if (exists) {
      var output = fs.readFileSync(file);
    }
    else {
      var output = fs.readFileSync(config.TEMPLATE_DIR+"/index.html", "utf-8");
    }

    response.writeHead(200, headers);
    response.end(output);
  });
});

server.listen(config.PORT, config.IP, function(){
});

var io     = socketio.listen(server);
var users = {};
var memberId = 0;

var ChatUser = function(name) {
  this.setId(++memberId);
  this.setName(name);
};
ChatUser.prototype = {
  setId: function(id){
    this.id = id;
  },
  getId: function(){
    return this.id;
  },
  setName: function(name){
    this.name = name;
  },
  getName: function(){
    return this.name;
  }
};

io.sockets.on('connection', function(socket){
  socket.on('try-enter', function(data){
    var sessionId = socket.id;

    if (Object.keys(users).length >= 2) { 
      socket.emit('connected', {
        status: false
      });
    }
    else {
      users[sessionId] = new ChatUser(data.name);

      socket.emit('connected', {
        status: true,
        id: users[sessionId].getId()
      });

      io.emit('admin-message', {
        message: '<strong>'+users[sessionId].getName()+'</strong>さんが入室しました'
      });
    }
  });

  socket.on('disconnect', function(){
    var sessionId = socket.id;
    io.emit('admin-message', {
      message: '<strong>'+users[sessionId].getName()+'</strong>さんが退室しました'
    });
    delete users[sessionId];
  });

  socket.on('post-message', function(data){
    if (data.message.length) {
      io.emit('user-message', {
        userId: users[socket.id].getId(),
        userName: users[socket.id].getName(),
        message: data.message,
      });
    }
  });
});

解説

今回実装した処理の要点を解説したいと思います。

入室処理

クライアント側

サーバに対して自分の名前を送信する処理です。
入力欄上でエンターキーを押したときにサーバ側に送信を行っています。
退室後の再入室時は再度socketの接続を行う必要があるため、socketの状態チェックを行っています

index.html:22行目~

$('input[name="username"]').on('keydown', function(e){

  if (!socket.connected) {
    socket.connect();
  }

  if (ENTER_KEY == e.keyCode && $(this).val().length) {
    var myName = $(this).val();
    socket.emit('try-enter', { name: myName });
    $(this).val('');
  }
});

サーバ側

クライアントから名前を受信し、接続結果を送信元のクライアントだけに返します。(socket.emit)
チャットルームの現在の定員を超えていなければ成功を返します。
その際、ソケットID(socket.ioの接続ID)と名前を紐づけて、発言の管理ができるようにしておきました。
またこの時に、ソケットIDとは別にユーザのシーケンシャルなIDを振り出しています。
定員を超えている場合は失敗を返します。

入室成功の場合は、すべてのクライアントにメッセージを送信しています(io.emit)

index.js:54行目~

socket.on('try-enter', function(data){
  var sessionId = socket.id;

  if (Object.keys(users).length >= 2) { 
    socket.emit('connected', {
      status: false
    });
  }
  else {
    users[sessionId] = new ChatUser(data.name);

    socket.emit('connected', {
      status: true,
      id: users[sessionId].getId()
    });

    io.emit('admin-message', {
      message: '<strong>'+users[sessionId].getName()+'</strong>さんが入室しました'
    });
  }
});

再びクライアント側

チャットルームへの入室が成功した場合は、発言を入力するインターフェースを表示します。
その際、サーバから受信したユーザーIDを保持して、自分の発言が判別できるようにしています。(global変数myIdに代入)
失敗した場合は定員オーバーの旨、アラートを表示するようにしました。

index.html:46行目~

socket.on('connected', function(data){
  if (data.status) {
    myId = data.id;
    $('#layer1').fadeOut();
    $('#layer2').fadeIn();
  }
  else {
    alert('定員オーバー');
  }
});

発言処理

クライアント側

自分の発言をサーバに送信する処理です
入力欄上でエンターキーを押したときにサーバ側に送信を行っています。

index.html:35行目~

$('input[name="message"]').on('keydown', function(e){
  if (ENTER_KEY == e.keyCode) {
    socket.emit('post-message', { message: $(this).val() });
    $(this).val('');
  }
});

サーバ側

ソケットIDから発言者を判別し発言者のユーザIDと名前、発言をすべてのクライアントに送信します。

index.js:85行目~

socket.on('post-message', function(data){
  if (data.message.length) {
    io.emit('user-message', {
      userId: users[socket.id].getId(),
      userName: users[socket.id].getName(),
      message: data.message,
    });
  }
});

再びクライアント側

受信した発言を表示します。
その際、自分の発言と他人の発言を別々の見た目で表示するようにしました。
(入室時にセットしたmyIdとサーバから受信した発言のユーザIDを比較)

index.html:66行目~

socket.on('user-message', function(data){
  if (typeof myId != 'undefined') {
    var $message;

    if (data.userId == myId) {
      $message = $('#tmpl-mine').clone().removeAttr('id');
    }
    else {
      $message = $('#tmpl-partner').clone().removeAttr('id');
    }

    $('> div', $message).html('<strong>'+data.userName+'</strong><br>'+data.message);
    $('#layer2 > .chatline').prepend($message);
    $message.fadeIn();
  }
});

退室処理(退室ボタン)

クライアント側

退室ボタンクリック時に、接続を切断します(socket.disconnect());
サーバ側に’disconnect’イベントが送出されます

index.js:42行目~

$('button.btn-exit').on('click', function(){
  disconnect();
});

disconnect関数は下記で定義しています
index.html:13行目~

function disconnect(){
  socket.disconnect();
}

サーバ側

ユーザーが退室した旨、全クライアントにメッセージを送ります。
また、入室済みユーザ一覧から退室したユーザを削除します(delete users[sessionId])

index.js:77行目~

socket.on('disconnect', function(){
  var sessionId = socket.id;
  io.emit('admin-message', {
    message: '<strong>'+users[sessionId].getName()+'</strong>さんが退室しました'
  });
  delete users[sessionId];
});

退室処理(ウインドウを閉じる)

クライアント側

window.beforeunload発火時にサーバ側に’disconnect’イベントが送出されます

index.html:17行目~

window.addEventListener('beforeunload', disconnect);

disconnect関数は下記で定義しています

index.html:13行目~

function disconnect(){
  socket.disconnect();
}

サーバ側

ユーザーが退室した旨、全クライアントにメッセージを送ります。
また、入室済みユーザ一覧から退室したユーザを削除します

入室済みユーザーは変数usersにソケットIDをプロパティ名として格納しているので、
usersからソケットIDのプロパティを削除(delete)しています。

index.js:77行目~

socket.on('disconnect', function(){
  var sessionId = socket.id;
  io.emit('admin-message', {
    message: '<strong>'+users[sessionId].getName()+'</strong>さんが退室しました'
  });
  delete users[sessionId];
});

次回

今回作ったテキストチャットと前回までの成果を組み合わせて、
ビデオ+テキストチャットにしたいと思います。