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框架时,我们的代码是围绕一个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();
前面我们编写的例子在单机服务端下是没有问题的,但如果我们使用多个服务端实例组成分布式集群,问题就出现了。多个服务端实例互相并不能通信,调用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