Node.jsとExpressでLINEみたいなwebアプリを作る

CPUみたいな画像 Express

はじめに

Node.jsとExpressを使ってLINEやSlackみたいな簡易的なリアルタイムチャットアプリを作ってみたので、作り方を解説してみる。あまり高度な内容は取り扱わず、初歩的な仕組みを使って作成するので、読者にはあまり多くの知識は求めない。ただし、私自身このアプリを使う上で「?」がたくさん浮かんだので、補足説明を適時入れていければと思う。質問や不明点があれば、ご連絡いただきたい。

事前準備・必要なもの

本アプリはNode.jsやExpress、他にも各種ライブラリを使用するので、事前にインストールを実施しておいていただきたい。簡単ではあるが、以下に準備方法を記載しておく。

Node.jsのインストール

Node.jsのインストール先は以下のリンクにある。
画面下の方に「(OS名)インストーラ」という緑のボタンがあるので、押してインストーラをダウンロードすること。ダウンロードが完了したら、インストーラをダブルクリックで起動すればインストール画面が立ち上がる。特別難しい設定はないので、あとは画面の指示に従ってインストールを完了すればよい。
https://nodejs.org/ja/download

インストールが完了したらバージョン情報をチェックしよう。
Windowsならコマンドプロンプト、Macならターミナルを起動し、node -vとコマンド入力すると、以下のようにバージョン情報が表示される。ここまで完了すれば、Node,jsのインストールは完了だ。

$ node -v
v22.19.0

各種モジュールのインストールについて

Node.jsをインストールすると自動的にnpm(Node Package Manager)も一緒にインストールされるはず。npmはJavaScript用のパッケージ管理ツールで、Node.jsを使う時に必要なライブラリ(例えばExpressとかSocket.IOなど)を簡単にインストール、更新、管理ができる。プログラムを共有する仕組みとしても使われているので、世界中の開発者が作ったモジュールを利用できるというのが強み。

今回npmでインストールが必要なライブラリはExpress, Socket.IOの2種類。ターミナルを開いて以下のコマンドを実行すればインストールできる。(後述するので、まだインストールはしなくてOK)

$ npm install express
$ npm install socket.io

Expressモジュールについて

ExpressはNode.js上で動作するWebアプリケーションフレームワークのこと。サーバを建ててクライアントからHTTPリクエストを受け取り、レスポンスを返すという仕組みを簡単に作成することができるようになる。例えば”GET /”や”POST /login”のようなルーティング処理や、HTML・JSONを返すレスポンス処理、静的ファイル(HTML/CSS/JS)を返すなどの処理を簡単に書ける。

よくある使い方の例として、具体的なJavaScriptのコード(最小構成)を示しておく。以下は、ポート番号3000でクライアントからのGET /リクエストを待ち受けるサーバを起動する。ChromeやEdgeなどのブラウザのURL欄に “http://localhost:3000″ と打ち込めば、”Hello Express!” という文字列が画面に表示される。なお、この時点では自身のPC上でサーバが稼働しているだけなので、外部からアクセスすることはできない。外部に向けて公開したい場合は、別途レンタルサーバを借り、そのサーバ上でExpressを実行する必要があるが、本記事では割愛する。

// Expressをインポート
const express = require("express");
// Expressの様々な機能を利用するために、express()でオブジェクトを作る
const app = express();

// "/"パスにGETリクエストしてきたクライアントに"Hello Express!"という文字列を返す
// ブラウザのURL欄に "http://localhost:3000/" と入力すればアクセスできる。
app.get("/", (req, res) => {
  res.send("Hello Express!");
});

// ポート番号3000でクライアントからのリクエストを待ち受けるサーバを起動
app.listen(3000, () => {
  console.log("Server running on http://localhost:3000");
});

Socket.ioモジュールについて

Node.jsでリアルタイム通信(クライアントとサーバの双方向通信)を実現するためのライブラリのことで、ExpressやHTTPサーバと組み合わせて使うのが一般的。今回はリアルタイムチャットアプリの作成に利用する。

