关闭/断开 ASP.NET Core signalR 客户端连接的正确方法是什么?

What Is The Correct Way of Closing / Disconnecting An ASP.NET Core signalR Client Connection?

我是一名新用户,正在努力从 ASP.NET 核心 Blazor 服务器页面优雅地关闭辅助 signalR 客户端。

我在第一次呈现 Blazor 服务器页面时设置辅助 signalR 客户端连接。当通过浏览器选项卡关闭页面时,我试图关闭此辅助 signalR 客户端连接。

在撰写本文时,DisposeAsync 似乎不会在通过浏览器选项卡关闭页面时触发。但是,Dispose方法IS被触发了。此外,在 Safari 13.0.5 中,关闭浏览器选项卡时不会触发 Dispose 方法? Opera、Firefox 和 Chrome 都在关闭浏览器选项卡时触发 Dispose。通过 macOS Catalina v10.15.7.

将 Safari 更新到 v14.0(15610.1.28.9、15610)解决了这个问题

目前,我正在从 Dispose 调用 DisposeAsync 以关闭 signalR 连接。我正在使用以下代码关闭客户端连接:

...
Logger.LogInformation("Closing secondary signalR connection...");
await hubConnection.StopAsync();
Logger.LogInformation("Closed secondary signalR connection");
...

StopAsync 方法出现阻塞,即没有消息输出 “已关闭的辅助信号 R 连接”。虽然,我的服务器集线器的 OnDisconnectedAsync 处理程序显示连接已断开。这类似于 behaviour described in this issue.

如何在 ASP.NET Core 3.1 中正确处理 signalR 连接?

完整的代码清单如下所示:

处理signalR连接

 #region Dispose
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }


        /// <summary>
        /// Clear secondary signalR Closed event handler and stop the
        /// secondary signalR connection
        /// </summary>
        /// <remarks>
        /// ASP.NET Core Release Candidate 5 calls DisposeAsync when 
        /// navigating away from a Blazor Server page. Until the 
        /// release is stable DisposeAsync will have to be triggered from
        /// Dispose. Sadly, this means having to use GetAwaiter().GetResult()
        /// in Dispose().
        /// However, providing DisposeAsync() now makes the migration easier
        /// https://github.com/dotnet/aspnetcore/issues/26737
        /// https://github.com/dotnet/aspnetcore/issues/9960
        /// https://github.com/dotnet/aspnetcore/milestone/57?closed=1
        /// </remarks>
        protected virtual void Dispose(bool disposing)
        {
            if (disposed)
                return;

            if (disposing)
            {
                Logger.LogInformation("Index.razor page is disposing...");

                try
                {
                    if (hubConnection != null)
                    {
                        Logger.LogInformation("Removing signalR client event handlers...");
                        hubConnection.Closed -= CloseHandler;
                    }

                    // Until ASP.NET Core 5 is released in November
                    // trigger DisposeAsync(). See docstring and DiposeAsync() below.
                    // not ideal, but having to use GetAwaiter().GetResult() until
                    // forthcoming release of ASP.NET Core 5 for the introduction
                    // of triggering DisposeAsync on pages that implement IAsyncDisposable
                    DisposeAsync().GetAwaiter().GetResult();
                }
                catch (Exception exception)
                {
                    Logger.LogError($"Exception encountered while disposing Index.razor page :: {exception.Message}");
                }
            }

            disposed = true;
        }


        /// <summary>
        /// Dispose the secondary backend signalR connection
        /// </summary>
        /// <remarks>
        /// ASP.NET Core Release Candidate 5 adds DisposeAsync when 
        /// navigating away from a Blazor Server page. Until the 
        /// release is stable DisposeAsync will have to be triggered from
        /// Dispose. Sadly, this means having to use GetAwaiter().GetResult()
        /// in Dispose().
        /// However, providing DisposeAsync() now makes the migration easier
        /// https://github.com/dotnet/aspnetcore/issues/26737
        /// https://github.com/dotnet/aspnetcore/issues/9960
        /// https://github.com/dotnet/aspnetcore/milestone/57?closed=1
        /// </remarks>
        public async virtual ValueTask DisposeAsync()
        {
            try
            {
                if (hubConnection != null)
                {
                    Logger.LogInformation("Closing secondary signalR connection...");
                    await hubConnection.StopAsync();
                    Logger.LogInformation("Closed secondary signalR connection");
                }
                // Dispose(); When migrated to ASP.NET Core 5 let DisposeAsync trigger Dispose
            }
            catch (Exception exception)
            {
                Logger.LogInformation($"Exception encountered wwhile stopping secondary signalR connection :: {exception.Message}");
            }
        }
        #endregion

