服务定位器与构造函数注入性能
Service Locator vs Constructor injection performance
服务定位器被认为是一种反模式。但是,如果仅在某些条件下使用它们,那么在构造函数中获取所有必要的依赖项是否正确?
方法 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,但仅限于某些情况。使用服务定位器提高性能和内存是否更好?
在讨论这两种方法之间的性能差异之前,我需要先讲讲 Service Locator anti-pattern,因为并非每个对 DI 容器的回调都是 Service Locator 的实现。
应防止应用程序代码调用 DI 容器(或对其进行抽象),例如在你的 MVC 控制器中,或者你的业务层的代码部分。来自代码库这些部分的回调可以被视为服务定位器的示例。
来自应用程序 启动路径、a.k.a 部分的回调。 Composition Root, on the other hand, are not considered to Service Locator implementations. That's because the Service Locator pattern is more than the mechanical description of a Resolve API, but rather a description of the role it plays 在你的应用程序中。这些从 inside Composition Root 对 Container 的调用是很好的、有益的,甚至是您的应用程序运行所必需的。因此,对于我的回答的其余部分,我宁愿参考“对 DI 容器的回调”而不是“使用服务定位器模式”。
谈到性能,有很多事情需要考虑。我不可能提及所有可能的性能瓶颈和调整,但我会提及我认为在您的问题上下文中最值得讨论的几件事。
通过回调容器来延迟解析依赖项是否比构造函数注入更快取决于很多因素。一般来说,我会说在这两种情况下性能通常是无关紧要的,因为对象组合不太可能成为应用程序的性能瓶颈。在大多数情况下,I/O 占用了大部分时间。通常最好将时间花在优化上 I/O — 它可以用更少的投资获得更好的性能。
也就是说,需要意识到的一件事是,DI 容器通常经过高度优化,并且可以在编译组成应用程序对象图的生成代码期间进行优化。但是,当您开始通过懒惰地回调容器来分解对象图时,这些优化就会被抛弃。这使得构造函数注入成为一种更优化的方法,与将对象图分解成多个部分并一个一个地解析它们相比。
例如,如果我使用我维护的 DI 容器 Simple Injector,它会在开始编译之前对生成的表达式树进行相当积极的优化。这些优化包括:
- 通过在图中重复使用编译代码来减少编译代码的大小。
- 通过将它们缓存在编译方法内的变量中,优化图中范围内组件的请求。这可以防止重复的字典查找。
你的里程显然会有所不同,但大多数 DI 容器都会执行某种优化。我不确定内置 ASP.NET 核心 DI 容器应用了哪些优化,但据我所知它的优化是有限的。
调用容器的 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。遵循它可以防止构造函数获得许多依赖项并防止构造函数过度注入代码异味。包含具有许多依赖项的 类 的对象图往往会变得更大,因此解析速度更慢。不过,这种最佳做法在处理那些有时使用的依赖项时没有帮助。
但是,您可能无法重构如此缓慢的构造函数,这需要您防止它被急切加载。但在其他情况下,预加载可能会导致问题。例如,当您的应用程序使用 Composites or Mediators 时,就会发生这种情况。 Composites 和 Mediators 通常包装许多组件,并且可以将传入调用转发给它们的有限子集。特别是 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
将两种不同的命令类型转发给中介。
即使使用简单的注入构造函数,当应用程序包含数百个 'handlers' 时,前面的示例也可能导致性能问题,以防这些处理程序本身包含深层对象图并且每次都重新创建 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
实现(以及回调)应该驻留在 inside 您的组合根中。可能的 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<T>
,即使某些 DI 容器支持这一点。这只会使消费者的代码及其测试变得复杂,并且很容易忘记将 Lazy<T>
应用于该依赖项的所有构造函数。
相反,创建代理会是更好的方法。该代理将位于组合根内,并且将包装一个 Lazy<T>
或回调到容器中:
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
的消费者保持干净,并且不会使用任何机制来延迟依赖项的创建。您现在注入 DelayedDependencyProxy
.
,而不是将 RealDependency
注入 IDependency
的消费者
最后但重要的一点:一定要防止过早的优化。更喜欢构造函数注入而不是容器回调,即使容器回调更快。如果您怀疑构造函数注入存在任何性能瓶颈:测量、测量、测量。并验证瓶颈是否真的存在于对象组合本身,或者存在于您的组件之一的构造函数中。如果已修复,请确认这会带来足够显着的性能提升,以证明它导致的复杂性增加是合理的。对于大多数应用程序来说,1 毫秒的性能提升并不重要。
服务定位器被认为是一种反模式。但是,如果仅在某些条件下使用它们,那么在构造函数中获取所有必要的依赖项是否正确?
方法 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,但仅限于某些情况。使用服务定位器提高性能和内存是否更好?
在讨论这两种方法之间的性能差异之前,我需要先讲讲 Service Locator anti-pattern,因为并非每个对 DI 容器的回调都是 Service Locator 的实现。
应防止应用程序代码调用 DI 容器(或对其进行抽象),例如在你的 MVC 控制器中,或者你的业务层的代码部分。来自代码库这些部分的回调可以被视为服务定位器的示例。
来自应用程序 启动路径、a.k.a 部分的回调。 Composition Root, on the other hand, are not considered to Service Locator implementations. That's because the Service Locator pattern is more than the mechanical description of a Resolve API, but rather a description of the role it plays 在你的应用程序中。这些从 inside Composition Root 对 Container 的调用是很好的、有益的,甚至是您的应用程序运行所必需的。因此,对于我的回答的其余部分,我宁愿参考“对 DI 容器的回调”而不是“使用服务定位器模式”。
谈到性能,有很多事情需要考虑。我不可能提及所有可能的性能瓶颈和调整,但我会提及我认为在您的问题上下文中最值得讨论的几件事。
通过回调容器来延迟解析依赖项是否比构造函数注入更快取决于很多因素。一般来说,我会说在这两种情况下性能通常是无关紧要的,因为对象组合不太可能成为应用程序的性能瓶颈。在大多数情况下,I/O 占用了大部分时间。通常最好将时间花在优化上 I/O — 它可以用更少的投资获得更好的性能。
也就是说,需要意识到的一件事是,DI 容器通常经过高度优化,并且可以在编译组成应用程序对象图的生成代码期间进行优化。但是,当您开始通过懒惰地回调容器来分解对象图时,这些优化就会被抛弃。这使得构造函数注入成为一种更优化的方法,与将对象图分解成多个部分并一个一个地解析它们相比。
例如,如果我使用我维护的 DI 容器 Simple Injector,它会在开始编译之前对生成的表达式树进行相当积极的优化。这些优化包括:
- 通过在图中重复使用编译代码来减少编译代码的大小。
- 通过将它们缓存在编译方法内的变量中,优化图中范围内组件的请求。这可以防止重复的字典查找。
你的里程显然会有所不同,但大多数 DI 容器都会执行某种优化。我不确定内置 ASP.NET 核心 DI 容器应用了哪些优化,但据我所知它的优化是有限的。
调用容器的 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。遵循它可以防止构造函数获得许多依赖项并防止构造函数过度注入代码异味。包含具有许多依赖项的 类 的对象图往往会变得更大,因此解析速度更慢。不过,这种最佳做法在处理那些有时使用的依赖项时没有帮助。
但是,您可能无法重构如此缓慢的构造函数,这需要您防止它被急切加载。但在其他情况下,预加载可能会导致问题。例如,当您的应用程序使用 Composites or Mediators 时,就会发生这种情况。 Composites 和 Mediators 通常包装许多组件,并且可以将传入调用转发给它们的有限子集。特别是 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
将两种不同的命令类型转发给中介。
即使使用简单的注入构造函数,当应用程序包含数百个 'handlers' 时,前面的示例也可能导致性能问题,以防这些处理程序本身包含深层对象图并且每次都重新创建 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
实现(以及回调)应该驻留在 inside 您的组合根中。可能的 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<T>
,即使某些 DI 容器支持这一点。这只会使消费者的代码及其测试变得复杂,并且很容易忘记将 Lazy<T>
应用于该依赖项的所有构造函数。
相反,创建代理会是更好的方法。该代理将位于组合根内,并且将包装一个 Lazy<T>
或回调到容器中:
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
的消费者保持干净,并且不会使用任何机制来延迟依赖项的创建。您现在注入 DelayedDependencyProxy
.
RealDependency
注入 IDependency
的消费者
最后但重要的一点:一定要防止过早的优化。更喜欢构造函数注入而不是容器回调,即使容器回调更快。如果您怀疑构造函数注入存在任何性能瓶颈:测量、测量、测量。并验证瓶颈是否真的存在于对象组合本身,或者存在于您的组件之一的构造函数中。如果已修复,请确认这会带来足够显着的性能提升,以证明它导致的复杂性增加是合理的。对于大多数应用程序来说,1 毫秒的性能提升并不重要。