在ConfigureServices()中调用BuildServiceProvider()的成本和可能产生的副作用

San*_*dro 2 c# dependency-injection .net-core asp.net-core

有时,在服务注册期间,我需要从DI容器解析其他(已注册)服务。使用Autofac或DryIoc之类的容器,这没什么大不了的,因为您可以在一行上注册该服务,而在下一行上可以立即解决该问题。

但是,使用Microsoft的DI容器,您需要注册服务,然后构建服务提供程序,然后才可以从该IServiceProvider实例解析服务。

请参阅以下SO问题的可接受答案:ASP.NET核心模型绑定错误消息本地化

public void ConfigureServices(IServiceCollection services)
{
    services.AddLocalization(options => { options.ResourcesPath = "Resources"; });
    services.AddMvc(options =>
    {
        var F = services.BuildServiceProvider().GetService<IStringLocalizerFactory>();
        var L = F.Create("ModelBindingMessages", "AspNetCoreLocalizationSample");
        options.ModelBindingMessageProvider.ValueIsInvalidAccessor =
            (x) => L["The value '{0}' is invalid."];

        // omitted the rest of the snippet
    })
}
Run Code Online (Sandbox Code Playgroud)

为了能够对ModelBindingMessageProvider.ValueIsInvalidAccessor消息进行本地化,答案建议IStringLocalizerFactory通过基于当前服务集合构建的服务提供商来解决。

那时“构建”服务提供者的成本是多少,并且这样做会有任何副作用,因为将至少再次构建一次服务提供者(在添加所有服务之后)?

Ste*_*ven 5

每个服务提供商都有自己的缓存。因此,构建多个服务提供者实例可能会导致一个名为Torn Lifestyles的问题:

当具有相同生活方式的多个[注册]映射到同一组件时,该组件被称为生活方式受损。该组件被认为是已损坏的,因为每个[注册]都将拥有其自己的给定组件缓存,这可能会导致单个范围内该组件的多个实例。当注册被撕毁时,应用程序可能接线不正确,这可能导致意外行为。

这意味着每个服务提供商将拥有自己的单例实例缓存。从同一源(即,从同一服务集合)构建多个服务提供者将导致创建一个以上实例而不止一次-这打破了对于给定的单一注册最多存在一个实例的保证。

但是还有其他一些细微的错误可能会出现。例如,当解析包含范围相关性的对象图时。为创建存储在下一个容器中的对象图而构建单独的临时服务提供程序,可能会导致这些范围内的依赖项在应用程序期间保持活动状态。此问题通常称为“ 强制依赖”

使用Autofac或DryIoc之类的容器,这没什么大不了的,因为您可以在一行上注册该服务,而在下一行上可以立即解决该问题。

该语句表示在注册阶段仍在进行时,尝试从容器解析实例没有问题。但是,这是不正确的-在解决实例后通过在容器中添加新的注册来更改容器是一种危险的做法-可能导致各种难以跟踪的错误。

It is especially because of those hard to track bugs that DI Containers, such as Autofac, Simple Injector, and Microsoft.Extensions.DependencyInjection (MS.DI) prevent you from doing this in the first place. Autofac and MS.DI do this by having registrations made in a 'container builder' (AutoFac's ContainerBuilder and MS.DI's ServiceCollection). Simple Injector, on the other hand, does not make this split. Instead, it locks the container from any modifications after the first instance is resolved. The effect, however, is similar; it prevents you from adding registrations after you resolve.

The Simple Injector documentation actually contains some decent explanation on why this Register-Resolve-Register pattern is problematic:

Imagine the scenario where you want to replace some FileLogger component for a different implementation with the same ILogger interface. If there’s a component that directly or indirectly depends on ILogger, replacing the ILogger implementation might not work as you would expect. If the consuming component is registered as singleton, for example, the container should guarantee that only one instance of this component will be created. When you are allowed to change the implementation of ILogger after a singleton instance already holds a reference to the “old” registered implementation the container has two choices—neither of which are correct:

  • Return the cached instance of the consuming component that has a reference to the “wrong” ILogger implementation.
  • Create and cache a new instance of that component and, in doing so, break the promise of the type being registered as a singleton and the guarantee that the container will always return the same instance.

