透過.net6 web api藉由websocket實作WebPush消息推送_如何判斷來自URL的GET請求是走 HTTP協議 還是WebSocket協議_user跟socket之間如何建立關聯

 

https://sendpulse.com/features/webpush

在不同載具(client-side)有各自不同的通知響應方式
以之前blog介紹過的




我們來探討Server-Side技術設計

一些網站早期為了實作推送功能,多半是透過ajax輪詢
ajax polling可能每隔1秒就從client端browser發送HTTP請求,再由server回傳
最新資料,這類傳統模式很明顯會有的缺點就是浪費較多的頻寬資源。


https://www.researchgate.net/figure/a-Server-side-notifications-mechanisms-AJAX-polling_fig5_236145030




HTML5 定義了新的WebSocket協定可以帶來更好節省server資源與頻寬的即時雙向通訊機制。
WebSocket別於Http協定可以支援持久連接。
其提供了一種在TCP連接上實踐全雙工通訊的協定


WebSocket可以讓Server主動向client端發送資料,別於過往的被動等待機制。
只要完成了第一次handshake,client跟server兩端就可建立持久性連線並進行雙向資料傳輸。

再各大瀏覽器支援程度也可以說是具有一定的成熟性


Websocket 通訊在傳輸資料量大小和效率方面比 HTTP 協定來得更佳,
特別是對於傳輸量大的、重複的資訊。
在 HTTP 協定中,每次請求都需加送標頭(每個請求至少8KB)
在 WebSockets 上初始請求後每條傳輸最少 2 個Byte




透過.net core web api實作WebPush消息推送


websocket配置

新增好專案







先安裝
websocket套件






在 Program.cs 配置 WebSockets 中介軟體:
app.UseWebSockets();

程式碼

 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
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

var webSocketOptions = new WebSocketOptions()
{
    KeepAliveInterval = TimeSpan.FromSeconds(120),//默認2分鐘
    ReceiveBufferSize = 4 * 1024,//預設4KB
};

app.UseWebSockets(webSocketOptions);


app.UseAuthorization();

app.MapControllers();

app.Run();


KeepAliveInterval:要將 "ping" 框架傳送到用戶端,以確保 Proxy 保持連線開啟的頻率。 (預設為兩分鐘。)

ReceiveBufferSize:用於接收資料緩衝區大小。

AllowedOrigins : WebSocket 要求允許 Origin 標頭值的清單。 根據預設,會允許所有來源。 



WebSocket請求接收(Server-Side)

如何判斷來自URL的GET請求是屬於走 HTTP協議 還是WebSocket協議呢?

可以採用asp.net core middleware方式(中介軟體管道去過濾)攔截請求

微軟官網的範例
https://learn.microsoft.com/en-us/aspnet/core/fundamentals/websockets?view=aspnetcore-7.0

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
app.Use(async (context, next) =>
{
    if (context.Request.Path == "/ws")
    {
        if (context.WebSockets.IsWebSocketRequest)
        {
            using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
            await Echo(webSocket);
        }
        else
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
        }
    }
    else
    {
        await next(context);
    }

});

以.net本身提供的API AcceptWebSocketAsync這個方法,會建立TCP連線
並且產生websocket相應的物件。
一個connection主要就是對硬一個socket物件(client-server建立一個長時間連線通道)
有了長時間的連接就可達成彼此資料互傳溝通的機制而且是雙向的發送接收

WebSocket又是如何去保持長連接的呢?(Server-Side)


