原文: How to Build a Real-time Chat App with React, Node, Socket.io, and HarperDB

この記事では、Socket.io と HarperDB を使用して、チャットルームを備えたフルスタックのリアルタイム・チャットアプリケーションを作成します。

このプロジェクトを通して、フルスタックアプリケーションの作り方や、バックエンドがフロントエンドとリアルタイムで通信できるウェブアプリの作り方を学ぶことができます。

通常、HTTP リクエストを使用する場合、サーバーはリアルタイムでデータをクライアントにプッシュすることはできません。しかし、Socket.io を使用することで、サーバーはサーバー上で発生したいくつかのイベントに関するリアルタイム情報をクライアントにプッシュすることができるようになります。

これから作成するウェブアプリには 2 つのページがあります。

チャットルームに参加するページ。

home-page

そしてチャットルームのページ。

chat-page

このウェブアプリ作成に使用するものは次のとおりです。

  • フロントエンド: React (インタラクティブなアプリケーションを構築するためのフロントエンド JavaScript のフレームワークです)
  • バックエンド: NodeExpress (Express は、API とバックエンドを簡単に作成できる非常に人気のある NodeJS のフレームワークです)
  • データベース: HarperDB (SQL または NoSQL を使用してデータをクエリできるデータ + アプリケーションのプラットフォーム。HarperDB には API も組み込まれているため、大量にバックエンドのコードを書く必要がなくなります)
  • リアルタイム通信: Socket.io (以下を参照してください!)

ソースコードはここをご覧ください。(星を付けるのをお忘れなく⭐)

目次

  1. Socket.io とは
  2. プロジェクトのセットアップ
  3. 「チャットルームに参加する」ページ作成方法
  4. サーバーの設定方法
  5. サーバー上で最初の Socket.io イベントリスナーを作成する方法
  6. Socket.io のルームの仕組み
  7. チャットページを作成する方法
  8. メッセージコンポーネント (B) を作成する方法
  9. HarperDB でスキーマとテーブルを作成する方法
  10. メッセージ送信コンポーネント (C) の作成方法
  11. HarperDB の環境変数を設定する方法
  12. Socket.io を利用してユーザーが相互にメッセージを送信できるようにする方法
  13. HarperDB からメッセージを取得する方法
  14. クライアントで最新メッセージ 100 件を表示する方法
  15. チャットルームとユーザー (A) の表示方法
  16. Socket.io のルームからユーザーを削除する方法
  17. Socket.io 接続を切断するイベントリスナーを追加する方法

Socket.io とは

Socket.IO を使用することで、サーバー上でイベントが発生したときに、サーバーはリアルタイムで情報をクライアントにプッシュすることができます。

たとえば、マルチプレイゲームをしているとします。「友達」があなたに対してものすごいゴールを決めてしまうというイベントが発生するかもしれません。

Socket.IO を使用することで、失点について (ほぼ) 瞬時に知ることができるのです。

Socket.IO を使用しない場合では、クライアントはサーバーでイベントが発生したかどうかを確認するために複数のポーリング AJAX 呼び出しを行う必要があります。例えば、クライアントは JavaScript を使用して、サーバー上のイベントを 5 秒ごとに確認することも可能でしょう。

Socket.IO を使用することで、クライアントはサーバー上でイベントが発生したかどうかを確認するために複数のポーリング AJAX 呼び出しを行う必要がなくなります。代わりに、サーバーは情報を取得したら直ちにそれをクライアントに送信します。はるかに効率的ですね。👌

そのため、Socket.IO を使用すると、チャットアプリやマルチプレイヤーゲームなどのリアルタイムアプリケーションを簡単に構築することができます。

プロジェクトのセットアップ

1. フォルダーの設定方法

お好みのテキストエディター (私の場合は VS Code) で新しいプロジェクトを立ち上げ、ルートフォルダーにクライアント (client) とサーバー (server) という 2 つのフォルダーを作成します。

folder-structure

フロントエンド React アプリケーションをクライアントフォルダーに、Node/Express バックエンドをサーバーフォルダーに作成していきます。

2. クライアントの依存関係をインストールする方法

