Современный веб стремительно эволюционирует от статичных страниц к динамичным приложениям, где пользователи ожидают мгновенной реакции на свои действия. Представим, что вы отправляете сообщение другу, и для получения ответа вам приходится постоянно обновлять страницу. Утомительно, не правда ли? Именно эту проблему решают приложения реального времени, позволяя создавать такие сервисы как мессенджеры, онлайн-игры и системы уведомлений. Реальное время в контексте веб-приложений означает практически мгновенную доставку информации между сервером и клиентом без явных запросов со стороны пользователя. Это создает ощущение непрерывного взаимодействия, приближая опыт пользования веб-приложением к настольному.
Создание чата в реальном времени: мощь SignalR и C# в действии
Но почему традиционная модель HTTP не справляется с такой задачей? Классическая архитектура веб-приложений построена на принципе "запрос-ответ". Клиент отправляет запрос, сервер его обрабатывает и возвращает ответ, после чего соединение закрывается. Для получения новых данных клиент должен инициировать новый запрос. Это порождает несколько фундаментальных проблем:
1. Высокая латентность — постоянное установление нового соединения требует времени.
2. Излишняя нагрузка на сервер — каждый запрос создает новое соединение.
3. Сложность реализации механизма уведомлений — сервер не может сам инициировать отправку данных клиенту.
Попытки решить эти проблемы привели к созданию различных подходов:- Polling (опрос) — клиент периодически опрашивает сервер на наличие изменений. Простой метод, но создает избыточный трафик и не обеспечивает мгновенность.
- Long Polling — клиент делает запрос, а сервер удерживает соединение открытым до появления новых данных. Лучше, но по-прежнему неэфективно.
- Server-Sent Events — однонаправленный канал коммуникации от сервера к клиенту. Решает проблему уведомлений, но не полноценного общения.
- WebSockets — протокол полнодуплексной связи поверх TCP-соединения. Лучшее решение, но требует поддержки браузера и серверной инфраструктуры.
И появляется SignalR — библиотека от Microsoft, которая элегантно разрешает головную боль разработчиков, предоставляя единый API для реализации коммуникации в реальном времени. SignalR автоматически выбирает оптимальный транспорт для каждого конкретного случая, начиная с WebSockets и деградируя до других механизмов при необходимости.
Главная фишка SignalR — абстрагирование сложности. Разработчику не нужно беспокоиться о том, какой транспорт использовать, как обрабатывать соединения и переподключения. Библиотека сама заботится об этих тонкостях, предоставляя удобный программный интерфейс через механизм "хабов" (Hubs).
В этой статье мы погрузимся в детали работы SignalR и создадим полноценное чат-приложение с нуля, используя C# и .NET Core. Вы узнаете не только как запустить базовую версию чата, но и как добавить продвинутые функции — приватные сообщения, управление пользователями, нотификации о наборе текста и многое другое.
Ошибка "Protocol error: Unknown transport" при использовании SignalR Всем привет!
Кто-то освоил SignalR ?
Отобразил у себя код из туториала:... Приватные сообщения SignalR я разрабатываю систему приватного обмена сообщениями между пользователями на сайте (примерно как в... Использование SignalR в Silverlight Уважаемые Гуру!
1. Кто может подсказать ссылки на примеры использования signalR с silverlight.
2.... Написать Чат не используя готовые технологии такие, как например SignalR, и тому подобные Вобщем суть в самом сабж. Нужно написать Чат не используя готовые технологии такие, как например...
Основы SignalR
История SignalR начинается в 2011 году, когда Дэвид Фаулер и Дэмиан Эдвардс, инженеры Microsoft, решили создать библиотеку для упрощения разработки приложений реального времени. В тот период WebSocket протокол еще не был стандартизирован, а большинство браузеров его не поддерживали. Разработчикам приходилось использовать неэффективные методы вроде AJAX-опроса или длинных запросов, что создавало массу проблем с производительностью и масштабируемостью. Первая версия SignalR вышла в 2013 году как часть ASP.NET и сразу получила признание в сообществе разработчиков. Библиотека предложила элегантное решение — абстрагирование от транспортного слоя коммуникации. Разработчик пишет код, не задумываясь о том, как именно будут передаваться данные, а SignalR сам выбирает оптимальный способ связи между клиентом и сервером.
С появлением ASP.NET Core библиотека была полностью переработана. Новая версия, ASP.NET Core SignalR, получила улучшенную архитектуру, кроссплатформенность и поддержку новых протоколов. Если первая версия была привязана к jQuery на клиентской стороне, то новая предлагает клиентские библиотеки для JavaScript, .NET, Java и даже мобильных платформ.
Архитектура и принципы работы
Архитектура SignalR строится вокруг двух ключевых концепций: соединения (Connections) и хабы (Hubs).
Соединения представляют абстракцию над транспортным уровнем. Они управляют физическим каналом связи между клиентом и сервером, заботясь о выборе протокола, поддержании состояния соединения и переподключении при необходимости.
Хабы являются высокоуровневым API для организации взаимодействия. Они позволяют серверу и клиенту вызывать методы друг друга напрямую, словно объекты находятся в одном адресном пространстве. Это делает код более читаемым и интуитивно понятным.
C# | 1
2
3
4
5
6
7
8
| // Пример простейшего хаба в SignalR
public class ChatHub : Hub
{
public async Task SendMessage(string user, string message)
{
await Clients.All.SendAsync("ReceiveMessage", user, message);
}
} |
|
В этом примере метод SendMessage может быть вызван с клиента, а метод ReceiveMessage будет вызван на всех подключенных клиентах. Магия SignalR скрывает всю сложность реализации такой двусторонней коммуникации.
Двусторонняя коммуникация в деталях
В традиционной модели HTTP клиент инициирует запрос, сервер отвечает, и соединение закрывается. SignalR же устанавливает постоянное соединение, через которое обе стороны могут отправлять сообщения в любой момент. Это называется полнодуплексной связью.
Ключевое преимущество такого подхода — способность сервера инициировать отправку данных клиенту без предварительного запроса. Представьте: пользователь отправил сообщение в чат, сервер получил его и тут же разослал всем участникам беседы. Никакого постоянного опроса сервера, никакой лишней нагрузки — только нужный трафик в нужное время.
Хабы — сердце SignalR
Хабы в SignalR — это классы, наследующие от базового класса Hub . Они служат точкой входа для клиентских вызовов и центром рассылки сообщений. Каждый публичный метод хаба автоматически становится доступным для вызова с клиента. Внутри хаба доступно несколько способов адресации клиентов:
Clients.All — все подключенные клиенты,
Clients.Caller — только клиент, инициировавший вызов,
Clients.Others — все, кроме вызывающего клиента,
Clients.Client(connectionId) — конкретный клиент по идентификатору,
Clients.Group(groupName) — клиенты, входящие в определенную группу.
Группы позволяют логически объединять клиентов для адресной рассылки сообщений. Например в чат-приложении группы могут соответствовать разным чат-комнатам.
C# | 1
2
3
4
5
6
| // Добавление клиента в группу
public async Task JoinRoom(string roomName)
{
await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
await Clients.Group(roomName).SendAsync("UserJoined", Context.ConnectionId);
} |
|
Транспортные протоколы и механизм fallback
SignalR поддерживает несколько транспортных протоколов и автоматически выбирает наиболее эффективный из доступных:
1. WebSockets — самый современный и эффективный протокол, обеспечивающий истинную двунаправленную связь через одно TCP-соединение. Поддерживается всеми современными браузерами, но может быть заблокирован некоторыми прокси-серверами и файерволами.
2. Server-Sent Events (SSE) — позволяет серверу отправлять данные клиенту через постоянное HTTP-соединение, но не поддерживает отправку данных от клиента к серверу в рамках того же соединения.
3. Long Polling — клиент отправляет запрос, который сервер удерживает открытым до появления новых данных или истечения таймаута. После получения ответа клиент немедленно отправляет новый запрос.
Если наиболее предпочтительный транспорт (WebSockets) недоступен, SignalR автоматически переключится на следующий в списке приоритета. Это называется механизмом fallback (деградации). Причем происходит это совершенно прозрачно для разработчика — код не меняется в зависимости от используемого транспорта.
Процесс установки соединения
Когда клиент пытается установить соединение с сервером SignalR, происходит следующая последовательность действий:
1. Клиент запрашивает у сервера информацию о поддерживаемых транспортах.
2. Сервер возвращает список, отсортированный по приоритету.
3. Клиент пытается установить соединение, начиная с самого приоритетного транспорта.
4. Если попытка неудачна, клиент переходит к следующему транспорту в списке.
Это обеспечивает максимальную совместимость с различными браузерами и сетевыми условиями. В новых версиях ASP.NET Core SignalR механизм согласования транспорта стал еще умнее, что позволяет быстрее установить соединение и уменьшить объем передаваемых при этом данных. Благодаря этим механизмам SignalR выигрывает по сравнению с ручной реализацией WebSockets или других способов реального времени. Разработчику не нужно писать код для обработки всех возможных сценариев — библиотека делает это автоматически.
Форматы сообщений и сериализация в SignalR
SignalR не просто передает данные между клиентом и сервером — он упаковывает их в специальные форматы для эффективной передачи. ASP.NET Core SignalR поддерживает два встроенных протокола сериализации: JSON и MessagePack.
JSON Protocol используется по умолчанию. Это текстовый формат, понятный человеку и широко поддерживаемый всеми платформами. Однако у него есть недостатки: относительно большой размер сообщений и медленная десериализация сложных объектов.
MessagePack Protocol — бинарный формат сериализации, который дает значительный прирост производительности. Сообщения в MessagePack весят меньше и обрабатываются быстрее, что особенно заметно при передаче больших объемов данных.
C# | 1
2
3
4
5
6
7
8
9
| // Настройка MessagePack в качестве протокола сериализации
services.AddSignalR()
.AddMessagePackProtocol(options =>
{
options.FormatterResolvers = new List<MessagePack.IFormatterResolver>()
{
MessagePack.Resolvers.StandardResolver.Instance
};
}); |
|
Отличительная особенность ASP.NET Core SignalR в том, что он позволяет разработчикам создавать собственные протоколы сериализации, если встроенных недостаточно для конкретных задач. Это открывает дополнительные возможности для оптимизации производительности.
Жизненный цикл соединения
Понимание жизненного цикла соединения критически важно при работе с SignalR. Каждое соединение проходит через несколько этапов, на каждом из которых можно перехватить события и выполнить необходимую логику.
1. Установка соединения — клиент инициирует соединение, происходит согласование транспорта.
2. Подключение — вызывается метод OnConnectedAsync() хаба, где можно выполнить логику инициализации клиента.
3. Взаимодействие — клиент и сервер обмениваются сообщениями через вызовы методов.
4. Отключение — вызывается метод OnDisconnectedAsync() хаба, где можно выполнить логику очистки.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public class ChatHub : Hub
{
public override async Task OnConnectedAsync()
{
await Clients.All.SendAsync("UserConnected", Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception exception)
{
await Clients.All.SendAsync("UserDisconnected", Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
} |
|
SignalR автоматически восстанавливает разорванные соединения, если это возможно. Клиентская библиотека предоставляет настраиваемую политику переподключения, позволяющую указать количество попыток, задержки между ними и другие параметры.
Сравнение с другими технологиями реального времени
На рынке существует несколько альтернатив SignalR, каждая со своими преимуществами и недостатками.
Socket.IO — популярная JavaScript-библиотека для веб-сокетов. В отличие от SignalR, ориентирована преимущественно на Node.js. Предлагает схожую функциональность: автоматический выбор транспорта, группы клиентов, восстановление соединений. Однако интеграция с экосистемой .NET может быть сложнее.
Firebase Realtime Database предлагает механизм синхронизации данных в реальном времени с облачной базой данных. Удобен для быстрого прототипирования и простых приложений. SignalR дает больше контроля над серверной логикой и лучше интегрируется с .NET, но требует больше ручной работы.
Pusher — облачный сервис для добавления функций реального времени в приложения. Предоставляет SDK для различных платформ, включая .NET. Преимущество — отсутствие необходимости настраивать инфраструктуру, недостаток — ежемесячная плата и ограничения на бесплатном тарифе.
gRPC — фреймворк от Google для высокопроизводительного RPC (Remote Procedure Call). Поддерживает потоковую передачу данных, что может использоваться для реализации коммуникации в реальном времени. Отличается от SignalR фокусом на общении между серверами, а не с браузером.
Главные преимущества SignalR:- Тесная интеграция с .NET экосистемой.
- Простота использования с минимумом настройки.
- Надежный механизм fallback.
- Встроенная поддержка групп и адресации клиентов.
- Автоматическое управление соединениями.
Ключевые преимущества над традиционными методами
По сравнению с традиционными методами коммуникации в веб, SignalR обеспечивает ряд существенных преимуществ:
1. Снижение латентности — постоянное соединение устраняет задержки на установление связи при каждом обмене данными.
2. Уменьшение нагрузки на сервер — отсутствие необходимости обрабатывать множество отдельных HTTP-запросов для поллинга снижает нагрузку на CPU и память.
3. Экономия трафика — передаются только реальные изменения, а не результаты периодических проверок.
4. Упрощение кода — абстракция Хабов делает логику приложения более понятной, устраняя необходимость в сложном управлении AJAX-запросами.
5. Масштабируемость — SignalR имеет встроенные механизмы для работы в кластере серверов.
Обработка состояния и контекст соединения
При работе с SignalR важно понимать особенности управления состоянием. В отличие от классических веб-приложений, где состояние хранится в сессии, SignalR работает с контекстом соединения. Каждому соединению присваивается уникальный идентификатор ConnectionId , доступный через свойство Context.ConnectionId внутри хаба. Этот идентификатор часто используется для отслеживания пользователей и адресации сообщений.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // Пример сохранения соответствия между пользователями и их ConnectionId
private static Dictionary<string, string> _userConnections = new Dictionary<string, string>();
public async Task Login(string username)
{
_userConnections[username] = Context.ConnectionId;
await Clients.All.SendAsync("UserLoggedIn", username);
}
public async Task SendPrivateMessage(string toUser, string message)
{
if (_userConnections.TryGetValue(toUser, out string connectionId))
{
await Clients.Client(connectionId).SendAsync("ReceivePrivateMessage", Context.User.Identity.Name, message);
}
} |
|
Стоит отметить, что этот подход имеет ограничения при масштабировании на несколько серверов. В таких случаях рекомендуется использовать внешнее хранилище для сопоставления пользователей и их соединений, например, Redis.
Безопасность в SignalR
Безопасность — критически важный аспект любого приложения реального времени. SignalR предоставляет несколько механизмов защиты:
1. Авторизация на уровне Хабов — можно ограничить доступ к хабу или отдельным методам с помощью атрибутов авторизации.
C# | 1
2
3
4
5
6
7
8
9
| [Authorize]
public class SecureHub : Hub
{
[Authorize(Roles = "Admin")]
public async Task AdminOnlyMethod()
{
// Только для администраторов
}
} |
|
2. Cross-Origin Resource Sharing (CORS) — настройка разрешенных источников запросов.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| public void ConfigureServices(IServiceCollection services)
{
services.AddCors(options =>
{
options.AddPolicy("ClientPermission", policy =>
{
policy.AllowAnyHeader()
.AllowAnyMethod()
.WithOrigins("https://example.com")
.AllowCredentials();
});
});
services.AddSignalR();
} |
|
3. Защита от подделки запросов — SignalR включает механизмы предотвращения CSRF-атак.
4. Шифрование соединения — рекомендуется всегда использовать HTTPS для защиты передаваемых данных.
Мониторинг соединений
При разработке приложений реального времени часто возникает необходимость мониторить активные соединения и их состояние. SignalR предоставляет несколько подходов к этому:
1. События жизненного цикла — отслеживание подключений и отключений через переопределение методов OnConnectedAsync и OnDisconnectedAsync .
2. Счетчики производительности — ASP.NET Core имеет встроенные метрики для SignalR, доступные через механизм диагностики.
C# | 1
2
3
4
5
6
| // Подключение диагностики SignalR
services.AddSignalR()
.AddHubOptions<ChatHub>(options =>
{
options.EnableDetailedErrors = true;
}); |
|
3. Логирование — настройка детального логирования событий SignalR.
C# | 1
2
3
4
5
6
| // Настройка логирования
services.AddLogging(builder =>
{
builder.AddFilter("Microsoft.AspNetCore.SignalR", LogLevel.Debug);
builder.AddFilter("Microsoft.AspNetCore.Http.Connections", LogLevel.Debug);
}); |
|
Когда использовать SignalR
SignalR не является универсальным решением для всех задач веб-разработки. Он особенно эффективен в следующих сценариях:- Чаты и мессенджеры.
- Доски для совместной работы.
- Игры в реальном времени.
- Мониторинг и дашборды.
- Уведомления и оповещения.
- Совместное редактирование документов.
- Системы аукционов.
Для простых CRUD-операций или редких обновлений данных традиционные REST API могут быть более подходящим выбором из-за их простоты и широкой поддержки.
Практическая часть
Теперь, когда мы разобрались с теоретической базой SignalR, самое время перейти к практике. В этом разделе мы создадим полноценное чат-приложение на C# с использованием ASP.NET Core SignalR. Начнем с настройки среды разработки и пошагово реализуем все необходимые компоненты.
Настройка окружения
Для работы с SignalR нам потребуются следующие инструменты:
1. Visual Studio 2019 или новее (можно использовать Community Edition).
2. .NET Core SDK 3.1 или выше.
3. Node.js и npm для работы с клиентскими библиотеками.
4. Веб-браузер с поддержкой WebSocket (Chrome, Firefox, Edge и т.д.).
Если у вас еще не установлены эти компоненты, скачайте и установите их с официальных сайтов. Убедитесь, что при установке Visual Studio выбран компонент "ASP.NET и веб-разработка".
Создание проекта
Запустите Visual Studio и создайте новый проект, выбрав шаблон "ASP.NET Core Web Application". Назовите проект "SignalRChat" и выберите место для его сохранения. На следующем экране выберите шаблон "Web Application (Model-View-Controller)" и убедитесь, что выбрана версия .NET Core 3.1 или выше. Галочку "Enable Docker Support" можно оставить неотмеченной для простоты. После создания проекта нам нужно установить пакеты SignalR через NuGet. В Visual Studio выберите "Tools" > "NuGet Package Manager" > "Package Manager Console" и выполните следующую команду:
PowerShell | 1
| Install-Package Microsoft.AspNetCore.SignalR |
|
Если вы планируете использовать MessagePack для сериализации, также установите:
PowerShell | 1
| Install-Package Microsoft.AspNetCore.SignalR.Protocols.MessagePack |
|
Создание хаба для чата
SignalR работает через концепцию хабов, поэтому первым делом создадим хаб для нашего чат-приложения. В корне проекта создайте папку "Hubs", а в ней файл ChatHub.cs со следующим содержимым:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;
namespace SignalRChat.Hubs
{
public class ChatHub : Hub
{
public async Task SendMessage(string user, string message)
{
await Clients.All.SendAsync("ReceiveMessage", user, message);
}
public override async Task OnConnectedAsync()
{
await Clients.All.SendAsync("UserConnected", Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception exception)
{
await Clients.All.SendAsync("UserDisconnected", Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}
} |
|
Этот простой хаб предоставляет три возможности:
1. Отправка сообщений всем подключенным клиентам через метод SendMessage .
2. Оповещение о подключении нового пользователя.
3. Оповещение об отключении пользователя.
Настройка SignalR в приложении
Теперь нужно зарегистрировать SignalR и наш хаб в приложении. Откройте файл Startup.cs и внесите следующие изменения:
1. В методе ConfigureServices добавьте регистрацию SignalR:
C# | 1
2
3
4
5
| public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
services.AddSignalR(); // Добавляем SignalR
} |
|
2. В методе Configure добавьте маршрутизацию для хабов:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ... существующий код ...
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
// Регистрируем путь для нашего хаба
endpoints.MapHub<ChatHub>("/chatHub");
});
} |
|
Создание клиентского интерфейса
Теперь создадим простой интерфейс чата. Откройте файл Views/Home/Index.cshtml и замените его содержимое следующим кодом:
HTML5 | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| @{
ViewData["Title"] = "Chat";
}
<div class="container">
<div class="row">
<div class="col-md-8">
<h2>Чат в реальном времени</h2>
<div class="form-group">
<label for="userInput">Ваше имя:</label>
<input type="text" id="userInput" class="form-control" />
</div>
<div class="form-group">
<label for="messageInput">Сообщение:</label>
<input type="text" id="messageInput" class="form-control" />
</div>
<button id="sendButton" class="btn btn-primary">Отправить</button>
</div>
</div>
<div class="row" style="margin-top: 20px;">
<div class="col-md-8">
<h3>Сообщения</h3>
<ul id="messagesList" class="list-group"></ul>
</div>
<div class="col-md-4">
<h3>Пользователи</h3>
<ul id="userList" class="list-group"></ul>
</div>
</div>
</div>
@section Scripts {
<script src="~/lib/microsoft/signalr/dist/browser/signalr.js"></script>
<script src="~/js/chat.js"></script>
} |
|
Добавление клиентской библиотеки SignalR
Нам нужно добавить клиентскую библиотеку SignalR. Создайте файл libman.json в корне проекта (если его еще нет) и добавьте следующее содержимое:
JSON | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| {
"version": "1.0",
"defaultProvider": "unpkg",
"libraries": [
{
"library": "@microsoft/signalr@latest",
"destination": "wwwroot/lib/microsoft/signalr/",
"files": [
"dist/browser/signalr.js",
"dist/browser/signalr.min.js"
]
}
]
} |
|
Затем откройте Package Manager Console и выполните команду:
Это загрузит клиентскую библиотеку SignalR в указанную папку проекта.
Реализация JavaScript логики
Создайте файл wwwroot/js/chat.js и добавьте следующий код для управления клиентской логикой чата:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
| // Создаем подключение к хабу
const connection = new signalR.HubConnectionBuilder()
.withUrl("/chatHub")
.configureLogging(signalR.LogLevel.Information)
.build();
// Список подключенных пользователей
let users = {};
// Обработчик получения сообщений
connection.on("ReceiveMessage", function (user, message) {
const encodedMsg = user + ": " + message;
const li = document.createElement("li");
li.classList.add("list-group-item");
li.textContent = encodedMsg;
document.getElementById("messagesList").appendChild(li);
});
// Обработчик подключения нового пользователя
connection.on("UserConnected", function (connectionId) {
users[connectionId] = connectionId.substr(0, 5); // Используем часть ID как имя для простоты
updateUserList();
const li = document.createElement("li");
li.classList.add("list-group-item", "list-group-item-success");
li.textContent = `Пользователь ${users[connectionId]} подключился`;
document.getElementById("messagesList").appendChild(li);
});
// Обработчик отключения пользователя
connection.on("UserDisconnected", function (connectionId) {
const userName = users[connectionId] || "Неизвестный";
delete users[connectionId];
updateUserList();
const li = document.createElement("li");
li.classList.add("list-group-item", "list-group-item-danger");
li.textContent = `Пользователь ${userName} отключился`;
document.getElementById("messagesList").appendChild(li);
});
// Обновление списка пользователей
function updateUserList() {
const userList = document.getElementById("userList");
userList.innerHTML = "";
for (const id in users) {
const li = document.createElement("li");
li.classList.add("list-group-item");
li.textContent = users[id];
userList.appendChild(li);
}
}
// Обработчик клика по кнопке отправки
document.getElementById("sendButton").addEventListener("click", function (event) {
const user = document.getElementById("userInput").value;
const message = document.getElementById("messageInput").value;
if (user && message) {
connection.invoke("SendMessage", user, message).catch(function (err) {
return console.error(err.toString());
});
document.getElementById("messageInput").value = "";
} else {
alert("Пожалуйста, введите имя и сообщение");
}
event.preventDefault();
});
// Начинаем соединение
connection.start().catch(function (err) {
return console.error(err.toString());
}); |
|
Этот JavaScript-код решает несколько задач:
1. Устанавливает соединение с нашим хабом.
2. Регистрирует обработчики событий для получения сообщений и уведомлений о пользователях.
3. Отправляет сообщения при нажатии на кнопку.
4. Поддерживает актуальный список пользователей.
Запуск и тестирование
Теперь все готово для запуска нашего чат-приложения. Нажмите F5 в Visual Studio для запуска проекта. Должен открыться браузер с нашим приложением. Чтобы проверить, как работает чат с несколькими пользователями, откройте еще одну или несколько вкладок с тем же URL. В каждой вкладке введите разные имена пользователей, напишите несколько сообщений и убедитесь, что они отображаются во всех открытых вкладках практически мгновенно. Также обратите внимание на уведомления о подключении и отключении пользователей при открытии и закрытии вкладок.
Это базовая версия чат-приложения, которая демонстрирует основные возможности SignalR. Но в реальных сценариях часто требуются более сложные функции, такие как приватные сообщения, сохранение истории сообщений, аутентификация пользователей и т.д. В следующей части мы добавим некоторые из этих возможностей в наше приложение.
Реализация приватных сообщений
Теперь усовершенствуем наш чат, добавив возможность отправки приватных сообщений между пользователями. Для этого нам потребуется расширить как серверную, так и клиентскую части приложения.
Сначала добавим новый метод в наш ChatHub :
C# | 1
2
3
4
5
| public async Task SendPrivateMessage(string toUserId, string fromUser, string message)
{
await Clients.User(toUserId).SendAsync("ReceivePrivateMessage", fromUser, message);
await Clients.Caller.SendAsync("ReceivePrivateMessage", fromUser, message);
} |
|
Однако метод Clients.User() работает с идентификатором пользователя, а не идентификатором соединения. Чтобы обойти это ограничение, создадим словарь для хранения соответствия между ConnectionId и именами пользователей:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| private static Dictionary<string, string> _connections = new Dictionary<string, string>();
public async Task RegisterUser(string username)
{
var connectionId = Context.ConnectionId;
_connections[username] = connectionId;
// Обновляем список пользователей у всех клиентов
await Clients.All.SendAsync("UpdateUserList", _connections.Keys.ToList());
}
public async Task SendPrivateMessage(string toUser, string message)
{
if (_connections.TryGetValue(toUser, out string connectionId))
{
var fromUser = _connections.FirstOrDefault(x => x.Value == Context.ConnectionId).Key;
await Clients.Client(connectionId).SendAsync("ReceivePrivateMessage", fromUser, message);
await Clients.Caller.SendAsync("ReceivePrivateMessage", fromUser, message);
}
} |
|
Теперь обновим клиентский JavaScript, чтобы поддержать приватные сообщения:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
| // Добавляем в chat.js
// Обработчик получения приватного сообщения
connection.on("ReceivePrivateMessage", function (from, message) {
const encodedMsg = `[Приватно от ${from}]: ${message}`;
const li = document.createElement("li");
li.classList.add("list-group-item", "private-message");
li.textContent = encodedMsg;
document.getElementById("messagesList").appendChild(li);
});
// Обработчик обновления списка пользователей
connection.on("UpdateUserList", function (users) {
const userList = document.getElementById("userList");
userList.innerHTML = "";
users.forEach(user => {
const li = document.createElement("li");
li.classList.add("list-group-item");
li.textContent = user;
// Добавляем возможность отправки приватного сообщения
li.addEventListener("click", function() {
const recipient = this.textContent;
document.getElementById("recipientInput").value = recipient;
document.getElementById("privateMessageModal").style.display = "block";
});
userList.appendChild(li);
});
});
// Функция для отправки приватного сообщения
document.getElementById("sendPrivateButton").addEventListener("click", function (event) {
const recipient = document.getElementById("recipientInput").value;
const message = document.getElementById("privateMessageInput").value;
if (recipient && message) {
connection.invoke("SendPrivateMessage", recipient, message).catch(function (err) {
return console.error(err.toString());
});
document.getElementById("privateMessageInput").value = "";
document.getElementById("privateMessageModal").style.display = "none";
}
event.preventDefault();
});
// Регистрация пользователя при входе в чат
document.getElementById("registerButton").addEventListener("click", function (event) {
const username = document.getElementById("userInput").value;
if (username) {
connection.invoke("RegisterUser", username).catch(function (err) {
return console.error(err.toString());
});
document.getElementById("registrationForm").style.display = "none";
document.getElementById("chatForm").style.display = "block";
} else {
alert("Пожалуйста, введите имя пользователя");
}
event.preventDefault();
}); |
|
Также добавим модальное окно для отправки приватных сообщений в HTML:
HTML5 | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| <!-- Добавляем в Index.cshtml -->
<div id="privateMessageModal" style="display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: white; padding: 20px; border: 1px solid #ccc; box-shadow: 0 0 10px rgba(0,0,0,0.3);">
<h4>Отправить приватное сообщение</h4>
<div class="form-group">
<label for="recipientInput">Получатель:</label>
<input type="text" id="recipientInput" class="form-control" readonly />
</div>
<div class="form-group">
<label for="privateMessageInput">Сообщение:</label>
<input type="text" id="privateMessageInput" class="form-control" />
</div>
<button id="sendPrivateButton" class="btn btn-primary">Отправить</button>
<button id="cancelPrivateButton" class="btn btn-secondary" onclick="document.getElementById('privateMessageModal').style.display = 'none';">Отмена</button>
</div> |
|
Сохранение истории сообщений
Добавим функционал для сохранения истории сообщений, чтобы новые пользователи могли видеть предыдущие сообщения при подключении.
Создадим модель сообщения:
C# | 1
2
3
4
5
6
7
8
| public class ChatMessage
{
public string User { get; set; }
public string Message { get; set; }
public DateTime Timestamp { get; set; }
public bool IsPrivate { get; set; }
public string Recipient { get; set; }
} |
|
Модифицируем ChatHub , добавив хранение истории:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
| private static readonly List<ChatMessage> _messageHistory = new List<ChatMessage>();
private const int MaxHistorySize = 50; // Ограничиваем количество хранимых сообщений
public async Task GetMessageHistory()
{
await Clients.Caller.SendAsync("ReceiveMessageHistory", _messageHistory);
}
public async Task SendMessage(string user, string message)
{
var chatMessage = new ChatMessage
{
User = user,
Message = message,
Timestamp = DateTime.Now,
IsPrivate = false
};
// Добавляем сообщение в историю
_messageHistory.Add(chatMessage);
// Если история превысила максимальный размер, удаляем старые сообщения
if (_messageHistory.Count > MaxHistorySize)
{
_messageHistory.RemoveAt(0);
}
await Clients.All.SendAsync("ReceiveMessage", user, message, chatMessage.Timestamp);
}
public async Task SendPrivateMessage(string toUser, string message)
{
if (_connections.TryGetValue(toUser, out string connectionId))
{
var fromUser = _connections.FirstOrDefault(x => x.Value == Context.ConnectionId).Key;
var chatMessage = new ChatMessage
{
User = fromUser,
Message = message,
Timestamp = DateTime.Now,
IsPrivate = true,
Recipient = toUser
};
// Добавляем приватное сообщение в историю
_messageHistory.Add(chatMessage);
// Если история превысила максимальный размер, удаляем старые сообщения
if (_messageHistory.Count > MaxHistorySize)
{
_messageHistory.RemoveAt(0);
}
await Clients.Client(connectionId).SendAsync("ReceivePrivateMessage", fromUser, message, chatMessage.Timestamp);
await Clients.Caller.SendAsync("ReceivePrivateMessage", fromUser, message, chatMessage.Timestamp);
}
} |
|
Теперь обновим клиентскую часть для отображения истории:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| // Обработчик получения истории сообщений
connection.on("ReceiveMessageHistory", function (messages) {
const messagesList = document.getElementById("messagesList");
messagesList.innerHTML = ""; // Очищаем текущий список
messages.forEach(msg => {
const encodedMsg = formatMessage(msg);
const li = document.createElement("li");
li.classList.add("list-group-item");
if (msg.isPrivate) {
li.classList.add("private-message");
}
li.textContent = encodedMsg;
messagesList.appendChild(li);
});
});
// Функция форматирования сообщения
function formatMessage(msg) {
const date = new Date(msg.timestamp);
const timeString = date.toLocaleTimeString();
if (msg.isPrivate) {
return `[${timeString}] [Приватно ${msg.user} -> ${msg.recipient}]: ${msg.message}`;
} else {
return `[${timeString}] ${msg.user}: ${msg.message}`;
}
}
// Запрашиваем историю при подключении
connection.start().then(function() {
connection.invoke("GetMessageHistory").catch(function (err) {
return console.error(err.toString());
});
}).catch(function (err) {
return console.error(err.toString());
}); |
|
Индикатор "пользователь печатает"
Добавим индикатор, который показывает, когда пользователь набирает сообщение:
C# | 1
2
3
4
5
6
7
8
9
10
| // Добавляем в ChatHub
public async Task UserIsTyping(string username)
{
await Clients.Others.SendAsync("UserTyping", username);
}
public async Task UserStoppedTyping(string username)
{
await Clients.Others.SendAsync("UserStoppedTyping", username);
} |
|
И соответствующий JavaScript:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| // Добавим переменную для таймера
let typingTimer;
const doneTypingInterval = 1000; // время в мс, после которого считаем, что пользователь закончил печатать
// Оповещаем сервер, когда пользователь начинает печатать
document.getElementById("messageInput").addEventListener("keydown", function() {
clearTimeout(typingTimer);
const username = document.getElementById("userInput").value;
connection.invoke("UserIsTyping", username).catch(function (err) {
return console.error(err.toString());
});
});
// Оповещаем сервер, когда пользователь закончил печатать
document.getElementById("messageInput").addEventListener("keyup", function() {
clearTimeout(typingTimer);
const username = document.getElementById("userInput").value;
typingTimer = setTimeout(function() {
connection.invoke("UserStoppedTyping", username).catch(function (err) {
return console.error(err.toString());
});
}, doneTypingInterval);
});
// Обработчик события "пользователь печатает"
connection.on("UserTyping", function (username) {
const typingIndicator = document.getElementById("typingIndicator");
typingIndicator.textContent = `${username} печатает...`;
typingIndicator.style.display = "block";
});
// Обработчик события "пользователь закончил печатать"
connection.on("UserStoppedTyping", function (username) {
const typingIndicator = document.getElementById("typingIndicator");
typingIndicator.style.display = "none";
}); |
|
Не забудем добавить элемент для отображения индикатора в HTML:
HTML5 | 1
2
| <!-- Добавляем перед списком сообщений -->
<div id="typingIndicator" style="display: none; font-style: italic; color: #888;"></div> |
|
Обработка ошибок и переподключение
Для повышения надежности приложения добавим механизм автоматического переподключения при разрыве соединения:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| // Модифицируем создание соединения
const connection = new signalR.HubConnectionBuilder()
.withUrl("/chatHub")
.withAutomaticReconnect([0, 2000, 10000, 30000]) // Повторные попытки через 0, 2, 10 и 30 секунд
.configureLogging(signalR.LogLevel.Information)
.build();
// Обработчики состояния соединения
connection.onreconnecting(error => {
const statusMessage = `Соединение потеряно. Переподключение... Ошибка: ${error}`;
document.getElementById("connectionStatus").textContent = statusMessage;
document.getElementById("connectionStatus").style.color = "orange";
});
connection.onreconnected(connectionId => {
const statusMessage = `Соединение восстановлено. ID: ${connectionId}`;
document.getElementById("connectionStatus").textContent = statusMessage;
document.getElementById("connectionStatus").style.color = "green";
// Запрашиваем историю сообщений и список пользователей заново
connection.invoke("GetMessageHistory").catch(function (err) {
return console.error(err.toString());
});
});
connection.onclose(error => {
const statusMessage = `Соединение закрыто. Обновите страницу для повторного подключения.`;
document.getElementById("connectionStatus").textContent = statusMessage;
document.getElementById("connectionStatus").style.color = "red";
}); |
|
Добавим элемент для отображения статуса соединения:
HTML5 | 1
2
| <!-- Добавляем в верхнюю часть контейнера -->
<div id="connectionStatus" style="color: green;">Соединение установлено</div> |
|
Реализация аутентификации пользователей
До сих пор наш чат позволял пользователям указывать любое имя без проверки подлинности. В реальном приложении такой подход неприемлем из соображений безопасности. Добавим систему аутентификации, чтобы пользователи входили с использованием логина и пароля. Начнем с создания простой модели пользователя:
C# | 1
2
3
4
5
6
7
| public class ChatUser
{
public string Id { get; set; }
public string Username { get; set; }
public string PasswordHash { get; set; }
public List<string> Connections { get; set; } = new List<string>();
} |
|
Для хранения пользователей создадим сервис:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| public interface IUserService
{
Task<ChatUser> Authenticate(string username, string password);
Task<ChatUser> GetById(string id);
Task<IEnumerable<ChatUser>> GetAll();
}
public class UserService : IUserService
{
// В реальном приложении эти данные хранились бы в базе
private List<ChatUser> _users = new List<ChatUser>
{
new ChatUser { Id = "1", Username = "admin", PasswordHash = BCrypt.Net.BCrypt.HashPassword("admin") },
new ChatUser { Id = "2", Username = "user", PasswordHash = BCrypt.Net.BCrypt.HashPassword("user") }
};
public async Task<ChatUser> Authenticate(string username, string password)
{
var user = _users.SingleOrDefault(x => x.Username == username);
if (user == null || !BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
return null;
return user;
}
public async Task<ChatUser> GetById(string id)
{
return _users.FirstOrDefault(x => x.Id == id);
}
public async Task<IEnumerable<ChatUser>> GetAll()
{
// Возвращаем копию списка без паролей
return _users.Select(u => new ChatUser
{
Id = u.Id,
Username = u.Username,
Connections = u.Connections
});
}
} |
|
Не забудем установить пакет для хеширования паролей:
PowerShell | 1
| Install-Package BCrypt.Net-Next |
|
Зарегистрируем наш сервис в Startup.cs :
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
services.AddSignalR();
// Регистрируем сервис пользователей
services.AddSingleton<IUserService, UserService>();
// Добавляем аутентификацию с куки
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/Account/Login";
options.ExpireTimeSpan = TimeSpan.FromDays(1);
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ... существующий код ...
app.UseAuthentication();
app.UseAuthorization();
// ... остальной код ...
} |
|
Создадим контроллер для авторизации:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| public class AccountController : Controller
{
private readonly IUserService _userService;
public AccountController(IUserService userService)
{
_userService = userService;
}
[HttpGet]
public IActionResult Login()
{
return View();
}
[HttpPost]
public async Task<IActionResult> Login(string username, string password)
{
var user = await _userService.Authenticate(username, password);
if (user == null)
{
ModelState.AddModelError("", "Неверное имя пользователя или пароль");
return View();
}
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.NameIdentifier, user.Id)
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties { IsPersistent = true });
return RedirectToAction("Index", "Home");
}
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return RedirectToAction("Login");
}
} |
|
Создадим представление для входа (Views/Account/Login.cshtml ):
HTML5 | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| @{
ViewData["Title"] = "Вход";
}
<div class="row">
<div class="col-md-4 offset-md-4">
<h2>Вход в чат</h2>
<form asp-action="Login" method="post">
<div class="text-danger" asp-validation-summary="All"></div>
<div class="form-group">
<label for="username">Имя пользователя</label>
<input type="text" id="username" name="username" class="form-control" required />
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" class="form-control" required />
</div>
<button type="submit" class="btn btn-primary">Войти</button>
</form>
</div>
</div> |
|
Теперь защитим наш хаб, добавив атрибут авторизации:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
| [Authorize]
public class ChatHub : Hub
{
private readonly IUserService _userService;
private static readonly List<ChatMessage> _messageHistory = new List<ChatMessage>();
private const int MaxHistorySize = 50;
public ChatHub(IUserService userService)
{
_userService = userService;
}
public override async Task OnConnectedAsync()
{
// Получаем ID пользователя из контекста
var userId = Context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var username = Context.User.Identity.Name;
if (userId != null)
{
var user = await _userService.GetById(userId);
// Добавляем текущее подключение к списку соединений пользователя
user.Connections.Add(Context.ConnectionId);
// Уведомляем всех о подключении пользователя
await Clients.All.SendAsync("UserConnected", username, (await _userService.GetAll())
.Select(u => u.Username).ToList());
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception exception)
{
var userId = Context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var username = Context.User.Identity.Name;
if (userId != null)
{
var user = await _userService.GetById(userId);
// Удаляем соединение из списка
user.Connections.Remove(Context.ConnectionId);
// Уведомляем всех об отключении только если нет других активных соединений
if (user.Connections.Count == 0)
{
await Clients.All.SendAsync("UserDisconnected", username, (await _userService.GetAll())
.Where(u => u.Connections.Count > 0)
.Select(u => u.Username).ToList());
}
}
await base.OnDisconnectedAsync(exception);
}
// Обновленные методы для работы с авторизацией
public async Task SendMessage(string message)
{
var username = Context.User.Identity.Name;
var chatMessage = new ChatMessage
{
User = username,
Message = message,
Timestamp = DateTime.Now,
IsPrivate = false
};
_messageHistory.Add(chatMessage);
if (_messageHistory.Count > MaxHistorySize)
_messageHistory.RemoveAt(0);
await Clients.All.SendAsync("ReceiveMessage", username, message, chatMessage.Timestamp);
}
public async Task SendPrivateMessage(string toUsername, string message)
{
var fromUsername = Context.User.Identity.Name;
var toUser = (await _userService.GetAll()).FirstOrDefault(u => u.Username == toUsername);
if (toUser != null && toUser.Connections.Count > 0)
{
var chatMessage = new ChatMessage
{
User = fromUsername,
Message = message,
Timestamp = DateTime.Now,
IsPrivate = true,
Recipient = toUsername
};
_messageHistory.Add(chatMessage);
if (_messageHistory.Count > MaxHistorySize)
_messageHistory.RemoveAt(0);
// Отправляем сообщение всем соединениям получателя
foreach (var connectionId in toUser.Connections)
{
await Clients.Client(connectionId).SendAsync("ReceivePrivateMessage",
fromUsername, message, chatMessage.Timestamp);
}
// Отправляем копию отправителю
await Clients.Caller.SendAsync("ReceivePrivateMessage",
fromUsername, message, chatMessage.Timestamp);
}
}
} |
|
Обновим наш JavaScript для работы с авторизованными пользователями:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| // Теперь не нужно вводить имя пользователя, оно берется из аутентификации
connection.on("ReceiveMessage", function (user, message, timestamp) {
const date = new Date(timestamp);
const timeString = date.toLocaleTimeString();
const encodedMsg = `[${timeString}] ${user}: ${message}`;
const li = document.createElement("li");
li.classList.add("list-group-item");
li.textContent = encodedMsg;
document.getElementById("messagesList").appendChild(li);
});
// Обновим обработчик отправки сообщения
document.getElementById("sendButton").addEventListener("click", function (event) {
const message = document.getElementById("messageInput").value;
if (message) {
connection.invoke("SendMessage", message).catch(function (err) {
return console.error(err.toString());
});
document.getElementById("messageInput").value = "";
}
event.preventDefault();
}); |
|
Создание чат-комнат
Расширим функциональность нашего чата, добавив возможность создания отдельных комнат для общения. Для этого используем концепцию групп в SignalR. Добавим модель для чат-комнаты:
C# | 1
2
3
4
5
6
7
8
| public class ChatRoom
{
public string Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public DateTime CreatedAt { get; set; }
public HashSet<string> Members { get; set; } = new HashSet<string>();
} |
|
Создадим сервис для управления комнатами:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
| public interface IChatRoomService
{
Task<IEnumerable<ChatRoom>> GetAllRooms();
Task<ChatRoom> GetRoomById(string id);
Task<ChatRoom> CreateRoom(string name, string description, string creatorId);
Task AddUserToRoom(string roomId, string userId);
Task RemoveUserFromRoom(string roomId, string userId);
}
public class ChatRoomService : IChatRoomService
{
private readonly List<ChatRoom> _rooms = new List<ChatRoom>
{
new ChatRoom
{
Id = "1",
Name = "Общий чат",
Description = "Комната для всех пользователей",
CreatedAt = DateTime.Now,
Members = new HashSet<string>()
}
};
public async Task<IEnumerable<ChatRoom>> GetAllRooms()
{
return _rooms;
}
public async Task<ChatRoom> GetRoomById(string id)
{
return _rooms.FirstOrDefault(r => r.Id == id);
}
public async Task<ChatRoom> CreateRoom(string name, string description, string creatorId)
{
var room = new ChatRoom
{
Id = Guid.NewGuid().ToString(),
Name = name,
Description = description,
CreatedAt = DateTime.Now,
Members = new HashSet<string> { creatorId }
};
_rooms.Add(room);
return room;
}
public async Task AddUserToRoom(string roomId, string userId)
{
var room = await GetRoomById(roomId);
if (room != null)
{
room.Members.Add(userId);
}
}
public async Task RemoveUserFromRoom(string roomId, string userId)
{
var room = await GetRoomById(roomId);
if (room != null)
{
room.Members.Remove(userId);
}
}
} |
|
Зарегистрируем этот сервис в Startup.cs :
C# | 1
| services.AddSingleton<IChatRoomService, ChatRoomService>(); |
|
Расширим наш ChatHub для работы с комнатами:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
| [Authorize]
public class ChatHub : Hub
{
private readonly IUserService _userService;
private readonly IChatRoomService _roomService;
private static readonly Dictionary<string, List<ChatMessage>> _roomMessageHistory =
new Dictionary<string, List<ChatMessage>>();
private const int MaxHistorySize = 50;
public ChatHub(IUserService userService, IChatRoomService roomService)
{
_userService = userService;
_roomService = roomService;
}
// Метод для получения списка комнат
public async Task GetRooms()
{
var rooms = await _roomService.GetAllRooms();
await Clients.Caller.SendAsync("ReceiveRooms", rooms);
}
// Метод для создания новой комнаты
public async Task CreateRoom(string name, string description)
{
var userId = Context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId != null)
{
var room = await _roomService.CreateRoom(name, description, userId);
// Инициализируем историю сообщений для новой комнаты
_roomMessageHistory[room.Id] = new List<ChatMessage>();
// Оповещаем всех о новой комнате
await Clients.All.SendAsync("RoomCreated", room);
}
}
// Метод для присоединения к комнате
public async Task JoinRoom(string roomId)
{
var userId = Context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var username = Context.User.Identity.Name;
if (userId != null)
{
// Добавляем пользователя в комнату в нашей модели
await _roomService.AddUserToRoom(roomId, userId);
// Добавляем соединение в группу SignalR
await Groups.AddToGroupAsync(Context.ConnectionId, roomId);
// Получаем историю сообщений комнаты
if (!_roomMessageHistory.ContainsKey(roomId))
{
_roomMessageHistory[roomId] = new List<ChatMessage>();
}
// Отправляем историю сообщений комнаты
await Clients.Caller.SendAsync("ReceiveRoomHistory", roomId, _roomMessageHistory[roomId]);
// Оповещаем участников комнаты о новом пользователе
await Clients.Group(roomId).SendAsync("UserJoinedRoom", roomId, username);
}
}
// Метод для выхода из комнаты
public async Task LeaveRoom(string roomId)
{
var userId = Context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var username = Context.User.Identity.Name;
if (userId != null)
{
// Удаляем пользователя из комнаты в нашей модели
await _roomService.RemoveUserFromRoom(roomId, userId);
// Удаляем соединение из группы SignalR
await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomId);
// Оповещаем участников комнаты об уходе пользователя
await Clients.Group(roomId).SendAsync("UserLeftRoom", roomId, username);
}
}
// Метод для отправки сообщения в комнату
public async Task SendRoomMessage(string roomId, string message)
{
var username = Context.User.Identity.Name;
// Создаем сообщение
var chatMessage = new ChatMessage
{
User = username,
Message = message,
Timestamp = DateTime.Now,
IsPrivate = false
};
// Добавляем сообщение в историю комнаты
if (!_roomMessageHistory.ContainsKey(roomId))
{
_roomMessageHistory[roomId] = new List<ChatMessage>();
}
_roomMessageHistory[roomId].Add(chatMessage);
// Ограничиваем размер истории
if (_roomMessageHistory[roomId].Count > MaxHistorySize)
{
_roomMessageHistory[roomId].RemoveAt(0);
}
// Отправляем сообщение всем участникам комнаты
await Clients.Group(roomId).SendAsync("ReceiveRoomMessage", roomId, username, message, chatMessage.Timestamp);
}
} |
|
Продвинутые техники
После освоения базовых принципов работы с SignalR наступает время погрузиться в продвинутые техники, которые позволят создавать более сложные, надежные и масштабируемые приложения реального времени. В этом разделе мы рассмотрим ряд подходов, которые выведут ваши проекты на новый уровень.
Управление состоянием и сессиями
В крупных приложениях часто возникает необходимость сохранять состояние между переподключениями пользователей. Стандартный подход с использованием словарей в памяти хаба имеет существенный недостаток — данные теряются при перезапуске сервера или масштабировании на несколько инстансов. Решением этой проблемы становится использование внешних хранилищ данных. Одним из популярных вариантов является Redis — быстрая in-memory база данных с поддержкой структур данных.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // Установка пакета для Redis
// Install-Package StackExchange.Redis
// Настройка подключения к Redis
var redis = ConnectionMultiplexer.Connect("localhost:6379");
var db = redis.GetDatabase();
// Сохранение состояния пользователя
public async Task SaveUserState(string userId, UserState state)
{
string serializedState = JsonConvert.SerializeObject(state);
await db.StringSetAsync($"user:{userId}:state", serializedState);
}
// Восстановление состояния
public async Task<UserState> GetUserState(string userId)
{
string serializedState = await db.StringGetAsync($"user:{userId}:state");
if (string.IsNullOrEmpty(serializedState))
return null;
return JsonConvert.DeserializeObject<UserState>(serializedState);
} |
|
Для сессионного управления особенно полезен метод OnReconnected хаба, позволяющий восстановить состояние пользователя при переподключении. Помните, что идентификатор соединения ConnectionId может измениться при переподключении, поэтому лучше использовать для идентификации пользователей более стабильные идентификаторы, такие как ID аккаунта из системы аутентификации.
Интеграция с современными фронтенд-фреймворками
Хотя мы рассмотрели примеры с использованием чистого JavaScript, в реальных проектах часто применяются фреймворки вроде React, Angular или Vue.js. SignalR отлично интегрируется с ними, но требует некоторой адаптации. Для React интеграция может выглядеть так:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| import React, { useState, useEffect, useRef } from 'react';
import * as signalR from '@microsoft/signalr';
function ChatComponent() {
const [messages, setMessages] = useState([]);
const [connection, setConnection] = useState(null);
const connectionRef = useRef(null);
useEffect(() => {
// Создаем соединение
const newConnection = new signalR.HubConnectionBuilder()
.withUrl('/chatHub')
.withAutomaticReconnect()
.build();
// Сохраняем ссылку на соединение
connectionRef.current = newConnection;
setConnection(newConnection);
// Обработчик сообщений
newConnection.on('ReceiveMessage', (user, message) => {
setMessages(prevMessages => [...prevMessages, { user, message }]);
});
// Запускаем соединение
newConnection.start()
.catch(err => console.error('Ошибка при подключении:', err));
// Очистка при размонтировании компонента
return () => {
if (connectionRef.current) {
connectionRef.current.stop();
}
};
}, []);
const sendMessage = (message) => {
if (connection) {
connection.invoke('SendMessage', 'User', message)
.catch(err => console.error('Ошибка при отправке:', err));
}
};
return (
<div>
<div className="messages">
{messages.map((msg, index) => (
<div key={index}>
<strong>{msg.user}:</strong> {msg.message}
</div>
))}
</div>
<MessageInput onSend={sendMessage} />
</div>
);
} |
|
Для Angular жизненный цикл компонентов необходимо привязать к жизненному циклу SignalR-соединения, что делает интеграцию немного сложнее, но более структурированной благодаря использованию сервисов:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| // chat.service.ts
@Injectable({
providedIn: 'root'
})
export class ChatService {
private hubConnection: signalR.HubConnection;
private messageSubject = new Subject<any>();
messages$ = this.messageSubject.asObservable();
constructor() {
this.createConnection();
this.registerHandlers();
this.startConnection();
}
private createConnection() {
this.hubConnection = new signalR.HubConnectionBuilder()
.withUrl('/chatHub')
.withAutomaticReconnect()
.build();
}
private registerHandlers() {
this.hubConnection.on('ReceiveMessage', (user, message) => {
this.messageSubject.next({ user, message });
});
}
private startConnection() {
this.hubConnection.start()
.catch(err => console.error('Ошибка соединения:', err));
}
sendMessage(user: string, message: string) {
this.hubConnection.invoke('SendMessage', user, message)
.catch(err => console.error('Ошибка отправки:', err));
}
} |
|
Развертывание в контейнерах Docker
Контейнеризация приложений SignalR имеет свои особенности, особенно в части настройки сетевого взаимодействия. Вот пример Dockerfile для ASP.NET Core приложения с SignalR:
Code | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["SignalRChat.csproj", "./"]
RUN dotnet restore "SignalRChat.csproj"
COPY . .
RUN dotnet build "SignalRChat.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "SignalRChat.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "SignalRChat.dll"] |
|
При работе с Docker-контейнерами важно правильно настроить проксирование WebSocket-соединений, если вы используете Nginx или другой веб-сервер перед вашим приложением:
JSON | 1
2
3
4
5
6
7
8
9
10
11
12
| server {
listen 80;
location / {
proxy_pass http://app:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
} |
|
Масштабирование SignalR-приложений
Когда ваше приложение достигает определенного масштаба, одного сервера становится недостаточно. В этом случае необходимо настроить масштабирование SignalR с использованием бэкплейна (backplane). Бэкплейн — это механизм, позволяющий нескольким серверам SignalR обмениваться сообщениями, чтобы клиент, подключенный к одному серверу, мог получать сообщения от клиентов, подключенных к другим серверам. Наиболее распространенным вариантом бэкплейна для SignalR является Redis:
C# | 1
2
3
4
5
6
7
8
| public void ConfigureServices(IServiceCollection services)
{
services.AddSignalR()
.AddStackExchangeRedis("localhost:6379", options =>
{
options.Configuration.ChannelPrefix = "SignalRChat";
});
} |
|
При масштабировании следует учитывать особенности маршрутизации запросов. Если вы используете балансировщик нагрузки, настройте "sticky sessions" (привязку сессий), чтобы все запросы от одного клиента попадали на один и тот же сервер. Это уменьшит накладные расходы на пересылку сообщений между серверами.
Мониторинг производительности
Для эффективного мониторинга приложений SignalR можно использовать встроенные средства ASP.NET Core и дополнительные инструменты:
1. Встроенные метрики ASP.NET Core: используйте пакет Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson для получения детальных метрик.
C# | 1
2
3
4
5
6
| services.AddSignalR()
.AddNewtonsoftJsonProtocol()
.AddHubOptions<ChatHub>(options =>
{
options.EnableDetailedErrors = true;
}); |
|
2. Application Insights: для глубокого мониторинга в реальном времени.
C# | 1
2
3
4
5
6
| services.AddApplicationInsightsTelemetry();
services.AddSignalR().AddAzureSignalR(options =>
{
options.ConnectionString = Configuration["Azure:SignalR:ConnectionString"];
options.ApplicationInsights.EnableMetrics = true;
}); |
|
3. Пользовательские метрики: создайте специальные счетчики для отслеживания важных показателей.
C# | 1
2
3
4
5
6
7
8
9
10
| private static readonly Counter MessagesSent = Metrics.CreateCounter(
"signalr_messages_sent_total",
"Total number of messages sent through SignalR"
);
public async Task SendMessage(string user, string message)
{
await Clients.All.SendAsync("ReceiveMessage", user, message);
MessagesSent.Inc(); // Увеличиваем счетчик
} |
|
Защита и авторизация
Помимо базовой аутентификации с использованием атрибута [Authorize] , которую мы рассмотрели ранее, для SignalR приложений можно реализовать более сложные схемы авторизации:
1. Проверка на уровне методов хаба: фильтры действий для хабов.
C# | 1
2
3
4
5
| [Authorize(Policy = "AdminOnly")]
public async Task AdminBroadcast(string message)
{
await Clients.All.SendAsync("ReceiveAdminMessage", message);
} |
|
2. Кастомные токены: использование JWT-токенов для авторизации SignalR-соединений.
JavaScript | 1
2
3
4
5
| const connection = new signalR.HubConnectionBuilder()
.withUrl("/chatHub", {
accessTokenFactory: () => localStorage.getItem("jwt_token")
})
.build(); |
|
3. Создание пользовательского хаб-фильтра: для более гибкой логики авторизации.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| public class CustomAuthorizationFilter : IHubFilter
{
public async ValueTask<object> InvokeMethodAsync(
HubInvocationContext invocationContext,
Func<HubInvocationContext, ValueTask<object>> next)
{
// Здесь можно проверить права доступа на основе контекста вызова
if (invocationContext.HubMethodName == "SendMessage")
{
var user = invocationContext.Context.User;
// Проверка прав...
}
return await next(invocationContext);
}
}
// Регистрация фильтра
services.AddSignalR(options =>
{
options.AddFilter<CustomAuthorizationFilter>();
}); |
|
Не забывайте о защите от атак. Для SignalR актуальны те же уязвимости, что и для обычных веб-приложений: XSS, CSRF и другие. Применяйте стандартные практики безопасности: валидируйте входные данные, используйте HTTPS, настраивайте CORS правильно.
C# | 1
2
3
4
5
6
7
| app.UseCors(builder =>
{
builder.WithOrigins("https://trusted-domain.com")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials(); // Необходимо для SignalR
}); |
|
На этом мы завершаем первую часть раздела о продвинутых техниках работы с SignalR. В следующей части мы продолжим рассматривать темы кастомизации, оптимизации производительности и интеграции с микросервисной архитектурой.
Кастомизация и оптимизация производительности
Для разработки высокопроизводительных приложений реального времени недостаточно просто использовать SignalR "из коробки". Важно понимать возможности тонкой настройки и оптимизации, которые предоставляет библиотека.
Настройка протоколов и сериализации
Как уже упоминалось, SignalR поддерживает несколько протоколов сериализации. MessagePack может значительно улучшить производительность по сравнению с JSON, особенно для больших сообщений:
C# | 1
2
3
4
5
6
7
8
| services.AddSignalR()
.AddMessagePackProtocol(options =>
{
options.FormatterResolvers = new List<MessagePack.IFormatterResolver>()
{
MessagePack.Resolvers.StandardResolver.Instance
};
}); |
|
На клиентской стороне также требуется настройка:
JavaScript | 1
2
3
4
| const connection = new signalR.HubConnectionBuilder()
.withUrl("/chatHub")
.withHubProtocol(new signalR.protocols.msgpack.MessagePackHubProtocol())
.build(); |
|
Стратегии управления соединениями
Настройка стратегий повторного подключения может значительно повысить надежность приложения:
JavaScript | 1
2
3
4
| const connection = new signalR.HubConnectionBuilder()
.withUrl("/chatHub")
.withAutomaticReconnect([0, 1000, 5000, null]) // Попытки через 0мс, 1с, 5с, затем остановка
.build(); |
|
Для более сложных сценариев можно реализовать кастомную стратегию:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| const connection = new signalR.HubConnectionBuilder()
.withUrl("/chatHub")
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: (retryContext) => {
// Экспоненциальная задержка, но не более 30 секунд
let delay = Math.min(30000, Math.pow(2, retryContext.previousRetryCount) * 1000);
console.log(`Повторное подключение через ${delay}мс...`);
return delay;
}
})
.build(); |
|
Потоковая передача данных
Для передачи больших объемов данных или длительных операций SignalR предлагает механизм потоковой передачи (streaming), который позволяет начать получать данные до завершения операции:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
| // Серверная часть
public async IAsyncEnumerable<int> StreamData(int count, int delay, [EnumeratorCancellation] CancellationToken cancellationToken)
{
for (var i = 0; i < count; i++)
{
// Проверка отмены потока клиентом
cancellationToken.ThrowIfCancellationRequested();
yield return i;
await Task.Delay(delay, cancellationToken);
}
} |
|
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // Клиентская часть
connection.stream("StreamData", 100, 500)
.subscribe({
next: (item) => {
document.getElementById("progress").style.width = `${item}%`;
},
complete: () => {
console.log("Поток завершен");
},
error: (err) => {
console.error(err);
}
}); |
|
Интеграция с микросервисной архитектурой
В современных приложениях SignalR часто используется как часть микросервисной архитектуры. Это создает дополнительные вызовы, связанные с координацией сообщений между различными сервисами и маршрутизацией их к нужным клиентам.
Использование паттерна событийной шины
Один из эффективных подходов — использование событийной шины (Event Bus) для коммуникации между микросервисами:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| // Подписка на события в сервисе уведомлений
public class NotificationService : IHostedService
{
private readonly IEventBus _eventBus;
private readonly IHubContext<NotificationHub> _hubContext;
public NotificationService(IEventBus eventBus, IHubContext<NotificationHub> hubContext)
{
_eventBus = eventBus;
_hubContext = hubContext;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_eventBus.Subscribe<OrderCreatedEvent, OrderCreatedEventHandler>();
return Task.CompletedTask;
}
// Обработчик события
public class OrderCreatedEventHandler : IEventHandler<OrderCreatedEvent>
{
private readonly IHubContext<NotificationHub> _hubContext;
public OrderCreatedEventHandler(IHubContext<NotificationHub> hubContext)
{
_hubContext = hubContext;
}
public async Task Handle(OrderCreatedEvent @event)
{
// Отправка уведомления клиентам
await _hubContext.Clients.User(@event.UserId).SendAsync("OrderCreated", @event);
}
}
} |
|
Доступ к хабу вне контекста HTTP-запроса
Часто возникает необходимость отправлять сообщения клиентам из фоновых задач или других сервисов. Для этого используется IHubContext :
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public class BackgroundMessageService : BackgroundService
{
private readonly IHubContext<ChatHub> _hubContext;
public BackgroundMessageService(IHubContext<ChatHub> hubContext)
{
_hubContext = hubContext;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Например, периодическая отправка системных сообщений
await _hubContext.Clients.All.SendAsync("ReceiveSystemMessage",
$"Системное сообщение: {DateTime.Now}", stoppingToken);
await Task.Delay(TimeSpan.FromMinutes(10), stoppingToken);
}
}
} |
|
Заключение и дальнейшие шаги
SignalR предоставляет мощный инструментарий для создания приложений реального времени на платформе .NET. Мы рассмотрели основные концепции, базовую реализацию чата и продвинутые техники, которые помогут создавать масштабируемые и надежные решения. Для дальнейшего изучения рекомендую обратить внимание на следующие темы:
1. Расширенные сценарии аутентификации и авторизации для чатов с ролевой моделью.
2. Интеграция с внешними сервисами для отправки уведомлений (push-notifications, email, SMS).
3. Аналитика и статистика использования чата.
4. Оптимизация работы с большим количеством подключений (более 10 000).
5. Реализация сценариев с географически распределенными пользователями.
Вооружившись знаниями из этой статьи, вы сможете создавать разнообразные приложения с функциональностью реального времени — от простых чатов до сложных коллаборативных инструментов, игр и систем мониторинга. Главное — правильно определить требования к вашему проекту и выбрать соответствующие инструменты и подходы из богатого арсенала SignalR.
SignalR или ajax что и где лучше использовать?
Добавлено через 1 минуту
сам считаю что аякс лучше там где... SignalR v2: передача данных от одного пользователя другому Нужна помощь по SignalR v2, конкретно:
Передача данных от js приложения одного пользователя к js... Signalr - оповещение по условию Доброго времени суток! Сразу приведу пример необходимой функциональности:
При обращении к сайту у... ASP + Autofac + SignalR Добрый день, столкнулся со следующий проблемой, нужно Autofac отрезолвить ILifetimeScope в... Аутентификация SignalR Identity из desktop приложения Уже больше недели пытаюсь понять, как можно сделать аутентификацию из десктопного приложения... Авторизация для чата на WinForms и SignalR Приветствую всех. Прошу консультации у людей с опытом.
Необходимо создать приложение-чат.... Как организовать работу SignalR если есть два вида пользователей? Один пользователь авторизован, есть данные в бд и есть имя. Другой не авторизован и у него есть... SignalR или websocket Задался целью прикрутить игру крестики нолики на свой сайт. Есть ли что то кроме SignalR в плане... SignalR создание клиент-сервер с постоянным подключением Всем привет.Хочу создать сервер,работающий по принципу клиент-сервер,c постоянным подключением,а не... Где лучше хранить id собеседника SignalR? Я думал сохранять в сессиях, но может есть лучше вариант? Почему не приходит сообщение группе SignalR? Методы из хаба.
public void UserConnect(string message)
{
... ASP.NET + SignalR Игровой сервер Добра всем!
Хочу разработать свою игру и набраться опыта в этом деле.
Сейчас главная задача...
|