【问题标题】:Service Locator vs Constructor injection performance服务定位器与构造器注入性能
【发布时间】:2021-09-26 09:27:22
【问题描述】:

Service Locator 被认为是一种反模式。但是如果只在特定条件下使用,在构造函数中获取所有必要的依赖是否正确?

方法 1(服务定位器)

public class MyType
{
    public void MyMethod()
    {
        if (someRareCondition1)
        {
            var dep1 = Locator.Resolve<IDep1>();
            dep1.DoSomething();
        }

        if (someRareCondition2)
        {
            var dep2 = Locator.Resolve<IDep2>();
            dep2.DoSomething();
        }
    }
}

方法 2(构造函数注入)

public class MyType
{
    private readonly IDep1 dep1;
    private readonly IDep2 dep2;

    public MyType(IDep1 dep1, IDep2 dep2)
    {
        this.dep1 = dep1;
        this.dep2 = dep2;
    }

    public void MyMethod()
    {
        if (someRareCondition1)
        {
            dep1.DoSomething();
        }

        if (someRareCondition2)
        {
            dep2.DoSomething();
        }
    }
}

您可以有许多需要不同依赖项的不同 void,但仅在某些情况下。使用服务定位器来提高性能和内存是否更好?

【问题讨论】:

  • Lazy&lt;Dependency&gt; - autofac.readthedocs.io/en/latest/resolve/relationships.html 。简短的回答 - 难以置信地 Service Locator 更可取。
  • 是的,我会说同时使用两个依赖项是正确的。您还可以将条件从方法中抽象出来并放入某种策略模式中,这样您就只需要 1 个依赖项,但该依赖项知道要做什么取决于 someRareConditionN
  • 对象构造应该是轻量级和快速的,因此,即使您的构造函数需要一个未使用的对象,也不应该影响性能。
  • 注入一个工厂。工厂将根据需要创建所需的类型。
  • 除了性能之外,通过构造函数注入的依赖项使得使用模拟依赖项对类进行单元测试变得更加容易。如果您使用构造函数依赖项,则不需要在 ServiceLocator 中映射模拟。

标签: c# performance .net-core dependency-injection


【解决方案1】:

在我谈论这两种方法的性能差异之前,我需要先做个铺垫,谈谈Service Locator anti-pattern,因为并非每个对 DI Container 的回调都是 Service Locator 的实现。

应防止应用程序代码调用 DI 容器(或对其进行抽象),例如在您的 MVC 控制器或业务层的代码部分中。来自代码库这些部分的回调可以被视为服务定位器的示例。

另一方面,来自应用程序启动路径部分的回调,也就是Composition Root考虑到服务定位器实现。这是因为服务定位器模式不仅仅是对 Resolve API 的机械描述,而是在您的应用程序中对 the role it plays 的描述。这些从合成根内部对容器的调用很好、有益,甚至是应用程序运行所必需的。因此,对于我回答的其余部分,我宁愿参考“回调 DI 容器”而不是“使用服务定位器模式”。

在性能方面,有很多事情需要考虑。我不可能提及所有可能的性能瓶颈和调整,但我会提及我认为在您的问题背景下最值得谈论的几件事。

通过回调容器来延迟解析依赖是否比构造函数注入更快取决于很多因素。一般来说,我会说在这两种情况下,性能通常都无关紧要,因为对象组合不太可能成为应用程序的性能瓶颈。在大多数情况下,I/O 占用了大部分时间。时间通常最好花在优化 I/O 上,这样可以用更少的投资获得更好的性能。

也就是说,要意识到的一件事是 DI 容器通常经过高度优化,并且可以在编译生成的代码(构成应用程序的对象图)期间进行优化。但是当你开始通过懒惰地回调容器来分解对象图时,这些优化就会被抛弃。这使得构造函数注入成为一种更优化的方法,与将对象图分解成碎片并逐个解析它们相比。

如果我使用 Simple Injector(我维护的 DI 容器)作为示例,它会在开始编译之前对生成的表达式树进行非常积极的优化。这些优化包括:

  • 通过在图表中重用已编译代码来减少已编译代码的大小。
  • 通过在已编译方法内的变量中缓存它们来优化图中范围组件的请求。这可以防止重复的字典查找。

您的里程显然会有所不同,但大多数 DI Container 都会执行某种优化。我不确定内置的 ASP.NET Core DI 容器应用了哪些类型的优化,但是 AFAIK 它的优化是有限的。

调用 Container 的 Resolve 方法会产生开销。至少它会导致从请求的类型到能够构成该类型图的代码的字典查找,而对于已解决的依赖项,字典查找往往不会发生(那么多)。但实际上对Resolve 的调用往往会进行一些有效性检查和其他必需的逻辑,这会增加此类调用的开销。这也是为什么构造函数注入比回调更优化的另一个原因。