Blazor 服务器页面的完整代码

using System;
using System.Threading.Tasks;

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

using WebApp.Data;
using WebApp.Data.Serializers.Converters;
using WebApp.Data.Serializers.Converters.Visitors;
using WebApp.Repository.Contracts;



namespace WebApp.Pages
{
    public partial class Index : IAsyncDisposable, IDisposable
    {
        private HubConnection hubConnection;
        public bool IsConnected => hubConnection.State == HubConnectionState.Connected;
        private bool disposed = false;


        [Inject]
        public NavigationManager NavigationManager { get; set; }
        [Inject]
        public IMotionDetectionRepository Repository { get; set; }
        [Inject]
        public ILogger<MotionDetectionConverter> LoggerMotionDetection { get; set; }
        [Inject]
        public ILogger<MotionInfoConverter> LoggerMotionInfo { get; set; }
        [Inject]
        public ILogger<JsonVisitor> LoggerJsonVisitor { get; set; }
        [Inject]
        public ILogger<Index> Logger { get; set; }


        #region Dispose
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }


        /// <summary>
        /// Clear secondary signalR Closed event handler and stop the
        /// secondary signalR connection
        /// </summary>
        /// <remarks>
        /// ASP.NET Core Release Candidate 5 calls DisposeAsync when 
        /// navigating away from a Blazor Server page. Until the 
        /// release is stable DisposeAsync will have to be triggered from
        /// Dispose. Sadly, this means having to use GetAwaiter().GetResult()
        /// in Dispose().
        /// However, providing DisposeAsync() now makes the migration easier
        /// https://github.com/dotnet/aspnetcore/issues/26737
        /// https://github.com/dotnet/aspnetcore/issues/9960
        /// https://github.com/dotnet/aspnetcore/milestone/57?closed=1
        /// </remarks>
        protected virtual void Dispose(bool disposing)
        {
            if (disposed)
                return;

            if (disposing)
            {
                Logger.LogInformation("Index.razor page is disposing...");

                try
                {
                    if (hubConnection != null)
                    {
                        Logger.LogInformation("Removing signalR client event handlers...");
                        hubConnection.Closed -= CloseHandler;
                    }

                    // Until ASP.NET Core 5 is released in November
                    // trigger DisposeAsync(). See docstring and DiposeAsync() below.
                    // not ideal, but having to use GetAwaiter().GetResult() until
                    // forthcoming release of ASP.NET Core 5 for the introduction
                    // of triggering DisposeAsync on pages that implement IAsyncDisposable
                    DisposeAsync().GetAwaiter().GetResult();
                }
                catch (Exception exception)
                {
                    Logger.LogError($"Exception encountered while disposing Index.razor page :: {exception.Message}");
                }
            }

            disposed = true;
        }


        /// <summary>
        /// Dispose the secondary backend signalR connection
        /// </summary>
        /// <remarks>
        /// ASP.NET Core Release Candidate 5 adds DisposeAsync when 
        /// navigating away from a Blazor Server page. Until the 
        /// release is stable DisposeAsync will have to be triggered from
        /// Dispose. Sadly, this means having to use GetAwaiter().GetResult()
        /// in Dispose().
        /// However, providing DisposeAsync() now makes the migration easier
        /// https://github.com/dotnet/aspnetcore/issues/26737
        /// https://github.com/dotnet/aspnetcore/issues/9960
        /// https://github.com/dotnet/aspnetcore/milestone/57?closed=1
        /// </remarks>
        public async virtual ValueTask DisposeAsync()
        {
            try
            {
                if (hubConnection != null)
                {
                    Logger.LogInformation("Closing secondary signalR connection...");
                    await hubConnection.StopAsync();
                    Logger.LogInformation("Closed secondary signalR connection");
                }
                // Dispose(); When migrated to ASP.NET Core 5 let DisposeAsync trigger Dispose
            }
            catch (Exception exception)
            {
                Logger.LogInformation($"Exception encountered wwhile stopping secondary signalR connection :: {exception.Message}");
            }
        }
        #endregion


        #region ComponentBase

