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秒嗎? 找不到原因,想在問一下,謝謝

Post a comment