现代 DI 容器通常经过优化,因此它们可以轻松解析大型对象图(尽管对于某些容器,对象图的大小存在限制,尽管该限制通常非常大)。与手动创建相同的对象图(使用普通的旧 C#)相比,它们的开销通常很小(尽管存在差异和例外)。但这只有在您遵循keep your injection constructors simple 的最佳实践时才有效。当注入构造函数很简单时,是否注入仅在部分时间使用的依赖项无关紧要。

如果您未能遵循此最佳实践,例如通过使用回调到数据库或执行一些日志记录到磁盘的注入构造函数,对象图解析的性能可能会大大降低。当您处理并不总是使用的组件时,这肯定会很痛苦。这似乎是您问题的背景。这是一个有问题的注入构造函数的示例:

// This Injection Constructor does more than just receiving its dependencies.
public OrderShippingService(
    ILogger logger, IConfigurationProvider provider)
{
    this.logger = logger;

    // Here it starts using its dependencies.
    logger.LogInfo("Creating OrderShippingService.");
    this.config = provider.Load<OrderShippingServiceConfig>();
    logger.LogInfo("OrderShippingService Config loaded.");
}

因此,我的建议是:遵循“简单注入构造函数”最佳实践,并确保注入构造函数仅接收和存储其传入的依赖项。不要使用构造函数内部的依赖项。这种做法也有助于处理仅在部分时间使用的依赖项,因为当这些依赖项快速创建时,问题就会消失,并且与使用回调相比,使用构造函数注入通常仍然更快。

除此之外,还应遵循其他最佳做法,例如Single Responsibility Principle。遵循它可以防止构造函数获得许多依赖项并防止构造函数过度注入代码气味。包含具有许多依赖关系的类的对象图往往会变得更大,因此解析速度会更慢。但是,这种最佳实践在处理那些有时使用的依赖项时并没有帮助。

但是,您可能无法重构如此缓慢的构造函数,这需要您防止它被急切加载。但是在其他情况下,急切加载可能会导致问题。例如,当您的应用程序使用CompositesMediators 时,就会发生这种情况。 Composites 和 Mediator 通常包装许多组件,并且可以将传入呼叫转发到其中的有限子集。尤其是 Mediator,它通常将调用转发到单个组件。例如:

// Component using a mediator abstraction.
public class ShipmentController : Controller
{
    private readonly IMediator mediator;
    
    public void ShipOrder(ShipOrderCommand cmd) =>
        this.mediator.Execute(cmd);
    
    public void CancelOrder(CancelOrderCommand cmd) =>
        this.mediator.Execute(cmd);
}

在上面的代码中,IMediator 实现应该将Execute 调用转发给知道如何处理提供的命令的组件。在此示例中,ShipmentController 将两种不同的命令类型转发给中介。

即使使用简单的注入构造函数,当应用程序包含数百个“处理程序”时,前面的示例也可能会导致性能问题,以防这些处理程序本身包含深层对象图并且每次组合 ShipmentController 时都会重新创建。

以下实现演示了这些性能问题:

class Mediator : IMediator
{
    private readonly IHandler[] handlers;
    
    public Mediator(IHandler[] handlers) =>
        this.handlers = handlers;

    public void Execute<T>(T command) =>
        this.handlers.OfType<IHandler<T>>().Single()
            .Execute(command);
    }
}

在这个例子中,所有的处理程序都是在Mediator 之前创建的,并注入到Mediator 的构造函数中,而对Execute 的调用只是从列表中选择一个。当有许多处理程序、需要为每个请求创建并包含它们自己的许多依赖项时,这可能会导致性能问题。

为防止出现此性能问题,可以考虑回调 DI 容器。不过,它不需要服务定位器反模式,因为Mediator 实现(因此,回调)应该驻留在您的组合根中内部。一个可能的IMediator 实现可能如下所示:

// As long as this implementation is placed inside the Composition Root,
// this is -not- an implementation of the Service Locator anti-pattern.
class Mediator : IMediator
{
    private readonly Container container;
    
    public Mediator(Container container) =>
        this.container = container;

    public void Execute<T>(T cmd) =>
        this.container.Resolve<IHandler<T>>().Execute(cmd);
}

在这种情况下,仅从 DI 容器请求相关的处理程序,而不是全部。这意味着此时的 DI 容器只为该特定处理程序创建对象图。

然而,在所有情况下,您都应该避免从应用程序代码中回调 DI 容器。我什至会争辩说不要为有条件使用的依赖注入Lazy&lt;T&gt;,即使某些 DI 容器对此提供支持。这只会使消费者的代码及其测试变得复杂,并且很容易忘记将Lazy&lt;T&gt; 应用于该依赖项的所有构造函数。

相反,创建代理会是更好的方法。该代理将存在于 Composition Root 中,并且将包装 Lazy&lt;T&gt; 或回调到容器中:

public class DelayedDependencyProxy : IDependency
{
    private readonly Container container;
    private IDependency real;
    
    public DelayedDependencyProxy(Container container) =>
        this.container = container;

    public object SomeMethod()
    {
        if (this.real is null)
            this.real = this.container.Resolve<RealDependency>();
        
        return this.real.SomeMethod();
    }    
}

此代理使IDependency 的消费者保持干净,并且不会使用任何机制来延迟依赖项的创建。不是将RealDependency 注入IDependency 的消费者,而是注入DelayedDependencyProxy

最后但重要的一点:要防止过早的优化。即使容器回调更快,也更喜欢构造函数注入而不是容器回调。如果您怀疑构造函数注入存在任何性能瓶颈:测量、测量、测量。并验证瓶颈是否真的在对象组合本身,或者在你的组件之一的构造函数中。如果已修复,请验证这是否可以显着提高性能,以证明其导致的复杂性增加是合理的。对于大多数应用程序而言,1 毫秒的性能提升并不重要。

【讨论】:

  • 除此之外:当对象构造恰好如此昂贵时,您需要考虑懒惰地解决依赖关系(这已经是您应该避免的事情,正如 Steven 提到的),然后考虑重新评估(昂贵的)组件的生命周期:例如,以前构造的单例几乎可以立即解决,因此即使您可能不需要它们,也不必担心它们会通过构造函数注入解决。
  • 伟大的补充@poke。可以使用生命周期管理来管理性能。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2012-08-25
  • 2021-12-17
  • 1970-01-01
  • 2017-02-09
  • 2013-01-21
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多