こちらも先述したExpressと同様に最小構成のコードを示しておく。ブラウザからサーバ宛にチャットを送信すると、同じサーバに接続している、他すべてのクライアントにメッセージを送信するという、サーバ側(=バックエンド側)の処理を記載した。「サーバ(に繋がっている他クライアント)に向けてチャットを送信する」というクライアント側のコードは別のファイルに分けて記載するが、現時点ではひとまず「LINEのグループチャットのようなもの」という理解でOK。io.on()やsocket.on()など見慣れないメソッドが目立つが、後ほど解説するので、イメージだけ理解できれば読み飛ばしていただいて構わない。

const express = require("express");
const http = require("http");
const { Server } = require("socket.io");

const app = express();
const server = http.createServer(app);
const io = new Server(server);

io.on("connection", (socket) => {
  console.log("ユーザー接続");

  // サーバにチャットが送られた時に、
  // 参加中の全クライアントにメッセージを送信するイベント処理
  socket.on("chat message", (msg) => {
    console.log("メッセージ:", msg);
    io.emit("chat message", msg); // 全員に送信
  });

  // クライアントの切断時に発火するイベント処理
  socket.on("disconnect", () => {
    console.log("ユーザー切断");
  });
});

// 3000番ポートでリクエストを受け付けるサーバを起動
server.listen(3000, () => {
  console.log("Server running on http://localhost:3000");
});

チャットアプリのフォルダ構成

本記事の目的であるチャットアプリを作るためにサーバとクライアントの処理をそれぞれJavaScriptとHTMLで記載するが、その前に各ファイルの配置や構成を簡単に示しておく。以下の構成でフォルダ・ファイルを作っておこう。

project/         ← 任意の名前でOK。新たにフォルダを作る
├─ server.js      ← サーバーのコード。新たにファイルを作る
├─ package.json   ← これは作らなくてOK
└─ public/        ← 静的ファイル置き場。新たにフォルダを作る
   └─ index.html  ← クライアントのコード。新たにファイルを作る

package.jsonはprojectフォルダの下で以下のコマンドを実行することで作成できる。

$ npm init -y

package.jsonを生成した直後のファイル中身は以下のように記載されている。

{
  "name": "project",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

サーバを起動する際のコマンドとして、”scripts”の記載を変更する。

"scripts": {
  "project": "node server.js"
}

これにより、以下のコマンドを打つことでserver.jsを実行、つまりサーバを起動することができる。現時点ではまだserver.jsの中身は空なので何も起こらない。

$ npm run project

続いて、expressとsocket.ioモジュールをインストールしておこう。以下のコマンドをprojectフォルダ配下で実行していただきたい。

$ npm install express
$ npm install socket.io

インストールが正常に完了すると、packege.jsonのdependenciesにインストールしたバージョンのexpressとsocket.ioが表示される。

  "dependencies": {
    "express": "^5.1.0",
    "socket.io": "^4.8.1"
  },

ここまでで事前準備は完了となる。続いてクライアント・サーバ側の処理を作り込んでいく。

クライアント用HTMLファイルの作成

今回はチャット部屋(サーバ)に接続したクライアントが好きな名前を入力してチャットを楽しむことができるというアプリにしたいので、まず先にクライアント側のHTMLファイル(index.html)を作る。最初にコードの全文を記載し、その後、上から順番に解説するというスタイルで記載を進める。まずはindex.htmlというファイルを作成し、中身を以下のように記載する。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My cool chat app</title>
    <style>
        /* 見た目を調整したい人はここにCSSを書くとよい */
        body {
            font-family: sans-serif;
            margin: 0;
            padding: 0;
        }

        #chat-container {
            display: flex;
            flex-direction: column;
            height: 100vh;
        }

        #messages {
            flex: 1;
            overflow-y: auto;
            list-style: none;
            margin: 0;
            padding: 10px;
        }

        #messages li {
            margin: 5px 0;
        }

        #form {
            display: flex;
            padding: 10px;
            background: #eee;
        }

        #input {
            flex: 1;
            padding: 8px;
        }

        #form button {
            padding: 8px;
        }

        .system {
            color: gray;
            font-style: italic;
        }

        .username {
            font-weight: bold;
            margin-right: 5px;
        }

        .time {
            font-size: 0.8em;
            color: #888;
            margin-left: 5px;
        }
    </style>
</head>

