WebSockets - отличная технология для добавления связи в ваши приложения в реальном времени. Он работает, позволяя приложениям отправлять события в другое приложение, передавая вместе с ним данные. Это означает, что пользователи могут видеть новые данные на своем экране без ручного извлечения новых данных, что обеспечивает лучшую интерактивность и упрощает взаимодействие с пользователем. HTTP также имеет много накладных расходов на отправку данных, которые нужны не всем приложениям, например, заголовков, это увеличивает задержку связи между приложениями.

Socket.io - это библиотека, которая использует как WebSockets, так и HTTP-запросы, чтобы позволить приложениям отправлять и получать данные между собой. Отправка данных между приложениями происходит практически мгновенно. Он работает, позволяя приложениям отправлять события другим приложениям, а приложения, получающие события, могут обрабатывать их так, как им нравится. Он также предоставляет пространство имен и чаты для разделения трафика.

Одно из лучших применений WebSockets и Socket.io - приложение для чата. Приложения для чата требуют общения в реальном времени, поскольку сообщения отправляются и принимаются постоянно. Если мы используем HTTP-запросы, нам придется многократно делать много запросов, чтобы сделать что-то подобное. Если мы будем постоянно посылать запросы на получение новых сообщений, это будет очень медленно и требует больших затрат на вычислительные и сетевые ресурсы.

В этой статье мы создадим приложение для чата, которое позволит вам присоединиться к нескольким комнатам чата и отправлять сообщения с разными дескрипторами чата. Дескриптор чата - это имя пользователя, которое вы используете для присоединения к чату. Мы будем использовать React для интерфейса и Express для серверной части. Клиент Socket.io будет использоваться во внешнем интерфейсе, а сервер Socket.io будет использоваться во внутренней части.

Для начала мы создаем пустую папку для нашего проекта, а затем внутри папки создаем папку с именем backend для нашего внутреннего проекта. Затем мы заходим в папку backend и запускаем Express Generator, чтобы сгенерировать начальный код для серверного приложения. Для этого запустите npx express-generator. Затем в той же папке запустите npm install, чтобы установить пакеты. Нам нужно будет добавить больше пакетов в наше серверное приложение. Нам нужен Babel для использования новейших функций JavaScript, включая синтаксис import для импорта модулей, который еще не поддерживается последними версиями Node.js. Нам также нужен пакет CORS, чтобы интерфейсная часть могла взаимодействовать с серверной частью. Sequelize необходим для управления нашей базой данных, которую мы будем использовать для хранения данных чата и сообщений чата. Sequelize - популярный ORM для Node.js. Нам также нужен пакет dotenv, чтобы мы могли извлекать учетные данные нашей базы данных из переменных среды. Postgres будет нашей предпочтительной системой баз данных для хранения данных.

Мы запускаем npm i @babel/cli @babel/core @babel/node @babel/preset-env cors dotenv pg pg-hstore sequelize sequelize-cli socket.io, чтобы установить пакеты. После установки пакетов мы запустим npx sequelize-cli init в той же папке, чтобы добавить код, необходимый для использования Sequelize для создания моделей и миграций.

Теперь нам нужно настроить Babel, чтобы мы могли запускать наше приложение с новейшим синтаксисом JavaScript. Сначала создайте файл с именем .babelrc в папке backend и добавьте:

{
    "presets": [
        "@babel/preset-env"
    ]
}

Затем мы заменяем раздел scripts в package.json на:

"scripts": {
    "start": "nodemon --exec npm run babel-node --  ./bin/www",
    "babel-node": "babel-node"
},

Обратите внимание, что мы также должны установить nodemon, запустив npm i -g nodemon, чтобы приложение перезапускалось при каждом изменении файла, что упростит нам разработку приложения. Теперь, если мы запустим npm start, мы сможем работать с новейшими функциями JavaScript в нашем приложении.

Затем нам нужно изменить config.json, созданный запуском npx sequelize init. Переименуйте config.json в config.js и замените существующий код на:

