MVC Core、Web Sockets和线程处理

8

我正在研究一种解决方案,它使用web socket协议来通知客户端(Web浏览器)当服务器(MVC Core Web应用程序)上发生某些事件时。我使用Microsoft.AspNetCore.WebSockets nuget。

这是我的客户端代码:

  $(function () {
    var socket = new WebSocket("ws://localhost:61019/data/openSocket");

    socket.onopen = function () {
      $(".socket-status").css("color", "green");
    }

    socket.onmessage = function (message) {
      $("body").append(document.createTextNode(message.data));
    }

    socket.onclose = function () {
      $(".socket-status").css("color", "red");
    }
  });

当此视图被加载时,套接字请求将立即发送到MVC Core应用程序。以下是控制器操作:

[Route("data")]
public class DataController : Controller
{
    [Route("openSocket")]
    [HttpGet]
    public ActionResult OpenSocket()
    {
        if (HttpContext.WebSockets.IsWebSocketRequest)
        {
            WebSocket socket = HttpContext.WebSockets.AcceptWebSocketAsync().Result;

            if (socket != null && socket.State == WebSocketState.Open)
            {
                while (!HttpContext.RequestAborted.IsCancellationRequested)
                {
                    var response = string.Format("Hello! Time {0}", System.DateTime.Now.ToString());
                    var bytes = System.Text.Encoding.UTF8.GetBytes(response);

                    Task.Run(() => socket.SendAsync(new System.ArraySegment<byte>(bytes),
                        WebSocketMessageType.Text, true, CancellationToken.None));
                    Thread.Sleep(3000);
                }
            }
        }
        return new StatusCodeResult(101);
    }
}

这段代码运行得很好。WebSocket仅用于发送,不接收任何内容。然而,问题在于while循环会一直占用DataController线程,直到检测到取消请求。

Web socket绑定到HttpContext对象。一旦Web请求的HttpContext被销毁,套接字连接就会立即关闭。

问题1:有没有办法将套接字保留在控制器线程之外?我尝试将其放入单例中,该单例位于运行在主应用程序线程上的MVC Core启动类中。是否有任何方法可以保持套接字打开或从主应用程序线程内重新建立连接,而不是使用while循环一直占用控制器线程?即使认为在套接字连接保持打开的情况下,暂停控制器线程也是可以接受的,我仍无法想到在OpenSocket的while循环中放置任何良好代码的好方法。您认为在控制器中设置手动重置事件并等待它在OpenSocket操作中的while循环内被设置怎么样呢?

问题2:如果无法在MVC中分离HttpContext和WebSocket对象,还可以利用哪些替代技术或开发模式来实现套接字连接的复用?如果有人认为SignalR或类似库有一些代码可以使套接字独立于HttpContext,可以分享一些示例代码。如果有人认为MVC不具备处理独立套接字通信的能力,则提供一个示例,我不介意切换到纯ASP.NET或Web API,

问题3:要求保持套接字连接处于活动状态或能够重新连接,直到用户明确的超时或取消请求。想法是服务器上发生某些独立事件触发建立的套接字发送数据。如果您认为其他技术(如HTML/2或流媒体)对于此场景更有用,您可以描述您将使用的模式和框架吗?

P.S. 可能的解决方案是每秒发送一个AJAX请求,以询问服务器上是否有新数据。这是最后的手段。


“SignalR” 看起来非常有前途,但是由于我对它了解不多,所以无法为您提供任何代码。 - VMAtm
1个回答

9