<body>
    <div id="chat-container">
        <!-- チャットを送信するたびに追加されるメッセージのコンテナを作成<li> -->
        <ul id="messages"></ul>

        <!-- チャット送信用フォーム。テキストボックスと送信ボタンがある -->
        <form id="form" action="">
            <!-- ユーザが入力するテキスト欄 -->
            <input id="input" autocomplete="off" placeholder="入力してください..." /><button>送信</button>
        </form>
    </div>

    <!-- サーバが自動で提供するSocket.IOクライアントライブラリを読み込む(io()が使えるようになる) -->
    <script src="/socket.io/socket.io.js"></script>
    <script>
        // Socketオブジェクトで送信(emit)や受信(on)を扱う。
        const socket = io();

        // DOM要素の参照を取得
        const form = document.getElementById("form");
        const input = document.getElementById("input");
        const messages = document.getElementById("messages");

        // 受信/送信メッセージを<li>要素にしてmessagesに追加するメソッド
        function addMessage({ user, msg, time }) {
            // 新しい<li>を作成
            const item = document.createElement("li");

            // ローカライズ済みのメッセージ
            const timestamp = new Date(time).toLocaleTimeString();

            if (user === "System") {
                // System(非ユーザ)の場合のメッセージを生成する
                item.classList.add("system");
                item.textContent = `[${timestamp}] ${msg}`;
            }
            else {
                // ユーザが一般の参加者の場合のメッセージを生成する
                // チャット時刻の文字列を生成
                const timeSpan = document.createElement("span");
                timeSpan.className = "time";
                timeSpan.textContent = `[${timestamp}]`;

                // ユーザ名を生成
                const userSpan = document.createElement("span");
                userSpan.className = "username";
                userSpan.textContent = `(${user}): `;

                // メッセージを生成
                const msgSpan = document.createElement("span");
                msgSpan.textContent = msg;

                item.appendChild(timeSpan);
                item.appendChild(userSpan);
                item.appendChild(msgSpan);
            }

            // チャット一覧に追加
            messages.appendChild(item);
            // 一番下にスクロール(新規チャットを見やすくするため)
            messages.scrollTop = messages.scrollHeight;
        };

        // 接続時にユーザ名を設定
        const username = prompt("ユーザ名を入力してください") || "名無し";
        // サーバ宛にset usernameイベントの発火を通知
        socket.emit("set username", username);

        // チャットメッセージのフォーム送信イベント
        form.addEventListener("submit", (e) => {
            // 通常は送信時にフォームをリロードするようになっているが、防止する
            e.preventDefault();

            // チャットメッセージから余分な空白を除去
            const message = input.value.trim();

            // メッセージが空文字でなければ...
            if (message) {
                // サーバ(に接続している全クライアント)宛にメッセージを送信する
                // server.js側ではsocket.on("chat message")でメッセージを受け取る
                socket.emit("chat message", message);
                // 入力フォームを空にセットしておく(次のメッセージ入力に備えて)
                input.value = "";
            }
        });

        // サーバからのメッセージを受信した際に発火するイベントを登録する
        // サーバからsocket.emit("chat message")を実行したら、addMessageメソッドを呼ぶ
        // 受け取ったメッセージで<li>を作って<ul>に追加し、チャット履歴が見えるようにする
        socket.on("chat message", addMessage);
    </script>
</body>

</html>

冒頭のDOCTYPEや<head>, <style>タグなど、基本的な記載については本記事の主旨から外れるため割愛する。<script>タグで囲われた部分に焦点を当てて解説する。

まずはsocket.IOライブラリを読みこむことで、io()を使えるようにする。ここではsocketというオブジェクトを作成しているが、このオブジェクトを経由してemit()やio()などのデータ送受信イベントを定義できるようにする。ここでemit()は「発する」という意味の単語であり、今回の例では、サーバとクライアント双方向へデータ送信する目的で使用する。emit(“<event name>”)の第一引数はイベント名を表し、サーバとクライアント間でこの名前を完全に合わせておく必要がある。例えば、クライアントからemit(“chat message”)というイベントが送信されると、サーバ側では”chat message”というイベントが送られてきたと認識することができる(逆も同様)。一方で、送信とは反対に受信する場合はon()というメソッドが用意されており、例えばサーバ側ではsocket.on(“chat message”)と書いておけば、クライアントからemit()で送られてきたメッセージを受け取ることができる。

