.NET SignalR 的學習與應用(2)

聊天室的小房間

SignalR Groups 簡介

延續上一篇文章介紹的 SignalR 聊天室概念,這次要來聊聊「小房間」功能。如果你熟悉 Discord,可以把 SignalR Groups 想像成 Discord 的語音或文字頻道,每個頻道就是一個 Group,使用者可以進出不同的頻道來交流。

SignalR 的 Groups 讓我們可以把連接的使用者分成不同的「小房間」,用來實現群組聊天、特定用戶廣播等功能。每個使用者可以同時在多個房間裡,這讓訊息傳遞變得很彈性。

Groups 主要特點

  • 動態管理:你可以隨時讓使用者加入或退出房間,像是隨時加入或離開 Discord 頻道一樣。

  • 臨時性:房間成員資料只存在伺服器記憶體裡,伺服器重啟後就會清空,不用擔心長期維護。

  • 廣播效率:可以一次把訊息發給整個房間的人,不需要重複發送。

  • 多組成員:同一個使用者可以同時待在很多房間,就像你可以加入好幾個 Discord 頻道一樣。

實作步驟

1. 服務器端實現

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
using Microsoft.AspNetCore.SignalR;

namespace WebApiDemo.Hubs;

public class ChatHub : Hub
{
    // 使用 Dictionary 追踪用戶所在的房間
    private static readonly Dictionary<string, string> UserRooms = new();
    private static readonly string[] SensitiveWords = new[] { "討厭", "笨蛋", "白痴" };

    // 前置需求:記得在 Program.cs 中設定 SignalR 服務
    // builder.Services.AddSignalR();
    // app.MapHub<ChatHub>("/chatHub");

    // 加入房間的方法
    public async Task JoinRoom(string user, string roomName)
    {
        // 檢查用戶是否已在其他房間
        if (UserRooms.TryGetValue(Context.ConnectionId, out string? oldRoom))
        {
            // 從舊房間移除
            await Groups.RemoveFromGroupAsync(Context.ConnectionId, oldRoom);
            // 通知舊房間的其他用戶
            await Clients.Group(oldRoom).SendAsync("ReceiveSystemMessage", 
                $"用戶 {user} 離開了房間");
        }

        // 將用戶加入新房間
        await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
        // 更新用戶房間記錄
        UserRooms[Context.ConnectionId] = roomName;
        // 通知新房間的所有用戶
        await Clients.Group(roomName).SendAsync("ReceiveSystemMessage", 
            $"用戶 {user} 加入了房間");
    }

    public async Task SendMessage(string user, string message)
    {
        if (ContainsSensitiveWords(message, out string foundWord))
        {
            await Clients.Caller.SendAsync("ReceiveSystemMessage", 
                $"消息包含敏感詞「{foundWord}」,已被攔截");
            return;
        }

        // 檢查用戶所在的房間
        if (UserRooms.TryGetValue(Context.ConnectionId, out string? room))
        {
            // 只向同一房間的用戶發送消息
            await Clients.Group(room).SendAsync("ReceiveMessage", user, message);
        }
    }

    private bool ContainsSensitiveWords(string message, out string foundWord)
    {
        foundWord = SensitiveWords.FirstOrDefault(word => 
            message.Contains(word, StringComparison.OrdinalIgnoreCase));
        
        return foundWord != null;
    }

    // 當新客戶端連接時觸發
    public override async Task OnConnectedAsync()
    {
        // Context.ConnectionId: 每個連接的唯一標識
        await Clients.All.SendAsync("UserConnected", Context.ConnectionId);
        await base.OnConnectedAsync();
    }

    // 當客戶端斷開連接時觸發
    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        // 用戶斷開連接時清理房間資訊
        if (UserRooms.TryGetValue(Context.ConnectionId, out string? room))
        {
            await Groups.RemoveFromGroupAsync(Context.ConnectionId, room);
            UserRooms.Remove(Context.ConnectionId);
        }
        await Clients.All.SendAsync("UserDisconnected", Context.ConnectionId);
        await base.OnDisconnectedAsync(exception);
    }
}