经过长时间的研究,我最终选择了自定义中间件解决方案。这是我的中间件类:

        public class SocketMiddleware
    {
        private static ConcurrentDictionary<string, SocketMiddleware> _activeConnections = new ConcurrentDictionary<string, SocketMiddleware>();
        private string _packet;

        private ManualResetEvent _send = new ManualResetEvent(false);
        private ManualResetEvent _exit = new ManualResetEvent(false);
        private readonly RequestDelegate _next;

        public SocketMiddleware(RequestDelegate next)
        {
            _next = next;
        }

        public void Send(string data)
        {
            _packet = data;
            _send.Set();
        }

        public async Task Invoke(HttpContext context)
        {
            if (context.WebSockets.IsWebSocketRequest)
            {    
                string connectionName = context.Request.Query["connectionName"]);
                if (!_activeConnections.Any(ac => ac.Key == connectionName))
                {
                    WebSocket socket = await context.WebSockets.AcceptWebSocketAsync();
                    if (socket == null || socket.State != WebSocketState.Open)
                    {
                        await _next.Invoke(context);
                        return;
                    }
                    Thread sender = new Thread(() => StartSending(socket));
                    sender.Start();

                    if (!_activeConnections.TryAdd(connectionName, this))
                    {
                        _exit.Set();
                        await _next.Invoke(context);
                        return;
                    }

                    while (true)
                    {
                        WebSocketReceiveResult result = socket.ReceiveAsync(new ArraySegment<byte>(new byte[1]), CancellationToken.None).Result;
                        if (result.CloseStatus.HasValue)
                        {
                            _exit.Set();
                            break;
                        }
                    }

                    SocketHandler dummy;
                    _activeConnections.TryRemove(key, out dummy);
                }
            }

            await _next.Invoke(context);

            string data = context.Items["Data"] as string;
            if (!string.IsNullOrEmpty(data))
            {
                string name = context.Items["ConnectionName"] as string;
                SocketMiddleware connection = _activeConnections.Where(ac => ac.Key == name)?.Single().Value;
                if (connection != null)
                {
                    connection.Send(data);
                }
            }
        }

        private void StartSending(WebSocket socket)
        {
            WaitHandle[] events = new WaitHandle[] { _send, _exit };
            while (true)
            {
                if (WaitHandle.WaitAny(events) == 1)
                {
                    break;
                }

                if (!string.IsNullOrEmpty(_packet))
                {
                    SendPacket(socket, _packet);
                }
                _send.Reset();
            }
        }

        private void SendPacket(WebSocket socket, string packet)
        {
            byte[] buffer = Encoding.UTF8.GetBytes(packet);
            ArraySegment<byte> segment = new ArraySegment<byte>(buffer);
            Task.Run(() => socket.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None));
        }
    }

这个中间件将在每个请求中运行。调用Invoke时,它会检查是否为Web套接字请求。如果是,则中间件会检查是否已经打开了这样的连接,如果没有打开,则握手被接受,并将其添加到连接字典中。重要的是,字典是静态的,因此只在应用程序生命周期内创建一次。
现在,如果我们在这里停下来并向上移动管道,HttpContext最终将被销毁,由于套接字没有正确封装,它也将被关闭。因此,我们必须保持中间件线程运行。这是通过要求套接字接收一些数据来完成的。
您可能会问为什么需要接收任何内容,如果要求只发送?答案是这是可靠地检测客户端断开的唯一方法。HttpContext.RequestAborted.IsCancellationRequested仅在while循环中不断发送时才有效。如果您需要在WaitHandle上等待某些服务器事件,则取消标志永远不会为真。我试图等待HttpContext.RequestAborted.WaitHandle作为我的退出事件,但它也从未设置。因此,我们要求套接字接收一些东西,如果这些东西将CloseStatus.HasValue设置为true,则我们知道客户端已断开连接。如果我们收到其他内容(客户端代码不安全),则会忽略它并重新开始接收。
发送是在单独的线程中完成的。原因是相同的,如果我们在主要的中间件线程上等待,则无法检测到断开连接。为了通知发送线程客户端已断开连接,我们使用_exit同步变量。请记住,这里可以有私有成员,因为SocketMiddleware实例保存在静态容器中。
那么,我们如何使用这个设置实际发送任何内容呢?假设服务器上发生事件,并且一些数据可用。为了简单起见,让我们假设此数据到达某个控制器操作内的普通http请求中。 SocketMiddleware将运行每个请求,但由于它不是Web套接字请求,_next.Invoke(context)被调用,请求到达控制器操作,可能看起来像这样:
[Route("ProvideData")]
[HttpGet]
public ActionResult ProvideData(string data, string connectionName)
{
    if (!string.IsNullOrEmpty(data) && !string.IsNullOrEmpty(connectionName))
    {
        HttpContext.Items.Add("ConnectionName", connectionName);
        HttpContext.Items.Add("Data", data);
    }
        return Ok();
}

控制器填充一个项目集合,用于在组件之间共享数据。然后管道返回到 SocketMiddleware,我们在其中检查上下文中的 Items 集合是否有有趣的内容。如果有,我们会从字典中选择相应的连接,并调用其 Send() 方法,该方法设置数据字符串并设置 _send 事件,允许在发送线程内部单次运行 while 循环。

看,我们有了一个能够在服务器端发送事件的套接字连接。这个例子非常简单,只是为了说明概念而存在。当然,要使用这个中间件,您需要在添加 MVC 之前,在 Startup 类中添加以下行:

app.UseWebSockets();
app.UseMiddleware<SocketMiddleware>();

代码非常奇怪,希望SignalR for dotnetcore发布后我们能写出更好的代码。希望这个示例对某人有用。欢迎评论和建议。


2
所以你已经构建了这个辅助类,但是你仍然需要为每个打开的套接字保持2个线程在运行。我的主要担忧是,如果打开的套接字数量增加,我们可能会耗尽可用的线程。你有进行任何研究/负载测试来证明这不会发生吗?连接的套接字数量可能很容易达到数百个。它仍然能正常工作吗?谢谢。 - Mike Keskinov

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接