如何用修饰的实现覆盖作用域服务?

How to overwrite a scoped service with a decorated implementation?

我正在尝试编写一个 ASP.NET Core 2.2 集成测试,其中测试设置装饰了一个特定服务,该服务通常可作为依赖项提供给 API。装饰器会给我一些额外的权力,我需要在我的集成测试中拦截对底层服务的调用,但是 我似乎无法在 ConfigureTestServices[=74 中正确装饰普通服务=],因为我当前的设置会给我:

An exception of type 'System.InvalidOperationException' occurred in Microsoft.Extensions.DependencyInjection.Abstractions.dll but was not handled in user code

No service for type 'Foo.Web.BarService' has been registered.

为了重现这一点,我刚刚使用 VS2019 创建了一个新的 ASP.NET Core 2.2 API Foo.Web 项目...

// In `Startup.cs`:
services.AddScoped<IBarService, BarService>();
public interface IBarService
{
    string GetValue();
}
public class BarService : IBarService
{
    public string GetValue() => "Service Value";
}
[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
    private readonly IBarService barService;

    public ValuesController(IBarService barService)
    {
        this.barService = barService;
    }

    [HttpGet]
    public ActionResult<string> Get()
    {
        return barService.GetValue();
    }
}

...和一个配套的 xUnit Foo.Web.Tests 项目 I utilize a WebApplicationfactory<TStartup>...

public class DecoratedBarService : IBarService
{
    private readonly IBarService innerService;

    public DecoratedBarService(IBarService innerService)
    {
        this.innerService = innerService;
    }

    public string GetValue() => $"{innerService.GetValue()} (decorated)";
}
public class IntegrationTestsFixture : WebApplicationFactory<Startup>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        base.ConfigureWebHost(builder);

        builder.ConfigureTestServices(servicesConfiguration =>
        {
            servicesConfiguration.AddScoped<IBarService>(di
                => new DecoratedBarService(di.GetRequiredService<BarService>()));
        });
    }
}
public class ValuesControllerTests : IClassFixture<IntegrationTestsFixture>
{
    private readonly IntegrationTestsFixture fixture;

    public ValuesControllerTests(IntegrationTestsFixture fixture)
    {
        this.fixture = fixture;
    }

    [Fact]
    public async Task Integration_test_uses_decorator()
    {
        var client = fixture.CreateClient();
        var result = await client.GetAsync("/api/values");
        var data = await result.Content.ReadAsStringAsync();
        result.EnsureSuccessStatusCode();
        Assert.Equal("Service Value (decorated)", data);
    }
}

这种行为是有道理的,或者至少我 认为 是这样:我想 [=20= 中的小工厂 lambda 函数 (di => new DecoratedBarService(...)) ] 无法从 di 容器中检索具体的 BarService,因为它在主服务集合中,而不是在测试服务中。

如何使默认的 ASP.NET 核心 DI 容器提供装饰器实例,将原始具体类型作为其内部服务?

尝试的解决方案 2:

我试过以下方法:

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    base.ConfigureWebHost(builder);

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

但这令人惊讶地遇到了同样的问题。

尝试的解决方案 3:

改为请求 IBarService,像这样:

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    base.ConfigureWebHost(builder);

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

给我一个不同的错误:

System.InvalidOperationException: 'Cannot resolve scoped service 'Foo.Web.IBarService' from root provider.'

解决方法 A:

我可以像这样在我的小型复制中解决这个问题:

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    base.ConfigureWebHost(builder);

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

但这对我的 实际 应用程序造成了 很多 伤害,因为 BarService 没有简单的无参数构造函数:它有一个相当复杂的依赖图,所以我真的很想从 Startup 的 DI 容器中解析实例。


PS。我试图使这个问题完全独立,但为了您的方便,还有 a clone-and-run rep(r)o

这似乎是 servicesConfiguration.AddXxx 方法的一个限制,该方法将首先从传递给 lambda 的 IServiceProvider 中删除类型。

您可以通过将 servicesConfiguration.AddScoped<IBarService>(...) 更改为 servicesConfiguration.TryAddScoped<IBarService>(...) 来验证这一点,您会看到在测试期间调用了原始 BarService.GetValue

