Skip to content

Commit

Permalink
Move startup validation to the options assembly (#88546)
Browse files Browse the repository at this point in the history
  • Loading branch information
steveharter committed Jul 12, 2023
1 parent 1b2664d commit 693eef6
Show file tree
Hide file tree
Showing 13 changed files with 225 additions and 107 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,8 @@
// Changes to this file must follow the https://aka.ms/api-review process.
// ------------------------------------------------------------------------------

namespace Microsoft.Extensions.DependencyInjection
{
public static partial class OptionsBuilderExtensions
{
public static Microsoft.Extensions.Options.OptionsBuilder<TOptions> ValidateOnStart<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions>(this Microsoft.Extensions.Options.OptionsBuilder<TOptions> optionsBuilder) where TOptions : class { throw null; }
}
}
[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(Microsoft.Extensions.DependencyInjection.OptionsBuilderExtensions))]

namespace Microsoft.Extensions.Hosting
{
public enum BackgroundServiceExceptionBehavior
Expand Down
68 changes: 48 additions & 20 deletions src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ public Host(IServiceProvider services,

/// <summary>
/// Order:
/// IHostLifetime.WaitForStartAsync
/// IHostLifetime.WaitForStartAsync (can abort chain)
/// Services.GetService{IStartupValidator}().Validate() (can abort chain)
/// IHostedLifecycleService.StartingAsync
/// IHostedService.Start
/// IHostedLifecycleService.StartedAsync
Expand Down Expand Up @@ -99,14 +100,34 @@ public async Task StartAsync(CancellationToken cancellationToken = default)
bool concurrent = _options.ServicesStartConcurrently;
bool abortOnFirstException = !concurrent;

// Call startup validators.
IStartupValidator? validator = Services.GetService<IStartupValidator>();
if (validator is not null)
{
try
{
validator.Validate();
}
catch (Exception ex)
{
exceptions.Add(ex);

// Validation errors cause startup to be aborted.
LogAndRethrow();
}
}

// Call StartingAsync().
if (_hostedLifecycleServices is not null)
{
// Call StartingAsync().
await ForeachService(_hostedLifecycleServices, token, concurrent, abortOnFirstException, exceptions,
(service, token) => service.StartingAsync(token)).ConfigureAwait(false);

// We do not abort on exceptions from StartingAsync.
}

// Call StartAsync().
// We do not abort on exceptions from StartAsync.
await ForeachService(_hostedServices, token, concurrent, abortOnFirstException, exceptions,
async (service, token) =>
{
Expand All @@ -118,33 +139,40 @@ await ForeachService(_hostedServices, token, concurrent, abortOnFirstException,
}
}).ConfigureAwait(false);

// Call StartedAsync().
// We do not abort on exceptions from StartedAsync.
if (_hostedLifecycleServices is not null)
{
// Call StartedAsync().
await ForeachService(_hostedLifecycleServices, token, concurrent, abortOnFirstException, exceptions,
(service, token) => service.StartedAsync(token)).ConfigureAwait(false);
}

if (exceptions.Count > 0)
{
if (exceptions.Count == 1)
{
// Rethrow if it's a single error
Exception singleException = exceptions[0];
_logger.HostedServiceStartupFaulted(singleException);
ExceptionDispatchInfo.Capture(singleException).Throw();
}
else
{
var ex = new AggregateException("One or more hosted services failed to start.", exceptions);
_logger.HostedServiceStartupFaulted(ex);
throw ex;
}
}
LogAndRethrow();

// Call IHostApplicationLifetime.Started
// This catches all exceptions and does not re-throw.
_applicationLifetime.NotifyStarted();

// Log and abort if there are exceptions.
void LogAndRethrow()
{
if (exceptions.Count > 0)
{
if (exceptions.Count == 1)
{
// Rethrow if it's a single error
Exception singleException = exceptions[0];
_logger.HostedServiceStartupFaulted(singleException);
ExceptionDispatchInfo.Capture(singleException).Throw();
}
else
{
var ex = new AggregateException("One or more hosted services failed to start.", exceptions);
_logger.HostedServiceStartupFaulted(ex);
throw ex;
}
}
}
}

_logger.Started();
Expand Down Expand Up @@ -244,9 +272,9 @@ await ForeachService(reversedLifetimeServices, token, concurrent, abortOnFirstEx
await ForeachService(reversedServices, token, concurrent, abortOnFirstException: false, exceptions, (service, token) =>
service.StopAsync(token)).ConfigureAwait(false);

// Call StoppedAsync().
if (reversedLifetimeServices is not null)
{
// Call StoppedAsync().
await ForeachService(reversedLifetimeServices, token, concurrent, abortOnFirstException: false, exceptions, (service, token) =>
service.StoppedAsync(token)).ConfigureAwait(false);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(Microsoft.Extensions.DependencyInjection.OptionsBuilderExtensions))]

This file was deleted.

14 changes: 0 additions & 14 deletions src/libraries/Microsoft.Extensions.Hosting/src/ValidatorOptions.cs

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;

namespace Microsoft.Extensions.Hosting.Tests
Expand Down Expand Up @@ -334,5 +335,24 @@ public async Task StartPhasesException(bool throwAfterAsyncCall)
Assert.Contains("(ThrowOnStarted)", ex.InnerExceptions[2].Message);
}
}

