Как перезаписать службу с заданной областью декорированной реализацией?

Я пытаюсь написать интеграционный тест ASP.NET Core 2.2, в котором настройка теста украшает конкретную службу, которая обычно доступна для API в качестве зависимости. Декоратор даст мне некоторые дополнительные возможности, которые мне понадобятся в моих интеграционных тестах для перехвата вызовов базовой службы, но Никак не могу нормально украсить нормальный сервиз в ConfigureTestServices, поскольку моя текущая настройка даст мне:

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 использовать 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(...)) в 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: 'Cannot resolve scoped service 'Foo.Web.IBarService' from root provider.'

Обходной путь А:

Я могу обойти проблему в моем небольшом репродукции следующим образом:

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

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

Но это вредит много в моем приложении действительный, потому что у BarService нет простого конструктора без параметров: у него умеренно сложный граф зависимостей, поэтому я действительно хотел бы разрешать экземпляры из контейнера DI Startup.


PS. Я попытался сделать этот вопрос полностью автономным, но для вашего удобства есть также клонированный и запущенный представитель (r) o.

Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
6
0
3 867
5
Перейти к ответу Данный вопрос помечен как решенный

Ответы 5

Это похоже на ограничение метода servicesConfiguration.AddXxx, который сначала удалит тип из IServiceProvider, переданного в лямбду.

Вы можете проверить это, изменив servicesConfiguration.AddScoped<IBarService>(...) на servicesConfiguration.TryAddScoped<IBarService>(...), и вы увидите, что во время теста вызывается исходный BarService.GetValue.

Кроме того, вы можете проверить это, потому что вы можете разрешить любой другой сервис внутри лямбды, кроме того, который вы собираетесь создать/переопределить. Вероятно, это делается для того, чтобы избежать странных рекурсивных циклов разрешения, которые могут привести к переполнению стека.

Спасибо, с вашей помощью, а также с ответом @ChrisPratt я обнаружил, что это действительно ограничение контейнера DI по умолчанию. Знание этого помогло мне поискать еще немного, что привело к возможное использование пакета NuGet "Scrutor", который добавляет эту функцию в контейнер DI.

Jeroen 09.04.2019 22:48

Здесь на самом деле есть несколько вещей. Во-первых, когда вы регистрируете службу с интерфейсом, вы можете внедрить только этот интерфейс. На самом деле вы говорите: «когда вы видите IBarService, введите экземпляр BarService». Сервисная коллекция ничего не знает о самой себе BarService, поэтому вы не можете внедрить BarService напрямую.

Что приводит ко второй проблеме. Когда вы добавляете новую регистрацию DecoratedBarService, у вас теперь есть зарегистрированные реализации два для IBarService. У него нет возможности узнать, что на самом деле вводить вместо IBarService, так что еще раз: сбой. Некоторые контейнеры внедрения зависимостей имеют специальные функции для этого типа сценариев, позволяя вам указать, когда что вводить, а Microsoft.Extensions.DependencyInjection — нет. Если вам действительно нужна эта функциональность, вы можете вместо этого использовать более продвинутый контейнер внедрения зависимостей, но, учитывая, что это только для тестирования, это было бы ошибкой.

В-третьих, здесь у вас есть своего рода циклическая зависимость, так как 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>();

Я предполагаю, что у BarService есть другие зависимости, иначе new BarService() действительно был бы выходом.

huysentruitw 09.04.2019 22:13

@huysentruitw Ах да, я добавил такое примечание к своему вопросу, пока писался этот ответ.

Jeroen 09.04.2019 22:14

В любом случае, очень полезный ответ! Я действительно предполагал (основываясь на работе с другими DI-фреймворками), что это возможно, но забыл учесть, что встроенный DI-контейнер может просто не справляться с этой задачей.

Jeroen 09.04.2019 22:16

Этот лямбда-параметр p на самом деле является экземпляром IServiceProvider, поэтому любые другие службы, необходимые для их создания, могут быть получены из него.

Chris Pratt 09.04.2019 22:26

Я обновил свой ответ, чтобы привести пример получения службы.

Chris Pratt 09.04.2019 22:52

Еще раз спасибо, ваш ответ был важен для понимания проблемы, и предложенное решение тоже полезно. Это также помогло мне найти/раскрыть другое решение, который я, вероятно, в конечном итоге буду использовать, так как он требует только изменений в тестовом проекте, добавляя некоторую поддержку декоратора «бедняка» в контейнер DI.

Jeroen 09.04.2019 23:27

Существует простая альтернатива этому, которая просто требует регистрации BarService в контейнере DI, а затем разрешает это при выполнении украшения. Все, что требуется, это обновить ConfigureTestServices, чтобы сначала зарегистрировать BarService, а затем использовать экземпляр IServiceProvider, переданный в ConfigureTestServices, для его разрешения. Вот полный пример:

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

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

Обратите внимание, что это не требует каких-либо изменений в проекте SUT. Вызов AddScoped<IBarService> здесь эффективно переопределяет тот, который предоставляется в классе Startup.

Ответ принят как подходящий

Все остальные ответы были очень полезны:

  • @ChrisPratt четко объясняет определяет основную проблему и предлагает решение, в котором Startup выполняет регистрацию службы virtual, а затем переопределяет ее в TestStartup, которая навязывается IWebHostBuilder
  • @huysentruitw ответы, а также то, что это ограничение базового контейнера DI по умолчанию.
  • @KirkLarkin предлагает прагматичное решение, где вы регистрируете BarService себя в Startup, а затем используете это, чтобы полностью перезаписать регистрацию IBarService

И все же я хотел бы предложить еще один ответ.

Другие ответы помогли мне найти правильные термины для Google. Оказывается, есть пакет NuGet "Scrutor", который добавляет необходимую поддержку декоратора в контейнер DI по умолчанию. Вы можете проверить это решение самостоятельно, так как это просто требует:

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

Упомянутый пакет является открытым исходным кодом (MIT), и вы также можете самостоятельно адаптировать только необходимые функции, таким образом, отвечая на исходный вопрос в его нынешнем виде, без внешних зависимостей или изменений чего-либо, кроме проекта тестовое задание:

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

Вопреки распространенному мнению, шаблон декоратора довольно легко реализовать с помощью встроенного контейнера.

Обычно мы хотим, чтобы перезаписывать регистрировал обычную реализацию украшенной, использование исходного в качестве параметра для декоратора. В результате запрос 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);
    }
}

Ваш второй метод не работает serviceProvider и decorated не определены. Но первый работает хорошо. Спасибо. По некоторым причинам AddDecorator от Scrutor не работал, но этот работает.

LukeP 15.12.2020 00:09

@LukeP Спасибо, что указали на ошибку в нижнем методе. Я исправил ошибочную строку. Теперь перегрузка без параметров должна работать: services.AddDecorator<IDependency, DecoratorImplementation>();

Timo 17.12.2020 14:59

Другие вопросы по теме