<!-- サーバが自動で提供するSocket.IOクライアントライブラリを読み込むio()が使えるようになる-->
<script src="/socket.io/socket.io.js"></script>
<script>
  // Socketオブジェクトで送信(emit)や受信(on)を扱う。
  const socket = io();

  ~ 中略 ~
</script>

続いてDOM要素の参照を取得する。formはユーザにチャット入力機能を提供するために用意したオブジェクトで、inputは入力内容を保持するためのオブジェクト、messagesはクライアントがこれまでのやり取りを見ることができるチャットメッセージの一覧のことである。今回のアプリではチャットメッセージに対し、タイムスタンプ(いつ)や送り主の名前(誰が)という情報を付け加えて送信するようにする。

// DOM要素の参照を取得
const form = document.getElementById("form");
const input = document.getElementById("input");
const messages = document.getElementById("messages");

次に自分が他クライアント(サーバ宛)に送ったメッセージや、反対に誰か別のクライアント(実際は仲介しているサーバ)から受け取ったメッセージを表示するaddMessage()メソッドを用意しておく。
途中のif文で処理を分けているが、前半はSystemからのメッセージを全クライアントに表示するための処理である。Systemというのは他でもないこのシステムを作成している開発者が用意したんメッセージという意味で、例えば「〇〇が参加しました」というメッセージは誰でもないシステムからユーザに対して送られるべきメッセージである。
一方、else文ではユーザ同士のやり取り(メッセージ)を組み立てている。先述の通り、ユーザが打ち込んだメッセージに加えて、いつ、誰が、という情報を付加している。

// 受信/送信メッセージを<li>要素にしてmessagesに追加するメソッド
function addMessage({ user, msg, time }) {
    // 新しい<li>を作成
    const item = document.createElement("li");

    // ローカライズ済みのメッセージ
    const timestamp = new Date(time).toLocaleTimeString();

    if (user === "System") {
        // System(非ユーザ)の場合のメッセージを生成する
        item.classList.add("system");
        item.textContent = `[${timestamp}] ${msg}`;
    }
    else {
        // ユーザが一般の参加者の場合のメッセージを生成する
        // チャット時刻の文字列を生成
        const timeSpan = document.createElement("span");
        timeSpan.className = "time";
        timeSpan.textContent = `[${timestamp}]`;

        // ユーザ名を生成
        const userSpan = document.createElement("span");
        userSpan.className = "username";
        userSpan.textContent = `(${user}): `;

        // メッセージを生成
        const msgSpan = document.createElement("span");
        msgSpan.textContent = msg;

        item.appendChild(timeSpan);
        item.appendChild(userSpan);
        item.appendChild(msgSpan);
    }

    // チャット一覧に追加
    messages.appendChild(item);
    // 一番下にスクロール(新規チャットを見やすくするため)
    messages.scrollTop = messages.scrollHeight;
};

少し行が前後してしまうが、クライアントがサーバからメッセージを受けとるためのイベントは以下の行で定義されている。先ほど紹介した、socket.on()というメソッドはemit()で送信されたメッセージを受信するための専用メソッドである。イベント名を”chat message”と合わせるようにしよう。これがないと、送信されたメッセージをクライアント側で受信することができない。
また、先ほど作成したaddMessage()メソッドを第二引数にしており、chat messageイベントの通知を受信したら、addMessage()に書いた処理を呼び出せという意味になる。

// サーバからのメッセージを受信した際に発火するイベントを登録する
// サーバからsocket.emit("chat message")を実行したら、addMessageメソッドを呼ぶ
// 受け取ったメッセージで<li>を作って<ul>に追加し、チャット履歴が見えるようにする
socket.on("chat message", addMessage);

チャットルームへ入室した際にユーザ名を入力するダイアログを作成する。ここではprompt()というメソッドを使ってusernameを作成している。なお、名無しで登録された場合に備えてデフォルト(=「名無し」)を用意しておく。
socket.emit()は先述の通り、set usernameイベントをサーバに向かって送信する処理を記述している。第二引数にはサーバに渡したいデータ、ここでは参加者のユーザ名を渡している。なお、データを渡す場合は{“name”: username, “age”: userage}のように複数のデータを辞書オブジェクトにまとめて渡すこともできる。その場合、受け取る側ではnameやageを指定して受け取ったデータを個別に参照することができる。