プロジェクトのルートディレクトリでターミナルを開きます。(VS Code では、Ctrl+' を押すか、ターミナル -> 新しいターミナルを選択してください。)

次に、React をクライアントディレクトリにインストールします。

$ npx create-react-app client

React がインストールされたら、ディレクトリをクライアントフォルダーに変更し、次の依存関係をインストールします。

$ cd client
$ npm i react-router-dom socket.io-client

React-router-dom を使用することで、さまざまな React コンポーネントへのルートを設定でき、それによって色々なページを作成することができます。

Socket.io-client は、socket.io のクライアント版であり、イベントをサーバーに「送信」できます。サーバーが受信すると、socket.io のサーバー版を使用して、送信者と同じルーム内のユーザーにメッセージを送信したり、ユーザーをソケットルームに参加させたりすることができます。

後で実際にコードを使って実践してみると、それが何を意味するのか、もっとわかるかと思います。

3. React アプリの起動方法

クライアントディレクトリから次のコマンドを実行して、すべてが順調に稼働していることを確認してみましょう。

$ npm start

Webpack は React アプリをビルドし、http://localhost:3000 で起動します。

react-is-running

次に、ユーザーが送信したメッセージを永続的に保存するために使用する HarperDB データベースを設定しましょう。

HarperDB のセットアップ方法

まず、HarperDB でアカウントを作成します。

次に、新しい HarperDB クラウドインスタンスを作成します。

harper_instance

クラウドインスタンスを選択すると、設定が楽になるのでおすすめです。

instance-type

クラウドプロバイダーを選択します (私は AWS を選択しました)。

cloud_provider

クラウドインスタンスに名前を付け、インスタンスの認証情報を作成します。

instance_credentials

HarperDB には、有難いことに充分な無料枠があるため、このプロジェクトにはそれを選択します。

instance_specs

全ての詳細が正しいことを確認してから、インスタンスを作成します。

インスタンスの立ち上げには数分かかるので、早速最初の React コンポーネントを作成してみましょう!

instance_loading

「チャットルームに参加する」ページ作成方法

私たちのホームページは最終的には次のようになる予定です。

home-page-1

ユーザーはユーザー名を入力し、ドロップダウンからチャットルームを選択して、Join Room (ルームに参加) をクリックします。すると、ユーザーはチャットルームのページへ移動します。

ということで、早速このホームページを作ってみましょう。

1. HTML フォームの作成とスタイルの追加方法

新しいファイル、src/pages/home/index.js を作成します。

CSS モジュールを使用してアプリに基本的なスタイルを追加するため、新しいファイル src/pages/home/styles.module.css を作成します。

ディレクトリの構成は次のようになります。

pages-folder-structure

次に、基本的な HTML のフォームを作成しましょう。

// client/src/pages/home/index.js

import styles from './styles.module.css';

const Home = () => {
  return (
    <div className={styles.container}>
      <div className={styles.formContainer}>
        <h1>{`<>DevRooms</>`}</h1>
        <input className={styles.input} placeholder='Username...' />

        <select className={styles.input}>
          <option>-- Select Room --</option>
          <option value='javascript'>JavaScript</option>
          <option value='node'>Node</option>
          <option value='express'>Express</option>
          <option value='react'>React</option>
        </select>

        <button className='btn btn-secondary'>Join Room</button>
      </div>
    </div>
  );
};

export default Home;

上では、ユーザー名を取得するための単純なテキスト入力と、ユーザーが参加するチャットルームを選択するためのいくつかのデフォルトオプションを含む select ドロップダウンがあります。

このコンポーネントを App.js にインポートし、react-router-dom パッケージを使用してコンポーネントのルートを設定します。これはホームページとなるため、パスは単に「/」になります。

// client/src/App.js

import './App.css';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Home from './pages/home';

function App() {
  return (
    <Router>
      <div className='App'>
        <Routes>
          <Route path='/' element={<Home />} />
        </Routes>
      </div>
    </Router>
  );
}

export default App;

次に、アプリの見栄えを良くするためにいくつか基本のスタイルを追加していきます。

/* client/src/App.css */

html * {
  font-family: Arial;
  box-sizing: border-box;
}
body {
  margin: 0;
  padding: 0;
  overflow: hidden;
  background: rgb(63, 73, 204);
}
::-webkit-scrollbar {
  width: 20px;
}
::-webkit-scrollbar-track {
  background-color: transparent;
}
::-webkit-scrollbar-thumb {
  background-color: #d6dee1;
  border-radius: 20px;
  border: 6px solid transparent;
  background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
  background-color: #a8bbbf;
}
.btn {
  padding: 14px 14px;
  border-radius: 6px;
  font-weight: bold;
  font-size: 1.1rem;
  cursor: pointer;
  border: none;
}
.btn-outline {
  color: rgb(153, 217, 234);
  border: 1px solid rgb(153, 217, 234);
  background: rgb(63, 73, 204);
}
.btn-primary {
  background: rgb(153, 217, 234);
  color: rgb(0, 24, 111);
}
.btn-secondary {
  background: rgb(0, 24, 111);
  color: #fff;
}

ホームページコンポーネントに固有のスタイルも追加していきます。

/* client/src/pages/home/styles.module.css */

.container {
  height: 100vh;
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  background: rgb(63, 73, 204);
}
.formContainer {
  width: 400px;
  margin: 0 auto 0 auto;
  padding: 32px;
  background: lightblue;
  border-radius: 6px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 28px;
}
.input {
  width: 100%;
  padding: 12px;
  border-radius: 6px;
  border: 1px solid rgb(63, 73, 204);
  font-size: 0.9rem;
}
.input option {
  margin-top: 20px;
}

また、スタイル属性を追加して、「ルームに参加」ボタンを全幅にしてみましょう。

// client/src/pages/home/index.js

<button className='btn btn-secondary' style={{ width: '100%' }}>Join Room</button>

なかなか良くなってきましたね。

home-page-html

2. ルーム参加フォームに機能を追加する方法

基本的なフォームとスタイルができたので、次に機能を追加して行きたいと思います。

ユーザーが「ルームに参加」ボタンをクリックしたときに起きて欲しい事項を、以下にまとめました。

  1. ユーザー名と部屋のフィールドが入力されていることを確認する。
  2. 確認できたら、ソケットイベントをサーバーに送信する。
  3. ユーザーをチャットページ (後で作成予定) にリダイレクトする。

usernameroom の値を保存する state の作成が必要です。また、ソケットインスタンスの作成も必要です。

これらの state をホームコンポーネント内で直接作成することもできますが、チャットページもユーザー名、ルーム、ソケットにアクセスする必要があります。そこで、state を App.js に引き上げ、そこでこれらの変数をホームページコンポーネントとチャットページコンポーネントの両方に渡します。

なのでまず、App.js 内で state の作成とソケットの設定をし、これらの変数を props として <Home /> コンポーネントに渡しましょう。<Home /> から state の値を更新できるように、state の set 関数も渡します。

// client/src/App.js

import './App.css';
import { useState } from 'react'; // Add this
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import io from 'socket.io-client'; // Add this
import Home from './pages/home';

const socket = io.connect('http://localhost:4000'); // Add this -- our server will run on port 4000, so we connect to it from here

function App() {
  const [username, setUsername] = useState(''); // Add this
  const [room, setRoom] = useState(''); // Add this

  return (
    <Router>
      <div className='App'>
        <Routes>
          <Route
            path='/'
            element={
              <Home
                username={username} // Add this
                setUsername={setUsername} // Add this
                room={room} // Add this
                setRoom={setRoom} // Add this
                socket={socket} // Add this
              />
            }
          />
        </Routes>
      </div>
    </Router>
  );
}

export default App;

これで、Home コンポーネント内でもこれらの props にアクセスできるようになりました。以下のように、分割代入 (デストラクチャリング) を使って、props にアクセスします。

// client/src/pages/home/index.js

import styles from './style.module.css';

const Home = ({ username, setUsername, room, setRoom, socket }) => {
  return (
    // ...
  );
};

export default Home;

ユーザーがユーザー名を入力するか、チャットルームを選択したとき、ユーザー名とルームの state を更新する必要があります。

// client/src/pages/home/index.js

// ...

const Home = ({ username, setUsername, room, setRoom, socket }) => {
  return (
    <div className={styles.container}>
      // ...
        <input
          className={styles.input}
          placeholder='Username...'
          onChange={(e) => setUsername(e.target.value)} // Add this
        />

        <select
          className={styles.input}
          onChange={(e) => setRoom(e.target.value)} // Add this
        >
         // ...
        </select>

        // ...
    </div>
  );
};

export default Home;

ユーザーが入力したデータを取得しているので、ユーザーが「ルームに参加」ボタンをクリックしたときのコールバック関数、joinRoom() を作成できます。

// client/src/pages/home/index.js

// ...

const Home = ({ username, setUsername, room, setRoom, socket }) => {
  
  // Add this
  const joinRoom = () => {
    if (room !== '' && username !== '') {
      socket.emit('join_room', { username, room });
    }
  };

  return (
    <div className={styles.container}>
      // ...
      
        <button
          className='btn btn-secondary'
          style={{ width: '100%' }}
          onClick={joinRoom} // Add this
        >
          Join Room
        </button>
      // ...
    </div>
  );
};

export default Home;

上では、ユーザーがボタンをクリックすると、ユーザーのユーザーネームと選択したルームを含むオブジェクトとともに、join_room というソケットイベントが発行されます。このイベントはその後サーバーに聞き取りされます。

ホームページコンポーネントを完成するには、ユーザーを /chat ページに誘導するリダイレクトロジックを joinRoom() 関数の最後に追加する必要があります。

// client/src/pages/home/index.js

// ...
import { useNavigate } from 'react-router-dom'; // Add this

const Home = ({ username, setUsername, room, setRoom, socket }) => {
  const navigate = useNavigate(); // Add this

  const joinRoom = () => {
    if (room !== '' && username !== '') {
      socket.emit('join_room', { username, room });
    }

    // Redirect to /chat
    navigate('/chat', { replace: true }); // Add this
  };

 // ...

テストしてみましょう: ユーザー名を入力してルームを選択し、[ルームに参加] をクリックします。現在は空のページですが、http://localhost:3000/chat というルートに転送されるはずです。

フロントエンドのチャットページを作成する前に、まずは少しサーバーの立ち上げをしてみましょう。

サーバーの設定方法

サーバー側は、イベントリスナーを使ってフロントエンドで発生したソケットイベントの通知を待ちます。現在、React に発生しているイベントは join_room のみなので、まずはこのイベントリスナーを追加します。

ただその前に、サーバーの依存関係をインストールし、サーバーを起動して実行する必要があります。

1. サーバーの依存関係をインストールする方法

新しいターミナルを開き (VS コード: [ターミナル] -> [新しいターミナル])、​​ディレクトリをサーバーフォルダーに変更し、package.json ファイルを初期化し、次の依存関係をインストールします。

$ cd server
$ npm init -y
$ npm i axios cors express socket.io dotenv
  • Axios は、API へのリクエストを簡易化するために一般的に使用されているパッケージです。
  • Cors を使用すると、クライアントは他のオリジンにもリクエストを送ることができます。これは socket.io が適切に作動するために不可欠です。CORS についてまだ聞いたことがない方は、ぜひ「CORS とは」を参照してみてください。
  • Express は、少ないコードでより簡単にバックエンドを作成できる NodeJS のフレームワークです。
  • Socket.io は、標準の HTTP リクエストでは実現不可能な、クライアントとサーバーのリアルタイム通信を可能にするライブラリです。
  • Dotenv は、秘密キーとパスワードを安全に保管し、必要に応じてコードに読み込むことができるようにするモジュールです。

また、nodemon を開発依存関係としてインストールすることで、コードを変更するたびにサーバーを再起動する必要がなく、時間と労力を節約することができます。

$ npm i -D nodemon

2. サーバーを起動する方法

サーバーディレクトリのルートに index.js というフォルダーを作成し、次のコードを追加してサーバーを起動して実行します。

// server/index.js

const express = require('express');
const app = express();
const http = require('http');
const cors = require('cors');

app.use(cors()); // Add cors middleware

const server = http.createServer(app);

server.listen(4000, () => 'Server is running on port 4000');

サーバー上の package.json ファイルを開き、開発で nodemon を使用できるようにするスクリプトを追加します。

{
  ...
  "scripts": {
    "dev": "nodemon index.js"
  },
  ...
}

次に、下記のコマンドでサーバーを起動しましょう。

$ npm run dev

GET リクエストハンドラーを追加することで、サーバーが正しく実行されていることをすぐに確認することができます。

// server/index.js

const express = require('express');
const app = express();
http = require('http');
const cors = require('cors');

app.use(cors()); // Add cors middleware

const server = http.createServer(app);

// Add this
app.get('/', (req, res) => {
  res.send('Hello world');
});

server.listen(4000, () => 'Server is running on port 3000');

http://localhost:4000/ に移動します。

localhost4000

サーバーは問題なく稼働しています。今度は、サーバー側の Socket.io の作業を行います。

サーバー上で最初の Socket.io イベントリスナーを作成する方法

クライアント側から、join_room イベントを送りましたね。これからそのイベント情報をサーバー側で聞き取り、ユーザーをソケットルームに追加していきます。

しかしまず、クライアントが Socket.io のクライアント経由でサーバーに接続したかを確認する必要があります。

// server/index.js

const express = require('express');
const app = express();
http = require('http');
const cors = require('cors');
const { Server } = require('socket.io'); // Add this

app.use(cors()); // Add cors middleware

const server = http.createServer(app); // Add this

// Add this
// Create an io server and allow for CORS from http://localhost:3000 with GET and POST methods
const io = new Server(server, {
  cors: {
    origin: 'http://localhost:3000',
    methods: ['GET', 'POST'],
  },
});

// Add this
// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
  console.log(`User connected ${socket.id}`);

  // We can write our socket event listeners in here...
});