require("dotenv").config();
const dbHost = process.env.DB_HOST;
const dbName = process.env.DB_NAME;
const dbUsername = process.env.DB_USERNAME;
const dbPassword = process.env.DB_PASSWORD;
const dbPort = process.env.DB_PORT || 5432;
module.exports = {
  development: {
    username: dbUsername,
    password: dbPassword,
    database: dbName,
    host: dbHost,
    port: dbPort,
    dialect: "postgres",
  },
  test: {
    username: dbUsername,
    password: dbPassword,
    database: "chat_app_test",
    host: dbHost,
    port: dbPort,
    dialect: "postgres",
  },
  production: {
    use_env_variable: "DATABASE_URL",
    username: dbUsername,
    password: dbPassword,
    database: dbName,
    host: dbHost,
    port: dbPort,
    dialect: "postgres",
  },
};

Это позволяет нам читать учетные данные базы данных из нашего .env, расположенного в папке backend, которые должны выглядеть примерно так:

DB_HOST='localhost'
DB_NAME='chat_app_development'
DB_USERNAME='postgres'
DB_PASSWORD='postgres'

Теперь, когда у нас настроено подключение к базе данных, мы можем создавать модели и миграции. Запустите npx sequelize model:generate --name ChatRoom --attributes name:string, чтобы создать ChatRooms таблицу со столбцом name и моделью ChatRoom в нашем коде вместе с соответствующей миграцией. Затем мы делаем миграцию и модель для хранения сообщений. Запустите npx sequelize model:generate --name ChatRoomMessages --attributes author:string,message:text,chatRoomId:integer. Обратите внимание, что в обеих командах мы используем единственное слово для названия модели. Также не должно быть пробелов после запятой в определениях столбцов.

Затем мы добавляем уникальное ограничение в столбец имени таблицы ChatRooms. Создайте новую миграцию, запустив npx sequelize-cli migration:create add-unique-constraint-for-chatroom-name, чтобы сделать пустую миграцию. Затем введите:

"use strict";
module.exports = {
  up: (queryInterface, Sequelize) => {
    return queryInterface.addConstraint("ChatRooms", ["name"], {
      type: "unique",
      name: "unique_name",
    });
  },
  down: (queryInterface, Sequelize) => {
    return queryInterface.removeConstraint("ChatRooms", "unique_name");
  },
};

После того, как все это будет сделано, мы запускаем npx sequelize-cli db:migrate, чтобы запустить миграции.

Затем в bin/www мы добавляем код для отправки и получения событий с помощью Socket.io. Замените существующий код на:

#!/usr/bin/env node
/**
 * Module dependencies.
 */
const app = require("../app");
const debug = require("debug")("backend:server");
const http = require("http");
const models = require("../models");
/**
 * Get port from environment and store in Express.
 */
const port = normalizePort(process.env.PORT || "3000");
app.set("port", port);
/**
 * Create HTTP server.
 */
const server = http.createServer(app);
const io = require("socket.io")(server);
io.on("connection", socket => {
  socket.on("join", async room => {
    socket.join(room);
    io.emit("roomJoined", room);
  });
  socket.on("message", async data => {
    const { chatRoomName, author, message } = data;
    const chatRoom = await models.ChatRoom.findAll({
      where: { name: chatRoomName },
    });
    const chatRoomId = chatRoom[0].id;
    const chatMessage = await models.ChatMessage.create({
      chatRoomId,
      author,
      message: message,
    });
    io.emit("newMessage", chatMessage);
  });
});
/**
 * Listen on provided port, on all network interfaces.
 */
server.listen(port);
server.on("error", onError);
server.on("listening", onListening);
/**
 * Normalize a port into a number, string, or false.
 */
function normalizePort(val) {
  const port = parseInt(val, 10);
if (isNaN(port)) {
    // named pipe
    return val;
  }
if (port >= 0) {
    // port number
    return port;
  }
return false;
}
/**
 * Event listener for HTTP server "error" event.
 */
