ASP.NET Core SignalR 即時通訊示範

建立基礎 SignalR 伺服器端服務

建立一個 MyHub ,繼承 Microsoft.AspNetCore.SignalR 的 Hub (在 ASP.NET Core 6 或以上不需要額外安裝套件),用來實現通訊功能。
不過 SignalR 其實已經簡化再簡化,不用做其他處理就可以完成通訊,這裡只是先顯示連接 Id 和回傳一個訊息,方便讓我們確認已經連接成功。
    
using Microsoft.AspNetCore.SignalR;

namespace WebApplicationSignalR;

public class MyHub : Hub
{
    private readonly ILogger<MyHub> _logger;

    public MyHub(ILogger<MyHub> logger)
    {
        _logger = logger;
    }

    public override Task OnConnectedAsync()
    {
        var connectionId = Context.ConnectionId;
        _logger.LogInformation($"連接成功: {connectionId}");
        Clients.Caller.SendAsync("ReceiveMessage", "連接成功"); // 傳送訊息給呼叫者

        return base.OnConnectedAsync();
    }

    public override Task OnDisconnectedAsync(Exception? exception)
    {
        var connectionId = Context.ConnectionId;
        _logger.LogInformation($"中斷連接: {connectionId}");

        return base.OnDisconnectedAsync(exception);
    }
}
    

註: ReceiveMessage 指的是客戶端的 function 名稱,會觸發名稱相同的 function

在 Program.cs 中註冊服務,並且指定 SignalR 通訊的 API 路徑:
    
builder.Services.AddSignalR();
    
var app = builder.Build();

app.MapHub<MyHub>("/my-hub");
    

使用 JavaScript 建立基礎 SignalR 客戶端服務

需要安裝微軟的 JavaScript 套件:
    
npm install @microsoft/signalr
    

使用下面的程式碼就可以直接建立連接了,唯一要替換的就是連接的網址:
    
import * as signalR from '@microsoft/signalr';

let connection = null;
async function connectToSignalR() {
  // 建立 SignalR 連接
  connection = new signalR.HubConnectionBuilder()
      .withUrl("http://localhost:5000/my-hub", {
        skipNegotiation: true,
        transport: signalR.HttpTransportType.WebSockets,
      })
      .configureLogging(signalR.LogLevel.Information)
      .build();

  // 接收來自 SignalR Hub 的訊息
  connection.on("ReceiveMessage", (message) => {
    console.log("收到訊息:", message);
  });

  // 開始連接
  try {
    await connection.start();
    console.log("SignalR 連接成功!");
  } catch (err) {
    console.error("SignalR 連接失敗:", err);
    // 處理連接失敗情況
  }
}
    

    
// 執行:
connectToSignalR();

// 結束時停止:
if (connection) {
  connection.stop();
}
    

在 MyHub 外存取

上面建立了 MyHub ,他主要的用途是在實現通訊,平時並不會在 MyHub 中發送訊息,通常會在 Service 中處理。

這部分筆者花了一段時間撞牆,在 Service 中發送訊息一直出現空引用的例外,後來才想到如果自己在 Program.cs 中註冊服務是錯的,因為在 app.MapHub<MyHub>("/my-hub"); 的時候已經交由框架處理,所以就算在 MyHub 中要注入其他服務也不用使用下面的程式碼畫蛇添足的多自己註冊一次:
    
// 這個是錯的
builder.Services.AddScoped<MyHub>();
    

在其他服務中應該使用 IHubContext<MyHub> 注入(這裡偷懶直接在 Controller 中示範):
    
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;

[ApiController]
[Route("[controller]")]
public class MyHubController: ControllerBase
{
    private readonly ILogger<MyHubController> _logger;
    private readonly IHubContext<MyHub> _hubContext;

    public MyHubController(ILogger<MyHubController> logger, IHubContext<MyHub> hubContext)
    {
        _logger = logger;
        _hubContext = hubContext;
    }
    
