Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Привет, друзья!
В данной статье я хочу показать вам, как разработать простое приложение для обмена сообщениями в режиме реального времени с использованием Socket.io
, Express
и React
с акцентом на работе с медиа.
Функционал нашего приложения будет следующим:
- при первом запуске приложение предлагает пользователю ввести свое имя;
- имя пользователя и его идентификатор записываются в локальное хранилище;
- при повторном запуске приложения имя и идентификатор пользователя извлекаются из локального хранилища (имитация системы аутентификации/авторизации);
- выполняется подключение к серверу через веб-сокеты и вход в комнату
main_room
(при желании можно легко реализовать возможность выбора или создания других комнат); - пользователи обмениваются сообщениями в реальном времени;
- типом сообщения может быть текст, аудио, видео или изображение;
- передаваемые файлы сохраняются на сервере;
- путь к сохраненному на сервере файлу добавляется в сообщение;
- сообщение записывается в базу данных;
- пользователи могут записывать аудио и видеосообщения;
- после прикрепления файла и записи аудио или видео сообщения, отображается превью созданного контента;
- пользователи могут добавлять в текст сообщения эмодзи;
- текстовые сообщения могут озвучиваться;
- и т.д.
Репозиторий с исходным кодом проекта.
Если вам это интересно, прошу под кат.
Справедливости ради следует отметить, что я уже писал о разработке чата на Хабре. Будем считать, что это новая (продвинутая) версия.
Подготовка и настройка проекта
Создаем директорию, переходим в нее и инициализируем Node.js-проект
:
mkdir chat-app
cd chat-app
yarn init -yp
# or
npm init -y
Создаем директорию для сервера и шаблон для клиента с помощью Create React App
:
mkdir server
yarn create react-app client
# or
npx create-react-app client
Нам потребуется одновременно запускать два сервера (для клиента и самого сервера), поэтому установим concurrently
— утилиту для одновременного выполнения нескольких команд, определенных в файле package.json
:
yarn add concurrently
# or
npm i concurrently
Определяем команды в package.json
:
"scripts": {
"dev:client": "yarn --cwd client start",
"dev:server": "yarn --cwd server dev",
"dev": "concurrently \"yarn dev:client\" \"yarn dev:server\""
}
Или, если вы используете npm
:
"scripts": {
"dev:client": "npm run start --prefix client",
"dev:server": "npm run dev --prefix server",
"dev": "concurrently \"npm run dev:client\" \"npm run dev:server\""
}
В качестве БД мы будем использовать MongoDb Atlas Database
.
Переходим по ссылке, создаем аккаунт, создаем проект и кластер и получаем строку для подключения вида mongodb+srv://<user>:<password>@cluster0.f7292.mongodb.net/<database>?retryWrites=true&w=majority
*, где <user>
, <password>
и <database>
— данные, которые вы указали при создании проекта и кластера.
- Для получения адреса БД необходимо нажать
Connect
рядом с названием кластера (Cluster0
) и затемConnect your application
. - Если у вас, как и у меня, динамический
IP
, во вкладкеNetwork Access
разделаSecurity
надо прописать0.0.0.0/0
Можно приступать к разработке сервера.
Сервер
Переходим в директорию server
и устанавливаем зависимости:
cd server
# производственные зависимости
yarn add express socket.io mongoose cors multer
# or
npm i ...
# зависимость для разработки
yarn add -D nodemon
# or
npm i -D nodemon
express
—Node.js-фреймворк
для разработки веб-серверов;socket.io
— библиотека, облегчающая работу с веб-сокетами;mongoose
— ORM для работы сMongoDB
;cors
— утилита для работы с CORS;multer
— утилита для разбора (парсинга) данных в форматеmultipart/form-data
(для сохранения файлов на сервере);nodemon
— утилита для запуска сервера для разработки.
Определяем тип кода сервера (модуль) и команду для запуска сервера для разработки в файле package.json
:
"type": "module",
"scripts": {
"dev": "nodemon"
}
Структура директории server
будет следующей:
- files - директория для хранения файлов
- models
- message.model.js - модель сообщения для `Mongoose`
- socket_io
- handlers
- message.handlers.js - обработчики для сообщений
- user.handler.js - обработчики для пользователей
- onConnection.js - обработка подключения
- utils
- file.js - утилиты для работы с файлами
- onError.js - обработчик ошибок
- upload.js - утилита для сохранения файлов
- config.js - настройки (в репозитории имеется файл `config.example.js` с примером настроек)
- index.js - основной файл сервера
Определяем настройки в файле config.js
(не забудьте добавить его в .gitignore
):
// разрешенный источник
export const ALLOWED_ORIGIN = 'http://localhost:3000'
// адрес БД
export const MONGODB_URI =
'mongodb+srv://<user>:<password>@cluster0.f7292.mongodb.net/<database>?retryWrites=true&w=majority'
Определяем модель в файле models/message.model.js
:
import mongoose from 'mongoose'
const { Schema, model } = mongoose
const messageSchema = new Schema(
{
messageId: {
type: String,
required: true,
unique: true
},
messageType: {
type: String,
required: true
},
textOrPathToFile: {
type: String,
required: true
},
roomId: {
type: String,
required: true
},
userId: {
type: String,
required: true
},
userName: {
type: String,
required: true
}
},
{
timestamps: true
}
)
export default model('Message', messageSchema)
Каждое наше сообщение будет включать следующую информацию:
messageId
— идентификатор сообщения;messageType
— тип сообщения;textOrPathToFile
— текст сообщения или путь к файлу;roomId
— идентификатор комнаты;userId
— идентификатор пользователя;userName
— имя пользователя;createdAt
,updatedAt
— дата и время создания и обновления сообщения, соответственно (timestamps: true
).
Кратко рассмотрим утилиты (директория utils
).
Обработчик ошибок (onError.js
):
export default function onError(err, req, res, next) {
console.log(err)
// если имеется объект ответа
if (res) {
// статус ошибки
const status = err.status || err.statusCode || 500
// сообщение об ошибке
const message = err.message || 'Something went wrong. Try again later'
res.status(status).json({ message })
}
}
Утилита для работы с файлами (file.js
):
import { unlink } from 'fs/promises'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import onError from './onError.js'
// путь к текущей директории
const _dirname = dirname(fileURLToPath(import.meta.url))
// путь к директории с файлами
const fileDir = join(_dirname, '../files')
// утилита для получения пути к файлу
export const getFilePath = (filePath) => join(fileDir, filePath)
// утилита для удаления файла
export const removeFile = async (filePath) => {
try {
await unlink(join(fileDir, filePath))
} catch (e) {
onError(e)
}
}
Утилита для сохранения файлов (upload.js
):
import { existsSync, mkdirSync } from 'fs'
import multer from 'multer'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
// путь к текущей директории
const _dirname = dirname(fileURLToPath(import.meta.url))
const upload = multer({
storage: multer.diskStorage({
// директория для записи файлов
destination: async (req, _, cb) => {
// извлекаем идентификатор комнаты из HTTP-заголовка `X-Room-Id`
const roomId = req.headers['x-room-id']
// файлы хранятся по комнатам
// название директории - идентификатор комнаты
const dirPath = join(_dirname, '../files', roomId)
// создаем директорию при отсутствии
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true })
}
cb(null, dirPath)
},
filename: (_, file, cb) => {
// названия файлов могут быть одинаковыми
// добавляем к названию время с начала эпохи и дефис
const fileName = `${Date.now()}-${file.originalname}`
cb(null, fileName)
}
})
})
export default upload
Рассмотрим основной файл сервера (index.js
).
Импортируем все и вся:
import cors from 'cors'
import express from 'express'
import { createServer } from 'http'
import mongoose from 'mongoose'
import { Server } from 'socket.io'
import { ALLOWED_ORIGIN, MONGODB_URI } from './config.js'
import onConnection from './socket_io/onConnection.js'
import { getFilePath } from './utils/file.js'
import onError from './utils/onError.js'
import upload from './utils/upload.js'
Создаем экземпляр Express-приложения
и подключаем посредников для работы с CORS
и парсинга JSON
:
const app = express()
app.use(
cors({
origin: ALLOWED_ORIGIN
})
)
app.use(express.json())
Обрабатываем загрузку файлов:
app.use('/upload', upload.single('file'), (req, res) => {
if (!req.file) return res.sendStatus(400)
// формируем относительный путь к файлу
const relativeFilePath = req.file.path
.replace(/\\/g, '/')
.split('server/files')[1]
// и возвращаем его
res.status(201).json(relativeFilePath)
})
Обрабатываем получение файлов:
app.use('/files', (req, res) => {
// формируем абсолютный путь к файлу
const filePath = getFilePath(req.url)
// и возвращаем файл по этому пути
res.status(200).sendFile(filePath)
})
Добавляем обработчик ошибок и подключаемся к БД:
app.use(onError)
try {
await mongoose.connect(MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true
})
console.log('