diff --git a/BootstrapBlazor.Extensions.slnx b/BootstrapBlazor.Extensions.slnx index 088bbf7d..35ea650e 100644 --- a/BootstrapBlazor.Extensions.slnx +++ b/BootstrapBlazor.Extensions.slnx @@ -84,6 +84,7 @@ + diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/BootstrapBlazor.SignalR.Client.csproj b/src/extensions/BootstrapBlazor.SignalR.Client/BootstrapBlazor.SignalR.Client.csproj new file mode 100644 index 00000000..511d95b1 --- /dev/null +++ b/src/extensions/BootstrapBlazor.SignalR.Client/BootstrapBlazor.SignalR.Client.csproj @@ -0,0 +1,39 @@ + + + + 11.0.0-rc.3.1 + + + + Optional SignalR client transport for BootstrapBlazor dispatch and connection services. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/DefaultConnectionService.cs b/src/extensions/BootstrapBlazor.SignalR.Client/DefaultConnectionService.cs new file mode 100644 index 00000000..2fea2e53 --- /dev/null +++ b/src/extensions/BootstrapBlazor.SignalR.Client/DefaultConnectionService.cs @@ -0,0 +1,148 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.Extensions.Logging; + +namespace BootstrapBlazor.Components; + +/// +/// 基于 SignalR 的客户端连接服务,通过心跳上报连接信息并轮询服务端连接快照 +/// SignalR client connection service that reports connection information through heartbeats and polls server connection snapshots +/// +sealed class DefaultConnectionService : IConnectionService, IAsyncDisposable +{ + private readonly SignalRConnection _connection; + private readonly ILogger _logger; + private readonly CancellationTokenSource _lifetime = new(); + private readonly Task _pollingTask; + private ConnectionSnapshot _snapshot = new(0, []); + private int _disposed; + + /// + /// 初始化客户端连接服务并启动连接快照轮询 + /// Initialize the client connection service and start connection snapshot polling + /// + /// 共享的 SignalR 连接The shared SignalR connection + /// 日志记录器The logger + public DefaultConnectionService(SignalRConnection connection, ILogger logger) + { + _connection = connection; + _logger = logger; + _connection.Retain(); + _pollingTask = PollAsync(_lifetime.Token); + } + + /// + /// 获得 最近一次服务端快照中尚未超时的在线连接数量 + /// Gets the online connection count from the latest server snapshot, excluding timed-out connections + /// + public long Count => _snapshot.Count; + + /// + /// 获得 最近一次服务端查询的连接集合 + /// Gets the connection collection from the latest server query + /// + public ICollection Connections => _snapshot.Connections; + + /// + /// 根据连接标识从本地快照中获取连接信息 + /// Try to get connection information from the local snapshot by connection identifier + /// + /// 连接标识The connection identifier + /// 匹配的连接信息,未找到时为 nullThe matching connection information, or null if not found + /// 找到连接时返回 true,否则返回 falseTrue if the connection is found; otherwise, false + public bool TryGetValue(string key, [NotNullWhen(true)] out ConnectionItem? value) + { + value = _snapshot.Connections.FirstOrDefault(item => item.Id == key); + return value != null; + } + + /// + /// 向服务端发送心跳以添加或更新客户端连接信息 + /// Send a heartbeat to the server to add or update client connection information + /// + /// 客户端信息The client information + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + public async Task AddOrUpdateAsync(ClientInfo client, CancellationToken cancellationToken = default) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + if (!await _connection.InvokeAsync("Heartbeat", client, cancellation.Token)) + { + _logger.LogWarning("Connection heartbeats are disabled on the server."); + } + } + + /// + /// 定期刷新连接快照,查询被禁用、未获授权或请求被拒绝时停止轮询 + /// Periodically refresh the connection snapshot, stopping when queries are disabled, unauthorized, or rejected + /// + /// 取消令牌The cancellation token + /// 表示轮询操作的任务A task representing the polling operation + private async Task PollAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + var snapshot = await _connection.InvokeAsync("GetConnectionSnapshot", null, cancellationToken); + if (snapshot == null) + { + _snapshot = new(0, []); + _logger.LogInformation("Connection snapshot polling stopped because queries are disabled or not authorized."); + return; + } + _snapshot = snapshot; + } + catch (HttpRequestException ex) when (!cancellationToken.IsCancellationRequested && !SignalRConnection.IsTransientFailure(ex)) + { + _snapshot = new(0, []); + _logger.LogWarning(ex, "Connection snapshot polling stopped because the Hub request was rejected."); + return; + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogError(ex, "Unable to refresh the connection snapshot."); + } + await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + /// + /// 停止连接快照轮询并释放共享连接的使用引用 + /// Stop connection snapshot polling and release the usage reference to the shared connection + /// + /// 表示异步释放操作的任务A task representing the asynchronous disposal operation + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _lifetime.Cancel(); + try + { + await _pollingTask; + } + finally + { + try + { + await _connection.ReleaseAsync(); + } + finally + { + _lifetime.Dispose(); + } + } + } +} diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/DefaultDispatchService.cs b/src/extensions/BootstrapBlazor.SignalR.Client/DefaultDispatchService.cs new file mode 100644 index 00000000..3ff5563f --- /dev/null +++ b/src/extensions/BootstrapBlazor.SignalR.Client/DefaultDispatchService.cs @@ -0,0 +1,248 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace BootstrapBlazor.Components; + +/// +/// 基于 SignalR 的客户端消息分发服务 +/// SignalR client dispatch service +/// +/// 消息数据类型The entry data type +/// 共享的 SignalR 连接The shared SignalR connection +/// 日志记录器The logger +sealed class DefaultDispatchService( + SignalRConnection connection, + ILogger> logger) : IDispatchService, IAsyncDisposable +{ + private static readonly string _entryType = typeof(TEntry).FullName ?? typeof(TEntry).Name; + private static readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web); + private readonly List _callbacks = []; + private readonly object _locker = new(); + private readonly SemaphoreSlim _subscriptions = new(1, 1); + private readonly CancellationTokenSource _lifetime = new(); + private bool _requiresUnsubscribe; + + /// + /// 向服务端分发消息,成功后通知本地订阅者 + /// Dispatch an entry to the server and notify local subscribers after a successful send + /// + /// 消息分发项The dispatch entry + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + public async Task DispatchAsync(DispatchEntry entry, CancellationToken cancellationToken = default) + { + await connection.DispatchAsync(_entryType, entry, cancellationToken); + await DispatchCoreAsync(entry, cancellationToken); + } + + /// + /// 注册消息回调,并在添加首个本地订阅者时订阅服务端消息 + /// Register a message callback and subscribe to server messages when adding the first local subscriber + /// + /// 消息回调委托The message callback + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + public async Task SubscribeAsync(Func, Task> callback, CancellationToken cancellationToken = default) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + var token = cancellation.Token; + // 串行处理订阅与退订,避免后续订阅者在首个远程订阅尚未就绪时返回成功。 + // Serialize subscription changes so later subscribers cannot succeed before the first remote subscription is ready. + await _subscriptions.WaitAsync(token); + try + { + var subscription = new Subscription(callback); + // first 表示本次添加前没有本地订阅者,而非服务生命周期内第一次订阅。 + // 同一消息类型仅在本地订阅数从 0 变为 1 时请求远程订阅,后续回调共用该订阅。 + // first means there were no local subscribers before this addition, not the first subscription in the service lifetime. + // Request a remote subscription only when the local count changes from 0 to 1; later callbacks share it. + bool first; + lock (_locker) + { + first = _callbacks.Count == 0; + _callbacks.Add(subscription); + } + if (first) + { + try + { + while (true) + { + // 这里不能写 while(!token.IsCancellationRequested) 因为取消时仍需清理远程订阅。 + // Do not write while(!token.IsCancellationRequested) here because remote subscription still needs to be cleaned up when canceled. + token.ThrowIfCancellationRequested(); + try + { + // 远程调用失败或取消时,服务端仍可能已完成订阅,因此提前标记需要清理。 + // The server may have subscribed even if the call fails or is canceled, so mark cleanup as required beforehand. + _requiresUnsubscribe = true; + await connection.SubscribeAsync(_entryType, ReceiveAsync, token); + break; + } + catch (Exception ex) when (!token.IsCancellationRequested && + (SignalRConnection.IsTransientFailure(ex) || + ex is InvalidOperationException && connection.IsReconnecting)) + { + logger.LogWarning(ex, "Unable to subscribe to dispatch entry type {EntryType}; retrying.", _entryType); + await Task.Delay(TimeSpan.FromSeconds(3), token); + } + } + } + catch + { + // 订阅未就绪时撤销本次本地注册,使后续订阅者仍能走 first 分支重新尝试。 + // Roll back this registration if subscribing fails so the next subscriber can retry through the first branch. + lock (_locker) + { + _callbacks.Remove(subscription); + } + throw; + } + } + } + finally + { + _subscriptions.Release(); + } + } + + /// + /// 移除消息回调,并在移除最后一个本地订阅者时取消服务端订阅 + /// Remove a message callback and unsubscribe from the server when removing the last local subscriber + /// + /// 消息回调委托The message callback + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + /// + /// 传输层退订取消或失败时保留待清理状态,再次退订或释放服务时继续清理 + /// If transport unsubscription is canceled or fails, retain the pending cleanup state for a subsequent unsubscription or service disposal + /// + public async Task UnSubscribeAsync(Func, Task> callback, CancellationToken cancellationToken = default) + { + await _subscriptions.WaitAsync(cancellationToken); + try + { + bool empty; + lock (_locker) + { + var index = _callbacks.FindIndex(subscription => subscription.Callback == callback); + if (index >= 0) + { + _callbacks.RemoveAt(index); + } + + // 集合中仍可能存在相同回调的其他订阅记录,因此仅在集合为空时才退订服务端。 + // There may still be other subscription records with the same callback in the collection, so unsubscribe from the server only when the collection is empty. + empty = _callbacks.Count == 0; + } + if (empty && _requiresUnsubscribe) + { + await connection.UnsubscribeAsync(_entryType, cancellationToken); + _requiresUnsubscribe = false; + } + } + finally + { + _subscriptions.Release(); + } + } + + /// + /// 反序列化服务端消息并通知本地订阅者 + /// Deserialize a server message and notify local subscribers + /// + /// 消息的 JSON 数据The message JSON data + /// 表示异步操作的任务A task representing the asynchronous operation + private Task ReceiveAsync(JsonElement json) + => DispatchCoreAsync(json.Deserialize>(_serializerOptions) + ?? throw new JsonException("The dispatch entry must not be null.")); + + /// + /// 依次调用本地消息回调,记录单个回调的异常并继续通知其他订阅者 + /// Invoke local message callbacks in order, logging individual callback failures and continuing to notify other subscribers + /// + /// 消息分发项The dispatch entry + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + private async Task DispatchCoreAsync(DispatchEntry entry, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + Subscription[] callbacks; + lock (_locker) + { + callbacks = _callbacks.ToArray(); + } + foreach (var subscription in callbacks) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + Task callbackTask; + lock (_locker) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!_callbacks.Contains(subscription)) + { + continue; + } + + callbackTask = subscription.Callback(entry); + } + + await callbackTask.WaitAsync(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + logger.LogError(ex, "A dispatch subscriber failed for entry type {EntryType}.", _entryType); + } + } + } + + /// + /// 取消正在进行的订阅操作,清空本地回调并清理传输层订阅,包括之前未完成的退订 + /// Cancel pending subscription operations, clear local callbacks, and clean up transport subscriptions, including previously incomplete unsubscriptions + /// + /// 表示异步释放操作的任务A task representing the asynchronous disposal operation + public async ValueTask DisposeAsync() + { + _lifetime.Cancel(); + await _subscriptions.WaitAsync(); + try + { + lock (_locker) + { + _callbacks.Clear(); + } + if (_requiresUnsubscribe) + { + await connection.UnsubscribeAsync(_entryType, CancellationToken.None); + _requiresUnsubscribe = false; + } + } + finally + { + _subscriptions.Release(); + } + } + + /// + /// 独立的消息订阅记录,用于区分同一回调的多次订阅 + /// An individual message subscription that distinguishes separate registrations of the same callback + /// + /// 消息回调委托The message callback + private sealed class Subscription(Func, Task> callback) + { + /// + /// 获得 消息回调委托 + /// Gets the message callback + /// + public Func, Task> Callback { get; } = callback; + } +} diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/ServiceCollectionExtensions.cs b/src/extensions/BootstrapBlazor.SignalR.Client/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..4c168530 --- /dev/null +++ b/src/extensions/BootstrapBlazor.SignalR.Client/ServiceCollectionExtensions.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using BootstrapBlazor.Components; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// SignalR 客户端服务扩展类 +/// SignalR client service collection extensions +/// +public static class ServiceCollectionExtensions +{ + /// + /// 注册 SignalR 客户端消息分发、心跳上报与连接快照服务,共用一个 Hub 连接 + /// Register SignalR client dispatch, heartbeat reporting, and connection snapshot services with one shared Hub connection + /// + /// 服务集合The service collection + /// 服务集合The service collection + /// + /// 在 WebAssembly 宿主中调用 AddBootstrapBlazor 后调用此方法。离线发送会失败,消息不会排队或重放 + /// Call this method after AddBootstrapBlazor in the WebAssembly host. Offline sends fail; messages are not queued or replayed + /// 服务端需注册并映射对应 Hub,Subscribe 方法需返回 bool,以区分订阅拒绝与可重试的执行失败。连接快照查询需在服务端单独启用并授权客户端访问 + /// The server must register and map a corresponding Hub whose Subscribe method returns bool to distinguish subscription rejection from retryable execution failures. Connection snapshot queries require explicit server-side opt-in and client authorization + /// + public static IServiceCollection AddBootstrapBlazorHub(this IServiceCollection services) + { + // 增加 Connection 服务 + services.TryAddSingleton(); + services.AddSingleton(); + + // 增加 Dispatch 服务 + services.AddSingleton(typeof(IDispatchService<>), typeof(DefaultDispatchService<>)); + return services; + } +} diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/SignalRConnection.cs b/src/extensions/BootstrapBlazor.SignalR.Client/SignalRConnection.cs new file mode 100644 index 00000000..2cb52b9c --- /dev/null +++ b/src/extensions/BootstrapBlazor.SignalR.Client/SignalRConnection.cs @@ -0,0 +1,396 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using System.Net; +using System.Net.WebSockets; +using System.Text.Json; + +namespace BootstrapBlazor.Components; + +/// +/// 消息分发与连接服务共用的 SignalR Hub 连接 +/// SignalR Hub connection shared by dispatch and connection services +/// +sealed class SignalRConnection : IAsyncDisposable +{ + private readonly HubConnection _connection; + private readonly ILogger _logger; + private readonly ConcurrentDictionary> _subscriptions = new(StringComparer.Ordinal); + private readonly HashSet _pendingSubscriptions = new(StringComparer.Ordinal); + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly CancellationTokenSource _lifetime = new(); + private readonly IDisposable _receiver; + private int _connectionUsers; + + /// + /// 获得 当前连接是否正在重连 + /// Gets whether the connection is reconnecting + /// + public bool IsReconnecting => _connection.State == HubConnectionState.Reconnecting; + + /// + /// 判断异常是否属于可重试的暂时性连接故障 + /// Determine whether an exception represents a transient connection failure that can be retried + /// + /// 待判断的异常The exception to check + /// 暂时性故障返回 true,否则返回 falseTrue for a transient failure; otherwise, false + internal static bool IsTransientFailure(Exception exception) => exception switch + { + HttpRequestException ex => ex.StatusCode is null or HttpStatusCode.RequestTimeout or + HttpStatusCode.TooManyRequests or HttpStatusCode.InternalServerError or + HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout, + IOException or WebSocketException or TimeoutException or OperationCanceledException => true, + _ => false + }; + + /// + /// 初始化共享连接,配置自动重连与消息接收回调 + /// Initialize the shared connection with automatic reconnection and a message receiver + /// + /// 导航管理器,用于解析 Hub 的绝对地址The navigation manager used to resolve the absolute Hub URL + /// 日志记录器The logger + public SignalRConnection(NavigationManager navigation, ILogger logger) + { + _logger = logger; + _connection = new HubConnectionBuilder() + .WithUrl(navigation.ToAbsoluteUri("bb.hub")) + .WithAutomaticReconnect(new ConnectionRetryPolicy()) + .Build(); + _receiver = _connection.On("Receive", ReceiveAsync); + _connection.Reconnected += RestoreSubscriptionsAsync; + } + + /// + /// 注册指定消息类型的接收回调并订阅服务端消息 + /// Register a receiver for the specified entry type and subscribe to server messages + /// + /// 消息类型标识The entry type identifier + /// 消息接收回调委托The message receiver callback + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + public async Task SubscribeAsync(string entryType, Func callback, CancellationToken cancellationToken) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + var token = cancellation.Token; + await _gate.WaitAsync(token); + try + { + _subscriptions[entryType] = callback; + await EnsureConnectedAsync(token); + await SubscribeCoreAsync(entryType, token); + _pendingSubscriptions.Remove(entryType); + } + catch + { + _subscriptions.TryRemove(entryType, out _); + _pendingSubscriptions.Remove(entryType); + throw; + } + finally + { + _gate.Release(); + } + } + + /// + /// 取消指定消息类型的订阅,并在无订阅和使用引用时停止连接 + /// Unsubscribe from the specified entry type and stop the connection when no subscriptions or usage references remain + /// + /// 消息类型标识The entry type identifier + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + public async Task UnsubscribeAsync(string entryType, CancellationToken cancellationToken) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + var token = cancellation.Token; + await _gate.WaitAsync(token); + try + { + _subscriptions.TryRemove(entryType, out _); + _pendingSubscriptions.Remove(entryType); + if (_connection.State == HubConnectionState.Connected) + { + await _connection.InvokeAsync("Unsubscribe", entryType, token); + } + if (_subscriptions.IsEmpty && Volatile.Read(ref _connectionUsers) == 0) + { + await _connection.StopAsync(token); + } + } + finally + { + _gate.Release(); + } + } + + /// + /// 增加共享连接的使用引用,防止取消消息订阅时停止仍在使用的连接 + /// Add a usage reference to prevent unsubscription from stopping a connection still in use + /// + public void Retain() => Interlocked.Increment(ref _connectionUsers); + + /// + /// 释放共享连接的使用引用,并在无使用引用和订阅时停止连接 + /// Release a usage reference and stop the connection when no usage references or subscriptions remain + /// + /// 表示异步操作的任务A task representing the asynchronous operation + public async Task ReleaseAsync() + { + await _gate.WaitAsync(_lifetime.Token); + try + { + if (Interlocked.Decrement(ref _connectionUsers) == 0 && _subscriptions.IsEmpty) + { + await _connection.StopAsync(_lifetime.Token); + } + } + finally + { + _gate.Release(); + } + } + + /// + /// 调用服务端 Hub 方法并返回结果,操作超时时间为 30 秒 + /// Invoke a server Hub method and return its result with a 30-second operation timeout + /// + /// 返回结果类型The result type + /// Hub 方法名称The Hub method name + /// 方法参数,为 null 时不传递参数The method argument; null invokes the method without arguments + /// 取消令牌The cancellation token + /// 服务端返回的结果The result returned by the server + public async Task InvokeAsync(string method, object? argument, CancellationToken cancellationToken) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + cancellation.CancelAfter(TimeSpan.FromSeconds(30)); + var token = cancellation.Token; + await _gate.WaitAsync(token); + try + { + await EnsureConnectedAsync(token); + return await _connection.InvokeCoreAsync(method, argument == null ? [] : [argument], token); + } + finally + { + _gate.Release(); + } + } + + /// + /// 通过共享 Hub 连接向服务端分发消息 + /// Dispatch an entry to the server through the shared Hub connection + /// + /// 消息数据类型The entry data type + /// 消息类型标识The entry type identifier + /// 消息分发项The dispatch entry + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + public async Task DispatchAsync(string entryType, DispatchEntry entry, CancellationToken cancellationToken) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + var token = cancellation.Token; + await _gate.WaitAsync(token); + try + { + await EnsureConnectedAsync(token); + await _connection.InvokeAsync("Dispatch", entryType, entry, token); + } + finally + { + _gate.Release(); + } + } + + /// + /// 在连接断开时尝试建立连接并恢复订阅,连接尚未就绪时抛出异常 + /// Attempt to start a disconnected connection and restore subscriptions, throwing if the connection is not ready + /// + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + private async Task EnsureConnectedAsync(CancellationToken cancellationToken) + { + if (_connection.State == HubConnectionState.Disconnected) + { + for (var attempt = 0; ; attempt++) + { + try + { + await _connection.StartAsync(cancellationToken); + break; + } + catch (Exception ex) when (attempt < 2 && !cancellationToken.IsCancellationRequested && IsTransientFailure(ex)) + { + _logger.LogWarning(ex, "Unable to connect to the BootstrapBlazor Hub; retrying."); + await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); + } + } + + _pendingSubscriptions.UnionWith(_subscriptions.Keys); + } + + if (_connection.State != HubConnectionState.Connected) + { + throw new InvalidOperationException("The BootstrapBlazor connection is reconnecting. Retry when it is connected."); + } + + await RestoreSubscriptionsCoreAsync(cancellationToken); + } + + /// + /// 自动重连成功后逐项恢复消息订阅,并重试暂时性故障 + /// Restore message subscriptions individually after automatic reconnection and retry transient failures + /// + /// 重连后的连接标识The connection identifier after reconnection + /// 表示异步操作的任务A task representing the asynchronous operation + private async Task RestoreSubscriptionsAsync(string? connectionId) + { + var token = _lifetime.Token; + try + { + var initialize = true; + while (true) + { + await _gate.WaitAsync(token); + try + { + if (initialize) + { + _pendingSubscriptions.UnionWith(_subscriptions.Keys); + initialize = false; + } + if (_connection.State != HubConnectionState.Connected) + { + return; + } + + await RestoreSubscriptionsCoreAsync(token); + if (_pendingSubscriptions.Count == 0) + { + return; + } + } + finally + { + _gate.Release(); + } + + await Task.Delay(TimeSpan.FromSeconds(3), token); + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to restore dispatch subscriptions after reconnecting."); + } + } + + /// + /// 在持有连接锁时恢复待处理订阅,隔离单项失败;服务端明确拒绝时停止重试,执行失败时保留待重试项 + /// Restore pending subscriptions while holding the connection gate, isolating individual failures; stop retrying explicit server rejections and retain server execution failures for retry + /// + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + private async Task RestoreSubscriptionsCoreAsync(CancellationToken cancellationToken) + { + foreach (var entryType in _pendingSubscriptions.ToArray()) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await SubscribeCoreAsync(entryType, cancellationToken); + _pendingSubscriptions.Remove(entryType); + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + if (!IsTransientFailure(ex) && _connection.State == HubConnectionState.Connected) + { + _pendingSubscriptions.Remove(entryType); + } + _logger.LogError(ex, "Unable to restore the dispatch subscription for entry type {EntryType}.", entryType); + } + } + } + + /// + /// 请求服务端订阅,区分明确拒绝与可重试的服务端执行失败 + /// Request a server subscription, distinguishing explicit rejection from retryable server execution failures + /// + /// 消息类型标识The entry type identifier + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + private async Task SubscribeCoreAsync(string entryType, CancellationToken cancellationToken) + { + try + { + if (!await _connection.InvokeAsync("Subscribe", entryType, cancellationToken)) + { + throw new UnauthorizedAccessException("Dispatch subscriptions are not allowed for this type."); + } + } + catch (HubException ex) + { + throw new IOException("The server could not complete the dispatch subscription.", ex); + } + } + + /// + /// 将服务端消息传递给对应消息类型的接收回调 + /// Forward a server message to the receiver registered for its entry type + /// + /// 消息类型标识The entry type identifier + /// 消息的 JSON 数据The message JSON data + /// 表示异步操作的任务A task representing the asynchronous operation + private async Task ReceiveAsync(string entryType, JsonElement entry) + { + if (_subscriptions.TryGetValue(entryType, out var callback)) + { + try + { + await callback(entry); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to receive a dispatch for entry type {EntryType}.", entryType); + } + } + } + + /// + /// 取消连接操作,注销消息接收回调并释放 Hub 连接 + /// Cancel connection operations, unregister the message receiver, and dispose of the Hub connection + /// + /// 表示异步释放操作的任务A task representing the asynchronous disposal operation + public async ValueTask DisposeAsync() + { + _lifetime.Cancel(); + _connection.Reconnected -= RestoreSubscriptionsAsync; + _receiver.Dispose(); + await _connection.DisposeAsync(); + _lifetime.Dispose(); + } + + /// + /// 自动重连策略,首次立即重试,后续间隔递增至最多 30 秒 + /// Automatic reconnection policy that retries immediately at first, then increases the delay up to 30 seconds + /// + private sealed class ConnectionRetryPolicy : IRetryPolicy + { + /// + /// 根据已重试次数计算下次重连的等待时间 + /// Calculate the delay before the next reconnection attempt based on the previous retry count + /// + /// 重试上下文The retry context + /// 下次重连前的等待时间The delay before the next reconnection attempt + public TimeSpan? NextRetryDelay(RetryContext retryContext) + => TimeSpan.FromSeconds(Math.Min(30, retryContext.PreviousRetryCount * 3)); + } +} diff --git a/src/middleware/BootstrapBlazor.Middleware/ConnectionSnapshot.cs b/src/middleware/BootstrapBlazor.Middleware/ConnectionSnapshot.cs new file mode 100644 index 00000000..bb587709 --- /dev/null +++ b/src/middleware/BootstrapBlazor.Middleware/ConnectionSnapshot.cs @@ -0,0 +1,10 @@ +// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved. +// Licensed under the Apache License, Version 2.0. + +namespace BootstrapBlazor.Components; + +/// +/// SignalR 连接快照及服务端计算的在线连接数量 +/// SignalR connection snapshot and the online connection count computed by the server +/// +public sealed record ConnectionSnapshot(long Count, ICollection Connections);