// 接続時にユーザ名を設定
const username = prompt("ユーザ名を入力してください") || "名無し";
// サーバ宛にset usernameイベントの発火を通知
socket.emit("set username", username);

最後に、HTML標準のformに送信イベントを登録する。チャットメッセージを入力して「送信」ボタンを押したタイミングで、サーバにメッセージを送信するイベントを発火する。preventDefault()はチャット送信時にページガリロードされるのを防ぐ処理である。通常、HTMLのformを送信(submit)すると、入力データをサーバに送信し、ページをリロード(結果画面や同じページに戻る等)するという機能がブラウザに備わっているが、チャットを送信するたびにページをリロードするというのはユーザ的には使いにくい。なお、本メソッドを呼び出しておくことで、その標準動作をキャンセルすることができるが、その代わりに開発者が自動で送信処理を書く必要がある。今回はチャットメッセージの中身を確認し、余分な余白を削除したり、そもそも空のメッセージを送信できないようチェックした後、socket.emit()でチャット送信するようにしている。くどいようだが、”chat message”とイベント名は合わせておこう。

// チャットメッセージのフォーム送信イベント
form.addEventListener("submit", (e) => {
    // 通常は送信時にフォームをリロードするようになっているが、防止する
    e.preventDefault();

    // チャットメッセージから余分な空白を除去
    const message = input.value.trim();

    // メッセージが空文字でなければ...
    if (message) {
        // サーバ(に接続している全クライアント)宛にメッセージを送信する
        // server.js側ではsocket.on("chat message")でメッセージを受け取る
        socket.emit("chat message", message);
        // 入力フォームを空にセットしておく(次のメッセージ入力に備えて)
        input.value = "";
    }
});

ここまででクライアント側の処理は完成となる。サーバにアクセスしてきたクライアントに対してindex.htmlを送信し、当該Webページを表示できるよう、サーバ側の処理を作っていこう。ただし、今回はローカルPC上での動作確認のみとなるため、自機PCでサーバを起動し、ブラウザ上で “http://localhost:8080” にアクセスすれば表示できるようにする。

サーバ用スクリプトの作成

それでは冒頭で作成したserver.jsにサーバ用のJavaScriptを書いていく。クライアントコード同様、まず最初にコードの全文を示し、その後、上から順に解説するというスタイルで進める。細かい点は一旦抜きにして、まずは以下のコード全文をながめてみてほしい。

// Node.jsのCommonJSでモジュールを読み込む
// 返り値はモジュールのエクスポート
const express = require("express");

// Node.js標準のHTTPモジュール(HTTPサーバ生成に使う)
const http = require("http");

// パス操作のユーティリティ(OS差異を吸収)。joinメソッドを使用するためにインポート
const path = require("path");

// Expressアプリ本体を生成
const app = express()

// HTTPサーバを作成。Expressアプリ(=app)をリクエストハンドラとして渡す
const server = http.createServer(app);

// Socket.IOサーバをHTTPサーバに紐づけて生成
const io = require("socket.io")(server);

// 指定したフォルダ内(public)のファイルを自動的に公開する
app.use(express.static(path.join(__dirname, "public")));