        /// <summary>
        /// Connect to the secondary signalR hub after rendering.
        /// Perform on the first render. 
        /// </summary>
        /// <remarks>
        /// This could have been performed in OnInitializedAsync but
        /// that method gets executed twice when server prerendering is used.
        /// </remarks>
        protected override async Task OnAfterRenderAsync(bool firstRender)
        {
            if (firstRender)
            {
                var hubUrl = NavigationManager.BaseUri.TrimEnd('/') + "/motionhub";

                try
                {
                    Logger.LogInformation("Index.razor page is performing initial render, connecting to secondary signalR hub");

                    hubConnection = new HubConnectionBuilder()
                        .WithUrl(hubUrl)
                        .ConfigureLogging(logging =>
                        {
                            logging.AddConsole();
                            logging.AddFilter("Microsoft.AspNetCore.SignalR", LogLevel.Information);
                        })
                        .AddJsonProtocol(options =>
                        {
                            options.PayloadSerializerOptions = JsonConvertersFactory.CreateDefaultJsonConverters(LoggerMotionDetection, LoggerMotionInfo, LoggerJsonVisitor);
                        })
                        .Build();

                    hubConnection.On<MotionDetection>("ReceiveMotionDetection", ReceiveMessage);
                    hubConnection.Closed += CloseHandler;

                    Logger.LogInformation("Starting HubConnection");

                    await hubConnection.StartAsync();

                    Logger.LogInformation("Index Razor Page initialised, listening on signalR hub url => " + hubUrl.ToString());
                }
                catch (Exception e)
                {
                    Logger.LogError(e, "Encountered exception => " + e);
                }
            }
        }

        protected override async Task OnInitializedAsync()
        {
            await Task.CompletedTask;
        }
        #endregion


        #region signalR

        /// <summary>Log signalR connection closing</summary>
        /// <param name="exception">
        /// If an exception occurred while closing then this argument describes the exception
        /// If the signaR connection was closed intentionally by client or server, then this
        /// argument is null
        /// </param>
        private Task CloseHandler(Exception exception)
        {
            if (exception == null)
            {
                Logger.LogInformation("signalR client connection closed");
            }
            else
            {
                Logger.LogInformation($"signalR client closed due to error => {exception.Message}");
            }

            return Task.CompletedTask;
        }

        /// <summary>
        /// Add motion detection notification to repository
        /// </summary>
        /// <param name="message">Motion detection received via signalR</param>
        private void ReceiveMessage(MotionDetection message)
        {
            try
            {
                Logger.LogInformation("Motion detection message received");

                Repository.AddItem(message);

                StateHasChanged();
            }
            catch (Exception ex)
            {
                Logger.LogError(ex, "An exception was encountered => " + ex.ToString());
            }
        }
        #endregion
    }
}

signalR 服务器集线器

using System;
using System.Threading.Tasks;

using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;


namespace WebApp.Realtime.SignalR
{
    /// <summary>
    /// This represents endpoints available on the server, available for the
    /// clients to call
    /// </summary>
    public class MotionHub : Hub<IMotion>
    {
        private bool _disposed = false;
        public ILogger<MotionHub> Logger { get; set; }

        public MotionHub(ILogger<MotionHub> logger) : base()
        {
            Logger = logger;
        }


        public override async Task OnConnectedAsync()
        {
            Logger.LogInformation($"OnConnectedAsync => Connection ID={Context.ConnectionId} : User={Context.User.Identity.Name}");
            await base.OnConnectedAsync();
        }

        public override async Task OnDisconnectedAsync(Exception exception)
        {
            if (exception != null)
            {
                Logger.LogInformation($"OnDisconnectedAsync => Connection ID={Context.ConnectionId} : User={Context.User.Identity.Name} : Exception={exception.Message}");
            }
            else
            {
                Logger.LogInformation($"OnDisconnectedAsync => Connection ID={Context.ConnectionId} : User={Context.User.Identity.Name}");
            }

            await base.OnDisconnectedAsync(exception);
        }

        // Protected implementation of Dispose pattern.
        protected override void Dispose(bool disposing)
        {
            if (_disposed)
            {
                return;
            }

            _disposed = true;

            // Call base class implementation.
            base.Dispose(disposing);
        }
    }
}

已在 ASP.NET Core Github Discussions 的帮助下修复。

Dispose 方法中替换了 DisposeAsync().GetAwaiter().GetResult(); to _ = DisposeAsync(); 这会调用 DiposeAsync() 而不等待任务结果。

还更新了停止集线器连接的代码:

  try { await hubConnection.StopAsync(); }
  finally
  {
    await hubConnection.DisposeAsync();
  }

DisposeAsync 内,对 HubConnectionStopAsync 调用不再阻塞,连接正常关闭。