Привет, друзья!
В данном туториале мы разработаем чат с использованием следующих технологий:
- TypeScript — статический типизатор;
- NestJS — сервер;
- Socket.IO — библиотека для работы в [веб-сокетами]();
- React — клиент;
- TailwindCSS — библиотека для стилизации;
- PostgreSQL — база данных (далее — БД);
- Prisma — ORM;
- Docker — платформа для разработки, доставки и запуска приложений в изолированной среде — контейнере.
Функционал чата будет таким:
- фейковая регистрация пользователей:
- хранение имен пользователей в памяти (объекте) на сервере;
- хранение имен и идентификаторов пользователей в localStorage на клиенте;
- регистрация подключений и отключений пользователей на сервере и передача этой информации подключенным клиентам;
- запись, обновление и удаление сообщений из БД в реальном времени на сервере и передача этой информации клиентам.
Если вам это интересно, прошу под кат.
Материалы для изучения (опционально):
- Карманная книга по TS;
- Шпаргалка по TS;
- Шпаргалка по React + TS;
- Руководство по NestJS;
- Руководство по Socket.IO;
- Руководство по Prisma;
- Руководство по Docker.
Полезные расширения для VSCode (опционально):
- Docker;
- ES7+ React/Redux/React-Native/JS snippets;
- ESLint;
- Prettier — Code formatter;
- Prisma;
- Tailwind CSS IntelliSense.
Подготовка и настройка проекта
Обратите внимание: для успешного прохождения туториала на вашей машине должны быть установлены Node.js и Docker.
Для работы с зависимостями будет использоваться Yarn.
Создаем директорию, переходим в нее и инициализируем Node.js-проект:
mkdir react-nest-postgres-chat cd react-nest-postgres-chat yarn init -yp
База данных
Создаем файл docker-compose.yml следующего содержания:
services: # название сервиса postgres: # образ image: postgres # политика перезапуска restart: on-failure # файл с переменными среды окружения env_file: - .env # порты ports: - 5432:5432 # тома для постоянного хранения данных volumes: - postgres-data:/var/lib/postgresql/data volumes: postgres-data:
Создаем файл .env и определяем в нем переменные среды окружения для Postgres:
POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=chat
Запускаем сервер Postgres в контейнере:
docker compose up -d
Это приводит к созданию и запуску контейнера react-nest-postgres-chat-1 с сервером Postgres в сервисе react-nest-postgres-chat.
БД доступна по адресу http://localhost:5432/chat.
ORM
Устанавливаем Prisma в качестве зависимости для разработки и инициализируем ее:
yarn add -D prisma prisma init
Это приводит к созданию файла prisma/schema.prisma. Определяем в нем модель сообщения:
model Message { id Int @id @default(autoincrement()) userId String userName String text String createdAt DateTime @default(now()) }
Определяем переменную со строкой для подключения к БД в файле .env:
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?schema=public
Выполняем миграцию:
yarn prisma migrate dev --name init
Это приводит к созданию директории migrations с выполненной миграцией в формате SQL.
Устанавливаем клиента Prisma и генерируем типы:
yarn add @prisma/client yarn prisma generate
Сервер и клиент
Глобально устанавливаем Nest CLI и инициализируем Nest-проект:
yarn global add @nestjs/cli # выбираем `yarn` для работы с зависимостями nest new server
Создаем шаблон React + TS приложения с помощью create-vite:
# client - название приложения (и директории) # react-ts - используемый шаблон yarn create vite client --template react-ts
Устанавливаем concurrently:
yarn add concurrently
Определяем команду для одновременного запуска сервера и клиента в режиме разработки в файле package.json:
"scripts": { "dev:client": "yarn --cwd client dev", "dev:server": "yarn --cwd server start:dev", "dev": "concurrently \"yarn dev:client\" \"yarn dev:server\"" }
Работоспособность приложения можно проверить, выполнив команду yarn dev.
На этом подготовка и настройка проекта завершены. Переходим к разработке сервера.
Сервер
Переходим в директорию сервера и устанавливаем модули для работы с сокетами:
cd server yarn add @nestjs/websockets @nestjs/platform-socket.io
Генерируем шлюз (gateway) для модуля App:
# g ga - generate gateway nest g ga app
Приводим файлы сервера к следующей структуре:
- src - app.gateway.ts - app.module.ts - app.service.ts - main.ts - prisma.service.ts - constants.ts - types.ts - ...
Определяем сервис Prisma в файле prisma.service.ts:
import { INestApplication, Injectable, OnModuleInit } from "@nestjs/common"; import { PrismaClient } from "@prisma/client"; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit { async onModuleInit() { await this.$connect(); } async enableShutdownHooks(app: INestApplication) { this.$on("beforeExit", async () => { await app.close(); }); } }
Настраиваем данный сервис в файле main.ts:
import { NestFactory } from "@nestjs/core"; import { AppModule } from "./app.module"; // ! import { PrismaService } from "./prisma.service"; async function bootstrap() { const app = await NestFactory.create(AppModule); // ! const prismaService = app.get(PrismaService); await prismaService.enableShutdownHooks(app); // обратите внимание на порт await app.listen(3001); } bootstrap();
И подключаем его в качестве провайдера в модуле App (app.module.ts):
import { Module } from "@nestjs/common"; import { AppService } from "./app.service"; // ! import { PrismaService } from "./prisma.service"; @Module({ imports: [], controllers: [], // ! providers: [PrismaService, AppService] }) export class AppModule {}
Определяем адрес клиента в файле constants.ts:
export const CLIENT_URI = "http://localhost:3000";
И тип полезной нагрузки для обновления сообщения в файле types.ts:
import { Prisma } from "@prisma/client"; // { id?: number, text?: string } export type MessageUpdatePayload = Prisma.MessageWhereUniqueInput & Pick<Prisma.MessageUpdateInput, "text">;
Определяем методы для работы с сообщениями в файле app.service.ts:
import { Injectable } from "@nestjs/common"; import { Message, Prisma } from "@prisma/client"; import { MessageUpdatePayload } from "types"; import { PrismaService } from "./prisma.service"; @Injectable() export class AppService { // инициализация сервиса `Prisma` constructor(private readonly prisma: PrismaService) {} // получение всех сообщений async getMessages(): Promise<Message[]> { return this.prisma.message.findMany(); } // удаление всех сообщений - для отладки в процессе разработки async clearMessages(): Promise<Prisma.BatchPayload> { return this.prisma.message.deleteMany(); } // создание сообщения async createMessage(data: Prisma.MessageCreateInput) { return this.prisma.message.create({ data }); } // обновление сообщения async updateMessage(payload: MessageUpdatePayload) { const { id, text } = payload; return this.prisma.message.update({ where: { id }, data: { text } }); } // удаление сообщения async removeMessage(where: Prisma.MessageWhereUniqueInput) { return this.prisma.message.delete({ where }); } }
Осталось реализовать обработку событий сокетов в файле app.gateway.ts.
Импортируем зависимости и прочее, а также определяем переменную для хранения записей «идентификатор сокета — имя пользователя»:
import { MessageBody, OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from "@nestjs/websockets"; import { Prisma } from "@prisma/client"; import { Server, Socket } from "Socket.IO"; import { MessageUpdatePayload } from "types"; import { CLIENT_URI } from "../constants"; import { AppService } from "./app.service"; const users: Record<string, string> = {};
Инициализируем сокет-соединение, определяем шлюз App и инициализируем в нем сервис App:
@WebSocketGateway({ cors: { origin: CLIENT_URI // можно указать `*` для отключения `CORS` }, serveClient: false, // название пространства может быть любым, но должно учитываться на клиенте namespace: "chat" }) export class AppGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { constructor(private readonly appService: AppService) {} /** * @todo */ }
Инициализируем сокет-сервер и регистрируем инициализацию:
@WebSocketServer() server: Server; afterInit(server: Server) { console.log(server); }
Обрабатываем подключение и отключение клиентов:
// подключение handleConnection(client: Socket, ...args: any[]) { // обратите внимание на структуру объекта `handshake` const userName = client.handshake.query.userName as string; const socketId = client.id; users[socketId] = userName; // передаем информацию всем клиентам, кроме текущего client.broadcast.emit("log", `${userName} connected`); } // отключение handleDisconnect(client: Socket) { const socketId = client.id; const userName = users[socketId]; delete users[socketId]; client.broadcast.emit("log", `${userName} disconnected`); }
Наконец, обрабатываем события сокетов:
// получение всех сообщений @SubscribeMessage("messages:get") async handleMessagesGet(): Promise<void> { const messages = await this.appService.getMessages(); this.server.emit("messages", messages); } // удаление всех сообщений @SubscribeMessage("messages:clear") async handleMessagesClear(): Promise<void> { await this.appService.clearMessages(); } // создание сообщения @SubscribeMessage("message:post") async handleMessagePost( @MessageBody() payload: // { userId: string, userName: string, text: string } Prisma.MessageCreateInput ): Promise<void> { const createdMessage = await this.appService.createMessage(payload); // можно сообщать клиентам о каждой операции по отдельности this.server.emit("message:post", createdMessage); // но мы пойдем более простым путем this.handleMessagesGet(); } // обновление сообщения @SubscribeMessage("message:put") async handleMessagePut( @MessageBody() payload: // { id: number, text: string } MessageUpdatePayload ): Promise<void> { const updatedMessage = await this.appService.updateMessage(payload); this.server.emit("message:put", updatedMessage); this.handleMessagesGet(); } // удаление сообщения @SubscribeMessage("message:delete") async handleMessageDelete( @MessageBody() payload: // { id: number } Prisma.MessageWhereUniqueInput ) { const removedMessage = await this.appService.removeMessage(payload); this.server.emit("message:delete", removedMessage); this.handleMessagesGet(); }
import { MessageBody, OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from "@nestjs/websockets"; import { Prisma } from "@prisma/client"; import { Server, Socket } from "socket.io"; import { MessageUpdatePayload } from "types"; import { CLIENT_URI } from "../constants"; import { AppService } from "./app.service"; const users: Record<string, string> = {}; @WebSocketGateway({ cors: { origin: CLIENT_URI }, serveClient: false, namespace: "chat" }) export class AppGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { constructor(private readonly appService: AppService) {} @WebSocketServer() server: Server; @SubscribeMessage("messages:get") async handleMessagesGet(): Promise<void> { const messages = await this.appService.getMessages(); this.server.emit("messages", messages); } @SubscribeMessage("messages:clear") async handleMessagesClear(): Promise<void> { await this.appService.clearMessages(); } @SubscribeMessage("message:post") async handleMessagePost( @MessageBody() payload: // { userId: string, userName: string, text: string } Prisma.MessageCreateInput ): Promise<void> { const createdMessage = await this.appService.createMessage(payload); this.server.emit("message:post", createdMessage); this.handleMessagesGet(); } @SubscribeMessage("message:put") async handleMessagePut( @MessageBody() payload: // { id: number, text: string } MessageUpdatePayload ): Promise<void> { const updatedMessage = await this.appService.updateMessage(payload); this.server.emit("message:put", updatedMessage); this.handleMessagesGet(); } @SubscribeMessage("message:delete") async handleMessageDelete( @MessageBody() payload: // { id: number } Prisma.MessageWhereUniqueInput ) { const removedMessage = await this.appService.removeMessage(payload); this.server.emit("message:delete", removedMessage); this.handleMessagesGet(); } afterInit(server: Server) { console.log(server); } handleConnection(client: Socket, ...args: any[]) { const userName = client.handshake.query.userName as string; const socketId = client.id; users[socketId] = userName; client.broadcast.emit("log", `${userName} connected`); } handleDisconnect(client: Socket) { const socketId = client.id; const userName = users[socketId]; delete users[socketId]; client.broadcast.emit("log", `${userName} disconnected`); } }
Подключаем шлюз в качестве провайдера в модуле App (app.module.ts):
import { Module } from "@nestjs/common"; import { AppService } from "./app.service"; // ! import { AppGateway } from "./app.gateway"; import { PrismaService } from "./prisma.service"; @Module({ imports: [], controllers: [], // ! providers: [PrismaService, AppService, AppGateway] }) export class AppModule {}
На этом разработка сервера завершена. Переходим к разработке клиента.
Клиент
Переходим в директорию клиента и инициализируем Tailwind:
cd client yarn add -D tailwindcss postcss autoprefixer yarn tailwindcss init -p
Редактируем файл tailwind.config.js:
/** @type {import('tailwindcss').Config} */ module.exports = { // ! content: ['./src/**/*.{ts,tsx}'], theme: { extend: {} }, plugins: [] }
Добавляем директивы @tailwind в файл App.css:
@tailwind base; @tailwind components; @tailwind utilities;
Переопределяем дефолтный шрифт и добавляем несколько переиспользуемых стилей (reused styles):
@layer base { html { font-family: "Montserrat", sans-serif; } } @layer components { #root { width: 100vw; height: 100vh; overflow: hidden; } .title { @apply mb-4 text-2xl text-center font-bold; } .btn { @apply py-2 px-4 text-white rounded-md shadow-md focus:outline-none focus:ring-2 focus:ring-opacity-75 transition-all duration-150; } .btn-primary { @apply btn bg-blue-500 hover:bg-blue-600 focus:ring-blue-400; } .btn-success { @apply btn bg-green-500 hover:bg-green-600 focus:ring-green-400; } .btn-error { @apply btn bg-red-500 hover:bg-red-600 focus:ring-red-400; } .input { @apply py-2 px-4 border border-blue-500 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-400 transition-all duration-150; } }
Подключаем шрифт в файле index.html:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" href="data:." /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>React Nest Postgres Chat</title> <!-- ! --> <link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link href="https://fonts.googleapis.com/css2?family=Montserrat&display=swap" rel="stylesheet" /> <!-- ! --> </head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body> </html>
Устанавливаем несколько библиотек:
yarn add socket.io-client react-icons react-timeago react-toastify
- socket.io-client — клиент
Socket.IO; - react-icons — иконки в виде компонентов;
- react-timeago — компонент для форматирования даты и времени с обновлением в реальном времени;
- react-toastify — библиотека для реализации всплывающих уведомлений.
Приводим файлы клиента к следующей структуре:
- src - components - ChatScreen.tsx - index.ts - WelcomeScreen.tsx - hooks - useChat.ts - App.css - App.tsx - constants.ts - main.ts - types.ts - utils.ts - postcss.config.js - tailwind.config.js - ...
Определяем константы в файле constants.ts:
// ключ для `localStorage` export const USER_INFO = "user-info"; // адрес шлюза на сервере export const SERVER_URI = "http://localhost:3001/chat";
Определяем типы в файле types.ts:
import { Prisma } from "@prisma/client"; export type UserInfo = { userId: string; userName: string; }; export type MessageUpdatePayload = Prisma.MessageWhereUniqueInput & Pick<Prisma.MessageUpdateInput, "text">;
И утилиты в файле utils.ts:
// утилита для генерации идентификатора пользователя export const getId = () => Math.random().toString(36).slice(2); // утилита для работы с `localStorage` export const storage = { set<T>(key: string, value: T) { localStorage.setItem(key, JSON.stringify(value)); }, get<T>(key: string): T | null { return localStorage.getItem(key) ? JSON.parse(localStorage.getItem(key) as string) : null; } };
Начнем, пожалуй, с основного компонента приложения (App.tsx):
import "react-toastify/dist/ReactToastify.css"; import "./App.css"; import { ChatScreen, WelcomeScreen } from "./components"; import { USER_INFO } from "./constants"; import { UserInfo } from "./types"; import { storage } from "./utils"; function App() { const userInfo = storage.get<UserInfo>(USER_INFO); return ( <section className="w-[480px] h-full mx-auto flex flex-col py-4"> {userInfo ? <ChatScreen /> : <WelcomeScreen />} </section> ); } export default App;
Если в локальном хранилище содержится информация о пользователе, рендерится экран чата. В противном случае, рендерится экран приветствия.
Экран приветствия (ChatScreen.tsx):
import React, { useState } from "react"; import { FiUser } from "react-icons/fi"; import { USER_INFO } from "../constants"; import { UserInfo } from "../types"; import { getId, storage } from "../utils"; export const WelcomeScreen = () => { const [userName, setUserName] = useState(""); const changeUserName = (e: React.ChangeEvent<HTMLInputElement>) => { setUserName(e.target.value); }; const setUserInfo = (e: React.FormEvent) => { e.preventDefault(); const trimmed = userName.trim(); if (!trimmed) return; // генерируем идентификатор пользователя const userId = getId(); // сохраняем информацию о пользователе в локальном хранилище storage.set<UserInfo>(USER_INFO, { userName: trimmed, userId }); // и перезагружаем локацию location.reload(); }; return ( <section> <h1 className="title">Welcome, friend!</h1> <form onSubmit={setUserInfo} className="flex flex-col items-center gap-4"> <div className="flex flex-col gap-2"> <label htmlFor="username" className="text-lg flex items-center justify-center" > <span className="mr-1"> <FiUser /> </span> <span>What is your name?</span> </label> <input type="text" id="username" name="userName" value={userName} onChange={changeUserName} required autoComplete="off" className="input" /> </div> <button className="btn-success">Start chat</button> </form> </section> ); };
Инкапсулируем логику обработки событий сокетов в кастомном хуке (useChat.ts):
import { Message, Prisma } from "@prisma/client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { io, Socket } from "Socket.IO-client"; import { SERVER_URI, USER_INFO } from "../constants"; import { MessageUpdatePayload, UserInfo } from "../types"; import { storage } from "../utils"; // экземпляр сокета let socket: Socket; export const useChat = () => { const userInfo = storage.get<UserInfo>(USER_INFO) as UserInfo; // это важно: один пользователь - один сокет if (!socket) { socket = io(SERVER_URI, { // помните сигнатуру объекта `handshake` на сервере? query: { userName: userInfo.userName } }); } const [messages, setMessages] = useState<Message[]>(); const [log, setLog] = useState<string>(); useEffect(() => { // подключение/отключение пользователя socket.on("log", (log: string) => { setLog(log); }); // получение сообщений socket.on("messages", (messages: Message[]) => { setMessages(messages); }); socket.emit("messages:get"); }, []); // отправка сообщения const send = useCallback((payload: Prisma.MessageCreateInput) => { socket.emit("message:post", payload); }, []); // обновление сообщения const update = useCallback((payload: MessageUpdatePayload) => { socket.emit("message:put", payload); }, []); // удаление сообщения const remove = useCallback((payload: Prisma.MessageWhereUniqueInput) => { socket.emit("message:delete", payload); }, []); // очистка сообщения - для отладки при разработке // можно вызывать в консоли браузера, например window.clearMessages = useCallback(() => { socket.emit("messages:clear"); location.reload(); }, []); // операции const chatActions = useMemo( () => ({ send, update, remove }), [] ); return { messages, log, chatActions }; };
Наконец, экран чата (ChatScreen.tsx):
import React, { useEffect, useState } from "react"; import { FiEdit2, FiSend, FiTrash } from "react-icons/fi"; import { MdOutlineClose } from "react-icons/md"; import TimeAgo from "react-timeago"; import { Slide, toast, ToastContainer } from "react-toastify"; import { USER_INFO } from "../constants"; import { useChat } from "../hooks/useChat"; import { UserInfo } from "../types"; import { storage } from "../utils"; // уведомление о подключении/отключении пользователя const notify = (message: string) => toast.info(message, { position: "top-left", autoClose: 1000, hideProgressBar: true, transition: Slide }); export const ChatScreen = () => { const userInfo = storage.get<UserInfo>(USER_INFO) as UserInfo; const { userId, userName } = userInfo; // получаем сообщения, лог и операции const { messages, log, chatActions } = useChat(); const [text, setText] = useState(""); // индикатор состояния редактирования сообщения const [editingState, setEditingState] = useState(false); // идентификатор редактируемого сообщения const [editingMessageId, setEditingMessageId] = useState(0); const changeText = (e: React.ChangeEvent<HTMLInputElement>) => { setText(e.target.value); }; const sendMessage = (e: React.FormEvent) => { e.preventDefault(); const trimmed = text.trim(); if (!trimmed) return; const message = { userId, userName, text }; // если компонент находится в состоянии редактирования if (editingState) { // обновляем сообщение chatActions.update({ id: editingMessageId, text }); setEditingState(false); // иначе } else { // отправляем сообщение chatActions.send(message); } setText(""); }; const removeMessage = (id: number) => { chatActions.remove({ id }); }; // эффект для отображения уведомлений при изменении лога useEffect(() => { if (!log) return; notify(log); }, [log]); return ( <> <h1 className="title">Let's Chat</h1> <div className="flex-1 flex flex-col"> {messages && messages.length > 0 && messages.map((message) => { // определяем принадлежность сообщения пользователю const isMsgBelongsToUser = message.userId === userInfo.userId; return ( <div key={message.id} // цвет фона сообщения зависит от 2 факторов: // 1) принадлежность пользователю; // 2) состояние редактирования className={[ "my-2 p-2 rounded-md text-white w-1/2", isMsgBelongsToUser ? "self-end bg-green-500" : "self-start bg-blue-500", editingState ? "bg-gray-300" : "" ].join(" ")} > <div className="flex justify-between text-sm mb-1"> <p> By <span>{message.userName}</span> </p> <TimeAgo date={message.createdAt} /> </div> <p>{message.text}</p> {/* пользователь может редактировать и удалять только принадлежащие ему сообщения */} {isMsgBelongsToUser && ( <div className="flex justify-end gap-2"> <button disabled={editingState} className={`${ editingState ? "hidden" : "text-orange-500" }`} // редактирование сообщения onClick={() => { setEditingState(true); setEditingMessageId(message.id); setText(message.text); }} > <FiEdit2 /> </button> <button disabled={editingState} className={`${ editingState ? "hidden" : "text-red-500" }`} // удаление сообщения onClick={() => { removeMessage(message.id); }} > <FiTrash /> </button> </div> )} </div> ); })} </div> {/* отправка сообщения */} <form onSubmit={sendMessage} className="flex items-stretch"> <div className="flex-1 flex"> <input type="text" id="message" name="message" value={text} onChange={changeText} required autoComplete="off" className="input flex-1" /> </div> {editingState && ( <button className="btn-error" type="button" // отмена редактирования onClick={() => { setEditingState(false); setText(""); }} > <MdOutlineClose fontSize={18} /> </button> )} <button className="btn-primary"> <FiSend fontSize={18} /> </button> </form> {/* контейнер для уведомлений */} <ToastContainer /> </> ); };
На этом разработка клиента также завершена. Посмотрим, как выглядит наш чат и убедимся в его работоспособности.
Результат
Находясь в корневой директории проекта, выполняем команду yarn dev и открываем 2 вкладки браузера по адресу http://localhost:3000 (хотя бы одну вкладку необходимо открыть в режиме инкогнито):
Вводим имя пользователя, например, Bob в одной из вкладок и нажимаем Start chat:
Делаем тоже самое (только с другим именем, например, Alice) в другой вкладке:
Получаем в первой вкладке сообщение о подключении Alice.
Данные пользователя можно найти в разделе Storage -> Local Storage вкладки Application инструментов разработчика в браузере:
Обмениваемся сообщениями:
Как проверить, что сообщения записываются в БД? С Prisma — сделать это проще простого. Выполняем команду yarn prisma studio и в открывшейся по адресу http://localhost:5555 вкладке выбираем модель Message:
Пробуем редактировать и удалять сообщения — все работает, как ожидается:
Закрываем одну из вкладок:
Получаем во второй вкладке сообщение об отключении Alice.
Открываем консоль инструментов разработчика и вызываем метод clearMessages. Все сообщения удаляются, вкладка перезагружается:
Пожалуй, это все, чем я хотел поделиться с вами в этой статье. Надеюсь, вы нашли для себя что-то интересное и не зря потратили время. Благодарю за внимание и happy coding!
ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/680670/

Добавить комментарий