[Fact]
public async Task ValidateOnStartAbortsChain()
{
ExceptionImpl impl = new(throwAfterAsyncCall: true, throwOnStartup: true, throwOnShutdown: false);
var hostBuilder = CreateHostBuilder(services =>
{
services.AddHostedService((token) => impl)
.AddOptions<ComplexOptions>()
.Validate(o => o.Boolean)
.ValidateOnStart();
});

using (IHost host = hostBuilder.Build())
{
await Assert.ThrowsAnyAsync<OptionsValidationException>(async () => await host.StartAsync());
Assert.False(impl.StartingCalled);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

namespace Microsoft.Extensions.DependencyInjection
{
public static partial class OptionsBuilderExtensions
{
public static Microsoft.Extensions.Options.OptionsBuilder<TOptions> ValidateOnStart<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions>(this Microsoft.Extensions.Options.OptionsBuilder<TOptions> optionsBuilder) where TOptions : class { throw null; }
}
public static partial class OptionsServiceCollectionExtensions
{
public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddOptions(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { throw null; }
Expand Down Expand Up @@ -135,6 +139,10 @@ public partial interface IPostConfigureOptions<in TOptions> where TOptions : cla
{
void PostConfigure(string? name, TOptions options);
}
public partial interface IStartupValidator
{
public void Validate();
}
public partial interface IValidateOptions<TOptions> where TOptions : class
{
Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options);
Expand Down Expand Up @@ -318,12 +326,12 @@ public ValidateOptionsResult() { }
public class ValidateOptionsResultBuilder
{
public ValidateOptionsResultBuilder() { }
public void AddError(string error, string? propertyName = null) { throw null; }
public void AddResult(System.ComponentModel.DataAnnotations.ValidationResult? result) { throw null; }
public void AddResults(System.Collections.Generic.IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult?>? results) { throw null; }
public void AddResult(ValidateOptionsResult result) { throw null; }
public ValidateOptionsResult Build() { throw null; }
public void Clear() { throw null; }
public void AddError(string error, string? propertyName = null) { }
public void AddResult(Microsoft.Extensions.Options.ValidateOptionsResult result) { }
public void AddResult(System.ComponentModel.DataAnnotations.ValidationResult? result) { }
public void AddResults(System.Collections.Generic.IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult?>? results) { }
public Microsoft.Extensions.Options.ValidateOptionsResult Build() { throw null; }
public void Clear() { }
}
public partial class ValidateOptions<TOptions> : Microsoft.Extensions.Options.IValidateOptions<TOptions> where TOptions : class
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.Extensions.Options
{
/// <summary>
/// Interface used by hosts to validate options during startup.
/// Options are enabled to be validated during startup by calling <see cref="DependencyInjection.OptionsBuilderExtensions.ValidateOnStart{TOptions}(OptionsBuilder{TOptions})"/>.
/// </summary>
public interface IStartupValidator
{
/// <summary>
/// Calls the <see cref="IValidateOptions{TOptions}"/> validators.
/// </summary>
/// <exception cref="OptionsValidationException">One or more <see cref="IValidateOptions{TOptions}"/> return failed <see cref="ValidateOptionsResult"/> when validating.</exception>
void Validate();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETFramework'">
<Reference Include="System.ComponentModel.DataAnnotations" />
<PackageReference Include="System.ValueTuple" Version="$(SystemValueTupleVersion)" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ public static class OptionsBuilderExtensions
{
ThrowHelper.ThrowIfNull(optionsBuilder);

optionsBuilder.Services.AddHostedService<ValidationHostedService>();
optionsBuilder.Services.AddOptions<ValidatorOptions>()
optionsBuilder.Services.AddTransient<IStartupValidator, StartupValidator>();
optionsBuilder.Services.AddOptions<StartupValidatorOptions>()
.Configure<IOptionsMonitor<TOptions>>((vo, options) =>
{
// This adds an action that resolves the options value to force evaluation
// We don't care about the result as duplicates are not important
vo.Validators[(typeof(TOptions), optionsBuilder.Name)] = () => options.Get(optionsBuilder.Name);
vo._validators[(typeof(TOptions), optionsBuilder.Name)] = () => options.Get(optionsBuilder.Name);
});

return optionsBuilder;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;

namespace Microsoft.Extensions.Options
{
internal sealed class StartupValidatorOptions
{
// Maps each pair of a) options type and b) options name to a method that forces its evaluation, e.g. IOptionsMonitor<TOptions>.Get(name)
public Dictionary<(Type, string), Action> _validators { get; } = new Dictionary<(Type, string), Action>();
}
}
54 changes: 54 additions & 0 deletions src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Runtime.ExceptionServices;
using Microsoft.Extensions.Options;

namespace Microsoft.Extensions.Options
{
internal sealed class StartupValidator : IStartupValidator
{
private readonly StartupValidatorOptions _validatorOptions;

public StartupValidator(IOptions<StartupValidatorOptions> validators)
{
_validatorOptions = validators.Value;
}

public void Validate()
{
List<Exception>? exceptions = null;

foreach (Action validator in _validatorOptions._validators.Values)
{
try
{
// Execute the validation method and catch the validation error
validator();
}
catch (OptionsValidationException ex)
{
exceptions ??= new();
exceptions.Add(ex);
}
}

if (exceptions != null)
{
if (exceptions.Count == 1)
{
// Rethrow if it's a single error
ExceptionDispatchInfo.Capture(exceptions[0]).Throw();
}

if (exceptions.Count > 1)
{
// Aggregate if we have many errors
throw new AggregateException(exceptions);
}
}
}
}
}
Loading

0 comments on commit 693eef6

Please sign in to comment.