发布于2026-07-23 阅读(0)
扫一扫,手机访问
在构建需要实时通信的应用时,WebSocket 是个绕不开的技术点。而 ASP.NET Core 生态中,SignalR 无疑是最趁手的工具之一。它把底层连接管理、协议协商、自动重连这些繁琐的事情都封装好了,让开发者能更专注于业务逻辑本身。下面就从服务端架构、客户端交互,再到一些关键优化点,把整个链路梳理一遍。
要做消息通信,首先得把消息本身的数据结构定义清楚。这不仅仅是几个字段的堆砌,背后其实藏着对消息生命周期的管理思路。
public class ChatMessage
{
public string SenderId { get; set; }
public string ReceiverId { get; set; }
public string Content { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public MessageStatus Status { get; set; } = MessageStatus.Sent;
}
public enum MessageStatus
{
Sent,
Delivered,
Read
}
从模型能看出来,一条消息从发出到被读取,可以经历三个状态:已发送、已送达、已读。这是很多即时通讯应用的基础逻辑,也是后面做状态同步的前提。
Hub 是整个通信的中枢。这里最核心的工作是管理用户连接、处理消息路由和状态同步。
using Microsoft.AspNetCore.SignalR;
using System.Collections.Concurrent;
public class ChatHub : Hub
{
private static readonly ConcurrentDictionary> _connections =
new ConcurrentDictionary>();
// 用户连接管理
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier;
if (!_connections.TryGetValue(userId, out var connections))
{
connections = new List();
_connections[userId] = connections;
}
connections.Add(Context.ConnectionId);
// 通知好友在线状态
await UpdateUserStatus(userId, true);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
if (_connections.TryRemove(Context.UserIdentifier, out var connections))
{
await UpdateUserStatus(Context.UserIdentifier, false);
connections.Remove(Context.ConnectionId);
}
await base.OnDisconnectedAsync(exception);
}
// 发送消息
public async Task SendMessage(string receiverId, string content)
{
var senderId = Context.UserIdentifier;
var message = new ChatMessage
{
SenderId = senderId,
ReceiverId = receiverId,
Content = content
};
// 存储消息(可选)
await Sa veMessageToDb(message);
// 推送消息
await Clients.Group($"user_{receiverId}").SendAsync(
"ReceiveMessage",
senderId,
content,
message.Timestamp
);
// 标记已送达
await Clients.Caller.SendAsync("MessageDelivered", message.Id);
}
// 标记已读
public async Task MarkAsRead(int messageId)
{
// 更新数据库状态
var message = await GetMessageById(messageId);
if (message?.ReceiverId == Context.UserIdentifier)
{
message.Status = MessageStatus.Read;
await Sa veMessageToDb(message);
// 通知发送者
await Clients.Group($"user_{message.SenderId}").SendAsync("MessageRead", messageId);
}
}
private static void UpdateUserStatus(string userId, bool isOnline)
{
// 更新全局状态缓存
// 触发好友状态变更通知
Clients.All.SendAsync("UserStatusChanged", userId, isOnline);
}
}
这里有一个细节值得注意:为什么用 Group 而不是直接向某个 ConnectionId 发送?因为一个用户可能同时登录多个设备(比如手机和电脑),给用户所在的组发消息,能确保所有设备都收到。而 ConcurrentDictionary 用来维护用户与连接ID的映射关系,这是多用户场景下的标准做法。
前端的交互界面很简单,但功能点一个不少:在线用户列表、消息展示区域、输入框和发送按钮。
连接 SignalR 的代码写起来很直接,但有几个关键点需要把握好:自动重连的配置、事件监听的处理,以及连接状态的日志输出。
const connection = new signalR.HubConnectionBuilder()
.withUrl("/chathub")
.withAutomaticReconnect()
.build();
// 接收消息
connection.on("ReceiveMessage", (senderId, content, timestamp) => {
const msgElement = document.createElement("div");
msgElement.innerHTML = `
${senderId} (${new Date(timestamp).toLocaleTimeString()}):
${content}
`;
document.getElementById("message-list").appendChild(msgElement);
});
// 用户状态更新
connection.on("UserStatusChanged", (userId, isOnline) => {
const statusElement = document.getElementById(`user-${userId}-status`);
statusElement.textContent = isOnline ? "在线" : "离线";
});
// 连接建立
connection.start().then(() => {
console.log("已连接到SignalR服务");
}).catch(err => console.error(err.toString()));
// 发送消息
document.getElementById("send-btn").addEventListener("click", async () => {
const receiverId = document.getElementById("receiverId").value;
const content = document.getElementById("message-input").value;
await connection.invoke("SendMessage", receiverId, content);
});
用过几次 SignalR 之后就会发现,withAutomaticReconnect() 这个配置几乎是必选的。网络波动时,它会自动尝试重新连接,体验上平滑很多。而事件监听部分,则按照服务端定义好的回调一一对应即可。
消息不能只停留在内存里,落地到数据库是刚需。用 EF Core 来操作,代码量很轻。
public class AppDbContext : DbContext
{
public DbSet Messages { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.HasIndex(m => new { m.SenderId, m.ReceiverId })
.IsUnique(false);
}
}
// 保存消息到数据库
public async Task Sa veMessageToDb(ChatMessage message)
{
using var dbContext = new AppDbContext();
dbContext.Messages.Add(message);
await dbContext.Sa veChangesAsync();
}
这里为 SenderId 和 ReceiverId 建了一个联合索引,但注意它是非唯一索引。因为一个用户对会发很多条消息,这个索引的设计是为了后续做消息检索用的。
浏览器通知是提升用户体验的利器。当用户没在看聊天界面时,一条弹窗通知能及时抓住注意力。
// 弹窗通知(使用iziToast)
connection.on("ReceiveMessage", async (senderId, content, timestamp) => {
iziToast.info({
title: senderId,
message: content,
position: 'topRight',
timeout: 5000
});
});
消息的已读/未读状态,是即时通讯里感知最明显的功能之一。前端在用户查看消息时触发标记已读,服务端收到后通知发送方。
// 前端标记已读
async function markMessageAsRead(messageId) {
await connection.invoke("MarkAsRead", messageId);
}
// 接收已读确认
connection.on("MessageRead", (messageId) => {
document.querySelector(`[data-id="${messageId}"]`).classList.add("read");
});
这个双向确认的机制,玩过即时通讯的应该都不陌生。它让消息的流转变得透明,用户能清楚地知道自己的消息到底有没有被对方看到。
服务端的配置集中在 Program.cs 里。SignalR 的服务注册、认证中间件、路由映射,都得在这里安排好。
var builder = WebApplication.CreateBuilder(args);
// 添加SignalR服务
builder.Services.AddSignalR(hubOptions => {
hubOptions.EnableDetailedErrors = true;
hubOptions.KeepAliveInterval = TimeSpan.FromMinutes(2);
});
// 添加身份认证(JWT示例)
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => {
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuer = true,
ValidateAudience = true
};
});
var app = builder.Build();
// 路由配置
app.UseAuthentication();
app.UseAuthorization();
app.MapHub("/chathub");
app.Run();
有个容易被忽略的点:KeepAliveInterval 的设置。如果间隔太短,会增加网络开销;太长又可能导致连接被误判为断开。2分钟是一个比较稳妥的折中方案。另外,EnableDetailedErrors 在开发阶段建议开着,但生产环境最好关掉,避免泄露内部细节。
到生产环境,性能问题就绕不过去了。这里提几个关键方向:连接管理、消息分片和负载均衡。
连接管理 —— 使用 ConcurrentDictionary 维护用户连接池,配合心跳检测机制,能及时发现并清理僵尸连接。
消息分片 —— 大文件传输时,不能指望一次搞定。分片传输是常规做法。
// 大文件分片传输
public async Task SendLargeFile(IFormFile file)
{
var buffer = new byte[4096];
using (var stream = file.OpenReadStream())
{
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer)) > 0)
{
await Clients.Caller.SendAsync("ReceiveFileChunk",
file.Name, buffer, bytesRead);
}
}
}
负载均衡 —— 单机扛不住,就得靠 Redis 来撑后端存储。SignalR 官方提供了 StackExchange.Redis 的扩展,配置起来很直接。
builder.Services.AddSignalR().AddStackExchangeRedis("redis_connection_string");
这三个方向基本覆盖了从单机到集群、从小消息到大数据传输的优化路径。实际项目中,还需要根据流量特点做针对性调整。