个人网站建站日记-集成SignalR实时消息推送

343 阅读5分钟

SignalR 是一个面向 .NET 开发人员的库,可简化向应用程序添加实时 Web 功能的过程。 实时 Web 功能是让服务器代码在可用时立即将内容推送到连接的客户端,而不是让服务器等待客户端请求新数据。

近期我给我的网站(闲蛋)集成了微语实时推送到主页的功能,就是在不用刷新页面的情况下,也能实时获取到最新动态的消息。这种技术的实现,在.net里面可以说是很简单的,因为有SignalR。下面我就分享一下我实现的过程。

1、安装Signalr的安装包

貌似好像.net6已经集成了Signalr,不需要你手动安装了,但是我这里因为把它单独抽离了出来,所以还是要安装的。

2、注册Signalr服务

新建一个SignalrHub类

Hub 是 SignalR 的核心组件,它是一个类,用作处理客户端和服务器之间通信的高级管道。Hub 允许客户端和服务器分别调用对方的方法,SignalR 自动处理跨计算机边界的调度。代码比较简单,如下

   public class SignalrHub : Hub
    {

        //连接中断时执行,微软这样描述的:
        //重写 OnDisconnectedAsync 虚方法,以便在客户端断开连接时执行操作。 如果客户端故意断开 连接(例如,通过调用 connection.stop()),exception 参数将 null。
        //但是,如果客户端由于错误(例如网络故障)而断开连接,则 exception 参数将包含描述失败的 异常
        public override Task OnDisconnectedAsync(Exception exception)
        {
            //todo 你自己的代码          
            return base.OnDisconnectedAsync(exception);
        }

    }

然后service.AddSignalR()注册服务,同时 创建对应的路由 app.MapHub("/SignalrHub")。

实现SignalrContext

SignalrContext是我自己封装的一个类,主要是通过构造函数完成IHubContext的注入然后获取Hub实例,然后业务代码通过调用SignalrContext.SendAllClientsMessage向所有客户端推送了消息,前提是客户端未关闭页面,当然你可以可以向指定或单个客户端发送消息

    public class SignalrContext : ISignalrContext
    {
       
        private IHubContext<SignalrHub> _hubContext;
        public SignalrContext(IHubContext<SignalrHub> hubContext)
        {
            _hubContext = hubContext;
        }
          
        #region 向客户端发送消息
        /// <summary>
        /// 向所有客户端发送消息
        /// </summary>
        /// <param name="message"></param>
        /// <param name="method"></param>
        /// <returns></returns>
        public async Task SendAllClientsMessage<T>(T message, string method = "AllReviceMesage")
        {
            await _hubContext.Clients.All.SendAsync(method, message);
        }
     /// <summary>
        /// 向指定的客户端集合发送消息
        /// </summary>
        /// <param name="connectionIds"></param>
        /// <param name="message"></param>
        /// <returns></returns>
        public async Task SendSomeClientsMessage<T>(IReadOnlyList<string> connectionIds, T message)
        {
            if (connectionIds != null && connectionIds.Count >0)
                await _hubContext.Clients.Clients(connectionIds).SendAsync("SendClientMessage", message);
        }
        /// <summary>
        /// 向指定的单个开户端发送消息
        /// </summary>
        /// <param name="message"></param>
        /// <returns></returns>
        /// <exception cref="ArgumentNullException"></exception>
        public async Task SendClientMessage<T>(T message,string revicer)
        {
            if (string.IsNullOrEmpty(revicer))
                throw new ArgumentNullException("指定的客户端连接为空");
            IReadOnlyList<string> connectionsByUser = SignalrConnectionMap.GetConnectionIds(revicer);
            await _hubContext.Clients.Clients(connectionsByUser).SendAsync("ReviceMesage", message);
        }
        #endregion
    }

我自己封装的一个

     internal class SignalrConnectionMap
    {
        /// <summary>
        /// 连接对象集合
        /// </summary>
        public static ConcurrentDictionary<string, string> ConnectionMaps = new ConcurrentDictionary<string, string>();
        /// <summary>
        /// 添加连接对象
        /// </summary>
        /// <param name="connectionId"></param>
        /// <param name="value"></param>
        public static void SetConnectionMaps(string connectionId, string value)
        {
            ConnectionMaps.AddOrUpdate(connectionId, value, (string s, string y) => value);
        }
        /// <summary>
        /// 删除对象集合
        /// </summary>
        /// <param name="key"></param>
        public static void Remove(string key)
        {
            if (ConnectionMaps.ContainsKey(key))
                ConnectionMaps.TryRemove(key, out string value);
        }
        /// <summary>
        /// 获取连接
        /// </summary>
        /// <param name="value"></param>
        /// <returns></returns>
        public static List<string> GetConnectionIds(string value)
        {
            List<string> connectionIds = new List<string>();
            foreach (KeyValuePair<string, string> item in ConnectionMaps)
            {
                if (item.Value == value)
                    connectionIds.Add(item.Key);
            }
            return connectionIds;
        }

    }

