Socket.IOで実現するリアルタイム通信
なぜSocket.IOを使うのか
Socket.IOは、ブラウザとサーバー間で双方向・リアルタイム通信を簡単に実現するためのJavaScriptライブラリです。WebSocketをベースにしつつ、フォールバック機能や自動再接続、イベント駆動モデルを提供しているため、開発者が複雑な通信ロジックを意識せずにリアルタイム機能を実装できます。ブラウザの互換性やネットワークの制約を気にせずに利用できるのも大きな利点です。
今回のチャットアプリケーションでは、コメントの即時反映やリアクションの同期といった要件を満たすために、Socket.IOを採用しています。
それでは、具体的な実装を見ていきましょう。まずはサーバー側のコードから解説します。
サーバー側のセットアップ(Express連携)
server.jsでは、ExpressとSocket.IOを組み合わせてサーバーを起動しています。
const express = require("express");
const { createServer } = require("node:http");
const { Server } = require("socket.io");
const db = require("./database");
const app = express();
const server = createServer(app);
const io = new Server(server);
このコードでは、createServer(app)でHTTPサーバーを生成しています。また、それをnew Server(server)に渡すことで、HTTPとWebSocketが同一ポート(3005)で共存する構成を実現しています。
これにより、フロントエンド側(client/comment-chat.js)からは単に io() を呼ぶだけで接続が確立します。
(引数を渡さない場合、ページを提供するホストに接続しようとします。※公式ドキュメントの該当ページ)
それでは具体的に、ユーザーから送られてきたデータをどのように処理しているか見ていきましょう。
ユーザーが送信したコメントをサーバーが受信する
ユーザーが送信したコメントは、ExpressのAPIエンドポイントで受け取っています。例えばコメント投稿のエンドポイントは次のようになっています。
app.all("/comments", upload.none(), (req, res) => {
commentResponse(req, res);
});
さらにコードを掘り下げていきましょう。commentResponseは次のように定義しています。
function commentResponse(req, res) {
switch (req.method) {
case "GET":
return handleCommentGet(res);
case "POST":
return handleCommentPost(req, res);
case "PUT":
return handleCommentPut(req, res);
case "DELETE":
return handleCommentDelete(req, res);
default:
return res.status(405).end();
}
}
switch文でHTTPメソッドごとに処理を分岐しています。今回はPOSTリクエストが送信されるので、handleCommentPost()が呼ばれます。
まずは、handleCommentPost()の全体像を見てみましょう。ちょっと長めのコードですが、後から解説します。
function handleCommentPost(req, res) {
const { content, userId, parentId, socketId } = req.body;
const now = new Date().toISOString();
const newComment = {
id: Date.now().toString(),
userId,
postTime: now,
updateTime: now,
parentCommentId: parentId || "",
content,
};
const stmt = db.prepare(
"INSERT INTO comments (id, userId, postTime, updateTime, parentCommentId, content) VALUES (?, ?, ?, ?, ?, ?)",
);
stmt.run(
newComment.id,
newComment.userId,
newComment.postTime,
newComment.updateTime,
newComment.parentCommentId,
newComment.content,
);
res.json(withDateObjects(newComment));
sendCommentChange(socketId, {
type: "add",
comment: {
id: newComment.id,
userInfo: getUserInfo(newComment.userId),
content: newComment.content,
postTime: newComment.postTime,
updateTime: newComment.updateTime,
parentCommentId: newComment.parentCommentId,
},
});
}
このhandleCommentPost()は、とても重要な関数です。
具体的には、3つの主要な処理が行われています。
- SQLiteにコメントデータを保存する
- クライアントに成功レスポンスを返す
- Socket.IOで他のクライアントに通知する
それぞれのコードを解説します。まず、const { content, userId, parentId, socketId } = req.body;でPOSTリクエストのボディから必要なデータを抽出しています。
そして、const newComment = {...} で新しいコメントオブジェクトを作成しています。ここでは、ID、ユーザーID、投稿時間、更新時間、親コメントID、内容を設定しています。
const stmt = db.prepare(...) でSQLiteのINSERT文を準備し、stmt.run(...) で実際にデータベースに保存します。
次に、res.json(withDateObjects(newComment)); でクライアントに成功レスポンスを返しています。日付オブジェクトを適切に変換するためにwithDateObjects()を使っています。
最後に、sendCommentChange(socketId, {...}) でSocket.IOを使って他のクライアントに通知しています。
この一連の処理によって、ユーザーが送信したコメントをDBに保存し、他のクライアントにリアルタイムで反映する仕組みが完成しました。
ブロードキャストで他のクライアントに通知する
ここで大事なポイントがあります。それはSocket.IOを使って「送信者以外のクライアントにのみ通知する」ということです。
送信者自身は、HTTPレスポンスで最新のコメントデータを受け取るため、Socket.IOで再度同じデータを受信する必要はありません。もし重複して受け取ると、コメントが二重に表示されてしまいます。これを防ぐために、Socket.IOのブロードキャスト機能を利用します。
それでは、先ほど取り上げたsendCommentChange()関数の中へと進みましょう。
function sendCommentChange(socketId, payload) {
emitToOtherClients(socketId, "commentupdated", payload);
}
この関数はシンプルで、"commentupdated"というイベント名を付けて、emitToOtherClients()関数を呼び出しています。
function emitToOtherClients(socketId, eventName, payload) {
if (!socketId) return;
const socket = io.sockets.sockets.get(socketId);
if (!socket) return;
socket.broadcast.emit(eventName, payload);
}
このemitToOtherClients()関数では、まずsocketIdが有効かどうかをチェックしています。無効な場合は即座にreturnします。
次に、io.sockets.sockets.get(socketId)で送信者のソケットオブジェクトを取得します。もしソケットが見つからない場合もreturnします。
最後に、socket.broadcast.emit(eventName, payload);で送信者以外の全クライアントにイベントをブロードキャストしています。
ブロードキャストとは、Socket.IOにおいて特定のクライアントを除いた全ての接続先にメッセージを送信することを指します。これにより、送信者が自分の投稿を二重に受け取ることを防ぎます。
次の画像は公式ドキュメントからの引用で、ブロードキャストのイメージを示しています。
ここでのclient Aは送信者、client Bとclient Cは他のクライアントです。socket.broadcast.emit()を使うことで、client Aを除いたBとCにのみイベントが送信されます。これでサーバー側から他のクライアントへと、リアルタイムにコメント更新イベントが送信される仕組みが完成しました。あとは他のクライアント側でこのイベントを受信し、UIを更新するだけです。
次に、受信→UI更新の流れを見ていきます。
クライアント側でのイベント受信とUI更新
さて、今度は再びcomment-chat.jsに戻ります。クライアント側でイベントを受信している部分はこちらです。
socket.on("commentupdated", (msg) => {
handleCommentsChange(msg);
});
Socket.IOクライアントで"commentupdated"イベントを監視し、受信したメッセージをmsgとしてhandleCommentsChange()関数に渡しています。この関数は、受信したコメントの情報をもとにUIを更新する役割を担っています。まず、handleCommentsChange()の全体像を示します。
function handleCommentsChange(msg) {
switch (msg.type) {
case "add":
gcComment.execCommand(GcCommentCommand.AddCommentElement, {
comment: {
...msg.comment,
postTime: new Date(msg.comment.postTime),
updateTime: new Date(msg.comment.updateTime),
},
scrollIntoView: true,
});
break;
case "delete":
gcComment.execCommand(GcCommentCommand.DeleteCommentElement, {
commentId: msg.comment.id,
});
break;
case "update": {
const comment = getComment(gcComment.comments, msg.comment.id);
if (comment) {
gcComment.execCommand(GcCommentCommand.UpdateCommentElement, {
comment: {
...comment,
content: msg.comment.content,
updateTime: new Date(msg.comment.updateTime),
},
});
}
break;
}
default:
return;
}
}
この関数では、msg.typeに応じて3つのケース(add, delete, update)に分岐しています。それぞれ、新しいコメントの追加、コメントの削除、コメントの更新に対応しています。
先ほどサーバー側で送信したペイロードには、type: "add"が含まれていました。したがって、ここでは最初のケースが実行されます。
case "add":
gcComment.execCommand(GcCommentCommand.AddCommentElement, {
comment: {
...msg.comment,
postTime: new Date(msg.comment.postTime),
updateTime: new Date(msg.comment.updateTime),
},
scrollIntoView: true,
});
break;
このコードでは、gcComment.execCommand()を使って新しいコメント要素を追加しています。GcCommentCommand.AddCommentElementは、コメントコンポーネントに新しいコメントを追加するためのコマンドです。
msg.commentにはサーバーから送信されたコメントデータが含まれており、postTimeとupdateTimeは文字列からDateオブジェクトに変換しています。
scrollIntoView: trueを指定することで、新しいコメントが追加された際に自動的にスクロールされ、ユーザーの視界に入るようになります。
以上で、他のクライアントの画面にも即座に新しいコメントが表示されるようになりました!
InputManJS(GcComment) の execCommand() を使うことで、UIの更新を簡潔に行えることが分かりました。「ユーザー操作 → API通信 → Socket.IO通知 → UI更新」の流れが明確になったと思います。
最後に、SQLiteを用いたデータ永続化について解説していきます。

