Практикум — WebSocket и события заказов
Практикум, шаг 7 из 8. Живые уведомления на orders-api. Теория — WebSockets в 8.05.
Зачем WebSocket здесь
После POST /api/v1/orders клиенту нужно узнать о смене статуса (reserved → confirmed) без polling. orders-api держит канал ws://localhost:5200/ws/orders и рассылает события подписчикам того же пользователя.
Формат сообщений (прикладной протокол)
Все кадры — текстовый JSON (UTF-8). Версия протокола в каждом сообщении:
{
"v": 1,
"type": "order.status_changed",
"payload": {
"orderId": "ord_x9y8",
"status": "confirmed",
"at": "2026-05-27T10:20:00Z"
}
}
| type | Направление | Смысл |
|---|---|---|
ping | клиент → сервер | keep-alive |
pong | сервер → клиент | ответ на ping |
order.status_changed | сервер → клиент | смена статуса заказа |
error | сервер → клиент | { "code": "unauthorized", "message": "..." } |
Hub в ASP.NET Core
Services/OrderWebSocketHub.cs:
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
public sealed class OrderWebSocketHub
{
private readonly ConcurrentDictionary<string, ConcurrentBag<WebSocket>> _byUser = new();
public async Task HandleAsync(WebSocket socket, string userId, CancellationToken ct)
{
var bag = _byUser.GetOrAdd(userId, _ => new ConcurrentBag<WebSocket>());
bag.Add(socket);
var buffer = new byte[4096];
try
{
while (socket.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
var result = await socket.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close) break;
if (result.MessageType != WebSocketMessageType.Text) continue;
var text = Encoding.UTF8.GetString(buffer, 0, result.Count);
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.GetProperty("type").GetString() == "ping")
await SendAsync(socket, new { v = 1, type = "pong" }, ct);
}
}
finally
{
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", ct);
}
}
public async Task BroadcastToUserAsync(string userId, object message, CancellationToken ct)
{
if (!_byUser.TryGetValue(userId, out var bag)) return;
foreach (var ws in bag)
if (ws.State == WebSocketState.Open)
await SendAsync(ws, message, ct);
}
private static Task SendAsync(WebSocket ws, object msg, CancellationToken ct)
{
var json = JsonSerializer.Serialize(msg);
var bytes = Encoding.UTF8.GetBytes(json);
return ws.SendAsync(bytes, WebSocketMessageType.Text, true, ct);
}
}
OrderEventPublisher вызывает BroadcastToUserAsync после Confirm / Cancel.
Middleware маршрута
Program.cs:
app.UseWebSockets();
app.Map("/ws/orders", async (HttpContext ctx, OrderWebSocketHub hub) =>
{
if (!ctx.WebSockets.IsWebSocketRequest)
{
ctx.Response.StatusCode = 400;
return;
}
if (ctx.User?.Identity?.IsAuthenticated != true)
{
ctx.Response.StatusCode = 401;
return;
}
var socket = await ctx.WebSockets.AcceptWebSocketAsync();
await hub.HandleAsync(socket, ctx.User.Identity!.Name!, ctx.RequestAborted);
});
JWT на handshake — через ?access_token= (см. шаг 6) или заголовок Authorization там, где прокси его не срезает.
Проверка в браузерной консоли
const token = "…"; // из POST /api/v1/auth/token
const ws = new WebSocket(`ws://localhost:5200/ws/orders?access_token=${token}`);
ws.onmessage = (e) => console.log(JSON.parse(e.data));
ws.onopen = () => ws.send(JSON.stringify({ v: 1, type: "ping" }));
Создайте заказ во второй вкладке Postman — в консоли придёт order.status_changed.
Масштабирование (кратко)
In-memory ConcurrentBag работает на одном экземпляре. При горизонтальном масштабировании:
- Redis pub/sub или SignalR backplane для fan-out;
- sticky sessions на балансировщике как временная мера;
- события как Kafka/RabbitMQ — если нужна история и гарантия доставки.
Для практикума достаточно одного процесса dotnet run.
Переподключение клиента
Браузер и Postman периодически рвут WS. Рекомендуемый алгоритм на клиенте:
- При
onclose— экспоненциальная задержка (1 с, 2 с, 4 с … до 30 с). - После
onopen—GET /api/v1/orders/{lastKnownId}для сверки статуса. - Не полагаться только на пропущенные WS-сообщения при долгом офлайне.
Коды закрытия (RFC 6455):
| Код | Смысл в OrderDesk |
|---|---|
1000 | Нормальное закрытие сессии |
1008 | Policy violation (просрочен JWT) |
1011 | Внутренняя ошибка сервера |
Сверка с песочницей
Кнопка WS ping на intro отправляет { "type": "ping" } и показывает pong. После POST confirm в ручном режиме в ленте появится order.status_changed — тот же JSON, что ожидается в Postman WebSocket.
Следующий шаг
Соберём полный сценарий в Postman: шаг 8.
См. также
Другие статьи этого же раздела в боковом меню (как на странице "О разделе"). Два сервиса OrderDesk: каталог на Python и заказы на C#, границы ответственности, потоки REST и WebSocket. Ресурсы OrderDesk, таблица методов HTTP, коды ответов и фрагмент OpenAPI для catalog-api и orders-api. Доменные сущности OrderDesk, DTO для REST, маппинг Python (Pydantic) и C# (record + ручной маппер). FastAPI, SQLite, эндпоинты товаров и резервирования, Pydantic и проверка через uvicorn. ASP.NET Core 8, Minimal API, HttpClient к catalog-api, SQLite и создание заказа с резервом. JWT, API-ключ между сервисами, HTTPS, таймауты, идемпотентность и заголовок X-Request-Id в OrderDesk. Коллекция Postman, переменные окружения и сквозной сценарий OrderDesk — товар, заказ, WebSocket.Практикум — сценарий и архитектура OrderDesk
Практикум — проектирование контракта API
Практикум — модели данных и маппинг DTO
Практикум — сервис каталога на Python
Практикум — сервис заказов на C#
Практикум — безопасность и устойчивость
Практикум — проверка в Postman