【.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有能力去协商支持的传输类型.

在这里插入图片描述

扫描二维码关注公众号,回复: 14594039 查看本文章

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