Сегодня я собираюсь поделиться знаниями, которые я получил при разработке своего первого приложения WebSocket с использованием TypeScript и NestJS. Основной целью этой части была разработка простого приложения для веб-чата в реальном времени.
План
Поскольку я никогда раньше не разрабатывал приложение WebSocket, я решил сосредоточиться на том, что я считал наиболее важным: на бэкэнде, и отложил в сторону внешний клиент. Я сосредоточился на разработке сервера, который будет обрабатывать соединения, отключения и сообщения.
Бэкенд
Первое, что я сделал, это создал свой проект NestJS с помощью:
nest new realtime-chat
После этого мне пришлось внести некоторые изменения в файл main.ts. Я решил создать приложение NestExpressApplication, которое использует порт 3002, разрешает клиентский доступ через HTML, и мне также пришлось включить CORS для своего приложения. Вот как выглядел мой файл main.ts после внесения всех этих изменений:
import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { NestExpressApplication } from '@nestjs/platform-express'; import { join } from 'path'; import { CustomIoAdapter } from './custom.io.adapter'; async function bootstrap() { const app = await NestFactory.create<NestExpressApplication>(AppModule); app.enableCors(); app.useStaticAssets(join(__dirname, '..', 'public')); app.setViewEngine('html'); app.useWebSocketAdapter(new CustomIoAdapter(app)); await app.listen(3002); } bootstrap();
А это мой CustomIoAdapter.ts:
import { IoAdapter } from '@nestjs/platform-socket.io'; import { Server } from 'socket.io'; export class CustomIoAdapter extends IoAdapter { createIOServer(port: number, options?: any): Server { const server = super.createIOServer(port, options); return server; } bindClientConnect(server: Server, callback: (...args: any[]) => void) { server.on('connection', callback); } bindClientDisconnect(client: Server, callback: (...args: any[]) => void) { client.on('disconnect', callback); } }
Этот адаптер был необходим для использования сервера socket.io, потому что при использовании вложенного WsAdapter он не мог правильно взаимодействовать с моим клиентом socket.io. Он всегда имел значение undefined Socket.id. Это решило мою проблему. (Для следующего P.O.C я планирую изучить, будет ли полезен RedisIoAdapter.)
В моем app.module.ts мне нужно было включить ServeStaticModule для папки public, которую я создал в корне проекта, содержащего HTML-клиент. Кроме того, мне пришлось настроить шлюз, о чем я расскажу позже.
import { Module } from '@nestjs/common'; import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; import { ChatGateway } from './real-time-chat/chat.gateway'; @Module({ imports: [ ServeStaticModule.forRoot({ rootPath: join(__dirname, '..', 'public'), }), ], controllers: [], providers: [ChatGateway], }) export class AppModule {}
После предоставления и импорта я создал chat.gateway.ts
// src/websocket/game.gateway.ts import { Logger, UsePipes, ValidationPipe } from '@nestjs/common'; import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; @UsePipes(new ValidationPipe()) @WebSocketGateway({ cors: { origin: '*', }, credentials: true, }) export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server: Server; private players: Set<Socket> = new Set(); private chat: Set<{ sender: string; message: string }> = new Set(); private connectionLogger = new Logger('connectionLogger'); private eventLogger = new Logger('eventLogger'); @SubscribeMessage('message') handleMessage(client: Socket, payload: string): void { this.eventLogger.log(`Client ${client.id} sent: ${payload}`); const { sender, message, timeSent } = JSON.parse(payload).data; this.chat.add(JSON.parse(payload).data); this.players.forEach(async (client) => { await client.emit('message', { sender, message, timeSent }); }); } handleConnection(client: Socket): void { this.players.add(client); this.connectionLogger.log(`client ${client.id} joined`); this.chat.forEach((conversa) => { client.send(conversa); }); } handleDisconnect(client: Socket): void { this.players.delete(client); this.connectionLogger.log(`client ${client.id} left`); } }
Подход к обработке подключений следующий: после подключения сокета к серверу WebSocket сервер регистрирует с помощью connectionLogger, а также отправляет событие для получения всех сообщений, которые сохраняются на сервере.
Это было одним из моих вызовов. Мне нужно было понять разницу между Socket.emit и Socket.send. Метод Socket.emit позволяет создавать настраиваемые события и отправлять объекты, а метод Socket.send отправляет сообщения, полученные с помощью message. событие и отправляет строку.
Я решил использовать оба, чтобы продемонстрировать их использование. Идея состоит в том, чтобы отправить событие двумя разными способами и захватить его, используя один и тот же прослушиватель на стороне клиента.
Самая важная часть — это когда клиент отправляет сообщение. Этим занимается
@SubscribeMessage('message') handleMessage(client: Socket, payload: string): void { this.eventLogger.log(`Client ${client.id} sent: ${payload}`); const { sender, message, timeSent } = JSON.parse(payload).data; this.chat.add(JSON.parse(payload).data); this.players.forEach(async (client) => { await client.emit('message', { sender, message, timeSent }); }); }
Первое, что делает сервер, — регистрирует с помощью eventLogger, после чего я сохраняю сообщение чата в массиве чата и для каждого клиента, подключенного к серверу, выдаю сообщение мероприятие со всей информацией.
Внешний интерфейс
<!-- index.html --> <!DOCTYPE html> <html> <head> <title>WebSocket chat</title> <style> textarea::-webkit-scrollbar { width: 10px; height: 10px; border-radius: 10px; } textarea::-webkit-scrollbar-thumb { background-color: #ccc; border-radius: 10px; } body { background-image: url('https://source.unsplash.com/random'); background-repeat: no-repeat; background-size: cover; font-family: Arial, sans-serif; /* margin: 20px; */ background-color: #f5f5f5; height: 100%; width: 100%; margin: 0; padding: 0; } h1 { font-family: sans-serif; font-size: 40px; font-weight: bold; color: #000; text-align: center; margin-top: 100px; /* Add some modern CSS properties */ text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid #ccc; } #messages { width: 100%; height: 200px; font-size: 16px; outline: none; background-color: #f1f1f1; border-radius: 20px; resize: none; } .message { margin-bottom: 5px; } .message.sent { background-color: #4caf50; color: white; } .message.received { background-color: #ccc; color: black; } .chat-input-container { display: flex; align-items: center; max-width: 100%; border-radius: 30px; background-color: #f1f1f1; box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.1); overflow: hidden; } .chat-input { flex: 1; padding: 12px 20px; font-size: 16px; border: none; outline: none; background-color: transparent; } .send-button { padding: 10px 20px; background-color: #4caf50; color: #ffffff; border: none; border-radius: 0 30px 30px 0; cursor: pointer; transition: background-color 0.2s; } .send-button:hover { background-color: #45a049; } </style> </head> <body> <script src="https://cdn.socket.io/4.6.0/socket.io.min.js" integrity="sha384-c79GN5VsunZvi+Q/WObgk2in0CbZsHnjEqvFxC5DxHn9lTfNce2WW6h2pH6u/kF+" crossorigin="anonymous" ></script> <h1>WebSocket Chat</h1> <textarea id="messages" readonly class="message" rows="10" cols="50" ></textarea> <div class="chat-input-container"> <input type="text" class="chat-input" id="message" placeholder="Type your message..." /> <button class="send-button" id="sendButton" onclick="sendMessage()"> Send </button> </div> <script> const socket = io('ws://192.168.210.107:3002'); let sender; sender = sessionStorage.getItem('sender'); socket.on('connect', () => { // Get or generate a unique sender name for this client session if (!sender) { sender = socket.id; sessionStorage.setItem('sender', sender); } }); socket.on('message', (event) => { console.log(event); let data = event; const messagesTextarea = document.getElementById('messages'); messagesTextarea.value += `${data.timeSent} ${data.sender} says:\n${data.message}\n`; messagesTextarea.scrollTop = messagesTextarea.scrollHeight; // Scroll to the bottom }); function sendMessage() { const messageInput = document.getElementById('message'); const message = messageInput.value; if (message.trim() !== '') { const payload = { event: 'message', data: { sender, message, timeSent: new Date(Date.now()).toLocaleString(), }, }; socket.send(JSON.stringify(payload)); messageInput.value = ''; } } </script> </body> </html>
Поскольку клиент довольно прост, он подключается к серверу, устанавливает Socket.id в StorageSession (каждая вкладка уникальна) в качестве отправителя и взаимодействует с сервером.
Полный проект можно найти на моем Github. (ссылка в справочниках)
Демо
https://vimeo.com/852853457?share=copy
Открытые вопросы
Как мне вообще это проверить? (Юнит и E2E)
Это лучший способ создания WebSockets?
Должен ли я использовать конечные точки вместо только событий?
Рекомендации
Шлюзы веб-сокетов NestJS:
Адаптеры веб-сокетов NestJS:
Socket.emit x Socket.send:
Полный проект: