zoukankan      html  css  js  c++  java
  • IdentityServer4 + SignalR Core +RabbitMQ 构建web即时通讯(三)

    IdentityServer4 + SignalR Core +RabbitMQ 构建web即时通讯(三)


    后台服务用户与认证 


    新建一个空的.net core web项目Demo.Chat,端口配置为5001,安装以下nuget包

    1.IdentityServer4.AccessTokenValidation,IdentityServer4客户端认证所用;

    2.Microsoft.AspNetCore.SignalR

    3.RabbitMQ.Client

    添加appsettings.json

    {
      "RabbitMQ": {
        "Host": "192.168.1.107",
        "User": "admin",
        "Password": "123123"
      },
      "Authentication": {
        "Authority": "http://localhost:5000"
      }
    }

    这里我们新增两个Dto类,一个消息传输类MsgDto,一个用户数据类UserDto

        public class MsgDto
        {
            public UserDto FromUser { get; set; }
            public UserDto ToUser { get; set; }
            public string Content { get; set; }
            public DateTime SendTime { get; set; }
        }
        public class UserDto
        {
            // signalr当前的连接id
            public string ConnectionId { get; set; }
            public Guid Id { get; set; }
            public string UserName { get; set; }
            public string EMail { get; set; }
            public string Avatar { get; set; }
        }

    当用户认证通过后,从Identity返回的token中我们已经返回了用户的基础信息了,那这里我们如何获取呢?很简单在上下文的User中Claims属性里面,所以这里我们增加一个扩展方法来转换为UserDto 

            public static UserDto GetUser(this ClaimsPrincipal claimsPrincipal)
            {
                return new UserDto
                {
                    Id = new Guid(claimsPrincipal.Claims.FirstOrDefault(r => r.Type == "sub").Value),
                    EMail = claimsPrincipal.Claims.FirstOrDefault(r => r.Type == "email").Value,
                    UserName = claimsPrincipal.Claims.FirstOrDefault(r => r.Type == "username").Value,
                    Avatar = claimsPrincipal.Claims.FirstOrDefault(r => r.Type == "avatar").Value,
                };
            }

    既然是在线聊天那必须得存储当前所有的在线用户对吧?新建一个OnlineUsers类,这里我们就不用数据库了,Demo嘛,里面就3个用户,嘿嘿。当然你完全可以自由发挥使用其他redis,mongo什么什么的。

        public class OnlineUsers
        {
            /// <summary>
            /// 用户id作为key
            /// </summary>
            private static ConcurrentDictionary<Guid, UserDto> onlineUsers { get; } = new ConcurrentDictionary<Guid, UserDto>();
    
            public void AddOrUpdateUser(UserDto user)
            {
                onlineUsers.AddOrUpdate(user.Id, user, (id, r) => user);
            }
    
            public List<UserDto> Get()
            {
                return onlineUsers.Values.ToList();
            }
    
            public UserDto Get(Guid userId)
            {
                onlineUsers.TryGetValue(userId, out UserDto user);
                return user;
            }
    
            public void Remove(Guid userId)
            {
                if (onlineUsers.ContainsKey(userId))
                    onlineUsers.TryRemove(userId, out UserDto user);
            }
        }

    后台服务RabbitMQ消息处理


    RabbitMQ消息队列相关的知识这里我也不再赘述,园子里面很多,大家自行研究,RabbitMQ大概有2个种模式:生产消费者模式和发布/订阅模式,生产消费者模式即消息只能被使用一次,比如一个商品生产出来你只能卖给一个消费者对吧,发布/订阅即只要订阅了都会收到该消息。这里我们用到的是生产消费者模式,参考官方文档

    消息发送和收到消息的处理,这里我们分为2个类单独处理,MsgSender和MsgHandler。

    MsgSender:当用户发送了一条消息,后端收到后就将消息添加到消息队列,MsgHandler:一直处于运行状态,当收到队列的消息时,开始处理消息,调用SignalR的方法,发送消息到客户端,RabbitMQ的连接配置在appsettings.json中,注入IConfiguration获取

    MsgSender

        public class MsgSender
        {
            public MsgSender(IConfiguration configuration)
            {
                factory = new ConnectionFactory();
                factory.HostName = configuration.GetValue<string>("RabbitMQ:Host");
                factory.UserName = configuration.GetValue<string>("RabbitMQ:User");
                factory.Password = configuration.GetValue<string>("RabbitMQ:Password");
            }
            ConnectionFactory factory;
    
            public void Send(MsgDto msg)
            {
                using (var connection = factory.CreateConnection())
                {
                    using (var channel = connection.CreateModel())
                    {
                        channel.QueueDeclare("chat_queue", false, false, false, null);//创建一个名称为hello的消息队列
                        var body = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(msg));
                        channel.BasicPublish("", "chat_queue", null, body); //开始传递
                    }
                }
            }
        }
    View Code

    MsgHandler,需要注入IHubContext接口,用于发送消息到客户端,ps:在Hub类中,可以通过Clients直接发送消息到客户端,在其他类里面可以使用这个接口,获取到Clients。

        public class MsgHandler : IDisposable
        {
            public MsgHandler(IConfiguration configuration, IHubContext<MessageHub> hubContext)
            {
                factory = new ConnectionFactory();
                factory.HostName = configuration.GetValue<string>("RabbitMQ:Host");
                factory.UserName = configuration.GetValue<string>("RabbitMQ:User");
                factory.Password = configuration.GetValue<string>("RabbitMQ:Password");
                this.hubContext = hubContext;
                connection = factory.CreateConnection();
                channel = connection.CreateModel();
    
            }
            ConnectionFactory factory;
            // 注入SignalR的消息处理器上下文,用以发送消息到客户端
            IHubContext<MessageHub> hubContext;
            IConnection connection;
            IModel channel;
            public void BeginHandleMsg()
            {
                channel.QueueDeclare("chat_queue", false, false, false, null);
                var consumer = new EventingBasicConsumer(channel);
                channel.BasicConsume("chat_queue", false, consumer);
                consumer.Received += (model, arg) =>
                {
                    var body = arg.Body;
                    var message = Encoding.UTF8.GetString(body);
                    var msg = JsonConvert.DeserializeObject<MsgDto>(message);
                    // 通过消息处理器上下文发送消息到客户端
                    hubContext.Clients?.Client(msg.ToUser.ConnectionId)
                                      ?.SendAsync("Receive", msg);
    
                    channel.BasicAck(arg.DeliveryTag, false);
                };
            }
    
            public void Dispose()
            {
                channel?.Dispose();
                connection?.Dispose();
            }
        }
    View Code

    后台服务SignalR消息处理器


    关于SignalR,官方文档

    SignalR的核心就是继承自Hub消息处理类,这个类中所有的public 方法都可以给客户端调用。我们的聊天室比较简陋,只需要一个Send方法给客户端就够了,是吧?当然服务端需要2个主动发送消息到客户端的方法,1.当有用户登录时通知所有客户端刷新在线用户列表,2.有什么错误的时候发送错误消息给客户端,比如我们不允许离线发送,用户发了条消息给一个不在线的用户。

    另外当用户登录和离开时需要在OnlineUsers中进行注册和注销。

    MessageHub,我们的聊天室必须登录,所以加上Authorize特性。

        [Authorize]
        public class MessageHub : Hub
        {
            MsgSender msgSender;
            MsgHandler msgQueueHandler;
            OnlineUsers onlineUsers;
            public MessageHub(MsgSender msgSender, MsgHandler msgQueueHandler, OnlineUsers onlineUsers)
            {
                this.msgSender = msgSender;
                this.msgQueueHandler = msgQueueHandler;
                this.onlineUsers = onlineUsers;
            }
    
            public async Task Send(string toUserId, string message)
            {
                string timestamp = DateTime.Now.ToShortTimeString();
                var toUser = onlineUsers.Get(new Guid(toUserId));
                if (toUser == null)
                {
                    await SendErrorAsync("用户已离线");
                    return;
                }
                var fromUser = Context.User.GetUser();
                msgSender.Send(new Dtos.MsgDto
                {
                    Content = message,
                    FromUser = fromUser,
                    SendTime = DateTime.Now,
                    ToUser = toUser
                });
            }
    
            /// <summary>
            /// 当有用户登录时 添加在线用户,并设置用户的ConnectionId
            /// </summary>
            /// <returns></returns>
            public override async Task OnConnectedAsync()
            {
                await base.OnConnectedAsync();
                var user = Context.User.GetUser();
                if (user == null)
                {
                    await SendErrorAsync("您没有登录");
                    return;
                }
                user.ConnectionId = Context.ConnectionId;
                onlineUsers.AddOrUpdateUser(user);
                await SendUserInfo();
                await RefreshUsersAsync();
            }
    
            /// <summary>
            /// 当有用户离开时,注销用户登录
            /// </summary>
            /// <param name="exception"></param>
            /// <returns></returns>
            public override async Task OnDisconnectedAsync(Exception exception)
            {
                //disconnection
                await base.OnDisconnectedAsync(exception);
                var userId = Context.User?.GetUser()?.Id;
                if (userId.HasValue)
                    onlineUsers.Remove(userId.Value);
                await RefreshUsersAsync();
            }
    
            private async Task RefreshUsersAsync()
            {
                var users = onlineUsers.Get().Where(r => r.Id != Context.User.GetUser().Id).ToList();
                // 发送给所有的在线客户端,通知刷新在线用户
                await Clients.All.SendAsync("Refresh", users);
            }
    
            private async Task SendErrorAsync(string errorMsg)
            {
                // 发送错误消息给调用者
                await Clients.Caller.SendAsync("Error", errorMsg);
            }
    
        }
    View Code

    这里就冒出来另外一个新的问题了,SignalR使用的是websocket,据我了解到的是没有header头这个东西的,而jwt token默认是通过header中Authorization信息进行认证的。那这个授权又如何实现呢?想办法咯,既然header传不进来,那直接url传进来总可以吧。

    后台服务:服务注册与认证授权


    好了,我们先将需要的服务先配置下。

    AddIdentityServerAuthentication实际上是AddJwtBearer的扩展,你要喜欢也可以用AddJwtBearer配置,由IdentityServer4.AccessTokenValidation提供,配置认证Authority为http://localshot:5000(Demo.Identity配置的端口号为5000,appsetting.json中配置),ApiName和Secret与Identity端配置的ApiResource一致。

            public void ConfigureServices(IServiceCollection services)
            {
                // 注册消息处理器 消息发送器,在线用户类
                services.AddSingleton<MsgHandler>()
                    .AddSingleton<MsgSender>()
                    .AddSingleton<OnlineUsers>();
    
                // 增加认证服务
                services.AddAuthentication(r =>
                {
                    r.DefaultScheme = "JwtBearer";
                })
                // 增加jwt认证
                .AddIdentityServerAuthentication("JwtBearer", r =>
                {
                    // 配置认证服务器
                    r.Authority = Configuration.GetValue<string>("Authentication:Authority");
                    // 配置无需验证https
                    r.RequireHttpsMetadata = false;
                    // 配置 当前资源服务器的名称
                    r.ApiName = "chatapi";
                    // 配置 当前资源服务器的连接密码
                    r.ApiSecret = "123123";
                    r.SaveToken = true;
                });
    
                // 跨域
                services.AddCors(r =>
                {
                    r.AddPolicy("all", policy =>
                    {
                        policy
                        .AllowAnyOrigin()
                        .AllowAnyHeader()
                        .AllowAnyMethod()
                        .AllowCredentials()
                        ;
                    });
                });
                // 增加授权服务
                services.AddAuthorization();
                // 增加SignalR 服务
                services.AddSignalR();
            }

    刚刚提到SignalR认证的问题,具体如何实现呢?这里也有2种方式,1.使用中间件在认证之前从url中获取token并添加到header中;2.r.MapHub<MessageHub>("/msg"),可以配置在参数中添加自定义的IAuthorizeData接口,可以自己实现获取token验证,我觉得比较麻烦,这里我们使用第一种方式。

    添加中间件,这个中间件一定要在UseAuthentication之前:

                // signalr jwt认证 token添加
                app.Use(async (context, next) =>
                {
                    // 这里从url中获取token参数,实际应用请实际考虑,加一些过滤条件
                    if (context.Request.Query.TryGetValue("token", out var token))
                    {
                        // 从url中拿到header,再添加到header中,一定要在UseAuthentication之前
                        context.Request.Headers.Add("Authorization", $"Bearer {token}");
                    }
                    await next.Invoke();
                });

    好了,还有一个问题,前面写的MsgHandler什么时候开始处理消息?Dispose什么时候调用?这里我们使用IApplicationLifetime接口,该接口提供了应用的整个生命周期事件处理。在应用启动的时候我们注册消息处理,应用结束时Dispose。

                // 应用启动时开始处理消息
                applicationLifetime.ApplicationStarted.Register(msgHandler.BeginHandleMsg);
                // 应用退出时,释放资源
                applicationLifetime.ApplicationStopping.Register(msgHandler.Dispose);

    完整的Configure代码:

            public void Configure(
                IApplicationBuilder app,
                IHostingEnvironment env,
                MsgHandler msgHandler,
                IApplicationLifetime applicationLifetime)
            {
                if (env.IsDevelopment())
                {
                    app.UseDeveloperExceptionPage();
                }
                app.UseStaticFiles();
                app.UseCors("all");
    
                app.UseAuthentication();
                // 使用SignalR 并添加MessageHub类的消息处理器
                app.UseSignalR(r =>
                {
                    r.MapHub<MessageHub>("/msg");
                });           
    
                // 应用启动时开始处理消息
                applicationLifetime.ApplicationStarted.Register(msgHandler.BeginHandleMsg);
                // 应用退出时,释放资源
                applicationLifetime.ApplicationStopping.Register(msgHandler.Dispose);
            }

    另外用户登录后需要展示用户信息,邮件地址啊头像什么的,这里我们也有2种方式,1是消息处理器中,当用户连接后主动发送消息给用户;2是建一个Api接口,当然放在消息处理器中会显得更纯洁,web项目里面没有一个controller,这里我们使用第一种方式。

    在MessageHub中添加方法,在OnConnectedAsync方法中调用

            private async Task SendUserInfo()
            {
                await Clients.Caller.SendAsync("UserInfo", Context.User.GetUser());
            }

    聊天室web前端


    官方提供了js库,可以用npm安装,npm install @aspnet/signalr。

    这个前端嘛,我就不花大功夫去做得漂亮高大上了,暂时就把代码直接丢在Demo.chat里面吧,2个页面,登录页login,聊天室页面chat。

     关于前端就不啰嗦了,再啰嗦就是关公面前耍大刀了,什么angular,vue,老夫写代码统统jquery。其他的大家自己发挥了。

    login.html

    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8" />
        <title>登录聊天室</title>
        <script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
    </head>
    <body>
        <div>
            <fieldset>
                <legend>登录聊天室</legend>
                <div>
                    <input type="text" name="uername" id="username" value="" />
                </div>
                <div>
                    <input type="password" name="password" id="password" value="" />
                </div>
                <div>
                    <button id="login" type="button">登录</button>
                </div>
            </fieldset>
        </div>
        <script type="text/javascript">
            $(function () {
                var identityUrl = 'http://localhost:5000/connect/token';
    
                $('#login').click(function () {
    
                    $.post(identityUrl, {
                        client_id: 'chat_client',
                        grant_type: 'password',
                        scope: 'openid chatapi profile offline_access',
                        username: $('#username').val(),
                        password: $('#password').val()
                    }, function (result) {
                        if (result && result.access_token) {
                            sessionStorage['token'] = result.access_token;
                            window.location = "http://localhost:5001/chat.html";
                        }
                    }, 'json');
                });
            });
        </script>
    </body>
    </html>
    View Code

    chat.html

    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8" />
        <title></title>
        <script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
        <script src="/lib/signalr.js"></script>
    </head>
    <body>
        <div>
            <div>
                <input type="hidden" id="userId" value="" />
                <p><label>UserName:</label><i id="userName"></i></p>
                <p><label>EMail:</label><i id="email"></i></p>
                <p><label>Avatar:</label><img id="avatar" style="48px; height:48px; border-radius:50%; overflow:hidden;" /> </p>
            </div>
            <div style="700px;height:500px;border:1px solid red;">
                <ul id="msgList"></ul>
            </div>
            <div>
                <select id="users"></select>
            </div>
            <div>
                <textarea id="msgSendContent" placeholder="请输入发送消息" cols="100" rows="4"></textarea>
                <br />
                <button id="send" type="button">发送</button>
            </div>
    
        </div>
    
        <script type="text/javascript">
            $(function () {
                var token = sessionStorage['token'];
    
                if (!token) {
                    alert('请先登录!');
                    window.location = 'http://localhost:5001/login.html';
                    return;
                }
                function timeFormat(time) {
                    time = new Date(time)
                    return time.toLocaleDateString() + ' ' + time.toLocaleTimeString();
                }
    
                var connection = new signalR.HubConnectionBuilder()
                    .withUrl("/msg?token=" + token)
                    .configureLogging(signalR.LogLevel.Information)
                    .build();
    
                connection.on('Receive', function (msg) {
                    var $ul = $('#msgList');
                    var $li = $('<li>' + msg.fromUser.userName + '[' + timeFormat(msg.sendTime) + '] : ' + msg.content + '</li>');
                    $ul.append($li);
                });
    
                connection.on('UserInfo', function (userInfo) {
                    $('#userName').text(userInfo.userName);
                    $('#email').text(userInfo.eMail);
                    $('#avatar').attr('src', userInfo.avatar);
                    $('#userId').val(userInfo.id);
                });
    
                connection.on('Refresh', function (users) {
                    $('#users').empty();
                    users.forEach(function (user) {
                        if (user.id != $('#userId').val())
                            $('#users').append('<option value="' + user.id + '">' + user.userName + '</option>');
                    });;
                });
    
                connection.on('Error', function (err) {
                    alert(err);
                });
    
                connection.start().catch(err => console.error(err.toString()));
                $('#send').click(function () {
                    var msg = $('#msgSendContent').val();
                    var toUerId = $('#users').val();
                    connection.invoke('Send', toUerId, msg).catch(err => console.error(err));
                    var $ul = $('#msgList');
                    var $li = $('<li>我[' + timeFormat(new Date()) + '] : ' + msg + '</li>');
                    $ul.append($li);
                });
            });
        </script>
    </body>
    </html>
    View Code

    好了,代码就写完了,同时运行Demo.Identity和Demo.Chat。打开2个浏览器:http://localhost:5001/login.html。

     输入用户名密码登录;

    发送个消息试试:

    是不是很简陋?嘿嘿

    好了,到处为止。其他不完善的地方,自己动手,丰衣足食,如离线消息,token自动刷新等等.

  • 相关阅读:
    react-动画
    react-json渲染
    SQL 只取重复记录一条记录并且是最小值
    SQL 函数:树结构指定父节点遍历所有的子节点
    EasyUI treegrid 删除一条或多条记录
    String.Format数字格式化输出 {0:N2} {0:D2} {0:C2} (转)
    EasyUI 左,右(上、下)布局
    DataTable 树形构造加全部
    easyui datagrid 格式化列显示两位小数、千分位
    c# 判断文件是否已使用
  • 原文地址:https://www.cnblogs.com/gucaocao/p/9163919.html
Copyright © 2011-2022 走看看