ASP.NET Core 6 練習 - WebSocket 聊天室
11 |
ASP.NET Core 從早期的版本就有支援 WebSocket,官方文件說明蠻清楚,還有個簡單的 Echo 範例:
從 Github 下載範例即可編譯執行,很容易讓人產生「我也會寫 WebSocket 呢」的錯覺(笑),但一旦想應用在實際情境,還是有不少地方得自己花時間搞懂。
實務一個常見的 WebSocket 用法是多客戶端同時跟伺服器間建立持續連線,以便隨雙向傳輸;客戶端可發送訊息給伺服器取得回應,伺服器端也要能主動派發訊息給指定對象或對所有 WebSocket 連線廣播,最好還能即時偵測到某條 WebSocket 被關閉做出因應。官方範例只涵蓋「客戶端發送訊息給伺服器獲得回應」,只參考其寫法很難擴充到前述的多 WebSocket 情境,我需要一個較符合自己應用情境的範例,作為日後開發參考。
就用最經典的線上聊天室當成假想需求,我打算在 Razor Page 加入 WebSocket 功能,發送訊息將廣播給所有建立 WebSocket 連線的瀏覽器,使用者退出網頁或關閉瀏覽器時要能主動發現通報。最後成果如下:
程式已放上 Github,已裝好 .NET 6 SDK 的同學,git clone 回去開 VS Code 應可直接執行。
以下簡單說明程式重點。
Models/WebSocketHandler.cs
這是主要的 WebSocket 處理邏輯。ProcessWebSocket 會接入 WebSocket 物件,每個 WebSocket 物件會對映一條已建立的連線,因此一開始先將 WebSocket 放入 ConcurrentDictionary<int, WebSocket> 方便對所有人廣播。while 迴圈跑 ReceiveAsync() 等待客戶端傳來指令,使用 /USER 語法宣告該連線使用者名稱,其餘視為訊息對所有成員廣播。用 WebSocketReceiveResult.CloseStatus.HasValue 可偵測連線中斷,中斷後將 WebSocket 移出 ConcurrentDictionary<int, WebSocket> 並對剩下成員廣播。廣播是用 Parallel.ForEach 平行執行呼叫 SendAsync() 傳送訊息。
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.WebSockets;
using System.Threading.Tasks;
using System.Text;
namespace WebSocketDemo.Models
{
public class WebSocketHandler
{
public WebSocketHandler(ILogger<WebSocketHandler> logger)
{
this.logger = logger;
}
//REF: https://radu-matei.com/blog/aspnet-core-websockets-middleware/
ConcurrentDictionary<int, WebSocket> WebSockets = new ConcurrentDictionary<int, WebSocket>();
private readonly ILogger<WebSocketHandler> logger;
public async Task ProcessWebSocket(WebSocket webSocket)
{
WebSockets.TryAdd(webSocket.GetHashCode(), webSocket);
var buffer = new byte[1024 * 4];
var res = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
var userName = "anonymous";
while (!res.CloseStatus.HasValue) {
var cmd = Encoding.UTF8.GetString(buffer, 0, res.Count);
if (!string.IsNullOrEmpty(cmd)) {
logger.LogInformation(cmd);
if (cmd.StartsWith("/USER "))
userName = cmd.Substring(6);
else
Broadcast($"{userName}:\t{cmd}");
}
res = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
}
await webSocket.CloseAsync(res.CloseStatus.Value, res.CloseStatusDescription, CancellationToken.None);
WebSockets.TryRemove(webSocket.GetHashCode(), out var removed);
Broadcast($"{userName} left the room.");
}
public void Broadcast(string message)
{
var buff = Encoding.UTF8.GetBytes($"{DateTime.Now:MM-dd HH:mm:ss}\t{message}");
var data = new ArraySegment<byte>(buff, 0, buff.Length);
Parallel.ForEach(WebSockets.Values, async (webSocket) =>
{
if (webSocket.State == WebSocketState.Open)
await webSocket.SendAsync(data, WebSocketMessageType.Text, true, CancellationToken.None);
});
}
}
}
Program.cs
以下摘要說明 Program.cs 修改部分:
//加入WebSocket處理服務
builder.Services.AddSingleton<WebSocketHandler>();
//加入 WebSocket 功能
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30)
});
//覺得用 Controller 接收 WebSocket 太複雜,我改在 Middleware 層處理
app.Use(async (context, next) =>
{
if (context.Request.Path == "/ws")
{
if (context.WebSockets.IsWebSocketRequest)
{
using (WebSocket ws = await context.WebSockets.AcceptWebSocketAsync())
{
var wsHandler = context.RequestServices.GetRequiredService<WebSocketHandler>();
await wsHandler.ProcessWebSocket(ws);
}
}
else
context.Response.StatusCode = (int)System.Net.HttpStatusCode.BadRequest;
}
else await next();
});
Index.cshtml
這部分程式還算簡單,我沒用 jQuery 或 Vue.js,以香草 JavaScript 寫完。
<div class="container" id="app">
<div class="row">
<input class="col-4 m-1" id="userName" />
<input class="col-6 m-1" id="message" placeholder="message" />
<button class="col-1 m-1" id="send">Send</button>
</div>
<div class="p-2 chat">
<ul id="list">
</ul>
</div>
</div>
@section Scripts {
<script>
var socket;
var l = document.location;
var scheme = l.protocol === 'https:' ? 'wss' : 'ws';
var port = l.port ? (':' + l.port) : '';
var wsUrl = scheme + '://' + l.hostname + port + '/ws';
function logWebSocketStatus(event) {
if (!socket) return;
var status = 'Unknown';
switch (socket.readyState) {
case WebSocket.CLOSED:
status = 'Closed / Code = ' + event.code + ', Reason = ' + event.reason;
break;
case WebSocket.CLOSING:
status = 'Closing';
break;
case WebSocket.OPEN:
status= 'Open';
break;
case WebSocket.CONNECTING:
status = 'Connecting';
break;
}
}
function connect() {
socket = new WebSocket(wsUrl);
socket.onopen = function() {
logWebSocketStatus();
userName.onchange();
};
socket.onclose = logWebSocketStatus;
socket.onerror = logWebSocketStatus;
socket.onmessage = function(e) {
processMessage(e.data);
}
}
var list = document.getElementById('list');
function processMessage(data) {
let li = document.createElement('li');
li.innerHTML = "<span class=t></span><span class=u></span><span class=m></span>";
let p = data.split('\t');
li.querySelector('.t').innerText = p[0];
li.querySelector('.u').innerText = p[1];
li.querySelector('.m').innerText = p[2] || '';
list.appendChild(li);
}
function sendMessage(msg) {
if (socket && socket.readyState == WebSocket.OPEN)
socket.send(msg);
}
connect();
var userName = document.getElementById('userName');
userName.value = 'User' + (new Date().getTime() % 10000);
userName.onchange = function() {
sendMessage('/USER ' + userName.value);
};
var message = document.getElementById('message');
var sedn = document.getElementById('send');
message.addEventListener('keydown', function(e) {
if (e.keyCode === 13) send.click();
});
send.addEventListener('click', function() {
sendMessage(message.value);
});
</script>
}
先做出基本範例,未來要寫 WebSocket 應用會比較有信心。至於怎麼把程式修得更漂亮,留待日後慢慢琢磨,也歡迎大家回饋意見。
A chatroom example built with WebSocket in ASP.NET Core 6.
Comments
# by SKC
黑大怎麼沒考慮直接用 SignalR,在 WebSockets 不通的時候還自動提供 fallback 呢
# by Jeffrey
to SKC, 好問題。這問題類似「既然用 jQuery 可以輕鬆解決,是否值得花時間學純 JavaScript 寫法?」,學會底層運作原理讓人更強大更自由。 總有些單純的應用情境,例如 Electron 跑 localhost ASP.NET Core 時,不用擔心網站不支援戓被防火牆阻擋,不用擔心瀏覽器不支援,除了拿出 SignalR 這把牛刀之外,我還有多了別人沒有的輕巧選擇。
# by 小黑
VS2019 可否使用 ASP.NET Core 6?
# by Jeffrey
to 小黑,蠻有趣的,依據官方 Blog,.NET 6 is supported with Visual Studio 2022 and Visual Studio 2022 for Mac. It is not supported with Visual Studio 2019, Visual Studio for Mac 8, or MSBuild 16. https://devblogs.microsoft.com/dotnet/announcing-net-6/ 這個範例我是用 VSCode 開發的。但實測,VS2019 問題出在 csproj GUI 設定不支援,若 csproj 用 VSCode 或手動改好 ( https://stackoverflow.com/a/69889703/288936 ),VS2019 可以開啟、編譯甚至 Line-By-Line 偵測 ASP.NET Core 6 沒問題。
# by JS
想知道websocket的客戶對客戶發送訊息的方法 不曉得黑大能不能指點迷津一下,網路上爬不到相關的資訊 目前有想到客戶端產出guid,但對指定guid發送訊息沒有想法...
# by Jeffrey
to JS, 若要擴充文章範例實現發訊息給特定客戶端,需另外建一個 Dictionary 對映 WebSocket 跟 UserName,發訊息時若指定 UserName,查 Dictionary 找到其所屬 WebSocket 發訊息。 不過要提醒,要處理實務應用,還有很多文章沒提到的細節,例如:斷線自動重連、瀏覽器不支援 WebSocket 的備援做法... 或許你可以考慮用 SignalR 做,會省事一些。
# by 小楓
請問websocket Server port跟MVC 執行中的port一樣,但在架設時顯示權限不足,可以去哪裡調整權限呢?
# by Jeffrey
to 小楓,你得再提供多一點操作步驟跟錯誤訊息,大家才能判斷問題跟提供建議。
# by 小楓
我在 Global.asax.cs 裡設置 ``` var wssv = new WebSocketServer("ws://localhost:44305"); wssv.AddWebSocketService<Echo>("/Echo"); wssv.Start(); ``` 然後執行之後 系統回復 System.InvalidOperationException: 'The underlying listener has failed to start.' SocketException: 嘗試存取通訊端被拒絕,因為存取權限不足。 我只是想試試這樣開啟之後能不能從前端打進來 用您上面前端的code試著call看看,也有試過改變port之後有過了,但在前端打變更過的port之後是連線不到
# by Jeffrey
to 小楓,你是用 Fleck WebSocketServer?我沒用過,但由錯誤訊息有可能是要用 netsh http add urlacl 設定或用管理者權限執行。參考:https://blog.darkthread.net/blog/self-host-web-api/
# by 小楓
抱歉~最近有點忙,現在才有時間再看弄這個,您的那篇文章我會在windform裡用看看 我最後是改用asp.net core作測試範例,但裡面的ping/pong 機制有點無法理解,F12裡面去找WS回應 只有看到每兩秒的空值回應,但設定上不是30秒嗎? 找不到原因,想在問一下,謝謝