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();