SignalR简介

SignalR是ASP.NET Core中的实时通信框架,它是基于WebSocket、SSE和长轮询三种协议封装的高级框架,相比直接使用WebSocket,SignalR提供了更高级的抽象,SignalR提供了一种RPC模式的API将服务端和客户端连结在一起,使用这个框架能够很方便的实现监视仪表盘、IM聊天室等需要服务端实时推送类型的程序。

这篇笔记我们介绍如何在ASP.NET Core6 WebAPI工程中集成SignalR并和浏览器客户端实时通信。

基本概念

我们知道HTTP协议是一种请求/响应模式的应用层网络协议,通常服务器不能主动向客户端发送消息,为了实现“服务端推送”功能,后来出现了如下几种方案。

短轮询:短轮询即客户端以固定时间间隔向服务端发起HTTP请求,查看是否有新的消息,如果有则拉取消息。短轮询并不是一个好的方案,持续的短轮询会给服务端造成巨大的压力,而且消息的拉取也不是真正实时的,这在金融等对实时性敏感的领域是非常不可接受的。

长轮询:长轮询即客户端向服务器端发起HTTP请求,但服务器端不立即返回响应,这个请求直到有消息时才会返回。长轮询也不是一个好的方案,虽然它一定程度上解决了服务端推送的实时性,但它非常不灵活,能实现的功能十分有限。

SSE(Server Side Events):SSE是HTML5标准的服务端推送协议,使用SSE时,浏览器不会断开HTTP连接,而是持续保持连接并接收数据。SSE通常是单向的,数据从服务端持续推向客户端。SSE适合于实时数据推送,如实时通知、股票行情等。

WebSocket:WebSocket也是HTML5标准的协议,它能够实现基于浏览器的在单个连接上全双工通信。WebSocket适合于实时互动性强、需要双向通信的场景,如在线聊天、游戏、协作办公等。

短轮询和长轮询都是在HTML5出现之前,为了实现“服务端推送”功能而相对“投机取巧”的方案,它们都有很大的缺陷。在现代的前端开发中,除非要兼容旧版本IE浏览器,否则根本没必要再考虑这两种方案了。对于目前主流的浏览器,SSE和WebSocket是首选方案。而对于SignalR,它封装了WebSocket、SSE和长轮询三种协议,在默认配置下,SignalR会通过协议协商机制自动选择一个支持的协议。

SignalR实现聊天室

SignalR的使用方式用文字描述比较抽象,这里我们直接以一个简单的聊天室例子演示如何使用SignalR框架。

服务端实现

使用SignalR框架时,我们的代码是围绕一个Hub类(集线器)编写的,下面代码我们创建了ChatHub类,它继承Hub基类,类中包含了一个自定义的异步方法。

ChatHub.cs

using Microsoft.AspNetCore.SignalR;

namespace DemoSignalR.Hubs;

public class ChatHub : Hub
{
    public async Task SendMessage(string message)
    {
        string connectionId = Context.ConnectionId;
        await Clients.All.SendAsync("ReceiveMessage", $"{connectionId}: {message}");
    }
}

SendMessage()是我们自定义的异步方法,它有一个字符串类型的参数,方法的逻辑很简单,它直接对所有的连接广播消息。发送消息时我们调用了SendAsync()方法,SignalR的API设计为了RPC模式,它的第一个参数其实是客户端接收方法的名字,在后面客户端代码中我们会指定这个名字并在其上监听消息,后面的则是方法的参数。

Program.cs

app.MapHub<ChatHub>("/api/chat");

Program.cs中我们需要注册Hub类,这需要调用MapHub方法,并指定Hub类和注册的URL。

客户端实现

这里我们以NextJS工程为例演示浏览器客户端中如何使用SignalR。首先我们要安装NPM依赖。

npm install @microsoft/signalr

下面代码我们实现了一个页面,页面上包含了列表,输入框和一个按钮。点击按钮时,消息被发送到服务端,服务端推送的消息则显示在列表中。

"use client";

import {useEffect, useState} from "react";
import * as signalR from '@microsoft/signalr';