For this same reason you see that the ASP.NET Core Startup class defines two separate phases:

  • The “Add” phase (the ConfigureServices method), where you add registrations to the “container builder” (a.k.a. IServiceCollection)
  • The “Use” phase (the Configure method), where you state you want to use MVC by setting up routes. During this phase, the IServiceCollection has been turned into a IServiceProvider and those services can even be method injected into the Configure method.

The general solution, therefore, is to postpone resolving services (like your IStringLocalizerFactory) until the “Use” phase, and with it postpone the final configuration of things that depend on the resolving of services.

This, unfortunately, seems to cause a chicken or the egg causality dilemma when it comes to configuring the ModelBindingMessageProvider because:

  • Configuring the ModelBindingMessageProvider requires the use of the MvcOptions class.
  • The MvcOptions class is only available during the “Add” (Configure) phase.
  • During the “Add” phase there is no access to an IStringLocalizerFactory and no access to a container or service provider and resolving it can’t be postponed by creating such value using a Lazy<IStringLocalizerFactory>.
  • During the “Use” phase, IStringLocalizerFactory is available, but at that point, there is no MvcOptions any longer that you can use to configure the ModelBindingMessageProvider.

The only way around this impasse is by using private fields inside the Startup class and use them in the closure of AddOptions. For instance:

public void ConfigureServices(IServiceCollection services)
{
    services.AddLocalization();
    services.AddMvc(options =>
    {
        options.ModelBindingMessageProvider.SetValueIsInvalidAccessor(
            _ => this.localizer["The value '{0}' is invalid."]);
    });
}

private IStringLocalizer localizer;

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    this.localizer = app.ApplicationServices
        .GetRequiredService<IStringLocalizerFactory>()
        .Create("ModelBindingMessages", "AspNetCoreLocalizationSample");
}
Run Code Online (Sandbox Code Playgroud)

The downside of this solution is that this causes Temporal Coupling, which is a code smell of its own.

You could, of course, argue that this a ugly workaround for a problem that might not even exist when dealing with IStringLocalizerFactory; creating a temporary service provider to resolve the localization factory might work just fine in that particular case. Thing is, however, that it is actually pretty hard to analyze whether or not you’re going to run in trouble. For instance:

  • Even though ResourceManagerStringLocalizerFactory, which is the default localizer factory, does not contain any state, it does takes a dependency on other services, namely IOptions<LocalizationOptions> and ILoggerFactory. Both of which are configured as singletons.
  • The default ILoggerFactory implementation (i.e. LoggerFactory), is created by the service provider, and ILoggerProvider instances can be added afterwards to that factory. What will happen if your second ResourceManagerStringLocalizerFactory depends on its own ILoggerFactory implementation? Will that work out correctly?
  • Same holds for IOptions<T>—implemented by OptionsManager<T>. It is a singleton, but OptionsManager<T> itself depends on IOptionsFactory<T> and contains its own private cache. What will happen if there is a second OptionsManager<T> for a particular T? And could that change in the future?
  • What if ResourceManagerStringLocalizerFactory is replaced with a different implementation? This is a not-unlikely scenario. What would the dependency graph than look like and would that cause trouble if lifestyles get torn?
  • In general, even if you would be able to conclude that works just fine right, are you sure that this will hold in any future version of ASP.NET Core? It is not that hard to imagine that an update to a future version of ASP.NET Core will break your application in utterly subtle and weird ways because you implicitly depend on this specific behavior. Those bugs will be pretty hard to track down.

Unfortunately, when it comes to configuring the ModelBindingMessageProvider, there seems no easy way out. This is IMO a design flaw in the ASP.NET Core MVC. Hopefully Microsoft will fix this in a future release.