server.listen(4000, () => 'Server is running on port 3000');

これで、クライアントがフロントエンドから接続すると、バックエンドが接続イベントをキャプチャし、その特定のクライアントの一意のソケット ID を使用して User connected とログに記録します。

一度、サーバーがクライアントからの接続イベントをキャプチャしているかどうかをテストしてみましょう。http://localhost:3000/ にある React アプリに移動し、ページを更新してみてください。

サーバー端末コンソールに次のログが表示されているはずです。

user-connected

見事、クライアントが socket.io 経由でサーバーに接続しました。これでクライアントとサーバーがリアルタイムで通信できるようになりました。

Socket.io のルームの仕組み

Socket.io ドキュメントより:

「ルームとは、ソケットが出入り (joinleave) できる任意のチャネルです。クライアントのサブセットにイベントをブロードキャストするために使用できます。」

したがって、ユーザーをルームに参加させると、サーバーはそのルーム内のすべてのユーザーにメッセージを送信できるため、ユーザーはリアルタイムで相互にメッセージを送信できるようになるのです。素晴らしい!

ユーザーを Socket.io ルームに参加させる方法

ユーザーが Socket.io を介して接続したら、クライアントから通知されたイベントを聞き取るために、サーバー上でソケットイベントリスナーを追加することができます。また、サーバー上でイベント情報を発行し、クライアント上で聞き取ることもできます。