function onError(error) {
  if (error.syscall !== "listen") {
    throw error;
  }
const bind = typeof port === "string" ? "Pipe " + port : "Port " + port;
// handle specific listen errors with friendly messages
  switch (error.code) {
    case "EACCES":
      console.error(bind + " requires elevated privileges");
      process.exit(1);
      break;
    case "EADDRINUSE":
      console.error(bind + " is already in use");
      process.exit(1);
      break;
    default:
      throw error;
  }
}
/**
 * Event listener for HTTP server "listening" event.
 */
function onListening() {
  const addr = server.address();
  const bind = typeof addr === "string" ? "pipe " + addr : "port " + addr.port;
  debug("Listening on " + bind);
}

так что приложение будет прослушивать подключение от клиентов и позволять подключаться к комнатам при получении события join. В этом блоке кода мы обрабатываем сообщения, полученные с событием message:

socket.on("message", async data => {
    const { chatRoomName, author, message } = data;
    const chatRoom = await models.ChatRoom.findAll({
      where: { name: chatRoomName },
    });
    const chatRoomId = chatRoom[0].id;
    const chatMessage = await models.ChatMessage.create({
      chatRoomId,
      author,
      message: message,
    });
    io.emit("newMessage", chatMessage);
  });

и генерировать событие newMessage после сохранения сообщения, отправленного с событием message, путем получения идентификатора комнаты чата и сохранения всего в таблице ChatMessages.

В наших моделях мы должны создать взаимосвязь между таблицами ChatRooms и ChatMessages, изменив код модели. В chatmessage.js мы помещаем:

'use strict';
module.exports = (sequelize, DataTypes) => {
  const ChatMessage = sequelize.define('ChatMessage', {
    chatRoomId: DataTypes.INTEGER,
    author: DataTypes.STRING,
    message: DataTypes.TEXT
  }, {});
  ChatMessage.associate = function(models) {
    // associations can be defined here
    ChatMessage.belongsTo(models.ChatRoom, {
      foreignKey: 'chatRoomId',
      targetKey: 'id'
    });
  };
  return ChatMessage;
};

чтобы таблица ChatMessages принадлежала таблице ChatRooms.

В ChatRoom.js мы помещаем:

"use strict";
module.exports = (sequelize, DataTypes) => {
  const ChatRoom = sequelize.define(
    "ChatRoom",
    {
      name: DataTypes.STRING,
    },
    {}
  );
  ChatRoom.associate = function(models) {
    // associations can be defined here
    ChatRoom.hasMany(models.ChatMessage, {
      foreignKey: "chatRoomId",
      sourceKey: "id",
    });
  };
  return ChatRoom;
};

так что у каждого ChatRoom есть много ChatMessages.

Затем нам нужно добавить несколько маршрутов к нашей серверной части для получения и настройки чатов и получения сообщений-сообщений. Создайте новый файл с именем chatRoom.js в папке routes и добавьте:

const express = require("express");
const models = require("../models");
const router = express.Router();
/* GET users listing. */
router.get("/chatrooms", async (req, res, next) => {
  const chatRooms = await models.ChatRoom.findAll();
  res.send(chatRooms);
});
router.post("/chatroom", async (req, res, next) => {
  const room = req.body.room;
  const chatRooms = await models.ChatRoom.findAll({
    where: { name: room },
  });
  const chatRoom = chatRooms[0];
  if (!chatRoom) {
    await models.ChatRoom.create({ name: room });
  }
  res.send(chatRooms);
});
router.get("/chatroom/messages/:chatRoomName", async (req, res, next) => {
  try {
    const chatRoomName = req.params.chatRoomName;
    const chatRooms = await models.ChatRoom.findAll({
      where: {
        name: chatRoomName,
      },
    });
    const chatRoomId = chatRooms[0].id;
    const messages = await models.ChatMessage.findAll({
      where: {
        chatRoomId,
      },
    });
    res.send(messages);
  } catch (error) {
    res.send([]);
  }
});
module.exports = router;