// クライアントからの接続確立時に一回よばれるイベントを登録
// サーバ側で新しい接続(クライアントがサーバに接続したとき)を監視するイベントリスナー
// 一度だけ定義しておけば、接続が発生したときにコールバック関数が呼ばれる
// ここでsocketが渡される。これは接続してきたクライアントとの専用通信チャンネルを表す
io.on("connection", (socket) => {
    // デフォルトのユーザ名を乱数で生成([0, 1)の浮動小数を1000倍し、小数点切り捨て)
    let username = "User_" + Math.floor(Math.random() * 1000);
    // コンソールにユーザ名を表示
    console.log(`${username} joined!`);

    // ユーザ名の設定イベント
    // クライアントから"set username"イベント受信時のハンドラ
    // 特定のクライアント(=socket)から送られてくるカスタムイベントを受け取るためのリスナー
    socket.on("set username", (name) => {
        // falsyなら規定名を維持
        // 左の値がtruthyならnameを返し、そうでないならusernameを返す
        // nameが存在していて、truthyならnameを返す
        // nameが空文字、null, undefined, 0, falseのようなfalsyならusernameを返す
        // デフォルト値を設定するためによく使われる
        username = name || username;

        // 全接続クライアントへイベントをブロードキャスト
        io.emit("chat message", {
            user: "System",
            msg: `${username} joined the chat`,
            // "YYYY-MM-DDTHH:mm:ss.sssZ" 形式の文字列
            time: new Date().toISOString(),
        });
    });

    // メッセージ受信イベント
    // クライアントからのチャット本文を受信
    socket.on("chat message", (msg) => {
        console.log("message: " + msg);

        // 受け取ったメッセージを全員に配信(いわゆるエコーバック/ブロードキャスト)
        io.emit("chat message", {
            user: username,
            msg: msg,
            time: new Date().toISOString(),
        });
    });

    // 切断時イベント
    // 接続が切れたときに発火
    socket.on("disconnect", (reason) => {
        console.log(`A user disconnected. (reason=${reason})`);
        io.emit("chat message", {
            user: "System",
            msg: `${username} left the chat`,
            time: new Date().toISOString(),
        });
    });
});

const PORT = 8080;
server.listen(PORT, () => {
    console.log(`listening on http://localhost:${PORT}`);
});

以下のコードを見ていただきたい。Express機能を利用するためのexpressモジュールや、HTTPサーバを立てるためのhttpモジュール、OS間の差異を吸収しつつパス操作を補助する機能を持つ、pathモジュールをインポートしている。モジュールをインポートする際は、require()というメソッドを使用する。頻繁に使用することになるので、使っているうちに自然と体がrequireするようになるだろう。なお、httpやpathはNode.jsに標準で搭載されているため、ExpressやSocket.IOのように、あらかじめnpm installでインストールしておく必要はない。

// Node.jsのCommonJSでモジュールを読み込む
// 返り値はモジュールのエクスポート
const express = require("express");

// Node標準のHTTPモジュール(HTTPサーバ生成に使う)
const http = require("http");

// パス操作のユーティリティ(OS差異を吸収)。joinメソッドを使用するためにインポート
const path = require("path");

つづいて、express()でExpressアプリケーションのインスタンス(=app)が作られる。このappオブジェクトは「クライアントからリクエストを受け取ったらどう処理するか」を決めるためのハンドラとして機能する。ハンドラというのは直訳すると「取り扱う人・処理するもの」という意味がある。プログラミングでは「特定のイベントやリクエストが発生した時に、それに応じて実行される関数・メソッド」のことを指す。「ハンドラ=メソッド、関数」という理解でおおむね良いだろう。例えば、app.get(“/”, (req, res) => res.send(“message from server”));のように、クライアントからGET /リクエストが送られた際に、その応答として、(req, res) => res.send(“message from server”)というカスタムハンドラを定義・登録しておくことで、クライアントに独自のメッセージを返すことができるのである。この例ではラムダを使っているが、functionで定義したメソッドを指定する形でもよい。

// Expressアプリ本体を生成
const app = express()

次にNode.js標準モジュールのhttpを使ってHTTPサーバを作成している。createServer(app)とすることで、HTTPリクエストを受けたときにExpressアプリ(app)がハンドラとして動作するようになる。この場合、serverはExpressを経由したWebサーバのように振る舞うようになる。

// HTTPサーバを作成。Expressアプリ(=app)をリクエストハンドラとして渡す
const server = http.createServer(app);

次にsocket.ioをインポート(=require)し、先ほど作ったserverに紐づけている。先述した通りsocket.ioはサーバとクライアント間の双方向通信を実現するモジュールであり、サーバに紐づけることで、そのサーバにリアルタイム通信の機能をくっつけてくれる。今後は、左辺に出力される io を使ってクライアントとやり取りすることができる。

// Socket.IOサーバをHTTPサーバに紐づけて生成
const io = require("socket.io")(server);