此外,您可以验证这一点,因为您可以解析 lambda 中的任何其他服务,但您将要解析的服务除外 create/override。这可能是为了避免奇怪的递归解析循环,这会导致堆栈溢出。

这里实际上有一些东西。首先,当你用一个接口注册一个服务时,你只能注入那个接口。你实际上是在说:"when you see IBarService inject an instance of BarService"。服务集合对 BarService 本身一无所知,因此您不能直接注入 BarService

由此引出第二个问题。当您添加新的 DecoratedBarService 注册时,您现在有 两个 IBarService 注册的实现。它无法知道实际注入哪个来代替 IBarService,所以再次:失败。一些 DI 容器具有针对此类场景的专门功能,允许您指定何时注入哪个,Microsoft.Extensions.DependencyInjection 则没有。如果你真的需要这个功能,你可以使用更高级的 DI 容器来代替,但考虑到这只是为了测试,那可能是一个错误。

第三,这里有点循环依赖,因为 DecoratedBarService 本身依赖于 IBarService。同样,更高级的 DI 容器可以处理这类事情; Microsoft.Extensions.DependencyInjection 不能。

这里最好的选择是使用继承的 TestStartup class 并将此依赖项注册分解为可以覆盖的受保护虚拟方法。在你的 Startup class:

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

然后,在您进行注册的地方调用此方法:

AddBarService(services);

接下来,在您的测试项目中创建一个 TestStartup 并继承您的 SUT 项目的 Startup。在那里覆盖此方法:

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

如果您需要获取依赖项以更新这些 class 中的任何一个,那么您可以使用传入的 IServiceProvider 实例:

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

最后,告诉你的 WebApplicationFactory 使用这个 TestStartup class。这需要通过构建器的 UseStartup 方法来完成,而不是 WebApplicationFactory 的通用类型参数。该通用类型参数对应于应用程序的入口点(即您的 SUT),而不是实际使用的启动 class。

builder.UseStartup<TestStartup>();

有一个简单的替代方法,只需要在 DI 容器中注册 BarService,然后在执行修饰时解析它。它所需要的只是更新 ConfigureTestServices 以首先注册 BarService,然后使用传递给 ConfigureTestServicesIServiceProvider 的实例来解析它。这是完整的示例:

builder.ConfigureTestServices(servicesConfiguration =>
{
    servicesConfiguration.AddScoped<BarService>();

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

请注意,这不需要对 SUT 项目进行任何更改。此处对 AddScoped<IBarService> 的调用有效地覆盖了 Startup class.

中提供的调用

所有其他答案都非常有帮助:

  • @ChrisPratt 潜在的问题,并提供了一个解决方案,其中 Startup 进行服务注册 virtual,然后在强制执行的 TestStartup 中覆盖它IWebHostBuilder
  • @huysentruitw 以及这是底层默认 DI 容器的限制
  • @KirkLarkin Startup 中注册 BarService 本身,然后使用 that 覆盖 IBarService 注册完全

而且,我还想提供另一个答案。

其他答案帮助我找到 Google 的正确术语。事实证明,有 the "Scrutor" NuGet package which adds the needed decorator support to the default DI container. You can test this solution yourself 因为它只需要:

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

提到的包是开源的(麻省理工学院),您也可以自己只调整需要的功能,因此 按原样回答原始问题,没有外部依赖或对 测试项目:

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}");
    }
}

与流行的看法相反,装饰器模式使用built-in容器相当容易实现。

我们一般想要的是覆盖正则实现的注册被装饰器实现,利用原来的作为装饰器的参数。因此,请求 IDependency 应该导致 DecoratorImplementation 包装 OriginalImplementation.

(如果我们只是想将装饰器注册为 与原始装饰器不同 TService,事情甚至 。)

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>();
}

一旦我们添加了以下扩展方法,上面的代码就可以工作了:

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>());

        if (decoratedServiceFactory is null) // Should be impossible
            throw new Exception($"Tried to register a decorator for type {typeof(TService).Name}, but the registration being wrapped specified no implementation at all.");

        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);
    }
}