第八章-对象生命周期
对象生命周期(Object lifetime)
时间的流逝对大多数食物和饮料都有深远的影响,但后果却各不相同。 就个人而言,我们发现12个月大的Gruyère比6个月大的Gruyère有趣,但Mark希望他的芦笋比任何一种都新鲜。在许多情况下,评估一件物品的适当年龄很容易; 但是在某些情况下,这样做变得很复杂。 当涉及到葡萄酒时,这一点最为明显(见图8.1)。
葡萄酒往往会随着年龄的增长而变得更好-直到它们突然变老并失去大部分风味。 这取决于许多因素,包括葡萄酒的来源和年份。 尽管葡萄酒引起了我们的兴趣,但我们从未期望过我们能够预测葡萄酒何时达到顶峰。 为此,我们依靠专家:家里的书和饭店的侍酒师。他们比我们更了解葡萄酒,所以我们很乐意让他们掌控葡萄酒。
图8.1 葡萄酒,奶酪和芦笋。 尽管两者的组合可能有些不足,但它们的年龄会极大地影响其整体素质。 |
---|
除非您直接阅读本章而不读任何先前的内容,否则您会知道,放开控制是DI中的关键概念。这源于控制反转(Inversion of Control)原则,在该原则下,您将对依赖项的控制权委派给第三方,但这还意味着不仅仅是让其他人选择必需的抽象的实现。当您允许Composer提供依赖关系时,您还必须接受无法控制其寿命的限制。
定义 Composer是一个统一术语,指的是组成依赖项的任何对象或方法。这是组合根(Composition Root)的重要组成部分。Composer通常是一个DI容器(DI Container),但是它也可以是任何手动构造对象图(使用Pure DI)的方法。
就像侍酒师完全了解餐厅酒窖的内容并可以做出比我们更明智的决定一样,我们应该相信Composer能够比消费者更有效地控制依赖关系的寿命。组成和管理组件是其唯一职责。
在本章中,我们将探讨依赖生命周期管理(Dependency Lifetime Management.)。理解此主题很重要,因为如果您在错误的年龄(自己的年龄和所用的葡萄酒的年龄)下饮用葡萄酒都能获得出色的体验,那么错误地配置依赖生命周期(Dependency Lifetime)可能会导致性能下降。更糟糕的是,您可能会得到等同于变质食物的生命周期管理:资源泄漏。了解正确管理组件生命周期的原理应使您能够做出明智的决定,以正确配置应用程序。
首先,我们将对依赖生命周期管理(Dependency Lifetime Management.)进行一般性介绍,然后再讨论有关一次性依赖的问题。 本章的第一部分旨在提供您需要的所有背景信息和指导原则,以便就您自己的应用程序的生命周期,范围和配置做出明智的决策。
之后,我们将研究不同的生命周期策略。本章的这一部分采用可用生活方式的目录形式。在大多数情况下,这些原始的生活方式模式中的一种将很好地应对给定的挑战,因此,预先了解它们可以使您应对许多困难的情况。
定义 生命周期模式是描述依赖关系预期生命周期的一种形式化方法。
我们将以一些有关生命周期管理(Lifetime Management)的坏习惯或反模式(anti-pattern)来结束本章。完成之后,您应该对生命周期管理(Lifetime Management)和常见的Lifestyle应该做的和不应该的都有很好的了解。 首先,让我们看一下对象生命周期(Object Lifetime)以及它与DI的一般关系。
管理依赖项生命周期(Managing Dependency Lifetime)
到目前为止,我们主要讨论了DI如何使您能够组成依赖关系。上一章对这一主题进行了详尽的探讨,但是,正如我们在1.4节中提到的那样,对象组合(Object Composition)只是DI的一个方面。 生命周期管理(Lifetime Management)是另一回事
第一次向我们介绍DI的范围包括生命周期管理的想法时,我们无法理解对象组成和对象生命周期之间的深层联系。我们终于知道了,它很简单,让我们来看一下!
在本节中,我们将介绍生命周期管理及其如何应用于依赖项。我们将研究组成对象的一般情况,以及它对依赖项生命周期的影响。首先,我们将研究为什么对象组合(Object Composition)意味着生命周期管理。
引入生命周期管理(Lifetime Management)
当我们接受我们应该放开对依赖关系控制的心理需求,而通过构造函数注入(Constructor Injection)或其他DI模式之一请求它们时,我们必须完全放开。 要了解原因,我们将逐步研究该问题。首先,让我们回顾一下标准.NET对象生命周期对依赖关系的含义。您可能已经知道这一点,但是在建立上下文时,请耐心等待下一页。
简单依赖生命周期
您知道DI意味着您让第三方(通常是我们的组合根(Composition Root))为您提供所需的依赖关系。 这也意味着您必须让它管理依赖项的生命周期。 关于对象创建,这是最容易理解的。这是示例电子商务应用程序的组合根(Composition Root)中的一个(略微重组的)代码片段。(您可以在清单7.8中看到完整的示例。)
var productRepository = new SqlProductRepository(new CommerceContext(connectionString));
var productService = new ProductService( productRepository, userContext);
我们希望很明显,在创建productRepository
时,ProductService
类不会控制。在这种情况下,可能会在同一毫秒内创建SqlProductRepository
。但作为一项思想实验,我们可以在这两行代码之间插入对Thread.Sleep
的调用,以证明您可以随时间任意分离它们。那将是一件很奇怪的事情,但要点是,并非必须同时创建依赖图的所有对象。
消费者不能控制其依赖关系的创建,但是销毁又如何呢?通常,您不控制何时在.NET中销毁对象。垃圾收集器会清理未使用的对象,但是除非您要处理一次性对象,否则您将无法明确销毁对象
注 我们将术语“一次性对象(disposable object)”用作表示实现
IDisposable
接口的类型的对象实例的简写。
当对象超出范围时,它们就有资格进行垃圾回收。 相反,只要他人持有对它们的引用,它们就会持续存在。尽管使用者无法明确销毁某个对象(取决于垃圾回收器),但它可以通过保留引用来保持该对象的生命。这是使用构造函数注入(Constructor Injection)时要做的,因为您将依赖项保存在私有字段中:
public class HomeController
{
private readonly IProductService service;
public HomeController(IProductService service) <---将依赖项注入到类的构造函数中
{
this.service = service; <--将对依赖项的引用保存在私有字段中,至少在消耗HomeController实例处于活动状态时,保持依赖项处于活动状态
}
}
这意味着,当使用者超出范围时,依赖关系也将超出范围。但是,即使使用者超出范围,如果其他对象持有对它的引用,则依存关系仍然可以存在。否则,将被垃圾收集。因为您是一位经验丰富的.NET
开发人员,所以这对您来说可能是个老新闻,但是现在讨论应该开始变得更加有趣。
增加依赖项生命周期的复杂性(Adding complexity to the Dependency lifecycle)
到目前为止,我们对依赖关系生命周期的分析还很平凡,但是现在我们可以增加一些复杂性。 当多个消费者需要相同的依赖关系时会发生什么? 一种选择是为每个使用者提供自己的实例,如图8.2所示。
图8.2 组成一个依赖关系的多个唯一实例 |
---|
以下清单由具有相同依赖项的多个实例的多个使用者组成,如图8.2所示。
清单8.1 用相同依赖项的多个实例组成
var repository1 = new SqlProductRepository(connString);
var repository2 = new SqlProductRepository(connString);
<-----两个使用者都需要一个IProductRepository实例,但是您使用相同的连接字符串连接了两个单独的实例。
var productService = new ProductService(repository1); <--现在,您可以将repository1传递到新的ProductService实例。
var calculator = new DiscountCalculator(repository2); <--您将repository2传递到新的DiscountCalculator实例。
关于清单8.1中每个存储库的生命周期,与之前讨论的示例电子商务应用程序的组合根(Composition Root)相比,没有任何变化。每个依赖项超出范围,并且当其使用者超出范围时被垃圾回收。这可以在不同的时间发生,但情况仅与以前稍有不同。如果两个使用者共享相同的依赖关系,情况将有所不同,如图8.3所示。
图8.3 通过将依赖项的相同实例注入多个使用者来重用它 |
---|
将其应用于清单8.1时,您将获得清单8.2中的代码。
代码清单8.2 使用具有相同依赖关系的单个实例组成
var repository = new SqlProductRepository(connString);
var productService = new ProductService(repository);
var calculator = new DiscountCalculator(repository);
<----无需创建两个不同的SqlProductRepository实例,而是创建一个注入到两个使用者中的单个实例。 两者都保存参考以供以后使用。
比较清单8.1和8.2时,您不会发现一个清单固有地比另一个清单更好。正如我们将在8.3节中讨论的那样,在何时以及如何重用依赖关系时,有几个因素需要考虑。
注 消费者非常高兴地没有意识到依赖项是共享的。由于它们都接受给定的依赖性版本,因此无需修改源代码即可适应依赖性配置中的这一更改。 这是里氏替换原则(Liskov Substitution Principle)的结果。
里氏替换原则(Liskov Substitution Principle)
正如最初所说的那样,里氏替换原则(Liskov Substitution Principle)是Barbara Liskov在1987年定义的一个学术抽象概念。但是在面向对象设计中,我们可以这样解释:“使用抽象的方法必须能够使用派生的任何类。 在没有注意到差异的情况下从该抽象中提取出来。”
我们必须能够在不更改系统正确性的情况下将抽象替换为任意实现。 未能遵守里氏替换原则(Liskov Substitution Principle)会使应用程序变得脆弱,因为它不允许替换依赖项,否则可能会导致使用者中断。
与前面的示例相比,存储库依赖项的生命周期发生了明显变化。 这两个使用者都必须超出范围,变量存储库才有资格进行垃圾回收,并且它们可以在不同时间这样做。当依赖项达到生命周期的尽头时,这种情况变得难以预测。只有当消费者数量增加时,这种特征才能得到加强。
如果有足够多的消费者,那么很可能总是会有一个人来维持依赖关系。这听起来像是一个问题,但很少是这样的:您没有一个类似的实例,而是只有一个实例,可以节省内存。这是一种非常理想的品质,因此我们将其以一种称为Singleton生命周期模式的Lifestyle模式进行形式化。尽管有相似之处,但不要将其与单例设计模式混淆。我们将在8.3.1节中详细介绍该主题。
要理解的关键点是,与任何单个使用者相比,Composer在依赖项的生命周期中具有更大的影响力。 Composer决定何时创建实例,并通过选择是否共享实例来确定依赖项是否超出单个使用者的范围,或者是否所有使用者都必须超出范围才能释放依赖性。
定义 释放(Releasing)是确定哪些依赖项可以被取消引用并可能处置的过程。组合根(Composition Root)向Composer请求对象图。 在组合根(Composition Root)完成了该解析图的处理之后,它通知Composer它已经完成了该图。 然后,作曲者可以决定可以释放该特定图的哪个依存关系。
这相当于拜访一家拥有良好侍酒师的餐厅。侍酒师一天中大部分时间都在管理和发展酒窖:购买新酒,对可用的酒瓶进行采样以追踪酒的发展过程,并与厨师合作确定与所供应食物的最佳搭配。当我们收到酒单时,它仅包括侍酒师认为适合今天菜单的内容。我们可以根据自己的口味自由选择葡萄酒,但我们假设与餐厅侍酒师相比,我们不了解更多关于餐厅选择的葡萄酒以及它们与食物搭配的信息。侍酒师通常会决定将很多瓶子保存几年。就像您将在下一节中看到的那样,Composer可能会决定通过保留实例的引用来使实例保持活动状态。
使用Pure DI管理生命周期(Managing lifetime with Pure DI)
上一节说明了如何更改依赖项的组成以影响其寿命。在本节中,我们将研究如何应用Pure DI并同时应用两种最常用的生活方式:Transient和Singleton。
在第7章中,您创建了专门的类来组成应用程序。 其中之一是用于ASP.NET Core MVC
应用程序-我们的Composer的CommerceControllerActivator
。清单7.8显示了其Create
方法的实现。
您可能还记得,Create
方法每次调用时都会动态创建整个对象图。每个依赖关系对已发布的控制者都是私有的,并且没有共享。当控制器实例超出范围时(每次服务器回复请求时都会这样做),所有依赖项也都超出范围。 这通常称为Transient 生命周期模式,我们将在第8.3.2节中详细介绍。
让我们分析由CommerceControllerActivator
创建的对象图,如图8.4所示,看是否还有改进的余地AspNetUserContextAdapter
和RouteCalculator
类都是完全无状态的服务,因此没有必要在每次需要处理请求时都创建一个新实例。连接字符串也不太可能更改,因此您可以在请求之间重用它。另一方面,SqlProductRepository
类依赖于实体框架DbContext
(由我们的CommerceContext
实现),该实体不能在请求之间共享.
图8.4 由CommerceControllerActivator 创建的对象图,该对象图使用其依赖项创建HomeController 和RouteController 实例 |
---|
给定此特定配置,在创建ProductService
和SqlProductRepository
的新实例时,更好的CommerceControllerActivator
实现将重用AspNetUserContextAdapter
和RouteCalculator
的相同实例。简而言之,您应该配置AspNetUserContextAdapter
和RouteCalculator
以使用Singleton 生命周期模式,并使用ProductService
和SqlProductRepository
作为Transient。 以下清单显示了如何实现此更改。
清单8.3 在
CommerceControllerActivator
中管理生命周期
public class CommerceControllerActivator : IControllerActivator
{
private readonly string connectionString;
private readonly IUserContext userContext;
private readonly RouteCalculator calculator; <--只读字段,用于存储Singleton依赖项
public CommerceControllerActivator(string connectionString)
{
this.connectionString = connectionString;
this.userContext =
new AspNetUserContextAdapter();
this.calculator =
new RouteCalculator(
this.CreateRouteAlgorithms()); <--创建Singleton依赖关系并将其存储在私有字段中。 这样,它们可以在应用程序的整个生命周期中被所有请求重用。
}
public object Create(ControllerContext context)
{
Type type = context.ActionDescriptor
.ControllerTypeInfo.AsType();
switch (type.Name)
{
case "HomeController":
return this.CreateHomeController();
case "RouteController":
return this.CreateRouteController();
default:
throw new Exception("Unknown controller " + type.Name);
}
}
private HomeController CreateHomeController()
{
return new HomeController(
new ProductService(
new SqlProductRepository(
new CommerceContext(
this.connectionString)),
this.userContext));
}
private RouteController CreateRouteController()
{
return new RouteController(this.calculator);
} <--29-41rows 每次要求CommerceControllerActivator创建一个新实例时,都会创建一个Transient实例。 较早创建的Singleton被注入到这些瞬态中。
public void Release(ControllerContext context,
object controller) { ... } <--我们暂时将Release方法保留为空,然后在8.2节中返回到该方法。
}
注 清单8.3的
readonly
关键字提供了额外的保证,即这些单例实例一经分配便是永久性的,无法替换。但是,除了上述保证之外,在实现Singleton 生命舟曲模式时也不需要只读。
在MVC
应用程序中,将配置值加载到Startup
类中是很实际的。这就是为什么在清单8.3中将连接字符串提供给CommerceControllerActivator
的构造函数。
清单8.3中的代码在功能上与清单7.8中的代码等效-由于某些依赖项是共享的,因此效率稍高。通过保留您创建的依赖关系,可以将它们保持活动的时间长短。 在此示例中,CommerceControllerActivator
在初始化后立即创建了两个单一的依赖项,但是它也可能使用了惰性初始化。
出于性能方面的考虑,微调每个依赖者生活方式的能力可能很重要,但对于正确的行为也可能很重要。例如,调解器设计模式依赖于共享的控制器,多个组件通过该控制器进行通信,只有在相关的协作者之间共享调解器时,此方法才有效。
到目前为止,我们已经讨论了控制反转如何意味着消费者无法管理其依赖项的生存期,因为他们无法控制对象的创建;并且由于.NET
使用垃圾回收,因此使用者也无法显式销毁对象。这留下了一个悬而未决的问题:如何处理一次性依赖关系?现在,我们将注意力转向这个棘手的问题。
处理一次性依赖项(Working with disposable Dependencies)
尽管.NET
是具有垃圾回收器的托管平台,但它仍可以与非托管代码进行交互。 发生这种情况时,.NET
代码将与未进行垃圾收集的非托管内存进行交互。 为防止内存泄漏,您必须具有一种确定性释放非托管内存的机制。这是IDisposable
接口的主要目的。
某些依赖项实施可能包含非托管资源。例如,ADO.NET
连接是一次性的,因为它们倾向于使用非托管内存。结果,与数据库相关的实现(例如由数据库支持的存储库)本身可能是一次性的。我们应该如何建模一次性依赖关系? 我们也应该让抽象是一次性的吗? 可能看起来像这样:
public interface IMyDependency : IDisposable
重要 这在技术上是可行的,但不是一个特别好的主意。正如我们在6.2.1节中所解释的那样,这种设计的味道表示泄漏抽象类。
如果您希望将IDisposable
添加到界面中,则可能是因为您考虑了特定的实现。但是,您一定不要让这些知识泄漏到界面设计中。 这样做会使其他类难以实现该接口,并将模糊性引入到抽象中。
谁负责处理可弃之遗属? 可能是消费者吗?
消耗一次性依赖(Consuming disposable Dependencies)
为了争辩,假设您有一个可抛弃的抽象,如以下IOrderRepository
接口。
清单8.4 实现
IDisposable
的IOrderRepository
(code smell)
public interface IOrderRepository : IDisposable
OrderService
类应如何处理这种依赖关系? 大多数设计准则(包括Visual Studio的内置代码分析)都坚持认为,如果一个类拥有一个可抛弃的资源作为成员,则它本身应该实现IDisposable
并处置该资源。下一个清单显示了如何。
清单8.5
OrderService
取决于可处置的依赖项 (坏代码)
public sealed class OrderService : IDisposable <--OrderService还实现IDisposable。
{
private readonly IOrderRepository repository;
public OrderService(IOrderRepository repository)
{
this.repository = repository;
}
public void Dispose()
{
this.repository.Dispose(); <--实现Dispose和Dispose IOrderRepository依赖关系
}
}
但这是一个坏主意,因为repository
成员最初是注入的,并且可以由其他使用者共享:
var repository =new SqlOrderRepository(connectionString);
var validator = new OrderValidator(repository);
var orderService = new OrderService(repository); <--SqlOrderRepository的单个实例被注入到OrderValidator和OrderService中。 这两个实例共享IOrderRepository Dependency的相同实例。
orderService.AcceptOrder(order);
orderService.Dispose(); <--如果OrderService在以后的某个时间处理其注入的IOrderRepository,它也会破坏OrderValidator的Dependency。
validator.Validate(order); <--这会导致在OrderValidator尝试在其Validate方法中使用它时引发异常。
如果不处理注入的存储库,则危险性会降低,但这意味着您将忽略抽象是一次性的事实。此外,在这种情况下,抽象类所公开的成员数超过了客户端所使用的成员数,这是违反接口隔离原则的(请参阅第6.2.1节)。将抽象声明为派生自IDisposable
不会带来任何好处。
再有,在某些情况下,您需要发出短暂作用域的开始和结束的信号.IDisposable
有时用于此目的。 在我们研究作曲家如何管理可处置依赖项的寿命之前,我们应该考虑如何处理此类临时性可处置项。
定义 临时性(ephemeral disposable)对象是寿命短且通常不超过单个方法调用的对象。
创建临时性对象(Creating ephemeral disposables)
.NET BCL
中的许多API
使用IDisposable
来表示特定作用域已结束。WCF
代理是最突出的例子之一。
WCF代理和 IDisposable
所有自动生成的
Windows Communication Foundation(WCF)
代理都实现IDisposable
,因此切记要尽快调用代理上的Dispose
方法,这一点很重要。许多绑定在提交第一个请求时会自动在该服务上创建一个会话,并且该会话持续存在 直到超时或被明确处置为止。如果忘记使用后处置
WCF
代理,则会话数会增加,直到达到来自同一源的并发连接的限制为止。当达到限制时,将引发异常。太多的会话也给服务增加了不必要的负担,因此尽快处置WCF
代理很重要。
重要的是要记住,为此目的使用IDisposable
不必表示泄漏抽象,因为这些类型并非一开始就总是抽象。另一方面,其中一些是。在这种情况下,您如何处理它们?
幸运的是,处理完一个对象后,您将无法重用它。如果要再次调用相同的API
,则必须创建一个新实例。作为一个非常适合您使用WCF
代理或ADO.NET
命令的示例,您可以创建代理,调用其操作并在完成后立即对其进行处理。如果您认为一次性抽象是泄漏抽象,那么如何与DI协调呢?
与往常一样,将混乱的细节隐藏在界面后面可能会有所帮助。从7.2节返回到UWP
应用程序,我们使用了IProductRepository
抽象来隐藏表示逻辑层与数据存储进行通信的细节。在讨论过程中,我们忽略了这种实现的细节,因为那时它并不重要。但是,假设UWP
应用程序必须与WCF Web
服务通信。 从EditProductViewModel
的角度来看,这是删除产品的方式:
private void DeleteProduct()
{
this.productRepository.Delete(this.Model.Id); <--您要求注入的存储库通过提供产品ID来删除产品。 EditProductViewModel可以安全地保存对存储库的引用,因为IProductRepository接口不是从IDisposable派生的。
this.whenDone();
}
当我们查看该接口的WCF
实现时,会形成另一幅图。 这是WcfProductRepository
及其Delete
方法的实现。
清单8.6 使用
WCF
通道作为临时使用的临时对象
public class WcfProductRepository : IProductRepository
{
private readonly ChannelFactory<IProductManagementService> factory;
public WcfProductRepository(
ChannelFactory<IProductManagementService> factory)
{
this.factory = factory;
}
public void Delete(Guid productId)
{
using (var channel = this.factory.CreateChannel())
{
channel.DeleteProduct(productId);
} <---Delete方法创建一个WCF通道,该通道是短暂的。 通道是在同一方法调用中创建和处理的。
}
...
}
WcfProductRepository
类没有可变状态,因此您注入ChannelFactory<TChannel>
可用于创建通道。Channel
只是WCF
代理的另一个词,它是在您使用Visual Studio
或svcutil.exe
创建服务引用时免费获得的自动生成的客户端界面。
由于此接口派生自IDisposable
,因此可以将其包装在using
语句中。然后,您可以使用该渠道删除产品。退出使用范围时,将废弃该通道。
警告 尽管在处理瞬时抽象类时,
using
语句是最佳做法,但在WCF
上却并非如此。根据所有准则,WCF
代理类在调用Dispose
时可能引发异常。 万一在using
块中引发了异常,这将导致您丢失原始的异常信息。不必依赖using
语句,您必须编写一个finally
块并忽略Dispose
引发的任何异常。 我们在此仅引用“使用”来演示实现临时用即弃型的一般概念。
每次在WcfProductRepository
类上调用一个方法时,它都会快速打开一个新通道并在使用后对其进行处理。 它的生命周期非常短,这就是为什么我们将这样的一次性抽象称为短暂的一次性的原因。
可是等等! 我们不是说瞬时抽象类是泄漏抽象类吗? 是的,我们做到了,但是我们必须在务实的关注与原则之间取得平衡。 在这种情况下,至少WcfProductRepository
和IProductManagementService
是在同一WCF
特定库中定义的。 这样可以确保泄漏抽象(Leaky Abstractions)可以限于对了解和管理这种复杂性有合理期望的代码。
注意,瞬时抽象类永远不会注入消费者。 取而代之的是使用工厂,然后使用该工厂控制临时性对象的使用寿命。
ChannelFactory<TChannel>
是线程安全的,可以作为Singleton
注入。在这种情况下,您可能想知道为什么我们选择将ChannelFactory<TChannel>
注入WcfProductRepository
的构造函数中; 您可以在内部创建它并将其存储在静态字段中。 但是,这导致WcfProductRepository
隐式依赖于配置文件,该配置文件必须存在才能创建新的WcfProductRepository
。正如我们在2.2.3中讨论的那样,只有完成的应用程序才应依赖配置文件。
总之,瞬时抽象类是泄漏抽象(Leaky Abstraction)。 有时我们必须接受这种泄漏以避免错误(例如WCF
连接被拒绝); 但是,当我们这样做时,我们应尽力遏制该泄漏,以使其不会在整个应用程序中传播。 现在,我们已经研究了如何使用一次性依赖。让我们将注意力转移到如何为消费者提供服务和管理它们上。
管理短暂的依赖(Managing disposable Dependencies)
因为我们坚决主张一次性抽象是泄漏抽象(Leaky Abstractions),所以结果就是抽象不应该是一次性的。另一方面,有时实现是一次性的。 如果您不正确处理它们,则应用程序中会发生资源泄漏。必须有人处置。
提示 努力实施服务,以使它们不引用一次性对象,而应按清单8.6所示按需创建和处置它们。这使内存管理更加简单,因为可以像其他对象一样对服务进行垃圾收集。
与往常一样,这项责任落在了Composer身上。它比其他任何东西都知道,它知道何时创建了一个可处置的实例,因此它也知道何时需要处置该实例。 对于Composer来说,保留对可处理实例的引用很容易,并在适当的时间调用其Dispose
方法。挑战在于确定何时是适当的时间。您怎么知道什么时候所有消费者都超出了范围?
除非您被告知何时发生,否则您不会知道。但是,通常,您的代码存在于具有明确定义的生存期的某种上下文中,以及在特定作用域完成时会告诉您的事件。例如,在ASP.NET Core
中,您可以围绕单个Web
请求确定实例范围。在Web
请求的最后,框架告诉IControllerActivator
(通常是我们的Composer),它应该释放给定对象的所有依赖项。 然后由Composer来跟踪这些依赖关系,并根据他们的生活方式决定是否必须丢弃任何东西。
释放依赖关系(Releasing Dependencies)
发布对象图与处理对象图不同。正如我们在引言中所述,释放是确定哪些依赖项可以被取消引用并可能被处置,以及哪些依赖项应保持活动状态以供重用的过程。由Composer决定释放的对象是否应该处理或重新使用。
对象图的发布是向Composer发出的信号,表明图的根超出范围,因此,如果根本身实现IDisposable
,则应将其丢弃。但是根的依赖关系可以与其他根共享,因此Composer可以决定保留其中的一些,因为它知道其他对象仍然依赖它们。图8.5说明了事件的顺序。
要释放依赖关系,Composer必须跟踪它曾经服务过的所有可弃式依赖关系,以及为谁服务的消费者,以便在最后一个消费者被释放时可以处置这些依赖关系。而且Composer必须注意以正确的顺序处理对象。
警告 一个对象可能需要在处置期间调用其依赖关系,这在已经处置这些依赖关系时会引起问题。因此,处置应以与创造相反的顺序进行-这意味着从外而内。
图8.5 释放依赖项的事件顺序 |
---|
我应该处置
DbContext
吗?
CommerceContext
是Entity Framework Core的DbContext
的特定于项目的版本,该版本实现IDisposable
。过去,我们在在线论坛上目睹了与同事和开发人员的多次讨论,讨论了需要处理DbContext
实例的问题。这些讨论通常来自以下观察:DbContext
使用数据库连接作为临时的一次性对象。 在同一方法调用中打开和关闭连接。例如,在DbContext
上调用SaveChanges
会创建并打开一个数据库连接,然后在保存所有更改后立即处理该连接。好吧,Entity Framework Core 2.0中的情况已经发生了变化。 随着版本2的推出,它现在支持
DbContext pool
,该功能类似于ADO.NET
的连接池。它允许重用相同的DbContext
实例,这可以在某些条件下提高应用程序性能。但是,在调用Dispose
时,DbContext
实例将返回到其池中,因此,在DbContext
实例上不调用Dispose
可能会使该池耗尽。这个故事的寓意是,您应始终确保正确处理瞬时对象。 即使您确定在特定情况下可以省略对
Dispose
的调用,外部组件(例如Entity Framework Core)也可以在将来的任何时候随意更改该行为。
让我们回到清单8.3中的CommerceControllerActivator
示例。事实证明,该清单中存在一个错误,因为CommerceContext
实现了IDisposable
。清单8.3中的代码创建了CommerceContext
的新实例,但从未处理这些实例。这可能会导致资源泄漏,所以让我们用新版本的Composer修复该错误。
首先,请记住,Web应用程序的Composer必须能够处理许多并发请求,因此它必须将每个CommerceContext
实例与其创建的根对象或与其关联的请求相关联。 在以下示例中,我们将使用该请求来跟踪一次性对象,因为这使我们不必定义静态字典实例。 静态可变状态更难正确使用,因为它必须以线程安全的方式实现。下一个清单显示CommerceControllerActivator
如何解决HomeController
实例的请求。
清单8.7 将瞬时依赖项与Web请求相关联
private HomeController CreateHomeController(ControllerContext context)
{
var dbContext = new CommerceContext(this.connectionString); <---创建需要处理的实例
TrackDisposable(context, dbContext); <--通过将实例与当前请求相关联来跟踪该实例
return new HomeController(
new ProductService(
new SqlProductRepository(dbContext),
this.userContext));
}
private static void TrackDisposable(ControllerContext context, IDisposable disposable)
{
IDictionary<object, object> items = context.HttpContext.Items;
object list;
if (!items.TryGetValue("Disposables", out list))
{
list = new List<IDisposable>();
items["Disposables"] = list;
}
((List<IDisposable>)list).Add(disposable);
} <---TrackDisposable方法通过将一次性实例存储在HttpContext .Items字典中,将一次性实例存储在与该请求相关联的列表中。 如果列表不存在,则会创建该列表。 一次性实例将添加到列表中。
CreateHomeController
方法从解决所有依赖关系开始。这类似于清单8.3中的实现,但是在返回已解析的服务之前,它必须以某种方式存储依赖关系和请求,以便在释放控制器时可以将其丢弃。清单8.7的应用流程如图8.6所示。
图8.6 跟踪瞬时依赖项 |
---|
当我们在清单7.8中实现CommerceControllerActivator
时,我们将Release
方法保留为空。到目前为止,我们还没有实现这种方法,而是依靠垃圾收集器来完成这项工作。但是对于可丢弃的依赖关系,至关重要的是,您必须借此机会进行清理。这是实施。
清单8.8 释放依赖对象
public void Release(ControllerContext context, object controller)
{
var disposables =(List<IDisposable>)context.HttpContext .Items["Disposables"]; <---从商品字典中获取追踪的一次性用品清单
if (disposables != null)
{
disposables.Reverse(); <--- 颠倒一次性物品清单的顺序,因此可以按照与创建物品相反的顺序处置实例
foreach (IDisposable disposable in disposables)
{
disposable.Dispose();
} <--遍历集合并一一处理所有实例
}
}
此Release
方法采用了一种捷径,该捷径可防止在引发异常时处置某些一次性用品。如果您一丝不苟,则需要确保继续处理实例,即使实例抛出异常也是如此,最好使用try
和finally
语句。我们将其留给读者练习。
在ASP.NET Core MVC
的上下文中,使用TrackDisposable
和Release
的给定解决方案可以简化为对HttpContext.Response.RegisterForDispose
的简单调用,因为这样做可以有效地完成相同的任务。 它既可以实现相反顺序的处理,又可以在发生故障时继续处理对象。 由于本章不是专门针对ASP.NET Core MVC
的,因此我们想为您提供一个更通用的解决方案,以说明基本思想。
提示 DI容器(DI Container)特别擅长于生命周期管理。 DI容器(DI Container)可以处理复杂的Lifestyles组合,并且它们提供了一些机会(例如Release方法),可以在组件完成后显式地释放它们。 当您发现使用Pure DI维护组合根(Composition Root)变得困难时,请考虑改用DI容器(DI Container)。(在第12章中讨论DI容器(DI Container)时,我们将进行更详细的介绍。)
应该在哪里释放依赖项?(Where should Dependencies be released?)
阅读完所有这些内容之后,还有两个问题:应将对象图发布到哪里,以及由谁负责? 请务必注意,请求对象图的代码也负责请求其释放。 因为对对象图的请求通常是组合根(Composition Root)的一部分,所以它的释放也是如此。
注 组合根(Composition Root)使用已解析的根对象完成后,将要求发布。
下面的清单再次显示了第7.1节中的控制台应用程序的Main
方法,但是现在具有附加的Release
方法。
清单8.9 释放已分解对象图的组合根(Composition Root)
static void Main(string[] args)
{
string connStr = LoadConnectionString();
CurrencyParser parser =CreateCurrencyParser(connStr); <---请求一个CurrencyParser根对象
ICommand command = parser.Parse(args);
command.Execute(); <---使用该根对象
Release(parser); <---要求在操作完成后将其释放
}
构建控制台应用程序时,您可以完全控制该应用程序。 正如我们在7.1节中讨论的那样,没有控制反转。 如果您使用的是框架,则经常会看到该框架控制着请求对象图和要求其释放的过程。 ASP.NET Core MVC
就是一个很好的例子。 就MVC
而言,它是调用CommerceControllerActivator
的Create
和Release
方法的框架。 在这些调用之间,它使用解析的控制器实例。
现在,我们详细讨论了生命周期管理。 作为消费者,您无法管理注入的依赖项的生命周期; 这项责任落在了Composer身上,后者可以决定在多个使用者之间共享一个实例,或为每个使用者提供自己的私有实例。 这些Singleton
和Transient
生活方式只是较大的一组生活方式中最常见的成员,我们将在下一节中使用最常见的生命周期策略的目录进行工作。
生命周期模式种类(Lifestyle catalog)
既然我们已经了解了生命周期管理的基本原理,那么我们将花一些时间研究常见的生命周期模式。 正如我们在引言中所描述的,生活方式是描述依赖关系预期生命周期的一种形式化方法。就像设计模式一样,这为我们提供了通用词汇。 这样,就可以更轻松地推断预期何时以及如何将依赖项超出范围,以及是否可以重用。
本节讨论表8.1中描述的三种最常见的生活方式。因为您已经遇到了Singleton
和Transient
,所以我们将从它们开始。
表8.1本节涵盖的生活方式
名称 | 描述 |
---|---|
Singleton | 单个实例将被永久重用。 |
Transient | 始终会提供新实例。 |
Scoped | 最多每个隐式或显式定义的范围提供每种类型的一个实例。 |
注 在本节中,我们将使用可比较的示例。但是,为了使我们能够专注于基本要素,我们将构成浅层次结构,并且有时会忽略具有一次性依赖关系的问题,以避免增加复杂性。
范围生活方式的使用很广泛。大多数各种各样的生命周期模式都是它的变种。与高级生命周期模式相比,Singleton生命周期模式看似平凡,但这仍然是一种常见且适当的生命周期策略。
Singleton生命周期模式
在本书中,我们不时隐含地使用Singleton生命周期模式。 这个名字既清晰又让人困惑。 但是,这是有道理的,因为产生的行为类似于单例设计模式,但是结构不同。
注 在单个Composer的范围内,Singleton生命周期模式将只有一个组件实例。 消费者每次请求组件时,都会提供相同的实例。
在Singleton生命周期模式和Singleton设计模式下,只有一个实例的依赖关系,但相似之处到此为止。单例设计模式提供了对其实例的全局访问点,这与我们在5.3节中讨论的Ambient Context反模式(anti-pattern)相似。消费者无法通过静态成员访问单例模式范围依赖关系。 如果您要求两个不同的作曲家为一个实例提供服务,那么您将获得两个不同的实例。因此,请不要将Singleton生命周期模式与单例设计模式混淆。
因为仅使用一个实例,所以Singleton生命周期模式通常只占用最少的内存,并且效率很高。唯一的情况不是这种情况:实例很少使用,但会占用大量内存。在这种情况下,实例可以包装在虚拟代理中,如我们将在8.4.2节中讨论的那样。
何时使用Singleton生命周期模式
尽可能使用Singleton生命周期模式。 可能导致您无法使用Singleton
的两个主要问题如下:
- 当组件不是线程安全的时。因为
Singleton
实例可能在许多使用者之间共享,所以它必须能够处理并发访问。 - 如果组件的依赖项之一的寿命预计会更短,则可能是因为它不是线程安全的。为组件提供
Singleton
生命周期模式将使其依赖项存活太长时间。 在这种情况下,这种依赖性就变成了强制依赖性。 我们将在8.4.1节中详细介绍强制依赖性。
按照定义,所有无状态服务都是线程安全的,不可变类型也是很明显的,显然,这些类是专门设计为线程安全的类。在这种情况下,没有理由不将其配置为单例模式。
除了提高效率的论点外,某些依赖项只有在共享时才能按预期运行。例如,我们将在第9章中讨论的Circuit Breaker 设计模式以及内存中高速缓存的实现就是这种情况。在这种情况下,实现必须是线程安全的,这一点至关重要。
让我们仔细看一下内存中的存储库。 接下来,我们将探讨一个示例。
示例:使用线程安全的内存存储库
让我们再次将注意力转移到实现CommerceControllerActivator
上,如第7.3.1和8.1.2节所述。代替使用基于SQL Server
的IProductRepository
,可以使用线程安全的内存中实现。 为了使内存数据存储有意义,必须在所有请求之间共享它,因此它必须是线程安全的。如图8.7所示。
图8.7 在单独线程上运行的多个ProductService 实例访问共享资源(例如内存中的IProductRepository )时,必须确保共享资源是线程安全的。 |
---|
不应使用单例设计模式来显式实现这种存储库,而应使用具体的类,并使用Singleton
生命周期模式对其范围进行适当调整。下一个清单显示了每次要求解析HomeController
时Composer如何返回新实例,而IProductRepository
在所有实例之间共享。
清单8.10 管理单例生命周期模式
public class CommerceControllerActivator : IControllerActivator
{
private readonly IUserContext userContext;
private readonly IProductRepository repository; <--单例实例的存储位置在Composer的生存期内保留引用Singleton依赖关系。
public CommerceControllerActivator()
{
this.userContext = new FakeUserContext();
this.repository = new InMemoryProductRepository(); <---在Composer的构造函数中创建单例
}
...
private HomeController CreateHomeController()
{
return new HomeController(
new ProductService(
this.repository,
this.userContext)); <---每次要求Composer解析HomeController实例时,它都会创建一个Transient ProductService,其中注入了两个Singleton。
}
}
请注意,在此示例中,存储库和userContext
都包含Singleton
生命周期模式。但是,您可以根据需要混合使用生命周期模式。图8.8显示了CommerceControllerActivator
在运行时会发生什么。
图8.8 使用CommerceControllerActivator 组成单例模式 |
---|
Singleton生命周期模式是最容易实现的生活方式之一。它所要做的就是保留对对象的引用,并在每次请求时提供相同的对象。在Composer超出范围之前,实例不会超出范围。发生这种情况时,如果是一次性类型的物件,则Composer应该处理该物件。
另一个易于实施的生活方式是Transient 生命周期模式。 接下来让我们看看。
Transient 生命周期模式
Transient 生命周期模式涉及每次请求都返回一个新实例。除非返回的实例实现IDisposable
,否则没有任何可跟踪的东西。 相反,当实例实现IDisposable
时,Composer必须牢记它,并在要求释放适用的对象图时明确处置它。本书中的大多数构造对象图示例都隐式使用了Transient 生命周期模式。
警告 当涉及Transient 生命周期模式时,请注意DI容器(DI Container)的行为可能有所不同。 尽管有些DI容器(DI Container)会跟踪Transient 组件,并在其使用者超出范围时倾向于将其处理掉,但其他DI容器(DI Container)则不会,因此不会处理Transient 。
值得注意的是,在台式机和类似应用程序中,我们往往只解析一次整个对象层次结构:在应用程序启动时。这意味着即使对于Transient 组件,也只能创建几个实例,并且它们可能存在很长时间。在退化的情况下,每个依赖项只有一个消费者,解析简单Transient组件图的最终结果等同于解析单纯的单例对象或其任何组合的图。 这是因为该图仅被解析一次,所以行为上的差异永远不会实现。
何时使用Transient 生命周期模式
Transient 生命周期模式是最安全的生命周期模式选择,但也是效率最低的一种。 即使单个实例已足够,它也可能导致创建大量实例并进行垃圾回收。
但是,如果您对组件的线程安全性有疑问,则Transient 生命周期模式是安全的,因为每个使用者都有自己的依赖实例。 在许多情况下,您可以安全地将Transient 生命周期模式替换为Scoped 生命周期模式 ,在这种情况下,也可以保证对依赖项的访问是顺序的。
示例:解析多个存储库
您已经在本章前面看到了一些使用 Scoped 生命周期模式的示例。 在清单8.3中,创建了存储库,并在解决方法中将其注入现场,而Composer则没有对其进行引用。 在清单8.8和8.9中,您随后看到了如何处理瞬时对象组件。
在这些示例中,您可能已经注意到userContext
始终保持为Singleton。 这是一项纯粹的无状态服务,因此没有理由为每个创建的ProductService
创建一个新实例。 值得注意的一点是,您可以将依赖项与不同的生命周期模式混合使用。
警告 尽管您可以将依存关系与不同的生命周期模式混合使用,但您应确保使用者的依存关系的生存期等于或超过自己的依存关系,因为消费者可以通过将依存关系存储在私有字段中来保持其依存关系。 否则,将导致强制依赖,我们将在8.4.1节中解决。
当多个组件需要相同的依赖关系时,将为每个组件分配一个单独的实例。 下面的清单显示了一种解决ASP.NET Core MVC
控制器的方法。
清单8.11 解决
TransientAspNetUserContextAdapter
实例
private HomeController CreateHomeController()
{
return new HomeController(
new ProductService(
new SqlProductRepository(this.connStr),
new AspNetUserContextAdapter(),
new SqlUserRepository(
this.connStr,
new AspNetUserContextAdapter())));
} <---ProductService和SqlUserRepository类都需要IUserContext依赖关系。 当AspNetUserContextAdapter是瞬态的时,每个使用者都将获得自己的私有实例,因此ProductService将获得一个实例,而SqlUserRepository将获得另一个实例。
Transient生命周期模式意味着,即使同一对象图中的多个消费者具有相同的依存关系,每个消费者都将收到一个私有的依存关系实例(如上一个清单中的情况)。如果许多消费者共享相同的依赖关系,则此方法可能效率不高; 但是,如果实施不是线程安全的,则不适合使用效率更高的Singleton生命周期模式。在这种情况下,Scoped 生命周期模式可能更合适。
Scoped 生命周期模式
作为网络应用程序的用户,即使其他用户同时访问系统,我们也希望尽快得到该应用程序的响应。 我们不希望将我们的请求与其他所有用户的请求一起放入队列。 如果在我们之前有很多请求,我们可能需要等待非常长的时间才能做出响应。 为了解决此问题,Web
应用程序可以并发处理请求。ASP.NET Core
基础架构通过让每个请求在其自己的上下文中以及在其自己的控制器实例(如果使用ASP.NET Core MVC
)中执行,来保护我们免受此伤害。
由于存在并发性,因此不是线程安全的依赖项不能用作Singleton。 另一方面,如果您需要在同一请求中的不同使用者之间共享依赖关系,则将它们用作Transient可能会效率低下甚至是彻底的问题。
尽管ASP.NET Core
引擎异步执行单个请求,并且单个请求的执行通常涉及多个线程,但它确实确保以顺序的方式执行代码-至少在您正确等待异步操作时是这样。这意味着 如果您可以在单个请求中共享一个依赖关系,则线程安全性不是问题。 第8.4.3节提供了有关异步,多线程方法如何在ASP.NET Core
中工作的更多详细信息。
尽管Web
请求的概念仅限于Web
应用程序和Web
服务,但请求的概念更为广泛。 大多数长时间运行的应用程序使用请求来执行单个操作。 例如,当构建一个服务应用程序来从队列中一个接一个地处理项目时,您可以将每个处理过的项目想象成一个单独的请求,由其自己的一组依赖关系组成。
对于台式机或电话应用程序也是如此。 尽管最上层的根类型(views
或ViewModels
)可能会生存很长时间,但您可以将按按钮视为请求,并且可以对该操作进行范围划分,并为其赋予自己的独立气泡(带有独立的依赖集)。 这导致了范围化生活方式的概念,您决定在给定范围内重用实例。 图8.9展示了Scoped 生命周期模式的工作方式。
图8.9 Scoped 生命周期模式指示您最多为每个指定范围创建一个实例。 |
---|
定义 范围内的依赖项(Scoped Dependencies)在单个定义良好的范围或请求中的行为类似于单例依赖项,但不会在范围之间共享。每个作用域都有自己的关联依赖项缓存。
请注意,DI容器(DI Container)可能具有针对特定技术的Scoped 生命周期模式的专用版本。 同样,在示波器结束时应丢弃所有一次性组件。
何时使用 Scoped 生命周期模式
Scoped 生命周期模式对于长时间运行的应用程序是有意义的,这些应用程序负责处理需要在某种程度上隔离运行的处理操作。当并行处理这些操作或每个操作包含其自己的状态时,需要隔离。Web应用程序是Scoped 生命周期模式运作良好的一个很好的例子,因为Web应用程序通常并行处理请求,并且这些请求通常包含特定于该请求的可变状态。 但是,即使网络应用程序启动了一些与网络请求无关的后台操作,Scoped 生命周期模式也很有价值。 即使是这些后台操作,通常也可以映射到请求的概念。
提示 如果您需要在Web请求中编写Entity Framework Core
DbContext
,那么Scoped 生命周期模式是一个绝佳的选择。DbContext
实例不是线程安全的,但是您通常只希望每个Web请求具有一个DbContext
实例。
与所有生命周期模式一样,您可以将Scoped生命周期模式与其他生命周期模式混合在一起,例如,将某些依赖项配置为Singleton,而其他依赖项则按请求共享。
示例:使用范围限定的DbContext
组成一个长时间运行的应用程序
在此示例中,您将看到如何组成具有一定范围的DbContext
依赖关系的长时间运行的控制台应用程序。 这个控制台应用程序是我们在7.1节中讨论的UpdateCurrency
程序的变体。
与UpdateCurrency
程序一样,此新的控制台应用程序读取货币汇率。但是,此版本的目标是每分钟输出一次特定货币金额的汇率,并继续这样做,直到用户停止应用程序为止。 图8.10概述了应用程序的主要类。
CurrencyMonitoring
程序重用了第7章的UpdateCurrency
程序中的SqlExchangeRateProvider
和CommerceContext
和第4章的ICurrencyConverter
抽象。ICurrencyRepository
抽象及其随附的SqlCurrencyRepository
实现是新的。CurrencyRateDisplayer
也是新的,并且特定于此程序;它显示在下面的清单中。
清单8.12
CurrencyRateDisplayer
类
public class CurrencyRateDisplayer
{
private readonly ICurrencyRepository repository;
private readonly ICurrencyConverter converter;
public CurrencyRateDisplayer(
ICurrencyRepository repository,
ICurrencyConverter converter)
{
this.repository = repository;
this.converter = converter;
}
public void DisplayRatesFor(Money amount)
{
Console.WriteLine(
"Exchange rates for {0} at {1}:",
amount,
DateTime.Now);
IEnumerable<Currency> currencies =
this.repository.GetAllCurrencies(); <--加载所有已知货币(currencies)
foreach (Currency target in currencies)
{
Money rate = this.converter.Exchange(
amount,
target); <--计算目标货币中给定金额的汇率
Console.WriteLine(rate); <--将请求的汇率打印到控制台
}
}
}
图8.10 CurrencyMonitoring 程序的类图 |
---|
您可以使用“EUR 1.00”作为参数从命令行运行该应用程序。这样做会输出以下文本:
Exchange rates for EUR 1.00000 at 12/10/2018 22:55:00.
CAD 1.48864
USD 1.13636
DKK 7.46591
EUR 1.00000
GBP 0.89773
要将应用程序组合在一起,您需要创建应用程序的组合根(Composition Root)。 在这种情况下,组合根(Composition Root)由两个类组成,如图8.11所示。
图8.11 应用程序的基础结构包含两个类,即Program和Composer。 |
---|
Program
类使用Composer
类来解析应用程序的对象图。代码清单8.13显示了Composer
类及其CreateRateDisplayer
方法。它确保为每个解析仅创建一个范围为CommerceContext
依赖的实例。
清单8.13 Composer类,负责组成对象图
public class Composer
{
private readonly string connectionString; <--Singletons的存储字段。 在这种情况下,只有一个连接字符串,但是典型的应用程序将具有更多的单例实例。
public Composer(string connectionString)
{
this.connectionString = connectionString;
}
public CurrencyRateDisplayer CreateRateDisplayer() <--允许组成临时根类型CurrencyRateDisplayer的公共方法
{
var context =
new CommerceContext(this.connectionString); <--创建范围依赖
return new CurrencyRateDisplayer(
new SqlCurrencyRepository(
context),
new CurrencyConverter(
new SqlExchangeRateProvider(
context))); <--将范围依赖项注入到瞬时对象图中
}
}
组合根(Composition Root)的其余部分是应用程序的入口点:程序类。它负责读取输入参数和配置文件,并设置每分钟运行一次以显示汇率的计时器。 以下清单完整地显示了它。
清单8.14 管理范围的应用程序的入口点
public static class Program
{
private static Composer composer;
public static void Main(string[] args)
{
var money = new Money(
currency: new Currency(code: args[0]),
amount: decimal.Parse(args[1])); <--基于传入的命令行参数创建新的资金。 这是将显示汇率的金额。
composer = new Composer(LoadConnectionString());
var timer = new Timer(interval: 60000);
timer.Elapsed += (s, e) => DisplayRates(money);
timer.Start(); <--创建一个系统.Timers.Timer.9每分钟触发一次Timer。 间隔过去后,调用DisplayRates方法。
Console.WriteLine("Press any key to exit.");
Console.ReadLine(); <--计时器启动后,程序将等待用户输入,并在发生这种情况时退出。
}
private static void DisplayRates(Money money)
{
CurrencyRateDisplayer displayer =
composer.CreateRateDisplayer(); <--要求Composer解析当前请求的CurrencyRateDisplayer。
displayer.DisplayRatesFor(money);
}
private static string LoadConnectionString() { ... }
}
Program
类配置一个计时器,该计时器在经过时调用DisplayRates
方法。 即使您每分钟仅调用一次DisplayRates
,在此示例中,您也可以轻松地在多个线程上并行调用DisplayRates
,甚至可以使DisplayRates
异步。 由于每个调用都会创建和管理其范围内的实例集,因此每个操作都可以独立于其他操作运行,因此该操作仍然有效。
注 为简化起见,前面的示例省略了创建的对象图的发布。 清单8.7和8.8在ASP.NET Core MVC
应用程序的上下文中演示了此概念。 与控制台应用程序一起使用的解决方案看起来很相似,因此我们将其作为练习。
Transient生命周期模式意味着每个消费者都将收到一个依赖关系的私有实例,而Scoped 生命周期模式则可以确保该范围内所有已解析图的所有消费者都获得相同的实例。除了常见的生命周期模式(例如Singleton,Transient和Scoped)外,还可以将某些模式定义为代码的味道甚至反模式(anti-pattern)。 下一节将讨论一些不良的生命周期模式选择。
不良的生命周期模式选择
众所周知,某些生活方式的选择对我们的健康不利,吸烟是其中之一。 在DI中应用生命周期模式时也是如此。 您可能会犯很多错误。 在本节中,我们讨论表8.2中显示的选择。
表8.2本节介绍的不良生命周期模式选择
主题 | 类型 | 描述 |
---|---|---|
Captive Dependencies | Bug | 保持引用超出其预期生命周期 |
Leaky Abstractions | 设计问题 | 使用泄漏抽象,将生活方式的选择泄漏给消费者 |
Per-thread Lifestyle | Bug | 通过将实例绑定到线程的生命周期期而导致并发错误 |
如表8.2所示,强制性依赖关系和按线程的生命周期模式可能会导致应用程序中的错误。 通常,这些错误仅在将应用程序部署到生产后才会出现,因为它们与并发相关。 当我们以开发人员身份启动应用程序时,通常会在短时间内运行它,一次运行一个请求。 对于通常以有序方式浏览应用程序的测试人员也是如此。 这可能会隐藏这样的问题,仅当多个用户同时访问该应用程序时才会弹出。
当我们向消费者泄露有关生命周期模式选择的详细信息时,通常不会导致错误,或者至少不会立即导致错误。 但是,它确实使依赖项的使用者及其测试复杂化,并可能导致整个代码库中的重大更改。 最后,这增加了错误的机会。
专属依赖(Captive Dependencies)
当涉及到生命周期管理时,常见的陷阱就是专属依赖(Captive Dependencies)。当消费者使依赖项存活的时间比您预期的要长时,就会发生这种情况。 即使依赖关系不是线程安全的,这甚至可能导致它被多个线程或多个请求并发使用。
定义 专属依赖项(Captive Dependency)是由于使用者的生命周期超出了预期关系的预期寿命而无意中存活了很长时间的依存关系。
强制性依赖性的一个非常常见的例子是将短暂的依赖性注入到Singleton
使用者中。Singleton
在Composer
的整个生命周期内都保持活动状态,因此其依赖关系也将保持活动状态。下面的清单说明了此问题。
清单8.15 专属依赖(Captive Dependencies)示例 (坏代码)
public class Composer
{
private readonly IProductRepository repository;
public Composer(string connectionString)
{
this.repository = new SqlProductRepository( <---将SqlProductRepository创建为Singleton并将其存储以供重用
new CommerceContext(connectionString)); <--将CommerceContext注入到Singleton中。 CommerceContext现在已成为专属依赖性; 它不是线程安全的,也不打算被多个线程重复使用。
}
...
}
因为整个应用程序只有一个SqlProductRepository
实例,并且SqlProductRepository
在其私有字段中引用了CommerceContext
,所以实际上也只有一个CommerceContext
实例。 这是一个问题,因为CommerceContext
并不是线程安全的,并且也不会超出单个请求的寿命。 由于CommerceProduct
在预期的发布时间之后仍被SqlProductRepository
保留,因此我们将CommerceContext
称为专属依赖(Captive Dependencies)。
重要 组件应仅引用预期寿命等于或更长于组件本身的依赖关系。
在使用DI容器(DI Container)时,常见的依赖性是一个普遍的问题。这是由DI容器(DI Container)的动态特性引起的,它很容易使您无法跟踪要构建的对象图的形状。但是,如前面的示例所示,使用Pure DI时也会出现问题。通过仔细构造Pure DI 组合根(Composition Root)中的代码,可以减少遇到此问题的机会。下面的清单显示了此方法的示例。
清单8.16 使用Pure DI缓解专属依赖(Captive Dependencies)
public class CommerceControllerActivator : IControllerActivator
{
private readonly string connStr;
private readonly IUserContext userContext; <--Singletons的存储字段
public CommerceControllerActivator(string connectionString)
{
this.connStr = connectionString;
this.userContext =
new AspNetUserContextAdapter(); <--创建单例
}
public object Create(ControllerContext ctx)
{
var context = new CommerceContext(this.connStr);
var provider = new SqlExchangeRateProvider(context); <--创建范围依赖
Type type = ctx.ActionDescriptor
.ControllerTypeInfo.AsType();
if (type == typeof(HomeController))
{
return this.CreateHomeController(context);
}
else if (type == typeof(ExchangeController))
{
return this.CreateExchangeController(
context, provider); <--为工厂方法提供创建的作用域相关性
}
else
{
throw new Exception("Unknown controller " + type.Name);
}
}
private HomeController CreateHomeController(
CommerceContext context)
{
return new HomeController(
new ProductService(
new SqlProductRepository(
context),
this.userContext)); <--组成包含Transient,作用域和单例实例的对象图
}
private RouteController CreateExchangeController(
CommerceContext context,
IExchangeRateProvider provider) { ... }
}
注 当使用DI容器(DI Container)时,俘虏依赖性的问题非常广泛,以至于某些DI容器(DI Container)会对构造的对象图进行分析以检测它们
清单8.16将所有依赖关系的创建分为三个不同的阶段。当您将这些阶段分开时,检测和防止专属依赖(Captive Dependencies)变得更加容易。 这些阶段是
- 在应用程序启动期间创建的单例
- 在请求开始时创建的作用域实例
- 根据请求,由Transient,Scoped和Singleton实例组成的特定对象图
使用此模型,即使不使用请求,也会为每个请求创建所有应用程序的范围依赖。这看似效率低下,但请记住,正如我们在4.2.2节中讨论的那样,组件构造函数应没有除保护检查和存储传入的依赖项外的所有逻辑。这样可以加快施工速度并防止出现大多数性能问题; 创建一些未使用的依赖关系是没有问题的。
从配置错误的角度来看,专属依赖(Captive Dependencies)是最常见的,最难发现的配置之一,或与错误的生活方式选择相关的编程错误。 比起我们想承认的更多,我们已经浪费了很多时间来寻找由专属依赖引起的错误。 因此,当您使用DI容器(DI Container)时,我们认为用于发现强制性依赖关系的工具支持非常宝贵。尽管专属依赖(Captive Dependencies)通常是由配置或编程错误引起的,但是其他不方便的生命周期模式选择是设计缺陷,例如,当您强迫消费者选择生命周期面膜是时。
使用泄漏抽象将生命周期模式的选择泄漏给消费者
另一种可能导致生活方式选择错误的情况是,您需要推迟创建依赖关系。如果您很少需要一个依赖项,并且创建成本很高,那么您最好在对象图组成后立即创建一个这样的实例。这是一个有效的担忧。但是,并非如此,这使依赖关系的消费者受到关注。如果这样做,则会向用户泄露有关组合根(Composition Root)的实现和实现选择的详细信息。依赖关系成为泄漏抽象,您违反了依赖关系反转原则。
在本节中,我们将展示两个常见的示例,说明如何使您的生命周期模式选择泄露给依存关系的消费者。这两个示例具有相同的解决方案:创建一个包装类,该包装类隐藏生命周期模式的选择,并充当原始抽象类的实现,而不是泄漏抽象类(Leaky Abstraction)的实现。
Lazy<T>
作为泄漏抽象类
让我们再次回到清单3.9中首次引入的定期重用的ProductService
示例。让我们想象一下,其依赖关系之一的创建成本很高,并且并非应用程序中的所有代码路径都需要它的存在。
您可能会想通过使用.NET的System.Lazy<T>
类来解决此问题。Lazy<T>
允许通过其Value
属性访问基础值。但是,只有在首次请求时才创建该值。 之后,只要Lazy<T>
实例存在,Lazy<T>
就会缓存该值。
这很有用,因为它允许您延迟依赖关系的创建。但是,将Lazy<T>
直接注入到消费者的构造函数中是错误的,我们将在后面讨论。下面的清单显示了Lazy<T>
的这种错误使用的示例。
清单8.17
Lazy<T>
作为泄漏抽象类 (坏代码)
public class ProductService : IProductService
{
private readonly IProductRepository repository;
private readonly Lazy<IUserContext> userContext;
public ProductService(
IProductRepository repository,
Lazy<IUserContext> userContext) <--现在,ProductService不再依赖IUserContext,而是依赖Lazy<IUserContext>。 这样,仅在需要时才创建IUserContext实例。 这是不好的,因为Lazy<IUserContext>是泄漏抽象。
{
this.repository = repository;
this.userContext = userContext;
}
public IEnumerable<DiscountedProduct> GetFeaturedProducts()
{
return
from product in this.repository
.GetFeaturedProducts()
select product.ApplyDiscountFor(
this.userContext.Value); <--Lazy<IUserContext>的Value属性可确保创建一次IUserContext依赖关系。当IProductRepository上的GetFeaturedProducts方法返回一个空列表时,将永远不会执行select子句,并且永远不会调用Value属性,从而阻止了IUserContext的创建。
}
}
清单8.18显示了清单8.17的ProductService
的组合根(Composition Root)的结构。
清单8.18 组成一个依赖于
Lazy<IUserContext>
的ProductService
Lazy<IUserContext> lazyUserContext =
new Lazy<IUserContext>( <--通过将其创建包装在Lazy<IUserContext>中来延迟创建真正的AspNetUserContextAdapter依赖关系。
() => new AspNetUserContextAdapter())
new HomeController(
new ProductService(
new SqlProductRepository(
new CommerceContext(connectionString)),
lazyUserContext)); <--因为ProductService现在依赖于Lazy<IUserContext>而不是IUserContext,所以您可以将Lazy<IUserContext>直接注入其构造函数中。
看到这段代码后,您可能会想知道这有什么不好。以下讨论列出了这种设计的几个问题,但重要的是要知道在组合根(Composition Root)内部使用Lazy <T>
并没有错—将Lazy <T>
注入到应用程序组件中会导致抽象泄漏。现在,回到问题所在。
首先,让使用者依赖Lazy<IUserContext>
会使使用者及其单元测试(Unit testing)变得复杂。您可能会认为,必须调用userContext.Value
才可以付出一点代价,因为它可以延迟加载昂贵的依赖关系,但事实并非如此。创建单元测试(Unit testing)时,不仅需要创建包装原始依赖的Lazy<T>实
例,而且还必须编写额外的测试来验证是否在错误的时间调用了Value
。
因为使依赖关系变得懒惰对于性能优化而言似乎已经足够重要,所以不验证您是否正确实现它就很奇怪。至少,这是您需要为该依赖项的每个使用者编写的一项额外测试。这样的依赖关系可能有数十个消费者,他们都需要额外的测试来验证其正确性。
其次,在开发过程的后期将现有的依赖项更改为惰性依赖(Lazy Dependency)会导致整个应用程序发生大范围的更改。当有数十个该依赖项的消费者时,这可能会花费大量精力,因为如前所述,不仅需要改变消费者本身,而且还需要更改其所有测试。 进行这些涟漪的更改既费时又冒险。
为避免这种情况,您可以默认使所有依赖项变得懒惰,因为从理论上讲,每个依赖项将来都可能变得昂贵。这样可以避免您将来进行级联更改。但这将是疯狂的事情,我们希望您同意这不是追求的好途径。如果您认为每个依赖关系都可能成为实现的列表,则尤其如此,我们将在稍后讨论。这将导致默认情况下使所有依赖项IEnumerable<Lazy<T>>
更加疯狂。
最后,由于必须进行的更改数量和需要添加的测试数量,很容易发生编程错误,这些错误将完全使这些更改无效。例如,如果您创建了一个新组件,而该组件意外地依赖于IUserContext
而不是Lazy<IUserContext>
,则意味着包含该组件的每个图都将始终获得热切加载的IUserContext
实现。
不过,这并不意味着您不能懒惰地构建依赖关系。不过,我们想重复第4.2.1节的内容:您应使组件的构造函数不要使用除防御性语句(Guard Clause)和传入依赖项之外的任何逻辑。这使您的类的构建快速而可靠,并且可以防止此类组件的实例化变得越来越昂贵。
但是,在某些情况下,您别无选择。例如,在与第三方组件打交道时,您几乎无法控制。在这种情况下,Lazy<T>
是一个很好的工具。但是,不是让所有使用者都依赖Lazy<T>
,而是应该将Lazy<T>
隐藏在虚拟代理后面,并将该虚拟代理放置在组合根(Composition Root)中。下面的清单提供了一个示例。
清单8.19 包装了
Lazy<T>
的虚拟代理 (好代码)
public class LazyUserContextProxy : IUserContext <--实现IUserContext
{
private readonly Lazy<IUserContext> userContext; <--依赖Lazy<IUserContext>允许延迟构造IUserContext
public LazyUserContextProxy(
Lazy<IUserContext> userContext)
{
this.userContext = userContext;
}
public bool IsInRole(Role role)
{
IUserContext real = this.userContext.Value;
return real.IsInRole(role); <--仅当调用Proxy的IsInRole方法时,才会构造和调用真正的IUserContext实现。
}
}
此新的LazyUserContextProxy
允许ProductService
依赖于IUserContext
而不是Lazy<IUserContext>
。 这是ProductService
的新构造函数:
public ProductService(IProductRepository repository,IUserContext userContext)
下一个清单显示了如何在将LazyUserContextProxy
注入ProductService
的同时为HomeController
组成对象图。
清单8.20 通过注入一个虚拟代理来组成一个
ProductService
(好代码)
IUserContext lazyProxy =
new LazyUserContextProxy(
new Lazy<IUserContext>(
() => new AspNetUserContextAdapter()));
new HomeController(
new ProductService(
new SqlProductRepository(
new CommerceContext(connectionString)),
lazyProxy));
如代码清单8.19所示,拥有一个依赖于Lazy<T>
的类本身并不是一件坏事,但是您希望将其集中在Composition Root的内部,并且只拥有一个依赖于Lazy<IUserContext>
的类。依赖Func<T>
实际上具有与依赖Lazy<T>
相同的效果,并且解决方案相似。这样做可以防止您的代码变得复杂,不添加单元测试(Unit testing),不进行大量更改以及引入不幸的错误。正如您接下来将看到的,相同的参数也适用于注入IEnumerable<T>
。
IEnumerable<T>
作为泄漏抽象类
就像使用Lazy<T>
来延迟依赖关系的创建一样,在许多情况下,您需要使用特定抽象的依赖关系集合。为此,可以使用BCL
集合抽象之一,例如IEnumerable<T>
。 尽管就其本身而言,使用IEnumerable<T>
作为抽象来表示依赖项集合并没有错,但在错误的地方使用它可能会再次导致泄漏抽象。 下面的清单显示了如何错误地使用IEnumerable<T>
。
清单8.21
IEnumerable<T>
作为泄漏抽象类 (坏代码)
public class Component
{
private readonly IEnumerable<ILogger> loggers;
public Component(IEnumerable<ILogger> loggers) <--注入ILogger依赖项的集合
{
this.loggers = loggers;
}
public void DoSomething()
{
foreach (var logger in this.loggers) <--遍历集合并对其进行操作
{
logger.Log("DoSomething called");
}
...
}
}
注 我们暂时将忽略第5.3.2节的建议,在该节中,我们指出您不应使用日志记录污染应用程序的代码库。 第10章详细介绍了如何设计应用程序以解决交叉切割问题。
我们希望避免消费者不得不处理某个依赖关系可能存在多个实例这一事实。 这是通过IEnumerable<ILogger>
依赖关系泄露的实现细节。正如我们之前所解释的,每个依赖项都可能有多个实现,但是您的使用者不需要知道这一点。与前面的Lazy<T>
示例一样,当您有多个这样的依赖项使用者时,这种泄漏会增加系统的复杂性和维护成本,因为每个使用者都必须处理遍历整个集合。消费者的测试也是如此。
尽管经验丰富的开发人员会在几秒钟之内吐出这样的foreach
构造,但是当需要以不同的方式处理依赖项集合时,事情会变得更加复杂。 例如,假设即使其中一个记录器发生故障,记录也应继续进行:
foreach (var logger in this.loggers)
{
try
{
logger.Log("DoSomething called");
}
catch <--空的catch子句允许在失败的情况下继续记录日志
{
}
}
或者,也许您不仅要继续处理,还希望将该错误记录到下一个记录器中。 这样,下一个记录器就可以作为失败记录器的后备:
for (int index = 0; index < this.loggers.Count; index++)
{
try
{
this.loggers[index].Log("DoSomething called"); <--将呼叫转发到基础记录器实现
}
catch (Exception ex)
{
if (loggers.Count > index + 1)
{
loggers[index + 1].Log(ex); <--将异常转发到后备记录器,该记录器是列表中的下一个记录器(如果有)
}
}
}
也许-嗯,我们认为您明白了。到处都是这样的代码构造会很痛苦。如果要更改日志记录策略,它将导致您在整个应用程序中进行级联更改。理想情况下,我们希望将这些知识集中到一个位置。
您可以使用组合设计模式来解决此设计问题。正如我们在第1章和第6章中所讨论的那样,您现在应该已经熟悉了组合设计模式(请参见图1.8,以及清单6.4和6.12)。 下一个清单显示了ILogger
的组合。
清单8.22 组合包装
IEnumerable<T>
public class CompositeLogger : ILogger <---CompositeLogger实现ILogger
{
private readonly IList<ILogger> loggers; <--CompositeLogger依赖于IList <ILogger>来允许将日志请求转发到所有可用的ILogger组件。
public CompositeLogger(IList<ILogger> loggers)
{
this.loggers = loggers;
}
public void Log(LogEntry entry) <--实现ILogger的Log方法。 在这种情况下,我们假定ILogger包含一个接受LogEntry方法的单个方法。
{
for (int index = 0; index < this.loggers.Count; index++)
{
try
{
this.loggers[index].Log(entry);
}
catch (Exception ex)
{
if (loggers.Count > index + 1)
{
var logger = loggers[index + 1];
logger.Log(new LogEntry(ex)); <--将失败的记录器引发的异常包装在LogEntry对象中,以便可以将其传递给后备记录器
}
}
}
}
}
以下代码片段显示了如何使用此新的CompositeLogger
来为Component
组成对象图,并使Component
依赖于单个ILogger
而不是IEnumerable<ILogger>
:(好代码)
ILogger composite =
new CompositeLogger(new ILogger[] <--构造具有多个ILogger实现的Composite
{
new SqlLogger(connectionString),
new WindowsEventLogLogger(source: "MyApp"),
new FileLogger(directory: "c:\\logs")
});
new Component(composite); <--使用Composite构造新的组件
如您前所述,良好的应用程序设计遵循依赖倒置原则(Dependency Inversion Principle)并防止泄漏抽象。这样可以使代码更清晰,并且更易于维护,并且可以更有效地抵抗编程错误。现在,让我们看一下不同的味道,它不会影响应用程序的设计本身,但是可能会导致难以解决的并发问题。
通过将实例与线程的生命周期相关联来引起并发错误
有时,您所处理的依赖项不是线程安全的,但不一定需要与请求的生命周期相关联。诱人的解决方案是将此类依赖项的生存期与线程的生存期同步。 尽管很诱人,但这种做法容易出错。
警告 某些DI容器(DI Container)将这种方法称为每线程生命周期模式(per-thread Lifestyle),并且对此方法具有内置支持-避免这种情况!
清单8.23显示了清单7.2中先前讨论的CreateCurrencyParser
方法如何利用SqlExchangeRateProvider
依赖关系。 将为应用程序中的每个线程创建一次。
清单8.23 依赖项的生命周期与线程的生命周期相关联 (坏代码)
[ThreadStatic]
private static CommerceContext context; <--静态字段标记有[ThreadStatic]属性。 CLR确保此类字段不会在线程之间共享,而是为每个执行线程提供该字段的单独实例。 如果在不同的线程上访问该字段,则该字段将包含一个不同的值。
static CurrencyParser CreateCurrencyParser(
string connectionString)
{
if (context == null)
{
context = new CommerceContext(
connectionString);
} <--如果正在执行当前代码的线程尚未初始化CommerceContext,则会创建一个新实例并将其存储在相应的线程静态字段中。
return new CurrencyParser(
new SqlExchangeRateProvider(context),
context); <--将每个线程的依赖关系注入到瞬态对象图中
}
尽管这看起来很纯真,但这离事实还远。 接下来,我们将讨论此清单的两个问题。
线程的生命周期通常不清楚
很难预测线程的寿命。使用new Thread().Start()
创建和启动线程时,您将获得一个全新的线程静态内存块。这意味着,如果在这样的线程中调用CreateCurrencyParser
,则所有线程静态字段都将被取消设置,从而导致创建新实例。
但是,当使用ThreadPool.QueueUserWorkItem
从线程池中启动线程时,您可能会从池中获得现有线程或新创建的线程,具体取决于线程池中的线程。 即使您自己没有创建线程,该框架也可能是(如我们之前讨论的有关ASP.NET Core
的讨论)。这意味着,尽管某些线程的生存期很短,但其他线程在整个应用程序的生存期内仍然有效。如果不能保证操作只能在单个线程上运行,则会带来更多的复杂性。
异步应用程序模型会导致多线程问题
现代应用程序框架本质上本质上是异步的。即使您的代码可能未使用async
和await
关键字实现新的异步编程模式,但您使用的框架仍可能决定在与启动线程不同的线程上完成请求。 例如,ASP.NET Core
完全围绕此异步编程模型构建。 但是,即使是较旧的框架,例如ASP.NET Web API
和ASP.NET Web
窗体,也允许请求异步运行。
对于依赖于特定线程的依赖关系,这是一个问题。 当请求在另一个线程上继续执行时,即使它们中的某些依赖关系与原始线程绑定在一起,它仍然引用相同的依赖关系。 图8.12对此进行了说明。
图8.12 特定于线程的依赖关系可能会导致异步环境中的并发错误。 |
---|
注 图8.12中请求1的对象图从一个线程移动到另一个线程,尽管依赖关系是特定于线程的。一旦对象图移动到另一个线程,依赖关系实际上就变成了一个俘虏依赖关系。
在异步上下文中运行时使用特定于线程的依赖项是一个特别糟糕的主意,因为这可能会导致并发问题,而并发问题通常很难找到和重现。这样的问题只有在特定于线程的依赖关系不是线程安全的情况下才会发生——它们通常不是线程安全的。否则,单例生活方式就可以正常工作了。
这个问题的解决方案是围绕一个请求或操作来确定范围,有几种方法可以实现这一点。如第8.3.3节所述,不要将依赖项的生存期与线程的生存期相链接,而是将其生存期限定为请求。下面的清单再次演示了这一点。
清单8.24 在局部变量中存储作用域依赖项 (好代码)
static CurrencyParser CreateCurrencyParser(string connectionString)
{
var context = new CommerceContext( connectionString);
return new CurrencyParser(new SqlExchangeRateProvider(context),context);
}
注 给定的解决方案可以大大缩短依赖关系的生存期。这通常不会是一个问题,但是如果是这样的话,可以考虑将依赖项池化,或者将对线程静态依赖项的访问包装到代理后面,代理只从其方法中访问依赖项。这可以防止依赖关系意外地从一个线程移动到另一个线程。我们把这个留给读者做练习。
本章中所讨论的生活方式代表了最常见的类型,但你可能有更多的异国情调的需求没有得到令人满意的解决。当我们发现自己处于这种情况时,我们的即时反应应该是意识到我们的方法肯定是错误的,如果我们稍微改变一下设计,一切都会很好地符合标准模式。
这种实现常常令人失望,但它会产生更好、更易于维护的代码。关键是,如果你觉得有必要实现一个定制的生活方式或创建一个漏洞百出的抽象,你应该首先认真地重新考虑你的设计。出于这个原因,我们决定在这本书中不谈特殊的生活方式。通过重新设计或拦截,我们通常可以更好地处理此类情况,您将在下一章中看到。
总结
- Composer是一个统一的术语,指任何组成依赖项的对象或方法。这是作文的重要组成部分。
- Composer可以是DI容器(DI Container),但也可以是使用Pure DI手动构造对象图的任何方法。
- 编写器对依赖关系的生命周期的影响程度比任何单个使用者都大。编写器决定创建实例的时间,并通过选择是否共享实例,确定依赖关系是否超出单个使用者的范围,或者是否必须在释放依赖关系之前超出所有使用者的范围。
- 生命周期模式是一种形式化的方式来描述依赖的预期寿命。
- 微调每个依赖项的生命周期模式的能力对于性能原因很重要,但是对于正确的行为也很重要。一些依赖关系必须在多个使用者之间共享,系统才能正常工作。
- 里氏转换原则(Liskov Substitution Principle)指出,您必须能够在不改变系统正确性的情况下,将抽象替换为任意实现。
- 不遵守里氏转换原则(Liskov Substitution Principle)会使应用程序变得脆弱,因为它不允许替换可能导致使用者崩溃的依赖项。
- 一个短暂的一次性对象是一个具有清晰而短暂的生命周期的对象,通常不超过一个方法调用。
- 勤勉地实现服务,这样他们就不会引用一次性物品,而是根据需要创建和处置它们。这使得内存管理更简单,因为服务可以像其他对象一样被垃圾收集。
- 处理依赖关系的责任落在编写者身上。它比其他任何东西都更了解何时创建一个一次性实例,因此它也知道该实例需要被处置。
- 释放
Releasing()
是确定哪些依赖项可以取消引用和(可能)处理的过程。组合根(Composition Root)向编写器发出信号,以释放已解析的依赖关系。 - Composer必须注意处理对象的正确顺序。对象可能要求在处理期间调用其依赖项,如果这些依赖项已被处理,则会导致问题。因此,处理应该按照与对象创建相反的顺序进行。
- Transient 生命周期模式包括每次请求时返回一个新实例。每个使用者都有自己的依赖实例。
- 在单个编写器的范围内,只有一个具有Singleton生命周期模式的组件实例。每次使用者请求组件时,都会提供相同的实例。
- Scoped 依赖项的行为类似于单个定义良好的Scoped或请求中的单例,但不会跨作用域共享。每个Scoped都有自己的一组相关依赖项。
- Scoped 生命周期模式对于长时间运行的应用程序是有意义的,这些应用程序的任务是处理需要在某种程度上隔离运行的操作。当并行处理这些操作时,或者当每个操作都包含自己的状态时,需要隔离。
- 如果您需要在web请求中构建实体框架核心
DbContext
,那么Scoped 生命周期模式是一个很好的选择。DbContext
实例不是线程安全的,但是通常每个web请求只需要一个DbContext
实例。 - 对象图可以由不同生命周期模式的依赖项组成,但是您应该确保使用者只有其生存期等于或超过其自身生存期的依赖项,因为使用者将保持其依赖项处于活动状态。如果不这样做,将导致强制依赖。
- 捕获依赖关系是一种不经意间保持了太长时间的依赖关系,因为它的使用者的生存期超过了依赖关系的预期生存期。
- 在使用DI容器(DI Container)时,捕获依赖是常见的错误源,尽管在使用Pure DI时也会出现此问题。
- 在应用Pure DI时,仔细构造复合根可以减少遇到问题的机会。
- 在使用DI容器(DI Container)时,捕获依赖关系是一个广泛存在的问题,一些DI容器(DI Container)对构建的对象图执行分析以检测它们。
- 有时需要推迟创建依赖项。但是,将依赖项注入为
Lazy<T>
、Func<T>
或IEnumerable<T
>是个坏主意,因为它会导致依赖项成为泄漏的抽象。相反,您应该将这些知识隐藏在代理或组合后面。 - 不要将依赖项的生存期绑定到线程的生存期。线程的生命周期通常是不明确的,在异步框架中使用它可能会导致多线程问题。相反,使用适当的作用域生活方式或隐藏对代理后面的线程静态值的访问。