const ChatPage = () => {
    const [connection, setConnection] = useState(null);
    const [messages, setMessages] = useState([]);
    const [input, setInput] = useState("");

    useEffect(() => {
        const connection = new signalR.HubConnectionBuilder()
            .withUrl("/api/chat")
            .build();
        setConnection(connection);
        connection.start().catch(err => console.error("Error while starting connection: " + err));
        connection.on("ReceiveMessage", (message) => {
            setMessages((prevMessages) => [...prevMessages, message]);
        });
    }, []);

    const sendMessage = async () => {
        await connection.invoke("SendMessage", input);
        setInput("");
    };

    return <div>
        <div>
            <ul>
                {
                    messages.map((message, index) => (<li key={index}>{message}</li>))
                }
            </ul>
        </div>
        <div>
            <div>
                <input type="text" onChange={(e) => setInput(e.target.value)} value={input}/>
            </div>
            <div>
                <button onClick={sendMessage}>发送消息</button>
            </div>
        </div>
    </div>;
}

export default ChatPage;

客户端代码就相对复杂了,首先我们在useEffect()中调用初始化了SignalR连接,这一步指定了服务端Hub注册的URL,随后我们在连接对象上调用start()方法开启连接。接着我们使用on()方法指定了接收消息时的回调,这里ReceiveMessage就是之前我们在服务端指定的接收消息的方法名,第二个参数则是回调方法,方法体内的代码中我们将接收的消息显示在列表内。发送消息时,我们在连接对象上调用invoke()方法,它的第一个参数是服务端的方法名,后面则是方法参数。

我们可以多打开几个浏览器页面验证效果。

通信流程

我们可以打开浏览器控制台观察SignalR的通信流程。首先,客户端会发送一个negotiate请求,它用于协议协商,在这个阶段客户端会请求查看服务端支持的通信方式,并根据自身情况选择一个协议通信。

一般来说,在现代浏览器下客户端都会选择WebSocket通信,因此第二步客户端就会发起WebSocket连接请求,此时服务端返回101 Switching Protocols,表示连接切换为WebSocket并开始全双工通信。

关闭协议协商

其实SignalR的协议协商有点鸡肋,SignalR的JavaScript客户端库并不支持IE浏览器,而非IE浏览器想找个不支持WebSocket协议的都难,一般来说SignalR都会基于WebSocket通信,以尽力保证消息实时性。

开启协议协商还会在分布式环境下产生问题,协议协商和建立WebSocket连接其实是两个HTTP请求,在分布式环境下,响应协议协商的服务器和建立连接的服务器还可能不是一个,这通常需要额外的“粘滞会话”让两次请求由同一个服务器节点处理,“粘滞会话”不是一个好的方案,它有诸多缺点,“粘滞会话”可能让负载均衡器起不到负载均衡的作用,我们最好避免使用。

总而言之,对于目前浏览器已经普遍支持WebSocket的场景下,我们直接关闭协议协商指定必须用WebSocket通信也无可厚非。下面配置限制了服务端和客户端都仅使用WebSocket通信,同时跳过协议协商。

app.MapHub<ChatHub>("/api/chat", options =>
{
    options.Transports = Microsoft.AspNetCore.Http.Connections.HttpTransportType.WebSockets;
});
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/api/chat", {
        skipNegotiation: true,
        transport: signalR.HttpTransportType.WebSockets,
    })
    .build();

水平扩展SignalR集群

前面我们编写的例子在单机服务端下是没有问题的,但如果我们使用多个服务端实例组成分布式集群,问题就出现了。多个服务端实例互相并不能通信,调用Clients.All.SendAsync()广播其实只会将消息群发给当前实例连接的所有客户端。

实际上,主流的IM集群方案都是通过消息中间件(例如RabbitMQ)实现广播的,每个服务端实例既是消息队列的发送端也是接收端,当一个服务端实例希望广播消息时,它会将消息发送到消息队列,消息队列将消息广播给所有服务端实例,每个服务端实例再将消息广播给所有连接到该服务端的客户端。不过微软默认提供的SignalR集群方案就比较Shabby(简陋)了,它直接集成Redis并将其当作消息队列使用,虽然也能实现集群广播功能,但并不能保证消息传递的可靠性。

SignalR集群方案需要使用Redis,我们需要安装相应的NuGet包。

dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis --version 6.0.36

安装依赖后,我们可以按如下配置SignalR服务启用集群支持。

builder.Services.AddSignalR().AddStackExchangeRedis("localhost:6379", options =>
{
    options.Configuration.ChannelPrefix = "SignalR";
});

此时我们可以搭建多个服务端实例查看集群广播效果,Redis数据库中,我们可以用以下命令查看SignalR建立的发布订阅频道。

PUBSUB CHANNELS
作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。
Copyright © 2017-2024 Gacfox All Rights Reserved.
Build with NextJS | Sitemap