在官網提供封裝的Echo函數
裡面封裝了一個無窮迴圈來達到長連接監聽
直到client端轉為關閉狀態

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private static async Task Echo(WebSocket webSocket)
{
    var buffer = new byte[1024 * 4];
    var receiveResult = await webSocket.ReceiveAsync(
        new ArraySegment<byte>(buffer), CancellationToken.None);

    while (!receiveResult.CloseStatus.HasValue)
    {
        await webSocket.SendAsync(
            new ArraySegment<byte>(buffer, 0, receiveResult.Count),
            receiveResult.MessageType,
            receiveResult.EndOfMessage,
            CancellationToken.None);

        receiveResult = await webSocket.ReceiveAsync(
            new ArraySegment<byte>(buffer), CancellationToken.None);
    }

    await webSocket.CloseAsync(
        receiveResult.CloseStatus.Value,
        receiveResult.CloseStatusDescription,
        CancellationToken.None);
}



WebSocket當中user跟socket之間如何建立關聯的呢?

我們需要思考的一點就是
Server-Side給特定的系統使用者(Client-Side)發送消息,其實是
針對該名user對應的SocketConnection發送消息


user 跟 Socket connection需要進行綁定關聯

一個user其實就是建立一個連線物件 SocketConnection
SocketConnection儲存在Server端的SocketConnectionPool裡頭(類似static修飾的全域共用概念)

可以想一下不管你client用網頁 或 winform桌面應用 , 手機app等
都會需要導入一個註冊登入的身分認證機制或者可能要給予user id名稱輸入
(for後續的user識別需求)才可放行進行連線



實作環節
在此我來封裝一個WebSocketConnectionManager
藉由ConcurrentDictionary來封裝每組user-id與相應socket connection於異步處裡方法
進行存取

 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
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;

namespace WebPushApp.Tools
{
    public class WebSocketConnectionManager
    {
        private static ConcurrentDictionary<string, System.Net.WebSockets.WebSocket> socketCollection =
                                          new ConcurrentDictionary<string, System.Net.WebSockets.WebSocket>();

        public ConcurrentDictionary<string, System.Net.WebSockets.WebSocket> GetClients()
        {
            return socketCollection;
        }

        public void AddSocket(System.Net.WebSockets.WebSocket socket, string userId)
        {
            socketCollection.TryAdd(userId, socket);
        }
        public System.Net.WebSockets.WebSocket GetSocketByUserId(string userId)
        {
            socketCollection.TryGetValue(userId, out var webSocket);
            return webSocket;
        }
        public async Task RemoveSocket(string userId)
        {
            System.Net.WebSockets.WebSocket socket;
            socketCollection.TryRemove(userId, out socket);
            await socket?.CloseAsync(closeStatus: WebSocketCloseStatus.NormalClosure, 
                                     statusDescription: "Closed by the WebSocketManager",
                                     cancellationToken: CancellationToken.None);
            socket?.Dispose();
        }
        public async Task SendMessageToAllAsync(string message)
        {
            foreach (var pair in socketCollection)
            {
                if (pair.Value.State == WebSocketState.Open)
                {
                    await SendMessageAsync(pair.Value, message);
                }
            }
        }
        public async Task SendMessageAsync(System.Net.WebSockets.WebSocket socket, string message)
        {
            if (socket.State != WebSocketState.Open)
                return;

            var bytes = Encoding.UTF8.GetBytes(message);
            await socket.SendAsync(buffer: new ArraySegment<byte>(array: bytes, offset: 0, count: bytes.Length),
                                                    messageType: WebSocketMessageType.Text,
                                                    endOfMessage: true,
                                                    cancellationToken: CancellationToken.None);
        }

        public async Task ReceiveAsync(System.Net.WebSockets.WebSocket socket, byte[] buffer)
        {
            if (socket.State != WebSocketState.Open)
            {
                return;
            }
            string data = Encoding.UTF8.GetString(buffer);
            string message = $"Server-side receive from client-side message:{data}";
            Console.WriteLine(message);
            await SendMessageAsync(socket, message: message);
        }

    }
}




這裡可自定義Custom Middleware
並將上述的WebSocketConnectionManager透過建構子相依注入近來做API存取

 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
using System.Net.WebSockets;
using WebPushApp.Tools;

namespace WebPushApp.Middleware
{
    public class WebSocketManagerMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly WebSocketConnectionManager _socketManager;