前端部分

安装Signalr

直接使用pnpm i @microsoft/signalr

监听Signalr

安装完成后,我们先import * as signalR from "@microsoft/signalr",然后创建于后端的连接

   const connection = new signalR.HubConnectionBuilder()
  .withUrl("http://localhost:5002/SignalrHub")//对应上面的app.MapHub<SignalrHub>("/SignalrHub")。
  .build();
  connection.start();

然后通过on方法监听,on方法第一个参数就是对应的我们后台的SendAllClientsMessage里面的method参数,一定要对应,然后第二个参数是回调函数,就是后端推送的数据。

    connection.on("AllReviceMesage", (message) => {
      console.log(message);
    })

当然,如果你想创建连接成功时,做些别的事情,如把当前的登录人传递到后端,则你需要定义一个变量接收 connection.start()的返回的Promise对象,如下

     let promise = connection.start();
     promise.then(con => {
       connection.invoke("SetConnectionMaps", "11")
     })
    然后你要在你的SignalrHub类里面添加一个对应的方法
     public void SetConnectionMaps(string account)
        {
            string connectionid = Context.ConnectionId;
            //todo 你自己的代码
        }

运行测试

一切就需完成后,运行前端以及后端,然后F12查看浏览器,你可能会出现以下两个错误

QQ截图20241202204015.png

QQ截图20241202204154.png 解决办法就是在跨域里面设置允许包含请求头"x-requested-with","x-signalr-user-agent"

      services.AddCors(s =>
    {
        IConfigurationSection section = ConfigureProvider.configuration.GetSection("policy");
        string[] origins = section.GetSection("origins").Value.Split(',');
        s.AddPolicy("cors",
            p => p.AllowAnyMethod()
            .AllowCredentials()
            .WithOrigins(origins)
            .WithHeaders("x-requested-with","x-signalr-user-agent")
            );
    });

让后重新运行,出现这个消息时,代码创建连接成功

QQ截图20241202204700.png

然后调试后台推送一个消息过来,前端虽然接收到了消息,但是我们看到时间字段没有格式化,并且userId也没有转为字符串
QQ截图20241202205013.png

据我所了解的,Signalr默认是使用Json的格式,所以我们这里可以使用AddJsonProtocol去设置格式,我这里没有使用Newtonsoft.Json,而是使用System.Text.Json设置,如果你也想使用System.Text.Json,那么就需要引入Microsoft.AspNetCore.SignalR.Protocol,才可以像我下面这样设置

     service.AddSignalR().AddJsonProtocol(option =>
            {
                option.PayloadSerializerOptions = new JsonSerializerOptions()
                {
                    //首字母小写
                    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
                    //不格式化输出
                    WriteIndented = false,
                    Converters = {
                        new DateTimeConverter(),
                        new LongToStringConverter()
                    }
                };
            });
    
     internal class DateTimeConverter: JsonConverter<DateTime>
    {
        private readonly string _dateFormat = "yyyy-MM-dd HH:mm:ss";

        public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            return DateTime.ParseExact(reader.GetString(), _dateFormat, null);
        }

        public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
        {
            writer.WriteStringValue(value.ToUniversalTime().ToString(_dateFormat));
        }
    }
    
     internal class LongToStringConverter : JsonConverter<long>
    {
        public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            return long.Parse(reader.GetString());
        }

        public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
        {
            writer.WriteStringValue(value.ToString());
        }
    }

以上设置完成后就可以按照我想要的格式了接收消息了。

Ubunu部署

我是通过nginx反向代理到我的服务器上的,默认的nginx是不支持Signalr的协议的,所以你需要在nginx里面添加 map $http_connection $connection_upgrade { "~*Upgrade" $http_connection; default keep-alive; } 可以参考我的配置

    
#user  nobody;
worker_processes 1;

#error_log  logs/error.log;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;

#pid        logs/nginx.pid;
events {
    worker_connections 1024;
}


http {
    include mime.types;
    default_type application/octet-stream;

    #log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
    #                  '$status $body_bytes_sent "$http_referer" '
    #                  '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx-access.log;
    sendfile on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    keepalive_timeout 65;

    #gzip  on;
    server_tokens off;
    map $http_connection $connection_upgrade {
    "~*Upgrade" $http_connection;
    default keep-alive;
   }
    server {
        listen 80;
        server_name www.xiandanplay.com ;
        rewrite ^(.*) https://$server_name$1 permanent;
    }
    server {
        #SSL 默认访问端口号为 443
        listen 443 ssl;
        #请填写绑定证书的域名
        server_name xiandanplay.com;
       location /signalr {
            proxy_pass http://127.0.0.1:5051/SignalrHub;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
            # proxy_redirect default;
        }
    }
}

作者:程序员奶牛
个人主页:https://www.xiandanplay.com
源码地址:https://gitee.com/MrHanchichi/xian-dan