早速 join_room イベントの発生を待ち、データ (ユーザー名とルーム) をキャプチャして、ユーザーをソケットルームに追加しましょう。

// server/index.js

// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
  console.log(`User connected ${socket.id}`);

  // Add this
  // Add a user to a room
  socket.on('join_room', (data) => {
    const { username, room } = data; // Data sent from client when join_room event emitted
    socket.join(room); // Join the user to a socket room
  });
});

ルーム内のユーザーにメッセージを送信する方法

まずは、参加したばかりのユーザー以外のルーム内すべてのユーザーにメッセージを送信して、新しいユーザーが参加したことを通知しましょう。

// server/index.js

const CHAT_BOT = 'ChatBot'; // Add this
// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
  console.log(`User connected ${socket.id}`);

  // Add a user to a room
  socket.on('join_room', (data) => {
    const { username, room } = data; // Data sent from client when join_room event emitted
    socket.join(room); // Join the user to a socket room

    // Add this
    let __createdtime__ = Date.now(); // Current timestamp
    // Send message to all users currently in the room, apart from the user that just joined
    socket.to(room).emit('receive_message', {
      message: `${username} has joined the chat room`,
      username: CHAT_BOT,
      __createdtime__,
    });
  });
});

上記では、現在のユーザーが参加したばかりのルーム内にいる全クライアントに、receive_message イベントを発行しています。そしてそのイベントには、メッセージ本文、メッセージを送信したユーザー名、メッセージの送信時刻などのデータが含まれています。

少し後で React アプリケーションにイベントリスナーを追加して、このイベントをキャプチャし、画面にメッセージを出力します。

新しく参加したユーザーにも、ウェルカムメッセージを送信しましょう。

// server/index.js

io.on('connection', (socket) => {
  // ...

    // Add this
    // Send welcome msg to user that just joined chat only
    socket.emit('receive_message', {
      message: `Welcome ${username}`,
      username: CHAT_BOT,
      __createdtime__,
    });
  });
});

ユーザーを Socket.io のルームに追加すると、Socket.io は各ユーザーのソケット ID のみを保存します。しかし今後、ルーム名だけでなく、ルームにいる全員のユーザー名も必要になってきます。そこで、これらのデータをサーバー上の変数に保存しておきましょう。

// server/index.js

// ...

const CHAT_BOT = 'ChatBot';
// Add this
let chatRoom = ''; // E.g. javascript, node,...
let allUsers = []; // All users in current chat room

// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
    // ...
    
    // Add this
    // Save the new user to the room
    chatRoom = room;
    allUsers.push({ id: socket.id, username, room });
    chatRoomUsers = allUsers.filter((user) => user.room === room);
    socket.to(room).emit('chatroom_users', chatRoomUsers);
    socket.emit('chatroom_users', chatRoomUsers);
  });
});

上記では、chatroom_users イベントを介して全ユーザー (chatRoomUsers) の配列もクライアントに送り返しています。それによって、フロントエンドでルーム内のすべてのユーザー名をリストすることができるようになります。

サーバーにもっとコードを追加する前に、フロントエンドに戻ってチャットページを作成しましょう。そうすることによって、receive_message イベントをフロントエンドできちんと受信できて​​いるかどうかをテストすることができます。

チャットページを作成する方法

クライアントフォルダーに、2 つの新しいファイルを作成します。

  1. src/pages/chat/index.js
  2. src/pages/chat/styles.module.css