Маршрут /chatrooms получает все чаты из базы данных. Маршрут chatroom POST добавляет новую чат-комнату, если она еще не существует, путем поиска существующей по имени. Маршрут /chatroom/messages/:chatRoomName получает сообщения для данной комнаты чата по имени комнаты чата.

Наконец, в app.js мы заменяем существующий код на:

var createError = require("http-errors");
var express = require("express");
var path = require("path");
var cookieParser = require("cookie-parser");
var logger = require("morgan");
var indexRouter = require("./routes/index");
var chatRoomRouter = require("./routes/chatRoom");
var app = express();
const cors = require("cors");
// view engine setup
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "jade");
app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));
app.use(cors());
app.use("/", indexRouter);
app.use("/chatroom", chatRoomRouter);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});
// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get("env") === "development" ? err : {};
// render the error page
  res.status(err.status || 500);
  res.render("error");
});
module.exports = app;

Нам нужно изменить этот файл, чтобы включить CORS, добавив:

app.use(cors());

и добавьте маршруты наших чатов, добавив:

app.use("/chatroom", chatRoomRouter);

Теперь, когда бэкэнд готов, мы можем построить наш интерфейс. Перейдите в корневую папку проекта и запустите npx create-react-app frontend. Это создает исходный код для внешнего интерфейса с установленными пакетами. Далее нам нужно самостоятельно установить некоторые пакеты. Запустите npm i axios bootstrap formik react-bootstrap react-router-dom socket.io-client yup, чтобы установить наш HTTP-клиент Axios, Bootstrap для стилизации, React Router для маршрутизации URL-адресов на наши страницы и Formik и Yup для упрощения обработки и проверки данных формы соответственно.

После того, как мы установили наши пакеты, мы можем написать код. Все файлы, которые мы изменяем, находятся в папке src, за исключением случаев, когда путь указан явно. Сначала в App.js мы меняем существующий код на следующий:

import React from "react";
import { Router, Route, Link } from "react-router-dom";
import HomePage from "./HomePage";
import TopBar from "./TopBar";
import { createBrowserHistory as createHistory } from "history";
import "./App.css";
import ChatRoomPage from "./ChatRoomPage";
const history = createHistory();
function App() {
  return (
    <div className="App">
      <Router history={history}>
        <TopBar />
        <Route path="/" exact component={HomePage} />
        <Route path="/chatroom" exact component={ChatRoomPage} />
      </Router>
    </div>
  );
}
export default App;

Чтобы определить наши маршруты и включить верхнюю панель в наше приложение, которое будет построено позже. Затем в App.css замените существующий код на:

.App {
  margin: 0 auto;
}

Затем создайте новую страницу с именем ChatRoomPage.js и добавьте следующее:

import React from "react";
import { useEffect, useState } from "react";
import { Formik } from "formik";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
import Button from "react-bootstrap/Button";
import * as yup from "yup";
import io from "socket.io-client";
import "./ChatRoomPage.css";
import { getChatRoomMessages, getChatRooms } from "./requests";
const SOCKET_IO_URL = "http://localhost:3000";
const socket = io(SOCKET_IO_URL);
const getChatData = () => {
  return JSON.parse(localStorage.getItem("chatData"));
};
const schema = yup.object({
  message: yup.string().required("Message is required"),
});
function ChatRoomPage() {
  const [initialized, setInitialized] = useState(false);
  const [messages, setMessages] = useState([]);
  const [rooms, setRooms] = useState([]);
  const handleSubmit = async evt => {
    const isValid = await schema.validate(evt);
    if (!isValid) {
      return;
    }
    const data = Object.assign({}, evt);
    data.chatRoomName = getChatData().chatRoomName;
    data.author = getChatData().handle;
    data.message = evt.message;
    socket.emit("message", data);
  };
  const connectToRoom = () => {
    socket.on("connect", data => {
      socket.emit("join", getChatData().chatRoomName);
    });
    socket.on("newMessage", data => {
      getMessages();
    });
    setInitialized(true);
  };
  const getMessages = async () => {
    const response = await getChatRoomMessages(getChatData().chatRoomName);
    setMessages(response.data);
    setInitialized(true);
  };
  const getRooms = async () => {
    const response = await getChatRooms();
    setRooms(response.data);
    setInitialized(true);
  };
  useEffect(() => {
   if (!initialized) {
      getMessages();
      connectToRoom();
      getRooms();
    }
  });
  return (
    <div className="chat-room-page">
      <h1>
        Chat Room: {getChatData().chatRoomName}. Chat Handle:{" "}
        {getChatData().handle}
      </h1>
      <div className="chat-box">
        {messages.map((m, i) => {
          return (
            <div className="col-12" key={i}>
              <div className="row">
                <div className="col-2">{m.author}</div>
                <div className="col">{m.message}</div>
                <div className="col-3">{m.createdAt}</div>
              </div>
            </div>
          );
        })}
      </div>
      <Formik validationSchema={schema} onSubmit={handleSubmit}>
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors,
        }) => (
          <Form noValidate onSubmit={handleSubmit}>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="handle">
                <Form.Label>Message</Form.Label>
                <Form.Control
                  type="text"
                  name="message"
                  placeholder="Message"
                  value={values.message || ""}
                  onChange={handleChange}
                  isInvalid={touched.message && errors.message}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.message}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>
            <Button type="submit" style={{ marginRight: "10px" }}>
              Send
            </Button>
          </Form>
        )}
      </Formik>
    </div>
  );
}
export default ChatRoomPage;

Он содержит код нашей основной комнаты чата. Пользователь увидит содержимое этой страницы после перехода на домашнюю страницу, где он заполнит свой дескриптор чата и название комнаты чата. Сначала мы подключаемся к нашему серверу Socket.io, запустив const socket = io(SOCKET_IO_URL);. Затем мы подключаемся к заданному имени комнаты чата, которое мы сохранили в локальном хранилище в функции connectToRoom. Функция будет иметь обработчик для события connect, которое выполняется после получения события connect. Как только событие получено, клиент испускает событие join, запустив socket.emit(“join”, getChatData().chatRoomName);, который отправляет событие join с именем нашей комнаты чата. Как только событие join получено сервером. Он вызовет функцию socket.join в своем обработчике событий. Каждый раз, когда пользователь отправляет сообщение, вызывается функция handleSubmit, которая отправляет событие message на наш сервер Socket.io. Как только message будет доставлен на сервер, он сохранит сообщение в базе данных, а затем отправит событие newMessage обратно во внешний интерфейс. Затем внешний интерфейс будет получать последние сообщения, используя маршрут, который мы определили в серверной части с помощью HTTP-запроса.

Обратите внимание, что мы отправляем данные чата на сервер через Socket.io вместо HTTP-запросов, так что все пользователи в комнате чата сразу получат одни и те же данные, поскольку событие newMessage будет транслироваться всем клиентам.

Мы создаем файл с именем ChatRoom.css, затем добавляем в него:

.chat-room-page {
  width: 90vw;
  margin: 0 auto;
}
.chat-box {
  height: calc(100vh - 300px);
  overflow-y: scroll;
}

Затем мы создаем домашнюю страницу, которая является первой страницей, которую пользователи видят, когда пользователь впервые открывает приложение. Здесь пользователь вводит свой дескриптор чата и имя чата. Создайте файл с именем HomePage.js и добавьте:

