Skip to content

Commit a952d17

Browse files
author
Paul Johnson
authored
Make using scoped services in notification handlers less painful. (#11733)
* Add failing test to demo issue with handlers + scoped dependencies. * Make using scoped services in notification handlers less painful. * Update comments for accuracy.
1 parent 187cac4 commit a952d17

File tree

4 files changed

+143
-6
lines changed

4 files changed

+143
-6
lines changed

src/Umbraco.Core/Events/EventAggregator.Notifications.cs

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
using System.Linq;
88
using System.Threading;
99
using System.Threading.Tasks;
10+
using Microsoft.Extensions.DependencyInjection;
11+
using Umbraco.Cms.Core.DependencyInjection;
1012
using Umbraco.Cms.Core.Notifications;
1113

1214
namespace Umbraco.Cms.Core.Events
@@ -84,17 +86,56 @@ public abstract Task HandleAsync(
8486
internal class NotificationAsyncHandlerWrapperImpl<TNotification> : NotificationAsyncHandlerWrapper
8587
where TNotification : INotification
8688
{
89+
/// <remarks>
90+
/// <para>
91+
/// Background - During v9 build we wanted an in-process message bus to facilitate removal of the old static event handlers. <br/>
92+
/// Instead of taking a dependency on MediatR we (the community) implemented our own using MediatR as inspiration.
93+
/// </para>
94+
///
95+
/// <para>
96+
/// Some things worth knowing about MediatR.
97+
/// <list type="number">
98+
/// <item>All handlers are by default registered with transient lifetime, but can easily depend on services with state.</item>
99+
/// <item>Both the Mediatr instance and its handler resolver are registered transient and as such it is always possible to depend on scoped services in a handler.</item>
100+
/// </list>
101+
/// </para>
102+
///
103+
/// <para>
104+
/// Our EventAggregator started out registered with a transient lifetime but later (before initial release) the registration was changed to singleton, presumably
105+
/// because there are a lot of singleton services in Umbraco which like to publish notifications and it's a pain to use scoped services from a singleton.
106+
/// <br/>
107+
/// The problem with a singleton EventAggregator is it forces handlers to create a service scope and service locate any scoped services
108+
/// they wish to make use of e.g. a unit of work (think entity framework DBContext).
109+
/// </para>
110+
///
111+
/// <para>
112+
/// Moving forwards it probably makes more sense to register EventAggregator transient but doing so now would mean an awful lot of service location to avoid breaking changes.
113+
/// <br/>
114+
/// For now we can do the next best thing which is to create a scope for each published notification, thus enabling the transient handlers to take a dependency on a scoped service.
115+
/// </para>
116+
///
117+
/// <para>
118+
/// Did discuss using HttpContextAccessor/IScopedServiceProvider to enable sharing of scopes when publisher has http context,
119+
/// but decided against because it's inconsistent with what happens in background threads and will just cause confusion.
120+
/// </para>
121+
/// </remarks>
87122
public override Task HandleAsync(
88123
INotification notification,
89124
CancellationToken cancellationToken,
90125
ServiceFactory serviceFactory,
91126
Func<IEnumerable<Func<INotification, CancellationToken, Task>>, INotification, CancellationToken, Task> publish)
92127
{
93-
IEnumerable<Func<INotification, CancellationToken, Task>> handlers = serviceFactory
94-
.GetInstances<INotificationAsyncHandler<TNotification>>()
128+
// Create a new service scope from which to resolve handlers and ensure it's disposed when it goes out of scope.
129+
// TODO: go back to using ServiceFactory to resolve
130+
IServiceScopeFactory scopeFactory = serviceFactory.GetInstance<IServiceScopeFactory>();
131+
using IServiceScope scope = scopeFactory.CreateScope();
132+
IServiceProvider container = scope.ServiceProvider;
133+
134+
IEnumerable<Func<INotification, CancellationToken, Task>> handlers = container
135+
.GetServices<INotificationAsyncHandler<TNotification>>()
95136
.Select(x => new Func<INotification, CancellationToken, Task>(
96137
(theNotification, theToken) =>
97-
x.HandleAsync((TNotification)theNotification, theToken)));
138+
x.HandleAsync((TNotification)theNotification, theToken)));
98139

99140
return publish(handlers, notification, cancellationToken);
100141
}
@@ -103,13 +144,23 @@ public override Task HandleAsync(
103144
internal class NotificationHandlerWrapperImpl<TNotification> : NotificationHandlerWrapper
104145
where TNotification : INotification
105146
{
147+
/// <remarks>
148+
/// See remarks on <see cref="NotificationAsyncHandlerWrapperImpl{T}.HandleAsync"/> for explanation on
149+
/// what's going on with the IServiceProvider stuff here.
150+
/// </remarks>
106151
public override void Handle(
107152
INotification notification,
108153
ServiceFactory serviceFactory,
109154
Action<IEnumerable<Action<INotification>>, INotification> publish)
110155
{
111-
IEnumerable<Action<INotification>> handlers = serviceFactory
112-
.GetInstances<INotificationHandler<TNotification>>()
156+
// Create a new service scope from which to resolve handlers and ensure it's disposed when it goes out of scope.
157+
// TODO: go back to using ServiceFactory to resolve
158+
IServiceScopeFactory scopeFactory = serviceFactory.GetInstance<IServiceScopeFactory>();
159+
using IServiceScope scope = scopeFactory.CreateScope();
160+
IServiceProvider container = scope.ServiceProvider;
161+
162+
IEnumerable<Action<INotification>> handlers = container
163+
.GetServices<INotificationHandler<TNotification>>()
113164
.Select(x => new Action<INotification>(
114165
(theNotification) =>
115166
x.Handle((TNotification)theNotification)));

tests/Umbraco.Tests.Integration/Implementations/TestHelper.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ public TestHelper()
6060
_ipResolver = new AspNetCoreIpResolver(_httpContextAccessor);
6161

6262
string contentRoot = Assembly.GetExecutingAssembly().GetRootDirectorySafe();
63+
64+
// The mock for IWebHostEnvironment has caused a good few issues.
65+
// We can UseContentRoot, UseWebRoot etc on the host builder instead.
66+
// possibly going down rabbit holes though as would need to cleanup all usages of
67+
// GetHostingEnvironment & GetWebHostEnvironment.
6368
var hostEnvironment = new Mock<IWebHostEnvironment>();
6469

6570
// This must be the assembly name for the WebApplicationFactory to work.
@@ -68,6 +73,7 @@ public TestHelper()
6873
hostEnvironment.Setup(x => x.ContentRootFileProvider).Returns(() => new PhysicalFileProvider(contentRoot));
6974
hostEnvironment.Setup(x => x.WebRootPath).Returns(() => WorkingDirectory);
7075
hostEnvironment.Setup(x => x.WebRootFileProvider).Returns(() => new PhysicalFileProvider(WorkingDirectory));
76+
hostEnvironment.Setup(x => x.EnvironmentName).Returns("Tests");
7177

7278
// We also need to expose it as the obsolete interface since netcore's WebApplicationFactory casts it.
7379
hostEnvironment.As<Microsoft.AspNetCore.Hosting.IHostingEnvironment>();

tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,16 @@ public override IHostBuilder CreateHostBuilder()
8787

8888
// call startup
8989
builder.Configure(app => Configure(app));
90-
}).UseEnvironment(Environments.Development);
90+
})
91+
.UseDefaultServiceProvider(cfg =>
92+
{
93+
// These default to true *if* WebHostEnvironment.EnvironmentName == Development
94+
// When running tests, EnvironmentName used to be null on the mock that we register into services.
95+
// Enable opt in for tests so that validation occurs regardless of environment name.
96+
// Would be nice to have this on for UmbracoIntegrationTest also but requires a lot more effort to resolve issues.
97+
cfg.ValidateOnBuild = true;
98+
cfg.ValidateScopes = true;
99+
});
91100

