SignalR 是什么?
ASP.NET Core SignalR 是一個開源的實時框架,它簡化了向應用中添加實時 Web 功能的過程。
實時 Web 功能是服務器端能夠即時的將數據推送到客戶端,而無需讓服務器等待客戶端請求后才返回數據。
SignalR 主要適用於:
- 從服務器獲取數據並高頻更新的應用。比如股票,GPS應用等。
- 儀表板和監視應用。比如狀態實時更新等。
- 需要通知的應用。比如即時聊天工具,以及社交網絡里面的通知等。
- 協作應用。比如團體會議軟件。
SignalR 支持下面幾種底層傳輸技術:
- Web Socket 是不同於HTTP的另一種TCP協議。它是全雙工的通信協議,瀏覽器和服務器之間可以相互通信。它會保持長連接狀態只到被主動關閉。它支持文本和二進制的消息傳輸,也支持流媒體。其實正常的HTTP請求也是使用TCP Socket. Web Socket標准使用了握手機制把用於HTTP的Socket升級為使用WS協議的 WebSocket socket.
- 服務器發送事件 (Server Sent Events) 服務器可以在任何時間把數據發送到瀏覽器,而瀏覽器則會監聽進來的信息,並使用一個叫做EventSource的對象用來處理傳過來的信息。這個連接一直保持開放,直到服務器主動關閉它。它是單向通信,只能發生文本信息,而且很多瀏覽器都有最大並發連接數的限制。
- 長輪詢(Long Polling) 客戶端會定期的向服務器發送HTTP請求,如果服務器沒有新數據的話,那么服務器會繼續保持連接,直到有新的數據產生, 服務器才把新的數據返回給客戶端。如果請求發出后一段時間內沒有響應, 那么請求就會超時。這時,客戶端會再次發出請求。
SignalR 封裝了這些底層傳輸技術,會從服務器和客戶端支持的功能中自動選擇最佳傳輸方法,讓我們只關注業務問題而不是底層傳輸技術問題.
可以只使用WebSocket,具體參考WebSockets support in ASP.NET Core
在 ASP.NET Core 中使用 SignalR
使用 SignalR 會涉及到服務端和客戶端.
- Hub 是SignalR服務端最關鍵的組件, 它作為通信中心, 接受從客戶端發來的消息, 也能把消息發送給客戶端. 它是服務器端的一個類, 自己創建的Hub類需要繼承於基類
Hub
. - 客戶端 微軟目前官方支持JavaScript, .NET 和 Java客戶端. 具體參考ASP.NET Core SignalR 支持的平台.
做一個小例子演練一下:
-
創建一個空白的Web項目, 然后添加 Hub 類
public class ChatHub : Hub { public override async Task OnConnectedAsync() { await Clients.All.SendAsync("ReceiveMessage", $"{Context.ConnectionId} joined"); } public override async Task OnDisconnectedAsync(Exception ex) { await Clients.All.SendAsync("ReceiveMessage", $"{Context.ConnectionId} left"); } public Task Send(string message) { return Clients.All.SendAsync("ReceiveMessage", $"{Context.ConnectionId}: {message}"); } public Task SendAllExceptMe(string message) { return Clients.AllExcept(Context.ConnectionId).SendAsync("ReceiveMessage", $"{Context.ConnectionId}: {message}"); } public Task SendToGroup(string groupName, string message) { return Clients.Group(groupName).SendAsync("ReceiveMessage", $"{Context.ConnectionId}@{groupName}: {message}"); } public async Task JoinGroup(string groupName) { await Groups.AddToGroupAsync(Context.ConnectionId, groupName); await Clients.Group(groupName).SendAsync("ReceiveMessage", $"{Context.ConnectionId} joined {groupName}"); } public async Task LeaveGroup(string groupName) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); await Clients.Group(groupName).SendAsync("ReceiveMessage", $"{Context.ConnectionId} left {groupName}"); } public Task Echo(string message) { return Clients.Client(Context.ConnectionId).SendAsync("ReceiveMessage", $"{Context.ConnectionId}: {message}"); } }
-
添加配置代碼
public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddSignalR(); } public void Configure(IApplicationBuilder app, IHostingEnvironment env) { app.UseStaticFiles(); app.UseSignalR(routes => { routes.MapHub<ChatHub>("/chatHub"); }); } }
-
添加客戶端
在wwwroot目錄下創建一個名為chat.html的Html靜態文件,內容如下:<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title></title> </head> <body> <h1 id="head1"></h1> <div> <input type="button" id="connect" value="Connect" /> <input type="button" id="disconnect" value="Disconnect" /> </div> <h4>To Everybody</h4> <form class="form-inline"> <div class="input-append"> <input type="text" id="message-text" placeholder="Type a message" /> <input type="button" id="broadcast" class="btn" value="Broadcast" /> <input type="button" id="broadcast-exceptme" class="btn" value="Broadcast (All Except Me)" /> </div> </form> <h4>To Me</h4> <form class="form-inline"> <div class="input-append"> <input type="text" id="me-message-text" placeholder="Type a message" /> <input type="button" id="sendtome" class="btn" value="Send to me" /> </div> </form> <h4>Group</h4> <form class="form-inline"> <div class="input-append"> <input type="text" id="group-text" placeholder="Type a group name" /> <input type="button" id="join-group" class="btn" value="Join Group" /> <input type="button" id="leave-group" class="btn" value="Leave Group" /> </div> </form> <h4>Private Message</h4> <form class="form-inline"> <div class="input-prepend input-append"> <input type="text" id="group-message-text" placeholder="Type a message" /> <input type="text" id="group-name" placeholder="Type the group name" /> <input type="button" id="sendgroupmsg" class="btn" value="Send to group" /> </div> </form> <ul id="message-list"></ul> </body> </html> <script src="signalr.js"></script> <script> let connectButton = document.getElementById('connect'); let disconnectButton = document.getElementById('disconnect'); disconnectButton.disabled = true; var connection = new signalR.HubConnectionBuilder().withUrl("/chatHub").build(); document.getElementById("connect").addEventListener("click", function (event) { connectButton.disabled = true; disconnectButton.disabled = false; connection.on('ReceiveMessage', msg => { addLine(msg); }); connection.onClosed = e => { if (e) { addLine('Connection closed with error: ' + e, 'red'); } else { addLine('Disconnected', 'green'); } } connection.start() .then(() => { addLine('Connected successfully', 'green'); }) .catch(err => { addLine(err, 'red'); }); event.preventDefault(); }); document.getElementById("disconnect").addEventListener("click", function (event) { connectButton.disabled = false; disconnectButton.disabled = true; connection.stop(); event.preventDefault(); }); document.getElementById("broadcast").addEventListener("click", function (event) { var message = document.getElementById('message-text').value; connection.invoke("Send", message).catch(function (err) { addLine(err, 'red'); }); event.preventDefault(); }); document.getElementById("broadcast-exceptme").addEventListener("click", function (event) { var message = document.getElementById('message-text').value; connection.invoke("SendAllExceptMe", message).catch(function (err) { addLine(err, 'red'); }); event.preventDefault(); }); document.getElementById("sendtome").addEventListener("click", function (event) { var message = document.getElementById('me-message-text').value; connection.invoke("Echo", message).catch(function (err) { addLine(err, 'red'); }); event.preventDefault(); }); document.getElementById("join-group").addEventListener("click", function (event) { var groupName = document.getElementById('group-text').value; connection.invoke("JoinGroup", groupName).catch(function (err) { addLine(err, 'red'); }); event.preventDefault(); }); document.getElementById("leave-group").addEventListener("click", function (event) { var groupName = document.getElementById('group-text').value; connection.invoke("LeaveGroup", groupName).catch(function (err) { addLine(err, 'red'); }); event.preventDefault(); }); document.getElementById("sendgroupmsg").addEventListener("click", function (event) { var groupName = document.getElementById('group-name').value; var message = document.getElementById('group-message-text').value; connection.invoke("SendToGroup", groupName, message).catch(function (err) { addLine(err, 'red'); }); event.preventDefault(); }); function addLine(line, color) { var child = document.createElement('li'); if (color) { child.style.color = color; } child.innerText = line; document.getElementById('message-list').appendChild(child); } </script>
-
編譯並運行 http://localhost:port/chat.html 測試.
權限驗證
SignalR 可以采用 ASP.NET Core 配置好的認證和授權體系, 比如 Cookie 認證, Bearer token 認證, Authorize
授權特性和 Policy 授權策略等.
- Cookie 認證基本上不需要額外配置, 但僅限於瀏覽器客戶端.
- Bearer token 認證適用於所有客戶端. 可以參考上篇文章 ASP.NET Core WebAPI中使用JWT Bearer認證和授權 進行Token的分發和驗證. 在 SignalR 中使用的時候需要注意兩點:
-
在 WebAPI 中, bearer token 是通過 HTTP header 傳輸的, 但當 SignalR 使用 WebSockets 和 Server-Sent Events 傳輸協議的時候, 由於不支持 header, Token是通過 query string 傳輸的, 類似於
ws://localhost:56202/chatHub?id=2fyJlq1T5vBOwAsITQaW8Q&access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
, 所以需要在服務端增加額外的配置如下:services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }).AddJwtBearer(configureOptions => { // Configure JWT Bearer Auth to expect our security key // We have to hook the OnMessageReceived event in order to // allow the JWT authentication handler to read the access // token from the query string when a WebSocket or // Server-Sent Events request comes in. configureOptions.Events = new JwtBearerEvents { OnMessageReceived = context => { var accessToken = context.Request.Query["access_token"]; if (!string.IsNullOrEmpty(accessToken) && (context.HttpContext.Request.Path.StartsWithSegments("/chatHub"))) { context.Token = accessToken; } return Task.CompletedTask; } }; });
同時, 給 Hub 添加 Authorize 特性.
[Authorize] public class ChatHub: Hub { }
-
JS 客戶端使用 accessTokenFactory 創建帶 Token 的連接.
this.connection = new signalR.HubConnectionBuilder() .withUrl("/chatHub", { accessTokenFactory: () => this.loginToken }) .build();
-
如果服務端認證通過, 可以使用 Context.User 獲取用戶信息, 它是一個 ClaimsPrinciple 對象.
-
橫向擴展
Hub 服務器可以支持的 TCP 並發連接數是有限的. 同時由於 SignalR 連接是持久的, 甚至當客戶端進入空閑狀態時,SignalR 連接依然保持着打開狀態。所以當連接數比較多時, 通過增加服務器來實現橫向擴展是很有必要的.
但相比於 WebAPI的單向通信(只存在客戶端請求,服務端響應的情景), SignalR 中可能使用雙向通信協議(客戶端可以請求服務端的數據, 服務端也可以向客戶端推送數據), 此時服務端水平擴展的時候, 一台服務器是不知道其他服務器上連接了哪些客戶端. 當在一台服務器想要將消息發送到所有客戶端時,消息只是發送到連接到該服務器的客戶端. 為了能夠把消息發送給所有服務器都連接的客戶端, 微軟提供了下面兩種方案:
-
Azure SignalR 服務 是一個代理。當客戶端啟動連接到服務器時,會重定向連接到 Azure SignalR 服務。
-
Redis 底板 當服務器想要將消息發送到所有客戶端時,它將先發送到 Redis 底板, 然后使用 Redis 的發布訂閱功能轉發給其他所有服務器從而發送給所有客戶端.
添加 NuGet 包, ASP.NET Core 2.2 及更高版本中使用
Microsoft.AspNetCore.SignalR.StackExchangeRedis
, 之前版本使用Microsoft.AspNetCore.SignalR.Redis
.
然后在Startup.ConfigureServices方法中, 添加 AddStackExchangeRedisservices.AddSignalR().AddStackExchangeRedis("<your_Redis_connection_string>");