【.Net Core】使用SignalR实现实时通信

SignalR

SignalR是一个.NET Core/.NET Framework的开源实时框架. SignalR的可使用Web Socket, Server Sent Events 和 Long Polling作为底层传输方式.

SignalR基于这三种技术构建, 抽象于它们之上, 它让你更好的关注业务问题而不是底层传输技术问题.

SignalR这个框架分服务器端和客户端, 服务器端支持ASP.NET Core 和 ASP.NET; 而客户端除了支持浏览器里的javascript以外, 也支持其它类型的客户端, 例如桌面应用.

三种通信方式

long polling(长轮询)

长轮询是客户端发起请求到服务端,服务器有数据就会直接返回。如果没有数据就保持连接并且等待,一直到有新的数据返回。如果请求保持到一段时间仍然没有返回,这时候就会超时,然后客户端再次发起请求。

这种方式优点就是简单,缺点就是资源消耗太多,基本是不考虑的。

server sent events(sse)

如果使用了sse,服务器就拥有了向客户端推送的能力,这些信息和流信息差不多,期间会保持连接。

这种方式优点还是简单,也支持自动重连,综合来讲比long polling好用。缺点也很明显,不支持旧的浏览器不说,还只能发送本文信息,而且浏览器对sse还有连接数量的限制(6个)。

web socket

web socket允许客户端和服务端同时向对方发送消息(也就是双工通信),而且不限制信息类型。虽然浏览器同样有连接数量限制(可能是50个),但比sse强得多。理论上最优先使用。

回落机制

在Web Socket, Server Sent Events 和 Long Polling中

Web Socket仅支持比较现代的浏览器, Web服务器也不能太老.
而Server Sent Events 情况可能好一点, 但是也存在同样的问题.
所以SignalR采用了回落机制, SignalR有能力去协商支持的传输类型.

在这里插入图片描述

RPC

RPC (Remote Procedure Call). 它的优点就是可以像调用本地方法一样调用远程服务.

SignalR采用RPC范式来进行客户端与服务器端之间的通信.

SignalR利用底层传输来让服务器可以调用客户端的方法, 反之亦然, 这些方法可以带参数, 参数也可以是复杂对象, SignalR负责序列化和反序列化.

Hub

Hub是SignalR的一个组件, 它运行在ASP.NET Core应用里. 所以它是服务器端的一个类.

Hub使用RPC接受从客户端发来的消息, 也能把消息发送给客户端. 所以它就是一个通信用的Hub.

在ASP.NET Core里, 自己创建的Hub类需要继承于基类Hub.

在Hub类里面, 我们就可以调用所有客户端上的方法了. 同样客户端也可以调用Hub类里的方法.
在这里插入图片描述
这种Hub+RPC的方式还是非常适合实时场景的.

之前说过方法调用的时候可以传递复杂参数, SignalR可以将参数序列化和反序列化. 这些参数被序列化的格式叫做Hub 协议, 所以Hub协议就是一种用来序列化和反序列化的格式.

Hub协议的默认协议是JSON, 还支持另外一个协议是MessagePack. MessagePack是二进制格式的, 它比JSON更紧凑, 而且处理起来更简单快速, 因为它是二进制的.

实现

.net core的SDK已经内置了Microsoft.AspNetCore.SignalR.Core

接下来注入SignalR,如下代码:

//注入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分钟,对应的客户端就得设置

注入了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,再不行就用轮询的方式,也就是我最开始想的那种办法。

另外得说一下的是假如前端调用的话,他是需要测试的,这时候其实需要跨域访问,不然每次打包好放到服务器再测这个实时通信的话有点麻烦。添加跨域的代码如下:

#if DEBUG
            //注入跨域
            services.AddCors(option => option.AddPolicy("cors",
                policy => policy.AllowAnyHeader().AllowAnyMethod().AllowCredentials()
                    .WithOrigins("http://localhost:8001", "http://localhost:8000", "http://localhost:8002")));
#endif

然后加上如下代码即可。

#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.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/[email protected]/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;
        }

来源

ASP.NET Core的实时库:SignalR简介及使用
.Net Core——SignalR(实时web应用)
在.net core3.0中使用SignalR实现实时通信

猜你喜欢

转载自blog.csdn.net/weixin_44231544/article/details/126477299