まずはチャットページとコンポーネントで使用するスタイルをいくつか追加しましょう。

/* client/src/pages/chat/styles.module.css */

.chatContainer {
  max-width: 1100px;
  margin: 0 auto;
  display: grid;
  grid-template-columns: 1fr 4fr;
  gap: 20px;
}

/* Room and users component */
.roomAndUsersColumn {
  border-right: 1px solid #dfdfdf;
}
.roomTitle {
  margin-bottom: 60px;
  text-transform: uppercase;
  font-size: 2rem;
  color: #fff;
}
.usersTitle {
  font-size: 1.2rem;
  color: #fff;
}
.usersList {
  list-style-type: none;
  padding-left: 0;
  margin-bottom: 60px;
  color: rgb(153, 217, 234);
}
.usersList li {
  margin-bottom: 12px;
}

/* Messages */
.messagesColumn {
  height: 85vh;
  overflow: auto;
  padding: 10px 10px 10px 40px;
}
.message {
  background: rgb(0, 24, 111);
  border-radius: 6px;
  margin-bottom: 24px;
  max-width: 600px;
  padding: 12px;
}
.msgMeta {
  color: rgb(153, 217, 234);
  font-size: 0.75rem;
}
.msgText {
  color: #fff;
}

/* Message input and button */
.sendMessageContainer {
  padding: 16px 20px 20px 16px;
}
.messageInput {
  padding: 14px;
  margin-right: 16px;
  width: 60%;
  border-radius: 6px;
  border: 1px solid rgb(153, 217, 234);
  font-size: 0.9rem;
}

追加できたら、チャットページが下のようになるかを見てみましょう。

chat-page

このページのすべてのコードとロジックを 1 つのファイルにまとめてしまうと、読みにくく管理が難しくなってしまうため、せっかく便利なフロントエンドフレームワーク (React) を使用しているので、このページをコンポーネントに分けます

image-248

チャットページのコンポーネント:

A: チャットルーム名、そのチャットルーム内にいるユーザーリスト、およびユーザーをチャットルームから削除する「退出」ボタンが含まれる部分。

B: 送信されたメッセージ。最初のレンダリング時に、そのチャットルームで送信された最新 100 件のメッセージがデータベースから取得され、ユーザーに表示されます。

C: メッセージを入力して送信するための入力フィールドとボタン。

まずはコンポーネント B を作成して、ユーザーにメッセージを表示できるようにします。

メッセージコンポーネント (B) を作成する方法

新しいファイル src/pages/chat/messages.js を作成し、次のコードを追加してください。

// client/src/pages/chat/messages.js

import styles from './styles.module.css';
import { useState, useEffect } from 'react';

const Messages = ({ socket }) => {
  const [messagesRecieved, setMessagesReceived] = useState([]);

  // Runs whenever a socket event is recieved from the server
  useEffect(() => {
    socket.on('receive_message', (data) => {
      console.log(data);
      setMessagesReceived((state) => [
        ...state,
        {
          message: data.message,
          username: data.username,
          __createdtime__: data.__createdtime__,
        },
      ]);
    });

	// Remove event listener on component unmount
    return () => socket.off('receive_message');
  }, [socket]);

  // dd/mm/yyyy, hh:mm:ss
  function formatDateFromTimestamp(timestamp) {
    const date = new Date(timestamp);
    return date.toLocaleString();
  }

  return (
    <div className={styles.messagesColumn}>
      {messagesRecieved.map((msg, i) => (
        <div className={styles.message} key={i}>
          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
            <span className={styles.msgMeta}>{msg.username}</span>
            <span className={styles.msgMeta}>
              {formatDateFromTimestamp(msg.__createdtime__)}
            </span>
          </div>
          <p className={styles.msgText}>{msg.message}</p>
          <br />
        </div>
      ))}
    </div>
  );
};

export default Messages;

上記には、ソケットイベントが受信されるたびに実行される useEffect フックがあります。次に、receive_message というイベントリスナーに渡されたメッセージデータを取得します。そこから、messagesReceived の state を設定します。これは、メッセージ、送信者のユーザー名、メッセージが送信された日付を含むメッセージオブジェクトの配列です。

今作ったこのメッセージコンポーネントをチャットページにインポートし、App.js でチャットページのルートを作成しましょう。

// client/src/pages/chat/index.js

import styles from './styles.module.css';
import MessagesReceived from './messages';

const Chat = ({ socket }) => {
  return (
    <div className={styles.chatContainer}>
      <div>
        <MessagesReceived socket={socket} />
      </div>
    </div>
  );
};

export default Chat;
// client/src/App.js

import './App.css';
import { useState } from 'react';
import Home from './pages/home';
import Chat from './pages/chat';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import io from 'socket.io-client';

const socket = io.connect('http://localhost:4000');

function App() {
  const [username, setUsername] = useState('');
  const [room, setRoom] = useState('');

  return (
    <Router>
      <div className='App'>
        <Routes>
          <Route
            path='/'
            element={
              <Home
                username={username}
                setUsername={setUsername}
                room={room}
                setRoom={setRoom}
                socket={socket}
              />
            }
          />
          {/* Add this */}
          <Route
            path='/chat'
            element={<Chat username={username} room={room} socket={socket} />}
          />
        </Routes>
      </div>
    </Router>
  );
}

export default App;

一度テストしてみましょう。ホームページにアクセスしてルームに参加してみてください。

joining-a-room

チャットページに移動し、ChatBot からウェルカムメッセージを受け取ります。

welcome-message

ユーザーは受信したメッセージを確認できるようになりました。上出来です!

次は、メッセージを永続的に保存できるようにデータベースを設定します。

HarperDB でスキーマとテーブルを作成する方法