        public WebSocketManagerMiddleware(RequestDelegate next, WebSocketConnectionManager socketManager)
        {
            _next = next;
            _socketManager = socketManager;
        }

        public async Task Invoke(HttpContext context)
        {
            //websocket請求可以來自認一URL,在此假設/messagePushHub路徑的請求。
            if (context.Request.Path == "/messagePushHub")
            {
                //如果不是WebSocket請求,則將請求傳送給下一個middleware。
                if (!context.WebSockets.IsWebSocketRequest)
                {
                    await _next.Invoke(context);
                    return;
                }

                //websocket是長時間連接的,因此client端與server都保持著TCP連接。
                var socket = await context.WebSockets.AcceptWebSocketAsync();
                string userId = context.Request.Query["userId"];
                Console.WriteLine($"User:{userId}連接成功");
                _socketManager.AddSocket(socket, userId);
                await RequestReceived(socket, async (result, buffer) =>
                {
                    //處裡client端打上來的資料(Text格式)
                    if (result.MessageType == WebSocketMessageType.Text)
                    {
                        await _socketManager.ReceiveAsync(socket, buffer);
                        return;
                    }

                    //處裡client端下線
                    if (result.MessageType == WebSocketMessageType.Close)
                    {
                        await _socketManager.RemoveSocket(userId);
                        return;
                    }
                });
            }
            else
            {
                await _next.Invoke(context);
            }
        }

        private async Task RequestReceived(System.Net.WebSockets.WebSocket socket, Action<WebSocketReceiveResult, byte[]> handleMessage)
        {
            var buffer = new byte[1024 * 4];

            while (socket.State == WebSocketState.Open)
            {
                var result = await socket.ReceiveAsync(buffer: new ArraySegment<byte>(buffer),
                                                       cancellationToken: CancellationToken.None);

                handleMessage(result, buffer);
            }
        }

    }
}

那可以自訂Request Path,在此路由定義為/messagePushHub。
改一下官方範例那一樣可透過HttpContext當中
context.WebSockets.IsWebSocketRequest
來判斷請求是否來自 websocket協議
若不是則將請求傳送給下一個middleware
做處裡





這裡要在記得將此服務類別進行註冊以及相應Middleware配置

 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
using WebPushApp.Middleware;
using WebPushApp.Tools;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSingleton<WebSocketConnectionManager>();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

var webSocketOptions = new WebSocketOptions()
{
    KeepAliveInterval = TimeSpan.FromSeconds(120),//默認2分鐘
    ReceiveBufferSize = 4 * 1024,//預設4KB
};

app.UseWebSockets(webSocketOptions);

app.UseMiddleware<WebSocketManagerMiddleware>();


app.UseAuthorization();

app.MapControllers();

app.Run();





Server-Side佈署上要注意IIS版本支援度

要留意要用IIS8以上的
Windows Server 2012 和 Windows 8 之後的OS
https://learn.microsoft.com/zh-tw/iis/get-started/whats-new-in-iis-8/iis-80-websocket-protocol-support


也建議IIS安裝時後或者已經安裝好了
要檢查補安裝這些功能































Ref:
https://learn.microsoft.com/en-us/aspnet/core/fundamentals/websockets?view=aspnetcore-7.0

https://developer.mozilla.org/zh-TW/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications

http://www.w3big.com/zh-TW/html/html5-websocket.html#gsc.tab=0

https://medium.com/swlh/how-to-build-a-websocket-applications-using-java-486b3e394139
https://flaviocopes.com/websockets/

https://coconauts.net/blog/2017/11/20/websocket-vs-rest/

留言

這個網誌中的熱門文章

何謂淨重(Net Weight)、皮重(Tare Weight)與毛重(Gross Weight)

Architecture(架構) 和 Framework(框架) 有何不同?_軟體設計前的事前規劃的藍圖概念

經得起原始碼資安弱點掃描的程式設計習慣培養(五)_Missing HSTS Header