WebSocket 與即時通訊
為什麼需要即時通訊?
傳統 HTTP 是「一問一答」模式:客戶端發請求,伺服器才回應。 但有些場景需要伺服器主動推送資料給客戶端:
需要即時通訊的場景:
├── 聊天室(LINE、Discord)
├── 股票即時報價
├── 多人線上遊戲
├── 通知系統
└── 協作編輯(Google Docs)
四種即時通訊方案比較
1. 短輪詢(Short Polling)
客戶端 伺服器
|--- 有新訊息嗎? -------->|
|<-- 沒有 -----------------|
|(等 3 秒) |
|--- 有新訊息嗎? -------->|
|<-- 沒有 -----------------|
|(等 3 秒) |
|--- 有新訊息嗎? -------->|
|<-- 有!這是新訊息 --------|
缺點:浪費資源,大部分請求都是空的
2. 長輪詢(Long Polling)
客戶端 伺服器
|--- 有新訊息嗎? -------->|
| (伺服器先不回應... |
| 等到有新訊息才回) |
|<-- 有!這是新訊息 --------|
|--- 有新訊息嗎? -------->| ← 馬上再問
| (繼續等...) |
優點:減少空的回應
缺點:每次收到訊息都要重新建立連線
3. Server-Sent Events(SSE)
客戶端 伺服器
|--- 我要訂閱 ------------>|
|<== 保持連線 ============>|
|<-- 新訊息 1 -------------|
|<-- 新訊息 2 -------------|
|<-- 新訊息 3 -------------|
優點:伺服器可以持續推送
缺點:單向(只能伺服器 → 客戶端)
4. WebSocket(雙向即時通訊)
客戶端 伺服器
|--- HTTP 升級請求 -------->|
|<-- 101 Switching ---------|
|<=== WebSocket 連線 =====>|
|<--> 雙向即時通訊 <------->|
|--- 我說哈囉 ------------>|
|<-- 伺服器說嗨 ------------|
|<-- 伺服器推通知 ----------|
優點:雙向、低延遲、省頻寬
方案比較表
方案 方向 延遲 複雜度 適用場景
───────────────────────────────────────────────────
短輪詢 單向 高 低 簡單通知
長輪詢 單向 中 中 即時通知
SSE 單向 低 中 股票報價、新聞推播
WebSocket 雙向 最低 高 聊天室、遊戲
WebSocket 握手過程
# 客戶端發送升級請求(從 HTTP 升級到 WebSocket)
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
# 伺服器回應同意升級
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
握手完成後:
├── 不再是 HTTP 協議了
├── 變成 WebSocket 協議(ws:// 或 wss://)
├── 連線保持開啟,雙方可以隨時傳資料
└── 資料以「Frame」為單位傳輸(比 HTTP 輕量很多)
C# WebSocket 客戶端
using System.Net.WebSockets;
using System.Text;
// 建立 WebSocket 客戶端
var ws = new ClientWebSocket();
// 連接到 WebSocket 伺服器
await ws.ConnectAsync(
new Uri("wss://echo.websocket.org"),
CancellationToken.None
);
// 確認連線狀態
Console.WriteLine($"連線狀態:{ws.State}"); // Open
// 傳送訊息
var message = "你好,WebSocket!";
// 把字串轉成 byte 陣列
var bytes = Encoding.UTF8.GetBytes(message);
// 送出訊息(Text 類型,EndOfMessage 表示這是完整的一筆)
await ws.SendAsync(
new ArraySegment<byte>(bytes),
WebSocketMessageType.Text,
endOfMessage: true,
CancellationToken.None
);
// 接收訊息
var buffer = new byte[1024];
// 等待伺服器回傳訊息
var result = await ws.ReceiveAsync(
new ArraySegment<byte>(buffer),
CancellationToken.None
);
// 把收到的 byte 轉回字串
var received = Encoding.UTF8.GetString(buffer, 0, result.Count);
Console.WriteLine($"收到:{received}");
// 關閉連線(禮貌地告訴對方要斷線了)
await ws.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"再見",
CancellationToken.None
);
SignalR — 更好用的即時通訊框架
💡 比喻 WebSocket 像是自己接水管——你要處理連線、斷線、重連、序列化... SignalR 像是請水電師傅來裝——幫你處理好所有細節,你只要開水龍頭就好。
SignalR Hub(伺服器端)
using Microsoft.AspNetCore.SignalR;
// 定義一個聊天 Hub(類似 Controller)
public class ChatHub : Hub
{
// 當客戶端呼叫 SendMessage 時觸發
public async Task SendMessage(string user, string message)
{
// 廣播給所有連線的客戶端
await Clients.All.SendAsync("ReceiveMessage", user, message);
}
// 加入特定群組(例如聊天室)
public async Task JoinRoom(string roomName)
{
// 把目前的連線加入指定群組
await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
// 通知群組內的其他人
await Clients.Group(roomName).SendAsync(
"ReceiveMessage", "系統", $"{Context.ConnectionId} 加入了 {roomName}"
);
}
// 當客戶端斷線時觸發
public override async Task OnDisconnectedAsync(Exception? exception)
{
// 可以在這裡做清理工作
Console.WriteLine($"使用者 {Context.ConnectionId} 已斷線");
await base.OnDisconnectedAsync(exception);
}
}
SignalR 客戶端(C#)
using Microsoft.AspNetCore.SignalR.Client;
// 建立 SignalR 連線
var connection = new HubConnectionBuilder()
// 指定 Hub 的 URL
.WithUrl("https://localhost:5001/chatHub")
// 啟用自動重連(斷線後自動嘗試重新連線)
.WithAutomaticReconnect()
.Build();
// 監聽伺服器推送的 ReceiveMessage 事件
connection.On<string, string>("ReceiveMessage", (user, message) =>
{
// 收到訊息時顯示在 Console
Console.WriteLine($"{user}: {message}");
});
// 監聽重連事件
connection.Reconnecting += error =>
{
Console.WriteLine("正在重新連線...");
return Task.CompletedTask;
};
// 開始連線
await connection.StartAsync();
Console.WriteLine("已連線到 SignalR Hub");
// 發送訊息
await connection.InvokeAsync("SendMessage", "小明", "大家好!");
🤔 我這樣寫為什麼會錯?
❌ 錯誤 1:沒有處理 WebSocket 斷線重連
// ❌ 錯誤:連線斷了就整個掛掉
var ws = new ClientWebSocket();
await ws.ConnectAsync(uri, CancellationToken.None);
// 如果網路中斷,下面的 ReceiveAsync 會拋出例外,程式就結束了
var result = await ws.ReceiveAsync(buffer, CancellationToken.None);
// ✅ 正確:加入斷線重連機制
async Task ConnectWithRetry(Uri uri)
{
// 最多重試 5 次
var maxRetries = 5;
for (int i = 0; i < maxRetries; i++)
{
try
{
var ws = new ClientWebSocket();
// 嘗試連線
await ws.ConnectAsync(uri, CancellationToken.None);
Console.WriteLine("連線成功!");
// 連線成功後開始接收訊息
await ReceiveLoop(ws);
}
catch (Exception ex)
{
// 連線失敗或斷線,等待後重試
Console.WriteLine($"連線失敗:{ex.Message},{i + 1}/{maxRetries} 次重試");
// 指數退避:每次等待時間加倍
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i)));
}
}
}
❌ 錯誤 2:WebSocket 記憶體洩漏
// ❌ 錯誤:每次接收都建立新的 byte 陣列,造成 GC 壓力
while (ws.State == WebSocketState.Open)
{
// 每次迴圈都分配新的記憶體(浪費!)
var buffer = new byte[4096];
await ws.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
}
// ✅ 正確:重複使用同一個 buffer
// 在迴圈外面就分配好 buffer
var buffer2 = new byte[4096];
while (ws.State == WebSocketState.Open)
{
// 重複使用同一塊記憶體
var result2 = await ws.ReceiveAsync(
new ArraySegment<byte>(buffer2),
CancellationToken.None
);
// 只處理實際收到的資料長度
var msg = Encoding.UTF8.GetString(buffer2, 0, result2.Count);
}
❌ 錯誤 3:沒有正確關閉 WebSocket 連線
// ❌ 錯誤:直接斷線,沒有通知對方
ws.Dispose(); // 粗暴地斷開,對方不知道發生什麼事
// ✅ 正確:先發送 Close 訊框,再關閉
// 禮貌地告訴對方要關閉連線
await ws.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"正常關閉",
CancellationToken.None
);
// 關閉後再釋放資源
ws.Dispose();
💡 重點整理
| 概念 | 說明 |
|---|---|
| Short Polling | 定時發請求問有沒有新資料(浪費資源) |
| Long Polling | 請求掛著等有新資料才回應 |
| SSE | 伺服器單向推送(適合通知) |
| WebSocket | 雙向即時通訊(適合聊天、遊戲) |
| SignalR | ASP.NET Core 的即時通訊框架,簡化 WebSocket 使用 |
| 重連機制 | WebSocket 必須處理斷線重連,建議用指數退避 |