在 ConfigureServices() 中调用 BuildServiceProvider() 的成本和可能的副作用是什么 [英] What are the costs and possible side effects of calling BuildServiceProvider() in ConfigureServices()

查看:42
本文介绍了在 ConfigureServices() 中调用 BuildServiceProvider() 的成本和可能的副作用是什么的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

有时,在服务注册期间,我需要从 DI 容器解析其他(已注册的)服务.对于像 Autofac 或 DryIoc 这样的容器,这没什么大不了的,因为您可以在一行注册服务,然后在下一行立即解决它.

但是对于 Microsoft 的 DI 容器,您需要注册服务,然后构建服务提供者,然后才能从该 IServiceProvider 实例解析服务.

请参阅此问题的已接受答案:ASP.NET Core Model绑定错误信息本地化

public void ConfigureServices(IServiceCollection services){services.AddLocalization(options => { options.ResourcesPath = "Resources"; });services.AddMvc(options =>{var F = services.BuildServiceProvider().GetService();var L = F.Create("ModelBindingMessages", "AspNetCoreLocalizationSample");options.ModelBindingMessageProvider.ValueIsInvalidAccessor =(x) =>L["值 '{0}' 无效."];//省略了代码段的其余部分})}

为了能够本地化 ModelBindingMessageProvider.ValueIsInvalidAccessor 消息,答案建议通过基于当前服务集合构建的服务提供者解析一个 IStringLocalizerFactory.

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

解决方案

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

<块引用>

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

这意味着每个服务提供者都有自己的单例实例缓存.从同一来源(即从同一个服务集合)构建多个服务提供者将导致单例实例被创建多次——这破坏了给定单例注册最多有一个实例的保证.

但是还有其他一些可能出现的细微错误.例如,在解析包含作用域依赖项的对象图时.为创建存储在下一个容器中的对象图构建单独的临时服务提供者可能会导致这些范围依赖项在应用程序的持续时间内保持活动状态.这个问题通常被称为Captive Dependencies.

<块引用>

对于像 Autofac 或 DryIoc 这样的容器,这没什么大不了的,因为您可以在一行注册服务,然后在下一行立即解决它.

此声明意味着在注册阶段仍在进行时尝试从容器解析实例没有问题.然而,这是不正确的——在你已经解决实例之后通过向容器添加新注册来改变容器是一种危险的做法——它可能导致各种难以跟踪的错误,独立于使用的 DI 容器.

尤其是因为那些难以跟踪的错误,DI 容器(例如 Autofac、Simple Injector 和 Microsoft.Extensions.DependencyInjection (MS.DI))首先阻止了您执行此操作.Autofac 和 MS.DI 通过在容器构建器"(AutoFac 的 ContainerBuilder 和 MS.DI 的 ServiceCollection)中进行注册来做到这一点.另一方面,Simple Injector 不会进行这种拆分.相反,它会在解析第一个实例后锁定容器,以免进行任何修改.然而,效果是相似的;它会阻止您在解决后添加注册.

Simple Injector 文档实际上包含一些关于为什么这种 Register-Resolve-Register 模式有问题的体面的解释:

<块引用>

想象一下您想用相同的 ILogger 接口为不同的实现替换某些 FileLogger 组件的场景.如果存在直接或间接依赖于 ILogger 的组件,替换 ILogger 实现可能不会像您预期的那样工作.例如,如果消费组件注册为单例,则容器应保证只会创建该组件的一个实例.如果在单例实例已经拥有对旧"注册实现的引用后允许您更改 ILogger 的实现,则容器有两种选择——这两种选择都不正确:>

  • 返回引用了错误"ILogger 实现的消费组件的缓存实例.
  • 创建并缓存该组件的新实例,这样做会破坏将类型注册为单例的承诺以及容器将始终返回相同实例的保证.

出于同样的原因,您会看到 ASP.NET Core Startup 类定义了两个单独的阶段:

  • 添加"阶段(ConfigureServices 方法),您可以在此向容器构建器"(又名 IServiceCollection)添加注册
  • 使用"阶段(Configure 方法),您可以在该阶段通过设置路由来声明您要使用 MVC.在这个阶段,IServiceCollection 已经变成了一个 IServiceProvider,这些服务甚至可以被方法注入到 Configure 方法中.

因此,一般的解决方案是将解析服务(如您的 IStringLocalizerFactory)推迟到使用"阶段,并推迟依赖于服务解析的事物的最终配置.

不幸的是,在配置 ModelBindingMessageProvider 时,这似乎导致了先有鸡还是先有蛋 因果关系困境,因为:

  • 配置 ModelBindingMessageProvider 需要使用 MvcOptions 类.
  • MvcOptions 类仅在添加"(ConfigureServices) 阶段可用.
  • 在添加"阶段,无法访问 IStringLocalizerFactory,也无法访问容器或服务提供者,并且无法通过使用 Lazy< 创建此类值来推迟解决此问题;IStringLocalizerFactory>.
  • 在使用"阶段,IStringLocalizerFactory 可用,但此时,没有任何 MvcOptions 可用于配置 ModelBindingMessageProvider.

解决这个僵局的唯一方法是在 Startup 类中使用私有字段,并在 AddOptions 的闭包中使用它们.例如:

public void ConfigureServices(IServiceCollection services){services.AddLocalization();services.AddMvc(options =>{options.ModelBindingMessageProvider.SetValueIsInvalidAccessor(_ =>this.localizer[值{0}"无效."]);});}私有 IStringLocalizer 本地化器;公共无效配置(IApplicationBuilder 应用程序,IHostingEnvironment 环境){this.localizer = app.ApplicationServices.GetRequiredService().Create("ModelBindingMessages", "AspNetCoreLocalizationSample");}

此解决方案的缺点是这会导致 时间耦合,是它自己的代码味道.

当然,您可以争辩说,对于在处理 IStringLocalizerFactory 时甚至可能不存在的问题,这是一种丑陋的解决方法;在这种特殊情况下,创建一个临时服务提供者来解决本地化工厂可能会正常工作.然而,实际上很难分析您是否会遇到麻烦.例如:

  • 尽管 ResourceManagerStringLocalizerFactory 是默认的本地化工厂,不包含任何状态,但它确实依赖于其他服务,即 IOptions 和 <代码>ILoggerFactory.两者都配置为单例.
  • 默认的 ILoggerFactory 实现(即 LoggerFactory)由服务提供者创建,并且 ILoggerProvider 实例可以随后添加到该工厂.如果您的第二个 ResourceManagerStringLocalizerFactory 依赖于它自己的 ILoggerFactory 实现,会发生什么?这样做会正确吗?
  • 同样适用于 IOptions——由 OptionsManager 实现.它是一个单例,但 OptionsManager 本身依赖于 IOptionsFactory 并包含它自己的私有缓存.如果特定 T 有第二个 OptionsManager 会发生什么?这在未来会改变吗?
  • 如果 ResourceManagerStringLocalizerFactory 被替换为不同的实现会怎样?这是一个不太可能发生的情况.依赖关系图会是什么样子,如果生活方式被破坏会导致麻烦吗?
  • 一般来说,即使您能够得出结论现在工作得很好,您确定这将适用于 ASP.NET Core 的任何未来版本吗?不难想象,对未来版本的 ASP.NET Core 的更新会以非常微妙和奇怪的方式破坏您的应用程序,因为您隐式地依赖于这种特定行为.这些错误很难追踪.

不幸的是,在配置 ModelBindingMessageProvider 时,似乎没有简单的出路.这是 IMO ASP.NET Core MVC 中的一个设计缺陷.希望微软会在未来的版本中修复这个问题.

Sometimes, during service registrations, I need to resolve other (already registered) services from the DI container. With containers like Autofac or DryIoc this was no big deal since you could register the service on one line and on the next line you could immediately resolve it.

But with Microsoft's DI container you need to register the service, then build a service provider and only then you are able resolve the services from that IServiceProvider instance.

See the accepted answer this SO question: ASP.NET Core Model Binding Error Messages Localization

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

To be able to localize the ModelBindingMessageProvider.ValueIsInvalidAccessor message, the answer suggests to resolve a IStringLocalizerFactory through the service provider built based on the current service collection.

What is the cost of "building" the service provider at that point and are there any side effects of doing that, since the service provider will be built at least once more (after all services are added)?

解决方案

Each service provider has its own cache. Building multiple service provider instances can, therefore, lead to a problem called Torn Lifestyles:

When multiple [registrations] with the same lifestyle map to the same component, the component is said to have a torn lifestyle. The component is considered torn because each [registration] will have its own cache of the given component, which can potentially result in multiple instances of the component within a single scope. When the registrations are torn the application may be wired incorrectly which could lead to unexpected behavior.

This means that each service provider will have its own cache of singleton instances. Building multiple service providers from the same source (i.e. from the same service collection) will cause a singleton instance to be created more than once—this breaks the guarantee that there is at most one instance for a given singleton registration.

But there are other, just as subtle bugs that can appear. For instance, when resolving object graphs that contain scoped dependencies. Building a separate temporary service provider for the creation of an object graph that is stored in the next container might cause those scoped dependencies to be kept alive for the duration of the application. This problem is commonly referred to as Captive Dependencies.

With containers like Autofac or DryIoc this was no big deal since you could register the service on one line and on the next line you could immediately resolve it.

This statement implies that there are no problems with trying to resolve instances from the container while the registration phase is still in progress. This, however, is incorrect—altering the container by adding new registrations to it after you already resolved instances is a dangerous practice—it can lead to all sorts of hard to track bugs, independently of the used DI Container.

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

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 now, 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.

这篇关于在 ConfigureServices() 中调用 BuildServiceProvider() 的成本和可能的副作用是什么的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