Как управлять сессиями веб-приложения для обновления обеих сессий через хук API?

Задача: обновить страницы на всех открытых сессиях по запросу API. Когда API отправляет запрос, обе сессии должны обновиться. Например, если Алиса и Боб открыли страницы, обе страницы должны обновляться.

Проблема: на данный момент, если открыть вторую сессию и она покидает страницу X, то первая сессия теряет подписку и больше не получает обновлений.

Условия:

  1. Первое веб-приложение написано на Blazor Server.
  2. Второе — это простой хук API.

Как обеспечить, чтобы обе сессии оставались подписанными и получали обновления, независимо от действий пользователя на другой сессии?

Hook API

public static readonly ConcurrentDictionary<string, string> Subscribers = new();

[HttpPost("subscribe")]
public IActionResult Subscribe([FromBody] SubscriptionRequest request)
{
    Subscribers[request.Url] = request.Url;
    return Ok(new { status = "subscribed" });
}

[HttpPost("unsubscribe")]
public IActionResult Unsubscribe([FromBody] SubscriptionRequest request)
{
    Subscribers.TryRemove(request.Url, out _);
    return Ok(new { status = "unsubscribed" });
}

private async Task<IActionResult> SendWebhookToSubscribers(IEnumerable<SimpleDataForHookTest> payload)
{
    var client = _httpClientFactory.CreateClient();
    foreach (var subscriber in Subscribers.Values)
    {
        await client.PostAsJsonAsync(subscriber, payload);
    }
    return Ok(new { status = "webhook sent" });
}

Blazor

protected override async Task OnInitializedAsync()
{
    await SubscriptionService.Subscribe("https://localhost:7052/api/TestHook/TestWebHook");
    HookService.Register(ReceivePlanningData);
}

private void ReceivePlanningData(IEnumerable<SimpleDataForHookTest> planningListDto)
{
    MyPropertyTestHook = planningListDto;
    InvokeAsync(StateHasChanged);
}

public void Dispose()
{
    HookService.UnRegister(ReceivePlanningData);
    SubscriptionService.Unsubscribe("https://localhost:7052/api/TestHook/TestWebHook").GetAwaiter().GetResult();
}

Полный код есть на GitHub (тут я поделилась основным кодом из контроллера и Razor страницы). Если запустить оба приложения, то через 15 секунд обновляется страница 1, но если открыть параллельно страницу 2 и покинуть её, то первая уже не обновляется.

я пробовала :

a) Таймер на стороне Blazor, чтобы делать GET запросы каждые эн минут. Это затратно и ломает страницу в момент работы пользователя.

b) SignalR, который не сработал и не обновляет страницу, хотя подписка происходит успешно. На main версия с SignalR, которую мне не удалось доработать.

Рабочие ветки на GitHub:

(Обычно работаю с Azure DevOps, не знаю, надо ли на GitHub создавать пул реквест или можно просто клонировать. Я создала новую ветку через Visual Studio и отправила, но на GitHub не нашла пункта создать пул реквест).

Может у кого-то есть свои идеи и он может подсказать, как можно обновлять данные с API. Как данные приходят на API не важно, но API отправляет данные тем, кто подписался, в данном примере Blazor веб.


Ответы (1 шт):

Автор решения: Dev18

Если добавить идентификатор к самому адресу приложения при реализации SignalR, то можно получить уникальную ссылку для каждой сессии, что делает каждое подключение уникальным.

введите сюда описание изображения

Таким образом, используя SignalR, можно реализовать обновления для всех сессий, независимо от того, покинула ли одна из них подписку.

Hook API

PlanningHub.cs

using System.Collections.Concurrent;
using Microsoft.AspNetCore.SignalR;

namespace TestHookApiSimpleTest.Models
{
    public class PlanningHub : Hub
    {
        public static readonly ConcurrentDictionary<string, string> Subscribers = new();

        public override Task OnConnectedAsync()
        {
            Console.WriteLine($"Client connected: {Context.ConnectionId}");
            return base.OnConnectedAsync();
        }

        public override Task OnDisconnectedAsync(Exception? exception)
        {
            Subscribers.TryRemove(Context.ConnectionId, out _);
            Console.WriteLine($"Client disconnected: {Context.ConnectionId}");
            return base.OnDisconnectedAsync(exception);
        }

        public Task Subscribe(string url)
        {
            Subscribers[Context.ConnectionId] = url;
            Console.WriteLine($"Client subscribed: {Context.ConnectionId} with URL: {url}");
            return Task.CompletedTask;
        }

