Обычно, используя HTTP-запросы, сервер не передает данные клиенту в режиме реального времени. Но используя Socket.io, сервер сможет передавать клиенту информацию о некоторых событиях, произошедших на сервере, в режиме реального времени.
Наше приложение будет состоять из двух страниц.
Страница входа в чат:
И страница самого чата:
Вот что мы будем использовать для создания этого приложения:
Frontend: React (фронтенд-фреймворк JavaScript для создания интерактивных приложений)
Бэкэнд: Node и Express (Express — очень популярный фреймворк NodeJS, который помогает легко создавать API и бэкенды)
База данных: HarperDB (платформа для работы с данными и приложениями, которая позволяет запрашивать данные с помощью SQL или NoSQL. HarperDB также имеет встроенный API, что избавляет нас от необходимости писать много кода для бэкенда)
Коммуникация в реальном времени: Socket.io (см. ниже).
Вот исходный код (не забудьте поставить ему звезду).
Оглавление
Что такое Socket.IO?
Socket.IO позволяет серверу передавать информацию клиенту в режиме реального времени, когда на сервере происходят события.
Например, если вы играете в многопользовательскую игру, событием может стать то, что ваш «друг» забьет вам эффектный гол.
С Socket.IO вы бы почти мгновенно узнали о пропущенном голе.
Без Socket.IO клиенту, чтобы убедиться, что событие произошло на сервере, пришлось бы делать несколько AJAX запросов. Например, клиент мог бы использовать JavaScript, чтобы проверять наличие события на сервере каждые 5 секунд.
С Socket.IO клиенту не нужно делать несколько AJAX запросов, чтобы проверить, произошло ли какое-то событие на сервере. Вместо этого сервер отправляет информацию клиенту, как только получает ее. Это намного лучше.
Итак, Socket.IO позволяет легко создавать приложения реального времени, такие как чат-приложения и многопользовательские игры.
Настройка проекта
1. Как настроить папки
Начните новый проект в вашем текстовом редакторе (у меня это VS Code) и создайте в корне две папки с названиями client и server.
Фронтенд React-приложения мы создадим в папке client, а бэкенд Node/Express — в папке server.
2. Как установить зависимости для клиента
Откройте терминал в корне проекта (в VS Code это можно сделать, нажав Ctrl+' или перейдя в терминал -> новый терминал).
Далее установим React в клиентскую директорию:
$ npx create-react-app client
После установки React перейдите в папку client и установите следующие зависимости:
$ cd client
$ npm i react-router-dom socket.io-client
React-router-dom позволит настраивать маршруты к различным компонентам React — по сути, создавать различные страницы.
Socket.io-client — это клиентская версия socket.io, которая позволяет «выпускать» события на сервер. Получив их на сервере, мы можем использовать серверную версию socket.io для таких действий, как отправка сообщений пользователям, находящимся в той же комнате, что и отправитель, или присоединение пользователя к socket room.
Вы сможете лучше понять это позже, когда мы начнем реализовывать эти идеи с помощью кода.
3. Как загрузить приложение React
Давайте проверим, все ли работает, путем выполнения следующей команды из каталога клиента:
$ npm start
Webpack соберет приложение React и отправит его на http://localhost:3000:
Теперь давайте настроим базу данных HarperDB — ее мы будем использовать для постоянного сохранения сообщений, отправленных пользователями.
Как настроить HarperDB
Сначала создайте учетную запись в HarperDB.
Затем создайте новый облачный экземпляр HarperDB:
Чтобы упростить работу, выберите экземпляр облака:
Выберите облачного провайдера (я выбрал AWS):
Задайте имя облачному экземпляру и создайте учетные данные экземпляра:
У HarperDB щедрый бесплатный уровень, который мы можем использовать для этого проекта, поэтому выбираем его:
Проверяем правильность данных и создаем экземпляр.
Создание экземпляра займет несколько минут, так что давайте приступим к созданию нашего первого компонента React.
Как создать страницу “Join a Room”
Главная страница будет выглядеть следующим образом:
Пользователь вводит имя пользователя, выбирает комнату чата из выпадающего списка и нажимает кнопку "Join Room". После этого пользователь попадает на страницу чата.
Итак, давайте сделаем эту домашнюю страницу.
1. Как создать HTML-форму и добавить стили
Создайте новый файл по адресу src/pages/home/index.js.
Мы добавим базовые стили в приложение с помощью модулей CSS, поэтому создайте новый файл: src/pages/home/styles.module.css.
Теперь структура папок должна выглядеть следующим образом:
Создадим базовую форму 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;
Выше у нас есть простой текстовый ввод для ввода имени пользователя и выпадающий список с некоторыми стандартными опциями для выбора чата, к которому пользователь может присоединиться.
Теперь импортируем этот компонент в 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;
}
Давайте также сделаем кнопку "Join Room" полноразмерной, добавив атрибут стиля:
// client/src/pages/home/index.js
<button className='btn btn-secondary' style={{ width: '100%' }}>Join Room</button>
Теперь главная страница выглядит хорошо:
2. Как добавить функциональность в форму Join Room
У нас есть базовая форма и стилизация, теперь пришло время добавить немного функциональности.
Вот что мы хотим, чтобы происходило, когда пользователь нажимает кнопку "Join Room":
Проверяем, заполнены ли поля с именем пользователя и комнатой.
Если да, то отправляем событие сокета на сервер.
Перенаправляем пользователя на страницу чата (которую мы создадим позже).
Нам понадобится создать состояние для хранения значений username и room. Также нам нужно создать экземпляр сокета.
Можно создать эти состояния непосредственно в компоненте home, но странице Chat также понадобится доступ к username, room и socket. Поэтому мы поднимем состояние в App.js, где затем сможем передать эти переменные компонентам домашней страницы и страницы чата.
Итак, давайте создадим состояние, настроим сокет в App.js и передадим эти переменные в качестве реквизитов компоненту <Home />. Мы также передадим функции set state
, чтобы можно было изменять состояние из <Home />:
// 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. Для этого используем деструктуризацию:
// client/src/pages/home/index.js
import styles from './style.module.css';
const Home = ({ username, setUsername, room, setRoom, socket }) => {
return (
// ...
);
};
export default Home;
Когда пользователь набирает свое имя пользователя или выбирает комнату, нужно обновить переменные состояния username и room:
// 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()
для случая, когда пользователь нажимает кнопку Join Room:
// 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
, а также объект, содержащий имя пользователя и выбранную комнату. Это событие будет получено сервером чуть позже, где мы совершим несколько волшебных действий.
Чтобы завершить работу над компонентом домашней страницы, нам нужно добавить редирект в нижней части функции joinRoom()
, чтобы перенести пользователя на страницу /chat:
// 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
};
// ...
Проверьте: введите имя пользователя и выберите комнату, затем нажмите Join Room. Вы должны попасть на маршрут http://localhost:3000/chat — пока это пустая страница.
Но прежде чем создать фронтенд Chat Page, давайте запустим кое-что на сервере.
Как настроить сервер
На сервере мы собираемся прослушивать события сокетов, исходящие от фронтенда. В настоящее время от React исходит только событие join_room, поэтому сначала мы добавим этот слушатель события.
Но перед этим нужно установить серверные зависимости и запустить сервер.
1. Как установить серверные зависимости
Откройте новый терминал (в коде VS: Terminal->New Terminal), перейдите в папку с сервером, инициализируйте файл 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 в качестве dev-зависимости, чтобы не перезапускать сервер каждый раз при внесении изменения в код — это сэкономит нам время и энергию:
$ 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/:
Наш сервер запущен и работает. Настало время сделать кое-что для Socket.io на стороне сервера!
Как создать первый слушатель событий Socket.io на сервере
Помните, как мы отправляли событие join_room
с клиента? Так вот, скоро мы будем слушать это событие на сервере и добавлять пользователя в сокет-комнату.
Но сначала нам нужно прослушать, когда клиент подключается к серверу через socket.io-client.
Теперь, когда клиент подключается с фронтенда, бэкенд перехватывает событие подключения и записывает его в журнал.
Пользователь подключился с помощью уникального идентификатора сокета для данного клиента.
// 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');
Давайте проверим, перехватывает ли теперь сервер событие подключения от клиента. Перейдите в приложение React по адресу http://localhost:3000/ и обновите страницу.
В консоли терминала сервера вы должны увидеть следующую запись:
Отлично, наш клиент подключился к серверу через socket.io. Теперь клиент и сервер могут общаться в режиме реального времени!
Как работают комнаты в Socket.io
room — это произвольный канал, к которому сокеты могут присоединяться (
join
) и покидать (leave
) его. Он может быть использован для трансляции событий подмножеству клиентов.
Итак, мы можем добавить пользователя в комнату, а затем сервер может отправлять сообщения всем пользователям в этой комнате — это позволит пользователям отправлять сообщения друг другу в режиме реального времени. Круто!
Как добавить пользователя в комнату Socket.io
После того как пользователь подключился через Socket.io, мы можем добавить слушатели событий сокета на сервер, чтобы слушать события, исходящие от клиента. Также можно отправлять события на сервер и слушать их на клиенте.
Давайте теперь прослушаем событие join_room
, перехватим данные (username и room) и добавим пользователя в socket 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 хранит только идентификаторы сокетов для каждого пользователя. Но нам понадобятся имена пользователей всех, кто находится в комнате, а также название комнаты. Поэтому давайте сохраним эти данные в переменных на сервере:
// 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);
});
});
Выше мы также отправляем массив всех пользователей chatRoomUsers обратно клиенту через событие chatroom_users
, чтобы можно было перечислить все имена пользователей в комнате на фронтенде.
Прежде чем добавить код на сервер, давайте вернемся на фронтенд и создадим страницу чата — чтобы проверить, получаем ли мы события receive_message
.
Как создать страницу чата
В папке клиента создайте два новых файла:
src/pages/chat/index.js
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;
}
Теперь посмотрим, как будет выглядеть страница чата:
Если собрать весь код и логику этой страницы в одном файле, это может запутать и усложнить управление, поэтому воспользуемся тем, что мы используем React, и разделим страницу на компоненты:
Компоненты страницы чата:
A: Содержит название комнаты, список пользователей в этой комнате и кнопку "Leave", которая удаляет пользователя из комнаты.
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
, которое представляет собой массив объектов сообщений — они содержат сообщение, имя пользователя отправителя и дату отправки сообщения.
Импортируем новый компонент messages на страницу Chat, а затем создадим маршрут для страницы Chat в 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;
Давайте проверим. Зайдите на главную страницу и войдите в комнату:
Мы должны попасть на страницу чата и получить приветственное сообщение от ChatBot:
Теперь пользователи могут видеть полученные сообщения. Супер!
Далее: настройка базы данных для непрерывного сохранения сообщений.
Как создать схему и таблицу в HarperDB
Вернитесь на панель HarperDB и нажмите на "browse". Затем создайте новую схему под названием "realtime_chat_app". Схема — это просто группа таблиц.
В рамках этой схемы создайте таблицу под названием "messages" с хэш-атрибутом "id".
Теперь у нас есть место для хранения сообщений, поэтому давайте создадим компонент SendMessage.
Как создать компонент Send Message (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", на сервер передается событие сокета 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;
Теперь страница чата выглядит так:
Далее нужно настроить переменные окружения HarperDB, чтобы можно было начать взаимодействовать с базой данных.
Как настроить переменные окружения HarperDB
Для того чтобы можно было сохранять сообщения в HarperDB, вам понадобится URL-адрес экземпляра HarperDB и пароль API.
На панели управления HarperDB нажмите на свой экземпляр, затем перейдите в раздел "config". Вы найдете URL-адрес вашего экземпляра и заголовок API Auth — то есть ваш пароль "super_user", который позволит делать любые запросы к базе данных, которые будете видеть только вы.
Хранить эти переменные будем в файле .env
. Внимание: не добавляйте файл .env
на GitHub! Этот файл не должен быть виден публично. Переменные загружаются через сервер за кулисами.
Создайте следующие файлы и добавьте в них URL-адрес и пароль HarperDB:
// server/.env
HARPERDB_URL="<your url goes here>"
HARPERDB_PW="Basic <your password here>"
Мы также создадим файл .gitignore, чтобы предотвратить публикацию .env на GitHub, а также папку node_modules:
// server/.gitignore
.env
node_modules
Примечание: умение работать с Git и GitHub — это 100% обязательное условие для всех разработчиков. Ознакомьтесь с моей статьей о рабочих процессах 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;
Сохранение данных может занять какое-то время, поэтому мы возвращаем обещание, которое будет выполнено, если данные сохранятся успешно, или отклонено, если нет.
Если вам интересно, откуда я взял приведенный выше код, HarperDB предоставляет раздел «примеры кода» на панели, который значительно облегчает жизнь:
Время тестировать! Войдите в комнату в качестве пользователя и отправьте сообщение. Затем перейдите в HarperDB и нажмите на "browse", затем щелкните по таблице "messages". Вы должны увидеть свое сообщение в базе данных:
Круто. И что дальше? Было бы здорово, если бы последние 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));
});
// ...
Выше, если сообщения успешно получены, мы выпускаем событие Socket.io под названием last_100_messages
. Теперь мы будем слушать это событие на фронтенде.
Как отобразить последние 100 сообщений на клиенте
Ниже мы добавляем хук useEffect
, который содержит слушатель события Socket.io для события last_100_messages
. После этого сообщения сортируются в порядке дат, самые последние внизу, и состояние messagesReceived
обновляется.
Когда состояние messagesReceived
обновляется, запускается useEffect
для прокрутки элемент messageColumn
до самого последнего сообщения. Это улучшает UX приложения.
// 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.
Когда пользователь присоединяется к комнате, мы генерируем на сервере событие 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 предоставляет метод leave()
, который можно использовать для удаления пользователя из комнаты Socket.io. Мы также храним данные о пользователях в массиве в памяти сервера, поэтому удалим пользователя и из этого массива:
// 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).
Давайте проверим: откройте два окна рядом друг с другом и присоединитесь к чату в обоих:
Затем нажмите кнопку "Leave" в окне 2:
Пользователь покидает чат, а остальным пользователям отправляется сообщение о его уходе. Отлично!
Как добавить слушателя события disconnect
Что делать, если пользователь почему-то потерял связь с сервером, например, у него пропал интернет? Socket.io предоставляет встроенный слушатель события отключения (disconnect event listener) для этого. Давайте добавим его на сервер, чтобы удалять пользователя из памяти при потери связи:
// 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. Это означает, что мы можем создать все наше приложение в одном месте! Пример того, как HarperDB сокращает стек, можно посмотреть в этой статье.
Небольшое задание на прощание
Если обновить страницу чата, имя пользователя и комната будут потеряны. Сможете ли вы предотвратить потерю этой информации? Подсказка: оказаться полезным может локальное хранилище. Предлагайте варианты решения в комментариях к статье.
Всех начинающих веб-разработчиков приглашаем на открытые занятия, на которых:
создадим html сайт за час в аналоге блокнота (6 декабря, -> регистрация)
доработаем его до полноценного чата, чтобы пользователи с разных компьютеров могли общаться друг с другом (21 декабря, -> регистрация).