    [HttpPost("sent/{message}")]
    public async Task<IActionResult> Sent([FromRoute] string message)
    {
    	// 向所有連接的客戶端發送訊息
        await _hubContext.Clients.All.SendAsync("ReceiveMessage", message);
        return Ok();
    }
}
    

這樣就可以很方便的發送訊息給所有使用者了

單獨發送訊息

通常最常見的情況還是單獨發送訊息給一個使用者,最常見的就是依據 ConnectionId 發送了,不過該如何確保使用者真的是那一位,而不是冒用的呢?

我們可以在首次客戶端連線時要求給予足以證明身分驗證的資訊,例如 JWT Token,驗證並解析 Token 後然後將使用者名稱和 ConnectionId 對應的關係儲存起來,之後依照使用者名稱去尋找,有找到就發送訊息:
    
using Microsoft.AspNetCore.SignalR;

public class MyHub : Hub
{
    private readonly ILogger<MyHub> _logger;

    public MyHub(ILogger<MyHub> logger)
    {
        _logger = logger;
    }

    public static readonly Dictionary<string, string> UserConnections = new();

    public override Task OnConnectedAsync()
    {
        var connectionId = Context.ConnectionId;

        var token = Context.GetHttpContext()?.Request.Query["access_token"];

        // TODO: 將 token 處理後為 userName
        var userName = token;
        
        if (string.IsNullOrWhiteSpace(userName))
            return Task.CompletedTask; // 拒絕連線

        if (UserConnections.TryGetValue(userName, out _))
            UserConnections[userName] = connectionId;
        else
            UserConnections.Add(userName, connectionId);

        _logger.LogInformation($"連接成功: token:{token}, connectionId: {connectionId}");
        Clients.Caller.SendAsync("ReceiveMessage", "連接成功"); // 傳送訊息給呼叫者

        return base.OnConnectedAsync();
    }

    public override Task OnDisconnectedAsync(Exception? exception)
    {
        var connectionId = Context.ConnectionId;
        _logger.LogInformation($"中斷連接: {connectionId}");

        if (UserConnections.ContainsValue(connectionId))
            UserConnections.Remove(UserConnections.FirstOrDefault(x => x.Value == connectionId).Key);

        return base.OnDisconnectedAsync(exception);
    }
}
    

    
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using WebApplicationSignalR.Service;

[ApiController]
[Route("[controller]")]
public class MyHubController: ControllerBase
{
    private readonly ILogger<MyHubController> _logger;
    private readonly IHubContext<MyHub> _hubContext;

    public MyHubController(ILogger<MyHubController> logger, IHubContext<MyHub> hubContext)
    {
        _logger = logger;
        _hubContext = hubContext;
    }
    
    [HttpPost("sent/{userName}/{message}")]
    public async Task<IActionResult> Sent([FromRoute]string userName,[FromRoute] string message)
    {
        _logger.LogInformation($"傳送訊息給: {userName}");
        if (!MyHub.UserConnections.TryGetValue(userName, out var connectionId))
            return NotFound();
        
        _logger.LogInformation($"傳送訊息給: {userName}, connectionId: {connectionId}");
        
        await _hubContext.Clients.Client(connectionId).SendAsync("ReceiveMessage", message);
        return Ok();
    }
}
    

而在 JavaScript 要處理也很簡單,只要使用 accessTokenFactory 放入 token 即可
    
// 建立 SignalR 連接
connection = new signalR.HubConnectionBuilder()
    .withUrl("http://localhost:5000/my-hub", {
      skipNegotiation: true,
      transport: signalR.HttpTransportType.WebSockets,
      accessTokenFactory: () => "my-token", // 這裡
    })
    .configureLogging(signalR.LogLevel.Information)
    .build();
    

客戶端發送訊息

SignalR 是雙向通訊,那在 JavaScript 中該如何發送訊息呢?也超級簡單:
    
connection?.invoke("SentMessage", "訊息");
    

然後在 MyHub.cs 中增加同名的方法即可:
    
    public async Task SentMessage(string message)
    {
        Console.WriteLine(message);
    }
    



參考資料:
Microsoft.Learn - Overview of ASP.NET Core SignalR

留言