HarperDB のダッシュボードに戻り、「browse (参照)」をクリックします。次に、realtime_chat_app という新しいスキーマを作成します。スキーマというのは、単に複数のテーブルをまとめたもののことです。

そのスキーマ内に、ハッシュ属性が id の messages というテーブルを作成します。

image-258

これで、メッセージを保存する場所ができたので、SendMessage コンポーネントを作成しましょう。

メッセージ送信コンポーネント (C) の作成方法

ファイル src/pages/chat/send-message.js を作成し、次のコードを追加してください。

// client/src/pages/chat/send-message.js

import styles from './styles.module.css';
import React, { useState } from 'react';

const SendMessage = ({ socket, username, room }) => {
  const [message, setMessage] = useState('');

  const sendMessage = () => {
    if (message !== '') {
      const __createdtime__ = Date.now();
      // Send message to server. We can't specify who we send the message to from the frontend. We can only send to server. Server can then send message to rest of users in room
      socket.emit('send_message', { username, room, message, __createdtime__ });
      setMessage('');
    }
  };

  return (
    <div className={styles.sendMessageContainer}>
      <input
        className={styles.messageInput}
        placeholder='Message...'
        onChange={(e) => setMessage(e.target.value)}
        value={message}
      />
      <button className='btn btn-primary' onClick={sendMessage}>
        Send Message
      </button>
    </div>
  );
};

export default SendMessage;

上では、ユーザーが「メッセージの送信」というボタンをクリックすると、send_message のソケットイベントがメッセージオブジェクトとともにサーバーに送信されます。後ほどこのイベントをサーバー上で処理します。

SendMessage をチャットページにインポートします。

// src/pages/chat/index.js

import styles from './styles.module.css';
import MessagesReceived from './messages';
import SendMessage from './send-message';

const Chat = ({ username, room, socket }) => {
  return (
    <div className={styles.chatContainer}>
      <div>
        <MessagesReceived socket={socket} />
        <SendMessage socket={socket} username={username} room={room} />
      </div>
    </div>
  );
};

export default Chat;

チャットページは次のようになります。

image-259

次に、データベースとのやり取りを開始できるように、HarperDB の環境変数を設定する必要があります。

HarperDB の環境変数を設定する方法

HarperDB にメッセージを保存できるようにするには、HarperDB インスタンスの URL と API パスワードが必要です。

HarperDB ダッシュボードでインスタンスをクリックし、「config (設定)」に移動します。インスタンス URL とインスタンス API 認証ヘッダー (データベースへのあらゆるリクエストを許可する「super_user」パスワード) が表示されます。絶対に誰とも共有しないでください!

image-263

これらの変数を .env ファイルに保存します。警告: .env ファイルは絶対に GitHub にプッシュしないでください。このファイルは一般に公開すべきものではありません。これらの変数はバックグラウンドでサーバー経由で読み込みされます。

次のファイルを作成し、あなたの HarperDB の URL とパスワードを追加します。

// server/.env

HARPERDB_URL="<your url goes here>"
HARPERDB_PW="Basic <your password here>"

また、.env が node_modules フォルダーとともに GitHub にプッシュされるのを防ぐための .gitignore ファイルも作成します。

// server/.gitignore

.env
node_modules

注意: Git や GitHub をうまく使いこなすことは全ての開発者にとって必須です。もし Git の流儀がまだ曖昧な方は、私の Git のワークフローに関する記事を見てみてください。

または、同じ Git コマンドをいつも検索している方や、コマンドを検索、修正、コピー/ ペーストする簡単な方法をお探しの方はぜひ、私が作った人気 Git コマンドチートシートの PDF 版Git チートシートポスター版をチェックしてみてください。

最後に、次のコードをメインサーバーファイルの先頭に追加して、環境変数をサーバーに読み込みましょう。

// server/index.js

require('dotenv').config();
console.log(process.env.HARPERDB_URL); // remove this after you've confirmed it working
const express = require('express');
// ...

Socket.io を利用してユーザーが相互にメッセージを送信できるようにする方法

サーバー上で send_message のイベントを聞き取り、ルーム内のすべてのユーザーにメッセージを送信します。

// server/index.js

const express = require('express');
// ...
const harperSaveMessage = require('./services/harper-save-message'); // Add this

// ...

// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
    
  // ...

  // Add this
  socket.on('send_message', (data) => {
    const { message, username, room, __createdtime__ } = data;
    io.in(room).emit('receive_message', data); // Send to all users in room, including sender
    harperSaveMessage(message, username, room, __createdtime__) // Save message in db
      .then((response) => console.log(response))
      .catch((err) => console.log(err));
  });
});

server.listen(4000, () => 'Server is running on port 3000');

次に、harperSaveMessage という関数を作成する必要があります。server/services/harper-save-message.js に新しいファイルを作成し、以下のコードを追加します。

// server/services/harper-save-message.js

var axios = require('axios');

function harperSaveMessage(message, username, room) {
  const dbUrl = process.env.HARPERDB_URL;
  const dbPw = process.env.HARPERDB_PW;
  if (!dbUrl || !dbPw) return null;

  var data = JSON.stringify({
    operation: 'insert',
    schema: 'realtime_chat_app',
    table: 'messages',
    records: [
      {
        message,
        username,
        room,
      },
    ],
  });

  var config = {
    method: 'post',
    url: dbUrl,
    headers: {
      'Content-Type': 'application/json',
      Authorization: dbPw,
    },
    data: data,
  };

  return new Promise((resolve, reject) => {
    axios(config)
      .then(function (response) {
        resolve(JSON.stringify(response.data));
      })
      .catch(function (error) {
        reject(error);
      });
  });
}

module.exports = harperSaveMessage;