92101
return builder;
93102
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using System;
2+
using System.Net;
3+
using System.Net.Http;
4+
using System.Threading.Tasks;
5+
using Microsoft.AspNetCore.Mvc;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using NUnit.Framework;
8+
using Umbraco.Cms.Core.Events;
9+
using Umbraco.Cms.Core.Notifications;
10+
using Umbraco.Cms.Tests.Integration.TestServerTest;
11+
12+
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Events
13+
{
14+
[TestFixture]
15+
public class EventAggregatorTests : UmbracoTestServerTestBase
16+
{
17+
public override void ConfigureServices(IServiceCollection services)
18+
{
19+
base.ConfigureServices(services);
20+
services.AddScoped<EventAggregatorTestScopedService>();
21+
services.AddTransient<INotificationHandler<EventAggregatorTestNotification>, EventAggregatorTestNotificationHandler>();
22+
}
23+
24+
[Test]
25+
public async Task Publish_HandlerWithScopedDependency_DoesNotThrow()
26+
{
27+
HttpResponseMessage result = await Client.GetAsync("/test-handler-with-scoped-services");
28+
Assert.AreEqual(HttpStatusCode.OK, result.StatusCode);
29+
}
30+
}
31+
32+
public class EventAggregatorTestsController : Controller
33+
{
34+
private readonly IEventAggregator _eventAggregator;
35+
36+
public EventAggregatorTestsController(IEventAggregator eventAggregator) => _eventAggregator = eventAggregator;
37+
38+
[HttpGet("test-handler-with-scoped-services")]
39+
public async Task<IActionResult> Test()
40+
{
41+
var notification = new EventAggregatorTestNotification();
42+
await _eventAggregator.PublishAsync(notification);
43+
44+
if (!notification.Mutated)
45+
{
46+
throw new ApplicationException("Doesn't work");
47+
}
48+
49+
return Ok();
50+
}
51+
}
52+
53+
public class EventAggregatorTestScopedService
54+
{
55+
}
56+
57+
public class EventAggregatorTestNotification : INotification
58+
{
59+
public bool Mutated { get; set; }
60+
}
61+
62+
public class EventAggregatorTestNotificationHandler : INotificationHandler<EventAggregatorTestNotification>
63+
{
64+
private readonly EventAggregatorTestScopedService _scopedService;
65+
66+
public EventAggregatorTestNotificationHandler(EventAggregatorTestScopedService scopedService) => _scopedService = scopedService;
67+
68+
// Mutation proves that the handler runs despite depending on scoped service.
69+
public void Handle(EventAggregatorTestNotification notification) => notification.Mutated = true;
70+
}
71+
}

0 commit comments

Comments
 (0)