最近用.net core3.0重構網站,老大想作個站內信功能,就是有些耗時的後臺任務的結果須要推送給用戶。一開始我想簡單點,客戶端每隔1分鐘調用一下個人接口,看看是否是有新消息,有的話就告訴用戶有新推送,但老大不幹了,他就是要實時通訊,因而我只好上SignalR了。javascript
說幹就幹,首先去Nuget搜索前端
可是隻有Common是有3.0版本的,後來發現我須要的是Microsoft.AspNetCore.SignalR.Core,然而這個停更的狀態?因而我一臉矇蔽,搗鼓了一陣發現,原來.net core的SDK已經內置了Microsoft.AspNetCore.SignalR.Core,,右鍵項目,打開C:\Program Files\dotnet\packs\Microsoft.AspNetCore.App.Ref\3.0.0\ref\netcoreapp3.0 文件夾搜索SignalR,添加引用便可。vue
接下來注入SignalR,以下代碼:java
//注入SignalR實時通信,默認用json傳輸 services.AddSignalR(options => { //客戶端發保持鏈接請求到服務端最長間隔,默認30秒,改爲4分鐘,網頁需跟着設置connection.keepAliveIntervalInMilliseconds = 12e4;即2分鐘 options.ClientTimeoutInterval = TimeSpan.FromMinutes(4); //服務端發保持鏈接請求到客戶端間隔,默認15秒,改爲2分鐘,網頁需跟着設置connection.serverTimeoutInMilliseconds = 24e4;即4分鐘 options.KeepAliveInterval = TimeSpan.FromMinutes(2); });
這個解釋一下,SignalR默認是用Json傳輸的,可是還有另一種更短小精悍的傳輸方式MessagePack,用這個的話性能會稍微高點,可是須要另外引入一個DLL,JAVA端調用的話也是暫時不支持的。可是我實際上是不須要這點性能的,因此我就用默認的json好了。另外有個概念,就是實時通訊,實際上是須要發「心跳包」的,就是雙方都須要肯定對方還在不在,若掛掉的話我好重連或者把你幹掉啊,因此就有了兩個參數,一個是發心跳包的間隔時間,另外一個就是等待對方心跳包的最長等待時間。通常等待的時間設置成發心跳包的間隔時間的兩倍便可,默認KeepAliveInterval是15秒,ClientTimeoutInterval是30秒,我以爲不須要這麼頻繁的確認對方「死掉」了沒,因此我改爲2分鐘發一次心跳包,最長等待對方的心跳包時間是4分鐘,對應的客戶端就得設置npm
connection.keepAliveIntervalInMilliseconds = 12e4;
connection.serverTimeoutInMilliseconds = 24e4;
注入了SignalR以後,接下來須要使用WebSocket和SignalR,對應代碼以下:
//添加WebSocket支持,SignalR優先使用WebSocket傳輸 app.UseWebSockets(); //app.UseWebSockets(new WebSocketOptions //{ // //發送保持鏈接請求的時間間隔,默認2分鐘 // KeepAliveInterval = TimeSpan.FromMinutes(2) //}); app.UseEndpoints(endpoints => { endpoints.MapControllers(); endpoints.MapHub<MessageHub>("/msg"); });
這裏提醒一下,WebSocket只是實現SignalR實時通訊的一種手段,若這個走不通的狀況下,他還能夠降級使用SSE,再不行就用輪詢的方式,也就是我最開始想的那種辦法。json
另外得說一下的是假如前端調用的話,他是須要測試的,這時候其實須要跨域訪問,否則每次打包好放到服務器再測這個實時通訊的話有點麻煩。添加跨域的代碼以下:後端
#if DEBUG //注入跨域 services.AddCors(option => option.AddPolicy("cors", policy => policy.AllowAnyHeader().AllowAnyMethod().AllowCredentials() .WithOrigins("http://localhost:8001", "http://localhost:8000", "http://localhost:8002"))); #endif
而後加上以下代碼便可。api
#if DEBUG //容許跨域,不支持向全部域名開放了,會有錯誤提示 app.UseCors("cors"); #endif
好了,能夠開始動工了。建立一個MessageHub:跨域
public class MessageHub : Hub { private readonly IUidClient _uidClient; public MessageHub(IUidClient uidClient) { _uidClient = uidClient; } public override async Task OnConnectedAsync() { var user = await _uidClient.GetLoginUser(); //將同一我的的鏈接ID綁定到同一個分組,推送時就推送給這個分組 await Groups.AddToGroupAsync(Context.ConnectionId, user.Account); } }
因爲每次鏈接的鏈接ID不一樣,因此最好把他和登陸用戶的用戶ID綁定起來,推送時直接推給綁定的這個用戶ID便可,作法能夠直接把鏈接ID和登陸用戶ID綁定起來,把這個用戶ID做爲一個分組ID。瀏覽器
而後使用時就以下:
public class MessageService : BaseService<Message, ObjectId>, IMessageService { private readonly IUidClient _uidClient; private readonly IHubContext<MessageHub> _messageHub; public MessageService(IMessageRepository repository, IUidClient uidClient, IHubContext<MessageHub> messageHub) : base(repository) { _uidClient = uidClient; _messageHub = messageHub; } /// <summary> /// 添加並推送站內信 /// </summary> /// <param name="dto"></param> /// <returns></returns> public async Task Add(MessageDTO dto) { var now = DateTime.Now; var log = new Message { Id = ObjectId.GenerateNewId(now), CreateTime = now, Name = dto.Name, Detail = dto.Detail, ToUser = dto.ToUser, Type = dto.Type }; var push = new PushMessageDTO { Id = log.Id.ToString(), Name = log.Name, Detail = log.Detail, Type = log.Type, ToUser = log.ToUser, CreateTime = now }; await Repository.Insert(log); //推送站內信 await _messageHub.Clients.Groups(dto.ToUser).SendAsync("newmsg", push); //推送未讀條數 await SendUnreadCount(dto.ToUser); if (dto.PushCorpWeixin) { const string content = @"<font color='blue'>{0}</font> <font color='comment'>{1}</font> 系統:**CMS** 站內信ID:<font color='info'>{2}</font> 詳情:<font color='comment'>{3}</font>"; //把站內信推送到企業微信 await _uidClient.SendMarkdown(new CorpSendTextDto { touser = dto.ToUser, content = string.Format(content, dto.Name, now, log.Id, dto.Detail) }); } } /// <summary> /// 獲取本人的站內信列表 /// </summary> /// <param name="name">標題</param> /// <param name="detail">詳情</param> /// <param name="unread">只顯示未讀</param> /// <param name="type">類型</param> /// <param name="createStart">建立起始時間</param> /// <param name="createEnd">建立結束時間</param> /// <param name="pageIndex">當前頁</param> /// <param name="pageSize">每頁個數</param> /// <returns></returns> public async Task<PagedData<PushMessageDTO>> GetMyMessage(string name, string detail, bool unread = false, EnumMessageType? type = null, DateTime? createStart = null, DateTime? createEnd = null, int pageIndex = 1, int pageSize = 10) { var user = await _uidClient.GetLoginUser(); Expression<Func<Message, bool>> exp = o => o.ToUser == user.Account; if (unread) { exp = exp.And(o => o.ReadTime == null); } if (!string.IsNullOrEmpty(name)) { exp = exp.And(o => o.Name.Contains(name)); } if (!string.IsNullOrEmpty(detail)) { exp = exp.And(o => o.Detail.Contains(detail)); } if (type != null) { exp = exp.And(o => o.Type == type.Value); } if (createStart != null) { exp.And(o => o.CreateTime >= createStart.Value); } if (createEnd != null) { exp.And(o => o.CreateTime < createEnd.Value); } return await Repository.FindPageObjectList(exp, o => o.Id, true, pageIndex, pageSize, o => new PushMessageDTO { Id = o.Id.ToString(), CreateTime = o.CreateTime, Detail = o.Detail, Name = o.Name, ToUser = o.ToUser, Type = o.Type, ReadTime = o.ReadTime }); } /// <summary> /// 設置已讀 /// </summary> /// <param name="id">站內信ID</param> /// <returns></returns> public async Task Read(ObjectId id) { var msg = await Repository.First(id); if (msg == null) { throw new CmsException(EnumStatusCode.ArgumentOutOfRange, "不存在此站內信"); } if (msg.ReadTime != null) { //已讀的再也不更新讀取時間 return; } msg.ReadTime = DateTime.Now; await Repository.Update(msg, "ReadTime"); await SendUnreadCount(msg.ToUser); } /// <summary> /// 設置本人所有已讀 /// </summary> /// <returns></returns> public async Task ReadAll() { var user = await _uidClient.GetLoginUser(); await Repository.UpdateMany(o => o.ToUser == user.Account && o.ReadTime == null, o => new Message { ReadTime = DateTime.Now }); await SendUnreadCount(user.Account); } /// <summary> /// 獲取本人未讀條數 /// </summary> /// <returns></returns> public async Task<int> GetUnreadCount() { var user = await _uidClient.GetLoginUser(); return await Repository.Count(o => o.ToUser == user.Account && o.ReadTime == null); } /// <summary> /// 推送未讀數到前端 /// </summary> /// <returns></returns> private async Task SendUnreadCount(string account) { var count = await Repository.Count(o => o.ToUser == account && o.ReadTime == null); await _messageHub.Clients.Groups(account).SendAsync("unread", count); } }
IHubContext<MessageHub>能夠直接注入而且使用,而後調用_messageHub.Clients.Groups(account).SendAsync便可推送。接下來就簡單了,在MessageController裏把這些接口暴露出去,經過HTTP請求添加站內信,或者直接內部調用添加站內信接口,就能夠添加站內信而且推送給前端頁面了,固然除了站內信,咱們還能夠作得更多,好比比較重要的順便也推送到第三方app,好比企業微信或釘釘,這樣你還會怕錯太重要信息?
接下來到了客戶端了,客戶端只說網頁端的,代碼以下:
<body> <div class="container"> <input type="button" id="getValues" value="Send" /> <ul id="discussion"></ul> </div> <script src="https://cdn.jsdelivr.net/npm/@microsoft/signalr@3.0.0-preview7.19365.7/dist/browser/signalr.min.js"></script> <script type="text/javascript"> var connection = new signalR.HubConnectionBuilder() .withUrl("/message") .build(); connection.serverTimeoutInMilliseconds = 24e4; connection.keepAliveIntervalInMilliseconds = 12e4; var button = document.getElementById("getValues"); connection.on('newmsg', (value) => { var liElement = document.createElement('li'); liElement.innerHTML = 'Someone caled a controller method with value: ' + value; document.getElementById('discussion').appendChild(liElement); }); button.addEventListener("click", event => { fetch("api/message/sendtest") .then(function (data) { console.log(data); }) .catch(function (error) { console.log(err); }); }); var connection = new signalR.HubConnectionBuilder() .withUrl("/message") .build(); connection.on('newmsg', (value) => { console.log(value); }); connection.start(); </script> </body>
上面的代碼仍是須要解釋下的,serverTimeoutInMilliseconds和keepAliveIntervalInMilliseconds必須和後端的配置保持一致,否則分分鐘出現下面異常:
這是由於你沒有在我規定的時間內向我發送「心跳包」,因此我認爲你已經「陣亡」了,爲了不沒必要要的傻傻鏈接,我中止了鏈接。另外須要說的是重連機制,有多種重連機制,這裏我選擇每隔10秒重連一次,由於我以爲須要重連,那通常是由於服務器掛了,既然掛了,那我每隔10秒重連也是不會浪費服務器性能的,浪費的是瀏覽器的性能,客戶端的就算了,忽略不計。自動重連代碼以下:
async function start() { try { await connection.start(); console.log(connection) } catch (err) { console.log(err); setTimeout(() => start(), 1e4); } }; connection.onclose(async () => { await start(); }); start();
固然還有其餘不少重連的方案,能夠去官網看看。
固然若你的客戶端是用vue寫的話,寫法會有些不一樣,以下:
import '../../public/signalR.js' const wsUrl = process.env.NODE_ENV === 'production' ? '/msg' :'http://xxx.net/msg' var connection = new signalR.HubConnectionBuilder().withUrl(wsUrl).build() connection.serverTimeoutInMilliseconds = 24e4 connection.keepAliveIntervalInMilliseconds = 12e4 Vue.prototype.$connection = connection
接下來就能夠用this.$connection 愉快的使用了。
到這裏或許你以爲大功告成了,若沒看瀏覽器的控制檯輸出,我也是這麼認爲的,而後控制檯出現了紅色!:
雖然出現了這個紅色,可是依然能夠正常使用,只是降級了,不使用WebSocket了,心跳包變成了一個個的post請求,以下圖:
這個是咋回事呢,咋就用不了WebSocket呢,個人是谷歌瀏覽器呀,確定是支持WebSocket的,咋辦,只好去羣裏討教了,後來大神告訴我,須要在ngnix配置一下下面的就能夠了:
location /msg { proxy_connect_timeout 300; proxy_read_timeout 300; proxy_send_timeout 300; proxy_pass http://xxx.net; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; }