3、Hub基础:创建第一个Hub、定义服务端方法、客户端调用、连接生命周期

好,咱们正式开始写代码了。

这一章是整个SignalR课程的基石。说白了,你后面所有花哨的功能——群聊、实时看板、游戏对战——都离不开今天要讲的这几个核心概念。

我个人习惯,学任何新技术,第一件事就是先跑通一个最简单的例子。哪怕只是让服务器给客户端说一句"Hello",也比看十遍文档管用。

3.1 什么是Hub?

Hub,翻译过来叫"集线器"。你可以把它想象成一个电话总机

客户端(浏览器、手机App)就是一个个电话机。它们不直接互相打电话,而是先拨到总机(Hub),由总机决定把消息转给谁。

在SignalR里,Hub就是服务端的一个类。它负责:

  • 接收客户端的调用(客户端说:我要发消息)
  • 向客户端推送消息(服务器说:有人发消息了,大家快看)
  • 管理连接(谁上线了?谁掉线了?)

嗯,这里要注意:Hub本身是瞬时对象。每次客户端调用,都会创建一个新的Hub实例。所以千万别在Hub里存什么全局状态,我早期踩过这个坑。

3.2 创建你的第一个Hub

咱们用ASP.NET Core来演示。先建一个空的Web项目,然后添加一个类,继承Hub基类。

using Microsoft.AspNetCore.SignalR;

public class ChatHub : Hub
{
    public async Task SendMessage(string user, string message)
    {
        // 这里写你的业务逻辑
        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }
}

就这么简单?对,就这么简单。

SendMessage是服务端方法,客户端可以调用它。Clients.All.SendAsync是向所有连接的客户端广播消息。

我记得第一次写完这个类,心里还犯嘀咕:这也太轻量了吧?后来才发现,SignalR的精髓就在这——把复杂的实时通信抽象成简单的方法调用。

核心概念:

服务端方法 = 客户端可以调用的方法(通过Invoke或Send)

客户端方法 = 服务端可以调用的方法(通过Clients.xxx.SendAsync)

别搞反了,我见过不少新手在这上面栽跟头。

3.3 注册Hub和配置端点

光有类不行,还得告诉ASP.NET Core:嘿,我有个Hub,你得把它暴露出去。

Program.cs里加上两行代码:

var builder = WebApplication.CreateBuilder(args);

// 注册SignalR服务
builder.Services.AddSignalR();

var app = builder.Build();

app.MapHub<ChatHub>("/chatHub");  // 映射Hub到路径

app.Run();

"/chatHub"这个路径,就是客户端要连接的URL。你可以随便起名,但团队里最好统一规范。我个人习惯用/hubs/功能名,比如/hubs/chat/hubs/notification

小提示:

如果你用了CORS(跨域请求),记得把Hub的路径也加进去。我曾经排查了半天,结果发现是CORS把SignalR的协商请求给拦了。

3.4 客户端调用服务端方法

服务端写好了,客户端怎么调?咱们用JavaScript举例。

// 建立连接
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/chatHub")
    .build();

// 启动连接
connection.start().then(() => {
    console.log("连接成功!");
    
    // 调用服务端的 SendMessage 方法
    connection.invoke("SendMessage", "张三", "大家好,我是张三")
        .catch(err => console.error(err));
}).catch(err => console.error(err));

注意看connection.invoke("SendMessage", "张三", "大家好,我是张三")

第一个参数是服务端方法名,后面跟着的是参数。参数个数、类型必须和服务端SendMessage(string user, string message)一一对应。

你想想看,如果服务端方法有3个参数,你只传了2个,SignalR会直接报错。嗯,这个错误信息还挺友好的,会告诉你"参数数量不匹配"。

3.5 服务端调用客户端方法

这是SignalR最核心的能力——服务器主动推消息

在Hub里,通过Clients属性可以拿到所有连接的客户端引用。常用的有:

属性/方法 作用
Clients.All 向所有连接的客户端发送
Clients.Caller 只向调用当前方法的客户端发送
Clients.Others 向除了调用者以外的所有客户端发送
Clients.Client("connectionId") 向指定的单个客户端发送
Clients.Group("groupName") 向指定组内的所有客户端发送

客户端这边,需要先注册一个方法来接收:

// 注册客户端方法,等待服务端调用
connection.on("ReceiveMessage", (user, message) => {
    console.log(`${user} 说:${message}`);
    // 这里可以更新UI,比如把消息显示在聊天框里
});

注意:connection.on必须在connection.start()之前注册。为什么?因为连接一旦建立,服务端随时可能发消息过来。如果你还没注册监听器,消息就丢了。

