Перейти к основному содержимому

Практикум — WebSocket и события заказов

Разработчику

Практикум, шаг 7 из 8. Живые уведомления на orders-api. Теория — WebSockets в 8.05.


Зачем WebSocket здесь

После POST /api/v1/orders клиенту нужно узнать о смене статуса (reservedconfirmed) без 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. Рекомендуемый алгоритм на клиенте:

  1. При onclose — экспоненциальная задержка (1 с, 2 с, 4 с … до 30 с).
  2. После onopenGET /api/v1/orders/{lastKnownId} для сверки статуса.
  3. Не полагаться только на пропущенные WS-сообщения при долгом офлайне.

Коды закрытия (RFC 6455):

КодСмысл в OrderDesk
1000Нормальное закрытие сессии
1008Policy violation (просрочен JWT)
1011Внутренняя ошибка сервера

Сверка с песочницей

Кнопка WS ping на intro отправляет { "type": "ping" } и показывает pong. После POST confirm в ручном режиме в ленте появится order.status_changed — тот же JSON, что ожидается в Postman WebSocket.


Следующий шаг

Соберём полный сценарий в Postman: шаг 8.

См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").