How to overwrite a scoped service with a decorated implementation?

后端 未结 5 564
日久生厌
日久生厌 2020-12-18 06:07

I\'m trying to write an ASP.NET Core 2.2 integration test, where the test setup decorates a specific service that would normally be available to the API as a dependency. The

相关标签:
5条回答
  • 2020-12-18 06:41

    Contrary to popular belief, the decorator pattern is fairly easy to implement using the built-in container.

    What we generally want is to overwrite the registration of the regular implementation by the decorated one, making us of the original one as a parameter to the decorator. As a result, asking for an IDependency should lead to a DecoratorImplementation wrapping the OriginalImplementation.

    (If we merely want to register the decorator as a different TService than the original, things are even easier.)

    public void ConfigureServices(IServiceCollection services)
    {
        // First add the regular implementation
        services.AddSingleton<IDependency, OriginalImplementation>();
    
        // Wouldn't it be nice if we could do this...
        services.AddDecorator<IDependency>(
            (serviceProvider, decorated) => new DecoratorImplementation(decorated));
                
        // ...or even this?
        services.AddDecorator<IDependency, DecoratorImplementation>();
    }
    

    The above code works once we add the following extension methods:

    public static class DecoratorRegistrationExtensions
    {
        /// <summary>
        /// Registers a <typeparamref name="TService"/> decorator on top of the previous registration of that type.
        /// </summary>
        /// <param name="decoratorFactory">Constructs a new instance based on the the instance to decorate and the <see cref="IServiceProvider"/>.</param>
        /// <param name="lifetime">If no lifetime is provided, the lifetime of the previous registration is used.</param>
        public static IServiceCollection AddDecorator<TService>(
            this IServiceCollection services,
            Func<IServiceProvider, TService, TService> decoratorFactory,
            ServiceLifetime? lifetime = null)
            where TService : class
        {
            // By convention, the last registration wins
            var previousRegistration = services.LastOrDefault(
                descriptor => descriptor.ServiceType == typeof(TService));
    
            if (previousRegistration is null)
                throw new InvalidOperationException($"Tried to register a decorator for type {typeof(TService).Name} when no such type was registered.");
    
            // Get a factory to produce the original implementation
            var decoratedServiceFactory = previousRegistration.ImplementationFactory;
            if (decoratedServiceFactory is null && previousRegistration.ImplementationInstance != null)
                decoratedServiceFactory = _ => previousRegistration.ImplementationInstance;
            if (decoratedServiceFactory is null && previousRegistration.ImplementationType != null)
                decoratedServiceFactory = serviceProvider => ActivatorUtilities.CreateInstance(
                    serviceProvider, previousRegistration.ImplementationType, Array.Empty<object>());
    
            var registration = new ServiceDescriptor(
                typeof(TService), CreateDecorator, lifetime ?? previousRegistration.Lifetime);
    
            services.Add(registration);
    
            return services;
    
            // Local function that creates the decorator instance
            TService CreateDecorator(IServiceProvider serviceProvider)
            {
                var decoratedInstance = (TService)decoratedServiceFactory(serviceProvider);
                var decorator = decoratorFactory(serviceProvider, decoratedInstance);
                return decorator;
            }
        }
    
        /// <summary>
        /// Registers a <typeparamref name="TService"/> decorator on top of the previous registration of that type.
        /// </summary>
        /// <param name="lifetime">If no lifetime is provided, the lifetime of the previous registration is used.</param>
        public static IServiceCollection AddDecorator<TService, TImplementation>(
            this IServiceCollection services,
            ServiceLifetime? lifetime = null)
            where TService : class
            where TImplementation : TService
        {
            return AddDecorator<TService>(
                services,
                (serviceProvider, decoratedInstance) =>
                    ActivatorUtilities.CreateInstance<TImplementation>(serviceProvider, decoratedInstance),
                lifetime);
        }
    }
    
    0 讨论(0)
  • 2020-12-18 06:46

    There's actually a few things here. First, when you register a service with an interface, you can only inject that interface. You are in fact saying: "when you see IBarService inject an instance of BarService". The service collection doesn't know anything about BarService itself, so you cannot inject BarService directly.

    Which leads to the second issue. When you add your new DecoratedBarService registration, you now have two registered implementations for IBarService. There's no way for it to know which to actually inject in place of IBarService, so again: failure. Some DI containers have specialized functionality for this type of scenario, allowing you to specify when to inject which, Microsoft.Extensions.DependencyInjection does not. If you truly need this functionality, you can use a more advanced DI container instead, but considering this is only for testing, that would like be a mistake.

    Third, you have a bit of a circular dependency here, as DecoratedBarService itself takes a dependency on IBarService. Again, a more advanced DI container can handle this sort of thing; Microsoft.Extensions.DependencyInjection cannot.

    Your best bet here is to use an inherited TestStartup class and factor out this dependency registration into a protected virtual method you can override. In your Startup class:

    protected virtual void AddBarService(IServiceCollection services)
    {
        services.AddScoped<IBarService, BarService>();
    }
    

    Then, where you were doing the registration, call this method instead:

    AddBarService(services);
    

    Next, in your test project create a TestStartup and inherit from your SUT project's Startup. Override this method there:

    public class TestStartup : Startup
    {
        protected override void AddBarService(IServiceCollection services)
        {
            services.AddScoped(_ => new DecoratedBarService(new BarService()));
        }
    }
    

    If you need to get dependencies in order to new up any of these classes, then you can use the passed in IServiceProvider instance:

    services.AddScoped(p =>
    {
        var dep = p.GetRequiredService<Dependency>();
        return new DecoratedBarService(new BarService(dep));
    }
    

    Finally, tell your WebApplicationFactory to use this TestStartup class. This will need to be done via the UseStartup method of the builder, not the generic type param of WebApplicationFactory. That generic type param corresponds to the entry point of the application (i.e. your SUT), not which startup class is actually used.

    builder.UseStartup<TestStartup>();
    
    0 讨论(0)
  • 2020-12-18 06:51

    This seems like a limitation of the servicesConfiguration.AddXxx method which will first remove the type from the IServiceProvider passed to the lambda.

    You can verify this by changing servicesConfiguration.AddScoped<IBarService>(...) to servicesConfiguration.TryAddScoped<IBarService>(...) and you'll see that the original BarService.GetValue is getting called during the test.

    Additionally, you can verify this because you can resolve any other service inside the lambda except the one you're about to create/override. This is probably to avoid weird recursive resolve loops which would lead to a stack-overflow.

    0 讨论(0)
  • 2020-12-18 06:51

    All the other answers were very helpful:

    • @ChrisPratt clearly explains the underlying problem, and offers a solution where Startup makes the service registration virtual and then overrides that in a TestStartup that is forced upon the IWebHostBuilder
    • @huysentruitw answers as well that this is a limitation of the underlying default DI container
    • @KirkLarkin offers a pragmatic solution where you register BarService itself in Startup and then use that to overwrite the IBarService registration completely

    And still, I'd like to offer yet another answer.

    The other answers helped me find the right terms to Google for. Turns out, there is the "Scrutor" NuGet package which adds the needed decorator support to the default DI container. You can test this solution yourself as it simply requires:

    builder.ConfigureTestServices(servicesConfiguration =>
    {
        // Requires "Scrutor" from NuGet:
        servicesConfiguration.Decorate<IBarService, DecoratedBarService>();
    });
    

    Mentioned package is open source (MIT), and you can also just adapt only the needed features yourself, thus answering the original question as it stood, without external dependencies or changes to anything except the test project:

    public class IntegrationTestsFixture : WebApplicationFactory<Startup>
    {
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            base.ConfigureWebHost(builder);
    
            builder.ConfigureTestServices(servicesConfiguration =>
            {
                // The chosen solution here is adapted from the "Scrutor" NuGet package, which
                // is MIT licensed, and can be found at: https://github.com/khellang/Scrutor
                // This solution might need further adaptation for things like open generics...
    
                var descriptor = servicesConfiguration.Single(s => s.ServiceType == typeof(IBarService));
    
                servicesConfiguration.AddScoped<IBarService>(di 
                    => new DecoratedBarService(GetInstance<IBarService>(di, descriptor)));
            });
        }
    
        // Method loosely based on Scrutor, MIT licensed: https://github.com/khellang/Scrutor/blob/68787e28376c640589100f974a5b759444d955b3/src/Scrutor/ServiceCollectionExtensions.Decoration.cs#L319
        private static T GetInstance<T>(IServiceProvider provider, ServiceDescriptor descriptor)
        {
            if (descriptor.ImplementationInstance != null)
            {
                return (T)descriptor.ImplementationInstance;
            }
    
            if (descriptor.ImplementationType != null)
            {
                return (T)ActivatorUtilities.CreateInstance(provider, descriptor.ImplementationType);
            }
    
            if (descriptor.ImplementationFactory != null)
            {
                return (T)descriptor.ImplementationFactory(provider);
            }
    
            throw new InvalidOperationException($"Could not create instance for {descriptor.ServiceType}");
        }
    }
    
    0 讨论(0)
  • 2020-12-18 07:03

    There's a simple alternative to this that just requires registering BarService with the DI container and then resolving that when performing the decoration. All it takes is updating ConfigureTestServices to first register BarService and then use the instance of IServiceProvider that's passed into ConfigureTestServices to resolve it. Here's the complete example:

    builder.ConfigureTestServices(servicesConfiguration =>
    {
        servicesConfiguration.AddScoped<BarService>();
    
        servicesConfiguration.AddScoped<IBarService>(di =>
            new DecoratedBarService(di.GetRequiredService<BarService>()));
    });
    

    Note that this doesn't require any changes to the SUT project. The call to AddScoped<IBarService> here effectively overrides the one provided in the Startup class.

    0 讨论(0)
提交回复
热议问题