次にクライアント側のHTMLファイル(index.html)を外部に公開するコードを示す。先ほどpublicというフォルダを作成したが、この中に入れた静的ファイル(HTML、CSS、JS、画像など)を公開するという処理(express.static())を行なっている。なので、クライアント側に公開したいWebページがあれば、このフォルダにどんどん格納していけばよい。今回はindex.htmlファイルのみを当該フォルダに格納するため、わざわざフォルダ分けをするメリットはないが、このようにサーバ側のスクリプト(server.js)と、外部に公開するファイルを分けておくと管理がしやすくなるため、紹介しておく。

また、__dirnameはNode.jsが自動で提供してくれるグローバル変数の一つで、今実行しているファイルがあるフォルダの絶対パスを表す。ここでは、server.jsを起動するコマンド(node server.jsなど)を実行した時のパスになる。本記事の例だと、path.join(__dirname, “public”)は”project”の絶対パスと”public”フォルダを連結したパスとなる(~/project/public/など)。もちろん__dirname + “/public”というように連結してしまってもよいが、joinを使うとOS毎のパス仕様の差異を吸収してくれるので、あえて+で連結する必要はないように思う。

// __dirnameは現在ファイルの絶対パス
// path.join()で連結したパスを返す(OS間の差異を吸収)
// express.static()で指定したフォルダ内のファイルを自動的に公開できる
// ここではpublicフォルダの中をそのまま公開してもいいよという宣言
// HTML/CSS/JS/画像など、ユーザのブラウザに直接送るファイルを置く場所
app.use(express.static(path.join(__dirname, "public")));

前置き処理の解説が長くなったが、ここでようやくメインの処理が登場する。io.on(“connection”)はクライアントからサーバへの接続確率時に一回だけ呼ばれるイベントを登録する。一度だけ定義しておけば、接続が発生した時にコールバック関数((socket) => {}の部分)が呼ばれるようになる。ここはfunctionで定義した関数を書いてもいいし、本記事の記載のようにラムダ関数で書いてよい。余談だが、私は学生時代にC/C++からプログラミングの勉強に入ったため、低級言語をメインで書いているプログラマーはラムダの記法に馴染みがないかもしれない。一見すると取っ付きにいく印象を受けるが、この特殊な記法は書きながら慣れるしかない(と思う)。

// クライアントからの接続確立時に一回よばれるイベントを登録
// サーバ側で新しい接続(クライアントがサーバに接続したとき)を監視するイベントリスナー
// 一度だけ定義しておけば、接続が発生したときにコールバック関数が呼ばれる
// ここでsocketが渡される。これは接続してきたクライアントとの専用通信チャンネルを表す
io.on("connection", (socket) => {
  // ここにサーバ側の処理を色々書いていく
});

