C--元编程指南-全-
C# 元编程指南(全)
原文:
zh.annas-archive.org/md5/ffc0f450cff8f3a6e36b56d28152cbd5译者:飞龙
前言
“始终编写代码,就好像最终维护你代码的人是一个知道你住在哪里、性格暴力的精神病患者。”
– 马丁·戈尔丁
在软件的世界里,有很多事情可能会出错,而且通常确实会出错。我们是一个相对年轻的行业,处于不断变化的状态。事物尚未稳定,创新以闪电般的速度发生。这不像木工,有着几千年的经验,知道什么可行什么不可行。在软件领域,我们仍在发明工具,并在前进的过程中重新发明它们。为我们的最终用户提高生产力的可能性设定了很高的期望,在日益竞争的市场中,上市时间至关重要。
在我们今天编写软件的水平上,我们有巨大的潜力利用围绕我们自己的代码的元数据来确保我们编写的软件的质量和可维护性。这就是元编程之旅开始的地方。
元编程在工作中真的很有趣,但它代表了开发者和企业区分自己的真正机会。元编程能为你做的事情包括以下内容:
-
提高代码的可维护性
-
自动化繁琐的任务
-
让开发者更多地关注你的业务,而不是基础设施
-
帮助你保持更高的合规性
-
降低与安全相关的风险
与任何其他技术一样,深入研究元编程有技术上的原因,但我认为通过利用它来帮助你和你的团队提高生产力,最终交付更多业务关键特性,这些特性在多年后更容易更改和维护,这是有真正价值的。
这本书面向的对象
这本书面向任何对元编程及其可能为你带来的益处感兴趣的 C#开发者。更具体地说,以下角色是目标受众:
-
熟悉 C#和.NET 作为运行时并希望扩展其视野、更深入地了解.NET 运行时和编译器功能的开发者
-
熟悉 C#和.NET 的软件架构师,正在寻找如何改进其架构的灵感
-
具有开发者背景或理解软件开发并希望为开发者组织提供灵感的 CTO 或开发经理
这本书涵盖的内容
第一章,元编程如何为你带来益处,深入探讨了元编程是什么以及它如何改善开发者的日常工作。它通过一些具体的例子来解释其功能。
第二章,元编程概念,通过具体的例子解释了隐式和显式元数据之间的区别。此外,它还揭示了.NET 运行时的工作原理。
第三章, 通过现有真实世界示例去神秘化,展示了微软作为例子如何利用元编程,以及您可能已经使用它的情况。
第四章, 使用反射推理类型,概述了.NET 运行时反射的强大功能以及如何利用其隐式元数据。
第五章, 利用属性,介绍了利用 C#属性作为显式元编程技术以及如何使用它,并提供了实际应用的示例。
第六章, 动态代理生成,介绍了在运行时生成代码的代码,这是一个真正能够提高开发者生产力的强大概念,如果明智地使用的话。
第七章, 推理表达式,介绍了 C#和.NET 表达式树,它们如何表示元数据的另一个方面,以及如何在代码中进行推理和展开。
第八章, 构建和执行表达式,介绍了如何在运行时构建自己的表达式以及如何执行这些表达式——生成代码的另一种技术。
第九章, 利用动态语言运行时,涵盖了动态语言运行时的概念以及如何使用它来动态生成代码——您的代码的另一种选择是创建代码。
第十章, 约定优于配置,揭示了约定的超级能力——重复您可能已经拥有的模式的代码,使您的开发者更加高效,代码库更加一致。
第十一章, 应用开闭原则,深入探讨了如何创建既可扩展又不可修改的代码库——这是一个强大的原则,用于维护性软件,使用元编程作为进入该原则的角度。
第十二章, 超越继承,在常规约定之上提供了另一个层次,为开发者提供了不仅仅受编程语言提供的功能所限制的机会,旨在提高可读性和可维护性。
第十三章, 应用横切关注点,揭示了如何在整个代码库中一致地应用代码,而无需回到手动配方。
第十四章, 面向方面编程,提供了关于面向方面编程形式化的详细信息以及如何作为一种更正式的技术来帮助您提供横切关注点。
第十五章, Roslyn 编译器扩展,详细介绍了.NET 编译器 SDK 提供的基本内容以及如何开始使用它,为以下章节奠定了基础。
第十六章, 生成代码,介绍了如何在进入运行时之前使用编译器级别的代码生成代码,这是提高开发人员生产力和保持代码库一致性的另一种绝佳方法。
第十七章, 静态代码分析,介绍了如何构建自己的规则,对添加到项目中的任何代码进行分析,帮助您创建一致、统一且易于维护的代码。
第十八章, 注意事项和结语,探讨了本书涵盖的内容、有哪些好处以及有哪些注意事项。与任何事物一样,你必须找到正确的平衡,并知道何时使用什么。
要充分利用本书
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| 使用.NET 7 的 C# | Windows、macOS 或 Linux |
| Postman | Windows、macOS 或 Linux |
| MongoDB | Windows、macOS 或 Linux |
如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Metaprogramming-in-C-Sharp。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有来自我们丰富的书籍和视频目录的其他代码包可供选择,这些代码包可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图和图表的彩色 PDF 文件。您可以从这里下载:packt.link/nZUlx。
使用的约定
本书使用了多种文本约定。
文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“提供商寻找ConfidentialAttribute以决定是否可以提供。”
代码块设置为以下格式:
namespace Fundamentals.Compliance;
public interface ICanProvideComplianceMetadataForType
{
bool CanProvide(Type type);
ComplianceMetadata Provide(Type type);
}
任何命令行输入或输出都按照以下方式编写:
Checking type for compliance rules: Chapter11.Patient
Property: FirstName - Employment records
Property: LastName - Employment records
Property: SocialSecurityNumber - Uniquely identifies the employee
Property JournalEntries is a collection of type Chapter11.JournalEntry with type level metadata
粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“然后在Body选项卡中选择JSON,添加一个空白的 JSON 文档,然后点击Send。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果你对本书的任何方面有疑问,请通过电子邮件联系我们 customercare@packtpub.com,并在邮件主题中提及书名。
勘误表:尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果你在互联网上以任何形式遇到我们作品的非法副本,我们将不胜感激,如果你能向我们提供位置地址或网站名称。请通过电子邮件联系我们 copyright@packtpub.com,并提供材料的链接。
如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你感兴趣的是撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享你的想法
一旦你阅读了《C#元编程》,我们很乐意听听你的想法!请点击此处直接进入此书的亚马逊评论页面并分享你的反馈。
你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢你购买这本书!
你喜欢在旅途中阅读,但无法携带你的印刷书籍到处走吗?你的电子书购买是否与你的选择设备不兼容?
别担心,现在每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。
优惠不会就此结束,你还可以获得独家折扣、时事通讯和每日收件箱中的精彩免费内容。
按照以下简单步骤获取优惠:
https://packt.link/free-ebook/9781837635429
-
提交你的购买证明
-
就这些!我们将直接将免费的 PDF 和其他优惠发送到你的邮箱。
第一部分:为什么需要元编程?
在这部分,你将了解什么是元编程,它的好处,以及如何通过实际案例利用它的想法。你还将看到你很可能会直接或间接地已经在使用它,以及如何获得一些快速胜利和早期收益。
本部分包含以下章节:
-
第一章,元编程如何为你带来好处?
-
第二章,元编程概念
-
第三章,通过现有实际案例去神秘化
第一章:元编程如何对你有益?
那么,什么是元编程,为什么你应该关心?如果你拿起这本书希望学习关于元宇宙编程的知识,你很快就会非常失望。
元编程全部关于代码将其他代码视为数据。这可能只是为了理解和推理代码,或者实际上基于元数据通过结构或显式添加来隐式地创建新代码。
“但我为什么要关心这些呢?”你可能会问,“难道仅仅编写代码并交付就足够了吗?”在本章中,我们将探讨从进行元编程中可以获得的实际好处,以及它如何使你日常受益。我们还将提供有关如何提高生产力和消除我们开发者往往必须做的繁琐任务的技巧。
本章的主要目标是向你介绍元编程及其用例示例。在这个过程中,我们将了解.NET 为元编程提供的构建块。在本章中,我们将涵盖以下主题:
-
对你的代码进行推理
-
移除手动结构和流程
到本章结束时,你应该对元编程如何对你有益有一些很好的想法和灵感。你也应该对元编程的实质有所了解。
对你的代码进行推理
软件行业非常年轻。在过去的 20-30 年里,我们看到了其使用的显著增长。
今天,软件已经融入我们生活的方方面面——我们的工作、我们的交通,以及我们的家庭,甚至包括我们许多人安装的智能灯泡。
由于我们制作的软件的应用范围和用户众多,软件有相应的期望。今天的用户对软件的期望远高于 20 年前。由于软件在我们的生活中如此根深蒂固,我们变得更加脆弱,这使其成为一个风险因素。
在本节中,我们将讨论为什么作为开发者的你应该关心元编程。我们将探讨开发者的关注点,如何进行一些巧妙的自动化,并介绍元编程的一些基础知识。
开发者关注点
对于我们开发者来说,我们需要涵盖很多不同的方面来保证我们软件的成功。
最终用户对优秀的用户体验有很高的期望,他们希望被置于成功的顶峰。我们还需要对我们的系统将拥有的不同类型的用户表示同情,并确保它对每个人都是可访问的。
我们的系统需要保持其数据的完整性,并帮助最终用户做正确的事情。为此,我们需要根据我们期望的或我们的数据模型要求的来验证所有输入。此外,我们还需要有强制执行系统完整性的业务规则。
数据也是我们想要保护的东西,因此安全性扮演着重要的角色。用户需要进行身份验证,我们还想确保用户有执行系统不同任务的正确授权。
我们还必须确保我们的系统没有安全漏洞,否则黑客可以入侵系统。用户的输入也需要被清理,以防止通过诸如SQL 注入之类的恶意攻击。
为了让我们的软件对用户可用,我们需要确保它在某处运行,无论是在本地服务器上还是在托管合作伙伴的服务器上,或者在云中,无论是物理的还是虚拟的。这意味着我们需要考虑如何打包软件,然后如何将其部署到运行环境中。
一旦运行,我们必须确保它始终运行,没有停机时间;我们的用户依赖于它。为此,我们希望考虑运行多个系统实例,以便在主实例出现故障时可以切换到第二个实例。
我们还需要确保运行环境能够处理它所针对的用户数量。
我们不仅要有故障转移实例,还可以水平扩展并有一个负载均衡机制,将用户分散到我们拥有的不同实例中。这使得我们的系统成为一个分布式系统。
这些都是很多不同的关注点。理想情况下,你希望有不同的人做不同方面的工作,但这种情况并不总是如此(取决于组织的规模以及其文化)。今天,你经常在招聘广告中看到公司正在寻找全栈开发者。在许多情况下,这可能意味着期望你需要与以下所有方面合作:
-
用户体验:这关乎交互、流程以及整体的感觉
-
可访问性:这涉及到创建对残疾人友好的同理心软件
-
前端代码:这是布局、样式以及使用户体验生动起来的必要逻辑
-
后端代码:这是为了创建代表我们正在工作的领域的粘合剂
-
数据建模:这是我们如何存储数据以及如何为我们需要的用途建模
-
身份验证和授权:这是为了确保用户已经通过身份验证,并且对不同的功能应用了适当的授权策略
-
安全:这使得应用程序能够抵御任何攻击并保护数据
-
DevOps:这涉及到及时地将功能交付到生产环境中,而不需要任何仪式
自动化
作为人类,我们会犯错,会忘记事情。有时,这会有一些非常糟糕的结果,比如系统崩溃,或者更糟的是,被黑客入侵。
幸运的是,计算机擅长执行它们被告诉的事情并且无限重复。它们从不抱怨,也不犯错误。这意味着我们有大量工作流程简化的机会。随着行业多年的成熟,我们看到了改进的工作流程和工具,这些可以帮助我们实现我们的目标,通常可以消除繁琐和耗时的工作。
自动化的一个绝佳例子是过去十年云计算领域所发生的变化。在此之前,我们必须设置物理硬件,并且通常需要手动流程将我们的软件部署到该硬件上。这一切已经完全改变,我们现在只需点击几下就能启动我们想要的一切,将其连接到一些持续部署软件,它会构建我们的软件,并自动将其部署到运行环境中。所有这些都可以在几分钟内完成,而不是几个小时或几天。
元编程
我为什么要讲这么多?这本书不是应该讲关于元编程的内容吗?
元编程全部关于围绕你的代码的附加信息。这些信息有时是隐含的——也就是说,它已经存在。然而,有时它需要你作为开发者明确或故意地添加。
运行软件的计算机只理解存储在内存中并为 CPU 执行准备的机器语言指令。对我们人类来说,这不太直观。早期,我们发明了可以帮助我们编写更友好、更容易推理的语言。这始于汇编语言,后来是能够编译成汇编语言的高级语言。
使用这些工具,我们获得了不仅仅是将一种语言翻译成另一种语言的能力,还可以推理我们的代码中正在发生的事情。1978 年,贝尔实验室的 Stephen C. Johnson 提出了他称之为lint的工具——一个静态代码分析工具,可以用来推理 C 代码并检测其潜在问题。如今,这已成为大多数编程语言的常见做法。对于JavaScript或TypeScript的 Web 开发,我们通常可以在构建管道中添加像ESLint这样的工具来完成这项工作。对于C#,这已经内置到编译器中,并且使用 Roslyn 编译器,它可以完全通过我们自己的自定义规则进行扩展,这一点我们将在第十七章,静态代码分析中介绍。
对于像C/C++这样的编程语言,它们编译成在 CPU 上本地运行的代码,我们在编译级别上所能推理的有限。然而,对于像 Java 或 C#这样的编程语言,通常被称为托管语言,我们现在在托管环境中运行代码。我们编写的代码编译成中间语言,在运行时会被即时翻译。这些语言携带有关我们编写的代码的信息——这被称为元数据。这使得我们可以在运行时将我们的程序或他人视为数据,并允许我们推理代码;我们甚至可以在运行时发现代码。
从C#的第一个版本开始,我们可以添加额外的信息和更多的元数据。通过使用 C# 特性,我们可以给类型、类型上的属性和方法添加额外的信息。这些信息会传递到运行程序中,我们可以用它来对软件进行推理。
例如,对于属性,我们现在可以添加额外的信息,我们可以在编译时和运行时对其进行分析。我们可以做诸如在对象上标记验证信息,例如[必需]:
public class RegisterPerson
{
[Required]
public string FirstName { get; set; }
[Required]
public string LastName { get; set; }
[Required]
public string SocialSecurityNumber { get; set; }
}
这段代码表示注册一个人所需的内容。我们需要的所有属性都有[必需]属性作为元数据。
现在我们已经将元数据添加到代码中,我们可以根据它采取具体行动。
移除手动结构和流程
添加显式元数据对于可见性很好,并且使代码中添加的元数据类型非常明确。然而,这些元数据本身是不可执行的。这意味着没有什么是固有的来处理它——例如,我们看到的属性是必需的。
这种元数据使我们能够不仅对围绕我们的代码的元数据进行推理,而且将其付诸行动。我们可以构建我们的系统,使其利用这些信息为我们做出决策,或者我们可以自动化繁琐的任务。
在我的整个职业生涯中,我见到的最常见的事情是我称之为步骤驱动开发。代码库往往会固定一种结构,并且在创建其中的功能时,开发者需要执行一系列特定的操作。这些步骤通常被记录为代码库文档的一部分,每个人都需要阅读并确保遵循。这并不一定是一件坏事,我认为所有代码库都有这种程度的情况。
退一步,可能会有一些潜在的改进我们的生产力和减少代码量的机会。这些步骤和模式可以被形式化和自动化。这样做的主要原因是因为遵循步骤可能会出错。我们可能会忘记做某事或者做错,甚至可能混淆步骤的顺序。
假设你有一个 API,并且对于每个操作,你都有以下步骤:
-
检查用户是否有权限
-
检查所有输入是否有效
-
检查恶意输入(例如,SQL 注入)
-
检查操作是否被域逻辑允许,通常是特定于业务的规则
-
执行业务逻辑
-
根据是否成功返回正确的 HTTP 状态码和结果
这些都是在同一个地方混合了很多关注点。在这个时候,你可能认为这不是我们在现代ASP.NET API 开发中做事的方式。这是正确的——它们通常被分成关注点和像 SQL 注入这样的管道处理的事情。
重要提示
我们将在第三章“通过现有现实世界示例去神秘化”中重新审视 ASP.NET 如何利用元编程来提供开发者体验。
即使这些事情可能不在同一种方法中且分散开来,它们仍然是我们必须注意的问题,因此食谱会明确指出这些事情需要被执行。通常,它们是重复的,并且有可能被优化以提高开发者的体验,同时降低系统发生致命错误的风险。
软件维护
这种重复代码的另一个方面是,我们添加到系统中的所有代码都需要维护。构建一个功能可能不会花费太多时间,但很可能会需要多年维护。可能不是由你维护,而是由团队中的其他人或你的继任者维护。因此,我们应该首先优化我们的代码库以适应维护。及时推出功能是我们所期望的,但如果我们不考虑代码的维护,作为代码的所有者,当需要维护时,我们将遭受痛苦。
维护不仅仅是保持代码正常运行并履行其承诺。它还关乎其适应和适应新需求的能力,无论是业务需求还是技术需求。项目的初期正是你对它了解最少的时候。
因此,为此进行规划非常困难,需要我们能够预测未来。但我们可以编写代码,使其更适应变化。
而不是在所有地方重复所有这些代码,我们可以在代码中放入元数据,我们可以利用这些数据。这通常是 ASP.NET 所支持的——例如,对于控制器的授权,使用[Authorize]属性。它需要满足特定的策略,例如用户必须处于某个角色。如果我们的系统为我们的功能有明确的结构,你可能会发现属于特定角色的功能自然分组。然后我们可以通过查看类型上的命名空间元数据并设置正确的授权规则来推理这种结构。对于开发者来说,你通过结构替换了显式信息的需求,使其变得隐式。这看起来可能是一件小事,但整个代码库的生命周期中,这种思维方式可以对生产力和可维护性产生巨大影响。
代码生成
使用 C#,我们可以比仅仅推理代码和基于我们找到的内容做出决策更进一步——我们可以生成代码。如果拥有所有必要的信息或被推到运行时级别,代码生成可以在编译时进行。这提供了更多的灵活性,并赋予我们巨大的力量。
例如,如果你曾经使用过基于 XAML 的前端技术,如 Windows Presentation Foundation (WPF) 或 Universal Windows Platform (UWP),并使用过数据绑定,你可能已经遇到过 INotifyPropertyChanged 接口。它的目的是使视图控件能够通知你,当绑定到视图的对象的属性值发生变化时。
假设你有一个表示人的对象:
public class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
}
现在,假设我们想要在任何一个属性发生变化时显示这个通知。使用用于绑定目的的 INotifyPropertyChanged 接口,对象需要扩展为以下内容:
public class Person : INotifyPropertyChanged
{
private string _firstName;
public string FirstName
{
get { return _firstName; }
set
{
_name = value;
RaisePropertyChanged("FirstName");
}
}
public string LastName { get; set; }
public event PropertyChangedEventHandler
PropertyChanged;
protected void RaisePropertyChanged(string
propertyName)
{
if (PropertyChanged != null)
{
PropertyChanged(this, new
PropertyChangedEventArgs(propertyName));
}
}
}
正如你所见,创建属性现在变得非常繁琐。想象一下,如果对象的所有属性都这样做。这很容易变成难以阅读的代码,需要维护的代码更多,而且这并没有给你的代码增加任何业务价值。
这可以通过最新的 C# 编译器版本得到改进。
几年前,微软重写了 C# 编译器。编译器被命名为 Roslyn。他们重写编译器有几个原因,其中之一是他们希望编译器本身是用 C# 编写的——这是语言和 .NET 运行时成熟度的证明。此外,作为微软向开源转变的一部分,重写并在公开环境中进行,放弃旧的许可模式更有意义。但在我看来,最重要的原因是使其更具可扩展性,而不仅仅是针对微软自身,而是针对所有人。
这种可扩展性的一部分被称为 Roslyn 代码生成。有了它,我们可以使代码非常接近原始代码。让我们假设我们引入了一些元数据,形式为 [Bindable] 属性,并创建了一个编译器扩展,将所有私有字段转换为需要的 InotifyPropertyChanged 属性。在这里,我们的对象看起来是这样的:
[Bindable]
public class Person
{
private string _firstName;
private string _lastName;
}
我们也可以在运行时做这件事。然而,在运行时,我们受限于已编译的内容,无法更改类型。因此,方法会有所不同。我们不会更改现有类型,而是需要创建一个新的类型,它继承自原始类型并对其进行扩展。这要求我们将原始属性设置为 virtual,以便在生成的类型中覆盖它们:
[Bindable]
public class Person
{
public virtual string FirstName { get; set; }
public virtual string LastName { get; set; }
}
为了使这可行,我们需要一个工厂,它知道如何创建这些对象。我们需要在需要其实例时调用它。
权力越大,责任也越大,而且选择走这条路需要非常谨慎。我们将在 第十八章 中讨论,注意事项和 结语。
我们将在 第十五章 中更深入地讨论 Roslyn 可扩展性,Roslyn 编译器扩展。
编译时安全性
有时候我们也必须添加某些元数据以使系统正常工作。这将是为 Roslyn 编译器编写代码分析器的候选任务。分析器将找出缺少的内容,并尽快通知开发者,从而提供一个紧密的反馈循环,而不是让开发者必须在运行时发现这个问题。
例如,在我工作的一个名为 Cratis (cratis.io) 的平台上,这是一个事件源平台。对于所有被持久化的事件,我们都需要一个唯一标识符来表示事件的类型。这个标识符作为事件的属性被添加:
[EventType("66f58b90-c027-41b3-aa2c-2cfd18e7db69")]
public record PersonRegistered(string FirstName, string LastName);
当在事件日志上调用 Append() 方法时,类型必须与唯一标识符相关联。如果事件类型与 .NET 类型之间没有关联,Append() 方法将抛出异常。这是一个在编译时检查发送到 Append() 方法的任何内容以及检查对象类型是否具有 [****EventType] 属性的绝佳机会。
我们将在 第十七章,静态代码分析 中重新回顾所有这些内容。
摘要
希望你现在已经了解了元编程的巨大潜力。它非常强大。这伴随着巨大的责任——在编译时间或运行时神秘添加的代码与每个代码文件中的明确性之间的平衡是困难的。根据我的经验,新开发者进入一个有很多隐式自动化的代码库时可能会遇到麻烦,并可能最终不相信这种魔法。
但过了一段时间后,一旦他们习惯了,他们往往想要更多的魔法。一旦你有了经验,这些好处是显而易见的,但一开始可能会有些可怕。为了解决这个问题,你应该沟通你所拥有的自动化。这样至少会让它更符合 最小惊讶原则。
在下一章中,我们将深入探讨元编程的更具体的概念,并探讨这些概念背后的内容。我们将熟悉 .NET 运行时如何看待代码以及它产生的元数据,以及如何在运行中的应用程序中利用这些数据。最后,我们将学习如何扩展这些元数据。
第二章:元编程概念
现在我们对元编程如何为您带来好处有了一些想法,我们需要介绍基本概念。
在使用元编程时,您可以从运行环境免费获得元数据,并且有机会显式地添加更多。通过显式性,您可以在代码库中实现清晰度,并为编写和阅读代码的开发者提供透明度。
您的源代码中的一些部分将受益于更多的显式元数据,而不是隐式地、神奇地执行一些可能难以让开发者推理其原因的操作。
显式性也带来了在代码中表示业务领域语言的可能性和更高的表达性。
在本章中,我们将涵盖以下主题:
-
隐式 - 使用已有的内容
-
显式 - 代码的额外修饰
-
领域特定语言
到本章结束时,您应该对可以使用的不同元编程概念有良好的感觉,知道何时使用哪个概念,以及每个概念的好处。
技术要求
您可以在 GitHub 仓库中找到本章使用的所有源代码:github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter2。
隐式 - 使用已有的内容
编译C#的编译器解析我们所有的代码,最终创建被称为IL-code(中间语言代码)的内容。这是标准化的,并且是通用语言基础设施(Common Language Infrastructure)的ECMA-335标准的一部分。您可以在以下链接中了解更多关于标准的信息:www.ecma-international.org/publications-and-standards/standards/ecma-335/。这种代码不是系统 CPU 所能理解的,它需要额外的步骤才能让 CPU 理解。程序的运行过程中,翻译的最后一个步骤是由.NET 运行时执行的,它解释 IL 代码,并为程序运行的计算机的 CPU 类型生成必要的指令。
通过查看二进制输出,您可能无法确定差异。但通过使用如ildasm之类的反编译工具或更直观的工具,如JetBrains dotPeek(www.jetbrains.com/decompiler/),我们可以一窥我们的程序看起来是什么样子。
以以下程序为例:
using System;
public class Program
{
public static void Main()
{
Console.WriteLine("Hello world!");
}
}
编译此代码将生成一个动态链接库(DLL)文件,当使用反编译器打开文件时,我们会看到如下内容:
.class private auto ansi '<Module>'
{
}
.class public auto ansi beforefieldinit Program
extends [System.Runtime]System.Object
{
.method public hidebysig static
void Main () cil managed
{ .maxstack 8
IL_0000: ldstr "Hello world!"
IL_0005: call void [System.Console]System.
Console::WriteLine(string)
IL_000a: ret
}
.method public hidebysig specialname rtspecialname
instance void .ctor () cil managed
{
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void [System.Runtime]System.
Object::.ctor()
IL_0006: ret
}
}
在基于 x86/AMD64 的 CPU 上,这随后会被转换成如下反汇编代码:
L0000: mov rcx, 0x2217fb34a50
L000a: mov rcx, [rcx]
L000d: jmp 0x00007ffef72f2fc8
实际的 Hello world! 字符串随后被放置在由第一个 mov 指令使用的内存位置。如果你对尝试代码并自己查看其翻译感兴趣,我建议你前往 sharplab.io。
在最终编译的结果中,实际上没有任何元数据,这使得我们无法以任何有意义的方式对代码进行推理。
虽然 IL 包含了我们编写的一切,但所有类型信息都是完整的(类型名称、方法名称等)。
有了这些,我们就为成功进行一些适当的元编程做好了准备。
利用反射的强大功能
所有这些信息都对我们开放,并且这一切都始于强大且我个人最喜欢的命名空间 System.Reflection。它包含了所有代表我们编写的不同代码元素的 C# 类型。由于 C# 是在托管运行时之上运行的托管语言,我们可以获得关于我们编写的所有代码的详细信息。
由于我们创建的每个类型以及我们将创建的类型都是 Object 类型的派生类型,因此每个派生类型都固有地获得了其方法和属性。其中一个方法被称为 GetType()。此方法返回一个对象类型的实例,形式为 Type。它包含了有关特定类型的所有详细信息——从它所在的命名空间,到字段、属性、方法,以及更多。对于类型,我们甚至可以查看它继承的内容以及它可能实现的接口。我们甚至可以看到它定义在哪个程序集(DLL)中。
如果你查看对象关系映射器,如 Microsoft 的 Entity Framework、Dapper、NHibernate 或甚至是 MongoDB C# 驱动程序,它们都使用反射来推理你必须翻译成底层数据存储期望的类型。对于关系型存储,它通常翻译成正确的 SQL 语句,而对于 MongoDB,它将是正确的 MongoDB Binary JSON (BSON)对象。
同样,从 .NET 类型序列化为其他格式(如 JSON)的东西也会这样做。例如,Newtonsoft.JSON 或内置的 System.Text.Json 命名空间利用反射来了解需要翻译的内容。
假设你有一个 Person 类型:
public class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string SocialSecurityNumber { get; set; }
}
一个极其简单的 JSON 转换可以轻松完成:
public string SerializeToJson(object instance)
{
var stringBuilder = new StringBuilder();
var type = instance.GetType();
var properties = type.GetProperties();
Var first = true;
stringBuilder.Append("{\n");
foreach( var property in properties )
{
if (!first)
{
StringBuilder.Append(",\n");
}
stringBuilder.Append($" \"{property.Name}\":
\"{property.GetValue(instance)}\"");
}
stringBuilder.Append("\n}");
}
这可以按以下方式使用:
var person = new Person
{
FirstName = "Jane",
LastName = "Doe",
SocialSecurityNumber = "12345abcd"
};
Console.WriteLine(Serializer.SerializeToJson(person));
运行此代码的输出将产生一些漂亮的 JSON:
{
"FirstName": "Jane",
"LastName": "Doe",
"SocialSecurityNumber": "12345abcd"
}
代码基本上从实例中获取类型信息,并获取其上的属性。它输出一个包含属性名称的字符串,并利用 PropertyInfo 从实例中获取值,并仅输出其 .ToString() 表示形式。
显然,这个示例非常简单,并且对于复杂类型不支持递归,也不识别已知的 JSON 原始类型。但这证明了你可以多么容易地获取这些信息。
隐式元数据和 .NET 的类型系统可以非常强大。您在反射的洞穴中走得越深,可能就越想继续深入并做更多的事情。另一方面,对于您自动执行的所有事情,您都会失去对正在发生的事情的透明度。这需要平衡,并尽可能接近最小惊讶元素。
对于某些事情,最好是做得非常明确。还有一些元数据无法通过编译器生成的信息来发现,唯一的方法就是非常明确地指定。
显式 - 代码的额外修饰
几乎所有的代码元素都可以添加额外的信息。这些被称为 属性。属性是将元数据与元素关联的强大方法。在 .NET 框架本身中,您会看到许多可以使用的属性类型。
在 System.ComponentModel.DataAnnotations 命名空间中,您可以找到一些添加由运行时使用的元数据的属性示例。在这里,您将找到用于添加验证元数据的属性。ASP.NET 会检测这些属性的用法,并检查发送到控制器操作的实体是否符合应用规则。正如我们在 第一章 简要提到的,“元编程如何为您带来好处?”,使用我们的 RegisterPerson 类型,我们可以指定哪些属性是必需的。它包含更多内容,例如 [StringLength] 和 [Range]。这些都是框架和它们所支持的组件识别的元数据的优秀示例。
框架中的一些属性被编译器识别,并将指导编译器执行某些操作。例如,[Flags] 属性可以添加到枚举中,指示它要求每个值都表示一个位字段:
[Flags]
public enum MyEnum
{
Flag1 = 1,
Flag2 = 1 << 1,
Flag3 = 1 << 2,
Flag4 = 1 << 3
}
使用这种类型的枚举,我们定义标志,每个标志都是一个左移位,用于将数字移动到正确的位的位置。您也可以通过给出实际的十进制或十六进制数来做到这一点(1、2、4 或 8,或 0x1、0x2、0x4、0x8、0x10,等等)。
System.Text.Json 序列化器也使用了元数据。它使用元数据来确定如何序列化或反序列化所提供的内容。例如,它可以忽略对象中的属性:
public class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
[JsonIgnore]
public string FullName => $"{FirstName} {LastName}";
public string SocialSecurityNumber { get; set; }
}
序列化此实例将省略 FullName。
创建自定义属性
您可以通过添加您想要为代码添加的元数据来轻松创建自己的自定义属性。所有属性都可以接受带有额外元数据的参数,这些元数据将与它们关联。这为您提供了机会,可以非常具体地指定您想要与被修饰的代码元素关联的数据作为元数据。
添加到属性中的所有元数据都需要在编译时由编译器解析。这限制了我们只能使用典型的基本类型和本质上是常量的东西。你不能动态创建实例并将其传递给属性。
一个属性的例子可能是用于描述包含个人可识别信息(PII)的类型或属性。这非常有用,可以让你在以后推理你的代码时知道正在收集哪些 PII 信息,并将其展示给用户。根据欧盟的隐私法律GDPR,如果你的公司在接受审计或发生与 GDPR 相关的事件时需要向当局报告,这也可以作为一个报告机制。
一旦你用这种元数据标记了类型和属性,你就有机会用于未来的用例——例如,加密 PII 数据或其他任何东西。
创建自定义属性的基本形式如下:
public class PersonalIdentifiableInformationAttribute :
Attribute
{
}
PersonalIdentifiableInformationAttribute类型需要继承基础Attribute类型。编译器还期望你创建的类型名称中包含Attribute作为后缀。然而,当使用你自定义的属性时,你可以在名称中省略Attribute,编译器会将它映射到带有后缀的完整名称。
下一步你需要指定的是实际上是一点编译器的元数据。这是通过使用[AttributeUsage]属性来完成的。使用它,我们需要指定支持哪些目标代码元素,并且我们可以通过或操作(OR操作)来支持多个。
对于PersonalIdentifiableInformationAttribute,我们通常希望它在类、属性和字段中使用:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.
Property | AttributeTargets.Parameter, AllowMultiple = false)]
public sealed class PersonalIdentifiableInformationAttribute :
Attribute
{
}
此外,我们指定我们不希望允许同一属性的多个副本。一个好的做法是让你的属性非常具体,不要创建任何属性的继承链。这将使你的属性成为一个密封类型,从 C#的角度来看不允许继承。
GDPR 提到的一件事是记录收集目的。因此,为了补充这一点,我们可以包括作为可选元数据的用途。你可以通过添加一个接受元数据作为参数的构造函数来实现这一点,如果你想让它成为可选的,你可以提供一个默认值:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.
Property | AttributeTargets.Parameter, AllowMultiple = false)]
public sealed class PersonalIdentifiableInformationAttribute :
Attribute
{
public PersonalIdentifiableInformationAttribute(string
purpose = "")
{
Purpose = purpose;
}
public string Purpose { get; }
}
如你所见,除了添加带有元数据参数的构造函数外,你还需要添加Purpose属性来公开元数据。
因此,我们可以开始将PersonalIdentifiableInformation属性应用于某些对象上的属性,例如Person对象:
public class Person
{
[PersonalIdentifiableInformation("First name of person")]
public string FirstName { get; set; }
[PersonalIdentifiableInformation("Last name of person")]
public string LastName { get; set; }
[PersonalIdentifiableInformation("Unique identifier for
person")]
public string SocialSecurityNumber { get; set; }
}
我们将在第五章中更深入地探讨如何进一步利用它,利用属性。
我们已经讨论了如何通过隐式结构来推理我们的代码,以及我们如何添加显式的额外元数据和甚至我们自己的自定义数据。那么反过来呢?我们的意思是从某个东西开始,基本上生成代码。
正如我们之前讨论的,编程语言本身基本上是一种高级元数据语言,旨在能够更有效地表达代码。
以这种心态,实际上没有什么能阻止我们发明自己的语言,并利用基础设施生成运行代码。
领域特定语言
创建自己的领域特定语言(DSL)的概念并不是什么新鲜事,公司们已经做了很多年了。这可以是一个非常有效的方法,将你的领域专家纳入其中,并为他们提供一种用他们更熟悉的语言贡献代码的方式。他们编写的代码通常处于一个更高的层次,并且拥有由底层代码结构支持的词汇表,而这些底层代码结构实际上在做着繁重的工作。
将其视为一种编程语言和你的业务的编译器,用来表达你的业务问题。
它也可以用于技术方面,而不仅仅是业务方面——例如,如果你有复杂的状态机或工作流程,它们有自己的词汇表,你希望将其转化为更容易推理的语言。你也可以想象这种语言以 JSON、YAML 或甚至 Excel 等众所周知的文件格式表示。
生成这种高级表示形式的代码的目的在于,你可以有机会使最终结果更加优化,你可以在它到达运行时之前做出优化流程的决定。你还可以让你的应用程序启动更快,因为它不需要在启动时解析东西并将其传递给运行它的引擎。最终,它将只是运行代码,就像你的解决方案中的任何其他代码一样。
Gherkin – 技术样本
如果你熟悉编写单元测试,典型的结构是准备、执行,然后是断言。一个测试可能看起来像这样:
public class CalculatorTests
{
[Fact]
public void Add()
{
// Arrange
var left = 5;
var right = 3;
var expectedResult = 8;
// Act
var actualResult = Calculator.Add(left, right);
Assert.Equal(expectedResult, actualResult, 0);
}
}
这段代码测试了一个计算器,以验证其加法功能是否按预期工作。它是通过设置输入和预期结果,然后调用计算器,最后通过断言来验证结果,断言结果应该与预期结果相同来完成的。
虽然这是更传统的测试驱动开发(TDD)风格,但还有一种叫做行为驱动设计(BDD)的方法。这种方法更多地关注系统的行为以及系统各部分之间的交互,而不是其状态。为了表达这种交互,创建了一个名为Gherkin的领域特定语言(DSL)。在基本形式上,它与 TDD 的“arrange”(准备)、“act”(执行)和“assert”(断言)相对应,即“给定”、“当...时”和“然后”。此外,它还包括了功能、场景和步骤的概念。目标是编写系统的具体功能需求。
对于高级功能,领域专家很难对 C#代码进行推理,以验证我们是否测试了正确的东西或是否交付了预期的内容。
使用正确的工具,我们可以用普通的英语来描述一个系统的功能,并将其连接到执行实际代码的代码片段,以便测试或指定。
一个很好的实现例子是.NET BDD 框架SpecFlow(specflow.org/)。当你访问他们的网站时,你会看到他们的数据隐私对话框,并且他们包括了场景的规范:

图 2.1 - SpecFlow 数据隐私对话框
SpecFlow 采用 Gherkin,并提供了一个编译器,该编译器将 DSL 编译为可运行的代码,并混合了必要的代码来调用测试的功能。
我们将在第六章“动态代码生成:在运行的应用程序中发射 IL 代码”中探讨如何在运行时实现这一点,以及如何利用.NET 表达式创建代码,并在第八章“构建和执行表达式”中探讨这些代码如何即时编译。在第十六章“生成代码”中,我们将探讨如何在编译器级别真正实现这一点。
摘要
现在,你应该对元编程的不同概念有了更清晰的认识,并对何时使用什么有了更好的理解。本书中会多次提醒你,要小心不要做太多开发者可能不理解的内隐或神奇的事情。这种平衡非常难以掌握,而且对于个人开发者以及整个团队来说,也涉及一定程度的成熟度。
.NET 编译器和它产生的代码给了你作为开发者很大的权力。明智地使用它。
为了消除神秘感并展示元编程在日常生活中的更多应用,我们将在下一章探讨微软的 ASP.NET 如何利用元编程技术。这应该能让你感到它并不那么神秘,同时也能让你感受到它如何提高你的工作效率并提供帮助。
第三章:通过现有的实际案例来揭秘
本章中,我们将探讨 Microsoft 的ASP.NET如何利用元编程来自动化繁琐的配置。
自从 2009 年ASP.NET MVC的第一个版本发布以来,为了提高开发者的生产力,已经进行了大量的自动化工作。本章的主要目标是通过对元编程的揭秘,展示您可能已经利用了利用元编程的优势。
本章将涵盖以下主题:
-
ASP.NET 控制器
-
ASP.NET 验证
到本章结束时,您将了解 ASP.NET 如何通过非侵入式方法利用元编程,并看到自动化的好处。
技术要求
要遵循本章的说明,您需要以下内容:
-
安装了 Windows、macOS 或 Linux 的计算机
-
.NET 6 SDK
-
建议使用代码编辑器或 IDE(例如 Visual Studio Code、Visual Studio 或 JetBrains Rider)
本章的完整代码可以在以下位置找到:github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter3
系统的先决条件
本章将具体深入到代码中,因此您需要准备好您的系统。
我们首先需要的是 Microsoft .NET SDK。请访问dot.net,点击下载按钮下载 SDK。
重要提示
本书基于.NET SDK 的第 7 版。
要创建和编辑文件,这完全取决于您的个人喜好以及您所运行的系统。Visual Studio Code 和 JetBrains Rider 都可在 Windows、macOS 和 Linux 上使用。Visual Studio 仅适用于 Windows 和 macOS。您可以从以下链接下载任何这些编辑器;如果您没有偏好,VSCode 轻量级且能快速让您开始使用:
-
VSCode (
code.visualstudio.com/) -
JetBrains Rider (
www.jetbrains.com/rider/) -
Visual Studio (
visualstudio.com/)
一旦您选择了您的编辑器,请继续按照您选择的产品相关的安装过程进行操作。
要调用 API,您可以使用网络浏览器、Wget或cURL,但我推荐使用Postman(www.postman.com/)进行此操作。本书中的示例将使用 Postman。
ASP.NET 控制器
在不同的框架中,明确注册应用程序的组件是很常见的。发现这些组件并自动进行自我注册变得越来越流行。但非常常见的情况是,您需要亲自手动添加所有内容。
虽然这些注册的进行过程非常清晰,但手动注册的缺点是您基本上添加了不直接贡献于您试图实现的业务价值的代码。这也是一种高度可重复的代码,往往最终会出现在包含所有初始化的大文件中。
例如,使用 ASP.NET Core 6 我们得到了一个全新的最小化 API,它旨在具有更小的占用空间和更少的启动仪式。您可以使用三行设置代码开始创建 Web API,然后随意添加您的 API 作为 HTTP 方法并指定路由。
这一切看起来都很不错,但随着项目的增长,很容易变得难以维护。
让我们更具体地探讨一下它是如何工作的。
在您的系统上为这次操作创建一个名为 第三章 的新文件夹。在这个文件夹中,我们想要创建一个简单的 ASP.NET 网络应用程序。这可以通过多种方式完成,具体取决于您选择的编辑器/IDE 以及个人偏好。然而,在本书中,我们将坚持使用命令行来完成,因为这样可以在所有环境中工作。
打开命令行界面(Windows CMD、macOS 终端、Linux bash 或类似)。导航到您为本次操作创建的文件夹(第三章)。然后运行以下命令:
dotnet new web
这将产生一个最小的设置以开始。在您的编辑器/IDE 中打开文件夹/项目。您的 Program.cs 文件应该看起来像这样:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
它基本上设置了一个网络应用程序,并在根级别添加了一个路由,当使用您的网络浏览器导航到它时,将返回 "Hello World!"。
app.MapGet() 调用是一种非常简单的方式来暴露端点,实际上可以用来构建简单的 REST API。让我们创建一个简单的 API 来返回系统的员工信息。
自定义 HTTP Get 处理器
首先,创建一个名为 Employee.cs 的新文件,并将以下内容添加到文件中:
namespace Chapter3;
public record Employee(string FirstName, string LastName);
这只是一个简单的员工表示,包括他们的名字和姓氏。显然,在一个合适的系统中,您会添加更多属性到这个中。但为了这个示例,这已经足够了。
在设置了 Employee 类型后,我们现在可以将我们的 Get 动作更改为不同的路由,并仅返回 Employee 类型的集合。将 .MapGet() 方法调用替换为以下内容:
app.MapGet("/api/employees", () => new Employee[]
{
new("Jane", "Doe"),
new("John", "Doe")
});
在文件顶部,您还需要为 Chapter3 命名空间添加一个 using 语句。新的 Program.cs 文件应该看起来如下:
using Chapter3;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/api/employees", () => new Employee[]
{
new("Jane", "Doe"),
new("John", "Doe")
});
app.Run();
您可以使用 dotnet run 从您的终端/控制台运行此程序,或者如果您更喜欢使用您的 IDE 运行它,您应该得到一个运行中的程序,显示如下内容:
info: Microsoft.Hosting.Lifetime[14]
Now listening on: https://localhost:7027
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5016
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: /Users/einari/Projects/Metaprogramming-in-C/Chapter3/
两条显示Now listening on:的行将在您的计算机上具有不同的端口,因为它们是在创建项目时随机分配的。将 URL 与.MapGet()方法中的/api/employees结合。它可能类似于https://localhost:7027/api/employees或非 HTTPS 的http://localhost:5016/api/employees,只需记得将您的端口放入其中。将这个组合 URL 在浏览器中导航到它。您应该看到以下内容:
[{"firstName":"Jane","lastName":"Doe"},{"firstName":"John","lastName":"Doe"}]
显然,如果您直接在程序文件中添加这些 API 端点,并在处理程序方法中添加额外的逻辑,那么这个文件将会变得很大,难以阅读和维护。
这是我们能够大幅改进并让 ASP.NET 变得聪明的地方。让我们先为这个创建一个控制器。
控制器
向项目中添加一个名为EmployeesController.cs的新文件。使文件看起来像这样:
using Microsoft.AspNetCore.Mvc;
namespace Chapter3;
[Route("/api/employees")]
public class EmployeesController : Controller
{
[HttpGet]
public IEnumerable<Employee> AllEmployees()
{
return new Employee[]
{
new("Jane", "Doe"),
new("John", "Doe")
};
}
}
现在,这将创建一个利用 ASP.NET 中可用的 C#属性显式元数据的 Web API 控制器。在EmployeesController类之前,您有[Route]属性,它告诉控制器将位于哪个基本路由。然后我们有一个我们想要表示特定 HTTP 动词的方法;这是[HttpGet]属性。
我们已经将代码配置为能够被 ASP.NET 引擎本身自动发现和配置。我们所需做的只是更改此应用程序的启动方式,并指示 ASP.NET 向我们的系统中添加控制器。打开Program.cs文件,将其内容替换为以下内容:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
builder.Services.AddControllers()调用将指示 ASP.NET 发现当前程序集中的所有控制器。您会注意到的第二个调用是app.MapControllers()。此调用将所有控制器映射到元数据中指定的路由。
通过运行此应用程序并导航到之前相同的 URL,我们应该看到完全相同的结果。
现在这种模型的美妙之处在于,我们可以非常容易地添加第二个控制器,而无需进入应用程序的配置来获取配置。它将只是被发现并自动存在。
这意味着我们现在可以专注于构建业务价值,并且默认情况下更易于管理和维护,尤其是在您将其他开发者引入其中共同工作或有人继承您的代码库时。
ASP.NET 验证
当对 ASP.NET 进行 HTTP 请求时,它将通过一个由不同中间件组成的管道,这些中间件具有特定的职责。这个管道完全可由您作为开发者进行配置和扩展。默认情况下,它预配置了处理发送到请求的对象验证的特定中间件。此验证引擎识别可以应用于对象的元数据形式的规则。此元数据再次基于 C#属性。
让我们从稍微改变一下我们的Employee对象开始。打开Employee.cs文件,使其看起来如下所示:
public record Employee(
[Required]
string FirstName,
[Required]
string LastName);
这通过将[Required]属性添加到它们中,使FirstName和LastName属性成为必填项。ASP.NET 管道将检测到这一点,并检查任何发送的输入是否包含这些属性值。
然而,ASP.NET 不会为你决定如何处理无效对象;它只是为你填充一个名为ModelState的对象,让你决定如何处理这个问题。
为了处理像注册新员工这样的操作,我们需要在我们的控制器中有一个处理该操作的动作。打开EmployeesController并添加以下Register方法:
using Microsoft.AspNetCore.Mvc;
namespace Chapter3;
[Route("/api/employees")]
public class EmployeesController : Controller
{
[HttpPost]
public IActionResult Register(Employee employee)
{
if (!ModelState.IsValid)
{
return ValidationProblem(ModelState);
}
// ...
// Do some business logic
// ...
return Ok();
}
}
注意到ModelState.IsValid语句。如果有无效的验证规则,它将返回包含在ModelState中找到的错误的结果ValidationProblem。
运行应用程序并打开之前讨论过的 Postman。在 Postman 中,你可以通过点击带有+符号的按钮来创建一个新的请求:

图 3.1 - 在 Postman 中创建新的请求
这将创建一个新的标签页,就像一个常规的网页浏览器,其中包含请求的所有内容。在请求的左侧下拉菜单中选择POST作为 HTTP 动词,然后输入我们 API 的 URL。现在你可以简单地点击发送按钮,在下面的部分查看结果:

图 3.2 – 创建包含请求详情的新标签页
由于我们实际上没有传递一个对象,所以没有任何属性被设置。因此,列出的错误将表明这些属性是必需的。
自动连接模型状态处理
在 ASP.NET 中,一切围绕着被称为中间件的东西展开——这些是执行单个任务的小型、专用代码块,然后将其传递给下一个中间件。每个由 ASP.NET 处理的 HTTP 请求都有这些中间件,甚至处理控制器的代码也是其中之一。每个中间件都可以决定是否继续到下一个中间件,或者是否带有或没有错误退出。
控制器处理器只是我们可以利用的许多形式化中间件之一。
你可以在这里找到有关中间件的更多详细信息:
learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-7.0
如果你永远不希望无效状态进入你的控制器,并想消除开发者手动执行此操作的可能性,那么我们可以将特定的中间件放入管道中,这样我们就可以在甚至触及控制器之前阻止它。
ASP.NET 有一个操作过滤器的概念。它在控制器执行任何操作之前被调用,并允许我们决定是否继续管道。您可以在以下位置了解更多关于操作过滤器的内容:learn.microsoft.com/en-us/aspnet/core/mvc/controllers/filters?view=aspnetcore-7.0。
让我们创建一个名为ValidationFilter.cs的新文件,并使其看起来如下所示:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Chapter3;
public class ValidationFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (context.ModelState.IsValid)
{
await next();
}
else
{
context.Result = new BadRequestObjectResult(new ValidationProblemDetails(context.ModelState));
}
}
}
这将接管检查ModelState是否有效的任务。带有await next()的行是管道的延续。因此,只有在状态有效时才调用此操作,我们就可以避免在无效状态下到达控制器。相反,我们随后创建与 ASP.NET 管道在从控制器调用ValidationProblem方法时创建的相同对象,并返回此对象。
我们可以简化控制器,使其看起来如下所示:
using Microsoft.AspNetCore.Mvc;
namespace Chapter3;
[Route("/api/employees")]
public class EmployeesController : Controller
{
[HttpPost]
public IActionResult Register(Employee employee)
{
// ...
// Do some business logic
// ...
return Ok();
}
}
此代码根本不需要考虑ModelState,只是假设它已经被处理,从而简化了每个控制器的实现,使其专注于其单一目的——注册员工。对于大多数控制器来说,这将是可行的,实际上,你现在正在消除开发者忘记在执行业务逻辑之前检查有效性的可能性。
最后一个拼图是要将其连接到 ASP.NET 管道中。打开Program.cs文件,并将其内容更改为以下内容:
using Chapter3;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(mvcOptions => mvcOptions.Filters.Add<ValidationFilter>());
var app = builder.Build();
app.MapControllers();
app.Run();
.AddControllers()调用接受一个委托,允许我们配置MvcOptions。在此之内,我们可以添加我们新的操作过滤器。
运行应用程序,并通过点击 postman 内的发送按钮验证您是否得到了完全相同的结果。
摘要
在本章中,我们学习了如何利用.NET 运行时的力量来简化现有技术的使用,使得使用该技术的开发者能够更容易地实现这一点。通过在正确上下文中使用元数据,我们可以专注于交付业务价值,而无需担心如何进行配置。它还自动为我们提供了一种遵循的结构,从长远来看,这将产生更易于维护、扩展和可预测的代码库。通过添加我们刚才所做的操作过滤器,我们添加了一个所谓的跨切面关注点,我们将在第十三章中更详细地回顾,即应用 跨切面关注点。
在下一章中,我们将深入了解框架(如 ASP.NET)如何实现发现和自动化,以及我们如何利用.NET 运行时类型系统来发现类型和元数据,以实现类似的功能。
第二部分:利用运行时
在本部分中,您将看到.NET 运行时的强大之处,并深入了解其元编程能力的细节。您将看到运行时提供的不同元编程模型,并通过实际示例了解它们如何被利用。
本部分包含以下章节:
-
第四章, 使用反射进行类型推理
-
第五章, 利用属性
-
第六章, 动态代理生成
-
第七章, 表达式的推理
-
第八章, 构建和执行表达式
-
第九章, 利用动态语言运行时
第四章:使用反射进行类型推理
现在我们已经介绍了一些元编程如何为你带来好处的基础知识,其核心概念以及一个真实世界的例子,现在是时候深入.NET 运行时,看看我们如何利用其力量。
在本章中,我们将探讨编译器和运行时提供的隐式元数据。我们将了解如何收集运行系统中的所有类型,并用于发现。
我们将涵盖以下主题:
-
运行过程中的程序集发现
-
利用库元数据获取项目引用的程序集
-
发现类型
-
应用开放/封闭原则
到本章结束时,你将了解如何使用.NET 运行时的强大 API 来推理系统中已存在的类型。你将学习如何将其应用于创建一个更具弹性和动态的代码库,它可以增长并确保其可维护性得到保留。
技术要求
本章的特定源代码可以在 GitHub 上找到(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter4),并且基于github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Fundamentals中找到的基础知识代码。
运行过程中的程序集发现
在.NET 中,我们编译的所有内容最终都会放入一个称为程序集的容器中。这是以中间语言(IL)的形式存储编译代码的二进制文件。与此并行,还有一些元数据与之相关,用于标识类型、方法、属性、字段以及任何其他符号和元数据。
所有这些工件都可以通过 API 发现,一切始于System.Reflection命名空间。在这个命名空间中,有一些 API 允许你反射正在运行的内容。在其他语言中,这通常被称为内省。
观察任何类型的任何实例,你会发现总有一个可以调用的GetType()方法。这是反射能力的一部分,并在Object的基类型中实现,所有类型都隐式继承自该类型。
GetType()方法返回一个Type类型,它描述了特定类型的特性——其字段、属性、方法等。它还包含有关它可能实现或继承自的基类型的信息。这些是非常强大的结构,我们将在本章后面利用它们。
我们不妨从单个类型开始,退一步看看,从没有任何类型开始,我们如何发现我们在运行过程中的内容。
程序集
System.Reflection命名空间中的一个类型是Assembly。这是表示程序集的类型,通常是包含 IL 代码的.dll文件。在Assembly类型上,有几个静态方法。我们想要关注一个特别的方法:.GetEntryAssembly()。此方法允许我们在任何时间获取作为起始点的程序集,即.NET 运行时调用以启动我们的应用程序的入口点:
-
让我们创建一个名为Chapter4的文件夹。对于这一章,我们将创建几个项目,所以让我们先创建一个名为AssemblyDiscovery的另一个文件夹。
-
在您的命令行界面中切换到这个文件夹,并创建一个新的控制台项目:
dotnet new console -
这将设置项目所需的必要工件。打开Program.cs文件,并用以下内容替换其内容:
using System.Reflection; var assembly = Assembly.GetEntryAssembly(); Console.WriteLine(assembly.FullName)
代码正在访问Assembly上的GetEntryAssembly()静态方法以获取作为应用程序入口点的程序集。
现在运行此程序,你应该得到一个类似于以下输出的结果:
AssemblyDiscovery, Version=1.0.0.0, Culture=neutral,
PublicKeyToken=null
这只是打印出程序集的名称、版本、程序集针对的文化信息,以及如果程序集已签名,则其公钥。
从入口程序集,我们现在可以开始查看哪些程序集已被引用。将Program.cs中的代码替换为以下内容:
using System.Reflection;
var assembly = Assembly.GetEntryAssembly();
Console.WriteLine(assembly!.FullName);
var assemblies = assembly!.GetReferencedAssemblies();
var assemblyNames = string.Join(", ", assemblies.Select(_
=> _.Name));
Console.WriteLine(assemblyNames);
代码从入口程序集获取引用的程序集并打印出来。您应该看到以下类似的内容:
AssemblyDiscovery, Version=1.0.0.0, Culture=neutral,
PublicKeyToken=null
System.Runtime, System.Console, System.Linq
由于我们没有显式引用任何程序集,所以我们只看到System.Runtime、System.Consol和System.Linq作为可用的程序集。
这一切都是非常基础的,但展示了关于您的应用程序及其构建方式的推理起点。但我们可以做更多。
利用库元数据获取项目引用的程序集
如果您打算跨正在运行的过程收集元数据,那么您可能只对解决方案中的程序集感兴趣,而不是所有的.NET 框架库或第三方库。查找所有程序集以获取元数据会有性能影响,因此过滤下来可能是个好主意。
在.NET 项目中,我们可以添加包引用,通常来自NuGet或您自己的包源,但我们也可以添加本地项目引用。这些引用指向其他.csproj文件,代表我们希望将其打包到自己的程序集中的内容。在.csproj文件中,您可以通过它们的 XML 标签来识别不同的引用 –
C#编译器为所有项目产生的不同类型的引用生成额外的元数据。这可以在代码中利用。
可重用的基础知识
让我们构建一个可重用的库,我们可以在接下来的章节中利用和扩展它。让我们把这个项目命名为 Fundamentals:
-
在你开始构建章节的根目录下创建一个新的文件夹,命名为 Fundamentals。切换到这个文件夹并创建新的项目:
dotnet new classlib
这将创建一个新的项目并添加一个名为 Class1.cs 的文件。由于我们根本不需要它,所以请将其删除。
- 为了让我们能够获取到适当的元数据,使我们能够区分包或项目引用,我们需要添加一个 NuGet 包引用。
我们正在寻找的包是 Microsoft.Extensions.DependencyModel。在终端中,你可以通过以下步骤添加引用:
dotnet add package
Microsoft.Extensions.DependencyModel
- 现在我们有了这个包,我们想要创建一个能够发现项目引用和任何我们明确告诉它加载的额外包引用的系统。从这些中,我们想要获取所有类型,这样我们就可以开始对我们系统进行一些严肃的探索。
首先,创建一个名为 Types.cs 的文件。然后向其中添加以下代码:
namespace Fundamentals;
public class Types
{
}
在这个新类中,我们希望能够公开所有发现的类型。在我们能够做到这一点之前,我们需要发现它们。
-
在 Types.cs 文件的命名空间声明之前,在顶部添加以下 using 语句:
using System.Reflection; using Microsoft.Extensions.DependencyModel; -
添加一个名为 DiscoverAllTypes() 的私有方法,并首先让它看起来如下:
IEnumerable<Type> DiscoverAllTypes() { var entryAssembly = Assembly.GetEntryAssembly(); var dependencyModel = DependencyContext.Load(entryAssembly); var projectReferencedAssemblies = dependencyModel.RuntimeLibraries .Where(_ => _.Type.Equals ("project")) .Select(_ => Assembly.Load (_.Name)) .ToArray(); }
这段代码将获取入口程序集,然后利用你引用的包中的 DependencyContext。这个模型包含有关程序集(或称为在 DependencyModel 扩展中称为库)的更多元数据。我们最后要做的就是加载我们找到的程序集。大多数情况下,程序集已经加载。但这将保证它们被加载,并返回一个程序集的实例。如果它已经加载,则不会再次加载它。
这样做结果是我们在一个数组中拥有所有项目引用的程序集。但这可能不是你想要的全部。例如,如果你在维护一套在组织内部共享的通用包,并且想从这些包中查找,一个常见的模式是将组织名称作为程序集名称的一部分,通常在开头。这意味着很容易添加包含以特定字符串为前缀的所有程序集的内容。
-
将 DiscoverAllTypes() 方法的签名更改为能够包含一个用于收集用于发现的程序集前缀的集合:
IEnumerable<Type> DiscoverAllTypes(IEnumerable<string> assemblyPrefixesToInclude) { // ... leave the content of this method as before } -
在 DiscoverAllTypes() 方法中,在末尾添加以下内容:
var assemblies = dependencyModel.RuntimeLibraries .Where(_ => _.RuntimeAssemblyGroups.Count > 0 && assemblyPrefixesToInclude.Any(asm => _.Name.StartsWith(asm))) .Select(_ => { try { return Assembly.Load (_.Name); } catch { return null!; } }) .Where(_ => _ is not null) .Distinct() .ToList();
代码检查相同的 RuntimeLibraries,但这次是针对以特定前缀开始的程序集。这里也有错误处理功能:一些程序集无法使用常规的 Assembly.Load() 方法加载,所以我们只是忽略它们,因为它们对我们没有兴趣。
由于我们在 LINQ 查询的末尾调用了.ToList(),我们可以轻松地将两个程序集集合和所有加载的程序集中的所有类型组合起来,并从DiscoverAllTypes()方法返回它们。在DiscoverAllTypes()方法的末尾添加以下内容:
assemblies.AddRange(projectReferencedAssemblies);
return assemblies.SelectMany(_ => _.GetTypes())
.ToArray();
-
现在我们已经收集了所有程序集及其类型并返回了它们,现在是时候将它们暴露给外部世界了。我们通过添加一个返回IEnumerable类型Type的公共属性来实现这一点。我们不希望它从外部被潜在地修改:
public IEnumerable<Type> All { get; } -
Types类的最后一部分是添加一个构造函数。我们希望构造函数能够接受DiscoverAllTypes()方法的任何程序集前缀:
public Types(params string[] assemblyPrefixesToInclude) { All = DiscoverAllTypes(assemblyPrefixesToInclude); }
我们现在封装了一个类型注册表,我们可以利用它来处理其他用例。现在我们有一些基本的构建块。让我们继续创建一个多项目应用程序。
商业应用程序
现在我们已经有一些基本的构建块。让我们继续创建一个多项目应用程序。
在Chapter4文件夹中,创建一个名为BusinessApp的文件夹。在这个文件夹中,我们将创建多个项目,这些项目将构成应用程序。在BusinessApp文件夹中,创建一个名为Domain的文件夹和一个名为Main的文件夹。Domain文件夹将代表我们应用程序的领域逻辑,应该是一个类库项目。在Domain文件夹中创建一个新的.NET 项目:
dotnet new classlib
删除生成的Class1.cs文件。然后,在Main文件夹中,我们想要创建一个控制台应用程序:
dotnet new console
在Main项目中,你现在应该添加对Fundamentals项目和Domain项目的引用:
dotnet add reference ../../../Fundamentals/Fundamentals.csproj
dotnet add reference ../Domain/Domain.csproj
在Program.cs文件中,在Main项目中,将内容替换为以下内容:
using Fundamentals;
var types = new Types();
var typeNames = string.Join("\n", types.All.Select(_ =>
_.Name));
Console.WriteLine(typeNames);
这段代码现在利用了我们放入Fundamentals中的Types类,该类将查看所有项目引用程序集,并给我们所有类型。运行此代码应该会得到类似以下输出:
Program
<>c
EmbeddedAttribute
NullableAttribute
NullableContextAttribute
EmbeddedAttribute
NullableAttribute
NullableContextAttribute
Types
你会注意到其中一些类型根本不是你创建的。这些是 C#编译器放入项目的类型。
前往Domain项目,在其中添加一个名为Employees的文件夹,并在其中创建一个名为RegisterEmployee.cs的文件,并添加以下内容:
namespace Domain.Employees;
public class RegisterEmployee
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string SocialSecurityNumber { get; set; } =
string.Empty;
}
再次运行Main项目后,你现在应该看到添加的类型:
Program
<>c
EmbeddedAttribute
NullableAttribute
NullableContextAttribute
RegisterEmployee <-- New
EmbeddedAttribute
NullableAttribute
NullableContextAttribute
Types
这样,我们就为使用类型打下了坚实的基础。我们只需要提高其功能,使其更加有用。
类型发现
通过Types类,我们现在有一种原始的方法来获取运行系统中的所有类型。这本身就可以非常有用,因为你现在可以使用它来执行语言集成查询(LINQ)查询,以找到符合你感兴趣的具体标准的类型。
在代码中,一个非常常见的场景是我们有基类或接口,它们代表了我们系统中已知工件类型的特征,并且我们创建了专门的版本来覆盖虚拟或抽象方法,或者只是实现一个特定的接口来表示它是什么。这是面向对象编程语言的力量。
通过这种方式,我们向我们的系统中添加了额外的隐式元数据,我们可以利用这些数据。例如,在 第三章,通过现有真实世界示例去神秘化 中,我们探讨了 ASP.NET 是如何通过发现所有以 Controller 为基类的类来实现这一点的。
根据我的经验,这是我在进行这些项目时使用最多的典型模式。事实上,它如此普遍,以至于优化类型查找以避免基于继承查找时的复杂性是一个好主意。优化的查找将显著提高性能。
完整地解释完整的查找缓存机制可能会有些冗长,因为其中涉及许多动态部分。你可以在以下链接中找到完整的代码:github.com/PacktPublishing/Metaprogramming-in-C-Sharp/blob/main/Fundamentals/ContractToImplementorsMap.cs.
ContractToImplementorsMap 所表示的接口如下:
public interface IContractToImplementorsMap
{
IDictionary<Type, IEnumerable<Type>>
ContractsAndImplementors { get; }
IEnumerable<Type> All { get; }
void Feed(IEnumerable<Type> types);
IEnumerable<Type> GetImplementorsFor<T>();
IEnumerable<Type> GetImplementorsFor(Type contract);
}
IContractToImplementorsMap API 的目的是提供一个快速获取特定类型实现的方法,无论是基类还是接口。IContractToImplementorsMap 的实现会接收所有传入的类型,并将这些类型正确映射到这个缓存中。
你会注意到有一个名为 Feed() 的方法。我们将在我们的 Types 类中调用这个方法。此外,我们还想有一些方法,使发现不同类型变得更加有用;例如,以下方法将很有帮助:
public Type FindSingle<T>();
public IEnumerable<Type> FindMultiple<T>();
public Type FindSingle(Type type);
public IEnumerable<Type> FindMultiple(Type type);
public Type FindTypeByFullName(string fullName);
这些方法允许我们找到实现特定接口或继承自基类型的方法。它们允许我们根据类型(通过泛型参数或通过传递 Type 对象)找到单个实例或多个实现者。为了方便起见,还有一个通过名称查找类型的方法。
你可以在本章开头指定的链接中找到 Fundamentals 文件夹中的完整实现。
回到正题
回到我们的业务示例,让我们在 Domain 项目中的 Employee 文件夹内添加一个名为 SetSalaryLevelForEmployee.cs 的第二个类。让它看起来如下:
namespace Domain.Employees;
public class SetSalaryLevelForEmployee
{
public string SocialSecurityNumber { get; set; } =
string.Empty;
public decimal SalaryLevel { get; set; }
}
注册员工和为员工设置薪资级别类都代表了我们在领域业务逻辑中需要执行特定操作的数据。这类操作通常被称为命令。如果我们想发现所有的命令,我们可以创建一个空接口,我们可以使用这个接口让所有命令实现,这样我们就可以轻松地发现它们。这种以这种方式使用的空接口通常被称为标记接口。
在基础项目中,添加一个名为ICommand.cs的文件,并使其看起来如下:
namespace Fundamentals;
public interface ICommand
{
}
现在,我们需要从域项目到基础项目的项目引用。从域项目运行以下命令:
dotnet add reference ../../../Fundamentals/
Fundamentals.csproj
使用新的ICommand接口,我们可以用它标记注册员工和为员工设置薪资级别命令。打开注册员工和为员工设置薪资级别的文件,并在每个文件的顶部添加以下using语句:
using Fundamentals;
对于这两个类定义,在末尾添加: ICommand,使它们实现ICommand接口。
打开Main项目中的Program.cs文件,并使用以下内容进行更改:
using Fundamentals;
var types = new Types();
var commands = types.FindMultiple<ICommand>();
var typeNames = string.Join("\n", commands.Select(_ => _.Name));
Console.WriteLine(typeNames);
运行Main项目现在应该得到以下结果:
SetSalaryLevelForEmployee
RegisterEmployee
有效地,我们现在已经复制了必要的基础设施来模仿 ASP.NET 对项目发现的操作。有了这个,我们使我们的软件更容易扩展。
领域概念
在大多数编程语言中最无聊的类型是语言提供的原始数据;通常,你的整数、布尔值、字符串等。它们提供绝对没有有趣或有意义的元数据。它们只是非常原始的。
很容易陷入使用这些数据的陷阱,最终导致对原始数据的执着。这不仅会使你失去宝贵的元数据,而且有时还会创建出不清楚的代码,甚至可能因为两个相同原始类型的属性可以互换而容易出错。
对于应用程序,有一个很好的机会通过将原始数据封装到有意义的数据类型中,从而从原始数据中脱离出来,给你的领域带来意义。
这种美在于,你不仅会使你的代码更易于阅读,而且更易于理解,并且减少歧义。这样做可以使你从对原始数据的执着转变为一个地方,在那里如果你做错了什么,你会得到编译器错误,从而让开发者从一开始就站在正确的位置。除此之外,你还会带来大量的元数据,这些数据可以被利用。
原始类型的关键特征是它们是值类型。这意味着你可以有两个相同的值实例,并且相等性检查将表明它们是相同的。在C# 9.0中,我们得到了一个新的结构体record。这使我们能够创建具有复杂类型但具有与值类型相同特性的类型。比较具有相同值的两个复杂record类型将被视为相等。
让我们介绍一种可用于概念的基本类型。在基础项目中,添加一个名为ConceptAs.cs的文件,并使其看起来如下所示:
namespace Fundamentals;
public record ConceptAs<T>
{
public ConceptAs(T value)
{
ArgumentNullException.ThrowIfNull(value,
nameof(value));
Value = value;
}
public T Value { get; init; }
public static implicit operator T(ConceptAs<T> value)
=> value.Value;
}
此实现为你提供了一种封装领域概念的方法。它是使用泛型构建的,允许你指定概念所表示的值的内部类型。它对封装内不允许 null 值执行严格的检查。如果你想在代码中允许 null 值,它不应在概念内,而应在概念的实例上。此外,实现提供了一个便利操作符,用于自动将概念转换为原始类型。
通常,当你与数据库一起工作或在不同格式之间传输数据时,你希望去除概念包装,只获取原始类型。然后,具有公共基类型的信息对于这些序列化器可以与之一起工作的类型信息来说是一份极好的信息。
在本章开头提到的基础链接中,你可以找到一个如何为System.Text.Json创建JsonConverter的示例,该转换器将在序列化期间自动将概念实现的任何实例转换为底层值,并在反序列化时将它们转换回概念类型。在基础链接的同一位置,你可以看到ConceptAsJsonConverterFactory实现,它根据ConceptAs基类型识别类型是否可以转换,并创建正确的转换器来序列化和反序列化概念。
使用ConceptAs结构体,我们现在可以创建特定领域的实现。正如你将在本章代码(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter4/BusinessApp)中找到的那样,你可以创建特定的概念。例如,对于之前创建的RegisterEmployee命令,你不仅可以使用原始类型,还可以创建以下特定类型:
public record FirstName(string Value) :
ConceptAs<string>(Value);
public record LastName(string Value) :
ConceptAs<string>(Value);
public record SocialSecurityName(string Value) :
ConceptAs<string>(Value);
然后,你可以将之前的RegisterEmployee修改为以下内容:
namespace Domain.Employees;
public class RegisterEmployee
{
public FirstName FirstName { get; set; } =
new(string.Empty)
public LastName LastName { get; set; } = new(string.Empty);
public SocialSecurityNumber SocialSecurityNumber { get;
set; } = new(string.Empty);
}
这将消除代码中可能出现的任何潜在错误。你不可能意外地将FirstName放入LastName或反之亦然,因为编译器会告诉你它们不是同一类型。
横切关注点
在有了类似概念的基础上,我们真正开始丰富我们的应用程序代码,使其包含有意义的元数据。这创造了新的机会。如前所述,对于序列化,由于我们有了所有事物都使用的基类型,我们可以轻松地在单一位置处理概念类型的序列化。这就是我们谈论横切关注点时所指的:创建一次即可应用于多个事物的结构或行为,自动完成。
这些事情的可能性是无限的。我们可以自动创建在类型被使用时应用的验证规则。或者,我们可以在使用时根据类型应用授权策略。对于命令模式和强大的基于管道的框架如 ASP.NET,这意味着我们可以继续创建可以注入处理这些事物的管道中的操作过滤器或中间件——仅仅因为我们现在有了所需的元数据。
谈到安全,这是你真正想要做对的一件事。不要误解我们,我们旨在把我们所做的一切都做好——但安全是你绝对不希望马虎对待的事情。类型系统的丰富性和与之相关的所有元数据,让你有机会让开发者更容易做正确的事情。
例如,可以做的事情之一是创建基于命名空间的授权策略。如果你有一个进入的命令属于需要用户特定角色或声明的命名空间,你可以在一个地方进行这个检查,并且按照惯例,让开发者将命令或其他工件放入正确的位置,它们就会是安全的。
合规性是另一个你真正想要做对的地方。如果你的软件不合规,可能会非常昂贵。在过去的几年中,最被谈论的合规法律可能是欧盟的法规GDPR。如果你不遵守这项规定,你可能会被罚款。
GDPR 的整个想法是为了保护计算机系统最终用户的隐私。许多系统收集被称为个人身份信息,简称PII的数据。诸如你的姓名、地址、出生日期、社会保障号码等许多信息都被归类为 PII。还有一项要求是透明度,让最终用户知道你拥有他们的哪些数据,以及收集数据的原因。如果你的公司接受审计,你必须也在报告中展示你正在收集的数据类型。
在你刚刚学到的概念的基础上,我们可以更进一步,创建基础 ConceptAs<> 类型的专用版本。让我们称它为 PIIConceptAs<>:
namespace Fundamentals;
public record PIIConceptAs<T>(T Value) : ConceptAs<T>
(Value)
{
}
正如你所见,它继承自 ConceptAs<>,这给了我们一次机会,可以围绕这个基础类型一次性创建序列化器和其他工具。但它添加了元数据,说明这是一个专门用于 PII 的概念。
在应用程序的运行时,你可以相当容易地向用户展示所有这些信息,以及任何审计机构或执法机构。
以我们之前创建的SocialSecurityNumber类型为例。将其更改为PIIConceptAs<>:
public record SocialSecurityName(string Value) :
PIIConceptAs<string>(Value);
如您从代码中看到的,要丰富它以利用元数据并不需要太多。而且,在章节开头构建的强大的类型发现功能,你现在可以轻松地创建一个简单的控制台报告,包含所有这些信息:
Console.WriteLine("GDPR Report");
var typesWithConcepts = types.All
.SelectMany(_ =>
_.GetProperties()
.Where(p =>
p.PropertyType
.IsPIIConcept()))
.GroupBy(_ => _.DeclaringType);
foreach (var typeWithConcepts in typesWithConcepts)
{
Console.WriteLine($"Type: {typeWithConcepts
.Key!.FullName}");
foreach (var property in typeWithConcepts)
{
Console.WriteLine($" Property : {property.Name}");
}
}
代码首先执行的操作是使用 LINQ 查询收集系统中所有PIIConceptAs<>类型的所有属性。如您所见,它使用了一个扩展方法,这是基础部分的一部分(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Fundamentals)。由于它选择了所有具有.SelectMany()的属性,所以我们根据声明类型将其分组。然后它只是遍历所有类型和所有属性,并打印出信息。
它应该产生以下结果:
GDPR Report
Type: Domain.Employees.RegisterEmployee
Property : FirstName
Property : LastName
Property : SocialSecurityNumber
仅用少量代码,我们就从没有额外的元数据转变为一个更丰富的模型,这为我们提供了在代码中应用逻辑的机会。实际上,有了它,我们在一定程度上为代码提供了未来保障。我们可以在以后回到它,并决定我们想要更改安全性。实际上,我们不需要更改任何实际代码,只需根据我们已有的元数据应用一条新规则。这很强大,并使系统变得灵活、可扩展和高度可维护。
应用开放/封闭原则
开放/封闭原则是伯特兰·梅耶在其 1988 年出版的《面向对象软件构造》一书中提出的原则。这是一个关于我们可以应用于我们软件中的类型的原理:
-
如果一个类型可以被扩展,则称其为开放
-
当一个类型对其他类型可用时,它被称为封闭
C#中的类默认是开放的,可以扩展。我们可以从它们继承并给它们添加新的含义。但是,我们正在继承的基类应该是封闭的,这意味着对于新类型来说,不应该需要更改基类型。
这有助于我们为代码的可扩展性设计,并保持责任在正确的位置。
退一步来说,我们可以在系统级别应用一些相同的思考。如果我们的系统可以仅通过扩展新功能来扩展,而无需在类型的中心添加配置以了解这些添加,那会怎样?
这种思维方式就是我们之前在ICommand和实现中做的事情。我们添加的两个命令没有被系统的任何部分所知,但通过实现ICommand接口,我们可以通过系统的内省看到这两种类型。
摘要
在本章中,我们学习了如何通过查看我们正在运行的过程并收集所有引用的程序集及其类型来发挥其力量。我们探讨了如何利用更多的元数据来获取程序集引用的类型,无论是包引用还是项目引用。
通过这一点,我们得以以更有意义的方式对类型进行推理,并真正利用类型系统。接口可以作为标记类型的非常强大的方法。当然,接口可以强制实现需要存在的成员,但它们也可以仅仅作为空标记接口,作为将明确元数据引入程序集的方式。
在下一章中,我们将深入探讨如何充分利用自定义属性为您的应用程序提供明确的元数据。
第五章:利用属性
我们简要介绍了 C# 属性的概念,见第二章,元编程概念。它们是向源代码添加显式元数据的明显选择。这正是它们的目的。属性不应携带复杂的逻辑,而应被视为仅是元数据。
在本章中,我们将探讨如何在代码库中利用它们,提供为类型和成员添加有价值、丰富信息的机制,这些信息可用于不同的场景。
我们将涵盖以下主题:
-
属性是什么,以及如何应用它?
-
查找具有特定属性的类型
-
泛型属性
从本章中,您应该了解属性作为元编程构建块的力量,如何创建自己的自定义属性,以及您如何发现它们的使用。
技术要求
该章节的特定源代码可以在 GitHub 上找到(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter5),并且它建立在基础代码之上,该代码位于github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Fundamentals。
属性是什么,以及如何应用它?
属性是 C# 编译器理解的特殊类型。它可以用来将元数据关联到程序集、类型以及类型的任何成员。在编译期间,编译器将拾取属性并将它们作为元数据添加到编译的程序集中。您可以在每个项目上放置多个属性。
创建自己的自定义属性就像这样:
public class CustomAttribute : Attribute
{
}
然后使用它的方法如下:
[Custom]
public class MyClass
{
}
注意,您在属性名称中使用Attribute后缀。在使用它时,您不需要它,您只需要[Custom]。C# 编译器内置了一个约定,即您必须使用后缀,但在使用时它将忽略它。这有点奇怪,并且肯定违反了最小惊讶原则。
属性的优点在于它们存在于元素本身的范围之外,这意味着您不需要创建具有元数据的类型的实例来访问元数据。
属性可以接受参数以提供您想要捕获的特定信息。然而,所有参数必须在编译时可用。这意味着您不能为任何参数动态创建新对象。
例如,我们可以通过获取另一个类型的实例来向属性添加参数:
public class CustomAttribute : Attribute
{
public CustomAttribute(SomeType instance)
{
}
}
编译器将允许属性接受类型——毕竟,它是一个有效的 C# 类型。然而,当您尝试使用它时,不允许您创建新实例:
[Custom(new SomeType())] // Will give you a compiler error
public class MyClass
{
}
这是因为编译器需要在编译时获取这些值。尽管属性最终是在运行时实例化的,但捕获并添加到编译后的汇编中的信息永远不会被执行。这意味着你只能限于编译器可以解析的事物,例如原始类型(例如,int、float 和 double)以及诸如字符串之类的对象——任何可以表示为常量且不需要由运行时创建以工作的事物。
一个有效的参数可以是字符串:
public class CustomAttribute : Attribute
{
public CustomAttribute(string information)
{
}
}
现在构造函数接受一个字符串,它不仅会在编译时工作,也会在运行时工作。
由于字符串可以是字面常量,你可以使用它们:
[Custom("I'm a valid parameter")]
public class MyClass
{
}
已经,你可以看到属性的力量——能够拥有附加信息,你的代码可以据此进行推理,你可以用它来做决策,甚至用于报告。
限制属性使用
对于属性,你还可以向它们添加元数据,这有点像“自我包含”;元数据的元数据。你添加的元数据是为了限制属性的使用范围。
你可以对代码中的哪些元素使用属性(类、属性、字段等)非常具体。
[AttributeUsage] 属性允许你具体指定属性。假设你只想将使用限制为类——你可以这样做:
[AttributeUsage(AttributeTargets.Class)]
public class CustomAttribute : Attribute
{
}
如果你尝试将属性添加到除类之外的其他事物上,你将得到一个编译器错误:
public class MyClass
{
[Custom] // Compiler error
public void SomeMethod()
{
}
}
[AttributeUsage] 类型是一个枚举,包含支持不同代码元素属性的不同值。枚举中的每个值都代表一个标志,这使得它们可以组合起来并针对多个代码元素类型。
让我们通过应用具有这些指定的自定义属性 [AttributeUsage] 属性来限制代码元素为 Class、Method 和 Property:
[AttributeUsage(
AttributeTargets.Class |
AttributeTargets.Method |
AttributeTargets.Property)]
public class CustomAttribute : Attribute
{
}
正如你所见,使用位运算符 OR 构造(|)你可以添加所有你想要支持的元素。
关于 [AttributeUsage] 的一个有趣的事实是,它使用自身来告诉编译器它只能用于类。再次,那里有点“自我包含”;[AttributeUsage] 属性正在使用 [AttributeUsage] 来提供关于自身的元数据。
除了限制属性可以关联的代码元素之外,你还可以指定是否允许应用属性的多个实例。你还可以指定是否允许属性作为具有该属性的应用类型的元数据。
[AttributeUsage] 属性在其构造函数中实际上只接受一个参数。这意味着我们必须显式地使用其属性。
默认情况下,属性仅限于每个代码元素类型仅关联一次。尝试多次关联属性将会导致编译器错误:
[Custom]
[Custom] // Compiler error
public class MyClass
{
}
通过简单地使用AllowMultiple属性,您可以改变这种行为:
[AttributeUsage(AttributeTargets.Class, AllowMultiple =
true)]
public class CustomAttribute : Attribute
{
}
现在将允许编译相同的代码。
您还可以使用Inherited属性来限制属性的用法。将此属性设置为false将告诉编译器,相关的属性仅与显式使用的特定类型相关联,而不是与派生类型相关联:
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class CustomAttribute : Attribute
{
}
如您之前所见,您可以通过常规方式将属性添加到类中:
[Custom]
public class MyClass
{
}
您可以添加一个继承自具有[Custom]属性的类型的类:
public class MyOtherClass : MyClass
{
}
当Inherited属性设置为false时,与MyClass基类型关联的元数据将不会与MyOtherClass关联。默认情况下,这是开启的,这意味着派生类型将具有与之关联的相同元数据。
要创建一个属性,您只需要从Attribute继承并应用[AttributeUsage]。然而,您可能希望通过不允许从您的属性继承来为您的元数据带来更多的清晰性和明确性。密封您的类将阻止任何人从您的自定义属性继承。
封闭您的属性类
由于属性代表特定的元数据,它们不是用于存储逻辑的常规代码结构。因此,您会发现您不需要创建其他更具体属性继承的基属性。实际上,如果您使用继承作为元数据,可能会使元数据变得不清晰,并且您会引入隐含性。
由于这个原因,被认为是一个好的实践,不允许属性的继承,并在编译器级别通过使属性类密封来停止它:
public sealed class CustomAttribute : Attribute
{
}
如果您尝试创建一个继承自它的更具体属性,您将得到编译器错误。
现在我们已经涵盖了创建自定义属性及其如何针对您的特定用例进行定制所涉及的所有机制,您可能已经迫不及待地想要开始实际发现它们并将它们用于良好的用途。
查找具有特定属性的类型
由于属性是在编译时创建的,并且不需要您有一个与属性关联的类型实例,您可以使用类型系统来发现属性。
如果您查看System.Type类型,您会发现它实现了一个名为MemberInfo的类型,该类型位于System.Reflection命名空间中。这个基类作为PropertyInfo、MethodInfo、FieldInfo以及大多数代表我们可以通过类型系统发现的代码元素的特定信息类型的基类。
在MemberInfo类型上,您会发现一个名为GetCustomAttributes()的方法。这允许您获取与特定代码元素关联的属性集合。
考虑我们之前拥有的类:
[Custom]
public class MyClass
{
}
然后,您可以非常容易地获取类型上的自定义属性,遍历它们并执行您想要的操作:
foreach( var attr in typeof(MyClass).GetCustomAttributes() )
{
// Do something based on the attribute
}
使用 typeof() 非常明确,并且可以仅用于此类型。对于更动态的解决方案,你可以发现具有特定属性的类型,你可以利用这些属性来完成我们在 第四章,[“使用反射进行类型推理”] 中所做的 “类型推理” 工作。
个人可识别信息 (PII)
让我们回到之前章节中提到的 GDPR 主题。在 第四章,[“使用反射进行类型推理”] 中,我们使用类型来发现哪些是个人可识别信息。另一种方法可能是使用自定义属性作为显式的元数据方法。通过属性,我们可以关联比我们在 第四章,[“使用反射进行类型推理”] 中使用的基本类型更多的信息。你可以添加关于收集数据原因的元数据。
你可以使用以下属性来捕获它:
[AttributeUsage(AttributeTargets.Class |
AttributeTargets.Property | AttributeTargets.Parameter,
AllowMultiple = false, Inherited = true)]
public class PersonalIdentifiableInformationAttribute :
Attribute
{
public PersonalIdentifiableInformationAttribute(string
reasonForCollecting = "")
{
ReasonForCollecting = reasonForCollecting;
}
public string ReasonForCollecting { get; }
}
代码创建了一个可以应用于类、属性和参数的属性。它不允许将多个实例应用于将要应用到的代码元素。它允许元数据对任何继承自应用了此元数据的类型或其成员的类型都可用。关于 GDPR,有趣的是了解系统收集特定数据的原因——因此,属性作为可选元数据具有这个功能。
重要提示
你可以在 GitHub 仓库中的 Fundamentals 项目中找到这个实现。
首先创建一个名为 第五章 的文件夹。在命令行界面中切换到该文件夹并创建一个新的控制台项目:
dotnet new console
接下来你需要做的是引用 Fundamentals 项目。如果你将项目放在 Chapter5 文件夹旁边,请执行以下操作:
dotnet add reference ../Fundamentals/Fundamentals.csproj
在此基础上,假设你想要创建一个封装员工的对象:
public class Employee
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string SocialSecurityNumber { get; set; } =
string.Empty;
}
此类型显然持有对个人可识别的属性;让我们为其成员添加适当的元数据:
using Fundamentals.Compliance.GDPR;
public class Employee
{
[PersonalIdentifiableInformation("Employment records")]
public string FirstName { get; set; } = string.Empty;
[PersonalIdentifiableInformation("Employment records")]
public string LastName { get; set; } = string.Empty;
[PersonalIdentifiableInformation("Uniquely identifies
the employee")]
public string SocialSecurityNumber { get; set; } =
string.Empty;
}
现在,我们有了足够的信息来发现系统中任何持有此类信息的类型。
基于 第四章 中引入的汇编和类型发现系统,“使用反射进行类型推理”,我们可以专门查询这个。
由于类型上的每个成员都继承自 System.Reflection 命名空间中找到的 MemberInfo 类型,我们可以轻松创建一个便利的扩展方法,允许我们检查成员是否具有与其关联的特定属性。
然后,你可以创建一个简单的扩展方法,允许你检查属性是否与成员关联:
public static class MemberInfoExtensions
{
public static bool HasAttribute<TAttribute>(this
MemberInfo memberInfo) where TAttribute : Attribute
=> memberInfo.GetCustomAttributes<TAttribute>()
.Any();
}
重要提示
你可以在 GitHub 仓库中的 Fundamentals 项目中找到这个实现。
在此基础上,你可以发现所有包含此类信息的类型:
using Fundamentals;
var types = new Types();
var piiTypes = types.All.Where(_ => _
.GetMembers()
.Any(m => m.HasAttribute<Personal
IdentifiableInformation
Attribute>()));
var typeNames = string.Join("\n", piiTypes.Select(_ =>
_.FullName));
Console.WriteLine(typeNames);
HasAttribute<> 扩展方法是一个强大的小助手,你会在所有想要根据属性进行简单类型元数据查询的场景中找到它。
要创建包含收集信息原因的 GDPR 报告,将 Program.cs 文件修改如下:
using System.Reflection;
using Fundamentals;
var types = new Types();
Console.WriteLine("\n\nGDPR Report");
var typesWithPII = types.All
.SelectMany(_ =>
_.GetProperties()
.Where(p => p.HasAttribute
<PersonalIdentifiable
InformationAttribute>()))
.GroupBy(_ => _.DeclaringType);
foreach (var typeWithPII in typesWithPII)
{
Console.WriteLine($"Type: {typeWithPII.Key!
.FullName}");
foreach (var property in typeWithPII)
{
var pii = property.GetCustomAttribute<
PersonalIdentifiableInformationAttribute>();
Console.WriteLine($" Property : {property.Name}");
Console.WriteLine($" Reason :
{pii.ReasonForCollecting}");
}
}
代码利用了在 第四章 中引入的类型发现,使用反射进行类型推理,并使用 LINQ 扩展方法选择所有应用了 [PersonalIdentifiableInformationAttribute] 属性的类型。然后按类型分组,这样你可以轻松地遍历并按类型展示具有该属性的成员。
运行此代码将产生以下结果:
GDPR Report
Type: Main.Employee
Property : FirstName
Reason : Employment records
Property : LastName
Reason : Employment records
Property : SocialSecurityNumber
Reason : Uniquely identifies the employee
这种类型的元数据对商业来说非常有价值。如果你的业务收到政府关于 GDPR 审计的查询,并且你的代码完全加载了元数据,你可以轻松地创建一份关于你收集的数据类型及其收集原因的报告。
你也可以将此类信息展示给系统最终用户。了解系统收集关于他们的信息对用户来说非常有价值。这建立了系统与用户之间的信任关系。
GDPR 是将非常有用的元数据引入代码库的一个非常好的用例,但它只是众多用例之一。当然,你可以以更可操作的方式利用元数据,而不仅仅是用于报告。
泛型属性
我们之前使用的 C# 属性的一个限制是属性不能是接受泛型参数的泛型类型。在 C# 11 之前,如果你在你的属性类中添加了泛型参数,你会得到编译器错误。这种限制随着 C# 11 的发布而被解除。
一直到 C# 11,你收集类型信息的方式只有当属性有参数或属性类型为 System.Type 时。这变得非常冗长:
public class CustomAttribute : Attribute
{
public CustomAttribute(Type theType)
}
然后用属性装饰类型的方式如下:
[Custom(typeof(string))]
public class MyClass
{
}
使用 C# 11,现在你可以改进获取类型信息的方式:
public class CustomAttribute<T> : Attribute
{
}
当你用属性装饰类型时,你使用泛型参数:
[Custom<string>]
public class MyClass
{
}
如果你需要一个动态类型的参数,你可以这样做:
[AttributeUsage(AttributeTargets.Class, AllowMultiple =
true)]
public class CustomAttribute<T> : Attribute
{
public CustomAttribute(T someParameter)
{
SomeParameter = someParameter;
}
public T SomeParameter { get; }
}
代码定义了一个接受泛型参数的属性,然后要求属性有一个参数,该参数将是泛型类型。然后它使用相同的泛型类型在属性上公开元数据作为属性。
当用属性装饰类型时,你指定类型和参数,因为属性必须是指定类型:
[Custom<int>(42)]
[Custom<string>("Forty two)]
public class MyClass
{
}
重要提示
通常,C# 编译器非常擅长根据传入的类型推断泛型参数的类型。但使用泛型属性时,你必须每次都显式地给出泛型类型。
泛型属性可以是一种强大的元数据收集方法。它增加了你构建元数据的方式的灵活性。
摘要
在本章中,我们探讨了 C#属性是什么以及它们在描述代码中的显式元数据方面的强大功能。我们研究了如何创建自己的自定义属性并将它们应用到不同的代码元素上的一切机制。从这种类型的元数据中,你现在可以丰富你的代码。
通过本章探讨的丰富内容,你看到了如何非常容易地发现这种元数据并将其用于你的业务。
在下一章中,我们将进一步深入探讨.NET 运行时的功能,并查看你如何根据元数据动态生成代码,这样可以使你在作为开发者进行这项工作时更加高效。
第六章:动态代理生成
在前面的章节中,我们探讨了拥有.NET 运行时提供的强大元数据类型是多么强大,结合创建自己的元数据、分析它的能力,以及将其转换为有用的信息或根据它采取行动的能力。现在,我们将进一步探索,让代码根据元数据生成新的代码。
在本章中,我们将探讨如何利用你的代码在托管运行时环境中运行的事实,以及如何在你代码编译后创建新的代码。
我们将涵盖以下主题:
-
IL 和 Reflection.Emit 简介
-
创建动态程序集和模块
-
虚拟成员和重写
-
实现接口
完成本章后,你应该理解.NET 运行时的强大功能以及如何将元数据转换为新的代码,从而提高你的工作效率。
技术要求
本章的特定源代码可以在 GitHub 上找到(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter6)。
IL 和 Reflection.Emit 简介
在第二章,元编程概念中,我们提到了 C#编译器将你的代码转换成什么。IL,即中间语言,是.NET 运行时理解的指令表示,并将其转换为针对你的代码运行的 CPU 的 CPU 指令。
由于.NET 运行时以这种方式动态地在你代码上运行,这意味着可以得出结论,你应该能够在程序执行时生成代码。幸运的是,情况确实如此。.NET API 包括一个专门用于生成代码的命名空间——System.Reflection.Emit。
使用Emit API,你可以从头开始创建任何你想要的构造,引入不存在于任何源代码中的新类型,或者创建从其他类型继承并添加新功能的新类型。
对于所有不同类型的工件,你可以创建类、方法、属性等。存在特定的构建器类型——TypeBuilder用于类和MethodBuilder用于方法。属性也被视为方法,并且基于一个前缀具有get_或set_名称的约定,分别代表get或set方法。
使用构建器,你可以调用一个名为.GetILGenerator()的方法。这个方法将返回一个名为ILGenerator的类型。ILGenerator方法就是所有魔法发生的地方。这是你可以用来生成实际代码的类型。你主要使用的方法是.Emit()方法。.Emit()方法有几个重载,是用于添加构成你程序的指令的方法。指令被称为操作码,有一个包含所有允许指令或操作码的类,称为OpCodes。
所有不同的操作码都定义良好且文档齐全,你可以在微软的文档页面上找到所有这些文档的链接(learn.microsoft.com/en-us/dotnet/api/system.reflection.emit.opcodes?view=net-7.0#fields)。
尽管它们定义良好且文档齐全,但要正确地准备指令顺序可能会很困难且令人畏惧。因此,从实际代码中推导指令是一个好主意。这样做的一个很好的资源是使用像Sharplab(sharplab.io)这样的工具。使用 Sharplab,你可以查看执行常规 C#代码所需的指令,以便能够重现它。
为了达到生成 IL 代码的实际阶段,你需要跳过几个额外的步骤。
创建动态组件和模块
当你的代码经过编译并输出为可运行的二进制文件时。这段代码被认为是静态的,不能被修改。表示为组件的二进制文件是完全静态的;你不仅不能修改其中的代码,而且也不能向其中添加内容。如果任意代码可以修改运行中的代码,这将是一个安全风险。
为了克服这一点,你必须显式地创建一个新的组件,它只存在于内存中。这被称为动态组件。
所有组件也都有模块的概念。一个组件至少必须有一个模块。模块是一个容器,它包含具体的 IL 代码及其相关的元数据,而组件是一个更高层次的抽象容器,包含更多的元数据,实际上可以引用多个.dll文件。通常,你只会看到组件和模块之间的一对一关系。
开始这个过程非常简单:
using System.Reflection;
using System.Reflection.Emit;
var assemblyName = new AssemblyName("MyDynamicAssembly");
var dynamicAssembly = AssemblyBuilder.Define
DynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
var dynamicModule = dynamicAssembly.DefineDynamicModule
("MyDynamicModule");
代码定义了所需的两个容器——首先,.DefineDynamicAssembly()方法创建动态组件,并告诉它提供一个你将用于运行代码的组件。一旦你有了动态组件,你调用.DefineDynamicModule()来获取你将生成实际运行代码的容器。
你应该考虑的一件事是动态程序集和动态模块的名称。程序集名称需要在运行过程中是唯一的,并且在程序集内部,每个模块也需要一个唯一的名称。因此,如果你打算创建多个动态程序集以及它们内部的多个模块,你需要保证名称的唯一性。
做这件事最简单的方法是利用Guid并将其混合到你的名字中。以下代码将给出一个唯一的名称:
static string CreateUniqueName(string prefix)
{
var uid = Guid.NewGuid().ToString();
uid = uid.Replace('-', '_');
return $"{prefix}{uid}";
}
代码生成一个新的Guid并将其与一个前缀组合。前缀的目的是能够识别具有友好名称的不同程序集。在程序集名称中可以使用的字符有一些限制;这就是为什么你会看到-被替换为_。
如果你的代码只需要一个动态程序集以及它内部的动态模块,那么创建唯一名称的需求可能不是必需的,因为你可以相当容易地给它一个唯一的名称。
你可能甚至不需要有多个动态程序集,以及很可能不需要在动态程序集内部有多个模块。拥有一个全局动态程序集是完全可行的。这完全取决于你的代码以及你是否会为不同的目的生成具有相同名称的类型,这些类型应该被分组到特定的程序集/模块对容器中。
在设置了动态程序集和动态模块之后,我们可以开始生成一些代码。
让我们动态创建一个没有源代码的简单类型,它可以打印出一条消息。如果我们用 C#编写,目标类型看起来可能如下所示:
public class MyType
{
public void SaySomething(string message)
{
System.Console.WriteLine(message);
}
}
如果我们将此代码放入 Sharplab(sharplab.io),我们可以看到其背后的 IL 代码,并将其作为我们想要实现的模板:

图 6.1 – IL 代码
-
首先创建一个名为Chapter6的文件夹。在命令行界面中打开此文件夹,并创建一个新的控制台项目:
dotnet new console
添加一个名为MyTypeGenerator.cs的文件。首先让文件看起来像以下这样:
using System.Reflection;
using System.Reflection.Emit;
namespace Chapter6;
public class MyTypeGenerator
{
public static Type Generate()
{
// Do the generation
}
}
- 如你所见,我们引入了两个命名空间——System.Reflection和System.Reflection.Emit。这些包含了我们将需要的 API。
我们想要做的第一件事是创建程序集和模块,并将以下内容添加到Generate方法中:
var name = new AssemblyName("MyDynamicAssembly");
var assembly = AssemblyBuilder.DefineDynamicAssembly(name,
AssemblyBuilderAccess.Run);
var module = assembly.DefineDynamicModule
("MyDynamicModule");
从模块中,我们可以创建一个新的类型,并在类型内部创建一个方法。然后,在定义了模块之后,我们将以下内容追加到Generate方法中:
var typeBuilder = module.DefineType("MyType",
TypeAttributes.Public | TypeAttributes.Class);
var methodBuilder = typeBuilder.DefineMethod
("SaySomething", MethodAttributes.Public);
methodBuilder.SetParameters(typeof(string));
methodBuilder.DefineParameter(0, ParameterAttributes.None,
"message");
代码创建了一个名为MyType的公共类,然后定义了一个名为SaySomething的公共方法。在方法中,我们设置它有参数。该方法接受param,这允许我们定义一个或多个参数类型。我们最后要做的是定义参数。这是通过给它参数索引和名称来完成的。
重要提示
你赋予参数的ParameterAttributes值表示它没有任何特殊之处;它是一个常规参数。如果你想让它成为一个out或ref参数,你需要这样告诉它。
- 现在你已经有了具有预期签名的方法的定义。现在是时候填写实际的代码了。
此方法的代码非常简单,因为我们只是将传入的参数传递给另一个方法。
方法定义就绪后,你可以开始构建代码。将以下代码追加到生成方法中:
var consoleType = typeof(Console);
var writeLineMethod = consoleType.GetMethod(nameof
(Console.WriteLine), new[] { typeof(string) })!;
var methodILGenerator = methodBuilder.GetILGenerator();
methodILGenerator.Emit(OpCodes.Ldarg_1);
methodILGenerator.EmitCall(OpCodes.Call, writeLineMethod,
new[] { typeof(string) });
methodILGenerator.Emit(OpCodes.Ret);
代码首先获取System.Console类型和名为WriteLine的方法,该方法接受一个简单的string。这个方法是你将要用来调用和转发最终在控制台产生消息的参数的方法。一旦你有了WriteLine方法,你需要为构建的SaySomething方法获取ILGenerator。然后,你做的第一件事是发出一个指令将实际传递给参数的参数加载到所谓的评估堆栈中。OpCodes.Ldarg_1指的是 1,这可能会显得有些反直觉。在实例类型的上下文中,OpCodes.Ldarg_0将代表this的值。参数加载到堆栈上后,你发出调用Console上的WriteLine方法的代码,给它传递参数的类型。完成你的方法后,你发出一个从方法返回的指令。
-
生成方法的最后一部分是构建实际的类型并将其返回。将以下内容追加到生成方法中:
return typeBuilder.CreateType()!;
MyTypeGenerator类的完整列表现在应该如下所示:
using System.Reflection;
using System.Reflection.Emit;
namespace Chapter6;
public class MyTypeGenerator
{
public static Type Generate()
{
var name = new AssemblyName("MyDynamicAssembly");
var assembly = AssemblyBuilder.DefineDynamic
Assembly(name, AssemblyBuilderAccess.Run);
var module = assembly.DefineDynamicModule
("MyDynamicModule");
var typeBuilder = module.DefineType("MyType",
TypeAttributes.Public | TypeAttributes.Class);
var methodBuilder = typeBuilder.DefineMethod
("SaySomething", MethodAttributes.Public);
methodBuilder.SetParameters(typedoc(string));
var parameterBuilder = methodBuilder
.DefineParameter(0, ParameterAttributes.None,
"message");
var consoleType = typeof(Console);
var writeLineMethod = consoleType.GetMethod
(nameof(Console.WriteLine), new[] { typeof
(string) })!;
var methodILGenerator = methodBuilder
.GetILGenerator();
methodILGenerator.Emit(OpCodes.Ldarg_1);
methodILGenerator.EmitCall(OpCodes.Call,
writeLineMethod, new[] { typeof(string) });
methodILGenerator.Emit(OpCodes.Ret);
return typeBuilder.CreateType()!;
}
}
在你的第一个代码生成器就绪后,你想要试运行它。由于这是一个对编译器完全未知的类型,实际上没有方法可以编写标准的 C#代码来调用它。你将不得不回到反射来做这件事。
在Chapter6项目的Program.cs文件中,将现有代码替换为以下内容:
using Chapter6;
var myType = MyTypeGenerator.Generate();
var method = myType.GetMethod("SaySomething")!;
var myTypeInstance = Activator.CreateInstance(myType);
method.Invoke(myTypeInstance, new[] { "Hello world" });
代码调用你的新生成器以获取生成的类型。接下来,它要求生成的类型调用名为SaySomething的方法。然后,你使用.NET 中的Activator类型创建该类型的实例。从该方法中,你可以调用它并将实例作为第一个参数传递,然后添加它期望的数组中的参数。
使用dotnet run或,如果你更喜欢,你的 IDE 运行此代码,你应该得到一条简单的消息:
Hello world
中间语言以及运行时如何与指令交互是逻辑的,但与编写 C#相比可能不太直观。然而,它赋予你巨大的力量并使新的场景成为可能。
虚拟成员和重写
根据我的经验,从头开始生成在编译时不存在的新类型并不是最常见的用例。我发现自己,大多数情况下,只是想自动化一些我觉得繁琐且被迫从必须使用的库中执行的任务。
在这种情况下,通常的做法是取一个类型,创建一个新的类型,使其继承自该类型,然后开始重写行为。
由于 C#不像 Java 那样所有成员都是虚拟的,因此成员必须显式声明为虚拟。一个虚拟方法的例子是所有对象都会继承的方法——ToString方法。
让我们继续对MyTypeGenerator代码的工作,通过添加对ToString方法的重写来了解它是如何实现的:
-
在MyTypeGenerator类的Generate方法中,在你返回类型之前,你需要定义一个新的方法,它将是MyType的ToString方法实现:
var toStringMethod = typeof(object).GetMethod(nameof (object.ToString))!; var newToStringMethod = typeBuilder.DefineMethod(nameof (object.ToString), toStringMethod.Attributes, typeof(string), Array.Empty<Type>()); var toStringGenerator = newToStringMethod.GetILGenerator(); toStringGenerator.Emit(OpCodes.Ldstr, "A message from ToString()"); toStringGenerator.Emit(OpCodes.Ret); typeBuilder.DefineMethodOverride(newToStringMethod, toStringMethod);
首先,代码从基类获取它想要重写的方法的引用。由于此类型没有特定的基类型,它将隐式地成为object。然后,你开始定义新的ToString方法,并指定它将返回string类型。由于ToString方法不接受任何参数,你只需传递一个空的Type数组。从方法定义中,你像之前一样获取ILGenerator。然后,你只需将字符串加载到评估堆栈中,这将是唯一的东西,并从方法返回。为了使其成为重写方法,你随后在类型构建器上调用.DefineMethodOverride()来告诉它你要重写哪个方法,并给出原始的ToString方法。
-
打开Program.cs文件,添加一行代码来调用ToString方法,以验证其是否正常工作:
using Chapter6; var myType = MyTypeGenerator.Generate(); var method = myType.GetMethod("SaySomething")!; var myTypeInstance = Activator.CreateInstance(myType); method.Invoke(myTypeInstance, new[] { "Hello world" }); Console.WriteLine(myTypeInstance); // Added line
运行程序应该会打印出以下消息:
A message from ToString()
在本章中,你已经学到了创建具有成员的类型以及从继承的基类型重写虚拟成员的基本构建块。所有这些都会让你走得很远。现在我们已经了解了生成代码的机制,让我们举一个更具体的例子。
实现接口
除了从基类型重写虚拟成员之外,通常还需要实现接口,以满足使用第三方库的需求。接口的实现可能对你的代码并不重要,但它是一种被迫执行的任务,以启用某些行为。
任何进行过任何 XAML 风格开发的人都会遇到一个名为INotifyPropertyChanged的接口。INotifyPropertyChanged接口是数据绑定引擎能够识别并自动使用的一个接口,如果一个类型实现了它。它的目的是在属性发生变化时通知使用你对象的任何人。当你有一个 UI 元素自动反映后台数据的变化时,这非常有用。
INotifyPropertyChanged接口本身非常简单,看起来如下:
public interface INotifyPropertyChanged
{
event PropertyChangedEventHandler? PropertyChanged;
}
对于实现INotifyPropertyChanged的对象,这意味着它需要在值设置时为每个属性实现逻辑。这可能会非常繁琐,并且会使你的代码库膨胀,包含不属于你领域代码。
假设你有一个代表人的对象:
public class Employee
{
public string FirstName { get; set; }
public string LastName { get; set; }
}
由于绑定目的需要INotifyPropertyChanged,对象只需对其中一个属性进行以下操作:
using System.ComponentModel;
public class Employee : INotifyPropertyChanged
{
private string _firstName;
public string FirstName
{
get { return _firstName; }
set
{
_firstName = value;
RaisePropertyChanged("FirstName");
}
}
public event PropertyChangedEventHandler
PropertyChanged;
protected void RaisePropertyChanged(string
propertyName)
{
if (PropertyChanged != null)
{
PropertyChanged(this, new Property
ChangedEventArgs(propertyName));
}
}
}
如你所见,代码从每个属性的简单单行语句变成了具有显式 getter、setter 和私有字段来保存实际值的结构。在 setter 中,你必须引发PropertyChanged事件,并且一个典型的模式是有一个方便的方法,用于所有属性的重用。
多亏了代码生成,你可以让这一切都消失,让你的代码回到可读性和可维护性更高的状态,从而在过程中提高你的生产力。
在Chapter6文件夹中,创建一个名为Person.cs的新文件,并使其看起来如下:
namespace Chapter6;
public class Employee
{
public virtual string FirstName { get; set; }
public virtual string LastName { get; set; }
}
现在,Person类代表的目标版本不包含任何INotifyPropertyChanged相关的内容。在编译时,它没有实现INotifyPropertyChanged接口,但我们将使其在运行时实现。
由于我们已经使属性virtual,我们可以创建一个新的类型,它从Person类型继承并覆盖属性以实现我们想要的功能。
NotifyObjectWeaver 类
为了能够做到我们想要的事情,我们需要做以下事情:
-
创建一个新的类型。
-
从现有类型继承。
-
实现接口INotifyPropertyChanged。
-
添加一个处理属性变化逻辑的方法。
-
覆盖任何虚拟方法并实现属性变化时所需的代码。
此外,也常见到一些属性依赖于其他属性——例如,将多个属性组合在一起。这些属性也应该通知你它们的变化,因此你希望有一种机制来处理这一点。
首先,在Chapter6项目中添加一个名为NotifyObjectWeaver.cs的文件。然后,向文件中添加以下内容:
using System.ComponentModel;
using System.Linq.Expressions;
using System.Reflection;
using System.Reflection.Emit;
namespace Chapter6;
public static class NotifyingObjectWeaver
{
const string DynamicAssemblyName = "Dynamic Assembly";
const string DynamicModuleName = "Dynamic Module";
const string PropertyChangedEventName = nameof
(INotifyPropertyChanged.PropertyChanged);
const string OnPropertyChangedMethodName =
"OnPropertyChanged";
static readonly Type VoidType = typeof(void);
static readonly Type DelegateType = typeof(Delegate);
const MethodAttributes EventMethodAttributes =
MethodAttributes.Public | MethodAttributes
.HideBySig | MethodAttributes.Virtual;
const MethodAttributes OnPropertyChanged
MethodAttributes =
MethodAttributes.Public | MethodAttributes
.HideBySig;
}
代码添加了在代码生成过程中将使用的常见常量和静态变量,确保你不需要在代码中重复它们,并且它们聚集在顶部以获得更好的结构。
通过这种方式,你现在有了NotifyObjectWeaver类的起点。
创建类型
你将要开始编写的是定义类型的代码。这基本上是你之前所做过的,只是我们现在将使你动态创建的类型继承自基类型,并实现一个接口:
static TypeBuilder DefineType(Type type)
{
var name = $"{type.Name}_Proxy";
var typeBuilder = DynamicModule.DefineType(name,
TypeAttributes.Public | TypeAttributes.Class);
typeBuilder.SetParent(type);
var interfaceType = typeof(INotifyPropertyChanged);
typeBuilder.AddInterfaceImplementation(interfaceType);
return typeBuilder;
}
调用 .SetParent() 是继承的关键。它指示构建器将方法提供的 Type 输入作为父类。接下来,你需要指示构建器你将实现 INotifyPropertyChanged 接口。
实现 INotifyPropertyChanged 接口
INotifyPropertyChanged 接口上只有一个字段,即我们需要实现的 PropertyChanged 事件字段。我们需要添加代码来定义事件,并实现添加和从事件中移除事件处理器的逻辑。
在 NotifyObjectWeaver 类中添加以下方法:
static void DefineEvent(TypeBuilder typeBuilder, Type
eventHandlerType, FieldBuilder fieldBuilder)
{
var eventBuilder = typeBuilder.DefineEvent(nameof
(INotifyPropertyChanged.PropertyChanged),
EventAttributes.None, eventHandlerType);
DefineAddMethodForEvent(typeBuilder, eventHandlerType,
fieldBuilder, eventBuilder);
DefineRemoveMethodForEvent(typeBuilder,
eventHandlerType, fieldBuilder, eventBuilder);
}
DefineEvent 方法在类型上定义实际的事件,然后调用两个方法来定义事件的 add 和 remove 方法。
将以下方法添加到 NotifyObjectWeaver 类中:
static void DefineAddMethodForEvent(TypeBuilder
typeBuilder, Type eventHandlerType, FieldBuilder
fieldBuilder, EventBuilder eventBuilder)
{
var combineMethodInfo = DelegateType.GetMethod
("Combine", BindingFlags.Public |
BindingFlags.Static, null,
new[] { DelegateType, DelegateType }, null)!;
var addEventMethod = string.Format("add_{0}",
PropertyChangedEventName);
var addMethodBuilder = typeBuilder.DefineMethod
(addEventMethod, EventMethodAttributes, VoidType,
new[] { eventHandlerType });
var addMethodGenerator = addMethodBuilder
.GetILGenerator();
addMethodGenerator.Emit(OpCodes.Ldarg_0);
addMethodGenerator.Emit(OpCodes.Ldarg_0);
addMethodGenerator.Emit(OpCodes.Ldfld, fieldBuilder);
addMethodGenerator.Emit(OpCodes.Ldarg_1);
addMethodGenerator.EmitCall(OpCodes.Call,
combineMethodInfo, null);
addMethodGenerator.Emit(OpCodes.Castclass,
eventHandlerType);
addMethodGenerator.Emit(OpCodes.Stfld, fieldBuilder);
addMethodGenerator.Emit(OpCodes.Ret);
eventBuilder.SetAddOnMethod(addMethodBuilder);
}
代码添加了一个方法,按照惯例,当添加事件处理器时将使用此方法,add_PropertyChanged。它使用在类开头添加的 Delegate 类型来获取将被调用的 Combine 方法。
然后,它生成所需的 IL 代码,通过调用检索到的 Combine 方法,将传入的回调添加到在发生更改时将被调用的回调中。
为了保险起见,你还应该添加相反的方法——即移除已添加的回调的方法。将以下方法添加到 NotifyObjectWeaver 类中:
static void DefineRemoveMethodForEvent(TypeBuilder
typeBuilder, Type eventHandlerType, FieldBuilder
fieldBuilder, EventBuilder eventBuilder)
{
var removeEventMethod = string.Format("remove_{0}",
PropertyChangedEventName)!;
var removeMethodInfo = DelegateType.GetMethod("Remove",
BindingFlags.Public | BindingFlags.Static, null,
new[] { DelegateType, DelegateType }, null)!;
var removeMethodBuilder = typeBuilder.DefineMethod
(removeEventMethod, EventMethodAttributes, VoidType,
new[] { eventHandlerType });
var removeMethodGenerator = removeMethodBuilder
.GetILGenerator();
removeMethodGenerator.Emit(OpCodes.Ldarg_0);
removeMethodGenerator.Emit(OpCodes.Ldarg_0);
removeMethodGenerator.Emit(OpCodes.Ldfld,
fieldBuilder);
removeMethodGenerator.Emit(OpCodes.Ldarg_1);
removeMethodGenerator.EmitCall(OpCodes.Call,
removeMethodInfo, null);
removeMethodGenerator.Emit(OpCodes.Castclass,
eventHandlerType);
removeMethodGenerator.Emit(OpCodes.Stfld,
fieldBuilder);
removeMethodGenerator.Emit(OpCodes.Ret);
eventBuilder.SetRemoveOnMethod(removeMethodBuilder);
}
remove 的实现与 add 几乎相同,区别在于从 Delegate 类型获取 Remove 方法并调用 .SetRemoveOnMethod()。
OnPropertyChanged 方法
在事件逻辑就绪后,你现在需要一个私有方法,所有属性都将调用它——一个将执行所有繁重工作的 OnPropertyChanged 方法。
首先在 NotifyObjectWeaver 类中添加以下方法:
static MethodBuilder DefineOnPropertyChangedMethod
(TypeBuilder typeBuilder, FieldBuilder
propertyChangedFieldBuilder)
{
var onPropertyChangedMethodBuilder =
typeBuilder.DefineMethod(OnPropertyChangedMethodName,
OnPropertyChangedMethodAttributes, VoidType,
new[] { typeof(string) });
var onPropertyChangedMethodGenerator =
onPropertyChangedMethodBuilder.GetILGenerator();
var invokeMethod = typeof(PropertyChangedEventHandler)
.GetMethod(nameof(PropertyChangedEventHandler
.Invoke))!;
}
代码定义了带有 string 签名的 OnPropertyChanged 方法,其中包含已更改属性的名称。
然后,在 DefineOnPropertyChangedMethod 的末尾添加以下内容,以声明一个用于存储代码将创建的事件参数类型的局部变量:
var propertyChangedEventArgsType = typeof(
PropertyChangedEventArgs);
onPropertyChangedMethodGenerator.DeclareLocal(
propertyChangedEventArgsType);
现在,你需要代码来检查 PropertyChanged 事件字段是否为空,并具有代码可以跳转到的 propertyChangedNullLabel 标签,如果值为空。
在 DefineOnPropertyChangedMethod 方法的末尾添加以下代码:
var propertyChangedNullLabel = onPropertyChangedMethod
Generator.DefineLabel();
onPropertyChangedMethodGenerator.Emit(OpCodes.Ldnull);
onPropertyChangedMethodGenerator.Emit(OpCodes.Ldarg_0);
onPropertyChangedMethodGenerator.Emit(OpCodes.Ldfld,
propertyChangedFieldBuilder);
onPropertyChangedMethodGenerator.Emit(OpCodes.Ceq);
onPropertyChangedMethodGenerator.Emit(OpCodes.Brtrue_S,
propertyChangedNullLabel);
现在,你需要添加代码来创建带有传递给 OnPropertyChanged 方法的参数的 PropertyChangedEventArgs 实例。然后,调用 invoke 方法:
onPropertyChangedMethodGenerator.Emit(OpCodes.Ldarg_1);
onPropertyChangedMethodGenerator.Emit(OpCodes.Newobj,
propertyChangedEventArgsType.GetConstructor(new[] {
typeof(string) })!);
onPropertyChangedMethodGenerator.Emit(OpCodes.Stloc_0);
onPropertyChangedMethodGenerator.Emit(OpCodes.Ldarg_0);
onPropertyChangedMethodGenerator.Emit(OpCodes.Ldfld,
propertyChangedFieldBuilder);
onPropertyChangedMethodGenerator.Emit(OpCodes.Ldarg_0);
onPropertyChangedMethodGenerator.Emit(OpCodes.Ldloc_0);
onPropertyChangedMethodGenerator.EmitCall(OpCodes.Callvirt,
invokeMethod, null);
最后一个拼图是标记如果为空,null 检查将跳转到的标签,然后从方法中返回。在 DefineOnPropertyChangedMethod 方法的末尾添加以下内容:
onPropertyChangedMethodGenerator.MarkLabel(propertyChanged
NullLabel);
onPropertyChangedMethodGenerator.Emit(OpCodes.Ret);
return onPropertyChangedMethodBuilder;
定义 OnPropertyChanged 方法的代码现在已完成。现在,你需要定义属性。
覆盖属性
我们一开始的一个要求是,我们希望能够通知其他属性以创建复合体,或者如果相关的话,让另一个属性重新评估:
-
添加一个名为 NotifyChangedForAttribute.cs 的文件,并使其看起来如下:
namespace Chapter6; [AttributeUsage(AttributeTargets.Property)] public class NotifyChangesForAttribute : Attribute { public NotifyChangesForAttribute(params string[] propertyNames) { PropertyNames = propertyNames; } public string[] PropertyNames { get; } }
此自定义属性接受一个 param 数组,其中包含将因更改而调用的属性名称,以及更改的属性。
-
为了方便起见,你应该添加一个方法来获取通知的属性,基于 PropertyInfo 对属性进行操作。在 NotifyingObjectWeaver 类中添加以下方法:
static string[] GetPropertiesToNotifyFor(PropertyInfo property) { var properties = new List<string> { property.Name }; foreach (var attribute in (NotifyChangesForAttribute[]) property.GetCustomAttributes(typeof(NotifyChangesFor Attribute), true)) { properties.AddRange(attribute.PropertyNames); } return properties.ToArray(); }
代码结合属性名称,查找 NotifyChangesForAttribute 属性,并添加声明的名称。
-
你现在可以添加定义新类型上所有属性的方法。将以下方法添加到 NotifyingObjectWeaver 类中:
static void DefineProperties(TypeBuilder typeBuilder, Type baseType, MethodBuilder onPropertyChangedMethodBuilder) { var properties = baseType.GetProperties(); var query = from p in properties where p.GetGetMethod()!.IsVirtual && !p .GetGetMethod()!.IsFinal select p; foreach (var property in query) { DefineGetMethodForProperty(property, typeBuilder); DefineSetMethodForProperty(property, typeBuilder, onPropertyChangedMethodBuilder); } }
代码检查基类型,获取所有属性,并筛选出仅虚拟方法。然后,对于它找到的所有属性,它定义属性的 get 和 set 方法。
-
将以下方法添加到 NotifyingObjectWeaver 类中:
static void DefineSetMethodForProperty(PropertyInfo property, TypeBuilder typeBuilder, MethodBuilder onPropertyChangedMethodBuilder) { var setMethodToOverride = property.GetSetMethod(); if (setMethodToOverride is null) { return; } var setMethodBuilder = typeBuilder.DefineMethod (setMethodToOverride.Name, setMethodToOverride .Attributes, VoidType, new[] { property .PropertyType }); var setMethodGenerator = setMethodBuilder .GetILGenerator(); var propertiesToNotifyFor = GetPropertiesToNotifyFor (property); setMethodGenerator.Emit(OpCodes.Ldarg_0); setMethodGenerator.Emit(OpCodes.Ldarg_1); setMethodGenerator.Emit(OpCodes.Call, setMethodToOverride); foreach (var propertyName in propertiesToNotifyFor) { setMethodGenerator.Emit(OpCodes.Ldarg_0); setMethodGenerator.Emit(OpCodes.Ldstr, propertyName); setMethodGenerator.Emit(OpCodes.Call, onPropertyChangedMethodBuilder); } setMethodGenerator.Emit(OpCodes.Ret); typeBuilder.DefineMethodOverride(setMethodBuilder, setMethodToOverride); }
set_* 方法是执行通知的方法。代码定义了该方法,并首先调用基类型的 set 方法,以便基类型可以为自己处理设置的值。然后,它遍历所有要通知的属性,并添加代码来调用带有属性名称的 OnPropertyChanged 方法。
- 获取值的方式略有不同,因为你只想让它读取到基类型并从中获取值。
将以下方法添加到 NotifyingObjectWeaver 类中:
static void DefineGetMethodForProperty(PropertyInfo
property, TypeBuilder typeBuilder)
{
var getMethodToOverride = property.GetGetMethod()!;
var getMethodBuilder = typeBuilder.DefineMethod
(getMethodToOverride.Name, getMethodToOverride
.Attributes, property.PropertyType,
Array.Empty<Type>());
var getMethodGenerator = getMethodBuilder
.GetILGenerator();
var label = getMethodGenerator.DefineLabel();
getMethodGenerator.DeclareLocal(property.PropertyType);
getMethodGenerator.Emit(OpCodes.Ldarg_0);
getMethodGenerator.Emit(OpCodes.Call,
getMethodToOverride);
getMethodGenerator.Emit(OpCodes.Stloc_0);
getMethodGenerator.Emit(OpCodes.Br_S, label);
getMethodGenerator.MarkLabel(label);
getMethodGenerator.Emit(OpCodes.Ldloc_0);
getMethodGenerator.Emit(OpCodes.Ret);
typeBuilder.DefineMethodOverride(getMethodBuilder,
getMethodToOverride);
}
你现在拥有了执行属性代码生成所需的全部代码,以及引发 PropertyChanged 事件的函数。
初始化和公共 API
最后一个拼图将是执行主要初始化,定义动态程序集和模块。
在 NotifyingObjectWeaver 类中添加以下内容:
static readonly AssemblyBuilder DynamicAssembly;
static readonly ModuleBuilder DynamicModule;
static readonly Dictionary<Type, Type> Proxies = new();
static NotifyingObjectWeaver()
{
var assemblyName = new AssemblyName
(DynamicAssemblyName);
DynamicAssembly = AssemblyBuilder
.DefineDynamicAssembly(assemblyName,
AssemblyBuilderAccess.Run);
DynamicModule = DynamicAssembly.DefineDynamicModule
(DynamicModuleName);
}
The code introduces a private Proxies field. This will serve as a cache to avoid generating the same type multiple times every time one needs a proxy of a type.
你现在需要一个公共 API,可以从外部调用以获取其他类型的代理类型。
将以下代码添加到 NotifyingObjectWeaver 类中:
public static Type GetProxyType(Type type)
{
Type proxyType;
if (Proxies.ContainsKey(type))
{
proxyType = Proxies[type];
}
else
{
proxyType = CreateProxyType(type);
Proxies[type] = proxyType;
}
return proxyType;
}
GetProxyType 方法首先检查是否存在给定类型的代理类型,如果存在,则返回现有的代理类型;如果缓存中没有,则创建代理类型。
使用 NotifyingObjectWeaver
现在你已经拥有了一个功能齐全的对象编织器。让我们在 Program.cs 文件中试运行它。添加以下代码:
var type = NotifyingObjectWeaver.GetProxyType
(typeof(Person));
Console.WriteLine($"Type name : {type}");
var instance = (Activator.CreateInstance(type) as
INotifyPropertyChanged)!;
instance.PropertyChanged += (sender, e) =>
Console.WriteLine($"{e.PropertyName} changed");
var instanceAsViewModel = (instance as Person)!;
instanceAsViewModel.FirstName = "John";
代码要求一个包装后的类型,然后创建它的一个实例。由于它现在实现了INotifyPropertyChanged,我们可以简单地将其转换为该类型,并与之交互PropertyChanged事件。
由于新类型继承自Person类,我们也可以将其转换为该类型,并设置其属性。
当你运行这个程序时,你应该看到以下结果:
Type name : Person6e46bfa7_e47a_4299_8ae6_f928b8a027ee
FirstName changed
现在是尝试你添加的NotifyChangesFor功能的好时机。打开Person.cs文件,将其修改为以下样子:
public class Person
{
[NotifyChangesFor(nameof(FullName))]
public virtual string FirstName { get; set; } =
string.Empty;
[NotifyChangesFor(nameof(FullName))]
public virtual string LastName { get; set; } =
string.Empty;
public virtual string FullName => $"{FirstName}
{LastName}";
}
由于FullName是FirstName和LastName的组合,因此当这些属性中的任何一个发生变化时通知是有意义的。
运行程序现在应该会给你以下结果:
Type name : Person6e46bfa7_e47a_4299_8ae6_f928b8a027ee
FirstName changed
FullName changed
GitHub 仓库中的代码具有更多功能,可以作为参考。例如,如果基类型有一个接受参数的构造函数,你应该在类型中实现相同的构造函数,并将构造函数参数传递给基构造函数。
你可能会遇到另一个常见的情况,即需要能够忽略属性。由于本章的实现是一个opt-out模型,所有虚拟属性都被视为通知变化的属性。这可能并不总是正确的,但很可能正是大多数属性所需要的东西。因此,你需要有一种方法来忽略这些属性。
请查看 GitHub 仓库中的完整列表,你可以看到这些功能是如何实现的。
摘要
在本章中,我们学习了拥有托管运行时环境的力量,看到我们如何通过动态创建将在与预编译代码相同的条件下执行代码来充分利用它。
这样的功能几乎有无穷的可能性。它作为自动化繁琐任务和优化开发者体验的方式非常有帮助,但它可以用于更多的事情。例如,如果你有一个动态连接到其他系统的系统,并且类型是通过配置或类似方式动态创建的,那么,而不是通过如Dictionary<,>类型这样的无类型机制来持有属性值,一种优化方法是在运行时动态创建类型。好处是,你将拥有在运行时类型安全的某种东西。这也可能对你的系统性能提升有所帮助,因为你不需要从字典中查找值。
在下一章中,我们将深入研究一个名为Expression的构造函数,了解它如何表示代码和逻辑,以及如何从Expression中提取信息。
第七章:关于表达式的推理
到目前为止,我们已经探讨了捕获的强大元数据以及我们如何使用反射来访问它。我们还探讨了如何利用相同的元数据并在运行时动态生成代码。
使用反射和生成代码作为元编程的技术在元编程中非常强大,但它们并不适用于所有场景。这也可能非常复杂,并可能产生难以阅读甚至难以维护的代码,尤其是在深入反射和代理生成时。
在许多场景中,C#表达式可以代表更好的方法或特定场景的附加方法,用于运行时发现和提取元数据。
本章将涵盖以下主题:
-
表达式是什么?
-
遍历表达式树
-
使用表达式作为类型成员的描述符
从本章中,你应该理解表达式是什么以及你如何利用它们作为在运行时推理运行代码的技术。
技术要求
本章的特定源代码可以在 GitHub 上找到(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter7),并且它建立在github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Fundamentals中找到的基础知识代码之上。
重要提示
GetMemberExpression()和GetPropertyInfo()方法都可以在 GitHub 仓库中的基础知识文件夹下的ExpressionExtensions.cs文件中找到。
表达式是什么?
C# 3.0于 2007 年底推出,其杀手级特性是所谓的语言集成查询(LINQ)。结合底层的 API 模型和 C#中新的功能,它引入了一种编程范式,将编程空间与更函数式编程空间相连接。如果你习惯了更面向对象的方法,流畅的接口和其使用的 lambda 表达式可能会感觉陌生。
它带来的好处是提供了一种以更自然、更本地的 C#方式表达数据查询的方法。这不仅适用于内存中的集合,也适用于任何其他数据源,如数据库。它基本上为开发者提供了一种表达查询、过滤和投影的统一方式。
它通过识别查询操作由以下三个不同的部分组成来实现这一点:
-
数据源
-
查询表达式
-
执行查询
让我们来看一个没有使用 LINQ 的例子,并将其与使用 LINQ 以更表达性的方式完成相同任务的方法进行比较:
int[] items = { 1, 2, 3, 42, 84, 37, 23 };
var filtered = new List<int>();
foreach (var item in items)
{
if( item >= 37 )
{
filtered.Add(item);
}
}
Console.WriteLine(string.Join(',', filtered));
运行此代码会打印出42, 84, 37,正如预期的那样。这种类型的问题的挑战在于它非常冗长,与数据源耦合,并且没有优化空间。
使用 LINQ 以及对前面提到的构成查询的三个不同部分的识别,存在一个概念叫做 延迟执行。查询的实际执行直到你开始枚举它才会发生。这意味着你可以在查询最终执行之前构建查询。执行是由查询提供者执行的,它可能是默认的内存中提供者,或者代表数据源(如数据库)的某种东西。
使用 LINQ 表示前面的示例要表达得多:
int[] items = { 1, 2, 3, 42, 84, 37, 23 };
var selectedItems =
from i in items
where i >= 37
select i;
Console.WriteLine(string.Join(',', selectedItems));
代码的可读性大大提高,但幕后发生的事情是,它也变得更加灵活,可以根据查询运行的数据源进行优化。
LINQ 是 C# 的语言特性,没有 .NET 类库和 .NET 运行时的支持是无法工作的。这就是表达式发挥作用的地方。
作为同时发布的基类库的一部分,有一个新的命名空间叫做 System.Linq,以及其中另一个叫做 System.Linq.Expressions 的命名空间。这里的 魔法 精华 就在这里。
C# 编译器使用 C# 3.0 中引入的另一个特性——扩展方法来翻译本机 LINQ 代码。扩展方法只是看起来像是它们扩展的实际类型的成员的静态方法,它们可以用来形成一个流畅的接口,可以链式调用方法。
从之前的 LINQ 代码出发,我们可以使用 System.Linq 命名空间中找到的表达式和扩展方法来表示它:
int[] items = { 1, 2, 3, 42, 84, 37, 23 };
var selectedItemsExpressions = items
.Where(i => i >= 37)
.Select(i => i);
Console.WriteLine(string.Join(',',
selectedItemsExpressions));
到目前为止讨论的所有三种方法都做同样的事情,并输出相同的结果。最大的区别在于可读性。
由于我们的数据源只是一个内存中的数组,扩展方法会反映这一点,你将注意到它使用的 .Where() 方法的签名:
public static IEnumerable<TSource> Where<TSource>(this
IEnumerable<TSource> source, Func<TSource, bool>
predicate);
第一个参数是它扩展的源,IEnumerable
这并没有真正引出本章的主题。让我们稍微改变一下,以展示真正的魔法。
表达式
为了使 LINQ 能够针对不同的数据源和延迟执行按预期工作,有一个名为 IQueryable 的接口。IQueryable 接口是数据源可以实现的,它不再只是迭代数据源的一种命令式方法,现在它可以获取表示为表达式的查询。
IQueryable 接口为我们提供了不同的扩展方法,这些方法公开了 Expression 作为一种类型。让我们稍微改变一下数组过滤,并引入查询的概念:
int[] items = { 1, 2, 3, 42, 84, 37, 23 };
var selectedItemsExpressions = items
.AsQueryable() // Making the array a queryable
.Where(i => i >= 37)
.Select(i => i);
Console.WriteLine(string.Join(',', selectedItems
Expressions));
.AsQueryable()数组从数组,或者更确切地说,从IEnumerable
如果你仔细观察.Where()方法,你会注意到现在有一个扩展方法具有以下签名:
public static IQueryable<TSource> Where<TSource>(this
IQueryable<TSource> source, Expression<Func<TSource,
bool>> predicate);
第一个参数是它扩展的源,第二个参数是Expression<Func<TSource, bool>>。这是最重要的部分。C#编译器识别任何表达式类型,而不是仅仅创建一个被调用的回调,而是将其展开为一个表达式。
为了调查编译器做了什么,我们可以将where子句单独成行,这样我们就可以在调试器中设置断点并查看发生了什么:
int[] items = { 1, 2, 3, 42, 84, 37, 23 };
// Extracted expression
Expression<Func<int, bool>> filter = (i) => i >= 37;
var selectedItemsExpressions = items
.AsQueryable()
.Where(filter)
.Select(i => i);
Console.WriteLine(string.Join(',', selectedItems
Expressions));
将表达式提取到自己的代码行中,我们可以轻松地看到编译器为我们生成了什么。通过在filter变量构造后设置断点,并在调试器的监视视图中查看filter变量,你可以看到以下内容:

图 7.1 – 过滤表达式
如你所见,它不再只是一个回调。它已经捕获了你所表达的所有细节。注意Body属性。这是内部表达式。在我们的例子中,类型被转换为LogicalBinaryExpression,它包含三个重要内容:
-
左
-
NodeType
-
右
左属性表示表达式左侧的内容,而右属性表示表达式右侧的内容。在这两者之间,操作符NodeType告诉我们它将要执行的操作。
对于左和右属性,你也会看到它们的类型是Expression。在这种情况下,左表达式是PrimitiveParameterExpression,表示参数,即我们在迭代或调用表达式时将传递的值。而右表达式变为ConstantExpression,持有具体值,NodeType属性设置为GreaterThanOrEqual,这正是>=所表达的含义。
Lambda 表达式
当你使用=>符号时,你形成了一个被称为lambda 表达式的结构。Lambda 表达式是无名函数。Lambda 表达式分为以下两种类型:
-
Expression lambdas
-
Statement lambdas
表达式 lambda 的特点是右侧表达一个表达式。表达式 lambda 返回表达式的结果,其形式如下:
(input parameters) => expression
这是你在之前看到的表达式中使用的表达式类型:
Expression<Func<int, bool>> filter = (i) => i >= 37;
它的签名由Func<int, bool>定义,其中第一个泛型参数是使用的参数类型,最后一个泛型参数是返回类型。你可以有多个参数。
语句 lambda 的特点是它被括号 {} 包围,并且通常包含多个语句:
(input parameters) => { <sequence of statements> }
这可能类似于以下代码片段:
(string name) =>
{
var message = $"Hello {name}";
Console.WriteLIne(message);
}
在一个语句中,lambda 通常是一种无法进行推理的东西,因为它不会形成一个表达式树,而是在其中包含多个语句。
Lambdas 代表一个非常强大的结构,可以用来深入了解正在发生的事情。这同样也是一个很好的方法来帮助理解表达式是如何工作和构建的。
遍历表达式树
Expression 是一个简单的结构,表示树中的一个节点。表达式有一个节点类型,实现决定节点类型表达什么以及意味着什么。这形成了一个可以递归遍历和推理的树。
对于 lambda 表达式,这意味着它有一个由特定类型的表达式组成的主体;这个类型可以是包含左右操作数和表示操作符(等于、不等于等)的 Binary Expression:

图 7.2 – 二元表达式
以过滤器为例:
Expression<Func<int, bool>> filter = (i) => i >= 37;
从视觉上看,它看起来如下:

图 7.3 - 一个大于常量表达式的参数
操作数是 大于或等于,左侧访问传递给它的参数,右侧持有常量值 37。
表达式也可以表示访问作为参数传递的类型上的成员。
假设我们有一个名为 Employee 的对象,其外观如下:
public record Employee(string FirstName, string LastName);
一个过滤器访问名字并寻找名为 Jane 的人将看起来如下:
Expression<Func<Employee, bool>> employeeFilter =
(employee) => employee.FirstName == "Jane";
解构表达式树,我们会看到它现在变成了以下形式:

图 7.4 – 成员访问等于常量表达式
由于我们现在正在访问一个成员,左侧的表达式现在是 MemberAccessExpression。MemberAccessExpression 上有一个表达式,表示持有成员的源,在我们的例子中,是一个传递进来的参数。为了获取实际的成员,MemberAccessExpression 拥有一个名为 Member 的属性。由于我们访问的成员是一个属性,它将是实际的 PropertyInfo。
表达式不仅用于过滤器;它们还可以表示其他操作。我们可能有一个表示添加值的表达式,如下所示:
Expression<Func<int, int>> addExpression = (i) => i + 42;
该表达式现在返回 int 而不是 bool,接受传递给它的参数,并加上 42。
表达式树如下图中所示:

图 7.5 – 参数添加常量表达式
正如你所见,节点类型是 Add。这只是你可以使用的许多表达式类型之一。你可以创建非常复杂的结构,并以抽象的方式表示 .NET 运行时本身能够运行的核心功能。我们将在 第八章,构建和执行表达式 中看到更多不同类型。
将表达式用作类型成员的描述符
表达式代表了一种以声明式方式描述意图的方法。而不是通过命令式方法,我们告诉计算机确切要做什么,我们可以声明式地描述它,并让代码决定最佳的处理方式。
正如我们之前在数字过滤中看到的那样,使用 foreach 的命令式方法不为我们打开任何其他执行方式,而使用 LINQ 和表达式方法,我们描述了我们想要的内容,执行是延迟的,我们不知道如何处理和执行,但我们得到的结果是相同的。
这种声明式思维可以是一种非常强大的方式来描述你希望你的系统有什么,也可以使你的代码更易于阅读,更容易被他人理解。命令式代码要求你彻底阅读和理解代码做了什么,而声明式方法描述了期望的结果,更容易推理。
显然,声明式模型在功能上有限,而命令式方法则完全灵活,你可以利用 C# 语言的全部功能。如果你可以使用流畅接口之类的工具声明式地描述事物,并充分利用 C# 扩展方法来持有表示命令式动作的方法,那么结合这两种方法是最好的。
让我们看看一个名为 FluentValidation 的库(docs.fluentvalidation.net/en/latest/)。这是一个允许你流畅地编写输入验证的库。它充分利用了表达式,并为你提供了一个可使用扩展方法进行扩展的声明式模型,以描述你的意图。
以下是从他们的文档中的示例:
public class CustomerValidator : AbstractValidator<Customer>
{
public CustomerValidator()
{
RuleFor(x => x.Surname).NotEmpty();
RuleFor(x => x.Forename).NotEmpty().WithMessage("Please
specify a first name");
RuleFor(x => x.Discount).NotEqual(0).When(x =>
x.HasDiscount);
RuleFor(x => x.Address).Length(20, 250);
RuleFor(x => x.Postcode).Must(
BeAValidPostcode).WithMessage("Please specify a
valid postcode");
}
private bool BeAValidPostcode(string postcode)
{
// custom postcode validating logic goes here
}
}
验证器从名为 AbstractValidator<> 的基类型继承,在这个类型上有一个名为 RuleFor() 的方法。这个方法是描述传递给 AbstractValidator<> 的泛型参数上的属性规则的起点。RuleFor() 的签名如下所示:
public IRuleBuilderInitial<T, TProperty>
RuleFor<TProperty>(Expression<Func<T, TProperty>>
expression);
正如你所见,RuleFor() 的参数是一个表达式。预期的表达式是接受传递给 AbstractValidator<> 的类型作为参数并返回任何类型的表达式。TProperty 将由 C# 编译器从表达式中自动推断出来。
FluentValidation 使用这个表达式通过检查表达式来了解你正在验证哪个属性。
在本书的基础知识部分代码中,您将找到一个名为ExpressionExtensions的类,它具有用于执行与FluentValidation相同类型检查的辅助扩展方法。
由于表达式可以描述各种成员,并且表示该成员的代码是相同的,因此存在一种获取表示成员的表达式的方法:
public static MemberExpression GetMemberExpression(this
Expression expression)
{
var lambda = expression as LambdaExpression;
if (lambda?.Body is UnaryExpression)
{
var unaryExpression = lambda.Body as
UnaryExpression;
return (MemberExpression)unaryExpression!.Operand!;
}
return (MemberExpression)lambda?.Body!;
}
代码假设传入的表达式是LambdaExpression,然后检查这是否是一个包含成员的UnaryExpression。如果不是UnaryExpression,我们假设它是MemberExpression。
如果它不是MemberExpression,这将导致无效的强制类型转换异常。您可能想要考虑检查类型并抛出一个更具体的异常。
检查UnaryExpression的原因是,对于值类型为object且实际值为需要转换、强制类型转换或拆箱以成为object类型的表达式,编译器可以决定插入一个执行此操作的一元表达式。
一旦您有了成员表达式,我们就可以获取实际的成员,作为表示属性的System.Reflection类型,即PropertyInfo:
public static PropertyInfo GetPropertyInfo(this Expression
expression)
{
var memberExpression = GetMemberExpression(expression);
return (PropertyInfo)memberExpression.Member!;
}
代码调用GetMemberExpression()并将Member强制转换为PropertyInfo。如果成员不是PropertyInfo,这将抛出异常。您可能想要检查正确性并抛出一个更具体的异常。
通过返回PropertyInfo,您现在拥有了关于描述的属性、其名称和类型以及更多信息的所有所需信息。
摘要
本章我们学习了.NET 类库中的一个宝贵成员,称为表达式。得益于 C#编译器和运行时的共生关系,您得到了另一种推理运行代码的方法。
表达式代表了一种表示表达式的结构化方法。它与抽象语法树有某种相似之处,所有代码编译器在解析代码时都会生成抽象语法树。我们将在第十五章,Roslyn 编译器扩展中了解更多。
如本章所见,表达式的类型不仅可以用于仅仅捕获信息,还可以更强大,捕获可以执行的操作。
在下一章中,我们将更深入地探讨表达式,看看您如何在运行时构建可以动态执行的表达式。
第八章:构建和执行表达式
C#中的表达式不仅非常适合用作捕获元数据和推理代码的手段;你还可以根据代码表示为 lambda 表达式生成表达式树,正如我们在第七章中看到的,推理表达式,或者通过程序化地添加不同的表达式节点类型自己生成。
你构建的表达式可以随后执行。由于表达式提供的功能范围很广,它们几乎和生成中间语言代码一样强大,正如我们在第六章中看到的,动态代理生成。因为每个表达式都是位于执行的具体表达式内部的代码,并执行表达式的任务,所以你不能期望其性能与在.NET 运行时本地运行的中间语言相同。因此,是否选择表达式生成而不是代理生成取决于你的用例。
在本章中,我们将探讨如何利用表达式来表达我们的意图并执行它们,并希望从中获得一些灵感,了解它们可能有什么用。
我们将涵盖以下主题:
-
创建你自己的表达式
-
将表达式作为委托创建并执行
-
创建查询引擎
通过本章,你应该理解表达式树是如何构建的,以及你如何可以利用它们生成可以随后执行的动态逻辑。
技术要求
本章的特定源代码可以在 GitHub 上找到(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter8),它基于这里找到的基础知识代码:github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Fundamentals。
创建你自己的表达式
表达式几乎和.NET 运行时一样强大和有能力。这意味着我们可以表达中间语言所包含的所有操作,最终由运行时执行。构造与中间语言非常不同,正如我们在第七章中看到的,推理表达式。它们都围绕一个树结构,左右表达式代表树中的节点。
使用表达式,我们不一定需要将它们限制为仅用作过滤数据的一种手段,实际上我们可以使用它们来持有执行操作的逻辑。由于它们的强大程度与中间语言相当,我们可以生成非常复杂的表达式树。
但权力越大,责任越大。尽管这是可能的,但这并不意味着这一定是个好主意。这些结构可能难以理解且难以调试,所以我建议不要全力以赴,而应将其视为一种新的编程语言。
假设我们有一个想要操作其属性的类型。为了简单起见,让我们将我们的类型设为一个具有字符串属性的类型,我们想要设置该属性:
public class MyType
{
public string StringProperty { get; set; } =
String.Empty;
}
对于这个示例,我们想要操作StringProperty属性,因此我们将其制作为一个类而不是记录,这样我们就可以操作其状态。
要创建一个表示我们需要构建的属性的表示式,首先从表示属性所属类型的某个东西开始。拥有类型也将由一个表示式表示。对于这个示例,我们不想让表达式创建MyType类型的实例,而是操作一个现有的实例。它将要操作的那个实例将由表示式的参数表示:
var parameter = Expression.Parameter(typeof(MyType));
参数接受参数的类型。但你也可以使用.Parameter()方法的某个重载给它一个名字,这在有多个参数时对于调试信息非常有用。
为了与属性一起工作,我们需要使用 C#反射来获取我们想要操作的属性的PropertyInfo:
var property = typeof(MyType).GetProperty
(nameof(MyType.StringProperty))!;
使用表示实例的参数,我们将操作实际属性。现在我们可以创建一个在类型上表示属性的表达式:
var propertyExpression = Expression.Property
(parameter,property);
我们最后想要做的是进行实际的赋值。为此,我们使用Expression.Assign()方法:
var assignExpression = Expression.Assign(
propertyExpression,
Expression.Constant("Hello world"));
Expression.Assign()方法接受表示目标的左侧表达式,而右侧表达式是源。在代码中,我们将其分配给一个Hello world的常量。
显然,这是一个非常简单的示例,实际上并没有做什么。你可以真正地利用表达式,利用诸如Expression.IfThen()和Expression.IfElse()之类的功能来处理if/else语句,你甚至可以执行Expression.Call()来调用方法,传递参数并处理结果。可以使用诸如Expression.Add()和Expression.Subtract()之类的功能来操作结果。你可以想象到的一切都可以做到。对于这本书,我们将在构建表示逻辑的表达式方面保持简单。
除非我们可以调用它们并获得结果,否则表达式实际上并没有什么用处。我们想要做的是构建我们想要的表达式树,然后能够非常容易地用标准的 C#代码调用它们。
创建表示式作为委托并执行它们
我们可以将表达式视为可以调用的方法或函数。它们可能包含参数或不包含参数,并且可能返回结果或不返回结果。我们可以将表达式表示为Action或Func。这两个都是System命名空间中找到的委托类型。Action表示一个无参数的操作,如果需要,可以返回结果。而Func表示一个具有一个或多个参数的函数,并且可以返回一个结果。这两种委托类型都可以接受泛型参数,代表输入参数类型和结果类型。
委托基本上只是方法的表示。它们定义了一个可调用签名。我们可以直接将委托作为方法调用。
使用表达式,我们可以将它们转换成可调用的表达式。这是通过Expression.Lambda()完成的。让我们基于我们之前拥有的属性赋值表达式来构建:
var parameter = Expression.Parameter(typeof(MyType));
var property = typeof(MyType).GetProperty
(nameof(MyType.StringProperty))!;
var propertyExpression = Expression.Property
(parameter,property);
var assignExpression = Expression.Assign(
propertyExpression,
Expression.Constant("Hello world"));
我们可以创建以下 lambda 表达式:
var lambdaExpression = Expression.Lambda<Action<MyType>>
(assignExpression, parameter);
Expression.Lambda()方法接受一个泛型参数——委托的类型。这个委托可以是任何你想要的委托类型——你自己的自定义委托或者直接使用Action或Func。在这个例子中,我们将使用Action
在 lambda 表达式就位的情况下,我们可以将表达式编译成一个可调用的委托并调用它:
var expressionAction = lambdaExpression.Compile();
var instance = new MyType();
expressionAction(instance);
Console.WriteLine(instance.StringProperty);
代码调用.Compile()方法,然后会将表达式编译成一个可以直接调用的委托。它创建一个MyType类型的实例,并将其传递给委托。运行程序,你现在应该看到以下内容:
Hello world
这非常直接,这个例子可能没有展示出全部潜力。
创建查询引擎
让我们稍微改变一下方向,提高几个级别的复杂性,使其更加相关。我们可以使用表达式来做数据动态查询。一些应用程序甚至可能希望让最终用户能够为你的数据创建任意查询。我参与过提供这种类型能力给最终用户的项目,如果为最终用户提供一个灵活的查询系统而没有像表达式这样的工具,这可能会是一个难题。我见过一些解决方案,它们基本上只是直接将 SQL 暴露给最终用户,而不是尝试解决这个问题。给最终用户提供这种级别的权力可能会在未来造成问题。你最终会完全消除数据存储和最终用户之间的所有抽象。祝你好运,改变技术或支持多种数据存储机制。但幸运的是,我们手中拥有表达式的力量,这给了我们创建一个成功之坑的抽象的机会。
如在第第七章中讨论的,表达式与它们如何为目标数据源执行翻译是分离的。只要我们保持我们的表达式树足够简单,就应能够针对数据源进行优化执行。
构建一个交互式查询用户界面是复杂的,所以对于这本书,让我们让它更侧重于开发者,并使我们的查询界面成为一个 JSON 文档。我们希望创建一个能够使用定义的查询语言查询数据的数据库。
一个类似 MongoDB 的数据库
让我们构建一个类似于文档数据库的东西。MongoDB 可以是一个好的蓝图。显然,我们不会构建一个完全功能的文档数据库,但我们将使用其特性,并从中汲取灵感。
MongoDB 有一个非常类似于 JSON 的查询语言,这意味着你将构建不同的子句作为键/值表达式。假设我们有一个如下所示的 JSON 文档:
{ "FirstName": "Jane", "LastName": "Doe", "Age": 57 }
使用类似 MongoDB 的语法,我们可以执行一个对LastName属性的等于匹配查询,如下所示:
{
"LastName": "Doe"
}
要添加更多需要匹配的属性——一个典型的And操作——你只需简单地添加另一个键/值属性,并设置你想要的值:
{
"LastName": "Doe",
"Age": 57
}
有时你会有Or语句,表示“这个”或“这个”,在 MongoDB 中,这将如下表示:
{
"LastName": "Doe",
"$or": [
{
"Age": 57
}
]
}
如果你想要执行需要值大于、小于或类似的查询,你需要一个右侧的对象结构:
{
"Age": {
"$gt": 50
}
}
如您所见,在 MongoDB 中,关键字前缀为$。我们不会实现所有这些,但只是几个——足以使其有趣。
构建一个简单的查询引擎
在满足要求后,我们现在可以开始构建能够解析查询并创建我们可以从代码中调用的表达式的引擎。
首先创建一个名为Chapter8的文件夹。在命令行界面中切换到这个文件夹,并创建一个新的控制台项目:
dotnet new console
添加一个名为QueryParser.cs的文件。向该文件添加以下内容:
using System.Linq.Expressions;
using System.Text.Json;
namespace Chapter8;
public static class QueryParser
{
static readonly ParameterExpression
_dictionaryParameter = Expression.Parameter(typeof
(IDictionary<string, object>), "input");
}
代码添加了我们需要为此工作所需的命名空间,然后添加了一个类,该类目前持有我们将传递到查询评估表达式的参数的表示,该表达式将在最后拥有。
文档数据库的一个特点是,默认情况下,它们没有放入其中的对象的形状定义,与具有列的表定义的 SQL 数据库不同。为了模仿这种行为,你将使用表示为IDictionary<string, object>的键/值字典。这是表达式的参数。
查询表达式将具有以下结构:
left-hand (operand) right-hand
这里有一个例子:
LastName equals Doe
这意味着我们需要构建正确的左值表达式和右值表达式。向QueryParser类添加以下方法:
static Expression GetLeftValueExpression(JsonProperty
parentProperty, JsonProperty property)
{
var keyParam =
Expression.Constant(parentProperty.Name);
var indexer = typeof(IDictionary<string,
object>).GetProperty("Item")!;
var indexerExpr = Expression.Property(
_dictionaryParameter, indexer, keyParam);
return property.Value.ValueKind switch
{
JsonValueKind.Number =>
Expression.Unbox(indexerExpr, typeof(int)),
JsonValueKind.String =>
Expression.TypeAs(indexerExpr, typeof(string)),
JsonValueKind.True or JsonValueKind.False =>
Expression.TypeAs(indexerExpr, typeof(bool)),
_ => indexerExpr
};
}
代码基于这样一个事实,即你的查询是以 JSON 构造的形式传入的。它是为递归而构建的,这就是为什么它需要一个 parentProperty 和一个 property。这是为了支持不仅仅是 equals 操作符,还支持嵌套的 greater than 类型操作符。在顶层,parentProperty 和 property 将是相同的,而当我们进行嵌套时,我们需要 parentProperty 而不是嵌套表达式中的 property,因为那代表操作符和值。
代码首先做的事情是创建一个访问字典的表达式。这是通过使用存在于 IDictionary<string, object> 上的 Item 属性来完成的。Item 属性不是你会在接口上看到的属性。它代表当你通常使用 ["SomeString"] 进行索引时的索引器。索引器是接受参数的属性。因此,代码通过使用 Expression.Property() 方法的某个重载来设置一个 IndexerExpression。它传递索引器属性和一个表示文档上属性的常量。
代码需要做的最后一件事是确保返回的值是正确类型的。由于我们有 IDictionary<string, object>,值被表示为 object。你这样做是为了能够使用不同的操作符(=、>、<)。如果你不这样做,你会得到一个异常,因为它不知道如何处理将 object 与右侧表达式的实际类型进行比较。
数字是值类型,需要取消装箱,而字符串和布尔值需要转换为它们的实际类型。
重要提示
JSON 数字被硬编码为 int。这只是本示例的一个简化。数字可能不仅仅是那样,例如 float、double、Int64 以及更多。
现在你有了左侧表达式,你需要右侧表达式:
static Expression GetRightValueExpression(JsonProperty
property)
{
return property.Value.ValueKind switch
{
JsonValueKind.Number =>
Expression.Constant(property.Value.GetInt32()),
JsonValueKind.String => Expression.Constant(
(object)property.Value.GetString()!),
JsonValueKind.True or JsonValueKind.False =>
Expression.Constant((object)property.Value
.GetBoolean()),
_ => Expression.Empty()
};
}
代码创建常量表达式,以获取正确类型的值。在 JsonProperty 类型中,Value 属性是 JsonElement 类型。它具有获取你期望类型实际值的方法。由于查询引擎在类型方面有些限制,它仅支持 int、string 和 bool。
由于右侧也到位了,你需要某种东西来构建左右两侧的表达式,并将它们组合成一个可以使用的表达式。定义的查询功能包括能够执行 greater than、less than 以及更多,我们将其定义为查询 JSON 中键/值部分的复杂对象。有了这个,你需要一个构建正确表达式的函数。
将以下方法添加到 QueryParser 类中:
static Expression GetNestedFilterExpression(JsonProperty
property)
{
Expression? currentExpression = null;
foreach (var expressionProperty in
property.Value.EnumerateObject())
{
var getValueExpression = GetLeftValueExpression(
property, expressionProperty);
var valueConstantExpression =
GetRightValueExpression(expressionProperty);
Expression comparisonExpression =
expressionProperty.Name switch
{
"$lt" => Expression.LessThan(
getValueExpression, valueConstantExpression),
"$lte" => Expression.LessThanOrEqual(
getValueExpression, valueConstantExpression),
"$gt" => Expression.GreaterThan(
getValueExpression, valueConstantExpression),
"$gte" => Expression.GreaterThanOrEqual(
getValueExpression, valueConstantExpression),
_ => Expression.Empty()
};
if (currentExpression is not null)
{
currentExpression = Expression.And(
currentExpression, comparisonExpression);
}
else
{
currentExpression = comparisonExpression;
}
}
return currentExpression ?? Expression.Empty();
}
代码枚举给定的对象,并使其能够有多个子句。它将这些子句作为 And 操作分组。它利用您之前创建的方法来获取左右表达式,然后对于每个支持的运算符,使用这些表达式来创建正确的运算符表达式。
由于 GetNestedFilterExpression 方法仅处理基于对象的嵌套过滤子句,您需要一个方法来处理顶层子句,并在它是嵌套对象时调用嵌套的子句:
static Expression GetFilterExpression(JsonProperty
property)
{
return property.Value.ValueKind switch
{
JsonValueKind.Object =>
GetNestedFilterExpression(property),
_ => Expression.Equal(GetLeftValueExpression(
property, property), GetRightValueExpression(
property))
};
}
根据值的类型,代码选择仅当它是对象表示时才使用嵌套表达式。对于其他所有内容,它创建一个简单的 equal 表达式。
之前,我们讨论了能够执行 Or 操作而不仅仅是 And 操作的能力。将以下方法添加到 QueryParser 类中:
static Expression GetOrExpression(Expression expression,
JsonProperty property)
{
Foreach (var element in property.Value.EnumerateArray())
{
var elementExpression = GetQueryExpression(element);
expression = Expression.OrElse(expression,
elementExpression);
}
return expression;
}
我们将 Or 表达式定义为表达式数组。代码将值枚举为数组,并对每个元素调用 GetQueryExpression –这是我们接下来需要的方法。从结果中,它创建一个 OrElse 表达式。OrElse 表达式的原因是我们只想在先前的表达式评估为 false 时评估 Or。
继续添加以下方法到 QueryParser 类中:
static Expression GetQueryExpression(JsonElement element)
{
Expression? currentExpression = null;
foreach (var property in element.EnumerateObject())
{
Expression expression = property.Name switch
{
"$or" => GetOrExpression(currentExpression!,
property),
_ => GetFilterExpression(property)
};
if (currentExpression is not null && expression is
not BinaryExpression)
{
currentExpression = Expression.And(
currentExpression, expression);
}
else
{
currentExpression = expression;
}
}
return currentExpression ?? Expression.Empty();
}
由于查询 JSON 可以包含多个语句,代码会遍历对象并评估每个属性。如果是 Or 表达式,则调用 GetOrExpression 方法。其他任何内容都转到 GetFilterExpression。这是您可以添加更多操作的地方。对于查询中的每个属性,它都会将它们组合在一起。
由于 GetQueryExpression 方法已经就位,我们已经拥有了查询引擎的所有逻辑。现在我们需要一个外部入口点。
将以下方法添加到 QueryParser 类中:
public static Expression<Func<IDictionary<string, object>,
bool>> Parse(JsonDocument json)
{
var element = json.RootElement;
var query = GetQueryExpression(element);
return Expression.Lambda<Func<IDictionary<string,
object>, bool>>(query, _dictionaryParameter);
}
Parse 方法接受一个表示查询的 JSON 文档,并返回一个表达式,该表达式旨在与单个文档一起使用,其中每个文档是 IDictionary<string, object>。它调用 GetQueryExpression 来根据 JSON 的根元素创建实际的表达式,然后将其包装在一个可调用的 lambda 表达式中,该表达式接受一个字典作为参数。
现在您已经设置了查询引擎,是时候创建一些数据和查询,以及一些用于测试的代码了。
添加一个名为 data.json 的文件,并在其中添加以下内容或您自己的内容:
[
{ "FirstName": "Jane", "LastName": "Doe", "Age": 57 },
{ "FirstName": "John", "LastName": "Doe", "Age": 55 },
{ "FirstName": "Michael", "LastName": "Corleone",
"Age": 47 },
{ "FirstName": "Anthony", "LastName": "Soprano",
"Age": 51 },
{ "FirstName": "Paulie", "LastName": "Gualtieri",
"Age": 58 }
]
对于查询本身,添加一个名为 query.json 的文件,并在其中添加以下内容或您自己的查询:
{
"Age": {
"$gte": 52
},
"$or": [
{
"LastName": "Doe"
}
]
}
您接下来想要编写代码来解析这些文件,并从 query.json 创建一个表达式。
为了能够解析 data.json 文件并获取一个漂亮的 Dictionary<string, object> 类型集合,我们需要一个 JSON 转换器。默认情况下,如果你尝试将 JSON 反序列化到这个集合中,序列化器会给你一个 JsonElement 类型的值。我们想要实际的值。在这个上下文中,JSON 转换器的列表和说明没有价值。它们可以在章节开头引用的 GitHub 仓库中找到。
在 Program.cs 文件中,将所有内容替换为以下内容:
var query = File.ReadAllText("query.json");
var queryDocument = JsonDocument.Parse(query);
var expression = QueryParser.Parse(queryDocument);
var documentsRaw = File.ReadAllText("data.json");
var serializerOptions = new JsonSerializerOptions();
serializerOptions.Converters.Add(new Dictionary
StringObjectJsonConverter());
var documents = JsonSerializer.Deserialize<Ienumerable
<Dictionary<string, object>>>(documentsRaw,
serializerOptions)!;
var filtered = documents.AsQueryable().Where(expression);
foreach (var document in filtered)
{
Console.WriteLine(JsonSerializer.Serialize(document));
}
代码加载 query.json 和 data.json 文件并解析它们。对于查询,在将其传递给 QueryParser.Parse() 方法之前,你需要将其作为 JsonDocument。而数据则反序列化成一个 Dictionary<string, object> 类型的集合。
由于文档是 IEnumerable,你得不到你想要的 .Where() 扩展方法。因此,代码首先执行 .AsQueryable(),然后将解析的查询表达式传递给它。
经过过滤的对象可以被枚举。
运行代码应该给出以下结果:
{"FirstName":"Jane","LastName":"Doe","Age":57}
{"FirstName":"John","LastName":"Doe","Age":55}
{"FirstName":"Paulie","LastName":"Gualtieri","Age":58}
与向最终用户展示 SQL 等类似的方法相比,这种方法的优点是,你现在有一个可以与不同数据存储一起工作的抽象。你甚至可以将相同的表达式应用于内存中,就像应用于数据库一样。
摘要
在本章中,我们学习了构建表达式和表达式树的力量。它们不仅能够表示查询,实际上,它们可以与生成中间语言代码一样强大。
使用表达式,你得到了中间语言的替代品。它们在生成中间语言代码方面确实有一些限制,例如,你不能简单地创建类型和实现接口或覆盖继承的虚拟方法的行为。但作为表达简单动作的工具,它们真的非常出色。
在下一章中,我们将探讨另一种利用 Dynamic 语言运行时 的能力动态创建类型和实现的方法。
第九章:利用动态语言运行时的优势
C# 是一种静态类型语言,这意味着我们将代码以文本形式提交给编译器,然后它生成一个二进制文件,稍后执行。编译器完成后,代码不会改变。并非所有语言都像这样;像 Ruby、Python 和 JavaScript 这样的语言是动态语言,在执行之前不会编译成二进制。它们在运行时被解释,这意味着它们也可以在运行时逐渐改变。这是一个非常强大的特性。
在本章中,我们将探讨如何利用.NET 运行时的动态语言运行时部分,以与我们迄今为止所做的方式不同地动态创建代码。
我们将涵盖以下主题:
-
理解 DLR
-
对动态类型进行推理
-
创建 DynamicObject 并提供元数据
到本章结束时,你将了解动态语言运行时是什么,以及你如何利用它动态创建类型并对它们进行推理。
技术要求
本章的特定源代码可以在 GitHub 上找到 (github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter9),并且它建立在以下位置找到的基础知识代码之上:github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Fundamentals。
理解 DLR
动态语言运行时(DLR)于 2010 年随.NET 4 一起引入。它运行在.NET 运行时之上,即公共语言运行时(CLR),通过 IronPython 和 IronRuby 实现为 Python 和 Ruby 等动态语言提供语言服务。有了 DLR,还可以在不同语言之间进行互操作,从而有效地使 C#和 Python、Ruby 或任何其他受支持的动态语言能够在同一进程中并行运行。
一瞥 CLR
在深入研究 DLR 之前,了解它必须与之合作的约束条件是有帮助的。CLR 是.NET 的一切核心,这意味着所有运行的内容都必须遵守 CLR 的规则。当查看 CLR 的特性时,这一点变得非常有趣。
即时编译(JIT)
CLR 使用 JIT 编译器在运行时将中间语言(IL)代码编译为特定机器和操作系统的本地代码。此过程使优化更好、执行更快,并实现跨平台兼容性。
自动内存管理
CLR 使用垃圾回收来管理内存分配和释放,自动释放不再使用的对象占用的内存。这有助于防止内存泄漏并优化内存使用。
类型安全
CLR 通过在代码执行期间强制执行严格规则来确保类型安全。这有助于防止无效的内存操作,例如访问尚未初始化的内存位置或使用一种类型的对象作为另一种类型。
异常处理
CLR 为所有 .NET 语言提供了一致且健壮的异常处理机制,允许开发者优雅地处理运行时错误并保持应用程序的稳定性。
跨语言互操作性
CLR 支持不同 .NET 语言之间的互操作性,允许开发者在同一应用程序中使用用不同语言编写的组件。这促进了代码重用并简化了开发过程。
版本控制和程序集管理
CLR 管理程序集(编译代码单元)的版本控制,有助于防止诸如“DLL 地狱”等问题,即共享库的冲突版本导致应用程序不稳定。它还提供了诸如并行执行等特性,允许同一系统上共存多个版本的程序集。
反射
CLR 允许开发者在运行时检查和交互类型、对象和程序集的元数据。这允许动态类型创建、方法调用和其他高级编程技术。
调试和性能分析
CLR 为 .NET 应用程序提供了集成的调试和性能分析支持,使开发者能够有效地诊断和优化代码。
DLR 的构建块
对于任何动态语言来说,DLR 需要支持一些与 CLR 更为静态的世界非常不同的特性。以下是需要的一些特性。
动态类型系统
一个动态运行时需要能够即时创建类型。类型永远不会是静态的,应该能够进行扩展。
动态方法分发
一个动态运行时应该允许调用事先未知的函数。
动态代码生成
一个动态运行时应该允许在运行时进行代码生成,允许你根据输入或其他代码的结果动态地添加代码。它还应该允许你修改代码。
从 CLR 的角度来看,这些类型的特性似乎非常不一致。微软的工程师扩展了核心功能,并实施了一个与 CLR 协作良好的动态运行时。
从元编程的角度来看,动态运行时的特性是完美的。作为 DLR 的一部分,有一系列构造和 API 可以让你动态创建类型,并对任何满足我们所需特性的动态类型进行推理。
由于 .NET 运行时本身是一个静态运行时,依赖于具有固定成员的已知类型,DLR 需要与此约束一起工作。因此,DLR 提供了一系列类和接口来表示动态对象、它们的属性和操作,例如 IDynamicMetaObjectProvider、DynamicMetaObject、DynamicObject 和 ExpandoObject。
为了让 DLR 能够与动态语言一起工作,它需要一种表示语言语义的方法。DLR 使用表达式树,我们在 第七章,推理表达式 和 第八章,构建和执行表达式 中更详细地讨论了这一点。随着 DLR 的到来,LINQ 表达式集增加了一系列扩展,为我们提供了控制流、赋值和其他语言建模节点。
从编译器的角度来看,C# 编译器有一个名为 dynamic 的关键字,允许你指定一个特定的变量或参数是动态类型。这告诉编译器在编译时不要评估实例的任何成员访问。相反,它将所有这些转换成将在执行时运行的适当 Expression。
要开始利用 DLR 及其动态功能,最简单的方法是使用 ExpandoObject 类型。ExpandoObject 提供了一种可以动态扩展的类型;它会保留你告诉它的任何内容,并且可以随着你的操作而扩展:
dynamic person = new ExpandoObject();
person.FirstName = "Jane";
person.LastName = "Doe";
代码创建了一个 ExpandoObject 实例,然后开始设置其上的值。ExpandoObject 类型本身不包含 FirstName 或 LastName 作为其类型的一部分,但通过设置它们,这些值就会存在。
访问这些成员的方式将相同;编译器理解 ExpandoObject 是 dynamic 的,并且将在运行时而不是在编译时尝试绑定到成员。以下代码将完全有效:
Console.WriteLine($"{person.FirstName} {person.LastName}");
结果将如预期:
Jane Doe
ExpandoObject 通过实现 IDynamicMetaObjectProvider 接口来完成这一点。通过 IDynamicMetaObjectProvider,它提供了自己的 DynamicMetaObject 实现,这实际上包含了执行我们告诉它执行的操作的所有魔法。
ExpandoObject 类型还实现了 IDictionary<string, object?> 接口,这是它表示所有赋予它的成员的方式,也是它能够动态增长的方式。通过其 DynamicMetaObject 的实现,它基本上只是在内部字典上工作。实际上,如果你想的话,完全可以把 ExpandoObject 当作字典来使用:
var person = new ExpandoObject() as IDictionary<string, object?>;
person["FirstName"] = "Jane";
person["LastName"] = "Doe";
如果你使用 dynamic 来处理数据对象,这会非常方便,因为它为你提供了一个简单的方式来推理对象及其内容,同时,通过将其视为具有属性的动态对象,提供了一个方便的编程体验。
Call sites 和绑定器
每当你想要在动态对象上执行操作时——例如,获取或设置属性或调用方法——它将通过一个称为 call site 的东西。call site 实际上是将在运行时执行代码的位置。对于编译后的 C#,这将通常是 IL 代码驻留的内存位置。对于其他语言,如 Ruby 或 Python,这将是在文本代码中的位置。每种语言都有自己的 call site 表示。
静态语言和动态语言之间的主要区别在于,静态语言是编译的,在运行时,它们要么执行某种中间语言,要么执行实际的机器语言。相比之下,动态语言将在运行时解释代码并执行结果。动态语言的好处是它可以动态地改变自己的代码,显然,这是以性能损失为代价的。
一旦你有了位置,或者调用点,你就可以绑定到成员,然后执行你想要的操作。
存在着执行对象典型操作的绑定器 – 获取和设置属性、调用方法、索引等。在整个书中,我们探讨了元数据的力量以及我们如何对运行中的代码进行推理;DLR 在这方面提出了一些挑战。
推理动态类型
DLR 在推理动态类型方面非常有限。主要的是,DLR 被构建为能够托管动态语言。自然地,与动态语言一样,你实际上并不知道动态类型在发生什么,它可能会随时间改变,因此即使成员已经存在,DLR 也无法通过提供任何细节(如类型信息)来提供反射体验。
然而,我们可以请求它有哪些成员。每个动态对象都实现了一个名为IDynamicMetaObjectProvider的接口。在这个接口上,你可以调用GetMetaObject()方法来获取一个允许我们与动态对象交互的对象。
让我们看看具有以下属性的ExpandoObject:
dynamic person = new ExpandoObject();
person["FirstName"] = "Jane";
person["LastName"] = "Doe";
由于ExpandoObject实际上实现了IDynamicMetaObjectProvider接口,我们可以请求它的元对象,然后请求它的成员:
var provider = (person as IDynamicMetaObjectProvider)!;
var meta = provider.GetMetaObject(Expression.Constant(person));
var members = string.Join(',', meta.GetDynamicMemberNames());
Console.WriteLine(members);
运行此代码将给出以下结果:
FirstName,LastName
代码本身通过将其转换为类型来假设它是IDynamicMetaObjectProvider,以便我们调用GetMetaObject()。我们创建的Expression代表我们正在获取元对象的个人实例。然后,我们调用GetDynamicMemberNames(),它返回一个包含所有成员名称的字符串集合。
这就是可以推理的范围。
然而,我们可以动态调用成员,这在不知道对象形状时非常有用。当不知道对象包含什么时调用可能会有些挑战,如果你想要支持一切,你需要回退机制,因为如果运行时无法以你想要的方式绑定,它将抛出RuntimeBinderException。
DynamicMetaObject有一组方法,可以进行所有绑定操作。这些绑定方法需要一个实际的绑定器,你可以从调用点获取。绑定后,你将得到一个新的DynamicMetaObject。使用所有这些方法来获取实际值是可能的,但让我们直接跳到 C#绑定器,获取我们想要的绑定器,并使用它。
在 Microsoft.CSharp.RuntimeBinder 命名空间中,有一个名为 Binder 的类。这个类包含一组静态方法来获取特定的绑定器。你可以使用的绑定器类型如下:
| 二元运算 | 用于二元运算 |
|---|---|
| 转换 | 用于转换为特定类型 |
| 获取索引 | 用于获取索引 |
| 获取成员 | 用于获取成员的值 |
| 调用 | 用于调用方法 |
| 调用构造函数 | 用于调用/构造构造函数 |
| 设置索引 | 用于设置索引 |
| 设置成员 | 用于将成员设置为某个值 |
| 一元运算 | 用于一元运算 |
由于我们拥有的对象只有属性,我们将使用 GetMember 绑定器来获取属性值,并通过绑定点最终获取实际值:
foreach (var member in meta.GetDynamicMemberNames())
{
var binder = Binder.GetMember(
CSharpBinderFlags.None,
member,
person.GetType(),
new[] {
CSharpArgumentInfo.Create(CsharpArgumentInfoFlags
.None, null) });
var site = CallSite<Func<CallSite, object,
object>>.Create(binder);
var propertyValue = site.Target(site, person);
Console.WriteLine($"{member} = {propertyValue}");
}
代码基于我们从 GetMetaObject() 获取的 meta 实例,并通过 GetDynamicMemberNames() 返回的成员名称进行迭代。对于每个成员,你可以获取它的绑定器——在我们的例子中,是 GetMember 绑定器——通过传递默认值 member 和 person 类型给它。
在 System.Runtime.CompilerService 命名空间中,你可以找到一个名为 CallSite<> 的类。这是一个动态站点类型,可以用来动态创建绑定点的站点。在我们的例子中,动态站点将是一个代表,Func<>,它允许我们用 person 来调用它并返回一个值。输入和输出的类型都是 object,因为实际类型是未知的。
运行此代码应该会给你以下结果:
FirstName = Jane
LastName = Doe
重要提示
代码假设所有成员都是属性,这可能会导致 RuntimeBinder 异常。如果你需要支持更多成员类型,你需要处理这个异常并回退到你想要支持的类型。
另一种你可以使用的方法是使用表达式。Expression 类有一个 Dynamic 方法,它允许你传入绑定器并创建一个 Lambda 表达式,这给你一个类似的 Func<> 委托,可以调用。
以下提供了一个可以调用的方法来创建 Func<>,我们可以调用它来获取成员的值:
Func<object, object> BuildDynamicGetter(Type type, string propertyName)
{
var binder = Binder.GetMember(
CSharpBinderFlags.None,
propertyName,
type,
new[] {
CSharpArgumentInfo.Create(CsharpArgumentInfoFlags
.None, null) });
var rootParameter =
Expression.Parameter(typeof(object));
var binderExpression = Expression.Dynamic(binder,
typeof(object), Expression.Convert(rootParameter,
type));
var getterExpression = Expression.Lambda<Func<object,
object>>(binderExpression, rootParameter);
return getterExpression.Compile();
}
代码获取 GetMember 绑定器,然后创建 DynamicExpression,这涉及到将我们将要传递的实例转换为 object。然后,代码创建一个 Lambda 表达式,我们随后对其进行编译以实现更高效的执行。
这个新方法可以按以下方式使用:
var firstNameExpression = BuildDynamicGetter(person.GetType(), "FirstName");
var lastNameExpression = BuildDynamicGetter(person.GetType(), "LastName");
Console.WriteLine($"{firstNameExpression(person)} {lastNameExpression(person)}");
此输出的结果如下:
Jane Doe
由于 DLR 本身在可发现性和推理方面有限,在某些用例中,自己提供元数据可能是一个好主意,使用其他格式和方法。
创建 DynamicObject 并提供元数据
有时候,你可能没有在代码中表示类型的奢侈。这可能是因为你正在调用某种外部 API,无论是 REST API、SOAP 服务还是类似的服务。然而,你调用的第三方可能有一个标准格式(如 WSDL 或 JSON 模式)中类型的表示。
尽管动态对象可以非常灵活,但在现实世界中,数据的形状往往更加严格。因此,你不必为所有内容都使用 ExpandoObject,你可以用自定义动态对象来表示这些类型,该对象从已知格式获取其元数据。今天,使用 JSON 作为数据载体非常普遍,利用 JSON 模式来表示数据的形状也很常见。让我们看看它如何成为元数据的提供者。
构建 JSON 模式类型
首先创建一个名为 Chapter9 的文件夹。在命令行界面中切换到这个文件夹,并创建一个新的控制台项目:
dotnet new console
我们将依赖第三方库,这为我们提供了一个轻松处理 JSON 模式的方法。通过运行以下命令将包添加到项目中:
dotnet add package NJsonSchema
JSON 模式是一个简单的结构,它描述了一个类型、其属性以及每个属性的类型。然而,它仅限于 JSON 可用的类型:
-
字符串
-
数字
-
布尔
-
数组
-
对象
然而,JSON 模式支持一个名为 格式 的概念,它可以作为类型的子类型使用。例如,日期不是 JSON 类型系统的一部分,但拥有一个类型为 string 且格式为 date 的属性将允许你支持任何你想要的类型,因为字符串可以包含任何内容。
JSON 模式还可以包含子模式,这使得在对象内拥有强类型对象定义成为可能。
对于这个示例,我们将保持非常简单,坚持使用简单类型。
将一个名为 person.json 的文件添加到你的项目中,并添加以下内容:
{
„$schema": „http://json-schema.org/draft-04/schema#",
"title": "Person",
"type": "object",
"additionalProperties": false,
"properties": {
"FirstName": {
"type": "string"
},
"LastName": {
"type": "string"
},
"Birthdate": {
"type": "string",
"format": "date"
}
}
}
在 JSON 中,你可以找到 title 属性,它是类型的名称,而 type 属性设置为 object,因为它描述了一个对象。然后模式包含三个属性,其类型如下:
| 属性 | 类型 |
|---|---|
| 首字母 | 字符串 |
| 姓氏 | 字符串 |
| 出生日期 | 日期 |
表 9.1 – 人的模式
添加一个名为 JsonSchemaType.cs 的文件,并将以下代码添加到其中:
using System.Dynamic;
using NJsonSchema;
namespace Chapter9;
public class JsonSchemaType : DynamicObject
{
readonly IDictionary<string, object?> _values = new
Dictionary<string, object?>();
readonly JsonSchema _schema;
public JsonSchemaType(JsonSchema schema)
{
_schema = schema;
}
代码创建了一个名为 JsonSchemaType 的新类型,它继承自 System.Dynamic 命名空间中找到的 DynamicObject 类型。这个特定的类型是一个辅助类型,创建它的目的是为了使实现动态对象更加容易。为了表示类型的实际内容,代码添加了 Dictionary<string, object?>。这使得你可以将其中的任何内容放入其中,就像 ExpandoObject 所做的那样。将 object? 作为值类型的原因是允许任何内容,并且明确表示我们允许其中包含空值。构造函数接受来自你之前添加的 NJsonSchema 依赖项的 JsonSchema 类型。
验证属性
由于 JSON 架构提供了类型的完整描述,包括其属性和属性的类型,它为你提供了验证对象上设置的值的机会。
让我们在对象中引入一些基本的验证。首先添加一个新文件,该文件将给出可能发生的具体问题的显式异常。添加一个名为InvalidTypeForProperty.cs的文件,并将以下代码添加到其中:
public class InvalidTypeForProperty : Exception
{
public InvalidTypeForProperty(string type, string
property) : base($"Property '{property}' on '{type}'
is invalid.")
{
}
}
自定义异常有两个属性 - 错误属性所属类型的名称以及实际错误的属性。你也可以为了保险起见包括它试图设置的类型以及期望的类型,但为了使示例简单,让我们只使用这个。
要进行实际验证,你需要将.NET 类型转换为JsonObjectType的东西。回到JsonSchemaType.cs文件,添加一个将.NET 类型转换为JsonObjectType所需的方法。将以下方法添加到JsonSchemaType类中:
JsonObjectType GetSchemaTypeFrom(Type type)
{
return type switch
{
Type _ when type == typeof(string) =>
JsonObjectType.String,
Type _ when type == typeof(DateOnly) =>
JsonObjectType.String,
Type _ when type == typeof(int) =>
JsonObjectType.Integer,
Type _ when type == typeof(float) =>
JsonObjectType.Number,
Type _ when type == typeof(double) =>
JsonObjectType.Number,
_ => JsonObjectType.Object
};
}
代码使用简单的模式匹配并将.NET 类型转换为JsonObjectType。对于没有明确转换的情况,它默认为JsonObjectType.Object。
重要提示
此实现非常简单,并不涵盖所有.NET 类型,你可能需要为生产环境使其更复杂。不过,它应该给你一个一般性的概念。
现在你有了从 CLR 类型转换为JsonObjectType的方法,你需要一种实际进行验证的方法。将以下方法添加到JsonSchemaType类中:
void ValidateType(string property, object? value)
{
if (value is not null)
{
var schemaType = GetSchemaTypeFrom(
value.GetType());
if (!_schema.ActualProperties[property]
.Type.HasFlag(schemaType))
{
throw new InvalidTypeForProperty(_schema.Title,
property);
}
}
}
代码只有在值不为 null 时才会验证该值,因为除非它有值,否则你无法知道它的类型。有了类型,它就获取实际的架构类型,然后检查属性是否具有此类型。JsonObjectType是一个标志枚举,允许组合,这就是为什么你必须使用HasFlag方法。如果类型不正确,代码会抛出InvalidTypeForProperty异常。
重要提示
在这里,一个提示是也要验证是否允许 null。此信息也由 JSON 架构支持。某些类型本身也不允许 null,例如整数或布尔值,你可能不应该允许这种情况。
实现获取和设置属性
在构造函数和验证就绪后,你现在可以覆盖DynamicObject类型的某些默认行为。DynamicObject类型提供了一组虚拟方法,可以覆盖以执行不同的操作,例如获取或设置属性和调用方法。
在这个示例中,我们将主要关注属性。将以下方法添加到JsonSchemaType类中:
public override bool TrySetMember(SetMemberBinder binder, object? value)
{
if (!_schema.ActualProperties.ContainsKey(binder.Name))
{
return false;
}
ValidateType(binder.Name, value);
_values[binder.Name] = value;
return true;
}
TrySetMember签名接受SetMemberBinder,它包含有关正在设置的属性的详细信息。它还获取值,该值可能为 null。然后代码首先通过查看ActualProperties字典来验证该属性是否实际存在于模式中。如果不存在,它立即返回false,如果你尝试设置一个未知的属性,那么你会得到RuntimeBinderException。当属性存在时,代码验证值的类型。如果类型正确,代码然后将其值设置在其私有字典中。
一旦你设置了一个属性,你也想读取它。向JsonSchemaType类添加以下方法:
public override bool TryGetMember(GetMemberBinder binder, out object? result)
{
if (!_schema.ActualProperties.ContainsKey(binder.Name))
{
result = null!;
return false;
}
result = _values.ContainsKey(binder.Name)
? result = _values[binder.Name] : result = null!;
return true;
}
与TrySetMember类似,代码会检查模式是否有该属性。然而,由于这是一个get操作,并且方法的签名规定结果应该作为out参数给出,我们需要显式地将它设置为null!。然后代码检查私有字典是否包含该属性的值,如果包含则返回它,如果不包含则返回一个null!值。
重要提示
在一个生产系统中,你通常不希望仅返回null,如果值没有被设置。它应该被设置为该类型的默认值,或者如果 JSON 模式包含具有默认值的附加元数据,你应该使用那个值。
作为一项值得拥有的功能,我们希望允许这种类型转换为另一种类型——在我们的例子中,是Dictionary<string, object?>。
DynamicObject提供的一个可以重写的方法是TryConvert方法。如果从类型显式转换为不同的目标类型,将调用此方法。向JsonSchemaType类添加以下方法:
public override bool TryConvert(ConvertBinder binder, out object? result)
{
if (binder.Type.Equals(typeof(Dictionary<string,
object?>)))
{
var returnValue = new Dictionary<string,
object?>(_values);
var missingProperties =
_schema.ActualProperties.Where(_ =>
!_values.Any(kvp => _.Key == kvp.Key));
foreach (var property in missingProperties)
{
object defaultValue = property.Value.Type
switch
{
JsonObjectType.Array =>
Enumerable.Empty<object>(),
JsonObjectType.Boolean => false,
JsonObjectType.Integer => 0,
JsonObjectType.Number => 0,
_ => null!
};
returnValue[property.Key] = defaultValue;
}
result = returnValue;
return true;
}
return base.TryConvert(binder, out result);
}
代码只允许转换为Dictionary<string, object?>,因此它首先通过查看传入的ConvertBinder类型的Type属性来检查这一点。返回模式中所有属性的值是一种良好的实践,不要遗漏任何属性,并且由于所有属性可能都已设置,代码从现有的_values字典创建一个新的字典,然后找到任何缺失的属性。对于每个缺失的属性,它都会设置一个默认值。
重要提示
默认值已经简化。如前所述,对于生产,你应该考虑对默认值采用更复杂的方法,并查看 JSON 模式中可能存在的附加元数据。
我们想要添加的最后一块拼图是让外部人士能够推理出哪些成员可用。DynamicObject类型为我们提供了一个可以重写的方法。向JsonSchemaType类添加以下方法:
public override IEnumerable<string> GetDynamicMemberNames() => _schema.ActualProperties.Keys;
此方法将由DynamicObject产生的DynamicMetaObject调用。DynamicObject也是IDynamicMetaObjectProvider,并实现了GetMetaObject()方法。
DynamicObject 有更多可重写的方法来调用方法、执行二元运算等。然而,对于这个示例,我们将专注于使用属性的数据方面。
使用模式基础设施
您到目前为止构建的是一个用于处理 JSON 模式的基础设施,以及 DLR。让我们来试一试 JsonSchemaType。打开 Program.cs 文件并移除其所有内容。添加以下内容:
using NJsonSchema;
using Chapter9;
var schema = await JsonSchema.FromFileAsync("person.json");
dynamic personInstance = new JsonSchemaType(schema);
var personMetaObject = personInstance.GetMetaObject(Expression.Constant(personInstance));
var personProperties = personMetaObject.GetDynamicMemberNames();
Console.WriteLine(string.Join(',', personProperties));
代码从您之前创建的 person.json 文件中读取 JSON 模式。然后,它创建一个 JsonSchemaType 实例并将其模式传递给它。由于 JsonSchemaType 是一个动态对象,它实现了 IDynamicMetaObjectProvider,我们可以使用对象的实例调用 GetMetaObject() 方法,然后获取其成员。
运行时,代码应该产生以下结果:
FirstName,LastName
设置和获取属性应该按预期工作:
personInstance.FirstName = "Jane";
Console.WriteLine($"FirstName : '{personInstance.FirstName}'");
运行代码应该给出以下输出:
FirstName : 'Jane'
由于 JsonSchemaType 的实现支持未设置的属性的默认值,您可以无任何问题地获取一个有效的属性:
Console.WriteLine($"LastName : '{personInstance.LastName}'");
这应该产生以下结果:
LastName : ''
将此属性转换为字典也应该通过显式转换直接工作:
var dictionary = (Dictionary<string, object>)personInstance;
Console.WriteLine(JsonSerializer.Serialize(dictionary));
这应该产生以下结果:
{"FirstName":"Jane","LastName":null}
要测试验证,您可以尝试将 LastName 属性设置为不支持的数据类型:
personInstance.LastName = 42;
这应该产生以下结果:
Unhandled exception. Chapter9.InvalidTypeForProperty: Property 'LastName' on 'Person' is invalid.
为了验证一切按预期工作,我们可以设置一个在模式中不存在的属性:
personInstance.FullName = "Jane Doe";
这应该产生以下结果:
Unhandled exception. Microsoft.CSharp.RuntimeBinder.RuntimeBinderException: 'Chapter9.JsonSchemaType' does not contain a definition for 'FullName'
使用 DynamicObject 辅助类型简化了动态对象的开发,因为您不必担心移动部件的复杂性,可以专注于您想要提供的动态对象的实际形状和能力。
摘要
本章探讨了 .NET 运行时的动态领域以及您如何利用它来创建在 C# 代码之外定义类型的动态类型。当通过 API 与第三方合作时,这种方法可能非常有用。如果您查看 REST API 和 OpenAPI 标准,您会发现 JSON 模式的广泛使用,将本章中介绍的方法与这些标准相结合,可以为您提供一种强大的机制来动态集成第三方,同时您还可以对数据的形状保持严格。
DLR 可以成为您工具箱中的一个强大工具。它提供了另一种动态创建类型和代码的方法。与生成中间语言代码相比,它可能看起来更直观。
DLR 的一个缺点是生成的类型在现代 IDE 或代码编辑器中可能难以处理,因为它不了解这些类型,无法提供诸如 IntelliSense 成员等服务。
使用 DLR 和动态方法的一个方面可能是性能。它的性能可能不如生成中间语言代码。这是您必须做出的权衡之一,但在特定场景下,这可能根本不是问题。
在下一章中,我们将稍微改变一下方向,看看我们如何利用本书中讨论的一些技术,并开始从约定 而非配置的角度思考。
第三部分:提高生产力、一致性和质量
在这部分,您将看到如何使用元编程来提高代码质量,并使您的代码库更加可维护和一致。同时,这部分还为您提供了关于如何通过技术提高您和您的开发者的生产力的想法。不同的章节涉及原则和软件设计模式,以及它们如何在现实生活中应用。
本部分包含以下章节:
-
第十章,约定优于配置
-
第十一章,应用开闭原则
-
第十二章,超越继承
-
第十三章,应用横切关注点
-
第十四章,面向方面编程
第十章:约定优于配置
我们的程序需要配置。这些配置中的一些是数据库连接字符串或我们正在调用的 REST API 的 URL 等。这些可能取决于我们的代码运行的不同环境(例如,开发、测试或生产)。除此之外,我们通常还需要配置我们的代码,以便能够按预期运行。我们进行的配置类型通常由我们使用的第三方库或框架决定。在增长型解决方案中,这种类型的配置也倾向于增长,并且由于配置通常在程序启动的特定点进行,因此最终成为这种类型事物的垃圾场的情况并不少见。
在本章中,我们将探讨如何利用我们在运行代码中已有的元数据的力量,使代码自动配置并因此变得更加一致。
我们将涵盖以下主题:
-
控制反转及其作用
-
通过约定自动进行 ServiceCollection 注册
到本章结束时,你将了解约定能为你做什么,以及它们如何使你更高效,并允许你创建更一致的代码库。
技术要求
本章的特定源代码可以在 GitHub 上找到 (github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter10),它基于可在github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Fundamentals找到的基础知识代码。
你需要安装 Docker Desktop (www.docker.com/products/docker-desktop/)、Postman (www.postman.com)以及 MongoDB 编辑器,如 Compass (www.mongodb.com/try/download/compass)。
控制反转及其作用
当软件的源代码超过一页时,软件需要快速建立结构。通常,你会根据系统中的特定目的将事物逻辑地分组到类型中。随着你的软件被拆分以更好地维护,不同的部分通常相互依赖,以便能够执行你需要它完成的整体任务。
构建用户注册模块
让我们构建一个简单的系统,该系统处理作为 REST API 公开的用户注册功能。首先创建一个名为 Chapter10 的文件夹。在命令行中切换到该文件夹,并创建一个新的基于 Web 的项目:
dotnet new web
你想要捕获的信息类型包括个人信息以及我们希望作为我们 API 主体的用户凭证。添加一个名为 RegisterUser.cs 的文件,并将其添加以下内容:
namespace Chapter10;
public record RegisterUser(string FirstName, string
LastName, string SocialSecurityNumber, string UserName,
string Password);
RegisterUser 类型包含您想要为 API 用户捕获的所有不同属性。这不是您想要直接存储在数据库中的内容。当您存储这些信息时,您希望将其存储为两个独立的部分——用户凭据和用户详细信息。创建一个名为 User.cs 的文件,并将其添加以下内容:
namespace Chapter10;
public record User(Guid Id, string UserName, string
Password);
User 类型仅捕获实际用户名和密码,并为用户有一个唯一的标识符。然后添加一个名为 UserDetails 的文件,并添加以下内容:
namespace Chapter10;
public record UserDetails(Guid Id, Guid UserId, string
FirstName, string LastName, string SocialSecurityNumber);
UserDetails 包含我们从 RegisterUser 类型中获取的其余信息。
下一步我们需要的是一个 API 控制器来接收这些信息并将信息存储到数据库中。我们将使用 MongoDB 作为后端存储。
我们将依赖第三方库来访问 MongoDB。通过在终端中运行以下命令将包添加到项目中:
dotnet add package mongodb.driver
创建一个名为 UsersController 的文件,并将其添加以下内容:
using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver;
namespace Chapter10;
[Route("/api/users")]
public class UsersController : Controller
{
IMongoCollection<User> _userCollection;
IMongoCollection<UserDetails> _userDetailsCollection;
public UsersController()
{
var client = new MongoClient
("mongodb://localhost:27017");
var database = client.GetDatabase("TheSystem");
_userCollection = database.GetCollection<User>
("Users");
_userDetailsCollection = database.GetCollection
<UserDetails>("UserDetails");
}
[HttpPost("register")]
public async Task Register([FromBody] RegisterUser
userRegistration)
{
var user = new User(Guid.NewGuid(),
userRegistration.UserName,
userRegistration.Password);
var userDetails = new UserDetails(Guid.NewGuid(),
user.Id, userRegistration.FirstName,
userRegistration.LastName, userRegistration
.SocialSecurityNumber);
await _userCollection.InsertOneAsync(user);
await _userDetailsCollection.InsertOneAsync
(userDetails);
}
}
代码在其构造函数中设置数据库并获取我们将存储用户信息的两个不同集合。注册 API 方法然后将 RegisterUser 分解成两个相应的类型并将它们插入到各自的 MongoDB 集合中。
重要提示
在实际系统中,您显然会使用强大的(最好是单向的)加密策略来加密密码,而不仅仅是将其作为明文存储。
打开您的 Program.cs 文件,并使其看起来如下所示:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.UseRouting();
app.UseEndpoints(_ => _.MapControllers());
app.Run();
在运行到目前为止的解决方案之前,您需要启动 MongoDB 服务器。您可以通过 Docker 来完成此操作。在您的终端中运行以下命令:
docker run -d -p 27017:27017 mongo
命令应启动 MongoDB 作为后台守护进程并公开端口 27017,以便您可以连接到它。您应该看到以下类似行:
9fb4b3c16d7647bfbb69eabd7863a169f6f2e4218191cc69c7454978627
f75d5
这是运行 Docker 图像的唯一标识符。
您现在可以从终端运行您到目前为止创建的代码:
dotnet run
您现在应该看到以下类似的内容:
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5000
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: /Users/einari/Projects/
Metaprogramming-in-C/Chapter10/
测试 API
到目前为止的代码,现在您有一个具有 /api/users/register 路由的 API,该路由接受 HTTP POST 请求。
您可以通过以下步骤使用 Postman 测试您的 API:
-
选择 POST。
-
输入 API 的 URL – http://localhost:5000/api/user/register。
-
在 Body 选项卡中,选择 Raw 作为输入,然后选择 JSON 作为类型。
重要提示
URL 的端口号必须与输出中提到的端口号匹配,即 Now listening on: http://localhost:{your port}。

图 10.1 – 使用 Postman 测试 API
点击 Send 后,您应该在底部获得 200 OK。然后您可以打开 MongoDB 编辑器——例如,Compass,如预置要求中建议的那样。
创建一个新的 MongoDB 服务器连接并执行以下步骤:
-
确保连接字符串指向您的 MongoDB 服务器。默认情况下,它应该显示 mongodb://localhost:27017,这与代码匹配。
-
点击连接按钮。

图 10.2 – 创建新的连接
连接成功后,你应该在左侧看到数据库TheSystem以及其中的集合。点击user集合或user-details,你应该在右侧看到你注册的数据。

图 10.3 – 注册的数据
这一切都很正常,代码确实按照预期完成了它的任务。但代码可以改进。
代码重构
这种类型的代码有几个挑战:
-
首先,控制器正在承担基础设施的责任
-
其次,它还承担了实际领域逻辑的责任,并确切知道如何在数据库中存储数据
API 界面应该只依赖于其他子系统来完成它们特定的任务,然后委托给它们,而不是成为组合。
例如,我们可以将用户凭证注册和用户详细信息注册隔离成两个不同的服务,我们可以使用它们。
创建服务
让我们稍微拆分一下,并开始添加一些结构。创建一个名为UsersService.cs的文件,并使其看起来如下:
using MongoDB.Driver;
namespace Chapter10;
public class UsersService
{
readonly IMongoCollection<User> _usersCollection;
public UserService()
{
var client = new MongoClient
("mongodb://localhost:27017");
var database = client.GetDatabase("TheSystem");
_usersCollection = database.GetCollection<User>
("Users");
}
public async Task<Guid> Register(string userName,
string password)
{
var user = new User(Guid.NewGuid(),
userRegistration.UserName, userRegistration
.Password);
await _usersCollection.InsertOneAsync(user);
return user.Id;
}
}
代码在注册用户方面与UsersController中执行的操作完全相同,只是现在正式化为服务。让我们为用户详细信息做同样的事情。创建一个名为UserDetailsService.cs的文件,并使其看起来如下:
namespace Chapter10;
public class UserDetailsService
{
readonly IMongoCollection<User> _userDetailsCollection;
public UserDetailsService(IDatabase database)
{
var client = new MongoClient
("mongodb://localhost:27017");
var database = client.GetDatabase("TheSystem");
_userDetailsCollection = database.GetCollection
<User>("UserDetails");
}
public Task Register(string firstName, string lastName,
string socialSecurityNumber, Guid userId)
=> _userDetailsCollection_.InsertOneAsync
(new(Guid.NewGuid(), userId, firstName, lastName,
socialSecurityNumber));
}
与UsersService一样,代码与UsersController中的原始代码完全相同,但现在它是分离的,并且更加专注。
这是一个很好的步骤。现在,数据库的基础设施细节对外部世界是隐藏的,任何想要注册用户的人只需要关注完成注册所需的信息,而不需要关注它是如何完成的。
下一步是你需要将UsersController更改为利用新服务。
更改控制器
去更改控制器,使其看起来如下:
[Route("/api/users")]
public class UsersController : Controller
{
readonly UsersService _usersService;
readonly UserDetailsService _usersDetailsService;
public UsersController()
{
_usersService = new UsersService();
_userDetailsService = new UserDetailsService();
}
[HttpPost("register")]
public async Task Register([FromBody] RegisterUser
userRegistration)
{
await _usersService.Register(
userRegistration.UserName,
userRegistration.Password);
await _userDetailsService.Register(
userRegistration.FirstName,
userRegistration.LastName,
userRegistration.SocialSecurityNumber);
}
}
代码在构造函数中创建UsersService类的实例,并在Register API 方法中直接使用Register方法。
如果你现在运行这个示例并再次执行HTTP POST,你将得到完全相同的结果。
UsersService和UserDetailsService现在是UsersController的依赖项,并且它作为实例创建这些依赖项。这有几个缺点。依赖项现在基本上遵循控制器的生活周期。由于控制器是每个 Web 请求创建一次,这意味着UsersService和UserDetailsService也将每次创建。这可能会成为性能问题,并且并不是控制器应该担心的问题。它的主要工作只是提供一个用于注册用户的 API 界面。
由于依赖项现在被硬编码,并且它带来了所有基础设施,这使得为UsersController编写测试变得非常困难,因为它使得单独测试UsersController的逻辑变得更加困难。
这就是依赖反转发挥作用的地方,通过反转关系,我们说系统,在我们的案例中是UsersController,不负责创建实例本身,而是将其作为构造函数的参数,并让任何实例化控制器的人负责提供UsersController所需的依赖项。
将UsersController修改为通过构造函数接受依赖项:
[Route("/api/users")]
public class UsersController : Controller
{
readonly UsersService _usersService;
readonly UserDetailsService _usersDetailsService;
public UsersController(
UsersService usersService,
UserDetailsService userDetailsService)
{
_usersService = usersService;
_userDetailsService = userDetailsService;
}
[HttpPost("register")]
public async Task Register([FromBody] RegisterUser userRegistration)
{
await _usersService.Register(
userRegistration.UserName,
userRegistration.Password);
await _userDetailsService.Register(
userRegistration.FirstName,
userRegistration.LastName,
userRegistration.SocialSecurityNumber);
}
}
代码现在接受UsersService和UserDetailsService作为参数,并直接使用它们,而不是自己创建它们的实例。
现在,依赖关系对外部世界来说非常清晰。因此,UsersService的生命周期可以在控制器之外进行管理。
然而,由于控制器正在处理具体的实例,它仍然与基础设施紧密相连。这可以通过解耦基础设施并使其更易于测试来改进。
基于契约
为了进一步改进,我们还可以将UsersService和UserDetailsService的内容提取到接口中,并使用这些接口。这样做的好处是,您将解耦具体实现及其基础设施需求,并通过允许不同的实现以及根据配置或系统处于特定状态来切换接口的实现来增加代码的灵活性。
将内容提取到接口中的另一个好处是,这使得编写仅关注被测试单元及其依赖项交互的测试变得更加容易,而无需引入整个基础设施来编写自动化测试。
创建一个名为IUsersService.cs的文件,并使其看起来如下:
namespace Chapter10;
public interface IUsersService
{
Task<Guid> Register(string userName, string password);
}
代码保留了与原始UsersService类中相同的签名的Register方法。然后,UsersService的实现仅通过添加IUsersService继承来改变。打开UsersService文件,使其实现IUsersService接口:
public class UsersService : IUsersService
{
/*
Same code as before within the UsersService
*/
}
对于UserDetailsService,我们想要做同样的事情。添加一个名为IUserDetailsService.cs的文件,并使其看起来如下:
namespace Chapter10.Structured;
public interface IUserDetailsService
{
Task Register(string firstName, string lastName, string
socialSecurityNumber, Guid userId);
}
代码保留了与原始UserDetailsService类中相同的签名的Register方法。然后,UserDetailsService的实现仅通过添加IUserDetailsService继承来改变。打开UserDetailsService文件,使其实现IUserDetailsService接口:
public class UserDetailsService : IUserDetailsService
{
/*
Same code as before within the UserDetailsService
*/
}
通过这两项更改,我们现在可以改变表达依赖关系的方式。在UsersController中,您将使用UsersService改为IUsersService,并将UserDetailsService改为IUserDetailsService:
[Route("/api/users")]
public class UsersController : Controller
{
readonly IUsersService _usersService;
readonly IUserDetailsService _userDetailsService;
public UsersController(
IUsersService usersService,
IUserDetailsService userDetailsService)
{
_usersService = usersService;
_userDetailsService = userDetailsService;
}
// Same register API method as before would go here
}
现在的代码使用它们的接口来获取两个 IUsersService 和 IUserDetailsService 依赖项,其余代码保持不变。
到目前为止,我们已经讨论了依赖项和 依赖反转原则 的好处。然而,我们还需要能够提供这些依赖项。如果我们必须手动在系统各处提供这些依赖项,并以不同的方式维护它们的生命周期,这将非常不切实际。它可能导致非常混乱、难以维护的代码库,也可能导致未知副作用。
你真正需要的是一种为你管理这些的机制。这就是所谓的控制反转容器(IoC 容器)。它的任务是保存有关所有服务的信息,包括用于什么接口的实现,以及这些服务的生命周期。IoC 容器是一个集中式的组件,你需要在应用程序开始时进行配置,配置完成后,你可以要求它提供已注册的任何实例。这对于注册任何类型的依赖项非常有用,而不仅仅是那些作为实现接口的依赖项。你可以注册具体类型、代理类型,或者几乎任何东西。
IoC 容器递归地工作,并将处理依赖项的依赖项,并正确地解决所有问题。
在 ASP.NET Core 中,IoC 容器的概念已经默认设置好,并且使用名为 ServiceCollection 的机制非常容易使用,其中你可以设置所有服务注册。
通过约定自动注册 ServiceCollection
我们现在将代码留在了非功能状态。这是因为内置的 IoC 容器不知道如何解决 IUsersService 依赖项和 IUserDetailsService。
你需要明确告诉 ASP.NET 应该使用哪个实现。打开你的 Program.cs 文件,并按照以下方式添加绑定:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// Add these two lines to bind the services
builder.Services.AddSingleton<IUsersService,
UsersService>();
builder.Services.AddSingleton<IUserDetailsService,
UserDetailsService>();
var app = builder.Build();
app.UseRouting();
app.UseEndpoints(_ => _.MapControllers());
app.Run();
代码在 ASP.NET Core 的 ServiceCollection 中添加了一个注册项,将 IUsersService 解析为 UsersService,并且它还明确指出应该将其添加为 单例。这意味着在进程内将只有一个此类服务的实例。
现在,你应该又有一个正常工作的程序了,唯一的区别现在就是 ASP.NET 的 IoC 容器正在为你解析实例,并且对于每个请求,它都将使用相同的实例。
重要提示
单例可能会很危险。如果一个类型作为单例,它依赖于不应该作为单例但应该在每个请求中都是新的东西,那么这个单例类型将成为一个阻碍。要明智地使用它们。
到目前为止,代码非常简洁且简单。在实际系统中,你通常会希望通过将代码划分为更专注的单元来更加清晰地划分责任。
进一步重构
目前 UsersService 承担的责任过多。数据库部分不是它应该拥有的东西。知道如何创建数据库连接是应该提取出来,以便你在同一个地方做这件事。要意识到你系统中每个单元的责任。
让我们引入一个表示数据库的单位。首先,通过向项目中添加一个名为 IDatabase.cs 的文件来创建一个接口,并使其看起来如下:
using MongoDB.Driver;
namespace Chapter10;
public interface IDatabase
{
IMongoCollection<T> GetCollectionFor<T>();
}
该接口使我们能够通过泛型指定类型来获取 MongoDB 集合。
重要提示
通过为数据库创建一个抽象,我们可以进一步争论,可以创建一个代表你通常执行的数据库操作的东西,这符合所谓的存储库模式。如果你这样做,你会很快发现你将无法与底层数据库及其功能一起工作。此外,对于本书的上下文,我们将保持在这个级别。
在有了 IDatabase 接口之后,你现在需要一个实现它的实现。创建一个名为 Database.cs 的文件,并将其添加如下:
using MongoDB.Driver;
namespace Chapter10;
public class Database : IDatabase
{
static readonly Dictionary<Type, string>
_typeToCollectionName = new()
{
{ typeof(User), "Users" },
{ typeof(UserDetails), "UserDetails" }
};
readonly IMongoDatabase _mongoDatabase;
public Database()
{
var client = new MongoClient
("mongodb://localhost:27017");
_mongoDatabase = client.GetDatabase("TheSystem");
}
public IMongoCollection<T> GetCollectionFor<T>() =>
_mongoDatabase.GetCollection<T>(_typeToCollectionName
[typeof(T)]);
}
代码包含了你在 UsersService 中大部分的数据库访问,但增加了将类型映射到集合名称的维度。
作为一个小插曲,但仍然是在约定优于配置的主题上,让我们稍微改进一下 Database 类。在类的顶部,有一个将 Type 映射到集合名称的映射。这是随着时间的推移而增长的东西。如果你查看 User 类型,它被映射到 Users – 我更喜欢这种约定,因为集合名称是复数形式,表示有多个用户。
重要提示
约定优于配置的概念是由 David Heinemeier Hansson 提出的,用来描述 Ruby on Rails 网络框架的哲学。你可以在这里了解更多信息:rubyonrails.org/doctrine#convention-over-configuration。
这是可以自动化的,真正是约定优于配置。让我们引入一个第三方库来为我们处理复数化。转到你的终端,并在 Chapter10 文件夹中运行以下命令:
dotnet add package Humanizer
Humanizer 库默认知道如何复数化英语单词,但它也支持其他语言。我建议你到 GitHub 上了解更多信息(github.com/Humanizr/Humanizer)。
安装了包后,你可以改进和简化你的 Database 代码。将 Database 类修改如下:
using Humanizer;
using MongoDB.Driver;
namespace Chapter10.Structured;
public class Database : IDatabase
{
readonly IMongoDatabase _mongoDatabase;
public Database()
{
var client = new MongoClient("mongodb://
localhost:27017");
_mongoDatabase = client.GetDatabase("TheSystem");
}
public IMongoCollection<T> GetCollectionFor<T>() =>
_mongoDatabase.GetCollection<T>(typeof(T).Name
.Pluralize());
}
代码几乎相同,只是你现在不再有Dictionary来映射Type和集合名称。对于GetCollection()方法,你不再需要进行查找,而是直接使用类型名称,并在其上使用.Pluralize()扩展方法。通过这种方式,你以非常简单的方式利用了类型元数据。
通过这次修复,你基本上使你的代码具备了未来性,并且不需要对代码进行开放心脏手术来添加对新集合的支持。这是一个可预测的约定。
由于你现在已经将系统的基础设施部分封装到了Database类中,你现在可以开始修复UserService和UserDetailsService,以便利用这个核心组件。
首先,将UsersService修改成以下样子:
namespace Chapter10;
public class UsersService : IUsersService
{
readonly IDatabase _database;
public UsersService(IDatabase database)
{
_database = database;
}
public async Task<Guid> Register(string userName,
string password)
{
var user = new User(Guid.NewGuid(), userName,
password);
await _database.GetCollectionFor<User>()
.InsertOneAsync(user);
return user.Id;
}
}
代码现在完全去除了对数据库连接的管理以及如何获取集合的管理,甚至不再需要集合的名称,而是引入IDatabase作为依赖项,并让该接口的实现承担全部的基础设施责任。现在这只是一个约定,你可以相信它将为提供的类型提供一个可预测的集合名称。
你需要对UserDetailsService做同样的操作。修改成以下样子:
namespace Chapter10.Structured;
public class UserDetailsService : IUserDetailsService
{
readonly IDatabase _database;
public UserDetailsService(IDatabase database)
{
_database = database;
}
public Task Register(string firstName, string lastName,
string socialSecurityNumber, Guid userId)
=> _database.GetCollectionFor<UserDetails>()
.InsertOneAsync(new(Guid.NewGuid(), userId,
firstName, lastName, socialSecurityNumber));
}
与UsersService一样,代码更改几乎相同,引入了IDatabase基础设施依赖,并让UserDetailsService专注于其主要的任务,即注册用户详情。
代码重构的活动导致了一个更加解耦的系统和一个更易于维护的系统,其中每个组件都专注于做一件事,单一职责。现在需要将其整合起来。
组合
将系统拆分成专注的组件后,我们需要将其整合起来。由于 IoC 容器不知道如何解析IDatabase,我们需要添加这个绑定。将你的Program.cs修改成以下样子:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSingleton<IUsersService,
UsersService>();
builder.Services.AddSingleton<IUserDetailsService,
UserDetailsService>();
// Add these two lines to bind the services
builder.Services.AddSingleton<IDatabase, Database>();
var app = builder.Build();
app.UseRouting();
app.UseEndpoints(_ => _.MapControllers());
app.Run();
代码添加了.AddSingleton<IDatabase, Database>()调用,以注册IDatabase和Database之间的绑定,并说明我们只需要它是一个单例。此时,你应该又拥有了一个正常工作的系统。运行这个程序并使用 Postman 进行 API 调用,应该会得到与之前相同的结果。
然而,这里已经有点代码异味了。我们必须手动添加注册到 IoC 容器中的每一项,这又是一次必须进行的开放心脏手术。对于只有几个组件,这不仅变得繁琐,而且很快就会使Program.cs成为此类配置的垃圾场。
幸运的是,有一个模式我们可以将其转变为一种约定。所有的实现都有一个接口表示,其名称与实现相同,只是前面加了一个大写字母 I。这是一个非常常见的约定。我们可以通过发现接口和实现之间的联系来使代码更具未来性。
尽管我们可以发现实现和接口之间的关系,但我们不知道如何知道它们的生命周期。为了做到这一点,我们将知道生命周期的责任转移到实现上。我们通过引入以属性形式存在的元数据来实现这一点。默认行为应该是我们进行的每个绑定都是 transient,这意味着每次我们向 IoC 容器请求时都会得到一个新的实例。然后我们只需要为覆盖该行为提供属性。对于这个示例,我们将只保留一个生命周期:单例。
添加一个名为 SingletonAttribute.cs 的文件,并使其看起来如下:
namespace Chapter10;
[AttributeUsage(AttributeTargets.Class)]
public sealed class SingletonAttribute : Attribute
{
}
代码代表了一个属性,它适用于类,并允许你在发现过程中查找,并决定它是否是一个单例。
让我们利用在先决条件中提到的 GitHub 仓库中提到的 Fundamentals 项目。你应该通过在终端执行以下操作来为这一章添加对该项目的引用:
dotnet add reference ../Fundamentals/Fundamentals.csproj
重要提示
项目的路径可能因你的计算机而异,取决于你从 GitHub 仓库中获取的 Fundamentals 项目。
你现在想要做的是为 IServiceCollection 创建一个扩展方法,你一直用它来注册绑定。
首先,添加一个名为 ServiceCollectionExtensions.cs 的文件,并使其看起来如下:
using Fundamentals;
namespace Chapter10;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddBindingsBy
Convention(this IServiceCollection services, ITypes
types)
{
return services;
}
}
这段代码仅设置了一个名为 AddBindingsByConvention() 的新扩展方法,它还从 Fundamentals 中获取 ITypes 系统,并返回给它的 services,以便在使用方法时能够进行链式调用。
前往并添加以下代码到 AddBindingsConvention() 方法的顶部:
Func<Type, Type, bool> convention = (i, t) => i.Namespace
== t.Namespace && i.Name == $"I{t.Name}";
var conventionBasedTypes = types!.All.Where(_ =>
{
var interfaces = _.GetInterfaces();
if (interfaces.Length > 0)
{
var conventionInterface = interfaces
.SingleOrDefault(i => convention(i, _));
if (conventionInterface != default)
{
return types!.All.Count(type => type
.HasInterface(conventionInterface)) == 1;
}
}
return false;
});
代码使用 ITypes 来获取系统中所有发现的数据类型。对于每一个类型,它都会检查该类型是否实现了任何接口。如果实现了接口,它将检查是否有任何接口符合约定。约定是接口类型和实现类型必须在同一个命名空间中,并且接口类型必须与实现类型的名称匹配,只是前面加了一个大写字母 I。
这样做将得到一个符合约定的类型集合。接下来,你需要添加代码来注册绑定。在 AddBindingsByConvention() 方法中添加以下代码之后:
foreach (var conventionBasedType in conventionBasedTypes)
{
var interfaceToBind = types.All.Single(_ =>
_.IsInterface && convention(_, conventionBasedType));
if (services.Any(_ => _.ServiceType == interfaceTo
Bind))
{
continue;
}
_ = conventionBasedType.HasAttribute
<SingletonAttribute>() ?
services.AddSingleton(interfaceToBind,
conventionBasedType) :
services.AddTransient(interfaceToBind,
conventionBasedType);
}
代码遍历所有遵循约定的类型,获取实际的接口,然后根据实现是否具有 SingletonAttribute 将其绑定为单例或瞬态。
让我们使用属性将所有服务设置为单例。打开Database.cs文件,在类型声明前添加SingletonAttribute:
[Singleton]
public class Database : IDatabase
{
// Keep your original code
}
对于UserDetailsService也做同样的操作:
[Singleton]
public class UserDetailsService : IUserDetailsService
{
// Keep your original code
}
然后对UsersService也做同样的操作。
[Singleton]
public class UsersService : IUsersService
{
// Keep your original code
}
您现在需要做的就是更改程序启动。打开Program.cs,将其更改为以下内容:
using Chapter10;
using Fundamentals;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// Create an instance of Types and register it with the IoC
var types = new Types();
builder.Services.AddSingleton<ITypes>(types);
// Add all the bindings based on convention
builder.Services.AddBindingsByConvention(types);
var app = builder.Build();
app.UseRouting();
app.UseEndpoints(_ => _.MapControllers());
app.Run();
代码从所有不同服务的显式绑定变为现在利用Fundamentals中的Types类,将其绑定为一个单例,然后添加所有由惯例发现的绑定。
运行您的应用程序应该仍然给您带来相同的行为,并且它应该以完全相同的方式工作。唯一的区别是现在一切都是通过惯例,您只需添加内容,无需进行任何配置。
摘要
在本章中,您已经尝到了惯例在具体日常 C#编码中的强大作用,了解了如何通过优化注册服务接口的常见场景来提高在 ASP.NET Core 中使用 IoC 容器的工作体验。我们还探讨了通过契约设计如何帮助您创建一个更加灵活且易于测试的系统。
“惯例优于配置”的概念可能是对我个人职业生涯影响最大的事情。它使您的代码更加一致,如果您未能保持一致,代码将无法工作,这本身就是一个好事,因为那时您将不得不修复代码以使其更加一致。
不必配置一切,只需能够添加代码,这确实能大大提高生产力,任何参与项目的人都会为此感谢您。然而,您需要向所有团队成员清楚地说明这些惯例,否则他们可能不会感谢您。没有什么比代码似乎任意地工作或不工作更糟糕的了。请记录下来,并清楚地说明事物是如何运作的。
值得一提的是,惯例并非适用于所有项目,也并非适用于所有团队。为了使惯例有意义,团队需要接受这种工作方式。如果团队更喜欢阅读代码并看到所有内容都明确设置,那么惯例只会造成混淆。如果项目非常小,可能不值得付出认知上的开销。
在下一章中,我们将更深入地探讨开放/封闭原则,这是我们本章中提到的内容,并看看它如何使您的代码库受益。
第十一章:应用开放封闭原则
开放封闭原则归功于伯特兰·梅耶(Bertrand Meyer),该原则在他的 1988 年著作《面向对象软件构造》(Object-Oriented Software Construction)中首次出现(en.wikipedia.org/wiki/Object-Oriented_Software_Construction)。这本书描述了以下我们可以应用到我们的软件中的原则:
-
一个类型是开放的,如果它可以被扩展
-
当一个类型对其他类型可用时,它就是封闭的
假设我们有一个名为 Shape 的类,它有一个名为 area 的方法,该方法反过来计算形状的面积。我们希望能够在不修改 Shape 类的情况下将新形状添加到我们的程序中,因此我们使 Shape 类对扩展开放。
为了做到这一点,我们创建了一个名为 Triangle 的新类,它继承自 Shape 并重写了 area 方法来计算三角形的面积。我们还可以创建一个 Rectangle 类和任何其他我们想要的新的形状。
现在,每当我们需要计算形状的面积时,我们只需创建适当的形状类的新实例并调用其 area 方法。因为 Shape 类对修改是封闭的,所以我们不需要每次添加新形状到我们的程序时都修改它。
C# 中的类默认是开放的,可以扩展。我们可以从任何非密封的类继承,并为它们添加新的含义。但是,我们正在继承的基类应该是封闭的,这意味着新类型要工作,不需要对基类型进行任何更改。
这有助于我们设计可扩展的代码,并保持责任在正确的位置。
退一步,我们可以在系统级别应用一些内容。如果我们能够简单地扩展我们的系统,而不需要在类型的中心添加配置以使其了解添加的内容,那会怎么样?
这种思维方式是我们之前在 第四章,使用反射推理类型 中使用的,与 ICommand 接口及其实现有关。我们添加的两个命令不为系统的任何部分所知,但通过实现 ICommand 接口,我们可以通过系统的内省看到这两种类型。
在本章中,我们将涵盖以下主题:
-
开放封闭原则的目标
-
封装类型发现
-
封装实例的发现
-
与服务集合连接
-
一个实际的应用场景
到本章结束时,你将了解如何设置你的代码和项目以成功,使它们更加灵活和可扩展,以及如何创建欢迎变化和添加的代码,而无需每次都对代码进行大手术。
技术要求
本章的特定源代码可以在 GitHub 上找到(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter11),并且它基于基础代码,可以在github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Fundamentals找到。
开放封闭原则的目标
在一个不断变化和增长的需求、新发现的商业机会,甚至是对你业务进行转型调整的世界中,如果你的软件需要进行三次旁路手术才能进行更改,那么这并不是很有帮助。敏捷思维的核心在于能够灵活并及时地应对变化。从商业角度来看,这种弹性是非常有用的。目标是当变化发生时能够成本效益。在我参与的项目或产品开发中,我经常注意到这一点转化为一种临时的心态,并且常常完全忽视以使代码能够持久并让开发者理解的方式进行编码。
开发初期,到达软件产品第一个版本令人兴奋的部分,是我们为代码库的未来代码设定基调的时候。然而,在大多数情况下,这仅代表了代码生命周期的一小部分。这就是为什么我相信我们应该更多地关注我们如何为代码的维护成功做好准备。
我经常遇到的一个非常常见的想法是,仅仅推出第一个版本是关键,之后我们可以开始修复我们最初不引以为豪的所有事情,比如我们因为认为没有时间做“正确”的事情而不得不采取的所有捷径。追逐最小可行产品(MVP)是一种非常流行的方法。这种追逐是由将产品推向市场并从中学习的需求驱动的。不幸的是,根据我的经验,我们往往根本无法创建一个可行的产品,而只是证明了我们想要构建的原型。它们可能表面上看起来像产品,但缺乏一个合适的立足点。从非技术角度来看,这正好是开发者想要的,他们想要你继续前进。而且谁又能责怪他们呢?开始使用这些产品的客户也可能觉得自己在使用一个产品,但他们根本不知道表面之下发生了什么。很快,在推出第一个版本之后,业务、客户和最终用户会回来提出他们希望软件能做的事情。我还没有看到任何企业在这个时候优先考虑修复基础。当这一点与软件中报告的不足或具体错误结合起来时,你最终会朝着新的目标冲刺。你推出第一个版本时的兴奋感消失了,工作变成了苦差事。
我们没有将基于设计的思考融入代码中,这对业务造成了巨大的损害,这本来可以为企业成功奠定基础。代码可能比原本的状态更糟糕,这使得进行所有这些更改变得困难。这通常会导致开发者满意度降低,他们最终可能会决定离开,希望他们下一个工作的地方能提供一个更好的工作环境来编写更好的代码。
我要带这些去哪里呢?我确实相信可以交付真正的最小可行产品(MVP),大幅度降低代码库中的技术债务,并使迭代 MVP 成为可能,以更快的速度、更准确的方式交付更多内容,并拥有一个强大的基础。我相当确信企业更愿意选择后者——一种不会一上市就慢慢消亡的东西。
在实现这一点的核心中,坐落着我最喜欢的一个原则:开闭原则。正如我之前提到的,我认为这是一种战略思维:不仅是一种编写类的战术方法,而是一种系统级的方法。我们如何确保我们可以在任何时候随意添加代码,扩展我们软件的功能,同时有信心它不会破坏现有的系统?如果我们能够避免修改现有代码来实现我们的新目标,我们就可以大幅度降低软件回归的风险。
我们可以利用这本书中迄今为止构建的构建块来实现这一点,并在此基础上进行改进,使它们更加友好。
封装类型发现
在第四章,“使用反射推理类型”,我们介绍了一个名为Types的类,它封装了用于查找类型的逻辑。这是一个非常强大的结构,它使软件能够扩展。我们可以使其更好一些,并在其之上构建一个结构,以简化其使用。
在Types类中,我们有一个名为FindMultiple()的方法,它允许我们找到实现了特定类型的类型。对这个方法的一个小改进是允许我们通过在描述此类型的特定类型的构造函数中引入依赖来表示我们想要实现的不同类型。
重要提示
您可以在技术要求部分提到的存储库的基础部分找到这个实现的实现。
这个概念基本上是将类型表示为一个接口,如下所示:
public interface IImplementationsOf<T> : IEnumerable<Type>
where T : class
{
}
该接口接受一个泛型类型,该类型描述了您感兴趣的类型的实现。然后它说明这是一个Type的可枚举类型,这使得您可以直接遍历它。class的泛型约束存在是为了限制您可以工作的类型范围,因为允许原始类型(如int)是不继承的,这并不实用。
接口的实现非常直接,看起来如下所示:
public class ImplementationsOf<T> : IImplementationsOf<T>
where T : class
{
readonly IEnumerable<Type> _types;
public ImplementationsOf(ITypes types)
{
_types = types.FindMultiple<T>();
}
public IEnumerator<Type> GetEnumerator()
{
return _types.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return _types.GetEnumerator();
}
}
代码将ITypes作为依赖项,因为它将使用它来实际找到实现。
发现类型是一回事,这使得方法更优雅,代码更易读、更清晰。但这只提供了类型。更常见的方法是不仅获取类型,还要获取其实例。
封装实例的发现
我们可以执行的另一个封装,这可能更适合我们当前的场景,是找到实现并直接获取它们的实例。
重要提示
您可以在存储库的基础部分,在技术要求部分的技术要求中找到这个实现的实现。
这个概念基本上是将代码表示得与使用IImplementationsOf
public interface IInstancesOf<T> : IEnumerable<T>
where T : class
{
}
代码中的接口使用泛型参数来确定它可以提供实例的类型。然后它实现IEnumerable
接口的实现非常直接,如下所示:
public class InstancesOf<T> : IInstancesOf<T>
where T : class
{
readonly IEnumerable<Type> _types;
readonly IServiceProvider _serviceProvider;
public InstancesOf(ITypes types,
IServiceProvider serviceProvider)
{
_types = types.FindMultiple<T>();
_serviceProvider = serviceProvider;
}
public IEnumerator<T> GetEnumerator()
{
foreach (var type in _types) yield return
(T)_serviceProvider.GetService(type)!;
}
IEnumerator IEnumerable.GetEnumerator()
{
foreach (var type in _types) yield return
_serviceProvider.GetService(type);
}
}
代码利用了ITypes,并且与ImplementationsOf
注意,实现仅在枚举时请求IServiceProvider,而不是在构造函数中直接这样做。这样做的原因是,你不想承担某些东西依赖于生命周期比实现更长的IInstancesOf
唯一的缺点是,当为没有在IServiceProvider上注册具体类型的类型调用GetService()时,你会得到null。这并不很有用。
连接到服务集合
由于我们利用ServiceProvider来创建实例,并且由于它的默认行为是将所有内容显式注册到它那里,它可能没有为我们请求的具体类型注册。
在第十章,“约定优于配置”中,你实现了一个根据约定发现接口和实现之间关系的功能。我们可以扩展这种思考,并说应该能够根据它们的类型解决类本身。
要做到这一点,我们利用更多的反射元数据来过滤掉我们不感兴趣的不同类型。
在第十章,“约定优于配置”中,你创建了一个名为Service CollectionExtensions的东西。我们希望在这里也使用它,但我们还想添加一些额外的功能。将文件移动到共享的Fundamentals项目中。
打开ServiceCollectionExtensions类并添加以下内容:
public static IServiceCollection AddSelfBinding(this IServiceCollection services, ITypes types)
{
const TypeAttributes staticType =
TypeAttributes.Abstract | TypeAttributes.Sealed;
types.All.Where(_ =>
(_.Attributes & staticType) != staticType &&
!_.IsInterface &&
!_.IsAbstract &&
services.Any(s => s.ServiceType !=
_)).ToList().ForEach(_ =>
{
var __ = _.HasAttribute<SingletonAttribute>() ?
services.AddSingleton(_, _) :
services.AddTransient(_, _);
});
return services;
}
代码使用ITypes系统,通过忽略静态类来过滤类型,因为它们不能被实例化。此外,它还忽略接口和抽象类型,原因相同。然后,它忽略任何已在services集合中注册的类型。任何剩下的类型都会被注册。如果类型有[Singleton]属性,它将注册为单例;否则,它使用transient生命周期,这意味着每次你请求时都会得到一个新的实例。
这样一来,我们正在为更实际地应用我们所拥有的内容做准备。
实际用例
让我们构建一些利用新发现形式的东西,更重要的是,展示一个弹性增长系统的概念,该系统不需要在核心进行更改即可添加新功能。
让我们重新审视合规性的概念,这在软件中是一个非常常见的场景。我们在 第四章,使用反射进行类型推理 和 第五章,利用属性 中探讨了 GDPR,GDPR 只是合规性的一种类型,处理它非常重要。
目标是创建一个系统,其中核心“引擎”不知道可能存在的不同类型的合规性元数据,而是提供可扩展点,开发者可以在需要时添加新的合规性元数据类型。
我们希望引擎是通用的,并且对每个人都是可访问的。根据技术要求中提到的 GitHub 仓库,你会发现有一个Fundamentals项目。这里列出的所有代码都可以在那里找到。
让我们首先创建一个记录,它表示利用Fundamentals中找到的ConceptAs<>基记录所利用的元数据类型。添加一个名为ComplianceMetadataType.cs的文件,并使其看起来如下:
namespace Fundamentals.Compliance;
public record ComplianceMetadataType(Guid Value) : ConceptAs<Guid>(Value)
{
public static readonly ComplianceMetadataType PII = new(Guid.Parse("cae5580e-83d6-44dc-9d7a-a72e8a2f17d7"));
public static implicit operator
ComplianceMetadataType(string value) =>
new(Guid.Parse(value));
public static implicit operator
ComplianceMetadataType(Guid value) => new(value);
}
代码引入了一个继承自ConceptAs<>的record实例,并将其中的值设为Guid实例。然后它添加了一个用于个人身份信息的已知类型,或简称为PII。该类型还添加了从Guid实例的string表示形式以及直接从Guid实例转换的隐式运算符。
重要提示
ConceptAs<> 类型在 第四章,使用反射进行类型推理 中进行了解释。
接下来,我们想要一个表示实际元数据的类型。添加一个名为ComplianceMetadata.cs的文件,并使其看起来如下:
namespace Fundamentals.Compliance;
public record ComplianceMetadata(ComplianceMetadataType MetadataType, string Details);
代码将元数据表示为对元数据类型的引用,后跟一个Details字符串。
为了使我们能够发现元数据,我们需要某种可以提供这些元数据的东西。我们将其表示为一个接口,它可以独立地为我们提供发现。
添加一个名为ICanProvideComplianceMetadataForType.cs的文件,并使其看起来如下:
namespace Fundamentals.Compliance;
public interface ICanProvideComplianceMetadataForType
{
bool CanProvide(Type type);
ComplianceMetadata Provide(Type type);
}
代码表示一个提供者,它通过CanProvide()方法决定是否能够提供元数据,然后是一个实际提供元数据的方法。可扩展性的关键在于这种模式,它使我们能够插入任何可以针对任何类型调用的实现,而实现本身则决定它是否可以提供元数据。
由于ICanProvideComplianceMetadataForType仅关注为Type提供元数据,因此我们需要另一个提供者来处理类型的属性。
添加一个名为ICanProvideComplianceMetadataForProperty.cs的文件,并使其看起来如下:
namespace Fundamentals.Compliance;
public interface ICanProvideComplianceMetadataForProperty
{
bool CanProvide(PropertyInfo property);
ComplianceMetadata Provide(PropertyInfo property);
}
与ICanProvideComplianceMetadataForType一样,您会看到ICanProvideComplianceMetadataForProperty有CanProvide()方法和一个Provide()方法;唯一的区别是它专注于PropertyInfo。
在可发现接口就绪后,我们可以开始构建发现这些内容并将它们组合成可以作为一个系统利用的引擎。
让我们先通过添加一个接口来定义合规引擎的合同。添加一个名为IComplianceMetadataResolver.cs的文件,并使其看起来如下:
namespace Fundamentals.Compliance;
public interface IComplianceMetadataResolver
{
bool HasMetadataFor(Type type);
bool HasMetadataFor(PropertyInfo property);
IEnumerable<ComplianceMetadata> GetMetadataFor(Type
type);
IEnumerable<ComplianceMetadata>
GetMetadataFor(PropertyInfo property);
}
代码添加了询问是否Type或PropertyInfo与其关联元数据的方法,以及获取关联元数据的方法。
帮助开发者
明确调用代码是否跳过调用Has*()方法并直接调用Get*()方法,以及是否没有元数据是很重要的。如果没有元数据,Get*()方法实际上无法做任何事情,只能抛出异常。
添加一个名为NoComplianceMetadataForType.cs的文件,并使其看起来如下:
namespace Fundamentals.Compliance;
public class NoComplianceMetadataForType : Exception
{
public NoComplianceMetadataForType(Type type)
: base($"Types '{type.FullName}' does not have any
compliance metadata.")
{
}
}
代码使用清晰的名称和消息表示类型没有任何元数据来表示异常。
让我们对没有元数据的属性做同样的事情。添加一个名为NoComplianceMetadataForProperty.cs的文件,并使其看起来如下:
namespace Fundamentals.Compliance;
public class NoComplianceMetadataForProperty : Exception
{
public NoComplianceMetadataForProperty(PropertyInfo
property)
: base($"Property '{property.Name}' on type
'{property.DeclaringType?.FullName}' does not
have any compliance metadata.")
{
}
}
与NoComplianceMetadataForType一样,NoComplianceMetadataForProperty异常很清晰,正如其异常名称和消息所暗示的,它说明了哪些属性没有与它们关联的元数据。
现在您可以创建实现IComplianceMetadataResolver接口的实现。
添加一个名为ComplianceMetadataResolver.cs的文件,并使其看起来如下:
namespace Fundamentals.Compliance;
public class ComplianceMetadataResolver : IComplianceMetadataResolver
{
readonly IEnumerable<
ICanProvideComplianceMetadataForType> _typeProviders;
readonly IEnumerable<
ICanProvideComplianceMetadataForProperty>
_propertyProviders;
public ComplianceMetadataResolver(
IInstancesOf<ICanProvideComplianceMetadataForType>
typeProviders,
IInstancesOf<
ICanProvideComplianceMetadataForProperty>
propertyProviders)
{
_typeProviders = typeProviders.ToArray();
_propertyProviders = propertyProviders.ToArray();
}
}
代码使用IInstancesOf<>来处理ICanProvideComplianceMetadataForType提供者类型和ICanProvideComplianceMetadataForProperty提供者类型。它在构造函数中收集所有这些类型并创建它们的实例。
重要提示
可能不希望保留实例,就像这里展示的情况一样。这完全取决于实现。在这个用例中,这是可以的。
现在您需要实现接口中的方法。将以下代码添加到ComplianceMetadataResolver类的末尾:
public bool HasMetadataFor(Type type) => _typeProviders.Any(_ => _.CanProvide(type));
public IEnumerable<ComplianceMetadata> GetMetadataFor(Type type)
{
ThrowIfNoComplianceMetadataForType(type);
return _typeProviders
.Where(_ => _.CanProvide(type))
.Select(_ => _.Provide(type))
.ToArray();
}
void ThrowIfNoComplianceMetadataForType(Type type)
{
if (!HasMetadataFor(type))
{
throw new NoComplianceMetadataForType(type);
}
}
代码使用在构造函数中发现的_typeProviders来确定它给出的类型是否是提供者可以提供元数据的类型。如果可以,它将返回 true;如果不可以,它将返回 false。GetMetadataFor()方法检查它是否可以提供;如果它不能,它将抛出NoComplianceMetadataForType异常。如果它可以,它将筛选出可以提供的提供者,然后要求它们提供。然后它将所有元数据组合成一个集合。
支持的属性
您现在想对属性做同样的事情。将以下代码添加到ComplianceMetadataResolver类的末尾:
public bool HasMetadataFor(PropertyInfo property) => _propertyProviders.Any(_ => _.CanProvide(property));
public IEnumerable<ComplianceMetadata> GetMetadataFor(PropertyInfo property)
{
ThrowIfNoComplianceMetadataForProperty(property);
return _propertyProviders
.Where(_ => _.CanProvide(property))
.Select(_ => _.Provide(property))
.ToArray();
}
void ThrowIfNoComplianceMetadataForProperty(PropertyInfo property)
{
if (!HasMetadataFor(property))
{
throw new
NoComplianceMetadataForProperty(property);
}
}
对于类型,代码询问_propertyProviders是否可以提供,然后使用它通过GetMetadataFor()方法过滤属性。如果没有元数据,当请求时将发生与类型抛出异常相同的操作。
在引擎就绪后,我们现在需要利用它并创建第一个实现。
在基础中,你应该已经有一个名为个人可识别信息属性的属性。现在你想要创建一个提供者,可以在属性级别提供此属性的元数据。
在Fundamentals文件夹中的Compliance文件夹的子文件夹中,创建一个名为PersonalIdentifiableInformationMetadataProvider.cs的文件,并使其看起来如下:
namespace Fundamentals.Compliance.GDPR;
public class PersonalIdentifiableInformationMetadataProvider : ICanProvideComplianceMetadataForProperty
{
public bool CanProvide(PropertyInfo property) =>
property.GetCustomAttribute<PersonalIdentifiableInformationAttribute>() != default ||
property.DeclaringType?.GetCustomAttribute<PersonalIdentifiableInformationAttribute>() != default;
public ComplianceMetadata Provide(PropertyInfo
property)
{
if (!CanProvide(property))
{
throw new
NoComplianceMetadataForProperty(property);
}
var details = property.GetCustomAttribute<
PersonalIdentifiableInformationAttribute>()!
.ReasonForCollecting;
return new
ComplianceMetadata(ComplianceMetadataType.PII,
details);
}
}
代码在给定的属性中查找个人可识别信息属性;如果它在属性或声明类型中存在,它可以提供合规元数据。如果存在,此方法提供合规元数据并使用收集原因来提供详细信息。
使用 GDPR 基础设施
在你的第一个 GDPR 提供者就绪后,你可以开始创建利用它的系统。
现在让我们在存储库的根目录下创建一个名为Chapter11的文件夹。在命令行界面中切换到这个文件夹,并创建一个新的控制台项目,如下所示:
dotnet new console
你将利用 Microsoft 托管模型来获取.NET 默认服务提供者,而不需要启动 Web 应用程序。为此,你需要名为Microsoft.Extensions.Hosting的包。在终端中,你将按照以下操作添加引用:
dotnet add package Microsoft.Extensions.Hosting
下一步你需要做的是引用基础项目。在终端中,执行以下操作:
dotnet add reference ../Fundamentals/Fundamentals.csproj
现在你可以开始为患者系统建模一个简单的领域模型。首先添加一个名为JournalEntry.cs的文件,并使其看起来如下:
namespace Chapter11;
public class JournalEntry
{
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
}
代码添加了一个表示患者日志条目的类型,具有标题和内容属性。
添加一个名为Patient.cs的文件,并使其看起来如下:
using Fundamentals.Compliance.GDPR;
namespace Chapter11;
public class Patient
{
[PersonalIdentifiableInformation("Employment records")]
public string FirstName { get; set; } = string.Empty;
[PersonalIdentifiableInformation("Employment records")]
public string LastName { get; set; } = string.Empty;
[PersonalIdentifiableInformation("Uniquely identifies
the employee")]
public string SocialSecurityNumber { get; set; } =
string.Empty;
public IEnumerable<JournalEntry> JournalEntries { get;
set; } = Enumerable.Empty<JournalEntry>();
}
代码添加了一个定义,包含患者的名字、姓氏和社保号码,以及该患者的所有日志条目。对于个人信息,使用[个人可识别信息]作为元数据的形式。
打开Program.cs文件,并使其看起来如下:
using Chapter11;
using Fundamentals;
using Fundamentals.Compliance;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var host = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
var types = new Types();
services.AddSingleton<ITypes>(types);
services.AddBindingsByConvention(types);
services.AddSelfBinding(types);
})
.Build();
代码设置了一个通用的宿主构建器,将类型注册为单例,然后利用AddBindingsByConvention()(你在第十章,约定优于配置中创建的)通过约定连接服务。然后调用AddSelfBinding(),这是你在本章早期引入的。最后,构建宿主实例。
使用宿主实例,我们获取服务属性,这是构建服务提供者。您使用此属性来获取IComplianceMetadataResolver的实例。
在 Program.cs 的末尾添加以下代码:
var complianceMetadataResolver = host.Services.
GetRequiredService<IComplianceMetadataResolver>();
var typeToCheck = typeof(Patient);
Console.WriteLine($"Checking type for compliance rules: {typeToCheck.
FullName}");
if (complianceMetadataResolver.HasMetadataFor(typeToCheck))
{
var metadata =
complianceMetadataResolver.GetMetadataFor(
typeToCheck);
foreach (var item in metadata)
{
Console.WriteLine($"Type level - {item.Details}");
}
}
代码使用 IComplianceMetadataResolver 来获取类型的元数据。在你的情况下,目前它是硬编码为 Patient。
要从属性中获取元数据,请在 Program.cs 的末尾添加以下代码:
foreach (var property in typeToCheck.GetProperties())
{
if (complianceMetadataResolver
.HasMetadataFor(property))
{
var metadata = complianceMetadataResolver
.GetMetadataFor(property);
foreach (var item in metadata)
{
Console.WriteLine($"Property: {property.Name} –
{item.Details}");
}
}
else if (property.PropertyType.IsGenericType &&
property.PropertyType.GetGenericTypeDefinition()
.IsAssignableTo(typeof(IEnumerable<>)))
{
var type = property.PropertyType
.GetGenericArguments().First();
if (complianceMetadataResolver
.HasMetadataFor(type))
{
Console.WriteLine($"\nProperty {property.Name}
is a collection of type {type.FullName} with
type level metadata");
var metadata = complianceMetadataResolver.
GetMetadataFor(type);
foreach (var item in metadata)
{
Console.WriteLine($"{property.Name} –
{item.Details}");
}
}
}
}
代码遍历 typeToCheck 的属性,然后打印出任何来自属性的元数据细节。它还寻找任何具有泛型参数并实现 IEnumerable<> 的属性,并打印出与项目类型关联的任何元数据。
运行你的程序应该会给你以下输出:
Checking type for compliance rules: Chapter11.Patient
Property: FirstName - Employment records
Property: LastName - Employment records
Property: SocialSecurityNumber - Uniquely identifies the employee
Property JournalEntries is a collection of type Chapter11.JournalEntry with type level metadata
添加更多提供者
在引擎就位后,你现在可以通过简单地添加新的提供者来开始向其中添加内容。让我们为 JournalEntry 添加一个。添加一个名为 JournalEntryMetadataProvider.cs 的文件,并使其看起来如下所示:
using Fundamentals.Compliance;
namespace Chapter11;
public class JournalEntryMetadataProvider : ICanProvideComplianceMetadataForType
{
public bool CanProvide(Type type) => type == typeof(JournalEntry);
public ComplianceMetadata Provide(Type type) => new("7242aed8-
8d70-49df-8713-eea45e2764d4", "Journal entry");
}
代码使用 JournalEntry 类型来决定它是否可以提供。每个 JournalEntry 都应该被特别对待,因为它包含不应共享的关键信息。Provide() 方法会为 JournalEntry 类型创建一个新的条目,并带有唯一的标识符。
运行程序现在应该会给你更多信息;注意添加到你的输出中的日志条目:
Checking type for compliance rules: Chapter11.Patient
Property: FirstName - Employment records
Property: LastName - Employment records
Property: SocialSecurityNumber - Uniquely identifies the employee
Property JournalEntries is a collection of type Chapter11.JournalEntry
with type level metadata
JournalEntries - Journal entry
为了继续向你的系统添加内容,让我们添加一些可以让你标记类型为 confidential 的东西。
添加一个名为 ConfidentialAttribute.cs 的文件,并使其看起来如下所示:
namespace Chapter11;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false,
Inherited = true)]
public sealed class ConfidentialAttribute : Attribute
{
}
该属性针对类。
接下来,你需要一个提供者,可以为新属性提供元数据。添加一个名为 ConfidentialMetadataProvider.cs 的文件,并使其看起来如下所示:
using System.Reflection;
using Fundamentals.Compliance;
namespace Chapter11;
public class ConfidentialMetadataProvider :
ICanProvideComplianceMetadataForType
{
public bool CanProvide(Type type) =>
type.GetCustomAttribute<ConfidentialAttribute>() !=
null;
public ComplianceMetadata Provide(Type type) =>
new("8dd1709a-bbe1-4b98-84e1-9e7be2fd4912", "The data
is confidential");
}
提供者寻找 ConfidentialAttribute 来决定它是否可以提供。如果可以,则 Provide() 方法会为它所代表的类型创建一个新的 ComplianceMetadata 实例,并带有唯一的标识符。
打开 Patient.cs 文件,并在 Patient 类前面添加 [Confidential] 属性,使类看起来如下所示:
using Fundamentals.Compliance.GDPR;
namespace Chapter11;
[Confidential]
public class Patient
{
[PersonalIdentifiableInformation("Employment records")]
public string FirstName { get; set; } = string.Empty;
[PersonalIdentifiableInformation("Employment records")]
public string LastName { get; set; } = string.Empty;
[PersonalIdentifiableInformation("Uniquely identifies
the employee")]
public string SocialSecurityNumber { get; set; } =
string.Empty;
public IEnumerable<JournalEntry> JournalEntries { get;
set; } = Enumerable.Empty<JournalEntry>();
}
现在运行程序应该会给你一个输出,其中在顶部包括类型级别的信息:
Checking type for compliance rules: Chapter11.Patient
Type level - The data is confidential
Property: FirstName - Employment records
Property: LastName - Employment records
Property: SocialSecurityNumber - Uniquely identifies the employee
Property JournalEntries is a collection of type Chapter11.JournalEntry with type level metadata
JournalEntries - Journal entry
你现在创建了一个灵活且可扩展的系统。你不必进入“引擎”来执行任何更改以引入新功能。这是一个强大的功能,也是健康系统的特征。
摘要
开发始终如一的软件很困难。能够经受住时间考验、不失去可维护性,并且能够在合理的时间内允许开发新功能的软件更加困难。将开发新业务价值所需的时间保持接近恒定是我们追求的目标。这使得企业能够更容易地确定新功能的影响。这也使得这种影响对开发者来说更加可预测。
为了达到这个目的,有一些技术、模式、实践和原则可以提供帮助。在我看来,以可扩展的方式思考并设计代码以避免变得僵化是关键。这样,大多数时候,我们可以专注于添加新功能,而不是不得不进行心脏手术般的修改才能添加新功能。
在下一章中,我们将探讨如何将本章所讨论的内容与你在第十章“约定优于配置”中开始实践的内容相结合。约定可以采取多种形式,并且它们可以帮助你超越 C#和.NET 的继承模型。
第十二章:超越继承
在像 C#这样的面向对象语言中,我们可以从其他类型派生我们的类型,我们还可以实现契约(接口)并使它们满足该契约。实现接口并使实现满足这些契约是类型安全语言的一大优点。有了编译器,我们可以确保我们实现了契约,而在运行时,我们可以利用接口类型作为表示,而不是必须知道实际的实现者。
然而,有时实现接口的冗长性可能过多。它也可能仅限于你试图实现的目标。
在本章中,我们将涵盖以下主题:
-
方法签名约定
-
到本章结束时,你将了解约定如何让你超越必须从基类型或接口继承的需求,以及有时这如何有助于更干净的代码库。
技术要求
本章的特定源代码可以在 GitHub 上找到(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter12),并且它建立在github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Fundamentals中找到的基础代码之上。
方法签名约定
提出一个好的例子来为一本书的主题做一次完美的展示可能非常困难。对于本章,我决定选择我日常工作的一部分内容。
我与一个以事件驱动架构为中心的平台合作,更具体地说,是与事件溯源。这个主题可能对你来说很陌生,所以让我们深入了解这个具体是什么,以便为你提供背景。
在传统的CRUD(代表创建、读取、更新、删除)系统中,主要关注的是通常存储在关系型或文档数据库中的具体数据。这些系统围绕着四个基本操作——创建新记录、检索现有记录、更新记录和删除记录。
这样的系统中的数据代表用户操作的结果,系统的流程通常遵循从用户输入表单到数据库的一对一映射。然而,这种软件开发方法有一个显著的缺点——它只捕捉效果,而不是原因。
在事件溯源中,效果并不那么重要,关键是捕捉导致效果的原因。原因捕捉了我们如何得出结论,如下面的图所示:

图 12.1 – 捕获原因
原因被称为事件——系统中发生的事情。我们通过给它们一个清晰的名字来捕捉这些事件,并将它们按发生的顺序存储。事件永远不会被删除。所有事件都存储在被称为事件存储的地方。有一些技术专门从事这项工作,例如我正在工作的平台,称为 Cratis (cratis.io),但您也可以使用任何数据库并自行完成这项工作。
通过优先捕获事件,我们获得了控制效果呈现方式的能力。这是通过在事件发生时从各种事件类型中提取相关信息,然后将其转换为易于访问的专用对象来实现的。这些对象通常存储在传统的数据存储中,如关系型或文档数据库,这允许高效地查询最终结果。
这种方法的一个主要优势是,事件存储成为系统中的真相来源,而不是仅仅依赖于传统的数据存储。这意味着修改数据表示方式变得显著更容易,因为您只需在实施更改时简单地重放相关事件。您还可以决定拥有多个表示形式,以满足系统内的特殊需求。这可能会带来巨大的性能优势,以及代码的清晰度,并避免在开发系统中常见的一种紧张关系,即共享相同的数据和对象。
以下图显示了一个假想的银行系统,其中产生了事件以及它们如何投影到被称为读取模型的实体对象中。它还显示了在微服务为中心的架构中,通过投影到可以超出系统边界进行通信的事件,与其他系统或微服务的关系。

图 12.2 – 产生事件的假想银行系统
观察者是这种架构的关键元素。他们是负责观察事件发生并产生效果的那些人。大部分情况下,效果以投影到存储的读取模型的形式出现,但它们也可能执行其他任务,例如发送电子邮件,或者实际上,作为观察者执行分析的结果,追加新事件。
我们希望在本章中将这些观察者作为例子提出来。让我们将其缩小到以下图所示的管道:

图 12.3 – 逻辑管道
命令代表用户希望执行的操作。该操作通常会决定应该生成什么事件,或者可能是多个事件。这些事件随后被附加到事件存储中的主事件日志中。事件日志是按发生顺序持有所有事件的序列,并带有递增的序列号。
一旦事件进入事件日志,我们希望通知所有感兴趣的观察者事件已被附加,并且他们应该做出反应。
下图更具体地展示了我们想要实现的内容,并将成为本章示例的基础。
![img/B19418_12_04.jpg]
图 12.4 – 具体管道
为了能够做到这一点,你需要一些基础设施来提供这些约定。
基础设施
让我们创建一个基础结构,以便拥有能够响应附加事件的观察者。
让我们在你的仓库根目录下创建一个名为Chapter12的文件夹。在命令行界面中切换到这个文件夹,并创建一个新的控制台项目:
dotnet new console
你将利用微软托管模型来获取.NET 默认服务提供者,而无需启动一个 Web 应用程序。为了实现这一点,你需要一个名为Microsoft.Extensions.Hosting的包。在终端中,你可以通过以下方式添加引用:
dotnet add package Microsoft.Extensions.Hosting
下一步你需要做的是引用Fundamentals项目。在终端中,执行以下操作:
dotnet add reference ../Fundamentals/Fundamentals.csproj
正如我在第四章中讨论的,使用反射进行类型推理,我在所有代码中做的一件事是正式化类型而不是使用原始类型。这使得 API 更加清晰,并有助于避免错误。
你将使用ConceptAs<>来正式化类型。
在Chapter12文件夹内,创建一个专门用于EventSourcing基础设施的文件夹,并将其命名为EventSourcing。
在EventSourcing文件夹内,创建一个名为EventSourceId.cs的文件,并使其看起来如下:
using Fundamentals;
namespace EventSourcing;
public record EventSourceId(string Value) :
ConceptAs<string>(Value)
{
public static EventSourceId New() =>
new(Guid.NewGuid().ToString());
}
EventSourceId概念表示事件源的唯一标识符。在领域建模中,这通常是领域内一个对象、名词的标识符。这个例子可以是银行账户的唯一标识符或系统中个人的唯一标识符。EventSourceId代码为ConceptAs<>设置,并将内部值设为字符串,基本上允许任何唯一标识符的表示。它添加了一个方便的方法来创建新的EventSourceId,通过利用Guid,这可以即时生成一个唯一标识符。你将在后面使用EventSourceId,它将变得更为清晰为什么需要它。
当将事件附加到一系列事件中,正如我们之前讨论的,每个事件都会得到一个序列号。这是一个增量数字,每次添加事件时增加 1。让我们正式化一个表示这个数字的类型。
在EventSourcing文件夹内,创建一个名为EventSequenceNumber.cs的文件,并使其看起来如下:
using Fundamentals;
namespace EventSourcing;
public record EventSequenceNumber(ulong Value) :
ConceptAs<ulong>(Value)
{
public static implicit operator
EventSequenceNumber(ulong value) => new(value);
}
代码引入了一个具体的EventSequenceNumber类型,它是ConceptAs<>,其内部值为ulong类型。使用ulong,您可以得到完整的 64 位值,这应该足以作为增量序列号。为了方便,还有一个隐式运算符可以将ulong转换为封装的EventSequenceNumber类型。
对于您稍后要添加的观察者,您将使用一个属性来标记它们是观察者。这种方法的替代方案是使用一个空接口。这样做的目的是仅仅能够标记一个类型,使其可以被发现。
在EventSourcing文件夹中,创建一个名为ObserverAttribute.cs的文件,并使其看起来如下:
namespace EventSourcing;
[AttributeUsage(AttributeTargets.Class, AllowMultiple =
false)]
public sealed class ObserverAttribute : Attribute
{
}
代码引入了一个可以添加到类中的属性。
对于观察者方法,您通常除了实际事件本身外,还会找到与事件相关的信息。您想了解的信息类型是EventSourceId、EventSequenceNumber以及事件发生的时间。我们称之为EventContext。
在EventSourcing文件夹中,创建一个名为EventContext.cs的文件,并使其看起来如下:
namespace EventSourcing;
public record EventContext(
EventSourceId EventSourceId,
EventSequenceNumber SequenceNumber,
DateTimeOffset Occurred);
代码以DateTimeOffset的形式持有EventSourceId、SequenceNumber和Occurred。在一个完整的事件源系统中,我们通常会保留更多细节,但这对这个例子就足够了。
为了使事件可发现并被分类为事件,您将需要在基础设施中有一个构建块。
在EventSourcing文件夹中,创建一个名为IEvent.cs的文件,并使其看起来如下:
namespace EventSourcing;
public interface IEvent { }
现在是精彩的部分:将发现符合某些标准的类型的代码。
我们正在寻找的约定是允许两种基本方法签名及其两种变体,支持同步和异步模型。
同步签名如下:
void <name-of-method>(YourEventType @event);
void <name-of-method>(YourEventType @event, EventContext
context);
然后异步签名如下:
Task <name-of-method>(YourEventType @event);
Task <name-of-method>(YourEventType @event, EventContext
context);
如您所见,该约定不关心方法名称,而只关心参数和返回类型。这为开发者提供了创建更精确命名的方法的灵活性,并增加了代码的可读性和可维护性,这是常规继承所不允许的。
让我们创建一个系统,使得可以通过约定调用方法。
在EventSourcing文件夹中,创建一个名为ObserverHandler.cs的文件,并使其看起来如下:
using System.Reflection;
namespace EventSourcing;
public class ObserverHandler
{
readonly Dictionary<Type, IEnumerable<MethodInfo>>
_methodsByEventType;
readonly IServiceProvider _serviceProvider;
readonly Type _targetType;
public IEnumerable<Type> EventTypes =>
_methodsByEventType.Keys;
public ObserverHandler(IServiceProvider
serviceProvider, Type targetType)
{
_serviceProvider = serviceProvider;
_targetType = targetType;
_methodsByEventType =
targetType.GetMethods(BindingFlags.Instance |
BindingFlags.NonPublic | BindingFlags.Public)
.Where(_ =>
IsObservingMethod(_))
.GroupBy(_ =>
_.GetParameters()[0].
ParameterType)
.ToDictionary(_ =>
_.Key, _ =>
_.ToArray()
.AsEnumerable());
}
}
代码为ObserverHandler类设置了基础。构造函数接受两个参数,serviceProvider和targetType。当需要处理事件时,serviceProvider参数将用于获取表示观察者的targetType的实例。在构造函数中,代码使用反射来查找实例方法,包括公共和非公共方法。
然后,通过一个名为IsObservingMethod()的方法过滤出与签名匹配的方法,你将在下一个步骤中添加它。然后,它根据方法的第一参数(即事件类型)进行分组,并创建一个字典以实现快速查找。
重要提示
注意添加的EventTypes属性;这暴露了处理程序支持的事件类型,这将在以后很有用。
在 LINQ 查询中,它使用IsObservingMethod()方法,这是一个应该位于ObserverHandler类内部的方法。在ObserverHandler类的底部添加以下私有方法:
bool IsObservingMethod(MethodInfo methodInfo)
{
var isObservingMethod =
methodInfo.ReturnType.IsAssignableTo(typeof(Task)) ||
methodInfo.ReturnType ==
typeof(void);
if (!isObservingMethod) return false;
var parameters = methodInfo.GetParameters();
if (parameters.Length >= 1)
{
isObservingMethod = parameters[0]
.ParameterType.IsAssignableTo(typeof(IEvent));
if (parameters.Length == 2)
{
isObservingMethod &= parameters[1]
.ParameterType == typeof(EventContext);
}
else if (parameters.Length > 2)
{
isObservingMethod = false;
}
return isObservingMethod;
}
return false;
}
为了识别允许的签名,代码会检查MethodInfo,并首先识别允许的返回类型,这些类型是Task或void。如果返回类型不是有效的观察方法,它将被视为无效。然后代码继续检查方法的参数。如果一个方法有一个参数,并且其类型实现了IEvent接口,它就符合观察方法的资格。或者,如果一个方法有两个参数,第一个参数类型实现了IEvent,第二个参数类型是EventContext,那么它也被归类为观察方法。
在发现部分就绪后,你所需要的只是一个理解约定并能调用观察者方法的方法。
在ObserverHandler类中,添加以下代码:
public async Task OnNext(IEvent @event, EventContext
context)
{
var eventType = @event.GetType();
if (_methodsByEventType.ContainsKey(eventType))
{
var actualObserver =
_serviceProvider.GetService(_targetType);
Task returnValue;
foreach (var method in
_methodsByEventType[eventType])
{
var parameters = method.GetParameters();
if (parameters.Length == 2)
{
returnValue =
(Task)method.Invoke(actualObserver, new
object[] { @event, context })!;
}
else
{
returnValue =
(Task)method.Invoke(actualObserver, new
object[] { @event })!;
}
if (returnValue is not null) await returnValue;
}
}
}
OnNext()方法负责对观察者进行调用;它通过接受任何类型实现IEvent接口的事件和事件对应的EventContext来实现。从这些信息中,它根据观察者支持的事件类型,在构造函数中填充的_methodsByEventType中找到相应的方法。如果支持,它将继续通过服务提供者获取观察者类型的实例。对于每个方法,它根据方法的正确签名进行调用,如果方法是异步的,它将等待返回的Task。
与处理调用的具体处理程序一起,你需要一个了解所有观察者并能调用正确观察者的服务。
在EventSourcing文件夹中,创建一个名为IObservers.cs的文件,并使其看起来如下:
namespace EventSourcing;
public interface IObservers
{
Task OnNext(IEvent @event, EventContext context);
}
代码代表了IObservers的契约。OnNext()方法将是系统调用以知道事件何时发生的方法。
你将需要一个此接口的实现。
在EventSourcing文件夹中,创建一个名为Observers.cs的文件,并使其看起来如下:
using System.Reflection;
using Fundamentals;
namespace EventSourcing;
[Singleton]
public class Observers : IObservers
{
readonly IEnumerable<ObserverHandler> _handlers;
public Observers(ITypes types, IServiceProvider
serviceProvider)
{
_handlers = types.All.Where(_ =>
_.HasAttribute<ObserverAttribute>())
.Select(_ =>
{
var observer =
_.GetCustomAttribute
<ObserverAttribute>()!;
return new
ObserverHandler(
serviceProvider, _);
});
}
}
代码利用ITypes从基础中进行发现。代码扫描所有类型,并过滤掉那些没有ObserverAttribute的类型,只留下具有ObserverAttribute的类型。对于每个具有ObserverAttribute的类型,它通过传递serviceProvider和目标类型(即观察者本身)来创建一个ObserverHandler实例。
为了调用处理器,您需要一个实现OnNext()方法的实例。在Observers类中,在底部添加以下方法:
public Task OnNext(IEvent @event, EventContext context)
{
var tasks = _handlers.Where(_ =>
_.EventTypes.Contains(@event.GetType()))
.Select(_ => _.OnNext(@event,
context));
return Task.WhenAll(tasks);
}
代码通过查看处理器的EventTypes属性以及它是否包含@event参数的类型来筛选出能够处理的事件处理器,然后调用处理器的OnNext()方法,并收集它所做所有调用中的所有Task实例,以便它可以等待它们全部完成。
在建立了观察者基础设施之后,您需要某种触发器。我们不会实现一个完整的工作事件源系统,因为这会过于复杂。相反,我们将采取一些捷径,并且不会将事件保存到任何地方。
在事件源系统中,您需要一个地方将事件追加到序列中;您追加到的主要地方称为事件日志。让我们介绍这个概念。
在EventSourcing文件夹内,创建一个名为IEventLog.cs的文件,并使其看起来如下:
namespace EventSourcing;
public interface IEventLog
{
Task Append(EventSourceId eventSourceId, IEvent
@event);
}
代码代表了这个版本的事件日志合约,其中只有一个方法,这使您能够为特定的EventSourceId追加一个事件。
您需要一个实现IEventLog接口的实例。
在EventSourcing文件夹内,创建一个名为EventLog.cs的文件,并使其看起来如下:
namespace EventSourcing;
public class EventLog : IEventLog
{
readonly IObservers _observers;
EventSequenceNumber _sequenceNumber = 0;
public EventLog(IObservers observers)
{
_observers = observers;
}
public async Task Append(EventSourceId eventSourceId,
IEvent @event)
{
// TODO: persist the event
await _observers.OnNext(
@event,
new EventContext(eventSourceId,
_sequenceNumber, DateTimeOffset.UtcNow));
_sequenceNumber++;
}
}
代码代表了一个非常简单的实现,它直接依赖于IObservers来调用,当事件被追加时。它内部管理_sequenceNumber。正如您所看到的,这里没有持久化,整个实现至多算是天真。但它服务于本章的目的。
现在您已经为通过约定在观察者上调用方法建立了所有这些美好的基础设施,您可能渴望将其付诸实践。
使用基础设施
沿着本章的银行主题,让我们创建一些代表该领域的东西。在银行中,您可以开设账户,从账户中存钱和取钱,然后可能最终关闭账户。所有这些都是在账户生命周期中发生的重要事件。
在章节代码的根目录下,创建一个名为Events.cs的文件,并使其看起来如下:
using EventSourcing;
namespace Chapter12;
public record BankAccountOpened(string CustomerName) :
IEvent;
public record BankAccountClosed() : IEvent;
public record DepositPerformed(decimal Amount) : IEvent;
public record WithdrawalPerformed(decimal Amount) : IEvent;
代码现在持有我们想要的全部事件,它们都是record类型,并且都实现了IEvent接口。
重要提示
在生产环境中,我建议每个类型保留一个文件,因为这使您更容易在系统中导航和发现事件。
在事件就绪后,你现在可以继续创建观察者,它们将对发生的事件做出反应。
在章节代码的根目录下创建一个名为AccountLifecycle.cs的文件,并使其看起来如下所示:
using EventSourcing;
namespace Chapter12;
[Observer]
public class AccountLifecycle
{
public Task Opened(BankAccountOpened @event)
{
Console.WriteLine($"Account opened for
{@event.CustomerName}");
return Task.CompletedTask;
}
public Task Closed(BankAccountClosed @event,
EventContext context)
{
Console.WriteLine($"Account with id
{context.EventSourceId} closed");
return Task.CompletedTask;
}
}
代码添加了一个名为AccountLifecycle的类,并使用[Observer]属性对其进行装饰。它的目的是仅处理BankAccountOpened和BankAccountClosed的生命周期事件。请注意,它完全遵循约定,使用自定义方法名称和不同的签名。
对于影响账户余额的事件,你可以将这部分特定逻辑分离成它自己的观察者。
在章节代码的根目录下创建一个名为AccountBalance.cs的文件,并使其看起来如下所示:
using EventSourcing;
namespace Chapter12;
[Observer]
public class AccountBalance
{
public Task DepositPerformed(DepositPerformed @event,
EventContext context)
{
Console.WriteLine($"Deposit of {@event.Amount}
performed on {context.EventSourceId}");
return Task.CompletedTask;
}
public Task WithdrawalPerformed(WithdrawalPerformed
@event, EventContext context)
{
Console.WriteLine($"Withdrawal of {@event.Amount}
performed on {context.EventSourceId}");
return Task.CompletedTask;
}
}
代码添加了一个名为AccountBalance的类,并使用[Observer]属性对其进行装饰。它的目的是仅处理DepositPerformed和WithdrawalPerformed的余额事件。
这两个观察者只是将发生的事情记录到控制台。在实际实现这些时,你可能想要将数据存储在某个地方。这里的优点是你可以将数据存储在两个不同的位置。对于生命周期事件,你只对所有权和与账户相关的任何细节感兴趣,而对于余额事件,你只对影响余额的内容感兴趣,而不对其他任何事情感兴趣。将这些事情分开来使得选择合适的技术和独立地建模每个部分以及创建一个松耦合的系统变得更加容易。
现在你想要进行最后的连接,向其抛出一些事件并验证它是否完成了工作。
打开Program.cs文件,使其看起来如下所示:
using Chapter12;
using EventSourcing;
using Fundamentals;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var host = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
var types = new Types();
services.AddSingleton<ITypes>(types);
services.AddBindingsByConvention(types);
services.AddSelfBinding(types);
})
.Build();
代码设置了必要的管道代码,以启动默认的.NET 控制反转容器;它利用你在第十章,约定优于配置中创建的AddBindingsByConvention()来通过约定连接服务,以及你在第十一章,应用开闭原则中创建的AddSelfBinding()。
在基本基础设施就绪后,你现在可以请求IEventLog的实例并将其开始附加事件。
在Program.cs文件的底部添加以下代码:
var eventLog = host.Services
.GetRequiredService<IEventLog>();
var bankAccountId = EventSourceId.New();
eventLog.Append(bankAccountId, new BankAccountOpened("Jane
Doe"));
eventLog.Append(bankAccountId, new DepositPerformed(100));
eventLog.Append(bankAccountId, new
WithdrawalPerformed(32));
eventLog.Append(bankAccountId, new BankAccountClosed());
运行你的程序应该给出类似的输出:
Account opened for Jane Doe
Deposit of 100 performed on a3d7dbae-9e2a-4d2d-a070-
ead70e48f87a
Withdrawal of 32 performed on a3d7dbae-9e2a-4d2d-a070-
ead70e48f87a
Account with id a3d7dbae-9e2a-4d2d-a070-ead70e48f87a closed
你现在有了事件源组件的开始。但更重要的是,它应该给你一个动态执行而不必严格依赖于继承的想法。
作为使用继承而不是这种方式进行操作的后果的说明,我们需要一个定义事件方法的接口;它可能看起来如下所示:
public interface IObserveEvent<TEvent> where TEvent :
IEvent
{
Task Handle(TEvent @event, EventContext context);
}
使用此接口,我们可以对AccountLifecycle观察者执行以下操作:
public class AccountLifecycle :
IObserveEvent<BankAccountOpened>,
IObserveEvent<BankAccountClosed>
{
public Task Handle(BankAccountOpened @event,
EventContext context)
{
Console.WriteLine($"Account opened for
{@event.CustomerName}");
return Task.CompletedTask;
}
public Task Handle(BankAccountClosed @event,
EventContext context)
{
Console.WriteLine($"Account with id
{context.EventSourceId} closed");
return Task.CompletedTask;
}
}
虽然使用这种方法可以提供编译时安全性,但也有一些缺点需要考虑。首先,给每个方法命名Handle可能会导致歧义和混淆,无论是在工具使用上还是可读性方面。如果不仔细检查参数,可能很难确定要使用哪个方法。此外,这种方法限制你只能处理一个事件的一个方法,而基于约定的方法则允许有多个具有特定目的的独立方法来处理事件的不同方面。
如果你有一个处理多个事件的观察者,你将不得不为它处理的每个类型实现IObserveEvent<>。这可能会使你的代码的可读性和可维护性降低。
这两种方法都有利有弊,但希望你能从中获得这种潜力,并且希望它对你所工作的代码库是有用且适用的。
摘要
约定,就像本章中解释的通过发现已知的签名,可以非常强大,并有助于清理你的代码。强迫开发者为每个支持的类实现一个接口可能会很繁琐,并使代码看起来有些奇怪。
使用方法签名约定的缺点显然是,你现在完全依赖于运行时检查;没有编译器能帮助你。如果你不小心犯了一点错误,它直到运行时才会被发现,这在专注开发时可能会非常恼人。在第十七章,“静态代码分析”中,我们将探讨如何在编译时检测错误。
在下一章中,我们将探讨如何通过约定进一步实现自动化,将开发者置于成功的深渊,并避免基于食谱的开发。
第十三章:应用跨领域关注点
随着软件项目的演变,它们会获得一定的结构,如果团队有纪律,那么这种结构将保持一定的一致性。当团队人数增加时,您甚至可能会记录下这种结构。除了结构之外,您还可能有执行某些任务的方法,以及创建不同类型功能时需要执行的具体操作指南。为了全局所有权,您也可能将这些内容记录下来。
这就是跨领域关注点可以提供帮助的地方。在本章中,我们将探讨如何通过移除日常任务、提高一致性和降低风险来提高您作为开发者的生产力。我们还将探讨跨领域关注点如何提高您软件的可维护性。
本章我们将涵盖以下主题:
-
跨领域关注点是什么?
-
利用 ASP.NET 管道
-
基于元数据或结构的授权
技术要求
本章的特定源代码可以在 GitHub 上找到 (github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter13),并且它建立在 GitHub 上可找到的基础代码之上 (github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Fundamentals)。
您需要使用 Postman (www.postman.com) 来测试本章中创建的 API。
跨领域关注点是什么?
如章节引言所暗示的,您可能会发现自己处于一个项目中,其中有一些指南,无论是有形的还是无形的,都提供了如何做事的食谱。例如,为了编写执行应用程序中操作的表示状态转移(REST)API,您可能有一个定义好的清单,旨在帮助您记住要做什么:
-
检查授权
-
检查输入是否有效
-
检查操作是否符合业务规则
-
为操作添加日志
-
通过调用领域逻辑来执行操作
-
将结果从领域转换为 REST 消费可接受的格式
-
记得将调用领域的代码包裹在try {} catch {}中,并返回正确的错误信息
对于这些步骤中的每一个,都存在开发者可能会忘记的风险。这可能会带来风险,导致安全问题、数据一致性或其他问题。
个人而言,我非常热衷于自动化任何重复性工作。计算机在执行重复性任务方面非常出色,那么为什么不让人类专注于创造业务价值呢?
横切关注点基本上是那些一旦应用就持续存在的事情。一些横切关注点可以完全自动,一旦应用就不需要开发者进行额外干预,而其他则更具有可配置性和基于上下文。
记录可能是最典型的横切关注点的例子。例如,如果你知道在你的 ASP.NET 应用程序中,每次调用 Web API 控制器时应该添加什么到日志语句中,你可以通过添加一个操作过滤器非常容易地做到这一点。
我倾向于关注如何提高团队的生产力。考虑到这一点,你可以进行相当多的自动化操作。为了更好地理解这一点,我们可以用一个来自 ASP.NET Web APIs 的例子来说明。假设你正在构建一个包含前端的应用程序,并且需要为前端提供一个 API。通常情况下,你的领域逻辑不应该在 API 层,因为那只是一个传输机制。这里的一个机会是自动从领域层根据约定生成 API 层。这个约定可以从命名空间中推导出来,然后自动创建正确的路由。这将消除一个完整的层。
应用横切关注点的一个可能的优点是,你最终在实现中写的代码更少,在通常添加自动化的地方。从维护的角度来看,这也是一件好事。它使得维护变得更加容易。例如,如果你想改变系统的行为,而不是在多个地方进行更改,你只需在一个地方进行更改。
我最喜欢的横切关注点之一是确保 Web API 有一个一致的结果,并且不仅仅依赖于 HTTP 状态码,而是始终一致地向消费者提供有关调用的所有必要信息。
利用 ASP.NET 管道
REST API 基于 HTTP 标准。这个标准是一个协议标准,并不一定能够很好地反映当你执行操作时实际发生的情况。
实现这一点的其中一种方法是为所有 Web API 控制器操作创建一个通用的结果对象。但这样就会成为那些可能会被遗忘的食谱之一,并使解决方案处于不一致的状态。
拥有一个通用结果对象的想法无疑是可取的,但我们应该努力实现所有 Web API 调用自动返回它。然而,执行操作和获取数据之间有一个区别。基本上,在 HTTP 中,这就是不同动词的作用,HTTP GET 代表获取数据,而诸如 POST、PUT 或 DELETE 这样的动词代表你想要执行的操作。
这些类型的操作通常是你作为一个数据驱动应用程序中的数据库操作所执行的操作。你通常会使用相同的模型进行所有操作,你基本上只是在修改数据。
我支持由 Greg Young 提出的命令查询责任分离(CQRS)原则,该原则是 Bertrand Meyers 的命令查询分离(CQS)原则的进一步形式化(www.martinfowler.com/bliki/CQRS.html),www.martinfowler.com/bliki/CommandQuerySeparation.html)。
CQRS 原则挑战了将所有事物都视为创建、读取、更新和删除(CRUD)数据的一般方法。它侧重于显式地建模系统中的状态变化,并用命令表示改变意图,而检索数据则表示为查询。由于 CQRS 是 CQS 的演变,它还意味着命令代表状态的变化,不返回值,而查询返回值但不改变任何状态。
我们不会深入探讨 CQRS 或 CQS,但我们想利用命令的概念,并以此限制本章示例中我们想要支持的范围。这里的示例与 CQRS 无关,但作为它的支持者,我认为我会把它融入到对话中,希望它能激发一些好奇心……哈哈。
构建一致的返回对象
让我们构建一个简单的系统,该系统将员工注册作为一个 REST API。目标是提供所有执行命令的一致返回对象。在这个上下文中,我们将命令定义为对任何 Web API 控制器的 HTTP POST 调用。
首先创建一个名为Chapter13的文件夹。在命令行中切换到这个文件夹,并创建一个新的基于 Web 的项目:
dotnet new web
让我们利用在技术要求部分提到的 GitHub 仓库中的基础知识项目。你应该通过在终端执行以下操作为这一章添加对该项目的引用:
dotnet add reference ../Fundamentals/Fundamentals.csproj
我们将利用 ASP.NET Core 提供的操作过滤器这一构建块。在第三章,通过现有现实世界示例去神秘化中,我们提到了这个构建块,用于改变与验证相关的默认行为——这是一个跨切面关注点的良好示例。
在 ASP.NET Core 中,作为开发者的你,对 Web API 控制器的行为有 100%的灵活性。这意味着你决定向客户端返回什么,你也决定你是否真的关心验证结果。正如我们在第三章,通过现有现实世界示例去神秘化中所做的那样,我们将更加有见地,不让控制器的行为决定是否有效,而是以跨切面的方式处理这个问题。此外,我们还想以一种优雅的方式将其封装起来,以便消费者能够一致地获取结果。
在 Chapter13 中创建一个名为 Commands 的子文件夹。这是你创建必要基础设施的地方。
让我们从添加一个一致的验证结果表示开始。ASP.NET Core 有 ModelError 的概念;如果你愿意,可以直接使用它。但 ModelError 既可以表示 Exception 也可以表示验证错误。这些是不同的关注点,我个人希望将它们分开并使其更清晰。处于无效状态与处于异常的非恢复状态是不同的。
在 Commands 文件夹中添加一个名为 ValidationResult.cs 的文件。你可以使其看起来如下:
namespace Chapter13.Commands;
public record ValidationResult(string Message, string Member);
代码引入了一个名为 ValidationResult 的类型,该类型包含错误和错误对应的成员。由于成员在结果中明确,消费者可以将错误映射回它发送的对象。作为一个用户界面,这非常有用,因为你可以轻松地为无效的用户输入字段显示错误。
由于 ASP.NET Core 有其 ModelError,而你现在引入了一个仅表示验证结果的类型,你可能需要一个可以转换为你类型的工具。在 Commands 文件夹中添加一个名为 ModelErrorExtensions.cs 的文件,并使其看起来如下:
using Fundamentals;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Chapter13.Commands;
public static class ModelErrorExtensions
{
public static ValidationResult ToValidationResult(this
ModelError error, string member)
{
member = string.Join('.',
member.Split('.').Select(_ => _.ToCamelCase()));
return new ValidationResult(error.ErrorMessage,
member);
}
}
代码引入了一个针对 ModelError 类型的扩展方法,它接受一个特定的成员作为字符串以关联错误。默认情况下,ASP.NET Core 中的所有 JSON 序列化都将采用驼峰命名法;因此,代码将成员转换为驼峰命名法。它甚至支持通过点表示的导航路径来支持深层嵌套的成员,该路径在每个嵌套层级中用点表示。ToCamelCase() 方法调用来自之前引用的 Fundamentals 项目中的 StringExtensions。
CommandResult
在有了验证表示之后,你现在可以创建所有操作或命令的通用结果类型。我们称之为 CommandResult。它将以结构化的方式封装 API 调用的所有不同方面。为此,在 Commands 文件夹中添加一个名为 CommandResult.cs 的文件,并使其看起来如下:
namespace Chapter13.Commands;
public class CommandResult
{
public Guid CorrelationId { get; init; }
public bool IsSuccess => IsAuthorized && IsValid &&
!HasExceptions;
public bool IsAuthorized { get; init; } = true;
public bool IsValid => !ValidationResults.Any();
public bool HasExceptions => ExceptionMessages.Any();
public IEnumerable<ValidationResult> ValidationResults
{get; init;} = Enumerable.Empty<ValidationResult>();
public IEnumerable<string> ExceptionMessages { get;
init; } = Enumerable.Empty<string>();
public string ExceptionStackTrace { get; init; } =
string.Empty;
public object? Response { get; init; }
}
代码中引入了一个名为 CommandResult 的类型,该类型包含与验证结果相关的具体信息以及是否发生了可能出现的异常。此外,它还包含一些属性,允许你轻松地判断结果是否代表成功。如果结果不是成功,你可以深入了解它是否与授权、有效性或异常有关。它还引入了 CorrelationId 属性,该属性用于标识已执行的调用,并可用于在日志或跟踪系统中回溯,以了解是否发生了异常以及原因。
在正式的 CommandResult 就位后,你需要一些可以生成这种结果的东西。这正是 ASP.NET Core 动作过滤器机制发挥作用的地方。
将名为CommandActionFilter.cs的文件添加到Commands文件夹中,并使其看起来如下:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Chapter13.Commands;
public class CommandActionFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
if (context.HttpContext.Request.Method ==
HttpMethod.Post.Method)
{
}
else
{
await next();
}
}
}
代码通过实现IAsyncActionFilter接口提供了一个裸骨动作过滤器。动作过滤器只对 HTTP POST 方法感兴趣,如前所述,其余实现将在该子句中完成。如果不是 HTTP POST 方法,它将使用next()方法将请求转发到下一个中间件。
让我们开始填补空白。在 HTTP POST 方法子句的作用域内,添加以下代码:
var exceptionMessages = new List<string>();
var exceptionStackTrace = string.Empty;
ActionExecutedContext? result = null;
object? response = null;
if (context.ModelState.IsValid)
{
result = await next();
if (result.Exception is not null)
{
var exception = result.Exception;
exceptionStackTrace = exception.StackTrace;
do
{
exceptionMessages.Add(exception.Message);
exception = exception.InnerException;
}
while (exception is not null);
result.Exception = null!;
}
if (result.Result is ObjectResult objectResult)
{
response = objectResult.Value;
}
}
代码处理ModelState是否有效,这意味着所有验证器都已成功运行,因此没有报告任何无效内容。这可能意味着以下两种情况之一:
-
有异常
-
一切正常,操作已执行
在IsValid子句中,代码调用next(),这会调用 ASP.NET Core 管道的其余部分,最终调用 Web API 控制器操作。result对象是ActionExecutedContext类型,它包含有关对操作调用的信息。在其上,你可以找到Exception和Result。如果有异常,代码会递归地通过每个异常的InnerException来展开所有消息,然后重置result对象上的Exception属性为null,以避免 ASP.NET Core 默认输出异常。如果没有异常,代码会尝试捕获操作的实际结果,如果它是来自操作的ObjectResult。
重要提示
尽管我们说命令应该只执行状态更改而不返回结果,但有时你需要向客户端返回某些内容。这可能很重要,例如创建的对象的键,然后可以直接由消费者利用。
接下来你需要做的是创建一个CommandResult实例并填充验证结果和异常。在之前的代码之后添加以下代码:
var commandResult = new CommandResult
{
CorrelationId = Guid.NewGuid(),
ValidationResults = context.ModelState.SelectMany(_ =>
_.Value!.Errors.Select(e => e.ToValidationResult(
_.Key))),
ExceptionMessages = exceptionMessages.ToArray(),
ExceptionStackTrace = exceptionStackTrace ??
string.Empty,
Response = response
};
代码创建了一个CommandResult类型的实例,并设置了其上的属性。CorrelationId生成一个新的Guid,ValidationResults使用你之前放入的扩展方法从ModelState派生,ExceptionMessages来自你放入的展开异常的代码。然后,如果有任何或如果没有,它将ExceptionStackTrace放入其中,或者只是string.Empty。最后,如果有的话,它将控制器操作的响应直接转发。
尽管你现在已经将结果封装在更易于阅读和一致的消费者中,但设置正确的 HTTP 状态码仍然是良好的实践。在CommandResult实例化之后添加以下代码:
if (!commandResult.IsAuthorized)
{
context.HttpContext.Response.StatusCode = 401;
}
else if (!commandResult.IsValid)
{
context.HttpContext.Response.StatusCode = 409;
}
else if (commandResult.HasExceptions)
{
context.HttpContext.Response.StatusCode = 500;
}
代码表示,当我们未授权时,是 HTTP 401状态码,如果无效,则是409状态码,如果有异常,则是500。
为了使 CommandResult 成为实际输出的结果,如果你被有效授权,你需要在 ActionExecutedContext 上显式设置 Result 属性,或者直接在 ActionExecutingContext 上设置,这是作为动作过滤器方法第一个参数传递的。在之前的代码块之后添加以下代码:
var actualResult = new ObjectResult(commandResult);
if (result is not null)
{
result.Result = actualResult;
}
else
{
context.Result = actualResult;
}
代码创建了一个新的 ObjectResult,内容为 CommandResult,并将其设置在 ActionExecutedContext 对象或 ActionExecutingContext 上。这将保证无论你是否调用了控制器动作,你都能得到相同结构的结果。
让我们创建一个具体的示例,将使用这个新改进的管道。在 Chapter13 项目的根目录下添加一个名为 Employee.cs 的文件,并使其看起来如下:
using System.ComponentModel.DataAnnotations;
namespace Chapter13;
public record Employee(
[Required]
string FirstName,
[Required]
string LastName);
代码引入了一个表示员工的 record 类型,它只有两个属性:FirstName 和 LastName。它指示这些属性都是必需的,通过利用 [Required] 属性来实现。
为了使 API 工作,你需要一个控制器。在 Chapter13 项目的根目录下添加一个名为 EmployeesController 的文件,并使其看起来如下:
using Microsoft.AspNetCore.Mvc;
namespace Chapter13;
[Route("/api/employees")]
public class EmployeesController : Controller
{
[HttpPost]
public int Register([FromBody] Employee employee)
{
// Todo: Implement logic for actually
// registering...
return 1;
}
}
代码引入了一个用于注册员工的控制器动作。在 employee 参数前面的 [FromBody] 属性表示 employee 的内容位于 HTTP 请求体中。动作返回一个整数,并硬编码为返回 1。请注意,这更多的是一个示例,说明你可以在需要时返回你的键。不返回任何内容并使方法返回 void 是完全可以接受的。在类前面的 [Route] 属性,API 的路由将是 /api/employees。
打开 Program.cs 文件并使其看起来如下:
using Chapter13.Commands;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(mvcOptions => mvcOptions.Filters.
Add<CommandActionFilter>());
var app = builder.Build();
app.MapControllers();
app.Run();
代码按照约定添加所有控制器并映射这些控制器的路由。当添加控制器时,代码将 CommandActionFilter 作为过滤器添加到管道中。
这应该足够你尝试一下了。使用以下命令运行项目:
dotnet run
你应该看到以下类似的输出:
Building...
info: Microsoft.Hosting.Lifetime[14]
Now listening on: https://localhost:7126
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5234
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: /Users/einari/Projects/Metaprogramming-in-C/
Chapter13/
你现在可以使用 Postman 测试 API:

图 13.1 – 使用 Postman 发布
将动词设置为 POST,然后使用运行输出的 URL 并将 /api/employees 添加到 URL。然后在 Body 选项卡中选择 JSON,添加一个空的 JSON 文档,然后点击 Send。
响应应该类似于以下内容:
{
"correlationId": "f0910061-0e1d-494e-90c2-a7e7c246069f",
"isSuccess": false,
"isAuthorized": true,
"isValid": false,
"hasExceptions": false,
"validationResults": [
{
"message": "The LastName field is required.",
"member": "lastName"
},
{
"message": "The FirstName field is required.",
"member": "firstName"
}
],
"exceptionMessages": [],
"exceptionStackTrace": "",
"response": null
}
使用 POST 请求有效对象:
{
"firstName": "Jane",
"lastName": "Doe"
}
在 Postman 中,你应该得到一个成功的结果:

图 13.2 – Postman 中的成功发布
你应该看到一个类似的响应:
{
"correlationId": "f44600ee-02f2-4d0c-9187-f02ff02c9353",
"isSuccess": true,
"isAuthorized": true,
"isValid": true,
"hasExceptions": false,
"validationResults": [],
"exceptionMessages": [],
"exceptionStackTrace": "",
"response": 1
}
现在,你为所有的 POST 动作有一个清晰、一致的结果对象。ASP.NET Core 非常可扩展和灵活,大部分部分都可以扩展以执行跨切面关注点;授权是一个很好的例子。
基于元数据或结构的授权
默认情况下,设置 ASP.NET Core 中控制器授权的方法使用[Authorize]属性或注册控制器或端点时的流畅接口。对于某些场景,这可以非常明确,在具有大量控制器端点的大型应用程序中,你可能希望考虑以跨切面方式对它们进行安全保护。
如果你的应用程序的部分只是打算由具有特定角色的用户使用,那么根据命名空间应用安全策略可能是一个很好的选择。通过结构,我们得到跟随类型的隐式元数据,我们可以利用这一点来为我们做出决策。
要完成这个任务,我们需要做一些准备工作。首先,我们需要一个用于验证用户的机制。对于这个示例,我们将使用硬编码的用户来避免设置与身份提供者进行适当身份验证的复杂性。
你需要在Chapter13的根目录下添加一个名为HardCodedAuthenticationOptions.cs的文件,然后添加以下内容:
using Microsoft.AspNetCore.Authentication;
namespace Chapter13;
public class HardCodedAuthenticationOptions :
AuthenticationSchemeOptions
{
}
代码引入了一个选项类型,该类型将由自定义硬编码的身份提供者使用。由于提供者将完全硬编码,因此它没有任何选项。
接下来,在Chapter13的根目录下添加一个名为HardCodedAuthenticationHandler.cs的文件,并使其看起来如下所示:
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace Chapter13;
public class HardCodedAuthenticationHandler : AuthenticationHandler<HardCodedAuthenticationOptions>
{
public const string SchemeName =
"HardCodedAuthenticationHandler";
public HardCodedAuthenticationHandler(
IOptionsMonitor<HardCodedAuthenticationOptions>
options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger,
encoder, clock)
{
}
protected override Task<AuthenticateResult>
HandleAuthenticateAsync() => Task.FromResult(
AuthenticateResult.Success(
new AuthenticationTicket(
new ClaimsPrincipal(
new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.Name,
"Bob"),
new Claim(ClaimTypes.Role,
"User")
},
SchemeName)), SchemeName)));
}
代码实现了AuthenticationHandler<>,并在构造函数中接收基类需要的依赖项,并将这些依赖项传递下去。HandleAuthenticateAsync()方法将始终返回一个成功的身份验证,并带有硬编码的具有身份的 principal。目前,该身份的角色是User。
你需要一个针对此示例的特定授权策略。它应该说明你必须是在以特定字符串开头的命名空间中的Admin。在 ASP.NET 中,这是通过实现一个要求来完成的,该要求基本上是策略的配置对象,然后是一个能够处理该要求的处理程序。
在Chapter13的根目录下添加一个名为AdminForNamespace.cs的文件,并使其看起来如下所示:
using Microsoft.AspNetCore.Authorization;
namespace Chapter13;
public class AdminForNamespace : IAuthorizationRequirement
{
public AdminForNamespace(string @namespace)
{
Namespace = @namespace;
}
public string Namespace { get; }
}
代码以命名空间字符串的形式保存配置,该字符串将在检查策略的代码中使用。IAuthorizationRequirement是一个空的标记接口,不需要实现任何内容。
接下来,你需要处理程序。在Chapter13的根目录下添加一个名为AdminForNamespaceHandler.cs的文件,并使其看起来如下所示:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Controllers;
namespace Chapter13;
public class AdminForNamespaceHandler : AuthorizationHandler<AdminForNamespace>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
AdminForNamespace requirement)
{
if (context.Resource is HttpContext httpContext)
{
var endpoint = httpContext.GetEndpoint();
if (endpoint is not null)
{
var controllerActionDescriptor =
endpoint!.Metadata.GetMetadata<Controller
ActionDescriptor>();
if (controllerActionDescriptor?
.MethodInfo
.DeclaringType?
.Namespace?
.StartsWith(requirement.Namespace,
StringComparison.InvariantCulture
) == true &&
!httpContext.User.IsInRole("Admin"))
{
context.Fail();
}
else
{
context.Succeed(requirement);
}
}
}
return Task.CompletedTask;
}
}
这覆盖了基类中的抽象方法 HandleRequirementAsync()。在 context 参数中,有一个名为 Resource 的属性。对于 Web API 控制器操作,这通常是 HttpContext 类型。因此,代码检查它是否是 HttpContext,然后在那个子句中实现策略。在 HttpContext 中,你可以获取端点信息。在端点中,与之关联的元数据,并且为了我们的目的,我们正在寻找特定的控制器信息。ControllerActionDescriptor 元数据包含应该在控制器上调用实际方法。在此,代码获取 DeclaringType 并使用其命名空间来查看命名空间要求是否匹配。如果类型具有以要求开始的命名空间,并且用户没有 Admin 角色,它将失败 context,这意味着用户未授权。你可以提供失败的原因和更多详细信息,但在这个示例中,我们只是保持简洁。
小贴士
如果用户是 Admin,则操作将成功。如果命名空间不以要求开始,它也会成功。
在策略处理程序就绪后,你需要将其连接起来,以便它实际上会被调用。ASP.NET Core 的默认方法是在授权设置期间添加策略。然而,这种方法会剥夺跨切面机会,因为你将需要明确指定应该应用策略的控制器。相反,你将实现 IAuthorizationPolicyProvider 并在此设置策略。
在 Chapter13 的根目录下添加一个名为 CrossCuttingPoliciesProvider.cs 的文件。它应该看起来如下所示:
using Microsoft.AspNetCore.Authorization;
namespace Chapter13;
public class CrossCuttingPoliciesProvider : IAuthorizationPolicyProvider
{
readonly AuthorizationPolicy _policy;
public CrossCuttingPoliciesProvider()
{
_policy = new AuthorizationPolicyBuilder()
.AddRequirements(new
AdminForNamespace("Chapter13")
).Build();
}
public Task<AuthorizationPolicy>
GetDefaultPolicyAsync() => Task.FromResult(_policy);
public Task<AuthorizationPolicy?>
GetFallbackPolicyAsync() =>
Task.FromResult<AuthorizationPolicy?>(_policy);
public Task<AuthorizationPolicy?> GetPolicyAsync(string
policyName) =>
Task.FromResult<AuthorizationPolicy?>(_policy);
}
代码设置了包含 AdminForNamespace 策略的 AuthorizationPolicy。IAuthorizationPolicyProvider 要求你实现获取不同场景策略的方法;所有这些方法都返回相同的策略。
重要提示
对于 GetDefaultPolicyAsync()、GetFallbackPolicyAsync() 和 GetPolicyAsync() 返回相同的策略可能不是期望的行为。这是为了简化示例而进行的。
接下来,你将把授权重新连接到你之前创建的 CommandActionFilter,为此,我们必须传达返回的授权结果。不幸的是,这个信息在 ASP.NET Core 管道后续阶段中不容易访问。
在 Chapter13 的根目录下添加一个名为 HttpContextExtensions.cs 的文件,并使其看起来如下所示:
using Microsoft.AspNetCore.Authorization.Policy;
namespace Chapter13;
public static class HttpContextExtensions
{
const string AuthorizeResultKey = "_AuthorizeResult";
public static PolicyAuthorizationResult?
GetAuthorizationResult(this HttpContext context) =>
(context.Items[AuthorizeResultKey] as
PolicyAuthorizationResult)!;
public static void SetAuthorizationResult(this
HttpContext context, PolicyAuthorizationResult
result) => context.Items[AuthorizeResultKey] =
result;
}
代码使用 HttpContext 上的 Items 字典,并为处理 PolicyAuthorizationResult 提供了设置方法和获取方法。Items 是一个键/值存储,可以作为当前 Web 请求的一部分存储任何内容。这非常适合当你想要将某些内容提供给其他阶段时。
ASP.NET Core 提供了特定的中间件来处理授权的结果。添加一个名为 CrossCuttingAuthorizationMiddlewareResultHandler.cs 的文件,并使其看起来如下:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
namespace Chapter13;
public class CrossCuttingAuthorizationMiddlewareResultHandler :
IAuthorizationMiddlewareResultHandler
{
readonly AuthorizationMiddlewareResultHandler
_defaultHandler = new();
public async Task HandleAsync(RequestDelegate next,
HttpContext context, AuthorizationPolicy policy,
PolicyAuthorizationResult authorizeResult)
{
context.SetAuthorizationResult(authorizeResult);
await _defaultHandler.HandleAsync(next, context,
policy, PolicyAuthorizationResult.Success());
}
}
代码实现了 IAuthorizationMiddlewareResultHandler 接口,该接口包含一个 HandleAsync() 方法。此方法在所有策略都已处理但在操作过滤器之前被调用。HandleAsync() 的实现将 authorizationResult 放置在 HttpContext 中,以便后续阶段可以使用。然后,它使用接口的默认实现 AuthorizationMiddlewareResultHandler 来调用管道的其余部分,但现在模拟成功。它模拟成功的原因是为了欺骗处理程序执行操作过滤器。我们希望 CommandActionFilter 添加对授权的支持并始终如一地返回 CommandResult 结构。
打开 Chapter13 项目根目录下的 CommandActionFilter.cs 文件,并将方法的顶部修改如下:
if (context.HttpContext.Request.Method == HttpMethod.Post.Method)
// Adding call to get authorization result and setting
// authorized variable
var authorizationResult =
context.HttpContext.GetAuthorizationResult();
var isAuthorized = authorizationResult?.Succeeded ??
true;
var exceptionMessages = new List<string>();
var exceptionStackTrace = string.Empty;
ActionExecutedContext? result = null;
object? response = null;
// Using authorized variable, we don't want to call the
// controller if we are
if (context.ModelState.IsValid && isAuthorized)
您正在进行的更改正在利用授权结果。如果授权结果未成功,您不希望调用管道的其余部分,但您希望将其捕获在命令结果中。
在同一文件和方法中,更改创建 CommandResult 的方式以包括 IsAuthorized 属性:
var commandResult = new CommandResult
{
CorrelationId = Guid.NewGuid(),
// Adding isAuthorized
IsAuthorized = isAuthorized,
ValidationResults = context.ModelState.SelectMany(_ =>
_.Value!.Errors.Select(e =>
e.ToValidationResult(_.Key))),
ExceptionMessages = exceptionMessages.ToArray(),
ExceptionStackTrace = exceptionStackTrace ??
string.Empty,
Response = response
};
代码现在包含 CommandResult,其中包含 IsAuthorized。由于操作过滤器的其余部分也考虑了这一点,因此您也应该得到正确的 HTTP 状态码。
打开 Chapter13 项目根目录下的 Program.cs 文件,并修改如下:
using Chapter13;
using Chapter13.Commands;
using Microsoft.AspNetCore.Authorization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(mvcOptions => mvcOptions.Filters.
Add<CommandActionFilter>());
// Adding authorization and the handlers
builder.Services.AddAuthorization(options => options.
AddPolicy("Chapter13Admins", policy => policy.Requirements.Add(new AdminForNamespace("Chapter13"))));
builder.Services.AddSingleton<IAuthorizationHandler,
AdminForNamespaceHandler>();
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler,
CrossCuttingAuthorizationMiddlewareResultHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider,
CrossCuttingPoliciesProvider>();
// Adding authentication with our hardcoded handler
builder.Services
.AddAuthentication(options => options.DefaultScheme =
HardCodedAuthenticationHandler.SchemeName)
.AddScheme<HardCodedAuthenticationOptions,
HardCodedAuthenticationHandler>(
HardCodedAuthenticationHandler.SchemeName, _ => {});
var app = builder.Build();
app.MapControllers();
// Use authentication and authorization
app.UseAuthentication();
app.UseAuthorization();
app.Run();
这些更改引入了身份验证和授权,并将服务钩子连接到您创建的不同处理程序和提供者。对于身份验证,它将硬编码的处理程序设置为默认的身份验证模式,并配置方案以使用您创建的处理程序类型。
使用 Postman 运行应用程序并执行与之前相同的操作应该得到以下结果:

图 13.3 – Postman 中的未授权结果
输出应将 isAuthorized 设置为 false:
{
"correlationId": "eb26b553-45f3-43a4-9d92-9860b2fe541e",
"isSuccess": false,
"isAuthorized": false,
"isValid": true,
"hasExceptions": false,
"validationResults": [],
"exceptionMessages": [],
"exceptionStackTrace": "",
"response": null
}
让我们尝试将用户的角色更改为 Admin。打开 Chapter13 根目录下的 HardCodedAuthenticationHandler.cs 文件,将角色从 User 更改为 Admin。为此,找到以下内容的行:
new Claim(ClaimTypes.Role, "User")
然后,使其看起来如下:
new Claim(ClaimTypes.Role, "Admin")
现在运行应用程序并再次执行相同的操作应该会得到一个授权的结果:

图 13.4 – Postman 中的授权结果
JSON 输出应如下所示:
{
"correlationId": "80191dbc-8908-4c89-9dfb-7bce8b4b9e44",
"isSuccess": true,
"isAuthorized": true,
"isValid": true,
"hasExceptions": false,
"validationResults": [],
"exceptionMessages": [],
"exceptionStackTrace": "",
"response": 1
}
这应该给您一个想法,了解可能发生的情况。使用 ASP.NET Core,您可以深入替换默认行为并按需定制,这在您想要应用一些横切关注点时非常棒。
摘要
脚本是明确、线性地指定它们所做事情的代码片段,对于新开发者来说是一个非常好的工具。开发者可以真正看到正在发生的事情,对代码进行推理,并找到错误。随着开发者、团队和项目的成熟,这些脚本开始感觉像是不必要的琐事,或者至少变得非常重复。这不仅可能影响生产力,而且这些重复性任务很容易出错。出错可能会对系统造成多重风险:
-
安全风险
-
持续无效数据的风险
-
允许不允许的操作的风险
-
由于缺乏日志记录而失去操作洞察力的风险
在开发者可以快速推理的命令式过程代码和需要一致的系统之间进行权衡,这是你应该考虑的。在较小的项目中,可能不值得在应用横切方面“不同”的认知负担,而在较大的系统中,这可能是至关重要的。这可能与团队的大小有关——团队越大,你想要自动化和标准化的东西就越多。
应用横切关注点可以非常强大,但如果开发者不了解它,无法理解为什么某些事情会发生,它可能会感觉像是一个黑盒。我的建议是确保所有开发者都了解如何处理横切关注点,并确保能够跟踪代码路径。与其记录脚本,我的建议是记录它们是如何自动化的,以及开发者如何调试问题,例如。
在下一章中,我们将深入探讨如何通过利用面向方面编程来进一步深入。
第十四章:面向方面编程
在整本书中,你应该已经注意到了一个主题:自动化。这意味着编写使你的代码更简单、更易于维护并消除重复工作的代码。在第十三章中,应用横切关注点,我们讨论了可以创建用于特定关注点的代码,并且可以自动应用。在本章中,我们将把这个概念提升到下一个层次,并深入探讨为此目的而设计的正式化;面向方面 编程(AOP)。
在本章中,我们将涵盖以下主题:
-
AOP 是什么?
-
记录
-
混合
-
授权
到本章结束时,你应该对 AOP 及其如何在 C#中用于创建更模块化、可维护和可扩展的应用程序有一个扎实的理解。
技术要求
该章节的特定源代码可以在 GitHub 上找到(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter14),并且它建立在 GitHub 上找到的基础代码之上(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Fundamentals)。
AOP 是什么?
在传统编程中,开发者编写代码以实现应用程序所需的行为。这些代码被组织成函数、类和模块,以实现特定的功能。然而,许多应用程序需要跨越代码库多个部分的功能,例如记录、错误处理和安全。这些功能,通常被称为横切关注点,当它们散布在代码库中时,可能会难以管理和维护。
AOP 是一种编程范式,旨在通过将横切关注点从其余代码中分离出来来解决此问题。在 AOP 中,开发者定义方面,它们封装了横切关注点的行为,并使用连接点和切入点将它们应用于代码库的特定部分。
AOP 非常适合与其他编程范式结合使用,例如面向对象编程(OOP)和函数式编程(FP),以创建更模块化、可维护和可扩展的应用程序。近年来,AOP 越来越受欢迎,并且有多个 AOP 框架可用于各种编程语言和平台。
在本章中,我们将探讨如何使用 AOP 在 C#中解决横切关注点并提高代码的可维护性和可重用性。我们将介绍 AOP 的关键概念,如方面、切入点、连接点,并展示如何将它们应用于特定用例,例如安全和日志记录。我们将使用名为 Castle Windsor 的框架([www.castleproject.org/projects/windsor/](https://www.castleproject.org/projects/windsor/))来完成这项工作,并展示如何将其用于实现 C#应用程序中的 AOP。
方面
在面向切面编程(AOP)中,方面是一个可以选择性应用于程序不同部分的行为模块单元。方面本质上是一组指令,描述了如何以特定方式修改程序的行为。
方面用于解决横切关注点,这些关注点跨越程序的多部分,不能封装在单个模块或类中。横切关注点的例子包括日志记录、安全、缓存和错误处理。
可以将方面视为可重用、模块化的代码片段,可以应用于程序的多部分。方面可以设计为可组合的,以便不同的方面可以组合起来实现更复杂的行为。
方面通常实现为定义要添加到程序中的行为的类或模块。在如 Castle Windsor 这样的 AOP 框架中,方面通常实现为拦截器,这些类拦截对方法或属性的调用并修改其行为。
切入点
切入点是 AOP 中用于指定方面应应用的位置的机制。切入点是一组连接点,是代码中方面可以应用的具体位置。
连接点是在程序执行过程中可以应用方面的点。连接点的例子包括方法调用、方法执行、字段访问和异常处理程序。
要定义一个切入点,你需要指定切入点包含的连接点。这可以通过各种标准完成,例如方法名、方法签名、类名或注解。
连接点
在 AOP 中,连接点是程序执行过程中可以应用方面的特定点。连接点代表程序中的特定事件或方法调用,可以被方面拦截。例如,一个连接点可以是方法调用、字段访问或抛出的异常。
连接点是通过切入点定义的,这些切入点指定了应用方面时应选择的连接点标准。切入点可以使用各种标准定义,例如方法签名、类名或注解。例如,一个切入点可以选择所有具有特定属性的方法或特定命名空间中的所有方法。
一旦定义了切入点,就可以将其用于将方面应用于选定的连接点。方面可以通过添加、修改或删除功能来修改它们所拦截的连接点的行为。例如,方面可以向方法调用添加日志或缓存功能,或者在允许处理之前验证用户输入。
连接点是 AOP 中的一个基本概念,因为它们允许方面有选择性地应用于代码库的特定部分,而不是必须修改整个代码库来实现横切关注点。连接点还使横切关注点的模块化成为可能,使它们更容易管理和维护。
C#中常见的连接点示例包括以下内容:
-
方法调用:这些是拦截方法调用的连接点,无论是方法被调用之前(使用前置通知)还是之后(使用后置通知)
-
字段访问:这些是拦截对字段读取或写入访问的连接点,无论是字段被访问之前(使用前置通知)还是之后(使用后置通知)
-
异常处理:拦截抛出异常的连接点,允许方面处理异常或修改其行为
以下图表总结了方面、切入点和连接点:

图 14.1 – AOP 术语可视化
在术语准备就绪后,我们现在应该准备好首次深入探索 AOP,并在一些典型示例中使用它。
日志
日志经常被引用为 AOP 如何用于提高软件模块化和可维护性的典型示例。日志是一个常见的横切关注点,意味着它影响软件系统的多个部分,并且不能轻易地封装在单个模块或类中。
AOP 提供了一种封装日志行为并在整个系统中一致应用的方法,而无需单独修改每个模块或类。这允许开发者专注于模块的核心功能,同时仍然提供一种一致且连贯的方式来记录系统行为。
在本节中,我们将探讨日志在软件系统中的作用以及如何使用 AOP 以模块化和可维护的方式实现日志行为。我们将分析不同日志方法的优缺点,以及 AOP 如何帮助解决与复杂软件系统中的日志相关的挑战。
创建日志示例
让我们从为这一章创建一个新项目开始。创建一个名为Chapter14的文件夹,在命令行中切换到这个文件夹,并创建一个新的控制台项目:
dotnet new console
对于本章,正如之前所讨论的,我们将使用一个名为 Castle Windsor 的框架。它是从 The Castle Project 中涌现出的许多框架之一,你可以在这里了解更多相关信息。Castle Windsor 是一个 控制反转 (IoC) 容器,它提供了执行 AOP 的广泛功能。
要使所有 AOP 魔法成为可能,Castle Windsor 是建立在名为 Castle Core 的项目之上的,它提供了一种方便的方式来创建动态运行时代理对象,就像我们在 第六章 中所做的那样,动态代理生成。这可能是从必须自己完成所有事情的自然步骤。
所有这些都是开源的,你在这里将使用的具体框架是 Windsor 部分,可以在 GitHub 上找到(github.com/castleproject/Windsor)。
将包添加到项目的依赖项中:
dotnet add package Castle.Windsor
要开始使用 Windsor 容器,你可以简单地替换 Program.cs 中的内容如下:
using System.Reflection;
using Castle.Windsor;
using Castle.Windsor.Installer;
var container = new WindsorContainer();
container.Install(FromAssembly.InThisApplication(Assembly.
GetEntryAssembly()));
代码创建了一个 WindsorContainer 的实例,并指示它安装运行应用程序中任何实现 IWindsorInstaller 的实例。安装器是一种配置容器的方式。它们在 .Install() 调用中被发现,并且你可以为特定的用例有多个安装器。
我们想要创建一个 DefaultInstaller,它将为容器设置默认行为。添加一个名为 DefaultInstaller.cs 的文件,并使其看起来如下:
using System.Reflection;
using Castle.MicroKernel.Registration;
using Castle.MicroKernel.SubSystems.Configuration;
using Castle.Windsor;
namespace Chapter14;
public class DefaultInstaller : IWindsorInstaller
{
public void Install(IWindsorContainer container,
IConfigurationStore store)
{
container.Register(Classes
.FromAssemblyInThisApplication(Assembly
.GetEntryAssembly())
.Pick()
.WithService.DefaultInterfaces()
.LifestyleTransient());
}
}
代码实现了 IWindsorInstaller 接口并实现了 Install() 方法。在它内部,代码指示 容器 通过将其与表示为 DefaultInterfaces 的服务相关联来注册应用程序中的所有类。这意味着它将建立一种约定,就像我们在 第十章 中所做的那样,即 约定优于配置,即任何具有以 I 为前缀的匹配接口的类将被绑定在一起(IFoo -> Foo)。最后,它告诉它应该使用瞬态的生命周期。Castle Windsor 默认的生命周期是单例,这可能是危险的,并可能产生不期望的副作用,因此我的建议是保持瞬态作为默认值,并在需要时进行覆盖。
安装器只是一个帮助你结构化代码并帮助你保持关注单一责任的工具。实际上,你可以在实例化后直接与容器一起工作,就像我们稍后将要看到的那样。有些事情应该立即做,而有些事情则应该分开。
Console.WriteLine() 对于日志记录来说不是最优的,所以让我们使用 Microsoft 日志记录器代替。
添加 Microsoft 日志记录器
通过使用 Console.WriteLine() 将信息写入控制台,你不会得到任何结构化的日志。日志消息只是文本,格式是你放入的内容。你通常还希望本地开发与生产环境有不同的输出。使用结构化日志方法可以捕获日志语句中使用的任何值,然后可以将这些值转发到集中的日志数据库中,对它们进行索引并使其可搜索。市面上有许多这样的工具,我推荐查看 Seq (datalust.co/seq),它提供了一个免费用于本地开发的工具。
对于这个示例,我们将使用来自微软的库来进行结构化日志记录。这是微软在构建所有内容时使用的相同库。它提供了扩展点,并且也可以与其他流行的日志库一起使用,例如 Serilog (serilog.net)。
首先添加对核心日志包的引用,以及到 Console 输出的引用:
dotnet add package Microsoft.Extensions.Logging
dotnet add package Microsoft.Extensions.Logging.Console
然后在 Program.cs 文件中,在末尾添加以下代码:
var loggerFactory = LoggerFactory.Create(builder => builder.
AddConsole());
container.Register(Component.For<ILoggerFactory>().
Instance(loggerFactory));
该代码创建 LoggerFactory 并将其配置为输出到控制台。然后它继续将 ILoggerFactory 接口与您刚刚配置的具体实例注册到 Windsor IoC 容器中。任何依赖于 ILoggerFactory 的构造函数现在将获得此实例。ILoggerFactory 提供了一种创建具体 ILogger 实例的方法,这就是用于日志记录的内容。
在 ASP.NET Core 中,ILoggerFactory 在某些情况下被内部用于创建日志实例。而在其他情况下,构造函数依赖于 ILogger,甚至更具体地,依赖于泛型 ILogger<> 版本。ILogger 的泛型版本允许你获取一个作用域特定的日志记录器,这是针对你的特定类型的。在日志输出中,你会看到日志消息的来源,这是重要的元数据。
让我们配置 IoC 容器以支持这两种场景。在 Program.cs 文件中,在末尾添加以下代码:
var createLoggerMethod = typeof(LoggerFactoryExtensions)
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.First(_ => _.Name == nameof(
LoggerFactory.CreateLogger) && _.IsGenericMethod);
container.Register(Component.For<ILogger>().
UsingFactoryMethod((kernel, context) =>
{
var loggerFactory = kernel.Resolve<ILoggerFactory>();
return loggerFactory.CreateLogger(
context.Handler.ComponentModel.Implementation);
}).LifestyleTransient());
container.Register(Component.For(typeof(ILogger<>)).
UsingFactoryMethod((kernel, context) =>
{
var loggerFactory = kernel.Resolve<ILoggerFactory>();
var logger = createLoggerMethod
.MakeGenericMethod(context.RequestedType
.GenericTypeArguments[0]).Invoke(null, new[] {
loggerFactory });
return logger;
}));
代码首先使用反射从 LoggerFactoryExtensions 获取 CreateLogger<>() 扩展方法。这是因为 ILoggerFactory 只有一种非类型化的创建日志的方式,而泛型的是一种扩展方法。接下来,代码使用一个会在需要时动态创建实例的工厂方法将未类型化的非泛型 ILogger 注册到容器中。然后,它利用容器获取 ILoggerFactory 并通过给定的 Ilogger 注入的类型创建一个日志记录器。由于 Windsor 的默认行为是将一切设置为单例,我们明确配置 Ilogger 为瞬时的。这样,我们就可以为使用它的类型获取不同的日志记录器实例。否则,你将共享同一个日志记录器跨所有类型。最后,代码使用一个工厂方法配置泛型 ILogger<>,该方法使用 LoggerFactoryExtensions 中的 CreateLogger<>() 扩展方法,通过为请求的类型及其泛型类型参数创建一个泛型方法来完成。
在基本日志基础设施到位后,我们现在可以应用横切日志了。
拦截器
在 Castle Windsor 中,存在拦截器的概念。它们代表了如何实现实际方面并执行横切操作。
让我们创建一个用于处理所有方法调用日志的拦截器。在 Chapter14 的根目录下添加一个名为 LoggingInterceptor.cs 的文件,并使其看起来如下:
using Castle.DynamicProxy;
namespace Chapter14;
public class LoggingInterceptor : IInterceptor
{
public void Intercept(IInvocation invocation)
{
// Do something before
invocation.Proceed();
// Do something after
}
}
代码设置了 Castle Windsor 的 IInterceptor 实现方案。在 Intercept() 方法中,代码在调用对象上调用 Proceed() 方法,这将执行它所拦截的实际调用。在这个调用之前和之后,我们可以执行我们的横切操作。
在 .NET 6 中,Microsoft 引入了一种将日志消息封装到其自己的代码文件中的日志方法,这有助于你通过封装日志消息来变得更加结构化。这有助于日志消息的维护,并使得在需要为不同场景输出相同日志消息时更加容易。
创建一个名为 LoggingInterceptorLogMessages.cs 的新文件,并将以下代码添加到其中:
using Microsoft.Extensions.Logging;
namespace Chapter14;
internal static partial class LoggingInterceptorLogMessages
{
[LoggerMessage(1, LogLevel.Information, "Before
invoking {Method}", EventName = "BeforeInvocation")]
internal static partial void BeforeInvocation(this
ILogger logger, string method);
[LoggerMessage(2, LogLevel.Error, "Error invoking
{Method}", EventName = "InvocationError")]
internal static partial void InvocationError(this
ILogger logger, string method, Exception exception);
[LoggerMessage(3, LogLevel.Information, "Before
invoking {Method}", EventName = "AfterInvocation")]
internal static partial void AfterInvocation(this
ILogger logger, string method);
}
代码设置了一个静态的局部类,其中包含每个日志语句的扩展方法,并利用 [LoggerMessage] 来配置日志消息,其严重性,文件内的唯一标识符或全局唯一标识符,以及可选的事件名称。由于所有方法都是局部的且没有实现,C# 编译器将生成必要的代码来完成这项工作。
重要注意事项
我认为将记录器消息像这样内部化是一种良好的实践,对于类和方法都是如此。这样,您可以将其隔离在模块中,并且不会冒将其变成一个将在您的编辑器中全局显示为 IntelliSense 的扩展方法的危险。通常,您还应该使用泛型的ILogger<>作为扩展方法的类型来扩展,因为扩展方法使其针对具体类型。但是,由于我们这里的日志消息是跨切的,我们不知道它们将在哪里使用。
在放置了我们想要记录的日志消息后,我们可以修改LoggingInterceptor以执行日志记录。打开LoggingInterceptor文件,并将其更改为以下内容:
using Castle.DynamicProxy;
using Microsoft.Extensions.Logging;
namespace Chapter14;
public class LoggingInterceptor : IInterceptor
{
readonly ILoggerFactory _loggerFactory;
public LoggingInterceptor(ILoggerFactory loggerFactory)
{
_loggerFactory = loggerFactory;
}
public void Intercept(IInvocation invocation)
{
var logger = _loggerFactory.CreateLogger(
invocation.TargetType)!;
logger.BeforeInvocation(invocation.Method.Name);
invocation.Proceed();
logger.AfterInvocation(invocation.Method.Name);
}
}
代码被修改为在构造函数中接受ILoggerFactory作为依赖项。在Intercept()方法中,您现在使用日志工厂为目标类型创建一个记录器。然后它使用结构化日志调用BeforeInvocation()和AfterInvocation(),并传递被调用的方法名称。
重要提示
仅通过日志中按名称看到被调用的方法可能不足以提供足够的信息。IInvocation类型包含有关传递的参数的详细信息,您也可以将它们记录下来。唯一需要注意的是,要删除敏感值,例如与通用数据保护条例(GDPR)相关的信息或安全信息。幸运的是,如果您遵循第四章中使用反射推理类型的建议,并使用ConceptAs<>封装类型,您可以轻松地识别需要删除的类型并自动执行删除。
在设置拦截器后,下一步是将它连接到 Castle Windsor。打开DefaultInstaller.cs文件,并将其更改为以下内容:
using System.Reflection;
using Castle.MicroKernel.Registration;
using Castle.MicroKernel.SubSystems.Configuration;
using Castle.Windsor;
namespace Chapter14;
public class DefaultInstaller : IWindsorInstaller
{
public void Install(IWindsorContainer container,
IConfigurationStore store)
{
// Added
container.Register(Component.For<
LoggingInterceptor>());
container.Register(Classes
.FromAssemblyInThisApplication(Assembly
.GetEntryAssembly())
.Pick()
.WithService.DefaultInterfaces()
// Added
.Configure(_ =>
_.Interceptors<LoggingInterceptor>())
.LifestyleTransient());
}
}
对安装程序所做的唯一更改是注册了LoggingInterceptor。Castle Windsor 不知道如何自动解析具体类型,因此我们手动注册它。第二个新增内容是为默认约定配置注册,包括LoggingInterceptor。
现在您已经设置了所有基础设施,您需要一些东西来测试它。让我们创建一个用于注册用户的用户服务,不是关注实现它,只是用来测试拦截器的东西。
尝试拦截器
在Chapter14的根目录下创建一个名为IUsersService.cs的文件,并使其看起来如下:
namespace Chapter14;
public interface IUsersService
{
Task<Guid> Register(string userName, string password);
}
接口包含一个用于注册用户的单一方法。
对于接口,您需要一个实现。添加一个名为UsersService.cs的文件,并将其以下代码添加到其中:
using Microsoft.Extensions.Logging;
namespace Chapter14;
public class UsersService : IUsersService
{
readonly ILogger<UsersService> _logger;
public UsersService(ILogger<UsersService> logger)
{
_logger = logger;
}
public Task<Guid> Register(string userName,
string password)
{
_logger.LogInformation("Inside register method");
var id = Guid.NewGuid();
return Task.FromResult(id);
}
}
代码表示IUsersService的实现。它将ILogger
在放置好示例之后,你现在需要获取其实例并验证拦截器是否工作。打开Program.cs文件,并在文件末尾添加以下内容:
var usersService = container.Resolve<IUsersService>();
var result = await usersService.Register("jane@doe.io", "Password1");
Console.ReadLine();
代码请求 Castle Windsor 容器提供IUsersService的实例,然后调用Register()方法。我们添加Console.ReadLine()是因为使用了异步;如果没有这样做,它将退出而不会打印任何日志消息。
你现在可以使用dotnet run或你编辑器中的首选方法运行此代码,你应该看到以下输出:
info: Chapter14.UsersService[1]
Before invoking Register
info: Chapter14.UsersService[0]
Inside register method
info: Chapter14.UsersService[3]
Before invoking Register
调用代码没有拦截器的概念;它一旦配置好就会自动编织到运行代码中。
然而,LoggingInterceptor中Intercept()方法的实现有些天真。它应该支持错误,并且还需要正确地支持异步方法调用。打开LoggingInterceptor.cs文件,并按如下方式更改Intercept()方法:
public void Intercept(IInvocation invocation)
{
var logger = _loggerFactory
.CreateLogger(invocation.TargetType)!;
logger.BeforeInvocation(invocation.Method.Name);
try
{
invocation.Proceed();
if (invocation.ReturnValue is Task task)
{
task.ContinueWith(t =>
{
if (t.IsFaulted)
{
logger.InvocationError(
invocation.Method.Name,
t.Exception!);
}
else
{
logger.AfterInvocation(
invocation.Method.Name);
}
});
}
else
{
logger.AfterInvocation(invocation.Method.Name);
}
}
catch (Exception ex)
{
logger.InvocationError(invocation.Method.Name, ex);
throw;
}
}
代码将Proceed()调用包裹在try {} catch {}中,以便能够记录错误,但会重新抛出异常,因为日志不应该吞没异常;它应该冒泡到原始调用者。对于处理异步调用,如果invocation实例是Task,它会查看ReturnValue。如果是任务,它将继续,并在完成时得到通知。任务可能处于错误状态,这将是如果调用导致异常的情况。
混入
在 C++中,多重继承提供了一种强大的方式来组合来自多个基类的行为。然而,这可能导致复杂性和菱形问题。混入提供了一种更简单的多重继承替代方案,避免了这些问题,并且特别适用于在代码中实现横切关注点。
然而,在.NET 公共语言运行时(CLR)中,不支持多重继承,因为它使用单继承模型。这意味着没有内置机制来组合来自多个类的行为。混入可以用来实现这一点,提供了一种在不修改其继承层次结构的情况下向类添加功能的方法。在本节中,我们将探讨混入是什么,它们是如何工作的,以及为什么你可能在 C#应用程序中使用它们来克服.NET CLR 单继承模型的限制。
城堡核心的关键特性之一是其对动态代理的支持,这允许你在运行时拦截方法调用并添加行为。
城堡 Windsor 对混入的支持建立在动态代理的此支持之上,以提供一种将来自多个来源的行为组合到单个对象中的方法。混入允许你定义一组行为作为独立的组件,这些组件可以与另一个对象的行为结合,以创建具有组合行为的新的对象。
在 Castle Windsor 中,混入(mixins)是通过动态代理和拦截的组合来实现的。当你注册带有混入的组件时,Castle Windsor 创建一个动态代理对象,该对象拦截对组件的方法调用,并将它们委托给混入。混入可以通过添加新功能或修改现有方法的行为来修改组件的行为。
在 Castle Windsor 中注册带有混入(mixins)的组件时,通常定义一个或多个表示混入的接口,并将它们作为单独的组件注册到容器中。然后,将您想要添加混入的组件注册到容器中,并指定混入作为依赖项。当组件从容器中解析出来时,Castle Windsor 创建一个动态代理对象,该对象实现了组件接口和混入接口,并将方法调用委托给适当的对象。
混合起来
你之前创建的 UserService 可能是混入的候选者。在一个系统中,通常需要一种方式来验证用户身份,以及一种方式来询问他们是否有权执行某个操作。显然,.NET 提供了构建块和出色的支持,用于认证和授权,但假设你想要在你的抽象之上构建一个特定于.NET 提供的抽象,以便能够通过用户名和密码验证用户,然后能够询问用户是否有权执行某个操作。
在 Chapter14 代码的文件夹中,添加一个名为 IAuthenticator.cs 的文件,并将以下内容放入其中:
namespace Chapter14;
public interface IAuthenticator
{
bool Authenticate(string username, string password);
}
IAuthenticator 接口定义了一个使用用户名和密码进行认证的方法。如果它能够成功认证用户,则通常返回 true,否则返回 false。
IAuthenticator 的实现可以放在一个名为 Authenticator.cs 的文件中,其内容如下:
namespace Chapter14;
public class Authenticator : IAuthenticator
{
public bool Authenticate(string username, string password)
{
return true;
}
}
代码实现了 IAuthenticator 接口,出于演示目的,它仅返回 true。由于我们不会构建任何特定内容,只是展示混入(mixins)的强大功能,所以目前这样是可以的。
对于授权部分,你需要另一个接口。添加一个名为 IAuthorizer.cs 的文件,并使其看起来如下:
namespace Chapter14;
public interface IAuthorizer
{
bool IsAuthorized(string username, string action);
}
通过提供用户名和操作来检查授权。如果用户被授权执行该操作,则该方法将返回 true,否则返回 false。
对于授权者,你还需要一个实现,因此创建一个名为 Authorizer.cs 的文件,并将以下内容放入其中:
namespace Chapter14;
public class Authorizer : IAuthorizer
{
public bool IsAuthorized(string username, string action)
{
return true;
}
}
与 Authenticator 实现一样,出于演示目的,它将仅返回 true。这就是你检查用户是否有权执行特定操作的地方。
IAuthenticator 和 IAuthorizer,以及它们各自的实现,是独立的,并且它们也与 IUsersService 独立。这很好,因为它们代表了与用户交互的不同方面。它们承担系统的特定责任,保持它们独立是逻辑上合理的,这有助于代码库的维护。
然而,在运行时能够一次性访问所有这些功能可能是有需求的。这就是混入(mixins)发挥作用的地方,它们可以使它看起来像是一个单一实现。
要实现这一点,你需要正确配置 Castle Windsor。打开 DefaultInstaller.cs 文件,并在 Install() 方法的顶部添加以下内容:
container.Register(
Component.For<IAuthenticator>()
.ImplementedBy<Authenticator>()
.LifestyleTransient());
container.Register(
Component.For<IAuthorizer>()
.ImplementedBy<Authorizer>()
.LifestyleTransient());
这段代码为 IAuthenticator 和 IAuthorizer 服务添加了显式的容器注册。添加这些显式注册的原因是我们必须为 IUsersService 添加一个显式注册,并且这必须发生在 DefaultInstaller 中已经设置的自动注册之前。如果你在自动注册之后注册 IUsersService,你会得到一个异常,表明有重复注册。
对于混入,你需要另一个显式注册。在 IAuthenticator 和 IAuthorize 注册之后,在自动注册之前添加以下代码:
container.Register(
Component.For<IUsersService>()
.ImplementedBy<UsersService>()
.Proxy.AdditionalInterfaces(typeof(IAuthorizer),
typeof(IAuthenticator))
.Proxy.MixIns(_ => _
.Component<Authorizer>()
.Component<Authenticator>())
.Interceptors<LoggingInterceptor>()
.LifestyleTransient());
IUsersService 的注册与之前所做的略有不同。首先,它指示 Castle Windsor IUsersService 由 UsersService 实现,然后它指示它实现一些额外的接口:IAuthorizer 和 IAuthenticator。然后,这些额外的接口被指示通过 .Mixins() 调用实现,该调用告诉它们由各自的 Authorizer 和 Authenticator 组件实现。由于这是一个显式注册,自动注册将不会启动,你之前连接的 LoggingInterceptor 也不会启动。为了使其启动,你需要为拦截器添加一个显式的 .Interceptors<>() 调用。最后,你设置生命周期为瞬时的。
如果你使用调试器运行此代码,你可以调查 UserService 发生了什么。在 Program.cs 文件中,你可以在调用 container.Resolve

图 14.2 – 调试断点
在你的编辑器/集成开发环境(IDE)的 Debug 控制台(即时窗口)中,你应该能够编写以下内容:
usersService.GetType().GetInterfaces()
此输出的内容可能如下所示:

图 14.3 – 实现的接口
为了证明调用是传递给混入的,你可以在 Program.cs 文件中在 Console.ReadLine(); 之前添加以下内容:
var authenticated = (usersService as IAuthenticator)!.
Authenticate("jane@doe.io", "Password1");
var authorized = (usersService as IAuthorizer)!.IsAuthorized("jane@
doe.io", "Some Action");
Console.WriteLine($"Authenticated: {authenticated}");
Console.WriteLine($"Authorized: {authorized}");
代码假设 UserService 也实现了 IAuthenticator 和 IAuthorizer 接口,并使用类型转换来获取它们,并分别调用 Authenticate() 和 IsAuthorized() 方法。然后它打印出这些方法的输出结果。
如果你现在运行应用程序,你应该会看到以下类似的输出:
info: Chapter14.UsersService[1]
Before invoking Register
info: Chapter14.UsersService[0]
Inside register method
info: Chapter14.Authenticator[1]
Before invoking Authenticate
info: Chapter14.UsersService[3]
Before invoking Register
info: Chapter14.Authenticator[3]
Before invoking Authenticate
info: Chapter14.Authorizer[1]
Before invoking IsAuthorized
info: Chapter14.Authorizer[3]
Before invoking IsAuthorized
Authenticated: True
Authorized: True
这种方法的缺点是,在 IUsersService 的契约中并不明确它还将实现 IAuthenticator 和 IAuthorizer 接口。然而,这可以通过不同的技术来克服。
一种方法是有这样一个没有实现但表示组合的接口,例如以下内容:
public interface IUsersServiceComposition : IUsersService, IAuthenticator, IAuthorizer
{
}
IUsersServiceComposition 接口仅用于组合;它不应该有任何直接成员,因为目标是结合 IUsersService、IAuthenticator 和 IAuthorizer 的实现。然后我们可以利用 Castle DynamicProxy 库中的底层 ProxyGenerator 来创建一个代理,以表示不同组件中的实现。
在 DefaultInstaller.cs 文件中,你现在可以为新的 IUsersServiceComposition 接口创建一种新的注册类型。在方法末尾,你可以添加以下代码:
container.Register(
Component.For<IUsersServiceComposition>()
.UsingFactoryMethod((kernel, context) =>
{
var proxyGenerator = new ProxyGenerator();
var proxyGenerationOptions = new ProxyGenerationOptions();
proxyGenerationOptions.AddMixinInstance(container.
Resolve<IAuthorizer>());
proxyGenerationOptions.AddMixinInstance(container.
Resolve<IAuthenticator>());
var logger = container.Resolve<ILogger<UsersService>>();
proxyGenerationOptions.AddMixinInstance(new
UsersService(logger));
var usersServiceComposition = (proxyGenerator.
CreateClassProxyWithTarget(
typeof(object),
new[] { typeof(IUsersServiceComposition) },
new object(),
proxyGenerationOptions) as IUsersServiceComposition)!;
return usersServiceComposition;
}));a
代码设置了一个使用方法创建实例的注册。使用 UsingFactoryMethod() 指令,你为 Castle Windsor 提供了一个当它需要解析实例时将被调用的方法。工厂方法使用 Castle DynamicProxy 中的 ProxyGenerator 并通过容器提供这些接口的实例来添加 IAuthorizer 和 IAuthenticator 接口的不同混合。对于 IUsersService,在这个示例中我们必须自己创建其实例,提供使用容器获取实例的记录器。这样做的原因是,你已经有了一个为 IUsersService 的注册,它为 IAuthorizer 和 IAuthenticator 接口添加了混合,这将在创建代理时抛出异常。
一旦所有混合配置完成,代码创建了一个基于 object 类型的目标类代理,并告诉它应该实现 IUsersServiceComposition 接口。
通过所有这些,你现在得到了一个实现了所有接口的实例,并将它们的实现委托给混合实例。相当巧妙。
使用 IUserServiceComposition 现在对于消费者来说更加直观和清晰,因为你不需要知道它可能实现的其他接口。打开 Program.cs 文件,在 Console.ReadLine() 之前添加以下代码:
var composition = container.Resolve<IUsersServiceComposition>();
authenticated = composition.Authenticate("jane@doe.io", "Password1");
authorized = composition.IsAuthorized("jane@doe.io", "Some Action");
Console.WriteLine($"Authenticated: {authenticated}");
Console.WriteLine($"Authorized: {authorized}");
运行程序现在应该给出以下类似的结果:
info: Chapter14.UsersService[1]
Before invoking Register
info: Chapter14.UsersService[0]
Inside register method
info: Chapter14.Authenticator[1]
Before invoking Authenticate
info: Chapter14.UsersService[3]
Before invoking Register
info: Chapter14.Authenticator[3]
Before invoking Authenticate
info: Chapter14.Authorizer[1]
Before invoking IsAuthorized
info: Chapter14.Authorizer[3]
Before invoking IsAuthorized
Authenticated: True
Authorized: True
Authenticated: True
Authorized: True
混合模式提供了一种通过结合其他对象的行为来向对象添加行为的有效方式。使用混合模式的一些好处包括以下内容:
-
组合:混入允许你从简单、可重用的组件中组合复杂的行为。这使得随着时间的推移维护和修改代码变得更加容易。
-
关注点分离:混入通过将对象分解成更小、更专注的功能部分,使你能够分离关注点。这使得理解代码和推理代码变得更加容易。
-
可重用性:混入允许你在多个对象之间重用行为,这减少了代码重复,并使得随着时间的推移维护和修改代码变得更加容易。
-
灵活性:混入通过允许你根据需要选择性地应用行为,提供了一种修改对象行为的方式。这为你提供了对代码行为的更大控制,并使得针对特定用例进行定制变得更加容易。
-
可测试性:混入允许你单独测试功能的一部分,这使得编写测试和确保代码的正确性更加容易。
混入通常与切点等技术结合使用,这些技术允许你在代码的特定点应用行为。切点提供了一种选择性地将混入应用于代码特定部分的方法,这为你提供了更大的灵活性和对代码行为的控制。
总结来说,混入(mixins)是一种强大的工具,可以以灵活、可维护和可重用的方式向对象添加行为。当与切点和其他 AOP 技术结合使用时,它们提供了一种强大的方式来自定义代码的行为,并实现更高的模块化、灵活性和可测试性。
授权
授权对于许多软件系统来说是一个关键问题,因为确保用户和应用只能访问他们被授权使用的资源和功能非常重要。AOP 可以作为一种强大的工具来实现授权行为,因为它允许开发者封装授权逻辑并在整个系统中一致地应用它。
实现 AOP 授权的一种方法是在 C#代码中使用连接点(join points)过滤到特定的命名空间。连接点是代码中可以应用方面的点,例如方法调用、字段访问或对象创建。通过使用连接点过滤到特定的命名空间,开发者可以将授权逻辑仅应用于系统的相关部分,从而降低错误或不一致的风险。
在面向切面编程(AOP)中,切点是指源代码中应用方面(aspect)的具体位置。换句话说,它是一种定义应该执行方面的集合(即程序执行流程中的特定点)的方法。
切点通常使用多种不同的标准定义,例如方法名称、方法签名、类名称、包名称、注解等。用于定义切点的标准通常使用一种称为“切点表达式”或“切点语言”的语法来表示。
使用的切点语言可能因所使用的 AOP 框架而异。例如,在 C# 中,借助 PostSharp 等库的帮助,你可以通过属性注解和方法签名的组合来定义切点。
一旦定义了切点,就可以将其用于在指定的连接点将一个或多个方面“编织”到代码中。这意味着方面的行为被插入到指定的连接点,而无需对原始源代码进行任何修改。
总体而言,切点提供了一种强大的方式,可以将跨切面关注点,如日志记录、缓存或安全,应用于程序执行流程的特定部分。
使用切点
使用拦截器时,你可能不希望它们应用于所有内容。这尤其是对于授权而言。你可能有一些不需要授权的应用程序部分,例如基础设施,以及可能对所有用户开放的具体部分。使用 Castle Windsor,我们可以通过利用拦截器的选择器来创建切点。这是一种根据类型和方法信息动态提供实际拦截器的方法。
让我们创建一个用于向列表添加待办事项的服务,该列表由 ITodoService 接口表示。在 Chapter14 项目的根目录下创建一个名为 Todo 的文件夹,添加一个名为 ITodoService.cs 的文件,并将以下内容添加到其中:
namespace Chapter14.Todo;
public interface ITodoService
{
void Add(string item);
}
接口仅公开一个简单的 Add() 方法用于以字符串形式添加一个项目。其实现应该添加到 Todo 文件夹内名为 TodoService.cs 的文件中,并看起来如下:
namespace Chapter14.Todo;
public class TodoService : ITodoService
{
public void Add(string item)
{
Console.WriteLine($"Adding '{item}' to the todo list");
}
}
由于我们不是专注于构建在数据存储中创建 todo 项的东西,我们只是在实现中打印添加的内容。这只是为了证明你可以如何利用拦截器和选择器来过滤调用。
下一步你需要的是一个检查用户是否授权的拦截器。
将名为 AuthorizationInterceptor.cs 的文件添加到 Chapter14 项目的根目录,并将以下代码添加到该文件中:
using Castle.DynamicProxy;
namespace Chapter14;
public class AuthorizationInterceptor : IInterceptor
{
readonly IUsersServiceComposition _usersService;
public AuthorizationInterceptor(IUsersServiceComposition
usersService)
{
_usersService = usersService;
}
public void Intercept(IInvocation invocation)
{
if (_usersService.IsAuthorized("jane@doe.io", invocation.
Method.Name))
{
invocation.Proceed();
}
}
}
代码实现了 Castle DynamicProxy IInterceptor 接口,构造函数依赖于为混合部分创建的 IUsersServiceComposition 服务。Intercept() 方法利用组合用户服务来检查用户是否授权。如果用户被授权,它允许调用继续进行。
重要提示:
用户被硬编码为 jane@doe.io,在生产系统中显然需要获取当前登录的用户。此外,如果未授权,实现不会做任何事情。一种方法可能是抛出 UnauthorizedException() 并让异常向上冒泡。
对于实际将过滤出何时应用哪些拦截器的实际切入点,你必须实现 Castle DynamicProxy 中找到的 IInterceptorSelector 接口。在项目的根目录中添加一个名为 InterceptorSelector.cs 的文件,并将其中的以下代码添加到该文件中:
using System.Reflection;
using Castle.DynamicProxy;
namespace Chapter14;
public class InterceptorSelector : IinterceptorSelector
{
public Iinterceptor[] SelectInterceptors(Type type, MethodInfo
method, Iinterceptor[] interceptors)
{
if (type.Namespace?.StartsWith("Chapter14.Todo", StringComparison.InvariantCulture) ?? false)
{
return interceptors;
}
return interceptors.Where(_ => _.GetType() !=
typeof(AuthorizationInterceptor)).ToArray();
}
}
SelectInterceptors() 方法会调用目标类型和被调用的方法,并给出配置的拦截器。在这个阶段,你可以决定哪些拦截器应该应用于方法调用。代码通过查看类型的命名空间来做出这个决定,任何以 Chapter14.Todo 开头的都应该有所有拦截器,而其他任何东西则除了 AuthorizationInterceptor 之外的所有拦截器。
在拦截器和选择器就位后,你必须在 Castle Windsor 容器中注册它们,并将它们连接起来以用于现有注册。打开 DefaultInstaller.cs 文件并在 Install() 方法的顶部添加以下内容:
container.Register(Component.For<InterceptorSelector>());
container.Register(Component.For<AuthorizationInterceptor>());
代码将 InterceptorSelector 和 AuthorizationInterceptor 都注册到容器中,使它们能够注入需要它们的服务。在 Install() 方法中,你已经添加了 LoggingInterceptor 的两个地方;在这两个地方,我们想要添加新的 AuthorizationInterceptor,并添加一个语句告诉 Castle Windsor 使用新的 InterceptorSelector 来选择正确的拦截器。
以下行添加了拦截器和选择器:
.Interceptors<AuthorizationInterceptor>()
.SelectInterceptorsWith(s => s.Service<InterceptorSelector>())
作为参考,配置 IUsersService 的代码块需要这两行:
container.Register(
Component.For<IUsersService>()
.ImplementedBy<UsersService>()
.Proxy.AdditionalInterfaces(typeof(IAuthorizer), typeof(IAuthenticator))
.Proxy.MixIns(_ => _
.Component<Authorizer>()
.Component<Authenticator>())
.Interceptors<LoggingInterceptor>()
// Add the interceptor and the selector
.Interceptors<AuthorizationInterceptor>()
.SelectInterceptorsWith(s => s.Service<InterceptorSelector>())
.LifestyleTransient());
需要两行的第二个块通常按照惯例是自动连接:
container.Register(Classes.FromAssemblyInThisApplication(Assembly.
GetEntryAssembly())
.Pick()
.WithService.DefaultInterfaces()
.Configure(_ => _
.Interceptors<LoggingInterceptor>()
.Interceptors<AuthorizationInterceptor>()
// Add the interceptor and the selector
.Interceptors<AuthorizationInterceptor>()
.SelectInterceptorsWith(s =>
s.Service<InterceptorSelector>()))
.LifestyleTransient());
现在一切都已注册,是时候尝试一下了。打开 Program.cs 文件并在 Console.ReadLine() 之前添加以下内容:
var todo = container.Resolve<ITodoService>();
todo.Add("Buy milk");
现在运行你的应用程序,你应该会看到以下输出:
info: Chapter14.UsersService[1]
Before invoking Register
info: Chapter14.UsersService[0]
Inside register method
info: Chapter14.UsersService[3]
Before invoking Register
info: Chapter14.Authenticator[1]
Before invoking Authenticate
info: Chapter14.Authenticator[3]
Before invoking Authenticate
info: Chapter14.Authorizer[1]
Before invoking IsAuthorized
info: Chapter14.Authorizer[3]
Before invoking IsAuthorized
Authenticated: True
Authorized: True
Authenticated: True
Authorized: True
info: Chapter14.Todo.TodoService[1]
Before invoking Add
Adding 'Buy milk' to the todo list
info: Chapter14.Todo.TodoService[3]
Before invoking Add
如预期的那样,你应该可以购买牛奶。让我们修改授权器以不允许这样做。打开 Authorizer.cs 文件并将返回值改为 false:
namespace Chapter14;
public class Authorizer : IAuthorizer
{
public bool IsAuthorized(string username, string action)
{
return false;
}
}
再次运行程序应该会产生不同的结果,Adding 'Buy milk' to the todo list 输出将被移除。你仍然会看到日志记录器发生的方法调用,但实际的调用被过滤掉了:
info: Chapter14.UsersService[1]
Before invoking Register
info: Chapter14.UsersService[0]
Inside register method
Authenticated: True
info: Chapter14.UsersService[3]
Before invoking Register
Authorized: False
info: Chapter14.Authenticator[1]
Before invoking Authenticate
info: Chapter14.Authenticator[3]
Before invoking Authenticate
info: Chapter14.Authorizer[1]
Before invoking IsAuthorized
info: Chapter14.Authorizer[3]
Before invoking IsAuthorized
Authenticated: True
Authorized: False
info: Chapter14.Todo.TodoService[1]
Before invoking Add
info: Chapter14.Todo.TodoService[3]
Before invoking Add
Windsor Castle 提供了一个基于 Castle DynamicProxy 的灵活且强大的切入点机制,允许你根据类型和方法上找到的元数据选择连接点。例如,你可以根据以下标准创建切入点:
-
方法的名称
-
方法的返回类型
-
方法的参数
-
类型或方法上存在属性
-
该方法的可访问性(例如,公共、私有等)
通过结合多个标准,您可以创建复杂的切入点,以匹配非常具体的连接点集合。例如,您可以创建一个匹配所有具有特定属性的公共方法或所有具有特定名称的非公共方法的切入点。
切入点是一种强大的机制,用于过滤调用并将方面仅应用于代码的特定部分。通过使用切入点,您可以避免将方面应用于每个单独的方法调用的开销,相反,仅在有需要的地方选择性地应用方面。这可以导致代码更快、更高效,并且关注点分离得更好。
总体而言,切入点是面向方面编程(AOP)的一个关键特性,Windsor Castle 提供了一个丰富且灵活的切入点机制,可用于创建复杂且强大的基于方面的解决方案。
摘要
虽然我们一直使用 Castle Windsor,但还有其他工具、框架或库也可以使用,例如 PostSharp (www.postsharp.net)、Autofac (autofac.org),或者只需使用底层的 Castle Core DynamicProxy来实现相同的功能,而无需购买完整的框架。您也可以使用反射发射自行实现这一点。
总体而言,AOP 和 Castle Windsor 提供了一种强大的机制,用于在您的代码中分离关注点,并使它们更加模块化和可重用。通过选择性地将方面应用于代码的特定部分,您可以实现高度灵活性和对应用程序行为的控制。
在第十三章,应用横切关注点中,我们讨论了在代码库中降低风险的重要性,这可能是最关键的使用场景。由于安全性是我们软件中最脆弱的方面,因此在处理这个问题时,采取零信任的心态并采取所有必要的步骤来预防安全漏洞是至关重要的。
进入下一章,我们将深入探讨 C#编译器(也称为 Roslyn)的力量。使用 Roslyn,您将获得一组全新的元数据来玩耍,以及元编程的新功能。
第四部分:使用 Roslyn 进行编译器魔法
在这部分,您将了解 C#编译器的功能以及它通过.NET 编译器 SDK 提供的不同扩展点。它深入探讨了编译器如何成为一个生态系统,以及您如何在编译时而不是仅在运行时进行元编程。这部分以对本书涵盖内容的概述、何时使用什么的思考以及一些结束语结束。
本部分包含以下章节:
-
第十五章,Roslyn 编译器扩展
-
第十六章,生成代码
-
第十七章,静态代码分析
-
第十八章,注意事项和结语
第十五章:Roslyn 编译器扩展
Roslyn 编译器扩展提供了一种强大的方式来修改和扩展 C#编译器的行为。使用 Roslyn 编译器,开发者可以编写在编译时分析和修改 C#代码的代码,为代码生成、代码转换和优化开辟了新的可能性。
在本章中,我们将探讨 Roslyn 编译器扩展项目的技术设置。本章本身并不专注于元编程,而是关注为接下来的两章提供的技术设置。我们将深入研究打包 Roslyn 编译器扩展以供重用的过程。我们将探讨不同的打包选项,例如 NuGet 包,并讨论使您的扩展易于其他开发者使用的最佳实践。
本章我们将涵盖以下主题:
-
如何设置项目和其组成部分
-
如何打包您的扩展以供重用
到本章结束时,您将牢固地理解如何设置 Roslyn 编译器扩展项目并将其打包以供重用。您将具备构建强大且灵活的扩展所需的知识和工具,这些扩展可以显著增强 C#编译器的功能。
技术要求
该章节的特定源代码可以在 GitHub 上找到,(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter15),并且它建立在 GitHub 上可找到的基础代码之上 (github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Fundamentals)。
如何设置项目和其组成部分
由 Roslyn 框架(learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/)驱动的 C#编译器提供了一个机制来加载和使用自定义扩展,这些扩展以分析器和代码修复提供者的形式存在。这些扩展可以打包成 NuGet 包或作为项目引用包含在内,并且它们被加载到编译过程中以分析或修改正在编译的源代码。
当 C#编译器遇到包含对 Roslyn 扩展引用的项目时,它使用.NET 的AssemblyLoadContext将扩展的程序集加载到编译过程中。这允许扩展的代码在编译过程中执行,并参与源代码的分析和转换。
Roslyn 扩展作为分析器或源生成器加载到编译器中。分析器负责检查源代码并报告诊断信息,这些诊断信息是关于代码中潜在问题的警告、错误或建议。代码修复提供者提供建议或自动修复以解决报告的问题,并用于你的代码编辑器。另一方面,源生成器根据特定的规则或模板在编译期间生成额外的源代码。
C#编译器扫描加载的程序集,寻找实现由 Roslyn 框架定义的不同扩展点的接口的类型。然后它创建这些类型的实例并调用它们的方法来执行源代码的分析和转换。
Roslyn 扩展在编译时动态加载,允许在不修改编译器本身的情况下灵活地添加或删除扩展,这是开放/封闭原则应用的绝佳例子。这种动态加载还使得扩展可以在不同的项目和解决方案之间重用,因为它们可以作为 NuGet 包打包和分发,或者作为项目引用共享。
他们能做什么?
Roslyn 编译器扩展为 C#中的元编程提供了一个强大且灵活的平台。使用 Roslyn,你可以完全访问正在编译的代码的语法树和语义模型,这让你对代码的结构、语法和语义有了深入的了解。这允许你执行复杂的代码分析,根据模式或约定生成代码,并对代码应用转换以实现各种目标。
这里有一些 Roslyn 编译器扩展如何成为元编程强大工具的方式:
-
代码生成:Roslyn 编译器扩展允许你在编译过程中生成代码。这可以用于自动生成重复的代码模式,例如数据访问层、序列化代码或重复任务的样板代码。你还可以根据约定、配置或元数据生成代码,这使得创建可重用和可定制的代码生成工具变得容易。
-
代码分析:Roslyn 编译器扩展允许你在编译过程中执行自定义代码分析。这可以帮助你捕捉潜在问题,强制执行编码标准,并提供自动化的代码审查反馈。例如,你可以使用 Roslyn 扩展在开发早期阶段识别并标记代码异味、安全漏洞或其他代码质量问题,帮助你保持项目中的代码质量处于高水平。
-
领域特定语言(DSL):Roslyn 编译器扩展可用于创建针对特定问题域提供专用语法和语义的 DSL。这允许您定义自己的 DSL 并在项目中使用它来提高可表达性和可维护性。使用 Roslyn 扩展,您可以创建自定义语法,创建自定义语义规则,并强制执行领域特定约定,使处理复杂领域特定概念变得更加容易。
-
工具和生产力:Roslyn 编译器扩展可用于为 Visual Studio 或 VSCode 等开发环境创建自定义工具和生产力功能。例如,您可以创建代码重构工具、代码补全提供者或诊断和快速修复,以简化开发工作流程并捕捉常见错误。使用 Roslyn 扩展,您可以创建符合团队特定需求和开发实践的定制工具,从而提高生产力和代码质量。
-
实验和创新:Roslyn 编译器扩展为编程语言和编译器领域的实验和创新提供了一个平台。您可以使用 Roslyn 扩展来原型设计新的语言功能,尝试不同的编程范式,或实现新颖的编程技术。这允许您推动 C# 的可能性边界,并在软件开发领域探索新想法。
Roslyn 编译器扩展是任何对元编程和推动 C# 可能性边界感兴趣的 C# 开发者的工具箱中的宝贵工具。
设置
在 Visual Studio 和其他 IDE 中,您有项目模板,可以轻松创建一个 Roslyn 编译器扩展。扩展实际上只是一个具有正确包引用的类库,具体取决于您创建的扩展类型。
由于我们不针对特定的 IDE,我们将从头开始使用 .NET CLI 并手动配置不同的文件。
我们首先需要的是一个项目文件夹。由于我们将重用扩展项目来编写后续章节,让我们创建一个名为 Roslyn.Extensions 的文件夹。
在 Roslyn.Extensions 文件夹中,运行以下命令:
dotnet new classlib
您现在应该得到两个名为 Roslyn.Extensions.csproj 和 Class1.cs 的文件。删除 Class1.cs 文件,因为您将不需要它。
在您的编辑器中打开 Roslyn.Extensions.csproj 文件。它应该看起来像以下这样:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
您需要稍作修改才能使其与编译器一起工作:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
</Project>
TargetFramework 的更改对于其正常工作是必要的。否则,C# 编译器将无法加载它。然而,你可能仍然希望使用最新的 C# 版本来编写你的扩展,因此你需要将 LangVersion 设置为反映你想要的 C# 版本。如果你希望保留 ImplicitUsings 和 Nullable,则可以这样做。包含分析器或源生成器的项目还需要将 EnforceExtendedAnalyzerRules 设置为 true。
对于开发分析器或源代码生成器,我们可能需要添加几个 NuGet 包引用。如前所述,本章的目标是为随后的章节设置一个通用的包,因此我们将包括我们想要的那些,以及一些额外的内容。
在 Roslyn.Extensions.csproj 文件的
<ItemGroup>
<PackageReference
Include="Microsoft.CodeAnalysis.CSharp"
Version="4.5.0" PrivateAssets="all" />
<PackageReference
Include="Microsoft.CodeAnalysis.Analyzers"
Version="3.3.4" PrivateAssets="all" />
<PackageReference
Include="Microsoft.CodeAnalysis.CSharp.CodeStyle"
Version="4.5.0" PrivateAssets="all"/>
<PackageReference
Include="Microsoft.CodeAnalysis.NetAnalyzers"
Version="7.0.1" PrivateAssets="all"/>
<PackageReference Include="StyleCop.Analyzers"
Version="1.1.118" PrivateAssets="all"/>
</ItemGroup>
前三个包用于分析器和源代码生成器的开发。而接下来的两个包是分析器,我们希望确保我们的代码遵循标准。
重要提示
在包中使用的 PrivateAssets 属性用于它们所添加的项目中的依赖项,并且引用此属性的人将无法直接继承这些依赖项。这对于我们将此项目打包为 NuGet 包时非常重要。
如果你希望所有包引用都仅限于这个包,你可以通过添加以下 ItemGroup 代码来执行一个巧妙的小 MSBuild 技巧:
<ItemGroup>
<PackageReference Update="@(PackageReference)"
PrivateAssets="All" />
</ItemGroup>
通过这样做,你不需要为所有引用设置 PrivateAssets="All" 属性。
根据你的 .NET 安装版本,引用包的版本号可能会有所不同。你可以在此处了解更多关于哪个版本适合你的信息:github.com/dotnet/roslyn/blob/main/docs/wiki/NuGet-packages.md。
添加通用规则
.NET 编译器和处理所有构建工作的底层 MSBuild 引擎的一个优点是,你可以有一个包含你想要的所有规则的项目。然后,引用它的每个项目都将继承这些规则。
EditorConfig (editorconfig.org) 是这些可以在项目间重复使用的项目之一。大多数 IDE 和代码编辑器都尊重 EditorConfig 的配置,这很好,因为你可以有一个团队使用各种编辑器,并且他们都会遵循相同的设置。
在存储库的根目录下,你可以放置一个名为 .editorconfig 的文件,该文件包含适用于项目内每个文件的通用规则设置。规则可以是格式化、制表符与空格、缩进级别,以及由编译器(如 C# 编译器)拾取的特定规则。
这对于确保代码库的一致性、避免潜在问题以及提高源代码的可维护性非常有用。在.NET 中,我们可以更进一步,通过将其打包到项目中,并确保所有引用该项目的项目都将获得这些规则。这种做法的好处是,您可以在仓库之外重用这些规则,我们将在本章稍后讨论这一点。
让我们在Roslyn.Extensions文件夹中创建一个名为.globalconfig的文件。向其中添加以下代码:
is_global = true
end_of_line = lf
indent_style = space
indent_size = 4
charset = utf-8
此配置将is_global设置为true,以指示它应该是一个全局设置文件。然后继续说明行尾格式、缩进样式和大小,以及使用的字符集。
然后,您可以继续指定针对.NET 的特定规则。有许多设置和规则可以进行配置,我建议您在这里了解更多信息:learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/code。
重要提示
在本书的 GitHub 仓库中,您将找到一个更完整的.globalconfig文件,其中指定了许多.NET 和 C#特定的规则。请注意,这些规则反映了我个人喜欢代码的方式。
由于您添加了对StyleCop的依赖,我们还可以为其配置全局选项。在Roslyn.Extensions文件夹中添加一个名为stylecop.json的文件,并向其中添加以下代码:
{
"$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/
StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/
Settings/stylecop.schema.json",
"settings": {
"indentation": {
"useTabs": false,
"indentationSize": 4,
"tabSize": 4
},
"orderingRules": {
"systemUsingDirectivesFirst": true,
"usingDirectivesPlacement": "outsideNamespace",
"blankLinesBetweenUsingGroups": "omit"
}
}
}
如您所见,它重复了缩进样式,这是推荐的。然后继续具体说明您希望代码呈现的规则。在这种情况下,它指定了using指令应该按照以System为前缀的顺序排序。接着,它说明using指令应该放在命名空间块之外,并且使用指令组之间不应有空行。这些选项以及更多可以在以下链接中找到:github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documenta。
常规项目设置
如果您有一组希望用于所有项目的配置属性,您可以创建一个通用的.props文件,该文件将自动被拾取并使用。您只需要一个与项目同名的文件,并带有.props扩展名。在我们的例子中,项目名称是Roslyn.Extensions,因此所需的文件应命名为Roslyn.Extensions.props。
props 文件基本上就是一个 MSBuild 项目文件,就像.csproj文件一样。MSBuild 有一个约定,即自动从像这样的公共扩展项目导入此文件到扩展包的消费者。
在一个通用的项目设置文件中,你不仅可以添加属性,还可以包含文件、添加包引用,或者做任何你可以在常规 .csproj 文件中做的事情。当你想要应用和强制执行通用设置时,这非常强大。
将一个名为 Roslyn.Extensions.props 的文件添加到 Roslyn.Extensions 项目中,并将以下代码放入其中:
<Project>
<PropertyGroup>
<!-- Compiler settings -->
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>True</TreatWarningsAsErrors>
<MSBuildTreatWarningsAsErrors>true
</MSBuildTreatWarningsAsErrors>
<!-- Code Analysis -->
<CodeAnalysisTreatWarningsAsErrors>True
</CodeAnalysisTreatWarningsAsErrors>
<RunAnalyzersDuringBuild>True
</RunAnalyzersDuringBuild>
<RunAnalyzersDuringLiveAnalysis>True
</RunAnalyzersDuringLiveAnalysis>
<RunAnalyzers>True</RunAnalyzers>
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
<!-- Code Style -->
<StyleCopTreatErrorsAsWarnings>false
</StyleCopTreatErrorsAsWarnings>
<EnforceCodeStyleInBuild>true
</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>
添加的属性首先配置了一些标准的 C# 编译器设置。它启用了 Nullable,然后告诉编译器要严格,并将任何警告视为错误。然后,它告诉 MSBuild 引擎做同样的事情。
由于我们包括了代码分析,下一节配置它强制编译器在构建期间运行分析器并启用所有分析器。
最后,它设置了样式检查分析器,以特别不将错误视为警告,并在构建期间强制执行。
你最后需要确保的是,.globalconfig 文件和 stylecop.json 文件被任何引用此项目的项目使用。这是通过在 Roslyn.Extensions.props 文件中在 PropertyGroup 之后添加 ItemGroup 来实现的,看起来是这样的:
<ItemGroup>
<GlobalAnalyzerConfigFiles
Include="$(MSBuildThisFileDirectory).globalconfig"/>
<AdditionalFiles
Include="$(MSBuildThisFileDirectory)stylecop.json"
Link="stylecop.json" />
</ItemGroup>
这让编译器知道关于配置静态代码分析和代码样式的两个文件。它使用一个名为 MSBuildThisFileDirectory 的变量,这是一个众所周知的 MSBuild 变量,它被设置为正在处理的文件的文件夹。省略此变量将使它相对于当前目录查找此文件,这对于每个引用此通用项目的项目来说都是不同的。
到目前为止,你所做的一切只是将通用的事情封装在一个通用项目中,这个项目可以在仓库中被引用,并自动配置所有引用它的项目。有时,你希望超越单个仓库的边界,将通用项目发布为一个包,然后其他项目可以重用它并获得相同的益处。
如何打包你的扩展以供重用
Roslyn 编译器扩展的一个关键优势是它们在不同项目和解决方案中的重用潜力。一旦你开发了一个 Roslyn 扩展,你可以打包它以供重用,并与其他开发者或团队共享,从而提供许多好处和优势:
-
代码一致性:重用 Roslyn 扩展可以帮助在不同项目和解决方案中强制执行一致的编码实践。你可以创建封装编码标准、约定或最佳实践的 Roslyn 扩展,并在你的组织中共享它们。这确保了所有项目都遵守相同的编码指南,减少了不一致性并提高了代码质量。
-
生产力:重用 Roslyn 扩展可以通过自动化重复性任务和提供生产力功能来提高开发者生产力。例如,你可以创建生成样板代码、自动化代码重构或提供自定义代码补全提供者的 Roslyn 扩展。通过重用此类扩展,你可以节省时间和精力,并提高整体开发生产力。
-
可维护性:重用 Roslyn 扩展可以通过封装复杂的逻辑或代码生成模式来提高代码的可维护性。你可以创建封装 DSL、自定义语法或语义规则的 Roslyn 扩展,并在项目之间共享它们。这使得维护和更新代码库变得更加容易,因为更改可以在中央位置进行,并通过共享扩展传播到所有使用该扩展的项目。
-
可扩展性:重用 Roslyn 扩展可以通过提供钩子或扩展点来使你的代码库更具可扩展性,供其他开发者使用。你可以创建提供扩展点的 Roslyn 扩展,例如自定义代码生成模板或代码分析规则,这些可以由其他开发者根据需要扩展或定制。这促进了协作,并使其他团队或开发者能够扩展你的代码库的功能。
-
创新:重用 Roslyn 扩展可以通过与社区分享新想法、技术或方法来促进创新。如果你开发了一个新颖或创新的 Roslyn 扩展,将其与社区分享可以鼓励他人在此基础上进行构建,从而带来新的发现、解决方案或技术。这有助于 Roslyn 生态系统的增长和进步,使整个社区受益。
通过共享和重用 Roslyn 扩展,你可以提高代码质量,增强生产力,并促进协作,为更强大和充满活力的 Roslyn 生态系统做出贡献。
Roslyn 扩展可以是元编程的强大工具,元编程涉及编写生成或操作其他代码的代码。通过创建和打包 Roslyn 扩展,你可以利用元编程技术来自动化重复性任务,强制执行编码标准,或在不同的项目和解决方案中应用合规规则,例如 通用数据保护条例(GDPR)。
例如,考虑一个场景,你有多个项目需要遵守 GDPR,确保遵循一致的数据处理实践。而不是手动检查和更新每个项目的代码库,你可以创建一个封装合规规则的 Roslyn 扩展,并将它们分布到各个项目中。这样,你可以确保在所有项目中统一应用相同的合规规则,节省时间和精力,并降低人为错误的风险。
此外,Roslyn 扩展还可以提供强大的元编程能力,根据特定要求生成代码或重构现有代码。例如,你可以创建一个 Roslyn 扩展,用于生成常见模式或模板的代码片段,例如实现设计模式、处理常见场景或生成样板代码。通过在项目之间打包和共享此扩展,你可以确保生成的代码符合你组织的编码标准或遵循特定模式,从而促进一致性和可维护性。
常见包属性
所有 NuGet 包都可以有额外的元数据。当发布到 NuGet 包仓库例如官方的 nuget.org时,这些元数据非常有用。这些元数据通常会在包的信息页面上显示。你添加的元数据包含有关作者、版权声明、项目位置等信息。
让我们添加所有元数据的属性。打开Roslyn.Extensions.csproj文件,并在<****Project>标签内的第一个
<PropertyGroup>
<Copyright>Packt Publishing</Copyright>
<Authors>all contributors</Authors>
<RepositoryUrl>https://github.com/PacktPublishing/Metaprogramming-
in-C-Sharp</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/PacktPublishing/
Metaprogramming-in-C-Sharp</PackageProjectUrl>
<PackageIcon>logo.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
显然,你可以将这些属性设置为适合你项目的内容。它还包括logo.png和一个README.md文件。如果你没有这些,可以简单地将其删除。然而,当发布到包仓库时,拥有一个README.md文件是推荐的。将有关如何使用包及其用途的信息放入此文件将对包的用户非常有帮助。
元数据仅指向logo.png文件和README.md文件,但它们必须明确添加才能成为包的一部分。在PropertyGroup元数据之后添加以下ItemGroup文本:
<ItemGroup>
<None Include="$(MSBuildThisFileDirectory)logo.png"
Pack="true" Visible="false" PackagePath="" />
<Content Include="$(MSBuildThisFileDirectory)README.md"
PackagePath="/" />
</ItemGroup>
重要提示
注意PackagePath属性的使用。这指示 NuGet 打包器将文件放入哪个目标路径。对于README.md文件,它将被放置在包的根目录下。
对于你之前添加到项目中的常见代码属性、代码分析和代码风格规则,它们也需要明确添加到包中才能生效。为这些文件添加另一个ItemGroup块:
<ItemGroup>
<Content Include=".globalconfig" PackagePath="build\" />
<Content Include="stylecop.json" PackagePath="build\" />
<Content Include="Roslyn.Extensions.props"
PackagePath="build\" />
</ItemGroup>
当Roslyn.Extensions.props文件作为包引用使用时,它需要位于包内的build文件夹中。由于我们使用带有MSBuildThisFileDirectory MSBuild 变量的路径前缀引用了常见文件,这意味着常见文件也必须在包内的build路径中。
分析器
最后一部分是分析器本身。为了使其工作,它需要位于 NuGet 包的特定部分,在名为analyzers/dotnet/cs的目录中。
在Roslyn.Extensions.csproj文件中添加另一个ItemGroup块:
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll"
Pack="true"
PackagePath="analyzers/dotnet/cs"
Visible="false" />
</ItemGroup>
ItemGroup根据OutputPath添加项目的 DLL 文件,这将根据您是构建调试版本还是发布版本而有所不同。通常,对于发布版本,它将是bin/Release/netstandard2.0,然后AssemblyName变量反映了输出程序集的名称,在我们的例子中,将是Roslyn.Extensions。
在Roslyn.Extensions项目的根目录下从终端运行dotnet pack -c release现在应该会创建一个包含所有组件的包。
包将被输出到bin/release文件夹,并命名为Roslyn.Extensions.1.0.0.nupkg。
我们可以检查包的内容,以确认我们希望包含的所有内容都已包含,并且位于正确的位置。NuGet 包不过是一个压缩的 ZIP 文件。这意味着我们可以使用您喜欢的 ZIP 实用程序打开它,并查看内容是否符合预期。
内容应类似于以下内容:

图 15.1 – 检查包内容
此包现在已准备好发布到集中式包管理器,如 NuGet。您可以在微软的官方文档中了解更多关于如何打包 NuGet 包的信息(learn.microsoft.com/en-us/nuget/nuget-org/publish-a-package)。
大致就是这样。您现在已为 Roslyn 扩展配置了一切,并且还添加了您希望每个引用您刚刚创建的包的项目都拥有的公共属性。
摘要
在本章中,我们探讨了 Roslyn 编译器扩展项目的技术设置,涵盖了 Roslyn 编译器扩展的关键组成部分。我们讨论了 Roslyn 编译器扩展可以修改 C#代码的各种方式。
我们还深入探讨了打包 Roslyn 编译器扩展以供重用的过程,探讨了可用的不同打包选项,并讨论了使您的扩展易于其他开发者使用的最佳实践。
在下一章中,我们将专注于使用 Roslyn 编译器扩展生成代码。我们将探讨基于现有代码生成新代码的技术,并讨论确保生成的代码质量高且符合既定约定和标准的最佳实践。通过本章和下一章获得的知识,您将能够构建功能强大且灵活的 Roslyn 编译器扩展,这些扩展可以显著增强 C#编译器的功能。
第十六章:生成代码
到目前为止,本书中我们已经探讨了元编程在.NET 运行时是多么强大。在运行时做所有事情的好处是能够适应运行时发生的事情。在运行时做这件事的缺点是它会影响性能。这正是 C# Roslyn 编译器真正发光的地方。我们过去有能力使用像 PostSharp (www.postsharp.net/) 或中间语言(IL)编织这样的商业产品来生成代码,使用 Fody (github.com/Fody/Fody)等项目。但有了 Roslyn,代码生成真正实现了民主化,并且变得对任何人来说都很容易做到。
个人而言,我多年来一直使用所有这些技术,最终,通过 Roslyn,我可以在不牺牲性能的情况下实现我喜欢的许多元编程。而且我可以以一种比以前更一致的方式做到这一点。
C# Roslyn 编译器通过允许开发者通过一组 API 参与其编译管道来实现这一点。有了这些 API,我们可以调查现有的代码,对其进行分析,然后生成新的代码,这些代码将被编译并集成到最终的二进制文件中。
在本章中,我们将探讨如何利用 Roslyn 编译器扩展来生成代码,深入探讨在编译时生成代码的细节。我们将学习如何检查语法树并生成额外的代码,甚至看看如何使用 Roslyn 通过元数据从代码中生成文本报告。
我们将涵盖以下主题:
-
为 Roslyn 编译器生成额外的代码
-
(滥用)编译器来生成不仅仅是 C#代码
-
提高开发者体验
到本章结束时,你将深刻理解如何使用 Roslyn 在编译时生成代码,并且你将拥有一套技术和最佳实践的工具包,用于在 C#中实现利用 Roslyn 编译器平台能力的元编程技术。因此,让我们深入探讨吧!
技术要求
本章特定的源代码可以在 GitHub 上找到(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter16),并且基于基础代码,该代码同样可在 GitHub 上找到(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Fundamentals)。
为 Roslyn 编译器生成额外的代码
Roslyn 编译器平台最强大的功能之一是能够在编译时生成额外的代码。这意味着我们可以在编译过程中创建新的 C#代码,并将其与其他代码一起编译。
我们将探讨如何利用 Roslyn 生成编译器的额外代码。这非常有用,可以帮助提高你和你的团队的生产力,通过消除重复性任务的需求。由于你在编译器内部工作,你将不得不使用编译器理解的语言以及它表示代码的方式——抽象语法树(ASTs)。
ASTs
AST 是一种用于表示源代码结构的数据结构。你可以将其与我们在第七章“推理表达式”中看到的 .NET 表达式 API 中的内容进行比较。它是一个由表示语言中找到的代码元素(如类、方法、字段和属性)的节点组成的层次结构。编译器从 AST 生成的结果是其最终阶段的二进制 IL 代码。虽然表达式在运行时执行此操作并且可以在运行时进行修改,但 AST 在进入编译器管道的最终阶段时是静态的。然而,在最终阶段之前,AST 可以被推理并修改。
AST 是通过解析源代码、解释所有关键字和变量,并将其分解为节点来构建的,这些节点随后以树状结构组合在一起。AST 被用作编译器或代码分析工具中代码的中间表示形式。一旦源代码被转换为 AST,分析和处理代码就变得容易得多。例如,一个工具可能使用 AST 来识别潜在的错误或以某种方式转换代码。
Roslyn 的一个关键优势是其可扩展性。因为 Roslyn 是开源的,并提供了一套丰富的 API 用于处理抽象语法树(AST),开发者可以轻松创建自己的代码分析工具,利用编译器的 AST。例如,开发者可能创建一个分析代码安全漏洞的工具,或者一个自动为代码库生成文档的工具。
为了让开发者更容易扩展 Roslyn,平台提供了一系列扩展点,例如以下内容:
-
语法树:开发者可以创建自己的语法树来表示代码,并使用 Roslyn API
-
语法重写器:开发者可以创建语法重写器,以各种方式转换 AST,例如重命名变量或提取方法
-
诊断:开发者可以创建自己的诊断,以识别代码中的问题,例如潜在的错误或样式违规
-
代码修复提供者:开发者可以创建代码修复提供者,自动修复诊断中识别出的任何问题
通过这些扩展点,Roslyn 使得开发者能够轻松创建扩展,这些扩展可以改善所编写的代码质量,或者通过自动生成管道代码来提高生产力。
编译器理论和 AST 的工作原理是一个很大的主题,这超出了本书的范围。相反,让我们动手实践,看看可以做什么。
应用程序指标
在生产中运行系统的一个重要方面是可观察性。通过可观察性,我指的是观察应用程序重要方面的能力。日志是这些方面之一,其中你使用日志消息对你的代码进行仪表化,这些日志消息会被写入并被日志搜索索引器捕获。日志可能非常冗长,因此它不适合简单的测量值,如计数器、仪表或直方图。
随着.NET 6 的发布,微软引入了一个名为System.Diagnostics.Metrics的命名空间。当您想要观察随时间变化的值时,这个新命名空间中的类是完美的。除此之外,还有一些支持OpenTelemetry(opentelemetry.io)的包,这使得您能够捕获像 Prometheus、Azure AppInsight 等流行收集器中的不同值。对于我们的示例,我们只将使用控制台查看器。
微软构建了对指标的支持,使用起来非常方便,尽管它缺乏微软为日志构建的优雅和结构化方法。为了看到问题,我们将首先从开箱即用的体验开始使用指标,然后对其进行改进。让我们开始吧!
- 让我们先为这一章创建一个新的项目。你应该在本书中使用的Fundamentals项目旁边创建这个新项目,以及在第十五章中建立的Roslyn.Extensions项目Chapter 15,Roslyn 编译器扩展。
创建一个名为Chapter16的文件夹,在命令行中切换到这个文件夹,并创建一个新的 Web 项目:
dotnet new web
-
你应该了解 Web 项目的基础知识。让我们将其修改为可以使用控制器。将Program.cs文件修改如下所示:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); var app = builder.Build(); app.UseRouting(); app.MapControllers(); app.Run();
此代码将控制器添加到builder.Services中,然后在运行应用程序之前映射应用程序中的所有控制器。
-
由于目标是捕获指标,你需要一个被称为仪表的东西,用于跟踪你想要的值。如果你想为系统中的不同区域创建多个仪表类,那也是可以的,但通常每个应用程序只有一个。添加一个名为Metrics.cs的文件,并使其看起来如下所示:
using System.Diagnostics.Metrics; namespace Chapter16; public static class Metrics { public static readonly Meter Meter = new("Chapter16"); }
此代码引入了System.Diagnostics.Metrics命名空间,并公开了一个名为Chapter16的全局仪表。然后,任何应用程序中的代码都可以使用它。
-
现在,你想要添加一些在仪表中创建值的东西。添加一个名为EmployeesController.cs的文件,并使其看起来如下所示:
using Microsoft.AspNetCore.Mvc; namespace Chapter16; [Route("/api/employees")] public class EmployeesController : Controller { [HttpGet] public IActionResult Register() { return Ok(); } }
此代码引入了一个位于/api/employees路由的单个操作的 Web API 控制器。该操作仅返回Ok() – HTTP 200 状态。
重要提示
对于本章,我们不是关注我们正在构建的功能,而是关注我们试图解决的技術问题。因此,我们也让它接受 HTTP GET 请求。通常,它会是 HTTP POST 请求,并包含有关要注册的员工的详细信息负载。
-
让我们使用一个计数器来测量已注册员工的数量来对代码进行度量。为此,你需要在 EmployeeController.cs 文件的顶部添加几个 using 语句:
using System.Diagnostics; using System.Diagnostics.Metrics; -
现在,你可以在 EmployeesController 类中添加一个计数器。在类的顶部添加以下内容:
static Counter<int> _registeredEmployees = Metrics.Meter.CreateCounter<int>("Registered Employees", "# of registered employees");
此代码引入了一个使用全局计量器创建的计数器。它是静态创建的,这样我们就不需要在同一个应用程序中创建多个相同的计数器实例。
-
要使用计数器,将 Register() 方法修改成以下样子:
[HttpGet] public IActionResult Register() { var now = DateTimeOffset.UtcNow; var tags = new TagList(new ReadOnlySpan <KeyValuePair<string, object?>>(new KeyValuePair<string, object?>[] { new("Year", now.Year), new("Month", now.Month), new("Day", now.Day), })); _registeredEmployees.Add(1, tags); return Ok(); }
代码通过在 _registeredEmployees 计数器上调用 Add() 方法来使用该计数器。它还传递了标签,这些标签是在调用 Add() 之前设置的。标签是一种将添加的值分组的方式。从顶级来看,计数器将聚合所有标记的值,而你可以在自己的标签上单独监控每个值。这对于分解你想要监控的指标非常有帮助。Register() 方法通过年份、月份和日期来分解值。
重要提示
标签值是 对象。你也可以传递一个 DateOnly 实例,但这说明了多个标签的使用。
-
在放置了第一个计数器之后,是时候看看这实际上是什么样子了。为此,你需要安装一个名为 dotnet-counters 的工具。这可以通过在终端中运行以下命令来完成:
dotnet tool install --global dotnet-counters -
然后,你可以通过运行以下命令来启动你的应用程序:
dotnet run
你应该会看到以下类似的内容:
Building...
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5000
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: /Users/einari/Projects/Metaprogramming-in-C/Chapter16
-
在另一个终端中,你可以通过运行以下命令来启动指标监控器:
dotnet counters monitor --name Chapter16 --counters Chapter16
你应该会看到以下类似的内容:
Press p to pause, r to resume, q to quit.
Status: Running
由于还没有对 API 端点发起任何请求,所以值目前不会显示。保持监控器运行,并在浏览器中导航到端点(例如,http://localhost:5000/api/employees);你应该会看到以下类似的内容:
Press p to pause, r to resume, q to quit.
Status: Running
[Chapter16]
RegisteredEmployees (# of registered employees / 1
sec)
Day=1,Month=5,Year=2023 0
值将每秒采样一次。如果你多次点击浏览器,你应该会看到行尾的 0 增加,然后回落到 0。这是预期的,因为它只显示当前的测量值,而不是随时间聚合的值。
尽管在 .NET 中的指标 API 简单易用,但它很容易变得非常冗长,尤其是当你想要关联标签时。
提高开发者体验
在你的业务代码中,每个方法上都有度量指标的设置代码看起来很奇怪。这也非常冗长且繁琐。想象一下,在一个已经发展并需要收集大量度量的应用中;这会变得有些混乱。你可以显然通过封装度量指标来清理这个问题,要么在需要度量的类中使用方法,要么将它们提取到它们自己的类中。
然而,我相当喜欢微软对日志的处理方法,正如我们在第十四章中看到的,面向方面编程。对于日志处理,它依赖于一个在编译时运行的代码生成器,并将代码放入最终的二进制文件中。
让我们模仿这个方法,并创建一个改进的开发者体验:
-
在Fundamentals项目中,创建一个名为Metrics的文件夹。在这个文件夹中,添加一个名为CounterAttribute.cs的文件,并使其看起来如下:
namespace Fundamentals.Metrics; [AttributeUsage(AttributeTargets.Method)] public sealed class CounterAttribute<T> : Attribute { public CounterAttribute(string name, string description) { Name = name; Description = description; } public string Name { get; } public string Description { get; } }
这段代码引入了一个表示计数器的属性。计数器可以有一个与之关联的名称和描述。这是一个通用属性,允许你指定用于计数器的类型。
-
在Fundamentals项目的Metrics文件夹中,添加一个名为GlobalMetrics.cs的文件,并使其看起来如下:
using System.Diagnostics.Metrics; namespace Fundamentals.Metrics; public static class GlobalMetrics { public static Meter Meter = new("Global"); } -
这引入了一个全局可访问的Meter实例,这将使你将要构建的代码生成器更容易预测,因为它需要访问这个实例。然而,它默认使用名为Global的计量器,我们希望覆盖它。打开Chapter16中的Program.cs文件,并在using语句之后添加以下内容:
GlobalMetrics.Meter = new Meter("Chapter16");
本章的目标是提供一个更简单的方式来执行度量。这将通过创建一个提供方法签名但不提供实现的局部类来实现。源代码生成器将创建局部类的实现并为每个方法提供实现。
-
让我们为EmployeesController添加度量文件。添加一个名为EmployeesControllerMetrics.cs的文件,并在其中添加以下内容:
using System.Diagnostics.Metrics; using Fundamentals.Metrics; namespace Chapter16; public static partial class EmployeesControllerMetrics { [Counter<int>("RegisteredEmployees", "# of registered employees")] public static partial void RegisteredEmployees(DateOnly date); }
代码设置了一个静态的局部类,这对于确定要为哪些类生成源代码是一个重要的标准。所有计数器都表示为具有给定名称和[Counter]属性(包含详细信息)的方法。方法上的每个参数都将用作标签。
现在,你已经为源代码生成器能够工作准备了所需的基本内容。
设置代码模板
代码生成器将生成实现局部类的代码。为此,你将使用一个表示要生成的源代码的模板文件。作为一个模板语言,你将使用一个叫做Handlebars的东西(handlebarsjs.com)。这有一个.NET 实现。
打开 Roslyn.Extensions.csproj 文件,该文件位于 Roslyn.Extensions 文件夹中,并在包含其他包引用的 ItemGroup 中添加以下包引用:
<PackageReference Include="handlebars.net" Version="2.1.4"
GeneratePathProperty="true" PrivateAssets="all" />
PrivateAssets="all" 属性指示它仅为此项目提供引用,并且只能与扩展本身一起使用,这意味着来自 Handlebars 的任何程序集都不会包含在引用此项目的任何项目中。此外,您必须设置 GeneratePathProperty="true"。这将创建一个特定于包的变量,并允许我们指定要使用的 Handlebars 的特定程序集;否则,编译器将显示 FileNotFoundError。
要指定正确的程序集,请将以下内容添加到 Roslyn.Extensions.csproj 文件中,该文件位于 Roslyn.Extensions 文件夹的末尾,在 Project 标签内:
<PropertyGroup>
<GetTargetPathDependsOn>$(GetTargetPathDependsOn);
GetDependencyTargetPaths</GetTargetPathDependsOn>
</PropertyGroup>
<Target Name="GetDependencyTargetPaths">
<ItemGroup>
<TargetPathWithTargetPlatformMoniker
Include="$(PKGHandlebars_Net)\lib\netstandard2.0\
Handlebars.dll" IncludeRuntimeDependency="false" />
</ItemGroup>
</Target>
在正确安装了 Handlebars 之后,您就可以创建生成代码所需的模板了。在创建模板之前,您需要设置所有将传递给模板的数据类型。
在 Roslyn.Extensions 文件夹中,创建一个名为 Metrics 的文件夹。添加一个名为 MetricsTemplateData.cs 的文件,并将其内容添加如下:
namespace Roslyn.Extensions.Metrics;
public class MetricsTemplateData
{
public string Namespace { get; set; } = string.Empty;
public string ClassName { get; set; } = string.Empty;
public IEnumerable<CounterTemplateData> Counters { get;
set; } = Enumerable.Empty<CounterTemplateData>();
}
MetricsTemplateData 将是传递给模板的根对象。它包含生成代码的命名空间,然后是生成类的类名。然后继续包含它将生成的所有计数器的集合。
对于计数器定义,添加一个名为 CounterTemplateData.cs 的文件,并将其内容添加如下:
namespace Roslyn.Extensions.Metrics;
public class CounterTemplateData
{
public string Type { get; set; } = string.Empty;
public string MethodName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public IEnumerable<CounterTagTemplateData> Tags { get;
set; } = Enumerable.Empty<CounterTagTemplateData>();
}
CounterTemplateData 类型包含有关计数器类型、表示它的方法名称、计数器名称以及与计数器一起使用的描述的信息。最后,它包含在调用时与计数器关联的所有标签。
对于标签定义,添加一个名为 CounterTagTemplateData.cs 的文件,并使其看起来如下:
namespace Roslyn.Extensions.Metrics;
public class CounterTagTemplateData
{
public string Type { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
标签包含一个类型,这将在被调用方法的签名中反映出来,然后是名称。
当模板的参数对象定义完成后,是时候添加模板了。
在 Roslyn.Extensions 文件夹中,创建一个名为 Templates 的文件夹,并在 Templates 文件夹中添加一个名为 Metrics.hbs 的文件,并使其看起来如下:
using System.Diagnostics;
using System.Diagnostics.Metrics;
using Fundamentals.Metrics;
namespace {{Namespace}};
#nullable enable
public static partial class {{ClassName}}
{
{{#Counters}}
static readonly Counter<{{Type}}> {{MethodName}}Metric
= GlobalMetrics.Meter.CreateCounter<{{Type}}>
("{{Name}}", "{{Description}}");
{{/Counters}}
{{#Counters}}
public static partial void {{MethodName}}({{#Tags}}
{{Type}} {{Name}}{{#unless @last}}, {{/unless}}
{{/Tags}})
{
var tags = new TagList(new ReadOnlySpan
<KeyValuePair<string, object?>>(new KeyValuePair
<string, object?>[]
{
{{#Tags}}
new("{{Name}}", {{name}}){{#unless @last}},
{{/unless}}
{{/Tags}}
}));
{{MethodName}}Metric.Add(1, tags);
}
{{/Counters}}
}
在模板中,存在数据上下文。顶级项将是MetricsTemplateData实例。这是插入{{Namespace}}和{{ClassName}}值的地方。使用{{}}与文本表示可替换的值,而文本本身是当前上下文中存在的属性。当引号内的值以#符号开头时,它使用一个函数来解析它。Handlebars 有一些自动魔法,可以识别可枚举的,如Counters。Handlebars 将遍历这些,并且在其作用域内的任何内容都将为每个实例输出。模板通过这些技术在整个过程中替换传递给它的对象中找到的所有值。
EmployeesControllerMetrics的最终结果将呈现如下:
using System.Diagnostics;
using System.Diagnostics.Metrics;
using Fundamentals.Metrics;
namespace Chapter16;
#nullable enable
public static partial class EmployeesControllerMetrics
{
static readonly Counter<int> RegisteredEmployeesMetric
= GlobalMetrics.Meter.CreateCounter<int>
("RegisteredEmployees", "# of registered employees");
public static partial void RegisteredEmployees(DateOnly
date)
{
var tags = new TagList(new ReadOnlySpan
<KeyValuePair<string, object?>>(new KeyValuePair
<string, object?>[]
{
new("date", date)
}));
RegisteredEmployeesMetric.Add(1, tags);
}
}
在模板就绪的情况下,我们需要一种在代码生成器中程序化访问它的方法。为了实现这一点,你希望在编译时将任何模板文件嵌入到程序集中。
打开Roslyn.Extensions.csproj,在Project标签内添加一个类似以下的ItemGroup:
<ItemGroup>
<EmbeddedResource Include="$(MSBuildThisFileDirectory)
/Templates/**/*.hbs" />
</ItemGroup>
EmbeddedResource标签指示编译器将Templates文件夹内的所有hbs文件包含在内,并将它们作为程序集的嵌入式资源。
嵌入式资源是程序集的一部分,被称为资源。它们可以直接在它们所属的程序集上访问。
让我们创建一个辅助类来获取访问模板和未来将添加的模板。在Roslyn.Extensions项目的Templates文件夹中添加一个名为TemplateTypes.cs的文件。使其看起来如下:
using HandlebarsDotNet;
namespace Roslyn.Extensions.Templates;
public static class TemplateTypes
{
public static readonly HandlebarsTemplate<object,
object> Metrics = Handlebars.Compile
(GetTemplate("Metrics"));
static string GetTemplate(string name)
{
var rootType = typeof(TemplateTypes);
var stream = rootType.Assembly.GetManifest
ResourceStream($"{rootType.Namespace}.{name}.hbs");
if (stream != default)
{
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
return string.Empty;
}
}
代码在TemplateTypes类中引入了一个名为GetTemplate()的私有方法。它利用程序集实例上的GetManifestResourceStream()。按照惯例,编译器将文件夹及其任何子文件夹的命名空间。访问资源将类似于访问其中的类型。由于模板位于Templates文件夹中,它将与TemplateTypes类的同一文件夹和同一命名空间相同。因此,这被用作模板名称前缀的前缀。然后代码使用StreamReader读取资源流到末尾,给你一个包含模板的字符串。
TemplateTypes类顶部有一个属性表示Metrics模板。
现在我们已经有了访问它的模板和代码,你可以继续创建代码生成器。
构建源代码生成器
为了使代码生成器只为符合标准的类生成代码,你需要一个在每个源代码编译节点上被调用的语法接收器。
在Roslyn.Extensions项目的Metrics文件夹内添加一个名为MetricsSyntaxReceiver.cs的文件。向其中添加以下代码:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Roslyn.Extensions.Metrics;
public class MetricsSyntaxReceiver : ISyntaxReceiver
{
readonly List<ClassDeclarationSyntax> _candidates =
new();
internal IEnumerable<ClassDeclarationSyntax> Candidates
=> _candidates;
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
if (syntaxNode is not ClassDeclarationSyntax
classSyntax) return;
if (classSyntax.Modifiers.Any(modifier =>
modifier.IsKind(SyntaxKind.PartialKeyword)) &&
classSyntax.Modifiers.Any(modifier =>
modifier.IsKind(SyntaxKind.StaticKeyword)))
{
if (classSyntax.Members.Any(member =>
member.IsKind(SyntaxKind.MethodDeclaration) &&
member.Modifiers.Any(modifier =>
modifier.IsKind
(SyntaxKind.PartialKeyword)) &&
member.Modifiers.Any(modifier => modifier
.IsKind(SyntaxKind.StaticKeyword))))
{
_candidates.Add(classSyntax);
}
}
}
}
代码实现了ISyntaxReceiver接口,并带有OnVisitSyntaxNode()方法,该方法将在编译器的每个 AST 节点上被调用。MetricsSyntaxReceiver的目的在于缩小对代码生成感兴趣的类的范围。首先,它通过要求它必须是类来过滤,然后看起来这个类是部分静态的。最后的过滤是查找类的任何成员,寻找静态部分方法。如果所有标准都满足,它将把该类添加到候选列表中。
在接收器过滤完成后,现在是设置生成器本身的时候了。在MetricsSyntaxReceiver.cs文件旁边,添加一个名为MetricsSourceGenerator.cs的文件。将以下代码添加到其中:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Roslyn.Extensions.Templates;
namespace Roslyn.Extensions.Metrics;
[Generator]
public class MetricsSourceGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext context)
{
}
public void Initialize(GeneratorInitializationContext
context)
{
context.RegisterForSyntaxNotifications(() => new
MetricsSyntaxReceiver());
}
}
代码创建了一个名为MetricsSourceGenerator的类,该类实现了ISourceGenerator接口,并带有其Execute()和Initialize()方法。为了生成器能够工作,您还必须添加[Generator]属性。在Initialize()方法中,代码注册了您放入的语法接收器。
在Execute()方法中,所有魔法都将发生。让我们首先将以下内容添加到Execute()方法的主体中:
if (context.SyntaxReceiver is not MetricsSyntaxReceiver
receiver) return;
var counterAttribute = context.Compilation
.GetTypeByMetadataName("Fundamentals
.Metrics.CounterAttribute`1");
foreach (var candidate in receiver.Candidates)
{
var templateData = new MetricsTemplateData
{
Namespace = (candidate.Parent as
BaseNamespaceDeclarationSyntax)!.Name.ToString(),
ClassName = candidate.Identifier.ValueText
};
var semanticModel = context.Compilation
.GetSemanticModel(candidate.SyntaxTree);
foreach (var member in candidate.Members)
{
if (member is not MethodDeclarationSyntax method)
continue;
var methodSymbol = semanticModel
.GetDeclaredSymbol(method);
if (methodSymbol is not null)
{
var attributes = methodSymbol.GetAttributes();
var attribute = attributes.FirstOrDefault(_ =>
SymbolEqualityComparer.Default.Equals
(_.AttributeClass?.OriginalDefinition,
counterAttribute));
if (attribute is not null)
{
// Generate
}
}
}
if (templateData.Counters.Count > 0)
{
var source = TemplateTypes.Metrics(templateData);
context.AddSource($"{candidate.Identifier
.ValueText}.g.cs", source);
}
}
代码期望SyntaxReceiver是MetricsSyntaxReceiver;如果不是,它就返回。然后,它继续获取CounterAttribute类型定义的实例。注意名称有点奇怪;CounterAttribute'1。这是因为该类型是泛型类型,在.NET 内部,类型将根据泛型参数的数量后缀一个数字。
对于MetricsSyntaxReceiver找到的所有候选者,代码将循环遍历并为类设置一个MetricsTemplateData实例。然后,它根据类的语法树获取所谓的语义模型。Roslyn 中的语义模型提供了对代码意义的更深入理解,这超出了其语法。它可以用于诸如名称绑定、类型检查、错误检查和自动化重构等任务。
类有成员,代码遍历所有成员并过滤掉那些不是方法的成员。从语义模型中,它获取方法的声明符号,这使得我们可以很好地访问其上的属性。然后,它查找CounterAttribute。
在最后,它从模板生成源代码,但仅当有要生成的计数器时才会这样做。它通过使用GeneratorExecutionContext提供的AddSource()方法来提供源代码。生成的文件约定是包含类型名称,然后后缀为.g.cs。
为了启动生成器,它需要计数器。将以下代码添加到Execute()方法中,替换// Generate注释:
var tags = method.ParameterList.Parameters.Select(parameter
=> new CounterTagTemplateData
{
Name = parameter.Identifier.ValueText,
Type = parameter.Type!.ToString()
});
var type = attribute.AttributeClass!.TypeArguments[0]
.ToString();
var name = attribute.ConstructorArguments[0].Value!
.ToString();
var description = attribute.ConstructorArguments[1].Value!
.ToString();
templateData.Counters.Add(
new CounterTemplateData
{
Name = name,
Description = description,
Type = type,
MethodName = method.Identifier.ValueText,
Tags = tags
});
代码从属性和方法信息中收集信息,以提供模板所需的数据。
这基本上就是生成器要做的所有事情。由于我们依赖于在第十五章中完成的设置,Roslyn 编译器扩展,我们现在需要做的就是开始使用它。
测试源生成器
要编译并使Chapter16代码工作,请按照以下步骤操作:
-
首先,您需要为基础项目添加一个引用:
dotnet add reference ../Fundamentals/Fundamentals.csproj -
然后,您需要Roslyn.Extensions项目的引用。这需要稍微不同一些,因为您希望它自动使用生成器。在Chapter16.csproj文件中,在基础项目的引用旁边添加一个ProjectReference,如下所示:
<ProjectReference Include="..\Roslyn.Extensions\ Roslyn.Extensions.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
这指示引用使用分析器,并且不在第十六章的输出中包含其任何程序集。
-
要开始使用新的指标方式,您需要更改EmployeeController类。在Chapter16文件夹中打开EmployeeController.cs文件,并将Register()方法更改为以下内容:
[HttpGet] public IActionResult Register() { EmployeesControllerMetrics.RegisteredEmployees (DateOnly.FromDateTime(DateTime.UtcNow)); return Ok(); }
代码现在使用新的部分类,而不是自己处理计数器和标签。
-
在第十六章项目上执行构建:
dotnet build
在obj文件夹中,您现在可以看到源生成器的结果:

图 16.1 – 文件系统中的生成文件
-
打开EmployeesControllerMetrics.g.cs并确认您有预期的结果:
using System.Diagnostics; using System.Diagnostics.Metrics; using Fundamentals.Metrics; namespace Chapter16; #nullable enable public static partial class EmployeesControllerMetrics { static readonly Counter<int> RegisteredEmployees Metric = GlobalMetrics.Meter.CreateCounter <int>("RegisteredEmployees", "# of registered employees"); public static partial void RegisteredEmployees (DateOnly date) { var tags = new TagList(new ReadOnlySpan <KeyValuePair<string, object?>>(new KeyValuePair<string, object?>[] { new("date", date) })); RegisteredEmployeesMetric.Add(1, tags); } } -
运行您的项目,然后通过运行以下命令启动监控:
dotnet counters monitor -n Chapter16 --counters Chapter16 -
然后通过浏览器导航到端点(例如,http://localhost:5000/api/employees)来触发 API。您应该看到以下类似输出:
Press p to pause, r to resume, q to quit. Status: Running [Chapter16] RegisteredEmployees (# of registered employees / 1 sec) date=5/1/2023 0
这种技术可以非常强大。可以支持更多类型的指标并扩展,为您提供一个更直观和简单的方式来处理指标值。
添加要编译的源代码非常强大,您不仅限于添加部分类;实际上,您可以添加任何您想要的,这可以非常有用。但话虽如此,您不仅限于为编译器输出源文件。您还可以生成其他工件。
(滥用)编译器来生成不仅仅是 C#代码
由于您基本上可以在代码生成器中做任何事情,您可以去生成其他任何东西。我们在日常工作中用它来做的一件事是从我们的 C#代码生成 TypeScript 文件。这非常有用,我们节省了大量时间,并在 TypeScript 中获得了与 C# REST API 一致的一致性。
让我们去做一些不会最终出现在 C#文件中的事情。基于 GitHub 仓库中的基础项目和您在本书中迄今为止所构建的内容,您应该有一个名为Fundamentals.Compliance.GDPR的命名空间和一个名为PersonalIdentifiableInformation的属性,该属性在第五章中介绍,利用属性。
这个属性非常适合标记收集个人身份信息(PII)的类型以及收集 PII 的原因。在第五章中,利用属性,我们在运行时用它来创建运行时报告。我们本可以做的另一件事是在编译时创建这个报告。
在Chapter16文件夹中添加一个名为Employee的文件,并使其看起来如下所示:
using Fundamentals.Compliance.GDPR;
namespace Chapter16;
public record Employee(
[PersonalIdentifiableInformation("Needed for
registration")]
string FirstName,
[PersonalIdentifiableInformation("Needed for
registration")]
string LastName,
[PersonalIdentifiableInformation("Needed for uniquely
identifying an employee")]
string SocialSecurityNumber);
Employee类型具有用[PersonalIdentifiableInformation]属性注解的属性,声明了收集信息的具体原因。这是我们希望在 GDPR 报告中输出的内容,说明哪些类型具有包含 PII 的成员。
构建生成器
为了让我们的生成器知道在哪里输出结果文件,它需要一个可配置的属性。在编译器上下文中运行时的当前目录是编译器所在的位置,通常是一个您没有写入权限的地方。此外,在随机位置写入文件也不是很有用。
生成器可以在.csproj文件中具有属性。为了让它们对生成器可见,您需要告诉编译器该属性应该是可见的。为此,打开位于Roslyn.Extensions文件夹中的Roslyn.Extensions.props文件,并在Project标签内添加一个看起来如下所示的ItemGroup:
<ItemGroup>
<CompilerVisibleProperty Include="GDPRReport"/>
</ItemGroup>
然后,在Chapter16文件夹中的Chapter16.csproj文件中,您需要添加对props文件的引用。在Project标签内文件的顶部添加以下内容:
<Import Project="$(MSBuildThisFileDirectory)../
Roslyn.Extensions/Roslyn.Extensions.props"/>
然后,在PropertyGroup内添加以下内容:
<GDPRReport>$(MSBuildThisFileDirectory)GDPRReport.txt</GDPR
Report>
这将GDPRReport变量配置为指向Chapter16.csproj文件的文件夹,然后向路径中添加GDPRReport.txt。
正如您在度量源生成器中所做的那样,您需要一个语法接收器来过滤候选者。
在Roslyn.Extensions项目中创建一个名为GDPR的文件夹,并添加一个名为GDPRSyntaxReceiver.cs的文件。使文件看起来如下所示:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Roslyn.Extensions.GDPR;
public class GDPRSyntaxReceiver : ISyntaxReceiver
{
readonly List<TypeDeclarationSyntax> _candidates =
new();
internal IEnumerable<TypeDeclarationSyntax> Candidates
=> _candidates;
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
if (syntaxNode is not TypeDeclarationSyntax
typeSyntax) return;
_candidates.Add(typeSyntax);
}
}
这个语法接收器的过滤器很简单。它对类型语法节点感兴趣。这包括类和记录。
现在您需要源生成器。在GDPRSyntaxReceiver.cs文件旁边添加一个名为GDPRSourceGenerator.cs的文件,并将其中的以下内容添加到其中:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Roslyn.Extensions.GDPR;
[Generator]
public class GDPRSourceGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext context)
{
if (context.SyntaxReceiver is not
GDPRSyntaxReceiver receiver) return;
context.AnalyzerConfigOptions.GlobalOptions
.TryGetValue("build_property.GDPRReport", out var
filename);
var writer = File.CreateText(filename);
writer.AutoFlush = true;
var piiAttribute = context.Compilation
.GetTypeByMetadataName("Fundamentals.Compliance
.GDPR.PersonalIdentifiableInformationAttribute");
}
public void Initialize(GeneratorInitializationContext
context)
{
context.RegisterForSyntaxNotifications(() => new
GDPRSyntaxReceiver());
}
}
代码为源生成器设置了基本设置,并为Execute()方法设置了初始值。它只有在SyntaxReceiver是GDPRSyntaxReceiver时才会执行任务。接下来,它从配置中获取GDPRReport变量。所有值都以build_property为前缀,对于来自构建的值。然后它继续创建报告文件,在获取用于后续过滤的PersonalIdentifiableInformationAttribute类型之前。
继续使用以下代码Execute()方法:
foreach (var candidate in receiver.Candidates)
{
var semanticModel = context.Compilation
.GetSemanticModel(candidate.SyntaxTree);
var symbols = new List<ISymbol>();
if (candidate is RecordDeclarationSyntax record)
{
foreach (var parameter in record.ParameterList!
.Parameters)
{
var parameterSymbol = semanticModel
.GetDeclaredSymbol(parameter);
if (parameterSymbol is not null)
{
symbols.Add(parameterSymbol);
}
}
}
foreach (var member in candidate.Members)
{
if (member is not PropertyDeclarationSyntax
property) continue;
var propertySymbol = semanticModel
.GetDeclaredSymbol(property);
if (propertySymbol is not null)
{
symbols.Add(propertySymbol);
}
}
}
代码从语法接收者查看候选者。如果候选者是一个记录,它将枚举其参数并将它们作为感兴趣的符号添加。然后它继续遍历候选者的成员,并将它们作为感兴趣的符号添加。
现在您已经收集了所有感兴趣的符号,是时候过滤出仅带有PersonalIdentifiableInformation属性的符号了。
在候选者的foreach循环中,在末尾添加以下内容:
var memberNamesAndReasons = new List<(string MemberName, string Reason)>();
foreach (var symbol in symbols)
{
var attributes = symbol.GetAttributes();
var attribute = attributes.FirstOrDefault(_ =>
SymbolEqualityComparer.Default.Equals(
_.AttributeClass?.OriginalDefinition,
piiAttribute));
if (attribute is not null)
{
memberNamesAndReasons.Add((symbol.Name,
attribute.ConstructorArguments[0].Value!
.ToString()));
}
}
if (memberNamesAndReasons.Count > 0)
{
var @namespace = (candidate.Parent as
BaseNamespaceDeclarationSyntax)!.Name.ToString();
writer.WriteLine($"Type: {@namespace}
.{candidate.Identifier.ValueText}");
writer.WriteLine("Members:");
foreach (var (memberName, reason) in
memberNamesAndReasons)
{
var reasonText = string.IsNullOrEmpty(reason) ? "No
reason provided" : reason;
writer.WriteLine($" {memberName}: {reasonText}");
}
writer.WriteLine(string.Empty);
}
代码通过查看任何属性来迭代符号。如果符号具有[个人可识别信息]属性,它将被添加到memberNamesAndReason列表中。
如果memberNamesAndReason列表中有成员,它将输出类型和成员及其原因。
现在,您可以在第十六章文件夹中构建您的应用程序:
dotnet build
您现在应该在项目文件夹中看到一个名为GDPRReport.txt的文件。打开它,确认您看到以下内容:
Type: Chapter16.Employee
Members:
FirstName: Needed for registration
LastName: Needed for registration
SocialSecurityNumber: Needed for uniquely identifying an
employee
在您的代码中拥有这种透明度,并能够将其展示给官方审计员是很好的。这表明您对合规性有控制权,这最终将有助于您长期发展。您还可以通过将其添加到源代码存储库来对文件进行版本控制,然后在发布构建期间,您可以提交对它的任何更改。
与扩展编译器本身相比,这是与您在正常应用程序开发中预期的运行时环境略有不同。您可能已经开始了如何调试的询问,并且可能已经经历过代码即使应该工作却不起作用的情况。
提高开发者体验
与 Roslyn 编译器一起工作可能很困难。毕竟,它是在编译器上下文中运行的。减轻痛苦的一种方法是通过单元测试,并实际上从测试中测试所有代码,我们将在第十七章中更详细地探讨,静态代码分析。
调试
然而,有时您只需要通过调试器用肉眼查看事物。我用于此的技术是将以下代码添加到我的 Roslyn 扩展代码中:
while (!System.Diagnostics.Debugger.IsAttached)
Thread.Sleep(10);
然后,我可以在想要中断的地方设置断点,然后附加调试器。您希望将其附加到编译器上,它通常显示如下:

图 16.2 – 附加到缓存的编译器过程
另一件可能令人痛苦的事情是,如果您在扩展中做更改,这些更改没有反映出来。这可能有几个原因。一个是它没有在项目中使用扩展的增量构建期间看到任何更改。您可以通过以下方式清理构建输出:
dotnet clean
否则,您可以运行构建,告诉它不要执行增量构建,如下所示:
dotnet build --no-incremental
另一种可能发生的情况是编译器在内存中保留了一个构建服务器,它缓存了一些内容并优化了开发者的体验。有时,你需要关闭它。你可以通过以下方式做到这一点:
dotnet build-server shutdown
优化
可以应用于源生成器的一种优化形式是使用增量源生成器方法。它结合了语法接收器和生成器,在构建服务器运行时持续运行,在编辑器中输入时提供代码生成(对于支持它的编辑器)。
与编译器一起工作可能会有些繁琐,但当你让一切正常运行时,这绝对是值得的。
摘要
在本章中,我们探讨了如何利用 Roslyn 编译器扩展在编译时生成代码。我们研究了为编译器生成额外代码的基础。我们还探讨了如何利用 Roslyn 源生成器生成除 C# 之外的文件,这是一种强大的技术,可以提高生产力,同时也提供真正的商业价值。
现在,你应该已经了解了 C# 代码生成器是什么以及如何实现一个。而且,希望你也已经有了一些关于如何使用它的想法。
正如我在本章中提到的,我们利用编译器的可扩展性和生成代码的能力来实际生成 TypeScript 代码。这已经证明对我们的开发者来说是一个巨大的生产力提升。已经有一些代码生成器可以将 OpenAPI 定义转换为 JavaScript 或 TypeScript,但它们随后就局限于这个标准所支持的功能。如果你希望它以某种形状存在,或者支持特定的前端框架,那么这可能就不够了。我们有了这些需求,然后决定构建一个支持我们需求的扩展。
在下一章中,我们将更进一步,探讨如何使用 Roslyn 编译器扩展进行静态代码分析。正如你可能已经注意到的,我倾向于关注代码质量。接下来,我们将探讨如何构建自定义代码分析器和代码修复,我们将看到这些工具如何被用来自动检测和纠正编码问题。
第十七章:静态代码分析
在当今的软件开发世界中,我认为编写干净且易于维护的代码比以往任何时候都更重要。软件的日益复杂化、劳动力留存率的降低以及竞争的加剧应促使我们标准化编写软件的方式,让下一个开发者进入代码库时能够站在成功的起点。当开发者心中有所意识时,尽早捕捉错误非常重要。实现这一目标的一种方式是通过使用静态代码分析,它允许开发者在代码执行之前就识别出潜在的问题和错误。在 C#中,通过 Roslyn 编译器扩展,开发者能够创建自定义分析器和代码修复,这有助于自动化这一过程。
在本章中,我们将探讨静态代码分析的基础以及如何使用 Roslyn 编译器扩展编写自己的分析器和代码修复。我们将从编写分析器的基础到创建自动化测试,确保代码按预期工作的一切内容。
不论你是寻求提高代码质量的资深开发者,还是静态代码分析领域的初学者,本章都将为你提供开始使用 Roslyn 编译器扩展并提升代码分析水平的工具和知识。
-
什么是静态代码分析?
-
如何编写分析器
-
如何为分析器编写代码修复
-
如何编写自动化测试
在本章结束时,你应该对如何使用 Roslyn 编译器扩展在 C#中实现静态代码分析有一个扎实的理解。你应该能够编写自己的自定义分析器和代码修复,并了解如何创建自动化测试以确保其实现的正确性。此外,你应该对静态代码分析的好处以及它如何提高代码的整体质量和可维护性有一个良好的理解。有了这些知识,你将能够将静态代码分析技术应用到自己的开发项目中,从而实现更高效和有效的软件开发。
技术要求
本章的特定源代码可以在 GitHub 上找到(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Chapter17),并且它建立在 GitHub 上找到的基础代码之上(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Fundamentals)。它还利用了 GitHub 仓库中找到的Roslyn.Extensions代码(github.com/PacktPublishing/Metaprogramming-in-C-Sharp/tree/main/Roslyn.Extensions)。
什么是静态代码分析?
静态代码分析是一种在代码执行之前检测问题的强大技术。虽然它可能看起来是一种相对较新的发展,但事实是静态代码分析已经存在了几十年,并且已经在 C/C++等语言中通过 linters 等工具得到应用。
Linters 本质上是一种静态代码分析工具,它分析源代码以标记可疑的结构或样式不一致。它们已经存在了几十年,并且在 C/C++等语言中得到了广泛的应用,以提高代码质量和可维护性。
近年来,随着 JavaScript 和 TypeScript 等语言的出现,静态代码分析变得更加流行。例如,ESLint 这样的工具已经开发出来,为 JavaScript 和 TypeScript 开发者提供类似的好处,通过分析代码以查找潜在问题并提供最佳实践的反馈。
微软的.NET 编译器软件开发工具包(SDK)采取了一种全面的方法来扩展编译器。它不仅关注扩展其功能,而且 SDK 还使代码能够识别代码编辑器或集成开发环境(IDE)中的潜在问题。此功能由流行的编辑器支持,包括Visual Studio Code(VSCode)、Rider 和 Visual Studio。当你处理文件时,编辑器会在后台运行分析器,用波浪线突出显示潜在问题。这些分析器可以标记不正确或可以改进的代码,提供实时反馈,帮助你更有效地捕捉和纠正错误。
除了分析器之外,微软还引入了代码修复功能,这是一个允许分析器供应商提供可以自动修复标记的代码问题的功能。这些代码修复在编辑器中以灯泡的形式表示,你可以点击它们来执行代码修复。有了这个功能,你可以快速轻松地应用修复并提高代码的整体质量。
在接下来的章节中,我们将概述在 C#编译器上下文中静态代码分析和代码修复,以及它们是如何工作的。虽然我们不会深入探讨这个主题,但我们会提供你开始所需的信息。重要的是要注意,C#编译器 API 非常广泛,提供了巨大的可能性,这个介绍将作为探索这些功能的起点。
让我们深入探讨如何编写分析器。
如何编写分析器
微软已经使编写在编译过程中自动运行的作为一部分的分析器变得非常简单。它遵循与我们在第十六章,“生成代码”中看到的源生成器相同的原理。一旦你设置了项目,就像我们在第十五章,“Roslyn 编译器扩展”中所做的那样,那就只是插入一个代表分析器的类。
在本章中,所有代码都假设你已经有了我们在第十五章中建立的Roslyn.Extensions项目,Roslyn 编译器扩展。
我们将要制作的分析器是一个高度有争议的,它会影响异常类型的命名。我们倾向于做的一件事是在我们的类型后缀上加上它们在技术上代表的内容;例如,异常通常后缀为Exception。查看.NET 基类库中找到的异常,你会看到像NotImplementedException、ArgumentException或ArgumentNullException这样的东西。这是我个人不喜欢的东西,我认为这不是需要传达的重要信息,我们应该将精力投入到为它们所做的事情正确命名类型。
以ArgumentException为例。其名称并不能传达其用途。只需将其更名为InvalidArgument,就能传达出存在违规——参数 无效。
你可能不会同意你代码库中这种类型的规则。但让我们先暂时放下这一点,只把它作为一个例子。
完善分析器
让我们从为分析器创建一个家开始。在Roslyn.Extensions项目中,添加一个名为CodeAnalysis的文件夹。我喜欢为每种分析器类型创建文件夹,因为我们可能需要为分析器提供代码修复,并且我们可能希望创建的不仅仅是分析器类。按照这个原则,在CodeAnalysis文件夹内添加一个名为ExceptionShouldNotBeSuffixed的文件夹;这将作为分析器的名称。
在ExceptionShouldNotBeSuffixed中,你现在可以添加一个名为Analyzer.cs的文件。将以下内容放入该文件中:
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace Roslyn.Extensions.CodeAnalysis
.ExceptionShouldNotBeSuffixed;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class Analyzer : DiagnosticAnalyzer
{
}
代码通过从.NET Compiler SDK中的DiagnosticAnalyzer类型继承来设置分析器的基础。除此之外,它还通过[DiagnosticAnalyzer]属性装饰了类,指示支持的语言是 C#。所有的using语句都是为了后续代码。
重要提示
分析器可以通过指定它支持的其他语言来支持多种语言。然而,这可能会影响分析器的复杂性,因为抽象语法树(AST)的表示方式存在差异。
为了使分析器正常工作并连接起来,它需要正确配置并注册任何应该被调用的操作。
将以下方法添加到Analyzer类中:
public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis
(GeneratedCodeAnalysisFlags.None);
context.RegisterSyntaxNodeAction(
HandleClassDeclaration,
ImmutableArray.Create(
SyntaxKind.ClassDeclaration));
}
代码在传递的 AnalysisContext 上调用 EnableConcurrentExecution(),这通知编译器您的分析器可以以异步方式与其他分析器并发执行。如果您的分析器不支持并发执行,您可以简单地省略此调用。如果您的分析器支持并发执行,它可以帮助加快您的构建速度,使您的开发过程更加高效。接下来,它配置是否应在生成的代码上运行您的分析器。您现在构建的分析器不应关心生成的代码;因此,它被配置为忽略它。
最后,代码注册了一个要在语法节点上运行的操作。语法节点 是 AST 的基本单元,正如我们在 第十六章,生成代码 中所看到的,对应于特定的语法结构,例如方法调用、循环语句或类声明。语法节点作为内存中的对象表示,并以树状结构链接在一起,反映了源代码的结构。
在我们的案例中,我们只对类声明感兴趣;因此,它使用 SyntaxKind.ClassDeclaration 注册了一个 HandleClassDeclaration 回调。
当分析器遇到问题时,它需要向编译器产生一个响应,告诉存在一个问题,并且应该报告给开发者。问题表示被形式化为称为 DiagnosticDescriptor 的东西。您需要为每个特定的破坏性规则创建特定的这些。
将以下内容添加到 Analyzer 类的顶部:
public const string DiagnosticId = "PP0001";
public static readonly DiagnosticDescriptor BrokenRule =
new(
id: DiagnosticId,
title: "ExceptionShouldNotBeSuffixed",
messageFormat: "The use of the word 'Exception'
should not be added as a suffix - create a well
understood and self explanatory name for the
exception",
category: "Naming",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: null,
helpLinkUri: string.Empty,
customTags: Array.Empty<string>());
public override ImmutableArray<DiagnosticDescriptor>
SupportedDiagnostics => ImmutableArray.Create
(BrokenRule);
代码建立了一个自定义的 DiagnosticDescriptor,它包含对唯一诊断标识符(PP0001)、标题和显示给开发者的消息的引用。它还将破坏性规则放入一个类别(命名)。由于类别是一个字符串,这可以是任何东西,但有一些其他分析器使用的知名类别,例如 命名、设计、正确性、性能和文档。这些类别被工具用于让开发者将代码库中的警告或错误分组。在描述符中,您还放入破坏性规则的严重程度级别。级别如下表所示:
| Level | Description |
|---|---|
| Hidden | 不会通过常规方式呈现 |
| Info | 不表示问题的信息 |
| Warning | 可疑但允许;开发者只需知道即可 |
| Error | 不允许;构建将被破坏 |
重要提示
如果使用您分析器的开发者决定在 .csproj 文件中使用 TreatWarningsAsErrors 选项,警告将被视为错误并中断构建。包含在此扩展项目中的 Roslyn.Extension.props 文件已将该选项启用。
DiagnosticDescriptor的最后几个属性是进一步细节,以帮助开发者理解你想要传达的编译器错误或警告。例如,你可以包含一个链接到网页,详细描述分析器或你已实现的特定规则。
处理语法节点
为了使分析器工作,你需要实现Initialize方法期间提供的HandleClassDeclaration回调。将以下私有方法添加到Analyzer类中:
void HandleClassDeclaration(SyntaxNodeAnalysisContext
context)
{
var classDeclaration = context.Node as
ClassDeclarationSyntax;
if (classDeclaration?.BaseList == null ||
classDeclaration?.BaseList?.Types == null)
return;
var classSymbol = context.SemanticModel
.GetDeclaredSymbol(classDeclaration);
if (classSymbol?.BaseType is null) return;
var exceptionType = context.Compilation
.GetTypeByMetadataName("System.Exception");
if (SymbolEqualityComparer.Default.Equals
(classSymbol?.BaseType, exceptionType) &&
classDeclaration.Identifier.Text
.EndsWith("Exception", StringComparison
.InvariantCulture))
{
var diagnostic = Diagnostic.Create(BrokenRule,
classDeclaration.Identifier.GetLocation());
context.ReportDiagnostic(diagnostic);
}
}
代码首先查看正在分析的语法节点。语法节点通常是方法声明、类定义或变量声明等。语法节点用于表示代码的结构,但不传达关于代码意义或语义的信息。
代码假设语法节点是ClassDeclarationSyntax节点,因为这是在Initialize()方法中配置为过滤器的。然后它查看BaseList属性,看类声明是否继承自其他类型。如果没有,分析器对节点不感兴趣,因为它只想分析继承自Exception的类型。
为了让分析器理解传递的节点语义意义,它必须使用SemanticModel来实现。从声明的符号,它确保有一个基类型;如果没有,它就返回。
接下来,代码请求System.Exception类型的表示,然后用于检查BaseType是否实际上是一个异常。如果是异常,它将检查类标识符,看它是否以Exception文本结尾。如果是,它将报告一个包含错误位置类声明标识符的断言规则的实例。
这基本上就是创建一个简单分析器的全部内容。
发布跟踪
假设你构建了Roslyn.Extensions项目,你会得到一个类似于以下警告:
MSBuild version 17.5.1+f6fdcf537 for .NET
Determining projects to restore...
All projects are up-to-date for restore.
/Users/einari/Projects/Metaprogramming-in-C/
Roslyn.Extensions/CodeAnalysis/ExceptionShouldNotBeSuffixed
/Analyzer.cs(15,10): warning RS2008: Enable analyzer
release tracking for the analyzer project containing rule
'PP0001' [/Users/einari/Projects/Metaprogramming-in-C/
Roslyn.Extensions/Roslyn.Extensions.csproj]
Roslyn.Extensions -> /Users/einari/Projects/
Metaprogramming-in-C/Roslyn.Extensions/bin/
Debug/netstandard2.0/Roslyn.Extensions.dll
Build succeeded.
/Users/einari/Projects/Metaprogramming-in-C/
Roslyn.Extensions/CodeAnalysis/ExceptionShouldNotBeSuffixed
/Analyzer.cs(15,10): warning RS2008: Enable analyzer
release tracking for the analyzer project containing rule
'PP0001' [/Users/einari/Projects/Metaprogramming-in-
C/Roslyn.Extensions/Roslyn.Extensions.csproj]
1 Warning(s)
0 Error(s)
Time Elapsed 00:00:00.52
RS2008警告告诉你,我们可以向项目中添加信息,使跟踪打包分析器和它提供的规则更容易。如果你认为这不重要,你可以忽略这个警告并继续。
为了满足警告,我们需要提供两个文件。一个是包含已发货规则的文件,另一个是包含未发货规则的文件。对于初始发布,未发货规则文件通常为空,而你可能会在即将发布的规则的发布中添加未发货规则。当规则发货时,你通常将这些规则从未发货移动到已发货。
你可以在 GitHub 上阅读更多关于这些文件目的的详细信息(github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)。
让我们在 Roslyn.Extensions 项目的根目录下添加一个名为 AnalyzerReleases.Shipped.md 的文件,并将其以下内容添加到其中:
## Release 1.0
### New Rules
Rule ID | Category | Severity | Notes
--------|----------|----------|--------------------
PP0001 | Naming | Error |
内容表明这是一个 1.0 版本的发布版本,并提供了一个规则表。在你的情况下,你只有一个规则。注释列可以包含分析器的名称,如果你愿意,还可以包含一个指向违反规则描述的链接。
在放置了发布文件后,你需要一个未发布的文件。添加一个名为 AnalyzerReleases.Unshipped.md 的文件。在此阶段,此文件可以是空的,所以只需保持原样。
当将你的 Roslyn 扩展打包成 NuGet 包时,你希望包含这些文件。打开 Roslyn.Extensions.csproj 文件,并在文件的底部,在 Project 标签内添加以下内容:
<ItemGroup>
<AdditionalFiles Include="AnalyzerReleases.Shipped.md" />
<AdditionalFiles Include="
AnalyzerReleases.Unshipped.md" />
</ItemGroup>
就这样!你的分析器现在已经准备好投入实际应用并使用了。
尝试使用分析器
为了测试分析器并确认其按预期工作,你需要一个包含违反规则代码的项目放入分析器中:
-
创建一个新文件夹,位于 Roslyn.Extensions 文件夹旁边,名为 Chapter17。在终端中,在 Chapter17 文件夹内创建一个新的项目:
dotnet new console -
然后你需要一个对 Roslyn.Extensions 项目的引用。项目引用不能是标准的项目引用;它需要稍微有所不同。在 Chapter17.csproj 文件的 Project 标签内添加以下内容:
<ItemGroup> <ProjectReference Include="..\Roslyn.Extensions\ Roslyn.Extensions.csproj" OutputItemType= "Analyzer" ReferenceOutputAssembly="false" /> </ItemGroup>
通过告诉它 OutputItemType 是 Analyzer,它将自动将 Roslyn.Extensions 项目的程序集输出连接到编译器。将 ReferenceOutputAssembly 设置为 false 告诉它,项目的编译输出将不会引用 Roslyn.Extensions 项目的输出程序集。
-
由于你的 Roslyn.Extensions 项目在此阶段应该包含在 第十六章 中构建的 通用数据保护条例 (GDPR)解决方案,并且它需要一个配置属性存在,你需要在 Chapter17.csproj 文件的 PropertyGroup 标签内添加以下内容:
<GDPRReport>$(MSBuildThisFileDirectory)GDPRReport.txt </GDPRReport> -
然后,在 Chapter17 文件夹中的 Chapter17.csproj 文件中,你需要添加对 Roslyn.Extensions 项目中 props 文件的引用。在文件的顶部,在 Project 标签内添加以下内容:
<Import Project="$(MSBuildThisFileDirectory) ../Roslyn.Extensions/Roslyn.Extensions.props"/> -
在 Chapter17 文件夹中,添加一个名为 MyException.cs 的文件,并将其内容添加到其中:
namespace Chapter17; public class MyException : Exception { } -
打开你的终端并执行构建,你应该看到如下所示的内容:
MSBuild version 17.5.1+f6fdcf537 for .NET Determining projects to restore... All projects are up-to-date for restore. Roslyn.Extensions -> /Users/einari/Projects/ Metaprogramming-in-C/Roslyn.Extensions/bin/Debug/ netstandard2.0/Roslyn.Extensions.dll /Users/einari/Projects/Metaprogramming-in-C/ Chapter17/MyException.cs(3,14): error PP0001: The use of the word 'Exception' should not be added as a suffix - create a well understood and self explanatory name for the exception [/Users/einari/Projects/ Metaprogramming-in-C/Chapter17/Chapter17.csproj] Build FAILED. /Users/einari/Projects/Metaprogramming-in-C/ Chapter17/MyException.cs(3,14): error PP0001: The use of the word 'Exception' should not be added as a suffix - create a well understood and self explanatory name for the exception [/Users/einari/Projects/ Metaprogramming-in-C/Chapter17/Chapter17.csproj] 0 Warning(s) 1 Error(s) Time Elapsed 00:00:01.81
输出清楚地表明你有一个 PP0001 错误,文本描述了实际的问题。
这很酷,但更酷的是,在你的编辑器中,你应该通过在 MyException 类名下得到一个波浪线来清楚地指示你有一个错误。在 Visual Studio Code (VS Code)中,这看起来如下所示:

图 17.1 – VS Code 分析错误
重要提示
如果你发现你的编辑器没有显示分析器错误,你可能需要重新启动它,或者如果它有一个语言服务器,只需重新启动那个。对于 VS Code,你可以简单地打开命令面板(F1)并输入OmniSharp,然后选择OmniSharp: 重启 OmniSharp。
在设置好分析器之后,现在是时候考虑如何让开发者更高效,以便他们可以轻松修复错误了。
如何为分析器编写代码修复
如前所述,.NET 编译器 SDK 不仅支持编写分析器来分析你的代码,你还可以提供快速修复任何发生的错误的代码。这些通常被大多数编辑器和 IDE 所理解,并且当适用时将自动加载并显示。
你将重用Roslyn.Extensions项目来修复代码。代码修复需要调用特定的 API,并且需要另一个包引用。在Roslyn.Extensions文件夹内运行以下命令来添加对Microsoft.CodeAnalysis.CSharp.Workspaces的引用:
dotnet add package Microsoft.CodeAnalysis.CSharp.Workspaces
在设置好包引用之后,现在是时候实现代码修复了:
-
首先,在Roslyn.Extensions项目文件夹中的CodeAnalysis/ExceptionShouldNotBeSuffixed文件夹内添加一个名为CodeFix.cs的文件。将其内容添加如下:
using System.Collections.Immutable; using System.Composition; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace Roslyn.Extensions.CodeAnalysis .ExceptionShouldNotBeSuffixed; [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(CodeFix))] [Shared] public class CodeFix : CodeFixProvider { }
代码通过从CodeFixProvider继承并添加[ExportCodeFixProvider]属性来设置代码修复。它指定了它支持的语言和代码修复的名称。与分析器一样,你可以通过在属性中指定来支持多种语言。如果你想,你可以通过设置ExportCodeFixProvider的DocumentKinds或DocumentExtensions属性来缩小你想要支持的文档类型和文件扩展名。我们将其保留为默认值,因为我们相信编辑器会正确地调用我们。
-
为了调用代码修复,它需要指定它可以修复的损坏规则。这是通过提供一个规则诊断标识符数组来实现的。在CodeFix类中添加以下内容:
public override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create (Analyzer.DiagnosticId); -
在设置好损坏规则关联之后,接下来你需要的是一个注册代码修复以及当代码修复被调用时将被调用的方法的函数。将以下方法添加到CodeFix类中:
public override Task RegisterCodeFixesAsync (CodeFixContext context) { var diagnostic = context.Diagnostics[0]; context.RegisterCodeFix( CodeAction.Create( title: "Remove Exception suffix", createChangedDocument: c => RemoveSuffix(context.Document, diagnostic, c)), diagnostic); return Task.CompletedTask; }
代码假设只有一个可能出错的Diagnostic。这是因为分析器中只有一个。如果你有多个,你需要找到正确的诊断并匹配适当的代码修复。然而,为了可维护性,我建议每个代码修复对应一个文件,链接到一个损坏的规则。
- 接下来,代码为诊断注册了一个代码修复;它是通过创建一个包含显示代码修复标题的CodeAction以及当开发者调用代码修复时将被调用的回调函数来实现的。
所有代码修复提供者都可以修复单个问题,但它们也可以通过提供FixAllProvider来修复多个问题。如果你需要特殊处理,可以选择自己实现它,或者使用默认的BatchFixer。添加以下方法以提供FixAllProvider。这是完全可选的;默认情况下,它不提供任何:
public override FixAllProvider?
GetFixAllProvider() => WellKnownFixAllProviders
.BatchFixer;
-
你最后需要的是执行代码修复的代码。你真正能做的唯一修复是提供移除后缀的代码。将以下代码添加到CodeFix类中:
async Task<Document> RemoveSuffix(Document document, Diagnostic diagnostic, CancellationToken c) { var root = await document.GetSyntaxRootAsync(c); if (!(root!.FindNode(diagnostic.Location .SourceSpan) is ClassDeclarationSyntax node)) return document; var newName = node.Identifier.Text.Replace ("Exception", string.Empty); var newRoot = root.ReplaceNode(node, node.WithIdentifier(SyntaxFactory.Identifier (newName))); return document.WithSyntaxRoot(newRoot); }
代码遍历文档并找到ClassDeclarationSyntax节点。如果找不到它,代码修复不会做任何事情。然后它将节点中的Exception文本替换为string.Empty,然后替换节点。然后它返回一个包含修改后节点的修改后的文档版本。
重要提示
由于这个代码修复非常简单,它并没有充分利用代码修复可用的 API。代码修复的一个重要方面是要注意格式,并确保结果格式正确。这是通过在修改节点时添加.WithAdditionalAnnotations(Formatter.Annotation)来完成的。
这就是实现简单代码修复所需的所有内容。你现在需要做的就是编译它,并打开一个违反规则的文件。在你的情况下,就是Chapter17文件夹中的MyException.cs文件。
编辑器在这方面略有不同,但在 VS Code 中,代码修复功能会显示为一个灯泡图标,点击灯泡图标会显示移除异常后缀代码修复:

图 17.2 – VS Code 代码修复
Important note
如果你发现你的编辑器无法显示代码修复,你可能需要重新启动它,或者如果它有一个语言服务器,只需重新启动那个。对于 VS Code,你可以简单地调出命令面板(F1)并输入OmniSharp,然后选择OmniSharp: 重启 OmniSharp。
使用编译器和编辑器测试你的分析器和代码修复并不能提供最佳的反馈循环,并且就像你写的任何代码一样,捕捉回归错误可能会很困难。
如何编写自动化测试
为你所有的代码编写自动化测试可以让你在更改代码时更有信心,并知道你是否破坏了任何东西。这适用于所有代码,包括分析器和代码修复。对于任何扩展编译器或为编辑器或 IDE 提供新功能的代码,测试其实现是否工作也更困难。有时候,让事情工作起来可能会很令人沮丧,并且通过构建这些可能会阻碍你的生产力。
幸运的是,Microsoft 提供了一种简单的方法来测试你的分析器和代码修复:
-
在Roslyn.Extensions文件夹旁边,创建一个名为Roslyn.Extensions.Tests的文件夹。在终端中,导航到Roslyn.Extensions.Tests文件夹,并运行以下命令:
dotnet new xunit
命令将设置一个使用 xUnit (xunit.net) 测试库的测试项目。
重要提示
你也可以使用其他测试框架,例如 MSTest 或 NUnit。
我们将不会涵盖单元测试或 xUnit 的特定内容。你可以在其网站上了解更多关于 xUnit 的信息。
-
下一步你需要的是对Roslyn.Extensions项目的项目引用。在Roslyn.Extensions.Tests文件夹内运行以下命令:
dotnet add reference ../Roslyn.Extensions
这将为Roslyn.Extensions项目添加一个项目引用,你可能注意到你这样做的方式与Chapter17项目不同。原因是,在测试的上下文中,你需要Roslyn.Extensions程序集被测试程序集引用,并且在测试的运行时存在。
分析器测试
在测试项目和扩展项目本身引用就绪后,我们可以开始填充我们想要的测试。你首先想要编写测试的是分析器。分析器测试的目的是验证当代码包含一个后缀的异常时,分析器会给出错误,而当异常类型没有后缀时不会给出错误。
在Roslyn.Extensions.Tests文件夹内添加一个名为ExceptionShouldNotBeSuffixed的文件夹。然后添加一个名为AnalyzerTests的文件。将以下内容放入其中:
namespace Roslyn.Extensions.CodeAnalysis
.ExceptionShouldNotBeSuffixed;
using Xunit;
using Verify = Microsoft.CodeAnalysis.CSharp.Testing
.XUnit.AnalyzerVerifier<Analyzer>;
public class AnalyzerTests
{
}
这设置了编写测试所需的基本内容。你可能想知道为什么命名空间声明在using语句之前。我们这样做是为了避免在Verify中使用别名时必须使用完全限定名。Verify别名创建了一个别名用于AnalyzerVerifier<>泛型类型,并将其分析器作为泛型参数。别名化只是为了方便,使你的测试更容易阅读和编写。
另外,还有一个稍微不同的事情是单词Tests,这是Roslyn.Extensions.Tests项目名称后缀,这通常是你在命名空间中也会反映出来的东西。我个人更喜欢不这样做,因为测试项目不是你部署的东西,如果你从命名空间中省略它,通常会使事情更简单。但这完全是个人的偏好。
你首先想要测试的是分析器是否正在分析正确的代码。将以下方法添加到AnalyzerTests类中:
[Fact]
public async Task WithoutSuffix()
{
const string content = @"
using System;
namespace MyNamespace;
public class SomethingWentWrong : Exception
{
}
";
await Verify.VerifyAnalyzerAsync(content);
}
测试设置了一个有效的 C#程序,并调用该程序的VerifyAnalyzerAsync()方法。
然后你需要一个测试来测试规则的违规情况。将以下方法添加到AnalyzerTests类中:
[Fact]
public async Task WithSuffix()
{
const string content = @"
using System;
namespace MyNamespace;
public class MyException : Exception
{
}
";
var expected = Verify.Diagnostic().WithLocation(5,
30).WithArguments("MyException");
await Verify.VerifyAnalyzerAsync(content, expected);
}
测试设置了一个无效的 C#程序,并设置了一个期望在第5行和第30列失败。由于你以这种方式输入文件内容,第一行将是空的,列数也是你在编辑器中看到的列数,应该是30,方法缩进后。你可以通过将文件作为嵌入资源嵌入来改进这一点,这样你就可以单独维护它们,并有一个更可预测的设置。然后,使用内容和期望调用VerifyAnalyzerAsync()方法。
到目前为止,这是我们想要对这个分析器执行的测试。但我们也有一个针对分析器的代码修复。
代码修复测试
与分析器类似,你可以为代码修复创建特定的测试。它使用与分析器不同的验证器:CodeFixVerifier。让我们开始吧:
-
在Roslyn.Extensions.Test项目文件夹中的ExceptionShouldNotBeSuffixed文件夹内添加一个名为CodeFixTests的文件。然后添加一个名为CodeFixTests.cs的文件,并将以下内容添加到其中:
namespace Roslyn.Extensions.CodeAnalysis .ExceptionShouldNotBeSuffixed; using Xunit; using Verify = Microsoft.CodeAnalysis.Csharp .Testing.XUnit.CodeFixVerifier<Analyzer, CodeFix>; public class CodeFixTests { }
正如你在分析器测试中所做的那样,你使用了验证器。对于代码修复,它是一种不同类型的验证器:CodeFixVerifier。
CodeFixVerifier验证器需要两个泛型参数,第一个代表分析器,第二个代表正在测试的代码修复。
-
将以下测试方法添加到CodeFixTests类中:
[Fact] public async Task WithoutSuffix() { const string content = @" using System; namespace MyNamespace; public class SomethingWentWrong : Exception { } "; await Verify.VerifyCodeFixAsync(content, content); }
代码验证了当 C#程序是一个有效的程序且不违反规则时,代码修复不会执行任何操作。
-
接下来,你需要一个测试来验证代码修复实际上执行了预期的操作。将以下方法添加到CodeFixTests类中:
[Fact] public async Task WithSuffix() { const string content = @" using System; namespace MyNamespace; public class MyException : Exception { } "; var expected = Verify.Diagnostic().WithLocation(5, 30).WithArguments("MyException"); await Verify.VerifyCodeFixAsync(content, expected, content.Replace("MyException", "My")); }
与违反规则场景的分析器测试类似,它设置了一个期望,即在特定位置应该有一个编译器错误。然后,它还验证代码修复实际上通过删除后缀来替换了MyException文本。
-
在Roslyn.Extensions.Tests文件夹中运行以下命令,即可运行针对分析器和代码修复的测试:
dotnet test
你应该会看到以下类似的内容:
Microsoft (R) Test Execution Command Line Tool Version
17.5.0 (arm64)
Copyright (c) Microsoft Corporation. All rights
reserved.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 4, Skipped:
0, Total: 4, Duration: 522 ms - Roslyn.Extensions
.Tests.dll (net7.0)
如果你使用的是支持 xUnit 测试的编辑器,那么它们可能已经在你的编辑器的测试资源管理器中可见,并且你可以从那里运行它们。
对于Microsoft.CodeAnalysis.Testing项目的全面概述,我建议您前往 GitHub(github.com/dotnet/roslyn-sdk/blob/main/src/Microsoft.CodeAnalysis.Testing/README.md)。
摘要
在本章中,我们介绍了在 C#中使用 Roslyn 编译器扩展进行静态代码分析的方法。我们首先解释了什么是静态代码分析,以及它与动态分析的区别,以及它的优点和局限性。然后,我们介绍了如何使用 Roslyn 编写自定义分析器,使用诊断来报告问题,并实现代码修复来自动纠正问题。
我们还讨论了测试和维护代码分析工具的最佳实践,以及如何构建针对您团队和领域的特定规则。快速捕捉错误对于最小化开发时间和成本至关重要,静态代码分析是实现这一目标的强大工具。通过在代码执行之前检测到问题,您可以避免代价高昂的错误,并提高整体代码质量和可维护性。
现在,您应该对如何使用 Roslyn 在 C#中进行静态代码分析有了牢固的理解,以及这种方法的好处和挑战。您还应该了解如何编写有效的分析器和代码修复,以及如何为特定团队和领域构建自定义规则。
静态代码分析是一种强大的技术,可以快速捕捉错误并提高整体代码质量和可维护性。通过构建针对您团队和领域的特定规则,您可以确保您的代码不仅无错误,而且符合您团队的标准和惯例。这可以节省在调试和测试上花费的时间和资源,让您能够更快地交付高质量的软件。
本章总结了本书的实践方面,下一章和最后一章将涵盖一些一般性的注意事项,以及对本书中讨论的所有内容的最后几句话。
第十八章:注意事项和结束语
恭喜你,你已经到达了这本书关于 C#元编程的最后一章!到现在为止,你应该已经对可用于 C#元编程的各种技术和工具有了坚实的理解。
在本章的最后,我们将退后一步,看看 C#中元编程的一些更宏观的影响。我们将探讨元编程的一些性能影响,并讨论一些处理这些强大技术可能带来的隐藏魔法的最佳实践。
最后,我们将通过总结到目前为止我们所涵盖的内容以及一些结束语来结束本书,希望这能激励你在继续作为 C#元编程者的旅程中。那么,让我们再次深入探索元编程的精彩世界!
性能影响
在 C#中处理元编程时,最重要的注意事项之一是其对性能的潜在影响。当涉及到 C#中的元编程时,有一些关键的性能影响需要记住:
-
额外的运行时开销:与传统的编程技术相比,元编程通常涉及额外的运行时开销。这是因为它通常涉及动态代码生成或操作,这可能需要额外的处理时间和内存使用。例如,如果你使用反射来动态调用方法或访问属性,这可能会比直接调用方法或属性慢。
-
增加内存使用:元编程也可能导致内存使用增加,尤其是如果你在动态生成或操作对象时。这可能导致更高的内存使用,如果不小心,甚至可能导致内存泄漏。例如,如果你使用反射来动态生成新类型或对象,这可能导致额外的内存使用,这些内存可能直到垃圾收集器运行才被释放。
-
可能导致代码次优:在某些情况下,元编程也可能导致代码次优,尤其是如果你不够小心的话。例如,如果你使用动态代码生成来即时生成代码,这可能会导致难以在编译时优化的次优代码。这可能导致执行时间变慢和运行时开销增加。
为了减轻这些性能影响,重要的是要记住一些最佳实践:
-
谨慎使用元编程:元编程只有在它提供比传统编程技术明显的好处时才应该使用。如果好处不明显或微不足道,可能最好完全避免使用元编程。
-
彻底测试你的元编程代码:与传统的代码相比,元编程代码可能更难测试,因此彻底测试你的元编程代码以确保其按预期工作非常重要。
-
根据需要优化你的元编程代码:如果你发现你的元编程代码影响了性能,可能需要根据需要对其进行优化。例如,你可能需要重构你的代码以使用更高效的算法或数据结构,或者你可能需要避免已知较慢的某些元编程技术。
-
考虑使用缓存:如果你的元编程代码频繁生成或操作对象,你可能需要考虑使用缓存来减少运行时开销和内存使用。这可能涉及缓存生成的类型或对象,或者使用表达式树等工具生成可缓存的编译代码以供重用。
通过牢记这些最佳实践,你可以最小化 C#中元编程的性能影响,并确保你的代码既灵活又高效。
隐藏的魔法——小心处理
C#中的元编程可以是一个强大的工具,但它也带来了一些隐藏的风险。元编程的最大危险之一是它可能会使代码难以理解和维护。因为元编程通常涉及动态代码生成或操作,所以它可能难以跟踪和调试,并且如果不小心使用,可能会导致意外的行为。此外,元编程代码可能难以阅读和理解,特别是如果它涉及复杂的反射或动态代码生成。为了避免这些问题,重要的是要谨慎使用元编程,并且仅在它提供明确的好处时使用,例如增加灵活性或减少样板代码。
当涉及到 C#中的元编程时,有一些技术可以被认为是隐藏的魔法。这些技术看起来可能很简单,但可能具有复杂和可能意外的行为。以下是一些元编程中的隐藏魔法示例:
-
反射:反射是一种强大的工具,允许你在运行时检查和操作对象。然而,如果不小心使用,它也可能很复杂且容易出错。例如,如果你使用反射访问私有字段或方法,这可能导致意外的行为,甚至如果底层实现发生变化,可能会破坏你的代码。
-
动态:C#中的
dynamic关键字允许你编写在运行时延迟绑定的代码,这在元编程场景中可能很有用。然而,如果不小心使用,它也可能难以理解,并可能导致微妙的错误。例如,如果你使用dynamic调用不存在的方法,这可能导致难以调试的运行时错误。 -
代码生成:代码生成是一种强大的技术,允许你在运行时或设计时动态生成 C#代码。然而,如果不小心使用,它也可能导致错误,并引发微妙的错误。例如,如果你生成了包含语法错误或无效的代码,这可能会导致难以诊断的编译错误。
要谨慎处理隐藏的魔法,重要的是要记住以下几点:
-
理解底层机制:在使用任何元编程技术之前,了解其底层机制和潜在陷阱非常重要。这可能包括阅读文档、研究示例代码或咨询该领域的专家。
-
彻底测试:元编程代码可能难以测试,但确保代码按预期工作非常重要。这可能涉及编写单元测试、集成测试或其他类型的测试,根据需要。
-
使用防御性编码技术:为了防止意外行为,使用防御性编码技术非常重要,例如输入验证、错误处理和防御性编程实践。
-
记录您的代码:最后,仔细记录您的元编程代码非常重要,以确保其他开发者能够理解其工作原理并有效地使用它。这可能包括编写清晰简洁的注释、提供示例和示例代码,以及随着代码的发展保持最新的文档。
通过牢记这些最佳实践,您可以谨慎处理隐藏的魔法,并确保您的元编程代码健壮且可维护。
何时使用什么
在 C#中知道何时使用哪种元编程技术对于编写有效且可维护的代码至关重要。元编程的一些常见用例包括以下内容:
-
减少样板代码:元编程可以通过在运行时动态生成代码来帮助减少您需要编写的重复性样板代码的数量。
-
启用动态行为:元编程可以使您的代码更加动态和灵活,允许您在运行时修改行为或即时添加新功能。
-
支持代码生成:元编程可以帮助您根据输入数据或其他因素动态生成代码,从而创建更复杂和定制的代码结构。
然而,也有一些情况下应避免使用元编程,例如以下情况:
-
当性能至关重要时:如前所述,元编程可能会引入运行时开销并影响性能。在性能至关重要的场合,可能最好完全避免使用元编程或谨慎使用。
-
当可读性很重要时:元编程可能会使代码难以阅读和理解,尤其是如果它涉及复杂的反射或动态代码生成。在可读性很重要的情况下,可能最好坚持使用更传统的代码结构。
-
当好处不明确时:如前所述,元编程仅在它提供比传统编程技术更明显的好处时才应使用。如果好处不明确或微不足道,可能最好完全避免使用元编程。
摘要
在这本书中,我们探索了 C#中元编程的精彩世界。我们看到了元编程技术如何帮助我们编写更灵活、更强大和更易于维护的代码,并了解了我们可用的各种工具和技术。
我们已经看到如何使用反射在运行时检查和操作对象,如何代码生成可以帮助我们动态生成代码,以及如何使用dynamic关键字编写在运行时延迟绑定的代码。我们还探讨了与元编程相关的一些陷阱和挑战,并学习了如何通过遵循最佳实践,如测试、防御性编码和文档化,来谨慎地处理隐藏的魔法。
当您完成这本书时,我希望您能被 C#中元编程的力量和潜力所启发。元编程可以成为熟练开发者手中的强大工具,让您编写出比您想象的更灵活、更易于维护和更强大的代码。
因此,勇敢地去探索元编程的精彩世界!尝试不同的技术,尝试新事物,并推动可能性的边界。记住,就像任何强大的工具一样,强大的力量伴随着巨大的责任。始终谨慎地使用元编程,遵循最佳实践,并花时间理解其底层机制。
感谢您阅读这本书。我希望它已经成为您在成为 C#元编程大师之旅中的宝贵资源,并帮助您记住让代码为您工作的原则。


浙公网安备 33010602011771号