上記では、データの保存には少し時間がかかる場合があるため、データが正常に保存された場合は解決され、失敗した場合は拒否される Promise を返しています。

上記のコードをどこで入手したか気になりましたか?HarperDB のスタジオダッシュボードには、便利な「コードサンプル」というセクションが用意されており、それを使うことによって作業がはるかに簡単になります。

image-265

テストの時間です!ユーザーとしてルームに参加し、メッセージを送信します。次に、HarperDB に移動し、「browse (参照)」をクリックして、「メッセージ」テーブルをクリックします。データベースにメッセージが表示されているはずです。

image-264

素晴らしい😎これができたら次は、ユーザーがチャットルームに参加したときに、ルーム内で送信された最新 100 件のメッセージが読み込まれるようになれば最高だと思いませんか?

HarperDB からメッセージを取得する方法

サーバー上で、特定のチャットルームで送信された最新 100 件のメッセージを取得する関数を作成しましょう (HarperDB では SQL クエリも使用できるんですよ👌):

// server/services/harper-get-messages.js

let axios = require('axios');

function harperGetMessages(room) {
  const dbUrl = process.env.HARPERDB_URL;
  const dbPw = process.env.HARPERDB_PW;
  if (!dbUrl || !dbPw) return null;

  let data = JSON.stringify({
    operation: 'sql',
    sql: `SELECT * FROM realtime_chat_app.messages WHERE room = '${room}' LIMIT 100`,
  });

  let config = {
    method: 'post',
    url: dbUrl,
    headers: {
      'Content-Type': 'application/json',
      Authorization: dbPw,
    },
    data: data,
  };

  return new Promise((resolve, reject) => {
    axios(config)
      .then(function (response) {
        resolve(JSON.stringify(response.data));
      })
      .catch(function (error) {
        reject(error);
      });
  });
}

module.exports = harperGetMessages;

ユーザーがチャットルームに参加するたびにこの関数を呼び出します。

// server/index.js

// ...
const harperSaveMessage = require('./services/harper-save-message');
const harperGetMessages = require('./services/harper-get-messages'); // Add this

// ...

// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
  console.log(`User connected ${socket.id}`);

  // Add a user to a room
  socket.on('join_room', (data) => {
      
    // ...

    // Add this
    // Get last 100 messages sent in the chat room
    harperGetMessages(room)
      .then((last100Messages) => {
        // console.log('latest messages', last100Messages);
        socket.emit('last_100_messages', last100Messages);
      })
      .catch((err) => console.log(err));
  });

 // ...

上記では、メッセージが正常にフェッチされると、last_100_messages という Socket.io イベントが発行されます。そうすると、フロントエンドでこのイベントを聞き取ります。

クライアントで最新メッセージ 100 件を表示する方法

以下では、last_100_messages イベントのための、Socket.io イベントリスナーを含む useEffect フックを追加します。そこから、メッセージは日付順に並べ替えられ、最新のメッセージが一番下に表示され、messagesReceived の state が更新されます。

messagesReceived が更新されると、useEffect が実行され、messageColumn div が最新のメッセージまでスクロールされます。これにより、アプリのユーザーエクスペリエンスが向上します👍。

// client/src/pages/chat/messages.js

import styles from './styles.module.css';
import { useState, useEffect, useRef } from 'react';

const Messages = ({ socket }) => {
  const [messagesRecieved, setMessagesReceived] = useState([]);

  const messagesColumnRef = useRef(null); // Add this

  // Runs whenever a socket event is recieved from the server
  useEffect(() => {
    socket.on('receive_message', (data) => {
      console.log(data);
      setMessagesReceived((state) => [
        ...state,
        {
          message: data.message,
          username: data.username,
          __createdtime__: data.__createdtime__,
        },
      ]);
    });

    // Remove event listener on component unmount
    return () => socket.off('receive_message');
  }, [socket]);

  // Add this
  useEffect(() => {
    // Last 100 messages sent in the chat room (fetched from the db in backend)
    socket.on('last_100_messages', (last100Messages) => {
      console.log('Last 100 messages:', JSON.parse(last100Messages));
      last100Messages = JSON.parse(last100Messages);
      // Sort these messages by __createdtime__
      last100Messages = sortMessagesByDate(last100Messages);
      setMessagesReceived((state) => [...last100Messages, ...state]);
    });

    return () => socket.off('last_100_messages');
  }, [socket]);

  // Add this
  // Scroll to the most recent message
  useEffect(() => {
    messagesColumnRef.current.scrollTop =
      messagesColumnRef.current.scrollHeight;
  }, [messagesRecieved]);

  // Add this
  function sortMessagesByDate(messages) {
    return messages.sort(
      (a, b) => parseInt(a.__createdtime__) - parseInt(b.__createdtime__)
    );
  }

  // dd/mm/yyyy, hh:mm:ss
  function formatDateFromTimestamp(timestamp) {
    const date = new Date(timestamp);
    return date.toLocaleString();
  }

  return (
    // Add ref to this div
    <div className={styles.messagesColumn} ref={messagesColumnRef}>
      {messagesRecieved.map((msg, i) => (
        <div className={styles.message} key={i}>
          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
            <span className={styles.msgMeta}>{msg.username}</span>
            <span className={styles.msgMeta}>
              {formatDateFromTimestamp(msg.__createdtime__)}
            </span>
          </div>
          <p className={styles.msgText}>{msg.message}</p>
          <br />
        </div>
      ))}
    </div>
  );
};

export default Messages;

チャットルームとユーザー (A) の表示方法

コンポーネント B と C の作成が完了したので、最後に A を作成しましょう。

image-248-1

サーバー上では、誰かユーザーがチャットルームに参加すると、ルーム内全ユーザーをそのルーム内の全クライアントに送る chatroom_users イベントが発行されます。RoomAndUsers というコンポーネントでそのイベントの聞き取りをしてみましょう。