io.on(“connection”, (socket) => { // この中の処理を書いていこう。 })
今回はサーバへ接続した際にユーザ名を入力してもらい、そのユーザ名でチャットを行うという仕様にしたのは先述の通り。ユーザ名を入力せず名無しで参加することも一応できるようにするが(空欄の場合はエラーにするなど、バリデーションする方法も考えられる)、その場合のデフォルト名を乱数で生成している。具体的にここでは「User_431」などの名前をつけるようにした。ユーザ名を決めずに空欄で参加してくるような不届き者?には乱数名が与えられるという運用だ。

// デフォルトのユーザ名を乱数で生成([0, 1)の浮動小数を1000倍し、小数点切り捨て)
let username = "User_" + Math.floor(Math.random() * 1000);
// コンソールにユーザ名を表示
console.log(`${username} joined!`);

ユーザ名を登録する際に呼ばれるイベントはsocket.on(“set username”, (name) => {});で登録している。ユーザ名を設定してチャットルームにアクセスしたら、「〇〇が参加しました」という主旨のメッセージをチャット部屋に参加している全員に表示したい。そのため、set usernameイベントが発火した際に、自分以外のすべてのクライアントにデータを送信するio.emit()メソッドを呼んでいる。なお、今回はクライアント全員に向けてメッセージデータを送信(ブロードキャスト)しているが、特定のグループ名を指定することで任意のグループにだけメッセージを送るということもできるため、グループチャット機能を実装する際は役にたつかもしれない。詳細については本記事の趣旨から外れるので、今回は解説を避ける。

// ユーザ名の設定イベント
// 特定のクライアント(=socket)から"set username"イベント受信した際のハンドラを登録
socket.on("set username", (name) => {
    // クライアントからユーザ名が送られてこなかった場合、デフォルト名をusernameとする
    username = name || username;

    // 接続中の全クライアントへブロードキャスト送信
    io.emit("chat message", {
        // System(=非ユーザ)からのメッセージ 
        user: "System",
        msg: `${username} joined the chat`,
        // "YYYY-MM-DDTHH:mm:ss.sssZ" 形式の文字列
        time: new Date().toISOString(),
    });
});

サーバ側の処理完成までもうすぐである。ここまで読んでいただいた読者に感謝すると共に、もう少しお付き合いいただきたい。

続けて、クライアントからメッセージを受信した場合のイベントハンドラをchat messageという名称で定義する。引数として受け取っているmsgには、クライアントから受け取ったメッセージ内容が格納されている。ここでサーバ側で実施する必要があるのは、Aというクライアントから受け取ったmsgを中継し、A以外のクライアント全員にブロードキャストすることである。そのため、io.emit()メソッドで、username(誰が)、msg(何を)、time(いつ)という情報を送信している。

なお余談であるが(読み飛ばしていただいて構わない)、timeにはサーバが全クライアントへデータ送信する直前の時刻(new Date().toISOString()の部分)が格納されている。つまり、Aさんが「送信」ボタンをポチッと押したタイミングではなく、Aさんからデータを受け取ったサーバが、その後io.emit()を実行する際の時刻を記載したメッセージを他クライアントに送ることになる。そのため、Aさんによる実際のメッセージ送信タイミングからは、少し遅れた時刻が打刻されることになる。それにもかかわらず、このような方式をとっているのは、クライアント側のコード(index.html)は改ざんされうる、というセキュリティ上の問題を防ぐためである。また、意図せずクライアント側の端末の時刻が不正確ということも考えうるため、サーバ時刻を基準として打刻するためにこのような処理となっている。

// メッセージ受信イベント
// クライアントからのチャット本文を受信
socket.on("chat message", (msg) => {
    console.log("message: " + msg);

    // 受け取ったメッセージを全員に配信(いわゆるエコーバック/ブロードキャスト)
    io.emit("chat message", {
        user: username,
        msg: msg,
        time: new Date().toISOString(),
    });
});

以下は必須ではないが、クライアントからの接続が切れた場合のイベントの登録も紹介しておこう。socket.on(“disconnect”)でクライアントが切断した際のイベントハンドラを定義することができる。disconnectはSocket.IOが事前に用意してくれているイベント名で、引数としては切断理由を(ここではreasonとしている)を受け取ることができる。reasonはネットワーク接続の不安定によるpingパケットのタイムアウトなど、その時の事情に応じたものが渡される。細かくエラーハンドリングしたい場合(タイムアウトの場合は接続をリトライする等)は、活用できるかもしれない。今回の例では、単に「〇〇が退室しました」という主旨のメッセージを他クライアントに知らせる処理(io.emit(“chat message”))のみに留めておく。

// 切断時イベント(接続が切れたときに発火)
socket.on("disconnect", (reason) => {
    console.log(`A user disconnected. (reason=${reason})`);
    io.emit("chat message", {
        user: "System",
        msg: `${username} left the chat`,
        time: new Date().toISOString(),
    });
});

ここまでの処理でようやくサーバ側の準備は完了となる。あとは、サーバを起動する処理を書いてあげよう。今回はポート番号8080でクライアントからの接続を待ち受けるローカルサーバを起動する。起動が完了したら “listening on http://localhost:8080” というメッセージがコンソールに出力される。

const PORT = 8080;
server.listen(PORT, () => {
    console.log(`listening on http://localhost:${PORT}`);
});

ローカルサーバの起動(チャットアプリの起動)

冒頭で説明した通り、package.jsonのscriptsに記載が完了していれば、以下のコマンドを実行して起動しよう。ブラウザで “http://localhost:8080” にアクセスしてみよう。チャットの送信フォームだけが表示されたシンプルな画面が起動するはずである。ブラウザで別タブを作成するなどで複数クライアントを準備することができるので、チャットを送信してみて動作を確認してみよう。

$ npm run project

コメント

タイトルとURLをコピーしました