Skip to content

Commit d434b1c

Browse files
authored
Observe more mutations to configuration and services (#34615)
* Observe more mutations to configuration and services - When using WebApplicationFactory with the WebApplicationFactory, mutations are made very late that aren't observable via the WebApplicationBuilder.Configuration nor WebApplicationBuilder.Services. The configuration tries to be the source of truth but more sources are added it can't see, the same with the service collection (though that is more rare). To fix this, the strategy is to use the initial service collection and configuration as data that will feed into the final IConfigurationBuilder and IServiceCollection, and then "update" these properties to point back to the final configuration and service collection. Results in the properties on the builder being consistent (from a data POV) with the built application. - Added tests that mimic what the WebApplicationFactory does but using the diagnostic source to capture the right events for the right host and mutate the IHostBuilder.
1 parent 37764a7 commit d434b1c

File tree

3 files changed

+262
-28
lines changed

3 files changed

+262
-28
lines changed

src/DefaultBuilder/src/WebApplicationBuilder.cs

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ public sealed class WebApplicationBuilder
2323
private readonly ConfigureHostBuilder _deferredHostBuilder;
2424
private readonly ConfigureWebHostBuilder _deferredWebHostBuilder;
2525
private readonly WebHostEnvironment _environment;
26+
private readonly WebApplicationServiceCollection _services = new();
2627
private WebApplication? _builtApplication;
2728

2829
internal WebApplicationBuilder(Assembly? callingAssembly, string[]? args = null)
2930
{
31+
Services = _services;
3032
// HACK: MVC and Identity do this horrible thing to get the hosting environment as an instance
3133
// from the service collection before it is built. That needs to be fixed...
3234
Environment = _environment = new WebHostEnvironment(callingAssembly);
@@ -46,9 +48,6 @@ internal WebApplicationBuilder(Assembly? callingAssembly, string[]? args = null)
4648
WebHost = _deferredWebHostBuilder = new ConfigureWebHostBuilder(Configuration, _environment, Services);
4749
Host = _deferredHostBuilder = new ConfigureHostBuilder(Configuration, _environment, Services);
4850

49-
// Register Configuration as IConfiguration so updates can be observed even after the WebApplication is built.
50-
Services.AddSingleton<IConfiguration>(Configuration);
51-
5251
// Add default services
5352
_deferredHostBuilder.ConfigureDefaults(args);
5453

@@ -73,7 +72,7 @@ internal WebApplicationBuilder(Assembly? callingAssembly, string[]? args = null)
7372
/// <summary>
7473
/// A collection of services for the application to compose. This is useful for adding user provided or framework provided services.
7574
/// </summary>
76-
public IServiceCollection Services { get; } = new ServiceCollection();
75+
public IServiceCollection Services { get; }
7776

7877
/// <summary>
7978
/// A collection of configuration providers for the application to compose. This is useful for adding new configuration sources and providers.
@@ -103,11 +102,33 @@ internal WebApplicationBuilder(Assembly? callingAssembly, string[]? args = null)
103102
/// <returns>A configured <see cref="WebApplication"/>.</returns>
104103
public WebApplication Build()
105104
{
105+
// Copy the configuration sources into the final IConfigurationBuilder
106+
_hostBuilder.ConfigureHostConfiguration(builder =>
107+
{
108+
foreach (var source in ((IConfigurationBuilder)Configuration).Sources)
109+
{
110+
builder.Sources.Add(source);
111+
}
112+
113+
foreach (var (key, value) in ((IConfigurationBuilder)Configuration).Properties)
114+
{
115+
builder.Properties[key] = value;
116+
}
117+
});
118+
106119
// We call ConfigureWebHostDefaults AGAIN because config might be added like "ForwardedHeaders_Enabled"
107120
// which can add even more services. If not for that, we probably call _hostBuilder.ConfigureWebHost(ConfigureWebHost)
108121
// instead in order to avoid duplicate service registration.
109122
_hostBuilder.ConfigureWebHostDefaults(ConfigureWebHost);
110-
return _builtApplication = new WebApplication(_hostBuilder.Build());
123+
124+
_builtApplication = new WebApplication(_hostBuilder.Build());
125+
126+
// Make builder.Configuration match the final configuration. To do that
127+
// we clear the sources and add the built configuration as a source
128+
((IConfigurationBuilder)Configuration).Sources.Clear();
129+
Configuration.AddConfiguration(_builtApplication.Configuration);
130+
131+
return _builtApplication;
111132
}
112133

113134
private void ConfigureApplication(WebHostBuilderContext context, IApplicationBuilder app)
@@ -183,20 +204,10 @@ private void ConfigureApplication(WebHostBuilderContext context, IApplicationBui
183204

184205
private void ConfigureWebHost(IWebHostBuilder genericWebHostBuilder)
185206
{
186-
_hostBuilder.ConfigureHostConfiguration(builder =>
187-
{
188-
// All the sources in builder.Sources should be in Configuration.Sources
189-
// already thanks to the BootstrapHostBuilder.
190-
builder.Sources.Clear();
191-
192-
foreach (var (key, value) in ((IConfigurationBuilder)Configuration).Properties)
193-
{
194-
builder.Properties[key] = value;
195-
}
196-
197-
builder.AddConfiguration(Configuration, shouldDisposeConfiguration: true);
198-
});
207+
genericWebHostBuilder.Configure(ConfigureApplication);
199208

209+
// This needs to go here to avoid adding the IHostedService that boots the server twice (the GenericWebHostService).
210+
// Copy the services that were added via WebApplicationBuilder.Services into the final IServiceCollection
200211
genericWebHostBuilder.ConfigureServices((context, services) =>
201212
{
202213
// We've only added services configured by the GenericWebHostBuilder and WebHost.ConfigureWebDefaults
@@ -209,24 +220,21 @@ private void ConfigureWebHost(IWebHostBuilder genericWebHostBuilder)
209220
// but we want to add services in the WebApplicationBuilder constructor so code can inspect
210221
// WebApplicationBuilder.Services. At the same time, we want to be able which services are loaded
211222
// to react to config changes (e.g. ForwardedHeadersStartupFilter).
212-
foreach (var s in Services)
223+
foreach (var s in _services)
213224
{
214225
services.Add(s);
215226
}
216227

217228
// Add any services to the user visible service collection so that they are observable
218229
// just in case users capture the Services property. Orchard does this to get a "blueprint"
219-
// of the service collection. The order needs to be preserved here so we clear the original
220-
// collection and add all of the services in order.
221-
Services.Clear();
222-
foreach (var s in services)
223-
{
224-
Services.Add(s);
225-
}
226-
});
230+
// of the service collection
227231

228-
genericWebHostBuilder.Configure(ConfigureApplication);
232+
// Drop the reference to the existing collection and set the inner collection
233+
// to the new one. This allows code that has references to the service collection to still function.
234+
_services.InnerCollection = services;
235+
});
229236

237+
// Run the other callbacks on the final host builder
230238
_deferredHostBuilder.RunDeferredCallbacks(_hostBuilder);
231239

232240
_environment.ApplyEnvironmentSettings(genericWebHostBuilder);
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections;
6+
using System.Collections.Generic;
7+
using System.Linq;
8+
using System.Text;
9+
using System.Threading.Tasks;
10+
using Microsoft.Extensions.DependencyInjection;
11+
12+
namespace Microsoft.AspNetCore
13+
{
14+
internal sealed class WebApplicationServiceCollection : IServiceCollection
15+
{
16+
private IServiceCollection _services = new ServiceCollection();
17+
18+
public ServiceDescriptor this[int index] { get => _services[index]; set => _services[index] = value; }
19+
20+
public int Count => _services.Count;
21+
22+
public bool IsReadOnly => _services.IsReadOnly;
23+
24+
public IServiceCollection InnerCollection { get => _services; set => _services = value; }
25+
26+
public void Add(ServiceDescriptor item)
27+
{
28+
_services.Add(item);
29+
}
30+
31+
public void Clear()
32+
{
33+
_services.Clear();
34+
}
35+
36+
public bool Contains(ServiceDescriptor item)
37+
{
38+
return _services.Contains(item);
39+
}
40+
41+
public void CopyTo(ServiceDescriptor[] array, int arrayIndex)
42+
{
43+
_services.CopyTo(array, arrayIndex);
44+
}
45+
46+
public IEnumerator<ServiceDescriptor> GetEnumerator()
47+
{
48+
return _services.GetEnumerator();
49+
}
50+
51+
public int IndexOf(ServiceDescriptor item)
52+
{
53+
return _services.IndexOf(item);
54+
}
55+
56+
public void Insert(int index, ServiceDescriptor item)
57+
{
58+
_services.Insert(index, item);
59+
}
60+
61+
public bool Remove(ServiceDescriptor item)
62+
{
63+
return _services.Remove(item);
64+
}
65+
66+
public void RemoveAt(int index)
67+
{
68+
_services.RemoveAt(index);
69+
}
70+
71+
IEnumerator IEnumerable.GetEnumerator()
72+
{
73+
return GetEnumerator();
74+
}
75+
}
76+
}

src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/WebApplicationTests.cs

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Collections.Concurrent;
5+
using System.Collections.Generic;
6+
using System.Diagnostics;
57
using System.Diagnostics.Tracing;
68
using Microsoft.AspNetCore.Builder;
79
using Microsoft.AspNetCore.HostFiltering;
@@ -210,6 +212,64 @@ public void WebApplicationBuilderWebHostUseSettingCanBeReadByConfiguration()
210212
Assert.Equal("another", builder.Configuration["B"]);
211213
}
212214

215+
[Fact]
216+
public async Task WebApplicationCanObserveConfigurationChangesMadeInBuild()
217+
{
218+
// This mimics what WebApplicationFactory<T> does and runs configure
219+
// services callbacks
220+
using var listener = new HostingListener(hostBuilder =>
221+
{
222+
hostBuilder.ConfigureHostConfiguration(config =>
223+
{
224+
config.AddInMemoryCollection(new Dictionary<string, string>()
225+
{
226+
{ "A", "A" },
227+
{ "B", "B" },
228+
});
229+
});
230+
231+
hostBuilder.ConfigureAppConfiguration(config =>
232+
{
233+
config.AddInMemoryCollection(new Dictionary<string, string>()
234+
{
235+
{ "C", "C" },
236+
{ "D", "D" },
237+
});
238+
});
239+
240+
hostBuilder.ConfigureWebHost(builder =>
241+
{
242+
builder.UseSetting("E", "E");
243+
244+
builder.ConfigureAppConfiguration(config =>
245+
{
246+
config.AddInMemoryCollection(new Dictionary<string, string>()
247+
{
248+
{ "F", "F" },
249+
});
250+
});
251+
});
252+
});
253+
254+
var builder = WebApplication.CreateBuilder();
255+
256+
await using var app = builder.Build();
257+
258+
Assert.Equal("A", app.Configuration["A"]);
259+
Assert.Equal("B", app.Configuration["B"]);
260+
Assert.Equal("C", app.Configuration["C"]);
261+
Assert.Equal("D", app.Configuration["D"]);
262+
Assert.Equal("E", app.Configuration["E"]);
263+
Assert.Equal("F", app.Configuration["F"]);
264+
265+
Assert.Equal("A", builder.Configuration["A"]);
266+
Assert.Equal("B", builder.Configuration["B"]);
267+
Assert.Equal("C", builder.Configuration["C"]);
268+
Assert.Equal("D", builder.Configuration["D"]);
269+
Assert.Equal("E", builder.Configuration["E"]);
270+
Assert.Equal("F", builder.Configuration["F"]);
271+
}
272+
213273
[Fact]
214274
public void WebApplicationBuilderHostProperties_IsCaseSensitive()
215275
{
@@ -300,6 +360,34 @@ public void WebApplication_CanResolveDefaultServicesFromServiceCollection()
300360
Assert.Equal(env0.ContentRootPath, env1.ContentRootPath);
301361
}
302362

363+
[Fact]
364+
public async Task WebApplication_CanResolveServicesAddedAfterBuildFromServiceCollection()
365+
{
366+
// This mimics what WebApplicationFactory<T> does and runs configure
367+
// services callbacks
368+
using var listener = new HostingListener(hostBuilder =>
369+
{
370+
hostBuilder.ConfigureServices(services =>
371+
{
372+
services.AddSingleton<IService, Service>();
373+
});
374+
});
375+
376+
var builder = WebApplication.CreateBuilder();
377+
378+
// Add the service collection to the service collection
379+
builder.Services.AddSingleton(builder.Services);
380+
381+
await using var app = builder.Build();
382+
383+
var service0 = app.Services.GetRequiredService<IService>();
384+
385+
var service1 = app.Services.GetRequiredService<IServiceCollection>().BuildServiceProvider().GetRequiredService<IService>();
386+
387+
Assert.IsType<Service>(service0);
388+
Assert.IsType<Service>(service1);
389+
}
390+
303391
[Fact]
304392
public void WebApplication_CanResolveDefaultServicesFromServiceCollectionInCorrectOrder()
305393
{
@@ -445,6 +533,68 @@ public async Task WebApplicationBuilder_StartupFilterCanAddTerminalMiddleware()
445533
Assert.Equal(418, (int)terminalResult.StatusCode);
446534
}
447535

536+
private class Service : IService { }
537+
private interface IService { }
538+
539+
private sealed class HostingListener : IObserver<DiagnosticListener>, IObserver<KeyValuePair<string, object>>, IDisposable
540+
{
541+
private readonly Action<IHostBuilder> _configure;
542+
private static readonly AsyncLocal<HostingListener> _currentListener = new();
543+
private readonly IDisposable _subscription0;
544+
private IDisposable _subscription1;
545+
546+
public HostingListener(Action<IHostBuilder> configure)
547+
{
548+
_configure = configure;
549+
550+
_subscription0 = DiagnosticListener.AllListeners.Subscribe(this);
551+
552+
_currentListener.Value = this;
553+
}
554+
555+
public void OnCompleted()
556+
{
557+
558+
}
559+
560+
public void OnError(Exception error)
561+
{
562+
563+
}
564+
565+
public void OnNext(DiagnosticListener value)
566+
{
567+
if (_currentListener.Value != this)
568+
{
569+
// Ignore events that aren't for this listener
570+
return;
571+
}
572+
573+
if (value.Name == "Microsoft.Extensions.Hosting")
574+
{
575+
_subscription1 = value.Subscribe(this);
576+
}
577+
}
578+
579+
public void OnNext(KeyValuePair<string, object> value)
580+
{
581+
if (value.Key == "HostBuilding")
582+
{
583+
_configure?.Invoke((IHostBuilder)value.Value);
584+
}
585+
}
586+
587+
public void Dispose()
588+
{
589+
// Undo this here just in case the code unwinds synchronously since that doesn't revert
590+
// the execution context to the original state. Only async methods do that on exit.
591+
_currentListener.Value = null;
592+
593+
_subscription0.Dispose();
594+
_subscription1?.Dispose();
595+
}
596+
}
597+
448598
private class CustomHostLifetime : IHostLifetime
449599
{
450600
public Task StopAsync(CancellationToken cancellationToken)

0 commit comments

Comments
 (0)