diff --git a/samples/ChatSample/ChatSample.csproj b/samples/ChatSample/ChatSample.csproj index aa1ad09a5a..8c6f0ddf1d 100644 --- a/samples/ChatSample/ChatSample.csproj +++ b/samples/ChatSample/ChatSample.csproj @@ -11,6 +11,7 @@ + @@ -26,14 +27,14 @@ - + All - + All - + All @@ -43,8 +44,7 @@ - + diff --git a/samples/ChatSample/DefaultPresenceManager.cs b/samples/ChatSample/DefaultPresenceManager.cs index b79a91397a..c895ae7f1e 100644 --- a/samples/ChatSample/DefaultPresenceManager.cs +++ b/samples/ChatSample/DefaultPresenceManager.cs @@ -1,54 +1,60 @@ - -using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.DependencyInjection; +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; -using System; +using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.Sockets; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace ChatSample { - // TODO: not possible to use TClient instead of (implicit) IClientProxy - // public class DefaultPresenceManager : IPresenceManager where THub : HubWithPresence public class DefaultPresenceManager : IPresenceManager where THub : HubWithPresence { private IHubContext _hubContext; private HubLifetimeManager _lifetimeManager; private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ILogger _logger; - public DefaultPresenceManager(IHubContext hubContext, HubLifetimeManager lifetimeManager, IServiceScopeFactory serviceScopeFactory) + public DefaultPresenceManager(IHubContext hubContext, HubLifetimeManager lifetimeManager, + IServiceScopeFactory serviceScopeFactory, ILoggerFactory loggerFactory) { _hubContext = hubContext; _lifetimeManager = lifetimeManager; _serviceScopeFactory = serviceScopeFactory; + _logger = loggerFactory.CreateLogger>(); } - private readonly ConcurrentDictionary usersOnline + private readonly ConcurrentDictionary _usersOnline = new ConcurrentDictionary(); - public IEnumerable UsersOnline => usersOnline.Values; + public Task> UsersOnline() + => Task.FromResult(_usersOnline.Values.AsEnumerable()); public async Task UserJoined(Connection connection) { - // `context.User?.Identity?.Name ?? string.Empty` ? var user = new UserDetails(connection.ConnectionId, connection.User.Identity.Name); await Notify(hub => hub.OnUserJoined(user)); - usersOnline.TryAdd(connection, user); + _usersOnline.TryAdd(connection, user); } public async Task UserLeft(Connection connection) { - usersOnline.TryRemove(connection, out UserDetails user); - - await Notify(hub => hub.OnUserLeft(user)); + if (_usersOnline.TryRemove(connection, out var userDetails)) + { + await Notify(hub => hub.OnUserLeft(userDetails)); + } } private async Task Notify(Func invocation) { - foreach (var connection in usersOnline.Keys) + foreach (var connection in _usersOnline.Keys) { using (var scope = _serviceScopeFactory.CreateScope()) { @@ -63,9 +69,9 @@ namespace ChatSample { await invocation(hub); } - catch + catch (Exception ex) { - // TODO: log + _logger.LogWarning(ex, "Presence notification failed."); } finally { diff --git a/samples/ChatSample/HubWithPresence.cs b/samples/ChatSample/HubWithPresence.cs index c955daabb9..e1c3480405 100644 --- a/samples/ChatSample/HubWithPresence.cs +++ b/samples/ChatSample/HubWithPresence.cs @@ -1,7 +1,10 @@ -using Microsoft.AspNetCore.SignalR; +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; using System.Collections.Generic; using System.Threading.Tasks; -using System; +using Microsoft.AspNetCore.SignalR; namespace ChatSample { @@ -22,11 +25,11 @@ namespace ChatSample _presenceManager = presenceManager; } - public IEnumerable UsersOnline + public Task> UsersOnline { get { - return _presenceManager.UsersOnline; + return _presenceManager.UsersOnline(); } } diff --git a/samples/ChatSample/Hubs/Chat.cs b/samples/ChatSample/Hubs/Chat.cs index cbfa913838..bcc86def43 100644 --- a/samples/ChatSample/Hubs/Chat.cs +++ b/samples/ChatSample/Hubs/Chat.cs @@ -22,7 +22,7 @@ namespace ChatSample.Hubs Context.Connection.Dispose(); } - await Clients.Client(Context.ConnectionId).InvokeAsync("SetUsersOnline", UsersOnline); + await Clients.Client(Context.ConnectionId).InvokeAsync("SetUsersOnline", await UsersOnline); await base.OnConnectedAsync(); } diff --git a/samples/ChatSample/IPresenceManager.cs b/samples/ChatSample/IPresenceManager.cs index c38e32e9d6..c73d59a94b 100644 --- a/samples/ChatSample/IPresenceManager.cs +++ b/samples/ChatSample/IPresenceManager.cs @@ -1,26 +1,15 @@ -using System; +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + using System.Collections.Generic; using System.Threading.Tasks; -using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.Sockets; namespace ChatSample { - public class UserDetails - { - public UserDetails(string connectionId, string name) - { - ConnectionId = connectionId; - Name = name; - } - - public string ConnectionId { get; } - public string Name { get; } - } - public interface IPresenceManager { - IEnumerable UsersOnline { get; } + Task> UsersOnline(); Task UserJoined(Connection connection); Task UserLeft(Connection connection); } diff --git a/samples/ChatSample/Program.cs b/samples/ChatSample/Program.cs index e08380975b..e4fd082339 100644 --- a/samples/ChatSample/Program.cs +++ b/samples/ChatSample/Program.cs @@ -1,11 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; -using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; namespace ChatSample diff --git a/samples/ChatSample/RedisPresenceManager.cs b/samples/ChatSample/RedisPresenceManager.cs new file mode 100644 index 0000000000..05110a4600 --- /dev/null +++ b/samples/ChatSample/RedisPresenceManager.cs @@ -0,0 +1,176 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.SignalR.Redis; +using Microsoft.AspNetCore.Sockets; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace ChatSample +{ + public class RedisPresenceManager : IDisposable, IPresenceManager where THub : HubWithPresence + { + private readonly RedisKey UsersOnlineRedisKey = "UsersOnline"; + private readonly RedisChannel _redisChannel; + + private IHubContext _hubContext; + private HubLifetimeManager _lifetimeManager; + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly int _redisDatabase; + private readonly ConnectionMultiplexer _redisConnection; + private readonly ISubscriber _redisSubscriber; + private readonly ILogger _logger; + + private readonly ConcurrentDictionary connections + = new ConcurrentDictionary(); + + // TODO: subscribe and handle lifecycle events/disconnects + // TODO: handle situations where Redis shuts down + // TODO: handle situations where a server goes down and the server has zombie connections + + public RedisPresenceManager(IHubContext hubContext, HubLifetimeManager lifetimeManager, + IServiceScopeFactory serviceScopeFactory, IOptions options, ILoggerFactory loggerFactory) + { + _hubContext = hubContext; + _lifetimeManager = lifetimeManager; + _serviceScopeFactory = serviceScopeFactory; + + _logger = loggerFactory.CreateLogger>(); + _redisDatabase = options.Value.Options.DefaultDatabase.GetValueOrDefault(); + _redisConnection = ConnectToRedis(options.Value, _logger); + _redisSubscriber = _redisConnection.GetSubscriber(); + _redisChannel = new RedisChannel((string)UsersOnlineRedisKey, RedisChannel.PatternMode.Literal); + + _redisSubscriber.Subscribe(_redisChannel, (channel, value) => + { + var stringValue = (string)value; + var user = ToUserDetails(stringValue.Substring(1)); + if (stringValue[0] == '-') + { + _ = Notify(hub => hub.OnUserLeft(user)); + } + else + { + _ = Notify(hub => hub.OnUserJoined(user)); + } + }); + } + + private static ConnectionMultiplexer ConnectToRedis(RedisOptions options, ILogger logger) + { + var loggerTextWriter = new LoggerTextWriter(logger); + return options.Factory == null + ? ConnectionMultiplexer.Connect(options.Options, loggerTextWriter) + : options.Factory(loggerTextWriter); + } + + public async Task> UsersOnline() + { + var database = _redisConnection.GetDatabase(_redisDatabase); + var usersOnline = await database.SetMembersAsync(UsersOnlineRedisKey); + + return usersOnline.Select(u => ToUserDetails(u)); + } + + private static UserDetails ToUserDetails(string user) + { + var pos = user.IndexOf("|"); + Debug.Assert(pos >= 0, "Invalid user details format"); + return new UserDetails(user.Substring(0, pos), user.Substring(pos + 1)); + } + + public Task UserJoined(Connection connection) + { + connections.TryAdd(connection, null); + + var database = _redisConnection.GetDatabase(_redisDatabase); + var user = $"{connection.ConnectionId}|{connection.User.Identity.Name}"; + // Fire and forget + _ = database.SetAddAsync(UsersOnlineRedisKey, $"{connection.ConnectionId}|{connection.User.Identity.Name}"); + _ = _redisSubscriber.PublishAsync(_redisChannel, "+" + user); + + return Task.CompletedTask; + } + + public Task UserLeft(Connection connection) + { + connections.TryRemove(connection, out object _); + + var database = _redisConnection.GetDatabase(_redisDatabase); + var user = $"{connection.ConnectionId}|{connection.User.Identity.Name}"; + // Fire and forget + _ = database.SetRemoveAsync(UsersOnlineRedisKey, user); + _ = _redisSubscriber.PublishAsync(_redisChannel, "-" + user); + + return Task.CompletedTask; + } + + private async Task Notify(Func invocation) + { + foreach (var connection in connections.Keys) + { + using (var scope = _serviceScopeFactory.CreateScope()) + { + var hubActivator = scope.ServiceProvider.GetRequiredService>(); + var hub = hubActivator.Create(); + + hub.Clients = _hubContext.Clients; + hub.Context = new HubCallerContext(connection); + hub.Groups = new GroupManager(connection, _lifetimeManager); + + try + { + await invocation(hub); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Presence notification failed."); + } + finally + { + hubActivator.Release(hub); + } + } + } + } + + public void Dispose() + { + _redisSubscriber.Unsubscribe(_redisChannel); + _redisConnection.Close(); + _redisConnection.Dispose(); + } + + private class LoggerTextWriter : TextWriter + { + private readonly ILogger _logger; + + public LoggerTextWriter(ILogger logger) + { + _logger = logger; + } + + public override Encoding Encoding => Encoding.UTF8; + + public override void Write(char value) + { + } + + public override void WriteLine(string value) + { + _logger.LogDebug(value); + } + } + } +} diff --git a/samples/ChatSample/Startup.cs b/samples/ChatSample/Startup.cs index be33807081..3f2ec901a0 100644 --- a/samples/ChatSample/Startup.cs +++ b/samples/ChatSample/Startup.cs @@ -54,11 +54,15 @@ namespace ChatSample services.AddTransient(); services.AddTransient(); - services.AddSignalR(); - + // To use Redis scaleout uncomment .AddRedis and register RedisPresenceManager + // instead of DefaultPresenceManager + services.AddSignalR() + // .AddRedis() + ; services.AddAuthentication(); services.AddSingleton>(); + // services.AddSingleton>(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. diff --git a/samples/ChatSample/UserDetails.cs b/samples/ChatSample/UserDetails.cs new file mode 100644 index 0000000000..3f4a9cf1b5 --- /dev/null +++ b/samples/ChatSample/UserDetails.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace ChatSample +{ + public class UserDetails + { + public UserDetails(string connectionId, string name) + { + ConnectionId = connectionId; + Name = name; + } + + public string ConnectionId { get; } + public string Name { get; } + } +} diff --git a/samples/ChatSample/Views/Home/Index.cshtml b/samples/ChatSample/Views/Home/Index.cshtml index 6d267c5e47..aa2ce7c793 100644 --- a/samples/ChatSample/Views/Home/Index.cshtml +++ b/samples/ChatSample/Views/Home/Index.cshtml @@ -19,7 +19,7 @@ let transportType = signalR.TransportType[getParameterByName('transport')] || signalR.TransportType.WebSockets; let connection = new signalR.HubConnection(`http://${document.location.host}/chat`, 'formatType=json&format=text'); -connection.on('Send', message => appendLine(message)); +// connection.on('Send', message => appendLine(message)); connection.onClosed = e => { if (e) { appendLine('Connection closed with error: ' + e, 'red'); @@ -33,27 +33,17 @@ connection.on('SetUsersOnline', function (usersOnline) { usersOnline.forEach(user => addUserOnline(user)); }); -connection.on('OnConnected', function (id, userName) { - addUserOnline(id, userName); - appendLine('User ' + userName + ' joined the chat'); -}); - -connection.on('OnDisconnected', function (id, userName) { - appendLine('User ' + userName + ' left the chat'); - document.getElementById(id).outerHTML = ''; -} - -connection.on('UserJoined', function (user) { - appendLine('User ' + userName + ' joined the chat'); +connection.on('UserJoined', user => { + appendLine('User ' + user.Name + ' joined the chat'); addUserOnline(user); }); -connection.on('UserLeft', function (user) { - appendLine('User ' + userName + ' left the chat'); +connection.on('UserLeft', user => { + appendLine('User ' + user.Name + ' left the chat'); document.getElementById(user.ConnectionId).outerHTML = ''; }); -connection.on('Send', function (userName, message) { +connection.on('Send', (userName, message) => { var nameElement = document.createElement('b'); nameElement.innerText = userName + ':'; @@ -81,13 +71,13 @@ function appendLine(line, color) { } child.innerText = line; document.getElementById('messages').appendChild(child); -} +}; -function addUserOnline(id, userName) { - var user = document.createElement('li'); - user.innerText = userName; - user.id = id; - document.getElementById('users').appendChild(user); +function addUserOnline (user) { + var userLi = document.createElement('li'); + userLi.innerText = user.Name; + userLi.id = user.ConnectionId; + document.getElementById('users').appendChild(userLi); } function getParameterByName(name, url) { @@ -100,6 +90,6 @@ function getParameterByName(name, url) { if (!results) return null; if (!results[2]) return ''; return decodeURIComponent(results[2].replace(/\+/g, " ")); -}); +}; diff --git a/src/Microsoft.AspNetCore.SignalR.Redis/RedisDependencyInjectionExtensions.cs b/src/Microsoft.AspNetCore.SignalR.Redis/RedisDependencyInjectionExtensions.cs index 3a96cec14d..b52330a5cb 100644 --- a/src/Microsoft.AspNetCore.SignalR.Redis/RedisDependencyInjectionExtensions.cs +++ b/src/Microsoft.AspNetCore.SignalR.Redis/RedisDependencyInjectionExtensions.cs @@ -2,9 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR.Redis; diff --git a/src/Microsoft.AspNetCore.SignalR/SignalRAppBuilderExtensions.cs b/src/Microsoft.AspNetCore.SignalR/SignalRAppBuilderExtensions.cs index 5c8daf39b2..1ded6ec809 100644 --- a/src/Microsoft.AspNetCore.SignalR/SignalRAppBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.SignalR/SignalRAppBuilderExtensions.cs @@ -2,9 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; namespace Microsoft.AspNetCore.Builder @@ -36,10 +33,5 @@ namespace Microsoft.AspNetCore.Builder { _routes.MapEndpoint>(path); } - - public void MapHub(string path) where THub : Hub - { - _routes.MapEndpoint>(path); - } } } diff --git a/src/Microsoft.AspNetCore.SignalR/SignalRDependencyInjectionExtensions.cs b/src/Microsoft.AspNetCore.SignalR/SignalRDependencyInjectionExtensions.cs index cab6726734..bceb79c04f 100644 --- a/src/Microsoft.AspNetCore.SignalR/SignalRDependencyInjectionExtensions.cs +++ b/src/Microsoft.AspNetCore.SignalR/SignalRDependencyInjectionExtensions.cs @@ -14,8 +14,6 @@ namespace Microsoft.Extensions.DependencyInjection services.AddSockets(); services.AddSingleton(typeof(HubLifetimeManager<>), typeof(DefaultHubLifetimeManager<>)); services.AddSingleton(typeof(IHubContext<>), typeof(HubContext<>)); - // TODO: this breaks because of hardcoded IClientProxy - // services.AddSingleton(typeof(IHubContext<,>), typeof(HubContext<,>)); services.AddSingleton(typeof(HubEndPoint<>), typeof(HubEndPoint<>)); services.AddSingleton, SignalROptionsSetup>(); services.AddSingleton();