本連載「Socket.IOで始めるWebSocket超入門」では、WebSocketを扱うことができるNode.jsのライブラリ「Socket.IO」を使って、サンプルアプリケーションを構築していきます。
具体的には、チャットを題材とし、送受信されるメッセージ内容が即時反映されるリアルタイムかつ双方向なWebアプリケーションの構築を目標とします。さらに構築の中で、Socket.IOの各種ライブラリの使い方について解説することで、Socket.IOを使ったWebSocketの実践方法を体系的に学びます。
前回の「チャットアプリ開発に見る、Socket.IOの基本ライブラリの使い方」では、実際にチャットアプリ開発をしながら、Socket.IO基本ライブラリの使い方を確認しました。今回は、さらにチャットアプリ開発を進めながら、「room」「namespace」ライブラリの使い方について説明します。
以降は、前回のチャットアプリ開発までが完了していることが前提です。もし、まだチャットアプリ開発まで終わっていない方は、前々回、前回記事を参考にしてみてください。
Socket.IOでは、データの送受信だけではなく、「room」「namespace」という2つのライブラリを提供しています。
roomは、双方向・リアルタイムデータ送受信を任意の範囲で行うための仕組みです。roomを使用すると、文字通り、その部屋に所属するクライアント間のみでデータをやりとりすることが可能です。
namespaceは、Socket.IOの実装を機能単位で分割するための仕組みです。例えば、現在のチャットアプリに外部API連携機能や、お知らせ機能を追加する際に、namespaceを使用すると簡単に実装することが可能です。
それでは、2つのライブラリについて、順に実装しながら説明します。
前回記事時点でのチャットアプリには、チャットルームがありません。そこで、チャットルーム機能をroomを使用して実装します。今回は、「部屋01」「部屋02」という2つの部屋を用意します。実現したいことのイメージ図を以下に記載します。
それでは、実際に実装してみましょう。
まずは、クライアントサイドです。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>websocket-chat</title>
<link rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
<!-- C01. Socket.IOクライアントライブラリの読込み -->
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
</head>
<body>
<div class="container">
<h1>WebSocket-Chat</h1>
<form class="form-inline">
<div class="form-group">
<label class="roomLabel" for="rooms">部屋:</label>
<select class="form-control" id="rooms">
<option value="room01">部屋01</option>
<option value="room02">部屋02</option>
</select>
<label class="nameLabel" for="msgForm">名前:</label>
<input type="text" class="form-control" id="msgForm">
</div>
<button type="submit" class="btn btn-primary" id="sendButton">入室</button>
</form>
<div id="chatLogs"></div>
</div>
<script type="text/javascript">
var socket = io.connect(); // C02. ソケットへの接続
var isEnter = false;
var name = '';
// C04. server_to_clientイベント・データを受信する
socket.on("server_to_client", function(data){appendMsg(data.value)});
function appendMsg(text) {
$("#chatLogs").append("<div>" + text + "</div>");
}
$("form").submit(function(e) {
var message = $("#msgForm").val();
var selectRoom = $("#rooms").val();
$("#msgForm").val('');
if (isEnter) {
message = "[" + name + "]: " + message;
// C03. client_to_serverイベント・データを送信する
socket.emit("client_to_server", {value : message});
} else {
name = message;
var entryMessage = name + "さんが入室しました。";
socket.emit("client_to_server_join", {value : selectRoom});
// C05. client_to_server_broadcastイベント・データを送信する
socket.emit("client_to_server_broadcast", {value : entryMessage});
// C06. client_to_server_personalイベント・データを送信する
socket.emit("client_to_server_personal", {value : name});
changeLabel();
}
e.preventDefault();
});
function changeLabel() {
$(".nameLabel").text("メッセージ:");
$("#rooms").prop("disabled", true);
$("button").text("送信");
isEnter = true;
}
</script>
</body>
</html>
クライアントサイドでは、チャット部屋のセレクトボックスと、選択された部屋名をサーバサイドに送信するclient_to_server_joinイベントを追加しています。
続いてサーバサイドです。
// S01. 必要なモジュールを読み込む
var http = require('http');
var socketio = require('socket.io');
var fs = require('fs');
// S02. HTTPサーバを生成する
var server = http.createServer(function(req, res) {
res.writeHead(200, {'Content-Type' : 'text/html'});
res.end(fs.readFileSync(__dirname + '/index.html', 'utf-8'));
}).listen(3000); // ポート競合の場合は値を変更
// S03. HTTPサーバにソケットをひも付ける(WebSocket有効化)
var io = socketio.listen(server);
// S04. connectionイベントを受信する
io.sockets.on('connection', function(socket) {
var room = '';
var name = '';
// roomへの入室は、「socket.join(room名)」
socket.on('client_to_server_join', function(data) {
room = data.value;
socket.join(room);
});
// S05. client_to_serverイベント・データを受信する
socket.on('client_to_server', function(data) {
// S06. server_to_clientイベント・データを送信する
io.to(room).emit('server_to_client', {value : data.value});
});
// S07. client_to_server_broadcastイベント・データを受信し、送信元以外に送信する
socket.on('client_to_server_broadcast', function(data) {
socket.broadcast.to(room).emit('server_to_client', {value : data.value});
});
// S08. client_to_server_personalイベント・データを受信し、送信元のみに送信する
socket.on('client_to_server_personal', function(data) {
var id = socket.id;
name = data.value;
var personalMessage = "あなたは、" + name + "さんとして入室しました。"
io.to(id).emit('server_to_client', {value : personalMessage});
});
// S09. dicconnectイベントを受信し、退出メッセージを送信する
socket.on('disconnect', function() {
if (name == '') {
console.log("未入室のまま、どこかへ去っていきました。");
} else {
var endMessage = name + "さんが退出しました。"
io.to(room).emit('server_to_client', {value : endMessage});
}
});
});
サーバサイドでは、client_to_server_joinイベント受信処理を追加しています。アクセスしたクライアントを特定のroomに入室させるには、「socket.join(room名)」を使用します。
また、emit(送信)処理を以下のように変更しています。
| 変更前 | 変更後 | |
|---|---|---|
| io.sockets.emit(全クライアント送信) | io.to(room).emit | |
| socket.broadcast.emit(ブロードキャスト送信) | socket.broadcast.to(room).emit | |
データ送信を特定のroomに限定するには、「to(room名)」を使用します。これにより、全クライアント送信・ブロードキャスト送信の範囲は指定したroomのみになります。
ちなみに、入室したroomから退室したい場合は、「socket.leave(room名)」を使用します。ただし、Socket.IOでは、disconnectイベントが発生すると自動的にそのクライアントをroomから退出させます。入室したチャットルームを切り替えるなど、disconnectイベント以外でroom退室処理が必要な場合のみ使用すればいいでしょう。
コードの変更・保存が完了したら、アプリケーションの動作を確認します。4つのブラウザウィンドウでアプリケーションを開いてください。その後、部屋01、部屋02にそれぞれ2クライアントずつ入室します。
部屋01、部屋02のクライアントでそれぞれメッセージを送信します。
メッセージが、全クライアントではなく、部屋01、部屋02の範囲内でのみ共有されたことが確認できたと思います。このように、roomを使用すると、データの送信対象を任意にグループ化することができます。
roomへの入室:socket.join(room名) / roomから退室:socket.leave(room名)
次に、namespaceを使って、チャット以外の機能を実装します。今回は例として、接続したクライアントに今日の運勢を表示する機能を追加します。イメージ図を以下に記載します。
それでは、実装です。
room解説時とは異なり、サーバサイドから説明します。
// S01. 必要なモジュールを読み込む
var http = require('http');
var socketio = require('socket.io');
var fs = require('fs');
// S02. HTTPサーバを生成する
var server = http.createServer(function(req, res) {
res.writeHead(200, {'Content-Type' : 'text/html'});
res.end(fs.readFileSync(__dirname + '/index.html', 'utf-8'));
}).listen(3000); // ポート競合の場合は値を変更
// S03. HTTPサーバにソケットをひも付ける(WebSocket有効化)
var io = socketio.listen(server);
// チャット機能
// S04. connectionイベントを受信する
var chat = io.of('/chat').on('connection', function(socket) {
var room = '';
var name = '';
// roomへの入室は、「socket.join(room名)」
socket.on('client_to_server_join', function(data) {
room = data.value;
socket.join(room);
});
// S05. client_to_serverイベント・データを受信する
socket.on('client_to_server', function(data) {
// S06. server_to_clientイベント・データを送信する
chat.to(room).emit('server_to_client', {value : data.value});
});
// S07. client_to_server_broadcastイベント・データを受信し、送信元以外に送信する
socket.on('client_to_server_broadcast', function(data) {
socket.broadcast.to(room).emit('server_to_client', {value : data.value});
});
// S08. client_to_server_personalイベント・データを受信し、送信元のみに送信する
socket.on('client_to_server_personal', function(data) {
var id = socket.id;
name = data.value;
var personalMessage = "あなたは、" + name + "さんとして入室しました。"
chat.to(id).emit('server_to_client', {value : personalMessage});
});
// S09. dicconnectイベントを受信し、退出メッセージを送信する
socket.on('disconnect', function() {
if (name == '') {
console.log("未入室のまま、どこかへ去っていきました。");
} else {
var endMessage = name + "さんが退出しました。"
chat.to(room).emit('server_to_client', {value : endMessage});
}
});
});
// 今日の運勢機能
var fortune = io.of('/fortune').on('connection', function(socket) {
var id = socket.id;
// 運勢の配列からランダムで取得してアクセスしたクライアントに送信する
var fortunes = ["大吉", "吉", "中吉", "小吉", "末吉", "凶", "大凶"];
var selectedFortune = fortunes[Math.floor(Math.random() * fortunes.length)];
var todaysFortune = "今日のあなたの運勢は… " + selectedFortune + " です。"
fortune.to(id).emit('server_to_client', {value : todaysFortune});
});
サーバサイドでは、チャット用・今日の運勢用それぞれのWebSocketコネクションをnamespace(名前空間)を区切って定義しています。チャットは/chat、今日の運勢は/fortuneです。namespaceを指定するには、「of(/namespace名)」を使用します。
追加実装前は、ioという変数を介してデータの送受信を行っていました。しかし、追加実装後は名前空間が指定されたコネクションを、それぞれchat、fortune変数を介して使用します。そのため、データのやりとりを行うためにはio変数部分をそれぞれchat、fortuneに変更することが必要です。
ただし、データの受信とbroadcast送信は、名前空間を定義した場合でもsocket変数を介して行います。socket.onとsocket.broadcast部分には変更がないことに注目してください。今日の運勢機能では、運勢データをランダムでクライアントに送信する処理を追加しています。
次に、クライアントサイドです。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>websocket-chat</title>
<link rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
<!-- C01. Socket.IOクライアントライブラリの読込み -->
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
</head>
<body>
<div class="container">
<h1>WebSocket-Chat</h1>
<form class="form-inline">
<div class="form-group">
<label class="roomLabel" for="rooms">部屋:</label>
<select class="form-control" id="rooms">
<option value="room01">部屋01</option>
<option value="room02">部屋02</option>
</select>
<label class="nameLabel" for="msgForm">名前:</label>
<input type="text" class="form-control" id="msgForm">
</div>
<button type="submit" class="btn btn-primary" id="sendButton">入室</button>
</form>
<br>
<div id="chatLogs">
<p>=====チャットログ=====</p>
</div>
<br>
<div id="fortune">
<p>======今日の運勢======</p>
</div>
</div>
<script type="text/javascript">
var chat = io('http://localhost:3000/chat');
var fortune = io('http://localhost:3000/fortune'); // C02. ソケットへの接続
var isEnter = false;
var name = '';
// C04. server_to_clientイベント・データを受信する
chat.on("server_to_client", function(data){appendMsg(data.value)});
fortune.on("server_to_client", function(data) {appendFortune(data.value)});
function appendMsg(text) {
$("#chatLogs").append("<div>" + text + "</div>");
}
function appendFortune(text) {
$("#fortune").append("<div>" + text + "</div>");
}
$("form").submit(function(e){
var message = $("#msgForm").val();
var selectRoom = $("#rooms").val();
$("#msgForm").val('');
if (isEnter) {
message = "[" + name + "]: " + message;
// C03. client_to_serverイベント・データを送信する
chat.emit("client_to_server", {value : message});
} else {
name = message;
var entryMessage = name + "さんが入室しました。";
chat.emit("client_to_server_join", {value : selectRoom});
// C05. client_to_server_broadcastイベント・データを送信する
chat.emit("client_to_server_broadcast", {value : entryMessage});
// C06. client_to_server_personalイベント・データを送信する
chat.emit("client_to_server_personal", {value : name});
changeLabel();
}
e.preventDefault();
});
function changeLabel() {
$(".nameLabel").text("メッセージ:");
$("#rooms").prop("disabled", true);
$("button").text("送信");
isEnter = true;
}
</script>
</body>
</html>
クライアントサイドでは、ソケット接続処理を変更しています。これは、サーバサイドで/chatと、/fortune2つのWebSocketコネクションを定義したためです。
namespaceが与えられたソケット接続には「io(connectionURL)」を使用します。引数なしの「io()」や「io.connect()」使用時には、デフォルトで「/」という名前空間を持つWebSocketコネクションが生成されます。チャット用のイベントはchat変数を、今日の運勢用のイベントはfortune変数を介して発火されます。
コードの変更・保存が完了したら、アプリケーションの動作を確認します。
nodeプロセスを再起動した後に、ブラウザウィンドウでアプリケーションを開いてください。チャットログエリアの下に、今日の運勢が表示されていることが確認できると思います。
クライアントサイド、サーバサイドそれぞれ独自に定義した変数を使って処理が実行されます。このようにnamespaceを使用することで、機能追加でコードの量が増えても、1つ1つの処理がどの機能のものか分かりやすい形で追加開発を進めることが可能です。
namespaceの定義:of(namespace名)
前々回、前回記事を含め、ここまでの内容でSocket.IOを使った開発のイメージをつかめたでしょうか。ここで、少し内容は逸れますが、開発を進める際に役立つツールを簡単に紹介します。
ご存じの方も多いと思います。Web開発における必須ツールと言っても過言ではないでしょう。画面レイアウトの調整だけではなく、JavaScriptのデバッグやコンソールログを使用して処理の時間計測をすることが可能です。
Node.jsプロセスをデーモン化するツールとしてforeverがあります。foreverは単にデーモン化するだけでなく、起動したNode.jsプロセスの死活監視を行い、停止した場合には自動的に再起動してくれます。
Node.jsログ出力ツールとしてlog4jsがあります。systemログ、applicationログなど、用途に応じた複数のロガーが定義できます。出力タイプは、ファイル・コンソール・日付単位のローテーションがあり、ログレベルは「FATAL」「ERROR」「WARN」「INFO」「DEBUG」「TRACE」の6レベル提供されています。
WebSocketは、異なるプロセス間でセッションを共有できません。そのため、本チャットアプリのサーバをスケールアウトしようとしても、Socket.IOはプロセスごとに動作するため、サーバAとサーバB間でチャットメッセージが共有されない問題が発生します。その問題を解決するために、socket.io-redisを使用します。別途Redisサーバを立てて、そこにスケールアウトしたいサーバを登録します。そうすることで、Redisサーバを経由して異なるプロセス間でもWebSocketセッションが共有され、上記のような問題を解決可能です。
3回にわたり、Socket.IOを使ったWebSocket入門記事を執筆してきました。本連載は、今回で最終回です。最後までお付き合いいただき、ありがとうございました。本連載がSocket.IOやWebSocket開発に興味を持った読者の皆さまに役立つことを願っています。
Copyright © ITmedia, Inc. All Rights Reserved.