Groups 相關的重要方法

  • Groups.AddToGroupAsync:將使用者加入房間
  • Groups.RemoveFromGroupAsync:將使用者從房間中移除
  • Clients.Group:向指定房間的使用者發送消息

2. 客戶端實現

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>SignalR 聊天室</title>
    <style>
        #messagesList { 
            border: 1px solid #ccc; 
            height: 300px; 
            overflow-y: auto; 
            padding: 10px; 
            margin-bottom: 10px; 
        }
        .form-group {
            margin: 10px 0;
        }
        .form-group label {
            display: inline-block;
            width: 70px;
        }
        .form-group input {
            padding: 5px;
            width: 200px;
        }
        #sendButton {
            padding: 5px 15px;
            margin-top: 10px;
        }
        .system-message {
            color: #ff4444;
            font-style: italic;
            margin: 5px 0;
        }
    </style>
</head>
<body>
    <div class="container">
        <h2>SignalR 聊天室</h2>
        
        <!-- 添加房間選擇 -->
        <div class="form-group">
            <label for="roomInput">房間:</label>
            <input type="text" id="roomInput" />
            <button id="joinButton">加入房間</button>
        </div>

        <div id="messagesList"></div>
        
        <div class="form-group">
            <label for="userInput">用戶名:</label>
            <input type="text" id="userInput" />
        </div>
        <div class="form-group">
            <label for="messageInput">消息:</label>
            <input type="text" id="messageInput" />
        </div>
        <button id="sendButton">發送</button>
    </div>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.0/signalr.min.js"></script>
    <script>
        // 建立連接
        const connection = new signalR.HubConnectionBuilder()
            .withUrl("/chatHub")  // Hub 的路由
            .build();

        document.getElementById("sendButton").disabled = true;

        // 註冊接收消息的處理函數
        connection.on("ReceiveMessage", function (user, message) {
            const msg = `${user}: ${message}`;
            const li = document.createElement("div");
            li.textContent = msg;
            document.getElementById("messagesList").appendChild(li);
        });

        // 處理用戶連接事件
        connection.on("UserConnected", function (connectionId) {
            const msg = `用戶已連接 (${connectionId})`;
            const li = document.createElement("div");
            li.textContent = msg;
            li.style.color = "green";
            document.getElementById("messagesList").appendChild(li);
        });

        connection.on("UserDisconnected", function (connectionId) {
            const msg = `用戶已斷開連接 (${connectionId})`;
            const li = document.createElement("div");
            li.textContent = msg;
            li.style.color = "red";
            document.getElementById("messagesList").appendChild(li);
        });

        connection.on("ReceiveSystemMessage", function (message) {
            const div = document.createElement("div");
            div.textContent = `系統提示: ${message}`;
            div.className = "system-message";
            document.getElementById("messagesList").appendChild(div);
        });

        // 啟動連接
        connection.start().then(function () {
            document.getElementById("sendButton").disabled = false;
        }).catch(function (err) {
            return console.error(err.toString());
        });

        // 發送消息
        document.getElementById("sendButton").addEventListener("click", function (event) {
            const user = document.getElementById("userInput").value;
            const message = document.getElementById("messageInput").value;
            // 調用服務器端的 SendMessage 方法
            connection.invoke("SendMessage", user, message).catch(function (err) {
                return console.error(err.toString());
            });
            document.getElementById("messageInput").value = "";
            event.preventDefault();
        });

        // 添加加入房間按鈕的處理
        document.getElementById("joinButton").addEventListener("click", function (event) {
            const user = document.getElementById("userInput").value;
            const room = document.getElementById("roomInput").value;
            
            if (!user || !room) {
                alert("請輸入用戶名和房間名");
                return;
            }

            connection.invoke("JoinRoom", user, room);
            event.preventDefault();
        });
    </script>
</body>
</html>

測試效果

這時候運行你的專案(你應該有記得在上一篇文章中一起設定過 launchSettings.json 對吧),輸入 localhost:5000/index.html,就可以看到以下的畫面:

一進入的時候沒有特別指定房間,如果 Anthea 進入到房間(明星三缺一),這時候他的訊息只會發給同一個房間裡的人,沒有進入同一個房間的 Ensui 就收不到訊息了。

Built with Hugo
Theme Stack designed by Jimmy