import React from "react";
import { useEffect, useState } from "react";
import { Formik } from "formik";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
import Button from "react-bootstrap/Button";
import * as yup from "yup";
import { Redirect } from "react-router";
import "./HomePage.css";
import { joinRoom } from "./requests";
const schema = yup.object({
  handle: yup.string().required("Handle is required"),
  chatRoomName: yup.string().required("Chat room is required"),
});
function HomePage() {
  const [redirect, setRedirect] = useState(false);
  const handleSubmit = async evt => {
    const isValid = await schema.validate(evt);
    if (!isValid) {
      return;
    }
    localStorage.setItem("chatData", JSON.stringify(evt));
    await joinRoom(evt.chatRoomName);
    setRedirect(true);
  };
  if (redirect) {
    return <Redirect to="/chatroom" />;
  }
  return (
    <div className="home-page">
      <h1>Join Chat</h1>
      <Formik
        validationSchema={schema}
        onSubmit={handleSubmit}
        initialValues={JSON.parse(localStorage.getItem("chatData") || "{}")}
      >
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors,
        }) => (
          <Form noValidate onSubmit={handleSubmit}>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="handle">
                <Form.Label>Handle</Form.Label>
                <Form.Control
                  type="text"
                  name="handle"
                  placeholder="Handle"
                  value={values.handle || ""}
                  onChange={handleChange}
                  isInvalid={touched.handle && errors.handle}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.firstName}
                </Form.Control.Feedback>
              </Form.Group>
              <Form.Group as={Col} md="12" controlId="chatRoomName">
                <Form.Label>Chat Room Name</Form.Label>
                <Form.Control
                  type="text"
                  name="chatRoomName"
                  placeholder="Chat Room Name"
                  value={values.chatRoomName || ""}
                  onChange={handleChange}
                  isInvalid={touched.chatRoomName && errors.chatRoomName}
                />
<Form.Control.Feedback type="invalid">
                  {errors.chatRoomName}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>
            <Button type="submit" style={{ marginRight: "10px" }}>
              Join
            </Button>
          </Form>
        )}
      </Formik>
    </div>
  );
}
export default HomePage;

Как только пользователь вводит данные в форму, будет проверяться, заполнены ли они, и как только они будут заполнены, на серверную часть будет отправлен запрос на добавление комнаты чата, если ее там нет. Мы также сохраняем заполненные данные в локальное хранилище и перенаправляем пользователя на страницу комнаты чата, где он будет подключаться к комнате чата с введенным им именем.

Обе формы созданы с использованием Form компонента React Bootstrap.

Затем мы создаем файл с именем HomePage.css и добавляем:

.home-page {
    width: 90vw;
    margin: 0 auto;
}

чтобы добавить поля к нашей странице.

Затем мы создаем файл с именем requests.js в папке src, чтобы добавить код для выполнения запросов к нашему серверу для управления комнатами чата и получения сообщений чата. В файл добавьте следующий код:

const APIURL = "http://localhost:3000";
const axios = require("axios");
export const getChatRooms = () => axios.get(`${APIURL}/chatroom/chatrooms`);
export const getChatRoomMessages = chatRoomName =>
  axios.get(`${APIURL}/chatroom/chatroom/messages/${chatRoomName}`);
export const joinRoom = room =>
  axios.post(`${APIURL}/chatroom/chatroom`, { room });

Наконец, мы создаем верхнюю панель. Создайте файл с именем TopBar.js и добавьте:

import React from "react";
import Navbar from "react-bootstrap/Navbar";
import Nav from "react-bootstrap/Nav";
import { withRouter } from "react-router-dom";
function TopBar({ location }) {
  const { pathname } = location;
  return (
    <Navbar bg="primary" expand="lg" variant="dark">
      <Navbar.Brand href="#home">Chat Room App</Navbar.Brand>
      <Navbar.Toggle aria-controls="basic-navbar-nav" />
      <Navbar.Collapse id="basic-navbar-nav">
        <Nav className="mr-auto">
          <Nav.Link href="/" active={pathname == "/"}>
            Join Another Chat Room
          </Nav.Link>
        </Nav>
      </Navbar.Collapse>
    </Navbar>
  );
}
export default withRouter(TopBar);

Мы создаем верхнюю панель с помощью виджета Navbar, предоставляемого React Bootstrap, со ссылкой на домашнюю страницу. Мы оборачиваем компонент функцией withRouter, чтобы получить объект местоположения из React Router.

После выполнения всех работ у нас есть:

Исходный код находится по адресу https://bitbucket.org/hauyeung/react-chat-tutorial-app/src/master/.