-NET-Core-2-0-依赖注入-全-
.NET Core 2.0 依赖注入(全)
原文:
zh.annas-archive.org/md5/a3b601cd96d2bd39870066f5d701d453译者:飞龙
前言
本书是针对新.NET Core 2.0 版本中依赖注入技术实现的方法。.NET Core 的设计者实现了许多与良好实践相关的功能,并在本版本的各个领域遵循了 Robert C. Martin(SOLID 原则)所阐述的原则。
本书的目的是深入探讨那些明确阐述的原则,并通过示例展示它们是如何实现的,以及程序员如何使用它们。
本书涵盖的内容
第一章,软件设计的 SOLID 原则,介绍了五个 SOLID 原则以及它们如何在.NET Core 2.0 中找到或轻松实现。
第二章,依赖注入和 IoC 容器,让你了解如何独立使用或借助第三方容器使用依赖注入。
第三章,在.NET Core 2.0 中介绍依赖注入,从控制台应用程序的角度回顾了.NET Core 2.0 内部依赖注入的实际实现。
第四章,ASP.NET Core 中的依赖注入,提供了对使用 ASP.NET Core 2.0 的 Web 应用程序中依赖注入技术实现的更详细研究,并充满了示例。
第五章,对象组合,带你了解对象组合概念背后的所有隐藏原则,以及如何在.NET Core 2.0 和 ASP.NET Core MVC 2.0 中应用,从而成为依赖注入的支柱。
第六章,对象生命周期,作为下一个依赖注入支柱,深入探讨了由依赖注入管理的对象的生活方式和典型管理策略,这有助于通过优化的配置做出更好的决策。
第七章,拦截,作为依赖注入生态系统的最后一根支柱,提供了拦截调用和动态地将代码插入管道的技术。本章还讨论了在.NET Core 2.0 和 ASP.NET Core 2.0 中拦截的应用,并配有适当的插图。
第八章,模式 – 依赖注入,将引导你了解 SOLID 原则中的 D,以及如何在.NET Core 2.0 应用程序中应用所有重要的依赖注入技术。
第九章,依赖注入的反模式和误解,处理了 DI 模式中的常见不良使用和编码时应该避免的场景,以便从应用程序中的依赖注入中获得良好的结果。
第十章,在其他 JavaScript 框架中使用依赖注入,教你如何使用其他流行的框架,如 Angular,来使用依赖注入技术。
第十一章,最佳实践和其他相关技术,涵盖了在应用依赖注入到当前和旧应用程序时应采用的经过验证的编码、架构和重构实践。
你需要为本书准备的内容
你将需要 Visual Studio 2017 Community Edition、Chrome 浏览器和 IIS(Internet Information Server Express)来成功测试和执行所有代码文件。
本书面向的对象
这本书是为那些对依赖注入(DI)一无所知,但希望了解如何在他们的应用程序中实现它的 C#和.NET 开发者而写的。
规范
在这本书中,您将找到许多不同的文本样式,以区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名如下所示:“对于这个新需求,我们可以创建一个新的并重载的ReadData()方法,它接收一个额外的参数。”
代码块设置如下:
public Student(int id, string name, DateTime dob)
{
Id = id;
Name = name;
Dob = dob;
}
当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:
static void Main(string[] args)
{
Packt packt = new Packt
{
Name = "Packt Publications",
OfficialFacebookLink = "https://www.facebook.com/PacktPub/",
TotalBooksPublished = 5000
};
packt.PrintPacktInfo();
Console.ReadKey();
}
任何命令行输入或输出都应如下编写:
npm install -g json
新术语和重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“双击添加 ArcGIS Server。”
警告或重要注意事项如下所示。
技巧和窍门看起来像这样。
读者反馈
我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者的反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。要发送一般反馈,请简单地发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍的标题。如果您在某个主题领域有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大价值。
下载示例代码
您可以从您的www.packtpub.com账户下载这本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的 SUPPORT 标签上。
-
点击代码下载与勘误。
-
在搜索框中输入书籍名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书籍的地方。
-
点击代码下载。
下载文件后,请确保您使用最新版本的软件解压缩或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Dependency-Injection-in-.NET-Core-2.0。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载本书的彩色图像
我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。这些彩色图像将帮助您更好地理解输出中的变化。您可以从www.packtpub.com/sites/default/files/downloads/DependencyInjectioninNETCore20_ColorImages.pdf下载此文件。
错误更正
尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误更正,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息来报告它们。一旦您的错误更正得到验证,您的提交将被接受,并将上传到我们的网站或添加到该标题的错误部分下现有的错误列表中。要查看之前提交的错误更正,请转到www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误部分下。
盗版
互联网上版权材料的盗版是一个跨所有媒体持续存在的问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。请通过发送链接到疑似盗版材料至copyright@packtpub.com与我们联系。我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您对本书的任何方面有问题,您可以通过questions@packtpub.com与我们联系,我们将尽力解决问题。
第一章:软件设计 SOLID 原则
本书专注于与依赖注入相关的技术,以及这些技术在.NET Core 中的默认实现方式,以及程序员如何扩展这些技术——这是.NET 的第一个可以在每个平台上运行的版本。
它在桌面上的 Windows、macOS 和 Linux 发行版上运行,这个想法甚至可以扩展到移动世界,覆盖苹果、安卓和 Tizen(三星)操作系统。
这无疑是微软在寻求对编程技术和工具实现通用覆盖的最雄心勃勃的项目,并且可以被视为在最初的UWP(通用 Windows 平台)项目之后的自然一步,该项目允许为支持 Windows 的任何设备构建应用程序,从物联网设备到桌面、XBOX 或 HoloLens。
因此,在本章中,我们将从.NET Core 及其衍生框架(如 ASP.NET Core)的主要架构组件的快速回顾开始,随后将介绍依赖注入技术的基础,这是 SOLID 原则的一部分,由 Robert C. Martin(Uncle Bob)在 2000 年提出。(参见维基百科:en.wikipedia.org/wiki/SOLID_(object-oriented_design)).)
因此,我们将回顾这五个 SOLID 原则,解释它们的目的和优势,以及使用.NET Core 编写的控制台应用程序中每个原则的基本实现。我们将看到每个原则及其覆盖范围的解释:
-
关注点分离(在.NET Core 的核心基础设施中明确实现,并在 ASP.NET Core 的初始管道和中间件配置中实现)
-
开放/封闭原则(自.NET Framework 3.0 版本以来已实现,此处也有体现)
-
李斯克夫替换原则(有两种实现方式——通过类型转换的经典方式,以及通过泛型使用)
-
接口分离:解释接口分离及其优势
-
依赖倒置:解释原则、其衍生品以及 IoC 容器概念
在开始时
编程技术的演变,某种程度上,与语言演变有关。一旦最初的(在某些方面,是混乱的)时期过去,计算的通用性变得清晰,对良好的模式和能够承担大型项目的语言的需求变得明显。
20 世纪 70 年代标志着采用其他范式的开始,例如过程式编程,后来,由 Ole-Johan Dahl 和 Kristen Nygaard 在挪威计算中心工作时提出的面向对象编程(OOP),通过 Simula 语言,他们两人都提出了这一概念。他们因这些成就获得了图灵奖以及其他认可。
几年后(大约在 1979 年),比约恩·斯特劳斯特鲁普在 Simula 语言中发现了一些有价值的方面,因此创建了具有类的 C 语言,这是今天 C++ 的原型,因为他认为它对于实际用途来说太慢了,这是第一个被普遍采用的面向对象语言。
C++ 最初具有命令式特性、面向对象和泛型特性,同时还能提供用于低级内存操作编程的能力。虽然它确实已经成为构建关键系统和应用的既定标准,但对于许多人来说,它并不足以满足 LOB (业务线) 应用程序的需求。
几年后,Java 和 .NET 平台为许多程序员提供了一个更简单、更经济的解决方案,同时仍然在面向对象编程语言所倡导的有序空间内发展。
因此,面向对象编程(OOP)被采纳,到目前为止,没有其他重要的编程范式取代这些思想。当然,还有其他方法,比如函数式编程,但即使是这一趋势的最显著代表 JavaScript,在最新版本(ECMAScript 2015)中也变得更加面向对象。
.NET 和 .NET Core
.NET 最近经过重新设计,以实现自萨蒂亚·纳德拉加入公司以来微软一直追求的目标--"*任何开发者,任何应用,任何平台。
"。
根据首席经理斯科特·亨特的说法,公司现在提供了一套统一的应用程序模型,以下截图可以概括:

来源:www.hanselman.com/blog/AnUpdateOnASPNETCore10RC2.aspx
如你所见,目前对于 .NET 开发者来说情况非常乐观。截图显示了一个 通用基础设施(编译器、语言和运行时组件),由 Roselyn 服务和其他功能提供支持。所有这些都与支持这些项目的 IDE 集成,现在包括 Visual Studio for Mac。
在其之上是 .NET 标准库,它具有共同点,使我们能够在三个不同的框架之间共享代码--经典的 .NET Framework(在撰写本文时为 4.6.2 版),.NET Core(现在为 2.0 版),以及 Xamarin,它允许为任何类型的移动目标--Android、iOS、Windows Phone 和 Tizen(三星)--构建应用程序。
关于 .NET Core
.NET Core 是 .NET 的新版本,于 2016 年夏季正式推出,并在同年 11 月的 Connect() 事件中更新到 1.1 版本。它被定义为 跨平台、开源、云就绪且模块化的 .NET 平台,用于创建可在任何地方运行(Windows、Linux 和 MacOS)的现代 Web 应用、微服务、库和控制台应用程序。
它可以与应用程序本身一起部署,从而最小化安装问题。
在发布之前,微软决定重新开始编号,强化了这是一个与经典版本完全不同的新概念的想法,作为避免歧义的一种更好的方式。
MSDN 架构师塞萨尔·德拉·托雷在他的博客中非常精确地定义了 .NET Core 的目标和结构——与传统的 .NET 框架不同,.NET 框架是一个全局安装的单个软件包,仅限于 Windows 的运行时环境,.NET Core 是将 .NET 从 Windows 解耦,允许它在非 Windows 环境中运行,无需安装一个巨大的 400 Mb 的二进制文件集(与仅从 .NET Core 需要的组件的足迹相比),以及能够部署与框架本身一起的应用程序,支持框架不同版本的并行执行。
正如同一来源中提到的,其架构和部署基础设施的一个非常有趣的部分是,.NET Core 不是操作系统的一部分,而是由 NuGet 软件包组成,可以直接编译到应用程序中,或者放入应用程序内部的文件夹中。这意味着应用程序可以携带 .NET Core,因此可以在机器上完全并行运行。
我个人认为这对项目的成功至关重要。没有副作用,目标机器上无需安装组件,也没有依赖。(正如你将在本书中看到的,避免依赖性在构建遵循良好实践的软件时是基础性的。)
NET Core 2.0 - 支持的操作系统版本建议:
| 操作系统 | 版本 | 架构 | 备注 |
|---|---|---|---|
| Windows 客户端 | 7 SP1+ | x64, x86 | |
| Windows Server | 2008 R2 SP1+ | x64, x86 | 配置:完整版、Server Core、Nano |
| Windows IoT | 10+ | [C] arm32 | IoT Core - 请参阅Raspberry Pi 指令 |
| 红帽企业 Linux | 7.3+ | x64 | 这包括 CentOS 和 Oracle Linux |
| Fedora | 25+ | x64 | |
| Debian | 8.7+ | x64 | Debian 9 (Stretch) 工作区 |
| Ubuntu | 14.04+ | x64, [C] arm32 | 这包括 x64 的 Linux Mint 17。对于 arm32,请参阅Raspberry Pi 指令 |
| openSUSE | 42.2+ | x64 | |
| Tizen | 4+ | [S] arm32 | Tizen .NET 开发者预览 |
| Mac OS X | 10.12+ | x64 | |
| 进行中的操作系统 | |||
| Arch Linux | [C] 待定 | 待定 | 由于在发行版中缺少OpenSSL 1.0 软件包而受阻。Arch Linux 社区努力情况可追踪此处。 |
| FreeBSD & NetBSD | [C] TBD | TBD | 跟踪 主要问题 和 标签. NetBSD 的 .NET Core 1.0.0 软件包 |
关于上述任何 IDE 可用的可编程项目类型,.NET Core 可以支持其自己的应用程序模型,也可以支持通用 Windows 平台模型,并且可以选择编译为 .NET Native(见以下截图):

来源:www.hanselman.com/blog/AnUpdateOnASPNETCore10RC2.aspx
我们以之前提到的同一页面上关于此框架的总结来结束对 .NET Core 的介绍:
-
跨平台:.NET Core 目前支持三个主要操作系统——Linux、Windows 和 OS X。还有其他操作系统移植正在进行中,例如 FreeBSD、NetBSD 和 Arch Linux。.NET Core 库可以在支持的操作系统上无修改地运行。由于应用程序使用本地宿主,因此必须针对每个环境重新编译应用程序。用户选择最适合其情况的 .NET Core 支持的环境。
-
开源:.NET Core 可在 GitHub 上找到,地址为
github.com/dotnet/core/blob/master/release-notes/2.0/2.0.0-preview1.md,采用 MIT 和 Apache 2 许可证(许可按组件划分)。它还使用了一组重要的开源行业依赖项(见发布说明)。作为开源软件对于拥有繁荣的社区以及对于许多将开源软件作为其开发策略一部分的组织来说至关重要。 -
自然获取:.NET Core 以一系列 NuGet 软件包的形式分发,开发者可以从中选择所需的内容。运行时和基础框架可以从 NuGet 和特定于操作系统的软件包管理器(如 APT、Homebrew 和 Yum)获取。Docker 镜像可在 Docker Hub 上找到。高级框架库和更大的 .NET 库生态系统可在 NuGet 上找到。
-
模块化框架:.NET Core 采用模块化设计,使应用程序仅包含所需的 .NET Core 库和依赖项。每个应用程序都做出自己的 .NET Core 版本选择,避免与共享组件冲突。这种方法与使用 Docker 等容器技术开发软件的趋势相一致。
-
更小的部署占用空间:即使在 v1.0/1.1 版本中,.NET Core 的大小也比 .NET Framework 小得多;请注意,.NET Core 的整体大小并不旨在随着时间的推移而小于 .NET Framework,但由于它是按需付费的,因此大多数仅利用 CoreFX 部分的应用程序将具有更小的部署占用空间。
-
.NET Core 的快速发布周期:.NET Core 的模块化架构加上其开源特性提供了比大型单体框架慢速发布周期(甚至每个 NuGet 包)更现代、更快的发布周期。这种方法使得微软和开源.NET 社区能够以比传统使用.NET Framework 时更快的速度进行创新。
因此,在.NET Core 之上构建了多个应用模型堆栈,允许开发者构建从控制台应用程序,跨越 UWP Windows 10 应用程序(PC、平板电脑和手机)到可扩展的 Web 应用程序和 ASP.NET Core 微服务的应用程序。
ASP.NET Core
使用.NET Core 的 ASP.NET 应用程序推广基于先前 MVC 模型的模式,尽管是从头开始构建的,旨在跨平台执行,消除了某些不必要的功能,并将先前的 MVC 与 Web API 变体统一;因此,它们使用相同的控制器类型。
此外,在开发过程中,代码不需要在执行前进行编译。BrowserSync 技术允许你即时更改代码,而 Roselyn 服务负责更新;因此,你只需刷新页面即可看到更改。
ASP.NET Core 还使用了一种新的托管模型,完全与托管应用程序的 Web 服务器环境解耦。它支持 IIS 版本,也支持通过 Kestrel(跨平台、极端优化、基于 LibUv,这是 Node.js 使用的相同组件)和 WebListener HTTP(仅限 Windows)服务器进行自托管上下文。
作为其架构的一部分,它提出了一代新的中间件,这些中间件是异步的、非常模块化的、轻量级的,并且完全可配置的,其中我们定义了诸如路由、认证、静态文件、诊断、错误处理、会话、CORS、本地化等;甚至可以由用户自定义。
注意,ASP.NET Core 同样可以在经典.NET Framework 中运行,并访问那些库暴露的功能。以下截图显示了架构:

ASP.NET Core 将许多在先前版本中分离的事物结合在一起。因此,MVC 和 Web API 之间没有区别,如果你针对.NET Core 或者如果你更喜欢针对.NET 的任何其他版本,可以使用这个重构的架构模型来构建 MVC。
此外,一个新的内置 IoC 容器负责依赖注入的启动,以及一个新的配置协议,我们将在接下来的章节中实际看到。
关于本书中使用的 IDE
由于本书涉及 .NET Core 和 ASP.NET Core 以及它们内置的功能,涵盖了 SOLID 原则以及特定的依赖注入(DI),我们使用最新可用的 Visual Studio 版本(Visual Studio 2017 Enterprise),它包括对这些平台的全支持,以及一系列方便的扩展和模板。
你也可以使用免费的 Visual Studio 2017 Community Edition,或者任何更高版本,只要代码示例没有实质性的变化。
如果你是一名 Mac 用户,你也可以使用自 2016 年 11 月以来可用的 Visual Studio for Mac (www.visualstudio.com/vs/visual-studio-mac/),如果你更喜欢任何平台(Linux、Mac 或 Windows)上的轻量级、全功能的免费 IDE,可以选择 Visual Studio Code (code.visualstudio.com/download),它也具有出色的编辑和调试功能。所有这些都对 .NET Core/ASP.NET Core 提供了全面支持(见以下截图):

在本章节和其他章节中,我将根据是否需要更复杂的用户界面,无差别地使用 .NET Core 或 ASP.NET Core 进行演示。注意,目前 .NET Core 不提供任何超出控制台应用程序的视觉 UI。
实际上,当我们选择 新建项目 并点击 .NET Core 时,默认显示的当前可用的模板就是你在以下截图中所看到的:

如你所见,选择基本上有三类(除了测试):控制台应用程序、类库和基于 .NET Core 的 ASP.NET Core 网络应用程序。在这三种情况下,生成的应用程序可以在任何平台上运行。
.NET Core 的其他基础变更
需要记住的是,在 .NET Core 中,你不再依赖于 .NET Framework 库(BCL 库),无论是操作系统安装的还是手动安装在 GAC(全局程序集缓存)中的。
所有库都可通过 NuGet 获取并相应下载。但是,如果你在 Visual Studio 2017 之前尝试过 .NET Core,你可能会错过包含所有依赖项的 project.json 文件。
官方文档指出,当使用 Visual Studio 2017 时:
-
MSBuild 支持 .NET Core 项目,使用简化的
csproj项目格式,这使得手动编辑更加容易,无需卸载项目 -
项目文件支持文件通配符,使得基于文件夹的项目不需要包含单个文件
-
NuGet 包引用现在是
csproj格式的一部分,将所有项目引用合并到一个文件中
因此,如果你尝试使用此工具创建新的 .NET Core 项目,项目的依赖现在已在 csproj 文件(XML 格式)中引用,正如你在任何文本编辑器中打开它时所见:

同时,Visual Studio 读取该文件,在解决方案资源管理器中创建一个Dependencies条目,并开始寻找该信息(在 PC 的缓存中或在 NuGet 中)。
注意,它们不是真正的、经典的 DLL,而是在编译时组装在一起的代码片段,以最小化大小和启动时间。如果你查看那个条目,你可以看到依赖项的依赖项,等等:

另一个需要强调的关键点与编译过程之后产生的可交付成果有关。如果你打开包含的 ConsoleApp1(或创建你自己的基本版本),并仅编译它,你会看到 bin 目录中不包含任何可执行文件。你会看到一个名为该名称的 DLL 文件(ConsoleApp1.dll)。
当你启动应用程序(在添加Console.Read()语句以停止执行之后),你会发现可执行文件确实是dotnet.exe。同样,当你打开诊断工具并捕获可执行文件的快照以查看那一刻的情况时也是如此。以下截图显示了这种情况:

这直接与该模型复杂性相关。该应用程序被认为将在不同的平台上执行。默认选项允许部署架构根据目标确定配置 JIT 编译器的最佳方式。这就是为什么执行由 dotnet 运行时(命名为 dotnet.exe)承担。
从部署的角度来看,在.NET Core 中定义了两种应用程序类型:可移植和自包含。
在.NET Core 中,可移植应用程序是默认的。当然,这意味着(作为开发者)我们可以确信它们在不同.NET Core 安装中的可移植性。然而,独立应用程序不依赖于任何之前的安装来运行。也就是说,它包含所有必要的组件和依赖项,包括与应用程序打包的运行时。当然,这会构建一个更大的应用程序,但同时也使应用程序能够在任何.NET Core 平台上执行,无论目标是否安装了.NET Core。
对于本书的主要目的来说,我们选择哪种运行时模式并不重要。无论如何,这个简短的介绍可以给你一个关于新框架如何在 Visual Studio 2017 内部行为和管理的想法。
并且,记住,我使用 Visual Studio 2017 所做的任何事情,你都可以使用 Visual Studio Code 来做。
SOLID 原则
一些编程指南具有全面、通用的目的,而另一些则主要是为了解决某些特定问题。因此,在我们专注于特定问题之前,回顾那些可以在不同场景和解决方案中应用的特征是很重要的。我的意思是那些你应考虑的原则,而不仅仅是针对解决方案类型或特定平台进行编程。
这就是 SOLID 原则(以及其他相关问题)发挥作用的地方。在 2001 年,罗伯特·马丁发表了一篇关于该主题的基础性文章(butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod),在其中他列举了一套原则和指南,用他自己的话说,这些原则非常紧密地关注依赖管理、其潜在问题以及如何避免这些问题。
为了进一步解释这一点,用他的话说,糟糕的依赖管理会导致难以更改、脆弱且不可重用的代码。因此,这个原则与两个面向对象的原则直接相关——可重用性和可维护性(随着项目的发展而改变的能力,继承的主要目标之一)。
总体而言,马丁提出了 11 条应考虑的戒律,但它们可以分为三个领域:
-
处理类设计的五个 SOLID 原则
-
其他六个原则主要关注包——其中三个是关于包的凝聚性,另外三个解释了包耦合的危险以及如何评估包结构
我们将从 SOLID 原则开始,这些原则不仅影响类设计,还影响软件架构的其他方面。
这些想法的应用,例如,在 HTML5 标准的重大修改中起到了决定性作用。具体来说,SRP(单一职责原则)的应用仅强调了完全将表示(CSS)与内容(HTML)分离的需要,以及随后一些标签(<cite>、<small>、<font>)的弃用。
这也适用于其他流行的框架,例如 AngularJS(在 Angular 2 中更是如此),这两个框架不仅考虑了单一职责原则,而且基于依赖倒置原则(SOLID 中的D)。
以下图表概述了五个原则的首字母及其对应关系:

维基百科中关于该缩写每个字母的解释如下:
-
S - 单一职责原则:一个类应该只有一个职责(也就是说,只有软件规范的一个潜在变化应该能够影响类的规范)。马丁指出,这个原则基于凝聚性原则,该原则由汤姆·德马尔科在其名为《结构化分析和系统规范》的书中以及梅利尔·佩奇-琼斯在其著作《结构化系统设计实用指南》中定义。
-
O - 开放/封闭原则:软件实体应该对扩展开放,但对修改封闭。伯特兰·迈耶是第一个提出这一原则的人。马丁在
www.butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod中以另一种方式表达了这个观点,说你应该能够扩展类的行为,而不需要修改它。 -
L - Liskov 替换原则:程序中的对象应该可以用其子类型实例替换,而不会改变该程序的正确性。芭芭拉·利斯科夫首先提出了这一观点,马丁以这种方式重新表述了原则--派生类必须可替换为其基类。
-
I - 接口隔离原则:许多针对特定客户端的接口比一个通用接口更好。罗伯特·C·马丁是第一个使用和制定这一原则的人,他在上述文章中将它重新表述为--创建细粒度且针对特定客户端的接口。
-
D - 依赖倒置原则:我们应该'依赖抽象'。不要依赖于具体实现。这也是罗伯特·C·马丁提出的一个想法。
单一职责原则(SRP)
单一职责原则(SRP)关注的是,一个类不应该有超过一个改变的理由。在这种情况下,职责被定义为改变的理由。如果在任何情况下,出现多个理由来改变类,那么类的职责是多个的,应该被重新定义。
这确实是最难正确应用的原则之一,因为正如马丁所说,合并职责是我们自然而然做的事情。在他的书《敏捷原则、模式和 C#实践》中,马丁提出了一个典型的例子来展示差异,如下所示:
interface Modem
{
public void dial(String phoneNumber);
public void hangup();
public void send(char c);
public char recv();
}
根据前面的接口,任何实现它的类都有两个职责:连接管理和通信本身。这些职责可以从应用程序的不同部分使用,而这些部分也可能随之改变。
我们将使用 Visual Studio 2017 类设计器来展示马丁提出的我们如何表达这种类设计的方式:

如我们所见,在马丁的解决方案中,类依赖于两个接口,每个接口负责一个职责--连接和通道传输(实际上两个抽象:记住接口不是编译的,它只作为编译器检查的合约)。
然而,有人会想,这两个职责应该分开吗?这取决于应用程序的变化。更准确地说,关键在于知道应用程序的变化是否会影响连接函数的签名。如果会,我们应该将两者分开;否则,没有必要分开,因为那样会创建不必要的复杂性。
因此,总的来说,改变的原因是关键,但请记住,只有在发生变化时,改变的原因才是适用的。
在其他情况下,只要它们与业务定义紧密相关或与操作系统的硬件要求有关,就有理由将不同的职责保持在一起。
关注点分离(SoC)的背景
正如通常发生的那样,软件分离问题之前有先前的解决方案。迪杰斯特拉在“关于科学思维的作用”(www.cs.utexas.edu/users/EWD/transcriptions/EWD04xx/EWD447.html)中提到:“这就是我有时所说的‘关注点分离’,即使不是完美可行的,但据我所知,是唯一有效的思维排序技术。”
另一项进步是信息隐藏,维基百科将其定义为“在计算机程序中分离最可能改变的设计决策的原则,从而在设计决策改变时保护程序的其他部分免受大量修改。”这是后来成为面向对象编程(OOP)基本支柱之一的封装的种子。
即使是提到替换原则时我们提到的芭芭拉·利斯科夫,也同时发表了《使用抽象数据类型编程》(dl.acm.org/citation.cfm?id=807045),她将其描述为一种计算机表示抽象的方法。ADT 作为一类对象,其逻辑行为由一组值和一组操作定义,将数据和功能联系起来。
后来的方法改进了这些想法。代码契约的提议,最初由伯特兰·梅耶在他的 Eiffel 语言中引入,并通过 C#中的代码契约实现(msdn.microsoft.com/es-es/library/dd264808(v=vs.110).aspx),鼓励使用软件必须完成的预条件和后条件。
最后,我们可以将海姆·马卡比([effectivesoftwaredesign.com/2012/02/05/separation-of-concerns/](https://effectivesoftwaredesign.com/2012/02/05/separation-of-concerns/))所报告的跨切面关注点的分离视为——可能影响软件的不同部分,甚至在应用的不同层中,应该以类似方式管理的方面(授权或仪表问题等)。在.NET 中,我们依赖于属性,这些属性可以应用于类及其成员,以修改和调整此类行为。
在同一篇文章的稍后部分,Makabee 明确提出了这些技术的主要目的。如果我们把耦合度理解为两个模块之间依赖的程度,那么目标是获得低耦合度。另一个术语是内聚度,即一个模块执行的功能集合的紧密程度。显然,高内聚度更好。
他最后总结了使用这些技术获得的好处:
模式和方法总是旨在减少耦合度,同时增加一致性。通过隐藏信息,我们减少了耦合度,因为我们隔离了实现细节。因此,ADT(抽象数据类型)通过使用清晰和抽象的接口来减少耦合度。我们有一个 ADT,它指定了可以在类型上执行的一组函数,这比由外部函数修改的全局数据结构更具有内聚度。面向对象编程(OOP)达到这种内聚度的方法是实现其两个基本原则——封装和多态,以及动态绑定。此外,继承通过基于泛化和特殊化的层次结构来加强内聚度,这允许从超类所属的功能与其子类之间进行适当的分离。另一方面,AOP(面向切面编程)为跨切面关注点提供了解决方案,这样两个方面和功能都可能变得更加内聚。
可维护性、可重用性和可扩展性只是通过其实施获得的主要优势中的三个。
分离关注点的知名例子
我们都经历过一些案例和场景,其中关注点的分离是实施该系统或技术的核心。其中之一就是 HTML(尤其是 HTML5)。
自从其诞生以来,标准 HTML5 被认为可以清楚地分离内容和表现。移动设备的普及使得这一要求更加明显。今天可用的各种形式因素需要一种能够适应这些尺寸的技术,这样内容就可以由 HTML 标签持有,而最终的表现形式则由设备在运行时根据设备决定。
因此,一些标签被宣布为已弃用,例如 <font>、<big>、<center> 以及其他一些标签,同样,一些属性,如 background、align、bgcolor 或 border 也发生了同样的情况,因为它们在这个新系统中没有意义。甚至一些仍然保持不变并且对输出有视觉效果的标签(如 <b>、<i> 或 <small>)也保留了下来,这是由于它们的语义意义,而不是它们的表现效果,这种角色完全依赖于 CSS3。
因此,主要目标之一是避免功能重叠,尽管这并非唯一的好处。如果我们把关注点理解为软件功能的不同方面,那么软件的业务逻辑是一个关注点,而一个人使用该逻辑的接口是另一个关注点。
实际上,这意味着将每个这些关注点的代码保持独立。这意味着,更改接口不需要更改业务逻辑代码,反之亦然。封装的底层原则在面向对象(OOP)范式中强化了这些想法,而模型-视图-控制器(MVC)设计模式是分离这些关注点以实现更好的软件可维护性的一个很好的例子。
分离关注点的一个基本示例
让我们用一个非常基础的示例来编写这段代码,并检查耦合和去耦合实现之间的差异。想象一下,一个.NET Core 控制台应用程序必须向用户显示控制台颜色的初始配置,更改一个值,并展示这些更改。
如果你创建一个基本的项目ConsoleApp1,以下代码可能是第一种方法:
using System;
class Program
{
static void Main(string[] args)
{
Console.ResetColor();
Console.WriteLine("This is the default configuration for Console");
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("Color changed...");
Console.Read();
}
}
这会产生预期的结果(以下屏幕截图显示了输出):

这段代码中我们可以发现哪些问题?首先,主要职责是负责一切:重置控制台之前的初始配置,更改前景,并打印结果。
分离的第一个尝试可能是意识到进一步的需求可能需要其他代码片段使用相同的功能。更进一步,这种功能最好位于另一个差异中——例如,一个库这样的软件。因此,我们应该通过一个新的项目来增强我们的解决方案,这个项目包含一个库项目,它将被任何其他项目以及解决方案引用。
此外,手动更改到青色颜色隐含地提醒我们需要一个允许更改到任何有效颜色的函数。
因此,我们可能会得到另一段像这样的代码:
namespace Utilities
{
public class ConsoleService
{
public void ChangeForegroundColor(ConsoleColor newColor)
{
Console.ForegroundColor = newColor;
}
public void ResetConsoleValues()
{
Console.ResetColor();
}
}
}
现在,在主入口点,我们可以编写:
/* This is version 2 (with utilities) */
ConsoleService cs = new ConsoleService();
cs.ResetConsoleValues();
Console.WriteLine("This is the default configuration for Console");
cs.ChangeForegroundColor(ConsoleColor.Cyan);
Console.WriteLine("Color changed...");
Console.Read();
结果完全相同(我省略了输出,因为没有变化)。因此,我们实现了物理分离和逻辑分离,因为现在对Console的任何更改都应该由Utilities库来管理,这增加了它们的可重用性,因此提高了可维护性和可测试性。
注意,我们还可以选择创建一个静态库,以避免实例化。
与.NET 的先前版本相比,唯一的改变是,正如我们在之前的屏幕截图中所展示的,现在对库的引用方式略有不同,它在解决方案资源管理器的依赖项部分中显示。一旦项目编译完成,我们也可以在编译产生的bin目录中看到这个引用:

另一个示例
让我们用一个更常见的示例来采取更实际的方法:简单的东西,比如从磁盘上的 JSON 文件中读取并展示结果。所以,我创建了一个包含来自 PACKT 的五本书的 JSON 文件的.NET Core 控制台应用程序。
一种可能的方法可能是以下代码:
using System;
using System.IO;
using Newtonsoft.Json;
class Program
{
static void Main(string[] args)
{
Console.WriteLine(" List of Books by PACKT");
Console.WriteLine(" ----------------------");
var cadJSON = ReadFile("Data/BookStore.json");
var bookList = JsonConvert.DeserializeObject<Book[]>(cadJSON);
foreach (var item in bookList)
{
Console.WriteLine($" {item.Title.PadRight(39,' ')} " +
$"{item.Author.PadRight(15,' ')} {item.Price}");
}
Console.Read();
}
static string ReadFile(string filename)
{
return File.ReadAllText(filename);
}
}
如我们所见,代码使用了一个实现 IBook 接口的 Book 类,该接口以非常简单的方式定义:
interface IBook
{
string Title { get; set; }
string Author { get; set; }
double Price { get; set; }
}
class Book : IBook
{
public string Author { get; set; }
public double Price { get; set; }
public string Title { get; set; }
}
这一切正常,并生成以下输出:

注意,我们正在使用流行的 Newtonsoft JSON 库,以便轻松地将字符串转换为 Book 对象数组。
如果我们分析代码,我们可以识别出几个存在 SoC 原理的地方:
-
首先,由于要管理的实体是
Book(具有三个属性),我创建了一个Model文件夹来保存Book接口(IBook)的定义,以及实现该接口的Book类 -
其次,使用
Newtonsoft库是另一种分离,因为它是负责将字符串转换为Books数组的库。 -
最后,文件读取发生在接收文件名的
ReadFile()方法中。
是否还需要其他分离?正如我们提到的,改变的原因在决定时是关键的。例如,应用程序是否读取另一种类型的信息(除了 Books 之外)?或者,我们的用户界面是否真的需要包含 ReadFile() 方法?还有,直接在用户界面中引用 Newtonsoft 库呢?
如果不是这样,可能更好的方法是将该方法分离到一个 Utilities 类中,就像在第一个示例中一样,从而确保架构有三个单独的文件夹来保存应用程序的不同方面:数据模型、实用工具区域和主要用户界面。
以这种方式,我们最终会得到一个如下所示的 Utilities 类:
using Newtonsoft.Json;
using System.IO;
internal class Utilities
{
internal static Book[] ReadData()
{
var cadJSON = ReadFile("Data/BookStore.json");
return JsonConvert.DeserializeObject<Book[]>(cadJSON);
}
static string ReadFile(string filename)
{
return File.ReadAllText(filename);
}
}
结果的 Program 类简化为以下内容:
using System;
class Program
{
static void Main(string[] args)
{
var bookList = Utilities.ReadData();
PrintBooks(bookList);
}
static void PrintBooks(Book[] books)
{
Console.WriteLine(" List of Books by PACKT");
Console.WriteLine(" ----------------------");
foreach (var item in books)
{
Console.WriteLine($" {item.Title.PadRight(39, ' ')} " +
$"{item.Author.PadRight(15, ' ')} {item.Price}");
}
Console.Read();
}
}
当然,我们得到相同的结果,但现在我们有一个初始的关注点分离。在用户界面中不需要引用外部库,这有助于维护性和可扩展性。
现在我们来探索第二个原则:开放/封闭。
开放/封闭原则
当模块的变更导致一系列影响依赖模块的连锁反应时,我们可以检测到需要使用这个原则。我们说设计过于不灵活。
开放/封闭原则(OCP)原则建议我们应以这种方式重构应用程序,以便未来的更改不会引发进一步的修改。
正确应用此原则的形式是通过扩展新代码(例如,使用多态)来扩展功能,而不会更改已经工作的旧代码。我们可以找到几种实现这一目标的方法。
注意,对于修改封闭性特别有意义,当你有独立的、分开的模块(DLLs、EXEs 等)依赖于必须更改的模块时。
另一方面,使用扩展方法或多态技术允许我们在不影响其余部分的情况下进行代码更改。例如,考虑自 C# 3.0 版本以来在 C# 语言中可用的扩展方法。
你可以将扩展方法视为一种特殊的静态方法,区别在于它们被调用时就像它们是扩展类型的实例方法一样。你可以在 LINQ 标准查询操作符中找到一个典型的例子,因为它们为现有类型添加了查询功能,例如System.Collections.IEnumerable或System.Collections.Generic.IEnumerable<T>。
这种模式的经典和最简单的例子是客户端/服务器耦合,这在多年的开发中已经大量出现。最好是客户端依赖于服务器抽象,而不是它们的具体实现。
这可以通过接口来实现。服务器可以实现客户端接口,客户端将使用它来连接到服务器。以这种方式,服务器可以改变,而不会影响客户端使用它们的方式(参见图表):

任何客户端接口的子类型都可以自由地以它认为更合适的方式实现接口,只要它不影响其他客户端的访问。
回到我们的示例
让我们想象一个简单的案例,其中应用程序必须覆盖一个新的方面。例如,现在应用程序必须允许用户列出额外的书籍文件以添加到之前的列表中。
对于这个新要求,我们可以创建一个新的重载ReadData()方法,它接收一个额外的参数。注意,这里甚至不需要使用参数。只要它声明了另一个签名来调用这种额外情况就足够了。
如果我们在另一个文件中(在我们的示例中是BookStore2.json)有额外的数据,我们可以创建这个额外版本的方法:
internal static List<Book> ReadData(string extra)
{
List<Book> books = ReadData();
var cadJSON = ReadFile("Data/BookStore2.json");
books.AddRange(JsonConvert.DeserializeObject<List<Book>>(cadJSON));
return books;
}
注意,我们甚至没有在这个实现中使用方法参数(当然,还有其他方法可以做到这一点,但为了演示的目的,我们就这样做)。
我们现在有两个版本的ReadData()方法,根据用户的选择在用户界面中调用(我也将Book[]定义改为List<Book>以简化,但您也可以在源代码中看到旧版本):
static List<Book> bookList;
static void Main(string[] args)
{
Console.WriteLine("Please, press 'yes' to read an extra file, ");
Console.WriteLine("or any other key for a single file");
var ans = Console.ReadLine();
bookList = (ans != "yes") ? Utilities.ReadData() : Utilities.ReadData(ans);
PrintBooks(bookList);
}
现在如果用户的答案是是,你将有一组额外的书籍添加到列表中,正如你在输出中可以看到的:

除了所有这些原因之外,你可以考虑像将Utilities代码分离到独立的库中这样的情况,这个库也可以被应用程序的其他部分使用。这里 Open/Closed 原则的实现允许更稳定和可扩展的方法。
Liskov 替换原则
让我们记住这个定义——子类型必须可替换为其基类型。这意味着这应该在不破坏执行或丢失任何其他类型的功能的情况下发生。
你会注意到这个想法是面向对象编程范式继承的基本原则背后的。
如果你有一个需要Person类型参数的方法(让我们这样表达),你可以传递另一个类的实例(Employee、Provider等)作为参数,只要这些实例继承自Person。
这就是设计良好的面向对象语言的主要优势之一,并且大多数流行和接受的语言都支持这一特性。
再次回到代码
让我们看看我们的示例中的支持,其中出现了一个新的需求。实际上,我们的演示只是调用PrintBooks方法,并期望接收一个List<Book>对象作为参数。
然而,当出现新的书籍列表,并且这些列表包含一些新字段,如每本书所属的主题(.NET、Node、Angular 等)时,可能还会出现改变的原因。
例如,出现了一个包含第四个字段Topic的新列表:
{
"Title": "AngularJS Services",
"Author": "Jim Lavin",
"Price": 30.99,
"Topic": "Angular"
}
类Book不应该被修改,因为它已经被使用。因此,我们可以从Book继承并创建一个TopicBook类,只需添加新的字段(我尽量保持事情尽可能简单,以便专注于我们正在处理的架构):
public class TopicBook : Book
{
public string Topic { get; set; }
}
为了涵盖这个新方面,我们可以更改用户界面,允许用户选择一个新选项(topic),该选项包括新的书籍类型:
static void Main(string[] args)
{
Console.WriteLine("Please, press 'yes' to read an extra file, ");
Console.WriteLine("'topic' to include topic books or any
other key for a single file");
var ans = Console.ReadLine();
bookList = ((ans != "yes") && (ans != "topic")) ?
Utilities.ReadData() : Utilities.ReadData(ans);
PrintBooks(bookList);
}
注意,我们只是在包括一个新条件,并在新条件被选中时调用重载方法。
至于ReadData()重载方法,我们可以进行一些最小程度的修改(基本上,添加一个if条件以包含额外的数据),就像你可以在以下代码中看到的那样:
internal static List<Book> ReadData(string extra)
{
List<Book> books = ReadData();
var filename = "Data/BookStore2.json";
var cadJSON = ReadFile(filename);
books.AddRange(JsonConvert.DeserializeObject<List<Book>>(cadJSON));
if (extra == "topic")
{
filename = "Data/BookStore3.json";
cadJSON = ReadFile(filename);
books.AddRange(JsonConvert.DeserializeObject<List<TopicBook>>(cadJSON));
}
return books;
}
注意到方法的变化很小,特别是我们在添加到书籍列表中的是反序列化不同类(TopicBook)的结果,没有任何编译或执行问题。
因此,.NET(以及在这种情况下.NET Core)中泛型的实现正确地实现了 Liskov 替换原则,我们不需要对我们的逻辑进行修改。
我们可以在ReadData函数的return语句之前使用断点在自动窗口中检查结果,看看List<Book>现在是否包含五个类型的TopicBook元素,没有任何错误:

那么,关于另一边(用户界面逻辑)以及我们的PrintBooks方法,它期望一个List<Book>对象作为参数呢?好吧,只要我们不尝试打印一个不存在的字段,就没有区别。
你可以在以下屏幕截图中检查输出:

多亏了 Liskov 替换原则的支持,我们能够以最小的努力添加行为和信息,从而强制执行面向对象编程的代码重用原则。
.NET 中 LSP 的其他实现
到目前为止我们所看到的是.NET 中 LSP 原则实现的唯一方法之一,因为框架的不同区域已经使用这种概念进行扩展。
事件足够灵活,可以以允许我们通过经典定义传递我们自己的信息的方式定义,或者通过泛型的参与,我们可以简单地定义一个通用的事件处理器,它可以持有任何类型的信息。所有这些技术都促进了良好实践的实现,而不仅仅是 SOLID 原则。
接口隔离原则
正如马丁所说,这个原则处理了胖接口的不便。问题出现在类的接口可以逻辑上分解成不同的组或方法时。
在这种情况下,如果我们的应用程序有多个客户端,那么有些客户端可能连接到他们从未使用过的功能。正如马丁在他的《敏捷原则、模式与实践》一书中所说,
当客户端分离时,接口也应该保持分离。为什么?因为客户端对其服务器接口施加力量。当我们想到导致软件变化的力时,我们通常考虑接口的变化将如何影响其用户。
最后,他总结说,
当客户端被迫依赖他们不使用的方法时,这些客户端会受到那些方法变化的影响。这导致所有客户端之间出现意外的耦合。换句话说,当一个客户端依赖于包含它不使用但其他客户端使用的方方法的类时,该客户端将受到那些其他客户端对类施加的变化的影响。我们希望尽可能避免这种耦合,因此我们想要分离接口。
另一个示例
让我们通过另一个从新场景开始的例子来看这种情况。让我们想象另一个应用程序,其中我们不仅要覆盖目前可用的两种书籍类型,还要覆盖一种新的视频格式出版物,该出版物有一个名为Duration的字段。
该文件的单一记录看起来像这样:
{
"Title": "HTML 5 Game Development",
"Author": "Daniel Albu",
"Price": 5.68,
"Topic": "HTML5 Games",
"Duration": "2h20m"
}
但是应用程序仍然保留了其他两种之前的格式,因此我们有可能根据用户选择的初始选项列出具有三个、四个或五个字段的文件。
第一种方法可能会引导我们到一个这样的接口:
interface IProduct
{
string Title {get; set;}
string Author {get; set;}
double Price {get; set;}
string Topic { get; set; }
string Duration { get; set; }
}
基于这个界面,我们可以创建Product类(新的名字意味着它应该位于书籍或视频之上,因为两者都有四个共同的字段):
public class Product : IProduct
{
public string Title { get; set; }
public string Author { get; set; }
public double Price { get; set; }
public string Topic { get; set; }
public string Duration { get; set; }
}
现在,等效的Utilities类可以根据用户的输入选择一个文件,读取它,反序列化它,并将信息发送回负责控制台输出的PrintProducts方法。
我们新的用户界面看起来像这样:
using System;
using System.Collections.Generic;
class Program
{
static List<Product> productList;
static void Main(string[] args)
{
string id = string.Empty;
do
{
Console.WriteLine("File no. to read: 1/2/3-Enter(exit): ");
id = Console.ReadLine();
if ("123".Contains(id) && !String.IsNullOrEmpty(id))
{
productList = Utilities.ReadData(id);
PrintBooks(productList);
}
} while (!String.IsNullOrWhiteSpace(id));
}
static void PrintBooks(List<Product> products)
{
Console.WriteLine(" List of Products by PACKT");
Console.WriteLine(" ----------------------");
foreach (var item in products)
{
Console.WriteLine($" {item.Title.PadRight(36, ' ')} " +
$"{item.Author.PadRight(20, ' ')} {item.Price}" + " " +
$"{item.Topic?.PadRight(12, ' ') } " +]
$"{item.Duration ?? ""}");
}
Console.WriteLine();
}
}
注意,我们必须处理两种情况,即某些字段可能为空,因此我们使用字符串插值,结合空合并运算符(??)和空条件运算符(?),以防止在这些情况下失败。
Utilities类简化为更简单的代码:
using System.Collections.Generic;
using System.IO;
internal class Utilities
{
internal static List<Product> ReadData(string fileId)
{
var filename = "Data/ProductStore" + fileId + ".json";
var cadJSON = ReadFile(filename);
return JsonConvert.DeserializeObject<List<Product>>(cadJSON);
}
static string ReadFile(string filename)
{
return File.ReadAllText(filename);
}
}
输出允许用户选择一个数字,并以与我们之前在演示中所做的方式打印文件内容,只是这次是单独选择每个文件:

如果我们的应用程序现在需要更多的更改,比如添加统计信息,例如,使用单个类来持有所有这些(这里的Product类)表示违反了接口分离原则。
这是因为我们应该分离接口,并使用复合方法来防止一个类处理不想要的或不必要的功能。
可行的和适当的分离可能是创建以下(不同的)接口:
interface IProduct
{
string Title { get; set; }
string Author { get; set; }
double Price { get; set; }
}
interface ITopic
{
string Topic { get; set; }
}
interface IDuration
{
string Duration { get; set; }
}
现在我们应该有三个类,因为可以区分出三个实体,但可以保持三个共同的字段。这三个类的定义可以表达如下:
class Book : IProduct
{
public string Author { get; set; }
public double Price { get; set; }
public string Title { get; set; }
}
class TopicBook: IProduct, ITopic
{
public string Author { get; set; }
public double Price { get; set; }
public string Title { get; set; }
public string Topic { get; set; }
}
class Video: IProduct, ITopic, IDuration
{
public string Author { get; set; }
public double Price { get; set; }
public string Title { get; set; }
public string Topic { get; set; }
public string Duration { get; set; }
}
由于这种划分,每个实体都保持了自己的个性,我们后来可以创建使用泛型的方法,或者应用 Liskov 替换原则来处理生命周期中可能出现的不同需求。
依赖倒置原则
SOLID 原则中的最后一条基于两个声明,维基百科(en.wikipedia.org/wiki/Dependency_inversion_principle)以这种形式定义了这两个声明:
-
高层模块不应当依赖于低层模块。两者都应当依赖于抽象
-
抽象不应当依赖于细节。细节应当依赖于抽象
关于第一个声明,我们应该明确我们理解的高层模块和低层模块是什么。这个术语与模块执行的动作相对于整个应用程序的重要性相关。
让我们简单地说:如果一个模块持有Customers类的业务逻辑,而另一个模块PrinterService包含Customers类在报告中使用的格式,那么第一个模块将是高层模块,第二个模块将是低层模块(第二个模块存在的原因是为第一个模块提供一些功能)。
第二个声明不言自明。如果一个抽象依赖于细节,那么作为定义合同的用法就会受到损害(细节的变化可能迫使重新定义)。
(更多或更少的)典型示例
依赖注入技术只是实现这一原则的一种方式,我们将在本书的许多形式和场景中看到它们的示例。
因此,我将在这里使用(几乎)互联网上关于这个主题的规范代码。我在这里向您展示了一个由 Munir Hassan(www.codeproject.com/Articles/495019/Dependency-Inversion-Principle-and-the-Dependency)在 CodeProject 上制作的改编,他用一个通知场景来说明这种情况,我认为这特别有趣。他从一个初始代码开始,如下所示:
public class Email
{
public void SendEmail()
{
// code
}
}
public class Notification
{
private Email _email;
public Notification()
{
_email = new Email();
}
public void PromotionalNotification()
{
_email.SendEmail();
}
}
通知依赖于 Email,在其构造函数中创建一个实例。这种交互被称为紧密耦合。如果我们还想发送其他类型的通知,我们必须修改 Notification 类的实现方式。
实现这一点的途径之一是引入一个接口(一个新的抽象级别)来定义发送消息的概念,并强制 Email 类实现该接口:
public interface IMessageService
{
void SendMessage();
}
public class Email : IMessageService
{
public void SendMessage()
{
// code
}
}
public class Notification
{
private IMessageService _iMessageService;
public Notification()
{
_iMessageService = new Email();
}
public void PromotionalNotification()
{
_iMessageService.SendMessage();
}
}
现在,类调用一个名为 _iMessageService 的东西,其实现可能会有所不同。正如 Hamir 提到的,有三种方法来实现这种模式:
依赖注入(DI)是指为服务提供所有所需类,而不是将获取依赖类的责任留给服务。依赖注入通常有三种形式:构造函数注入、属性注入、方法注入
在第一种形式中(构造函数注入),Hamir 提出了以下建议:
public class Notification
{
private IMessageService _iMessageService;
public Notification(IMessageService _messageService)
{
this._iMessageService = _messageService;
}
public void PromotionalNotification()
{
_iMessageService.SendMessage();
}
}
这使我们想起了在下一章中我们将要看到的 ASP.NET Core 中依赖注入的实现。这里没有提到 Emails:只隐含了一个 IMessageService。
您可以访问上述页面以获取有关其他实现注入方式的更多详细信息,但正如我提到的,我们将在接下来的章节中详细介绍所有这些。
实现依赖倒置的其他方法
一般而言,有许多方式可以实现 DIP 原则,从而得到解决方案。实现这一原则的另一种方法是使用依赖注入技术,这是从另一种看待依赖倒置的方式中衍生出来的,即所谓的控制反转(IoC)。
根据 Martin Fowler(martinfowler.com/articles/injection.html)撰写的论文,控制反转(Inversion of Control,IoC)是一种原则,其中程序的流程被反转;不是程序员控制程序的流程,而是外部来源(框架、服务和其他组件)控制它。
其中之一是一个依赖容器,它是一个组件,并在需要时为您提供服务或代码的注入。
一些流行的 C#依赖容器有 Unity 和 Ninject,仅举两个例子。在.NET Core 中,有一个内置的容器,因此不需要使用外部容器,除非我们可能需要它们提供的某些特殊功能。
在代码中,你指导这个组件注册你的应用程序中的某些类;因此,稍后当你需要其中一个类的实例时,你只需声明它(通常在构造函数中),它就会自动提供给你的代码。
其他框架也实现了这一原则,即使它们不是纯粹面向对象的。AngularJS 或 Angular 2 就是这种情况,其中,当你创建一个需要访问服务的控制器时,你会在控制器函数声明中请求该服务,而 Angular 的内部 DI 系统会自动提供一个服务的单例实例,无需客户端代码的干预。
摘要
在本章中,我们回顾了罗伯特·C·马丁在 2000 年提出的五个 SOLID 原则。
我们已经探讨了这些原则中的每一个,讨论了它们的优点,并使用.NET Core 控制台应用程序的简单代码来检查它们的实现,以了解它们如何被编码。
在下一章中,我们将讨论依赖注入以及最流行的 IoC 容器,回顾它们如何被使用,并分析它们在日常应用中的优缺点。
第二章:依赖注入和 IoC 容器
本章旨在更深入地探讨依赖反转原则。这意味着我们将涵盖它如何在不同语言的流行框架中使用,例如 C#或 JavaScript。我们将看到它的主要优点和缺点,以及对其在全球开发者中为何获得势头和共识的简要分析。
在本章中,我们将涵盖以下主题:
-
总的来说,我们将讨论软件工件(如工厂和服务定位器)的概念和实现,以及它们如何与依赖注入相关。工厂和服务定位器——概念、实现以及它们与依赖注入的关系
-
我们还将涵盖 IoC 容器如何实现这个概念,以及这些 IoC 容器在它们实现的语言之外有哪些共同的主要点
-
然后,我们将简要介绍对象组合、对象生命周期以及不同类型的注入,最后对那些依赖注入不是最佳选择的情况进行一些评论
-
我们将以关于今天使用的流行.NET 框架 IoC 容器的演示和一些关于在 JavaScript 框架(如 AngularJS)中如何使用 DI 的简要介绍结束。
本章我们将涵盖以下主题:
-
工厂和服务定位器——概念、实现以及它们与依赖注入的关系
-
IoC 容器
-
关于今天使用的流行.NET 框架 IoC 容器的演示
-
对象组合、对象生命周期以及不同类型注入的介绍
-
简要介绍 DI 在 JavaScript 框架(如 AngularJS)中的应用
更详细的依赖原则
但是,在所有这些之前,让我们记住,我们推迟了本章对原则的更详细解释。在第一章,“软件设计的 SOLID 原则”,我们讨论了五个 SOLID 原则的一般性,所以现在是时候深入探讨依赖原则了。实际上,这并不难。它只需要正确理解罗伯特·马丁强调的两个基本点,并通过一些源代码表达这个想法。回想一下:
“高层模块不应当依赖于低层模块。两者都应当依赖于抽象。”
抽象不应当依赖于细节。细节应当依赖于抽象。”
记住,我们还解释了高层和低层类的直观概念,根据它们对应用程序的功能性。在这方面,你可以将应用程序视为一个组织层次结构,其中不同的级别依赖于他们在公司中的角色。
让我们看看一个例子
因此,让我们看看一个违反依赖原则的例子,提出一个解决方案,并看看这个解决方案可能根据应用程序的需求(甚至在许多情况下根据程序员的喜好)有多种不同的风味。
我们将使用一个基本的 .NET 应用程序来设置初始场景。它只是一个经典的控制台应用程序,从特定目录读取包含电影信息的文件,并在控制台中显示内容。
我们将从名为 MovieDB.xml 的文件开始(该文件采用 XML 格式,存储在应用程序的数据子目录中)并具有以下数据结构:
<Movie>
<ID>1</ID>
<Title>Jurassic Park</Title>
<OscarNominations>3</OscarNominations>
<OscarWins>3</OscarWins>
</Movie>
因此,我们可以使用 LINQ to XML 来轻松地从该文件读取数据,并遍历结果以在控制台中显示电影标题列表。作为一个好的实践,我们将定义一个表示要读取的数据的类(模型)。
因此,我们将得到以下:
public class Movie
{
public string ID { get; set; }
public string Title { get; set; }
public string OscarNominations { get; set; }
public string OscarWins { get; set; }
}
记住,您可以在 Visual Studio 的编辑菜单中选择粘贴特殊选项,以获取粘贴 XML 作为类和粘贴 JSON 作为类的选项,这将在一个新的编辑页面中构建一个新的类,并根据粘贴的数据插入定义,类名为 Rootobject。
解决问题的第一个方法可能得到以下代码(请注意,我这里使用的是最初和最简单的方法,以便同时拥有模型和处理所需的功能):
class Program
{
static string url = @"Data";
static XDocument films = XDocument.Load(url + "MoviesDB.xml");
static List<Movie> movies = new List<Movie>();
static void Main(string[] args)
{
var movieCollection =
(from f in films.Descendants("Movie")
select new Movie
{
ID = f.Element("Title").Value,
Title = f.Element("Title").Value,
OscarNominations = f.Element("OscarNominations").Value,
OscarWins = f.Element("OscarWins").Value
}).ToList();
Console.WriteLine("Movie Titles");
Console.WriteLine("------------");
foreach (var movie in movieCollection.Take(10))
Console.WriteLine(movie.Title);
Console.ReadLine();
}
}
正如你所看到的,我们最终得到一个 List<Movie> 集合,并遍历它,在控制台中显示 Title 字段的前十个结果(请参阅以下截图):

显然,这对于单次使用来说是不错的,但不适用于扩展用途。由于 Program 类有多个职责,因此应该相应地解耦。因此,我们可以考虑一个 MovieReader 类,该类负责读取数据并应用第一个 SOLID 原则(关注点分离)。
新的 MovieReader 类可能看起来像这样:
public class XMLMovieReader
{
static string url = @"Data";
static XDocument films = XDocument.Load(url + "MoviesDB.xml");
static List<Movie> movies = new List<Movie>();
public List<Movie> ReadMovies()
{
var movieCollection =
(from f in films.Descendants("Movie")
select new Movie
{
ID = f.Element("Title").Value,
Title = f.Element("Title").Value,
OscarNominations = f.Element("OscarNominations").Value,
OscarWins = f.Element("OscarWins").Value
}).ToList();
return movieCollection;
}
}
因此,我们只需将访问数据所需的声明移动到新类中,并在 ReadMovies 方法周围包装读取功能,该方法读取并返回所需的数据。
我们的 Main 入口点现在要简单得多。考虑以下代码片段:
static void Main(string[] args)
{
XMLMovieReader mr = new XMLMovieReader();
var movieCollection = mr.ReadMovies();
Console.WriteLine("Movie Titles");
Console.WriteLine("------------");
foreach (var movie in movieCollection.Take(10))
Console.WriteLine(movie.Title);
Console.ReadLine();
}
这很好,但我们的 Program 类仍然依赖于 XMLMovieReader。如果我们(或其他人)需要以其他格式读取数据,例如 JSON,会发生什么?
这就是依赖注入发挥作用的地方。如果我们的 Program 类能够依赖于一个抽象,而不是一个具体类,那就好多了。它可以是抽象类,也可以是接口。
这意味着还需要另一个类来决定根据文件格式应该提供哪种具体实现。这样,也可以在不更改已工作的代码的情况下添加读取数据的其他方法(如访问 Web 服务或数据库)。
因此,我们可以有另一个专门的读取器,称为 JSONMovieReader,具有以下实现:
public class JSONMovieReader
{
static string file = @"Data\MoviesDB.json";
static List<Movie> movies = new List<Movie>();
static string cadMovies;
public List<Movie> ReadMovies(string file)
{
var moviesText = File.ReadAllText(file);
return JsonConvert.DeserializeObject<List<Movie>>(moviesText);
}
}
因此,我们只需根据我们使用的格式实现适当的类。除此之外,鉴于两个文件包含完全相同的数据,两种情况下都会得到相同的结果(出于这个原因,我省略了输出)。
现在,我们应该创建一个接口,定义所有读取器都将具有的共同操作;ReadMovies() 方法。
interface IMovieReader
{
List<Movie> ReadMovies();
}
这个接口是两个类(以及其他可能的候选者)实现的合同,因此,我们只需更改两个声明,明确指出它们确实实现了 IMovieReader 接口。这样,最终的定义将是:
public class XMLMovieReader : IMovieReader
...
public class JSONMovieReader : IMovieReader
...
最后一步是创建一个新的类,负责决定使用哪个读取器(在这个演示中是 ReaderFactory):
public class ReaderFactory
{
public IMovieReader _IMovieReader { get; set; }
public ReaderFactory(string fileType)
{
switch (fileType)
{
case "XML":
_IMovieReader = new XMLMovieReader();
break;
case "JSON":
_IMovieReader = new JSONMovieReader();
break;
default:
break;
}
}
}
注意,ReaderFactory 构造函数决定了分配给 _IMovieReader 属性的读取器类型。这可以根据需要轻松扩展。我们的 Program 类有一个新的定义,但它是一个可扩展的,我们可以添加所需的读取方法,而无需或只需少量更改:
class Program3
{
static IMovieReader _IMovieReader;
static void Main(string[] args)
{
Console.SetWindowSize(60, 15);
Console.WriteLine("Please, select the file type to read (1)
XML, (2) JSON: ");
var ans = Console.ReadLine();
var fileType = (ans == "1") ? "XML" : "JSON";
_IMovieReader = new ReaderFactory(fileType)._IMovieReader;
var typeSelected = _IMovieReader.GetType().Name;
var movieCollection = _IMovieReader.ReadMovies();
Console.WriteLine($"Movie Titles ({typeSelected})");
Console.WriteLine("------------");
foreach (var movie in movieCollection.Take(10))
Console.WriteLine(movie.Title);
Console.ReadLine();
}
}
在这种情况下,我们提供了一个选项来选择文件格式,并且根据用户的选择,IMovieReader 返回的处理会处理该格式的特殊性(你也可以考虑其他格式,例如 Excel 电子表格、纯文本格式、逗号分隔的文件、数据库、网络服务等)。
Visual Studio 从该架构生成的类图采用以下方面(只需右键单击类的名称--在这个演示中为 Program3,并选择查看类图),以获得以下图形结构:

总结来说,IMovieReader 接口是两个类都同意的合同。只要任何其他类实现了这个接口,我们就能像上面提到的那样,以新的方式扩展潜在的数据访问机制。
实现的一个重要部分是,从用户界面,我们可以访问 ReaderFactory 类内部的一个只读属性 _IMovieReader。以这种方式,我们避免了属性一旦分配了值之后的进一步更改。最后,我们获取 _IMovieReader 结果类型的 Name 属性,以将其包含在最终输出中。
列表与之前类似,但这次用户可以选择格式的类型(XML 或 JSON):

初看之下,你可能会认为在使用 DI 方法时我们需要编写更多的代码,但这种情况只发生在我们处理简单的演示时,就像在这个例子中一样。
在实际应用中,随着数千或数万行代码,所需的代码量通常会减少,并且它极大地简化了生命周期的其他方面,如可维护性、可测试性、可扩展性、并行开发等。
依赖注入的方面
然而,在继续探讨依赖注入的各个方面之前,建议回顾一些对形成这一原则有深远影响的基本概念,并在其实施之前考虑它们。具体来说,有三个主要点需要评估--对象组合、对象生命周期和拦截。
由于这三个主题在软件开发中至关重要(而不仅仅是讨论依赖注入时),我们将在第六章对象生命周期、第七章拦截和第八章模式 - 依赖注入中回到它们,但让我们现在先简要介绍,作为对即将到来的内容的提醒。
对象组合
依赖注入和其他 SOLID 模式背后的一个重要概念是对象组合,正如维基百科(en.wikipedia.org/wiki/Object_composition)提醒的,“是一种将简单对象或数据类型组合成更复杂对象的方法。组合是许多基本数据结构的关键构建块,包括标记联合、链表和二叉树,以及面向对象编程中使用的对象。”
它给出了一个相当清晰的例子;类型通常可以分为复合类型和非复合类型,组合可以被视为类型之间的关系:一个复合类型的对象(例如,一辆车)拥有一个更简单类型的对象(例如,一个轮子)。
如你所知,这些关系自面向对象编程的起源以来一直是其核心。这也与聚合有关,不应与继承混淆。
实际上,面向对象编程(OOP)中有一个著名的原则,称为组合优于继承,它指出“类应该通过组合(通过包含实现所需功能的其他类的实例)而不是从基类或父类继承来实现多态行为和代码复用。”
因此,建议优先考虑对象组合而不是类继承:

(图片来源:atomicobject.com/resources/oo-programming/object-oriented-aggregation)
之前的图示显示了两种方法之间的区别:组合和聚合。用于组合汽车的各个元素是汽车的一部分。没有它们,对象无法完成任务。
在第二种情况下,乘客可以来去(或者现在,由于自动驾驶汽车不需要驾驶员,甚至可以不需要),但它们最终可以由汽车的实例来管理。
请记住,维基百科(en.wikipedia.org/wiki/Composition_over_inheritance)指出——“实现已识别接口的类根据需要构建并添加到业务域类中。因此,系统行为是通过不使用继承来实现的。实际上,业务域类可能全部是基类,没有任何继承。系统行为的替代实现是通过提供另一个实现所需行为接口的类来完成的。任何包含接口引用的业务域类都可以轻松支持该接口的任何实现,甚至可以选择在运行时进行选择。”
对象生命周期
在之前的演示中,我们看到了通过抽象来消除类依赖的方法,以及我们后来根据需要更改这些抽象的可能性,以及与应用程序的生命周期相关联的可能性。
但是,除了这种基本能力之外,这种做法还允许我们确定抽象的生命周期:它们何时诞生(实例化)以及何时超出作用域(并让垃圾回收器负责完成其有用生命周期的任务)。
你知道,当一个对象没有被其他对象引用时,它就会自动成为可销毁的候选对象,从而释放其关联的内存。
垃圾回收器的工作方式并不简单(尽管对用户来说是透明的),而且有很多事情需要考虑,特别是对象生成和处理以及内存回收的方式,即使在简化模型中也是如此(参见以下图片):

(图片来源:msdn.microsoft.com/en-us/library/ms973837.aspx)
关于垃圾回收的一些信息来自维基百科(en.wikipedia.org/wiki/Garbage_collection_(computer_science))——“在计算机科学中,垃圾回收(GC)是一种自动内存管理形式。垃圾回收器,或简称回收器,试图回收垃圾 或程序不再使用的对象占用的内存。垃圾回收是由约翰·麦卡锡在 1959 年发明的,目的是简化 Lisp 中的手动内存管理。”
请记住,当两个对象共享同一接口的实例时,或者当我们向不同的客户端注入两个不同的实例时出现新的场景时,可能会出现问题。
这些对象在内存中的管理方式在很大程度上也取决于我们的代码。因此,我们将在第七章,拦截中解释这些复杂性,以便你能详细了解这种行为及其可能对你的代码产生的影响。
拦截
我们可以将拦截视为装饰器设计模式的运用。对于一些作者来说,拦截是预先过滤给定调用的过程,这样我们就可以从其标准(原始)行为中包含(或排除)某些信息。
在 IMovieReader 实现的案例中,创建一个能够读取电影的合法对象的过程被重定向到ReaderFactory,而不是之前的调用具体构造函数。这是可能的,因为抽象允许我们延迟实例化,并根据参数、系统配置、配置文件等决定创建什么。
拦截的另一种典型用途与仪表化相关:超出应用程序域的应用程序的不同方面,如日志记录、审计、验证等。
最后,当我们使用所谓的子类化技术捕获系统组件的默认行为时,我们可以找到拦截。这种技术允许交织系统调用,并有效地改变系统的行为,用我们自己的行为来替代它。
实现 DI 的方法
在这种情况下,依赖注入是通过构造函数实现的,这是今天许多流行的 IoC 容器(对于.NET Framework 以及甚至其他框架,如 Angular)的首选实现方式。
然而,还有其他两种经典的 DI 实现路径:通过属性(也称为 setter 注入)或方法。
在属性注入的口味中,我们处理的是一种场景,在这种情况下,允许用户在程序运行时更改依赖项是有意义的。例如,想象一下,你从一个具体实现开始,后来客户端或某些程序的条件需要改变。
有时,这种改变并不严格地需要一个新的类实例,因此仅仅为了改变一个特定的值而创建它是不可取的。保持 DI 所倡导的独立性水平要好得多,但允许依赖项的客户端在一旦使用后更改该值。
为了实现这个目标,我们必须创建一个可写属性(而不是像之前那样只读属性)。但是,存在一个风险。我们必须避免空值。我们可以通过创建一个默认值轻松地做到这一点,使用 C#的最新技术,这将在演示中看到。因此,依赖项值的更改是确定是否需要在注入中使用属性的关键。
在方法注入中,需要依赖项的代码块通常是一些方法的参数,依赖项参数的目的是提供一个上下文,该上下文决定了方法应该如何行为。
因此,我们可以说这个场景是作用域相关的。当依赖项的作用域是局部的,比如它只在一个具体的方法中使用(它不会影响整个类)时,将依赖项的存在限制在将要使用它的方法中是一种良好的实践。
让我们看看关于这两种实现依赖注入的几个示例。
属性注入的实际应用
让我们为这个演示想象一个非常简单的场景。代码展示了当前控制台的颜色值初始配置,以及指示这些值的消息。我们为用户提供更改主题的能力,以避免难以阅读的组合。
我们将这些组合减少到亮色和暗色,除了初始的黑色/白色组合。我们可以定义一个非常简单的ConsoleDisplayFactory类,其中包含两个在其实例化时分配的默认值:
public class ConsoleDisplayFactory
{
// Both properties asume a default (initial) configuration
public ConsoleColor ForeColor { get; set; } = ConsoleColor.White;
public ConsoleColor BackColor { get; set; } = ConsoleColor.Black;
public ConsoleDisplayFactory ConfigureConsole (string theme)
{
switch (theme)
{
case "light":
BackColor = ConsoleColor.Yellow;
ForeColor = ConsoleColor.DarkBlue;
break;
case "dark":
BackColor = ConsoleColor.DarkBlue;
ForeColor = ConsoleColor.Yellow;
break;
default:
break;
}
return this;
}
}
根据这个定义,每次我们创建一个实例时,两个属性(ForeColor和BackColor)都会被分配默认的主题配置。我们的Program类将依赖于ConsoleDisplayFactory,但我们确保这两个值保持一致的颜色配置。
现在,我们的主要入口点,位于Program4中,将如下所示:
class Program4
{
static ConsoleDisplayFactory cdf = new ConsoleDisplayFactory();
static void Main(string[] args)
{
// Initial config
cdf.ConfigureConsole("default");
Console.BackgroundColor = cdf.BackColor;
Console.ForegroundColor = cdf.ForeColor;
Console.WriteLine("Console Information");
Console.WriteLine("-------------------");
Console.WriteLine("Initial configuration: \n");
Console.WriteLine($"Back Color: { cdf.BackColor}");
Console.WriteLine($"Fore Color: { cdf.ForeColor }");
// User's config
Console.WriteLine("New theme ('light', 'dark',
'Enter'=>default):");
var newTheme = Console.ReadLine();
cdf.ConfigureConsole(newTheme);
Console.BackgroundColor = cdf.BackColor;
Console.ForegroundColor = cdf.ForeColor;
Console.WriteLine("New configuration: \n");
Console.WriteLine($"Back Color: { cdf.BackColor}");
Console.WriteLine($"Fore Color: { cdf.ForeColor }");
Console.ReadLine();
}
}
观察通过ConsoleDisplayFactory实例执行的控制台配置更改,该实例是Program4类的一个属性。对于这个演示的初始版本,我们选择了一个方法(作为设置器)来处理分配的值。
另一种方法可能是以这种方式编写ConsoleDisplayFactory类的ForeColor和BackColor属性,即每个属性的设置器将负责为每个主题分配适当的更改。
虽然很简单,但这段代码展示了属性注入背后的理念。我们不需要整个类被重新实例化,因此我们允许客户端更改所需的属性,但要注意结果应按照业务规则进行注入。
我们使用注入方法或直接编程设置器,这始终取决于代码的架构和您的需求。
(我们省略了输出,因为在这种情况下它相当简单)。
方法注入的实际应用
正如我们之前提到的,关键在于注入的资源在客户端类的方法中是有意义的。这主要有两个原因,如下所述:
-
注入的参数会影响方法的行为方式,并且它可以在其生命周期内改变(在不同的方法调用中)。
-
注入参数的功能会影响方法代码块,而不影响其他任何内容。因此,当它仅在该代码块内部使用时,没有必要创建类作用域的依赖项。
当我们编写一个具有动态功能的方法时,会出现这种情况(例如 HTTP 上下文、访问在执行过程中可能发生变化的文件目录、Web Sockets 等)。
众所周知,.NET Framework 已经在各种命名空间的一些类中实现了这个功能。例如,在基类库(BCL)中,System.ComponentModel命名空间允许使用TypeConverter类,这在涉及 WPF 的上下文中特别有用,允许在纯 CLR 类型和 XAML 类型或其他业务逻辑类型之间进行转换。
一些此类方法的实现使用ITypeDescriptorContext的实例,它携带有关执行上下文的信息。
但有一个更简单、更常见的场景,这种情况一直在发生:.NET 事件系统的结构,我认为理解它是如何工作的对于意识到我们如何在日常情况下找到这种模式的实现非常有用,以及它从一开始就被如何使用。
.NET 事件架构作为模型注入
让我们思考一下事件模型:在实践中,方法 A 调用方法 B 并传递一些参数(默认为两个)。当你用经典(和现代).NET 编程一个点击、SelectedItemChanged或FormClosing事件时,就会发生一个通信过程。
该过程涉及一个负责调用(发送者)和被调用者(接收者)的方法。这可以用我们所有人都从信息论第一本书中知道的任何其他通信过程的经典方案来表示:

聚合是一个简单的集合,就像一袋弹珠,而组合则意味着内部/功能依赖,就像盒子上的铰链。汽车聚合乘客;乘客上下车而不会破坏汽车的功能,但轮胎是组件;移除一个,汽车就不再正确工作。
如果你不知道这些概念(组合和聚合),PACKT 有一些优秀的书籍可以开始阅读,比如 Gaston C. Hillar 的《面向对象编程》(www.packtpub.com/application-development/learning-object-oriented-programming)。
在四个隐含元素中,两种方案之间存在对应关系:
-
发起者(发送者):它是进行调用的方法
-
接收者:它是另一个类(或相同的)在另一个方法中做出响应
-
通道:它是环境,在.NET 中由托管环境替代
-
消息:传递给接收者的值集(.NET 中的
EventArgs)
让我们思考一个 Windows 应用程序,其中用户界面生成事件,例如,当我们使用按钮关闭窗口时。表示这种场景的代码片段可以用以下代码表示:
private void btnClose_Click(object sender, EventArgs e)
{
this.Close();
}

谁启动了这段代码的执行?嗯,当我们在Form类的设计器部分编程点击事件时,按钮对象包括以下代码:
this.btnClose.Click += new System.EventHandler(this.btnClose_Click);
这创建了一个类型为EventHandler(默认类型)的代理,它将负责在用户点击时调用目标方法。为了避免可能的问题,该事件只是一个具有一些特性的类:
-
它的签名与要调用的方法相同。以这种方式,避免了可能的类型转换问题。
-
代理在调用之前检查
btnClose_Click方法的可用性,因此它保证了没有空指针问题。
由于类型转换和空指针是臭名昭著的 BSOD(蓝屏死机)的两个主要原因,因此从.NET 的最初阶段开始,这种架构的实施是至关重要的。
然而,这里还有其他一些东西暗示了方法注入,如果您分析代码,即使在那些情况下,例如在下一个演示中,当代理不是默认时。
要真正理解这一点,让我们编写一个FormClosing事件,它将在用户点击关闭按钮或以任何其他可用方式尝试关闭窗口时触发:Alt + F4,窗口的 x 按钮,或窗口的菜单:

如您所见,这次FormClosing事件的第二个参数不是默认的,而是一个继承自EventArgs的对象实例,它包含了提供上下文的额外信息,这是我们之前提到的。
实际上,该对象包含两个属性:Cancel(可赋值并强制在退出过程中停止),以及CloseReason,一个只读属性,表示哪个机制真正触发了关闭过程。
因此,我们不是在编程或实例化这个参数:它是通过注入给我们,每次我们定义一个事件过程时。这个内部注入系统负责提供与执行上下文相关的信息。这是一个方法注入的明显示例。
如果您查看FormClosingEventArgs参数的定义,您将看到它确实是一个继承自CancelEventArgs(它反过来继承自EventArgs)的另一个类:
public class FormClosingEventArgs : CancelEventArgs
{
//
// Summary:
// Initializes a new instance of the
System.Windows.Forms.FormClosingEventArgs class.
//
// Parameters:
// closeReason:
// A System.Windows.Forms.CloseReason value that represents
the reason why the form
// is being closed.
//
// cancel:
// true to cancel the event; otherwise, false.
public FormClosingEventArgs(CloseReason closeReason, bool cancel);
//
// Summary:
// Gets a value that indicates why the form is being closed.
//
// Returns:
// One of the System.Windows.Forms.CloseReason enumerated values.
public CloseReason CloseReason { get; }
}
有趣的是要注意,ClosingEventArgs也属于我们之前提到的System.ComponentModel命名空间。
因此,即使我们在做像关闭窗口这样简单的事情时,我们也在隐式地使用方法注入,这是.NET 框架的核心。
这种架构可以通过用户以多种方式扩展,甚至可以使用事件链等技术,当我们需要连接依赖于用户选择的过程时,这些过程通常通过事件生成,例如。
对于级联选择,一个典型的例子是当用户从一个组合框(例如选择一个国家)中进行选择时,会生成代码来填充另一个组合框,比如选择一个城市。你首先必须选择一个国家,这样城市组合框才能填充属于该国家的城市。
其中一个例子可能是当一个窗口的关闭过程(类似于前面显示的代码)需要额外的用户干预时。例如,想象一下,你必须询问用户是否想要保存审计(或执行任何其他操作),但仅当先前的询问是肯定的,比如确认用户想要退出应用程序(这反过来可能取决于其他条件,如 FormClosing 事件中表达的前一个代码中的 CloseReason)。
一种可能的方法是创建一个我们自己的通用事件,如果满足请求的条件,则可以触发。比如说,只有当 ClosingReason 是 CloseReason.UserClosing 时,我们应该询问用户是否确认应用程序退出,如果答案是肯定的,再询问他是否想要保存信息。
我们可以编写以下代码:
private void frmMethodInjection_FormClosing(object sender,
FormClosingEventArgs e)
{
if(e.CloseReason == CloseReason.UserClosing)
{
var res = MessageBox.Show("Confirm application exit?", "Notice",
MessageBoxButtons.YesNo, MessageBoxIcon.Question);
if (res == DialogResult.Yes)
FormClosingExtended?.Invoke(this, DateTime.Now.ToLongTimeString());
else e.Cancel = true;
}
}
public event EventHandler<string> FormClosingExtended;
因此,我们仅在 if 块评估为真时调用 FormClosingExtended 事件。但是,使用新的通用事件处理器,我们可以将登录信息传递给另一个事件,该事件从那里跳转到不同的事件过程:
private void FrmMethodInjection_FormClosingExtended(object
sender, string e)
{
var ans = MessageBox.Show($"Save Closing time: ({e})?","Notice",
MessageBoxButtons.YesNo,MessageBoxIcon.Question);
if (ans == DialogResult.Yes)
{
File.WriteAllText("ClosingTime.txt", e);
}
}
此事件过程接收 e 参数中的数据,并询问用户是否希望将其保存为审计信息。这样,我们就不需要连接两个 MessageBox 调用,代码也更加清晰。
此外,由于 e 参数可以是任何类型的通用事件处理器,因此作为事件参数传递的信息类型也可以是任何类型,任何复杂程度。例如,它可以是具有适合验证目的、安全检查等自己方法的对象。
代码中唯一缺少的是定义过程中涉及到的每个事件的处理器,这可以在 MethodInjection 构造函数内部完成:
public frmMethodInjection()
{
InitializeComponent();
this.FormClosing += frmMethodInjection_FormClosing;
FormClosingExtended += FrmMethodInjection_FormClosingExtended;
}
因此,在 DI 方面,我们在 .NET Framework 中以两种不同的方式使用内部注入引擎:
-
在第一种情况下,为了获取框架传递给我们的与引发该事件的原因相关的信息(
ClosingReason)。 -
在第二种情况下,因为我们已经实现了一个自己的事件,该事件在连接到前一个事件后执行,并期望用户批准所需的信息。
注意,如果没有为第二个事件处理器需要业务逻辑参数,我们也可以将其注册为 FormClosing 事件,因为事件在 .NET 中是可多播的。
DI 容器
在日常实践中,使 DI 工作的部分管道不是通过手动编码完成的,而是使用称为 DI 容器(也称为 IoC 容器)的东西。之前使用 .NET 框架事件系统的演示暗示了存在某些 DI 容器。
基本上,DI 容器是一个软件库,它简化了依赖注入及其基于对象组合、生命周期管理等方法的用法。这对程序员来说非常方便,因为他们不必担心创建、销毁等细节。
许多人对 DI 和 IoC 有一些混淆。您可以参考这个网站来消除任何混淆:www.itgo.me/a/8238364259424879474/inversion-of-control-vs-dependency-injection.
在许多情况下,DI 容器强制执行一些良好的实践,例如通过提供所需组件的单例实例来避免实例的重复,就像 AngularJS 从初始版本开始所做的那样。
在 .NET 中,正如我们提到的,IoC 在几个场景中都存在,以下图像显示了使用此技术的某些主要组件:

(图片来源:hotproton.com/category/dependency-injection/)
正如你在图中看到的,.NET(甚至在之前的版本中)包含几个提供控制反转(IoC)的组件,这些组件要么与事件和委托相关联,正如我们在之前的演示中所见,要么作为服务定位器,或者作为 DI 注入机制。
服务定位器
基本上,服务定位器只是一个设计模式,它指导实现者如何构建能够通过强大的抽象层获取服务的 DI 容器。
所有服务定位器都需要满足一个条件:服务必须注册,以便可以在代码请求时找到并提供服务。
维基百科 (www.itgo.me/a/8238364259424879474/inversion-of-control-vs-dependency-injection) 总结了服务定位器的三个主要优点:
-
服务定位器可以充当简单的运行时链接器。这允许在运行时添加代码,而无需重新编译应用程序,在某些情况下甚至无需重新启动它。
-
应用程序可以通过从服务定位器中选择性地添加和删除项目来在运行时优化自己。例如,一个应用程序可以检测到它有一个比默认更好的库来读取 JPG 图像,并相应地更改注册表。
-
库或应用程序的大块区域可以完全分离。它们之间的唯一联系就是注册表。
通常,我们可以这样说,依赖倒置原则的应用导致了 IoC 容器的构建,它们在具体的 DI 技术和服务定位器中得到了体现。(见以下图示):

当然,这些优势并非没有对应的缺点。可能出现的问题包括注册表像黑盒一样作用于应用程序的其他部分、唯一性、安全漏洞、隐藏类依赖关系、增加一些测试难度等等。
.NET 的依赖注入容器
除了与.NET 内部 DI 相关的功能外,使用外部容器提供额外或扩展功能给程序员是非常常见的,而且作为非常流行的编程框架的.NET,近年来已经出现了许多这样的容器。
因此,我们的标准将是展示那些在社区中似乎接受度更高的基本实现,如 Unity、Castle Windsor、StructureMap 和 Autofac。
实际上,在过去几年中,可用的选择数量一直在增加,其中一些相当受欢迎,如 Ninject、Simple Injector、Dynamo、LinFu、Spring.NET、Hiro 等等,因此在这种情况下,我们的选择主要是由社区中的实现水平、易用性、API 的一致性以及最新版本的性能测试驱动的。由于我不想对任何这些测试发表意见,你可以查看网络上可用的不同基准测试,你可能会得出与我相同的或类似的结论。
这些依赖注入容器(以及其他容器)有一些共同点:它们都需要之前的配置,并且能够在运行时解析所需的依赖项。以下图示展示了实现这一想法的方法:

在实践中,这意味着我们将实例化和配置一个容器对象,然后稍后,我们会要求容器在我们的代码中的一个或多个位置解析所需的依赖项。
此外,在大多数情况下,组件都是从我们最初实例化的同一个实例中解析出来的。
我们演示的常见(且非常简单)的上下文:
由于在实际编码真实应用程序时会出现复杂性,我选择了一个非常简单、非常简单的起点,它可以作为一个常见的场景来解决它所提出的 DI 问题。它基于我们之前的MovieReader想法,但让我们说在这种情况下,我们甚至没有从磁盘读取任何内容(只显示控制台消息),以关注代码的架构而不是其实施的细节。
示例提出了两个负责从磁盘读取一些书籍信息的类的存在,它们都共享一个公共接口 IBookReader,该接口实现了一个独特的方法 ReadBooks()。这三个元素构成了数据访问层:
// Data Access Layer
public interface IBookReader
{
void ReadBooks();
}
public class XMLBookReader : IBookReader
{
public void ReadBooks()
{
Console.WriteLine("Books read in XML Format");
}
}
public class JSONBookReader : IBookReader
{
public void ReadBooks()
{
Console.WriteLine("Books read in JSON Format");
}
}
很简单,对吧?现在,我们构建另一个简洁的业务层,由一个名为 BookManager 的类组成,其唯一目的是执行业务逻辑,因此它公开了一个构造函数,接收两个可能读取器之一的实例,并实现了对 ReadBooks 方法的调用,该调用将转而引用对应于每个情况的读取方法:
public class BookManager
{
public IBookReader bookReader;
public BookManager(IBookReader reader)
{
bookReader = reader;
}
public void ReadBooks()
{
bookReader.ReadBooks();
}
}
最后,在用户界面中,在这个例子中是控制台,我们要求用户决定读取机制并调用相应的 BookManager 版本,以便我们可以调用 ReadBooks 方法:
static void Main(string[] args)
{
UnityContainer uc = new UnityContainer();
BookManager bm;
Console.WriteLine("Please, select reading type (XML, JSON)");
var ans = Console.ReadLine();
if (ans.ToLower() == "xml")
{
bm = new BookManager(new XMLBookReader());
}
else { bm = new BookManager(new JSONBookReader()); }
bm.ReadBooks();
Console.ReadLine();
}
到目前为止,代码非常简单,已经使用了一些依赖反转,但仍然依赖于用户界面中读取器类型的创建。这正是当我们使用一个外部创建机制来为我们处理这项工作时我们所获得的结果。
现在我们来看看如何使用之前提到的不同 DI 容器来改变这一点。
使用 Unity 容器
Unity 容器已经存在了好几年。某种程度上,它曾是官方的 Microsoft 外部容器,并且多年来一直与 Patterns & Practices 创新计划相关联。
请注意,Unity 不是一个官方的 Microsoft 产品,也不再属于 Patterns & Practices 团队。该项目已被转交给其他人(Pablo Cibraro 和 Pedro Wood),正如 Immo Landwerth 在 2015 年的 .NET 博客中发布的消息([blogs.msdn.microsoft.com/dotnet/2015/08/21/the-future-of-unity/](https://blogs.msdn.microsoft.com/dotnet/2015/08/21/the-future-of-unity/))中所述,评论道:“依赖注入容器在 .NET 中已经持续成熟和显著发展。此外,开源组件现在被更广泛地接受。拥有来自 Microsoft 的“官方”容器不再是像以前那样普遍的需求了。”
话虽如此,Unity 仍然是成千上万个项目中非常常见的功能,并且已经达到了 4.01 版本,你可以在任何项目中使用 NuGet 包管理器 安装它,该管理器可通过解决方案资源管理器的上下文菜单或项目菜单访问,无论是在 V. Studio 2017 还是其他版本。
安装完成后,你会发现它实际上引用了两个不同的库:Unity 4.01 和 CommonServiceLocator 1.30 库,如下截图所示:

安装完成后,你会在解决方案资源管理器中看到四个新的 DLL 引用:其中三个属于 Unity,第四个属于 CommonServiceLocator:

在Microsoft.Practices.Unity.Configuration库内部,您有工具允许您在 XML 文件中编写所需的配置,这样它就充当 DI 容器的初始设置。该命名空间中的类将允许您根据该 XML 读取和配置给定的执行上下文。
在另一方面,Microsoft.Practices.Unity.RegistrationByConvention库旨在提供一种可编程的配置方式,通过使用一系列规则和约定,自动将多个类型与容器注册,正如官方文档所定义的(msdn.microsoft.com/en-us/library/dn507479(v=pandp.30).aspx)。
现在,如果我们只想注册属于我们的业务层和数据访问层的类,那么将所有元素包含在我们的业务模型中并使我们的数据准备就绪的最明显的方法可能如下所示:
static void Main(string[] args)
{
UnityContainer uc = new UnityContainer();
uc.RegisterType<BookManager>();
uc.RegisterType<IBookReader, XMLBookReader>();
uc.RegisterType<IBookReader, JSONBookReader>();
BookManager bm = uc.Resolve<BookManager>();
bm.ReadBooks();
Console.ReadLine();
}
注意,然而,我们正在定义BookManager与XMLBookReader和JSONBookReader一起。这意味着如果我们运行代码,我们将得到最后一个注册的类的实例(JSONBookReader),它成为默认选项。原因是我们没有命名这些注册,因此它们被分配了未命名的标识符。
您可以在Chapter02_02.Unity命名空间内的演示中进行测试,并设置断点以证明它。
为了重现用户选择格式的初始情况,我们需要为注册的类型注册不同的别名,以便它们可以在运行时解析,传递我们需要的具体版本。
此外,请注意,Unity 扮演着之前由BookManager类扮演的角色。因此,在这种情况下,我们不再需要BookManager类:
static void Main(string[] args)
{
Console.WriteLine("Please, select reading type (XML, JSON)");
// we asume a predefault value
var format = (Console.ReadLine() != "xml") ? "json" : "xml";
UnityContainer uc = new UnityContainer();
uc.RegisterType<IBookReader, XMLBookReader>("xml");
uc.RegisterType<IBookReader, JSONBookReader>("json");
IBookReader ibr = uc.Resolve<IBookReader>(format);
ibr.ReadBooks();
Console.ReadLine();
}
现在,Unity 通过我们传递给Resolve()方法的参数解决依赖关系,正如我们可以通过在此行设置断点或简单地观察输出所看到的那样。
UnityContainer类接受替代注册机制。例如,我们可以使用一个完全致力于 Unity 注册的新业务层类,以下代码(注意我们应在代码的using部分中引用Microsoft.Practices.Unity):
public class UnityRegistration
{
public void Register()
{
using (var container = new UnityContainer())
{
container.RegisterTypes(
AllClasses.FromLoadedAssemblies(),
WithMappings.FromAllInterfaces,
WithName.Default,
WithLifetime.ContainerControlled);
}
}
}
以这种方式,所有从加载的组件中加载的类都将注册到 Unity 中,所有现有接口及其实现类之间的映射(或对应关系)都得到了定义,使用它们的默认名称,并将它们的生存期分配给容器管理,因此容器本身在运行时决定何时将对象实例留给垃圾回收器。
使用 Castle Windsor
在基准测试和可用性测试中获胜的赢家之一,Castle Windsor 已经存在一段时间了,现在它将所有活动都聚集在其专门的 GitHub 项目网站上github.com/castleproject/Windsor.
这个项目周围的社区非常活跃,在撰写这些行的时候,已经有超过 500 个星标和 265 次分支,他们正在准备发布 3.4 版本。当然,你可以单独下载并安装它,或者使用NuGet以通常的方式为你的项目安装它:

安装过程实际上安装了两个组件:Castle.Core 3.3 和 Castle.Windsor 3.4。它们共同工作,尽管它们包含了几个命名空间,以覆盖我们可能需要依赖注入(以及其他功能)的许多可能的编程场景。
Castle Windsor 的 API 集在可能性上非常丰富,官方网站上的文档让你可以通过一些示例快速开始(见github.com/castleproject/Windsor/blob/master/docs/basic-tutorial.md)。
对于我们的演示,我们只需要引用初始化WindsorContainer类所需的命名空间,然后进行注册:
using Castle.Windsor;
using Castle.MicroKernel.Registration;
第一个允许创建一个新的WindsorContainer类,而另一个定义了注册所需的类。整个过程与我们之前看到的 Unity 类似:
static void Main(string[] args)
{
Console.WriteLine("Please, select reading type (XML, JSON)");
// we asume a predefault value
var format = (Console.ReadLine() != "xml") ? "json" : "xml";
var container = new WindsorContainer();
container.Register(Component.For<IBookReader>().
ImplementedBy<XMLBookReader>().Named("xml"));
container.Register(Component.For<IBookReader>().
ImplementedBy<JSONBookReader>().Named("json"));
IBookReader ibr = container.Resolve<IBookReader>(format);
ibr.ReadBooks();
Console.ReadLine();
// clean up, application exits
container.Dispose();
}
注意到组件类包含了静态、泛型方法,允许定义任何接口(例如这里的IBookReader),并且你可以通过连续调用来表示哪个类实现了什么接口以及我们想要为每个注册分配的名称,这样可以在运行时解决。
注册完成后,在具体实现中解析它的方式接受一个与我们之前使用 Unity 时相同的格式。
代码与上一个演示完全相同。
使用 StructureMap
这个 DI 容器的官方网站精确地定义了这种实现背后的差异和精神:
StructureMap是.NET 中最早的、持续使用的 IoC/DI 容器,可以追溯到 2004 年 6 月的第一次公开发布和生产使用,当时是.NET 1.1。当前的 4.0 版本代表了 StructureMap 和更大的.NET 社区 12+年的经验教训--同时也摒弃了许多不再有意义的旧设计决策。
因此,我们在这里处理的是一个老手,这意味着稳定性,以及广泛的互联网论坛和开发者网站上的存在,如StackOverflow。
在使用和配置背后的哲学与我们已经看到的另外两种非常相似,但它提供了多种配置应用程序的方法。正如官方文档所述:“从 3.0 版本开始,StructureMap 提供了一个简化的流畅接口,称为 Registry DSL,用于配置 StructureMap 容器,包括显式注册和传统自动注册。StructureMap 不再支持 XML 配置或 MEF 风格的属性配置--但有一些工具可以创建自己的基于属性的配置支持。”
主要区别在于它推荐通过 lambda 表达式进行配置的方法,但仍然具有类似的机制,正如你在以下代码中看到的,该代码用于在单个操作中创建和配置 Container 对象:
var container1 = new Container(c =>
{
c.For<IFoo>().Use<Foo>();
c.For<IBar>().Use<Bar>();
});
另一个主要的选择是创建一个Registry对象,然后根据它来配置容器。类似于以下内容:
public class FooBarRegistry : Registry
{
public FooBarRegistry()
{
For<IFoo>().Use<Foo>();
For<IBar>().Use<Bar>();
}
}
var container1 = new Container(new FooBarRegistry());
所有这些都取决于要构建的应用程序的架构和复杂性。为了我们的演示目的,我们首先通过 NuGet 引用库(这次只有一个命名空间),以展示这种安装选项:

对于基本配置和使用,我们只需要引用基本的StructureMap命名空间:
using StructureMap;
之前演示的源代码等价物将是(在执行中具有相同的结果):
static void Main(string[] args)
{
Console.WriteLine("Please, select reading type (XML, JSON)");
// we asume a predefault value
var format = (Console.ReadLine() != "xml") ? "json" : "xml";
var container = new Container();
// container configuration
container = new Container(x => {
x.For<IBookReader>().Add<XMLBookReader>().Named("xml");
x.For<IBookReader>().Add<JSONBookReader>().Named("json");
});
var ibr = container.GetInstance<IBookReader>(format);
ibr.ReadBooks();
Console.ReadLine();
// clean up, application exits
container.Dispose();
}
注意容器是如何通过传递一个 lambda 表达式到新创建的 Container 实例来配置的,在表达式体内部,我们使用了以下模式:
container à For <Interface> à Add(Class) à Named("alias")
前面的模式允许我们在单个操作中表达我们想要的任意多的注册。
获取IBookReader实例的方式略有不同,因为它不使用解析范式。反过来,我们可以找到几种解析实例的方法,如下面的截图所示:

当然,执行与其他情况相同,输出中并没有真正相关的内容,你可以在Chapter02_02.StructureMap演示的源代码中找到。
使用 Autofac
我们将结束这次对.NET 容器的短暂访问,考察 AutoFac 的基本知识,这是社区中另一个广为人知的 DI 容器,它声称与涵盖.NET Core、ASP.NET Core、通用 Windows 应用和.NET Framework 4.5.1 及更高版本的版本保持最新。它还支持基于 WCF 的应用程序。
它有自己的专用网站(autofac.org/),作为起点,尽管它也可以通过NuGet包引用。除此之外,你还可以在这个页面或 NuGet.org 的www.nuget.org/packages/Autofac/找到对几个库的引用,其中一些是专业的。
如果你决定继续在 Visual Studio 中使用NuGet,你应该在 NuGet 包编辑器中搜索 Autofac 时找到以下参考:

标准架构与我们已经看到的另外三个类似,有一些细微的差别。例如,这里的容器被命名为ContainerBuilder。
在实例化之后,我们必须配置所需的类型和接口,最后,我们应该调用ContainerBuilder的Build()方法,以确保一切准备就绪。
尽管我们可能使用与其他演示类似的方法,但在这个案例中,我们决定只注入用户选择的版本。这可以通过以下代码轻松实现:
static void Main(string[] args)
{
Console.WriteLine("Please, select reading type
(XML, JSON)");
// we asume a predefault value
var builder = new ContainerBuilder();
if (Console.ReadLine() != "json")
{
builder.RegisterType<XMLBookReader>().As<IBookReader>();
}
else
{
builder.RegisterType<JSONBookReader>().As<IBookReader>();
}
var container = builder.Build();
var ibr = container.Resolve<IBookReader>();
ibr.ReadBooks();
Console.ReadLine();
}
简而言之,我们在 IoC 容器方面有很多选择,并且在配置它们的方式上也有很多选择,但它们都为我们提供了类似的功能:我们可以抽象出在以后时间解决的依赖项。
当然,在这种情况下,我们也可以选择其他形式的配置,比如使用 XML 或 JSON 文件,还可以使用更复杂的配置类来支持我们应用程序所需的任何可能的情况。
虽然这只是一个关于 IoC 容器的介绍,但如果你对这些 API 感兴趣,你会发现可以处理最初讨论的三个方面:对象组合、对象生命周期和拦截。
其他框架中的依赖注入
.NET 或 Java 并不是唯一可以找到依赖注入容器的编程环境。许多最流行的 JavaScript 框架也从一开始就支持 DI。AngularJS 就是这种情况。
Angular 中的 DI
AngularJS (1.x) 和 Angular (2.x, 4.x 等) 可能是当今使用最广泛的 JavaScript 应用程序框架。它们在编程模型和通用目的上相当不同,所以我会区分它们两个:

Angular 框架是 Google 团队 Misko Hevery、Igor Minar 和 Brad Green 领导的项目成果,最初于 2010 年出现。该项目已分为两个不同的分支:AngularJS 1.x 用于小型/中型项目,Angular 2(或简称 Angular)旨在满足大型/复杂项目的需求,并由于其强类型特性而使用 TypeScript 作为编程语言。
2016 年 12 月,他们宣布采用语义版本控制方法,并制定了一个持续交付路线图,每六个月发布一个新版本,非常注意避免破坏性变更。最新的版本是 2017 年 3 月出现的 Angular 4,它与 Angular 2 完全向后兼容。
这两个项目也由 Google 维护,尽管它们的编程模型和语言不同,但它们有一些共同之处:它们都推广 单页应用程序(SPA)模型,并且使用 模型-视图-控制器(MVC)架构来提供从第一刻起的责任分离。
AngularJS 以一系列库的形式呈现,用户可以选择特定目的所需的库,从而实现更好的粒度。所有库都可通过 GitHub、NuGet、NPM、Bower 等方式获取。
我们的第一次演示
让我们开始我们的第一次演示,并从这个初始方法中,我们将测试 AngularJS 提供的出色、集成的依赖注入系统,这在很大程度上简化了程序员的开发工作。
顺便说一句,我们可以使用任何 IDE 来与 Angular 一起工作,因为涉及到的三种语言(如果不考虑 CSS 提供的视觉方面,则是两种语言)只是 HTML 和 JavaScript。
然而,我将继续使用 Visual Studio,它对 Angular 编程有非常好的支持,并提供原生 Intellisense 功能,以及对 Angular 指令的相当不错的调试体验。
因此,让我们开始一个新的项目或一个新的网站(我们不需要任何编译的库)。我们可以在 ASP.NET 部分选择一个空项目。这会创建一个只包含 web.config 文件的项目,以防我们需要配置 互联网信息服务(IIS)。
在项目中,我们将创建一个新的 HTML 文件,一旦项目保存,我们就可以以通常的方式添加 Angular 库,通过 NuGet 包管理器。我们应该看到 AngularJS 现在是 1.6.x 或更高版本(我们应该选择 Angular.Core 用于此演示,这是基本模块)。
一旦我们接受安装,我们将看到一个包含 Angular 1.6 的开发(angular.js)和部署(angular.min.js)版本的 Scripts 文件夹,以及用于测试目的的模拟库。
我们只需要包含开发库并创建所需的最小管道,就可以看到 Angular 的实际应用——只需包含库,一些对象和服务就会被加载到内存中并准备就绪。
尤其是存在一个 $injector 对象,它会负责检索对象实例、实例化类型、加载模块和调用方法。
此外,Angular 创建了一个初始的基本模型,作为应用程序的根模型($rootScope),并期望用户将一个 HTML 元素标记为应用程序的作用域。我们将在 <body> 标签中这样做,命名为 app,并使用以下语法定义一个具有相同名称的模块:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Dependency Injection Demo</title>
<script src="img/angular.js"></script>
</head>
<body ng-app="app">
<h1>Dependency Injection Demo</h1>
<h3>Current time: {{ time }}</h3>
<script>
var app = angular.module("app", []);
</script>
</body>
</html>
注意我们使用 Angular 特定的属性(ng-app)来标记应用程序的作用域。这些属性在 Angular 中被称为指令,它们允许我们修改 DOM 以满足我们的需求(所有预定义的指令都以 ng- 开头)。
现在,我们希望我们的页面在浏览器加载时显示本地时间。在 Angular 中实现这一点的模式是选择 DOM 的一个目标区域,并将控制器分配给该区域。我们可以通过在相应的标签中包含 ng-controller 指令来实现这一点。
如您在代码中所见,在页面标题旁边,我们包含了一个 <h3> 标签来包含该信息,在该标签的文本中,一个消息,后面跟着 {{ time }}。
双大括号语法是 Angular 使用的一种可能的绑定机制,用于将模型内部的数据与活动视图链接起来,它被称为胡子语法。因此,我们以这种方式修改标签:
<h3 ng-controller="TimeController">Current time:
{{ time }}</h3>
现在,我们有一个名为 TimeController 的 ng-controller 指令,我们需要在脚本中定义它。控制器是通过调用我们之前创建的应用程序主模块来定义的,并将一个函数作为第二个参数传递,该函数将负责与该控制器相关的逻辑。
现在,我们终于来到了 AngularJS 的依赖注入机制。这个函数应该能够访问 Angular 创建的特定模型,以存储控制器管理的信息。我们通过 $scope 对象来实现这一点。
每个控制器都有自己的 $scope(它就像一个子模型),这允许在内存中进行读写操作,并允许在 DOM 中的胡子表达式和该模型中存储的数据之间进行绑定。
那么,用户代码是如何访问那个(或任何其他)服务的呢?当然是通过依赖注入。最终的代码非常简单:
<body ng-app="app">
<h1>Demo Dependency Injection</h1>
<h3 ng-controller="TimeController">Current time:
{{ time }}</h3>
<script>
var app = angular.module("app", []);
app.controller("TimeController", function ($scope) {
$scope.time = new Date().toLocaleTimeString();
});
</script>
</body>
如您所见,控制器的定义接收一个字符串来标识控制器的名称,以及一个匿名函数,用于包含与该控制器相关的功能。
在那个函数中,我们只声明了 $scope 对象:Angular 的注入系统负责其余部分。在下一行代码中,我们可以使用那个对象,并确保其存在。更进一步,Angular 通过单例模式提供服务和此类对象,因此同一时间不会有其他的 $scope。
当然,输出是预期的,并且每次我们重新加载页面时,当前时间都会更新:

这种理念扩展到任何 AngularJS 对象或服务,甚至扩展到用户可能想要创建的服务。
在 Angular(现代版本)中,架构类似,只是它使用 TypeScript,并且首选的依赖注入方式是我们在其他演示中看到的构造函数注入模型,因为 TypeScript 是一种完全面向对象的编程语言。
在第十章《其他 JavaScript 框架中的依赖注入》中,我们将介绍 JavaScript(ES6,或 ES 2015,更确切地说)和 TypeScript 以及 Angular 2 中的依赖注入,这样您可以对现代 Web 框架中依赖注入的当前实现有更广泛的了解。
摘要
在本章中,我们专注于依赖注入的原则和优势,以及如何从一个非常简单的应用程序开始,通过修改它来实现类之间的解耦。
一旦我们了解了 DI 的基础,我们就简要地涵盖了三个影响这些技术应用的编程方面:对象组合、对象生命周期和拦截,这是我们将在后续章节中更详细地探讨的第一个方法。
最后,我们看到了一些流行的 IoC 容器如何实现这些概念,以及对其在其他框架(如 AngularJS)中应用的简要介绍。
在第三章,《.NET Core 2.0 中的依赖注入介绍》中,我们将开始研究.NET Core 中包含的关于依赖注入的最有趣的功能。
第三章:.NET Core 2.0 中的依赖注入介绍
本章是.NET Core 依赖注入技术在.NET Core 最新版本(2.0)中的首次尝试实现。它的主要功能、功能以及包含支持这些功能的类集的命名空间。
在本章中,我们将讨论以下内容:
-
总的来说,我们将讨论.NET Core 如何包括对 SOLID 原则的支持,特别是与依赖注入相关的原则
-
我们将从.NET Core 的主要特性及其在 Visual Studio 中的安装和使用开始,特别是关注最新版本 Visual Studio 2017,以及这个版本允许的不同类型的部署
-
然后,我们将深入了解.NET Core 中的依赖注入,
ActivatorUtilities类,以及Microsoft.Extensions.DependencyInjection容器 -
之后,我们将看到一些与对象生命周期实现相关的演示,以及如何将接口映射到实例类,以及当应用于服务时对作用域的一些更多方面的简要回顾
-
最后,我们将看到这种依赖注入功能是如何在.NET Core 服务中实现的,特别是与日志记录相关的服务,以及大量演示如何在使用纯.NET Core 时使用它。
.NET Core 的主要特性
我们已经在第一章《软件设计的 SOLID 原则》中解释了.NET Core 架构提案的基础,以及它如何试图成为一个变革者,因为它提供了在相同语言(C#或 VB.NET)中创建代码的可能性,能够在任何设备或平台上执行。
请注意,VB.NET 对新特性的支持总是落后于 C#语言中的新进展,因此,如果你尝试使用此语言使用某些新特性,请确保它已经实现了我们在这本书中使用的版本。
这种能力也扩展到了移动应用程序,这得益于将 Xamarin 环境(和 IDE)纳入到与.NET Core 开发相关的工具集。
.NET Core 的主要优势
如果我们从更开发者的角度来审视这个框架,我们可以说,使.NET Core 与其他选择不同的因素可以总结如下:
-
跨平台: 这意味着在 Windows、macOS 和 Linux 上执行,以及将其移植到其他操作系统。您可以在各种网站上查看支持的操作系统列表,例如
github.com/dotnet/core/blob/master/roadmap.md,并且您应该记住,无论是微软还是其他公司提供的,CPU 和应用场景都将持续增长。 -
兼容性:.NET Core 不仅与.NET Framework 兼容,还与 Xamarin 和 Mono 兼容,这得益于.NET Standard 库。正如官方文档所述,.NET Standard 库是,
“.NET API 的正式规范,旨在在所有.NET 运行时中可用。标准库背后的动机是在.NET 生态系统中建立更大的统一性。ECMA 335 继续为.NET 运行时行为建立统一性,但对于.NET 库实现没有类似的规范。”
-
部署:关于运行时最有趣的特点可能是它可以在应用程序内部部署或以并排方式安装,适用于用户或机器范围
-
独特的命令行选项:所有独特的场景都可以在命令行工具中使用(并且这可以扩展到其他平台)
-
开源:.NET Core 平台自诞生以来就是开源的。它使用 MIT 和 Apache 2 许可证,文档在 Creative Commons 4.0(CC-BY)许可下发布(见
creativecommons.org/licenses/by/4.0/)。除此之外,.NET Core 是.NET Foundation(www.dotnetfoundation.org/)的项目 -
Microsoft 的支持:.NET Core 完全由 Microsoft 支持,您将在公司的推广网站上找到大量文档、视频、论坛等内容,这些内容通常在.NET Core 支持(
www.microsoft.com/net/core/support/)中提及
在 IDE 中安装.NET Core
在第一章《软件设计的 SOLID 原则》中,我们提到您可以使用.NET Core(和 ASP.NET Core)与您选择的任何 IDE 一起使用。然而,在这本书中,我使用 Visual Studio 2017,因为它集成了工具和设施,并且对.NET Core 项目的优化程度很高。
尽管如此,由于其年轻,Visual Studio 的所有版本都没有一个共同的安装路径,并且根据您使用的版本,您会发现两种不同的方法。
Visual Studio 2015 中.NET Core 的安装路径
如果您想使用 Visual Studio 2015,您应该安装更新 3.3。它可以从以下链接获取:www.visualstudio.com/en-us/news/releasenotes/vs2015-update3-vs。在这个网站上,您将看到更新如何与.NET Core 1.0.0 和.NET Core 1.0.0 SDK Preview 2 相关。
如果您对此版本不确定,请转到帮助菜单中的“关于 Microsoft Visual Studio”,并确保版本号是 14.0.25424.00 或更高版本,并且包含更新 3。
您还需要:
-
Visual Studio 的 NuGet 管理器 扩展(你知道,NuGet 是微软开发的官方包管理器,我们可以确信它包含所有版本的 .NET Core)。你需要 NuGet 3.5.0-beta 或更高版本来构建 .NET Core 应用程序。
-
.NET Core 工具预览版 2+,负责 Visual Studio 2015 的项目模板和其他工具,你可以在
go.microsoft.com/fwlink/?LinkID=827546找到它。
Visual Studio 2017 中的 .NET Core
对于 Visual Studio 2017 来说,情况相当不同,因为它负责安装先决条件,只要你在初始安装过程中选择了 .NET Core 和 Docker 工作负载(记住,在 Visual Studio 2017 中,安装是模块化的,所以默认情况下,它只安装最小功能集)。
就像在之前的例子中发生的那样,如果你想确认已安装的版本,只需在“帮助/关于 Visual Studio”菜单中进行检查,看看你是否拥有版本 15.0.26020.0 或更高版本。
部署类型
我们已经提到了 .NET Core 允许为你的应用程序使用两种不同的部署方式--框架依赖部署(FDD)和自包含部署(SCD)。选择哪一个取决于目标系统和我们对它的知识和管理控制程度。
让我们简要回顾一下这两种部署类型之间的主要区别及其对任何 .NET Core 编译过程产生的程序集管理和结构的影响:
-
框架依赖部署依赖于目标系统上安装的共享 .NET Core 版本。如果这种情况成立,则应用程序可以在 .NET Core 的不同安装之间移植。
-
注意,在这种情况下,该应用程序仅包含其自身的代码以及任何位于 .NET Core 库之外的第三方依赖项。这是因为 FDD 包含可以通过命令行中的
dotnet工具启动的.dll文件。记住,如果你启动,例如,命令dotnet application1.dll,这就足够运行名为application1.dll的应用程序了。 -
在另一方面,SCD 应用程序不依赖于目标系统上安装的任何外部代码。也就是说,所有组件(包括 .NET Core 库和 .NET Core 运行时)都包含在最终的、可安装的包中,并且与其他 .NET Core 应用程序隔离。
-
因此,SCD 包含一个可执行文件(例如,在 Windows 平台上名为
app1.exe的应用程序)。这是特定平台 .NET Core 主机的重命名版本,以及一个 DLL 文件(例如app.dll),这是实际的应用程序。 -
因此,你正在将 .NET Core 的具体版本作为可执行文件与你的应用程序一起部署,它将始终以 DLL 的形式存在,并在由可执行文件创建的上下文中运行。
如您所见,这是一个非常不同的方法,也许是我们第一次能够使用.NET 生成完全独立的可执行应用程序。
在.NET Core 中检查其他依赖项
在我们继续了解.NET Core 功能之前,明智的做法是记住,依赖项不仅涉及类之间的关系,还涉及构建应用程序所使用的组件,IDE 在定义和可视化分析这些依赖项时可能会帮助我们,即使应用程序已经编译完成。
在这样一个框架中,这些组件总是从 NuGet(或任何其他有效存储库)下载,并在 Visual Studio 2017 项目中以动态方式更新,这一点尤为重要。
不注意这些方面可能会导致许多问题。其中,我想强调以下问题:
-
可维护性问题
-
安全漏洞
-
许可证滥用
-
不需要的依赖项
为了帮助开发者防止依赖项问题,从 Visual Studio 2010 版本开始,IDE 提供了创建层图的能力,自那个版本以来,这些图一直在持续发展。
使用这些图,您可以表达层之间的依赖关系,这些依赖关系不仅通过图例显示,从最新版本(2017)开始,还在代码本身中显示。
当您设计这些图之一时,可以使用设计器提供的符号来表达依赖关系,包括单向和双向依赖、层以及简单的形状,正如您可以在以下屏幕截图中所见:

此图是通过 IDE 中与架构菜单相关的新菜单选项创建的,它还展示了与代码分析相关的某些功能,例如代码图生成、创建外部文件(包括文件)的图,以及其他功能。总的来说,该选项提供了以下选项:

请记住,此架构菜单仅在 V.Studio 2017 Enterprise 中可用。
这份菜单中的一个新选项是依赖项验证图,它将打开一个新的编辑窗口,在这里我们可以从解决方案中拖放元素,包括文件夹、文件(C# 和 VB.NET)甚至程序集。我们可以将这些功能视为 IDE 提供的其他实现,用于研究任何应用程序的依赖项。
这种技术是从头开始使用 Roslyn 重建的(参考 Pack 的《精通 C#和.NET Framework》一书,了解更多关于此功能的信息和演示),它允许编码者以完全定制的方式配置编辑器的行为,编程 IDE 在遇到代码(甚至在其他程序集,因为该工具接受拖放已编译的组件)中的任何这些功能时应如何响应。
一旦你在图中建立了关系和依赖,这个新的 Intellisense 将能够验证现有代码,向程序员建议不同应用域内的冲突区域。
实际上,你可以在配置中激活这些功能,这样一旦发现问题,你将看到一个波浪线突出显示你的违规代码,同时还有一个问题根源的指示。
例如,我们可以确定哪些类可以存在于每个应用程序的命名空间中。如果一个类被移动到依赖关系图中禁止的命名空间,代码编辑器本身将显示提示,同样,在错误列表窗口中也会显示(见以下截图):

当然,这种行为也是可配置的,这些功能与其他选项无关,我们在分析菜单中找到的选项,如代码度量、性能分析器等。
.NET Core 中的依赖注入
当处理与.NET Core 相关的依赖注入的正确功能时,有许多先前的方法需要考虑。其中之一是new是粘合剂的格言,这是我们经常作为建议听到的。
这意味着每次你创建一个类的新的实例(你使用new关键字),在幕后都有一些代码的凝聚力。你是在建立这样一个事实,即定义该实例的类将依赖于实例化的类。
我们已经看到了如何通过工厂或使用专注于 DI 问题的第三方库来解决这个问题,但在这个章节中,我们将依靠框架本身提供的这些功能来实现相同的结果。
理想情况下,这是我们能够定义的,一旦定义注册,每次我们需要任何预定义类的实例时,其他东西应该负责提供这个实例。
换句话说,我们看到的与其他 DI 容器相关的行为(或多或少)也应该在这里存在,并涵盖那些资深容器提供的重要功能,包括对象的生命周期、注册和泛型类和接口的定义等。
DI 架构和对象的生命周期
考虑到前面提到的点,即使我们知道我们正在处理框架的初始版本,.NET Core 团队设计了基于两个想法的依赖注入功能:
-
在一方面,已经存在的一些功能,可以完美地扩展到.NET Core 内部工作
-
在另一方面,他们认为将其他倡议中最常用的功能包含在内是明智的,无论是微软的还是外部的,比如那些在 Prism Patterns & Practices 或第三方 DI 容器中存在的功能(记得我们在前几章中看到的四个容器)
因此(遵循这些想法),在处理对象的生存周期时,.NET Core 提供了三种类型,这取决于实例的配置和使用方式--单例(Singleton)、作用域(Scoped)和瞬态(Transient)。
-
这些选项不仅影响我们定义它们的方式,还影响我们使用它们的方式,在某些情况下,还需要考虑线程安全预防措施和其他方面。
-
在单例选项中,使用对象的唯一实例(这意味着我们只管理对象的引用)。任何进一步的请求都使用相同的实例,根据单例模式。
-
作用域版本仅限于其使用的上下文,不会超出该上下文。
-
最后,每次请求时,瞬态选项都会创建该类的新实例。
虽然包含在 ASP.NET Core 文档中,但 Microsoft 提供了以下图表,与这些选项相关:

由于在现实世界中,此功能主要与 ASP.NET Core 应用程序、服务和中间件相关联,因此它们会显示有助于任务的函数(这就是为什么那些引用是针对请求的)。但实际上,它同样适用于纯.NET Core 应用程序。
正如我们最初提到的,这部分功能与Microsoft.Extensions.DependencyInjection命名空间相关,该命名空间包含同名的 DLL,它可以与另一个辅助 DLL(如Microsoft.Extensions.DependencyInjection.Abstractions)和其他 DLL 一起工作。
具体来说,IServiceCollection接口是一组提供方法的类的基类,这些方法可以通过 DI 使用这三种选项来实例化对象(在这个上下文中通常称为服务)。我们将在下一节中看到这一点,但也会在第四章,ASP.NET Core 中的依赖注入中看到,该章节专门介绍 ASP.NET Core。
ActivatorUtilities类和其他辅助类
在我们进入演示之前,请记住,这个命名空间中包含的类数量相当大,因为它试图提供广泛的覆盖范围,同时仍然是多平台的。
其中一个例子是ActivatorUtilities类,它也包含在Microsoft.Extensions.DependencyInjection库中,该库包含静态方法,有助于配置和实现服务、实例和工厂,从而简化 DI 管理和控制。
因此,如果你发现自己缺少某些功能或功能,请查看docs.microsoft.com上的文档,但请注意,你会发现它与 ASP.NET Core 相关。
这意味着你将在互联网和其他来源看到的大多数实现都不会链接到.NET Core 应用程序,而是链接到 ASP.NET Core 应用程序,在这些应用程序中,许多此类功能默认为不同场景实现。
显然,这些类中还有更多内容,我们无法在一个章节中全部涵盖。
为了让你了解与这个类相关的可能性,我包括了一个官方文档中呈现的功能摘要,提醒你每个方法和其主要用途:
| 方法摘要 | 描述 |
|---|---|
| CreateFactory(Type, Type[]) | 创建一个委托,该委托将使用直接提供的构造函数参数和/或从 System.IServiceProvider 提供的参数实例化一个类型。 |
| CreateInstance(IServiceProvider, Type, Object[]) | 使用直接提供的构造函数参数和/或从 System.IServiceProvider 提供的参数实例化一个类型。 |
使用直接提供的构造函数参数和/或从 System.IServiceProvider 提供的参数实例化一个类型。 |
|
| GetServiceOrCreateInstance(IServiceProvider, Type) | 从服务提供程序中检索给定类型的实例。如果没有找到,则直接实例化。 |
| GetServiceOrCreateInstance |
从服务提供程序中检索给定类型的实例。如果没有找到,则直接实例化。 |
现在是时候开始使用 **Microsoft.Extensions.DependencyInjection** 和 **Microsoft.Extensions.DependencyInjection.Abstractions** 库中与依赖注入相关的主要类了。
Microsoft.Extensions.DependencyInjection 容器
你在网上看到的关于依赖注入和 .NET Core 的大多数示例都将引用 ASP.NET Core 应用程序,因为当你使用 Visual Studio 内置的预定义模板时,它默认包含。
然而,如果你想从头开始了解依赖注入的工作原理,使用 ASP.NET Core 应用程序并不是强制性的。也就是说,我们必须手动配置服务的注册。这意味着某些额外的方面,例如严重级别、对象的生存期以及可处置功能,如果想让这种架构无缝工作。
由于 .NET Core 提供了自己的容器,与外部库 Microsoft.Extensions.DependencyInjection 链接(现在在 2.0 版本中),我们必须通过 NuGet 包引用它。
对于第一次演示,我们可以创建一个简单的 .NET Core 控制台应用程序,使用 NuGet 包插件引用库,并在网上查找其名称。你会发现,当你过滤该名称时,NuGet 包管理器会显示该库的几个版本(见以下截图):

如你所见,其他库也被声明为补充的(Microsoft.Extensions.DependencyInjection.Abstractions 和 Microsoft.Extensions.DependencyInjection.Specification.Tests)。第一个(Abstractions)在安装完成后也应该出现在我们的项目中作为引用。但就目前而言,让我们专注于主要库。
在此刻,不要害怕下面的对话框。它会告诉你将更新一大堆库,并且还将安装另一组新的库。原因可能是双重的——一方面,使用该库的最新版本意味着根据 Visual Studio 2017 使用的内部库依赖管理器更新先前引用的其他库。
另一方面,安装一些新的库,如这个库,可能意味着其他依赖项,因此我们最终有两个不同的更新区域(安装后转到解决方案资源管理器中的依赖项部分)。
Visual Studio 2017 将列出所有正在更新的库,如果你向下滚动一点,还会显示所有依赖库的列表:

在此对话框之后,你将看到一个包含每个库许可接受的新对话框。完成后,解决方案资源管理器依赖项部分将出现另一个条目,指向 NuGet。那里你可以找到所有新东西。
进一步查看这些新条目将揭示与那个 DependencyInjection 引用链接的所有主要和依赖库,可能有一些。
由于我们最初只想测试这个命名空间的工作方式,我们将以非常简单的方式进行测试(可以说,我受到了 Jurgen Gustch 的博客文章《在 .NET Core 控制台应用程序中使用依赖注入》的启发,因为对于这种初始方法,我发现它特别适合且具有解释性)。
因此,我修改了这些想法来创建几个类,其中一个类恰好依赖于另一个(即,第一个类引用第二个类)。
仅为了完整性,我增加了一个额外的方法来检查调用正确的时间,并且实现了 IDisposable 接口,以便能够通过垃圾回收器传达有关销毁的信息(关于这一点稍后讨论)。
因此,我最终得到了以下代码(在默认由模板创建的 Program 类之外):
public class DependencyClass1 : IDisposable
{
private readonly DependencyClass2 _DC2;
public DependencyClass1(DependencyClass2 DC2instance)
{
_DC2 = DC2instance;
Console.WriteLine("Constructor of DependencyClass1 finished");
}
public void CurrentTime()
{
string time = DateTime.Now.Hour.ToString() + ":" +
DateTime.Now.Minute.ToString() + ":" +
DateTime.Now.Second.ToString();
Console.WriteLine($"Current time: {time}");
}
public void Dispose()
{
_DC2.Dispose();
Console.WriteLine("DependencyClass1 disposed");
}
}
public class DependencyClass2 : IDisposable
{
public DependencyClass2()
{
Console.WriteLine("Constructor of DependencyClass2 finished");
}
public void Dispose()
{
Console.WriteLine("DependencyClass2 Disposed");
}
}
注意,DependencyClass1 会负责在完成使用后销毁 DependencyClass2。
现在是当需要 DependencyInjection 类的时候。首先,我们在代码顶部引用相应的命名空间(对于这个简单的演示只需要两个命名空间):
using Microsoft.Extensions.DependencyInjection;
using System;
然后,在 Program 类中,我们需要注册和使用这些类。第一步是使用 ServiceCollection 类的新实例来执行。
在这个类中,我们可以找到注册所有所需服务的方法,同时配置了之前提到的对象的生命周期,如下面的截图所示:

如我们稍后将会看到的,我们提供了每个方法的两个版本--通用和非通用。这为我们提供了更多的灵活性,可以将不同的类和服务集成到我们的依赖注入架构中。
对于这个初始版本,我们使用方法的 AddTransient 通用版本,每次我们在代码中创建引用时,它将返回每个类的新的实例。
一旦我们的类注册完毕,就需要根据这个定义构建一个提供者。这是通过在刚刚创建的 ServicesCollection 类上调用 BuildServiceProvider() 来完成的(我们稍后会回到服务提供者)。但就现在而言,只需说一个 IServiceProvider 接口的实例将被创建并配置为管理使用 Add* 方法注册的任何先前注册的类的请求即可。
另一点需要指出的是,对象的实例化遵循了您可能已经从其他上下文(如 LINQ)中了解的懒加载模式。这意味着直到请求第一个 DI 对象的实例之前,不会创建任何内容。
因此,即使我们不做任何(有用)的事情,一旦请求 DependencyClass1,整个依赖注入机制就会启动。
以下代码在 Program 类中被修改以配置此架构:
static void Main(string[] args)
{
Console.WriteLine("Dependency Injection Demo");
Console.WriteLine("Basic use of the Microsoft.Extensions.
DependencyInjection Library");
Console.WriteLine("--------------------------------------
---------------------------");
var services = new ServiceCollection();
services.AddTransient<DependencyClass2>();
services.AddTransient<DependencyClass1>();
var provider = services.BuildServiceProvider();
using (var DC1Instance = provider.GetService<DependencyClass1>())
{
// Merely by declaring DC1Instance
// everything gets launched, but we also call
// CurrentTime() just to check functionality
DC1Instance.CurrentTime();
// Notice also how classes are properly disposed
// after used.
}
Console.ReadLine();
}
如您在以下输出中看到的,一切按预期工作:

代码展示了每当我们需要一个类的实例时,我们都会调用通用的 GetService() 方法,并且我们可以在下一句中开始使用它。
另一个需要注意的方面是,对 DependencyClass2 的引用先出现,因此它先进行清理。请记住,DependencyClass1 的构造函数接收 DependencyClass2 的一个实例,所以它不会完成,直到后者完全创建。
此外,在处置第一个类之前,我们调用第二个类的 Dispose,这就是为什么顺序是相反的。
检查对象的生命周期
我们应该仔细考虑的此架构的另一个重要方面,是从其生命周期的角度来看我们如何获取实例。让我们看看在这个演示中的差异,添加引用并更改它们在 IServiceProvider 类中的注册方式。
如果我们为第一个类创建另一个实例会发生什么?正如预期的那样,当我们将代码中的 using 块改为包含 DependencyClass1 的另一个实例时,例如以下代码:
using (var DC1Instance = provider.
GetService<DependencyClass1>())
{
// Merely by declaring DependencyClass1
// everything gets launched, but we also call
// CurrentTime() just to check functionality
DC1Instance.CurrentTime();
// Notice also how classes are properly disposed
// after used.
var DC1Instance2 = provider.GetService<DependencyClass1>();
DC1Instance2.CurrentTime();
}
输出发生了明显的变化,因为我们迫使引擎创建一个新的实例,而不是重用之前的实例:

如前一个屏幕截图所示,我们让 DI 引擎调用构造函数两次,因为我们使用了 services 配置对象的 AddTransient() 版本。
然而,在这种情况下,如果我们更改 AddSingleton 的 AddScoped 注册方法,我们将重用相同的对象实例,因此可以节省内存和资源。
例如,只需以这种方式更改这两行代码:
services.AddScoped<DependencyClass2>();
services.AddScoped<DependencyClass1>();
我们可以通过简单地查看相应的输出来检查不同的创建行为:

如您所见,CurrentTime 函数仍然被调用了两次,但正在使用的实例数量只有一个。
在这种情况下,我们将使用 AddSingleton<>() 方法得到完全相同的输出,因为在这种情况下,存在一个巧合,并且不会使用超过一个实例。
此服务提供的功能的另一个有趣方面是,作为一个泛型集合本身,我们可以在运行时添加/删除/清除服务实例,因此我们可以始终完全控制集合中的内容以及定义和实例化的顺序。
为了达到这个目的,我们找到了像 Clear()、Contains()、IndexOf()、Insert()、InsertAt()、Remove 和 RemoveAt() 这样的方法,就像我们在任何其他泛型集合中找到的方法一样。
将接口映射到实例类
之前的演示足够简单,可以理解 Microsoft.Extensions.DependencyInjection 库内部的依赖注入(DI)的基本原理,但在实际应用中,你可能会定义一些接口和一系列实现这些接口的类。
在这种情况下,能够将接口映射到实现它们的类会更方便,因此你只需请求相应接口的实现,思考接口提供的功能(要解决的问题的业务问题),而不是实现它的具体类。
另一个优点是我们能够在运行时更改定义(记住,这是一个我们可以添加/删除项的集合),因此根据我们应用程序的需求,重新定义任何之前的映射到新的映射是完全可能的。
与先前的演示一样,我们使用一个非常简单的方法来展示这一点。我创建了两个接口和两个实现它们的类,每个类都有一个将基本消息写入Console的方法。这是初始代码:
public interface IXMLWriter
{
void WriteXML();
}
public interface IJSONWriter
{
void WriteJSON();
}
public class XMLWriter : IXMLWriter
{
public void WriteXML()
{
Console.WriteLine("<message>Writing in XML Format</message>");
}
}
public class JSONWriter : IJSONWriter
{
public void WriteJSON()
{
Console.WriteLine("{'message': 'Writing in JSON Format'}");
}
}
类和接口之间存在对应关系,因此我们现在可以引用接口,让 DI 引擎决定返回给我们哪个类实例。这与我们在上一章使用第三方 DI 容器时看到的演示非常相似。
为了达到这个目的,ServiceCollection类支持一种定义引用的替代方式,在调用GetService<Interface>()时,你可以传递(泛型签名)一个接口名称和映射该接口的类。
在请求这些实现之一时,我们将请求预定义接口的实例,而不是具体类。
注意,我们还有通过GetRequiredService<Interface>()方法请求服务的另一种方式,如果服务类型未注册,它会抛出异常。
假设这个变更,演示的实现相当简单:
static void Main(string[] args)
{
var services = new ServiceCollection();
services.AddTransient<IXMLWriter, XMLWriter>();
services.AddTransient<IJSONWriter, JSONWriter>();
var provider = services.BuildServiceProvider();
Console.WriteLine("Dependency Injection Demo (2)");
Console.WriteLine("Mapping Interfaces to instance classes");
Console.WriteLine("--------------------------------------");
Console.WriteLine("Please, select message format
(1):XML // (2):JSON");
var res = Console.ReadLine();
if (res == "1")
{
var XMLInstance = provider.GetService<IXMLWriter>();
XMLInstance.WriteXML();
}
else
{
var JSONInstance = provider.GetService<IJSONWriter>();
JSONInstance.WriteJSON();
}
Console.ReadLine();
}
与前一种情况不同,我们不请求一个特定的类,而是请求实现所需接口的类。
输出再次符合预期(见以下截图):

注意,注册的方式与我们之前在其他 DI 容器中看到的方式类似。也就是说,如果我们使用这种语法,最新注册的映射就是返回的映射,尽管这可以动态地改变。
另一个非常有用的功能是GetServices<Interface>方法,因为它允许我们恢复所有已注册的服务并随意调用它们。
我们可以通过添加几个实现相同接口的新类并将它们与之前的类一起注册来证明这一点:
public class XMLWriter2 : IXMLWriter
{
public void WriteXML()
{
Console.WriteLine("<message>Writing in XML Format (2)</message>");
}
}
public class JSONWriter2 : IJSONWriter
{
public void WriteJSON()
{
Console.WriteLine("{'message': 'Writing in JSON Format (2)'}");
}
}
在这些定义之后,我们在相同的接口合约下注册这两个类,因此它们可以一起访问:
services.AddTransient<IXMLWriter, XMLWriter>();
services.AddTransient<IXMLWriter, XMLWriter2>();
services.AddTransient<IJSONWriter, JSONWriter>();
services.AddTransient<IJSONWriter, JSONWriter2>();
Now we can use a whole collection by asking for it by means of the
GetServices<Interface>() method that I mentioned above:
var registeredXMLServices = provider.GetServices<IXMLWriter>();
foreach (var svc in registeredXMLServices)
{
svc.WriteXML();
}
由于我们使用的是定义的接口功能,我们知道它们都将实现WriteXML()函数,即使它们实现的方式不同。
你可以在相应的输出中看到不同的调用:

获取整个服务列表的另一种替代方法,当然是从 services 集合本身。为此,我们需要另一个由 Microsoft.Extensions.DependencyInjection 库提供的辅助类。
在这种情况下,过程是创建一个包含我们 ServiceCollection 类中所有服务信息的 ServiceDescriptor 集合。我们使用枚举器和 CopyTo() 方法(它期望 ServiceCollection 作为第一个参数)来创建这样一个集合:
var myServiceArray = new ServiceDescriptor[services.Count];
// Copy the services into an array.
services.CopyTo(myServiceArray, 0);
IEnumerator myEnumerator = myServiceArray.GetEnumerator();
Console.WriteLine("The Implementation Types in the array are");
while (myEnumerator.MoveNext())
{
var myService1 = (ServiceDescriptor)myEnumerator.Current;
Console.WriteLine(myService1.ImplementationType);
}
当集合被复制到 ServiceDescriptor 集合时,我们可以看到至少五个可能后来用于确定在特定场景中所需服务的有趣属性:

注意,在这里我们请求 ImplementationType 属性以获取所有定义的类型:

这为我们提供了如何独立于服务在集合中的位置来选择单个服务的线索。与这一功能相关联的另一个辅助方法是简单的 Contains() 方法,它要求一个唯一的 ServiceDescriptor 对象作为其参数。
要获取我们服务容器中当前注册的组件的信息,另一个简单的方法是直接遍历它,使用一个简单的 foreach 循环:
//Description of properties in the service collection
foreach (var svc in services)
{
Console.WriteLine($"Type: {svc.ImplementationType} \n" +
$"Lifetime: {svc.Lifetime} \n" +
$"Service Type: {svc.ServiceType}");
}
注意,根据服务注册的方式和其他编程功能,并非所有属性都将有值(在这种情况下,只请求接口(ServiceType)、实现(Types)及其生命周期是有意义的)。
这在我们有多个实现相同接口的类时也很有用,因为我们可以根据这些值来决定我们需要哪一个:

此外,还可以使用一些与这里暗示的命名空间相关联的辅助类来执行 a posteriori 注册。例如,ServiceProviderServiceExtensions 类包含一个静态方法,能够获取与特定提供者相关联的给定 ServiceType。
换句话说,只要你能传递相应的提供者作为参数,你就可以获取一个已注册服务的实例,而无需使用注册它的 ServiceCollection。
我已经创建了一个之前演示的变体,这次在每个 *writer 类中包含一个只读属性来保存一个唯一的标识符(一个 GUID),这样就可以很容易地确定我们是否正在使用相同的或另一个服务实例。
考虑以下代码(前一个演示的变体):
static void Main(string[] args)
{
var services = new ServiceCollection();
services.AddSingleton<IXMLWriter, XMLWriter>();
var provider = services.BuildServiceProvider();
Console.WriteLine("Dependency Injection Demo (3)");
Console.WriteLine("Choice between implementations");
Console.WriteLine("------------------------------");
// Instance via services class
var XMLInstance = provider.GetService<IXMLWriter>();
XMLInstance.WriteXML();
// Instance via ServiceProviderServiceExtensions
var XMLInstance2 = ServiceProviderServiceExtensions.
GetService<IXMLWriter>(provider);
XMLInstance2.WriteXML();
Console.ReadLine();
}
如您所见,我们正在使用两种不同的方法来获取相同的实例(通过其 GUID 标识)。我们可以通过比较两个输出(见以下截图)来测试它:

这在某种情况下可能特别有用,在这种情况下,出于某种原因,调用服务可能不适合或不方便。
除了ServiceCollection的BuildServiceProvider方法之外,还可以通过辅助类获取提供者。为此,我们可以使用CreateDefaultServiceProvider类,它也有实例方法来创建提供者或Builder。
目前,这两个是该类唯一可用的可能性,但在某些场景中,它也可以非常有用,在这些场景中我们更愿意不使用服务集合:

以下代码是通过DefaultServiceProvider类创建的提供者:
var services = new ServiceCollection();
services.AddSingleton<IXMLWriter, XMLWriter>();
// Provider via DefaultServiceProviderFactory
var factory = new DefaultServiceProviderFactory();
IServiceProvider prov = factory.CreateServiceProvider(services);
var XMLInstance = prov.GetService<IXMLWriter>();
XMLInstance.WriteXML();
我在这里省略了输出,因为它与之前的演示完全相同,你可以在本章伴随的代码中自行检查。
这不是我们获取服务提供者的唯一方式。是的,还有另一种方式,与ServiceCollectionContainerBuilderExtensions类的静态方法BuildServiceProvider相关联。
在这种情况下,编程甚至更简单,因为我们不需要类的任何实例,代码简化为以下代码:
var services = new ServiceCollection();
services.AddSingleton<IXMLWriter, XMLWriter>();
// Provider via ServiceCollectionContainerBuilderExtensions
IServiceProvider prov = ServiceCollectionContainerBuilderExtensions.
BuildServiceProvider(services);
var XMLInstance = prov.GetService<IXMLWriter>();
XMLInstance.WriteXML();
只是为了得到与我们之前相同的结果(再次,我省略了输出)。
将作用域概念应用于服务
在处理服务和与 DI 相关的其他功能时,一个重要的问题是定义其范围。DI 文档将服务的范围与其生命周期紧密相关联,因此,与垃圾收集器应该销毁该服务的时刻相关联。
我们之前已经讨论过 Transitory 和 Singleton 的生命周期,但 Scope 生命周期确实有点令人困惑。
具体来说,实现了IDisposable接口的IServiceDispose接口包含了一个Disposed of()方法,该方法在调用时结束作用域生命周期。它包含在Microsoft.Extensions.DependencyInjection.Abstractions.dll中。
更详细地说,文档中声明:“一旦此对象被销毁,从 M**icrosoft.Extensions.DependencyInjection.IServiceScope.ServiceProvider 解析出的任何作用域服务也将被销毁”。
它的声明如下:
public interface IServiceScope : IDisposable
如果你记得本章的第一个演示,我们的DependencyClass1和DependencyClass2类实现了IDisposable接口,因此我们可以在类的主要操作完成后调用这些方法。
正如我们在第四章,“ASP.NET Core 中的依赖注入”中将会看到的,这个概念特别适合某些互联网应用的场景,在这些场景中,对某些服务的生命周期进行特定控制非常有意义,并且始终可以通过实例类访问执行上下文。
在第四章,“ASP.NET Core 中的依赖注入”中,你会看到这个特性在面对性能问题、服务器资源、可伸缩性问题等时可能非常重要。
其他具有 DI 功能的扩展
与 Microsoft.Extensions 全局命名空间相关联,我们发现了一些在开发者中越来越受欢迎的相关命名空间,因为它们有助于应用程序生命周期的不同领域。
其中两个最常用的库是 Microsoft.Extensions.Logging 和 Microsoft.Extensions.Logging.Console,您可以使用我们在本章中看到的依赖注入技术来配置和编写日志服务。它们提供了类似于其他流行框架(如 Serilog、Log4Net 或 NLog)的功能。
我在谈论 ILoggerFactory 和 ILogger<T>,它们主要用于(尤其是在 ASP.NET Core 应用中)在运行时发出信息,并具有将信息重定向到不同目标(控制台、输出窗口等)的能力。
但它们也可以用于监控和调试 .NET Core 应用程序,尽管它们不提供我们在那些更专业的框架中找到的全部资源,但很多时候它们足以满足我们的需求。
对 .NET Core 结构的反思
看到 .NET Core 的创造者如何基于依赖注入原则设计这项技术,非常有趣。这就是为什么,除了它作为开发者工具的价值之外,我们还可以看到在 .NET Core 库中默认实现依赖注入(DI)的额外价值。
这两个类都位于 Microsoft.Extensions.Logging 命名空间内,但其他互补的命名空间,如 Microsoft.Extensions.Logging.Console 和 Microsoft.Extensions.Logging.Debug,也允许我们使用在下一个演示中将要使用的扩展日志功能。它们可以通过 NuGet 包轻松引用,就像我们之前做的那样。
为了使用尽可能简单的代码来演示事物,我将使用之前演示的简化版本,这次只使用一个外部类 XMLWriter 及其相应接口,并进行了一些细微的改动。
就像在 DependencyInjection 命名空间中一样,当在搜索框中按此标准筛选时,Microsoft.Extensions.Logging 会出现在 Configuration 和 Dependency 库旁边(请注意,我们总共需要四个额外的库,包括 Logging.Console 和 Logging.Debug):

安装了这些库之后,如果您对这些 API 的可能性感到好奇,您还可以检查在 Solution Explorer 中出现的引用,它位于 NuGet 引用条目旁边。
这些日志服务提供的功能包括将信息写入 Console,使用不同类型的消息(根据输出的性质:调试、信息、警告或错误)以及使用多种颜色和格式来表示这些类别。
Mark Michaelis 在他的 MSDN 文章 Essential .NET - Dependency Injection with .NET Core 中清楚地解释了我们发现与 .NET Core 链接的一些 DI 实现的优势。
他指出,当你想要在某个服务的不同实现之间切换,并避免硬编码对任何服务实现的引用时,请求此类实例的工厂会更加可扩展和易于维护,就像我们在其他演示中所做的那样。ILoggerFactory 实现了该功能。
他更进一步,强调说:“你请求一个接口(例如 ILoggerFactory**)的期望是服务提供者(在这种情况下,NLog、Log4Net 或 Serilog)将实现该接口”。
因此,ILoggerFactory 即使与第三方库一起也是可扩展的!他还评论说:“结果是,虽然客户端将直接引用定义服务接口的抽象程序集(Logging.Abstractions),但不需要引用直接实现”。
实际上,你可能已经注意到 ServiceCollection 本身有一个名为 AddLogging() 的方法,这是一个方便的方法,用于激活集合的日志功能。这个调用实际上是将 ILoggerFactory 服务作为我们正在配置的服务集合的一部分的内部注册。
考虑到我们也可以连接调用来配置我们的服务集合,让我们看看我们的新 Main() 方法的第一部分,包括那个调用:
// Enabling logging with the ServiceCollection
var services = new ServiceCollection()
.AddSingleton<IXMLWriter, XMLWriter>()
.AddLogging();
var serviceProvider = services.BuildServiceProvider();
因此,我们通过 AddLogging() 在 ServiceCollection 中启用日志记录。那么,发生了什么?让我们通过迭代结果服务对象来检查我们的集合现在具有的新成员,就像在之前的演示中那样:
// Test the register of AddLoggin()
foreach (var svc in services)
{
Console.WriteLine($"Type: {svc.ImplementationType} \n" +
$"Lifetime: {svc.Lifetime} \n" +
$"Service Type: {svc.ServiceType}");
}
我们将获得一个包含三个服务的集合(见下面的输出),因为 AddLogging() 方法确实已将 LoggingFactory 类注册为 ILoggingFactory 接口,以及另一个泛型类,ILogger<>。该 ILogger<> 类将被配置为提供将日志消息发送到控制台的能力,为任何其他类:

因此,下一步是获取一个 ILoggerFactory 对象,并将其与 Console 相关联,我们通过以这种方式调用 AddConsole() 来执行此操作:
//Obtain service and configure logging
serviceProvider.GetService<ILoggerFactory>()
.AddConsole(LogLevel.Debug);
正如你所见,AddConsole 预期一些额外的配置,形式为 LogLevel 类型的 enum 值,它决定了运行时在向控制台发送消息时将过滤的最小严重程度级别——每当日志系统接收到条目时,如果它低于该级别,它将忽略它。
日志级别严重性
LogLevel 枚举还建立了消息的优先级(如果我们想区分低级消息和高级消息,甚至将它们重定向到不同的输出窗口,这非常有用)。
根据官方文档,这些级别按以下顺序组织(从最低严重性到最高严重性):
-
跟踪 = 0:对于只有开发人员在调试问题时才有价值的信息。这些消息可能包含敏感的应用程序数据,因此不应在生产环境中启用。默认情况下禁用。例如,凭证:
{"User":"someuser", "Password":"P@ssword"} -
调试 = 1:对于在开发和调试期间具有短期有用性的信息。例如,使用 fl the g 设置为 true 进入
Configure方法。 -
信息 = 2:用于跟踪应用程序的一般流程。这些日志通常具有一些长期价值。例如,接收到的路径
/api/todo的请求。 -
警告 = 3:对于应用程序流程中的异常或意外事件。这些可能包括不会导致应用程序停止的错误或其他条件,但可能需要调查。处理异常是使用警告日志级别的常见地方。例如,对于文件
quotes.txt的FileNotFoundException。 -
错误 = 4:对于无法处理的错误和异常。这些消息表明当前活动或操作(如当前 HTTP 请求)失败,而不是应用程序级别的失败。例如,日志消息:
由于重复键违反无法插入记录。 -
关键 = 5:对于需要立即注意的失败。例如,数据丢失场景,磁盘空间不足。
IDE 还通过 IntelliSense 服务显示这些级别,以及每个目的和功能的说明:

这样,当我们请求引用时返回的ILoggerFactory服务将根据调试级别配置将任何输出重定向到控制台,除非有其他指示。
ILogger和ILoggerFactory接口位于Microsoft.Extensions.Logging.Abstractions中,它们的默认实现位于Microsoft.Extensions.Logging。
现在,如果我们想让这两个类(XMLWriter和Program)都使用这些日志服务,我们需要为每个类提供一个ILogger实例。我们将开始为Program创建一个实例,并在控制台中展示一组初始消息:
// Create a logger class from ILoggerFactory
// and print an initial set of messages.
var ILoggerService = serviceProvider.GetService<ILoggerFactory>();
var logger = ILoggerService.CreateLogger<Program>();
注意到创建日志类意味着调用CreateLogger<Program>()泛型方法。一旦实例化,日志器就有方法来声明不同的作用域(标记每个作用域的开始和结束)以及向控制台发送六种不同类型的消息,每种消息代表不同的严重级别:
logger.LogCritical("Critical format message from Program");
logger.LogDebug("Debug format message from Program");
logger.LogError("Error format message from Program");
logger.LogInformation("Information format message from Program");
logger.LogTrace("Trace format message from Program");
logger.LogWarning("Warning format message from Program");
如果我们查看输出,我们可以欣赏到这些消息格式之间的差异:

如您所见,不同的消息严重级别在输出中生成不同的格式,使用不同的颜色和前缀来表示其LogLevel类别。但是,等等!有一个缺失的(Trace消息)。
嗯,还不完全是。发生的事情是,Trace LogLevel不会输出到控制台,它主要准备用于启用跟踪开关的 Web 应用程序(我们将在第四章,ASP.NET Core 中的依赖注入)中。
所以,所有这些都说完了,我们如何使用这个架构和从我们的XMLWriter类中使用的日志服务呢?让我们改变实现方式,以便我们使用我们在上一章中看到的一个 DI 模式——构造函数依赖模型。
要使用该模型,我们必须稍微改变我们的XMLWriter类,以包括一个只读属性,它持有ILogger<>实例,并在类的构造函数中分配其值。因此,这次我们独特的XMLWriter类的最终格式将是(接口定义尚未受到影响,所以它和之前的演示相同):
public class XMLWriter : IXMLWriter
{
private readonly ILogger<XMLWriter> logger;
public XMLWriter(ILoggerFactory loggerFactory)
{
logger = loggerFactory.CreateLogger<XMLWriter>();
}
public void WriteXML()
{
logger.LogInformation("<message>Writing in XML Format
(via Logger)</message>");
}
}
剩下的唯一事情就是使用logger代替之前的Console调用,并调用一个Log*方法来生成预期的输出。就是这样。
我们已经完全用ILogginFactory和ILogger对象提供的日志服务替换了Console类提供的功能,我们还可以配置严重级别以生成不同的输出格式。
所以,考虑到所有这些,我们最终得到了这个实现版本的Main方法:
static void Main(string[] args)
{
// Enabling logging in the ServiceCollection
// via AddLogging()
var services = new ServiceCollection()
.AddSingleton<IXMLWriter, XMLWriter>()
.AddLogging();
var serviceProvider = services.BuildServiceProvider();
//Obtain service and configure logging
serviceProvider.GetService<ILoggerFactory>()
.AddConsole(LogLevel.Debug);
// Create a logger class from ILoggerFactory
// and print all types of messages.
var ILoggerService = serviceProvider.GetService<ILoggerFactory>();
var logger = ILoggerService.CreateLogger<Program>();
logger.LogCritical("Critical format message from Program");
logger.LogDebug("Debug format message from Program");
logger.LogError("Error format message from Program");
logger.LogInformation("Information format message from Program");
logger.LogTrace("Trace format message from Program");
logger.LogWarning("Warning format message from Program");
//Instantiation of XMLInstance
var XMLInstance = serviceProvider.GetService<IXMLWriter>();
XMLInstance.WriteXML();
logger.LogDebug("Process finished!");
Console.Read();
}
正如我们在最终输出中可以看到的(请参阅以下截图),所有消息都按照它们被调用的顺序在控制台中呈现,使用.NET Core 配置的预定义格式,包括我们的XMLWriter消息:

这还不是全部。我们还有其他一些选项可供选择,允许我们分离和过滤输出消息的目的地。这种可能性与Microsoft.Extensions.Logging.Debug库相关联,它也应该像我们在这里使用的其他库一样被引用。
该库包含的一个功能是ILoggerFactory的AddDebug()方法。一旦激活,它允许我们将消息发送到调试窗口,并能够根据消息的严重级别进行条件分离,例如。
为了测试这个功能,我们将在IXMLWriter接口的定义中做一些更改,并相应地更新实现。我们的新接口将有一个额外的方法,该方法也会将消息发送到预定义的输出(在这种情况下,将在运行时出现在几个地方):
public interface IXMLWriter
{
void WriteXML();
void WriteXMLWithSeverityLevel();
}
因此,XMLWriter 的更新代码将是:
public class XMLWriter : IXMLWriter
{
private readonly ILogger<XMLWriter> logger;
public XMLWriter(ILoggerFactory loggerFactory)
{
loggerFactory.AddDebug().AddConsole(LogLevel.Information);
logger = loggerFactory.CreateLogger<XMLWriter>();
}
public void WriteXML()
{
logger.LogDebug(
"<msg>First Message (LogDebug/SeverityLevel:
Information)</msg>");
}
public void WriteXMLWithSeverityLevel()
{
logger.LogDebug(
"<msg>Second Message (LogDebug/SeverityLevel:
Information</msg>");
}
}
因此,现在我们有两种不同的方法来写入消息。为了测试这个功能,我们可以在Main()方法中配置ILoggerService对象(记住,它是ILoggerFactory类型)。一旦新的命名空间被加载并可用,我们可以写入:
var ILoggerService = serviceProvider.GetService<
ILoggerFactory>();
ILoggerService.AddDebug();
以这种方式,我们允许将消息发送到Debug或Output窗口,无论是控制台还是 Web 应用程序。
测试不同的选项很容易,只需更改调用此方法时使用的严重性级别类型,以及现有的那些。例如,我们可以调用WriteXMLWithSeverityLevel()并观察在执行过程中生成的两个输出(现在我们有两个):
//Instantiation of XMLInstance
var XMLInstance = serviceProvider.GetService<IXMLWriter>();
XMLInstance.WriteXML();
XMLInstance.WriteXMLWithSeverityLevel();
一方面,输出现在显示了新的消息(没有意外):

但是,现在我们有了更多的消息。如果我们查看输出窗口,我们会看到根据我们配置的LogLevel出现的新条目——其中一些将被展示,而其他一些则会被忽略(正如你所见,在这个版本中,只有前四条消息被复制到输出窗口中,所有 XMLWriter 消息都被忽略):

这只是对采用 DI 架构的一些服务以及.NET Core 内部可用的服务的初步了解。当我们处理 ASP.NET Core 编码时,在第四章,“ASP.NET Core 中的依赖注入”中,我们将看到更多关于这些实现的细节。
摘要
在本章中,我们初步探讨了如何在当前版本的.NET Core(2.0)中支持和使用依赖注入技术,以及我们如何在 ASP.NET 项目之外使用它们。
总结来说,我们已经看到了.NET Core 的主要特性和从 Visual Studio 安装和使用的步骤,特别是关注最新版本,Visual Studio 2017,以及与这个框架版本相关的不同类型的部署,以及包含在 DI 和相关命名空间中的主要功能和功能。
我们还分析了与此架构相关的类和接口,以及通过一系列示例实现它的方法,最后是一些真实实现,它们已经是.NET Core 2.0 的一部分,例如日志服务,以及如何从任何类中使用它们。
在第四章,“ASP.NET Core 中的依赖注入”,我们的方法将更加真实,因为它涉及到 Web 应用程序以及网站的新架构和配置如何管理这些新概念,例如中间件和服务配置,其中 DI 从一开始就扮演着重要角色。
第四章:ASP.NET Core 中的依赖注入。
在第三章“介绍.NET Core 2.0 中的依赖注入”,我们专注于.NET Core,分析了该平台在依赖注入方面的可能性以及实现它的不同方式。在本章中,我们将继续分析 DI,但这次将专注于 ASP.NET Core 的实现以及当时为程序员提供的配置网站和其他相关功能的可能性,这些功能贯穿了整个生命周期。
理念是从命令行工具(CLI)开始,看看如何修改控制台应用程序并将其转换为 Web 应用程序,这样你可以更好地理解中间件的概念以及它在 ASP.NET Core 中的使用方式。
有了这些,我们将准备好分析 Visual Studio 2017 为 ASP.NET Core 应用程序提供的默认模板以及与 DI 相关的特定功能。
最后,我们将看看如何调整我们自己的服务以及如何通过依赖注入在注册选项、控制器和视图中使用它们。
总的来说,在本章中,我们将涵盖以下主题:
-
使用命令行工具构建 ASP.NET Core 应用程序。
-
ASP.NET Core 的中间件架构。
-
分析 Visual Studio 提供的默认模板。
-
ASP.NET 应用程序中 DI 的特性。定制服务。
注意官方文档网站使用与 Visual Studio 2017 提供的模板相同的代码。你可以在docs.microsoft.com/en-us/aspnet/core/tutorials/first-mvc-app/start-mvc找到它。
从命令行工具使用 ASP.NET Core。
一旦我们在我们的机器上安装了.NET Core 并(如果需要)更新到 2.0 版本,我们就可以从头开始使用命令行工具启动一个非常简单但具有说明性的网站,并看看我们如何通过几个步骤将这个最小的.NET Core 控制台应用程序转换为 ASP.NET Core 应用程序。
这个过程将帮助你理解 ASP.NET Core 带来的架构上的深刻变化,以及我们看到的某些 SOLID 原则是如何以各种方式应用于实现这一目标的。
因此,第一步应该是检查我们安装的.NET Core 版本,我们可以在命令行窗口中这样做(记住 Visual Studio 2017 在 Windows 菜单中安装了几个指向这些窗口的链接,并且开发者命令提示符已经定义了环境变量以适应程序员的 主要需求)。
如果你还没有安装.NET Core 命令行工具,请记住你可以在命令行/其他部分中的网站www.microsoft.com/net/download/core进行单独安装,这允许你下载所有当前支持版本的安装程序。运行安装程序时,它应该看起来如下:

安装后,应该会在 C:\Program Files\dotnet\sdk 中出现一个名为 2.0 的新文件夹(或当时可用的最新版本)。
因此,我们可以使用 dotnet -version 和 dotnet --info 命令来检查当前版本的详细信息,并查看是否已安装版本 2.0:

窗口将显示一些基本命令,例如 --help 和 --version。要检查最新版本可用的模板类型,只需输入 dotnet new(不带任何额外参数),你将看到一个类似于此的列表:

注意,第一次输入该命令时,它将解压缩一些文件(需要几秒钟),以最初填充你的本地包缓存。这只会发生一次,可以提高恢复速度并允许离线访问。
创建可能的最小应用程序
我们有 11 种默认的模板类型(你可以从 GitHub 安装额外的模板),针对多种项目类型,还有一个创建包含依赖文件夹中项目的 .sln 文件以及与配置和 Web/ASP.NET 解决方案相关的不同选项的解决方案选项。
要测试这个最新版本,在命令提示符中,创建一个新的文件夹,进入它,然后只需输入一个 dotnet new console 命令。这将创建两个文件,定义了 .NET Core 的最简单应用程序(控制台应用程序)。
在那一刻,你应该会看到一个 program.cs 文件和一个 [NameOfDirectory].csproj 文件,其中包含你的应用程序的依赖信息。
注意,之前的版本使用了一个 project.json 文件,其中包含相同的信息,但如果你用 V. Studio 2017 打开任何之前的项目,它将识别它并自动迁移。
.csproj 文件的内容包含一些基本的 XML 格式指令。
为了在这个初始演示中使用非常基本的资源,我将使用 Notepad++ 进行代码着色和一些其他编辑功能。
你应该在 .csproj 文件中看到以下内容:

如你所见,这表明我们正在使用 .NET SDK,输出是一个 exe 文件,并且我们针对的是 NET Core 2.0。
我们的 program.cs 文件的内容如预期(与典型的控制台应用程序在经典 .NET Framework 中没有变化):
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
}
}
但是,我们仍然需要下载依赖项,这是一个使用 dotnet restore 命令在所有其他选项之前执行的任务。发出该命令后,你会看到它下载所有必需的包,并且会出现一个新的 obj 目录。
就这样。你最终可以发出 dotnet run 命令,该命令会编译并执行应用程序,在控制台中显示预期的消息(没有新内容,我省略了输出)。
转换到 ASP .NET Core
现在,要将我们的应用程序更改为 ASP.NET Core 应用程序,首先要做的事情是安装名为 Microsoft.AspNetCore 的包。我们可以通过发出 dotnet add package Microsoft.AspNetCore 命令来完成此操作。
当我们这样做时,命令行工具将下载适当的包并相应地修改我们的 .csproj 文件,因此它也被包含在我们的解决方案中(在发出命令后查看 csproj 的新版本):

我们看到一个新的 <ItemGroup> 标签的存在,它指示要包含的引用及其已下载并添加到项目中的版本。
现在,我们已经准备好创建我们的网站了,它将以一个名为 Startup.cs 的类的形式出现(当然,只要你稍后进行配置,你可以将其命名为你想要的任何名称。这不是一个约定名称)。
在那个文件中,我们将引用创建网站所需的三个额外命名空间(尽管是一个基本的网站):
-
Microsoft.AspNetCore.Builder:使用我们定义的config参数实际构建 Web 服务器 -
Microsoft.AspNetCore.Hosting:用于持有 Web 应用程序 -
Microsoft.AspNetCore.Http:用于所有 HTTP 相关活动
有这些引用后,我们需要添加一个名为 Configure 的方法(这是通过约定),我们将指示服务器启动时需要执行的最小操作。
在这里,我们将开始看到依赖注入(DI)的实际应用,因为这个非常基本的方法的形状如下:
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
namespace WebApplication1
{
public class Startup
{
public void Configure(IApplicationBuilder app,
IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.Run(async (context) =>
{
await context.Response.WriteAsync("This is a first
web app...");
});
}
}
}
但是,在我们开始解释该文件的内部细节之前,让我们确保它能够正确编译,并且将我们的应用程序重定向到新的网站。
因此,我们将发出另一个 dotnet restore 命令,以便所有新的引用都正确定位,下一步将是修改我们的主入口点,创建一个新的使用刚刚创建的 Startup 类的 Web 宿主。
为了这个目的,新的 Main() 入口点将使用对 Microsoft.AspNetCore.Hosting 命名空间的引用,并定义以下内容:
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
namespace WebApplication1
{
public class Program
{
public static void Main(string[] args)
{
BuildWebHost(args).Run();
}
public static IWebHost BuildWebHost(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>()
.Build();
}
}
最后,我们可以重复执行 dotnet run 命令,我们将看到两个不同的输出。在一侧的 CLI 环境中,我们将生成一个 Web 宿主并运行它(默认情况下,使用端口号 5000),在控制台输出中指示:

该宿主将持续运行并监听该端口,直到我们使用 Ctrl + C 停止它。现在,我们可以打开浏览器并输入 URL 以查看以下页面:

当然,那个页面不包含任何 HTML,只有我们命令服务器在接收到该端口的请求时发送给用户的文本。如果你想检查这一点,请查看源代码。
但是,在我们继续解释前面的代码之前,理解中间件的概念非常重要,它从 ASP.NET Core 一开始就存在。
中间件
当然,中间件指的是软件,但是指使用应用程序管道组装的软件,这种方式便于处理请求和响应。
显然,你可能想知道应用程序管道是什么。维基百科这样定义这些术语:“在软件工程中,管道由一系列处理元素(进程、线程、协程、函数等)组成,排列得使得每个元素的输出是下一个元素的输入;名称是类比物理管道”。
这些管道在许多软件语言中都很常见,甚至在现代构建中,如 JavaScript Promises,它们定义了异步管道以进行一系列调用,从而提高了执行控制。
管道的一个重要特性是,管道中的每个组件都可以决定是否将信息传递给下一个组件或直接返回,并且能够在调用该组件前后执行自定义操作。
在 ASP.NET Core 中,为了构建请求管道,我们使用委托来处理每个请求。而且,再次发现这个架构中存在一些 SOLID 原则。
每次需要配置这些委托之一时,你都会使用属于 Use*、Run* 和 Map* 方法家族的一个方法(它们是以这些前缀开始的预定义扩展方法集,每个都有特定的用途)。
这些方法与 Configure 方法通过依赖注入接收到的 IApplicationBuilder 对象相关联。以下图表直观地解释了这种结构(注意执行线程遵循黑色箭头):

以这种方式,程序员完全负责继续将 IApplicationBuilder 对象传递给另一个中间件组件(注意对 next() 的调用)或避免任何其他调用,在这种情况下,它将返回到调用方法。
如果这些委托中的任何一个决定不将请求传递给下一个委托,那么就称为短路请求管道。这通常很方便,可以避免执行任何不必要的操作。
在异常处理委托的情况下,它们需要在管道的早期调用,以便它们可以捕获可能在序列中稍后发生的异常。
现在,让我们带着这些想法来解释之前的代码。
Startup 类和依赖注入
因此,之前编写的 Startup 类是负责配置请求管道并处理所有发送到应用程序的请求的组件。
这个类是强制性的(尽管,如前所述,它可以有不同的名称),因为运行时会查找它内部的配置方面负责的方法,这包括服务。你可以将其视为通过第一个 SOLID 原则(SoC)实现独立配置的一种便捷方式。
与这种责任分离相关的有趣特性之一是,你可以根据环境(开发、生产等)定义不同的 Startup 类。适当的类将在运行时被选中。
这个类是以接受通过依赖注入提供的依赖项的方式定义的。例如,你可以声明 IHostingEnvironment 或 ILoggerFactory 类型的变量,以获得配置与托管或日志相关的不同功能的能力(记住在第三章中介绍 .Net Core 2.0 中的依赖注入)。
那么,在这个类中什么是必需的,什么是可选的?Configure 方法是必需的。无论类的名称是什么,运行时都会在其内部查找它,并调用它以确保应用所需的条件。
然而,ConfigureServices() 方法是可选的,但如果它存在,它将在初始过程中在 Configure() 之前被调用。以下图示说明了这个顺序:

(图片来源:developer.telerik.com/featured/understanding-asp-net-core-initialization/)
在进一步进行演示之前,让我们更详细地解释之前的代码。
代码解释
从 Main() 方法开始,在提到之前提到的 Hosting 子空间之后,我们通过调用 WebHostBuilder 类来构建一个新的网络服务器。这个类允许使用各种中间件组件和入口点条件来配置和构建网络服务器。
因此,在调用构造函数之后,我们管道了另外三个调用:
-
一个
UseKestrel()方法,这是 Visual Studio 2017(以及 CLI)使用的默认轻量级开发服务器(我们稍后会解释这一点) -
另一次调用
UseStartup<Startup>(),以指示服务器将在何处找到Configure()方法来启动其进程(记住类的名称无关紧要,只要它包含Kestrel初始查找的方法即可) -
最后,还有一个
Build()方法,它使用之前设置的值创建并初始化新的服务器
在所有这些准备就绪之后,最后一句话只是调用了 Run() 方法来启动进程。
在启动该进程时,Configure 方法被激活。它的唯一参数(在这种情况下)是 IApplicationBuilder 类型,并且,正如你所看到的,它通过依赖注入(我们的代码没有进行任何先前的引用或实例化)传递给这个方法。
因此,当创建服务器时,在通信过程中隐含的主要基本对象以这种方式提供服务,期望用户以后续调用其方法的形式提供所有所需的行为。
如果我们查看接口定义,这相当直观:

当Configure方法接收到IApplicationBuilder类的实例时,将提供一些额外的功能。如前所述,这些功能是通过扩展方法提供的,采用Use*、Run*和Map*方法的形式,这些方法帮助程序员在编码配置方面获得更多独立性和粒度。
注意以下截图如何显示不同的配置选项并建议使用中间件:

每种扩展方法都提供了一种调用隐式委托的方式。请注意,虽然Use*方法族在管道中隐式地多次调用Next()以继续进行(实际上这取决于其他功能),但Run*方法族停止了传播并短路了管道(因此,它不会调用下一个请求委托)。
此外,Map*方法族允许分支管道,执行返回该点的调用并相应地扩展功能。
新的 ASP.NET 服务器
让我们快速回顾一下在为 ASP.NET Core 编程时使用的服务器的一些重要方面,因为这是与这个新平台相关的主要变化之一。
首先,一个 ASP.NET Core 应用程序运行一个进程内的 HTTP 服务器实现。该实现正在监听 HTTP 请求,并将这些请求发送到名为HttpContext的对象中,该对象包含一组组合到其中的功能。
这个版本的 ASP.NET 提供了两种不同的服务器实现:Kestrel和WebListener。正如官方文档提醒我们的:
Kestrel
Kestrel是一个基于libuv的跨平台 HTTP 服务器,libuv是一个跨平台的异步 I/O 库:
libuv被定义为一种多平台支持库,专注于异步 I/O。它最初是为 Node.js 开发的,但也被 Luvit、Julia、pyuv 和其他使用。
Kestrel 是包含在 ASP.NET 项目模板中的默认 Web 服务器。优点是,如果您的应用程序仅从内部网络接受请求,则可以单独使用它。
这是 Kestrel 默认场景的工作方案:

(图片来源:docs.microsoft.com/en-us/aspnet/core/fundamentals/servers/)
然而,如果您将应用程序暴露给互联网,将存在一些 Kestrel 未准备好应对的安全问题(它相对较新,并且还没有整个所需的安全资源集)。对于这些情况,建议的配置是使用反向代理服务器,例如 IIS、Apache 或 Nginx,以提供功能。
注意,正如文档所述,反向代理服务器从互联网接收 HTTP 请求,并在进行一些初步处理后将其转发到 Kestrel(请参阅以下截图):

(图片来源:docs.microsoft.com/en-us/aspnet/core/fundamentals/servers/)
另一个重要的一点是,没有 Kestrel 或自定义服务器实现,您不能使用任何那些反向代理服务器。这是因为 ASP.NET Core 被设计为在其自己的进程中运行,以便它可以在各个平台上保持一致的行为。
我们可能面临的问题是我们可能遇到的是 IIS、Nginx 和 Apache 规定了它们自己的启动过程和环境。结果是,为了直接使用它们,应该适应每个的要求。
这样,Kestrel 赋予了 ASP.NET Core 以任何所需形式编码Program和Startup类的功能,以满足用户的需求,同时避免对具体、特定服务器的另一个依赖。这也是中间件在这个环境中如此重要的原因之一。
WebListener
WebListener是一个基于Http.Sys内核驱动器的 Windows 专用 HTTP 服务器。它作为那些场景的替代方案,在这些场景中,将我们的应用程序暴露给互联网是强制性的,但我们不希望使用 IIS,如果出于某种原因不能这样做的话。
以下架构表示 WebListener 在类似之前 Kestrel 所展示的场景中的角色:

(图片来源:docs.microsoft.com/en-us/aspnet/core/fundamentals/servers/)
同样,如果您在内部网络中工作需要 Kestrel 不支持的一些功能,您可以使用 WebListener 进行相当类似的配置:

(图片来源:docs.microsoft.com/en-us/aspnet/core/fundamentals/servers/)
最后,请记住,对于内部网络场景,Kestrel 是推荐的,因为它提供了改进的性能。无论如何,如果您想了解更多关于 WebListener 提供的功能,官方文档可在docs.microsoft.com/en-us/aspnet/core/fundamentals/servers/weblistener找到。
架构中的依赖倒置 - OWIN
还有可能创建自己的服务器实现,以与 ASP.NET Core 一起工作。.NET 开放 Web 接口(OWIN)是平台支持的第三种可能的实现。
在某些方面,OWIN 的实现也与依赖倒置原则相关。其主要目标是使 Web 应用程序与 Web 服务器解耦。
为了这个目的,它定义了创建中间件的标准方式,这些中间件可以在管道中使用,以配置和调整您的服务器。
分析默认模板
现在是时候打开 Visual Studio 2017(或安装了 ASP.NET Core 的 2015 版本)并查看它,并解释默认模板是如何工作的。
记住,ASP.NET Core 也可以使用经典的.NET Framework 执行,因此,当你创建一个新的 Web 项目时,你最初会被要求在三个主要选项之间进行选择:经典 ASP.NET、带有.NET Core 的 ASP.NET Core 和带有经典.NET Framework 的 ASP.NET Core:

一旦选择,你将看到一个额外的选择窗口:空、Web API、Web 应用程序、Web 应用程序(模型-视图-控制器),以及为第三方库添加到 2.0 版本的一些新选项,包括 Angular、React.js 和 React.js 与 Redux。
在第一种情况下,我们现在使用的是创建一个具有最小配置的应用程序,以便能够使用 Kestrel 创建和运行一个 Web 服务器,并在浏览器中显示一些文本。它很简单,但允许我们更详细地了解它是如何完成的,并做出一些更改。
其他三个(Web API、Web 应用程序和 Web App MVC)与经典 ASP.NET 中的对应项相似,不同之处在于它们使用新的架构和配置文件。这样,我们将能够更好地欣赏从旧架构迁移到新架构所需的迁移过程。
你应该看到以下对话框:

注意,你也可以像以前版本一样更改身份验证,并且有一个复选框允许启用 Docker 支持。
结果项目比我们之前做的基本演示要复杂一些,尽管基本组件是相同的。然而,在配置方面有一些显著的变化。
配置文件
一旦编译了应用程序并激活了解决方案资源管理器中的“查看所有文件”选项,你将注意到一些额外的配置文件,它们负责一些任务,例如在默认 URL 上启动浏览器。这些选项定义在Properties目录中可用的launchSettings.json文件内。
看一看也是相当说明性的:
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:57539/",
"sslPort": 0
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"ASPNETCoreDemo1": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:57540"
}
}
如你所见,这里应用了三个主要的配置区域:iisSettings,用于指示 IIS 行为,包括要使用的 URL,一个只包含一个IISExpress配置文件的profiles部分,表示应该启动一个浏览器,以及关于开发模式的提示,以及一个名为应用程序本身(ASPNETCoreDemo1)的最终配置,包含类似的信息。
当然,如果你深入到\bin或\obj目录,你会看到更多,例如带有额外信息的ASPNETCoreDemo1.runtimeconfig.json。最后,如果你检查.csproj文件,你也会看到一些添加项:
记住,在解决方案资源管理器的项目上下文菜单选项中,你现在有一个选项可以直接在 Visual Studio 2017 中打开它。
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp1.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include= "Microsoft.ApplicationInsights.AspNetCore"
Version="2.0.0" />
<PackageReference Include="Microsoft.AspNetCore" Version="1.1.1" />
</ItemGroup>
</Project>
没有太多变化,但现在它表明了使用wwwroot文件夹,并添加了ApplicationInsights调用。显然,没有文件类型指示,因为默认情况下,编译的组件是一个 DLL。
入口点
让我们从program.cs开始。它的main()方法类似,但它包含新的中间件:
public static void Main(string[] args)
{
var host = new WebHostBuilder()
.UseKestrel()
.UseContentRoot(Directory.GetCurrentDirectory())
.UseIISIntegration()
.UseStartup<Startup>()
.UseApplicationInsights()
.Build();
host.Run();
}
三大主要区别是:UseContentRoot()、UseIISIntegration()和UseApplicationInsights()。
UseContentRoot(Directory.GetCurrentDirectory())表示,当用户请求物理资源时,将搜索该目录。默认情况下,它将指向wwwroot目录。
UseIISIntegration()用于指示将使用 IIS 作为反向代理(如我们之前提到的),而UseApplicationInsights()则有助于监控和审计您的应用程序。
正如官方文档所述(github.com/Microsoft/ApplicationInsights-aspnetcore/wiki/Getting-Started-with-Application-Insights-for-ASP.NET-Core),它允许我们使用 Visual Studio Application Insights 监控您的实时 ASP.NET Core 应用程序。Application Insights 是一个可扩展的分析平台,它监控您的实时 Web 应用程序的性能和用法。通过您从应用程序在野外的性能和有效性获得的反馈,您可以在每个开发生命周期中做出明智的设计方向选择。
因此,我们有一个通过新的中间件加强的入口点,因此我们可以从开始使用 DI。让我们看看Startup类(配置)中它做了什么。
默认的 Startup 类
首先要注意的是ConfigureServices的存在(即使它是空的)。如图所示,它允许向我们的管道添加不同的服务,并将它们存储在services集合中。这将是我们注册我们自己的服务的地方。
还要注意,其中一些方法已经准备好添加具有不同生命周期配置的服务(AddSingleton、AddScoped和AddTransient)。稍后,我们将看到如何在此处添加服务,以便应用程序可以使用给定的功能,例如数据库访问等,使用它通过依赖注入接收到的IServiceCollection对象(请参阅以下截图):

关于Configure()方法,这次它通过依赖注入(当然,当然是通过依赖注入),接收了三个类型的实例:IApplicationBuilder、IHostingEnvironment和ILoggerFactory,如下代码所示:
// This method gets called by the runtime. Use this method to //
// configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app,
IHostingEnvironment env, ILoggerFactory loggerFactory)
{
loggerFactory.AddConsole();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.Run(async (context) =>
{
await context.Response.WriteAsync("Hello World!");
});
}
第一个在应用程序结束时使用,用于启动应用程序,这次使用async/await结构来保证正式的异步调用,返回一个字符串。
ILoggerFactory 的使用方式与我们在第三章,“.NET Core 2.0 中的依赖注入介绍”中所做的方式相似,用于配置将输出到控制台的消息。
最后,IHostingEnvironment 变量用于检查应用程序是否处于开发模式,如果是这样,则配置一个开发者异常页面,我们可以在其他地方定义它。
ASP.NET Core 区分了四种可能的开发模式:development、production、staging 和一个第四个 environment 模式,允许定义自己的模式。该值可以在 Project/Properties/Debug 窗口中配置,您现在可以添加不同的环境变量,就像您在下面的屏幕截图中所看到的那样:

这些值由 IHostingEnvironment 对象读取,允许在应用程序启动过程之前插入操作。请注意,它不是一个单一值,而是一个集合,因此您可以添加所需的环境值,并使用该对象的方法来读取其中的一些是否为真。
您也可以使用类似 ASPNETCORE_ENVIRONMENT="MyCompany" 这样的方式来自定义并轻松检查这个值,就像您在下面的屏幕截图中所看到的那样,使用 IsEnvironment() 方法。

因此,如果我们将之前的 development 值更改为一个自定义值,例如 PACKT,我们可以在浏览器中用以下方式修改退出:
app.Run(async (context) =>
{
if (env.IsEnvironment("Packt"))
{
await context.Response.WriteAsync("We're in PACKT
development mode!");
}
else await context.Response.WriteAsync("Hello World!");
});
在这种情况下,输出将不同,因此我们可以自由配置任何内容,并将其与其他值混合,以获得完全定制、模式依赖的体验:

但是,关于依赖注入还有很多内容,我们将在接下来的几节中看到。
ASP.NET Core 中的依赖注入
当然,这种行为得益于 ASP.NET Core 引擎内部存在一个依赖注入容器。官方文档非常清楚地说明了这一点:如果给定类型已声明它有依赖项,并且容器已配置为提供依赖类型,它将在创建请求的实例时创建这些依赖项。
以这种方式,容器管理一个对象的生命周期,避免了硬编码对象构造的需要。
除了其他内置实现之外,请记住 ASP.NET Core 提供了一个简单的依赖注入容器(我们在第三章,“.Net Core 2.0 中的依赖注入介绍”中已经测试过),它由 IServiceProvider 接口表示。
正如我们所提到的,在这个平台上使用该接口配置服务的位置是 ConfigureServices 方法,我们将在下一节中分析它。
ASP.NET Core 提供的服务
通过依赖注入,ASP.NET Core 内部提供了相当多的服务。以下表格显示了这些服务及其生命周期的指示:
| 服务类型 | 生命周期 |
|---|---|
Microsoft.AspNetCore.Hosting.IHostingEnvironment |
单例 |
Microsoft.Extensions.Logging.ILoggerFactory |
单例 |
Microsoft.Extensions.Logging.ILogger<T> |
单例 |
Microsoft.AspNetCore.Hosting.Builder.IApplicationBuilderFactory |
请求范围 |
Microsoft.AspNetCore.Http.IHttpContextFactory |
请求范围 |
Microsoft.Extensions.Options.IOptions<T> |
单例 |
System.Diagnostics.DiagnosticSource |
单例 |
System.Diagnostics.DiagnosticListener |
单例 |
Microsoft.AspNetCore.Hosting.IStartupFilter |
请求范围 |
Microsoft.Extensions.ObjectPool.ObjectPoolProvider |
单例 |
Microsoft.Extensions.Options.IConfigureOptions<T> |
请求范围 |
Microsoft.AspNetCore.Hosting.Server.IServer |
单例 |
Microsoft.AspNetCore.Hosting.IStartup |
单例 |
Microsoft.AspNetCore.Hosting.IApplicationLifetime |
单例 |
如你所见,这是一个相当全面的选项列表,我们必须添加通过 Run*, Use* 和 Map* 方法注释中提到的“已安装”和“可用”的功能。
因此,我们可以区分两种方法类型,在这里:那些已经可用且可以随意包含的方法(在 Program/Main 中显示的),以及那些你可以自定义的方法(使用 Startup 类),无论是通过添加还是通过创建自己的类和接口并将它们添加到 ConfigureServices 初始过程中。
启动时可用服务的关联
总结来说,我们有几种方式可以通过依赖注入在 ASP.NET Core 2.0 中通过 Startup 类包含功能:
-
通过为 Startup 类创建自己的构造函数,该构造函数引用先前定义的映射接口 => 类
-
通过引用
ConfigureServices方法中所需的服务 -
通过使用我们看到的
Configure()方法的方式
如果你按照执行顺序考虑 Startup 的方法,以下服务可供使用:
-
构造函数:
IHostingEnvironment,ILoggerFactory -
* 服务配置:IServiceCollection -
配置:
IApplicationBuilder,IHostingEnvironment,ILoggerFactory,IApplicationLifetime
在 Web 应用程序模板中识别服务
Web 应用程序模板在展示服务和依赖注入的角色方面更为明显,因此我们将创建一个与经典 ASP.NET MVC 5 相当的项目,以便在经典 .NET Framework 中进行比较,从而识别这些功能。
一旦创建了一个这样的新项目,你可能会发现许多在先前版本中存在的元素:用于控制器和视图的文件夹,定义应用程序中不同视图的 Razor 文件等。
但是,现在我们已经看到了一些主要的变化,对关键文件的回顾是非常有启发性的。在 Program/Main 中没有变化,但我们在 Startup 类中会发现很多。引用(using 语句)与基本演示相同,所以我在这里省略了代码。
最有趣的部分在 Startup 类本身:
public class Startup
{
public Startup(IHostingEnvironment env)
{
var builder = new ConfigurationBuilder()
.SetBasePath(env.ContentRootPath)
.AddJsonFile("appsettings.json", optional: false,
reloadOnChange: true)
.AddJsonFile($"appsettings.{env.EnvironmentName}.json",
optional: true)
.AddEnvironmentVariables();
Configuration = builder.Build();
}
public IConfigurationRoot Configuration { get; }
// This method gets called by the runtime. Use this method to
// add services to the container.
public void ConfigureServices(IServiceCollection services)
{
// Add framework services.
services.AddMvc();
}
到目前为止,默认模板只初始化 MVC 引擎,将其视为一个额外的服务,对用户来说完全是可选的。
接下来,我们将看到如何使用此方法注册和配置其他内置或自定义服务:
// This method gets called by the runtime. Use this method to
// configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app,
IHostingEnvironment env, ILoggerFactory loggerFactory)
{
loggerFactory.AddConsole(Configuration.GetSection("Logging"));
loggerFactory.AddDebug();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseBrowserLink();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
app.UseStaticFiles();
app.UseMvc(routes =>
{
routes.MapRoute( name: "default",
template:{controller=Home}/{action=Index}/{id?}");
});
}
首先,我们现在有一个构造函数和一个只读私有属性(Configuration),其类型为 IConfigurationRoot。嗯,在构造函数末尾调用的 builder.Build() 方法就是这种类型,并提供了一种方便的方式来包含和访问从多个 .json 文件中加载的所有配置信息。
所有这些信息都在初始化过程的非常开始时收集,所以任何需要从外部读取的内容都在 ConfigureServices 和 Configure 调用之前准备好(参见以下截图显示加载后的 Configuration 值):

正如你所见,ConfigurationBuilder 类在构造函数的第一句话中被创建,并负责加载和读取所有这些 .json 文件的内容,并添加环境变量。这允许进一步访问任何 json 定义的值,以及使用外部信息调整应用程序的能力。
收集了所有这些信息后,ConfigureServices 方法出现并注册了一组服务,例如 MVC,通过添加 UseMvc 调用来实现(是的,它默认不可用,如果我们想使用该架构,必须显式将其添加到管道中)。
如你在这些示例中看到的,这个 ASP.NET 中间件,如 MVC,遵循使用单个 Add*ServiceName* 扩展方法来注册该功能所需的所有服务的约定。注意,这个调用只将服务添加到服务集合中,但它并没有配置它(这将在稍后进行)。
然后,Configure() 方法出现。它首先从 Configuration 对象(Logging 部分)中恢复信息,并添加了我们之前章节中看到的方式的调试功能。
接着是错误处理,通过检查我们是否处于开发模式或其他模式(它还启用了 BrowserLink 功能),然后继续调用 UseStaticFiles(),允许恢复和提供本地文件(正如你所猜到的,它也默认不可用)。
注意,这个功能可以被配置为指向服务器上的其他位置(相对请求路径):

最后一步是通过调用 UseMvc() 配置 MVC 路由,其中提供了路由的配置。注意,与传统的 ASP.NET MVC 4/5 相比,您会在不同的文件中注册这些路由,这种方法是如何不同的。然而,语法是相似的,只是这次您通过 lambda 表达式定义它。
如您从代码中可以推断出的,第一个 lambda 参数是由依赖注入提供的,并且其类型为 IRootBuilder。查看 IDE 提供的 Intellisense 信息以检查此功能,如下面的截图所示:

然后,默认路由被配置为指向 HomeController 类和 Index 动作方法,如果没有其他组合由请求提供(语法也略有简化)。
因此,我们通过 Startup 类的这次旅行得出的结论是,ASP.NET Core 及其 MVC 变体的最重要的架构方面是通过依赖注入提供的,并且开发者有责任以这种方式调整他们的应用程序,以便只包含/排除应用程序需要的部分,以最小化过载。
在 ASP.NET MVC 视图中使用依赖注入
史蒂夫·史密斯提出一个演示(ardalis.com/how-to-list-all-services-available-to-an-asp-net-core-app),它可以阐明在特定时刻可用的服务总数。
这给了我一个关于如何使用 MVC 获取所有可用服务的列表的另一个演示的想法,即在视图中包含 Microsoft.Extensions.DependencyInjection 命名空间。让我们从我们刚刚分析的默认模板开始,并进行适当的修改。
首先,如果我们想在 Web 应用程序模板的主菜单中集成列表作为新的选项,我们需要在 _Layout.cshtml 文件中添加一个新的链接,指向将显示所有服务的相应视图。这相当直接(注意,这里没有像上一个版本中的 ActionLinks):

通过这一新行,我们创建了一个新的应用程序菜单元素和相应的视图(稍后命名为 ServicesAvailable),它将利用在 _Layout.cshtml 标题中加载的 Bootstrap 类来格式化输出并使其更易于阅读。
现在,如果我们考虑控制器(在这个例子中是 HomeController),我们可以添加一个新的动作方法,遵循其他方法的语法,使用 ViewData 对象将所需信息传递给我们的新视图。
我们需要的信息存储在 Startup.cs 中定义的 IServiceCollection 对象的实例中,我们希望使其对控制器可用,以便我们可以在以后将其分配给我们的 ViewData 传递者。
让我们回顾一下Startup,并进行一些更改(不多)。实际上,只需要以下这些:
public static IServiceCollection _services { get; private set; }
public void ConfigureServices(IServiceCollection services)
{
// Add framework services.
services.AddMvc();
_services = services;
}
我们创建一个公共静态变量_services来保存所有服务,一旦配置完成,就将services变量分配给它,其唯一目的是从控制器中访问其内容。
现在,回到HomeController,我们可以添加一个新的控制器,代码如下:
public IActionResult ServicesAvailable()
{
ViewData["Services"] = Startup._services;
return View();
}
通过这几行代码,我们的服务现在在控制器中可用,并且我们可以以非常简单的方式将它们传递给视图(注意这里没有数据模型,因为它只是一个简单的数据集合,而ViewData对象正好可以满足这个目的)。
最后一步将是添加ServicesAvailable视图。代码如下(我将从头部的解释开始):
@using Microsoft.Extensions.DependencyInjection;
@{
ViewData["Title"] = "Services Available";
var _services = @ViewData["Services"];
}
<h2>@ViewData["Title"]</h2>
<h1>All Services</h1>
首先,回想一下,当我们在一个视图中引用一个命名空间时,using语句不应属于一个代码块。相反,它将是一个以@符号为首的独立句子(我们需要DependencyInjection命名空间,以便将传递到ViewData对象中的信息转换为真正的IServiceCollection对象)。
接下来,我们在变量中恢复该信息(该变量将在整个视图中本地可用)。请注意,我使用的是var关键字而不是接口名称,因为否则编译器会报错。这样做更简单,并且可以在代码中稍后进行类型转换。
最后,我们将使用表格来展示IServiceCollection中的三个服务(在视图中称为_services)所保存的一些信息。注意,这里也使用了as运算符进行类型转换,以获取真正的IServiceCollection对象:
<table class="table table-bordered">
<thead>
<tr>
<th>Type</th>
<th>Lifetime</th>
<th>Instance</th>
</tr>
</thead>
<tbody>
@foreach (var svc in _services as IServiceCollection)
{
<tr>
<td>@svc.ServiceType.Name</td>
<td>@svc.Lifetime</td>
<td>@svc.ImplementationType?.Name</td>
</tr>
}
</tbody>
</table>
就这样!现在我们可以启动应用程序并选择新的菜单元素服务列表。一个新视图将显示出来,其中包含一个相当长的服务列表,同时展示了我们如何在视图中使用DependencyInjection命名空间(或任何其他命名空间)(请参见以下截图):

垃圾回收和自定义服务
与垃圾回收行为相关,ASP.NET MVC 自动化了一些操作,并将其他操作留给用户的判断。
主要规则如下:如果您在过程中注册了一个服务并调用了其构造函数,那么您有义务销毁该对象;否则,如果容器负责创建对象,它将调用实现的对象上的Dispose()。
在实践中,想象一下,我们有两个服务(Service1和Service2),它们位于一个文件夹(Services)中,并且这两个服务都实现了Disposable接口。为了简化这部分,我将代码折叠起来,所以我只显示理解这个想法的相关行(请参见以下截图):

行为将根据每个服务的实例化而有所不同(我已经在每个情况下进行了注释,所以你会看到差异):
publicvoidConfigureServices(IServiceCollectionservices)
{ // Add framework services
services.AddMvc();
// container will create the instance(s) of these types
// and will dispose themservices
AddScoped<Service1>();
// Here, the container did not create instance (we do it)
// so it will NOT dispose itservices
AddSingleton(newService2());
}
总的来说,这是一种管理服务垃圾回收相当方便的方法。
通过依赖注入使用自定义服务
使用这种架构,创建属于我们数据模型中的任何类,或者应用的其他任何部分,并将其准备好并可用于依赖注入,都是非常容易的,并且能够带来与之相关的所有优势。
例如,假设我们想在某个视图中添加一些装饰,形式为随机句子(比如关于程序员和软件工程师的句子),并且我们希望包含这些信息的类通过依赖注入被当作服务处理,这样我们就可以在应用的不同视图中使用这些内容。
我们可以将前一个示例中使用的类重命名为ProgrammerSentenceSvc和EngineerSentenceSvc,并添加一些非常简单的信息:一个List<string>的句子列表,稍后将在我们的控制器中使用这些句子随机检索几个句子并将它们传递给相应的视图。
因此,让我们修改我们的服务以保存所需的信息(我只是展示这些截图,以便专注于相关代码):


下一步将是通过ConfigureServices在Startup类中注册这些类,正如我们之前所看到的(我使用两种不同的语法只是为了展示):
publicvoidConfigureServices(IServiceCollectionservices)
{ // Add framework services.
services.AddMvc();
// container will create the instance(s) of these types
// and will dispose them
services.AddScoped<ProgrammerSentenceSvc>();
// Here, the container did not create instance (we do it)
// so it will NOT dispose it
services.AddSingleton(newEngineerSentenceSvc());
}
这就是我们需要的,以便在任何一个控制器中都有我们的服务可用。所以,让我们回顾一下我们的HomeController,并添加以下操作方法(记住,我们必须通过using ASPNETCoreDisposeDemo.Services;来引用我们的服务命名空间):
publicIActionResultSentences(ProgrammerSentenceSvcsvc,
EngineerSentenceSvcsvc2
{
Randomrnd = newRandom();ViewData["ProgSentence"] =
svc.programmersSentences[rnd.Next(1,5)];
ViewData["EngSentence"] = svc2.engineersSentences[rnd.Next(1,5)];
returnView();
}
就这样!以这种方式注册的任何服务都通过依赖注入自动在控制器中可用,只需将其作为相应操作方法的参数引用即可。
最后一步是创建名为Sentences的视图来恢复信息并向用户展示:
@{ViewData["Title"] = "Random sentences about programmers
and engineers";
}
<h1>@ViewData["Title"].</h1>
< hr />
< h1 > Programmer's Sentence </ h1 >
<h2>@ViewData["ProgSentence"]</h2>
< h1 > Engineers' Sentence </ h1 >
<h2>@ViewData["EngSentence"]</h2>
如果我们添加(就像之前一样),在默认菜单旁边添加一个指向此操作方法名称的新链接,我们将看到以下输出:

如您所见,输出是预期的,并且与 MVC 模型相比,应用程序的一般架构与之前的版本相当相似。
服务和数据管理
尽管数据管理不是本书的目标,但我想要提到,当在 ASP.NET Core MVC 应用程序中访问数据时,所提出的架构相对类似。
要测试这个功能并突出与依赖注入直接相关的那部分,请遵循初始说明在 docs.microsoft.com/en-us/ef/core/get-started/aspnetcore/existing-db。这将创建一个使用 SQLLocalDb 的非常简单的数据库,名为 Blogging。它创建了一个 Blogs 表,并添加了三个记录,以便有一些数据可以操作。你还可以找到它提出的完整示例的链接,使用多种方法:数据库优先、新建数据库等。
我想在这里指出的是那些暗示在该解决方案中使用 DI 的代码片段。
在 Models 应用程序文件夹中,你可以找到基于该数据库的模型定义,其方式与使用经典 Entity Framework 搭建现有数据库时的常规结果相似。主要类继承自 DbContext,定义了指向现有实体的公共虚拟属性,并将 DBContext 实例命名为 BloggingContext。
因此,第一步是在 ConfigureServices 方法中注册这个上下文,它执行以下操作:

如你所见,连接字符串被直接定义,以便它指向新创建的数据库(Blogging)。
接下来,通过泛型方法 AddDbContext<BlogginContext> 向服务变量中添加了一个新条目。然后,通过将 Action 委托传递给方法,将那个 DbContext 连接到 Blogging 数据库,并允许它覆盖 BloggingContext 类的 OnConfiguring 方法。这样,实际的连接配置可以延迟到调用此方法时进行。
最后,BloggingController 类是如何访问这个 BloggingContext 的呢?通过构造函数中的依赖注入。它的操作方式与我们之前在示例中做的是类似的:

输出显示了加载到 Blogs 表中的三个条目,正如预期的那样,具有典型的 CRUD 选项,通过 Razor 视图展示信息,但同样有趣的是强调 DI 在这个架构中的作用(见以下截图):

再次,你已经看到了在 .NET Core 相关平台,如 Entity Framework Core 中,DI 的存在。
然而,还有一个我们还没有涉及到的有趣点。我们看到了如何在视图中引用 DependencyInjection Namespace,但我们实际上并没有使用 DI。让我们通过一个简单的示例来看看如何在视图中使用新的 @inject 指令。
在视图中使用依赖注入
为了完整性,我将解释一个简单的示例,说明如何在 Razor 视图中使用 DI。这是一个相当有趣的功能,它可以用来访问数据、服务相关的信息,甚至你自己的 Razor 辅助工具。
想法是创建另一个视图,使其能够访问与之前的Services视图相同的数据,但这次不涉及业务逻辑中的控制器。除此之外,我将创建一个 Razor 辅助器,以展示我们如何通过@inject指令完成这两项任务。
首先,在新的文件夹(Helpers)中,让我们创建一个简单的辅助器,提供某种信息(例如当前系统的时间):
namespaceASPNETCoreDisposeDemo.Helpers{
publicclassDateTimeHelpers
{
publicDateTimeHelpers()
{
LocalTime = DateTime.Now.TimeOfDay.ToString();
}
publicstringLocalTime { get; privateset; }
}
}
在这个新服务到位后,我们需要像对Sentences*服务那样注册它。ConfigureServices方法的新版本将如下所示:
publicvoidConfigureServices(IServiceCollectionservices)
{
// Add framework services.
services.AddMvc();
// container will create the instance(s) of these types
// and will dispose them
services.AddScoped<ProgrammerSentenceSvc>();
// Here, the container did not create instance (we do it)
// so it will NOT dispose it
services.AddSingleton(newEngineerSentenceSvc());
services.AddTransient<DateTimeHelpers>();
}
在业务逻辑方面,我们需要的就这些,因为我们可以使用现有的服务,只是以不同的方式。所以,我将创建另一个名为ServicesDI的动作方法作为HomeController的一部分,并尝试复制之前的功能。
我们还可以使用[Route("key")]属性将 URL 查询重定向到这个动作方法。实际上,这比其他方法还要简单:
[Route("SentencesDI")]publicIActionResultSentencesDI()
{
returnView();
}
其余的编程逻辑将推迟到视图本身。因此,SentencesDI视图将需要引用与它将要使用的服务相关的命名空间,并声明任何所需的服务:
@usingASPNETCoreDisposeDemo.Helpers
@usingASPNETCoreDisposeDemo.Services
@inject DateTimeHelpersTimeHelpers
@inject ProgrammerSentenceSvcPSentences
@inject EngineerSentenceSvcESentences
@{
Randomrnd = newRandom();
ViewData["Title"] = "Random sentences obtained via
Dependency Injection";
}
<h1>@ViewData["Title"]</h1>
<h3>Local Time: @TimeHelpers.LocalTime</h3>
< hr />
< h2 > Programmer's Sentences (DI) </ h2 >
<h3>@PSentences.programmersSentences[rnd.Next(1,5)]</h3>
< h2 > Engineers' Sentences (DI) </ h2 >
<h3>@ESentences.engineersSentences[rnd.Next(1,5)]</h3>
正如你所见,其余的代码相当直观。一旦服务注册并建立引用,我们可以使用@inject指令来指导 DI 容器关于我们的视图将要需要的资源。语法如下:
@inject [Type] [Name/Alias]
这样,任何与注入到视图中的服务相关的功能都可以访问服务内部的数据,而无需在控制器中处理它。你可能会说这某种程度上打破了 MVC 基础的架构,但在某些情况下,如果某些数据仅与特定视图相关,将其从控制器中分离出来可能是有趣的。
并且,你肯定会发现更多有用的可能性。顺便说一句,你可以在浏览器中输入localhost:[port]/ServicesDI来获取相应的输出:

简而言之,这是与依赖注入相关的另一个功能,这次是在 Razor 视图中使用,我们可以在 ASP.NET Core 平台上使用它。
摘要
在本章中,我们专注于 ASP.NET Core 和依赖注入,分析了整个架构和配置过程是基于 ASP.NET Core 内部 DI 容器的。
我们还看到了如何从简单的控制台应用程序迁移到 ASP.NET Core 应用程序,以及如何调整开发和生产环境中不同的服务器。
然后,我们分析了 Visual Studio 2017 为 ASP.NET Core 应用程序提供的主要模板,并回顾了它们如何使用 DI 来配置和管理所需的信息和功能。
最后,我们看到了如何使用我们自己的自定义服务,并通过 DI 将它们集成到 ASP.NET Core 中,无论是在控制器中还是在 Razor 视图中。
在第五章,对象组合,我们将分析对象组合及其在 DI 环境中的应用。
第五章:对象组合
在第四章,“ASP.NET Core 中的依赖注入”,我们了解了.NET Core 和 ASP.NET Core 的依赖注入以及默认 DI 容器。我们探讨了如何将 DI 应用于应用程序的不同组件,如控制器和视图。现在是时候深入探讨依赖注入背后的实际基础了。
在继续讨论主要主题之前,我们首先需要了解为什么我们要关心阅读这个主题。编程世界充满了对象及其交互。我们通过从我们为两个基本原因生成的类中获取帮助来实现某些解决方案或构建功能,这两个基本原因是代码重用和可维护性。
现在,你可能会问我为什么还要创建类!是的,我同意你的看法,除非你让我几天后修改代码。那时,即使是我也无法帮助你,因为那对我来说将是一场噩梦。你知道为什么吗?那是因为我可能不得不在我的新类中重复(你已经写过的)代码。
假设你有一个名为Customer的类,具有CustomerId、FirstName、LastName、Email、MobileNumber、Address1、Address2、City等属性。我介入并开始处理另一个实体,名为Seller,具有SellerId、FirstName、LastName、Email、MobileNumber、Address1、Address2、City等属性。
我认为你足够聪明,能够识别这个问题。我们可以看到那些被斜体标注的属性正在重复。解决这个问题的一个方法就是创建另一个类,比如命名为User的类,包含所有这些公共属性。然后,Customer和Seller类就变成了它的子类。这样,我们将实现一个简单的设计来处理未来的实体。我可以轻松地重用User类来创建其他子类,如果需要的话,这些子类将继承公共属性。
因此,这一章在帮助你掌握这些关系方面起着重要的作用。设计一个由更小、更灵活、可重用的类组成的复杂类是软件项目的一个基本部分,这将是本章的重点。以下是我们将要涵盖的关键主题:
-
关系
-
组合
-
聚合
-
关联
-
继承
-
组合优于继承
-
对象组合在依赖注入中的重要性
-
.NET Core 2.0 控制台和 MVC 应用中的对象组合
理解对象关系
让我们先通过考虑人类关系来尝试理解对象关系。这些例子可能不是最恰当的,但如果我们要学习对象关系,为什么不看看它呢?
-
你在你找到工作之前依赖你的父母
-
你有一个妻子和两个孩子
-
树上长着花朵和叶子
-
主板是计算机的一部分
所有这些关系都具有独特的特征。让我简化我的说法。你依赖于你的父母。然而,如果他们失业了,你不会死去。你会找到某种方法来应对这种情况。相反,如果一棵树死了,它的花朵和叶子最终也会死去。树与其部分之间的关系是紧密耦合的。一旦树获得生命(实例化),在很短的时间内,其部分也会苏醒。没有电脑的主板是没有用的。它只有在成为电脑的组成部分时才会发挥作用。
让我们考虑以下代码片段来理解类之间的关系:
class Organisation
{
public Organisation() { }
public string Name { get; set; }
public string OfficialFacebookLink { get; set; }
}
class Packt : Organisation
{
public Packt() { }
public int TotalBooksPublished { get; set; }
public void PrintPacktInfo()
{
Console.WriteLine($"This is {Name}!\n" +
$"Our official facebook page link is
{OfficialFacebookLink}.\n" +
$"We have published {TotalBooksPublished} books.\n");
Account account = new Account();
account.PrintAcountInfo(1, "Packt Account");
}
}
public class Account
{
public int AccountId { get; set; }
public string AccountName { get; set; }
public void PrintAcountInfo(int accId, string accName)
{
Console.WriteLine("Account Id: " + accId + "
and Account Name: " + accName);
}
}
我们有一个Organisation类,而Packt作为一个组织,从父类Organisation派生。这种关系表示为“是一个”关系,因为Packt是一个组织。Account是一个类,它可以成为Packt类的一部分。因此,Packt和Account之间存在另一种关系。这种关系的名称是“一部分”。**
注意Packt类内部的PrintPacktInfo()方法,它打印有关Packt的所有信息。然而,这还不是全部,因为你可以看到,在该方法内部生成了一个Account类实例,通过它我们能够打印出Packt的账户信息。
Main方法如下所示,我们创建一个Packt实例,并通过属性提供任何必要的详细信息,然后调用PrintPacktInfo():
static void Main(string[] args)
{
Packt packt = new Packt
{
Name = "Packt Publications",
OfficialFacebookLink = "https://www.facebook.com/PacktPub/",
TotalBooksPublished = 5000
};
packt.PrintPacktInfo(); // Prints the Account information.
Console.ReadKey();
}
代码产生以下输出:

从这个例子中,我们应该吸取的重要信息是Packt类对Account类的依赖方式。依赖是在Packt类内部生成的。
就像前面的例子一样,我们可以在编程中找到模式、关系和层次结构。让我们更详细地调查这些内容,并学习如何提高代码的可重用性和类的弹性。
对象组合
当我在谷歌搜索“词组成”时,首先看到的是:
某物成分或构成的本质;整体或混合物的构成方式。
现在很容易猜测对象组合是什么。对象集体混合自身以构成(成为)复杂对象的一部分。
一个简单的现实生活例子就是一辆车,其整个车身由不同类型的组件组成,如发动机、刹车、齿轮、电池、车门等。因此,这些部件实际上是汽车的构建块,并且由汽车制造商以非常创新的方式组合在一起。
同样,正如我们之前讨论的,上一节中Packt类方法内部的Account类引用,这在其之间生成了一种关系。我们可以将其视为一种依赖关系,因为我们不能在没有Account类实例的情况下执行Packt类的函数。显然,我们可以这样说,Packt对象通过Account实例的帮助来组合自身。
你是否注意到了第一段中括号内的短语是一部分?困惑!让我们再次讨论并掌握这个短语。换句话说,这个短语也可以表示为有,如果我从一个复杂对象的角度来构建这个句子。参考下面的行。
-
一台计算机有一个键盘。(键盘是计算机的一部分)
-
一辆车有一个引擎。(引擎是车的一部分)
现在已经很清楚了,正如你所见,这些复杂对象是如何由小对象组成,这些小对象就是对象组合的概念。
如你所知,不同的基本数据类型,如int、string、布尔值等,或者其他的类类型,可以被包装到一个结构或另一个类类型中,因此,类通常被认为是组合类型。
将这个概念应用到你的项目中最重要的好处是获得更容易管理的部分。这不仅减少了应用程序的复杂性,还帮助我们更快地编写代码。另一个明显的优势是代码重用,这导致错误更少,因为你将使用经过测试后已经验证的代码。
对象组合类型
对象组合有两种子类型,组合和聚合。让我们逐一讨论。
组合
组合是一种将对象绑定在一起的方式。一个对象可以包含另一个对象,无论是同一类还是另一类,作为构建块。
换句话说,在我们上一个例子中,Packt依赖于Account类来运行。实例是由Packt类创建的,给它生命,然后使用实例执行一些功能。你可以添加另一个类如Packt,并使用Account实例做同样的事情。因此,你试图组合对象以形成一个更复杂的对象,这使得我们可以通过一个复合对象执行所有组合/部分对象的行为(方法)。
以下是由对象及其成员或部分满足的关系,符合组合的条件。
-
部分(成员)是对象(类)的组成部分:正如我们已经讨论过的,部分或较小的类应该是较大复杂类的组成部分。例如,你的肾脏是你的身体的一部分。
-
部分(成员)一次只能属于一个对象(类):如果较小的类在某个时期被引用到复杂类中,那么它不能同时是其他类的部分。例如,你的肾脏,它是你身体的一部分,不能同时是其他人身体的一部分。
-
部分(成员)的存在由对象(类)管理:对象负责组合关系中部分的存在。简单来说,部分在对象创建时被创建,在对象销毁时被销毁。这意味着对象以这种方式管理部分的生命周期,使得对象的使用者不需要介入。例如,当身体被创建时,肾脏也会被创建。当一个人的身体被销毁时,他们的肾脏也会被销毁。正因为如此,组合有时被称为死亡关系。
-
部分(成员)不知道对象(类)的存在:组合中的特定部分不知道整个对象的存在。你的肾脏不知道它是更大结构的一部分,但按预期工作。这被称为单向关系。例如,身体知道肾脏,但反之则不然。
如果你认为身体部分可以转移,那么成员类为什么不能,那么你的想法是对的。成员类也可以被转移。因此,新的更大的类现在成为了成员类的所有者,而成员类不再与之前的所有者相关,除非再次转移。
考虑一个例子
我们最喜欢的Student类:
public class Student
{
private int Id { get; set; }
private string Name { get; set; }
private DateTime Dob { get; set; }
private Address Address { get; set; }
private ICollection<Book> Books { get; set; }
public void PrintStudent()
{
Console.WriteLine("Student: " + Name);
Console.WriteLine("City: " + Address.City + "");
Console.WriteLine("-----------------------");
}
}
来吧,别这么惊讶,现在也请不要责怪我。我知道这看起来像是一个非常基础的课程,但这就是组合的本质,简而言之。你不信?好吧,让我用这个Student类来匹配这些关系:
-
规则 1:复杂类的一部分:你可以看到类成员具有不同的类型,例如
Integer、string、DateTime、Class和List<Class>类型。Integer、string和DateTime是.NET Framework 中System命名空间内已经定义的数据类型,而Address和Book类是用户定义的类。所有这些都是复杂类Student的一部分。因此,第一个条件得到了满足。 -
规则 2:成员应属于一个对象:如果我创建一个
Student类的实例,带有构造函数,那么这些成员在那个时刻只属于学生对象。它们不能成为另一个实例的成员。此外,成员是私有的,这阻止了它们被任何其他类使用。
Student student = new Student(1, "Bhagirathi Panda",
new DateTime(1990, 4, 23));
构造函数看起来如下:
public Student(int id, string name, DateTime dob)
{
Id = id;
Name = name;
Dob = dob;
}
-
规则 3:成员通过复杂类获得生命(死亡关系):正如你所见,成员在没有实例化
Student类之前并不存在,当对象死亡时它们也会被销毁;这证明了我们的规则 3。 -
规则 4:成员不知道复杂对象的存在(单向关系):成员非常听话。他们只存储分配给他们的任何值,甚至不关心是谁以及为什么分配给他们。复杂实例是他们的父级,但这些成员表现得像孤儿一样,不认识它。同样,我们不在乎他们在做什么,因为我们的规则 4 已经得到证明。
这里需要注意的另一件重要的事情是,复杂类可以有一个乘法成员,例如List<Book> Books。
你知道创建构造函数的快捷键吗?只需在你想创建构造函数的行中输入ctor,然后按两次Tab键。你会看到一个空构造函数块可供使用。此外,Visual Studio 的提示信息会告诉你如何处理这个命令:

组合的对比特性
部件是在组合类的创建过程中创建的。这意味着组合类负责创建。此外,部件的销毁取决于其创建者组合类的销毁。然而,规则是为了被打破的,这在组合的情况下也是如此。
考虑以下场景:
-
部件的创建被推迟到实际使用时。例如,我们的
Student类不会创建书籍列表,直到用户或任何其他方法向它分配一些数据。 -
组合将销毁部件的责任分配给其他对象。我们已经知道这样一个名为垃圾回收器(Garbage Collector)的常规程序,它会定期管理未使用对象的销毁。
组合就像一个守护者,所有成员的关怀都由组合类承担。创建、分配和销毁;所有这些主要都是由组合类管理的。
子类为什么在组合(Composition)内部?
在编程过程中,你总是需要做出决定。如果你仔细分析我们的Student类,你会意识到一些事情。Address类的属性可以直接在Student类内部声明,而不是作为不同的实体声明。
所以,而不是以下内容:
public class Student
{
// Other properties.
int AddressId {get; set;}
string City {get; set;}
string State {get; set;}
string Country {get; set;}
}
我们就是这样做的。基本上,我们只是将地址属性分离到一个名为Address的容器类中。以下代码块展示了我们如何提取出Address类:
public class Student
{
// Other properties.
private Address Address { get; set; }
}
public class Address
{
public int AddressId { get; set; }
public string City { get; set; }
public string State { get; set; }
public string Country { get; set; }
// Constructor. Just initialising City for now.
public Address(string city)
{
City = city;
}
}
这些是使用子类而不是直接将它们添加到组合类中的优点之一:
-
子类是独立的,简化了架构。代码看起来很容易理解。你从中获得的实际好处是在将来尝试对它做额外的事情时。假设,我告诉你为
Address添加另一个成员街道。如果你直接在组合类中设计地址属性,那么你必须手动进入每个类并添加另一个成员。然而,在子类的情况下,你只需要将其添加到子类中,所有使用该子类的组合类将自动获得访问权限。 -
你将类分解成子类越多,它们就越具有可重用性。例如,地址类也可以在其他类中重用。它不再与组合紧密耦合。
-
由于引入了子类,组合类不再复杂。此外,子类可以在其中定义方法,这使得组合对象的生活变得简单。这是因为子类可以定义与其相关的函数,这些函数可以被组合类调用。例如,如果我们想获取完整的地址,我们可以在
Address类中轻松地定义一个方法,该方法将使用所有地址属性返回一个字符串。因此,组合类不需要在Address相关的事情上做任何事情。
重要注意事项
考虑更好的设计,以便在不同实体之间分配责任,是困难的。但这并非不可能。当我们开始构建应用程序时,我们并不总是拥有完整的规格或意识到接下来可能发生什么。最终,当更多的规格出现时,我们面临问题,因为我们忽略了为某些常见用途构建子类。因此,你也会在许多类中看到不良和重复的代码,这些代码可以封装在子类中,并且可以轻松重用。
组合或主要类应该负责其设计的内容。Student类负责管理学生信息。毫无疑问,学生的地址是它应该处理的事情。但是,如果我们得到另一个实体,比如Teacher,它也有地址呢?我们必须在Teacher类内部重复相同的属性集来实现这一点。这不是代码异味吗!这就是你应该决定将Address相关的信息分离到另一个类的时候。
聚合
聚合是另一种类型的对象组合。让我们详细探讨这一点。
这是将现有对象组合成新对象的过程。起初,它看起来与组合相似。但事实上,它有区别。要成为聚合,复杂对象及其部分必须满足以下关系:
-
部分(成员)是对象(类)的组成部分:它与组合类似。这意味着较小的对象是复杂对象的一部分。例如,一个人有一个 Google Drive 文件夹。
-
部分(成员)可以同时属于多个对象(类):与组合不同,这里的成员与类是独立的。它可能同时被其他类引用。例如,一个驱动器文件夹可以同时被许多用户共享。
-
部分(成员)的存在不由对象(类)管理:正如最后一点所述,成员与复杂类没有绑定,因此它的创建和销毁不由它管理。例如,所有与文件夹共享的人都没有创建它。除非他们被赋予管理员权限,否则他们不能删除文件夹。
-
部分(成员)不知道对象(类)的存在:成员不知道复杂对象是否存在,就像组合一样。例如,一个人不知道驱动器文件夹是否存在。
考虑一个例子
现在,我们将尝试修改在组合课程中展示的Student类。我这样做是因为那里有一个Address属性。看看构造函数是如何更新的,以便将地址作为参数:
public Student(int id, string name, DateTime dob, Address address)
{
Id = id;
Name = name;
Dob = dob;
Address = address;
}
这对你来说并不陌生。让我们试着详细说明一下。我们只是将Address对象传递给构造函数,然后它被分配给复合类Student的Address属性。
下一个困惑是那么区别是什么?让我来解释。Address属性在复合类内部声明,其创建/销毁与其他成员一样。但在构造函数中,我们将其分配给一个外部的Address对象。这意味着复合类对该外部传入的对象没有任何控制权。
Student类的初始化也将按以下方式修改:
static void Main(string[] args)
{
Address add = new Address("Dhenkanal");
Student pallu = new Student(1, "Pallavi Praharaj",
new DateTime(1990, 6, 12), add);
}
让我们分析一下这种变化如何被视为聚合。
-
规则 1:部分(成员)是对象(类)的组成部分:
Address属性在Student类中被引用,因此它成为了一个部分。 -
规则 2:部分(成员)可以同时属于多个对象(类):我向构造函数传递了一个
Address对象,它在复合类内部被用于进一步操作。然而,对象add相当独立,因为它是由例程(如前述代码中的Main)创建的。让我允许它也被另一个Student使用:
static void Main(string[] args)
{
Address add = new Address("Nayagarh");
Student rinu = new Student(1, "Jayashree Satapathy",
new DateTime(1995, 11, 14), add);
Student gudy = new Student(2, "Lipsa Rath",
new DateTime(1995, 4, 23), add);
rinu.PrintStudent();
gudy.PrintStudent();
Console.ReadKey();
}
我知道你可能对它是否有效有所保留。以下是为你提供的输出:

很直接,不是吗!Main方法是创建者,因此它可以在其作用域内任何地方使用,直到它被它或垃圾收集器销毁。add对象被作为引用传递给两个学生。从这些事实中,我们可以推断出Jayashree和Lipsa是住在同一地址的两个学生。
-
规则 3:部分(成员)的存在不由对象(类)管理:因为它被包裹在
Main方法中,组合对象无法销毁它。顺便说一下,它也没有创建它。是Main让它诞生的。 -
规则 4:部分(成员)不知道对象(类)的存在:
Address对象对这些学生对象没有任何了解。它在Main块内部是自主的。
组合与聚合
现在我们已经探讨了这两种类型的对象组合,让我们看看它们之间的区别:
| 组合 | 聚合 |
|---|---|
| 通常包含内置类型成员变量管理成员的创建和销毁 | 通常包含超出组合类作用域的引用变量不管理成员的创建和销毁 |
虽然这些概念有如此微小但重要的区别,但它们仍然可以在组合类中混合使用。这意味着一个Student类,如果将其简单部分如Id、Name、Address等作为聚合,将被视为这两种概念的混合。
优缺点
在组合的情况下,你可以随心所欲地管理这些成员。然而,它们并不是独立的,你不能在其他地方使用它们。
而在聚合中,没有对成员生命周期的控制,如果你在创建后忘记销毁成员,它将成为内存泄漏的罪魁祸首。
虽然它们各自都有优点和缺点,但只是根据场景决定在哪里使用哪一个的问题。此外,你可以混合使用它们,做一些惊人的事情。例如,一些属性如Id、Name等仅在类内部使用,所以理想情况下我们不会在应用程序的任何地方重用这些属性。因此,如果你构建另一个包含所有这些属性的类,并在第一个类中引用(聚合),那就变得不必要了,因为其他类在第一个类之外没有这样的用途。
另一方面,当你确定某些属性可以打包成一个子类(AddressId、Address、State、City、Country等等),你可能在将来需要在代码的某个地方引用这个新的子类。最好是进行聚合。让调用者管理其生命周期。只需将其放入你的组合类中,按需使用,并忘记其管理。
其他重要关系
还有其他一些关系,你应该了解。除非你知道这些,否则你将无法可视化对象通常是如何相互协作的。
关联
到目前为止,我们已经完成了两种类型的对象组合:组合和聚合。只是为了确认我们处于同一页面上,对象组合用于将关系打包到一个复杂对象中,该对象由一个或多个更简单的对象(部分/成员)组成。
现在,我们将看看两种原本无关的对象之间的一种关系类型,称为关联。关联是两个无关对象之间的联系,它们必须满足以下条件:
-
关联的对象(成员)与其他对象(类)无关:这里将要关联的对象与对象无关。它不是像组合或聚合中那样成为复杂对象的一部分,而是本质上完全独立。例如,
Teacher和Student是两个独立的对象,但它们不包含彼此。这些实体在上课时相互关联。 -
关联的对象(成员)可以同时属于多个对象(类):像聚合一样,关联对象可以同时属于多个对象。例如,一个
Student可以与许多教师相关联,反之亦然。 -
关联的对象(成员)的存在不由另一个对象(类)管理:这里所有聚合的对象都是独立的。每个人自己管理自己。例如,
Teacher不会决定Student会做什么,Student也不会决定Teacher的行动应该是什么。 -
关联的对象(成员)可能知道也可能不知道对象(类)的存在:关联关系可能是单向的或双向的。这意味着两个关联的对象可能知道对方,也可能不知道。一旦我们看到了代码片段,我们将对此有更多的了解。例如,一个
Student可能认识或不认识一个Teacher。
关联可以定义为使用...a关系。Teacher使用Student来授课。Student使用Teacher来澄清疑问。
示例
让我们以一个板球运动员和板为例。考虑以下内容:
public class CricketPlayer
{
public string PlayerName { get; set; }
public CricketPlayer(string name)
{
PlayerName = name;
}
public void Play(Bat bat)
{
bat.StartPlay(this);
}
public string GetPlayerName()
{
return PlayerName;
}
}
public class Bat
{
public string BrandName { get; set; }
public void StartPlay(CricketPlayer player)
{
// Do something with the player.
Console.WriteLine("Player Named as " + player.PlayerName
+ " is playing.");
Console.ReadLine();
}
public string GetBrandName()
{
return "Some Brand Name";
}
}
上述代码的解释
我们有两个不同的类,CricketPlayer和Bat。现在让我按照以下方式创建对象:
var cPlayer = new CricketPlayer("Hardik Pandya");
Bat bat = new Bat();
cPlayer.Play(bat);
bat.StartPlay(cPlayer);
输出如下所示:

CricketPlayer类内部的Play方法和Bat类内部的StartPlay方法是此时你应该关注的。两者都接受一个指向另一个类对象的引用参数。这种玩家和板之间的关系的关键点是共同的原因是开始比赛。
让我们看看这个关系遵循的规则。
-
规则 1:关联的对象(成员)与其他对象(类)无关:板与球员无关,球员与板也无关。但我们将逐步看到它们是如何相互关联的。
-
规则 2:关联的对象(成员)可以同时属于多个对象(类):板可以被团队中的许多其他球员使用,而不仅仅是其中一个球员。
-
规则 3:关联的对象(成员)的存在不由另一个对象(类)管理:球员不负责管理球拍。它是在球员加入球队之前就创建的。同样,球拍也不会导致球员出生或死亡,除非球员用球拍打自己,这是不可能发生的。
-
规则 4:关联的对象(成员)可能知道也可能不知道对象(类)的存在:球员知道球拍,因为球拍作为参数传递给了
Play方法。同样,球拍知道球员,因为它被传递给了StartPlay方法。
如果你稍微了解一点板球,你必须知道球员使用球拍来击球。这意味着它依赖于球拍对象。然而,球拍对象可以被团队中的任何一名击球手使用,这导致了Bat类中的StartPlay方法。显然,球拍对象需要与一个球员关联,这最终开始了比赛。
这两个对象相互依赖,以便开始比赛。它们仍然作为独立对象存在。假设你没有调用Play和StartPlay方法,什么都不会改变。代码将编译。这定义了它们为了共同目的而相互关联:
CricketPlayer cPlayer = new CricketPlayer("Hardik Pandya");
Bat bat = new Bat();
//cPlayer.Play(bat);
//bat.StartPlay(cPlayer);
Console.WriteLine($"Name of the Player is: {
cPlayer.GetPlayerName() }");
Console.WriteLine($"Brand of Bat is: { bat.GetBrandName() }");
在这里,我只是注释掉了代码以供玩耍。这并没有对对象产生影响,它们仍然存在。然后我使用它们来调用其他方法,如GetPlayerName和GetBrandName。

关联类型
我们已经对关联有所了解。然而,关联有不同的类型,了解它们对我们来说会很有帮助。
自反关联
当两个相同类型的对象相互关联时,这种关联被称为自反关联。让我们考虑一下Medicine类:
public class Medicine
{
public string Name { get; set; }
public Medicine AlternateMedicine { get; set; }
public Medicine(string name, Medicine altMedicine)
{
Name = name;
AlternateMedicine = altMedicine;
}
}
在许多场景中,当我们有相同实体的依赖关系时,这非常有用。我们知道一种药物可能有一种替代药物,而这种替代药物又可能有另一种替代药物,依此类推。
间接关联
当关联通过其他方式形成,而不是直接在两个对象之间时,这种关联被称为间接关联。我们将通过以下示例来尝试理解这个概念:
public class SoftwareEngineer
{
public string Name { get; set; }
public int LaptopId { get; set; }
public SoftwareEngineer(string name, int laptopId)
{
Name = name;
LaptopId = laptopId;
}
}
public class Laptop
{
public int LaptopId { get; set; }
public string LaptopName { get; set; }
public Laptop(int id, string name)
{
LaptopId = id;
LaptopName = name;
}
}
public class AvailableLaptops
{
public static List<Laptop> Laptops { get; set; }
static AvailableLaptops()
{
Laptops = new List<Laptop>
{
new Laptop(1, "Laptop1"),
new Laptop(2, "Laptop2"),
new Laptop(3, "Laptop3"),
new Laptop(4, "Laptop4"),
};
}
public static Laptop GetLaptop(int id)
{
return Laptops.Find(l => l.LaptopId == id);
}
}
static void Main(string[] args)
{
SoftwareEngineer softEng = new SoftwareEngineer("Tworit Dash", 3);
// Get the Laptop object from AvailableLaptops class by id.
Laptop usedLaptop = AvailableLaptops.GetLaptop(3);
Console.WriteLine(softEng.Name + " is using " +
usedLaptop.LaptopName);
Console.ReadLine();
}
我们有两个类名为SoftwareEngineer和Laptop,它们应该相互关联。在这种情况下,我们试图通过一个静态类AvailableLaptops间接地将它们联系起来,这个类包含一个包含它们的Id和Name的Laptop对象列表。
让我们现在关注一下Main方法。一个SoftwareEngineer对象被实例化了(Name Tworit Dash,LaptopId 3)。我们需要打印他的名字和他正在使用的笔记本电脑名称。我们拥有LaptopId。如果你仔细查看AvailableLaptops类,你会发现一个静态方法GetLaptop,它接受laptopId作为参数,然后从它已有的列表中找到对应的笔记本电脑。
因此,Laptop usedLaptop = AvailableLaptops.GetLaptop(3); 将会获取到具有 ID 3 和 Name Laptop3 的所需 Laptop 对象。现在,只需要打印工程师的名字和由静态类返回的笔记本电脑的名字:

这被称为 间接关联,因为关联是通过另一个类建立的,这个类可以与一个类交互,并将结果返回给请求从第一个类获取数据的另一个类。
为了总结这些关系,让我们快速回顾一下。
组合:
-
复杂类的一部分。
-
成员不能是多个类的部分。
-
成员由一个复杂的类创建和销毁。
-
单向:成员不知道复杂的对象。
-
部分 关系
聚合:
-
复杂类的一部分
-
成员可以是多个类的部分
-
成员既不是由一个复杂的类创建,也不是由一个复杂的类销毁
-
单向:成员不知道复杂的对象
-
拥有 关系
关联:
-
类之间没有关系,但在需要时相互需要
-
关联的成员或对象可以被多个对象使用
-
成员既不是由一个复杂的类创建,也不是由一个复杂的类销毁
-
单向或双向:对象可能知道或不了解彼此
-
使用 关系
组合优于继承
这个主题非常有趣,在许多网站和博客上都有讨论。正如主题标题所说 组合优于继承,我们需要通过识别问题来理解为什么它如此重要。从软件开发的开始就有一个更好的设计,将使可维护性和重用性更好。
继承和组合是面向对象概念的两个支柱。除非它们根据你的架构明智地使用,否则在开始向应用程序添加复杂性时,它们会在未来造成问题。
我们已经讨论了组合,现在,在我们转向讨论的主题之前,让我们先讨论继承。
继承
如其名所示,从某人那里获取或派生某些行为的行为被称为 继承。在编程世界中,当一个类从另一个类继承时,它就创建了一个继承。以下是一些基本示例:
-
一辆车 是一种 车辆。车辆具有某些行为,当它被建造时,这些行为会被车所获得。
-
一个矩形 是一种 形状。
-
一个
HourlyEmployee是一个 员工。一个MonthlyEmployee也是一个 员工。 -
鸡肉咖喱 是一种 菜肴。
注意,它们都有一些共同点。那就是短语 是。继承被定义为一种 是 关系。
车是一种车辆,但它可能还配备了一个音乐系统,这不是车辆的一种常见行为。因此,派生或子类也可以有自己的行为。HourlyEmployee 和 MonthlyEmployee 是一家公司的员工,他们可以从公司分享许多福利。然而,他们的薪水并不相同。
用户类示例
让我们看看如何实现继承。
public abstract class User
{
public int Id { get; set; }
public int RoleId { get; set; }
public string Name { get; set; }
public string EmailId { get; set; }
public string MobileNumber { get; set; }
public int SaveUser(int userId)
{
// Database operation to save the user.
return userId;
}
}
public class Admin : User
{
public string CompanyDepartment { get; set; }
public Admin()
{
RoleId = 1;
}
}
public class Manager : User
{
public List<TeamLead> TeamLeads { get; set; }
public Manager()
{
RoleId = 2;
}
}
public class TeamLead : User
{
public List<string> Projects { get; set; }
public TeamLead()
{
RoleId = 3;
}
}
在我们公司中,有不同类型的用户,例如Admin、Manager、TeamLeads、HR等。尽管这些实体是不同的,但它们有一些共同的属性。它们必须有一个Id、RoleId、Name、EmailId、MobileNumber等。
由于它们有共同的属性和行为,我们创建了一个抽象的基类User,其中包含了所有这些声明。我们不会实例化这个类,因此它通过使用抽象关键字被限制。每种类型的用户都将有一些操作。最简单的操作是SaveUser(),它在基类中定义,以便所有子类都可以使用。
在这个例子中,我们还在子类中声明了不同的属性。
考虑以下内容:
-
Admin--
public string CompanyDepartment { get; set; } -
Manager--
public List<TeamLead> TeamLeads { get; set; } -
TeamLead--
public List<string> Projects { get; set; }
我们也可以在这些子类中定义针对它们的特定方法。当我们尝试创建对象,换句话说,创建一个Admin和一个Manager时,它看起来会像以下这样:
static void Main(string[] args)
{
Admin admin = new Admin()
{
Id = 12
};
admin.SaveUser(admin.Id);
Manager manager = new Manager
{
Id = 13
};
manager.SaveUser(manager.Id);
Console.WriteLine("Admin (Role Id: {0}) with UserId {1}
is saved", admin.RoleId, admin.Id);
Console.WriteLine("Manager (Role Id: {0}) with UserId {1}
is saved", manager.RoleId, manager.Id);
}
你可以看到我们正在创建每种类型的用户,然后将它们的 ID 发送到保存方法以进行进一步的数据库处理。RoleId在每种类型的User类的构造函数中分配。输出如下:

新用户类型
公司决定有一个新的员工类型,名为“配送经理”,他将拥有某些特权,但不是全部。这个角色将从Manager和TeamLead那里承担部分责任。
配送经理可以CreateProject(像 TeamLead 一样)和AssignProjectToTeamLead(像 Manager 一样)。在执行所有这些操作时,他/她还可以SendNotificationToCto,这是一个新方法。因此,这两个方法将包含不是直接从Manager和TeamLead类复制过来的额外代码:
多重继承是一种特定于语言的特性,它允许一个类继承多个父类的特性。这个特性可能会在设计上引入复杂性,而支持这种特性的语言也有它们自己处理此类场景的方式。C#、Java、Swift 等语言不支持多重继承,但它们允许实现多个协议,这些协议被称为接口。我只是想向你展示,我们必须采取一些替代方法来解决问题,而不是像上面那样从多个类中继承,因为 C#本身就不支持这种做法。
我们遇到的问题
Visual Studio 告诉我我不能这样编码,这实际上被称为多重继承。此外,当一家公司引入更多角色时,如果我们每个类中都有定义职责(方法),系统在管理上就会变得复杂。因为当我们需要混合特定用户角色的职责时,我们必须创建重复的代码,而这些代码本应该已经在某个类中编写过了。
为了解释我所说的,想象一个 Car 类,它被 Toyota、BMW、Volkswagen 等类继承。由于某种原因,我生气了,把 Toyota 和 Volkswagen 都带到了我的车间,然后从它们中创建了一个新的品牌。我将把它命名为 VolksTaditToy。请不要因为这个名字杀了我。
VolksTaditToy 现在结合了这两款汽车的功能。但在我的程序中,没有方法来处理它们。如果你在路上看到更多这样的汽车,请不要感到惊讶,因为在这个世界上,这样的傻瓜并不稀缺。渐渐地,你的程序将陷入无法逃脱的境地。
我们如何用继承的概念来编写这个类?不可能吧!让我们让我们的初始 User 问题成为可能。
解决这个问题的方案
组合在这里是我们的救星。让我们看看我们如何利用这种关系来解决这个问题。我们将引入一个 Role 类。显然!Manager、TeamLead 和 DeliveryManager 是员工扮演的不同角色:
public class Role
{
public int RoleId { get; set; }
public string RoleName { get; set; }
}
现在所有的其他用户类型类都将从这个类派生:
public class Admin : Role
{
public string CompanyDepartment { get; set; }
public Admin()
{
RoleId = 1;
}
}
public class Manager : Role
{
public List<TeamLead> TeamLeads { get; set; }
public Manager()
{
RoleId = 2;
}
}
public class TeamLead : Role
{
public List<string> Projects { get; set; }
public TeamLead()
{
RoleId = 3;
}
}
public class DeliveryHead : Role
{
public DeliveryHead()
{
RoleId = 4;
}
}
好吧,下一步是什么?剩下的类是 User。我们需要对其进行一些修改,如下所示:
public class User
{
public int Id { get; set; }
public List<Role> RoleIds { get; set; }
public string Name { get; set; }
public string EmailId { get; set; }
public string MobileNumber { get; set; }
public int SaveUser(int userId)
{
// Database operation to save the user.
return userId;
}
}
第一个修改是移除抽象关键字,因为我们现在将创建这个类的对象。接下来是拥有一个属性 public List<Role> RoleIds { get; set; } 而不是 public int RoleId { get; set; }。我们这样做是为了允许给用户/员工分配多个角色。
观察,我们如何在以下主方法中创建具有多个角色的用户:
static void Main(string[] args)
{
User deliveryManager = new User()
{
RoleIds = new List<Role>
{
new Manager(),
new TeamLead()
}
};
Console.WriteLine(string.Format("User has Roles:\n\n\t-
{0}", string.Join("\n\t- ", deliveryManager.RoleIds)));
}
在创建 DeliveryManager 类型的 User 时,我们通过创建 Manager 和 TeamLead 类型的列表来给用户分配多个角色。由于它们继承自 Role 基类,所以 RoleIds 能够识别这些类型。
这段代码产生了以下输出:

我们得出结论,在许多这样的情况下,组合取代了继承。这意味着你在开始设计类时需要非常小心。否则,随着你的系统增长,未来的情况会变得更糟,你将陷入混乱。你绝对应该避免重复代码。当你看到你正在编写的东西之前已经编写过时,在继续之前停下来思考。尽可能在那个时刻进行规范化。
对象组合在依赖注入中的作用
现在我们已经理解了对象组合的概念,让我们分析一个实际的软件项目问题,以及如何使用对象组合来陷入困境。在这个过程中,我们将发现这个概念在 DI(依赖注入)环境中的重要性:
class Mail
{
protected bool SendMail(string mailId, string message)
{
// Logic to send an email
return true;
}
}
class Notification : Mail
{
void SendNotification(string mailId, string message)
{
SendMail(mailId, message);
}
}
因此,Notification类继承了Mail类,以便它可以调用SendMail()。这种结构本身并没有错,但将来可能会产生复杂性。
想象一下另一个用于Sms的类,其中我们可以有一个类似SendSms()的方法。在这种情况下,Notification类无法调用该方法,因为多重继承是不可能的。
为了解决这个问题,我们可以轻松地使用对象组合与依赖注入。让我们首先修改代码。如下所示:
interface IMail
{
bool SendMail(string mailId, string message);
}
interface ISms
{
bool SendSms(string mobile, string message);
}
public class Mail : IMail
{
public bool SendMail(string mailId, string message)
{
// Logic to send an email
Console.WriteLine("SendMail Called");
return true;
}
}
public class Sms : ISms
{
public bool SendSms(string mailId, string message)
{
// Logic to send a Sms
Console.WriteLine("SendSms Called");
return true;
}
}
class Notification
{
private readonly IMail _mail;
private readonly ISms _sms;
public Notification(IMail mail, ISms sms)
{
_mail = mail;
_sms = sms;
}
public void SendNotification(string mailId, string mobile,
string message)
{
_mail.SendMail(mailId, message);
_sms.SendSms(mobile, message);
}
}
IEmail和ISms是具有SendMail()和SendSms()方法的接口。接下来,我们需要在Mail和Sms类中实现这些接口。我们将在这些类实现的方法中编写我们的发送逻辑。
注意到Notification类,它没有继承任何类,而是引用了新的接口。然后在参数化构造函数中,我们有IMail和ISms作为参数。现在SendNotification()方法需要像mailId、mobile和message这样的必要细节来调用接口的方法。
那么优势在哪里呢?我们不是写了更多的代码吗?这里的关键点非常有趣。如果你查看实例化Notification类的代码,你会得到一些提示。让我们看看那个:
static void Main(string[] args)
{
Notification notify = new Notification(new Mail(),
new Sms());
notify.SendNotification("taditdash@gmail.com",
"9132994288", "Hello Tadit!");
Console.ReadLine();
}
看到了提示吗?让我来解释一下。我们将Mail和Sms类的实例注入到Notification构造函数中,这些实例被分配给属性_mail和_sms。它会自动调用Mail和Sms类内部的方法。因此,我们使用IMail和ISms引用组合了Notification类。这就是对象组合与依赖注入出现的地方。
假设你在某个时间点想包含另一个用于邮件发送的类(比如SmtpMail),你只需编写一个实现相同IMail接口的类,并定义SendMail方法。就这样,完成了。不再需要让Notification类变得复杂。它将按预期工作。
public class SmtpMail : IMail
{
public bool SendMail(string mailId, string message)
{
// Logic to send an email
Console.WriteLine("SmtpMail Called");
return true;
}
}
static void Main(string[] args)
{
Notification notify = new Notification(new SmtpMail(),
new Sms());
notify.SendNotification("taditdash@gmail.com",
"9132994288", "Hello Tadit!");
Console.ReadLine();
}
正如你所见,我只是声明了新的类,并像new SmtpMail()一样注入对象,而不是在Notification类内部直接引用它。这就是唯一的改变。其余的将按预期工作,因为我已经注入了对象,而不是在Notification类内部直接引用它。
总结一下,以下是我们所取得的成果:
-
我们通过接口为具体依赖项引入了灵活性。
-
我们可以通过实现接口抽象轻松地插入新的具体类依赖项。
-
我们只需一次操作,就将
Notification对象与依赖对象组合在一起。 -
我们将所有初始化代码移动到
Main方法内部的一个地方。
当我说我们将初始化代码移动到一处时,那个位置被标记为应用程序的组合根。
组合根
组合根(Composition Root)组合了应用程序的所有独立模块。在运行时,对象组合是任何其他操作之前的第一件事。一旦对象图与依赖项连接,对象组合就完成了,然后与应用程序相关的组件可以接管。对象组合应该尽可能接近应用程序的入口点。
在.NET Core 2.0 控制台应用程序和 ASP.NET Core 2.0 MVC 应用程序中,入口点是相同的,都在Program.cs类的Main方法内部。.NET Core 2.0 控制台应用程序的Main方法很简洁,但另一方面,ASP.NET Core 2.0 MVC 在Main方法内部有一些启动代码。然而,我们通常在ConfigureServices方法中编写组合代码,这个方法可以在Main方法内部调用。
当你在 Visual Studio 2017 中执行 File | New | Project | .NET Core | Console App (.NET Core)时,你将在Main方法中看到以下内容:
namespace PacktConsoleApp
{
class Program
{
static void Main(string[] args)
{
// We will do all Object Composition here directly or
calling a ConfigureServices method.
Console.WriteLine("Hello World!");
}
}
}
在 ASP.NET Core 2.0 MVC 应用程序的情况下,当你选择 File | New | Project | Web | ASP.NET Core Web Application(在下一个屏幕中,选择合适的模板)时,Web 应用程序模板会在Program类中生成Main方法,而Startup看起来如下所示:

我们在上一个章节中创建Notification对象的方式被称为穷人版依赖注入(DI)。而不是这样做,我们应该在该位置应用 DI 容器来组合和管理对象。
对象组合是 DI 的基本构建块,也是最容易理解的一个,因为我们已经通过许多例子知道了如何组合对象。现在,我们需要学习在框架能力的影响下,组合对象时会面临哪些挑战。这些问题与框架有关,与对象组合的概念无关。让我们在接下来的章节中找出答案。
组合.NET Core 2.0 控制台应用程序
在Main方法内部,我们可以轻松地使用内置的 DI 容器组合对象。如果你还记得,我们已经讨论过从容器初始化、将对象注册到容器、解析依赖项到最后从容器中释放组件,所有这些都应该在组合根内部发生,在这里被认为是Main方法。
考虑一个控制台应用程序中的Main方法示例:
static void Main(string[] args)
{
// Setup container and register dependencies.
var serviceProvider = new ServiceCollection()
.AddTransient<IEmployeeService, EmployeeService>()
.BuildServiceProvider();
// Get the service instance from the container and
do actual operation.
var emp = serviceProvider.GetService<IEmployeeService>();
emp.HelloEmployee();
Console.ReadKey();
}
这段代码很简单,它利用了可用的扩展方法Add***来将依赖项注册到容器中。然后我们使用GetService方法通过接口获取实现类型。有了这个实例,我们就可以在应用程序中做任何我们想做的事情。
回想一下,组合根是我们应该进行所有与依赖管理相关的操作的地方。不建议在组合根或更具体地说在 Main 方法之外使用 serviceProvider。同样的规则也适用于 ASP.NET Core MVC。我们稍后会探讨这一点。
当然,你可以引入另一个方法,你可以将其命名为 ConfigureServices(如下所示)以获得更清晰的代码结构。你可以给方法起任何名字,但这个名字与我们在上一节中看到的 ASP.NET Core MVC 应用程序中专门用于依赖注入配置的方法的名称相似。新添加的方法如下代码片段所示:
static void Main(string[] args)
{
ConfigureServices(new ServiceCollection());
Console.ReadKey();
}
public static void ConfigureServices(IServiceCollection
serviceCollection)
{
// Setup container and register dependencies.
var serviceProvider = serviceCollection
.AddTransient<IEmployeeService, EmployeeService>()
.BuildServiceProvider();
// Get the service instance from the container and
do actual operation.
var emp = serviceProvider.GetService<IEmployeeService>();
emp.HelloEmployee();
}
注意,我们没有手动释放对象或容器。原因是释放会根据你决定的生存周期自动由 DI 容器处理。AddTransient、AddSingleton 和 AddScoped 是现成的方法,可以帮助执行不同类型的对象生存周期。我们将在 第六章 中更深入地探讨对象生存周期,对象生存周期。
ASP.NET Core MVC 2.0 的对象组合
就像控制台应用程序一样,我们可以遵循相同的程序来处理 ASP.NET Core MVC 2.0 应用程序内部的依赖项。与控制台应用程序不同,在这个例子中,Program.cs 中的 Main 方法包含默认代码来初始化 MVC 应用程序并配置所需设置。它是从这个位置指示框架加载启动类的。Main 方法中的 host 执行 Startup 类的 ConfigureServices 方法。
ASP.NET Core MVC 被设计成支持依赖注入。但它并不强迫你总是应用依赖注入。为了处理 ASP.NET MVC 中的依赖项,我们可以采取穷人的 DI 方法来手动管理它们,或者利用内置/第三方 DI 容器的技术来注册、解析和释放依赖项。让我们深入探讨控制器初始化过程,看看是否能找到有用的东西。
MVC 的核心在于控制器。控制器处理请求,处理它们,并将响应返回给客户端。因此,控制器应该根据需要将责任委托给其他模块。这意味着控制器将引用其他类来完成某些任务。它将在操作方法内部使用 new 关键字来创建依赖对象,而我们可以通过使用 DI 容器轻松避免这一点。使用依赖注入技术,我们应该能够通过构造函数注入将依赖项注入到控制器中。
IControllerFactory 是 Microsoft.AspNetCore.Mvc.Controllers 命名空间中的一个接口,它使我们能够创建和释放控制器。该接口包含两个方法,如下所示:
namespace Microsoft.AspNetCore.Mvc.Controllers
{
/// <summary>
/// Provides methods for creation and disposal of controllers.
/// </summary>
public interface IControllerFactory
{
object CreateController(ControllerContext context);
void ReleaseController(ControllerContext context,
object controller);
}
}
ASP.NET Core MVC 2.0 内置了一个 DefaultControlFactory,它实现了这个接口。让我们看看源代码:
namespace Microsoft.AspNetCore.Mvc.Controllers
{
/// <summary>
/// Default implementation for <see cref="IControllerFactory"/>.
/// </summary>
public class DefaultControllerFactory : IControllerFactory
{
private readonly IControllerActivator _controllerActivator;
private readonly IControllerPropertyActivator[]
_propertyActivators;
public DefaultControllerFactory(
IControllerActivator controllerActivator,
IEnumerable<IControllerPropertyActivator> propertyActivators)
{
if (controllerActivator == null)
{
throw new ArgumentNullException(nameof(
controllerActivator));
}
if (propertyActivators == null)
{
throw
new ArgumentNullException(nameof(propertyActivators));
}
_controllerActivator = controllerActivator;
_propertyActivators = propertyActivators.ToArray();
}
public virtual object CreateController
(ControllerContext context)
{
// Codes removed just for book.
You can find codes in Github.
}
public virtual void ReleaseController(ControllerContext
context, object controller)
{
// Codes removed just for book. You can
find codes in Github.
}
}
}
DefaultControllerFactory 具有构造函数注入,用于提供 ControllerActivator 和 PropertyActivators 所需的依赖项。因此,这个工厂由激活器组成。就像一个工厂一样,还有一个名为 IControllerActivator 的 Activator 接口。分别有名为 ControllerFactoryProvider 和 ControllerActivatorProvider 的工厂和激活器提供者。
现在,最重要的部分。这些工厂的对象组合实际上是在 MvcServiceCollectionExtensions 类的 AddMvcCore() 方法中完成的,该方法位于 namespace Microsoft.Extensions.DependencyInjection 内。namespace 名称包含 DependencyInjection,这本身给我们一个提示,即我们肯定会进行一些注入来初始化这些激活器和工厂。让我们看看 AddMvcCoreServices() 方法(这是从 AddMvcCore() 调用的另一个方法)的快照,它负责组合所有用于控制器激活和初始化的必需依赖项:

您可以看到接口是如何注册为具体类的。此方法包含许多其他服务注册,用于控制器过程所需的全部后台工作。但我们对框架内部如何实现对象组合有了了解。
如果我们想要设计自己的自定义控制器工厂,我们也可以通过在此方法中注册所需的工厂和提供者来进行初始化。
摘要
本章探讨了编程中对象之间的重要关系。我们创建了非常基础的类,并试图掌握这些概念。然后,我们将注意力转向对象组合,以及它的类型、组合和聚合。
此外,我们还讨论了关联。通过代码示例和输出,我们看到了这些关系在编码中的重要性。
最后,我们通过一个示例介绍了继承。一旦我们完成了所有这些,我们就转向一个非常重要的说法:组合优于继承。这是开发者在将新需求应用于现有类结构时面临的一个实际问题。
然后,我们讨论了对象组合在依赖注入中扮演的重要角色。我们还看到了这种模式如何在 ASP.NET Core MVC 2.0 中遵循。
是时候看看对象是如何创建的,它们是如何生存的,然后又是如何被销毁的,在第六章,对象生命周期中。我们将对象生命周期与我们在这章中学到的知识联系起来。
第六章:对象生命周期
对象生命周期是指对象从创建到销毁的时间段。在函数式编程语言中,存储在常量变量中的数据有一个定义的范围,其本质上是不可变的。这意味着只要应用程序没有停止,它们的生命周期就具有功能范围(没有销毁)。另一方面,面向对象编程中的对象是可变的,具有不同类型的范围,这导致了不同的生活方式。
内存在应用程序生命周期中起着至关重要的作用。所有对象和变量都使用内存空间。因此,了解在应用程序执行期间对象流动的概念非常重要。除非我们知道如何通过适当的代码或模式释放空间,否则会导致内存泄漏。
如果计算机程序暴露了错误并错误地管理内存分配,那么资源将变得不可用。这种情况被称为内存泄漏。
为了避免内存泄漏,我们在设计类时应该采取适当的注意,以确保在需要时资源可用。这只会发生在程序在对象超出作用域时立即释放与对象关联的资源的情况下。因此,应用程序可以无缝运行,因为未使用的空间会定期清理。然而,这个过程并不是在所有情况下都是自动的。我们要探讨这个主题的原因是了解 DI 技术在不同场景下如何管理对象的生命周期不同,这反过来又帮助我们设计类时做出适当的决策。
当相关类被实例化时,对象就诞生了。新生的对象会存在一段时间,只要应用程序保持对该对象的引用并继续使用它。如果你的应用程序关闭或对象的引用在代码中超出作用域,那么.NET Framework 将标记该对象从内存中删除。
在这个特定的章节中,我们将学习.NET Core 如何管理对象。此外,我们还将探讨确定对象何时可回收以及如何与之交互的技术。
本章我们将涵盖以下主题:
-
管理和非管理资源。
-
对象的创建和销毁
-
IDisposal接口 -
.NET Core 中的对象生命周期管理
管理对象生命周期
内存中有两个基本的地方——栈和堆。
栈与堆的比较
让我们简要了解一下这些内存空间类型。
| 栈 | 堆 |
|---|---|
| 静态内存:为应用程序分配的固定内存空间。 | 动态内存:没有固定空间。 |
| 当一个方法被调用时,会预留一块栈空间来存储方法信息,如方法名、局部变量、返回类型等。 | 可以存储任何东西。开发者有灵活性来管理这个空间。 |
内存分配遵循后进先出(LIFO)的顺序。所以,当 Main 函数调用方法 A(),然后 A() 调用 B() 时,B() 将被存储在顶部并首先执行。参见图表: |
没有这样的模式来存储数据。 |
以下图表显示了数据在一个程序中如何存储在栈和堆中:

Main() 调用 A(),然后 A() 调用 B() 方法。根据栈的特性,它首先移除最后一个,即 B()。所以, B() 首先执行。然后 B() 从栈中移除,然后处理并从内存中移除 A()。之后, Main() 方法执行并移除。在 Main() 中名为 foo 的引用类型变量存储在栈上,但实际的对象是在堆上分配内存的。
考虑以下示例:
static void Main(string[] args)
{
string name = "Dependency Injection";
SomeClass sc = new SomeClass()
}
变量 name 是一个值类型,它直接存储在栈上。但是,当我们编写 SomeClass sc = new SomeClass() 时,实际上是在告诉框架将对象存储在堆上。除此之外,它还在栈上为变量 sc 分配了一块内存空间,该空间持有对这个对象的引用。
现在,当 Main 方法执行完成后,变量 name 和 sc 将被释放,内存空间变为空闲。但是这里有一个问题。变量 sc(引用类型)从栈上释放,但实际的对象仍然在堆上。只是引用被移除了。由于引用从栈上移除,所以实际上无法知道堆上是否存在与之相关的对象。我们现在遇到了一个管理问题。
为了解决这个问题(在 C++ 中),我们可能已经做了如下操作 delete sc;。然而,在 C# 这种托管语言中,有一个名为垃圾回收器(GC)的服务,它会通过分析所有那些标记为超出作用域的对象来自动清理未使用的内存。
托管语言是一种高级语言,它依赖于运行时环境提供的服务来执行,例如垃圾回收服务、安全服务、异常处理等等。它使用公共语言运行时(CLR)在 .Net 语言中执行或在 Java 中使用Java 虚拟机(JVM)。
托管和非托管资源
纯 .NET 代码被称为托管资源,因为它们可以直接由运行时环境管理。另一方面,非托管资源是指那些不在运行时直接控制之下的资源,例如文件句柄、COM 对象、数据库连接等等。例如,如果你打开到数据库服务器的连接,这将使用服务器上的资源(用于维护连接)以及可能的其他非 .NET 资源。
管理资源直接由 CLR(公共语言运行时)针对,因此垃圾收集器会清理它们,这是一个自动的过程。作为开发者,你通常不需要显式调用 GC。然而,有一个问题,当我们考虑非托管资源,如数据库连接时。我们必须自己处理它们,因为 CLR 无法处理。我们必须使用Finalize方法手动释放它们。
代数
堆被分为三代,以便它可以处理长期和短期对象。垃圾收集基本上回收了通常只占用堆一小部分空间的短暂对象。
堆上有以下三代对象:
-
第 0 代:当一个对象被初始化时,它的代数开始。它首先落入第 0 代。这个代的对象通常是短暂的。这些对象更容易被 GC 销毁。GC 收集这些短暂的对象,以便它们可以被释放出来,从而释放内存空间。如果对象在 GC 收集后仍然存活,这意味着它们将停留更长的时间,因此被提升到下一代。
-
第 1 代:这个代的对象比第 0 代对象存在的时间更长。GC 确实会从这个代收集对象,但不像第 0 代那样频繁,因为它们的生存期被应用程序扩展以进行更多操作。这个代幸存下来的对象将进入第 2 代。
-
第 2 代:这些是在应用程序中存在时间最长的对象。成功通过前两代的对象被认为是第 2 代。GC 在释放这些对象时很少介入。
对象创建
构造函数负责创建特定类的对象。如果我们创建任何没有构造函数的类,编译器将自动为该类创建一个默认构造函数。每个类至少有一个构造函数。
构造函数也可以重载,这提供了一种方便的方式来使用不同的属性构建对象,这意味着它可以通过接受某些参数(就像一个普通方法一样)并在其体内将其属性赋值(也称为参数化构造函数)来实例化对象。构造函数应该与类名具有相同的名称。
复制构造函数
另一种类型的构造函数称为复制构造函数。正如其名所示,它可以复制一个对象到新的对象(同一类的对象)中,该对象将被实例化。换句话说,它是一个包含相同类型参数的参数化构造函数。复制构造函数的主要目的是将新实例初始化为现有实例的值。我们将在稍后的示例中看到如何实现这一点。
对象销毁
我们有不同的方式来销毁一个对象。让我们逐一探索。
最终化
终结器用于销毁对象。我们可以使用带有类名波浪号 (~) 符号的析构方法来设计终结器。我们很快就会看到它的实际应用:
~Order()
{
// Destructor or Finalizer
}
垃圾回收器完全控制着终结过程,因为它在对象超出作用域时内部调用此方法。然而,我们可以在析构函数中编写代码以自定义我们的需求,但我们不能只是告诉某人调用析构函数。即使你非常确定对象不再需要并决定释放它,你也不能显式执行析构函数以释放空间。你必须等待垃圾回收器收集对象以进行销毁。
终结过程有两个收集周期。在第一个周期中,短生命周期的对象被标记为需要终结。在下一个周期中,它调用终结器以完全从内存空间中释放它们。
IDisposable 接口
如我们之前讨论的,未托管资源不在框架的直接控制之下。我们可以在终结器中轻松回收这些资源,正如我们之前讨论的那样。这意味着,当对象被垃圾回收器销毁时,它们将被释放。然而,垃圾回收器仅在 CLR 需要更多空闲内存时才会销毁对象。因此,即使对象超出作用域很长时间,资源仍然可能存在。
因此,我们需要在完成使用资源后立即释放资源。如果你的类实现了 IDisposable 接口,它们可以提供一种机制来主动管理系统资源。此接口公开了一个方法,Dispose(),当客户端完成对一个对象的使用时应该调用它。你可以使用 Dispose 方法立即释放资源以执行诸如关闭文件和数据库连接等任务。与 Finalize 析构函数不同,Dispose 方法不是自动调用的。对象必须显式调用 Dispose 以释放资源。
此方法是 IDisposable 接口中的唯一方法,可以用来手动释放未托管资源。
public interface IDisposable
{
void Dispose();
}
现在只需调用 Dispose() 方法即可。但等等。你只能从实现了此接口并定义了 Dispose() 方法的类对象中调用此方法。
例如,SqlConnection 类实现了此接口,并为我们提供了 Dispose() 方法,可以使用如下方式使用它。一旦你完成对连接对象的操作,就调用 Dispose:
var connection = new SqlConnection("ConnectionString");
// Do Database related stuff here.
// After we are done, let's call Dispose.
connection.Dispose();
在 .NET 中处理对象销毁的另一种优雅方法是,而不是直接调用 Dispose,我们可以使用 using 块。相同的语句可以用以下方式用 using 块装饰:
using (var connection = new SqlConnection("ConnectionString"))
{
// Use the connection object and do database operation.
}
当我们这样做时,它将代码转换为 try...finally 中间代码。在 finally 块中销毁连接对象,这是我们创建的。除非你这样做,否则连接对象将保留在内存中。随着时间的推移,当我们获得大量连接时,内存开始泄漏。
如果您正在使用终结器(析构函数)方法,请确保在其中调用 Dispose() 以释放您想要释放的资源。这样,您将确保即使有人在使用您的类时忘记释放它们,资源也会被垃圾回收器清理。
出于好奇,您可能想知道,如果在调用 Dispose() 之前发生了一些异常,会发生什么?那个对象会被释放吗?这个问题的解决方案是将它包裹在一个 try...finally 块中,这样无论程序发生什么情况,finally 都会被调用,您可以在其中释放对象。为了简单起见,框架有一个叫做 using 块的漂亮功能,可以如下使用:
Dispose() 与 Close(): 对于 SqlConnection 对象,您是否感到困惑应该调用哪一个?它们是两种不同的方法,用于解决不同的问题。Close() 仅关闭连接。您可以使用相同的对象重新打开连接。然而,Dispose() 关闭连接(在底层调用 Close())然后从内存中释放对象。您不能再使用该对象了。
您可以在 docs.microsoft.com/en-us/dotnet/standard/garbage-collection/ 上了解更多关于垃圾回收器的信息。
考虑一个例子
一个简单的 Order 类可以有一个默认的、参数化的、复制构造函数以及一个析构函数(用于销毁对象):
class Order
{
public string ProductName { get; set; }
public int Quantity { get; set; }
public Order()
{
// Default Constructor
}
public Order(string productName, int quantity)
{
// Constructor with two arguments
ProductName = productName;
Quantity = quantity;
}
public Order(Order someOrder)
{
// Copy constructor
ProductName = someOrder.ProductName;
Quantity = someOrder.Quantity;
}
~Order()
{
// Destructor or Finalizer
}
}
您可以看到构造函数是如何使用和不使用参数形成的。注意复制构造函数,它接受一个与同一类相同的对象作为参数,并在体内将它的属性赋值给正在创建的对象。
终结器隐式地调用对象的基类的 Finalize 方法。因此,当垃圾回收器调用终结器时,可能会调用如下所示的方法:
protected override void Finalize()
{
try
{
// Cleanup statements...
}
finally
{
base.Finalize();
}
}
让我们通过在 .NET Core 2.0 的控制台应用程序中使用代码片段来验证这种行为:
class BaseClass
{
~BaseClass()
{
System.Diagnostics.Trace.WriteLine("BaseClass's destructor is called.");
}
}
class DeriveClass1 : BaseClass
{
~DeriveClass1()
{
System.Diagnostics.Trace.WriteLine("DeriveClass1's destructor
is called.");
}
}
class DeriveClass2 : DeriveClass1
{
public DeriveClass2()
{
System.Diagnostics.Trace.WriteLine("DeriveClass2's constructor is called.");
}
~DeriveClass2()
{
System.Diagnostics.Trace.WriteLine("DeriveClass2's destructor
is called.");
}
}
class Program
{
static void Main(string[] args)
{
DeriveClass2 t = new DeriveClass2();
// Unlike .NET Framework, .NET Core 2.0, as of now,
// does not call GC on application termination
// to finalise the objects.
// So, we are trying to manually call GC
// to see the output.
System.GC.Collect();
}
}
首先创建 DeriveClass2 对象,它记录构造函数消息。然后执行 DeriveClass2 的析构函数。因此,当 Main 函数执行完成后,它首先被销毁。此外,父类也有析构函数。因为子类 (DeriveClass2) 对象已经被销毁,它也会运行父类的析构函数。以下截图来自 Visual Studio 的输出窗口。请确保以发布模式运行应用程序:

实现 IDisposable 接口
学习如何实现 IDisposable 接口很重要,因为您可能在项目中使用用户定义的类来处理非托管资源。系统定义使用非托管资源的类实现 IDisposable 并公开 Dispose,这样我们就可以轻松地调用该方法来释放对象,就像我们在 SqlConnection 类的代码片段中看到的那样。
有一个名为 Dispose 模式 的模式,开发者在实现 IDisposable 时必须遵循。让我们来探索一下。我会一步一步地讲解。
Step1 - 类的基本结构
我们将有一个 ExampleIDisposable 类,它实现了 IDisposable 接口。我不会演示非托管资源的用法,因为我们的目的是学习这个模式。我只是在构造函数中有一个控制台行,说明我们正在获取非托管资源。
class ExampleIDisposable : IDisposable
{
public Dictionary<int, string> Chapters{ get; set; }
public ExampleIDisposable(Dictionary<int, string> chapters)
{
// Managed resources
Console.WriteLine("Managed Resources acquired");
Chapters = chapters;
// Some Unmanaged resources
Console.WriteLine("Unmanaged Resources acquired");
}
public void Dispose()
{
Console.WriteLine("Someone called Dispose");
// Dispose managed resources
if (Chapters != null)
{
Chapters = null;
}
// Dispose unmanaged resources
}
}
你可以看到这个类包含一个托管属性,它在构造函数中被初始化。我们将为它打印一行。同样,我们可能有一些在类中声明并由构造函数赋予生命的非托管资源属性。由于我们实现了 IDisposable,我们被迫定义唯一的方法 Dispose()。目前,我们只是在其中有一个控制台行。
让我们试一试:
static void Main(string[] args)
{
ExampleIDisposable disposable = new ExampleIDisposable(new Dictionary<int,
string> {{ 5, "Object Composition" },
{ 6, "Object Lifetime" }
});
disposable.Dispose();
Console.ReadLine();
}
这会产生以下输出:

在继续前进之前,我们需要理解两个重要的观点。
当 GC 调用 Finalizer 时会发生什么?当一个对象超出作用域时,它将被添加到 Finalizer 队列中,垃圾回收器将对其采取行动以从内存中释放它。我们不知道这会发生什么时间。如果你在 Dispose() 中杀死了托管资源,那么我们需要限制进入 Finalizer 队列的对象,从而通知 GC 不要对它们采取行动,因为它们已经不存在了。此外,这对 GC 来说也是一个开销。
如果开发者忘记调用 Dispose 会怎样?假设,使用我们类的开发者没有销毁它。我们仍然需要处理这种情况。我们可以通过在 Finalizer 中调用 Dispose() 来轻松地做到这一点,但是等等!我们需要要求 Dispose() 方法只杀死非托管资源,而不是托管资源,因为 GC 会自动处理它们。
这就是另一个方法出现的地方。
Step2 - 定义一个 Dispose 重载方法
我们在类内部定义的 Dispose() 方法将在我们直接通过类的对象调用它时帮助我们。然而,我们还需要在类内部定义另一个 Dispose() 重载,这将回答我们之前讨论过的问题。让我们将这个方法引入到我们的类中。
class ExampleIDisposable : IDisposable
{
public Dictionary<int, string> Chapters { get; set; }
public ExampleIDisposable(Dictionary<int, string> chapters)
{
// Managed resources
System.Diagnostics.Trace.WriteLine("Managed Resources acquired");
Chapters = chapters;
// Some Unmanaged resources
System.Diagnostics.Trace.WriteLine("Unmanaged Resources acquired");
}
public void Dispose()
{
System.Diagnostics.Trace.WriteLine("Someone called Dispose");
Dispose(true);
GC.SuppressFinalize(this);
}
public void Dispose(bool disposeManagedResources)
{
if (disposeManagedResources)
{
if (Chapters != null)
{
Chapters = null;
}
System.Diagnostics.Trace.WriteLine("Managed Resources disposed");
}
System.Diagnostics.Trace.WriteLine("Unmanaged Resources disposed");
}
~ExampleIDisposable()
{
System.Diagnostics.Trace.WriteLine("Finalizer called: Managed
resources will be cleaned");
Dispose(false);
}
}
对 Dispose 方法所做的修改如下所述:
-
public void Dispose(): 现在,我们要求Dispose(bool)方法释放所有类型的资源。public void Dispose(). GC.SuppressFinalize(this); 抑制了 GC Finalizer 的调用,因为我们已经在Dispose(bool)中处理了所有内容。 -
public void Dispose(bool): 这个方法是这个模式的重要部分。通过bool参数,它决定是否需要杀死托管资源。
我已经将控制台行替换为跟踪行,这样main方法就会结束,我们可以在输出屏幕上看到这些行。如果您只是从Main方法中删除Console.ReadLine();并再次运行应用程序,生成的输出将如下所示:

从Main方法中移除Dispose()调用,即disposable.Dispose();,将导致以下结果。注意在Main方法结束时调用GC.Collect();,就像我们在第一步中做的那样:

这意味着无论开发者是否忘记丢弃,最终都会调用最终化器,我们在其中调用了Dispose(false);,最终释放了非托管资源。当然,最终化器会自动删除托管资源。您可以看到,在最后一种情况下,缺少了“有人调用了 Dispose”和“已丢弃托管资源”这两行。
第 3 步 - 修改派生类的 Dispose(bool)
由于我们有Dispose(bool)重载,它将直接在对象上可用以调用。我们没有必要将Dispose(bool)暴露给对象以进行直接调用,因为我们从Dispose()和最终化器内部调用它。
用户不应该传递布尔值并决定丢弃什么以及如何丢弃。他们唯一要做的事情是调用Dispose(),这将释放所有类型的资源。因此,我们将通过将访问修饰符从public更改为protected来限制对Dispose(bool)的调用。
Dispose(bool)是正在实现IDisposable的类的逻辑块。任何将要派生实现IDisposable的基类的类都可能有自己的自定义丢弃逻辑。因此,他们不需要添加另一个丢弃方法,只需覆盖基类的Dispose(bool)即可。为了实现这一点,我们需要在方法名前添加一个virtual关键字。
前面的段落要求修改我们非常知名的方法Dispose(bool):
protected virtual void Dispose(bool disposeManagedResources)
{
if (disposeManagedResources)
{
if (Chapters != null)
{
Chapters = null;
}
System.Diagnostics.Trace.WriteLine("Managed Resources disposed");
}
System.Diagnostics.Trace.WriteLine("Unmanaged Resources disposed");
}
第 4 步 - 处理重复的 Dispose 调用
我们应该管理用户可能多次调用Dispose()的情况。如果我们不处理这种情况,后续的调用将只是对运行时来说不必要的执行,因为运行时将尝试释放已经被丢弃的对象。
我们可以在类内部轻松地放置一个标志,该标志将指示对象是否已被丢弃。
bool disposed = false;
protected virtual void Dispose(bool disposeManagedResources)
{
if (disposed)
{
System.Diagnostics.Trace.WriteLine("Dispose(bool) already called");
return;
}
if (disposeManagedResources)
{
if (Chapters != null)
{
Chapters = null;
}
System.Diagnostics.Trace.WriteLine("Managed Resources disposed");
}
System.Diagnostics.Trace.WriteLine("Unmanaged Resources disposed");
disposed = true;
}
注意disposed变量,它在Dispose(bool)内部使用。我们在方法内部检查它是否为真。如果是真的,那么我们就从方法中返回/退出,否则执行丢弃代码。最后,我们将它设置为真。因此,第一次Dispose(bool)将完全执行,之后,它将仅在调用一次后返回。这样,我们防止了多次丢弃同一个对象,这是一个开销。
让我们修改代码以多次调用Dispose():
disposable.Dispose();
disposable.Dispose();
disposable.Dispose();
这将给出以下输出:

你可以看到,对于第一次调用,一切如预期进行。对于同一对象的下一个两个后续的 Dispose()调用,结果只是方法的一个简单返回。这就是为什么我们看到两组“Someone called Dispose”和“Dispose(bool) already called”的消息。
好的,我想在所有这些步骤之后展示最终的代码:
class ExampleIDisposable : IDisposable
{
public Dictionary<int, string> Chapters { get; set; }
bool disposed = false;
public ExampleIDisposable(Dictionary<int, string> chapters)
{
// Managed resources
System.Diagnostics.Trace.WriteLine("Managed Resources acquired");
Chapters = chapters;
// Some Unmanaged resources
System.Diagnostics.Trace.WriteLine("Unmanaged Resources acquired");
}
public void Dispose()
{
System.Diagnostics.Trace.WriteLine("Someone called Dispose");
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposeManagedResources)
{
if (disposed)
{
System.Diagnostics.Trace.WriteLine("Dispose(bool) already called");
return;
}
if (disposeManagedResources)
{
if (Chapters != null)
{
Chapters = null;
}
System.Diagnostics.Trace.WriteLine("Managed Resources
disposed");
}
System.Diagnostics.Trace.WriteLine("Unmanaged Resources disposed");
disposed = true;
}
~ExampleIDisposable()
{
System.Diagnostics.Trace.WriteLine("Finalizer called: Managed
resources will be cleaned");
Dispose(false);
}
}
不要忘记,你可以使用using语句与任何实现了IDisposable接口的类一起使用。例如,让我们为ExampleIDisposable编写代码:
using (ExampleIDisposable disposable =
new ExampleIDisposable(new Dictionary<int, string> {
{ 5, "Object Composition" },
{ 6, "Object Lifetime" }
}))
{
// Do something with the "disposable" object.
}
如果你运行这个,它会产生与步骤 2:定义 Dispose 重载方法部分下的第一个截图相同的结果。
.NET Core 中的对象生命周期管理
在前面的章节中,我们已经探讨了依赖注入是如何集成到.NET Core 中的。现在我们已经了解了对象是如何由.NET Framework 管理的,让我们来看看它们在.NET Core 中的生命周期。
只用一行代码,我可以说,在启动时,.NET Core 会取一个类,给它标记一个生命周期,然后实例化并存储在容器或服务集合中。考虑以下截图:

我们将探讨在.NET Core 中如何处理以下内容:
-
对象创建。
-
对象的生命周期。
-
完成所有工作后对象的处置。
对象创建
通常在 ASP.NET Core 2.0 中,注入的类型被称为服务。例如,注入的接口被称为IServiceCollection,我们可以通过使用这里的AddSingleton方法添加所需的服务。我们很快就会了解更多关于它的内容:
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddSingleton<IExampleService, ExampleService>();
}
当我们执行前面的代码时,ASP.NET Core 内置的 DI 框架执行两个重要的步骤:
-
实例化:提供的服务的对象(例如:
ExampleService)被实例化,以便在调用控制器时可以使用。对象通过构造函数注入或属性注入获得。IExampleService将是控制器的参数。这个接口的实现者ExampleService可以被实例化和注入。我们稍后会看到构造函数。 -
生命周期管理:然后它决定注入到控制器中的对象的生命周期(创建和处置)。框架提供了不同类型的生活周期,我们将在下一节学习。
让我们探索ASP.NET Core默认提供的三个生命周期模式。以下是一个关于这些生活周期的快速参考表:
| 生命周期 | 描述 | 处置 |
|---|---|---|
| 原型(临时或短期) | 每次请求服务时都会创建一个新的实例。 | 从不 |
| 作用域(范围或范围) | 为定义的作用域创建一个实例。当达到作用域结束时,如果需要,将创建另一个实例。一个简单的范围可以表述为一个Web 请求。任何请求特定Web 请求实例的资源都将从容器中提供相同的实例。 |
作用域结束时被处置 |
| 单例(单一或个体) | 容器创建一个实例,该实例将在整个应用程序的每个请求中用于/共享。 | 当容器被释放时被销毁 |
我们现在将详细查看每个概念。不用担心,如果你在阅读后感到困惑。在精心设计的例子之后,你肯定会理解这个概念的。
首先,让我们设计我们的 ASP.NET MVC Core 应用程序以使用所有这些类型的生命周期。这个例子与官方文档中给出的例子相似,可以在docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection找到。
此处的主要目标是理解实例在传入请求期间是如何保持的。我们将从两个地方调查这些实例:一个是从控制器,另一个是从一个类。这两个家伙都将使用相同的接口作为构造函数中注入的依赖项。让我们一步一步地来了解这些内容。
设计接口
首先编写一个接口IExampleService,它有一个Guid类型的属性ExampleId。然后编写四个实现此接口的接口。我们根据不同的生命周期类型给它们命名,这样我们就可以在之后轻松地识别它们:
public interface IExampleService
{
Guid ExampleId { get; }
}
public interface IExampleTransient : IExampleService
{
}
public interface IExampleScoped : IExampleService
{
}
public interface IExampleSingleton : IExampleService
{
}
public interface IExampleSingletonInstance : IExampleService
{
}
具体类
抽象意味着可以概念化的东西,而具体则意味着存在于现实中的东西,而不是抽象的。假设我们考虑三个形状,比如圆形、矩形和正方形。形状看起来像是一个概念,不是吗?另一方面,圆形、矩形和正方形实际上具有形状的行为来表示一个具体的概念。抽象类在本质上是不完整的,当一个类(或继承)其行为时,它就变得完整,这被表示为具体。
因此,抽象(或不完整)的概念意味着通过继承它的其他类来完成,形成具体类。抽象类是不完整的,只是一个概念,所以实例化它是没有意义的。例如,形状不是用来实例化的,而是用来被实际的形状类继承的。以下图表显示了这种关系:

一个单独的Example类实现了所有这些接口,并定义了ExampleId guid。有两个构造函数:一个接受Guid作为参数,另一个是默认的,它初始化一个新的 guid。所有这些接口都将在这个类的Startup中解析。我们很快就会接近那段代码:
using LifetimesExample.Interfaces;
using System;
namespace LifetimesExample.Models
{
public class Example : IExampleScoped, IExampleSingleton,
IExampleTransient, IExampleSingletonInstance
{
public Guid ExampleId { get; set; }
public Example()
{
ExampleId = Guid.NewGuid();
}
public Example(Guid exampleId)
{
ExampleId = exampleId;
}
}
}
服务类
正如我们之前所说的,使用这些接口创建一个简单的类,这个类被称作ExampleService。这里有一个构造函数,它等待接口被注入并分配给局部接口类型变量。
using LifetimesExample.Interfaces;
namespace LifetimesExample.Services
{
public class ExampleService
{
public IExampleTransient TransientExample { get; }
public IExampleScoped ScopedExample { get; }
public IExampleSingleton SingletonExample { get; }
public IExampleSingletonInstance SingletonInstanceExample { get; }
public ExampleService(IExampleTransient transientExample,
IExampleScoped scopedExample,
IExampleSingleton singletonExample,
IExampleSingletonInstance instanceExample)
{
TransientExample = transientExample;
ScopedExample = scopedExample;
SingletonExample = singletonExample;
SingletonInstanceExample = instanceExample;
}
}
}
控制器
控制器几乎与 Service 类相同,只是多了一个 ExampleService 的引用。它有一个构造函数来初始化它们。ExampleService 的属性将在视图中打印出来,这就是为什么我们引用那个类:
using Microsoft.AspNetCore.Mvc;
using LifetimesExample.Services;
using LifetimesExample.Interfaces;
namespace LifetimesExample.Controllers
{
public class ExampleController : Controller
{
private readonly ExampleService _exampleService;
private readonly IExampleTransient _transientExample;
private readonly IExampleScoped _scopedExample;
private readonly IExampleSingleton _singletonExample;
private readonly IExampleSingletonInstance _singletonInstanceExample;
public ExampleController(ExampleService ExampleService,
IExampleTransient transientExample,
IExampleScoped scopedExample,
IExampleSingleton singletonExample,
IExampleSingletonInstance singletonInstanceExample)
{
_exampleService = ExampleService;
_transientExample = transientExample;
_scopedExample = scopedExample;
_singletonExample = singletonExample;
_singletonInstanceExample = singletonInstanceExample;
}
public IActionResult Index()
{
// viewbag contains controller-requested services
ViewBag.Transient = _transientExample;
ViewBag.Scoped = _scopedExample;
ViewBag.Singleton = _singletonExample;
ViewBag.SingletonInstance = _singletonInstanceExample;
// Example service has its own requested services
ViewBag.Service = _exampleService;
return View();
}
}
}
在 Index() 动作中,我们正在将所有这些值返回到 ViewBag。
视图
最后,但同样重要的是,我们将简单地设计一个视图,以显示所有这些值,这样我们就可以开始观察。这将是 Views/Example 中的 Index.cshtml。
@using LifetimesExample.Interfaces
@using LifetimesExample.Services
@{
ViewData["Title"] = "Index";
}
@{
IExampleTransient transient = (IExampleTransient)ViewData["Transient"];
IExampleTransient scoped = (IExampleTransient)ViewData["Scoped"];
IExampleTransient singleton = (IExampleTransient)ViewData["Singleton"];
IExampleTransient singletonInstance = (IExampleTransient)ViewData["SingletonInstance"];
ExampleService service = (ExampleService)ViewBag.Service;
}
<h2>Lifetimes</h2>
<h3>ExampleController Dependencies</h3>
<table>
<tr>
<th>Lifestyle</th>
<th>Guid Value</th>
</tr>
<tr>
<td>Transient</td>
<td>@transient.ExampleId</td>
</tr>
<tr>
<td>Scoped</td>
<td>@scoped.ExampleId</td>
</tr>
<tr>
<td>Singleton</td>
<td>@singleton.ExampleId</td>
</tr>
<tr>
<td>Instance</td>
<td>@singletonInstance.ExampleId</td>
</tr>
</table>
<h3>ExampleService Dependencies</h3>
<table>
<tr>
<th>Lifestyle</th>
<th>Guid Value</th>
</tr>
<tr>
<td>Transient</td>
<td>@service.TransientExample.ExampleId</td>
</tr>
<tr>
<td>Scoped</td>
<td>@service.ScopedExample.ExampleId</td>
</tr>
<tr>
<td>Singleton</td>
<td>@service.SingletonExample.ExampleId</td>
</tr>
<tr>
<td>Instance</td>
<td>@service.SingletonInstanceExample.ExampleId</td>
</tr>
</table>
最后,我们完成了代码。你确定吗?我们忘记了主要入口点。决定哪个类将针对哪个接口进行解析的那个。
Startup ConfigureServices
现在,每种类型的生命周期都被添加到使用 Example 类解析的容器中。ExampleService 类为自己解析。这意味着,无论何时在应用程序的任何地方需要 ExampleService 进行注入,它都会自动分配一个具有所有属性的该类对象:
public void ConfigureServices(IServiceCollection services)
{
// Add framework services.
services.AddMvc();
services.AddTransient<IExampleTransient, Example>();
services.AddScoped<IExampleScoped, Example>();
services.AddSingleton<IExampleSingleton, Example>();
services.AddSingleton<IExampleSingletonInstance, Example>();
services.AddSingleton(new Example(Guid.Empty));
services.AddTransient<ExampleService, ExampleService>();
}
Add*** 方法(具有不同的生命周期)确保对象根据预期的行为被创建。一旦这些对象被初始化,它们就可以在任何需要的地方被注入。
如果我们现在运行应用程序,对于第一个请求,我们将得到以下输出:

我在另一个标签页中打开了页面(或者你也可以简单地刷新标签页)。我看到以下输出:

对象生命周期
让我们逐一检查所有生命周期,根据我们在这些截图中看到的价值。这些值帮助我们识别对象。
单例
观察截图和图表,首先明显看到的是 Singleton。无论在运行应用程序后进行多少次请求,对象都是相同的。它不依赖于控制器或 Service 类。
作用域
对于特定的请求,对象在整个作用域内是相同的。注意在第二个请求中对象的 guid 值是如何不同的。然而,在两个请求中,控制器和 Service 类的值是相同的。这里的范围是 Web 请求。当服务另一个请求时,对象将被重新创建。
虽然在代码中创建作用域是可能的。有一个名为 IServiceScopeFactory 的接口,它公开了 CreateScope 方法。CreateScope 是 IServiceScope 类型,它实现了 IDisposable。在这里,using 块帮助我们处理作用域实例的释放:
var serviceProvider = services.BuildServiceProvider();
var serviceScopeFactory = serviceProvider.GetRequiredService<
IServiceScopeFactory>();
IExampleScoped scopedOne;
IExampleScoped scopedTwo;
using (var scope = serviceScopeFactory.CreateScope())
{
scopedOne = scope.ServiceProvider.GetService<IExampleScoped>();
}
using (var scope = serviceScopeFactory.CreateScope())
{
scopedTwo = scope.ServiceProvider.GetService<IExampleScoped>();
}
我们在前面代码中使用 CreateScope 创建了两个作用域实例。这两个实例相互独立,并且不像普通的作用域实例那样在请求中共享。这是因为我们手动指定了作用域而不是默认的 Web 请求作用域。
临时
简单!每次从容器请求时都会创建一个新的对象。在截图中,你可以看到即使对于单个请求,控制器和 Service 类的 guid 值也是不同的。
实例
最后一个是 Singleton 的特殊情况,用户创建对象并将其提供给AddSingleton方法。因此,我们明确地创建了Example类的对象(services.AddSingleton(new Example(Guid.Empty))),并要求 DI 框架将其注册为 Singleton。在这种情况下,我们发送了Guid.Empty。因此,一个空的 GUID 被分配,对所有请求保持不变。
对象销毁
当我们直接使用Add***方法注册服务时,容器负责创建对象和管理其生命周期。本质上,它会为实现了IDisposable接口的对象调用Dispose方法,根据生命周期进行管理。
考虑以下示例,其中ServiceDisposable实现了IDisposable,我们告诉服务将其生命周期管理为Scoped。因此,它将创建实例,然后在整个应用程序的资源中为单个请求提供它。最后,当请求结束时,它将销毁它:
public class ServiceDisposable : IDisposable {}
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped(ServiceDisposable);
}
然而,当我们显式创建对象并将其提供给 DI 时,我们需要自己处理其销毁。在以下情况下,我们必须在某个地方手动调用Dispose()以销毁实例:
public class ServiceDisposable : IDisposable {}
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped(new ServiceDisposable());
}
何时选择什么?
我们如何处理应用程序中的不同对象,以使它们消耗有限的内存空间和资源,同时优化性能,这一点很重要。
-
需要占用更多空间并使用大量服务器资源的对象不应被重新创建,而应重用。例如,数据库对象应重用于所有遵循
Singleton模式的后续请求。 -
在批量或循环中运行的操作可以在特定请求中重用,但对于另一个请求则应重新创建。这表明是 Scoped 生命周期。
-
每次我们尝试创建
Model和View Model类时,都应该实例化它们。在进行 CRUD 操作时,我们不能重用Model类对象,否则可能会导致错误的值进入数据库。当然,这是Transient的,因为它存在的时间很短。
生命周期之间的关系和依赖
在本节中,我们将深入挖掘生命周期之间的关系。原因很清楚。当我们用不同类型的生活方式实例化我们的类时,我们可能会遇到需要另一个类依赖一个类的情况。但是,这里的难点是它们可能不遵循类似的生活方式。那么,在Singleton类内部引用的遵循 scoped 生命周期的实例会发生什么?它是否表现得像 Scoped?
让我们动手做一些代码修改,将依赖注入到彼此中。
根据 Scoped 和 Transient 选择 Singleton
首先,我们需要向现有的IExampleSingleton接口添加两个新属性:
public interface IExampleSingleton : IExampleService
{
Guid ScopedExampleId { get; }
Guid TransientExampleId { get; }
}
接下来,我们想要为所有生命周期设计一个新类。正如我们计划的,让我们通过构造函数将 Transient 和 Scoped 依赖项注入到这个Singleton类中。为依赖的生活方式定义的属性将从参数中相应地分配值。
using System;
namespace LifetimesExample
{
public class ExampleSingleton : IExampleSingleton
{
public Guid ExampleId { get; set; }
public Guid ScopedExampleId { get; set; }
public Guid TransientExampleId { get; set; }
public ExampleSingleton(IExampleTransient transient, IExampleScoped scoped)
{
ExampleId = Guid.NewGuid();
ScopedExampleId = scoped.ExampleId;
TransientExampleId = transient.ExampleId;
}
}
public class ExampleScoped : IExampleScoped
{
public Guid ExampleId { get; set; }
public ExampleScoped()
{
ExampleId = Guid.NewGuid();
}
}
public class ExampleTransient : IExampleTransient
{
public Guid ExampleId { get; set; }
public ExampleTransient()
{
ExampleId = Guid.NewGuid();
}
}
}
我只是为了这本书的可读性,在同一个地方定义了所有类。理想情况下,你应该每次都把它们添加到不同的文件中。
控制器是我们需要添加操作的下一个地方,该操作将返回一个视图,我们将展示其中的值。
using Microsoft.AspNetCore.Mvc;
namespace LifetimesExample.Controllers
{
public class ExampleController : Controller
{
private readonly ExampleService _exampleService;
private readonly IExampleTransient _transientExample;
private readonly IExampleScoped _scopedExample;
private readonly IExampleSingleton _singletonExample;
public ExampleController(ExampleService ExampleService,
IExampleTransient transientExample,
IExampleScoped scopedExample,
IExampleSingleton singletonExample)
{
_exampleService = ExampleService;
_transientExample = transientExample;
_scopedExample = scopedExample;
_singletonExample = singletonExample;
}
public IActionResult SingletonDependencies()
{
ViewBag.Singleton = _singletonExample;
ViewBag.Service = _exampleService;
return View("Singleton");
}
}
}
这与我们在Index操作中做的是相似的。区别在于我们移除了SingletonInstance引用,并返回了一个名为Singleton的视图。
视图看起来可能如下所示:
@{
ViewData["Title"] = "Index";
}
@{
IExampleSingleton singleton = (IExampleSingleton)ViewData["Singleton"];
ExampleService service = (ExampleService)ViewBag.Service;
}
<h2>Singleton Lifetime Dependencies</h2>
<h3>ExampleController</h3>
<h5><u>Singleton ExampleId: @singleton.ExampleId</u></h5>
<table>
<tr>
<th>Dependencies</th>
<th>Guid Value</th>
</tr>
<tr>
<td>Scoped Dependency</td>
<td>@singleton.ScopedExampleId</td>
</tr>
<tr>
<td>Transient Dependency</td>
<td>@singleton.TransientExampleId</td>
</tr>
</table>
<h3>ExampleService</h3>
<h5><u>Singleton ExampleId: @service.SingletonExample.ExampleId</u></h5>
<table>
<tr>
<th>Dependencies</th>
<th>Guid Value</th>
</tr>
<tr>
<td>Scoped Dependency</td>
<td>@service.SingletonExample.ScopedExampleId</td>
</tr>
<tr>
<td>Transient Dependency</td>
<td>@service.SingletonExample.TransientExampleId</td>
</tr>
</table>
因此,我正在尝试打印Singleton对象的ExampleId以及与依赖对象(Transient和Scoped)相关的属性。我已经省略了这段代码中的样式,只是为了使表格看起来更酷。
是时候告诉Startup ConfigureService注册具有适当生命周期的类了:
public void ConfigureServices(IServiceCollection services)
{
// Add framework services.
services.AddMvc();
services.AddSingleton<IExampleSingleton, ExampleSingleton>();
services.AddScoped<IExampleScoped, ExampleScoped>();
services.AddTransient<IExampleTransient, ExampleTransient>();
services.AddTransient<ExampleService, ExampleService>();
}
哇!我们完成了。让我们检查输出。我已经并排粘贴了两个请求的截图,这样我们可以轻松地标记发现的内容:

观察:由于下划线的ExampleId值相同,Singleton对象在两个请求之间被共享。
等一下!这里有些奇怪。依赖对象的值在请求之间也是相同的。注意红色块中的值。尽管这些类被注册为Scoped和Transient,但它们的行为像Singleton。这意味着这些对象的正常生命周期被篡改了。
推断:不建议在Singleton类内部引用Scoped和Transient生命周期类,因为它们将失去其正常行为并变成 Singleton。
显然,一个Singleton类可以依赖于另一个Singleton类。同样,其他生活方式遵循相同的规则。因此,一个Scoped类可以引用另一个Scoped类,一个Transient可以引用另一个Transient。当执行时,它们都将按预期行为。
Scoped 依赖于 Singleton 和 Transient
同样,我们可以在Scoped类内部测试依赖关系。我们将从向接口IExampleScoped添加两个属性开始:
public interface IExampleScoped : IExampleService
{
Guid SingletonExampleId { get; }
Guid TransientExampleId { get; }
}
ExampleScoped现在应该实现这两个属性。同时,与Transient和Singleton相关的接口需要注入到constructor中:
public class ExampleScoped : IExampleScoped
{
public Guid ExampleId { get; set; }
public Guid SingletonExampleId { get; set; }
public Guid TransientExampleId { get; set; }
public ExampleScoped(IExampleTransient transient, IExampleSingleton singleton)
{
ExampleId = Guid.NewGuid();
SingletonExampleId = singleton.ExampleId;
TransientExampleId = transient.ExampleId;
}
}
添加了一个新的操作,它将返回名为Scoped的视图:
public IActionResult ScopedDependencies()
{
ViewBag.Scoped = _scopedExample;
ViewBag.Service = _exampleService;
return View("Scoped");
}
看起来我们已经完成了。让我们运行应用程序:

哎呀!我们看到了一个异常屏幕,上面显示检测到一个循环依赖。
循环依赖,正如其名所示,是一个依赖于另一个类的类,而另一个类又反过来依赖于第一个类。我们设计了一切来测试 Scoped 生活方式中其他生活方式的依赖,但在做之前,我们忘记了一件事。之前,我们在Singleton类内部添加了Scoped类的依赖,现在,如果你看到前面的ExampleScoped构造函数,我们现在注入IExampleSingleton,它被解析为Singleton类ExampleSingleton。这就是它变成循环的原因。
因此,我们需要从Singleton类中移除依赖以进行测试。我们还可以通过为Singleton创建另一个接口和类来进行测试。所以,当你修复代码时,我们将得到以下输出。我这里不会写View的代码。它与我们在Singleton中做的几乎一样。我们只需要打印ExampleId、SingletonExampleId和TransientExampleId:

观察: 在红色框中,我们有瞬态对象值。这是因为它们不再是Transient了,它们的行为像Scoped一样,因为值在请求中是相同的,这并不是Transient的样子。它应该在每次请求时都不同。但在Singleton依赖的情况下,它在请求之间是相同的,这不仅满足了Singleton范式,而且在特定请求中表现得像Scoped。
推断: 这就是为什么建议在Scoped类内部使用Scoped和Singleton依赖,而不是Transient。
Transient 依赖于 Singleton 和 Scoped
对于Transient,我们将按照相同的模式设计所需的接口和类。接口看起来如下:
public interface IExampleTransient : IExampleService
{
Guid SingletonExampleId { get; }
Guid ScopedExampleId { get; }
}
接下来是Transient类依赖于Singleton和Scoped。
public class ExampleTransient : IExampleTransient
{
public Guid ExampleId { get; set; }
public Guid SingletonExampleId { get; set; }
public Guid ScopedExampleId { get; set; }
public ExampleTransient(IExampleSingleton singleton, IExampleScoped scoped)
{
ExampleId = Guid.NewGuid();
SingletonExampleId = singleton.ExampleId;
ScopedExampleId = scoped.ExampleId;
}
}
最后,但同样重要的是,一个新的动作来渲染视图Transient:
public IActionResult TransientDependencies()
{
ViewBag.Transient = _transientExample;
ViewBag.Service = _exampleService;
return View("Transient");
}
在设计视图之后运行,最终结果可能如下所示:

你可以看到Transient ExampleId在请求内外是如何不同的。
观察: 你可能想知道为什么这张图片中没有红色框。那是因为一切看起来都很完美。Singleton到处都是相同的,Scoped在特定请求中是相同的,然而,在下一个请求中会有变化。
推断: 这意味着,这两种依赖在注入到Transient类内部时都保留了它们通常的特性。另外,如果注入到Transient类,另一个Transient依赖肯定也能工作。
说了这么多,我想说的是,这些都是将一种生活方式注入到另一种生活方式时应遵循的推荐模式。在设计类及其生活方式时,我们应该小心。要么我们最终会陷入循环依赖,要么会失去注入到生活方式中的适当行为,正如我们在前面的例子中所看到的。只要我们意识到后果,我们仍然可以混合使用。
只用一个简单的表格,我就能表达整个要点:

摘要
在本章中,我们学习了.NET Framework 如何创建和销毁对象。我们讨论了创建和销毁机制。垃圾回收器在通过终结器自动处理中扮演着重要角色,我们通过示例进行了分析。
最重要的是,我们看到了如何通过实现IDisposal接口来手动处理对象的步骤分解。
之后,我们探讨了.NET Core 中对象维护的不同生命周期。我们通过控制器和服务类示例了解了对象的创建和销毁过程。最重要的是,我们实验了不同生活方式之间的适应性。
依赖注入的另一个支柱——拦截,将在第七章“拦截”中介绍。
第七章:拦截
在前两章中,我们已经讨论了 DI 生态系统的两个支柱。这些支柱为我们提供了识别依赖项、注册它们以及根据需要管理它们的生命周期的途径。在本章中,我们将介绍一些不同的事情,它解决了 DI 的架构问题。
一个没有安全、日志记录、缓存、事务管理等功能的程序似乎很不完整。当我们编写代码来覆盖所有这些功能时,我们可能会在应用程序的每个模块中编写重复的代码。即使我们的应用程序中存在依赖注入,试图管理类的需求并提供之前提到的日志记录或其他类型,也违反了 SRP 和 DRY 原则。因此,我们需要一种不同的方法来解决这个问题,那就是 拦截。简单来说,拦截将帮助我们动态地在需要的地方注入代码块。
现在,你可能会有一个关于何时以及如何注入此代码的问题。现在我们先保留“如何”这个问题,专注于“何时”。我们很快会讨论“如何”。为了回答“何时”,注入可以在执行之前或之后进行,甚至可以完全替换实际的方法执行。
在本章中,我们将了解拦截是什么以及如何实现它。我们将找出可能的干扰执行流程的技术。当然,我们还会讨论为什么我们需要拦截。
.NET Core 中拦截的模式和原则是我们接下来要说明的内容。我们甚至将扩展 ASP.NET MVC Core 中使用过滤器和中件间的拦截概念。
在本章中,我们将涵盖以下主题:
-
横切关注点和方面
-
面向切面编程(AOP):其类型、优点、缺点和过程
-
每种类型 AOP 的演示
-
使用过滤器和中件间在 ASP.NET Core MVC 中实现拦截
介绍拦截
在本节中,我将向您介绍 拦截 以及它与 依赖注入 的联系。
拦截是另一个重要的支柱。拦截是通过它可以轻松拦截消费者和服务之间的调用,以便在服务被调用之前或之后执行某些代码的过程。
以下图表显示了有和无拦截过程时会发生什么:

如果我们将前面的请求视为一个名为CompanyController的控制器索引操作的调用,并且在将公司详细信息提供给客户端之前,我们可以运行一些账目任务,例如谁请求了公司详细信息?,何时进行的调用**?,用户是否有权接收公司详细信息?等等。对于所有这些任务,我们必须在控制器中停止流程以执行所有这些操作,完成后,我们再次恢复主要任务,即返回数据。但是,所有这些任务都不能写在动作方法中,因为动作的主要责任是返回公司详细信息。如果我们把所有东西都写在动作方法中,就会违反 SRP。
装饰器
让我们通过一个需要基本日志记录作为其操作一部分的类来找出答案。CompanyRepository可能有一个将User分配给Company的方法。
public void AssignUserToCompany(int userId, int companyId)
{
// Database operation to assign User to Company.
}
现在,一个客户端要求你在执行此操作时记录一些重要信息,仅用于账目和未来参考。这些信息可能是操作的开始时间、操作的结束时间、谁请求了操作、是否有异常等。你脑海中浮现出的即时解决方案可能看起来像以下这样:
public void AssignUserToCompany(int userId, int companyId)
{
_logger.Log("Assign to User started.");
// Database operation to assign User to Company.
_logger.Log("Assign to User ended.");
}
注意粗体线条。ILogger可以通过构造函数注入到存储库中,以进行日志操作。我们还可以放置try...catch块并记录异常。一切看起来都很好,但你难道不认为这个类做得比它打算的更多,并且我们违反了单一职责原则(SRP)吗?因此,让我们考虑另一个解决方案。以下是一个例子?
public class LoggingCompanyRepository : CompanyRepository
{
private readonly CompanyRepository _companyReposiory;
private readonly ILogger _logger;
public LoggingCompanyRepository(CompanyRepository
companyRepository, ILogger logger)
{
this._companyReposiory = companyRepository ??
throw new ArgumentNullException("companyRepository");
this._logger = logger ?? throw new
ArgumentNullException("logger");
}
public override void AssignUserToCompany(int userId,
int companyId)
{
_logger.Log("Assign to User started.");
_companyReposiory.AssignUserToCompany(userId,
companyId);
_logger.Log("Assign to User ended.");
}
}
我们引入了一个名为LoggingCompanyRepository的装饰CompanyRepository,它负责管理日志部分。它通过接受存储库和日志记录依赖项并在需要时执行带有日志条目的方法进行初始化。遵循装饰器模式,新类试图协调存储库和记录器之间的工作。
最后的任务是组合装饰器,以下步骤如下:
-
我们可以创建
SqlCompanyRepository和SqlLogger的实例,它们源自相关抽象。 -
然后,我们通过注入这些依赖项来创建装饰器的实例。
-
我们返回带有装饰存储库的
CompanyService实例。
参考以下代码以获取这些步骤:
public ICompanyService ResolveCompanyService()
{
CompanyRepository companyRepository = new
SqlCompanyRepository("ConnectionString");
Controllers.ILogger logger = new SqlLogger();
CompanyRepository loggingCompanyRepository = new
LoggingCompanyRepository(companyRepository, logger);
return new CompanyService(loggingCompanyRepository);
}
这就是截获的全部内容。我们能够中断对具体类SqlCompanyRepository的AssignUserToCompany方法的调用,因为Service现在由一个装饰器与具体类组合而成,而不是直接代码块。当你调用方法时,它首先会调用装饰器的方法,然后调用具体类的方法。
下面的图解说明了流程:

截获模式和原则
你可能已经察觉到,我想让你从不仅注入依赖,还管理并维护代码以构建良好架构的角度来体验依赖注入(DI)。在上一个章节中,我们探讨了装饰器模式,这使得我们能够在不触及应用中任何类的情况下,用一点额外的代码来装饰具体的实现。这构成了拦截的基础。
通过拦截,我们清楚地遵循了软件设计的 SOLID 原则。当我们开始设计装饰器类时,这个类在 SRP 中扮演了至关重要的角色。CompanyRepository 负责数据库部分,而 LoggingCompanyRepository 负责日志记录。
里氏替换原则(Liskov Substitution Principle,LSP)要求消费者不应该感觉到依赖任何实现的变化。我们通过装饰器实现了 Service 所要求的相同抽象,即 CompanyRepository,这样我们就可以在不破坏服务代码的情况下,用装饰器 LoggingCompanyRepository 替换原始的 SqlCompanyRepository。
在进行所有前面的步骤时,我们没有更改任何类来实现装饰器。相反,我们进行了扩展,这强烈遵循了开放/封闭原则。没有必要触及 Service 类或消费者。
遵循 SOLID 原则的拦截背后的原则让我们了解到装饰器模式与 DI 密切相关,因为 DI 支持拦截。装饰器模式是这一概念的基本构建块,但我们的实现方式并没有解决手头的架构问题。让我们分析相关的利益和问题。
装饰器模式的好处
使用装饰器模式,我们获得了许多好处。请参考以下列表:
-
Service不了解它接收的是哪个仓库。请注意,CompanyRepository和LoggingCompanyRespository都被声明为CompanyRepository实例。 -
就因为类型相同,没有必要对
Service类进行任何更改。此外,我们在不更改CompanyRepository类的情况下添加了日志行为。这支持了开放/封闭原则。所有类都保持完好。 -
我们能够拦截具体的
SqlCompanyRepository方法以实现日志条目。因此,我们没有违反仓库的单一职责原则(SRP)。
然而,我们没有意识到这种方法存在的一些问题。这种结构从长远来看会引导我们走向糟糕的架构。让我们找出原因。
而不是手动处理实例,始终使用 DI 容器来解析依赖。我们很快就会看到如何使用 DI 容器进行拦截。
装饰器模式的问题
在一个典型的项目中,我们将拥有许多这样的仓库和服务。我们肯定会遇到以下问题:
-
想象一下,你需要编写多少行代码来编写装饰器,以便为数百个类进行拦截。
-
接下来的一件事非常重要,这实际上是讨论主题的基础。如果你需要在那些一百个类的一千个方法中记录日志,这是常见的,我们几乎在每个方法都需要它。想想涉及的工作量和你的应用程序的大小。
由于所有这些,我们将最终采用不同的方法来使用切面拦截方法调用。依赖注入容器使我们能够轻松设计切面并附加拦截器。让我们继续前进。
面向切面编程
面向对象编程(OOP)处理使用底层对象模型解决现实世界问题的技术。为了设计软件,我们需要采用 OOP 以及良好的设计方法,以便使其可维护、一致和可靠。在应用程序开发过程中,我们会遇到许多这样的模式或问题,它们是设计决策,既不能通过 OOP 实践解决,也不能通过过程式方法解决。
在本章中,我们将讨论一个可以通过切面轻松管理的设计决策。为了澄清,我们可以考虑一个在代码中非常常见的简单示例,那就是日志记录。我们在几乎每个方法中都进行日志记录。这意味着你每天都在重复代码,违反了DRY(不要重复自己)原则。
以下图表显示了在您的代码中常见的大量重复日志代码:

如果我告诉你,我们可以将这些日志行打包成一个模块化的代码块,并要求所有需要日志记录的方法在运行时只需导入它,你会怎么反应?是的,这就是切面的全部意义。因此,我们可以编写一个切面来管理日志。我们在应用中实现切面时遵循的图案被称为面向切面编程(AOP)*。它旨在将这些重复代码从核心功能中分离或模块化。
以下是一个名为LoggingAspect的切面的解决方案预览,它将所有日志代码打包在其中,并作为方法属性附加到方法之上(写作 [LoggingAspect])。现在不必担心日志行如何应用于特定位置的方法,例如开始或结束。我们很快就会学习所有这些,这些都是面向方面编程(AOP)背后的概念:

当我们在应用程序中遵循 AOP 时,我们可以轻松地将这些模块自动附加到我们的方法上。因此,开发者可以完全专注于方法内的业务逻辑或核心关注点,而无需担心所有重复的代码。
横切关注点
关注点可以定义为系统的一部分,提供一些特定的功能。如果你考虑一个典型的例子,在你的系统中保存用户,它可能有一个类似以下的业务逻辑:
-
验证用户的所有字段,以确保正确的数据类型、
null值等。 -
通过唯一值(如
Email或UserName)检查系统中是否存在用户。 -
如果用户不存在,则创建用户,否则更新用户。
所有这些步骤都被标记为业务逻辑,这可能在不同的应用程序中不同,因为它取决于你的需求和设计。因此,我们的SaveUser方法的主要职责是执行所有这些步骤,这可以定义为主要关注点。
然而,在一个典型的应用程序中,这些并不是SaveUser方法中发生的唯一事情。在保存用户的过程中,你可能必须做以下事情:
-
检查登录用户是否有权限保存用户。
-
在文本/数据库中记录
SaveUser开始。 -
[执行业务逻辑(所有之前定义的步骤)] - 主要关注点。
-
在文本/数据库中记录
SaveUser方法成功。
步骤 1、2 和 3 是次要步骤,与主要关注点不匹配。但这些步骤不能被忽略。此外,这些特定的步骤几乎在应用程序的每个方法中都会执行。
当我们的方法试图运行主要关注点时,这些家伙试图干扰执行并做其他事情。这就是为什么它们被称为横切关注点。这些关注点位于整个应用程序中,影响整个架构。看看以下图表,其中箭头表示主要关注点,矩形区域表示横切关注点:

Aspect
简而言之,当我们看到应用程序中存在一些重复的模式代码时,这就是我们将其视为横切关注点的线索。这些是代码补丁,它们与执行方法中的主要关注点不匹配。
当我在编程范式中说aspect时,它基本上是指独立于实际任务/关注点的行为模式。因此,当你将横切关注点打包成一个可以注入到方法中的模块化部分时,你实际上设计了一个方面。
例如,我们希望在许多方法中的某些行进行日志记录:
public void SaveUser(User user)
{
Log.Debug("SaveUser started");
try
{
// Service call to save the user.
}
catch(Exception ex)
{
Log.Error(ex, "Exception in SaveUser Method");
throw;
}
finally
{
Log.Debug("SaveUser Method completed.");
}
}
显然,Log类方法帮助我们记录方法内部的调试步骤和异常。这种特定的模式在应用程序的许多地方都可以看到。这就是所谓的横切关注点。SaveUser方法实际的任务是更新用户详情,例如UserName、Email、Password等,但此方法还承担记录一些行到文件的责任,这违反了 SRP 原则。此外,当这些模式在项目中重复出现时,它并不遵循 DRY 原则。
看看以下图表,它描述了日志记录作为跨应用层的一个常见范式:

因此,在这里,AOP 就派上用场了,它封装了这个模式来记录开始、记录结束和记录异常,然后将其包装在SaveUser方法周围。
另一个常见的场景是将我们的数据库操作包裹在一个由Begin Transaction和Commit/Rollback Transaction组成的Transaction块中。那么,如果有人帮我们处理这部分工作,我们就可以专注于操作数据库的核心代码,怎么样?
横切关注点通常可以在应用程序中找到,例如记录步骤、处理异常、管理缓存、访问控制等。例如,在处理异常时,你使用try..catch块来操作找到的异常(记录到文本文件/数据库或发送电子邮件给管理员等)。这种特定的模式需要在每个方法中。现在我们需要找到一种方法来请求(更具体地说,包装)我们需要用于异常处理的每个方法,而不是在它内部添加try..catch行。因此,这个包装的模块化部分可以被称为方面,它最终将所有指示它执行异常处理的方法包装起来,而不是让这些方法本身执行相同的操作。
方面特性
当我们尝试将这些横切关注点封装成方面时,我们实际上确保它们遵循一些特性:
-
包装器:所有这些方面都将围绕一些业务功能进行包装。
-
单一职责原则(SRP):包装器只关注一个特定的任务。例如,日志记录只会执行日志记录的任务,不会做其他事情。
-
装饰器模式:然后使用装饰器模式将方面附加到现有函数上。
- 开闭原则:当这些重复的代码在业务函数中,并且如果未来需要对这些代码进行任何更改,那么我们就必须更改包含这些片段的业务函数,这违反了开闭原则。现在,当我们将这些片段隔离到模块中时,业务函数就变得开放于扩展,但关闭于更改。
请参考以下图表以获得图形说明:

AOP 不是 OOP 的竞争对手。两者完全不同。一个不是另一个的替代品。有了所有这些特性,AOP 帮助我们保持项目的良好结构,从而实现良好的 OOP 实践。
优点
让我总结一下 AOP 的重要优点:
-
增加模块化:常见功能被集中到独立的模块中,并在应用程序的许多地方附加这些模块或方面。
-
管理横切关注点:这些分散的关注点集中在一个地方,可以轻松管理,而无需对实际业务代码进行任何代码更改。
-
更好的架构:将这些关注点分离到单一责任方面有助于我们构建和整理业务需求,而无需在代码中不必要地重复自己。
方面附加位置
基本上,方面可以附加到方法的基本有三个位置:
-
启动时:当我们希望方面在底层函数执行之前立即执行。
-
错误处理:显然,只有当方法中发生异常时,此位置的方面才会运行。
-
成功时:在方法执行后立即运行。然而,它仅限于不抛出异常的函数。
AOP 类型
AOP 中有两种技术:
-
拦截器:动态的,在运行时附加拦截器
-
IL 代码织入:静态的,在编译后运行并插入代码到程序集
静态(编译后)的包括 Fody、SheepAspect、Mono.Cecil 和 PostSharp。静态可能更快,但在动态的拦截器中我们获得更多的灵活性,因为我们可以在运行时动态地更改代码。
将方面应用于代码取决于所使用的框架。有不同的技术来附加方面,例如编写属性、XML 配置和流畅接口。
检查拦截
要拦截意味着阻止某物或某人达到预期的目的地。
拦截(一种编程范式),帮助我们设计方面,并在运行时根据需要注入横切关注点。使用拦截器,我们可以轻松拦截类中方法和对属性的调用。为了实现这一点,我们通常最终使用控制反转(IoC)容器。
IoC 提供类功能,然后将其包装为我们请求的拦截器。假设在代码的某个地方,你向特定类型的 IoC 容器(例如IStudent)请求一个类(例如Student),该容器有一个用于记录的方面,那么 IoC 容器就能够提供带有该方面装饰的拦截器来满足所需。
拦截器由外部组件管理,这些组件创建动态装饰器,用于围绕现有的业务组件包装方面。
拦截器的主要优势是它们不受编译过程的限制,并且不会在构建后修改我们的程序集。换句话说,这只是 IoC 容器的配置,你可以轻松地将其带到另一个项目中,而不是携带 DLL 并再次编译它们。
然而,拦截器可以配置为在运行时或编译时工作。
Unity、Ninject、Spring.NET、Castle Windsor、LinFu、Autofac、LOOM.NET、Seasar 等是一些允许在编译时或运行时注入拦截器的 IoC 容器。
这些 IoC 容器使用动态代理创建内存中的装饰器,这些装饰器包装了你的现有代码。这些动态代理负责代码执行。因此,它允许执行包装方面以及被包装的底层代码。
拦截过程
拦截过程可以描述如下:

下面是具体发生的情况:
-
调用代码询问 IoC 容器它正在寻找的类型。例如,
IExampleInterface类型。 -
IoC 容器现在尝试匹配请求的类型的具体实现,它可以将其返回给调用代码,即
Example类。在这个过程中,它认识到该类型已经配置为使用拦截器。 -
现在,IoC 容器不是直接返回到调用代码,而是将拦截器和请求类型的实现类发送给动态代理。
-
动态代理现在将具体类包装起来,并使用拦截器。然后,它生成一个实现最初请求的类型,并使用 IoC 容器提供的具体类实例和拦截器。之后,它将请求类型的实现返回给 IoC 容器。这就是
Interceptor类。 -
现在,IoC 容器将动态代理生成的
Interceptor类发送回调用代码。 -
调用代码执行返回的类,该类随后运行拦截器和具体类
Example的底层代码。
Castle Windsor
Castle Windsor 是一个控制反转容器。这个库是开源项目 Castle Project 的一部分。Castle Project (www.castleproject.org/) 提供了许多用途的可重用库。Castle 有许多组件,Windsor 是 Castle Project 的依赖注入容器组件。然而,它可以独立于其他 Castle 组件使用。
市场上还有许多其他库,如 Microsoft Unity、Autofac、Ninject 等。每个框架都提供了一些不同的优点和功能。然而,在底层,它们对大多数核心概念(包括类型注册、解析和注入)的实现是相同的。你可以无疑地使用这些中的任何一个来在你的应用程序中应用拦截。
尽管如此,我们使用 Castle Windsor 的逻辑并不存在。使用这个工具,我们可以轻松地将拦截器附加到我们的代码上。我们稍后会看到这一点。
使用 Castle Windsor 的演示
让我们开始使用 Nuget 包 Castle Windsor 实现一个拦截器。首先,创建一个控制台应用程序,进入 Nuget 包管理器并安装 Castle Windsor 包。安装成功后,你的项目引用将如下所示:

因此,它安装了 Castle.Core 和 Castle.Windsor。现在我们准备好创建一个拦截器了。
创建一个拦截器
我们将做一个简单的日志拦截器,它将为我们记录步骤以及异常。让我们称它为 LoggingInterceptor.cs。要成为拦截器,类应该实现 Interceptor 接口。这个接口中只有一个方法,即 Intercept,它接受 IInvocation 作为参数。
以下代码块展示了我说的话:
using Castle.DynamicProxy;
using System;
namespace ConsolePacktApp
{
public class LoggingInterceptor : IInterceptor
{
public void Intercept(IInvocation invocation)
{
invocation.Proceed();
}
}
}
调用 invocation.Proceed() 只是调用应该被拦截的底层方法。这意味着,当任何注册使用此拦截器的注册方法时,它将来到这个方法,然后从这个方法中调用相同的 Proceed() 方法。
为了调查拥有拦截器的实际好处,我们将在以下代码块中添加更多代码:
using Castle.DynamicProxy;
using System;
namespace ConsolePacktApp
{
public class LoggingInterceptor : IInterceptor
{
public void Intercept(IInvocation invocation)
{
try
{
Console.WriteLine("Log Interceptor Starts");
invocation.Proceed();
Console.WriteLine("Log Interceptor Success");
}
catch (Exception e)
{
Console.WriteLine("Log Interceptor Exception");
throw;
}
finally
{
Console.WriteLine("Log Interceptor Exit");
}
}
}
}
接下来是设计一个接口和一个具体类:
using System;
namespace ConsolePacktApp
{
public interface IExample
{
void PrintName(string FirstName, string LastName);
}
public class Example : IExample
{
public void PrintName(string FirstName, string LastName)
{
Console.WriteLine($"Name is {FirstName} {LastName}");
}
}
}
因此,PrintName() 方法使用 FirstName 和 LastName 打印名字。我们将拦截这个方法并尝试使用拦截器添加日志步骤。
添加拦截器
现在最重要的部分是 ExampleRegistration,它实现了 Castle.MicroKernel.Registration 命名空间中的 IRegistration 接口。
这是我们注册 LoggingInterceptor(参考以下代码块中的第一条注释)并声明将被 LoggingInterceptor 拦截的 Example 类(参考第二条注释)的地方:
using Castle.MicroKernel.Registration;
using Castle.MicroKernel;
using Castle.Core;
namespace ConsolePacktApp
{
public class ExampleRegistration : IRegistration
{
public void Register(IKernelInternal kernel)
{
// 1\. Interceptor Registration
kernel.Register(
Component.For<LoggingInterceptor>()
.ImplementedBy<LoggingInterceptor>());
// 2\. Interceptor attached with Example Class.
kernel.Register(
Component.For<IExample>()
.ImplementedBy<Example>()
.Interceptors(InterceptorReference.ForType<LoggingInterceptor>
()).Anywhere);
}
}
}
最后但同样重要的是,main 方法:
using Castle.Windsor;
using System;
namespace ConsolePacktApp
{
class Program
{
private static IWindsorContainer _container;
static void Main(string[] args)
{
_container = new WindsorContainer();
_container.Register(new ExampleRegistration());
var example = _container.Resolve<IExample>();
try
{
example.PrintName("Gobinda", "Dash");
}
catch (Exception ex)
{
}
Console.ReadKey();
}
}
}
我们获取一个 IWindsorContainer 实例。首先,我们注册包含我们的目标方法和拦截器配置的 ExampleRegistration 类。然后,_container.Resolve() 帮助我们获取实现 IExample 的所需实例。
正如我们在 ExampleRegistration 类的 Register() 方法中已经定义的那样,Example 是实现 IExample 的类,因此创建了一个 Example 类的实例。然后,我们在 try 块中有一个 PrintName() 调用。
现在运行程序并查看输出:

显然,在打印名字之前,首先执行的是日志开始消息。其次,实际的方法执行,打印名字。之后,正如我们所预期的,是成功和退出消息。退出意味着它已从拦截器中退出。
显然,我们没有得到任何异常,所以那条消息被跳过了。
现在,让我们尝试看看。修改后的 Example 类将如下所示,包含 throw new Exception() 代码行:
using System;
namespace ConsolePacktApp
{
public class Example : IExample
{
public void PrintName(string FirstName, string LastName)
{
throw new Exception();
Console.WriteLine($"Name is {FirstName} {LastName}");
}
}
}
强制性地,我们在 Console.WriteLine 之前抛出一个异常。当我们运行这个程序时,我们会看到以下输出:

有趣的是,我们没有看到成功日志消息,但打印了退出消息,并且打印名字的方法的实际执行也没有发生。这是因为无论调用代码发生什么,退出消息都一定会打印,因为那是在拦截器的 finally 块中。
我们在实际代码执行之前手动抛出一个异常来打印名称,这导致打印出异常日志消息以及 Start 和 Exit。
可以将多个拦截器附加到单个类。有两种类型的拦截器注册,如下所示。您可以使用以下任一种:
kernel.Register( Component.For() .ImplementedBy() .Interceptors<LoggingInterceptor, AnotherInterceptor>());
或者您可以使用这个:
kernel.Register( Component.For<IExample>() .ImplementedBy<Example>() .Interceptors(new InterceptorReference[] { InterceptorReference.ForType<LoggingInterceptor>(), InterceptorReference.ForType<AnotherInterceptor>() }).Anywhere);
中间语言 (IL) 线程
在这种类型的 AOP 中,方面是在应用程序编译之后附加到底层代码的。这个编译后过程在 IL 级别修改程序集,以便在配置的位置调用方面钩子点。
如果我用简单的话来解释,这是一个在编译后将方面代码插入到原始代码中的过程,但这只发生在 IL 代码(原始源代码保持不变)中,并将其打包到程序集中。您的原始代码将保持不变。然而,除了您的代码之外,方面块也将被包含,程序集将获得修改后的代码。与拦截不同,拦截是在运行时插入代码,这个过程是静态的,代码是在之前包含的。
最广泛使用的线程工具是 PostSharp,我们稍后将演示它。其他包括 LOOM.NET、Fody、SheepAspect、Mono.Cecil 等等。
IL 线程过程
由于我已经解释了过程,让我们在以下图中看看它是如何实际操作的:

简单,不是吗!现在我们了解了这个过程是如何进行的,是时候动手做一些真正的代码并尝试一下了。
创建一个方面
在启动之前,我们需要将名为 PostSharp 的 Nuget 包添加到项目中。
要创建一个方面,我们需要设计一个类,该类将继承自 OnMethodBoundaryAspect 方面类,这是一个位于 PostSharp.Aspects 命名空间内的抽象类。这可以在以下代码块中看到:
using PostSharp.Aspects;
using System;
namespace ConsolePacktApp
{
[Serializable]
class LoggingWeaverAspect : OnMethodBoundaryAspect
{
public override void OnEntry(MethodExecutionArgs args)
{
Console.WriteLine("Inside OnEntry");
}
public override void OnExit(MethodExecutionArgs args)
{
Console.WriteLine("Inside OnExit");
}
public override void OnException(MethodExecutionArgs args)
{
Console.WriteLine("Inside OnException");
}
public override void OnSuccess(MethodExecutionArgs args)
{
Console.WriteLine("Inside OnSuccess");
}
}
}
OnMethodBoundaryAspect 方面有助于编写可以在方法执行前后或异常情况下执行的代码。正如您从前一个类中看到的那样,我们正在重写 OnEntry、OnExit、OnException 和 OnSucess 方法。还有其他方面类可以解决不同的目的。
按照以下方式更新 main 方法:
static void Main(string[] args)
{
try
{
Example example = new Example();
example.PrintName("Gobinda", "Dash");
}
catch
{
}
Console.ReadKey();
}
现在,当您运行应用程序时,没有任何事情发生。我们可以通过查看生成的程序集代码来得到确认。
ILSpy 是开源的 .NET 程序集浏览器和反编译器。您可以从 ilspy.net/ 下载它。这将帮助我们查看程序集中打包的实际代码。
当你运行 ILSpy 应用程序并选择我们的应用程序的程序集时,它看起来会像以下这样:

你可以看到 Example 类中的代码保持完整。没有其他代码被插入到程序集中。这是因为我们还没有将编织器与我们的 Example 类关联起来。让我们接下来这样做。
如果你在构建应用程序时遇到 PostSharp 许可证错误,这意味着你必须购买一个许可证。你也可以使用免费许可证,它有一定的限制。你可以在 www.postsharp.net/essentials 找到下载页面。
绑定方面
绑定方面非常简单。你只需将编织器作为属性添加到你想包装方面的类中即可。
注意以下代码片段中 PrintName 方法上方的粗体行:
using System;
namespace ConsolePacktApp
{
public class Example : IExample
{
[LoggingWeaverAspect]
public void PrintName(string FirstName, string LastName)
{
Console.WriteLine($"Name is {FirstName} {LastName}");
}
}
}
我们在 Example 类周围使用了 [LoggingWeaverAspect] 属性。现在,让我们运行它并查看输出:

看看以下程序集代码:

这里红色的方框表示在构建应用程序后插入到程序集中的代码。由于我们在编织器方面有一个 OnException,因此 PrintName() 方法现在包含一个 try...catch 块。最后,OnEntry 在开始处,而 OnExit 在 Console.WriteLine() 之内。在 Console.WriteLine() 之后,我们可以看到 OnSuccess 的调用。
如果在方法实际执行之前(在本例中为 Console.WriteLine() 之前)发生异常,我们将看到以下输出,而没有成功消息:

既然我们已经对概念有了初步的了解,让我们来探讨如何在 .NET Core 中实现拦截。
ASP.NET Core 中的拦截
ASP.NET Core 实现了拦截的概念,用于中断对控制器操作和请求-响应管道的调用。我们可以通过不同的技术来实现,这些技术被称为过滤器和中件。接下来,我们将通过示例逐一讨论。
过滤器
过滤器允许 ASP.NET Core 拦截动作方法。你可以配置一个全局过滤器,每次请求控制器动作时都会运行,或者为某些动作方法配置独特的过滤器。
过滤器将实现 Microsoft.AspNet.Mvc.Filters 命名空间中驻留的一个过滤器接口。让我们看看以下简单的过滤器框架:
using Microsoft.AspNetCore.Mvc.Filters;
namespace FiltersAndMiddlewares.Filters
{
public class SomeFilter : IActionFilter
{
public void OnActionExecuted(ActionExecutedContext context)
{
// Do something.
}
public void OnActionExecuting(ActionExecutingContext context)
{
// Do something.
}
}
}
如前例所示,SomeFilter 实现了 IActionFilter。正如其名称所暗示的,OnActionExecuting 和 OnActionExecuted 分别在动作执行时和执行完成后运行。
如果你还记得拦截(拦截原则,即“我们可以运行一些代码在执行方法之前或之后,或者完全替换该方法的过程”),你会注意到这些 IActionFilter 方法也遵循该原则,并且旨在拦截对控制器动作方法的调用。
全局过滤器
假设你想创建一个拦截器,它会拦截所有动作方法。听起来像是一个常见的或全局声明的代码块,可以被称为 全局过滤器。使用 ConfigureServices 中的服务集合将过滤器注册到 MVC 过滤器中会使过滤器全局可执行或可拦截。
参考以下代码,这是将普通过滤器转换为全局过滤器(除了过滤器定义外,当然你需要编写代码)的唯一一行:
services.AddMvc(mvc => mvc.Filters.AddService(
typeof(SomeGlobalFilter)));
SomeGlobalFilter 也可以注入依赖项。以下代码块可以被视为全局过滤器,其中 ISomeService 使用最流行的构造函数注入模式进行注入:
using FiltersAndMiddlewares.Interfaces;
using Microsoft.AspNetCore.Mvc.Filters;
namespace FiltersAndMiddlewares.Filters
{
public class SomeGlobalFilter : IActionFilter
{
public SomeGlobalFilter(ISomeService service)
{
// Do something with the service.
}
public void OnActionExecuted(ActionExecutedContext context)
{
// Do something.
}
public void OnActionExecuting(ActionExecutingContext context)
{
// Do something.
}
}
}
你注意到一件事吗?在 SomeGlobalFilter 中的参数化构造函数注入了类型为 ISomeService 的依赖。虽然不是可选的,但如果我们在过滤器中需要任何必需的依赖项,则可以进行此操作。想象一下,如果我们需要在动作开始执行时在数据库中添加日志条目,就像记录事件发生的顺序一样。为了实现这一点,我们可以注入一个服务,然后使用其方法对数据库进行操作。
属性
另一种附加过滤器的方法是为所需操作编写属性。当你全局应用时,它适用于所有操作。然而,如果我们只想将属性附加到某些操作上,则可以编写属性。让我们看看一个带有 SomeFilter 属性的动作方法:

编译器在抱怨。如果你悬停,它会说 SomeFilter 不是一个属性类。实际上,它期望 SomeFilter 如下所示:
public class SomeFilterAttribute : Attribute {}
注意过滤器名称已更改为 SomeFilterAttribute 并从 Attribute 抽象类派生。这是通过语法实现的。此外,属性需要无参构造函数。但为了拦截,我们需要实现一些过滤器接口,如 IActionFilter。
为了克服这一点,我们可以借助从 Attribute 类派生并通过其构造函数接受类型的 TypeFilterAttribute 类。因此,以下代码显示了使用过滤器作为属性的正确模式:
[TypeFilter(typeof (SomeFilter))]
public IActionResult About()
{
ViewData["Message"] = "Your application description page.";
return View();
}
这里有两个重要的事项需要注意:
-
我们没有在应用程序的入口点注册
SomeFilter;然而,它仍然可以工作 -
TypeFilter属性有助于创建SomeFilter的实例
由于我们在玩.NET Core,我们应该从服务注册中获取过滤器实例,而不是通过TypeFilter动态创建它。这就是ServiceFilterAttribute类出现的地方。让我们看看我们如何修改代码以使用ServiceFilter:
[ServiceFilter(typeof (SomeFilter))]
public IActionResult About()
{
ViewData["Message"] = "Your application description page.";
return View();
}
你确定这个更改会工作吗?不,它不会。当你运行应用程序时,你会看到以下错误:

哎,我知道了!现在你知道我们遗漏了什么。当你使用 DI 工作时,这是一个非常熟悉的异常。正如我所说,ServiceFilter从服务集合中找到过滤器实例;如果你没有注册它,它肯定会抛出异常。
以下代码是使它工作所需的内容。虽然这不是必须的,使其变为瞬时的,但它取决于你的场景:
services.AddTransient<SomeFilter>();
中间件
中间件,它所知名的是,拦截执行管道,开发者可以在响应发送到客户端之前做任何事情。我们将在下一节中看到如何具体实现中间件以进行拦截。
ASP.NET Core 中的中间件遵循拦截技术,在请求和响应之间插入方面。它基本上是一段与应用程序管道注册的代码块。以下图表显示了请求如何逐个通过中间件,直到响应出来:

可以将大量的中间件插入到管道中。每个中间件决定是否将执行传递给下一个中间件,并在调用下一个组件之前和之后执行一些逻辑。这些组件被设计来解决特定的目的,如日志记录、异常处理、授权等。
注册
IApplicationBuilder接口帮助我们使用Configure()中的app.Use注册中间件。让我们检查一个简单的代码块:
public void Configure(IApplicationBuilder app)
{
var response = string.Empty;
app.Use(async (context, next) =>
{
response += "Inside Middleware 1\n";
await next.Invoke();
});
app.Use(async (context, next) =>
{
response += "Inside Middleware 2\n";
await next.Invoke();
});
app.Run(async context =>
{
response += "App run\n";
await context.Response.WriteAsync(response);
});
}
执行
我们有两个中间件然后是app.Run。当我们运行这个时,我们会看到以下内容:

这里有一个问题。如果你刷新页面,而不构建代码,你将看到以下内容:

原因是我将字符串连接在一起,当你刷新页面时,它直接跳到中间件 1,然后是中间件 2,最后是app.Run。因为变量在Configure内部初始化,它具有应用程序作用域。因此,它将被附加。如果你再次刷新页面,你将看到另一组相同的消息被附加到前一个截图中的内容上。
这是中间件劫持应用程序管道并在响应发送到客户端之前执行的方式。让我们考虑另一个代码块:
public void Configure(IApplicationBuilder app, IHostingEnvironment env,
ILoggerFactory loggerFactory)
{
var response = string.Empty;
app.Use(async (context, next) =>
{
response += "Inside Middleware 1\n";
await next.Invoke();
});
app.Run(async context =>
{
response += "App run\n";
await context.Response.WriteAsync(response);
});
app.Use(async (context, next) =>
{
response += "Inside Middleware 2\n";
await next.Invoke();
});
}
你认为输出会是什么?检查一下:

中间件 2 被遗漏了。这是因为我们在 Middleware 2 之前写了 app.Run。app.Run 是退出点,它终止了管道。在那之后的所有内容都不会被运行时考虑。
你当然可以在调用 invoke 之前和之后进行编码,如下所示:
app.Use(async (context, next) => { // 在调用下一个中间件之前做一些事情 await next.Invoke(); // 在调用下一个中间件之后做一些事情 });
排序
存在一条简单的规则,即它们注册的顺序就是它们将被执行的顺序。当某个组件依赖于另一个组件时,排序扮演着重要的角色。例外情况是可能在应用程序的任何时间、任何地方发生的事情。因此,我们需要在所有其他事情之前先注册它,这样我们就可以轻松地捕获异常。
摘要
我们探讨了在典型编程场景中哪些方面是跨切面关注点以及如何确定它们。有一些特定的位置可以标记方面,我们将在下面进行说明。
面向方面编程帮助我们运行自定义逻辑,在方法注册之前、之后,甚至完全替换方法,其中注册了它。我们看到了使用 IL Weaving 和拦截器分别附加方面的静态和动态技术。拦截是一种比 IL Weaving 更受欢迎的技术,因为它可以动态运行注册的代码块(通常称为方面)。另一方面,IL Weaving 处理在构建过程完成后更新程序集,将方面代码插入到已注册的方法中。
然后,我们深入讨论了在 ASP.NET Core 中如何以过滤器和中件间的形式采用拦截。
过滤器可以拦截动作方法执行的路径,这可以是按需将属性分配给方法,或者将其注册到服务集合中,以将其标记为全局过滤器。注册一个过滤器的单行代码可以使过滤器轻松拦截所有动作方法。
中间件是 ASP.NET Core 中的一种技术,它允许我们在请求-响应管道中插入内置/自定义代码块。
现在我们已经学习了 DI 的所有基本概念,在 第八章 模式 - 依赖注入 中,我们将发现不同的技术/模式来向客户端注入依赖。
第八章:模式 - 依赖注入
在第七章中,拦截,我们完成了探索依赖注入(DI)支柱的旅程。现在,是时候学习依赖注入背后的原则,并探索如何应用不同的技术来实现这些原则,以获得松散耦合的架构。有各种技术可以实现 DI,但如果你在编码时没有选择合适的技术,你将成为项目的麻烦制造者。
在本章中,我们将讨论不同的技术,也称为依赖注入模式,并附上适当的说明。我将阐明用例、优点和缺点,以便您能够轻松地可视化您当前在应用程序中看到的问题。这将最终说服您在应用程序中采用这些模式,从而实现更好的架构。
构造函数注入是所有这些模式中最重要的一个。然而,作为开发者,我们应该理解每个可用的模式。始终建议通过分析依赖的使用频率和情况来为特定场景选择最佳匹配的模式。
虽然这些模式中的大多数都针对某些特定上下文,但请始终记住构造函数注入是最简单的,并且可以在没有任何混淆的情况下选择使用。
本章将涵盖以下主题:
-
依赖倒置原则
-
控制反转
-
DI 模式
-
实现模式的正确方式
-
每个模式的优缺点
-
.NET Core 2.0 中的采用和示例
依赖倒置原则
SOLID 原则中有一个 D,称为依赖倒置原则(DIP)。以下是对 DIP 的描述,由 Robert C. Martin 提供:
“高级模块不应依赖于低级模块。两者都应依赖于抽象。”
你可以将高级模块视为一个应用程序的业务模块,它包含应用程序的复杂逻辑,而低级模块则是实际执行基本或主要功能的类,例如将数据写入磁盘、与数据库交互等,这些操作由业务模块接收的命令执行。
当高级模块的对象与低级模块的对象交互时,它们会产生耦合。这是因为你必须引用低级模块的类才能访问它们以进行实例化。然而,DIP 并不推荐这样做。该原则鼓励我们减少耦合,使模块能够独立生活。它还解释了如何通过抽象来实现这一点。两者都应该致力于抽象,而不是直接相互依赖。让我们用一个例子来理解这个观点。
考虑一个名为FeedbackService的类,它将一个Feedback详情保存到数据库中:
public class FeedbackService : IFeedbackService
{
private INotifier notifier;
public void SaveFeedback(Feedback feedback, NotifyType notify)
{
SaveFeedbackToDb(feedback);
SendNotification(feedback, notify);
}
private void SendNotification(Feedback feedback, NotifyType notify)
{
if (notify == NotifyType.Email)
{
notifier = new EmailNotifier();
}
else if (notify == NotifyType.Sms)
{
notifier = new SmsNotifier();
}
else if (notify == NotifyType.Voice)
{
notifier = new VoiceNotifier();
}
else
{
throw new ArgumentException("No matched notify type
found.", notify.ToString());
}
notifier.SendNotification(feedback);
}
private int SaveFeedbackToDb(Feedback feedback)
{
// Save details in db.
Console.WriteLine("Db Saving Started.");
return 1;
}
}
这里的主要方法是 SaveFeedback,它不仅保存反馈,还发送通知。它接受 Feedback 对象和通知类型作为参数。当保存反馈时,通常向客户和管理员发送一条通知。
FeedbackService 实现了一个接口,其代码可以如下编写:
public interface IFeedbackService
{
void SaveFeedback(Feedback feedback, NotifyType notify);
}
INotifier 是一个接口,所有通知类型都实现了这个接口。看看下面的代码:
public interface INotifier
{
void SendNotification(Feedback feedback);
}
public class EmailNotifier : INotifier
{
public void SendNotification(Feedback feedback)
{
Console.WriteLine("Email Notification starts!");
}
}
public class SmsNotifier : INotifier
{
public void SendNotification(Feedback feedback)
{
Console.WriteLine("Sms Notification starts!");
}
}
public class VoiceNotifier : INotifier
{
public void SendNotification(Feedback feedback)
{
Console.WriteLine("Voice Notification starts!");
}
}
注意,我没有定义方法的主体,因为它们在这里是为了说明。你可以编写逻辑并实现自己的代码。
问题
这种方法看起来是正确的,但不推荐,并且不尊重以下描述的软件设计原则:
显然,我们正在违反单一职责原则。FeedbackService 不仅负责保存反馈,还在成功后发送通知。
与具体的类 Feedback 以及其他 Notifier 类如 EmailNotifier、SmsNotifier 和 VoiceNotifier 存在紧密耦合。
我们最常用的 new 关键字在 FeedbackService 类的 SendNotification 方法中使用,根据输入的 notify 类型的 enum 来决定创建哪个 Notifier 实例,它可以定义如下:
public enum NotifyType
{
Email = 1,
Sms = 2,
Voice = 3
}
解决方案
解决方案应该解决前面方法的所有问题,从而使最终的架构变得松耦合。让我们开始吧。
第一件事是将 FeedbackService 类的责任分离出来。这可以通过将 SendNotification 责任转移到通知者而不是在类内部完成。
因此,我们可以在构造函数中注入 INotifier 类型的依赖。这样,我们就可以通过 _notifier.SendNotification() 发送注入的类型通知。我们还改变了 SaveFeedback 的签名,现在它接受 IFeedback 类型的参数。
FeedbackService 可以重写如下:
public class FeedbackService : IFeedbackService
{
private readonly INotifier _notifier;
public FeedbackService(INotifier notifier)
{
_notifier = notifier;
}
public void SaveFeedback(IFeedback feedback)
{
SaveFeedbackToDb(feedback);
_notifier.SendNotification(feedback);
}
private int SaveFeedbackToDb(IFeedback feedback)
{
throw new NotImplementedException();
}
}
现在 Service 不依赖于具体的实现,并且我们从类中消除了决定发送哪种通知的 if...else...if 繁琐代码。通知类型的决定现在由 Service 类实例的创建者来决定。这可以在组合根(Composition Root)内部完成,如下所示:
var feedbackService = new FeedbackService(new SmsNotifier());
feedbackService.SaveFeedback(new Feedback());
控制反转(IoC)
控制反转(IoC)是一种支持 依赖倒置原则(DIP)的软件设计模式。这两个术语之间有很多混淆,但如果你仔细观察,名称本身就能澄清疑惑。
DIP 是一种原则或规则集合的理论。另一方面,控制反转是一种定义良好的步骤模式,我们可以遵循它来使我们的代码符合 DIP。你已经体验过 IoC 的步骤,不是吗?
注意我们采取的步骤,将 FeedbackService 类的对象创建控制权从外部位置移除,这可以被视为组合根。我们做了以下具体操作:
-
介绍具体依赖项的接口:
INotifier和IFeedback。 -
将具体依赖项更新为从接口实现:
EmailNotifier、SmsNotifier和VoiceNotifier。 -
将依赖项提供给构造函数并存储在
private readonly成员中:
public FeedbackService(INotifier notifier)
{
_notifier = notifier;
}
-
通过私有成员,我们可以直接调用依赖类的方法:
_notifier.SendNotification(feedback); -
在组合根中实例化
Service并包含依赖项:var feedbackService = new FeedbackService(new SmsNotifier());。
因此,现在有人正在尝试管理对象创建和生命周期。在我们执行这些步骤之前,该类本身使用 new 关键字在其内部实例化所有依赖项。因此,我们反转了控制权,对象的管理流程是通过其他人反向进行的,不是吗!就是这样,这正是 IoC(控制反转)试图表达的内容。
最后一步是将依赖对象注入到 Service 的构造函数中,这种模式被称为 构造函数注入模式。让我们在下一节中探讨这个模式以及其他重要模式。
下图表示了依赖倒置原则(DIP)和非依赖倒置原则(non-DIP)的实现。注意箭头。在非 DIP 中,依赖项由类本身管理,因此箭头指向具体类对象,而在 DIP 中,箭头指向类,因为依赖对象是由其他人发送的:

当一个类自己管理所有依赖项时,它被视为 控制狂,这是一种反模式。我们将在下一章中讨论更多关于这种反模式的内容。
模式
以下是我们可以通过实现依赖注入的四个重要模式:
-
构造函数注入模式
-
属性注入模式
-
方法注入模式
-
环境上下文
让我们逐一通过插图、优点和缺点来探讨。
构造函数注入模式
如其名所示,注入将借助构造函数。换句话说,构造函数是注入的目标。注入是通过将依赖项作为参数提供给构造函数来执行的。这是将依赖项注入到类中最常见的技巧。
客户端调用构造函数来创建对象,在实例化过程中,依赖项被注入,如下所示:

例如,一家公司最初只设立了一个名为工程部的部门。让我们为这家公司设计以下 Employee 类:
public class Employee
{
public int EmployeeId;
public string EmployeeName;
public Department EmployeeDept;
public Employee(int id, string name)
{
EmployeeId = id;
EmployeeName = name;
EmployeeDept = new Engineering();
}
}
我们有一个构造函数,它接受 id 和 name 作为参数,并初始化一个 Employee 对象。正如我之前所说的,公司最初只有工程部门,所以很明显,我们将 EmployeeDept 赋值为 Engineering 类的新对象。Engineering 和 Department 类可能看起来如下所示:
public class Department
{
public int DeptId { get; set; }
public string DeptName { get; set; }
}
public class Engineering : Department { }
问题
您能预见这个设计中可能存在的复杂问题吗?如果公司扩大并建立了另一个部门,比如营销部门,我们将如何将这个新部门纳入 Employee 类中?如何表示某个员工属于营销部门?
不幸的是,在这里没有方法可以做到这一点,因为 Employee 类与 Engineering 类紧密耦合。这不仅会导致设计不良,而且开发者也会在单元测试中遇到障碍。
解决方案
那么,解决方案是什么?当我们用 Employee 实例化时,我们只需要一个 Department 对象。我们是否可以直接向 Employee 类提供所需的 Department 类型(工程/营销)引用呢?
这非常简单。让我们看看修改后的代码。
- 首先,设计一个接口
IDepartment,该接口可以被Department类实现:
public interface IDepartment
{
int DeptId { get; set; }
string DeptName { get; set; }
}
public class Department : IDepartment
{
public int DeptId { get; set; }
public string DeptName { get; set; }
}
- 我们不再拥有
Department类型的属性,现在我们将拥有IDepartment类型的属性。基本上,我们可以通过这种技术允许不同的部门类型。我们很快就会看到这个技术的实际应用。同时,您也可以看到这个属性是如何在构造函数中使用IDepartment类型的dept参数进行初始化的。
public class Employee
{
public int EmployeeId;
public string EmployeeName;
public IDepartment EmployeeDept;
public Employee(int id, string name, IDepartment dept)
{
EmployeeId = id;
EmployeeName = name;
EmployeeDept = dept;
}
}
- 现在让我们看看不同部门的类。它们从
Department继承,从而实现了IDepartment:
public class Engineering : Department
{
public Engineering()
{
DeptName = "Engineering";
}
}
public class Marketing: Department
{
public Marketing()
{
DeptName = "Marketing";
}
}
- 现在创建不同部门的员工变得容易了。如果我们将在
main方法内部创建这样的对象,我们可以做以下操作:
static void Main(string[] args)
{
var engineering = new Engineering();
Employee emp = new Employee(1, "Sasmita Tripathy",
new Engineering());
var marketing = new Marketing();
Employee emp1 = new Employee(2, "Ganeswar Tripathy",
new Marketing());
Console.WriteLine("Emp Name: " + emp.EmployeeName + ",
Department: " + emp.EmployeeDept.DeptName);
Console.WriteLine();
Console.WriteLine("Emp Name: " + emp1.EmployeeName + ",
Department: " + emp1.EmployeeDept.DeptName);
Console.ReadLine();
}
您可以看到一个特定的 Department 对象是如何传递给 Employee 构造函数以创建属于不同部门的员工。这就是我们进行的注入。我们将 IDepartment 类型的对象(Engineering 和 Marketing)注入到 Employee 构造函数中。
以下为输出结果:

曲线球
您可能认为前面的内容都是完美的。但事实并非如此。让我们来分析一下:
var engineering = new Engineering();
Employee emp = new Employee(1, "Sasmita Tripathy",
new Engineering());
emp.EmployeeDept = new Marketing();
您可以看到我创建了一个部门为 Engineering 的对象。然后在下一行,我们可以轻松地通过将 Marketing 对象赋值给它来更改部门。这应该完全避免。
为了做到这一点,该字段必须被标记为 private 和 readonly。
private readonly IDepartment EmployeeDept;
虽然这并不是严格推荐的,但我们确实应该遵循这一点,以便开发者不能无端地篡改实际值。
其次,构造函数没有处理依赖实例作为 null 接收的情况。构造函数可以被修改如下:
public Employee(int id, string name, IDepartment dept)
{
EmployeeDept = dept ?? throw new ArgumentNullException();
EmployeeId = id;
EmployeeName = name;
}
现在重要的是,构造函数不仅负责将依赖实例推送到一个 private 属性,还向其他成员变量(如 EmployeeId 和 EmployeeName)插入值。这违反了单一职责原则。因此,我们应该让构造函数只处理依赖项,而无需做其他事情:
public Employee(IDepartment dept)
{
EmployeeDept = dept ?? throw new ArgumentNullException();
}
此外,以这种方式编写可以保证当创建一个对象时,它将包含其中的依赖项。同时,这只有在类从此以后完全依赖于依赖项的情况下才有价值。
让我快速总结以下要点:
-
依赖的类必须公开一个
public构造函数,以便注入依赖项 -
如果需要多个依赖项,则可以向构造函数添加更多参数
-
依赖类应将成员变量标记为
private readonly,其中将分配依赖实例 -
依赖类构造函数应仅执行管理依赖项的工作,而无需做其他事情
使用 .NET Core 2.0 注入
我们之前看到了每个示例,都是使用 new 关键字进行初始化。然而,我们应该使用 .NET Core 2.0 的方式来解决依赖项:
static void Main(string[] args)
{
var serviceProvider = new ServiceCollection()
.AddTransient<IDepartment, Engineering>()
.AddTransient<Employee>()
.BuildServiceProvider();
var emp = serviceProvider.GetService<Employee>();
emp.EmployeeId = 1;
emp.EmployeeName = "Sasmita Tripathy";
var emp1 = serviceProvider.GetService<Employee>();
emp1.EmployeeId = 2;
emp1.EmployeeName = "Ganeswar Tripathy";
Console.WriteLine("Emp Name: " + emp.EmployeeName + ",
Department: " + emp.GetEmployeeDepartment());
Console.WriteLine();
Console.WriteLine("Emp Name: " + emp1.EmployeeName + ",
Department: " + emp1.GetEmployeeDepartment());
Console.ReadLine();
}
我们使用 ServiceCollection 对象来注册在解析时我们期望的类型接口。当我们执行 serviceProvider.GetService(); 时,它返回一个部门为 Engineering 的员工对象,因为我们已在集合中将 IDepartment 注册为 Engineering。
ASP.NET Core 2.0 内部的实现
让我们更仔细地看看位于 Microsoft.AspNetCore.Mvc.Core Controllers 中的 ControllerActivatorProvider.cs 的 ASP.NET Core 2.0 内部代码,以了解构造函数注入是如何实现的。
ControllerActivatorProvider.cs
我将展示一个包含构造函数的类的截图:

IControllerActivator 是注入到这个类构造函数中的依赖项。然后,它使用这个实例在类内部执行一些操作。我们可以在 .NET Core 2.0 中轻松找到更多这样的例子。
要查看这种依赖是如何解决的,我们可以检查位于 Microsoft.AspNetCore.Mvc.Core DependencyInjection 中的 MvcCoreServiceCollectionExtensions.cs 类的 AddMvcCoreServices 方法:

构造函数注入模式的重要性
由于注入与构造函数相关联,这意味着每次尝试实例化特定类时,我们都能 100%确信依赖项被包含或与创建的对象绑定。通过 Guard Clause 确保依赖项不是 null 并将其分配给 private readonly 数据成员,从而确保了拥有依赖项的保证。否则,类的构造函数将抛出异常。
没有可能不注入依赖就拥有此类的一个对象。因此,对象的状态始终有效,没有差异。使用模拟进行单元测试变得容易。让我们也看看其优缺点。
构造函数注入模式的优势
这是尊重 DI 概念的最受欢迎的方式。大多数 DI 容器都针对构造函数。它也是 DI 最常见和更广泛使用的模式。注入的组件可以在类的任何地方使用,因为当你创建对象时,注入的依赖项会自动进入类并保存在某个变量中。
当类需要一些外部依赖来启动所有其他操作时,开发者更喜欢这种技术。由于构造函数涉及其中,一旦实例化,对象就准备好了依赖对象。
属性注入模式的不利之处
当类包含许多构造函数时,这并不容易实现。此外,当类的依赖项数量增加时,每次更新构造函数都不是一项可行的任务。
当构造函数有多个参数时,通过添加依赖项与现有参数一起,类看起来会很混乱。
属性注入模式
在这种技术中,我们不会要求构造函数支持注入,而是直接将依赖注入到类的属性中。让我们开始吧。
以下图表描述了一个属性EmployeeDept,它可以直接注入IDepartment实现:

没有构造函数参数IDepartment和新的设置属性Employee类看起来如下所示:
public class Employee
{
public int EmployeeId;
public string EmployeeName;
private IDepartment _employeeDept;
public IDepartment EmployeeDept
{
set
{
this._employeeDept = value;
}
}
public Employee(int id, string name)
{
EmployeeId = id;
EmployeeName = name;
}
}
我们刚刚从构造函数中移除了IDepartment参数,因为我们打算直接将一个IDepartment类型的对象分配给属性EmployeeDept。这可以在创建Employee对象时完成,如下所示:
static void Main(string[] args)
{
Employee emp = new Employee(1, "Sasmita Tripathy")
{
EmployeeDept = new Engineering()
};
Employee emp1 = new Employee(2, "Ganeswar Tripathy")
{
EmployeeDept = new Marketing()
};
Console.WriteLine("Emp Name: " + emp.EmployeeName + ",
Department: " + emp.EmployeeDept.DeptName);
Console.WriteLine();
Console.WriteLine("Emp Name: " + emp1.EmployeeName + ",
Department: " + emp1.EmployeeDept.DeptName);
Console.ReadLine();
}
因此,通过执行emp.EmployeeDept = new Engineering();,我们直接将Engineering对象推入属性EmployeeDept。对于emp1也执行了同样的操作。
然而,这段代码无法编译。我们遗漏了一些重要的东西。考虑以下截图:

错误完全自解释。我们忘记包含获取器块,因为我们想打印部门名称。考虑以下代码片段:
private IDepartment _employeeDept;
public IDepartment EmployeeDept
{
get {
return this._employeeDept;
}
set
{
this._employeeDept = value;
}
}
现在,我们做得很好。当你运行它时,你会看到与构造函数注入案例中相同的输出。
当类需要可选依赖项时,首选属性注入。这意味着,如果你不提供值,类不会介意,因为它可以在没有此依赖项的情况下运行。如果开发者忘记分配依赖项,它不应影响应用程序的流程。
例如,在我们的例子中,如果您不对属性EmployeeDept做任何操作,只要您不读取该属性,类将像往常一样表现。在下一节中,我将告诉您如何美化代码,因为代码还没有完美。
曲球
我们采取的方法存在的问题可以是以下任何一个:
-
如果您没有为属性分配任何内容(就像我们开发者有时因为懒惰而忘记做的那样),当您打印时,输出不会返回任何内容。
-
您可以像这样简单地分配
null给属性:emp.EmployeeDept = null;。 -
一旦您将依赖项分配给属性,您就可以轻松地分配另一个依赖项,这可能会成为一个问题。这可能会意外发生,然而,它将在某个时间点对整个系统产生副作用。想象一下这种情况:您用工程初始化了属性,然后将其更改为营销,这是错误的。
为了处理所有这些情况,我们应该为属性引入更多的保护。让我们这样做:
private IDepartment _employeeDept;
public IDepartment EmployeeDept
{
get
{
if (this._employeeDept == null)
this.EmployeeDept = new Engineering();
return this._employeeDept;
}
set
{
if (value == null)
throw new ArgumentNullException("value");
if (this._employeeDept != null)
throw new InvalidOperationException();
this._employeeDept = value;
}
}
在设置器内部,我们检查null并抛出异常。然后,我们再次调查它是否已经存在,并抛出InvalidOperationException。同样,在获取器中,当传入null时,我们将其分配为默认值作为工程。因此,我们保护了我们的属性以应对所有这些困难的情况,如前所述。
属性注入不是.NET Core 内置的 DI 或 IoC 容器默认支持的。目前也没有计划将其引入默认容器。您必须使用外部容器,例如 Autofac,来支持此功能。
属性注入模式的优势
我们可以看到以下拥有设置器注入模式的优点:
-
它不需要添加新的构造函数或修改现有的构造函数。
-
如果类有多个不同类型的依赖项,这个模式就派上用场了。假设有一个类,它将在不同条件下同时使用不同类型的日志记录,例如日志记录到文本或数据库,那么拥有两个设置器属性,我们可以简单地注入所需的记录器来完成我们的任务。
属性注入模式的缺点
在特定设计中识别设置器以注入外部依赖项并不容易。尽管如此,在某些情况下它们仍然被优先考虑。然而,在使用注入的属性时我们应该小心,因为那可能为null。在您想要使用它的任何地方都应该进行额外的null检查。正如我们之前讨论的,必须为属性提供保护以避免不一致的行为。
属性注入在大多数情况下被认为是不良实践,因为它隐藏了依赖关系。当类被实例化时,没有保证依赖对象将可用于类执行任何操作,这与构造函数注入不同,在实例化过程中依赖项本身就会找到路径。
方法注入模式
正如其名所示,我们将像在构造函数的情况中那样将依赖项注入到一个方法中。当我们想在类的不同方法中使用不同类型的依赖项时,这会非常有用。
看以下图表,它告诉我们 Employee 类中的 AssignDepartment(IDepartment) 方法如何作为依赖项使用:
客户端可以创建 Employee 类的一个对象,然后使用适当的 IDepartment 实现调用 AssignDepartment,从而提供依赖项。
让我们看看如何编码 Employee 类:
public class Employee
{
public int EmployeeId;
public string EmployeeName;
public IDepartment EmployeeDept;
// Default Constructor added for .NET Core 2.0 DI.
// So that it can automatically create the instance.
public Employee() { }
public Employee(int id, string name)
{
EmployeeId = id;
EmployeeName = name;
}
public void AssignDepartment(IDepartment dept)
{
EmployeeDept = dept;
// Other business logic if required.
}
}
在这个特定场景中,有一个 AssignDepartment 方法,它接受一个 IDepartment 类型的参数,并将其分配给属性。可以相应地创建一个 Employee 对象。考虑以下代码片段:
Employee emp = new Employee(1, "Sasmita Tripathy");
emp.AssignDepartment(new Engineering());
Employee emp1 = new Employee(2, "Ganeswar Tripathy");
emp1.AssignDepartment(new Marketing());
它也产生了我们之前已经看到的相同输出。
这种模式在需要某些依赖项仅用于方法中的某些特定操作,而不是整个类时非常有用。有时,情况需要一个小改动,可能是一个边缘情况。为了适应这个小改动,最简单的方法是创建一个方法,并在其中做任何你想做的事情(使用作为参数传递的依赖项),而不是触摸构造函数或属性。
Curveball
在方法注入的情况下,按照惯例,我们也应该注意空值检查,以确保在使用之前依赖项可用:
public void AssignDepartment(IDepartment dept)
{
EmployeeDept = dept ?? throw new ArgumentNullException("value");
// Other business logic if required.
}
使用 .NET Core 2.0 注入
如我们在之前的模式中所做的那样,我们将使用 GetService 方法获取实例,然后通过注入依赖项调用所需的方法:
Employee emp = serviceProvider.GetService<Employee>();
emp.EmployeeId = 1;
emp.EmployeeName = "Sasmita Tripathy";
emp.AssignDepartment(serviceProvider.GetService<IDepartment>());
Employee emp1 = serviceProvider.GetService<Employee>();
emp1.AssignDepartment(serviceProvider.GetService<IDepartment>());
emp1.EmployeeId = 2;
emp1.EmployeeName = "Ganeswar Tripathy";
你接下来会看到以下输出:

这是因为我们有一个接受 integer 和 string 参数的构造函数。提供者不知道如何解析它们的过程,因此抛出异常。解决方案是提供一个默认构造函数,因为服务提供者正在寻找它。
public Employee() { }
.NET Core 2.0 中的实现
在 .NET Core 2.0 中,我们可以找到许多方法注入模式的使用实例。其中之一是在最简单且最常用的类 MvcServiceCollectionExtensions.cs 中。
MvcServiceCollectionExtensions.cs
MvcServiceCollectionExtensions.cs 类包含 AddMvcCore 方法,这是一个方法注入的例子。以下截图展示了该方法的概览:

IServiceCollection 被注入到 AddMvcCore 方法中。在它被验证为非 null 之后,服务集合会被进一步处理。我仅展示相关代码的截图以理解该概念。你肯定可以在其他类中找到这样的例子。
接口注入模式是我们已经在其他模式中讨论过的一种模式,其中我们将实现(如 IDepartment,它是一个接口)注入到构造函数、属性或方法中。建议注入实现而不是具体类,以避免紧密耦合。接口注入使我们能够实现解耦和抽象。
Ambient 上下文
Ambient 是一个形容词,意为完全围绕或包含。这意味着当我们说 Ambient 上下文时,它表示某种上下文,这将涉及其在背景或周围的存在和行为。
当许多类需要相同的依赖项时,我们应该遵循一些技术来使其对每个此类客户端可用。
我们讨论的模式不适用于这种场景。例如,如果你尝试使用构造函数注入来实现这一点,你最终将为每个类添加一个构造函数,并将相同的依赖项注入其中。
这听起来更像是一个横切关注点,不是吗?为了实现这一点,我们可以简单地为依赖项添加一个静态访问器。这将确保依赖项对所有要求它的客户端都是可访问的。
这个概念可以在以下图中可视化:

让我们通过一个例子来更好地理解实现方式。可以设计一个抽象类 DepartmentProvider,以提供名为 Current 的静态访问器:
abstract class DepartmentProvider
{
private static DepartmentProvider current;
public static DepartmentProvider Current
{
get
{
return current;
}
set
{
current = value;
}
}
public virtual Department Department { get; }
}
我们将类标记为 abstract,并且有一个名为 Department 的 virtual 属性,它将被任何需要它的类访问。
让我们看看如何使用 Current 属性。以下代码使用了 MarketingProvider 类的实例,它是 DepartmentProvider 的派生类:
static void Main(string[] args)
{
var serviceProvider = new ServiceCollection()
.AddTransient<IDepartment, Engineering>()
.AddTransient<Employee>()
.AddTransient<MarketingProvider>()
.BuildServiceProvider();
// Set the Current value by resolving with
MarketingProvider.
DepartmentProvider.Current = serviceProvider.GetService
<MarketingProvider>();
Employee emp = serviceProvider.GetService<Employee>();
emp.EmployeeId = 1;
emp.EmployeeName = "Sasmita Tripathy";
emp.EmployeeDept = DepartmentProvider.Current.Department;
Employee emp1 = serviceProvider.GetService<Employee>();
emp1.EmployeeId = 2;
emp1.EmployeeName = "Ganeswar Tripathy";
emp1.EmployeeDept = DepartmentProvider.Current.Department;
Console.WriteLine("Emp Name: " + emp.EmployeeName + ",
Department: " + emp.EmployeeDept.DeptName); // Marketing
Console.WriteLine();
Console.WriteLine("Emp Name: " + emp1.EmployeeName + ",
Department: " + emp1.EmployeeDept.DeptName); // Marketing
Console.ReadLine();
}
看看我们如何使用内置的 DI 容器注册 MarketingProvider。然后我们将它分配给 Current 属性,通过这个属性,我们能够在读取 DepartmentProvider.Current.Department 时获取到营销值。MarketingProvider 是 DepartmentProvider 的子类,它返回一个 Marketing 对象。请参考以下代码:
class MarketingProvider : DepartmentProvider
{
public override Department Department
{
get { return new Marketing(); }
}
}
因此,我们得出结论。我们可以在应用程序内部需要的地方使用 static 属性,除非你在其中设置了不同的值,否则值将是相同的。
曲球
在实现 Ambient Context 时,以下是一些重要要点需要记住:
-
这应该只在真正必要时使用。在决定使用 Ambient Context 之前,构造函数注入或属性注入应该是首选。如果你无法决定,就选择其他 DI 模式。
-
服务定位器反模式在提供依赖的方式上与这个模式非常相似。然而,有一个区别,那就是环境上下文提供单个依赖项,而服务定位器负责提供所有请求的依赖项。我们将在第九章 Anti-Patterns and Misconceptions on Dependency Injection 中进一步讨论服务定位器。
-
如果你没有正确实现环境上下文,它将会有副作用。假设你开始使用一个上下文或提供者,在过程中由于某些原因更改了它。现在,当你读取值时,由于静态特性,它将提供更改后的值而不是第一个值。这意味着你在实现时需要非常小心。
-
通过我们讨论的实现,你可以轻松地将
null设置到Current属性DepartmentProvider.Current = null;。因此,我们必须通过使用守卫子句来保护它。
以下代码表示在获取器和设置器块中的简单守卫子句:
abstract class DepartmentProvider
{
private static DepartmentProvider current;
public static DepartmentProvider Current
{
get
{
if (current == null)
current = new DefaultDepartmentProvider();
return current;
}
set
{
current = value ?? new DefaultDepartmentProvider();
}
}
public virtual Department Department { get; }
}
我们在获取器和设置器中都进行了空检查,并使用了一个名为 DefaultDepartmentProvider 的备用提供者来克服处理上下文不当的情况。
在 .NET Core 2.0 中的实现
corefx 库在 System.Threading 命名空间下的部分类 Thread 中有一个 CurrentPrincipal static 属性。
public static IPrincipal CurrentPrincipal
{
get
{
return CurrentThread._principal;
}
set
{
CurrentThread._principal = value;
}
}
使用示例可以在命名空间 System.Security.Permissions 下的类 PrincipalPermission 的方法 Demand() 中看到:

环境上下文的优点
当应用程序在执行期间多次请求相同的依赖项时,环境上下文绝对是一个救星。在编写注入依赖项的代码时,很难意识到在应用程序内部有如此多的重复请求相同依赖项的实例。这就是我们应该利用环境上下文模式的时候,通过一个简单的 static 访问器,我们就能获取到依赖项。这不仅减少了代码,还遵循了 DRY 原则。
环境上下文的缺点
环境上下文不易实现,在使用时需要仔细注意。仅通过查看一个类,我们无法判断它是否实现了环境上下文模式。如果在执行过程中上下文发生变化,结果将不同,从而导致副作用。
摘要
在实现依赖注入时,我们绝对应该学习与之相关的技术。这些技术,也称为依赖注入模式,在应用程序架构中起着至关重要的作用。因此,决定何时使用哪种模式是注入依赖项时最重要的因素。
构造函数注入是最广泛使用的,简单易用,并且应该始终是你的首选。然而,在某些情况下,你可能需要选择另一种模式。
当你看到某个类的不同操作需要多个依赖项时,选择方法注入模式,因为它允许你根据需要灵活地注入依赖项。
当你遇到需要在应用程序的多个地方使用特定依赖项的情况时,另一个漂亮的设计案例就会显现出来。这看起来像是一个横切关注点,然而,你期望得到一个特定的返回类型,即依赖项。为此,你需要使用环境上下文,而不能依赖于拦截器。这是因为你需要返回的依赖项在类中执行某些操作。
属性注入是一种允许你注入可选依赖项的技术。这意味着它肯定依赖于本地默认值,否则在请求它时我们可能会遇到异常。
在第九章《依赖注入中的反模式和误解》中,我们将探讨在实现依赖注入时的一些不良做法,这些做法被称为反模式。
第九章:关于依赖注入的反模式和误解
模式向我们展示了实现依赖注入的正确方式。在上一章中,我们深入了解到了 DI 生态系统中每个模式的使用、它们的优点、缺点,以及何时选择哪一个。如果正确遵循这些模式,我们就能实现一个松耦合的架构,这将更容易进行单元测试。
然而,在应用这些模式时,我们通常忽视了一些原则,这可能导致我们未来遇到问题。这可能是由于对模式行为的无知,或者简单地由于懒惰。
到那时,一个模式变成了反模式,因为它没有解决问题;相反,它产生了更多的错误,维护变成了头疼的问题。
在阅读本章之后,如果你在项目中发现了反模式,请不要担心,因为我们将学习如何通过选择上一章中讨论的任何技术来将每个反模式重构为合适的模式。如果你直接来到这一章,我建议你首先完成上一章(其中详细介绍了你应该知道的关于依赖注入模式的全部内容),然后再继续前进。
在本章中,我们将通过示例讨论这样的场景,以了解模式如何表现为反模式。
本章我们将涵盖以下主题:
-
当 DI 成为反模式时
-
DI 反模式
-
在项目中识别反模式
-
反模式给应用带来的问题
-
每种反模式的解决方案和重构步骤
何时依赖注入成为反模式?
我们将探索在具有 DI 的项目中开发者遵循的反模式。然而,你有没有想过 DI 本身成为反模式的场景!是的,有时它可以成为反模式。以下列出了这些情况:
假设我们有一个控制器依赖于Service进行某些数据库操作。例如,UsersController需要一个IUsersService类型的依赖项来进行User表相关的操作。我们为依赖项配置了UsersService,并且它已经注册到容器中。现在,请稍微帮助自己,并问以下问题。
你打算将依赖项更改为任何其他IUsersService 的实现吗?如果你的答案是否,那么请稍作思考。原因是,如果你不会通过代码或配置动态地更改依赖项的实现,那么依赖注入就不会发挥重要作用。例如,如果你将数据保存到数据库/XML/文本文件,你可能会有不同的实现,并且在某些条件下需要交换,那么依赖注入肯定会派上用场。但是,如果你只保存到数据库而没有其他操作,就没有必要添加额外的代码来注入依赖项。这样做没有太多意义。
你一启动应用程序就需要依赖项吗?依赖注入建议我们在一个称为组合根(Composition Root)的地方注册所有内容。然而,想象一下一个名为CompanyService的Service,它的实例只有在我想将User添加到公司时才需要。例如,看看以下代码:
public IActionResult AddUser(UserModel userModel)
{
var user = _usersService.CreateUser(userModel);
if (userModel.AddUserToCompany)
{
var companyService = new CompanyService();
companyService.AssignUserToCompany(user);
}
return View();
}
通常,我们公司有管理员负责管理用户记录。想象一下这样的场景,管理员登录后想要将一些用户分配给特定的公司。在这种情况下,有一个从名为AddUserToCompany的模型中来的boolean值。如果它是true,我们需要将用户分配给公司。这意味着懒加载实例化,看起来相当不错。
因此,在这里,它的意思是快速获取CompanyService实例并与之工作。然而,如果你选择了 DI,那么CompanyService实例将保留在容器中(因为你将其注册在组合根中),直到你实际上在代码中使用它,这通常不会发生。有时,如果登录的用户是普通用户而不是管理员,这种情况根本不会发生。
这只是一个简单的例子。你可以想象一个复杂的应用程序,这些类型的场景可能会在 DI 中产生不良影响。有时,懒加载比在容器中占用不必要的内存空间更好。
如果 DI 被不必要地选择或使用不当,它本身就会导致反模式。如果你永远不会为一种实现注入不同的依赖项,那么就没有必要使用 DI。现在让我们探索在使用项目中的 DI 时可能会遇到的反模式。
反模式
在实现依赖注入(DI)的项目中,可以发现四种主要的反模式,如下列出:
-
控制狂
-
恶魔注入
-
限制性构造
-
服务定位器
让我们分析每种类型,看看如何避免它们。
控制狂
控制狂与控制反转(Inversion of Control)相反。当一个类持有其依赖项并试图在没有他人干扰的情况下自己管理它们时,它将被标记为控制狂。
以下图表展示了控制狂的概览:

当我们说它试图自己管理依赖项时,我们指的是什么?记住在第六章,对象生命周期中,如果一个类想要管理一个依赖项,这意味着它想要实例化它,然后管理其生命周期,以及杀死或处理它。
实例化,是的,这是通过我们熟知的关键字new完成的。类使用new关键字在内部实例化每个依赖项,然后与对象一起工作,并通过析构函数或终结器来处理它们。因此,一旦这样做,它就创建了一个紧密耦合的系统。此外,它还使得单元测试变得困难。
该类要么自己创建对象,要么代表其他类执行此操作。让我们通过一个例子来理解这个场景:
public class EmployeeService
{
private readonly EmployeeRepository repository;
public EmployeeService()
{
string connectionString = "Read String from config";
this.repository = new SqlEmployeeRepository(connectionString);
}
}
立即发挥作用的第一件事是new SqlEmplyeeRepository()。现在,这个服务与SqlEmployeeRepository紧密耦合。假设你想使用另一个仓库类来替代它,那么我们必须更改服务代码并重新编译。没有这样的插件点来说明“我提供给你这个仓库,请使用它”。
问题
为了解决这个问题,开发者可能会考虑不同的模式,这最终会使情况复杂化。我们可以看到以下类型的工厂被普遍使用:
-
具体工厂
-
抽象工厂
-
静态工厂
具体工厂
创建EmployeeRepositoryFactory是拥有一个Create()方法的另一个借口(以及一种懒惰的方法),这个方法将通过new关键字创建一个SqlEmployeeRepository实例:
public class EmployeeRepositoryFactory
{
public EmployeeRepository Create()
{
string connectionString = "Read String from config";
return new SqlEmployeeRepository(connectionString);
}
}
我们从EmployeeService中移除了这个块,但添加了另一个与之前非常相似的新类。然后我们可以这样使用工厂:
public EmployeeService()
{
var employeeRepofactory = new EmployeeRepositoryFactory();
this.repository = employeeRepofactory.Create();
}
在EmployeeService构造函数中,我们使用new关键字获取工厂实例,然后调用Create()方法来获取SqlEmployeeRepository实例,并将其分配给repository变量。
我们是否实现了什么有用的东西?完全没有。我们只是在Service中添加了另一堆代码,以间接的方式(通过工厂)做了同样的事情(使用new关键字实例化)。基本上,工厂使用相同的new关键字来实例化SqlEmployeeRepository类。这正是我们想要避免的,但我们没有实现这一点。
抽象工厂
抽象工厂作为一个封装组件,封装了包括与其相关的依赖在内的复杂逻辑。因为它不完全允许消费者控制对象的生存周期,所以它可以从消费者那里转移控制权。
派生工厂负责创建和管理所需的仓库,而不是最初设计的工厂。考虑以下代码片段:
public abstract class EmployeeRepositoryFactory
{
public abstract EmployeeRepository Create();
}
这意味着我们试图隐藏将要服务的仓库是哪一个。我们试图通过隐藏实际提供的类型来实现松耦合。
为了分配repository变量,我们必须继承这个类并创建一个子类,返回SqlEmployeeRepository:
public class SqlEmployeeService : EmployeeRepositoryFactory
{
public override EmployeeRepository Create()
{
string connectionString = "Read String from config";
return new SqlEmployeeRepository(connectionString);
}
}
基本上,我们将repository的实例化与主服务解耦了。同样的问题再次出现。我们是否实现了什么有用的东西?我不这么认为。这是因为这种新的架构再次以在EmployeeService(使用new关键字)内部的一个实例化为代价:
public EmployeeService()
{
var sqlEmployeeService = new SqlEmployeeService();
this.repository = sqlEmployeeService.Create();
}
虽然你能够通过将其抽象化来隐藏SqlEmplyeeRepository从工厂中,但你没有改变在EmployeeService构造函数内部处理事情的方式。你现在正在实例化SqlEmployeeService。
静态工厂
下一个方法是通过引入static模式来避免工厂实例化。考虑以下代码片段:
public static class EmployeeRepositoryFactory
{
public static EmployeeRepository Create()
{
string connectionstring = "read string from config";
return new SqlEmployeeRepository(connectionstring);
}
}
这将阻止我们创建一个对象并直接使用它。让我们看看如何:
public EmployeeService()
{
repository = EmployeeRepositoryFactory.Create();
}
哈雷!我们终于移除了new关键字。好吧,看起来我们完成了。哦,等等!我们仍然在Create()方法中使用new创建了SqlEmployeeRepository实例。但是,有一个简单的解决方案可以从config或类似的地方读取这种类型的存储库:
public static EmployeeRepository Create()
{
var repository = "read from config";
switch (repository)
{
case "sql":
return EmployeeRepositoryFactory.CreateSql();
case "azure":
return EmployeeRepositoryFactory.CreateAzure();
default:
throw new InvalidOperationException("Invalid operation");
}
}
初看似乎很有希望,但实际上并非如此。所有类都变得紧密耦合。这如下面的图中所示:

EmployeeService依赖于EmployeeRepositoryFactory以获取EmployeeRepository实例,这意味着服务的客户端需要引用工厂、存储库以及CreateSql()和CreateAzure()返回的存储库类型,如SqlEmployeeRepository和AzureEmployeeRepository。
这些具体类之间产生了耦合,这不能产生灵活的设计,导致应用程序的后续程序员日子不好过。
解决方案
我们已经在上一章中探讨了模式。对于控制狂问题,构造函数注入是最合适的。考虑以下代码片段:
public class EmployeeService
{
private readonly IEmployeeRepository repository;
public EmployeeService(IEmployeeRepository repository)
{
this.repository = repository ?? throw new
ArgumentNullException("repository");
}
}
这样,你抽象出了具体的存储库,并且通过构造函数插入了依赖。通过引入一个工厂类,可以进一步进行重构,该工厂类将负责生成存储库。
现在可以使用 DI 容器注册工厂接口或存储库接口,并按需解析,以便依赖项对服务可用。
控制狂是最常见的一种反模式,在项目中实现。当开发者在他们的项目中考虑使用 DI 时,他们有时会发现这很困难,并且他们被控制对象创建而不是其他组件为他们做这件事所吸引。如果他们只是忽略成为控制狂,并跟随 DI 流程,结果将会很棒。
下一个反模式是“混蛋注入”。然而,在进入那个之前,我们需要了解一种名为“穷人 DI”的手动依赖管理方法Poor Man's DI。
穷人 DI
穷人 DI;这个名字听起来非常有趣,不是吗!当你自己尝试在普通场合(否则可以称为组合根)处理依赖项的注册,而不是使用库(特别是 DI 容器)时,这种技术可以定义为穷人 DI。
方法
让我们通过一个快速的代码示例来看看如何实现。假设EmployeeService依赖于EmployeeRepository类型的依赖项,我们可以直接将其提供到构造函数中,如下所示:
static void Main(string[] args)
{
EmployeeService empService = new
EmployeeService(new EmployeeRepository());
Console.ReadKey();
}
考虑这个控制台应用程序示例,其中 EmployeeService 在 Main 方法中实例化。它看起来简单而美观。但如果依赖项嵌套较深,则效果并不理想。
如果 EmployeeRepository 再次需要其他依赖项,然后又是另一个,依此类推,你可能会做到以下这样:
EmployeeService empService = new
EmployeeService(new EmployeeRepository(new
Cass1(new Class2(new Class3()))));
现在代码变得复杂且难以维护。然后你可能想要通过为每个类引入默认构造函数来在一定程度上简化这个结构。所以,以下是你为 Service 和 Repository 要做的事情:
public class EmployeeService : IEmployeeService
{
private readonly IEmployeeRepository repository;
// Default Constructor calls the parameterized one
public EmployeeService() : this(new EmployeeRepository())
{
}
public EmployeeService(IEmployeeRepository employeeRepository)
{
repository = employeeRepository;
}
}
public class EmployeeRepository : IEmployeeRepository
{
private readonly ISomeClass class1;
// Default constructor calls the parameterised one.
public EmployeeRepository() : this(new Class1())
{
}
public EmployeeRepository(ISomeClass someClass)
{
class1 = someClass;
}
}
你可以对所有嵌套的类做同样的事情。我们为所有类添加了默认构造函数;这些构造函数内部调用参数化构造函数,并带有默认依赖项实例。这肯定会减少代码。请看以下简化后的代码:
static void Main(string[] args)
{
EmployeeService empService = new
EmployeeService(); // No Dependency passed here.
Console.ReadKey();
}
现在不需要将这些构造函数传递任何依赖项。此外,如果你传递了期望类型的任何依赖项,它也会正常工作,因为还存在参数化构造函数。这意味着我们得到了一个非常灵活的类结构,并且我们也减少了实例化。
问题
尽管我们试图使实例化灵活、可测试和简单,但我们没有意识到这种方法的以下缺点:
-
当我们执行
new EmployeeRepository()时,在默认构造函数中创建了一个具体的引用。 -
默认依赖项实例与使用
new操作符的所有类绑定在一起。但依赖注入技术的整个目的就是减少应用程序中的new关键字。 -
我们还违反了在注册所有依赖项时遵循的一个原则,即称为 Composition Root。现在,Composition Roots 在应用程序的所有类中到处都是。这是不好的。
-
想象一下有 10 个类使用相同的依赖项的情况;现在再次使用相同的
new关键字实例化所有内容将非常麻烦。 -
你没有任何使用
new关键字管理创建的依赖项生命周期的工具。你必须手动销毁一切,这在完整的应用程序中可能会变得头疼。此外,如果你想要重用单个实例,你必须小心处理。这可能会导致不一致的行为和错误的数据。
那么,我们如何处理这个问题呢?让我们来看看。
解决方案
显然,使用依赖注入容器来注册依赖项解决了问题。我们可以移除实际上与所需类型创建紧密耦合的默认构造函数:
-
与“穷人的依赖注入”不同,那里的依赖项注册被限制在一个地方,在这里你在这里和那里实例化具体的类。
-
在使用依赖注入容器在 Composition Root 内部进行注册时,我们可以利用该功能通过不同的配置选项来配置应用程序使用不同类型的依赖。
-
对于深层嵌套的依赖项,我们得到了更干净的代码。
-
要让许多类使用依赖项,只需将其注册到容器中,然后放松即可。
-
你可以按自己的意愿管理依赖项的生命周期。实例可以表现为
Singleton、Transient或Scoped,具体取决于你的配置。
建议使用 DI 容器来注册和解析依赖项,而不是在组合根中手动管理它们。因此,默认构造函数方法被称为恶劣注入。在下一节中,我们将对此进行更多探讨。
恶劣注入
通常,类有多个构造函数。你可能会有这样的情况,在默认构造函数中从另一个组件引用了某个类。
下面的图示显示了如何一个类有两个构造函数:一个是默认的,另一个是参数化的。参数化构造函数处理注入并使ISomeClass实现可用于类操作。因此,创建SomeClass实例的默认构造函数变得不再必要:

问题
为了简单起见,让我们考虑同一个例子:EmployeeService,它需要一个仓库来工作:
public class EmployeeService
{
private readonly IEmployeeRepository repository;
// Default Constructor.
public EmployeeService()
{
repository = CreateDefaultRepository();
}
// Constructor Injection can happen here.
public EmployeeService(IEmployeeRepository repository)
{
if (repository == null)
{
throw new ArgumentNullException("repository");
}
this.repository = repository;
}
// Method creating a default repository.
private static EmployeeRepository CreateDefaultRepository()
{
string connectionString = "Read String from config";
return new SqlEmployeeRepository(connectionString);
}
}
存在一个默认构造函数,它通过创建一个SqlEmployeeRepository的实例来确保仓库的可用性。显然,默认仓库是从另一个组件中引用的,因为服务和仓库通常不会位于同一个组件中。这就是为什么默认构造函数可以被标记为外部默认。
当我们考虑只为了使服务在实例化后即可使用而设置一个默认仓库时,我们不知不觉中设计了一个服务和仓库之间紧密耦合的系统。
解决方案
恶劣注入之所以不好,仅仅是因为这个外部默认。此外,它可能依赖于我们甚至不需要在类中的某些东西。从前面的例子中可以看出,如果我们使用 DI 容器,它将自动将解析的依赖项连接到其他参数化构造函数。然而,如果我们有这种类型的默认构造函数,那么 DI 容器在选择目标时可能会感到困惑。只有一个构造函数用于注入确保与容器的顺畅操作。
当你遇到默认构造函数与外部默认产生耦合时,你可以考虑消除它,因为当你决定应用构造函数注入时。有一个构造函数用于 DI 就足够了,因为确保外部默认在服务请求时始终就绪是 DI 容器的责任。
当你重构代码并移除默认构造函数时,编译器可能会报错。在这种情况下,你需要将实例化代码移动到组合根。如果所引用的依赖项是局部默认值(它位于同一程序集内),那么我们仍然需要移除该构造函数,因为构造函数歧义会导致自动装配复杂性的增加。
如果你还记得,我们已经在第八章中讨论了局部默认值,模式 - 依赖注入。处理局部默认值的最简单方法就是引入属性注入。默认构造函数可以被转换为一个可写属性。
约束构造
接下来,还有一种类型的反模式,试图劫持构造函数。因此,开发者迫使依赖项的构造函数具有特定的签名,这导致了问题。背后的原因可能是为了通过外部配置文件定义依赖项以实现后期绑定。
后期绑定可以指从配置文件中读取存储库类型(存储库的派生类)和连接字符串以实例化某些存储库。
拥有后期绑定技术不仅有助于将代码与依赖项隔离,而且确保在配置更新时代码不会被重新编译。使用通用或应用程序根来定义所有依赖项不会暴露任何问题,但当我们想要更新依赖项时,重新编译是必须的:

问题
想象有两个在应用程序中使用的存储库EmployeeRepository和StaffRepository。它们都有构造函数,你将传递存储库类型和连接字符串给它们,以便使用这些参数创建存储库。这是不好的,因为你现在将从配置中获取存储库类型和连接字符串,如果config没有所需的键,可能会引发问题。
现在你已经从config中获取了存储库类型名称,你必须使用该名称创建一个System.Type实例:
var employeeRepositoryTypeName = "Read from config";
//SqlEmployeeRepository
var connectionString = "Read from config";
var employeeRepositoryType = Type.GetType(employeeRepositoryTypeName,
true);
var employeeRepository = Activator.CreateInstance(employeeRepositoryType,
connectionString);
Activator.CreateInstance用于调用给定类型的构造函数。此方法中的第二个参数传递给第一个参数中提供的类型的构造函数。
假设config中的类型是SqlEmployeeRepository。基本上,它假设以下内容:
-
SqlEmployeeRepository继承自EmployeeRepository -
实现应该包含一个可以接受连接字符串作为参数的构造函数
这些实际上就是约束。
从对象构建的角度来看,它可能看起来很完美,但也有一些缺点。例如,考虑使用相同依赖项(如单个上下文)构建存储库。使用 Activator.CreateInstance,我们可以无疑地创建指定类型的实例,在我们的例子中,这将创建 EmployeeRepository 和 StaffRepository 的实例。但我们无法将单个上下文分配给它们中的任何一个,因为每个人都会根据他们的构造函数创建他们自己的上下文。以下图表描述了我想要传达的内容:

只有当外部人员为它们中的每一个提供上下文,而不是它们自己创建上下文时,才能共享上下文:

在这种情况下,在类之间共享单个依赖项变得困难。将创建多个相同依赖项的实例,这是不必要的。这将消耗内存和资源。
在设计时应该仔细选择特定依赖项的单个实例。如果没有妥善处理,可能会对在不同线程中运行的应用程序产生不利影响。如果您还记得,我们已经在 第六章 中讨论了 Singleton Lifetime,对象生命周期,我们讨论了该模式的优点和用法。
可以将某人视为一个工厂,这是我们接下来将要探讨的。
解决方案
DI 容器在组合根处提供帮助,以克服这些困难并一次性解决所有依赖项。因此,注入可以发生,一切运行顺利。不需要为具有连接字符串的依赖项提供单独的构造函数。
即使没有 DI 容器,我们也可以以不同的方式制定我们的解决方案。当我们想到集中依赖项构建时,抽象工厂就派上用场了。
首先,工厂、服务和存储库应该位于不同的程序集中。这背后有一个强有力的原因。我们将很快探讨它。
在 ASP.NET 应用程序中,我们不会使用 Activator.CreateInstance 在 Global asax 内部创建每个依赖对象,而是将设计成不同的方式,这样就不需要每次想要使用不同类型的存储库时都重新编译应用程序。
我们将设计一个名为 EmployeeServiceFactory 的工厂,该工厂实现 IEmployeeServiceFactory 并使用特定的存储库。这个工厂将负责创建服务。Service 可能看起来像以下这样:
public class EmployeeService : IEmployeeService
{
public EmployeeService(IEmployeeRepository repository)
{
}
}
EmployeeServiceFactory 包含一个 CreateService() 方法来创建它所负责的服务(在本例中为 EmployeeService)。
将工厂与应用程序和 DataAccess 隔离是很重要的,以消除耦合。因此,工厂应该位于不同的程序集。
你可以在.config文件中存储工厂的合格类型名称。然后可以使用Activator.CreateInstance来创建IEmployeeServiceFactory实现(你将在配置中定义)的实例,因为它有一个默认构造函数:
var employeeFactoryTypeName = "Read from config";
var employeeFactoryType = Type.GetType(employeeFactoryTypeName, true);
var employeeFactory = Activator.CreateInstance(employeeFactoryType);
现在有了employeeFactory,你可以调用CreateService(),这将通过相关的存储库EmployeeRepository返回EmployeeService实例。
如果你决定使用除EmployeeRepository之外的其他类型作为服务的依赖项,那么你可以更新config中的相关键。但在那之前,你需要向工厂程序集添加另一个工厂来实现IEmployeeServiceFactory并操作新的配置存储库。这样,你重新编译工厂程序集,而不需要重新编译应用程序,一切按预期工作。
服务定位器
如果你记得,在控制狂部分,我们讨论了静态工厂。通过某些修改,静态工厂可以表现得像服务定位器。正如其名所示,它将定位或找到你所需的服务:

服务定位器在许多情况下非常有用,这就是为什么开发者认为它是一个模式。但它有很多缺点。这就是为什么我们把它列在本章而不是上一章。
我不会阻止你使用这种技术,但我想揭示一些其优缺点。这肯定有助于你根据你的应用程序做出更好的设计决策。
定位器的一些显著特性包括以下内容:
-
定位器背后的重要逻辑是它允许依赖项或服务轻松地注入其中
-
当配置了服务或依赖项时,静态工厂通常被认为是服务定位器
-
服务定位器的配置可以在组合根处进行
-
服务定位器的配置可以通过代码或读取相关的配置设置来管理
依赖注入容器看起来像服务定位器。在 DI 上下文中,定位器或容器的主要职责是在其他操作开始之前解决依赖图。理想情况下,解决图应该只在组合根处发生,以实现正确的实现。问题开始于你直接在应用程序中使用定位器或容器请求依赖项或服务,而不是将它们注入到消费者中。在这种情况下,定位器将被标记为反模式。
定位器过程可以定义为如下:
-
在注册服务时,定位器将一个实例存储在字典中:注册通常使用接口完成。你基本上告诉定位器一个接口及其具体实现。例如,你会说如果请求
EmployeeService则提供EmployeeService,如果需要IStudentService则提供StudentService。 -
定位器接收请求以提供其接口注册的服务的一个实例:所以,当你的应用程序中的某些代码想要与
Student实体一起工作时,它会向定位器请求一个IStudentInterface实现。 -
定位器从存储的实例中搜索实例,然后将其返回给客户端:正如你已经训练过的定位器,它将只通过检查所有存储的实例来返回请求的接口实现。
设计
一个简单的服务定位器可能类似于以下类:
public static class ServiceLocator
{
static Dictionary<Type, object> servicesDictionary =
new Dictionary<Type, object>();
public static void Register<T>(T service)
{
servicesDictionary[typeof(T)] = service;
}
public static T GetService<T>()
{
T instance = default(T);
if (servicesDictionary.ContainsKey(typeof(T)) == true)
{
instance = (T)servicesDictionary[typeof(T)];
}
return instance;
}
}
Register方法将服务存储在字典中,GetService方法返回。我们可以使用定位器来获取特定类型的实例:
public class EmployeeService : IEmployeeService
{
private readonly IEmployeeRepository repository;
public EmployeeService()
{
this.repository = ServiceLocator.GetService<IEmployeeRepository>();
}
}
如果你已经使用Register方法预先注册了服务,那么你可以从字典中获取它。
优点
当然,这个模式也有一些优点:
-
它通过更改注册代码来支持后期绑定。
-
它采用基于接口的程序,这样我们就可以并行开发代码,并且可以按照我们的要求替换模块。
-
可以实现关注点的分离。我们可以编写可维护的代码,尽管这并不容易。
不要被这些优势所迷惑。这可能看起来对你来说非常完美,但这种方法有很多缺点。
问题
服务定位器作为一个合适的模式;然而,你必须忍受以下问题:
代码可重用性
它阻碍了类的可重用性,因为依赖关系不再由定位器集中管理。它们可能通过定位器的GetService方法散布在整个类中:

EmployeeService现在依赖于EmployeeRepository和ServiceLocator。理想情况下,它应该只依赖于存储库来遵循 DI 原则。
由于这两个依赖项都存在,如果有人想要重用EmployeeService,那么他们必须引用这两个依赖项。如果ServiceLocator位于不同的程序集,那么还需要程序集引用,这会导致一个非常低效的设计。如果你说我这是一个紧密耦合的架构,你一定会同意我的观点。
此外,服务的消费者在实例化服务时无法识别依赖关系。这是因为定位器在构造函数或方法内部使用,而不是像 DI 策略那样公开:
var empService = new EmployeeService();
现在你可能会争辩,开发者为什么要试图了解内部的内容,因为依赖关系是在默认构造函数内部处理的吗?但是如果你忘记将依赖关系注册到定位器中会发生什么?不要说你不会忘记。很可能发生这种情况,因为在你实例化类的时候,依赖关系的存在并不能通过构造函数来明确。因此,如果类没有告诉你它依赖于什么,你在寻找和注册它时就不会那么小心,这可能会导致意外的异常。
我知道你现在在想什么;显然,服务是一种控制狂,不是吗?它正在使用定位器来控制依赖项。
需求类(needy class)指的是依赖于他人的类。它不再遵循 DI,因为依赖项不是注入的;而是从定位器的static字典中获取的。
另一个问题是在开发者想要向已经采用定位器模式的类添加更多依赖项时可以清楚地识别出来。您要么遵循相同的原理引入更多依赖项,要么移除服务定位器模式并实现 DI。在这两种情况下,编译错误是肯定的。
由于所有上述原因,服务定位器被认为是一种反模式。让我们谈谈解决这种反模式的稳健解决方案。
解决方案
如同往常,构造函数注入(Constructor Injection)是最合适的选择,当我们决定重构服务定位器(Service Locator)代码时,首先想到的就是它。通过构造函数注入可以完全移除服务定位器。
重构步骤
在大多数情况下,定位器(Locator)在代码库的各个地方被用来获取依赖项的实例。按照以下步骤重构它以实现 DI:
-
识别代码库中所有的定位器(Locator)调用。
-
如果类中没有,引入一个成员变量来持有依赖项。
-
将字段标记为
readonly,这样它就不能在构造函数外部被修改。 -
在构造函数内部使用定位器分配字段。现在定位器调用只在一个地方。
-
为依赖项添加一个构造函数参数。
-
从构造函数块中移除定位器,并直接将构造函数参数分配给
readonly字段。 -
识别对类的所有实例化调用,并将连接(wiring)移动到组合根(Composition Root)。
经过所有这些步骤后,使用构造函数注入模式(Constructor Injection Pattern)的相同EmployeeService可以被设计出来,如下所示:
public class EmployeeService : IEmployeeService
{
private readonly IEmployeeRepository repository;
public EmployeeService(IEmployeeRepository repository)
{
this.repository = repository;
}
}
现在服务要求其消费者提供一个IEmployeeRepository实现的依赖项,之前并没有发生这种情况。
摘要
最后一章介绍了实现依赖注入(DI)的方法。当我们没有正确实现模式时,我们的应用程序设计就会变得糟糕。我们了解到了在实现 DI 过程中经常犯的错误。
在继续讨论 DI 反模式之前,我们讨论了为什么以及何时我们可以将 DI 本身视为反模式!
然后我们继续前进,讨论了所有由对依赖注入(Dependency Injection)的误解引起的常见反模式。我们探讨了控制狂(Control Freak)、糟糕的注入(Bastard Injection)、约束构造(Constrained Construction)和(最重要的)服务定位器(Service Locator)。
控制狂(Control Freak)是最容易发现的。每当您看到任何类使用new关键字来实例化其依赖项时,这意味着它试图在没有外部模块控制的情况下管理它们。这是不好的,在 DI 生态系统中应该避免这样做。
这是其中最危险的一个,我们在重构时应该首先解决。其他模式比这个危害小,因为它对松耦合有直接影响。组合根(Composition Root)应该是实例化应用程序所需的一切的地方,然后通过注入,所有可能的依赖项都将可用。
恶魔注入(Bastard Injection)可以通过外部默认值(Foreign Defaults)观察到,从依赖注入(DI)的角度来看,这是不必要的。我们可以通过移除与外部默认值相关的代码,轻松地过渡到构造函数注入(Constructor Injection)。
另一方面,约束构造(Constrained Construction)通过对构造函数施加限制,通过从配置文件中获取类型来支持服务的后期绑定,这反过来又创建了紧密耦合。采用依赖注入容器(DI Container)或一个抽象工厂(Abstract Factory)将有助于消除这些限制。
最后但同样重要的是,我们讨论了服务定位器(Service Locator),这可以说是正确的设计模式。然而,我们探讨了它的优缺点,并得出结论,它是一个反模式。
如果你已经到达这个阶段,这意味着你现在已经掌握了实现依赖注入的方法以及要避免的事项。在下一章中,我们将讨论项目中更现实的问题以及如何处理这些问题。
第十章:其他 JavaScript 框架中的依赖注入
在第九章,依赖注入的反模式和误解中,我们讨论了使用依赖注入时最重要的反模式,以及在使用它时的一些典型误解。在本章中,我们将处理其他框架中的依赖注入,特别是 TypeScript 2.3 和 Angular 4+。
回顾 TypeScript 的基础知识的原因是 Angular 2+ 使用了这种语言,因此我们需要了解如何进行类创建和模块管理,才能真正掌握这种架构背后的主要概念。
在本章中,我们将涵盖:
-
TypeScript 2.3+ 中的类创建和模块管理
-
AngularJS(版本 1.x)中 DI 技术的原生实现和使用
-
在 Angular 4+ 中使用 DI 的原生实现和可定制选项
TypeScript
你可能已经知道 TypeScript 在现代 JavaScript 框架开发中的作用,甚至作为 JavaScript 的改进替代品本身。
TypeScript 多次被定义为 JavaScript 的强类型超集。它是一个超集,因为它包括了 JavaScript 所有的内容(这扩展到版本 3、5 和 6,也称为 ES2015),以及一些特性,允许程序员以面向对象的方式编程。在撰写本文时,可用的最新版本是 2.3:

(图片来源:blog.soat.fr/2016/08/feedback-typescript/)
因此,我们在保持可以编写正常 JavaScript 的同时(一个重要的点是任何有效的 JavaScript 都是有效的 TypeScript),仍然发现许多语法上的好处。
以这种方式,TypeScript 通过使用接口和静态类型来促进更声明式的编程风格,它提供了模块和类的概念,并且能够很好地与大多数现有的 JavaScript 库和代码集成。我们可以将其视为一个强静态层,覆盖在 JavaScript 之上,并附带许多功能,使程序员的(尤其是调试)工作变得更加易于管理。
如果你想对 TypeScript 语言有一个好的介绍,你可以阅读 Remo H. Jansen 的 Introducing Object-Oriented Programming with TypeScript(见 www.packtpub.com/books/content/introducing-object-oriented-programmng-typescript),如果你更喜欢深入语言及其可能性,可以查看 Nathan Rozentals 的优秀作品 Mastering TypeScript(可在 www.packtpub.com/web-development/mastering-typescript 购买)。
当创建语言时,Anders Hejlsberg(TypeScript 的首席架构师)的主要目标是:
-
创建一个完全面向对象的编程语言,在编译时转换为 JavaScript(由于它只是产生另一种语言而不是编译模块,因此称为transpilation),允许最终生成的 JavaScript 在任何浏览器(或 Web 平台)中执行。
-
使语言静态类型化,这样工具就可以在任何编辑器中提供现代开发技术:Intellisense、代码补全、智能重构等。
-
通过使语言完全开源来让社区参与到项目中。你可以在其网站上看到项目的当前状态并协作,网址为
www.typescriptlang.org/。
实际上,TypeScript 已经非常成功,以至于 Angular 开发团队采用了它来创建 Angular 2,并且继续为未来的版本(最新的是 Angular 4.1)与该语言合作。
架构变化
理解一些架构变化对于实现 DI(依赖注入)在编译后不是面向对象的编程语言中是基本的,除非你是在将其转换为 ES2015。
ES2015(或简称 ES6)中最大的变化之一是模块的存在。基本上,模块就是存储在文件中的 JavaScript 代码。一旦编写,你可以说每个模块对应一个文件,每个文件对应一个模块。
TypeScript 中的模块
TypeScript 定义了两种不同类型的模块——内部和外部。此外,我们还可以将内部模块进一步区分成两类:那些有名称的和那些没有名称的(你可以称它们为隐式的)。在这种情况下,区别在于你定义和使用它们的方式。
假设你有一个包含以下内容的 TypeScript 文件:
// Implicit module (goes to the global namespace)
// It appears as part of the window object
class ClassA {
private x = "The String";
public y = 4;
};
var ca = new ClassA();
这段代码本身就是一个隐式模块。它成为全局命名空间的一部分,你可以在运行时在window对象中找到它。你也可以将全局命名空间视为隐式(默认)模块。
当然,我们有一些比污染全局命名空间更好的解决方案。最明显的一个是module关键字,它允许定义一个私有代码区域。
根据定义,模块内部声明的所有内容对该模块都是私有的。因此,新的保留字import和export被用来允许访问命名模块内的代码片段。
如果我们将之前的类包裹在一个模块定义中,那么在模块外部尝试引用模块成员将不被识别(请参阅以下截图,位于 Visual Studio 2017 编辑器内):

要使模块的成员在模块外部可用,你应该使用export关键字。将之前的声明改为如下:
export class ClassA
然后,你可以使用模块的名称来访问其公共成员:
var c = new MyClasses.ClassA();
console.log(c.y); // Logs 4 in the console window.
注意,由于ClassA.x成员的私有声明,它也无法访问。这样,我们就有了将事物与其应属于的命名空间关联起来的便捷方式。
外部模块
然而,当你处理大型应用程序时,最有用的方法就是使用外部模块。实际上,正如 John Papa 建议的(johnpapa.net/typescriptpost4/),按你需要的模块来组织应用程序的功能可能是有用的。
假设我们在ES6Code.ts文件中有一个模块定义。要导出foo()函数和Timer类,你可以这样声明:
// File ES6Code.ts
module ES6Code {
export function foo() { console.log("Foo executed"); }
export class Timer {
localTime: string;
currentDate: string;
constructor( todaysDate:Date ) {
this.localTime = todaysDate.toTimeString();
this.currentDate = todaysDate.toLocaleDateString();
}
}
}
现在,在单独的模块或脚本部分,我们可以使用稍微不同的引用来访问该功能:
// File: app.ts ---------
// Simple Function Import
import foo = ES6Code.foo;
foo(); // OK.
对于Timer类也是如此,只不过我们还可以采用另一种方法来引用这个类:
// File: app.ts ---------
// Reference Class Import (alternative syntax)
var timer = new ES6Code.Timer(new Date());
console.log(timer.currentDate);
console.log(timer.localTime);
在这种情况下,模块的名称用作类实例化的前缀,这与在处理 C#代码时找到的命名空间方式类似。
要测试这段代码,我们只需在 HTML 页面中包含对应编译后的文件(.js扩展名)的引用,或者在内置的 Node 控制台中测试它。例如,如果你使用一个空白 HTML 页面,你可以这样包含文件:
<head>
<meta charset="utf-8" />
<title></title>
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<script src="img/ES6Code.js"></script>
<script src="img/app.js"></script>
</head>
注意:<link>标签是一个假 favicon,用于防止 Chrome 在控制台输出“文件未找到”错误。
在这两种情况下,引用都是正确的,代码按预期执行,正如我们可以在下面的屏幕截图中看到(记住:在任何现代浏览器的开发者工具中的F12/Console。这里我使用的是 Chrome):

这是因为,一旦文件在页面内部被引用,只要它们被标记为export,它们的成员就可以访问。
实际上,我们可以引用多个文件,并且它们将按顺序加载和执行。注意,控制台窗口还指示了哪些代码负责执行控制台中的哪个条目。
在初始示例中,MyClasses存储在app1.ts文件中。因此,现在我们也可以在</head>标签之前引用它,并检查第三个源在控制台中的显示,输出数字 4 在最后一个位置(如下面的屏幕截图所示):

此外,你会发现 Chrome 足够智能,能够引用原始的.ts文件而不是编译后的文件,这允许你调试这些文件中的任何一个,设置断点等。在下面的屏幕截图中,我展示了在打印日期和时间值之前设置断点后的 Chrome 调试窗口:

除了这些选项之外,现在还可以使用 Chrome 作为默认浏览器在 Visual Studio 2017 中进行调试。
这种模块分离很重要,因为,正如你稍后将会看到的,这意味着文件分离,这对于 TypeScript 和,尤其是 Angular 组织应用程序的不同组件的方式变得至关重要。
TypeScript 中的依赖注入
所有这些说完,TypeScript 本身并没有 DI 容器。然而,你可以使用一些可用的第三方选项。其中一些最受欢迎的是 Infuse.js(可在 github.com/soundstep/infuse.js 获取)和 TypeScript IoC,你可以在 NPMJS 网站上找到它(www.npmjs.com/package/typescript-ioc),两者都以与我们已经在 .NET Core 中看到的方式非常相似的方式工作。
也就是说,你必须做出定义,将接口映射到类,甚至将描述符映射到类,然后注册这些选项。之后,你可以引用所需的类型,并期望 DI 容器为你提供相应的类型。
让我们回顾一下 TypeScript IoC 的工作方式,以定义一个简单的注入场景,就像它在官方页面上展示的那样。
首先,假设你已经安装了 TypeScript,你还应该使用典型的 npm 命令安装 TypeScript IoC:
npm install typescript-ioc
此外,配置文件(tsconfig.json 文件)中还需要一些现代选项:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
一旦配置完成,你就可以使用 import 语法的一种变体来投入使用:
import {AutoWired, Inject} from "typescript-ioc";
class PersonDAO {
@Inject restProxy: PersonRestProxy;
}
如你所见,restProxy 属性(属于另一种类型 PersonRestProxy)被标记为 @Inject 装饰器(JavaScript 最新版本中的新功能,并在 TypeScript 中可用),以表明它可以在其他代码中稍后注入)。
在代码的另一个地方,你将能够使用这些定义,语法非常简单:
let personDAO: PersonDAO = new PersonDAO();
restProxy 属性将由容器提供,解决依赖关系。参数注入也通过一个具有构造函数的类提供,如下面的代码所示:
class PersonService {
private personDAO: PersonDAO;
constructor( @Inject personDAO: PersonDAO ) {
this.personDAO = personDAO;
}
}
如果后来你有一个使用 PersonService 作为属性的另一个类,你可以在该属性上标记 @Inject,就像以下代码中所示:
class PersonController {
@Inject private personService: PersonService;
}
你可以依赖容器管理的依赖关系链,它将通过所有之前标记为 @Inject 的引用。
然而,在实践中,很少看到 TypeScript 本身的应用程序,这种语言最常见的用途是向其他框架,如 Angular 或 Ionic,提供一致的开发语言。
Angular
如 第二章 中提到的,依赖注入和 IoC 容器,Angular 是由 Google 团队(由 MiskoHevery 领导)创建的一个开发框架,现在已经成为非常流行的框架(你可以在 angularjs.org 访问官方信息)。
现在,Angular 以两种不同的风味提供,遵循不同的发布路径或分支:
-
版本 1.x:也称为 AngularJS。它适用于小型/中型应用程序,并使用 MVC 模型从开始就实现适当的关注点分离:
-
它由一组 JavaScript 库组成,每个库提供所需功能的一部分。然而,所有库都依赖于基本的 AngularJS 库。
-
在撰写本文时,最新版本是 1.6.4,团队保证提供支持和未来的更新。它也可以通过 CDN 在
ajax.googleapis.com/ajax/libs/angularjs/1.6.4/angular.min.js下载。
-
-
版本 2+:它被称为 Angular,遵循语义版本控制路径,这意味着在次要修订中不允许有破坏性变化,只有在新的版本中才允许。为了避免 3.0,版本号跳过了 3.0,所以最新版本是 Angular 4(确切地说,是 4.1)。它除了 AngularJS 之外还有一个专门的网站
angular.io/:-
可能最大的变化是 Angular 不与 AngularJS 兼容,因为它采用了 ES6 中出现的一些变化,这直接影响了模块的管理方式。
-
它的主要特性包括跨兼容性、改进的速度和性能、优秀的工具支持,以及全球社区日益增长的采用率。
-
Angular 是通过与微软 TypeScript 团队的合作在 TypeScript 中构建的,该团队积极参与项目。最近,团队的一位主要成员宣布:“自 2017 年 3 月起,TypeScript 已被允许用于不受限制的客户端开发。TypeScript 和 TypeScript 上的 Angular 在 Google Analytics、Firebase 和 Google Cloud Platform 以及关键内部工具(如错误跟踪、员工评审和产品审批及发布工具)中使用。”
-
从架构角度来看,这两个版本有一些共同点。它们促进 SOLID 原则,特别是 SRP 和 DIP,因此它们都依赖于依赖注入容器。此外,采用 MVC 模型有助于结构化关注点的分离。
让我们看看这两大流行架构如何实现依赖注入(尽管方式不同)。
AngularJS
一旦清楚 MVC 的基本原理,创建一个非常基础的 Angular 应用程序就很容易了。MVC 模型建议将应用程序组件的基本分离为三个部分(见以下图示):

(图片来源:维基百科:en.wikipedia.org/wiki/Model-view-controller)
用户的交互生成一个电路,其中包含 MVC 的三个支柱:
-
模型(MODEL)在加载 AngularJS 库的过程中自动创建。
-
视图(VIEW)对应于 HTML 部分,通过自定义属性(所有以 ng- 开头)来标记所需的特定功能。视图还使用一种称为 mustache 的语法来指示哪些部分受数据绑定的影响。
-
控制器是 JavaScript 片段,编码以反映用户请求的任何更改,并在需要时操作模型。
当用户与页面中的 UI 元素交互时,控制器会处理相应的操作,如果需要则修改模型,Angular 会更新视图以反映这些变化。最后,用户接收到的视图继续不间断地循环。
使用 Visual Studio 2017 的示例
一旦你有一个加载了 AngularJS 库的 HTML 页面,你应该用一个特殊的属性(ng-app)标记一个 DOM 元素,以通知 Angular 其工作作用域。这定义了 AngularJS 在页面内的作用区域。
让我们通过一个非常简单的例子来看一下所有这些。我们也可以使用 Visual Studio 2017,例如创建一个新的网站(请注意,我们不需要项目,因为我们不需要在服务器上编译任何代码:所有操作都在客户端进行)。
因此,我建议选择 ASP.NET 空网站来进行这个初始演示。这将创建一个文件夹来保存解决方案,并且会包含一个 Web.config 文件,以防你需要指导服务器关于某些行为或需要一些编译后的代码。
接下来,我们添加一个 HTML 页面并将解决方案保存,以便能够使用管理 NuGet 包选项并搜索 AngularJS.Core 库。确保如果你不想被这个框架的所有可用库所淹没,你选择的是 AngularJS.Core 而不是 just angularjs(参见以下截图):

安装完成后,解决方案资源管理器中会出现一个新的 Scripts 文件夹,包括一些库。你只需要将 angular.js 库拖放到 <head> 标签内,让 Visual Studio 创建一个指向库的 <script> 标签,然后你就可以开始了!
下一步是添加 ng-app 属性(例如,到 <body> 标签)并给它一个有效的名称,比如 app。目前,我们有一个页面,加载了 angular 库,并且定义了一个作用域。
我们如何使用这个来看到一些 AngularJS 的实际应用?我们可以创建一个 HTML 标签,如 <h2>、<h3>、<div>、<article> 等,并在其中包含一个莫斯塔奇链接(它们被称为 AngularJS 绑定表达式),这些链接应该在运行时解析,例如,一个显示当前时间的 <h2> 标签。总的来说,到目前为止,我们应该有一个像这样的页面:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>AngularJS initial demo</title>
<script src="img/angular.js"></script>
</head>
<body ng-app="app">
<h2>Current time: {{time}}</h2>
</body>
</html>
观察两个尚未定义的标识符(app 和 time)。这将是我们这个页面的 JavaScript 部分。因此,在关闭 </body> 标签之前,我们将包含一个 <script> 标签,其中包含以下代码:
<script>
var app = angular.module("app", []);
app.controller("TimeController", function ($scope) {
$scope.time = new Date().toLocaleTimeString();
});
</script>
这需要一些解释:首先,我们创建一个名为 app 的 module。这是一个由 AngularJS 管理的对象,用于建立感兴趣的 DOM 区域,并且它是在加载时通过在 angular 对象上调用 method module 创建的。我们只是将返回值缓存到同名变量中,以便使接下来的代码更清晰。
下一步至关重要。在这里,我们创建一个名为 TimeController 的控制器,并给它分配一个回调函数。嗯,这个函数默认使用依赖注入!如果你注意到函数的定义,其中定义了一个 $scope 变量。它从哪里来?
解释是,在几个 Angular 构造中,当你定义一个回调函数并声明一个可识别的服务作为参数(如 $scope)时,$injector 对象会提供该服务的单例实例给你,而无需我们自己的干预。
正是那个 $injector 对象提供了 AngularJS 中的 DI 容器服务。每个由模块管理的对象都有该服务的实例,并负责解决所有依赖。
官方的 AngularJS 文档以这种方式定义了其实现:
AngularJS 的注入子系统负责创建组件,解决它们的依赖关系,并在请求时将它们提供给其他组件。
理解 AngularJS 引导结构
以下图表显示了加载 Angular 应用时发生的引导过程的结构:

详细解释如下:带有以 ng- 开头的属性的 HTML 元素表示 DOM 的一部分,称为动态 DOM(没有 ng- 属性的,被认为是静态 DOM)。
让我们简要地重现所使用的步骤:
-
当 DOM 加载时,AngularJS 会寻找带有
ng-app标记的元素,并定义一个与它关联的$injector。 -
反过来,该注入器定义了一个
$compile服务,它教会 HTML 解释器一些新的语法。更准确地说,文档通过以下方式解释这一点:
编译器允许你将行为附加到任何 HTML 元素或属性,甚至可以创建具有自定义行为的新的 HTML 元素或属性。AngularJS 将这些行为扩展称为指令。
-
记住所有这些,AngularJS 还创建了一个特殊的服务,称为
$rootScope,它作为模块的根模型。当然,你可以在自己的代码中使用它。 -
现在,你创建的每个控制器都有一个
$rootScope的子控制器,简单地称为$scope:这就是该控制器管理的模型部分。 -
作为最后一步,
$compile对象遍历模块,寻找具有ng-*属性的元素,这里称为指令,或者 AngularJS 表达式({{moustache}}注释),并用所需的数据或代码替换这些元素。
因此,我们 HTML 代码的最后一个方面将是(我只包括 <body> 元素的內容):
<body ng-app="app">
<h2 ng-controller="TimeController">Current time: {{time}}</h2>
<script>
var app = angular.module("app", []);
app.controller("TimeController", function ($scope) {
$scope.time = new Date().toLocaleTimeString();
});
</script>
</body>
因此,我在这里所做的是创建一个名为 TimeController 的控制器变量(time),其值等于表示当前系统时间的字符串。
最后,我们需要指出哪个 AngularJS 元素由哪个控制器管理:在这种情况下,是包含在模型中定义的time变量的<h2>元素。在运行时,AngularJS 将变量的值替换为{{time}}表达式。
浏览器中的输出每次刷新页面时都会改变(请参阅以下截图):

这是一个非常简单的示例,但它说明了 AngularJS 的基础以及 DI 如何在整个框架中无处不在,因为你会发现它无处不在。
数据访问和依赖注入
实际上,如果我们使用另一个名为$http的 AngularJS 服务访问一些真实数据,我们可以看到控制器的一个更好的实现以及如何使用$injector对象。
以下代码读取了我们在第九章中使用的BookStore2.json文件的所有数据,依赖注入的反模式和误解演示,并创建了一个元素列表。我将首先展示代码,然后我们将进行解释:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>AngularJS Data Access Demo</title>
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<link href="Content/bootstrap.css" rel="stylesheet" />
<script src="img/angular.js"></script>
</head>
<body ng-app="app" class="container">
<h2>A list of ASP.NET Core Books (by PACKT)</h2>
<ul ng-controller="ListController">
<li ng-repeat="book in Books">
{{book.Title}}, <strong><em>{{book.Author}}</em></strong>
</li>
</ul>
<script>
angular.module("app", []);
var ListController = function ($scope, $http) {
$http.get("BookStore.json").
then(function (response) {
$scope.Books = response.data;
}).
catch(function (error) {
alert(error.statusText);
});
};
ListController.$inject = ["$scope", "$http"];
angular.module("app").controller("ListController",
ListController);
</script>
</body>
</html>
在取消注释之前,让我们注意,我还在使用 BootStrap 库,但这只是为了展示目的;它与 DI 无关。
如果你在任何浏览器中查看之前的代码,它将呈现一个与以下截图非常相似的输出:

让我们回顾一下这段代码中最重要的一些更改。在 JavaScript 方面:
-
模块创建不会缓存在变量中(这避免了在全局空间中不必要的变量)。
-
控制器是通过将一个函数表达式赋值给同名的变量来创建的。它声明了两个稍后将要注入的变量:
-
$scope:与控制器链接的模型引用 -
$http:一个服务,它通过使用 AngularJS 中的XmlHttpRequest对象(AJAX)或 JSONP 与 HTTP 服务器进行请求/响应操作
-
-
在函数内部,使用
$http通过调用其get方法并传递要恢复的资源 URL 来获取数据。该调用返回一个承诺,该承诺异步解决:-
当承诺解决时,它返回预期的信息,并且与之链接的回调函数将接收到一个对象,该对象具有以下属性,根据官方网站的文档:
-
data{string|Object}: 使用转换函数转换后的响应体 -
status{number}: 响应的 HTTP 状态码 -
headers{function([headerName])}: 获取头部的函数 -
config{Object}: 生成请求时使用的配置对象 -
statusText{string}: 响应的 HTTP 状态文本
-
-
如果状态码是 200 到 299 之间的数字,则过程成功,并且后续调用
.then()将接收其[parameter].data属性内的信息 -
否则(如果你得到不同的状态码),将出现错误,你应该通过额外的
.catch()调用来捕获它,就像我们在前面的代码中所做的那样
-
-
$injector对象能够解析变量的名称,作为它们所代表服务的单例实例,但当你打包应用程序并且minifiers更改这些变量的名称时会发生什么?这就是$inject数组发挥作用的地方:- 它允许minifiers重命名函数的参数,同时仍然能够注入引用的服务。请注意,这是一个与控制器链接的数组,它可以随意增长或缩小。
-
最后一步是在模块内部定义控制器本身。这就是为什么我们使用
angular.module("app")语法,它访问模块并在其中调用所需的方法(注意这次我们没有传递第二个参数:这意味着访问,而不是创建)。
当然,AngularJS 使用 DI 和框架本身还有很多其他功能,但我希望现在对依赖注入在这里是如何实现的有一个大致的了解。让我们总结并完成 AngularJS 中这个模式最重要的要点。
总结 AngularJS 中的依赖注入功能
在 AngularJS 中,依赖项的创建是injector对象的责任。顺便说一下,该对象使用构造函数注入范式。实际上,注入器表现得像一个服务定位器,负责构造和查找依赖项。
这是通过在 HTML 模板中使用声明性记号来实现的。当 HTML 被处理(解析)时,它将组件创建的责任传递给注入器,从而避免了在整个应用程序中传递注入器的需要。所有这些工作都是在幕后完成的。
正如文档所述,以这种方式工作,应用程序代码只需声明它需要的依赖项,无需处理注入器。这种设置不会违反迪米特法则*。
现在,让我们关注 Angular 的最新版本(版本 2.0+),它们在许多方面基于这些原则,但它们的实现方式有很大的不同,因为它们采用了 ES6 和相关技术。
Angular 2+
自从 2009 年 AngularJS(版本 1.x)发布以来,网络发生了巨大的变化。我们现在有各种各样的构建系统、模块化加载能力和可用的 Web 组件。JavaScript 开发也取得了飞跃性的进步。
这些更改在 AngularJS 中没有反映出来,因此它的性能不如团队所希望的那样,主要由于消化周期(与我们之前提到的 DOM 解析相关),以及直接与变更检测相关。
在 Angular 中,变更检测可以想象成从根到叶子的单向工作树,这样既快又可预测。
任何高于 1.x 版本的名称只是 Angular,没有JS后缀
Angular 有可观察和不可变对象,这大大加快了检查多少个属性的速度。
此外,可观察对象仅在它们依赖的输入发出事件时才会触发。不可变对象仅在它们的输入属性之一发生变化时才会检查。然而,在大多数情况下,这两种类型的对象不需要检查,这意味着你的整个应用程序将会加速。
Microsoft 的 TypeScript 是 Angular 2+ 中的首选语言
另一个重大变化是使用 TypeScript 作为首选语言(Angular 团队也使用 TypeScript 语言服务和 Visual Studio Code,以检测代码中的失败和不一致性,正如布拉德·格林最近所宣布的)。
TypeScript 在 Visual Studio Code 和 WebStorm 等编辑器中得到了很好的支持,并且当你导入模块和自动完成智能建议时,它表现得像一个永久的助手。
此外,由于它是一种类型化语言,提供的提示比 JavaScript 的 Intellisense 深得多。此外,任何有效的 JavaScript 都是 TypeScript,因此你可以根据你的舒适度使用尽可能多或尽可能少的 TypeScript。许多区域有助于 Angular,例如接口、构造函数、公共变量、类、类型化参数等等。
但也许 Angular 中最大的变化是其架构基于组件的概念。这些组件是通过类注解或装饰器定义的,这是一个允许向类添加元数据的特性。
在深入探讨之前,让我们首先提醒自己与 Angular 一起工作所需的工具。
Angular 工具
要使用 Angular 的最新版本,你可以当然从各种工具中选择,但我将使用官方站点推荐的工具,以及你作为需求所需的工具。
首先,你需要安装一个较新的 Node.js 版本。在撰写本文时,其网站上提供了两个版本(nodejs.org/es/):6.10.3 和 7.10.0。任何一个都可以,尽管我安装了 7.10.0。这个安装提供了两个基本工具来与 Angular 一起工作:Node 和 NPM(Node 包管理器)。
安装完成后,请确保版本正确,通过在控制台窗口中输入以下内容:
node-v
并且通过输入以下内容:
npm -v
在此基础上,有许多适合 Angular 的编辑器,但你可以尝试免费的跨平台 Visual Studio Code,它具有调试功能,在 Windows、Linux 和 OSX 上运行良好。
使用 Angular
让我们安装 Angular CLI,这是一个用于与 Angular 一起工作的命令行界面,它在这个框架的初始步骤中非常有帮助。我们将能够非常容易地创建早期应用程序,并了解架构的变化。
Angular CLI 有一个专门的网站(cli.angular.io/),在那里你可以找到安装过程、下载和相关文档。然而,最简单的方法是通过 NPM 安装它。你只需在命令提示符中输入以下命令:
npm install -g @angular/cli
这将在全局范围内安装 Angular-CLI 工具,因此它在整个文件系统中都是可用的。
再次强调,检查安装的最终状态是一个好习惯,你可以通过输入以下命令来完成:
ng --version
输出应显示以下信息:

我们已经准备就绪!如果你想查看 Angular CLI 所持有的命令列表,只需输入ng --help。会出现一个长长的列表,这样你就可以了解这个工具在当前版本中的强大之处。
要创建第一个应用程序,打开命令提示符(你可以使用 Visual Studio 安装的开发者命令提示符链接),转到你想要放置演示的目录,创建一个新的目录,并输入以下命令:
ng new [your-app-name]
在我的情况下,我输入了ng new ng4-demo,然后你等待 NPM 的一堆库在你的选择目录中下载和安装。
在新目录中,你现在可以看到由工具创建的文件和目录列表,并准备好启动。请注意,有三个新的目录:e2e、node_modules和src。
第一个包含应用程序的端到端测试。这是默认设置的,你应该进一步修改这些定义以满足你的需求。
第二个最大的目录包含了几乎任何 Angular 应用程序所需的 JavaScript 库列表。不要因为它的长度而感到害怕:它们被下载并本地安装,为程序员提供他们可能需要的工具,但在部署时,只有那些必需的库会被包含在部署前的打包和压缩过程中(这被称为tree-shaking)。
最后,在src目录中,你会找到这个初始演示所需的全部文件。其余的是 Angular CLI 和其他工具(如编辑器)用于管理项目的文件(特别是package.json、tsconfig.json和angular-cli.json)。
你最终会得到以下列表:

要看到它的实际效果,只需输入ng serve。这将运行 Webpack 工具准备一切,并在默认情况下在 4200 端口启动一个服务器。
最后一步是打开任何浏览器,输入 URL http://localhost:4200,并查看显示app works的非常简单的页面(我省略了输出;这很显然)。
编辑初始项目
现在我们知道一切正常,让我们在 Visual Studio Code(或你选择的编辑器)中查看项目,并尝试理解其背后的架构。
因此,我将从命令提示符打开编辑器,只需在主项目目录中输入code .即可。
想了解更多关于 VSCode 的信息,请访问code.visualstudio.com/。
也许,这个初始演示最令人惊奇的事情(尤其是如果你将其与我们之前在 Angular 1.6 中的演示进行比较)是这个应用程序主 HTML 页面内缺少引用和指令:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Ng4demo</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root>Loading...</app-root>
</body>
</html>
唯一的非静态 HTML 用粗体标出:<app-root>。Angular 通过一个复杂的组件相关架构过程将这个自定义元素解析成你在浏览器中看到的真实页面,在这个过程中,每个组件定义其行为、其视觉元素(如果有),以及它与应用程序其余部分的通信方式。
如果你查看src目录,你会看到几个 TypeScript 文件。负责在调用http://localhost:4200时指导浏览器做什么的模块位于angular-cli.json文件中。此文件包含有关编辑器和服务器行为等的大量定义。
它包含一个应用程序的入口,包含那个main属性,以及应用程序的入口点,它与一个index字段相关联(见以下截图):

通过这些定义,浏览器知道要启动哪个页面,服务器知道应该解析哪些组件:无论main模块指示什么。它显示的是一个基本的环境配置:

因此,这就是 Angular 管理应用程序初始化的方式。无论AppModule内部有什么,都将被加载和解析。
主模块的结构
然而,由于每个 Angular 应用程序都必须至少包含一个模块,app.module.ts就成为了设置应用程序的那个模块。理解它是如何工作的至关重要:

首先,我们找到import语句,这些语句加载了 Angular 库中之前导出的某些组件,例如platform-browser、core、forms和http。然而,这些库中的一些在这个基本演示中并不需要。
最后一个import语句是连接此模块与剩余功能,加载app.component的那个。
定义模块的方式是通过一个类(这里命名为AppModule),并用@NgModule装饰器标记。里面没有功能或定义。只有装饰器提供了与应用程序其余元素的正确链接。
实际上,bootstrap属性指示哪个组件将负责启动应用程序。
让我们看看这个组件是如何构成的:

现在我们有一个带有@Component装饰器的AppComponent类。在里面,我们找到了在浏览器中看到的 HTML 部分。它是类的标题字段。
但是,在装饰器内部,我们有一些线索:
-
selector:指示将被转换为运行时真实 HTML 片段的定制动态 DOM 部分。记住,Index.html内部的 HTML 的唯一外部部分是对<app-root>元素的引用。 -
templateUrl(可选,如果指示的话):包含将替换选择器的 HTML 片段的文件。它们接受{{moustache}}语法,就像这里发生的那样。 -
styleUrls:一个 CSS 文件数组,将在运行时加载用于展示目的。它们只会影响定义它们的组件,而不会影响整个页面。
最后,我们在浏览器初始页面中看到的 app works 句子只是 AppComponent 类的 title 属性。是的,它是通过数据绑定链接到模板内的 HTML 的,而模板只是一个 <h1> 元素:
<h1>
{{title}}
</h1>
因此,让我们做一些更改,看看效果如何。我将 app works 改为 title 属性的 First demo in Angular 4,而空的 CSS 文件也将包含一些格式化规则:
h1 {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 2em;
border: 3px solid navy;
}
当然,我们也可以将任何静态内容添加到 **index.html** 本身(例如,一个图像,位于选择器旁边):
<img src="img/business" alt="">
环境将负责处理应用中的每一个变化,并增量地重新编译这些变化。
注意,最后我们看到的是类的 title 属性,它由 Angular 框架的组件架构评估和管理,正是这个组件架构促进了依赖注入的实现。
因此,我们应该看到这些变化,而无需任何更多干预:

这个模型中隐含的一些过程使用在编译时间和运行时解决的依赖类型。但我们如何看到 DI 的实际应用呢?
Angular 4 中的依赖注入(DI)
好吧,Angular 中的 DI 遵循构造函数注入模式;因此,由于我们在这里处理的是类(组件),大多数注入都需要在构造函数参数定义中进行。
官方 Angular 文档网站上有一个专门的页面提供了有关依赖注入的更多信息,请参阅angular.io/docs/ts/latest/guide/dependency-injection.html。
与 AngularJS 一样,Angular 在引导过程中创建了一个注入器对象,因此您不需要自己创建它(记住,main.ts 中的 platformBrowserDynamic().bootstrapModule(AppModule) 语句)。
提供者的概念
在这个前提下,程序员的职责是注册那些将通过 DI 服务的类。您可以在 NgModule 中或任何 Component 内完成此操作,但无论如何,声明可注入类的途径是将它的名称添加到组件或模块的 provider 集合中。
差别在于,当在@**NgModule**内部声明时,这些服务将与整个应用程序一起可用。在其他情况下,它将限制在组件的层次结构内。
让我们修改之前的例子,这样你就可以看到这个技术的实际应用。实际上,这相当简单。
第一步是创建一个提供所需注入信息的类(我将称之为DIClass)。让我们假设我们想要一个额外的文本属性和一张图片。由于我们只需要图片的 URL,我们将定义两个字符串属性。
但为了使这个类可注入,我们需要将其标记为可注入。我们通过从@angular/core导入"Injectable"定义,并用该注解装饰类来实现这一点。这个类的代码的最后一个方面将是:

现在,我们可以从index.html中移除我们插入的<img>元素,并将插入图片的任务交给组件。
此外,仅为了展示目的,我已经修改了app.component.css代码,用边框标记注入的信息,这样这些片段就变得清晰可见。
类准备好了,我们现在定义这个服务的范围。由于我们只需要在AppComponent中使用它,我们将在@Component装饰器的类中添加一个新的providers定义;这将使DIClass的内容在组件内部可用。
最后,我们可以修改我们的类以包含两个额外的属性(subtitle和fotosource),它们的值是通过注入获得的。在实践中,这个注入的类通常会是被多个应用程序的组件所需的数据访问服务或其他类型的资源。
由于这些值是通过在构造函数中声明注入的类(或值)来接收的,最终的版本将是:

现在,如果你保持服务器运行并查看页面的新方面,在初始消息旁边,我们应该看到那些注入的元素,格式化根据**app.component.css**文件内的 CSS 规则:
就这样。只要我们之前用**@Injectable**装饰器声明了这些值,我们就可以注入所需数量的项目;然而,如果您需要更多可定制的服务或数据,我建议阅读我之前提到的文档参考。
总结
这些技术使你的代码更易于维护和扩展,并且也减少了对外部变化的依赖。由于你只需要引用所需的服务,一个服务的实现变化并不一定意味着使用这些服务的高级类也会发生变化。
当然,在 Angular 4 中的依赖注入(DI)远不止本章所包含的基本信息。到现在为止,我希望你已经更好地理解了 DI 在这些框架中的存在方式,以及为什么它正成为普遍流行的实现设计模式。
摘要
在本章中,我们介绍了其他框架中的 DI,特别关注了 Angular 的两个当前分支。
首先,我们了解了 TypeScript 的基础知识,它是 Angular 2+的基础,以及它的类定义和模块加载能力如何允许以更面向对象、模块化的方式构建基于 JavaScript 的应用程序。
接着,我们探讨了 AngularJS(Angular 的遗留分支)内部的 DI 实现,该实现仍在全球超过 70%的 Angular 项目中使用。
最后,我们探讨了 Angular 4(撰写本文时的最新版本)的基础知识,以及上述面向对象及其基于注解的组件架构如何使得依赖注入的实现变得非常简单。
在第十一章,“最佳实践与其他相关技术”,我们将介绍 DI 和其他相关技术中最常见的最佳实践。
第十一章:最佳实践和其他相关技术
上一章非常有趣,因为我们探索了在不同框架中实现依赖注入,例如 TypeScript、ES2015 和 Angular 2。这是最后一章,我们将讨论在项目中实现依赖注入时证明是最好的编码和架构实践。
显然,当你学习新事物并尝试将其应用于你的应用程序时,兴奋可能会让你创建一个糟糕的架构。这最终会导致不良的实践,因为在你不知不觉中多次编码之后,它就变成了习惯。假设,你接到一个将用户详情保存到数据库的要求。你肯定会将详情发送到服务层方法,该方法反过来会连接到数据访问层方法以保存详情。如果你是新手并且不了解应用的架构,你首先会做的事情就是使用new关键字来实例化依赖项并直接调用方法。这样,我们就无法实现松散耦合的模块。然而,我们可以以这样的方式设计和设计应用和层,使得消费者无法通过任何方式产生耦合。我们将在本章中探讨这些技术。
因此,探索如何利用你刚刚学到的概念来达到最佳效果对你和你的应用来说非常重要。为此,有一些最佳实践你应该了解。依赖注入涉及对象组合、对象生命周期和拦截的整体处理,这些理想上应由 DI 容器管理。如果我们试图自己处理这些构建块而不是使用容器来管理依赖项,我们可能会得到一个糟糕的设计。
虽然我们可以选择一个 DI 容器,但根据容器设计并重构我们的代码以帮助容器与我们的类一起工作可能是一项错误的任务。
在本章中,我们将详细探讨这些点,以了解在项目引入 DI 时面临的实时问题。我们将学习避免 DI 中不良习惯的技术。
我们将讨论以下主题:
-
依赖注入中的最佳实践和建议
-
一些推荐的去除紧密耦合的技术
-
重构和实现遗留分层应用中的 DI
紧密耦合的系统
到目前为止,我们已经讨论了在.NET Core 2.0 中的依赖注入(DI)、DI 的支柱、DI 模式、反模式以及在其他框架中的 DI。对于依赖注入概念的新手来说,犯错误是很正常的。我们将在接下来的章节中看到更多这样的场景。在此之前,让我们分析一下你可能在 ASP.NET MVC 项目中更常遇到的情况。如果你没有遇到,可以询问你的同事和朋友。你肯定会看到这样的例子。
让我们看看一个没有实现 DI 的简单控制器,并学习如何重构它,使其遵循控制反转(IoC)的原则。
控制反转(IoC)原则是“不要调用我们,我们会调用你”。当一个类试图在其内部实例化另一个类时,它基本上与第二个类建立了耦合。现在第一个类的创建依赖于第二个类。
如果第二个类因为任何原因失败,我们将无法获取第一个类的实例,因为构造函数会出错:

图中的某人可以是一个 IoC 容器。
如果你想深入理解这个概念,我强烈推荐 Shivprasad Koirala 先生的这篇文章。以下是链接--www.codeproject.com/Articles/29271/Design-pattern-Inversion-of-control-and-Dependency。
问题
以下图表展示了我们现在将要做什么。我们将有一个控制器依赖于一个服务,而这个服务又依赖于一个仓库来获取数据:

首先,让我们考虑一个简单的控制器以及它在项目中通常是如何实现的。以下是一个简单的UsersController,它有一个动作方法Edit:
public class UsersController : Controller
{
public IActionResult Edit(int userId)
{
var usersService = new UsersService();
return View(usersService.GetUser(userId));
}
}
以下是从前面的代码块中可以得出的结论(更准确地说,是问题),:
-
UsersController使用new关键字实例化UsersService(创建一个具体类的实例) -
UsersController与UsersService紧密耦合
就我们从前几章学到的知识而言,我们绝对应该避免前述的两个点。这会使单元测试变得困难。依赖注入(DI)原则鼓励我们利用组合根区域来注册所有必需的类/依赖(例如UsersService),然后使用 DI 容器来解析实例,而不是在需要时每次都使用new关键字。
接下来是UsersService,它可能看起来如下所示:
public class UsersService
{
private readonly UsersRepository _usersRepository;
public UsersService()
{
_usersRepository = new UsersRepository();
// Concrete Class Dependency
}
public User GetUser(int userId) => _usersRepository.GetUser(userId);
}
另一个具体的依赖项与UsersRepository相关。UsersService现在所做的与我们在控制器案例中看到的是同一类事情。在这个例子中,服务试图传递从仓库返回的数据。但在现实中,它可能做很多事情。这里的重要方面是我们如何在一个可能在未来构建复杂系统时变得更加复杂的类中管理我们的依赖关系。我们现在所做的练习只是指出问题,所以不要仅仅根据结构的简单性来判断。
仓库看起来可能如下所示:
public class UsersRepository
{
public User GetUser(int userId) => new
DataContext().Users.SingleOrDefault(u => u.UserId == userId);
// You can just return a demo user like:
// new User { UserId = 1, UserName = "Tadit" };
}
以下图表解释了到目前为止所做的工作:

一切都到此为止。这是队列中的最后一个。它从Users表中获取数据并将其发送回客户端。这种架构是可行的,但存在一些缺点,这些缺点可能会随着你继续前进而变得更加严重:
-
单元测试并不容易,因为类之间紧密耦合。例如,
UsersController依赖于UsersService,而UsersService又依赖于UsersRepository,最后,它处理数据库,而单元测试可能会因为数据库交互而失败。 -
如果这种架构增长并成为一个完整的应用程序,那么维护会变得困难,因为类被各种依赖关系缠绕,这些依赖关系难以识别和管理。
-
没有任何技术/方法可以轻松地修改依赖关系。假设我们想要实现一个名为
UsersNewService的服务类,并希望使用它而不是UsersService。这将是一项繁琐的任务,因为我们必须识别UsersService的所有依赖关系,然后更改它们。 -
此外,具体服务引用可能在某些情况下失败,因为方法签名可能在不同类之间有所不同。例如,这两个服务类中的
GetUser方法可能在签名上有所不同,这使得开发者的生活变得困难,因为你必须修改GetUser调用方法,包括服务引用更改。
这些缺陷不仅描述了一个架构不良的项目,而且成为开发者重构和维护的噩梦。
解决方案 - 使用依赖注入进行重构
正如我们所知,依赖倒置原则(Dependency Inversion Principle)表述如下:
-
高级模块不应该依赖于低级模块。两者都应该依赖于抽象。
-
抽象不应该依赖于细节。细节应该依赖于抽象。
因此,让我们设计接口以抽象出具体类。
从层次结构的最后一个开始,即仓库:

仓库的抽象看起来如下:
public interface IUsersRepository
{
User GetUser(int userId);
}
因此,UsersRepository将如下实现IUsersRepository:
public class UsersRepository : IUsersRepository
{
public User GetUser(int userId) => new
DataContext().Users.SingleOrDefault(u => u.UserId == userId);
// You can just return a demo user like:
// new User { UserId = 1, UserName = "Tadit" };
}
接口提取
你知道从类中提取接口有多容易吗?使用 Visual Studio 非常容易且有效。让我们看看以下步骤:
-
将鼠标悬停在具体类上;你会在左侧看到一个灯泡。
-
点击它以打开下拉菜单。参见图示:

-
选择
提取接口。 -
会弹出一个模态窗口,其中也列出了类的公共成员。参见以下截图:

- 点击“确定”。它将为您创建接口。
另一种提取接口的方法是,你可以写下接口名称,然后按Ctrl + .(点),这样就会出现选项气泡。你需要选择顶部两个选项之一来生成接口:

然而,与第一种方法相比,这里有一个区别;它用红色块标记了。如果你选择截图中所显示的任何 Generate 选项,它将创建一个带有空白块的接口块,其中没有任何代码(如红色块内所示)。然而,在第一种情况下,我们有选择将公共成员包含在接口中的机会。想象一下一个具有大量公共成员的大类。毫无疑问,正如之前所述,第一种方法在明显的原因下获胜。
好的。现在我们可以修改 UsersService,将其作为依赖项注入,而不是在构造函数中使用 new 关键字来实例化。IUsersService 接口可以设计成我们为仓库所做的那样,以便在控制器中使用,我们稍后会看到:

IUsersService 只是一个简单的接口,其中 GetUser 是一个公共成员:
public interface IUsersService
{
User GetUser(int userId);
}
现在可以修改 UsersService 以实现 IUsersService。可以使用构造函数注入模式来注入 IUserRepository 依赖项:
public class UsersService : IUsersService // Abstraction
{
private readonly IUsersRepository _usersRepository;
public UsersService(IUsersRepository
usersRepository) // Constructor Injection
{
_usersRepository = usersRepository;
}
public User GetUser(int userId) => _usersRepository.GetUser(userId);
}
此更改将触发对控制器的修改;否则,编译器会报错。让我们让编译器高兴起来:
public IActionResult Edit(int userId)
{
var usersService = new UsersService(new UsersRepository());
return View(usersService.GetUser(userId));
}
我们现在可以将仓库实例注入到控制器中的 Edit 动作服务中。
到目前为止,我们已经从架构中消除了一个具体引用,即来自 UsersService 类的引用。理解我们的目标是至关重要的。我们实际上是在尝试将每个依赖项放在一个地方,以便简化设计。因此,通过最小的更改,我们可以轻松地将不同类型的依赖项注入到系统中。
例如,我们的 UsersService 现在负责管理 UsersRepository 依赖项,而不是服务内部通过紧密耦合来管理它。
然而,我们还没有完成。我们还有一个明显的依赖项在控制器中,使用 new 关键字在 Edit 动作中实例化,那就是 UsersService。
重构后的可注入 UsersController 将如下所示:
public class UsersController : Controller
{
private readonly IUsersService _usersService;
public UsersController(IUsersService usersService) //
Constructor Injection
{
_usersService = usersService;
}
public IActionResult Edit(int userId)
{
// We commented out the following line and used
// private member _usersService instead.
//var userService = new UsersService(new UsersRepository());
return View(_usersService.GetUser(userId));
}
}
简单吧?构造函数注入再次拯救了我们,并允许我们注入服务,以便我们可以用于后续操作。
太棒了,现在我们移除了所有依赖项。继续运行应用程序:

不幸的是,它抛出了一个异常。这并不奇怪。框架期望控制器中的构造函数少一个参数,而我们不再有这个参数,然后它发现了一个接受 IUsersService 实现实例的参数化构造函数,而这个实例在应用程序的任何地方都没有提供。因此,异常被形成。
好吧,我知道你在想什么。添加一个参数更少或默认构造函数不会改变场景。相反,它会在浏览器中显示以下内容:

此外,依赖注入(DI)不推荐使用多个构造函数,所以这也不是一个选择。
在我们继续解决方案之前,让我总结一下到目前为止我们所做的工作。
-
我们为具体类创建了抽象
-
我们用带有抽象的构造函数注入替换了
new关键字 -
在这个过程中,我们还修改了
UsersController的默认构造函数,以注入IUsersService依赖
然而,我们遇到了异常。这是因为我们既没有实例化IUsersService接口的任何实现,也没有将其提供给构造函数。
为了使应用程序运行,我们还需要做两件事。那就是让编译器知道以下实现的细节:
-
IUsersService接口 -
IUsersRepository接口
这可以通过一个 IoC 容器来完成。既然我们使用的是.NET Core 2.0,让我们使用内置的容器并看看会发生什么。Startup类的ConfigureServices方法看起来如下:
public void ConfigureServices(IServiceCollection services)
{
services.AddTransient<IUsersService, UsersService>()
.AddTransient<IUsersRepository, UsersRepository>();
services.AddMvc();
}
现在让我们运行应用程序:

最后,它运行起来了。一切都很完美,我们在浏览器中得到了输出,多亏了.NET Core 的 DI 引擎,当然,也多亏了你在这本书中到目前为止所取得的进步。
我们在重构类以注入依赖而不是引用具体类方面做得非常出色,这些具体类效率低下且难以维护。尽管如此,紧耦合并没有完全消除。服务仍然向控制器返回数据库模型类User的实例。
想象一个三层架构,其中层可以是ASP.NET MVC Web App Layer、Service/Business Layer和Data Access Layer。如果一个开发者在表示层(ASP.NET MVC Web App Layer)直接使用模型类,这意味着它是紧密耦合的,并且不能在没有项目中的Data Access Layer引用的情况下生存。在下一节中,我们将看到这样的问题,并尝试将其重构为依赖注入。
分层架构
在一个典型的现代 ASP.NET MVC Web 应用程序中,你会找到一个三层架构,其中各个层相互独立且相互依赖,如下面的图所示:

当我们说某个层依赖于另一个层时,这意味着依赖的那个层需要依赖对象的实例来执行某些操作。正如我们在上一节中看到的,要从数据库获取用户,控制器会要求服务从存储库中提供它。因此,我们可以很容易地将类分离并将它们作为组件或类库打包,从技术角度来说。
这意味着我的控制器仍然位于UI Layer或Presentation Layer,Service Class则创建另一个层,该层通过Data Access Layer类与数据库通信。现在,由于我们选择了依赖注入技术来管理依赖关系,因此 IoC 容器负责向Service Layer提供依赖的Data Access Layer对象,并向 UI 层提供Service Layer对象。最重要的是,它从每个层中接管了对象创建的控制权。
问题 - 再次是具体类的引用
我想你可能没有注意到上一节中的一个东西。我们尽可能地解耦了系统。但我们并没有完全实现松散耦合的架构。让我在下一段中解释。
组合根是注册所有应用程序将使用的依赖项的地方。Startup类中的ConfigureServices方法就是依赖项注册的位置,正如我们所看到的:
services.AddTransient<IUsersService, UsersService>()
.AddTransient<IUsersRepository, UsersRepository>();
注意,当我们说,当需要IUserService依赖项时,取UsersService实例,当需要IUsersRepository时,取UsersRepository实例。所以,基本上,我们现在必须获取Service Layer和Data Access Layer的组件引用,才能在没有编译错误的情况下运行此代码。查看以下截图,它证明了我说的话:

我们迄今为止设计的所有类都只是在这三个层次中分离出来。仓库位于Data Access Layer,而服务位于Business Layer内部。
很明显,你可以看到在 UI 层中存在一个Business Layer的引用。由于Business Layer有一个对Data Access Layer的引用,所以它自动传递到 UI 层。
因此,Presentation Layer现在可以访问Service Layer和Data Access Layer的各种实现。这实际上是一种非常糟糕的编码实践,因为我们试图完全违反分层架构的概念。层与层之间没有存在分离。此外,由于开发者可以直接通过引用访问具体的类实现,他们可能会滥用这些实现。这是绝对不可接受的。
商业层和数据访问层组件的滥用
在这里,我们将讨论作为开发者,我们如何通过直接引用层来滥用代码中的层。让我们尝试理解一些在这些组件中可能被滥用的场景,特别是在UI Layer中:
- 直接访问业务层服务:由于我们有
Business Layer的引用,因此我们可以实例化它,然后调用GetUser()方法。我们需要一个Repository实例用于Service构造函数,这也可以很容易地提供。
public IActionResult Edit(int userId)
{
UsersService service = new UsersService(new UsersRepository());
return View(service.GetUser(userId));
}
注意我们如何可以轻松地通过将Repository实例传递给其构造函数来实例化UsersService类。这意味着我们仍然喜欢使用new关键字。
- 直接数据访问仓库访问:当我们已经有了
数据访问引用时,为什么还要通过服务呢?为什么不直接与仓库打交道来获取数据?这不是很酷吗!
public IActionResult Edit(int userId)
{
UsersRepository repo = new UsersRepository();
return View(repo.GetUser(userId));
}
另一种new关键字的使用。
- 直接数据层上下文访问:更好的是以下方法,我们甚至不需要这些中间的
服务或仓库实例。我们可以直接从上下文中获取数据:
public IActionResult Edit(int userId)
{
return View(new DataContext()
.Users
.SingleOrDefault(u => u.UserId == userId)
);
}
DataContext可以在控制器操作中直接提供数据,如前述代码所示。
我们破坏了分层架构,模块也不再是独立的。相反,业务层和数据访问层现在与UI 层紧密耦合。这仅仅是因为我们不得不在ConfigureServices方法内部为接口注册具体的实现到服务集合中。为了进行依赖注入,我们需要这个注册步骤,这样我们就可以在需要时向容器请求依赖项。
现在,我们必须找到一种方法。让我们为这类问题找到一个最佳实践。
分层架构的最佳实践
记住,在使用依赖注入技术的同时,你可以做很多事情来改进架构和优化性能;然而,你不能从 ASP.NET MVP Web 应用的bin文件夹中移除程序集。你需要理解,问题在于表示层对业务层和数据访问层内部的不同具体实现访问;正如我们所见,当开发者试图玩弄层而不是正确使用它们时,这可能会造成灾难性的后果。
因此,我们需要找出一种方法来克服这种情况,不让表示层在受益于依赖注入的同时访问其他层的具体类。解决方案是 MEF。让我们在下一节中进一步探讨。
管理扩展性框架 (MEF)
在探索 MEF 之前,让我总结一下这个问题。
紧密耦合:在ConfigureServices方法中的Startup阶段,通过依赖注入在UI 层内部引用了具体的类。
因此,基本上,我们需要做一些自动化工作,这样我们就不依赖于其他层的具体类。这就是 MEF 发挥作用的地方。
MEF 是一个用于创建轻量级可扩展应用的库。借助 MEF,开发者可以轻松构建一个应用程序,自动将扩展绑定在一起,而无需任何配置。有关 MEF 的更多信息,请参阅docs.microsoft.com/en-us/dotnet/framework/mef/index[.]。
我们不会深入探讨 MEF;您可以从我分享的先前的链接中了解。我们只需要了解我们如何用 MEF 解决我们的问题。例如,我们有一个 Service 类和一个 Repository 类,我们不想将其直接暴露给 UI 以供消费,就像我们在先前的代码片段中看到的那样。MEF 会为我们做这件事,通过拉取注册依赖项所需的类。
下面的图示展示了 MEF 在我们的应用中将扮演的角色:

您可以看到,在每个层级中我们都有 ModuleInit 类,每个类都有一个 Init() 方法,并且这些类实现了一个名为 IModule 的通用接口。很明显,所有层级的 DLL 都将位于 ASP.NET MVC Web 项目的 bin 文件夹中。
现在是有趣的一幕。MEF 从应用的 bin 文件夹中获取所有程序集,然后执行以下操作:
-
搜索
IModule的实现。 -
触发每个找到的模块的
Init()方法。
在下一节中,我们将看到不同层级的代码以及 MEF 如何将松散耦合的模块带回来以优化架构。
层级和 MEF 实现
在深入代码之前,让我们首先分析一下层级。所以,这里有三个层级。
-
Web 应用层: 包含
Controller、View等。最重要的是,它有入口点Startup.cs。 -
业务/服务层: 包含
Services,其中我们可以包含我们的业务逻辑。 -
数据访问层: 包含
Contexts、DbSets和Repository,用于从数据库中检索数据。
让我们快速查看一下 Visual Studio 解决方案 窗口:

我们已经将我们的类组织到不同的层级中。有趣的是,我们引入了另一个名为 Common Layer 的层,其中我们放置了与 Business Layer 和 Data Access Layer 相关的所有通用代码。一个通用项目被 Business Layer 和 Data Access Layer 同时引用。
这段通用代码将帮助我们从与不同层级相关的程序集中获取依赖项,并将它们打包成一个(称为 ModuleLoader),这样 web 应用就可以通过调用一个方法来启动一切。让我们逐个理解每个部分。
引入 IUser
目标是从 UI 层移除对具体类的访问。我们开始将所有通用项移动到一个名为 Common 的中央层。当我们把 IUsersService 移到 Common 时,我们识别了一个 GetUser 方法,它实际上返回一个具体的 User 实例。此外,Business Layer 内的 UserService 也有相同的方法,因为它实现了 IUsersService。
以下代码展示了如何将 GetUser 方法的返回类型从 User 类型更新为 IUser 类型:
public IUser GetUser(int userId) =>
_usersRepository.GetUser(userId);
// Return type is changed from User to IUser.
我们需要消除对User类的依赖,否则Common Layer又必须再次引用Data Access Layer,因为User类就位于那里,对于Business Layer引用Data Access的情况也是一样的。此外,Common被设计成可以被其他层引用,而不是在自身内部获取其他层的引用。它应该摆脱项目依赖。这一点很重要。
解决方案非常简单。在Common/Entities/Users目录中添加一个IUser接口,它将被用作GetUser方法的返回类型。因此,UsersService和UsersRepository中的GetUser可以返回IUser类型而不是User实例。现在我们可以轻松地消除Data Access与Common Layer的耦合。同样,Data Access与Business Layer的耦合。
下面的图示告诉我们,当我们使用User类时出了什么问题。在Business Layer中存在对Common Layer和Data Access Layer的引用:

下面的方法使用接口IUser是正确的,它允许我们减少层的耦合:

现在在Common Layer内部不再需要Data Layer的引用。相反,Business Layer和Data Layer依赖于Common,这正是我们的意图。
IModuleRegistrar 接口
IModuleRegistrar接口负责将依赖项添加到服务集合中。基本上,实现此接口的类将有一个具有指定签名的Add()方法,该方法执行Add***(AddTransient/AddScoped/AddSingleton)方法的工作。
using Microsoft.Extensions.DependencyInjection;
using System;
namespace PacktDIExamples.Common
{
public interface IModuleRegistrar
{
void Add(Type serviceType, Type implementationType,
ServiceLifetime lifetime);
}
}
这是在Common Layer中实现的。
ModuleRegistrar 类
ModuleRegistrar,该类实现了之前提到的接口,其结构大致如下。它基本上是一个内置 IoC 容器的包装器,用于注册依赖项:
using Microsoft.Extensions.DependencyInjection;
using System;
namespace PacktDIExamples.Common
{
internal class ModuleRegistrar : IModuleRegistrar
{
private readonly IServiceCollection _serviceCollection;
public ModuleRegistrar(IServiceCollection serviceCollection)
{
this._serviceCollection = serviceCollection;
}
public void Add(Type serviceType, Type implementationType,
ServiceLifetime lifetime)
{
var descriptor = new ServiceDescriptor(serviceType,
implementationType, lifetime);
this._serviceCollection.Add(descriptor);
}
}
}
注意这个类的两个重要方面:
-
IServiceCollection被注入到构造函数中 -
使用
ServiceDescriptor实例注入IServiceCollection,用于将依赖项添加到容器中
这个类是 Common 库的一部分。
服务描述符:IServiceCollection是服务描述符的集合。当创建服务描述符实例时,它可以提供关于服务或依赖项的完整信息。我们有不同的方法用于不同的生命周期,如AddTransient、AddScoped和AddSingleton来注册依赖项。然而,我们无法将所有三种方法都写在一个地方来管理依赖项。这就是服务描述符发挥作用的地方,它可以将生命周期作为参数,并可以直接使用Add方法将其添加到ServiceCollection中。
IModule 接口
IModule是架构中的主要英雄,因为这是 Loader 首先使用的东西,用于识别需要从所有目标程序集中获取哪些模块。此接口公开一个方法,Initialize(),由 Loader 调用以将依赖项添加到容器中。考虑以下代码片段:
namespace PacktDIExamples.Common
{
public interface IModule
{
void Initialize(IModuleRegistrar registrar);
}
}
此接口也位于通用库中。
ModuleInit类
让我们看看前面的IModule接口如何在Business Layer中实现:
using Microsoft.Extensions.DependencyInjection;
using PacktDIExamples.Common;
using System.Composition;
namespace PacktDIExamples.Business
{
[Export(typeof(IModule))] // Will be used by MEF to
fetch the class.
public class ModuleInit : IModule
{
public void Initialize(IModuleRegistrar registrar) // Registrar
injected.
{
registrar.Add(typeof(IUsersService), typeof(UsersService), ServiceLifetime.Transient);
// Adds the UserService instance
// to the container with Transient Lifetime.
}
}
}
ModuleInit存在于Business Layer和Data Access Layer中。这个类帮助我们向每一层添加不同的依赖项,例如在Business中的UsersService和在Data Access中的UsersRepository。您可以看到UserService是如何在Business Layer中添加到注册器的。我正在跳过Data Access Layer中的ModuleInit。它只是在Initialize中更改了一行。以下代码可以在Data Access Layer的ModuleInit中添加以注册UserRepository依赖项:
registrar.Add(typeof(IUsersRepository), typeof(UsersRepository),
ServiceLifetime.Transient);
请注意最后第二个代码块中的粗体部分([Export(typeof(IModule))]),这非常重要。这是一个属性,它帮助 MEF 从指定的程序集中抓取具有IModule接口的ModuleInit类。然后它可以轻松调用Initialize以启动注册依赖项的过程。我们很快就会看到执行此任务的代码。
等等!这里有一个构造函数注入。那它是用来做什么的呢?实际上,注册依赖项的代码位于注册器内部,因此我们确实需要IModuleRegistrar依赖项来调用Registrar的Add()方法,以便注册所需的依赖项,例如在Business Layer中的UsersService。
检查依赖项注册过程中的控件流。通常流程如下:
ModuleLoader | ModuleInit(IRegistrar) | ModuleRegistrar | Add()(将依赖项添加到集合):
以下图表通过讨论的可用层可视化了模块加载器的工作:

模块加载器类
要使用 MEF,我们需要一个名为Microsoft.Composition的 Nuget 包。这将System.Composition安装到项目中。您需要在Common Layer中添加此包。ContainerConfiguration是System.Composition.Hosting命名空间中的类,它组合所有程序集,并可以为我们提供一个容器,我们可以从中轻松提取所需的具体实现。
以下截图是在 NuGet 包管理器窗口中搜索时显示的NuGet包预览:

最后,但同样重要的是,是 ModuleLoader。我们至今所学的一切都由 Loader 管理,它是一个具有一个方法 LoadContainer 的静态类。此方法由我们的 MVC Web App Startup 调用以初始化依赖项注册过程。让我们先睹为快代码:
namespace PacktDIExamples.Common
{
public static class ModuleLoader
{
public static void LoadContainer(IServiceCollection collection,
string pattern)
{
// Gets the Assembly Location: The total path of the Web App
assembly, which in our case is
"C:\\Users\\taditd\\Desktop\\Packt\\Codes\\PacktDIExamples\\
PacktDIExamples\\bin\\Debug\\netcoreapp2.0\\
PacktDIExamples.dll".
var executableLocation = Assembly.GetEntryAssembly().Location;
// Get all assemblies inside the location with a pattern
from "bin\Debug\netcoreapp2.0".
var assemblies = Directory
.GetFiles(Path.GetDirectoryName(executableLocation),
pattern, SearchOption.AllDirectories)
.Select(AssemblyLoadContext.Default.LoadFromAssemblyPath)
.ToList();
// Composes the assemblies.
var configuration = new ContainerConfiguration()
.WithAssemblies(assemblies);
using (var container = configuration.CreateContainer())
{
// This is important. The following line extracts
all the IModule implementations from the
assemblies we fetched.
IEnumerable<IModule> modules = container.GetExports<IModule>();
var registrar = new ModuleRegistrar(collection);
foreach (IModule module in modules)
{
// Invoke Initialize for each module with the registrar
as a dependency.
module.Initialize(registrar);
}
}
}
}
}
我在每个步骤上都添加了注释,这样你就可以轻松分析这些步骤了。这些步骤可以定义为以下内容:
-
获取
Web App 程序集位置。由于我们将从这个 Web App 调用此方法,并且每个其他程序集都放置在那里,我们需要这个位置来查找其他程序集。 -
查找在该特定位置存在的其他程序集。然而,我们需要我们应用中层的 DLL。因此,需要一个模式来识别层的程序集。根据我们的层,我们只需要找到所有以 PacktDIExamples 开头并以
.dll结尾的程序集名称,因为层名称类似于PacktDIExamples.Business.dll和PacktDIExamples.DataAccess.dll。因此,模式将是PacktDIExamples.*.dll。我们将在稍后看到代码将此模式发送到LoadContainer。你可以在调试时看到提取的模块,如下所示:

-
使用
ContainerConfiguration实例创建一个容器来保存所有程序集。 -
现在
container.GetExports<IModule>()用于从这些程序集中提取IModule实现 -
使用注册实例作为依赖项为每个
IModule实现执行Initialize方法,因为Registrar具有实际的Add方法,用于在ServiceCollection中进行依赖项注册。
我建议你在该方法中添加 try...catch 块来处理异常。由于空间限制,我没有发布 try...catch 块。我只是想展示 ModuleLoader 的核心逻辑。我还移除了占用大量空间的 usings,但你可以自己找出这些 usings;如果找不到,可以使用 Visual Studio。将鼠标悬停在红色行上,然后按照步骤进行,这些步骤将包括所需的库。
从 Web App 执行 ModuleLoader.LoaderContainer() 方法
这是最后一步,也是最简单的一步。看看我们如何在 Startup 内部从 Web App 调用此 Loader,如下所示:
public void ConfigureServices(IServiceCollection services)
{
// Commented out codes because we now load dependencies from
another layer.
// services.AddTransient<IUsersService, UsersService>()
// .AddTransient<IUsersRepository, UsersRepository>();
ModuleLoader.LoadContainer(services, "PacktDIExamples.*.dll");
services.AddMvc();
}
正如我提到的,我们需要发送一个模式来从 bin/debug/netcoreapp2.0 获取程序集名称;否则,它会获取所有程序集,这是不必要的。
注意,我们现在正在将服务发送到 LoadContainer。现在之前的依赖项注册代码已被注释,并且通过 ModuleLoader.LoadContainer 调用进行了优化。
我们使用 MEF 实现了什么?
如果你还没有意识到使用 MEF 重构分层架构的好处,请仔细查看以下带有解释的截图。
层被分隔
层与层之间紧密耦合,因为 UI 引用了Business Layer和Data Access Layer。现在在 MEF 之后,UI 不再与Data Access Layer绑定。然而,UI 有一个Business Layer程序集引用,如以下截图所示。但它在项目内部任何地方都没有被使用。它之所以存在,是因为程序集应该放在bin文件夹中,这样 MEF 模块在初始化时能够读取它,以便导出IModule实现。以下是在 MEF 实现前后项目依赖的快速比较:

如果你尝试从 UI 中移除业务引用并运行应用程序,它将抛出异常,如下所示。这是显而易见的原因。除非你有引用,否则 MEF 无法提取IUsersService实现并将其注册到 DI 容器中,简单!
因此,问题发生在你点击控制器的那一刻,因为IServiceCollection上还没有注册任何内容:

现在层内的所有类都是internal
我们可以将类的访问修饰符从public改为internal,这样它们就只能在程序集内部访问(这意味着只在 Layer 内部内部访问):
UI 层内部不允许有具体的类实例化
在 MEF 实现之前,我们看到了如何在Edit操作方法内部直接引用Data Access Layer和Business Layer类。现在我们无法这样做,因为类不再可访问。
这可以从UsersController内部的编译错误中得到证明,如以下截图所示。注意工具提示预览,它表明UsersService不可用。因此,即使添加了引用,也限制了滥用其他层的类:

可以向架构中添加更多层
当未来为了优化或任何其他业务相关需求添加更多层时,该程序集可以简单地由 UI 引用;然后每个依赖项将自动注册到 IoC 容器中。记住,你需要有一个实现IModule接口的ModuleInit类来自动化这个过程。现在应用程序非常灵活,可以轻松地附加新模块,无需麻烦。
结论
大多数现代应用程序都遵循分层架构。拥有一种依赖注入技术来管理层内所需的依赖关系是我们应该遵循的。话虽如此,我们需要小心设计层。这是因为我们应用了 DI,认为它将解决类依赖的紧密耦合问题。然而,我们没有意识到层通过具体的依赖关系(如User实例的返回类型)相互连接。
托管扩展性框架为我们提供了一些简单的步骤,用于动态绑定来自不同层的依赖项,将它们打包成一个,然后将其注册到容器中,以便 UI 层的控制器进一步使用。
摘要
在学习了 DI 技术、模式、反模式等内容之后,我们仍然需要一些指导来处理 DI 发挥作用的实际场景。
我们探索了一些实时应用程序实例,其中 DI 可以帮助消除耦合并引入更清晰的依赖结构。
当开发者试图偷懒,直接在类中使用 new 操作符实例化所需的依赖项时,经常会看到紧密耦合的系统。请记住,new 是粘合剂。这不仅在你作为编码者时养成了一种不良习惯,而且也使得你的代码不可测试。单元测试变得困难。我们通过遵循 DI 技术学习了处理紧密耦合问题的技术。
然后,我们意识到在应用程序中分层是一种常见的方法。拥有 DI 一定会在长期内帮助我们处理分层系统。然而,分层方法中可能存在一些悬而未决的问题,需要解决。
MEF 是我们采用的技术,用于解决具有分层架构的一些问题。通过实现 MEF,业务或数据层实现可以被标记为内部,这样它们就不会暴露给其他层。此外,使用 DI 容器,关注点分离的分层架构规则可以保持完整。因此,层可以独立呼吸,不受任何干扰,并以一种方式装饰架构,使得新模块的引入变得非常顺畅。




浙公网安备 33010602011771号