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); //开始传递 } } } }
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(); } }
后台服务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); } }
这里就冒出来另外一个新的问题了,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>
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="width:48px; height:48px; border-radius:50%; overflow:hidden;" /> </p> </div> <div style="width: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>
好了,代码就写完了,同时运行Demo.Identity和Demo.Chat。打开2个浏览器:http://localhost:5001/login.html。
输入用户名密码登录;
发送个消息试试:
是不是很简陋?嘿嘿
好了,到处为止。其他不完善的地方,自己动手,丰衣足食,如离线消息,token自动刷新等等.