diff --git a/src/NServiceBus.AcceptanceTests/Core/Installers/InstallationOnlyComponent.cs b/src/NServiceBus.AcceptanceTests/Core/Installers/InstallationOnlyComponent.cs deleted file mode 100644 index 3530475c48..0000000000 --- a/src/NServiceBus.AcceptanceTests/Core/Installers/InstallationOnlyComponent.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace NServiceBus.AcceptanceTests.Core.Installers; - -using System.Threading; -using System.Threading.Tasks; -using AcceptanceTesting; -using AcceptanceTesting.Customization; -using AcceptanceTesting.Support; -using Configuration.AdvancedExtensibility; -using Installation; - -/// -/// Custom test component that uses the API instead of fully starting the endpoint. -/// -public class InstallationOnlyComponent : IComponentBehavior - where TConfigurationFactory : IEndpointConfigurationFactory, new() -{ - public async Task CreateRunner(RunDescriptor run) - { - var configurationFactory = new TConfigurationFactory(); - var customizationConfiguration = configurationFactory.Get(); - customizationConfiguration.EndpointName = Conventions.EndpointNamingConvention(configurationFactory.GetType()); - var endpointConfiguration = await customizationConfiguration.GetConfiguration(run); - RegisterScenarioContext(endpointConfiguration, run.ScenarioContext); - var installer = Installer.CreateInstallerWithExternallyManagedContainer(endpointConfiguration, run.Services); - return new InstallationRunner(installer, run); - } - - static void RegisterScenarioContext(EndpointConfiguration endpointConfiguration, ScenarioContext scenarioContext) - { - var type = scenarioContext.GetType(); - var settings = endpointConfiguration.GetSettings(); - - while (type != typeof(object)) - { - var currentType = type; - settings.Set(currentType.FullName, scenarioContext); - type = type.BaseType; - } - } - - public class InstallationRunner(InstallerWithExternallyManagedContainer installer, RunDescriptor run) : ComponentRunner - { - public override string Name => "Installation only runner"; - - public override Task Start(CancellationToken cancellationToken = default) => installer.Setup(run.ServiceProvider!, cancellationToken); - } -} \ No newline at end of file diff --git a/src/NServiceBus.AcceptanceTests/Core/Installers/ServiceProviderExtensions.cs b/src/NServiceBus.AcceptanceTests/Core/Installers/ServiceProviderExtensions.cs new file mode 100644 index 0000000000..81e7469904 --- /dev/null +++ b/src/NServiceBus.AcceptanceTests/Core/Installers/ServiceProviderExtensions.cs @@ -0,0 +1,35 @@ +namespace NServiceBus.AcceptanceTests.Core.Installers; + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +static class ServiceProviderExtensions +{ + extension(IServiceProvider provider) + { + public async Task RunHostedServices(CancellationToken cancellationToken = default) + { + // We don't have host support in the acceptance tests, so we need to manually start/stop the services + var hostedServices = provider.GetServices().ToList(); + foreach (var hostedService in hostedServices) + { + await hostedService.StartAsync(cancellationToken); + } + + hostedServices.Reverse(); + foreach (var hostedService in hostedServices) + { + await hostedService.StopAsync(cancellationToken); + + if (hostedService is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + } + } + } +} \ No newline at end of file diff --git a/src/NServiceBus.AcceptanceTests/Core/Installers/When_installing_endpoint.cs b/src/NServiceBus.AcceptanceTests/Core/Installers/When_installing_endpoint.cs index 0e042c3b51..a3410e4fdf 100644 --- a/src/NServiceBus.AcceptanceTests/Core/Installers/When_installing_endpoint.cs +++ b/src/NServiceBus.AcceptanceTests/Core/Installers/When_installing_endpoint.cs @@ -1,9 +1,10 @@ namespace NServiceBus.AcceptanceTests.Core.Installers; +using System; using System.Threading; using System.Threading.Tasks; using AcceptanceTesting; -using EndpointTemplates; +using Configuration.AdvancedExtensibility; using FakeTransport; using Features; using Installation; @@ -15,79 +16,74 @@ public class When_installing_endpoint : NServiceBusAcceptanceTest [Test] public async Task Should_only_execute_setup_and_complete() { - var context = await Scenario.Define() - .WithComponent(new InstallationOnlyComponent()) + var fakeTransport = new FakeTransport(); + var endpointConfiguration = new EndpointConfiguration("EndpointWithInstaller"); + endpointConfiguration.AssemblyScanner().Disable = true; + endpointConfiguration.UsePersistence(); + endpointConfiguration.UseTransport(fakeTransport); + endpointConfiguration.UseSerialization(); + + endpointConfiguration.EnableFeature(); + + var context = await Scenario.Define(ctx => + { + endpointConfiguration.GetSettings().Set(ctx); + }) + .WithServices(services => services.AddNServiceBusEndpointInstaller(endpointConfiguration)) + .WithServiceResolve(static async (provider, token) => await provider.RunHostedServices(token)) .Run(); using (Assert.EnterMultipleScope()) { Assert.That(context.InstallerCalled, Is.True, "Should run installers"); Assert.That(context.FeatureSetupCalled, Is.True, "Should initialize Features"); - Assert.That(context.FeatureStartupTaskCalled, Is.False, "Should not start FeatureStartupTasks"); } Assert.That(new[] { $"{nameof(TransportDefinition)}.{nameof(TransportDefinition.Initialize)}", $"{nameof(IMessageReceiver)}.{nameof(IMessageReceiver.Initialize)} for receiver Main", - }, Is.EqualTo(context.TransportStartupSequence).AsCollection, "Should not start the receivers"); + }, Is.EqualTo(fakeTransport.StartupSequence).AsCollection, "Should not start the receivers"); } class Context : ScenarioContext { public bool InstallerCalled { get; set; } public bool FeatureSetupCalled { get; set; } - public bool FeatureStartupTaskCalled { get; set; } - public FakeTransport.StartUpSequence TransportStartupSequence { get; set; } - public void MaybeCompleted() => MarkAsCompleted(InstallerCalled, FeatureSetupCalled, TransportStartupSequence != null); + public void MaybeCompleted() => MarkAsCompleted(InstallerCalled, FeatureSetupCalled); } - class EndpointWithInstaller : EndpointConfigurationBuilder + class CustomInstaller(Context testContext) : INeedToInstallSomething { - public EndpointWithInstaller() => - EndpointSetup((c, r) => - { - c.EnableFeature(); - - // Register FakeTransport to track transport seam usage during installation - var fakeTransport = new FakeTransport(); - c.UseTransport(fakeTransport); - ((Context)r.ScenarioContext).TransportStartupSequence = fakeTransport.StartupSequence; - }); - - class CustomInstaller(Context testContext) : INeedToInstallSomething + public Task Install(string identity, CancellationToken cancellationToken = default) { - public Task Install(string identity, CancellationToken cancellationToken = default) - { - testContext.InstallerCalled = true; - testContext.MaybeCompleted(); - return Task.CompletedTask; - } + testContext.InstallerCalled = true; + testContext.MaybeCompleted(); + return Task.CompletedTask; } + } - class CustomFeature : Feature + class CustomFeature : Feature + { + protected override void Setup(FeatureConfigurationContext context) { - protected override void Setup(FeatureConfigurationContext context) - { - context.AddInstaller(); + context.AddInstaller(); - var testContext = context.Settings.Get(); - testContext.FeatureSetupCalled = true; + var testContext = context.Settings.Get(); + testContext.FeatureSetupCalled = true; - context.RegisterStartupTask(new CustomFeatureStartupTask(testContext)); - } + context.RegisterStartupTask(new CustomFeatureStartupTask(testContext)); + } - class CustomFeatureStartupTask(Context testContext) : FeatureStartupTask + class CustomFeatureStartupTask(Context testContext) : FeatureStartupTask + { + protected override Task OnStart(IMessageSession session, CancellationToken cancellationToken = default) { - protected override Task OnStart(IMessageSession session, CancellationToken cancellationToken = default) - { - testContext.FeatureStartupTaskCalled = true; - testContext.MaybeCompleted(); - return Task.CompletedTask; - } - - protected override Task OnStop(IMessageSession session, CancellationToken cancellationToken = default) => Task.CompletedTask; + testContext.MarkAsFailed(new InvalidOperationException("FeatureStartupTask should not be called")); + return Task.CompletedTask; } + + protected override Task OnStop(IMessageSession session, CancellationToken cancellationToken = default) => Task.CompletedTask; } } } \ No newline at end of file diff --git a/src/NServiceBus.AcceptanceTests/Core/Installers/When_installing_endpoints.cs b/src/NServiceBus.AcceptanceTests/Core/Installers/When_installing_endpoints.cs new file mode 100644 index 0000000000..7fb0e028ca --- /dev/null +++ b/src/NServiceBus.AcceptanceTests/Core/Installers/When_installing_endpoints.cs @@ -0,0 +1,114 @@ +namespace NServiceBus.AcceptanceTests.Core.Installers; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AcceptanceTesting; +using Configuration.AdvancedExtensibility; +using FakeTransport; +using Features; +using Installation; +using NUnit.Framework; +using Settings; +using Transport; + +public class When_installing_endpoints : NServiceBusAcceptanceTest +{ + [Test] + public async Task Should_only_execute_setup_and_complete() + { + var transport1 = new FakeTransport(); + var endpointConfiguration1 = CreateEndpointConfiguration(transport1, "EndpointWithInstaller1"); + + var transport2 = new FakeTransport(); + EndpointConfiguration endpointConfiguration2 = CreateEndpointConfiguration(transport2, "EndpointWithInstaller2"); + + var context = await Scenario.Define(ctx => + { + endpointConfiguration1.GetSettings().Set(ctx); + endpointConfiguration2.GetSettings().Set(ctx); + }) + .WithServices(services => + { + services.AddNServiceBusEndpointInstaller(endpointConfiguration1, "EndpointWithInstaller1"); + services.AddNServiceBusEndpointInstaller(endpointConfiguration2, "EndpointWithInstaller2"); + }) + .WithServiceResolve(static async (provider, token) => await provider.RunHostedServices(token)) + .Run(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(context.InstallerCalled.All(x => x.Value), Is.True, "Should run installers"); + Assert.That(context.FeatureSetupCalled.All(x => x.Value), Is.True, "Should initialize Features"); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(new[] + { + $"{nameof(TransportDefinition)}.{nameof(TransportDefinition.Initialize)}", $"{nameof(IMessageReceiver)}.{nameof(IMessageReceiver.Initialize)} for receiver Main", + }, Is.EqualTo(transport1.StartupSequence).AsCollection, "Should not start the receivers"); + Assert.That(new[] + { + $"{nameof(TransportDefinition)}.{nameof(TransportDefinition.Initialize)}", $"{nameof(IMessageReceiver)}.{nameof(IMessageReceiver.Initialize)} for receiver Main", + }, Is.EqualTo(transport2.StartupSequence).AsCollection, "Should not start the receivers"); + } + } + + static EndpointConfiguration CreateEndpointConfiguration(FakeTransport transport, string endpointName) + { + var endpointConfiguration = new EndpointConfiguration(endpointName); + endpointConfiguration.AssemblyScanner().Disable = true; + endpointConfiguration.UsePersistence(); + endpointConfiguration.UseTransport(transport); + endpointConfiguration.UseSerialization(); + endpointConfiguration.EnableFeature(); + return endpointConfiguration; + } + + class Context : ScenarioContext + { + public Dictionary InstallerCalled { get; set; } = []; + public Dictionary FeatureSetupCalled { get; set; } = []; + + public void MaybeCompleted() => MarkAsCompleted(InstallerCalled.Count == 2, FeatureSetupCalled.Count == 2); + } + + class CustomInstaller(Context testContext, IReadOnlySettings settings) : INeedToInstallSomething + { + public Task Install(string identity, CancellationToken cancellationToken = default) + { + testContext.InstallerCalled[settings.EndpointName()] = true; + testContext.MaybeCompleted(); + return Task.CompletedTask; + } + } + + class CustomFeature : Feature + { + protected override void Setup(FeatureConfigurationContext context) + { + context.AddInstaller(); + + var settings = context.Settings; + + var testContext = settings.Get(); + testContext.FeatureSetupCalled[settings.EndpointName()] = true; + + context.RegisterStartupTask(new CustomFeatureStartupTask(testContext)); + } + + class CustomFeatureStartupTask(Context testContext) : FeatureStartupTask + { + protected override Task OnStart(IMessageSession session, CancellationToken cancellationToken = default) + { + testContext.MarkAsFailed(new InvalidOperationException("FeatureStartupTask should not be called")); + return Task.CompletedTask; + } + + protected override Task OnStop(IMessageSession session, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt b/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt index 861e06ce49..c0deb54667 100644 --- a/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt +++ b/src/NServiceBus.Core.Tests/ApprovalFiles/APIApprovals.ApproveNServiceBus.approved.txt @@ -1042,6 +1042,7 @@ namespace NServiceBus public static class ServiceCollectionExtensions { public static void AddNServiceBusEndpoint(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, NServiceBus.EndpointConfiguration endpointConfiguration, object? endpointIdentifier = null) { } + public static void AddNServiceBusEndpointInstaller(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, NServiceBus.EndpointConfiguration endpointConfiguration, object? endpointIdentifier = null) { } } public static class SettingsExtensions { @@ -1573,9 +1574,12 @@ namespace NServiceBus.Installation { System.Threading.Tasks.Task Install(string identity, System.Threading.CancellationToken cancellationToken = default); } + [System.Obsolete(@"Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint. Use 'IServiceCollection.AddNServiceBusEndpointInstaller' instead. Will be treated as an error from version 11.0.0. Will be removed in version 12.0.0.", false)] public static class Installer { + [System.Obsolete(@"Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint. Use 'IServiceCollection.AddNServiceBusEndpointInstaller' instead. Will be treated as an error from version 11.0.0. Will be removed in version 12.0.0.", false)] public static NServiceBus.Installation.InstallerWithExternallyManagedContainer CreateInstallerWithExternallyManagedContainer(NServiceBus.EndpointConfiguration configuration, Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection) { } + [System.Obsolete(@"Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint. Use 'IServiceCollection.AddNServiceBusEndpointInstaller' instead. Will be treated as an error from version 11.0.0. Will be removed in version 12.0.0.", false)] public static System.Threading.Tasks.Task Setup(NServiceBus.EndpointConfiguration configuration, System.Threading.CancellationToken cancellationToken = default) { } } public class InstallerWithExternallyManagedContainer diff --git a/src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensionsTests.cs b/src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensions_AddEndpoint_Tests.cs similarity index 69% rename from src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensionsTests.cs rename to src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensions_AddEndpoint_Tests.cs index 8aa0182d7f..c82ecc44ec 100644 --- a/src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensionsTests.cs +++ b/src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensions_AddEndpoint_Tests.cs @@ -8,7 +8,7 @@ namespace NServiceBus.Core.Tests.Host; using NUnit.Framework; [TestFixture] -public class ServiceCollectionExtensionsTests +public partial class ServiceCollectionExtensions_AddEndpoint_Tests { [Test] public void Should_register_single_endpoint_without_identifier() @@ -88,12 +88,36 @@ public void Should_throw_when_transport_not_specified() Assert.That(ex!.Message, Does.Contain("A transport has not been configured. Use 'EndpointConfiguration.UseTransport()' to specify a transport")); } - static EndpointConfiguration CreateConfig(string endpointName) + [Test] + public void Should_throw_when_multiple_endpoints_assembly_scanning_enabled() + { + var services = new ServiceCollection(); + + services.AddNServiceBusEndpoint(CreateConfig("Sales"), "Sales"); + + var ex = Assert.Throws(() => services.AddNServiceBusEndpoint(CreateConfig("Billing", assemblyScanningEnabled: true), "Billing")); + + Assert.That(ex!.Message, Does.Contain("When multiple endpoints are registered, each endpoint must disable assembly scanning (cfg.AssemblyScanner().Disable = true) and explicitly register its handlers and sagas using the corresponding registrations methods like AddHandler(), AddSaga() etc. The following endpoints have assembly scanning enabled: 'Billing'.")); + } + + [Test] + public void Should_throw_when_used_with_add_installer() + { + var services = new ServiceCollection(); + + services.AddNServiceBusEndpoint(CreateConfig("Sales"), "Sales"); + + var ex = Assert.Throws(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Billing"), "Billing")); + + Assert.That(ex!.Message, Does.Contain("'AddNServiceBusEndpointInstaller' cannot be used together with 'AddNServiceBusEndpoint'.")); + } + + static EndpointConfiguration CreateConfig(string endpointName, bool assemblyScanningEnabled = false) { var config = new EndpointConfiguration(endpointName); config.UseSerialization(); config.UseTransport(new LearningTransport()); - config.AssemblyScanner().Disable = true; + config.AssemblyScanner().Disable = !assemblyScanningEnabled; return config; } } \ No newline at end of file diff --git a/src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensions_AddInstaller_Tests.cs b/src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensions_AddInstaller_Tests.cs new file mode 100644 index 0000000000..b1c1b37e44 --- /dev/null +++ b/src/NServiceBus.Core.Tests/Hosting/ServiceCollectionExtensions_AddInstaller_Tests.cs @@ -0,0 +1,123 @@ +#nullable enable + +namespace NServiceBus.Core.Tests.Host; + +using System; +using Microsoft.Extensions.DependencyInjection; +using NServiceBus; +using NUnit.Framework; + +[TestFixture] +public class ServiceCollectionExtensions_AddInstaller_Tests +{ + [Test] + public void Should_register_single_endpoint_without_identifier() + { + var services = new ServiceCollection(); + + Assert.DoesNotThrow(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Sales"))); + } + + [Test] + public void Should_register_single_endpoint_with_identifier() + { + var services = new ServiceCollection(); + + Assert.DoesNotThrow(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Sales"), "sales-key")); + } + + [Test] + public void Should_throw_when_first_endpoint_has_no_identifier_and_second_has_one() + { + var services = new ServiceCollection(); + + services.AddNServiceBusEndpointInstaller(CreateConfig("Sales")); + + var ex = Assert.Throws(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Billing"), "billing-key")); + + Assert.That(ex!.Message, Does.Contain("each endpoint must provide an endpointIdentifier")); + } + + [Test] + public void Should_throw_when_first_endpoint_has_identifier_and_second_has_none() + { + var services = new ServiceCollection(); + + services.AddNServiceBusEndpointInstaller(CreateConfig("Sales"), "sales-key"); + + var ex = Assert.Throws(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Billing"))); + + Assert.That(ex!.Message, Does.Contain("each endpoint must provide an endpointIdentifier")); + } + + [Test] + public void Should_register_multiple_endpoints_when_all_have_identifiers() + { + var services = new ServiceCollection(); + + services.AddNServiceBusEndpointInstaller(CreateConfig("Sales"), "sales-key"); + + Assert.DoesNotThrow(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Billing"), "billing-key")); + } + + [Test] + public void Should_throw_when_multiple_endpoints_have_duplicate_identifiers() + { + var services = new ServiceCollection(); + + services.AddNServiceBusEndpointInstaller(CreateConfig("Sales"), "shared-key"); + + var ex = Assert.Throws(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Billing"), "shared-key")); + + Assert.That(ex!.Message, Does.Contain("An endpoint with the identifier 'shared-key' has already been registered")); + } + + [Test] + public void Should_throw_when_transport_not_specified() + { + var services = new ServiceCollection(); + + var ex = Assert.Throws(() => + { + var endpointConfiguration = new EndpointConfiguration("Billing"); + endpointConfiguration.UseSerialization(); + + services.AddNServiceBusEndpointInstaller(endpointConfiguration); + }); + + Assert.That(ex!.Message, Does.Contain("A transport has not been configured. Use 'EndpointConfiguration.UseTransport()' to specify a transport")); + } + + [Test] + public void Should_throw_when_multiple_endpoints_assembly_scanning_enabled() + { + var services = new ServiceCollection(); + + services.AddNServiceBusEndpointInstaller(CreateConfig("Sales"), "Sales"); + + var ex = Assert.Throws(() => services.AddNServiceBusEndpointInstaller(CreateConfig("Billing", assemblyScanningEnabled: true), "Billing")); + + Assert.That(ex!.Message, Does.Contain("When multiple endpoints are registered, each endpoint must disable assembly scanning (cfg.AssemblyScanner().Disable = true) and explicitly register its installers using the corresponding registrations methods like AddInstaller(). The following endpoints have assembly scanning enabled: 'Billing'.")); + } + + [Test] + public void Should_throw_when_used_with_add_installer() + { + var services = new ServiceCollection(); + + services.AddNServiceBusEndpointInstaller(CreateConfig("Sales"), "Sales"); + + var ex = Assert.Throws(() => services.AddNServiceBusEndpoint(CreateConfig("Billing"), "Billing")); + + Assert.That(ex!.Message, Does.Contain("'AddNServiceBusEndpoint' cannot be used together with 'AddNServiceBusEndpointInstaller'.")); + } + + static EndpointConfiguration CreateConfig(string endpointName, bool assemblyScanningEnabled = false) + { + var config = new EndpointConfiguration(endpointName); + config.UseSerialization(); + config.UseTransport(new LearningTransport()); + config.AssemblyScanner().Disable = !assemblyScanningEnabled; + return config; + } +} \ No newline at end of file diff --git a/src/NServiceBus.Core/Hosting/EndpointHostedInstallerService.cs b/src/NServiceBus.Core/Hosting/EndpointHostedInstallerService.cs new file mode 100644 index 0000000000..fdda25504b --- /dev/null +++ b/src/NServiceBus.Core/Hosting/EndpointHostedInstallerService.cs @@ -0,0 +1,18 @@ +#nullable enable +namespace NServiceBus; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; + +class EndpointHostedInstallerService(IEndpointLifecycle endpointLifecycle) : IHostedService, IAsyncDisposable +{ + public async Task StartAsync(CancellationToken cancellationToken = default) + // we only ever create but not start + => await endpointLifecycle.Create(cancellationToken).ConfigureAwait(false); + + public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public ValueTask DisposeAsync() => endpointLifecycle.DisposeAsync(); +} \ No newline at end of file diff --git a/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs b/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs index cc8295ee73..b7f625a390 100644 --- a/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs +++ b/src/NServiceBus.Core/Hosting/ServiceCollectionExtensions.cs @@ -16,6 +16,94 @@ namespace NServiceBus; /// public static class ServiceCollectionExtensions { + /// + /// Registers an NServiceBus endpoint installer with the dependency injection container, enabling the endpoint installers + /// to participate in the hosted service lifecycle. + /// + /// Registering installers is mutually exclusive to registering endpoints. This is to force the installation API being used on a dedicated host that is only used to run installers + /// for example as part of a deployment. + /// The to add the endpoint to. + /// The defining how the endpoint should be configured. + /// + /// An optional identifier that uniquely identifies this endpoint within the dependency injection container. + /// When multiple endpoints are registered (by calling this method multiple times), this parameter is required + /// and must be a well-defined value that serves as a keyed service identifier. + /// + /// In most scenarios, using the endpoint name as the identifier is a good choice. + /// + /// + /// For more complex scenarios such as multi-tenant applications where endpoint infrastructure + /// per tenant is dynamically resolved, the identifier can be any object that implements + /// and in a way that conforms to Microsoft Dependency Injection keyed services assumptions. + /// The key is used with keyed service registration methods like AddKeyedSingleton and related methods, + /// and can be retrieved using keyed service resolution APIs like GetRequiredKeyedService or + /// the [FromKeyedServices] attribute on constructor parameters. + /// + /// + /// + /// When using a keyed endpoint, all services resolved within NServiceBus extension points + /// (message handlers, sagas, features, installers, etc.) are automatically resolved as keyed services + /// for that endpoint and do not require the [FromKeyedServices] attribute. + /// Conversely, the [FromKeyedServices] attribute is required when accessing endpoint-specific services + /// (such as ) outside of NServiceBus extension points, for example in controllers + /// or background jobs. + /// + /// + /// By default, only endpoint-specific registrations are resolved when resolving all services of a given type + /// within an endpoint. However, for advanced scenarios where global services registered on the shared + /// service collection need to be resolved along with endpoint-specific ones, use + /// with the [FromKeyedServices] attribute (for example: [FromKeyedServices(KeyedServiceKey.Any)] IEnumerable<IMyService>). + /// This bypasses the default safeguards that isolate endpoints, allowing resolution of all services including + /// globally shared ones. + /// + public static void AddNServiceBusEndpointInstaller(this IServiceCollection services, EndpointConfiguration endpointConfiguration, object? endpointIdentifier = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(endpointConfiguration); + + var settings = endpointConfiguration.GetSettings(); + // Unfortunately we have to also check this here due to the multiple hosting variants as long as + // the old hosting is still supported. + settings.AssertNotReused(); + + var endpointName = settings.EndpointName(); + var transport = settings.Get().TransportDefinition; + var endpointRegistrations = GetExistingRegistrations(services); + var installerRegistrations = GetExistingRegistrations(services); + + ValidateNotUsedWithAddNServiceBusEndpoints(endpointRegistrations, $"'{nameof(AddNServiceBusEndpointInstaller)}' cannot be used together with '{nameof(AddNServiceBusEndpoint)}'."); + ValidateEndpointIdentifier(endpointIdentifier, installerRegistrations); + ValidateAssemblyScanning(endpointConfiguration, endpointName, installerRegistrations, message: "its installers using the corresponding registrations methods like AddInstaller()"); + ValidateTransportReuse(transport, installerRegistrations); + + endpointConfiguration.EnableInstallers(); + + if (endpointIdentifier is null) + { + // Deliberately creating it here to make sure we are not accidentally doing it too late. + var externallyManagedContainerHost = EndpointExternallyManaged.Create(endpointConfiguration, services); + + services.AddSingleton(externallyManagedContainerHost); + services.AddSingleton(sp => new BaseEndpointLifecycle(externallyManagedContainerHost, sp)); + services.AddSingleton(sp => new EndpointHostedInstallerService(sp.GetRequiredService())); + } + else + { + // Backdoor for acceptance testing + var keyedServices = settings.GetOrDefault() ?? new KeyedServiceCollectionAdapter(services, endpointIdentifier); + var baseKey = keyedServices.ServiceKey.BaseKey; + + // Deliberately creating it here to make sure we are not accidentally doing it too late. + var externallyManagedContainerHost = EndpointExternallyManaged.Create(endpointConfiguration, keyedServices); + + services.AddKeyedSingleton(baseKey, externallyManagedContainerHost); + services.AddKeyedSingleton(baseKey, (sp, _) => new EndpointLifecycle(externallyManagedContainerHost, sp, keyedServices.ServiceKey, keyedServices)); + services.AddSingleton(sp => new EndpointHostedInstallerService(sp.GetRequiredKeyedService(baseKey))); + } + + services.AddSingleton(new EndpointInstallerRegistration(endpointName, endpointIdentifier, endpointConfiguration.AssemblyScanner().Disable, RuntimeHelpers.GetHashCode(transport))); + } + /// /// Registers an NServiceBus endpoint with the dependency injection container, enabling the endpoint /// to resolve services from the application's service provider and participate in the hosted service lifecycle. @@ -56,10 +144,7 @@ public static class ServiceCollectionExtensions /// globally shared ones. /// /// - public static void AddNServiceBusEndpoint( - this IServiceCollection services, - EndpointConfiguration endpointConfiguration, - object? endpointIdentifier = null) + public static void AddNServiceBusEndpoint(this IServiceCollection services, EndpointConfiguration endpointConfiguration, object? endpointIdentifier = null) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(endpointConfiguration); @@ -70,13 +155,15 @@ public static void AddNServiceBusEndpoint( settings.AssertNotReused(); var endpointName = settings.EndpointName(); - var hostingSettings = settings.Get(); var transport = settings.Get().TransportDefinition; - var registrations = GetExistingRegistrations(services); + var endpointRegistrations = GetExistingRegistrations(services); + var installerRegistrations = GetExistingRegistrations(services); + var hostingSettings = settings.Get(); - ValidateEndpointIdentifier(endpointIdentifier, registrations); - ValidateAssemblyScanning(endpointConfiguration, endpointName, registrations); - ValidateTransportReuse(transport, registrations); + ValidateNotUsedWithAddNServiceBusEndpoints(installerRegistrations, $"'{nameof(AddNServiceBusEndpoint)}' cannot be used together with '{nameof(AddNServiceBusEndpointInstaller)}'."); + ValidateEndpointIdentifier(endpointIdentifier, endpointRegistrations); + ValidateAssemblyScanning(endpointConfiguration, endpointName, endpointRegistrations); + ValidateTransportReuse(transport, endpointRegistrations); hostingSettings.ConfigureHostLogging(endpointIdentifier); @@ -108,7 +195,16 @@ public static void AddNServiceBusEndpoint( internal static IServiceCollection Unwrap(this IServiceCollection services) => (services as KeyedServiceCollectionAdapter)?.Inner ?? services; - static void ValidateEndpointIdentifier(object? endpointIdentifier, List registrations) + static void ValidateNotUsedWithAddNServiceBusEndpoints(List endpointRegistrations, string message) + { + if (endpointRegistrations.Count > 0) + { + throw new InvalidOperationException(message); + } + } + + static void ValidateEndpointIdentifier(object? endpointIdentifier, List registrations) + where TRegistration : EndpointRegistration { if (registrations.Count == 0) { @@ -128,7 +224,8 @@ static void ValidateEndpointIdentifier(object? endpointIdentifier, List registrations) + static void ValidateAssemblyScanning(EndpointConfiguration endpointConfiguration, string endpointName, List registrations, string message = "its handlers and sagas using the corresponding registrations methods like AddHandler(), AddSaga() etc") + where TRegistration : EndpointRegistration { if (endpointConfiguration.GetSettings().HasSetting("NServiceBus.Hosting.DisableAssemblyScanningValidation")) { @@ -153,12 +250,13 @@ static void ValidateAssemblyScanning(EndpointConfiguration endpointConfiguration { throw new InvalidOperationException( $"When multiple endpoints are registered, each endpoint must disable assembly scanning " + - $"(cfg.AssemblyScanner().Disable = true) and explicitly register its handlers using AddHandler(). " + + $"(cfg.AssemblyScanner().Disable = true) and explicitly register {message}. " + $"The following endpoints have assembly scanning enabled: {string.Join(", ", endpointsWithScanning.Select(n => $"'{n}'"))}."); } } - static void ValidateTransportReuse(TransportDefinition transport, List registrations) + static void ValidateTransportReuse(TransportDefinition transport, List registrations) + where TRegistration : EndpointRegistration { var transportHash = RuntimeHelpers.GetHashCode(transport); var existingRegistration = registrations.FirstOrDefault(r => r.TransportHashCode == transportHash); @@ -169,10 +267,13 @@ static void ValidateTransportReuse(TransportDefinition transport, List GetExistingRegistrations(IServiceCollection services) => + static List GetExistingRegistrations(IServiceCollection services) + where TRegistration : EndpointRegistration => [.. services - .Where(d => d.ServiceType == typeof(EndpointRegistration) && d.ImplementationInstance is EndpointRegistration) - .Select(d => (EndpointRegistration)d.ImplementationInstance!)]; + .Where(d => d.ServiceType == typeof(TRegistration) && d.ImplementationInstance is TRegistration) + .Select(d => (TRegistration)d.ImplementationInstance!)]; + + record EndpointRegistration(string EndpointName, object? EndpointIdentifier, bool ScanningDisabled, int TransportHashCode); - sealed record EndpointRegistration(string EndpointName, object? EndpointIdentifier, bool ScanningDisabled, int TransportHashCode); + record EndpointInstallerRegistration(string EndpointName, object? EndpointIdentifier, bool ScanningDisabled, int TransportHashCode) : EndpointRegistration(EndpointName, EndpointIdentifier, ScanningDisabled, TransportHashCode); } \ No newline at end of file diff --git a/src/NServiceBus.Core/Installation/Installer.cs b/src/NServiceBus.Core/Installation/Installer.cs index f98d032573..cad0fb2544 100644 --- a/src/NServiceBus.Core/Installation/Installer.cs +++ b/src/NServiceBus.Core/Installation/Installer.cs @@ -3,19 +3,32 @@ namespace NServiceBus.Installation; using System; -using Microsoft.Extensions.DependencyInjection; -using System.Threading.Tasks; using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Particular.Obsoletes; /// /// Provides methods to setup an NServiceBus endpoint. /// +[ObsoleteMetadata( + Message = "Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint", + TreatAsErrorFromVersion = "11", + RemoveInVersion = "12", + ReplacementTypeOrMember = "IServiceCollection.AddNServiceBusEndpointInstaller")] +[Obsolete("Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint. Use 'IServiceCollection.AddNServiceBusEndpointInstaller' instead. Will be treated as an error from version 11.0.0. Will be removed in version 12.0.0.", false)] public static class Installer { /// /// Executes all the installers and transport configuration without starting the endpoint. /// always runs installers, even if has not been configured. /// + [ObsoleteMetadata( + Message = "Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint", + TreatAsErrorFromVersion = "11", + RemoveInVersion = "12", + ReplacementTypeOrMember = "IServiceCollection.AddNServiceBusEndpointInstaller")] + [Obsolete("Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint. Use 'IServiceCollection.AddNServiceBusEndpointInstaller' instead. Will be treated as an error from version 11.0.0. Will be removed in version 12.0.0.", false)] public static async Task Setup(EndpointConfiguration configuration, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(configuration); @@ -38,6 +51,12 @@ public static async Task Setup(EndpointConfiguration configuration, Cancellation /// /// Creates an instance of that can be used to setup an NServiceBus when access to externally registered container dependencies are required. /// + [ObsoleteMetadata( + Message = "Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint", + TreatAsErrorFromVersion = "11", + RemoveInVersion = "12", + ReplacementTypeOrMember = "IServiceCollection.AddNServiceBusEndpointInstaller")] + [Obsolete("Self-hosting installers is no longer recommended. Instead, consider using a Microsoft IHostApplicationBuilder-based host to manage the installation lifecycle of your endpoint. Use 'IServiceCollection.AddNServiceBusEndpointInstaller' instead. Will be treated as an error from version 11.0.0. Will be removed in version 12.0.0.", false)] public static InstallerWithExternallyManagedContainer CreateInstallerWithExternallyManagedContainer(EndpointConfiguration configuration, IServiceCollection serviceCollection) { ArgumentNullException.ThrowIfNull(configuration);