下のコードには、「Leave (退室)」ボタンもあり、これを押すとサーバーに leave_room イベントが発行されます。その後、そのユーザーをホームページへリダイレクトします。

// client/src/pages/chat/room-and-users.js

import styles from './styles.module.css';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';

const RoomAndUsers = ({ socket, username, room }) => {
  const [roomUsers, setRoomUsers] = useState([]);

  const navigate = useNavigate();

  useEffect(() => {
    socket.on('chatroom_users', (data) => {
      console.log(data);
      setRoomUsers(data);
    });

    return () => socket.off('chatroom_users');
  }, [socket]);

  const leaveRoom = () => {
    const __createdtime__ = Date.now();
    socket.emit('leave_room', { username, room, __createdtime__ });
    // Redirect to home page
    navigate('/', { replace: true });
  };

  return (
    <div className={styles.roomAndUsersColumn}>
      <h2 className={styles.roomTitle}>{room}</h2>

      <div>
        {roomUsers.length > 0 && <h5 className={styles.usersTitle}>Users:</h5>}
        <ul className={styles.usersList}>
          {roomUsers.map((user) => (
            <li
              style={{
                fontWeight: `${user.username === username ? 'bold' : 'normal'}`,
              }}
              key={user.id}
            >
              {user.username}
            </li>
          ))}
        </ul>
      </div>

      <button className='btn btn-outline' onClick={leaveRoom}>
        Leave
      </button>
    </div>
  );
};

export default RoomAndUsers;

このコンポーネントをチャットページにインポートしましょう。

// client/src/pages/chat/index.js

import styles from './styles.module.css';
import RoomAndUsersColumn from './room-and-users'; // Add this
import SendMessage from './send-message';
import MessagesReceived from './messages';

const Chat = ({ username, room, socket }) => {
  return (
    <div className={styles.chatContainer}>
      {/* Add this */}
      <RoomAndUsersColumn socket={socket} username={username} room={room} />

      <div>
        <MessagesReceived socket={socket} />
        <SendMessage socket={socket} username={username} room={room} />
      </div>
    </div>
  );
};

export default Chat;

Socket.io のルームからユーザーを削除する方法

Socket.io には、Socket.io ルームからユーザーを削除するために使用できる leave() メソッドが用意されています。また、サーバーメモリー上の配列でユーザー情報を管理しているため、この配列からもユーザーを削除します。

// server/index.js

const leaveRoom = require('./utils/leave-room'); // Add this

// ...

// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
    
  // ...

  // Add this
  socket.on('leave_room', (data) => {
    const { username, room } = data;
    socket.leave(room);
    const __createdtime__ = Date.now();
    // Remove user from memory
    allUsers = leaveRoom(socket.id, allUsers);
    socket.to(room).emit('chatroom_users', allUsers);
    socket.to(room).emit('receive_message', {
      username: CHAT_BOT,
      message: `${username} has left the chat`,
      __createdtime__,
    });
    console.log(`${username} has left the chat`);
  });
});

server.listen(4000, () => 'Server is running on port 3000');

次に、leaveRoom() という関数を作成する必要があります。

// server/utils/leave-room.js

function leaveRoom(userID, chatRoomUsers) {
  return chatRoomUsers.filter((user) => user.id != userID);
}

module.exports = leaveRoom;

なぜこの短い関数を別の utils フォルダーに置いておく必要があるのでしょうか。それは、後で再び使用することになるので、同じことを繰り返さなくてもいいようにです。(コードを DRY に保つことができます。)

さあ、テストしてみましょう。2 つのブラウザーを並べて開き、両方のブラウザーでチャットに参加してみてください。

image-266

次に、ブラウザー 2 の Leave (退室) ボタンをクリックします。

image-267

ユーザーはチャットから削除され、他のユーザーに、退席したことを通知するメッセージが送信されます。ナイス!

Socket.io 接続を切断するイベントリスナーを追加する方法

インターネットの接続が切断された場合など、ユーザーが何らかの理由でサーバーから切断された場合はどうなるのでしょうか?Socket.io にはそのような時のために、ビルトインの切断イベントリスナーがあります。これをサーバーに追加して、接続の切断時にユーザーをメモリから削除します。

// server/index.js

// ...

// Listen for when the client connects via socket.io-client
io.on('connection', (socket) => {
    
  // ...
    
  // Add this
  socket.on('disconnect', () => {
    console.log('User disconnected from the chat');
    const user = allUsers.find((user) => user.id == socket.id);
    if (user?.username) {
      allUsers = leaveRoom(socket.id, allUsers);
      socket.to(chatRoom).emit('chatroom_users', allUsers);
      socket.to(chatRoom).emit('receive_message', {
        message: `${user.username} has disconnected from the chat.`,
      });
    }
  });
});

server.listen(4000, () => 'Server is running on port 3000');

これで、React フロントエンド、Node/Express バックエンド、HarperDB データベースを備えたフルスタックのリアルタイムチャットアプリケーションが構築されました。お見事!

次回は、ユーザーが HarperDB 内で独自の API エンドポイントを定義できるようにする HarperDB のカスタム関数をご紹介する予定です。こうすることで、アプリケーション全体を 1 か所でまとめて構築できます。HarperDB でスタックを縮小 (シンプル化) する例は、この記事でご覧ください。

挑戦状💪

チャットページでページを更新すると、ユーザーのユーザーネームとルームが失われてしまいます。ユーザーがページを更新してもこの情報が失われないようにする方法を探し出してみてください。ヒント: ローカルストレージが役立つかもしれません!

最後まで読んでいただきありがとうございます!

この記事が少しでもお役に立てたのであれば、ぜひ以下もお願いします。