避坑指南:

我曾经在项目里犯过一个低级错误——把connection.on放在了start()之后。结果呢?前几条消息永远收不到。排查了半天,最后发现是注册顺序的问题。所以记住:先注册,再启动

3.6 连接生命周期

一个SignalR连接,从生到死,会经历几个阶段。理解这个,对调试和异常处理特别有帮助。

3.6.1 连接状态

连接对象有一个state属性,取值如下:

状态 说明
Disconnected 未连接或已断开
Connecting 正在连接中
Connected 已连接,可以通信
Reconnecting 自动重连中(需要启用自动重连)

3.6.2 生命周期事件

SignalR提供了几个事件钩子,让你可以在关键时刻做点事情:

connection.onclose(() => {
    console.log("连接已关闭");
    // 可以在这里提示用户,或者尝试手动重连
});

connection.onreconnecting(() => {
    console.log("正在重连...");
    // 可以显示一个"网络不稳定"的提示
});

connection.onreconnected((connectionId) => {
    console.log("重连成功,新ID:" + connectionId);
    // 可以重新订阅一些必要的数据
});

我个人强烈建议,生产环境一定要启用自动重连。网络抖动是常态,用户不可能每次都手动刷新页面。

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/chatHub")
    .withAutomaticReconnect()  // 启用自动重连
    .build();

默认的重连策略是:先等0秒,然后2秒,10秒,30秒……每次间隔翻倍。你也可以自定义重连间隔,这个后面章节会细讲。

3.6.3 Hub的生命周期方法

在服务端,Hub基类提供了两个虚方法,你可以重写它们:

public class ChatHub : Hub
{
    public override async Task OnConnectedAsync()
    {
        // 有客户端连接时触发
        Console.WriteLine($"客户端连接:{Context.ConnectionId}");
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        // 有客户端断开时触发
        Console.WriteLine($"客户端断开:{Context.ConnectionId}");
        if (exception != null)
        {
            Console.WriteLine($"断开原因:{exception.Message}");
        }
        await base.OnDisconnectedAsync(exception);
    }
}

这两个方法特别适合做在线用户管理。比如,有人上线了,你把他加到在线列表;有人下线了,你把他移除。

小技巧:

OnDisconnectedAsync里,记得清理这个用户加入的所有组。否则下次他重连时,组里会有重复记录。我早期没注意这个,结果用户退出了,组里还留着他的影子,导致广播时出现重复消息。

3.7 一个完整的例子

把上面所有知识点串起来,就是一个最简单的聊天室:

// 服务端 ChatHub.cs
public class ChatHub : Hub
{
    public async Task SendMessage(string user, string message)
    {
        string formattedMessage = $"[{DateTime.Now:HH:mm:ss}] {user}:{message}";
        await Clients.All.SendAsync("ReceiveMessage", formattedMessage);
    }

    public override async Task OnConnectedAsync()
    {
        await Clients.Others.SendAsync("ReceiveMessage", "系统", $"用户 {Context.ConnectionId} 加入了聊天室");
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        await Clients.Others.SendAsync("ReceiveMessage", "系统", $"用户 {Context.ConnectionId} 离开了聊天室");
        await base.OnDisconnectedAsync(exception);
    }
}

// 客户端 JavaScript
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/chatHub")
    .withAutomaticReconnect()
    .build();

connection.on("ReceiveMessage", (message) => {
    const li = document.createElement("li");
    li.textContent = message;
    document.getElementById("messages").appendChild(li);
});

connection.start().then(() => {
    document.getElementById("sendBtn").addEventListener("click", () => {
        const user = document.getElementById("userInput").value;
        const msg = document.getElementById("msgInput").value;
        connection.invoke("SendMessage", user, msg).catch(err => console.error(err));
    });
});

你看,核心代码也就三四十行。一个能实时聊天的功能就出来了。

这就是SignalR的魅力——把复杂的WebSocket、长轮询、Server-Sent Events统统封装掉,让你专注于业务逻辑。

3.8 本章小结

  • Hub是SignalR的核心抽象,负责服务端和客户端的双向通信
  • 服务端方法用public async Task定义,客户端通过invoke调用
  • 客户端方法用on注册,服务端通过Clients.xxx.SendAsync调用
  • 连接生命周期包括:连接、重连、断开,每个阶段都有对应的事件
  • 生产环境务必启用自动重连,并处理好OnConnectedAsyncOnDisconnectedAsync

下一章,咱们会深入聊聊强类型Hub。说白了,就是让方法调用更安全、更优雅。到时候见。