Я пытаюсь написать интеграционный тест 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.
Это похоже на ограничение метода servicesConfiguration.AddXxx
, который сначала удалит тип из IServiceProvider
, переданного в лямбду.
Вы можете проверить это, изменив servicesConfiguration.AddScoped<IBarService>(...)
на servicesConfiguration.TryAddScoped<IBarService>(...)
, и вы увидите, что во время теста вызывается исходный BarService.GetValue
.
Кроме того, вы можете проверить это, потому что вы можете разрешить любой другой сервис внутри лямбды, кроме того, который вы собираетесь создать/переопределить. Вероятно, это делается для того, чтобы избежать странных рекурсивных циклов разрешения, которые могут привести к переполнению стека.
Здесь на самом деле есть несколько вещей. Во-первых, когда вы регистрируете службу с интерфейсом, вы можете внедрить только этот интерфейс. На самом деле вы говорите: «когда вы видите 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 Ах да, я добавил такое примечание к своему вопросу, пока писался этот ответ.
В любом случае, очень полезный ответ! Я действительно предполагал (основываясь на работе с другими DI-фреймворками), что это возможно, но забыл учесть, что встроенный DI-контейнер может просто не справляться с этой задачей.
Этот лямбда-параметр p
на самом деле является экземпляром IServiceProvider
, поэтому любые другие службы, необходимые для их создания, могут быть получены из него.
Я обновил свой ответ, чтобы привести пример получения службы.
Еще раз спасибо, ваш ответ был важен для понимания проблемы, и предложенное решение тоже полезно. Это также помогло мне найти/раскрыть другое решение, который я, вероятно, в конечном итоге буду использовать, так как он требует только изменений в тестовом проекте, добавляя некоторую поддержку декоратора «бедняка» в контейнер DI.
Существует простая альтернатива этому, которая просто требует регистрации 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
.
Все остальные ответы были очень полезны:
Startup
выполняет регистрацию службы virtual
, а затем переопределяет ее в TestStartup
, которая навязывается IWebHostBuilder
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 Спасибо, что указали на ошибку в нижнем методе. Я исправил ошибочную строку. Теперь перегрузка без параметров должна работать: services.AddDecorator<IDependency, DecoratorImplementation>();
Спасибо, с вашей помощью, а также с ответом @ChrisPratt я обнаружил, что это действительно ограничение контейнера DI по умолчанию. Знание этого помогло мне поискать еще немного, что привело к возможное использование пакета NuGet "Scrutor", который добавляет эту функцию в контейнер DI.