第三章-编写松耦合代码
在烤牛排时,重要的做法是在切成薄片之前先让肉静置。 休息时,果汁会重新分布,结果变得更加美味。另一方面,如果切得太早,所有汁液都用光了,那么肉就会变干,味道会变差。发生这种情况真是太可惜了,因为您想为您的客人提供最好的品尝体验。尽管了解任何行业的最佳做法很重要,但了解不良做法并理解为什么会导致不满意的结果也同样重要。
了解好习惯和坏习惯之间的区别对于学习至关重要。这就是为什么上一章完全致力于紧密耦合(tightly coupled)代码的示例和分析的原因:分析为您提供了原因。
总而言之,松散耦合(Loose Coupling)提供了许多好处-后期绑定(Late binding),可扩展性,可维护性,可测试性和并行开发。 如果紧密耦合(tightly coupled,您将失去这些好处。尽管并非所有紧密耦合(tightly coupled)都不理想,但您应努力避免与过度性依赖项(Volatile Dependency)紧密耦合(tightly coupled)。 此外,您可以使用依赖注入(DI)解决在该分析过程中发现的问题。由于DI与Mary创建应用程序的方式完全不同,因此我们不会修改其现有代码。相反,我们将从头开始重新创建它。
注意 您不应从此决定中推断出无法将现有应用程序重构为DI,还是建议您完全从头开始重写现有应用程序。大量重写成本高昂且风险高。最好是缓慢的逐步重构。这并不是说重构很容易,因为事实并非如此。这个很难(难。根据我们的经验,要到达那里需要大量的工作)
让我们先简要回顾一下Mary的申请。我们还将讨论如何进行重写以及完成后的预期结果。
重建电子商务应用程序(Rebuilding the e-commerce application)
在第二章中对Mary的应用程序的分析得出的结论是,过度性依赖项(Volatile Dependency)在各个层次之间紧密耦合(tightly coupled)。 如图3.1中Mary的应用程序的依赖关系图所示,领域层和UI
层均依赖于数据访问层。
我们将在本章中实现的目标是反转领域层和数据访问层之间的依赖关系。这意味着数据访问层将依赖于领域层,而不是依赖于数据访问层的领域层,如图3.2所示。
图3.1 Mary的应用程序的依赖关系图显示了模块之间如何相互依赖。 |
---|
图3.2 Mary应用程序所需反转的依赖关系图 |
---|
翻译
The data access layer now depends on the domain layer instead of the other way around.
现在,数据访问层依赖于领域层,而不是其他方式。
The
UI
layer only depends on the domain layer.
UI
层仅取决于领域层。
通过创建此反转,我们可以替换数据访问层,而不必完全重写应用程序。(这与Mary开发应用程序的方式完全不同。)我们还将在此过程中应用几种模式。然后,我们将应用在第1章中讨论过的构造函数注入(Constructor Injection)。最后,我们还将使用方法注入(Method Injection)和组合根(Composition Root),我们将在后面进行讨论。
当我们专注于分离应用程序关注点时,这种方法将导致更多的类。在Mary定义了四个类别的地方,我们将定义九个类别和三个接口。图3.3对应用程序进行了更深入的研究,并显示了我们将在本章中创建的类和接口。
图3.3 本章末尾将提供的类和接口。 接口以虚线标记。 |
---|
图3.4显示了应用程序中的主要类将如何交互。 在本章的最后,我们将再次看一下该图的更详细的版本。
当我们编写软件时,我们更喜欢从最重要的地方开始-对我们的利益相关者最具可见性的那部分。与Mary的电子商务应用程序一样,它通常是UI
。 从那里开始,我们继续前进,添加更多功能,直到完成一项功能为止。 然后我们继续下一个。 这种由外而内的技术可帮助我们专注于所需的功能,而无需过度设计解决方案。
图3.4 序列图显示了我们在本章中构建的电子商务应用程序中DI所涉及的元素之间的交互 |
---|
翻译
After the
HomeController
is constructed,MVC
will invoke its Index method.构造
HomeController
之后,MVC
将调用其Index
方法。The
HomeController's
Index method will call into theProductService
andProductService
calls intoSqlProductRepository
.
HomeController
的Index
方法将调用SqlProductRepository
的ProductService
和ProductService
调用。When a request arrives, a custom controller activator takes care of the construction of the application’s object graphs. This ensures that once a request arrives for the
HomeController
, the activator will create aHomeController
with its DEPENDENCIES.当请求到达时,自定义控制器激活器会处理应用程序对象图的构造。 这样可以确保一旦收到对
HomeController
的请求,激活器将创建一个带有依赖项(Dependencies)的HomeController
。
在第二章中,Mary使用了相反的方法。她从数据访问层入手,从头到尾地进行了自己的工作。我们很难说“由内而外”的工作很糟糕,但是正如您稍后将看到的那样,“由内而外”的方法可以为您提供有关构建内容的更快反馈。 因此,我们将以相反的顺序构建应用程序,从UI
层开始,再到领域层,然后最后构建数据访问层。
注意 “从里到外(outside-in)”技术与YAGNI原则密切相关,“您将不再需要它”。该原则强调仅应实现必需的功能,并且实现应尽可能简单。
因为我们练习测试驱动开发(TDD),所以从外到内的方法提示我们创建一个新的类后,我们就首先编写单元测试(Unit testing)。 尽管我们编写了单元测试(Unit testing)来创建此示例,但实现和使用DI并不需要TDD,因此我们不会在本书中显示这些测试。 如果您有兴趣,本书随附的源代码将包含测试。 让我们直接进入我们的项目,并从用户界面开始。
建立一个更易于维护的UI(Building a more maintainable UI)
Mary对特色产品列表的规范是编写一个应用程序,该应用程序从数据库中提取这些项目并将其显示在列表中(再次显示在图3.5中)。 因为我们知道项目利益相关者将主要对视觉结果感兴趣,所以UI似乎是一个不错的起点。
打开Visual Studio之后,您要做的第一件事是向解决方案中添加一个新的ASP.NET Core MVC
应用程序。由于特色产品列表需要放在首页上,因此您首先需要修改Index.cshtml
文件以包括以下列表中所示的标记
图3.5 电子商务Web应用程序的屏幕截图 |
---|
清单3.1
Index.cshtml
视图标记
@model FeaturedProductsViewModel
<h2>Featured Products</h2>
<div>
@foreach (ProductViewModel product in this.Model.Products)
{
<div>@product.SummaryText</div>
}
</div>
请注意,与Mary的原始标记相比,清单3.1的简洁程度更高。
清单3.2 第二章中Mary的原始Index视图标记
<h2>Featured Products</h2>
<div>
@{
var products = (IEnumerable<Product>)this.ViewData["Products"];
foreach (Product product in products)
{
<div>@product.Name (@product.UnitPrice.ToString("C"))</div>
}
}
</div>
第一个改进是,您无需在可以进行迭代之前就将字典项目投射到一系列产品上。 您可以使用MVC
的特殊@mode
l指令轻松完成此操作。 这意味着页面的Model
属性属于FeaturedProductsViewModel
类型。 使用@model
指令,MVC
将确保从控制器返回的值将强制转换为FeaturedProductsViewModel
类型。 其次,整个产品显示字符串直接从ProductViewModel
的SummaryText
属性中提取。
两项改进都与引入特定于视图的模型有关,该模型封装了视图的行为。 这些模型是普通的旧CLR对象(POCO
)。下面的清单概述了它们的结构。
清单3.3
FeaturedProductsViewModel
和ProductViewModel
类
public class FeaturedProductsViewModel
{
public FeaturedProductsViewModel(
IEnumerable<ProductViewModel> products)
{
this.Products = products;
}
public IEnumerable<ProductViewModel> Products { get; }
<--- FeaturedProductsViewModel包含一个ProductViewModel实例列表。 两者都是POCO,这使它们适合于单元测试(Unit testing)。
}
public class ProductViewModel
{
private static CultureInfo PriceCulture = new CultureInfo("en-US");
public ProductViewModel(string name, decimal unitPrice)
{
this.SummaryText = string.Format(PriceCulture,"{0} ({1:C})", name, unitPrice);
}
public string SummaryText { get; } <-- SummaryText属性是从两个值(name和unitPrice)派生而来的,以封装呈现逻辑。
}
视图模型的使用简化了视图,这很好,因为视图更难测试。 这也使UI
设计人员可以更轻松地在应用程序上工作。
注意 您是否偶然发现了Mary原始标记中的错误? 尽管对
UnitPrice.ToString("C")
的调用将十进制格式设置为货币,但它是根据用户的浏览器提供给应用程序的文化偏爱来这样做的。 这意味着来自美国的游客看到美元符号,而来自丹麦的游客看到丹麦克朗符号。 如果两种货币都具有相同的价值,那么这并不坏,但是它们却没有。 这将导致丹麦游客以原价的一小部分获得产品。 这就是为什么ProductViewModel
明确说明区域性信息的原因。
HomeController
必须返回带有FeaturedProductsViewModel
实例的视图,清单3.1中的代码才能正常工作。 第一步,可以在HomeController
内部实现,如下所示:
public ViewResult Index()
{
var vm = new FeaturedProductsViewModel(new[]
{
new ProductViewModel("Chocolate", 34.95m),
new ProductViewModel("Asparagus", 39.80m)
}); <-- 用折扣码的硬编码列表创建视图模型
return this.View(vm); <--使用MVC的辅助方法 View 将视图模型包装在MVC ViewResult对象中
}
我们在Index
方法中对折扣产品列表进行了硬编码。 这不是理想的最终结果,但是它使Web
应用程序能够正确执行,并允许我们向涉众显示一个不完整但正在运行的应用程序示例(存根(Subs)),以供他们评论。
重要 从DI的角度来看,
POCO
,DTO
和诸如FeaturedProductsViewModel
和ProductViewModel
之类的视图模型并不是很有趣。 它们不包含您可能想要拦截,替换或模拟的任何行为。 它们仅仅是数据对象。这样可以安全地在您的代码中创建代码,因此将代码紧密耦合到这些数据对象没有风险。 这些对象包含应用程序的运行时数据,这些数据在创建了HomeController
和ProductService
之类的类很久之后便流经系统。
在此阶段,仅实现了UI
层的存根。领域层和数据访问层的完整实现仍然保留。 从UI
开始的一个优点是我们已经拥有可以运行和测试的软件。 与此相对比的是Mary在类似阶段的进步。 Mary只有在更晚的阶段才能到达可以运行该应用程序的地步。图3.6显示了残存的Web应用程序。
图3.6 存根电子商务Web应用程序的屏幕截图。 产品列表在这里是硬编码的。 |
---|
为了使我们的HomeController
履行其义务并进行任何有兴趣的事情,它要求从领域层获得一系列特色产品。这些产品需要有折扣。 在第2章中,Mary将这种逻辑包装在她的ProductService
类中,我们也将这样做。
HomeController
上的Index
方法应使用ProductService
实例检索特色产品列表,将其转换为ProductViewModel
实例,然后将其添加到FeaturedProductsViewModel
。 但是,从HomeController
的角度来看,ProductService
是过度性依赖项(Volatile Dependency),因为它是一个尚不存在且仍在开发中的依赖项。 如果要隔离测试HomeController
,并行开发ProductService
或在将来替换或拦截它,则需要引入缝隙(Seam)。
回想一下对Mary的执行情况的分析,过度性依赖项(Volatile Dependency)是一个主要的过失。一旦这样做,您就可以与刚刚使用的类型紧密结合。为避免这种紧密耦合,我们将介绍一个接口,并使用一种称为构造函数注入(Constructor Injection)的技术; 如何创建实例以及由谁创建实例与HomeController
无关。
清单3.4
HomeController
类
public class HomeController : Controller
{
private readonly IProductService productService;
public HomeController(
IProductService productService) <-- 构造函数指定任何想要使用该类的人都必须提供IProductService接口的实例。
{
if (productService == null) <-- 保护子句通过引发异常来防止提供的实例为null。
throw new ArgumentNullException(
"productService");
this.productService = productService; <--注入的Dependency可以存储,以便以后由HomeController类的其他成员安全使用。
}
public ViewResult Index()
{
IEnumerable<DiscountedProduct> products =
this.productService.GetFeaturedProducts(); <--存储的productService依赖关系。 注意GetFeaturedProducts如何返回DiscountedProduct而不是Product的集合。DiscountedProduct类在领域层中定义。
var vm = new FeaturedProductsViewModel( <--视图模型是从特色产品列表中构建的。
from product in products
select new ProductViewModel(product)); <--我们将ProductViewModel更改为接受DiscountedProduct而不是字符串和十进制。
return this.View(vm);
}
}
正如我们在第1章中所述,构造函数注入(Constructor Injection)是通过将所需的依赖项指定为类的构造函数的参数来静态定义所需依赖项列表的行为。这正是HomeController
所做的。 在其公共构造函数中,它定义了正常运行所需的依赖关系。
第一次听说构造函数注入(Constructor Injection)时,我们很难理解真正的好处。 难道不是把控制依赖性的负担推到了其他类别上吗? 是的,确实如此,这就是重点。 在n层应用程序中,您可以将这种负担一直推到应用程序的顶部,进入组合根(Composition Root)。
组合根(Composition Root)
正如我们在1.4.1节中讨论的那样,我们希望能够以类似于将电器连接在一起的方式将类组合到应用程序中。 可以通过将类的创建集中到一个地方来实现这种级别的模块化。我们将此位置称为
组合根(Composition Root)
。组合根(Composition Root)位于离应用程序入口点尽可能近的位置。 在大多数.NET Core应用程序类型中,入口点是
Main
方法。 在组合根(Composition Root)中,您可以决定使用Pure DI手动编写应用程序,也可以将其委托给DI容器(DI Container)。 我们将在第4章中详细讨论组合根(Composition Root)。
因为我们在HomeController
中添加了带有参数的构造函数,所以如果没有这种依赖关系就不可能创建HomeController
,这正是我们这么做的原因。 但这确实意味着该应用程序的主屏幕坏了,因为MVC
不知道如何创建我们的HomeController
-除非您另外指示MVC
。
实际上,HomeController
的创建与UI
层无关。 这是组合根(Composition Root)的责任。因此,我们认为UI
层已完成,稍后我们将返回到HomeController
的创建。 图3.7显示了实现图3.2中设想的体系结构的当前状态。
图3.7 在这一阶段,仅实现了UI 层,领域层和数据访问层尚未解决。 |
---|
这使我们进入了重新创建电子商务应用程序领域模型的下一个阶段。
建立独立的领域模型(Building an independent domain model)
领域模型是我们添加到解决方案中的普通 vanilla C#库。 该库将包含POCO
和接口。POCO
将对域进行建模,而接口将提供抽象,这些抽象将用作我们进入领域模型的主要外部入口点。 他们将提供合同,领域模型通过该合同与即将到来的数据访问层进行交互。
上一节中提供的HomeController
尚未编译,因为我们尚未定义IProductService
抽象。 在本部分中,我们将像Mary一样,向电子商务应用程序中添加一个新的领域层项目,并从MVC
项目中引用该领域层项目。 可以,但是我们将推迟进行依赖图分析直到3.2节,以便为您提供完整的信息。 以下清单显示了IProductService
抽象。
清单3.5
IProductService
接口
public interface IProductService
{
IEnumerable<DiscountedProduct> GetFeaturedProducts();
}
IProductService
代表了我们当前领域层的核心,因为它将UI
层与数据访问层联系在一起。是将我们最初的应用程序绑定在一起的胶水。
IProductService
抽象的唯一成员是GetFeaturedProducts
方法。 它返回DiscountedProduct
实例的集合。每个DiscountedProduct
都包含一个名称和一个单价。这是一个简单的POCO
类,如下面的清单所示,该定义使我们有足够的能力来编译我们的Visual Studio解决方案。
清单3.6
DiscountedProduct
POCO
类
public class DiscountedProduct
{
public DiscountedProduct(string name, decimal unitPrice)
{
if (name == null) throw new ArgumentNullException("name");
this.Name = name;
this.UnitPrice = unitPrice;
}
public string Name { get; }
public decimal UnitPrice { get; }
}
面向接口编程而不是面向具体类编程的原则是DI的基石。正是这一原理,使您可以将一个具体的实现替换为另一个。在继续之前,我们应该花一点时间来认识接口在此讨论中的作用。
重要 对接口进行编程并不意味着所有类都应实现一个接口。 将
POCO
,DTO
和视图模型隐藏在接口后面通常没有多大意义,因为它们不包含需要模拟(Mock),拦截或替换的行为。 因为DiscountedProduct
,FeaturedProductsViewModel
和ProductViewModel
是(视图)模型,所以它们不实现任何接口。 在本节的后面,我们将再看看是使用接口还是抽象类。
接下来,我们将编写我们的ProductService
实现。 此ProductService
类的GetFeaturedProducts
方法应使用IProductRepository
实例检索特色产品列表,应用所有折扣并返回DiscountedProduct
实例列表。
仓储(Repository)模式提供了关于数据访问的通用抽象类,因此我们将在领域模型库中定义IProductRepository
抽象类。
清单3.7
IProductRepository
public interface IProductRepository
{
IEnumerable<Product> GetFeaturedProducts();
}
IProductRepository
是数据访问层的接口,从持久性存储返回“原始(raw)”实体。 相比之下,IProductService
应用业务逻辑(在这种情况下为折扣),并将实体类转换为较窄的对象。一个完善的仓储库将拥有更多查找和修改产品的方法,但是,按照从外而内的原则,我们仅定义手头任务所需的类和成员。向代码中添加功能比删除任何内容都容易。
实体(Entity)
实体是域驱动设计中的一个术语,它涵盖具有与特定对象实例无关的长期标识的领域对象。这听起来可能是抽象的和理论上的,但这意味着实体表示存在于任意对象之外的对象。内存中的位。任何.NET对象实例都具有一个内存中的地址(身份),但是一个实体具有一个在整个进程生命周期中均存在的身份。
我们经常使用数据库和主键来标识实体,并确保即使主机重新启动,我们也可以保留并读取它们。领域对象
Product
是一个实体(Entity),因为产品的概念比单个过程具有更长的生存期,并且我们使用产品ID在IProductRepository
中对其进行标识。
因为我们的目标是反转领域层和数据访问层之间的依赖关系,所以在领域层中定义了IProductRepository
。 在下一部分中,我们将创建IProductRepository
的实现,作为数据访问层的一部分。这使我们的依赖关系指向领域层。
注意 通过让
ProductService
依赖IProductRepository
,我们允许替换或拦截行为。 通过将该行为放置在其他库中,我们可以替换整个库。
Product
类也用最少的成员实现,如下面的清单所示。
清单3.8 Product 实体
public class Product
{
public string Name { get; set; }
public decimal UnitPrice { get; set; }
public bool IsFeatured { get; set; }
<---Product类仅包含Name,UnitPrice和IsFeatured属性,因为这些是实现所需应用程序功能所需的唯一属性。
public DiscountedProduct ApplyDiscountFor(
IUserContext user) <--- 此方法需要IUserContext作为参数。 IUserContext是域层的一部分,我们稍后将对其进行定义。
{
bool preferred = user.IsInRole(Role.PreferredCustomer);
decimal discount = preferred ? .95m : 1.00m;
return new DiscountedProduct(
name: this.Name,
unitPrice: this.UnitPrice * discount);
<-此方法需要IUserContext作为参数。 IUserContext是域层的一部分,我们稍后将对其进行定义。
}
}
图3.8 ProductService 及其依赖项 |
---|
ProductService
类的GetFeaturedProducts
方法应使用IProductRepository
实例检索特色产品列表,应用所有折扣并返回DiscountedProduct
实例列表。ProductService
类与Mary的同名类相对应,但现在是纯领域模型类,因为它没有对数据访问层的硬编码引用。与我们的HomeController
一样,我们将再次使用构造函数注入(Constructor Injection)来放弃对其挥发依赖项的控制,如下所示。
清单3.9 使用构造函数注入(Constructor Injection)的
ProductService
public class ProductService : IProductService
{
private readonly IProductRepository repository;
private readonly IUserContext userContext;
public ProductService(
IProductRepository repository,
IUserContext userContext) <-- 构造函数注入
{
if (repository == null)
throw new ArgumentNullException("repository");
if (userContext == null)
throw new ArgumentNullException("userContext");
this.repository = repository;
this.userContext = userContext;
}
public IEnumerable<DiscountedProduct> GetFeaturedProducts()
{
return
from product in this.repository
.GetFeaturedProducts() <--repository和userContext依赖关系分别拉出产品列表,并分别为每个特色产品应用折扣。
select product.ApplyDiscountFor(this.userContext); <--使用方法注入将userContext依赖项提供给ApplyDiscountFor方法
}
}
除了IProductRepository
之外,ProductService
构造函数还需要IUserContext
的一个实例:
public interface IUserContext
{
bool IsInRole(Role role);
}
public enum Role { PreferredCustomer }
这与Mary的实现方式有所不同,后者仅使用bool
作为GetFeaturedProducts
方法的参数,从而表明用户是否是首选客户。 由于确定用户是否是首选客户是领域层的一部分,因此将其显式建模为依赖关系更为正确。除此之外,有关代表其运行请求的用户的信息是上下文的。我们不希望每个控制器都负责收集此信息。这将是重复的并且容易出错,并可能导致意外的安全错误。
代替让UI
层将信息提供给领域层,我们允许检索此信息成为ProductService
的实现细节。IUserContext
接口允许ProductService
检索有关当前用户的信息,而HomeController
无需提供此信息。 HomeController
不需要知道哪个角色被授予折扣价,HomeController
也不容易通过传递例如true
而不是false
来无意间启用折扣。这降低了UI
层的整体复杂性。
提示 为了降低系统的整体复杂性,描述上下文信息(Contextual information)的运行时数据最好隐藏在抽象背后,并注入要求其运行的使用者中。上下文信息(Contextual information)是有关当前请求的元数据。这通常是不应该允许用户直接影响用户的信息。例如用户的身份(在登录时建立的身份)和系统的当前时间。
尽管.NET基础类库(BCL)包含一个IPrincipal
接口,该接口代表了对应用程序用户进行建模的标准方式,但该接口本质上是通用的,并非为我们的应用程序的特殊需求量身定制。 相反,我们让应用程序定义抽象类。
ProductService.GetFeaturedProducts
方法将IUserContext
依赖项传递给Product.ApplyDiscountFor
方法。 该技术称为方法注入(Method Injection)。 在诸如实体之类的短暂对象(例如我们的产品实体)需要依赖关系的情况下,方法注入(Method Injection)特别有用。尽管细节有所不同,但主要技术仍保持不变。 我们将在第4章中更详细地讨论这种模式。在此阶段,该应用程序根本无法运行。 这是因为仍然存在三个问题:
- 没有
IProductRepository
的具体实现。 这很容易解决。在下一节中,我们将实现一个具体SqlProductRepository
,该产品将从数据库中读取特色产品。 - 没有
IUserContext
的具体实现。 我们还将在下一部分中对此进行研究。 MVC
框架不知道要使用哪种具体类型。这是因为我们向HomeController
的构造函数引入了IProductService
类型的抽象参数。 可以通过多种方式解决此问题,但是我们的首选是开发自定义Microsoft.AspNetCore.Mvc.Controllers.IControllerActivator
。 如何完成此操作不在本章的讨论范围之内,但这是我们将在第7章中讨论的主题。足以说明这个自定义工厂将创建具体ProductService
的实例,并将其提供给HomeController
的构造函数。
在领域层中,我们仅使用在领域层中定义的类型和.NET BCL
的稳定依赖项(Stable Dependencies)。 领域层的概念被实现为POCO
。 在此阶段,仅代表一个概念,即一个产品。领域层必须能够与外界通信(例如数据库)。这种需求被建模为抽象类(Abstractions)(例如存储库),在领域层变得有用之前,我们必须将其替换为具体的实现。 图3.9显示了实现图3.2中设想的体系结构的当前状态。
我们成功地使我们的领域模型得以编译。这意味着我们创建了一领域模型,该模型独立于我们仍然需要创建的数据访问层。但是,在此之前,我们要先详细说明一些要点。
图3.9 现在,UI 和领域层都已就位,而数据访问层仍有待实现。 |
---|
依赖倒置原则(Dependency Inversion Principle)
我们使用DI尝试完成的大部分工作都与依赖倒置原则(Dependency Inversion Principle)有关。该原则指出,应用程序中的高级模块不应依赖于较低级的模块;相反,两个级别的模块都应依赖抽象类。
这正是我们定义IProductRepository
时所做的。 ProductService
组件是较高级别的域层模块的一部分,而IProductRepository
实现(我们称为SqlProductRepository
)是较低级别的数据访问模块的一部分。 而不是让我们的ProductService
依赖于SqlProductRepository
,我们让ProductService
和SqlProductRepository
都依赖于IProductRepository
抽象类。 SqlProductRepository
实现了抽象类,而ProductService
使用了抽象类。 图3.10对此进行了说明。
图3.10 代替SqlProductRepository 的ProductService ,这两个类都依赖于抽象类。 |
---|
依赖倒置原则(Dependency Inversion Principle)和DI之间的关系是:依赖倒置原则(Dependency Inversion Principle)规定了我们要完成的工作,而DI则说明了我们要如何完成它。 该原则并未描述消费者如何获得其依存关系。但是,许多开发人员并没有意识到依赖倒置原则(Dependency Inversion Principle)的另一个有趣部分。
该原理不仅规定了松散耦合(Loose Coupling),而且还声明抽象类应归于使用抽象的模块所拥有。在这种情况下,“拥有(owned)”是指使用方模块可以控制抽象类的状态,并且随该模块一起分发,而不是通过实现该模块的模块进行分发。消费模块应该能够以最有利于自己的方式定义抽象类。
您已经看到我们这样做了两次:IUserContext
和IProductRepository
都是通过这种方式定义的。 尽管它们的实现分别由UI
层和数据访问层负责,但它们的设计方式最适合领域层,如图3.11所示。
图3.11 IUserContext 和IProductRepository 都是域层的一部分,因为ProductService “拥有(owned)”它们。 |
---|
让高层模块或层定义自己的抽象不仅可以防止它必须依赖于较低层模块,还可以简化高层模块,因为抽象是针对其特定需求量身定制的。这使我们回到BCL的IPrincipal
界面。
正如我们所描述的,IPrincipal
本质上是通用的。相反,依赖倒置原则(Dependency Inversion Principle)指导我们定义针对应用程序特殊需求的抽象类。 这就是为什么我们定义自己的IUserContext
抽象类,而不是让领域层依赖IPrincipal
的原因。但是,这的确意味着我们必须创建一个适配器(Adapter)实现,该实现允许将调用从此特定于应用程序的IUserContext
抽象类转换为对应用程序框架的调用。
如果依赖性反转原则规定抽象应该与它们自己的模块一起分发,那么领域层IProductService
接口是否违反了该原则? 毕竟,IProductService
由UI
层使用,但由领域层实现,如图3.12所示。 答案是肯定的,这确实违反了依赖倒置原则。
图3.12 通过使IProductService 成为领域层的一部分,我们违反了依赖倒置原则(Dependency Inversion Principle)。 |
---|
如果我们热衷于解决此冲突,则应将IProductService
移出领领域层。但是,将IProductService
移至UI
层将使我们的领域层依赖于该层。 由于领域层是应用程序的核心部分,因此我们不希望它依赖于其他任何内容。此外,这种依赖性将使以后无法替换UI
成为可能。
这意味着要解决此违规问题,我们在解决方案中还需要两个额外的项目— 一个用于没有组合根(Composition Root)的隔离UI
层,另一个用于UI
层拥有的IProductService
抽象。但是,出于实用主义的考虑,我们选择在本示例中不走这条路,因此将违规行为留在原地。希望您能理解我们不想使事情变得过于复杂。
接口还是抽象类?(Interfaces or abstract classes?)
许多面向对象设计的指南都将接口作为主要的抽象机制,而.NET Framework设计指南在接口上认可了抽象类。您应该使用接口还是抽象类? 关于DI,可以放心的答案是,这无关紧要。重要的部分是您要针对某种抽象进行编程。
在其他上下文中,在接口和抽象类之间进行选择很重要,但在这里并不重要。您会注意到,我们可以交替使用这些词;我们经常使用术语抽象来包含接口和抽象类。 这并不意味着我们(作为作者)不会偏重一个。实际上,我们做到了。在编写应用程序时,由于以下原因,我们通常更喜欢接口而不是抽象类:
- 抽象类很容易被滥用为基类(Abstract classes can easily be abused as base classes)。 基类可以轻松地变成千变万化,不断增长的上帝对象。派生类与基类紧密相关,当基类包含易变行为时,这可能会成为问题。 另一方面,接口迫使我们进入“继承之上的构成”的口头禅。
- 具体的类可以实现多个接口,尽管在.NET中,这些接口只能从单个基类派生(Concrete classes can implement several interfaces, although in .NET, those can only derive from a single base class.)。 使用接口作为抽象的工具更加灵活。
- 与抽象类相比,C#中的接口定义不那么笨拙(Interface definitions in C# are less clumsy compared to abstract classes.)。 通过接口,我们可以省略其成员中的抽象关键字和公共关键字。 这使接口的定义更加简洁。
但是,在编写可重用的库时,由于需要处理向后兼容性,因此主题变得不那么清晰。 鉴于此,抽象类可能更有意义,因为稍后可以添加非抽象成员,而将成员添加到接口是一项重大更改。 这就是.NET Framework设计指南偏爱抽象类的原因。
可重用的库(Reusable libraries)
.NET生态系统中的可重用库通常是通过NuGet分发的。一个重要的特征是在编译时不了解其客户。 这与同一Visual Studio解决方案中的其他项目可重复使用的项目不同。尽管您的Visual Studio解决方案可能包含可被同一解决方案中的多个项目重用的项目,但此类项目不被视为可重用的库。例如,域层项目可能会被多个项目重用,但这仍然不能使它成为可重用的库。
外部库更难更改,因为它们可能具有成千上万个消耗代码的库,而库设计人员都无法访问这些库。 此类可重用的库无法针对其消耗的代码库进行测试。
现在,我们进入数据访问层。 我们将为先前定义的IProductRepository
接口创建一个实现。
建立一个新的数据访问层(Building a new data access layer)
与Mary一样,我们想使用Entity Framework Core
来实现我们的数据访问层,因此我们遵循她在第2章中创建实体模型的相同步骤。 主要区别在于CommerceContext
现在仅是数据访问层的实现细节,而不是整个数据访问层。
在此模型中,数据访问层之外的任何事物都不会对实体框架有任何了解或依赖。 可以换出而没有任何上游影响。考虑到这一点,我们可以创建IProductRepository
的实现。
清单3.10 使用Entity Framework Core实现
IProductRepository
public class SqlProductRepository : IProductRepository
{
private readonly CommerceContext context;
public SqlProductRepository(CommerceContext context)
{
if (context == null) throw new ArgumentNullException("context");
this.context = context;
}
public IEnumerable<Product> GetFeaturedProducts()
{
return
from product in this.context.Products
where product.IsFeatured
select product;
}
}
在Mary的应用程序中,尽管产品实体是在数据访问层中定义的,但它也被用作领域对象。 这已不再是这种情况。 现在,在我们的领域层中定义了Product
类。 我们的数据访问层重用了该层的Product
类。
为简单起见,我们选择让数据访问层重用我们的领域对象,而不是定义其自己的实现。 之所以能够这样做,是因为Entity Framework Core
允许我们编写对持久性无知的实体。这是否合理,很大程度上取决于域对象的结构和复杂性。 如果我们以后得出结论,说该共享模型对模型强加了不必要的约束,则可以通过引入内部持久性对象来更改数据访问层,而无需接触应用程序的其余部分。 在这种情况下,我们需要数据访问层将这些内部持久性对象转换为领域对象。
在上一章中,我们讨论了Mary的CommerceContext
对连接字符串的隐式依赖性是如何导致其问题的。 我们新的CommerceContext
将使此依赖关系明确,这与Mary的实现又有出入。 下一个清单显示了我们的新CommerceContext
。
清单3.11 更好的
CommerceContext
类
public class CommerceContext : DbContext
{
private readonly string connectionString;
public CommerceContext(string connectionString) <-- 在所需的依赖项上使用构造函数注入; 在这种情况下,connectionString
{
if (string.IsNullOrWhiteSpace(connectionString))
throw new ArgumentException(
"connectionString should not be empty.",
"connectionString");
this.connectionString = connectionString; <--依赖关系将在以后的OnConfiguring方法中存储和使用,以设置要使用的CommerceContext。
}
public DbSet<Product> Products { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder builder)
{
builder.UseSqlServer(this.connectionString);
}
}
这几乎使我们结束了重新实现电子商务应用程序的工作。 仍然缺少的唯一实现是IUserContext
的实现。
实现特定于ASP.NET Core
的IUserContext
适配器
缺少的最后一个具体实现是IUserContext
。 在Web应用程序中,有关发出请求的用户的信息通常随每个请求传递到服务器。 此信息使用cookie
或HTTP
标头中继。 我们如何检索当前用户的身份很大程度上取决于我们使用的框架。这意味着,与Windows服务相比,在构建ASP.NET Core
应用程序时需要一个完全不同的实现。
IUserContext
的实现是特定于框架的。我们既不希望领域层也不希望数据层了解有关应用程序框架的任何信息。这将使得不可能在不同的上下文中使用这些层。 我们需要在其他地方实现这一点。因此,UI
层是实现IUserContext
实现的理想场所。
下面的清单显示了ASP.NET Core
应用程序可能的IUserContext
实现。
清单3.12
ASP.NET Core
的IUserContext
实现
public class AspNetUserContextAdapter : IUserContext
{
private static HttpContextAccessor Accessor = new HttpContextAccessor();
public bool IsInRole(Role role)
{
return Accessor.HttpContext.User.IsInRole(role.ToString());
}
}
AspNetUserContextAdapter
需要HttpContextAccessor
才能工作。 HttpContextAccessor
是ASP.NET Core
框架指定的组件,它允许访问当前请求的HttpContext
,就像我们能够使用HttpContext.Current
在ASP.NET
中“经典”一样。 我们使用HttpContext
访问有关当前用户的请求信息。
AspNetUserContextAdapter
使我们特定于应用程序的IUserContext
抽象适应ASP.NET Core API
。 此类是我们在1.13章中讨论的适配器(Adapter)设计模式的实现。
适配器设计模式(Adapter design pattern)
提醒一下,适配器设计模式属于结构模式的类别。该小组关注类和对象如何组成更大的结构。此类别中的其他模式是组合模式,装饰器模式,外观模式和代理模式。像电器适配器一样,适配器设计模式将接口转换为客户期望的接口。 这允许类(或插头和插座)一起使用,因为它们的接口不兼容,否则它们将无法协同工作。
适配器模式的一般结构 |
---|
翻译
Client uses a target ABSTRACTION, while calling its Request method.
客户端在调用其Request方法时使用目标抽象类。
The Adapter implements the target ABSTRACTION. It transforms and forwards the call to the adaptee that has an interface incompatible with the target.
适配器实现目标抽象类。 它转换呼叫并将其转发给接口与目标不兼容的适配器。
适配器模式的实现通常非常简单,但是如果适配器包含复杂的转换,也不要感到惊讶。想法是这种复杂性对客户端是隐藏的。
客户端使用由AspNetUserContextAdapter 实现的IUserContext 抽象的具体示例 |
---|
翻译
Client uses the
IUserContext
ABSTRACTION, while calling itsIsInRole
method.客户端在调用其
IsInRole
方法时使用IUserContext
抽象。
AspNetUserContextAdapter
implementsIUserContext
and forwards the call to theHttpContext
property of theHttpContextAccessor
adaptee.
AspNetUserContextAdapter
实现IUserContext
并将呼叫转发到HttpContextAccessor
适配器的HttpContext
属性。The adaptee's HttpContext is used to determine the user's role.
适配器的
HttpContext
用于确定用户的角色。
如清单3.12所示,AspNetUserContextAdapter
必须在调用适配器上做一些额外的工作。 这样可以简化客户端代码,在这种情况下,可以防止客户端不得不依赖HttpContext
。
在组合根(Composition Root)中编写应用程序(Composing the application in the Composition Root)
有了ProductService
,SqlProductRepository
和AspNetUserContextAdapter
,我们现在可以设置ASP.NET Core MVC
来构造HomeController
的实例,其中HomeController
由ProductService
实例提供,该实例本身是使用SqlProductRepository
和AspNetUserContextAdapter
构造的。这最终导致对象图看起来如下。
清单3.13 应用程序的对象图
new HomeController(
new ProductService(
new SqlProductRepository(
new CommerceContext(connectionString)),
new AspNetUserContextAdapter()));
定义 在面向对象的应用程序中,对象组通过彼此之间的关系(通过直接引用另一个对象或通过一系列中间引用)形成网络。 这些对象组称为对象图(object graphs)。
在第7章中,我们将详细讨论如何将这种对象图的结构插入ASP.NET Core
框架,因此在此不再赘述。 但是,既然一切都正确地连接在一起,我们就可以浏览到应用程序的主页,并得到如图3.13所示的页面。
图3.13 完成的应用程序的屏幕截图 |
---|
分析松耦合实现(Analyzing the loosely coupled implementation)
上一节包含了许多细节,因此,如果您一路上看不到全局,也就不足为奇了。 在本节中,我们将尝试更广泛地解释发生的情况。
了解组件之间的交互(Understanding the interaction between components)
每层中的类直接或以抽象类形式相互交互。它们跨模块边界进行操作,因此很难了解它们之间的交互方式。图3.14显示了不同的依赖关系如何相互作用,从而更详细地概述了图3.4中描述的原始轮廓。
当应用程序启动时,Startup
中的代码将创建一个新的自定义控制器激活器,并从应用程序的配置文件中查找连接字符串。 当页面请求进入时,应用程序将在控制器激活器上调用Create
。
激活器将存储的连接字符串提供给CommerceContext
的新实例(图中未显示)。它将CommerceContext
注入到SqlProductRepository
的新实例中。依次将SqlProductRepository
实例和AspNetUserContextAdapter
实例(在图中未显示)一起注入到ProductService
的新实例中。同样,将ProductService
注入到HomeController
的新实例中,然后从Create
方法中返回该实例。
然后,ASP.NET Core MVC
框架在HomeController
实例上调用Index
方法,从而使其在ProductRepository
实例上调用GetFeaturedProducts
方法。 依次调用SqlProductRepository
实例上的GetFeaturedProducts
方法。 最后,返回带有填充的FeaturedProductsViewModel
的ViewResult
,然后MVC
查找并呈现正确的视图。
图3.14 电子商务应用程序中涉及DI的元素之间的交互 |
---|
翻译
After the
HomeController
is constructed,MVC
will invoke its Index method.构造
HomeController
之后,MVC
将调用其Index
方法。When the application starts, the code in Startup creates a new custom controller activator using the application's connection string. The application keeps a reference to the controller activator, so when a page request comes in, the application will use that controller activator instance.
当应用程序启动时,
Startup
中的代码使用应用程序的连接字符串创建一个新的自定义控制器激活器。 该应用程序保留对控制器激活器的引用,因此,当页面请求进入时,应用程序将使用该控制器激活器实例。When a request arrives, the custom controller activator takes care of the construction of
HomeController
.当请求到达时,定制控制器激活器负责
HomeController
的构造。
分析新的依赖图(Analyzing the new dependency graph)
在2.2节中,您了解了依赖图如何帮助您分析和理解体系结构实现所提供的灵活性。DI是否更改了应用程序的依赖关系图?
图3.15显示依赖关系图确实发生了变化。领域模型不再具有任何依赖关系,并且可以充当独立模块。另一方面,数据访问层现在具有依赖性。 在Mary的申请中,没有任何申请。
图3.15 依赖关系图显示了应用了DI的示例电子商务应用程序。 显示了所有类和接口,以及它们之间的关系。 |
---|
在图3.15中要注意的最重要的一点是:领域层不再具有任何依赖关系。 这应该引起我们的希望,我们这次可以更有利地回答有关可组合性的原始问题(请参阅第2.2节):
-
我们可以用基于
WPF UI
替换基于Web UI
吗? 这以前是可能的,但新设计仍然可以实现。领域模型库和数据访问库都不依赖于基于Web UI
,因此我们可以轻松地将其他内容放到它的位置。 -
我们可以用与Azure表服务一起使用的关系数据访问层替换关系数据访问层吗?在下一章中,我们将描述应用程序如何定位和实例化正确的
IProductRepository
,因此,现在,以实际值进行以下操作:数据访问层通过后期绑定(Late binding)加载,并且类型名称定义为 应用程序配置文件中的应用程序设置。 只要可以提供IProductRepository
的实现,就可以丢弃当前的数据访问层并注入新的数据访问层。
关于DI容器(DI Container)
DI容器(DI Container)是一个软件库,提供DI功能并自动执行与对象组成,拦截和生命周期管理有关的许多任务。 DI容器(DI Container)也称为控制反转(IoC)容器。 到目前为止,我们仅轻轻地涉及了DI容器(DI Container)的主题。这是有意的,因为正如我们在第1章中所述,DI容器(DI Container)是有用但可选的工具。我们将对DI容器(DI Container)的详细讨论推迟到第4部分,因为我们认为,教给您有关DI组成的一组原理和模式以及现有的代码的味道(Code smells)和反模式(anti-pattern)更为重要。
我们构建带有或不带有DI容器(DI Container)的应用程序,您也应该能够这样做。但是,我们认为,如果您在没有第2部分和第3部分中介绍的知识的情况下开始使用DI容器(DI Container),将会适得其反。另一方面,在您了解了原理和实践之后,使用DI容器(DI Container)主要包括熟悉其
API
。 在这一点上,重要的是要全面了解DI容器(DI Container)是什么以及它如何为您提供帮助。当本书的第一版发行时,我们在构建的所有应用程序中仅使用了DI容器(DI Container)。尽管我们知道可以在没有DI容器(DI Container)的情况下应用DI,但我们认为这从不切实际。我们对此的想法已经改变,这就是为什么我们现在更加关注DI背后的模式和技术的原因。
尽管您需要解决应用程序的基础结构,但这并不能增加业务价值; 有时,使用通用库可能很有意义。这与实现日志记录或数据访问没有什么不同。 记录应用程序数据是通用记录库可以最好地解决的问题。 组成对象图也是如此。在第4部分中,我们将更详细地讨论何时使用DI容器(DI Container),何时不使用DI容器(DI Container)。
不要指望DI容器(DI Container)会神奇地将紧密耦合的代码更改为松散耦合(Loose Coupling)的代码。 DI容器(DI Container)可以使您的组合根(Composition Root)更具可维护性,但是要使应用程序变得可维护,必须首先考虑DI模式和技术来设计它。 使用DI容器(DI Container)既不能保证也不需要正确使用DI。
本章中描述的示例电子商务应用程序仅向我们展示了有限的复杂性:只读场景中仅涉及一个存储库。到目前为止,我们一直将应用程序保持在尽可能简单和小巧的状态,以温和地介绍一些核心概念和原理。 因为DI的主要目的之一是管理复杂性,所以我们需要一个复杂的应用程序来充分了解其功能。在本书中,我们将扩展示例电子商务应用程序,以全面演示DI的各个方面。
DI会让我失去更大的前景吗?
从DI开始的开发人员普遍抱怨说,他们觉得自己看不到应用程序的结构。 目前尚不清楚谁在给谁打电话。 尽管使用DI绝对是正确的,但我们将这些知识从单个类中移开了,清单3.13证明我们完全不必丢失这些信息。 清单3.13是Pure DI的示例。 练习Pure DI时,组合根(Composition Root)通常以连贯的方式包含此信息。 更好的是,它使您可以查看完整的对象图,而不仅仅是类的直接Dependencies,这是紧密耦合的代码所带来的。
另一方面,从Pure DI转移到DI容器(DI Container)可能会使您失去此概述。 这是因为与使用编程语言在编译时指定对象图相比,DI容器(DI Container)在运行时使用反射来创建对象图。但是,当应用程序设计良好时,我们发现这种损失不再是问题。我们体验到,当应用程序的可维护性提高时,从类到其依赖项以及返回的导航量都减少了。
尽管如此,Pure DI和DI容器(DI Container)之间的这种差异还是要考虑的,因为它可能会影响您对另一个的选择。 第12.3节详细介绍了何时使用DI容器(DI Container)以及何时坚持使用Pure DI。
本章总结了本书的第一部分。 第1部分的目的是将DI放在地图上,并大体上介绍DI。 在本章中,您已经看到了构造函数注入(Constructor Injection)的示例。 我们还介绍了方法注入(Method Injection)和组合根(Composition Root)作为与DI相关的模式。在下一章中,我们将更深入地研究这些和其他设计模式。
总结
- 很难将现有应用程序重构为更易于视图模型的使用可以简化视图,因为传入的数据是专门为视图而设计的。
- 维护的,松散耦合(Loose Coupling)的设计。 另一方面,大笔改写往往风险更大且昂贵。
- 因为视图很难测试,所以使视图变暗会更好。 它还简化了可能在视图上工作的UI设计器的工作。
- 当您在领域层中限制过度性依赖项(Volatile Dependency)的数量时,您将获得更高程度的解耦,重用和可测试性。
- 在构建应用程序时,由外而内的方法有助于更快地进行原型设计,从而可以缩短反馈周期。
- 如果您希望在应用程序中实现高度的模块化,则需要应用构造器注入模式,并在靠近应用程序入口点的组合根(Composition Root)目录中构建对象图。
- 接口编程是DI的基石。 它使您可以替换,模拟和拦截依赖项,而不必更改其使用方。当将实现和抽象放在不同的程序集中时,它将使整个库都可以被替换。
- 对接口进行编程并不意味着所有类都应实现一个接口。 短暂对象(例如实体,视图模型和DTO)通常不包含需要模拟,拦截,修饰或替换的行为。
- 对于DI而言,无论使用接口还是纯粹的抽象类都没有关系。 从一般的开发角度来看,作为作者,我们通常更喜欢接口而不是抽象类。
- 可重复使用的库是一个在编译时尚不了解客户端的库。可重用的库通常是通过
NuGet
运送的。 在同一(Visual Studio)解决方案中只有调用者的库不被视为可重用的库。 - DI与依赖倒置原则密切相关。 该原理意味着您应该针对接口进行编程,并且必须控制层对其使用的接口的控制。
- 使用DI容器(DI Container)有助于提高应用程序的组合根(Composition Root)的可维护性,但不会神奇地使紧密耦合的代码松散耦合(Loose Coupling)。 为了使应用程序变得可维护,必须在设计时考虑到DI模式和技术。