        public Task Unsubscribe()
        {
            Subscribers.TryRemove(Context.ConnectionId, out _);
            Console.WriteLine($"Client unsubscribed: {Context.ConnectionId}");
            return Task.CompletedTask;
        }

        public static IReadOnlyCollection<string> GetSubscribers()
        {
            return Subscribers.Values.ToList();
        }
    }
}

EventController.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using TestHookApiSimpleTest.Models;

namespace TestHookApiSimpleTest.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class EventController : ControllerBase
    {
        private readonly IHttpClientFactory _httpClientFactory;
        private readonly IHubContext<PlanningHub> _hubContext;

        public EventController(IHttpClientFactory httpClientFactory, IHubContext<PlanningHub> hubContext)
        {
            _httpClientFactory = httpClientFactory;
            _hubContext = hubContext;
        }

        [HttpPost("planning")]
        public async Task<IActionResult> SendWebhook([FromBody] IEnumerable<SimpleDataForHookTest> payload)
        {
            return await SendWebhookToSubscribers(payload);
        }

        [HttpPost("subscribe")]
        public async Task<IActionResult> Subscribe([FromBody] SubscriptionRequest request)
        {
            await _hubContext.Clients.All.SendAsync("Subscribe", request.Url);
            return Ok(new { status = "subscribed" });
        }

        [HttpPost("unsubscribe")]
        public async Task<IActionResult> Unsubscribe([FromBody] SubscriptionRequest request)
        {
            await _hubContext.Clients.All.SendAsync("Unsubscribe");
            return Ok(new { status = "unsubscribed" });
        }

        private async Task<IActionResult> SendWebhookToSubscribers(IEnumerable<SimpleDataForHookTest> payload)
        {
            try
            {
                var subscribers = PlanningHub.GetSubscribers();

                var client = _httpClientFactory.CreateClient();
                foreach (var subscriber in subscribers)
                {
                    var response = await client.PostAsJsonAsync(subscriber, payload);
                    response.EnsureSuccessStatusCode();
                }

                return Ok(new { status = "webhook sent" });
            }
            catch (Exception ex)
            {
                return StatusCode(500, new { error = ex.Message });
            }
        }
    }
}

Blazor

UpdateHub.cs

using Microsoft.AspNetCore.SignalR;

namespace TestHook.Data
{
    public class UpdateHub : Hub
    {
        public async Task SendUpdate(IEnumerable<SimpleDataForHookTest> data)
        {
            await Clients.All.SendAsync("ReceiveUpdate", data);
        }
    }
}

PageRazorExample.razor.cs

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using TestHook.Data;
using TestHook.Services;

namespace TestHook.Pages
{
    public partial class PageRazorExample : IAsyncDisposable
    {
        private List<SimpleDataForHookTest> updates;
        private HubConnection hubConnection;

        [Inject]
        public ISubscriptionService SubscriptionService { get; set; }

        [Inject]
        public IHookService HookService { get; set; }

        protected override async Task OnInitializedAsync()
        {
            hubConnection = new HubConnectionBuilder()
                .WithUrl(Navigation.ToAbsoluteUri("https://localhost:7006/planninghub"))
                .Build();

            hubConnection.On<IEnumerable<SimpleDataForHookTest>>("ReceiveUpdate", (data) =>
            {
                updates = data.ToList();
                InvokeAsync(StateHasChanged);
            });

            await hubConnection.StartAsync();
            await hubConnection.SendAsync("Subscribe", "https://localhost:7052/api/TestHook/TestWebHook");

            HookService.Register(ReceivePlanningData);
        }

        IEnumerable<SimpleDataForHookTest> MyPropertyTestHook { get; set; }

        private void ReceivePlanningData(IEnumerable<SimpleDataForHookTest> planningListDto)
        {
            if (planningListDto != null && planningListDto.Any())
            {
                MyPropertyTestHook = planningListDto;
                Console.WriteLine("Received planning data.");
                InvokeAsync(StateHasChanged);
            }
            else
            {
                Console.WriteLine("Not send data from hook");
            }
        }

        public async ValueTask DisposeAsync()
        {
            await hubConnection.SendAsync("Unsubscribe");
            HookService.UnRegister(ReceivePlanningData);
            await hubConnection.DisposeAsync();
        }
    }
}

Program.cs

....
// Add SignalR services
builder.Services.AddSignalR();
...
app.MapHub<UpdateHub>("/updatehub");

Проверено: работает при нескольких сессиях (1 страница в Chrome + 2 страница через Ctrl+Shift+N). Обновите обе страницы, чтобы сбросить данные с HTML, затем покиньте страницу 2, и страница 1 продолжит обновляться.

→ Ссылка