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

时间:2019-04-09 18:45:50

标签: c# asp.net-core .net-core integration-testing xunit

我正在尝试编写一个ASP.NET Core 2.2集成测试,该测试设置将装饰特定的服务,该服务通常作为依赖项可用于API。装饰器将为我提供一些集成测试所需的其他功能,以拦截对基础服务的调用,但是我似乎无法在ConfigureTestServices 中正确装饰常规服务,因为我当前的设置会给我:

  

Microsoft.Extensions.DependencyInjection.Abstractions.dll中发生了'System.InvalidOperationException'类型的异常,但未在用户代码中处理

     

尚未注册类型为“ Foo.Web.BarService”的服务。

为重现这一点,我刚刚使用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);
    }
}

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

如何使默认的ASP.NET Core 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:'无法从根提供者解析作用域服务'Foo.Web.IBarService'。

解决方法A:

我可以这样在小型程序中解决该问题:

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

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

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


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

5 个答案:

答案 0 :(得分:3)

这似乎是servicesConfiguration.AddXxx方法的局限性,它将首先从传递给lambda的IServiceProvider中删除类型。

您可以通过将servicesConfiguration.AddScoped<IBarService>(...)更改为servicesConfiguration.TryAddScoped<IBarService>(...)来进行验证,然后您会看到原始BarService.GetValue在测试过程中被调用。

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

答案 1 :(得分:3)

这里实际上有一些东西。首先,在使用接口注册服务时,只能注入该接口。实际上,您是在说:“看到IBarService时注入BarService的实例”。服务集合对BarService本身一无所知,因此您不能直接注入BarService

这导致第二个问题。现在,当您添加新的DecoratedBarService注册时,您将为IBarService拥有两个注册实现。它没有办法知道实际注入哪个来代替IBarService,因此再次:失败。某些DI容器具有针对这种情况的特殊功能,允许您指定何时注入哪些Microsoft.Extensions.DependencyInjection不注入。如果您确实需要此功能,则可以使用更高级的DI容器,但是考虑到这仅用于测试,那会是一个错误。

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

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

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

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

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

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

builder.UseStartup<TestStartup>();

答案 2 :(得分:3)

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

  • @ChrisPratt clearly explains的根本问题,并提供了一种解决方案,其中Startup进行服务注册virtual,然后覆盖在{{ {1}}
  • @huysentruitw answers以及这是基础默认DI容器的限制
  • @KirkLarkin offers a pragmatic solution,您在TestStartup中自己注册IWebHostBuilder,然后使用 that 完全覆盖BarService注册

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

其他答案帮助我找到了适合Google的术语。事实证明,有the "Scrutor" NuGet package可以将所需的装饰器支持添加到默认的DI容器中。您可以test this solution yourself,因为它只需要:

Startup

提到的软件包是开源(MIT),您也可以自己仅调整所需的功能,从而回答原始问题,没有外部依赖性或对 test <进行任何更改< / em>项目

IBarService

答案 3 :(得分:2)

有一个简单的替代方案,只需在DI容器中注册BarService,然后在执行装饰时解决该问题。只需更新ConfigureTestServices使其首先注册BarService,然后使用传递到IServiceProvider的{​​{1}}实例来解决它。这是完整的示例:

ConfigureTestServices

请注意,这不需要对SUT项目进行任何更改。这里对builder.ConfigureTestServices(servicesConfiguration => { servicesConfiguration.AddScoped<BarService>(); servicesConfiguration.AddScoped<IBarService>(di => new DecoratedBarService(di.GetRequiredService<BarService>())); }); 的调用实际上将覆盖AddScoped<IBarService>类中提供的调用。

答案 4 :(得分:1)

与流行的看法相反,装饰器模式使用内置容器非常容易实现。

我们通常想要的是覆盖由修饰的对象注册常规实现,将原始的实现作为修饰符的参数。结果,请求IDependency应该导致DecoratorImplementation包装OriginalImplementation

(如果我们只想将装饰器注册为与原始装饰器不同的 TService,则甚至是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>();
}

添加以下扩展方法后,以上代码将起作用:

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