C--专家编程指南-全-

C# 专家编程指南(全)

原文:Code Like a Pro in C#

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

我第一次接触 C#是在 2016 年加入富士胶片医疗系统公司时。我之前有 Java 和 Python 的经验,但当 C#出现时,我没有回头。我喜欢它的低门槛和(起初令人痛苦地令人愤怒的)对显式类型化的关注。在我公司的整个时间里,我都用关于 C#和如何最好地使用它的问题打扰了我的同事。入门很容易,但精通却是另一回事。无论背景如何,每个人在 10 分钟内都可以编写一个“Hello, World”应用程序,但使用一种语言发挥其最大优势,同时知道某些事情为什么被实现成这样,这需要时间。过了一段时间,我觉得我在 C#知识上已经达到了一个平台期,正在寻找能把我带到下一个层次的资源。很快,我就意识到有三类主要的关于.NET 和 C#的书籍:关于语言超越主题(如清洁代码、架构、基础设施等)的书籍,这些书籍恰好使用了 C#;关于如何使用 C#开始编程的书籍;以及那些非常高级的书籍,你可能在阅读后可能就有资格成为微软的 CTO。我希望有一本书能介于这三者之间:一本书处理清洁代码,并弥合初学者和高级主题之间的差距。那样一本书不存在,所以我写了它。这就是那本书。

如果你是一位有(最好是面向对象)编程语言经验的软件工程师(或开发者、或编码者,或无论你的头衔是什么),并且想要跳入 C#,这本书就是为你准备的。你不必学习如何编写一个if语句,我也不会向你解释什么是对象。你在这本书中会发现的是为深入语言和平台学习所做的准备。当然,我无法保证涵盖一个更难的资源假设你应知道的所有内容,但在本书有限的页数内,我确实尽力了。我非常希望你喜欢这本书,并且学到一些东西。如果不这样,嗯,重新回顾你所知道的事情从来都不是坏事。

致谢

当我最初开始与 Manning 讨论写这本书时,我对它将如何占据我大约一年的生活几乎没有概念。公平地说,我多次被告知作者往往会低估写一本书所需的时间。我固执己见,认为我会是个例外。我并不是。从 2019 年 12 月到 2021 年 3 月,我在这本书上投入了大量的时间。有好几次,我自己想,“这肯定就是终点了。”但每次(除了显然的那一次)都不是。幸运的是,我有一位非常耐心妻子,以及很多消磨时间的机会。

在这种想法下,我首先想感谢我的妻子,感谢她在这一段过山车般的旅程中一直支持我,并向她道歉,因为我消失在她的生活中整整一年。没有她的坚定不移的支持,我无法完成这本书。她是这本书的基石。我还要感谢我的家人,他们总是非常兴奋地听到关于新书的发展和更新。我大胆地将书中所遵循的商业案例中的公司 CEO 命名为我的外祖父(Aljen)的名字和我的祖母(van der Meulen)的姓氏。

我还要感谢 Manning 出版社的杰出团队。特别是,我想特别感谢 Marina Michaels。作为我的编辑,她将这本书塑造成不仅仅是杂乱无章的抱怨的集合。多亏了 Marina,我在写作中不敢轻易使用“will”这个词。我还拥有一个非常有价值的团队,包括 Jean-François Morin、Tanya Wilke、Eric Lippert、Rich Ward、Enrico Buonanno 和 Katie Tennant。这个跨越洲际的超级英雄/忍者/摇滚明星团队提供了惊人的反馈,并捕捉到了大量的(通常是非常尴尬的)技术错误。我还要感谢所有在出版前阅读手稿并给出精彩反馈的审稿人和 MEAP 读者,他们的反馈往往非常明确。我不敢声称这本书是一部杰作,但我确实希望你能从中获得一些有用的东西。

对于所有审稿人,我要说谢谢:Arnaud Bailly、Christian Thoudahl、Daniel Vásquez Estupiñan、Edin Kapic、Foster Haines、George Thomas、Goetz Heller、Gustavo Filipe Ramos Gomes、Hilde Van Gysel、Jared Duncan、Jason Hales、Jean-François Morin、Jeff Neumann、Karthikeyarajan Rajendran、Luis Moux、Marc Roulleau、Mario Solomou、Noah Betzen、Oliver Korten、Patrick Regan、Prabhuti Prakash、Raymond Cheung、Reza Zeinali、Richard B. Ward、Richard DeHoff、Sau Fai Fong、Slavomir Furman、Tanya Wilke、Thomas F. Gueth、Víctor M. Pérez 和 Viktor Bek。你们的建议帮助使这本书变得更好。

最后,我想感谢一些人在这本书的某个部分或我的整个职业生涯中给予我帮助的人。首先感谢 David Lavielle 和 Duncan Henderson:感谢你们给我一个机会,并给了我软件开发的第一份工作。Jerry Finegan:感谢你让我接触 C#并让我一个接一个地问一些愚蠢的问题。你的耐心和反馈非常受赞赏。Michael Breecher:你在这本书中关于一致性内容的一些部分起到了作用(由于我深夜发来一些关于符号的奇怪数学问题),这使得这本书更好。Szymon Zuberek:第二章的第一个草稿是在你的纽约公寓里写的。感谢我们任何时候想拜访时都让我们在你沙发上打地铺,还有,一如既往地,为我们提供谈话素材。我还感谢 Acronis 和 Workiva 的出色团队,他们不得不听我无休止地谈论“我正在写的这本书”。他们一直表现得很好(大多数情况下)。

关于这本书

这本书基于你现有的编程技能,帮助你无缝提升编码实践或从 Java 或其他面向对象语言过渡到 C#。你将学会编写对企业开发至关重要的符合语法的 C#代码。这本书讨论了必要的后端技能,并通过一个常见的职业挑战将其付诸实践:重构遗留代码库以使其安全、干净和可读。等你完成时,你将具备专业级别的 C#理解,并准备好开始使用高级资源进行专业化学习。

没有所谓的“Hello, World”或计算机科学 101 基础知识——你将通过重构一个过时的遗留代码库来学习,使用新技术、工具和最佳实践将其提升到现代 C#标准。在这本书中,我们从一个现有的代码库(使用.NET Framework 编写)开始重构,通过简化 API 将其迁移到.NET 5。

适合阅读这本书的人

如果你是一个精通面向对象编程语言的开发者,无论是 Java、Dart、C++还是其他什么,这本书可以帮助你快速掌握 C#和.NET,而无需从头开始。你很多知识可以迁移,所以为什么还要第 500 次学习如何编写if语句呢?

类似地,如果你精通像 Go、C、JavaScript、Python 或其他主流编程语言,在阅读这本书之后,你可以写出干净、符合语法的 C#代码。你可能需要了解一些面向对象设计原则,但这不应该成为入门的巨大障碍(如果你来自 Go,确保在我们使用接口时格外注意;它们的工作方式不同)。

最后,如果你是一个已经使用 C#一段时间并想知道如何“提升”你的知识水平的开发者:这本书是为你准备的。许多高级 C#资源都假设了入门或初学者资源中没有涵盖的知识。这本书旨在填补这一差距。

本书如何组织:路线图

与常规技术书籍相比,本书的结构在某种程度上有些不寻常。大多数技术书籍是参考书籍,可以按任何顺序阅读。本书不是参考书籍,要充分利用它,你需要按顺序阅读章节。本书的结构围绕以下六个部分展开,如图 1 所示:

  1. “使用 C#和.NET”——在第一章中,我们讨论了本书的内容、它教授的内容以及它不教授的内容。第二章是对 C#语言和.NET 生态系统的简要介绍,重点关注.NET 与其他平台的不同之处以及 C#编译的故事。

  2. “现有代码库”——在这一部分,我将引导你探索我们继承的代码库。这一部分是对现有代码库的详细讲解,讨论了潜在的改进和设计缺陷。

  3. “数据库访问层”——在第二部分之后,我们开始重写整个服务。在第三部分,我们专注于创建一个新的.NET Core 项目,并学习如何使用 Entity Framework Core 连接到云(或本地)数据库。其他讨论的主题包括存储/服务模式、虚拟方法和属性以及密封类。

  4. “存储层”——在第四部分,我们进入了存储/服务模式的领域,并实现了五个存储类。你还将了解依赖注入、多线程(包括锁定、互斥锁和信号量)、自定义相等比较、测试驱动开发、泛型、扩展方法和 LINQ。

  5. “服务层”——下一步是实现服务层类。在第五部分,我们从零开始编写了四个服务层,并讨论了反射、模拟、耦合、运行时断言和类型检查、错误处理、结构体和 yield return。

  6. “控制器层”——第六部分是我们对第二部分最初继承的服务进行重写的最后一步。在这一部分,我们将编写两个控制器类,并进行验收测试。除了这些主题外,我们还涉及 ASP.NET Core 中间件、HTTP 路由、自定义数据绑定、数据序列化和反序列化,以及在运行时生成 OpenAPI 规范。

本书许多章节(以及一些章节中的某些部分)都设计了练习,以测试你对材料的了解。你可以快速完成这些练习。我鼓励你在遇到它们时完成这些练习,并重新阅读你可能略过或理解有误的部分。

FM

图 0.1 建议的阅读本书的流程图。按照步骤进行,回答问题以获得理想的阅读体验。此流程图灵感来源于唐纳德·克努特(Donald Knuth)的《计算机程序设计艺术》系列书籍中的结构流程图。

关于代码

在撰写本书时,.NET 生态系统可以分为三个主要部分:.NET Framework 4.x、.NET Core 3.x 和 .NET 5。本书除第三章和第四章外(您在阅读这些章节后会明白原因)全部使用 .NET 5。

使用的 C# 语言版本是 C# 3 和 C# 9(本书大多数情况下不使用任何 C# 9 特定功能,因此安装 C# 8 也同样有效)。由于 C# 语言具有向后兼容性,您只需安装最新版本(撰写本书时为 C# 8 或 C# 9 预览版)。提供源代码的章节是 2、3 和 4(合并)、5、6、7、8、9、10、11、12、13 和 14。

要运行代码,您需要安装一个高于 3.5 版本的 .NET Framework(如果您想在第三章和第四章中运行代码)和 .NET 5。如果您想在本地运行本书中使用的数据库或遇到安装本书所需任何内容的困难,您可以在附录 C(“安装指南”)中找到安装说明。本书主要使用 Visual Studio 作为其 IDE,但您可以使用任何支持 C#(或根本不使用)的 IDE。Visual Studio 2019 有一个名为 Visual Studio 2019 Community 的免费版本。当我们遇到需要 Visual Studio 的内容时,本书会进行标注。代码和 .NET 5 应该可以在 Windows、macOS 和 Linux 上运行。本书尽可能使用命令行(或终端,针对 macOS 用户)来避免对任何特定 IDE 或操作系统的依赖。

本书包含许多示例源代码,既有编号列表中的,也有与普通文本内联的。在两种情况下,源代码都使用类似于这样的固定宽度字体格式化,以将其与普通文本区分开来。有时代码也会被**粗体**显示,以突出显示章节中从先前步骤更改的代码,例如当新功能添加到现有代码行时。

在许多情况下,原始源代码已被重新格式化;增加了换行符,并重新调整了缩进以适应书中的可用页面空间。在某些情况下,即使这样也不够,列表中还包括了行续接标记(➥)。代码注释伴随许多列表,突出显示重要概念。请注意,大括号通常被放置在新代码块的前一行。这不是合适的现实世界 C# 习惯,但这样做是为了节省空间。源代码本身不使用这种约定。

liveBook 讨论论坛

购买《C#编程之道》包括免费访问由曼宁出版社运行的私人网络论坛,您可以在论坛上对书籍发表评论、提出技术问题,并从作者和其他用户那里获得帮助。要访问论坛,请访问livebook.manning.com/book/code-like-a-pro-in-c-sharp/welcome/v-9/。您还可以在livebook.manning.com/#!/discussion了解更多关于曼宁论坛和行为准则的信息。

曼宁对读者的承诺是提供一个平台,让读者之间以及读者与作者之间可以进行有意义的对话。这并不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向他提出一些挑战性的问题,以免他的兴趣转移!只要这本书有售,论坛和先前讨论的存档将可通过出版社的网站访问。

关于作者

Jort Rodenburg是一位软件工程师、作家和公众演讲者。他专长于 C#,并在金融合规和报告、喷墨打印、医学成像、分布式系统和网络安全等众多领域的软件上工作过。Jort 曾指导精通不同编程语言的工程师,帮助他们掌握 C#和.NET。Jort 还在会议上和聚会中就 C#、.NET 和编程等所有相关话题发表演讲。

关于封面插图

《C#编程之道》的封面图上标注为“Homme Samojede”,或“萨莫耶德人”。这幅插图取自雅克·格拉塞·德·圣索沃尔(1757–1810)的作品集,名为《各国服饰》,于 1797 年在法国出版。每一幅插图都是手工精心绘制和着色的。格拉塞·德·圣索沃尔收藏中的丰富多样性生动地提醒我们,200 年前世界的城镇和地区在文化上有多么不同。他们彼此孤立,说着不同的方言和语言。在街道或乡村,仅凭他们的服饰就能轻易识别他们居住的地方以及他们的职业或社会地位。

从那时起,我们的着装方式发生了变化,当时地区间的多样性已经逐渐消失。现在很难区分不同大陆、城镇、地区或国家的人们。也许我们用文化多样性换取了更加丰富多彩的个人生活——当然,是为了更加多样化和快节奏的技术生活。

在难以区分一本计算机书籍与另一本的时候,曼宁通过书籍封面庆祝了计算机行业的创新精神和主动性,这些封面基于两百年前丰富多样的地区生活,并由 Grasset de Saint-Sauveur 的画作使之重现生机。

第一部分 使用 C#和.NET

在本书的第一部分,我们将简要地游览 C#语言,并讨论其一些特性。第一章介绍了 C#和.NET 是什么,以及为什么你(或不应该)在你的项目中使用它们。第二章深入探讨了.NET 的各种迭代版本,并跟踪一个 C#方法通过其编译过程,在每个主要步骤中停留。

尽管这部分确实是本书的引言,但它仍然为熟悉 C#的人提供了宝贵的资料。这些前两章中介绍的一些知识,是在深入更高级主题之前你需要了解的内容。

1 介绍 C#和.NET

本章涵盖

  • 理解 C#和.NET 是什么

  • 学习为什么要在你的项目中使用 C#(以及为什么不用)

  • 转向 C#以及如何入门

另一本关于 C#的书,你说?是的,又一本。关于 C#和.NET 的书已经有很多了,但本书有一个基本的不同点:我写这本书是为了帮助你开发在日常生活中的干净、惯用的 C#代码。本书不是一本参考书,而是一本实用指南。本书不涵盖如何编写if语句、方法签名是什么或对象是什么等内容。我们不关心语法,而是关注概念和想法。知道一种语言的语法和能够编写干净、惯用的代码之间存在差异。在阅读完这本书后,你将能够做到这一点。无论你的背景如何,无论你了解哪些编程语言,只要你能理解面向对象编程,这本书就能帮助你过渡到 C#和.NET 生态系统,如图 1.1 所示。

图片

图 1.1 每章引言都包含一个进度图,这让你可以快速了解你在书中的位置。

微软、谷歌和美国政府等组织有什么共同之处?它们都使用 C#——而且有充分的理由。但为什么呢?C#只是另一种编程语言。它与 Java 和 C++相似,允许进行面向对象和函数式编程,并且得到了一个大型开源社区的广泛支持。很好。那么,你为什么要关心这个呢?在本章中,我们将深入探讨这个问题,但让我先透露一些预告:C#擅长让你创建可扩展的软件。要开始编写 C#,你只需要选择你喜欢的.NET SDK(关于这一点,请参阅第二章)以及可能需要一个 IDE。语言和运行时都是开源的。

每次你在网上查找 C#时,很可能会遇到.NET 框架。你可以把.NET 框架想象成你的温暖毯子、温暖的火炉和冬天的热巧克力杯,为你提供你需要的一切:封装低级 Windows API 的库、公开常用数据结构并为复杂算法提供包装器。在 C#的日常开发中,几乎肯定会涉及到.NET 框架、.NET Core 或.NET 5,因此我们将根据需要探讨这些框架。

图 1.2 显示了本书的主题在一般.NET 网络架构中的位置。它还显示了我们将用于完全重写现有应用的架构,我们将在第五章开始介绍(绿色/虚线箭头表示此路径)。

图片

图 1.2 在 Microsoft 堆栈上典型网络服务架构的示例。本书遵循绿色/虚线箭头所示的方法。本书涵盖了表示层、业务逻辑层和数据访问层。

对于那些在 C#方面有先验经验的你们来说,这本书位于初学者和高级资源之间。通过这本书教授的技能,你可以填补知识差距,并为高级技能做好准备。前两章可能对你们来说有点基础,但我邀请你们不要匆匆略过。时常刷新你的知识总是好的。

1.1 为什么选择 C#?

如果你已经熟悉除了 C#以外的其他编程语言并且喜欢使用它,为什么你应该使用 C#呢?也许你是被一家只使用 C#的公司雇佣的。或者也许你只是想看看所有这些喧嚣究竟是怎么回事。

我保证不会反复告诉你 C#是一种“强类型面向对象编程语言,它使跨平台开发可扩展的企业软件成为可能。”你可能已经知道了这一点,而且这几乎不是最激动人心的句子来剖析。在本节中,我们一次性覆盖了那个定义中的术语,并且不再涉及。冒着听起来像是在微软市场营销部门工作的风险,在本节的剩余部分,我们将关注以下 C#的亮点和用例:

  • C#(以及.NET 生态系统)以经济的方式使软件开发成为可能。经济解决方案很重要,因为企业开发是 C#的精髓。

  • C#可以通过支持自文档化代码、安全库和易用性来提高代码的稳定性并使其可维护。

  • C#对开发者友好且易于使用。没有什么比发现你想要使用的编程语言没有对你所喜爱的功能(例如稳定的包管理器、良好的单元测试支持以及跨平台运行时)提供良好支持更糟糕的了。

当然,在大多数(如果不是所有)编程语言中都可以编写可扩展、可维护且对开发者友好的“清洁代码”。区别在于开发者体验。有些语言在引导你编写清洁代码方面做得非常好,而有些则不然。C#并不完美,但它确实试图在这方面帮助你。

1.1.1 原因 1:C#经济高效

C#的使用和开发是免费的。语言和平台是完全开源的,所有文档都是免费的,大多数工具都有免费选项。例如,常见的 C#配置包括安装 C# 8、.NET 5 和 Visual Studio Community。所有这些都是免费的,并在本书中使用。运行时不需任何许可费用,你可以将最终产品部署到你想要的地方。

1.1.2 原因 2:C#可维护

当我们在这本书中谈论可维护性时,我们指的是在不产生意外副作用的情况下修复错误、更改功能以及解决其他问题的能力。这听起来像是任何编程语言的明显要求,但实际上很难实现。C#具有提高大型代码库可维护性(因此,安全可扩展性)的功能。例如,考虑泛型和语言集成查询(LINQ)。我们将在整本书中讨论这两件事,但它们是平台暴露的功能的例子,可以帮助你编写更好的代码。

对于一家公司来说,表面上,可维护性可能不是首要任务,但如果你开发了可维护的代码(意味着易于扩展且由测试支持的干净代码),开发成本就会降低。一开始,编写可维护代码时开发成本的降低可能看起来有些不合逻辑:可维护的代码需要更长的时间来编写和设计,从而增加了开发的初始成本。然而,想象一下,当用户发现一个错误或他们想要一个额外的功能时,过了一段时间会发生什么。如果我们编写可维护的代码,我们可以快速轻松地找到错误(并修复它)。添加功能更简单,因为代码库是可扩展的。如果我们能够轻松扩展和修复代码库,开发成本就会降低。

点赞

开闭原则

1988 年,法国计算机科学家贝尔特朗·梅耶(Eiffel 编程语言的创造者)出版了一本名为《面向对象软件构造》(Prentice Hall,1988 年)的书。梅耶的书的发布是面向对象编程和设计历史上的一个转折点,因为在这本书中,他引入了开闭原则(OCP)。OCP 旨在提高软件设计的可维护性和灵活性。梅耶说,OCP 意味着“软件实体(类、模块、函数等)应该是可扩展的,但应该是封闭的以修改。”

但在实际意义上,OCP(开闭原则)意味着什么?为了检验这一点,让我们将 OCP 应用于一个类:如果我们能够在不改变现有功能(因此,可能破坏代码的某些部分)的情况下向类中添加功能,我们认为这个类是“开放”于扩展的,并且是“封闭”于修改的。如果你遵守这个规则,你引入回归(或新错误)的可能性比试图强制修复错误或添加新功能而不考虑可维护性和可扩展性的可能性要小得多。当你处理更复杂(并且耦合;在第八章中讨论)的代码时,你更有可能因为误解你更改的副作用而引入新的错误。这是我们无论如何都要避免的事情。

|

1.1.3 第 3 个原因:C#对开发者友好且易于使用

企业开发是 C# 开发的基础,也是 C# 和 .NET 发挥优势的地方。在你的理想代码库中,企业环境会是什么样子?也许你希望代码库易于导航,拥有可靠的包管理器,并支持测试(单元、集成和冒烟测试)。让我们再添加优秀的文档和跨平台支持。

定义 自我文档化的代码意味着代码编写得足够清晰,以至于我们不需要注释来解释逻辑。代码本身就是文档。例如,如果你有一个名为 DownloadDocument 的方法,其他人可以大致了解它的功能。不需要添加注释来说明方法内部逻辑是下载文档。

为了锦上添花,也许我们可以与云服务进行良好的集成,以实现持续集成和持续交付(CI/CD)。实用主义观点告诉我们,你拥有如此代码库的可能性并不高。当然,这个愿望清单对于大多数场景来说是不切实际的。然而,如果你想做一些这些事情(如果你喜欢冒险,甚至所有这些),C# 不会成为你的障碍。它提供了现有的工作流程、功能性和原生库,让你达到 99% 的目标。

来自 Java 等语言的开发者可能会在项目结构中看到一些相似之处。尽管存在一些差异,但它们并不大。我们将在本书中深入讨论 C# 项目结构。

.NET 还支持几个流行的测试框架。Microsoft 提供了 Visual Studio 单元测试框架,其中包含(并且有时也被称为)MSTest。MSTest 只是 Visual Studio 单元测试框架的命令行运行器。其他常用的测试框架包括 xUnit 和 NUnit。你还可以找到对模拟框架的支持,如 Moq(Moq 与 Java 的 Mockito 或 Go 的 GoMock 类似。我们将在第 10.3.3 节中学习如何使用 Moq 进行单元测试),SpecFlow(类似于 Cucumber 的行为驱动开发),NFluent(一个流畅断言库),FitNesse,以及许多其他框架。

最后,你可以在众多平台上运行 C#,尽管有一些限制(一些较老的平台仅限于较老的 C# 版本和 .NET Framework)。使用 .NET 5,你可以在 Windows 10、Linux 和 macOS 上运行相同的代码。这种功能最初起源于 .NET Core,它是 .NET Framework 的一个分支,后来与 .NET Framework(以及其他框架)合并,形成了 .NET 5。你甚至可以通过 Xamarin 在 iOS 和 Android 上运行 C# 代码,通过 Mono 在 PlayStation、Xbox 和 Nintendo Switch 平台上运行。

1.2 为什么不使用 C#?

C# 并不是在所有情况下都是最佳选择。务必选择最适合工作的工具。C# 在各种情况下都表现良好,但以下是一些你可能不想使用 C# 和 .NET 的用例:

  • 操作系统开发

  • 实时操作系统驱动的代码(嵌入式开发)

  • 数值计算

让我们简要地探讨一下为什么 C#可能不适合这些用例。

1.2.1 操作系统开发

操作系统(OS)开发是软件工程中一个极其重要的领域,但并不是很多人开发操作系统。开发操作系统需要大量的时间和承诺,代码库通常包含数百万行代码,这些代码在多年甚至几十年内开发和维护。

C#不适合操作系统开发的主要原因归结为对手动内存管理(未管理代码)的支持不完整以及 C#的编译过程。尽管 C#在“不安全”模式下允许使用指针,但它在使用手动内存管理方面的易用性并不像 C 语言那样。

使用 C#开发操作系统的问题之一是其对即时编译器(JIT)的局部依赖(更多内容在第二章中介绍)。想象一下,你必须通过虚拟机运行你的操作系统。性能将是一个问题,因为虚拟机必须不断追赶以运行 JIT 编译的代码,这与.NET 代码在你的机器上运行时发生的情况类似。这种批评意味着,一个完全静态编译的语言更适合操作系统开发。

然而,确实存在使用高级语言开发的操作系统(OS)的例子。例如,Pilot-OS(由施乐帕克研究中心在 1977 年创建)是用 Mesa 编写的,¹它是 Java 的前身。

如果你想了解更多关于操作系统开发的信息,osdev.org 社区维基是一个极好的资源(wiki.osdev.org)。在那里你可以找到入门指南、教程和阅读建议。学习 C 的资源包括 Jens Gustedt 的《现代 C》(Manning,2019 年)和经典书籍《C 程序设计语言》,由 Brian Kernighan 和 Dennis Ritchie 所著(Prentice Hall,1988 年)。

1.2.2 C#中的实时操作系统嵌入式开发

与操作系统(OS)开发(第 1.2.1 节)类似,实时操作系统(RTOS)驱动的代码,你通常在嵌入式系统中找到,当通过虚拟机运行时,会经历很大的性能问题。RTOS 线性地、实时地扫描代码,并在可配置的间隔内执行指令,这个间隔从每秒一个操作到每微秒多次操作不等,具体取决于开发者的意愿和代码运行的微控制器或可编程逻辑控制器(PLC)的能力。由于虚拟机在运行时增加了延迟和开销,它阻碍了真正的实时执行。

如果你想了解更多关于实时操作系统(RTOS)驱动的代码和嵌入式开发的信息,你可以查看几本备受推崇的书籍,例如 David E. Simon 的《嵌入式软件入门》(Addison-Wesley Professional,1999 年),或者 Elecia White 的《制作嵌入式系统:优秀软件的设计模式》(O’Reilly Media,2011 年)。

1.2.3 数值计算和 C#

数值计算(也称为 数值分析)涉及算法的研究、开发和分析。在数值计算领域工作的个人(通常是计算机科学家或数学家)使用数值近似来解决科学和工程几乎每个分支的问题。从编程语言的角度来看,它提出了独特的挑战和考虑。每种编程语言都可以评估数学语句和公式,但有些是专门为此目的构建的。

考虑绘制图表。C# 完全可以处理绘图,但与 MATLAB 等工具相比,C# 在性能和易用性方面提供了什么?(MATLAB 是 MathWorks 创建的既是一个计算环境又是一种编程语言。)简短的答案是,它们不可比。在 C# 中进行图形编程,你将工作在类似于 WPF(使用 Direct3D)、OpenGL、DirectX 或不同的第三方图形库(通常针对视频游戏)的环境中。使用 MATLAB,你有一个与一个构建来渲染复杂 3-D 图形的语言绑定在一起的环境。你可以直接调用 plot(x, y),MATLAB 会为你绘制图表。

因此,C# 可以进行数值计算,但与 MATLAB 这样的具有高级库和抽象处理图形绘制的语言相比,并不提供相同的易用性。如果你对学习更多关于 MATLAB 或数值计算感兴趣,这些主题的一些可用资源包括 Richard Hamming 的 《科学家和工程师的数值方法》(Dover Publications,1987 年),Amos Gilat 的 《MATLAB:应用入门》(Wiley,2016 年),以及 MATLAB 的 Cody 教程程序(www.mathworks.com/matlabcentral/cody)。

1.3 转向 C#

由于语言之间的相似性,对 Java 虚拟机 (JVM) 语言(尤其是 Java、Scala 和 Kotlin)或 C++ 语法有良好理解的开发者,与来自非 C 风格语言、非虚拟机类似语言或 Dart、Ruby 或 Go 等关注网络和云的语言的背景的人相比,可能会更容易理解这本书。来自非 C 风格语言背景并不意味着 C# 就不可能理解。你可能会发现自己需要反复阅读一些段落两次,但最终你会顺利地理解它。

如果你来自像 Python 这样的解释型语言,.NET 编译过程可能一开始看起来很奇怪。.NET 限制内的语言使用两步编译过程。首先,代码被静态编译到一个称为公共中间语言 (CIL,IL 或 MSIL 的简称;MS 代表 Microsoft——对于 Java 开发者来说,它与 Java 字节码有些相似),然后当 .NET 运行时在宿主上执行代码时,它会即时编译 (JIT) 到本地代码。所有这些可能听起来突然有很多要消化,但在几章之后,你就会理解这一切。

如果您来自像 JavaScript 这样的脚本语言,静态类型可能看起来会限制并让您感到沮丧。但一旦您习惯了始终知道您的类型是什么,我认为您会喜欢它的。

如果您来自像 Go 或 Dart 这样的语言,其中原生库有时难以找到,.NET 5 可能会因其丰富的库存储而让您感到惊讶。通过提供您能想到的大多数功能的功能,.NET 库是您功能的主要来源。许多用 .NET 编写的应用程序从未使用过任何第三方库。

为了避免不必要的麻烦,让我们先讨论一下工具。在本章中,我们不会深入讨论如何安装 IDE 或 .NET SDK。如果您尚未安装 .NET SDK 或 IDE 并需要一些帮助,您可以在附录 C 中找到一些快速安装指南。为了跟随本书的内容,您需要安装 .NET Framework 和 .NET 5 的最新版本。在本书中,我们将从一个使用 .NET Framework 的旧代码库开始。正因为如此,我们在将代码迁移到 .NET 5 的过程中将使用 .NET Framework 来运行那个旧代码库。

如前所述,C# 是开源的,由社区维护,并得到微软的支持。您不需要为运行时、SDK 或 IDE 许可证付费。关于 IDE,Visual Studio(本书示例中将使用的 IDE)有一个免费的社区版,您可以使用它来开发个人项目和开源软件。如果您喜欢您当前的 IDE,那么您很可能可以找到适用于它的 C# 插件。您还可以使用命令行来编译、运行和测试 C# 项目,尽管我鼓励您尝试一下专门的 C# 工具(Visual Studio),因为它提供了最流畅的体验和编写惯用 C# 代码的最简单途径。

您在其他地方学到的许多概念和技术可以转移到 C# 中,但也有一些不行。C# 在后端比在前端更加成熟,因为它传统上主要用于这个目的。C# 对后端开发的历史关注并不意味着前端体验有任何不令人印象深刻的地方。您可以使用 C# 编写全栈应用程序,而无需接触 JavaScript。尽管本书侧重于后端开发,但这里教授的许多概念对前端开发也有帮助。

你是否曾经遇到过一种包含五个嵌套的for循环、一堆硬编码的数字(所谓的魔法数字)以及比代码还多的注释的“怪物方法”?想象一下,你是一名刚刚加入团队的新开发者。当你启动你的集成开发环境(IDE),下载源代码,并看到这个方法时,你会作何感想?绝望可能都无法完全概括你的感受。现在想象一下,如果你将你的“怪物方法”中的所有单个操作都放在它们自己的小方法中(可能少于 5 到 10 行代码)。你的“怪物方法”会是什么样子?它不再是难以跟随的条件和赋值语句的集合,除非你有特定的领域知识,否则没有明确的路径可以理解,代码几乎就像一个叙述。如果你给你的方法取好名字,你的主方法应该像一份食谱,即使是糟糕的厨师也能遵循。

当我提到“清洁代码”时,我指的是由 Robert C. Martin 在他的视频(cleancoders.com/videos)和书籍《Clean Code》(Prentice Hall, 2008)、《Clean Architecture》(Prentice Hall, 2017),以及与 Micah Martin 合著的《Agile Principles, Patterns, and Practices in C#》(Pearson, 2006)中宣扬的编码实践,以及他汇编的“SOLID”原则(单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则)。当这些原则在书中出现时,我会全面解释清洁代码原则,并附带如何实际使用它们的实用信息。

到头来,为什么还要编写清洁代码呢?清洁代码就像一个用于错误和不正确功能的洗衣机。如果我们把我们的代码库放入图 1.3 所示的清洁代码洗衣机中,我们会看到,一旦你重构某样东西使其更加“清洁”,错误就会显现出来,不正确的功能会毫无藏身之处地盯着你。毕竟,“所有的东西都会在洗涤中显现出来。”当然,重构生产代码也有风险;常常会引入意外的副作用。这使得管理层在没有增加功能的情况下很难批准大的重构。然而,有了正确的工具(本书中讨论了一些),你可以最大限度地减少负面副作用的可能性,并提高代码库的质量。

图 1.3

图 1.3 清洁代码就像你的代码的洗衣机。它将你的脏衣服(你的代码)放入,加入肥皂和水(清洁代码原则),并将污垢从衣服中分离出来(将错误从代码中分离出来)。它留给你的衣服(代码)比开始时更干净(错误更少)。

这本书包含了关于代码整洁性的侧边栏信息。如果侧边栏与代码整洁性相关,我会将其标记为相关内容,并解释这些概念以及如何将它们应用到现实世界中。附录 B 包含了一个代码整洁性检查清单。你可以使用这个清单来判断是否需要对现有代码进行重构。这个清单可以作为一些容易忘记(但仍然重要)的概念的提醒。

1.4 你将在本书中学到什么

本书将教你编写符合习惯用法和整洁性的 C#代码。它不教授 C#语言、.NET 5 或从头开始的编程。我们采用一种实用方法:在一个业务场景中,我们将旧 API 重构得更加整洁和安全。在这个过程中,你会学到很多东西。以下是一些亮点:

  • 对旧代码库进行重构以提高安全性、性能和整洁性

  • 编写自文档化的代码,使其能够通过任何代码审查

  • 使用测试驱动开发与实现代码并行编写单元测试

  • 通过 Entity Framework Core 安全地连接到云数据库

  • 将代码整洁性原则引入现有代码库

  • 阅读通用中间语言并解释 C#编译过程

那么,为了充分利用这本书,你需要了解哪些内容呢?预期是,你理解面向对象编程的基本原则(继承、封装、抽象和多态),并且熟悉支持通过面向对象方法开发代码的另一种编程语言(无论是 C++、Go、Python 还是 Java)。

阅读本书后,你将能够编写符合良好面向对象设计原则的整洁、安全、可测试的 C#代码。此外,你将准备好通过高级资源进一步深化你的 C#知识。本书之后的一些推荐阅读包括 Jon Skeet 的《C#深度探索》第 4 版(Manning,2019 年),Jeffrey Richter 的《CLR via C#》第 4 版(Microsoft Press,2012 年),Bill Wagner 的《Effective C#》第 2 版(Microsoft Press,2016 年),Dustin Metzgar 的《.NET Core in Action》(Manning,2018 年),John Smith 的《Entity Framework Core in Action》第 2 版(Manning,2021 年),以及 Andrew Lock 的《ASP.NET Core in Action》第 2 版(Manning,2021 年)。

1.5 你在本书中不会学到什么

本书旨在填补初学者和高级 C#资源之间的差距。为此,我对你对 C#和编程的理解做出了一些假设。正如在此简要讨论的,我预期你有一些专业的编程经验,并且对 C#的基础知识或另一种面向对象编程语言感到舒适。

我这是什么意思?为了最大限度地利用这本书,你应该理解面向对象原则,并且能够使用你喜欢的编程语言开发基本的应用程序或 API。因此,这本书不会教授一些通常出现在初学者编程书中的以下主题:

  • C#语言本身。这不是一本从零开始学习 C#的书籍。相反,我教你如何将你现有的 C#或面向对象编程知识提升到下一个层次。

  • 与条件语句和分支语句相关的语法,这些语句不是特定于 C#的(ifforforeachwhiledo-while等)。

  • 多态性、封装和继承是什么(尽管我们在本书中经常使用这些概念)。

  • 什么是类以及我们如何通过类来模拟现实世界的对象。

  • 变量是什么,或者如何给一个变量赋值。

如果你是一个编程新手,我强烈推荐在阅读这本书之前,先阅读像 Jennifer Greene 的《Head First C#》第 4 版(O’Reilly,2020 年)或 Harold Abelson、Gerald Jay Sussman 和 Julie Sussman 的《计算机程序的结构与解释》第 2 版(麻省理工学院出版社,1996 年)这样的书籍²。

本书也没有涵盖这些更专业的 C#使用方式:

  • 微服务架构。本书并没有深入探讨微服务是什么以及如何使用它们。微服务架构是一个非常流行的趋势,在许多用例中都很有用,但它与 C#或如何像专业人士一样编码无关。学习更多关于微服务的三个优秀资源是 Chris Richardson 的《Microservices Patterns》(Manning,2018 年),Prabath Siriwardena 和 Nuwan Dias 的《Microservices Security in Action》(Manning,2019 年),以及 Christian Horsdal Gammelgaard 的《Microservices in .NET Core》(Manning,2020 年)。

  • 如何在 Kubernetes 和/或 Docker 等容器化环境中使用 C#。尽管非常实用并且在许多企业级开发环境中使用,但知道如何使用 Kubernetes 或 Docker 并不能保证你能在 C#中“像专业人士一样编码”。要了解更多关于这些技术,请参阅 Marko Lukša 的《Kubernetes in Action》第 2 版(Manning,2021 年),Elton Stoneman 的《Learn Docker in a Month of Lunches》(Manning,2020 年),以及 Ashley Davis 的《Bootstrapping Microservices with Docker, Kubernetes, and Terraform》(Manning,2021 年)。

  • 超越多线程和锁的 C#并发(在第六章中讨论)。我们经常在高度线程化和性能关键的场景中遇到这个话题。大多数开发者并不经常与这样的代码打交道。如果你发现自己处于那种位置,学习更多关于 C#并发编程的绝佳资源是 Joe Duffy 的《Windows 上的并发编程》(Addison-Wesley,2008 年)。

  • CLR 或.NET Framework 本身的深层内部细节。尽管 CLR 和.NET 5 很有趣,但了解它们的每一个细节对大多数开发者来说几乎没有实际用途。本书对 CLR 和.NET Framework 进行了详细阐述,但停止在事情变得不实用或不便处理的地方。“CLR 和.NET Framework 的圣经”是 Jeffrey Richter 的《CLR via C#》第 4 版(微软出版社,2012 年)。

您有两种阅读这本书的方式。推荐的方式是从头到尾、按顺序阅读整本书。如果您只对重构和最佳实践感兴趣,您只需阅读第三部分至第六部分即可。

摘要

  • 本书不涵盖“编程 101”。它假设您已经了解面向对象编程。这使得我们可以专注于实际概念。

  • C#和.NET 5 在可扩展的企业开发方面表现出色,重点在于稳定性和可维护性。这使得 C#和.NET 成为公司和个人开发者理想的选择平台。

  • C#和.NET 5 在操作系统开发、RTOS-嵌入式开发或数值计算(或分析)方面并不出色。对于这些任务,C 和 MATLAB 更为合适。


^(1.)名称“Mesa”是一个双关语,指的是编程语言是高级的,就像一个孤立、高耸、顶部平坦的小山一样。

^(2.)可以从麻省理工学院出版社免费获取mitpress.mit.edu/sites/default/files/sicp/index.html

2 .NET 及其编译方式

本章涵盖

  • 将 C# 编译成本地代码

  • 阅读和理解中间语言

在 2020 年,微软发布了全功能的软件开发平台 .NET 5。在此之前,在 1990 年代末和 2000 年代初,微软创建了 .NET Framework,它是 .NET 5 的前身。.NET Framework 的原始用途是开发企业级 Windows 应用程序。实际上,我们将在第三章和第四章中使用 .NET Framework 来检查这样的代码库。.NET Framework 将大量库连接在一起。尽管 .NET Framework 和 C# 经常一起使用,但我们确实遇到过不使用 C# 的 .NET Framework 用例(最显著的是使用不同的 .NET 语言)。.NET Framework 的两个最重要的支柱是框架类库(FCL;这是一个庞大的类库,是 .NET Framework 的骨架)和公共语言运行时(CLR;包含 JIT 编译器、垃圾回收器、原始数据类型等 .NET 的运行环境)。换句话说,FCL 包含了你可能使用的所有库,而 CLR 执行代码。后来,微软推出了旨在多平台开发的 .NET Core。参见图 2.1 了解本章在本书结构中的位置。

图片

图 2.1 到目前为止,你已经了解了本书的预期内容。在本章中,我们将深入了解 .NET 及其变体。通过讨论 .NET 生态系统,我们将获得一个基准理解,这将有助于我们在本书的其余部分。

在本章中,我们将讨论 .NET 5 的几个特性,并将它们与其他平台(如 Java、Python 和 Go)的实现(有时是没有实现)进行对比。之后,我们将通过展示一个 C# 方法是如何从 C# 转换为通用中间语言(CIL)再到本地代码的过程来学习 C# 的编译过程。这些基本构建块使我们能够在 C# 语言和 .NET 生态系统方面的知识上打下坚实的基础。如果你已经熟悉 C# 和 .NET,那么本章对你来说可能有些重复。至少,我建议你阅读第 2.3 节。关于 C# 编译过程的讨论比大多数资源中的内容都要深入,并且在一些高级 C# 资源中被视为基础知识。为了测试你对相关主题的了解,第 2.2 节和第 2.3 节为你提供了练习题。

2.1 什么是 .NET Framework?

在一开始……是 .NET Framework——使用 .NET 的传统方式。.NET Framework 由微软在 2000 年代初引入。开发者可以使用 C# 与之配合编写企业桌面应用程序。由于微软对针对 Windows 有内在兴趣,.NET Framework 只能在 Windows 上运行,并依赖于许多 Windows API 来执行图形操作。如果你在 2020 年晚些时候(以及 .NET 5 的引入)之前从事任何用 C# 编写的桌面应用程序,我保证它使用的是 .NET Framework。

.NET Framework 随着时间的推移经历了多次迭代,但最新的版本(2019 年 7 月)是 4.8.0。由于已被 .NET 5 取代,因此将不再发布 .NET Framework 的后续版本,但它的许多遗留应用程序将继续存在。本书中涵盖的许多内容都适用于 .NET Framework。实际上,我们将在第三章和第四章中看到一个 .NET Framework 应用程序。

2.2 什么是 .NET 5?

在本节中,我们将讨论 .NET 5 是什么以及为什么它存在。自 2016 年以来,.NET 存在为两大主要分支:.NET Framework 和 .NET Core。新的 .NET 5 将这两个分支(以及如 Xamarin 和 Unity 这样的不同辅助分支)合并在一起,如图 2.2 所示。实际上,.NET 5 是将 .NET Core 重命名,因为它构成了新 .NET 的基础。因此,我们应该将 .NET 5 视为不仅仅是 .NET Framework 或 .NET Core 的另一个迭代,而更是一个重启和合并了之前技术的过程。

将所有 .NET 技术置于一个统一的框架下,你可以访问所有你想要的工具和用例。你可以开发企业软件、网站、视频游戏、物联网(IoT)、在 Windows/macOS/Linux 上运行的嵌入式应用程序、ARM 处理器(ARM32/64)、机器学习服务(ML.NET)、应用程序、云服务和移动应用,这一切都在同一个框架中完成。并且由于 .NET Framework 遵循 .NET 标准规范,所有现有的代码库和库都应该与 .NET 5 兼容(只要 .NET 5 支持代码库使用的底层包和功能)。

图片

图 2.2 .NET 5 将 .NET Framework 4 与 ARM32/64、Xamarin、.NET Core 3、Unity 和 ML.NET 合并。这使得我们可以在一个统一的框架下使用所有这些 .NET 变体:.NET 5。

.NET 5,就像 .NET Framework 和 .NET Core 一样,是 .NET 标准的实现——这是一个用于开发各种 .NET 实现的规范:.NET 5、.NET Framework、.NET Core、Mono(.NET Core 所基于的跨平台技术)、Unity(视频游戏开发)和 Xamarin(iOS 和 Android 开发)。这些实现有不同的用途,但本质上非常相似。针对 .NET 标准开发意味着实现之间的代码共享尽可能无缝。

.NET 标准包含有关与 CLR(C#所依赖的运行时)交互时可用哪些 API 的信息。在.NET 标准之前,我们除了使用可移植类库(PCL)之外,没有真正的方法来确保我们的代码或库能够在.NET 实现之间工作。PCL 是可以在不同项目之间共享的库,但只能针对.NET 实现的一个特定版本(或“配置文件”)。今天我们称这些 PCL 为“基于配置文件的 PCL”。针对遵循.NET 标准的.NET 实现的库也是 PCL,但它们不是针对特定实现,而是针对.NET 标准的某个版本。为了区分基于配置文件的 PCL,我们称这些为“基于.NET 标准的 PCL”。.NET 标准封装了在.NET 标准时代之前编写的库所使用的许多 Windows API(因此,基于配置文件的 PCL)。因此,我们可以在任何.NET 标准实现的.NET 中无问题地使用这些库。第一个实现.NET 标准的.NET Framework 版本是 4.5。

遵循微软对开源软件的推动,.NET 5 及其所有相关仓库都是开源的,可在 GitHub 上找到(github.com/dotnet)。有关新版本.NET 中计划包含的新功能的更多信息,请参阅 CoreFX 路线图github.com/dotnet/corefx/milestones。您可以通过github.com/dotnet/standard访问.NET 标准。

练习

练习 2.1

以下哪个操作系统不受.NET 5 支持?

a. Windows

b. macOS

c. Linux

d. AmigaOS

练习 2.2

“CLR”这个术语代表什么?

a. 创意许可资源

b. 类库参考

c. 常见语言运行时

练习 2.3

填空:.NET 标准是一个(n) __________,它规定了所有.NET 平台实现细节,以实现代码共享。

a. 实现

b. 前身

c. 工具

d. 规范

2.3 如何编译符合 CLI 的语言

在本节中,您将深入了解 C#(以及其他符合通用语言基础设施(Common Language Infrastructure)的语言;参见 2.3.2 节)的编译过程。了解整个编译过程可以帮助您充分利用 C#的所有功能,同时了解一些与内存和执行相关的陷阱。C#的编译过程知道三个状态(C#、中间语言和本地代码)和两个阶段,如图 2.3 所示:从 C#到通用中间语言,再到本地代码。

图 2.3 完整的 C#编译过程。它从 C#代码到通用中间语言,再到本地代码。理解编译过程使我们能够了解 C#和.NET 内部的一些选择。

注意:本地代码有时被称为机器代码

通过观察从一步到另一步需要什么,并遵循编译器和 CLR 将高级 C# 代码编译成可执行原生代码的方法,我们能够理解 C# 和 .NET 5 的复杂机器。对这一过程的理解通常是初学者资源中发现的资源差距,但高级资源要求你理解这一点。

我们使用静态编译和即时编译的组合将 C# 编译成原生代码,如下所示:

  1. 开发者编写完 C# 代码后,他们会编译他们的代码。这会在可移植可执行文件(PE 文件,32 位为 PE,64 位为 PE+)中存储通用中间语言(CIL),例如 .exe 和 .dll 文件,用于 Windows。这些文件被分发或部署给用户。

  2. 当我们启动 .NET 程序时,操作系统调用公共语言运行时(CLR)。公共语言运行时的即时编译器将 CIL 编译成适合其运行平台的原生代码。这允许 CLI 兼容的语言在许多平台和编译器类型上运行。然而,不提一下使用虚拟机和即时编译器运行代码的主要负面影响:性能。

静态编译的程序在执行时具有优势,因为你不需要等待运行时来编译代码。

定义 静态编译和即时编译是两种常用的代码编译方式。C# 使用静态编译和即时编译的组合。这意味着代码在最后可能的时刻被编译成字节码。静态编译在编译前将所有源代码编译完成。

2.3.1 步骤 1:C# 代码(高级)

我第一次遇到勾股定理是在 2008 年。当时我在荷兰高中版本的学校,看到数学课本中提到我们那年会学习勾股定理。几天后的深夜,我和父亲在车里。我们已经开了一段时间车,所以对话自然慢了下来。在一个完全不合时宜的时刻,我问了他,“什么是勾股定理?”这个问题显然让他很惊讶,因为我当时对学术几乎没有兴趣,尤其是在数学方面。接下来的十分钟,他试图向我解释,一个数学能力相当于葡萄柚的人,什么是勾股定理。我惊讶地发现我实际上理解了他所说的内容,现在,多年以后,这已经证明是展示 C# 编译过程第一步的一个极好的资源。

在本节中,我们将查看 C# 编译过程的第一个步骤:编译 C# 代码,如图 2.4 所示。我们将跟随编译过程的是勾股定理。使用代表勾股定理的程序来向您展示 C# 编译过程是直截了当的原因是:我们可以将勾股定理压缩为几行代码,这些代码可以用高中水平的数学知识理解。这使得我们可以专注于编译故事而不是实现细节。

图片

图 2.4 C# 编译过程,步骤 1:C# 代码。这是静态编译阶段。

注意:如果需要快速复习,勾股定理表明 a² + b² = c²。我们通常将勾股定理应用于通过取结果的平方根来发现直角三角形斜边的长度,该结果为直角三角形相邻两边的平方长度的和。

我们将从一个简单的、当给定两个参数时证明勾股定理的方法开始,如下一列表所示。

列表 2.1 勾股定理(高级)

public double Pythagoras(double sideLengthA, double sideLengthB) {                ❶
    double squaredLength = sideLengthA * sideLengthA + sideLengthB * sideLengthB; ❷
  return squaredLength;
}

❶ 我们声明一个具有公共访问修饰符的方法,返回一个浮点数,名为 Pythagoras,它期望两个浮点数(double)参数:sideLengthA 和 sideLengthB。

❷ 我们执行勾股定理并将结果赋值给一个名为 squaredLength 的变量。

如果我们运行此代码并给它 [3, 8] 的参数,我们看到结果是 73,这是正确的。或者,因为我们使用的是 64 位浮点数(doubles),我们也可以测试类似集合的参数。结果是 12037.7057。

访问修饰符、程序集和命名空间

C# 知道六个访问修饰符(从最开放到最限制):公共、受保护内部、内部、受保护、受保护私有和私有。您在日常生活中使用最多的两个是公共和私有。公共 表示对所有类和项目的可用性(这是某些语言中“导出”的概念;与某些编程语言不同,在 C# 中,方法名称的大小写对访问修饰符没有影响),而 私有 表示仅在当前类中可见。

其他四个(内部、受保护的、受保护的内部和私有受保护的)使用较少,但了解它们是有好处的。内部 允许对其自己的程序集内的所有类进行访问,而 受保护的 限制了对从原始类派生的类的访问。这留下了 内部受保护的。这个访问修饰符是内部和受保护的访问修饰符的组合。它允许派生类及其自己的程序集进行访问。私有受保护的 允许在其自己的程序集内进行访问,但仅限于同一类或派生类中的代码。

图片

C# 访问修饰符从开放到受限。使用正确的访问修饰符有助于封装我们的数据并保护我们的类。

现在我们编译代码。假设列表 2.1 中的方法是一个名为 HelloPythagoras 的类的部分,该类是名为 HelloPythagoras 的项目和解决方案的一部分。要将 .NET 5(或 .NET Framework/.NET Core 解决方案)编译成存储在 PE/PE+ 文件中的中间语言,您可以使用 IDE 中的构建或编译器按钮,或者在命令行中运行以下命令:

dotnet build [solution file path]

解决方案文件以 .sln 扩展名结尾。创建我们的解决方案的命令如下:

dotnet build HelloPythagoras.sln

在我们运行命令后,编译器启动。首先,编译器通过 NuGet 包管理器恢复所有必需的依赖项包。然后,命令行工具编译项目并将输出存储在一个名为 bin 的新文件夹中。在 bin 文件夹中,根据我们设置的编译器模式(如果您想的话,可以定义自己的模式),有两个潜在的文件夹选项,即调试和发布。默认情况下,编译器以调试模式编译。调试模式包含所有调试信息(存储在 .pdb 文件中),这些信息是您使用断点逐步通过应用程序所需的。

要通过命令行以发布模式编译,请将 --Configuration release 标志附加到命令中。或者,在 Visual Studio 中,从下拉列表中选择调试或发布模式。这是您编译代码最容易、最快且最可能的方式。

灯泡

调试和发布构建模式

在日常生活中,调试和发布模式之间的实际区别在于性能和安全。通过在调试构建的输出代码中包含对 .pdb 文件的引用,运行时必须迭代更多代码来完成相同的逻辑,这比在发布模式中要复杂,因为在发布模式中这些引用不存在。因此,与发布模式相比,建模该代码所需的中间语言更大,编译时间更长。

此外,如果您包含调试信息,心怀恶意的人可能会利用这些信息,从而更容易地访问您的代码库。这并不是说在发布模式下编译就消除了对良好安全实践的任何需求。中间语言可以很容易地反编译(无论最初是在调试还是发布模式下编译),变成与原始源代码类似的东西。如果您想适当地保护您的源代码,请考虑研究混淆器(Dotfuscator、.NET Reactor)和威胁模型。

一个好的经验法则是使用调试模式进行开发,并使用调试和发布模式进行测试。通常,这包括在调试模式下本地测试,并在具有发布构建的专用环境中进行用户验收测试。因为不同模式之间的代码略有不同,你可能会在发布模式下发现调试模式下没有发现的错误。你不想处于只测试了调试构建,而在截止日期前发现发布构建中存在阻止性错误的境地。

|

在这个阶段,C# 高级代码被编译成一个包含中间语言代码的可执行文件。

2.3.2 步骤 2:公共中间语言(汇编级别)

从日常工作的角度来看,你的工作已经完成。代码已经以可执行形式存在,你可以关闭你的工单或用户故事。从技术角度来看,旅程才刚刚开始。C# 代码被静态编译成公共中间语言,如图 2.5 所示,但 IL 不能由操作系统运行。

图片 2.5

图 2.5 C# 编译过程,步骤 2:中间语言。我们从静态编译转换到即时编译。

但你是如何从 IL 转换到本地代码的呢?缺失的部分是公共语言运行时(CLR)。.NET 5 的这部分将公共中间语言转换为本地代码。它是 .NET 的“运行时”。我们可以将 CLR 与 Java 虚拟机(JVM)进行比较。CLR 一直是 .NET 的一部分。值得注意的是,随着向 .NET Core 和 .NET 5 的转变,一个新的 CLR 实现正在取代旧的 CLR:CoreCLR。本书中关于 CLR 的解释适用于传统的 CLR 和 CoreCLR,术语 CLR 适用于常规的公共语言运行时和 CoreCLR。

任何使用称为公共语言基础设施(CLI)的技术标准实现的代码,例如 .NET 5,都可以编译成公共中间语言。CLI 描述了 .NET 背后的基础设施,其具体风味是 CLI 的实现本身,并为语言提供了一个形成其类型系统的基础。因为 CLR 可以接受任何中间语言(IL)片段,而 .NET 编译器可以从任何 CLI 兼容的语言生成这种 IL,所以我们可以从混合源代码生成 IL 代码。C#、Visual Basic 和 F# 是最常用的 .NET 编程语言,但还有更多可供选择。请参阅表 2.1 以了解这些缩写的汇总。

直到 2017 年,微软还支持 J#,这是 Java 的 CLI 兼容实现。理论上,你可以下载兼容的编译器并使用 J#,但为了在 .NET 平台上开发,你将错过一些现代 Java 功能。

注意:CLR 是一个极其复杂的软件组件。如果你想了解更多关于(传统、基于 Windows 的)CLR 的信息,请参阅 Jeffrey Richter 的 CLR via C#(第 4 版;Microsoft Press,2012)。

因为编译器将 IL 嵌入文件中,我们需要使用反汇编器来查看 CIL。所有 .NET 版本都包含 Microsoft 自己的反汇编器,称为 ILDASM(中间语言反汇编器)。要使用 ILDASM,我们需要运行 Visual Studio 的开发者命令提示符,它与 Visual Studio 一起安装。这是一个命令提示符环境,它为我们提供了访问 .NET 工具的权限。请注意,ILDASM 仅适用于 Windows。

表 2.1 .NET 缩写总结

缩写 全称 描述
CLR 通用语言运行时 .NET 虚拟机运行时。CLR 管理诸如代码执行、垃圾回收、线程和内存分配等关键任务。
CLI 通用语言基础设施 描述 .NET 生态系统中可执行代码的外观及其执行方式的规范。
CIL 通用中间语言 一种可以被 CLR JIT 编译以执行 CLI 兼容代码的语言。这是 C# 在第一次编译阶段编译成的内容。
IL 中间语言 CIL 的另一个术语。
MSIL 微软中间语言 CIL 的另一个术语。

一旦进入开发者命令提示符,我们就可以在我们的编译文件上调用 ILDASM,并指定一个输出文件,如下所示:

>\ ildasm HelloPythagoras.dll /output:HelloPythagoras.il

如果我们没有指定输出文件,命令行工具将启动 ILDASM 的 GUI。在那里,你还可以查看反汇编可执行文件的 IL 代码。输出文件可以是任何你想要的文件扩展名,因为它是一个简单的二进制文本文件。请注意,在 .NET Framework 中,ILDASM 针对的是 .exe 文件,而不是 .dll。.NET 5 和 .NET Core 使用 .dll 文件。

当我们在文本编辑器中打开 HelloPythagoras.il 文件或查看 ILDASM 图形用户界面时,一个充满神秘代码的文件就会打开。这就是 IL 代码。我们关注的是 Pythagoras 方法(如果以调试模式编译)的 IL,如下所示。

列表 2.2 斐波那契定理(通用中间语言)

.method public hidebysig static float64
  Pythagoras(float64 sideLengthA,
              float64 sideLengthB) cil managed {
  .maxstack 3
  .locals init ([0] float64 squaredLength,
                  [1] float64 V_1)
  IL_0000:    nop
  IL_0001:    ldarg.0
  IL_0002:    ldarg.0
  IL_0003:    mul
  IL_0004:    ldarg.1
  IL_0005:    ldarg.1
  IL_0006:    mul
  IL_0007:    add
  IL_0008:    stloc.0
  IL_0009:    ldloc.0
  IL_000a:    stloc.1
  IL_000b:    br.s        IL_000d
  IL_000c:    ldloc.1
  IL_000e:    ret    
}

如果你曾经从事或见过汇编级编程,你可能会注意到一些相似之处。通用中间语言(Common Intermediate Language)确实比常规 C# 代码更难阅读,并且更“接近金属”,但它并不像看起来那么神秘。通过逐行查看 IL,你会发现这仅仅是编程概念的不同语法,而你已经熟悉这些概念。由你的机器上的编译器生成的 IL 代码可能看起来略有不同(特别是使用 ldarg 操作码的数字),但操作码的功能和类型应该是相同的。

我们看到的第一件事是方法声明,如下所示:

.method private hidebysig static float64
        Pythagoras(float64 sideLengthA,
                   float64 sideLengthB) cil managed

我们可以很容易地看到,该方法是私有的、静态的,并返回一个 64 位浮点数(在 C#中称为double)。我们还可以看到,该方法名为Pythagoras,接受两个名为sideLengthAsideLengthB的参数,它们都是 64 位浮点数。看起来奇怪的术语是hidebysigcil managed

首先,术语hidebysig告诉我们,毕达哥拉斯方法会隐藏具有相同方法签名的其他方法。当省略时,该方法会隐藏所有具有相同名称的方法(不仅限于签名匹配)。其次,cil managed意味着此代码是公共中间语言,并且我们正在托管模式下运行。硬币的另一面是未托管。这指的是 CLR 是否可以执行该方法,可能需要手动处理内存,并且具有 CLR 所需的全部元数据。默认情况下,所有代码都在托管模式下运行,除非你通过启用编译器的“不安全”标志并指定代码为“不安全”来明确告诉它不要这样做。

接下来,让我们看看方法本身,我们可以将方法分为两部分:设置(构造函数)和执行(逻辑)。首先,让我们看看构造函数,如下所示:

.maxstack 3
  .locals init ([0] float64 squaredLength,
                  [1] float64 V_1)

这里有一些不熟悉的术语。首先,.maxstack 3告诉我们,在执行过程中,内存堆栈上允许的最大元素数是三个。静态编译器自动生成这个数字,并告诉 CLR JIT 编译器为该方法保留多少元素。这个方法代码的部分很重要——想象一下,如果我们不能告诉 CLR 我们需要多少内存,它可能会决定在系统上保留所有可用的堆栈空间,或者根本不保留任何空间。任何一种情况都会是灾难性的。

接下来是

.locals init (...)

当我们在 CLI 兼容的编程语言中声明一个变量时,编译器会在编译时为该变量分配一个作用域,并将变量的值初始化为默认值。locals关键字告诉我们,在此代码块中声明的变量的作用域是局部的(作用域限于方法,而不是类),而init意味着我们正在将声明的变量初始化为其默认值。编译器将其分配给null或零值,具体取决于变量是引用类型还是值类型。

扩展.locals init (...)代码块,我们可以看到我们声明并初始化的变量如下:

.locals init (
  [0] float64 squaredLength,
  [1] float64 V_1

IL 声明了两个局部变量,并将它们初始化为零值:squaredLengthV_1

现在,你可能会说,等等!在我们的 C#代码中,我们只声明了一个局部变量:squaredLength。这个V_1是什么意思?再次查看以下 C#代码:

public double Pythagoras(double sideLengthA, double sideLengthB) {
  double squaredLength = 
➥ sideLengthA * sidelengthA + sideLengthB * sideLengthB;
  return squaredLength;
}

我们明确声明了仅有一个局部变量。然而,我们是通过值而不是通过引用返回squaredLength。这意味着在底层,会声明一个新的变量,初始化并分配给squaredLength的值。这就是V_1

总结一下,我们研究了方法签名和设置。现在我们可以深入逻辑的细节。让我们也将以下部分分为两个部分——勾股定理的评估和返回结果值:

IL_0000:    nop
IL_0001:    ldarg.0
IL_0002:    ldarg.0
IL_0003:    mul
IL_0004:    ldarg.1
IL_0005:    ldarg.1
IL_0006:    mul
IL_0007:    add
IL_0008:    stloc.0

首先,我们看到一个操作(我们也将这些操作称为 opcodes)称为 nop。这也被称为“什么都不做的操作”或“无操作”,因为单独的 nop 操作本身不做任何事情。它们在 IL 和汇编代码中广泛使用,以启用断点调试。与在调试构建中生成的 PDB 文件一起,CLR 可以注入指令,在 nop 操作处停止程序执行。这允许我们在运行时“单步执行”代码。

接下来,我们来看一下勾股定理本身的评估,如下所示:

    double squaredLength = 
➥ sideLengthA * sideLengthA + sideLengthB * sideLengthB;

以下两个操作是一个双重头:两个 ldarg.0 操作。第一个操作(IL_0001)将第一个 sideLengthA 出现在栈上加载。第二个操作(IL_0002)也将第二个 sideLengthA 出现在栈上加载。

在我们将第一个数学评估参数加载到栈上之后,IL 代码调用以下乘法操作:

IL_0003:    mul

这导致在 IL_0001IL_0002 中加载的两个参数被相乘并存储到栈上的一个新元素中。垃圾收集器现在从栈中清除先前(现在不再使用)的栈元素。

我们按照以下方式重复此过程以对 sideLengthB 参数进行平方:

IL_0004:    ldarg.1
IL_0005:    ldarg.1
IL_0006:    mul

因此,现在栈中包含 sideLengthA2sideLengthB2 的值。为了满足勾股定理和我们的代码,我们必须将这些两个值相加并将它们存储在 squaredLength 中。这是在 IL_0007IL_0008 中完成的,如下所示:

IL_0007:    add
IL_0008:    stloc.0

mul 操作(IL_0003IL_0006)类似,add 操作(IL_0007)评估先前存储的参数之和,并将结果值放置在栈上的一个元素中。IL 将此元素存储到我们在设置中初始化的 squaredLength 变量中([0] float64 squaredLength)通过 stloc.0 命令(IL_0008)。stloc.0 操作从栈中弹出一个值并将其存储在索引 0 的变量中。

现在我们已经完全评估并将勾股定理的结果存储到一个变量中。剩下的只是从方法中返回值,正如我们在原始方法签名中承诺的那样,如下所示:

IL_0009:    ldloc.0
IL_000a:    stloc.1
IL_000b:    br.s        IL_000d
IL_000c:    ldloc.1
IL_000e:    ret  

首先,我们将位于位置 0 的变量的值加载到内存中(IL_0009)。在上一个段中,我们将毕达哥拉斯定理的值存储到位置 0 的变量中,所以那必须是squaredLength。但是,如前所述,我们是按值传递变量,而不是按引用传递,因此我们创建了一个squaredLength的副本以从方法中返回。幸运的是,我们在索引 1 处声明并初始化了一个变量专门用于此目的:V_1 ([1] float64 V_1)。我们通过stloc.1操作(IL_000a)将值存储到索引 1 中。

接下来,我们看到另一个奇怪的操作:br.s IL_000dIL_000b)。这是一个分支操作符,表示返回值已计算并存储以供返回。IL 使用分支操作符进行调试目的。分支操作符类似于nop操作。当调用返回时,所有不同的代码分支(具有其他返回值的条件)都会跳转到br.s操作符。br.s操作符占用两个字节,因此有两个 IL 位置(IL_000bIL_000d);一个操作码通常占用一个字节。因为br.s操作符的大小为两个字节,所以IL_000cldloc.1)被包含在分支操作符中。这允许调试器在加载存储的返回值时停止执行,并在必要时对其进行操作。

最后,我们准备通过IL_000cIL_000e从方法中返回,如下所示:

IL_000c:    ldloc.1
IL_000e:    ret  

ldloc.1IL_000c)操作加载之前存储的返回值。这之后跟着ret操作符,它将我们在IL_000c加载的值从方法中返回。请参见列表 2.3 中的整个代码示例。

这就带我们来到了本节的结尾。希望你现在对 C#和.NET 静态编译步骤的细节部分有了更多的了解。

列表 2.3 毕达哥拉斯定理方法的 IL 源代码

.method private hidebysig static float64                  ❶
  Pythagoras(float64 sideLengthA,                         ❷
                   float64 sideLengthB) cil managed {     ❸
  .maxstack 3                                             ❹
  .locals init ([0] float64 squaredLength,                ❺
                  [1] float64 V_1)
  IL_0000:    nop                                         ❻
  IL_0001:    ldarg.0                                     ❼
  IL_0002:    ldarg.0
  IL_0003:    mul                                         ❽
  IL_0004:    ldarg.1                                     ❾
  IL_0005:    ldarg.1
  IL_0006:    mul                                         ❿
  IL_0007:    add                                         ⓫
  IL_0008:    stloc.0                                     ⓬
  IL_0009:    ldloc.0                                     ⓭
  IL_000a:    stloc.1                                     ⓮
  IL_000b:    br.s        IL_000d                         ⓯
  IL_000c:    ldloc.1                                     ⓰
  IL_000e:    ret                                         ⓱
}

❶ 方法的开始,该方法是私有的、静态的、返回 double 类型,并隐藏具有相同签名的其他方法

❷ 该方法被称为毕达哥拉斯方法。它期望两个类型为 float64(双精度浮点数)的参数。

❸ 这是一个 CIL(通用中间语言)方法,在托管模式下运行。

❾ 在栈上需要的最大元素数量是三个。

❺ 声明并初始化了两个类型为 float64 的局部变量:索引 0 的 squaredLength 和索引 1 的 V_1。

❽ 一个“不做任何事”的操作;由调试器用于设置断点

❻ 第一个 sideLengthA 参数被加载到内存中。

❽ 被加载到内存中的两个 sideLengthA 值相乘并存储在栈元素中。

❽ 第一个 sideLengthB 参数被加载到内存中。

❾ 被加载到内存中的两个 sideLengthB 值相乘并存储在栈元素中。

⓫ sideLengthA 和 sideLengthB 的平方值相加并存储在栈元素中。

⓭ 之前存储在栈元素中的平方值被存储在为新索引 0 的变量指定的新的栈元素中:squaredLength。

⓭ 将 squaredLength 的值加载到内存中。

⓮ 将之前加载到内存中的 squaredLength 的值存储在索引为 1 的变量对应的堆栈元素中:V_1。

⓯ 分支操作符;表示方法的完成和返回值的存储

⓰ 返回值(变量 V_1)被加载到内存中。

⓱ 我们使用 V_1 的值从方法中返回。

2.3.3 步骤 3:本地代码(处理器级别)

编译过程的最后一步是将通用中间语言转换为本地代码,如图 2.6 所示,这是处理器可以实际运行的代码。到目前为止,代码已经被静态编译,但在这里发生了变化。当.NET 5 执行应用程序时,CLR 启动并扫描可执行文件中的 IL 代码。然后,CLR 调用 JIT 编译器在运行时将 IL 转换为本地代码。本地代码是(某种程度上)可读性最低的代码级别。由于包含了预定义的操作(操作码),处理器可以直接执行此代码,这与通用中间语言包含 IL 操作码的方式类似。

图 2.6 C#编译过程,步骤 3:本地代码。这是过程的 JIT 阶段。

JIT 编译我们的代码会带来性能成本,但也意味着我们可以在 CLR 和编译器支持的任何平台上执行.NET 代码。我们可以通过.NET Core 和新的 CoreCLR 来看到这一点。CoreCLR 可以将中间语言 JIT 编译为 Windows、macOS 和 Linux,如图 2.7 所示。

图 2.7 CoreCLR 可以为 Linux、Windows 和 macOS 等目标进行 JIT 编译。这允许 C#代码跨平台执行。

由于 JIT 编译步骤的性质,查看实际的本地代码有点棘手。查看从您的中间语言生成的本地代码的唯一方法是通过一个名为ngen的命令行工具,该工具预装在.NET 5 中。此工具允许您生成所谓的本地图像,其中包含存储在 PE 文件中的通用中间语言中的本地代码。CLR 将本地代码输出存储在%SystemRoot%/Assembly/NativeAssembly 的子文件夹中(仅在 Windows 上可用)。但是请注意,您不能使用常规文件资源管理器来导航到此位置,并且生成的输出也不易阅读。运行ngen后,CLR 会看到 IL 已经被编译(静态)为本地代码,并基于此执行。这带来了预期的性能提升;然而,当发布新构建时,本地代码和 IL 代码可能会不同步,如果 CLR 决定使用旧的静态编译的本地图像而不是重新编译新的、更新的代码,可能会产生意外的副作用。

在日常操作中,你可能不太会接触到 IL,或者过分关注 IL 到本地代码的编译。然而,理解编译过程是基础知识的一个基本组成部分,因为它揭示了我们在本书中会遇到 .NET 5 的设计决策。

练习

练习 2.4

.NET 编译过程的步骤和顺序是什么?

a. NET 代码 -> 中间语言 -> 本地代码

b. 中间语言 -> .NET 代码 -> 本地代码

c. .NET 代码 -> 本地代码

d. Java -> JVM

练习 2.5

填空:一个 __________ 编译器在代码需要之前编译代码,而预先编译的代码是通过一个 __________ 编译器完成的。

a. 静态

b. JIT

c. 动态

练习 2.6

中间语言存储在哪里?

a. DOCX 文件

b. 文本文件

c. HTML 文件

d. 字体文件

e. 可移植可执行文件

练习 2.7

填空:如果我们必须复制堆栈元素以传递变量,那么这个变量是一个 __________ 类型。

a. 引用

b. 海盗

c. 值

d. 可空

练习 2.8

填空:如果我们可以通过堆中元素的指针来操作变量值,那么这个变量是一个 _________ 类型。

a. 引用

b. 海盗

c. 值

d. 可空

摘要

  • .NET 5 消耗并重新品牌化 .NET Core 和 .NET Framework(以及其他 .NET 实现),实际上除了名称外,都成为 .NET Core 4。

  • .NET 使用静态和即时编译(“即时”)的组合。与完全即时编译的语言相比,这允许更快的执行,并且支持跨平台执行。

  • C# 编译过程有三个状态:(1)C# 代码,(2)中间语言代码和(3)本地代码。

  • C# 编译过程有两个步骤:C# 到中间语言(静态编译)和中间语言到本地代码(即时编译)。

  • 中间语言存储在可移植可执行文件中(例如 Windows 上的 .exe 和 .dll)。CLR 扫描这些文件以查找嵌入的 IL 并执行它,将其即时编译为适当的本地代码。

  • 当启动 .NET 应用程序时,公共语言运行时被调用,并将中间语言代码即时编译为本地代码。

  • 在 C# 中,64 位浮点数是“double”类型。

  • C# 有六个不同的访问修饰符:public、protected internal、internal、protected、protected private 和 private。这些用于控制对方法的访问。

  • 命令行可以通过 dotnet build [解决方案文件路径] 命令编译 C#。你也可以通过 Visual Studio 等集成开发环境进行编译。

  • 公共语言基础设施是一个技术标准,为所有针对 .NET 的语言提供基础。这使我们能够使用 F#、VB.NET 和 C# 等语言。

  • 中间语言命令大致对应于字节码操作码。

第二部分 现有的代码库

在阅读了第一部分之后,你对 C#和.NET Framework 的各种版本已经熟悉。你知道 C#是如何编译的,以及为什么你(或不会)想在自己的项目中使用它。在这一部分,我将向你介绍荷兰飞机制造航空公司。这家公司在本书的其余部分将作为我们的案例研究。

在接下来的两章中,我们将深入研究一个现有的代码库,并对其进行全面检查,评估我们可以进行改进的地方以及原因。

3 这段代码有多糟糕?

本章涵盖

  • HTTP 路由、资源和端点

  • 自动属性和仅初始化设置器

  • 配置 ASP.NET 服务

在本章中,我们将遇到飞行荷兰人航空公司,他们雇佣我们重构他们的遗留代码库。飞行荷兰人告诉我们他们的商业需求和重构的要求。我们将在这(以及下一)章中检查的遗留应用程序是一个基于.NET Framework 并遵循模型-视图-控制器(MVC)模式的后端 Web 服务。代码存在许多可读性和安全问题,所以如果你在本章中看到你不喜欢的代码片段,请不要感到惊讶。本章的目的是让我们确定我们可以改变现有代码库的地方。我们将深入查看本章中(混乱的)代码库的模型、视图和配置,为后续章节中重构代码做准备。图 3.1 显示了我们在本书结构中的位置。

图 3.1 在本章中,我们将开始第二部分:现有的代码库。我们将查看在本书的其余部分我们需要解决的要求,以及现有代码库包含哪些模型和视图。

飞行荷兰人航空公司,其标志如图 3.2 所示,是一家以荷兰格罗宁根为基地的低成本航空公司。该航空公司服务 20 个目的地,包括伦敦、巴黎、布拉格和米兰。成立于 1977 年,该公司在市场上遇到了困难。飞行荷兰人航空公司将自己定位为“超低成本航空公司”。现在我们正处于 21 世纪的中期,管理层认为现在是时候将业务带入这个世纪了。这就是你发挥作用的地方。在本节中,我们将遇到我们的新老板,并获取我们即将创建的产品规格。

图 3.2 飞行荷兰人航空公司标志。飞行荷兰人航空公司是我们在这本书中所工作的公司。

3.1 介绍飞行荷兰人航空公司

这是你在新工作第一天,你提前 10 分钟到达停车场。你的衬衫已经洗净、蒸过、熨烫并压平。你准备好开始你的第一天工作了。你的到来备受期待,在例行的人事文件和身份证照片之后,第一件事就是与首席执行官会面。你是他们很久以来雇佣的第一个内部软件工程师,期望很高。

首席执行官开始交谈,并指给你一个椅子。他告诉你他的名字是 Aljen van der Meulen,并且他最近才加入飞行荷兰人航空公司,但他看到航空公司在技术部门有很多改进的潜力。飞行荷兰人航空公司的网站运行良好,但人们无法通过搜索聚合器预订航班。(聚合器是一种收集或汇总来自特定来源信息的搜索引擎。在这种情况下,FlyTomorrow 从航空公司汇总了可预订的航班信息。)

因此,Aljen 与航班聚合器 FlyTomorrow.com 签署了一份合同。FlyTomorrow 是去年访问量最大的航空公司相关网站,并且对希望与其搜索引擎集成的航空公司有一些具体要求。航空公司内部系统的现有代码库确实有一个用于搜索和预订航班的 API,但它非常混乱,需要进行彻底的重构。Aljen 把一张纸滑过来,示意你看看。如图 3.3 所示,这是 FlyTomorrow 和飞行荷兰人航空公司之间合同的一部分,突出了满足合同所需的技术要求。

图片

图 3.3 飞行荷兰人航空公司与 FlyTomorrow 之间的合同中的一些与 API 相关的语言。该合同包含我们需要实现以满足要求的服务端点信息。

最重要的两个需求是当前 API 中存在 HTTP GETHTTP POST 端点。FlyTomorrow 使用这些端点来查询可用航班(GET)并预订它们(POST)。此外,API 必须在适当的情况下返回错误代码。

注意:如果你对 POSTGET 等 HTTP 操作不熟悉,或者对 HTTP 和网络开发总体上不太了解,你应该考虑进一步探索这些主题。一个很好的资源是 Mozilla 关于 HTTP 请求方法的文档:developer.mozilla.org/en-US/docs/Web/HTTP/Methods

3.2 拼图碎片:审视我们的需求

乔治,首席技术官,走进办公室。他没有浪费时间,直接开始讨论代码库。现有的代码库虽小但很混乱。所有内容都是用一种史前的 C# 版本(确切地说是 C# 3.0)编写的,使用的 .NET Framework 版本是 4.5。数据库在本地托管的 Microsoft SQL Server 上运行,并且没有使用对象关系映射(ORM)框架。乔治希望重构后的版本能在 .NET 5 上运行并使用最新的 C# 版本。虽然我们无法更改数据库模式,但我们确实可以访问它。实际上,乔治已经将其部署到了 Microsoft Azure。

3.2.1 对象关系映射

当你想对数据库进行更改时,你通常会使用数据库管理工具,如 SQL Server Management Studio (SSMS)、MySQL Workbench 或 Oracle SQL Developer。你可以使用这些工具编写 SQL 查询并在数据库上执行它们,以及设置存储过程和执行备份等操作。我们如何在运行时通过代码查询和与数据库交互呢?我们可以使用对象关系映射工具。ORM 是一种将数据从数据库映射到代码库中的表示以及相反的技术。在实践中,假设你有一个名为 BookShop 的数据库。这个数据库可能包含 Books、Customers 和 Orders 等表。你如何在面向对象的代码库中建模这些表?很可能是通过使用名为BooksCustomersOrders的模型。

定义:一个 实体 指的是数据库中对现实世界的一个定义,而一个 模型 是这种模型(或任何其他现实世界对象)的类表示。只需记住,实体是数据库,模型是代码。

同样可以合理假设,开发者已经将字段在数据库和代码中同步为相同。但这并不意味着它们的类型相同。以书籍为例:当查询特定书籍时,数据库以某种流的形式返回一个 Book 记录,通常以 JSON 或二进制形式。代码中的模型是Book类型,但我们自己定义了这个类。数据库不知道这个类的存在。数据库表和代码库模型表示不是本质上兼容的,但它们确实相互映射。这是一种同构关系,我们将在 3.3.3 节中进一步探讨。

乔治和阿尔金认为与 FlyTomorrow 的合同是一个重写现有代码库的机会,因为该公司打算扩大其用户群并提高其可扩展性。FlyTomorrow 甚至为航空公司提供了一个 OpenAPI 规范来检查端点(端点是不同服务调用我们的服务到我们的代码库的入口点)。FlyTomorrow 需要以下三个端点:

  • GET /flight

  • GET /flight/{flightNumber}

  • POST /booking/{flightNumber}

定义:OpenAPI(以前称为 Swagger)是指定 API 的行业标准方式。在 OpenAPI 规范中,你通常会找到 API 拥有的端点以及如何与 API 交互的指导。

3.2.2 GET /flight 端点:检索所有航班的详细信息

在本节中,我们将探讨第一个端点:GET /flight/flight端点接受一个GET请求并返回数据库中所有航班的详细信息。图 3.4 讲述了我们的端点需求故事。

图片

图 3.4 生成的 OpenAPI GET /flight端点规范截图。它接受一个GET响应,可以返回 HTTP 状态 200(附带所有可用航班的详细信息)、404 或 500。

根据 FlyTomorrow 的 OpenAPI 规范,GET /flight 端点应返回所有可用航班的列表。航班数据应包含航班号和两份机场元数据。机场模型包含机场服务的城市和国际航空运输协会(IATA)机场代码。当没有找到航班(意味着数据库中没有航班)时,我们应该返回 HTTP 代码 404(未找到)。如果发生错误,无论是什么错误,返回值应该是一个 HTTP 代码 500(内部服务器错误)响应。

3.2.3 GET /flight/{flightNumber} 端点:获取特定航班信息

FlyTomorrow 需要的第二个端点是 GET /flight/{flightNumber}。在图 3.5 中,OpenAPI 规范显示了端点的预期输入和输出。

图 3.5 为生成用于 GET /flight/{flightNumber} 端点的 OpenAPI 规范的屏幕截图。当提供航班号时,此端点返回数据库中特定航班的详细信息。

此端点有一个路径参数 {flightNumber},指定返回给调用者的哪个航班的详细信息。

如果在路径中提供了无效的航班号,API 应返回 HTTP 代码 400(无效请求)。无效的航班号可能是一个负数或只包含字母的字符串。如果请求的航班号在数据库中没有对应航班,则返回 HTTP 代码 404(未找到)。

3.2.4 POST /booking/{flightNumber} 端点:预订航班

最后必需的端点是路径为 /booking/{flightNumber}POST 端点,如图 3.6 所示。

图 3.6 /booking/{flightNumber}POST 端点的 OpenAPI 规范。该端点要求我们传递一个名和姓,以及使用我们想要预订的航班号作为路径参数。在成功的情况下,它返回 HTTP 状态码 201 以及预订信息。在失败的情况下,它返回 HTTP 状态码 404 或 500。

POST 端点有一个 URL 路径参数 {flightNumber},并需要一个包含两个字段(firstNamelastName)的请求体,这两个字段都是字符串。端点在成功预订时返回 HTTP 状态码 201(已创建),或者在由于逻辑或数据库错误而无法预订时返回 HTTP 状态码 500。见图 3.7。

图 3.7 POST /booking/{flightNumber} 端点的请求-返回生命周期。在成功的情况下,端点返回 HTTP 状态 201 以及预订 ID、航班信息和客户信息。如果服务找不到适当的航班,则返回 HTTP 状态 404。当发生内部错误时,返回 HTTP 状态 500。

注意:完整的 OpenAPI 文件可以在附录 D 中查看(以 YAML 格式)。

FlyTomorrow 用于搜索和预订航班的流程,如图 3.8 所示,如下所示:

  1. FlyTomorrow 查询我们的GET /flight端点以列出所有到消费者的航班。

  2. 当消费者选择航班以获取详细信息时,FlyTomorrow 使用航班号查询我们的GET /flight/{flightNumber}端点。

  3. 当客户准备好预订航班时,FlyTomorrow 向POST /booking/{flightNumber}发送POST请求以预订航班。

图 3.8 搜索 -> 点击 -> 预订工作流程和 API 调用。这是我们客户使用的工作流程,也是我们围绕代码库建模的工作流程。

3.3 接受现有的代码库

在本章的剩余部分,我们将逐步介绍我们继承的代码部分:模型、视图和配置代码。我们将讨论改进点、清洁代码和安全问题。我们还将涉及数据库模式以及继承的代码的模型与模式如何比较。

警告:本章的其余部分(以及下一章)涉及现有的代码库。这意味着我们将看到混乱和不正确的代码、偏离给定要求的情况,以及所有不好的事情。我们将在后面的章节中修复它们。

本章作为我们构建改进服务的基础。阅读本章和下一章后,您将深入了解我们试图改进的代码库,并渴望在第三部分开始重构。

3.3.1 评估现有的数据库模式和表

现在我们有了 OpenAPI 文件并知道对我们有什么期望,是时候查看现有的代码库和数据库了。我们继承的代码有很多痛点可以改进。根据乔治和 Aljen 的说法,我们可以改变我们想要的任何东西,但不能改变数据库。因为它是我们唯一坚如磐石的基础块,让我们先从查看数据库模式开始。数据库部署到 Microsoft Azure,是一个普通的、常规的 SQL 数据库,只有几张表,如下所示:

  • 机场

  • 预订

  • 客户

  • 航班

在本节中,我们将查看数据库模式,如图 3.9 所示,并分析该模式为我们提供的核心约束。

图 3.9 飞行荷兰人航空公司数据库的数据库模式和外键约束,该数据库托管在 Microsoft Azure 上。此模式显示了本书中使用的所有表。

正如乔治告诉我们,现有的代码库中没有使用 ORM,但即使没有,您也会期望看到一些根据这些表建模的对象。

注意:如果您对数据库和/或 SQL 不太熟悉,您可能需要了解基础知识。两个很好的资源是康奈尔大学的关系数据库虚拟工作坊,网址为cvw.cac.cornell.edu/databases/,以及 Mana Takahashi、Shoko Azuma 和 Trend-Pro Co., Ltd.所著的数据库漫画指南(No Starch Press,2009 年)。

3.3.2 现有代码库:Web 服务配置文件

通过查看解决方案的结构,我们可以对项目的布局有所了解。在本节中,我们正在查看处理服务配置的源文件,如图 3.10 所示。

图 3.10

图 3.10 展示了在 Visual Studio 2019 的解决方案资源管理器中显示的 FlyingDutchmanAirlinesExisting.sln 的文件夹结构。这是我们将在本章剩余部分探索的结构。该解决方案包含一个具有配置、控制器、对象和视图文件的单一项目。

C#使用解决方案和项目之间的层次关系来组织其代码库。一个解决方案可以有多个项目,但一个项目通常只属于一个解决方案。这非常像一个父(解决方案)-子(项目)模式。然而,请注意,项目不能包含子项目。图 3.10 清楚地展示了代码库的布局。我们看到名为 App_Start、Controller 和 Objects 等文件夹。这些显示了使用模型-视图-控制器模式,尽管术语略有不同。

模型-视图-控制器设计模式

在软件开发中最常用的设计模式之一,模型-视图-控制器(MVC)模式将任何用户和外部接口与业务逻辑和存储数据隔离开来。MVC 模式在过去十年中越来越受欢迎,因为它对桌面和 Web 开发都非常有用。

在使用 MVC 时,模型层做大部分工作。我们将所有数据存储在模型中,并在模型本身执行大部分必要的业务逻辑。为了与这些数据交互,我们通常需要某种用户交互。这就是控制器和视图层发挥作用的地方。控制器充当漏斗,将用户请求路由到模型层。视图层由表示模型层中特定“视图”的对象组成。控制器层将这些视图返回给用户。

在本书中,你将逐步深入理解如何使用类似 MVC 的模式和模型。阅读完本书后,你将对模型、视图和控制器有深刻的了解。

另一个关于 MVC 模式(以及其他设计模式)的好资源是 Eric Freeman、Elisabeth Robson、Bert Bates 和 Kathy Sierra 的《Head First: Design Patterns》(O’Reilly,2004 年)。

从顶部看,我们看到解决方案的名称是FlyingDutchmanAirlinesExisting,并且它包含一个同名项目。这带我们来到了第一个源代码文件:AssemblyInfo.cs。此文件位于项目的根目录中,但 Visual Studio 将其可视化在单独的属性类别中。

AssemblyInfo 文件不是你在日常生活中经常冒险进入的文件。它包含有关你的程序集的元数据,例如程序集标题和版本。

与 AssemblyInfo.cs 相比,下一个文件更有趣。因为我们正在处理一个 Web 服务,我们需要对我们的端点进行某种路由。这由 App_Start 文件夹中的 RouteConfig 类提供。

打开 RouteConfig 文件

如下一个列表所示,打开 RouteConfig,我们看到该类只有一个方法:RegisterRoutes

列表 3.1 RouteConfig.cs

public class RouteConfig {
  public static void RegisterRoutes(RouteCollection routes) {       
    routes.MapRoute(
      "Default",
      "{controller}/{action}/{id}"
    };
  }
}

列表 3.1 显示了 RouteConfig 类,省略了诸如命名空间声明和包导入等常规事项。

备注:本书中的大多数源代码列表都没有包含所需的导入,因为它们会在每个列表中占用很多空间。

RegisterRoutes 方法允许我们指定一个模式,将传入的 HTTP 路由映射到我们代码中的端点。RegisterRoutes 返回无值(void),并接受一个 RouteCollection 实例,这是 ASP.NET 框架中的一个类。

定义:ASP.NET 是一个深度集成到 .NET Framework、.NET Core 和 .NET 5 的 Web 框架。ASP.NET 为 C# 添加了 Web 开发功能,例如 WebAPI 支持 和 URL 路由。由于其深度集成到 .NET 生态系统,人们有时并没有意识到他们正在调用 ASP.NET 的库。在这本书中,我们将使用 ASP.NET,但不会明确指出我们正在这样做。有关 ASP.NET 的更多信息,请参阅安德鲁·洛克编写的优秀的 ASP.NET Core in Action(第 2 版;Manning,2020 年)。

点赞

你应该避免使用静态吗?

能够在不创建类实例的情况下访问你的方法或字段可能非常有用。当然,在任何你想要和需要的时候访问你的代码都很容易(假设你试图访问的内容也是公共的)。但在你将所有内容标记为静态之前,我强烈建议你重新考虑。

是的,你可以访问一个静态方法或字段,而不需要创建一个对象实例,但其他人也可以这样做。你不太可能是唯一一个在给定代码库中工作的开发者,因此你无法预测他们的需求和假设。

例如,考虑以下在一个虚构的视频游戏中找到的代码:

public record Player {
  public static int Health;
}

我们有一个名为 Player 的记录类型,以及一个表示玩家 health 的公共静态字符串字段。当玩家的健康受到损害时,游戏循环逻辑调用如下:

Player.Health--;

这将玩家的健康减少 1。对于单人冒险来说一切都很顺利,但如果我们想有多个玩家,可能为了本地合作或分屏功能呢?我们只需实例化另一个 Player!但现在我们有两个 Player 实例,它们都在使用相同的静态 Health 字段。当我们现在为一名玩家减少健康时,所有玩家的健康都会减少。这可能会是一个很酷的游戏玩法转折,但总的来说,我们想要避免通过 static 在实例之间改变状态。

|

RouteConfig.RegisterRoutes 方法中执行的唯一操作是调用 MapRoute 方法,这是传递给 RegisterRoutes 方法的 RouteCollection 实例的一部分。MapRoute 接收两个参数:我们想要命名的路由模式(Default)以及实际的路由模式({controller}/{action}/{id})。当你向路由模式添加一个 ID,就像我们这样做,它就变成了一个 URL 路径变量参数。URL 路径变量的一个常见用例是使用 HTTP GET 调用来指定通过资源 ID 获取特定资源的操作,如下一列表所示。

列表 3.2 设置 HTTP 路由

routes.MapRoute(                  ❶
  "Default",                      ❷
  "{controller}/{action}/{id}"    ❸
};

❶ MapRoute 在代码库中扫描匹配的路由。

❷ 我们将路由的名称指定为 Default

❸ 我们在这里确定要扫描的路由

当我们在第四章中查看 FlightController 时,我们将看到更多关于这种路由模式及其工作方式的示例。

查看 WebApiConfig 文件

我们接下来看到的文件是 WebApiConfig.cs。当我们打开文件时,我们看到一些特别的东西:在 WebApiConfig 中还有一个类,如下一代码示例所示。

列表 3.3 WebApiConfig.cs 及其嵌套类,Defaults

public class WebApiConfig {
  public class Defaults {                                             ❶
    public readonly RouteParameter Id;                                ❶

    public Defaults(RouteParameter id) {                              ❶
      Id = id;                                                        ❶
    }                                                                 ❶
  }                                                                   ❶

  public static void Register(HttpConfiguration config) {             ❷
    config.MapHttpAttributeRoutes();                                  ❸

    config.Routes.MapHttpRoute(                                       ❹
      "DefaultApi",                                                   ❹
      "api/{controller}/{id}",                                        ❹
      new Defaults(RouteParameter.Optional)                           ❹
    );

    GlobalConfiguration.Configuration.Formatters.JsonFormatter.Add(   ❺
      new System.Http.Formatting.RequestHeaderMapping(                ❺
        "Accept",                                                     ❺
        "text/html",                                                  ❺
        StringComparison.InvariantCultureIgnoreCase,                  ❺
        true,                                                         ❺
        "application/json")                                           ❺
      );                                                              ❺
    }                                                                 ❺
  }                                                                   ❷
}

❶ Defaults 是一个嵌套类。

❷ 注册方法属于 WebApiConfig 类,而不是 Defaults。

❸ 调用 MapHttpAttributeRoutes 启用属性路由

❹ 我们按照 API/Controller/ID 模式映射路由。

❺ 允许返回 JSON 响应

在 C# 中,你可以拥有嵌套类,并且可以像使用常规类一样访问它们(取决于它们的访问修饰符)。嵌套类在创建一个专门的类文件可能比你想的给整个项目结构带来更多混淆时非常有用。有时,在只使用嵌套类的唯一类中的“一次性”类可能比创建一个新文件更干净。我们将在 5.2.3 节中讨论如何改进这段代码。定义:列表 3.3 中的代码有一个我们还没有见过的关键字:readonly。在 C# 中,当某物被指定为只读后,它的值在赋值后是不可变的。

要从嵌套类的外部访问公共嵌套类,请使用封装类来访问它。例如,我们可以通过使用 WebApiConfig.Defaults 来访问嵌套的 defaults 类。你可以使用任何访问修饰符的嵌套类,但你仍然受外部类访问修饰符的支配。如果外部类的访问修饰符为 internal 且嵌套类为 public,你仍然需要在访问嵌套类之前满足外部类的访问要求。

注意:对于来自 Java 的开发者:C# 中的嵌套类没有对外部类的隐式引用。这是 Java 中静态嵌套类的实践与 C# 中嵌套类的实践之间的一个区别。C# 还允许在一个文件中存在多个非嵌套类,而 Java 则不允许。这不是一个好的实践,但它是允许的。

Register方法需要一个类型为HttpConfiguration的参数。它使用HttpConfiguration实例执行以下两个操作:

  • 运行时扫描并映射所有带有路由方法属性的端点。

  • 运行时允许具有可选 URL 参数的路由。

方法属性(在 Java 中称为方法注解)可以标记任何方法,并用于给它一些元数据。以下是一些我们会使用属性的情况的例子:

  • 标记哪些字段应该被序列化

  • 标记哪些方法是过时的

  • 标记方法是否分配了特定的 HTTP 路由

对于现有代码,我们在FlightController类中看到了带有路由的方法属性。一个方法属性由两个括号包围,其中包含相应的属性(例如,[MyMethodAttribute])。

config.Routes.MapHttpRouteRouteConfig中的routes.MapRoute方法类似(列表 3.1)。RouteConfig中的代码配置了具有 URL 路径参数的端点路由,但现在我们还需要配置允许没有这些参数的路由。再次,我们传递一个名称(DefaultApi)和一个模板(api/{controller}/{id}),但这次我们还传递了一个新的Defaults对象,其Id设置为RouteParameter.Optional。这使得我们可以路由带有和不带有参数的端点(因为它现在是可选的)。

最后,我们在调用GlobalConfiguration.Configuration.Formatters.JsonFormatter.MediaTypeMappings时将接受的MediaTypeMappings设置为 application/json。

绘制 ASP.NET 和配置文件:global.asax、packages.config 和 web.config

让我们跳过名为 Controller、Objects 和 ReturnViews 的文件夹,在下一列表中查看解决方案底部的三个源文件:Global.asax、packages.config 和 Web.config。

列表 3.4 Global.asax

namespace FlyingDutchmanAirlinesExisting {
  public class WebApiApplication : System.Web.HttpApplication {
    protected void Application_Start() {
      GlobalConfiguration.Configure(WebApiConfig.Register);
      RouteConfig.RegisterRoutes(RouteTable.Routes);
    }
  }
}

这个奇怪的文件扩展名.asax 是什么?我们之前没有见过这个扩展名。一个.asax 文件表示一个全局应用程序类。这些类用于 ASP.NET 应用程序中执行对服务启动或结束等低级系统事件的响应代码。全局应用程序类中的逻辑是我们可以操纵的第一段代码。我们可以在应用程序开始时通过创建一个Application_Start方法来执行代码,如列表 3.4 所示。要在应用程序结束时执行代码,将其放在Application_End方法中。

Application_Start方法具有protected访问修饰符,不返回任何内容。GlobalConfiguration.Configure调用注册了一个回调,其中WebApiConfig注册其路由。

DEFinition 一个 回调 是一个计划在当前函数执行之后执行的函数。你可以将其视为一个排队系统,其中回调被排队(或者从队列中插入,取决于你的视角)以便在当前方法处理完毕后立即执行。调用者调用被调用者,并传递一个回调,被调用者在工作完成后调用这个回调。

在注册回调以注册路由之后,RegisterRoutesRouteConfig 上被调用,并将 RouteTable 的路由传递进去。这导致在 RouteTable 中定义的路由区域(例如,斜杠之间的内容,如“/flight/”表示flight是一个区域)被注册并可用。我们需要在启动时注册回调并调用 RouteConfig,因为我们否则无法执行它们。因为路由没有被注册,我们无法通过触发端点来启动任何执行。

剩下两个文件可以归入配置类别:packages.config 和 Web.config。packages.config 是一个与 NuGet 包管理器相关的文件。NuGet 包管理器是 .NET 的默认包管理器,并与 Visual Studio 深度集成。packages.config 文件指定了在解决方案和项目中引用和安装的哪些包(以及这些包的版本)。还指定了一个目标 ASP.NET Framework。例如,这是一个 ASP.NET 应用程序,而 ASP.NET 是一个与 .NET(但具有深度集成和主要自动安装支持)分开的框架,它在 packages.config 文件中如下引用:

<package id="Microsoft.AspNet.WebApi" version="5.2.7"
 ➥ targetFramework="net45" />

Web.Config 为我们提供了配置应用程序如何运行、使用哪个版本的 .NET Framework(记住,此代码库运行在 .NET Framework 上)以及编译器设置的设置。例如,我们正在运行的编译模式是调试(默认模式)。这定义在以下行:

  <compilation debug="true" targetFramework="4.5"/>

这就带我们来到了配置文件的结尾。现在我们将跳过 FlightController 类,看看我们提供的模型和视图:Booking.cs,Customer.cs,Flight.cs,和 FlightReturnView.cs。

3.3.3 考虑现有代码库中的模型和视图

在 MVC 模式下,模型应该反映数据库表的结构,而视图则由客户端驱动。视图充当数据的表示,由客户端决定。我们将在第 10.1.1 节进一步讨论这意味着什么。在本节中,我们将查看继承的代码库中包含的模型和视图。项目有以下三个模型:

  • 预订

  • 客户

  • Flight

代码还有一个视图:FlightReturnView。在一个理想的世界里,模型与数据库中的内容非常相似,但看起来现有的代码还没有完全达到这一点。

揭示预订模型及其内部细节

模型代表了一个网络服务的重要基石。它们持有我们可以旋转和扭曲成不同视角并通过视图展示不同角度的数据。我们将首先查看的模型是Booking模型。这个模型背后的想法是提供一个对象来保存有关航班预订的数据,如以下代码示例所示:

namespace FlyingDutchmanAirlinesExisting.Objects {
  public class Booking {
    public string OriginAirportIATA;
    public string DestinationAirportIATA;
    public string Name;
  }
}

我们可以看到,Booking模型相当简单,包含三个字段(OriginAirportIATADestinationAirportIATAName),所有字段都具有公共访问修饰符。这将是一个引入一些封装的机会,通过添加后置字段、获取器和设置器。

为什么获取器和设置器很重要(自动属性和仅初始化设置器)

封装:你以前多次听说过这个术语,但正确实施它很棘手。封装的主要动机是提供对代码的受控访问。你可以微调其他人如何与你互动,并通过访问修饰符提供访问指南。获取器和设置器的反对者说,它们会使代码膨胀,并且为每个属性编写获取器和设置器很耗时。支持者会通过指出控制属性的访问不是代码膨胀,并且从长远来看它会增加速度来反驳。

想象一下,你有一个像我们现在考虑的这样的代码库。也许你在 50 个地方直接访问Booking.Name字符串。如果你需要将原始属性的名字更改为Booking.NewName,会发生什么?你将不得不在 50 个不同的位置更改调用,这会让你感到非常痛苦。一些 IDE 确实具有自动化此过程的功能,但这样你就是在依赖 IDE 来修复你的代码问题。我更喜欢代码干净,这样我们就不需要使用工具来自动修复问题。

现在想象一下,你编写了一个(有些人称之为“Java 风格”)的获取器(Booking.GetName)和一个设置器(Booking.SetName(string name)),并使用它们来访问和更改你的属性?你只需要在一个地方更改内容:原始类。获取器和设置器还有一个关键用途:它们控制对属性的访问并规定谁可以做什么。获取器和设置器的另一个用例是使你的属性readonly,但仅限于外部类。如果你要将readonly修饰符应用于字段,它将对所有人有效。相反,你可以通过策略性地使用获取器和设置器来达到同样的效果。如果你将设置器设为私有但获取器为公共,封装类之外的外部代码可以访问但不能编辑该属性。你还可以在设置器和获取器中添加逻辑。例如,在设置属性为新值之前,你需要对传入的参数进行一些验证吗?在设置器中添加逻辑。

你可以在 C#中使用获取器和设置器的一些方法包括以下内容:

  • 传统的双重方法技术,其中你创建两个新方法(一个专门的获取器和专门的设置器方法)并使用这些方法

  • 自动属性

  • 只初始化设置器¹

使用自动属性,你可以内联获取器和设置器,让编译器在幕后创建方法。这是你可以利用.NET 提供的抽象并为你带来好处的一个地方。

让我们通过将它们应用于一个name字段来比较和对比这两种方法。首先介绍的是传统的双重方法选项,如下所示:

private string _name;
public string GetName() {
  return _name;
} 

protected void SetName(string value) {
  _name = value;
} 

包含该值的字段是私有的(在 C#中,私有字段通常以下划线开头)并命名为_name。这个字段有时被称为“后置字段”,因为该字段“支持”获取器和设置器。创建了两个方法来规范设置和获取_nameGetNameSetName。在这个例子中,每个人都可以获取名称,但只有这个类及其继承自这个类的类可以设置名称字段(受保护)。为了更好地规范访问(并提高可读性),我们可以使用如下所示的自动属性:

public string Name { get; protected set; }

自动属性只有一行,但它提供了与双重方法技术相同的功能。如果没有提供访问修饰符,获取器或设置器的默认值是属性的方法访问器,如本例中的get所示。你还可以通过添加花括号和主体来为获取器和设置器提供方法体。

从 C# 9 开始,引入了一种使用设置器的新方法:只初始化设置器。这种技术允许你通过使用init关键字创建不可变属性(通常封装在对象中)。我们就像创建一个自动属性一样开始,但不是指定获取器,而是使用init。假设我们一直在使用的Name属性是Person类的一部分,并使用只初始化设置器如下所示:
class Person {
  public string Name { get; init; }
}

我们可以很容易地创建Person的实例,但由于我们为Name使用了只初始化设置器,所以我们不能在实例化后返回并设置Name的值,如下所示:

Person p = new Person();
p.Name = "Sally";

这使我们陷入了一点困境。如果我们尝试将值分配给Name,我们会收到编译器错误,告诉我们除非我们处于对象初始化器、构造函数或初始化访问器中,否则不能将值分配给只初始化的属性。我们将在第 6.2.5 节中更深入地探讨对象初始化器,但为了给你一点预览,我们可以使用以下方式使用对象初始化器将初始值分配给Person.Name

Person p = new Person() {
  Name = "Sally"
};

这在对象创建时将Name的值设置为"Sally",而不是在对象创建后尝试设置值。这个限制迫使你以非常具体的方式将值分配给只初始化的设置器,并阻止人们在值设置后覆盖这些值。

比较预订模型和表格

如果我们将Booking模型与数据库中的 Booking 表进行比较,我们会发现一些差异,包括一些可能会让安全工程师感到不满的差异。Booking模型中的所有字段都与数据库中的 Booking 表不匹配。甚至似乎还有一些错误添加,如图 3.11 所示。

图 3.11 Booking类与 dbo.Booking 之间的同构关系。每个字段都是错误的;这并不好。X 表示同构关系不正确的字段。

关于Booking类,有一件积极的事情可以说:它有一个正确的名字。但仅此而已。正如我们所见,该模型不包含FlightNumberBookingIDCustomerID的表示。回顾图 3.9,我们看到这些字段与关键约束有关(BookingID是主键。FlightNumberCustomerID有外键关系)。模型包含出发机场 IATA 代码、目的地机场 IATA 代码和客户名称的字段。

客户模型及其内部细节

我们有些犹豫地查看下一个模型,即接下来的Customer

namespace FlyingDutchmanAirlinesExisting.Objects {
  public class Customer {
    public int CustomerId;
    public string Name;

    public Customer(int customerID, string name) {
      CustomerID = customerID;
      Name = name;
    }
  }
}

Customer类中,损害得到了相当的控制。Customer与数据库表之间也有良好的同构性,如图 3.12 所示。

图 3.12 (缩略) Customer类与 dbo.Customer 之间的同构关系。CustomerID 和 Name 映射正确。

根据我们的了解,为了使Customer与数据库表保持一致,不需要进行任何更改。

登上Flight类及其内部细节

转到Flight类,在下面的代码示例中我们可以看到Flight有三个字段,它们都是整数类型,几乎完全映射到数据库表 Flight:

namespace FlyingDutchmanAirlinesExisting.Objects {
  public class Flight {
    public int FlightNumber;
    public int OriginID;
    public int DestinationID;

    public Flight(int flightNumber, int originID, int destinationID) {
      FlightNumber = flightNumber;
      OriginID = originID;
      DestinationID = destinationID;
    }
  }
}

Flight类的字段与数据库表进行比较,我们看到本质上模型是正确的。但我们从事的是编写干净的代码,这也意味着在代码库和数据库中,关于字段名,我们需要保持一致性。还值得一提的是,代码库中拥有数据库模型(无论是通过 ORM 还是手动)的整个基础都依赖于对数据库行与类之间同构关系的最接近解释的实践。² 需要记住的是,尽管字段名可能与数据库中的列名相同,但它仍然是一个抽象。它们并不相同;然而,对我们来说,它们尽可能地接近。

将同构关系应用于数据库和代码库之间的通信,强烈建议始终使用 ORM,因为您将获得代码表示和数据库表示之间最接近的匹配。

如图 3.13 中的“?!”图标所示,我们发现了两个字段名的不匹配:

  • OriginID与 Origin 的比较

  • DestinationID与 Destination 的比较

图 3.13 (简化的)Flight类与 dbo.Flight 之间的同构关系。Origin/OriginID和 Destination/DestinationID非常接近匹配。FlightNumber 是一个完美的匹配。

在这种情况下,数据库规则。当我们开始对 API 重构版本的工作时,Entity Framework Core 确保这些差异不会发生。

FlightBookingCustomer构成了Models文件夹的内容。但是等等,让我们再次看看我们的数据库模式。看起来可能有些东西缺失 ...

如我们从图 3.14 中可以推断出的,我们没有遇到任何可以称为模拟机场表的类。那么,代码是否工作正常呢?

图 3.14 与代码库模型相比的数据库模式。代码库中存在代表 Flight、Booking 和 Customer 的模型;机场不存在。这意味着代码库与数据库之间存在不完整的同构关系。

如果我们有测试,我们可能能够确定一种方式或另一种方式,或者如果覆盖率零散且不在正确的位置,我们至少可以尝试一种基于现有测试的归纳证明³,以证明方法的正确功能。可能非常真实的是,没有Airport类,我们仍然可以执行我们想要的全部功能,并为客户提供服务。这也意味着,这个代码的开发者可能通过不坚持源数据格式而使自己的生活变得更难。

FlightReturnView 视图及其内部细节

在彻底探索了模型以及它们如何与数据库匹配之后,我们将目光转向ReturnViews文件夹。一个View允许我们以任何我们想要的方式表示模型(或多个模型)中封装的数据。如以下所示,ReturnViews文件夹中只有一个视图——FlightReturnView

namespace FlyingDutchmanAirlinesExisting.ReturnView {
  public class FlightReturnView {
    public int FlightNumber;
    public string Origin;
    public string Destination;
  }
}

FlightReturnView 是一个非常简单的类,只有三个字段:FlightNumber(整数)、Origin(字符串)和Destination(字符串)。视图是对象(或多个对象)的一个切片,被塑造成仅反映细节的一个子集或多个模型细节的组合(一个非规范化视图)。在这里,开发者希望将FlightNumberOriginDestination字段返回给用户。返回Flight类是不够的,因为它不包含OriginDestination。同样,返回Booking类也不够,因为它只包含FlightNumber,而不包含OriginDestination。使用视图返回数据是一种强大的设计模式,常用于 API 开发。它可以被理解为具有类似于 SQL JOIN 操作的能力,因为你可以以任何方式连接多个数据集。话虽如此,如果我们能避免使用它,那就更好了,因为它增加了代码库的复杂性。

摘要

  • 一个对象关系映射工具允许我们在比直接使用 SQL 查询数据库更高的抽象级别上处理数据库。

  • 一个 C# 仓库通常包含一个解决方案,该解决方案又包含多个项目。遵循这种模式可以使你的代码库对经验丰富的 C# 开发者更容易导航。

  • ASP.NET 框架是一个旨在开发 Web 服务的框架,它是 .NET 生态系统的一部分。我们可以在 .NET 框架、.NET Core 和 .NET 5 中使用 ASP.NET 来创建 Web 服务。

  • 我们需要通过使用 RouteConfig 来定义和注册 HTTP 路由。如果我们不这样做,我们就无法到达我们的端点。

  • 我们可以为方法、字段和属性附加属性。一个属性的例子是 [FromBody]

  • 回调是一个要在当前函数执行后执行的函数。我们可以使用回调来排队某些方法,以便在我们想要它们执行时而不是立即执行。

  • NuGet 包管理器是 C# 的首选包管理器。我们可以用它来安装第三方包或不属于常规 SDK 的 .NET 包,例如 ASP.NET。


^ (1.) 仅初始化设置器是在 C# 9 中引入的。只有 .NET 5(及以后的版本)支持 C# 9。仅初始化设置器(和 C# 9)在 .NET 框架或 .NET Core 上不受支持。

^ (2.) 关于同构关系及其如何将真实陈述映射到解释定理(例如,数据库模式到模型)的更多信息,请参阅道格拉斯·R·霍夫施塔特(Douglas R. Hofstadter)的普利策奖获奖作品 哥德尔、艾舍尔、巴赫:永恒的金色纽带(Basic Books,1977)的第二章(“数学的意义与形式”)和理查德·J·特鲁多(Richard J. Trudeau)的 图论导论(第 2 版;Dover Publications,1994)。

^ (3.) 如果你想了解更多关于归纳证明的信息,我推荐两个资源。首先,观看麻省理工学院 计算机科学数学 6.042J 课程 的前三场视频讲座(引言与证明归纳强归纳)(MIT OpenCourseWare,2010),网址为 ocw.mit.edu/courses/electrical-engineering-and-computer-science/6-042j-mathematics-for-computer-science-fall-2010/index.htm。其次,我推荐唐纳德·E·克努特(Donald Knuth)的 计算机程序设计艺术,第一卷:基本算法(第 3 版;Addison Wesley Longman,1977)中的第 1.2.1 节(“数学预备知识/数学归纳法”)。

4 管理你的非托管资源!

本章涵盖

  • 在编译时和运行时发现对象的底层类型

  • 编写使用 IDisposableusing 语句来释放非托管资源的代码

  • 使用方法和构造函数重载

  • 使用属性

  • 在端点中接受 JSON 或 XML 输入并将其解析为自定义对象

在第三章,飞荷兰人航空公司的首席执行官 Aljen van der Meulen 分配给我们一个项目,即重新设计飞荷兰人航空公司的后端服务,以便该公司可以与第三方系统(一个名为 FlyTomorow 的航班聚合器)集成。我们得到了一个 OpenAPI 规范,并查看了数据库模式以及配置、模型和视图类。图 4.1 显示了我们在本书结构中的位置。

图 4.1 在本章中,我们将结束第二部分。我们将查看现有的代码库的控制器类,并讨论我们可以对代码进行的潜在改进。

警告 本章涉及用 .NET Framework 编写的现有代码库。这意味着我们将看到混乱和不正确的代码、偏离给定要求以及各种不良情况。我们将在后面的章节中修复这些问题,并迁移到 .NET 5。

我们对现有代码库的理解逐渐加深,我们几乎覆盖了它的全部内容。在本章中,我们将查看最后剩下的部分(代码库中的唯一控制器)并逐个深入探讨端点,如下所示:

  • GET /flight—此端点允许用户获取数据库中所有航班的详细信息。

  • GET /flight/{flightNumber}—此端点允许用户在给定航班号的情况下检索特定航班的详细信息。

  • POST /flight/{flightNumber}—此端点允许用户在给定航班号时预订航班。

  • DELETE /Flight/{flightNumber}—此端点允许用户在给定航班号时从数据库中删除航班。

我们还将讨论连接字符串、可枚举类型、垃圾回收、方法重载、静态构造函数、方法属性等等。阅读本章后,你应该清楚地了解我们可以进行哪些改进,我们可以做出哪些改进,以及为什么。

4.1 飞行控制器:评估 GET /flight 端点

现在我们来到了我们应当修复和润色的代码库的核心部分。正如我们在第三章中学到的,FlyTomorrow 计划使用此端点来显示所有可能的航班供用户预订。我们面前的问题是:原始代码库在多大程度上接近了这一意图?

上一章涵盖了数据库模式、配置和支持模型。这些都是非常重要的内容,但我们实际上想要使用所有这些模型、模式和配置来处理一些数据(或预订航班)。这就是控制器(在 MVC 模式下)发挥作用的地方,而这个代码库只有一个:FlightController.cs。这段代码比之前的代码文件要大,所以请确保仔细阅读代码。以这种方式审查代码让我们非常清楚地了解我们可以进行改进和错误修复的地方。

4.1.1 GET /flight 端点和它所执行的操作

在本节中,我们将通过第一个端点:GET flight(如列表 4.1 所示),来探索 FlightController 类。我们将看到如何利用方法属性来动态生成文档,如何确定对象的运行时和编译时类型,为什么你可能不想硬编码数据库连接字符串,以及如何从控制器返回 HTTP 状态码。希望在我们审查现有代码后,我们能感受到我们可以进行改进的地方以及为什么我们想要进行这些改进。

列表 4.1 FlightController.cs GET /flight

// GET: api/Flight                                                     ❶
[ResponseType(typeof(IEnumerable<FlightReturnView>))]                  ❷
public HttpResponseMessage Get() {
  var flightReturnViews = new List<FlightReturnViews>();
  var flights = new List<Flight>();

  var connectionString =                                               ❸
➥ "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial    ❸
➥ Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User     ❸
➥ ID=dev;Password=FlyingDutchmanAirlines1972!;                        ❸
➥ MultipleActiveResultSets=False;Encrypt=True;                        ❸
➥ TrustServerCertificate=False;Connection Timeout=30;";               ❸

  using (var connection = new SqlConnection(connectionString)) {       ❹
    connection.Open();                                                 ❺

  // Get Flights
    var cmd = new SqlCommand("SELECT * FROM flight", connection);      ❻

    using (var reader = cmd.ExecuteReader()) {
      while (reader.Read()) {                                          ❼
        flights.Add(new Flight(reader.GetInt32(0), 
➥ reader.GetInt32(1), reader.GetInt32(2)));
    }
  }

  cmd.Dispose();                                                       ❽

  foreach (var flight in flights) {
    // Get Destination Airport details                                 ❾
    cmd = new SqlCommand("SELECT City FROM Airport WHERE AirportID =   ❾
➥ " + flight.DestinationID, connection);                              ❾

    var returnView = new FlightReturnView();                           ❾
    returnView.FlightNumber = flight.FlightNumber;                     ❾

    using (var reader = cmd.ExecuteReader()) {                         ❾
      while (reader.Read()) {                                          ❾
        returnView.Destination = reader.GetString(0);                  ❾
        break;                                                         ❾
      }
    }

    cmd.Dispose();                                                     ❾

    // Get Origin Airport details                                      ❿
    cmd = new SqlCommand("SELECT City FROM Airport WHERE AirportID =   ❿
➥ " + flight.OriginID, connection);                                   ❿

    using (var reader = cmd.ExecuteReader()) {                         ❿
      while (reader.Read()) {                                          ❿
        returnView.Origin = reader.GetString(0);                       ❿
        break;                                                         ❿
      }
    }

    cmd.Dispose();                                                     ❿

    flightReturnViews.add(returnView);                                 ⓫
  }

  return Request.CreateResponse(HttpStatusCode.OK, 
➥ flightReturnViews);                                                 ⓬
}

❶ 一个试图描述代码的注释。我们应该移除这样的注释。

❷ 动态生成文档

❸ 这个连接字符串是硬编码的,这是一个安全问题。

❹ 使用语句用于处理可处置对象。

❺ 打开到数据库的连接

❻ 设置一个 GET SQL 查询

⓬ 读取数据库返回结果

❽ 处理对象的另一种方法

❾ 对于每趟航班,获取目的地机场的详细信息

❿ 对于每趟航班,获取出发机场的详细信息

⓫ 将生成的视图添加到内部集合中

⓬ 返回 HTTP 200 状态码和航班信息

4.1.2 方法签名:ResponseType 和 typeof 的含义

欢迎来到深入学习的部分。列表 4.1 是一段相当多的代码,其中包含许多对我们来说可能很新的内容。控制器中的所有端点看起来都像这个一样。它们都使用相同的模式来获取和返回数据,所以在我们完全理解这里发生的事情之后,其他端点将会变得非常简单。

在本节中,我们将查看 /flightGet 方法的方法签名。我们将首先检查 ResponseType 属性,然后讨论 typeof 关键字及其作用。最后,我们将一瞥 ResponseType 属性如何使用 IEnumerable 接口和 typeof 操作符。方法签名看起来是什么样子?请看以下内容:

[ResponseType(typeof(IEnumerable<FlightReturnView>))]
public HttpResponseMessage Get()

ResponseType 属性用于动态生成文档,并且在我们处理 OpenAPI(或 Swagger)规范的情况下不常使用。如果你不使用某种自动 OpenAPI 生成工具,这个属性非常有用。ResponseType 属性不会影响我们从方法中返回的类型,但确实要求我们指定类型。该属性会将我们返回的数据包装成 HTTPResponseMessage 类型,并从方法中返回。为了确定一个实例的类型,我们可以使用 typeof 操作符,我们可以向其中传递要测试的参数。typeof 操作符返回一个 System.Type 类型的实例,它包含描述传递给 typeof 操作符的类型的数据。这是在编译时由编译器完成的。

灯泡 在编译时确定的只读和常量表达式和语句值可以赋给 readonlyconst 属性。在运行时动态确定的值不能赋给 const 属性,因为常量在编译后不能改变,而 readonly 属性一旦声明或构造函数中写入后就可以被写入一次。使用 readonlyconst 可以防止在运行时重新赋值。实际上,这允许你禁止对代码进行不希望的改变,从而强制执行一个值在运行时不应该改变的意图,并可能最小化由其他开发者做出的更改引起的不期望的副作用数量。

如果我们想在运行时获取一个实例的类型(通过反射,¹ 在 6.2.6 节中讨论),我们可以使用对象类型公开的 GetType 方法(因为 object 是所有类型的基类,如图 4.2 所示,它在所有类型上都是公开的)。如果我们省略了 typeof 操作符,由于 ResponseType 期望一个 System.Type 类型的实例,这将导致编译器错误。

图片 04_02

图 4.2 所有类型的共同基类是 Object。这些截图是使用 Visual Studio 的对象浏览器生成的,它允许你检查任何对象及其基类型。

注意:你经常会遇到直接或间接实现 IEnumerable 接口的数据结构。IEnumerable 接口允许你以各种方式创建枚举器来遍历集合(最著名的是 foreach 构造)。如果你想创建一个具有枚举器的自定义数据结构,只需实现 IEnumerable 接口即可。

Get 方法返回一个 HttpResponseMessage 类型的实例。该类型包含用于返回 HTTP 响应的数据,包括 HTTP 状态码和 JSON 主体。

4.1.3 使用集合收集航班信息

我们准备深入探讨 FlightControllerGET 方法。在本节中,我们将迈出第一步,将数据库中每架飞机的信息返回给用户。我们将讨论我们将使用的方法实例集合来实现这一目标,以及硬编码到源代码中的连接字符串以及为什么这并不理想。

查看以下代码的第一行,我们看到一些可以改变的地方:

var flightReturnViews = new List<FlightReturnView>();

代码声明了一个名为 flightReturnViews 的变量,并将其分配给一个空的 FlightReturnView 类型的 List 实例。

注意:我更喜欢使用显式类型而不是 var 关键字。对我来说,这使代码更易于阅读,因为我可以轻松地找到我正在使用的类型。在这本书中,我使用显式类型,但如果你愿意,当然可以使用 var 关键字。无论你使用显式类型、隐式类型还是两者的混合,代码通常都能正常运行。意见差异很大,关于是否使用 var 关键字的讨论不可避免地会变得激烈。选择哪种方式取决于你,以及你将在何种场景下使用它。

灯泡

var 关键字

使用 var 关键字是声明变量的快捷且简单的方式。编译器会推断类型,然后你可以继续前进。故事的另一面是,使用 var 关键字可能会导致不必要的歧义。例如,比较以下两个语句:

var result = ProcessGrades();
List<Grades> result = ProcessGrades();

如果你使用 var 关键字,你必须查看 ProcessGrades 方法以找出返回类型。这促进了不需要知道你调用的代码的任何实现细节的想法。另一方面,如果你在变量声明中显式地写下返回类型,你总是知道你正在操作的类型。了解类型可能允许我们根据如何实现特定的代码块做出不同的决策。

var 关键字可以帮助你更快地编写代码,并且根据你的背景,更加直观。有时,你不需要知道底层类型——你只想继续编写你的代码。

|

接下来的两行有相似的故事:

var flights = new List<Flight>();
var connectionString = 
➥ "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial 
➥ Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User 
➥ ID=dev;Password=FlyingDutchmanAirlines1972!;MultipleActiveResultSets=False;
➥ Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;";

花一分钟时间查看两个变量 flightsconnectionString,并思考改进代码的方法。

4.1.4 连接字符串,或如何让安全工程师心惊胆战

当考虑第 4.1.3 节中的硬编码连接字符串时,你脑海中想的是什么?你是否看到了任何问题?如果有,它们是什么?问题不在于连接字符串的实际内容。连接细节是正确的,我们想要有一个包含类型为 Flight 的对象的列表。问题在于我们在控制器中有一个硬编码的连接字符串。

固定的连接字符串通常是一个重大的安全和操作漏洞。想象一下,将此代码提交到源代码控制系统,并意外地使其对公众可见。这可能是一个不太可能的情况,但我见过一两次(也可能有一次是我自己造成的)。现在,当涉及到你的数据库时,你已暴露了自己面临各种糟糕的事情。如果这还不能说服你,让我试试这个:你将连接字符串硬编码,而不是从某个中央存储(无论是配置文件还是注入到容器化环境中的环境变量)中获取,而另一个开发者不小心多按了几次退格键,删除了连接字符串的一部分。当然,开发者没有运行任何测试,而且代码在你休假时被审查和合并。现在一切都坏了。这个故事的意义在于:不硬编码连接字符串只需要一点小小的努力(我们将在第 5.3.3 节中看到如何使用本地环境变量来设置连接字符串)。

注意:本章中列出的连接字符串实际上是用于我们数据库的正确连接字符串。数据库通过 Microsoft Azure 部署,并公开可访问。如果您无法连接(或不想连接)到数据库,本书源代码文件中提供了一个本地 SQL 版本的数据库。有关安装和启动部署数据库的本地版本的说明见附录 C。

与硬编码连接字符串相比,更好的做法是:

  • 将连接字符串存储在某种配置文件中,或者

  • 通过环境变量访问它们

当我们修复这个安全问题的时候,我们将探索这两种方法之间的权衡。

4.1.5 使用 IDisposable 释放非托管资源

下一个代码块是一个包含某些逻辑的语句,我们之前见过类似的东西。本节将处理下面的 using 语句和 IDisposable 接口。我们将了解它们如何与垃圾回收器相关联以及如何使用它们。

using (var connection = new SqlConnection(connectionString)) {
  ...
}

当我们以这种方式使用 using 语句时,我们将封装变量的作用域限制在 using 代码块内,并在我们完成 using 块后自动释放它。因此,在这个例子中,一旦我们到达 using 语句的结束括号,SqlConnection 类型的连接变量就被指定为准备进行垃圾回收。

但这为什么重要呢?C# 是一种托管语言,拥有垃圾回收器,它应该为我们处理这些事情。这意味着我们不需要像在 C 这样的非托管语言中那样进行手动内存分配和释放。然而,有时我们可能需要稍微帮助垃圾回收器,因为它可能会感到困惑。例如,如果某个东西可能需要超出当前代码块或变量作用域继续存在,垃圾回收器如何知道何时可以回收它呢?

.NET 垃圾回收器在运行时扫描代码,寻找不再有任何“链接”到它们的对象。这些链接可以是方法调用或变量赋值等。为了做到这一点,它使用所谓的代数。这些代数是运行中的“列表”,其中包含的对象要么准备好被收集,要么可能在将来准备好被收集。对象存活的时间越长,其代数(垃圾回收器总共使用三代)就越高。第三代中的对象比早期代数的对象被垃圾回收器访问的频率要低。假设我们有一个包含整型属性的对象,赋值为 3。这个属性在条件中充当计数器。如果这个变量在方法结束后还存活一段时间(其变量作用域比代码块长),等待垃圾回收器收集它,这并不是什么大问题。变量占用的内存量很小,并且它没有阻止其他执行。当一个对象如这样没有剩余的链接(通常是因为其变量作用域已过期)时,垃圾回收器将该对象标记为可收集,在其下一次迭代中释放适当的内存,并从其代数列表中删除相应的条目。

现在想象一下,我们有一个与 SQL 数据库的连接,就像上一页的代码所示。如果这个连接超出了其预期的使用范围,可能会成为一个问题。我们可能会遇到连接保持打开的情况,阻止其他代码在同一个数据库上执行,或者我们甚至可能暴露于缓冲区溢出攻击。为了对抗这种内存泄漏,我们需要处理“未管理”的资源。与在变量作用域结束后任何时候都会被垃圾回收的托管资源不同,我们需要更直接地处理未管理资源。然而,正确地处理未管理资源是一件容易忘记的事情。通常,我们希望在完成对未管理资源的操作时释放它,而不是当所有对对象的引用(或链接)消失,垃圾回收器说我们已经完成时。未管理资源通常实现IDisposable接口,因此要释放未管理资源,我们需要调用Dispose方法。

释放未管理的资源可以采取在方法末尾调用Dispose方法的形式。但如果你的代码中有分支结构,并且有多个返回点,你会需要多次调用Dispose。这可能适用于小方法,但当处理大块的条件代码和代码中的多个遍历路径时,可能会很快变得令人困惑。using语句是解决这个问题的方法。在底层,编译器将using语句转换为try-finally代码块。这在一个示例中如图 4.3 所示。

图片

图 4.3 编译器将 using 语句转换为 try-finally 块。使用 try-catch 允许我们抽象手动调用 Dispose

try-finally 是我们在处理错误处理时经常使用的 try-catch-finally 构造的一个子集。当我们用 try 代码块包裹代码,然后是 catch 代码块,如果抛出异常,它会在 catch 代码块中被捕获,而不是让我们的代码硬性崩溃。finally 是一个可选的代码块,附加在 catch 的末尾,在离开代码块时执行代码,无论是否捕获到错误。我们可以在 finally 代码块中调用 Dispose 方法,确保无论结果如何或是否抛出任何错误,Dispose 方法都会被调用。

注意:在实现 IDisposable 的资源上调用 Dispose 不会立即触发垃圾回收。我们只是在标记它为安全收集,并请求在下一个机会进行收集。不会启动即兴的垃圾回收,但我们把何时确定资源安全收集的管理权掌握在自己手中,而不是让垃圾回收器来决定。

4.1.6 使用 SqlCommand 查询数据库

SqlConnection 的构造函数接受一个类型为 string 的参数,代表我们用来连接的连接字符串。进入 using 块后,我们现在可以操作我们新创建的 SqlConnection 并查询数据库。在接下来的代码列表中,代码打开到数据库的连接。

列表 4.2 FlightController.cs GET Flight:在 SqlConnection using 语句内

connection.Open();                                                 ❶

// Get Flights
var cmd = new SqlCommand("SELECT * FROM Flight", connection);      ❷

using (var reader = cmd.ExecuteReader()) {
  while (reader.Read()) {
    flights.Add(new Flight(reader.GetInt32(0), reader.GetInt32(1), 
➥ reader.GetInt32(2)));                                           ❸
  }
}

cmd.Dispose();                                                     ❹

❶ 打开数据库连接

❷ 使用 SQL 查询创建一个 SqlCommand 来选择所有航班

❸ 创建新的航班实例

❹ 释放 cmd 实例

如果无法通过提供的连接字符串访问数据库,代码会抛出一个异常(未处理)。之后,创建一个 SqlCommand,查询从 Flight 表中选择所有记录("SELECT * FROM Flight")。细心的读者可能会注意到,向下几行,调用了 cmd.Dispose。如果我们没有使用 using 语句,我们还需要在 reader 上调用 Dispose。看起来我们的前辈在使用 using 语句或手动释放请求方面并不一致。我们将解决这个问题。列表 4.2 中的代码有一个 using 语句,它创建了一个 reader 对象,由 cmd.ExecuteReader() 方法生成。

reader 允许我们将数据库响应解析成更易于管理的形式。如果我们进入 using 语句,创建一个新的 Flight 对象,我们可以看到这一点,如图 4.4 所示。

图片

图 4.4 在 using 语句中创建的变量的作用域。reader 实例的作用域限于 using 语句,当代码离开 using 代码块时不可访问。

Flight对象接受三个参数,都是 32 位整数(int):flightNumberoriginIDdestinationID。这些也是我们航班表中的列(如果我们考虑到本章前面讨论的轻微命名错误)。我们知道列的返回顺序,因为我们知道数据库模式。指定查询应返回的列可能更干净。如果我们明确说明我们想要返回的列,我们可以更好地控制数据流并确切知道我们将得到什么。这不需要对代码或数据库模式不熟悉的开发者进行更多研究以找出预期的返回值。

列表 4.2 中的代码调用readerGetInt32方法并传入我们正在寻找的值的索引。一旦创建Flight对象,它就被添加到flights集合中。继续前进,花一分钟时间查看列表 4.3 中的代码。希望你会看到一些非常熟悉的东西。

列表 4.3 FlightController.cs GET Flight:获取Origin Airport详情

// Get Origin Airport details
cmd = new SqlCommand("SELECT City FROM Airport WHERE AirportID = " + 
➥ flight.OriginID, connection);                  ❶

using (var reader = cmd.ExecuteReader()) {        ❷
  while (reader.Read()) {                         ❸
    returnView.Origin = reader.GetString(0);      ❹
    break;
  }
}

cmd.Dispose();

flightReturnViews.Add(returnView);

❶ 创建一个 SQL 查询以选择特定机场的City

❷ 执行 SqlCommand

❸ 从数据库读取响应

❹ 将数据库响应的第一个元素赋值给 returnView.Origin

列表 4.3 中的代码创建了一个新的SqlCommand来从机场表中选择City列,其中AirportID等于flight.OriginID(上一次是flight.destination)。代码执行SqlCommand并将返回值读取到returnView.Origin字段中。然后代码释放SqlCommand并将returnView添加到flightReturnViews集合中。就这样,我们终于到达了这个端点的末尾。接下来只需考虑一行代码:

return Request.CreateResponse(HttpStatusCode.OK, flightReturnViews);

记得我们查看方法签名时吗?我们发现我们应该返回一个HttpResponseMessage,这正是Request.CreateResponse给我们的。

提示:如果你想知道更多关于.NET Framework、.NET Core 或.NET 5 的特定命名空间或类的信息,Microsoft 在线文档非常出色,可以在docs.microsoft.com/en-us/找到。例如,HttpRequest的.NET Framework 文档在docs.microsoft.com/en-us/dotnet/api/system.web.httprequest?view=netframework-4.8

CreateResponse方法有几个方法重载我们可以使用,但为此,我们想要传递一个 HTTP 状态码和一个要序列化并返回给调用者的对象。

方法重载和静态构造函数

方法重载,也称为函数重载,允许在同一个类中有多个具有相同名称(但参数不同)的方法。这意味着我们可以在同一个类中拥有public uint ProcessData(short a, byte b)public uint ProcessData(long a, string b)这样的方法,而不会有问题。当我们调用ProcessData方法时,我们的请求由 CLR 根据输入参数类型路由到适当的方法。我们不能做的是有两个(或更多)具有相同名称和输入参数的方法。这是因为方法调用变得模糊不清。CLR 应该如何知道我们的调用应该指向哪里?这也意味着如果我们有internal void GetZebra(bool isRealZebra)internal bool GetZebra(bool isRealZebra)这样的方法,我们将得到编译器错误。仅仅改变返回类型并不能使调用对 CLR 来说不那么模糊。

重载ProcessData。编译器在编译时将ProcessData调用路由到适当的重载方法。如果没有匹配的重载方法,将生成编译器错误。

我们也可以重载构造函数。我们称这种做法为构造函数重载,但它的原理与方法重载相同。我们可以使用重载构造函数来有多个对象实例化的路径。对于构造函数,也存在static构造函数。因为我们处理的是static,所以只能有一个静态构造函数,因此它不能被重载。在实例化类或调用类上的静态成员之前,总是先调用static构造函数。我们可以有static构造函数和常规构造函数,但运行时总是在使用第一个常规构造函数之前调用static(一次)。因此,static构造函数总是无参数的,static构造函数不包含访问修饰符(static构造函数总是公共的)。

静态和默认构造函数。静态构造函数在调用任何其他构造函数之前首先被调用。步骤 1:静态构造函数;步骤 2:默认(或显式声明)构造函数。

对于那些 Java 程序员,请注意,Java 的匿名静态初始化块在 C#中相当于静态构造函数。然而,C#只能有一个静态构造函数,而 Java 可以有多个匿名静态初始化块。

要传递一个状态码,我们不能简单地传递一个整数。CreateResponse方法要求我们传递对HttpStatusCode枚举字段的选取,在这种情况下,HttpStatusCode.OK(映射到状态码 200)。随着返回的执行,我们在这个方法中的工作就完成了。

总结来说:尽管GET Flight端点有其优点,但我们看到了许多重构和改进的机会。

4.2 飞行控制器:评估 GET /flight/

现在我们已经查看了端点以从数据库中获取所有航班信息,接下来让我们看看之前的开发者是如何实现从数据库中获取特定航班的逻辑。在本节中,我们将探讨GET flight/{flightNumber}端点,并考虑其优点和缺点。我们还将考虑是否可以移除多余的注释,并展示代码作为叙述的例子。

在列表 4.4 中,我们将揭开GET /flight/{flightNumber}端点的面纱,并看到熟悉的非最佳实践,例如硬编码的连接字符串。列表 4.4 中的大部分代码你应该都能轻松阅读。区别在于细节:我们将讨论注释的丰富性、HttpResponseMessage类以及将null赋值给隐式类型(由var关键字表示)。

列表 4.4 FlightController.cs GET flight/{flightNumber}

// GET: api/Flight/5
[ResponseType(typeof(FlightReturnView))]
public HttpResponseMessage Get(int id) {
  var flightReturnView = new FlightReturnView();
  Flight flight = null;

  var connectionString =     
➥ "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial 
➥ Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User 
➥ ID=dev;Password=FlyingDutchmanAirlines1972!;MultipleActiveResultSets=False;
➥ Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;";

  using(var connection = new SqlConnection(connectionString)) {
    connection.Open();

    // Get Flight
    var cmd = new SqlCommand("SELECT * FROM Flight WHERE FlightNumber = 
➥ " + id, connection);

    using (var reader = cmd.ExecuteReader()) {
      while (reader.Read()) {
        flight = new Flight(reader.GetInt32(0), reader.GetInt32(1),
➥ reader.GetInt32(2));
        flightReturnView.FlightNumber = flight.FlightNumber;
        break;
      }
    }

    cmd.Dispose();

    // Get Destination Airport Details
    cmd = new SqlCommand("SELECT City FROM Airport WHERE AirportID = " 
➥ + flight.DestinationID, connection);

    using (var reader = cmd.ExecuteReader()) {
      while (reader.Read()) {
        flightReturnView.Destination = reader.GetString(0);
        break;
      }
    }

    cmd.Dispose();

    // Get Origin Airport Details
    cmd = new SqlCommand("SELECT City FROM Airport WHERE AirportID = " 
➥ + flight.OriginID, connection);

    using (var reader = cmd.ExecuteReader()) {
      while (reader.Read()) {
        flightReturnView.Origin = reader.GetString(0);
        break;
      }
    }
    cmd.Dispose();
  }

  return Request.CreateResponse(HttpStatusCode.OK, flightReturnView);
}

如你所见,99%的端点逻辑都由上一个端点(列表 4.1)的模式和代码组成,但也有一些不同之处。第一个是我们可以在方法签名中找到的,如下所示:

public HttpResponseMessage Get(int id) 

Get flight/{flightNumber}端点接收一个类型为integer的参数,存储在一个名为id的变量中。这直接映射到 API 路径中的{flightNumber}:"/flight/{flightNumber}"。另一个不同之处在于,这里声明了一个Flight对象而不是航班列表。这很有道理,因为我们只想处理单个航班,而不是一大堆。

Flight flight = null;

刚开始可能会觉得开发者在这里没有使用var关键字看起来有些奇怪,但那样是无法正确编译的。你不能将null赋值给用var关键字声明的变量,因为在使用var时,类型是隐式地从赋值表达式推导出来的。因为null不包含任何类型信息,开发者不得不显式地声明flight的类型。

代码非常相似,这使得我们可以退一步,发现一些其他不干净的代码片段,而无需专注于我们已经知道的内容。首先,关于逻辑的注释有什么问题?它们无疑是为了在你努力通过方法时,像面包屑一样引导你:

  • // 获取航班

  • // 获取目的地机场详情

  • // 获取出发机场详情

如果我们将这些放入可以由其他端点重用的小型方法中会怎么样?我在列表 4.5 中正是这样做的。想象一下一个像叙述或步骤列表一样的方法,它只包含几个小方法,而不是我们现在所拥有的巨大混乱。列表 4.5 从列表 4.4 中提取代码,并想象一个开发者将内部细节提取到单独的方法中,在一个公共方法中调用它们。比较列表 4.5 和 4.4。复杂性的差异是巨大的。当然,我们现在正在使用多个数据库连接来检索与一个项目相关的数据。总是有缺点,那就是可能对某些人来说过于沉重。所有处理从数据库获取事物“如何”的逻辑都已被抽象到私有方法中。一个不熟悉这个类的开发者现在可以查看这个方法,并立即知道它做什么,而无需了解所有实现细节。了解方法的总体流程通常对开发者来说已经足够了。注意,在公共方法中没有处理连接字符串、打开数据库连接和释放对象的代码。

列表 4.5 清理后的 FlightController.cs GET flight/{flightNumber}

[ResponseType(typeof(FlightReturnView))]
public HttResponseMessage Get(int id) {
  Flight flight = GetFlight(id);                                          ❶

  FlightReturnView flightReturnView = new FlightReturnView();             ❷
  flightReturnView.FlightNumber = flight.FlightNumber;                    ❸

  flightReturnView.Destination = 
➥ GetDestinationAirport(flight.DestinationID);                           ❹
  flightReturnView.Origin = GetOriginAirport(flight.OriginID);            ❺

  return Request.CreateResponse(HttpStatusCode.OK, flightReturnView);     ❻
}

❶ 从数据库获取航班详情

❷ 创建一个新的 FlightReturnView 实例

❸ 填充 returnView 的航班号字段

❹ 填充 returnView 的目的地字段

❺ 填充 returnView 的出发地字段

❻ 返回 HTTP 200 状态码和 returnView

在列表 4.5 中,我将所有琐碎的细节提取到它们自己的私有方法中。列表 4.5 中的方法远非完美(首先没有错误处理),但它是一个改进。

下一个端点是创建数据库中预订的 POST 端点。它与之前的端点类似,但这次我们处理了 JSON 反序列化。

4.3 航班控制器:POST /flight

我们已经看到了两种获取航班逻辑:一次性获取所有航班,或者根据航班号获取单个航班。但如果我们想预订航班怎么办?本节检查了下一个列表中显示的 POST /flight 端点,它允许用户预订航班。它与之前的端点类似,但这是第一次处理 JSON 反序列化。除了 JSON 反序列化之外,本节还涉及了 Don’t Repeat Yourself (DRY) 原则和 ModelState 静态类。然而,有一点需要注意,FlyTomorrow 的 OpenAPI 规范表示我们需要一个 POST /booking 端点,而不是 POST /flight 端点。让我们记下这一点,并在适当的时候进行修复。

列表 4.6 FlightController.cs POST /flight

[ResponseType(typeof(HttpResponseMessage))]
public HttpResponseMessage Post([FromBody] Booking value) {
  var connectionString =    
➥ "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial
➥ Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User
➥ ID=dev;Password=FlyingDutchmanAirlines1972!;MultipleActiveResultSets=False;
➥ Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;";
  using (var connection = new SqlConnection(connectionString)) {
    connection.Open();                                                    ❶

        // Get Destination Airport ID                                     ❶
    var cmd = new SqlCommand("SELECT AirportID FROM Airport WHERE IATA    ❶
➥  = "‘" + value.DestinationAirportIATA + "’", connection);              ❶
    var destinationAirportID = 0;                                         ❶

    using (var reader = cmd.ExecuteReader()) {                            ❶
      while (reader.Read()) {                                             ❶
        destinationAirportID = reader.GetInt32(0);                        ❶
        break;                                                            ❶
      }
    }

    cmd.Dispose();                                                        ❶

    // Get Origin Airport ID                                              ❷
    var cmd = new SqlCommand("SELECT AirportID FROM Airport WHERE IATA    ❷
➥  = ‘" + value.OriginAirportIATA + "’", connection);                    ❷
    var originAirportID = 0;                                              ❷

    using (var reader = cmd.ExecuteReader()) {                            ❷
      while (reader.Read()) {                                             ❷
        originAirportID = reader.GetInt32(0);                             ❷
        break;                                                            ❷
      }
    }

    cmd.Dispose();                                                        ❷

    // Get Flight Details                                                 ❸
    cmd = new SqlCommand("SELECT * FROM Flight WHERE Origin = " +         ❸
➥ originAirportID + " AND Destination = " + destinationAirportID,        ❸
➥ connection);                                                           ❸

    Flight flight = null;                                                 ❸

    using (var reader = cmd.ExecuteReader()) {                            ❸
      while (reader.Read()) {                                             ❸
        flight = new Flight(reader.GetInt32(0), reader.GetInt32(1),       ❸
➥ reader.GetInt(2));                                                     ❸
        break;                                                            ❸
      }
    }

    cmd.Dispose();                                                        ❸

    // Create new customer
    cmd = new SqlCommand("SELECT COUNT(*) FROM Customer",                 ❹
➥ connection);                                     
    var newCustomerID = 0;

    using (var reader = cmd.ExecuteReader()) {
      while (reader.Read()) {
        newCustomerID = reader.GetInt32(0);                              ❺
      }
    }

    cmd.Dispose();

    cmd = new SqlCommand("INSERT INTO Customer (CustomerID, Name) 
➥ VALUES (‘" + (newCustomerID + 1) + "’, ’" + value.Name + "’)",        ❻
➥ connection);                
    cmd.ExecuteNonQuery();                                               ❼
    cmd.Dispose();

    var customer = new Customer(newCustomerID, value.Name);              ❽

    // Book flight                                                       ❾
    cmd = new SqlCommand("INSERT INTO Booking (FlightNumber,             ❾
➥ CustomerID) VALUES (" + flight.FlightNumber + ", ‘" +                 ❾
➥ customer.CustomerID + "’)", connection);                              ❾
    cmd.ExecuteNonQuery();                                               ❾
    cmd.Dispose();                                                       ❾

    return Request.CreateResponse(HttpStatusCode.Created), “Hooray! A 
➥ customer with the name \"" + customer.Name + 
➥ "\" has booked a flight!!!");                                         ❿
  }   
} 

❶ 从数据库获取目的地机场

❷ 从数据库获取出发地机场

❸ 获取我们想要预订的航班的详细信息

❹ 数据库中所有客户的 SQL 查询

❺ 将数据库中所有客户的数量分配给 newCustomerID

❻ 执行插入新客户到数据库的 SQL 命令

❼ 执行命令

❽ 创建一个模拟数据库中客户的内部客户对象

❾ 在数据库中创建一个预订

❿ 返回 HTTP 状态 201 和包含敏感客户数据的信息

这一定是迄今为止我们见过的最长、最复杂的端点。由于我们之前的方案证明是相当成功的,让我们再来一次。再次,我们看到一个带有ResponseType属性的方法签名

[ResponseType(typeof(HttpResponseMessage))]
public HttpResponseMessage Post([FromBody] Booking value)

到现在为止,这个故事对我们来说已经很熟悉了。我们也返回一个 HttpResponseMessage。但是,与之前我们查看的端点相比,这个方法签名有一个不同之处:Post 方法接受一个类型为 Booking 的参数,并且这个参数上也有一个属性。

注意:您不仅可以给方法应用属性,还可以给变量、类、委托、接口等等应用。您不能在变量上使用属性,因为与属性相关的所有数据必须在编译时已知。这不能保证对于变量。

您可以使用 FromBody 属性自动将 XML 或 JSON 主体解析为任何您想要的类(只要输入和指定的类之间的属性匹配)。在这里,发送的 JSON 主体被 CLR 映射到 Booking 类的一个实例。这个神奇的小属性是您在 C# 中遇到的最节省时间的事情之一。此端点的有效 JSON 有效载荷如下:

{
  "OriginAirportIATA": "GRQ",
  "DestinationAirportIATA": "CDG",
  "Name" : "Donald Knuth"
}

这些值直接映射到Booking类中的字段。.NET 框架将 JSON 解析并输出带有这些值的新的Booking实例,如图 4.5 所示。因为这个过程将一个参数绑定到一个模型上,所以我们称这个过程为模型绑定。我们将在第十四章深入探讨模型绑定。

图片

图 4.5 将 JSON 有效载荷反序列化为 C# 类。[FromBody] 属性接收一个 JSON 或 XML 主体并将其解析为模型。

使用模型绑定,我们仍然依赖于输入数据的质量。如果输入数据缺少字段,则 [FromBody] 的底层代码会抛出异常,并且方法会自动返回 HTTP 状态码 400。如果所有字段都在 JSON 主体中,但 CLR 由于某种原因无法解析其中一个字段,CLR 将全局 ModelState.IsValid 属性设置为 false。因此,始终检查这一点是个好主意,我们将在重构此方法时这么做。

当我们扫描这个方法时,我们很快意识到我们以前见过这一切。事实上,直到我们到达方法的最后一个代码块,一切都是旧闻——这是一个巨大的警告信号,表明这段代码违反了 DRY 原则。

点赞

不要重复自己原则

之前,我们讨论了将方法重构为小块。这导致的方法读起来像叙述,遵循几个简单的步骤来生成输出。我们这样做是为了提高可读性,但还有一个角度需要检查:DRY 原则。

DRY 代表“不要重复自己”,这一概念首次出现在安德鲁·亨特和戴夫·托马斯合著的书籍《实用程序员》(Addison-Wesley,1999)中。亨特和托马斯将 DRY 原则定义为:“系统内每条知识都必须有一个单一、明确、权威的表示(27)。”在实践中,这通常意味着你只需编写一次相同的代码。换句话说:不要重复代码。

例如,如果你发现自己在同一方法、类甚至整个代码库中多次复制粘贴相同的foreach循环(可能迭代不同的集合),请将其提取到一个专用方法中并调用它。这样做有两个好处:首先,它使得调用此提取方法的函数更容易阅读,因为你已经封装了实现细节。其次,如果你需要更改此foreach循环的实现,你只需在一个地方进行更改,而不是在代码库的每个地方。所以,就像喜剧一样,保持 DRY!

|

该方法最后一段代码块,如下一列表所示,简短且几乎出人意料地直接。

列表 4.7 FlightController.cs POST /flight:在数据库中插入Booking对象

// Book Flight
cmd = new SqlCommand("INSERT INTO Booking (FlightNumber, CustomerID) VALUES
➥ (" + flight.FlightNumber + ", ‘" + customer.CustomerID + "’)",           ❶
➥ connection);       
cmd.ExecuteNonQuery();                                                      ❷
cmd.Dispose();                                                              ❸

return Request.CreateResponse(HttpStatusCode.Created, "Hooray! A customer
➥ with the name \"" + customer.Name + "\" has booked a flight!!! ");       ❹

❶ 将预订插入数据库的 SQL 查询命令

❷ 执行命令

❸ 释放 SqlCommand 对象

❹ 返回一个 HTTP 状态码 201 以及敏感的客户数据

列表 4.7 中的代码创建了一个新的SqlCommand以将新记录插入Booking表,然后执行该查询并释放SqlCommand。最后,它返回一个包含 HTTP 状态码 201 和包含customer.Name文本摘要的响应。

4.4 飞行控制器:DELETE /flight/{flightNumber}

在本章中,我们已经探讨了FlightController类中的大多数端点,并发现了许多可以改进代码的方法。在控制器中,我们只剩下一个端点要处理:DELETE flight/{flightNumber}。或许幸运且恰当地,这个方法不到 20 行长。我们可以通过提取连接字符串来简化它,但总体来说,我们在本章中已经看到了更糟糕的代码。

在这个DELETE方法中(除了传递给SqlCommand构造函数的不同查询之外),没有新的内容,我将在下一个列表中详细说明时不会占用你太多时间。然而,有两个特殊性:我们在第三章从 FlyTomorrow 收到的 OpenAPI 规范根本没有指定我们需要一个DELETE /flight/{flightNumber}端点。毕竟,我们为什么要允许用户从数据库中删除航班?因此,这个端点不是我们改进的内容,也不是需求的一部分。相反,我们省略了它,并在接下来的章节中不对其进行重构。

列表 4.8 FlightController.cs DELETE flight/{flightNumber}

[ResponseType(typeof(HttpResponseMessage))]
public HttpResponseMessage Delete(int id) {
  var connectionString =    
➥ "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial
➥ Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User
➥ ID=dev;Password=FlyingDutchmanAirlines1972!;MultipleActiveResultSets=False;
➥ Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;";

  using (var connection = new SqlConnection(connectionString)) {
    connection.Open();

    var cmd = new SqlCommand("DELETE FROM Booking WHERE BookingID = ‘" 
➥ + id + "’", connection);
    cmd.ExecuteNonQuery();
    cmd.Dispose();

    return Request.CreateResponse(HttpStatusCode.OK);
  }
}

有了这些,我们已经完成了对现有代码库的探索。我们可以改进很多事情,我们也有一些安全问题必须解决。

练习

练习 4.1

真的是假的?你只能将属性应用于方法。

练习 4.2

真的是假的?你不能将属性应用于变量。

练习 4.3

真的是假的?IEnumerable接口允许我们创建新的枚举。

练习 4.4

数据库连接字符串有哪些不良做法?

a. 将硬编码的连接字符串提交到 SCM。

b. 永远不要硬编码连接字符串。

c. 将连接字符串存储在配置文件或环境变量中。

d. 将连接字符串写在便利贴上,并将其放在你最喜欢的《哈利·波特与密室连接字符串》副本中。

练习 4.5

为什么我们需要销毁实现IDisposable接口的类?

a. 否则,它就会变得不可用。

b. 实现IDisposable的类通常持有一些资源,如果不释放,可能会导致内存泄漏。

c. 你不需要销毁实现IDisposable接口的类。

练习 4.6

如果我们在类上调用Dispose,垃圾收集器何时回收资源?

a. 下次它在垃圾收集轮次中遇到相应的资源时

b. 立即

c. 在方法结束时

练习 4.7

以下哪一项不是处理对象适当的技术?

a. 在using语句代码块中包装对象创建。

b. 在方法的每个退出点调用Dispose

c. 从对象的源代码中移除IDisposable实现。

练习 4.8

真的是假的?静态构造函数在默认构造函数或定义(常规)构造函数之前运行。

练习 4.9

真的是假的?每次实例化对象时都会运行静态构造函数。

摘要

  • 我们可以使用typeof运算符在编译时确定对象的类型,或者使用GetType方法(来自object基类型)在运行时确定。

  • Object是 C#中所有类型的基类型。这意味着,通过多态,object公开的所有方法都可以在所有类型上使用(如GetType)。这允许我们在 C#的每个类型上使用一组基本方法。

  • 通过实现IEnumerable接口,我们可以创建具有枚举器的类。我们可以使用这些类来表示集合并对它们包含的元素执行操作。当我们想要创建.NET 生态系统未提供的集合时,这非常有用。

  • 我们永远不应该硬编码连接字符串。这是一个安全问题。相反,应在配置文件或环境变量中存储连接字符串。

  • .NET 垃圾回收器在运行时扫描内存,寻找没有剩余“链接”的资源,将其标记,并在垃圾回收器下一次运行时释放其内存。这就是 C#成为托管语言的原因。正因为如此,我们不需要在 C#中手动和显式地处理指针和内存分配。

  • 编译器将using语句解析为try-finally代码块。这允许我们在使用实现IDisposable的类时,抽象掉在try-finally块中找到的Dispose调用。抽象掉Dispose调用减少了忘记正确释放对象并因此创建可能的内存泄漏的机会。

  • try-catch代码块可以捕获和处理异常。每当您有抛出异常(预期的或未预期的)的代码时,请考虑将其包裹在try-catch块中。当您将代码包裹在try-catch块中并捕获异常时,您有机会优雅地处理异常并记录它,或者优雅地关闭应用程序。

  • try-catch-finallytry-finally中的finally代码块总是在代码块退出之前执行,即使捕获到异常也是如此。finally块是try-catch代码块的可选附加部分。如果您需要执行拆卸或清理操作(例如,释放实现IDisposable的对象),这将非常有用。

  • C#支持方法重载。这意味着我们可以有具有相同名称但具有不同参数的方法。方法调用在运行时由 CLR 路由到适当的方法。这在扩展现有类的功能而不更改原始方法时非常有用。

  • 一个static构造函数总是在对象的第一次实例化之前执行一次。这可以用来在执行任何使用它们的逻辑之前设置静态属性的值。

  • [FromBody]属性允许您进行参数绑定并将 JSON 主体反序列化为模型。当处理 HTTP 端点时,这是一个节省大量时间的方法,因为您不需要编写自己的 JSON 映射逻辑。

  • 不要重复自己(DRY)原则告诉我们不要重复代码。相反,将代码重构为提取的方法并调用它。使用 DRY 原则可以促进代码的可维护性。


(1.) 尽管Object.GetType方法不是反射命名空间的一部分,但我确实认为它是“反射”工作流程的一部分。反射通常从使用Object.GetType开始,并且它用于在运行时从实例中获取数据。这是一个非常“反射”的操作。更多信息,请参阅第 6.2.6 节或杰弗里·里希特(Jeffrey Richter)的《CLR via C#》(第 4 版;微软出版社,2012 年)。

第三部分 数据库访问层

在第二部分,我们深入审查了飞荷兰人航空公司的现有代码库。我们提出了可以改变的地方,并讨论了为什么这些改变是必要的。在这一部分,我们将更进一步,开始对服务进行重写。你将学习如何创建一个新的.NET 5 项目,以及如何使用 Entity Framework Core 连接、查询和逆向工程数据库。

使用 Entity Framework Core 设置项目和数据库

本章涵盖

  • 重构遗留代码库以使其整洁和安全

  • 使用 Entity Framework Core 查询数据库

  • 实现存储库/服务模式

  • 使用命令行创建新的 .NET 5 解决方案和项目

时机终于到了。你可能急于修复第三章和第四章中我们看到的一些问题,现在我们将着手解决这些问题。首先,让我们制定一个如何处理这个重构的计划。我们已经知道我们需要做一些不同的事情:

  • 在第三章中,我们被告知使用 .NET 5 而不是 .NET 框架来创建飞荷兰人航空公司服务的新版本。

  • 我们需要重写端点以实现整洁的代码(特别是遵守 DRY 原则)。

  • 我们需要修复安全漏洞——硬编码的连接字符串。

  • 对象名称与数据库列名称不匹配。我们应该修复这个问题,以确保代码库和数据库之间完美的同构关系。

  • 我们需要遵守第三章中讨论的 OpenAPI 文件和附录 D 中展示的。

虽然这不一定属于需求的一部分,但我们希望包含一些额外的可交付成果以提高工作质量,从而确保工作做得很好:

  • 我们希望使用测试驱动开发来编写支持代码库的单元测试。

  • 我们希望使用 Entity Framework Core 通过逆向工程已部署的数据库来翻新数据库层。

  • 我们希望在服务启动时自动生成更新的 OpenAPI 文件,以便与提供的来自 FlyTomorrow 的 OpenAPI 进行比较。

当然,我们将做更多的工作,但有一些一般性的大致方向是好的。我们还处于一个非常有趣的位置:我们被困在必须保持旧代码库活跃和工作的中间,同时进行绿色场开发。

定义 绿色场开发 意味着我们正在处理一个不受任何先前设计决策或旧代码限制的项目。在实践中,这通常意味着一个全新的项目。

我们已经设定了要求和一个旧代码库,我们需要模仿(在适当和可能的情况下),但我们也是从一个空项目开始的。在现实世界中,你经常会遇到这种情况。毫无疑问,你有过尝试创建现有产品新版本的体验——如果你愿意,可以称之为“下一代”版本。图 5.1 展示了我们在本书方案中的位置。

图片

图 5.1 在本章中,我们将开始重新实现飞荷兰人航空公司代码库的过程。我们将从数据库访问层开始。在接下来的章节中,我们将查看存储库层、服务层和控制层。

我们的首要任务是创建一个新的 .NET 5 解决方案。

5.1 创建 .NET 5 解决方案和项目

在本节中,我们将创建一个新的.NET 5 解决方案和项目。我们还将查看.NET 5 中存在哪些预定义的解决方案和项目模板。您有以下两种创建新的.NET 5 解决方案的方法:

  • 您可以使用命令行,无论是 Windows 命令行还是 macOS/Linux 终端。

  • 您可以使用像 Visual Studio 这样的 IDE。使用 Visual Studio 可以在一定程度上自动化这个过程。您可以在命令行或终端中用 C#做的几乎所有事情,您也可以在 Visual Studio 中通过几个点击来完成。¹

使用这两种途径的结果是相同的:您最终会得到一个新的.NET 5 解决方案。我们将使用命令行。创建一个新的空.NET 5 解决方案或项目非常简单,如下所示:

\> dotnet new [TEMPLATE] [FLAGS]

注意:在尝试创建.NET 5 项目之前,请确保您已安装最新的.NET 5 SDK 和运行时。安装说明见附录 C。

我们可以使用各种模板。其中一些更常见的有webwebappmvcwebapi。就我们的目的而言,我们使用可能是最受欢迎的两个:slnconsoledotnet new sln命令创建一个新的解决方案,而dotnet new console则创建一个新的项目和“hello, world”源文件。如第 3.3.2 节所述,C#使用解决方案和项目来组织其代码库。解决方案是顶级实体,包含多个项目。我们在项目中编写我们的逻辑。项目可以被视为不同模块、包或库,具体取决于我们偏好的语言。

图片

我们还通过创建命令传递了-n标志。这允许我们为我们的解决方案和项目指定一个名称。如果我们没有明确指定解决方案的名称,我们的项目或解决方案的名称将默认为创建文件的文件夹名称。

要创建我们的起点,请运行以下命令。请注意,命令行工具在创建新解决方案时不允许您创建新的解决方案文件夹。如果您想这样做,您可以使用 Visual Studio(它允许这样做)或首先创建文件夹,然后在解决方案文件夹中运行以下命令。

\> dotnet new sln -n "FlyingDutchmanAirlinesNextGen"

该命令只创建了一件事:一个名为FlyingDutchmanAirlinesNextGen.sln的解决方案文件,如图 5.2 所示。我们可以在这个解决方案文件中打开 Visual Studio,但没有项目我们无法做很多事情。

图片

图 5.2 运行创建新的.NET 解决方案的命令后,命令行会告诉我们操作已成功。

现在我们有了解决方案文件,我们应该创建一个名为 FlyingDutchmanAirlines 的项目。要创建一个新的项目,我们使用console模板,如下所示。这会创建一个.NET 5 控制台应用程序,然后我们将将其更改为一个网络服务。

\> dotnet new console -n "FlyingDutchmanAirlines"

运行命令后,我们会看到一个消息说“Restore succeeded.”。恢复是.NET CLI 在创建新项目之前以及在“clean”操作(“clean”删除所有本地可执行文件,包括依赖项)之后编译之前执行的过程(“clean”删除所有本地可执行文件,包括依赖项)或第一次编译,以收集所需的依赖项。我们也可以通过以下方式单独运行此命令:

\> dotnet restore

在处理依赖问题的时候,恢复操作可能会很有用。restore命令还会在我们解决方案文件旁边创建一个新的文件夹,名为 FlyingDutchmanAirlines(与传递给我们的项目名称相同),如图 5.3 所示。当我们进入文件夹时,我们会看到一个名为 obj 的文件夹。obj 文件夹包含 NuGet 及其包的配置文件。回到项目的根目录,我们有一个项目文件和一个 C#源文件。

图 5.3

图 5.3 运行创建解决方案和项目的命令行命令后的文件夹结构。FlyingDutchmanAirlines 文件夹是通过创建项目的命令创建的,而 FlyingDutchmanAirlinesNextGen.sln 文件是通过创建新解决方案的命令创建的。

我们的项目已经创建,但我们还需要将其添加到解决方案中。当您创建一个项目时,dotnet不会扫描任何子目录以查找包含的解决方案。要向解决方案添加项目,请使用以下“solution add”命令:

\> dotnet sln [SOLUTION PATH] add [PROJECT PATH]

[SOLUTION PATH]指向您想要添加项目的解决方案文件的路径。同样地,[PROJECT PATH]指向要添加到解决方案中的 csproj 文件。您可以通过向命令添加多个[PROJECT PATH]参数来同时添加多个项目。

图 5.3

在我们的情况下,从根目录 FlyingDutchmanAirlinesNextGen 运行,命令只考虑了一个 csproj,如下所示:

\> dotnet sln FlyingDutchmanAirlinesNextGen.sln add
➥ .\FlyingDutchmanAirlines\FlyingDutchmanAirlines.csproj

终端通过一条消息——Project `FlyingDutchmanAirlines\ FlyingDutchmanAirlines.csproj` added to the solution——告诉我们我们的努力是成功的。如果我们用文本编辑器打开 FlyingDutchmanAirlinesNextGen.sln 文件,我们会看到如下所示的 FlyingDutchmanAirlines.csproj 文件的引用:

Project("{...}") = 
➥ "FlyingDutchmanAirlines",
➥ "FlyingDutchmanAirlines\FlyingDutchmanAirlines.csproj", "{...}"
EndProject

这是solution add命令添加的引用。该引用告诉 IDE 和编译器,在这个解决方案中有一个名为 FlyingDutchmanAirlines 的项目。

5.2 设置和配置一个网络服务

在 5.1 节中,我们创建了一个新的解决方案和项目,用于使用飞荷兰人航空公司服务的下一代版本。在本节中,我们将查看 5.1 节中采取行动生成的源代码,并配置控制台应用程序以作为网络服务运行。

在此阶段,解决方案(和项目)中只有一个源文件,即 Program.cs,如下一列表所示。此文件是通过我们在第 5.1 节中创建新项目时使用的 console 模板自动生成的。它包含程序的入口点——一个返回无值的 static 方法,称为 Main——它接受一个名为 args 的字符串数组。此数组包含在启动时传递的任何命令行参数。

列表 5.1 包含 Main 方法的 Program.cs

using System;

namespace FlyingDutchmanAirlines {
  class Program {
    static void Main(string[] args) {        ❶
      Console.WriteLine("Hello World!");     ❶
    }                                        ❶
  }
}

static void Main 是 C# 控制台应用程序的默认入口点。

使用命令行运行 FlyingDutchmanAirlinesNextGen 项目,它会在控制台输出“Hello World!”让我们从代码中移除 "Hello World!" 字符串。这使我们处于一个很好的位置,可以将控制台应用程序更改为更实用的东西:一个 Web 服务。

5.2.1 配置 .NET 5 Web 服务

我们需要配置我们的全新 .NET 5 应用程序以接受 HTTP 请求并将它们路由到我们将要实现的端点。为此,我们还需要设置 Host,这是运行 Web 服务并与 CLR 交互的底层进程。我们的应用程序位于 Host 内,而 Host 又位于 CLR 内。

注意:我们可以将 Web 容器(如 IIS)与 Tomcat 进行比较。用 Java 术语来说,.NET 5 是你的 JVM 和 Spring,而 Host 是你的 Tomcat。

我们配置 Host 以启动一个负责应用程序启动和生命周期管理的“宿主进程”。我们还告诉 Host 我们想使用 WebHostDefaults。这允许我们使用 Host 来实现 Web 服务,如图 5.4 所示。至少,宿主配置了服务器和请求处理管道。

图片

图 5.4 一个 Web 服务在 Host 内运行,而 Host 又在 CLR 内运行。这种模型允许 CLR 启动一个可以执行我们的 Web 服务的 Host

我在 .NET 5 中配置 Host 的首选方法是遵循以下三个步骤:

  1. 使用静态 Host 类(Microsoft.Extensions.Hosting 命名空间的一部分)上的 CreateDefaultBuilder 方法来创建一个构建器。

  2. 通过告诉 Host 构建器我们想使用 WebHostDefaults 并设置一个带有指定端口的启动类和启动 URL 来配置 Host

  3. 构建并运行构建的 Host 实例。

当我们尝试为构建器返回的 Host 实例配置启动类时,我们必须使用 UseStartup 类。这作为 ASP.NET 的一部分,默认情况下不是通过 .NET 5 安装的。为了访问此功能(以及 ASP.NET 中的任何内容),我们需要将 ASP.NET 包添加到 FlyingDutchmanAirlines 项目中。我们可以通过 Visual Studio 中的 NuGet 包管理器或在我们处于项目文件夹内时通过我们信任的命令行来完成此操作,如下所示:

\> dotnet add package Microsoft.AspNetCore.App

执行命令后,命令行会通知您已成功将包添加到项目中。

注意:该命令还执行了一个 restore 操作。有关 restore 的更多详细信息,请参阅第 5.1 节。

如果我们现在尝试构建项目,我们会收到一个警告,说我们应该使用框架引用而不是包引用。这是由于在过去的几年中.NET 命名空间发生的一些变动。这个警告不会阻止我们使用当前的代码,但我们可以很容易地消除它。在一个文本编辑器,如 Notepad++或(对于勇敢者)Vim 中,打开 FlyingDutchmanAirlines.csproj 文件。在那个文件中,添加粗体代码并移除对 ASP.NET 的包引用:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

 <ItemGroup>
 <FrameworkReference Include="Microsoft.AspNetCore.App" />
 </ItemGroup>

 <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.8" /> 
    ...
  </ItemGroup>
</Project>

现在,Microsoft.AspNetCore 包已安装(作为框架引用),并且我们消除了编译器警告,我们可以使用 ASP.NET 功能。我们首先想告诉编译器我们想要使用 AspNetCore.Hosting 命名空间,如下一列表所示。在这本书中,命名空间导入通常从代码列表中省略。这样做是因为它们占用宝贵的空间,并且可以在大多数 IDE 中自动填充。

列表 5.2 Program.cs 无“Hello, World!”输出

using System;
using Microsoft.AspNetCore.Hosting;    ❶

namespace FlyingDutchmanAirlines {
  class Program {
    static void Main(string[] args) {
                                       ❷
    }
  }
}

❶ 我们使用 Microsoft.AspNetCore.Hosting 命名空间。

❷ 我们不再将“Hello, World!”输出到控制台。

5.2.2 创建和使用 HostBuilder

在本节中,我们将

  1. 创建一个 HostBuilder 的实例。

  2. 假设我们想将 Host 作为 Web 服务使用。

  3. 将启动 URL 设置为 0.0.0.0:8080`。

  4. 使用 HostBuilder 构建 Host 的实例。

  5. 运行 Host

图片

在程序的 Main 方法中,我们添加了对 Host.CreateDefaultBuilder 的调用。这个调用返回一个带有一些默认设置的 HostBuilder。然后我们通过调用 UseUrls 告诉结果构建器我们想要使用特定的 URL 和端口。然后我们调用 Build 来构建并返回实际的 Host。我们将输出分配给一个类型为 IHost 的变量。我们将我们的新 Host 分配给一个显式类型为 IHost 的变量。最后,代码通过调用 host.Run() 启动 Host,如下所示:

using System;
using Microsoft.AspNetCore; 
using Microsoft.AspNetCore.Hosting;

namespace FlyingDutchmanAirlines {
  class Program {
    static void Main(string[] args) {
      IHost host = 
➥ Host.CreateDefaultBuilder().ConfigureWebHostDefaults(builder => {
        builder.UseUrls("http:/ /0.0.0.0:8080");
      }).Build();

      host.Run();    
    }
  }
}

如果你尝试以当前状态编译和运行该服务,服务会启动然后以 InvalidOperationException 异常终止。这个异常告诉我们我们没有配置并绑定到 HostStartup 类。但在我们创建这个 Startup 类之前,让我们让 Program 类保持最佳状态。我们在 Main 方法中有 Host 的创建和调用 Run 的操作,但它真的应该在那里吗?

在 1.4 节中,我们讨论了编写像叙事一样阅读的方法的重要性。如果我是新开发者,看到公开的方法(在这种情况下是 Main),我可能不会关心实现细节。相反,我想了解这个方法的大致功能。为此,我们可以将 host 的初始化和赋值以及调用 host.Run 的操作提取到一个单独的私有方法中,如下所示:

private static void InitalizeHost() {
  IHost host = Host.CreateDefaultBuilder()
      .ConfigureWebHostDefaults(builder =>
    {
      builder.UseUrls("http:/ /0.0.0.0:8080");
    }).Build();

  host.Run();
}

Host创建逻辑提取到单独的方法中是一个好步骤,但我们还可以做更多。我们应该考虑两件事。首先,我们不需要将HostBuilder的结果存储在变量中,因为我们只使用它来调用Run。为什么我们不在Build之后直接调用Run,以避免不必要的内存分配,如下所示:

IHost host = Host.CreateDefaultBuilder()
      .ConfigureWebHostDefaults(builder =>
    {
      builder.UseUrls("http:/ /0.0.0.0:8080");
    }).Build().Run();
我们应该考虑的第二件事是将方法更改为“表达式”方法,如下所示。类似于 lambda 表达式,表达式方法使用=>符号来表示该方法将评估=>右侧的表达式并返回其结果。您可以将=>运算符视为赋值和评估代数(=)以及返回语句(>)的组合。Lambda 表达式一开始可能看起来有点奇怪,但随着您看到的越来越多,您就越想使用它们。
private static void InitalizeHost() => 
  Host.CreateDefaultBuilder()
    .ConfigureWebHostDefaults(builder =>
  {
    builder.UseUrls("http:/ /0.0.0.0:8080");
  }).Build().Run();

这对我们Main方法有什么影响?不大。我们只需按照以下方式调用InitializeHost方法:

namespace FlyingDutchmanAirlines {
  class Program {
    static void Main(string[] args) {
 InitializeHost();
    }

 private static void InitalizeHost() => 
 Host.CreateDefaultBuilder()
 .ConfigureWebHostDefaults(builder =>
 {
 builder.UseUrls("http:/ /0.0.0.0:8080");
 }).Build().Run();  
 }
}

我们的代码干净且易于阅读,但我们仍然需要处理那个运行时异常。干净的代码很好,但如果它没有所需的功能,那就不够好了。异常表示我们在构建和运行生成的IHost之前需要使用HostBuilder注册一个Startup类。我想我们别无选择,只能将其作为我们下一个工作项目。

5.2.3 实现 Startup 类

我们还没有Startup类,但我们可以通过创建一个名为 Startup.cs 的文件(在项目的根文件夹中即可)来解决这个问题,如下所示:

namespace FlyingDutchmanAirlines {
  class Startup { }
}

要配置我们的Startup类,在Startup类中创建一个Configure方法。该方法由HostBuilder调用,并包含一个关键的配置选项,如下一列表所示,它允许我们使用控制器和端点。

列表 5.3 Startup.cs Configure方法

public void Configure(IApplicationBuilder app) {
  app.UseRouting();                                            ❶
  app.UseEndpoints(endpoints => endpoints.MapControllers());   ❷
}

❶ 使用路由并在此类中为服务做出路由决策

❷ 使用端点模式进行路由 Web 请求。MapControllers 扫描并映射我们服务中的所有控制器。

列表 5.3 中的小方法是我们的配置代码的核心。当调用UseRouting时,我们告诉运行时,某些服务路由决策是在这个类中做出的。如果我们没有调用UseRouting,我们就无法访问任何端点。UseEndpoints做的是它所说的:它允许我们使用和指定端点。它接受一个我们之前未遇到的类型作为参数:Action。这是一个代理的实例。

代理和匿名方法

代理提供了一种引用方法的方式。它也是类型安全的,因此它只能指向具有给定签名的某个方法。代理可以被传递到其他方法和类中,然后在需要时调用。它们通常用作回调。

您可以通过以下三种方式之一创建代理:

  • 使用delegate关键字

  • 使用匿名方法

  • 使用 lambda 表达式

创建它们的最早方式是显式声明一个delegate类型,并通过将方法分配给委托来创建该委托的新实例,如下所示:

delegate int MyDelegate(int x);
public int BeanCounter(int beans) => beans++;

public void AnyMethod(){
  MyDelegate del = BeanCounter;
}

这段代码可读性较好,但略显笨拙。随着 C#的成熟,引入了新的方法来处理委托。

第二种选择是使用匿名方法。要使用匿名方法创建委托,我们指定方法返回类型和主体,在新的委托实例化中,如下所示:

delegate int MyDelegate(int x);
public void AnyMethod() {
  MyDelegate del = delegate(int beans) { 
    return beans++;
  };
}

注意创建委托的原始方式和匿名方式之间的区别。匿名方法可以极大地清理您的代码,但有一个大警告:您应该只在需要这样做或您确信自己可以遵守 DRY 原则的情况下使用匿名方法。如果您需要在代码库的另一个地方执行相同的逻辑,并且没有将委托传递到该位置,请使用普通方法,并从两个地方调用它。

这个过程的第三次,也是当前的一次进化,是从匿名方法到 lambda 表达式,如下所示:

delegate int MyDelegate(int x);
public void AnyMethod() {
  MyDelegate del = beans => beans++;
}

我们只需在匿名方法(beans)中确定我们想要的输入是什么,以及我们想要执行并返回的逻辑(beans++)。此外,您可以通过使用加法(+)和减法(-)运算符从委托中添加和删除方法。如果您有多个与同一委托相关联的方法,则该委托成为多播委托。

最后,要使用委托,请调用下面的Invoke方法。这将调用底层的Action,执行您附加到其上的任何代码。

del.Invoke();

我们传递一个 lambda 表达式,当执行时,将通过调用MapControllers来配置应用程序的端点。一个方便的方法,MapControllers扫描我们的代码库中的任何控制器,并为我们的控制器中的端点生成适当的路由。

在将我们的Startup类注册到Host之前,唯一剩下的事情是创建一个ConfigureServices方法,并在传入的IServiceCollection上调用AddControllers,如下面的代码示例所示。IServiceCollection接口允许我们向服务添加功能,例如支持控制器或依赖注入的类型。这些功能被添加到内部服务集合中。

public void ConfigureServices(IServiceCollection services) {
  services.AddControllers();
}

为什么我们需要将控制器支持添加到服务集合中?我们不是刚刚扫描了控制器并添加了路由到RouteTable吗?在运行时,Host首先调用ConfigureServices,这给了我们机会注册我们想要在应用程序中使用(在这种情况下,我们的控制器)的任何服务。如果我们跳过了这一步,MapControllers将找不到任何控制器。

要使用 IServiceCollection,我们需要使用 Microsoft.Extensions.DependencyInjection 命名空间,如下面的代码片段所示。依赖注入由运行时用来提供当前的、最新的 ServiceCollection。你可以在第 6.2.9 节中找到更多关于依赖注入的信息。

namespace FlyingDutchmanAirlines {
  class Startup {
    public void Configure(IApplicationBuilder app){
      app.UseRouting();
      app.UseEndpoints(endpoints => endpoints.MapControllers(); }); 
    }    

 public void ConfigureServices (IServiceCollection services) {
 services.AddControllers();
 }
  }
}

我们已经完成了 Startup 类。现在,让我们配置它以便由 HostBuilder 使用。我们通过回到 Program.cs 并向 HostBuilder 添加对 UseStartup<Startup>() 的调用来实现这一点:

namespace FlyingDutchmanAirlines {
  class Program {
    static void Main(string[] args) {
      InitializeHost();
    }

    private static void InitalizeHost() => 
      Host.CreateDefaultBuilder()
        .ConfigureWebHostDefaults(builder =>
      {
 builder.UseStartup<Startup>();
        builder.UseUrls("http:/ /0.0.0.0:8080");
      }).Build().Run();  
  }
}

现在我们启动应用程序时,我们得到一个控制台窗口告诉我们服务正在运行并监听 0.0.0.0:8080。这段代码看起来与自动生成的模板给出的代码略有不同。功能保持不变,两者都是很好的起点。

现在我们已经解决了先决条件,我们可以开始向我们的服务添加一些逻辑。

5.2.4 使用存储库/服务模式为我们网络服务架构

我们计划为飞荷兰人航空公司下一代服务使用的架构范式是存储库/服务模式。使用这种模式,我们采用自下而上的开发策略,从底层开始工作:首先实现低级数据库调用,然后逐步向上创建端点。

我们的服务架构由以下四个层组成:

  1. 数据库访问层

  2. 存储库层

  3. 服务层

  4. 控制器层

图 5.5 在 FlyingDutchmanAirlinesNextGen.sln 中使用的存储库模式。数据和用户查询从控制器流向服务,再到存储库,最后到数据库。这种模式使我们能够轻松地在层之间分离关注点并进行增量开发。

从底部开始工作的好处是代码复杂性会自然增长。通常,那会是一件非常糟糕的事情。但在这个案例中,我们有工具来控制这种增长并使其保持在可控范围内。

我们可以通过选择任何端点并逐步执行满足要求的步骤来检查我们架构的数据流(图 5.5)。例如,让我们以 POST /Booking/{flightNumber} 为例。首先,一个 HTTP 请求进入 Booking 控制器。这将有一个 BookingService 的实例(每个实体都将有自己的服务和存储库),它将调用 BookingRepository 和它可能需要与之交互的任何其他服务。然后 BookingRepository 调用任何适当的数据库方法。在那个点上,流程逆转,我们回到链的顶部,将结果值返回给用户。

如前所述,并在图 5.6 中所示,所有实体都有自己的服务类和存储库类。如果需要对另一个实体进行操作,初始服务将调用该实体的服务以请求执行该操作。

图 5.6 将仓储模式应用于数据库实体。FlightController 持有它需要操作的每个实体的服务实例。一个实体的服务持有(至少)相应实体的存储库实例。如果需要,服务可以调用其他存储库。此图形追踪了机场(彩色框)的依赖关系流。

5.3 实现数据库访问层

如果我们回顾第四章,我们会想起在应用程序的前一个版本中处理数据库访问的奇特方式。连接字符串被硬编码到类本身中,并且没有使用 ORM。为了刷新我们的记忆:对象关系映射工具用于将代码映射到数据库,确保良好的匹配(或同构关系)。本节的两个主要目标是

  1. 设置 Entity Framework Core 并“逆向工程”已部署的数据库。

  2. 通过使用环境变量安全地存储连接字符串。

Entity Framework Core 最强大的功能之一是能够“逆向工程”已部署的数据库。逆向工程意味着 Entity Framework Core 从已部署的数据库自动生成代码库中的所有模型,为您节省了大量时间。逆向工程还保证了您的模型与数据库兼容,并且正确映射到模式。在第三章中,我们讨论了模型与模式之间正确同构关系的需求,使用 ORM 工具逆向工程模型是实现这一目标的方法。

5.3.1 Entity Framework Core 和逆向工程

在本节中,我们将学习如何使用 Entity Framework Core 来逆向工程已部署的数据库,并自动创建与数据库表相匹配的模型。由于我们逆向工程数据库,我们可以确信我们正在使用兼容的代码来查询数据库。

要逆向工程我们的数据库,我们首先需要通过运行 dotnet install 命令来安装 Entity Framework Core,如下所示。Entity Framework Core(EF Core)不是随 .NET 5 自动安装的,因为它是一个独立的项目。

\> dotnet tool install -–global dotnet-ef

成功后,命令行会通知您可以使用 dotnet-ef 命令调用工具,以及您刚刚安装的版本。Entity Framework Core 可以连接到许多不同类型的数据库。大多数数据库(SQL、NoSQL、Redis)都有包(也称为数据库驱动程序),允许 Entity Framework Core 连接到它们。因为我们的数据库是 SQL Server,我们安装相应的驱动程序。我们还需要添加 Entity Framework Core 设计包。这些包包含我们连接到 SQL Server 数据库(SqlServer 命名空间)和逆向工程模型(Design 命名空间)所需的功能。

确保您从项目的根目录(FlyingDutchmanAirlines,而不是解决方案的根目录,FlyingDutchmanAirlinesNextGen)运行以下命令:

\> dotnet add package Microsoft.EntityFrameworkCore.SqlServer
\> dotnet add package Microsoft.EntityFrameworkCore.Design

这些命令会安装连接到使用 Entity Framework Core 的 SQL Server 所需的所有软件包和依赖项。

我们现在可以使用以下命令来逆向工程数据库:

\> dotnet ef dbcontext scaffold [CONNECTION STRING] [DATABASE DRIVER] [FLAGS]

命令中包含两个不熟悉的术语——dbcontextscaffold

  • dbcontext 指的是创建一个类型为 DbContext 的类。dbcontext 是我们在代码中设置数据库连接的主要类。

  • scaffold 指示 Entity Framework Core 为我们连接到的数据库中的所有数据库实体创建模型。就像现实生活中的脚手架一样,它为原始项目(房屋或建筑物)创建了一个类似包裹的结构,我们可以用它来修改该项目。在我们的情况下,它将脚手架放在已部署的 SQL 数据库周围。

我们可以使用标志来指定生成的模型和 dbContext 的文件夹。我们将将这些保存到一个专用文件夹中,如下所示,以避免在项目根文件夹中有许多模型文件:

\> dotnet ef dbcontext scaffold
➥ "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial 
➥ Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User  
➥ Id=dev;Password=FlyingDutchmanAirlines1972!;
➥ MultipleActiveResultSets=False;Encrypt=True;
➥ TrustServerCertificate=False;Connection Timeout=30;" 
➥ Microsoft.EntityFrameworkCore.SqlServer -–context-dir DatabaseLayer 
➥ –-output-dir DatabaseLayer/Models

如果在运行命令时遇到问题,请仔细检查所有空格、换行符(不应该有)和标志。命令首先构建当前项目。然后,它尝试使用给定的连接字符串连接到数据库。最后,它生成 dbContext 类(FlyingDutchmanAirlinesContext.cs)和适当的模型。让我们检查创建的 FlyingDutchmanAirlinesContext 类。一个生成的 DatabaseContext 包含以下四个主要部分:

  • 构造函数

  • 包含实体的 DbSet 集合

  • 配置方法

  • 模型创建选项

在我们查看这些项目之前,类声明中有一个特别之处:

public partial class FlyingDutchmanAirlinesContext : DbContext

partial 是什么意思?

部分类

您可以使用 partial 关键字将类的定义拆分到多个文件中。通常,这会稍微降低可读性,但可能很有用。部分类对于自动代码生成器(如 Entity Framework Core)特别有用,因为生成器可以将代码放在部分类中,从而允许开发者丰富类的实现。

话虽如此,我们知道我们不会在另一个文件中为 FlyingDutchmanAirlinesContext 提供更多功能,因此我们可以从类中移除 partial 关键字。这是一个确保自动生成的代码正好符合您期望的好例子。仅仅因为生成器或模板以某种方式完成了它,并不意味着您不能编辑它。

public class FlyingDutchmanAirlinesContext : DbContext

注意,这个更改是可选的。

|

如果您查看生成的类,您会注意到它有两个不同的构造函数。在 C# 中,如果您不提供构造函数,编译器会在幕后为您生成一个无参数的构造函数。这个构造函数被称为默认构造函数或隐式构造函数。C# 在没有显式构造函数的情况下创建默认构造函数,以便您可以实例化该类的新实例。

如列表 5.4 所示,这两个构造函数都可以创建一个 FlyingDutchmanAirlinesContext 的实例。在 FlyingDutchmanAirlines 的情况下,你可以创建一个带有或不带有 DbContextOptions 类型实例的新实例。如果你确实将那个实例传递给构造函数,它将调用其基类的构造函数(在这种情况下是 DbContext)。

列表 5.4 FlyingDutchmanAirlinesContext 构造函数

public FlyingDutchmanAirlinesContext() { }                        ❶

public FlyingDutchmanAirlinesContext(DbContextOptions
➥ <FlyingDutchmanAirlinesContext> options) : base(options) { }   ❷

❶ 显式默认构造函数

❷ 带有调用基构造函数的参数的重载构造函数

有关方法构造函数重载的更多信息,请参阅第四章。

5.3.2 DbSet 和 Entity Framework Core 工作流程

在本节中,我们将讨论 DbSet 类型以及使用 Entity Framework Core 时的通用工作流程。超越构造函数,我们看到四个 DbSet 类型的集合,每个集合都包含我们的一个数据库模型。DbSet 类型是我们认为属于 EF Core 内部集合的一部分。Entity Framework Core 使用 DbSet<Entity> 集合来存储和维护数据库表及其内容的准确副本。

我们还看到了一个熟悉的概念:自动属性。集合是 public 的,但它们也是虚拟的,如下所示:

public virtual DbSet<Airport> Airport { get; set; }
public virtual DbSet<Booking> Booking { get; set; }
public virtual DbSet<Customer> Customer { get; set;}
public virtual DbSet<Flight> Flight { get; set; }

当你声明某个属性或方法为 virtual 时,你告诉编译器你允许在派生类中覆盖该属性或方法。如果你没有将某个属性或方法声明为 virtual,则不能覆盖它。

隐藏父属性和方法/密封类

在一个类实现了包含未声明为 virtual 的属性或方法的基础类的情况下,我们无法覆盖这些属性和方法的具体实现。怎么办呢?嗯,我们有一个解决这个问题的工作区。我们可以通过在方法或属性签名中插入 new 关键字来“隐藏”父类的属性和方法,如下面的代码所示。这个关键字告诉编译器,我们不是要为现有的父方法提供一个新的实现,而是只想调用这个恰好有相同名称的新方法。在实践中,它允许你“覆盖”非虚拟属性和方法。

public new void ThisMethodWasNotVirtual() {}

但是,请注意,隐藏是不受欢迎的。在一个理想的世界里,原始类的开发者有足够的知识来预测哪些属性和方法应该声明为 virtual。如果你需要在系统之外做事(使用工作区来执行意外和不受控制的覆盖),在点击提交代码按钮之前三思。原始开发者没有预料到你这样做,他们最初也不想让你覆盖它(如果他们想,他们就会提供一个 virtual 属性或方法)。

从基类开发者的角度来看,你该如何防止你的非虚方法或属性在派生类中被隐藏?遗憾的是,没有一种原子方式可以针对每个属性或方法进行指定。然而,我们确实有一个更核心的选项:sealed 关键字。你可以使用 sealed 关键字声明一个密封类,如下所示。这是一个很好的选项来保护你的类,因为基于密封类的派生类无法创建。由于继承被排除在外,因此重写或隐藏任何内容也是不可能的。

public sealed class Pinniped(){}

|

与许多其他 ORM 工具一样,Entity Framework Core 在一开始往往表现得不够直观。所有通常直接对数据库进行的操作都是在保存到数据库之前,先对内存中的模型进行操作。为此,Entity Framework Core 存储了大多数可用的数据库记录在 DbSet 中。这意味着,如果你在数据库中添加了一个主键为 192Flight 实体,那么在运行时也会将该特定实体加载到内存中。在运行时从内存中访问数据库内容允许你轻松地操作对象,并抽象出你实际上是在使用数据库。缺点是性能。根据你的数据库大小(或成为的大小),在内存中保留大量记录可能会变得非常消耗资源。如图 5.7 所示,通过 Entity Framework Core 操作实体的正常工作流程如下:

  1. 查询你想要操作的对象的适当 DbSet(对于 INSERT/ADD 操作不需要)。

  2. 操作对象(对于 READ 操作不需要)。

  3. 适当地更改 DbSet(对于 READ 操作不需要)。

图 5.7 通过 Entity Framework Core 更改数据库的三个一般步骤:查询 DbSet,操作对象,然后更改 DbSet

需要记住的是,尽管在 DbSet 中已经进行了更改,但这些更改并不一定已经应用到数据库中。Entity Framework Core 仍然需要将这些更改提交到数据库中,我们将在本章中进一步探讨如何做到这一点。

5.3.3 配置方法和环境变量

FlyingDutchmanAirlinesContext 类的第三个构建块包括两个配置方法:OnConfiguringOnModelCreating,如下代码所示。OnConfiguringDbContext 的配置时被调用,这是在启动时自动完成的,而 OnModelCreating 在模型创建期间(在启动时的运行时)被调用。

protected override void OnConfiguring(DbContextOptionsBuilder 
➥  optionsBuilder) {
  if (!optionsBuilder.IsConfigured) {
    optionsBuilder.UseSqlServer(
➥ "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial 
➥ Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User 
➥ ID=dev;Password=FlyingDutchmanAirlines1972!;
➥ MultipleActiveResultSets=False; 
➥ Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;");
  }
}

OnConfiguring 方法接受一个类型为 DbContextOptionsBuilder 的参数。OnConfiguring 方法由运行时自动在 DbContext 的配置时调用,并使用依赖注入提供 DbContextOptionsBuilder。在这里,我们应该配置任何与如何连接到数据库相关的设置。因此,我们需要提供一个连接字符串。

但,不幸的是,硬编码的连接字符串再次露出了它丑陋的真相。肯定有更好的方法来做这件事。我建议我们使用环境变量。环境变量是一个键值对,{KV},我们在操作系统级别设置它。我们可以在运行时检索环境变量,这使得它们非常适合提供随系统或部署而变化的变量,或者我们不希望在代码库中硬编码的值。

NOTE 环境变量通常用于通过容器编排系统(如 Kubernetes)部署的 Web 服务。如果您不想(或不能)在操作系统级别设置环境变量,您可以使用云解决方案,如 Azure Key Vault 和 Amazon AWS 密钥管理服务。有关 Kubernetes 的更多信息,请参阅 Ashley David 的 使用 Docker、Kubernetes 和 Terraform 启动微服务(Manning,2021)或 Marko Lukša 的 Kubernetes in Action(第 2 版;Manning,2021)。

每个操作系统对环境变量的处理方式都略有不同——我们稍后将讨论 Windows 和 macOS 之间的实际差异。然而,我们在 C# 中检索环境变量的方式不会根据操作系统而改变。在 System.IO 命名空间中有一个名为 GetEnvironmentVariable 的方法,我们可以用它来完成这个特定的目的,如下所示:

Environment.GetEnvironmentVariable([ENVIRONMENT VARIABLE KEY]);

您只需传递您想要检索的环境变量的键(ENVIRONMENT VARIABLE KEY),该方法就会为您完成。如果环境变量不存在,它将返回一个空值而不抛出异常,因此您需要根据该空值进行一些验证。您的环境变量看起来会是什么样子?因为它是一个键值对,并且因为环境变量不能包含任何空格,您可以这样做:{FlyingDutchmanAirlines_Database_Connection_String, [Connection String]}

TIP 因为环境变量是系统范围的,所以不能有重复键的环境变量。在选择键的值时,请记住这一点。

5.3.4 在 Windows 上设置环境变量

设置环境变量的过程因操作系统而异。在 Windows 上,您可以通过 Windows 命令行使用 setx 命令来设置环境变量,后跟所需的键值对,如下所示:

\> setx [KEY] [VALUE]
\> setx FlyingDutchmanAirlines_Database_Connection_String 
➥ "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial 
➥ Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User 
➥ ID=dev;Password=FlyingDutchmanAirlines1972!;
➥ MultipleActiveResultSets=False;Encrypt=True;
➥ TrustServerCertificate=False;Connection Timeout=30;"

如果成功,命令行会报告值已成功保存(SUCCESS: Specified value was saved.)。要验证环境变量已保存,请启动一个新的命令行(新设置的环境变量不会在活动命令行会话中显示),并运行环境变量的 echo 命令。如果您没有看到环境变量如以下所示出现,您可能需要重新启动您的机器:

\> echo %FlyingDutchmanAirlines_Database_Connection_String%

如果一切顺利,echo命令应该返回环境变量的值(在这种情况下,我们的连接字符串)。现在我们可以在我们的服务中使用这个环境变量了!

5.3.5 在 macOS 上设置环境变量

与 Windows 一样,我们在 macOS 上使用命令行环境设置环境变量:macOS 终端。在 macOS 上设置环境变量与在 Windows 上一样简单,如下所示:

\> export [KEY] [VALUE]
\> export FlyingDutchmanAirlines_Database_Connection_String 
➥ "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial 
➥ Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User 
➥ ID=dev;Password=FlyingDutchmanAirlines1972!;
➥ MultipleActiveResultSets=False;Encrypt=True;
➥ TrustServerCertificate=False;Connection Timeout=30;"

您也可以在 macOS 上使用echo来验证,如下所示:

\> echo $FlyingDutchmanAirlines_Database_Connection_String

在 macOS 上,当我们运行服务并尝试在通过 Visual Studio 调试代码库时获取环境变量时,事情变得有些复杂。在 macOS 上,通过命令行定义的环境变量不会自动对 Visual Studio 等 GUI 应用程序可用。解决方案是使用 macOS 终端启动 Visual Studio,或者将环境变量添加到 Visual Studio 的运行时配置中。

5.3.6 在运行时在您的代码中检索环境变量

设置环境变量后,我们现在可以在代码中获取它。我们希望在OnConfigure方法中获取它,而不是硬编码连接字符串。我们可以使用Environment.GetEnvironmentVariable方法来完成这个任务。因为Environment.GetEnvironmentVariable如果找不到环境变量会返回 null 值,所以我们使用空合并运算符(??)将其设置为空字符串,如下所示:

protected override void OnConfiguring(DbContextOptionsBuilder
➥ optionsBuilder)  {
  if(!optionsBuilder.IsConfigured) {
 string connectionString = Environment.GetEnvironmentVariable(
➥ "FlyingDutchmanAirlines_Database_Connection_String") 
➥ ?? string.Empty;
 optionsBuilder.UseSqlServer(connectionString);
  }
}

我们可以以几种不同的方式处理null情况(最显著的是通过使用条件语句或将GetEnvironmentVariable调用与空合并运算符内联到UseSqlServer方法中),但这是我的首选方式。它既易于阅读又简洁。通过这个小技巧,我们提高了应用程序的安全性十倍,尤其是当你考虑到硬编码的连接字符串提交到源代码管理系统中可能引起的问题。

我们尚未在FlyingDutchmanAirlinesContext中涉及到的代码是OnModelCreating方法,如下一列表所示。

列表 5.5 FlyingDutchmanAirlinesContext OnModelCreating

protected override void OnModelCreating(ModelBuilder modelBuilder) {   ❶
  modelBuilder.Entity<Airport>(entity => {                             ❷
    entity.Property(e => e.AirportId)                                  ❷
      .HasColumnName("AirportID")                                      ❷
      .ValueGeneratedNever();                                          ❷

    entity.Property(e => e.City)                                       ❷
      .IsRequired()                                                    ❷
      .HasMaxLength(50)                                                ❷
      .IsUnicode(false)                                                ❷

    entity.Property(e => e.Iata)                                       ❷
      .IsRequired()                                                    ❷
      .HasColumnName("IATA")                                           ❷
      .HasMaxLength(3)                                                 ❷
      .IsUnicode(false)                                                ❷
    });                                                                ❷

  modelBuilder.Entity<Booking>(entity =>  {                            ❸
    entity.Property(e => e.BookingId).HasColumnName("BookingID");      ❸

    entity.Property(e => e.CustomerId).HasColumnName("CustomerID");    ❸

    entity.HasOne(d => d.Customer)                                     ❸
      .WithMany(p => p.Booking)                                        ❸
      .HasForeignKey(d => d.CustomerId)                                ❸
      .HasConstraingName("FK__Booking__Custome_71D1E811");             ❸

    entity.HasOne(d => d.FlightNumberNavigation)                       ❸
      .WithMany(p => p.Booking)                                        ❸
      .HasForeignKey(d => d.FlightNumber)                              ❸
      .OnDelete(DeleteBehavior.ClientSetNull)                          ❸
      .HasConstraintName(“FK__Booking__FlightN__4F7CD00D”);            ❸
  });                                                                  ❸
  modelBuilder.Entity<Customer>(entity => {                            ❹
    entity.Property(e => e.CustomerId)                                 ❹
      .HasColumnName("CustomerID")                                     ❹

      entity.Property(e => e.Name)                                     ❹
      .IsRequired()                                                    ❹
      .HasMaxLength(50)                                                ❹
      .IsUnicode(false)                                                ❹
    });                                                                ❹

  modelBuilder.Entity<Flight>(entity => {                              ❺
    entity.HasKey(e => e.FlightNumber);                                ❺

    entity.Property(e => e.FlightNumber).ValueGeneratedNever();        ❺

    entity.HasOne(d => d.DestinationNavigation)                        ❺
      .WithMany(p => p.FlightDestinationNavigation)                    ❺
      .HasForeignKey(d => d.Destination)                               ❺
      .OnDelete(DeleteBehavior.ClientSetNull)                          ❺
      .HasConstraintName("FK_Flight_AirportDestination");              ❺

    entity.HasOne(d => d.OriginNavigation)                             ❺
      .WithMany(p => p.FlightOriginNavigation)                         ❺
      .HasForeignKey(d => d.Origin)                                    ❺
      .OnDelete(DeleteBehavior.ClientSetNull);                         ❺
    });                                                                ❺

    OnModelCreatingPartial(modelBuilder);                              ❻
}

partial void OnModelCreatingPartial(ModelBuilder modelBuilder);        ❼

❶ 覆盖基类的OnModelCreating方法

❷ 准备 EF Core 使用Airport模型

❸ 准备 EF Core 使用Booking模型

❹ 准备 EF Core 使用Customer模型

❺ 准备 EF Core 使用Flight模型

❻ 调用部分OnModelCreatingPartial方法

❼ 定义部分OnModelCreatingPartial方法

注意,由于它们是自动生成的,系统上的确切约束名称可能会有所不同。OnModelCreating方法在内部为 Entity Framework Core 设置实体,包括在数据库架构中定义的关键约束。这允许我们在不直接与数据库交互的情况下操作实体(这正是 Entity Framework Core 的整个想法)。生成的(以及对其的调用)方法也称为OnModelCreatingPartial。Entity Framework Core 控制台工具集生成了OnModelCreatingPartial方法,因此您可以在模型创建过程中执行额外的逻辑。我们不会这样做,因此我们可以删除OnModelCreatingPartial方法和对其的调用。但请注意,如果您必须重新运行反向工程过程(或任何其他代码生成工具),您的更改将被覆盖。

练习

练习 5.1

如果我们想要防止某人从类中派生,我们需要将哪个关键字附加到类上?

a. 虚拟的

b. 密封的

c. 受保护的

练习 5.2

如果我们想要允许某人重写属性或方法,我们应该附加哪个关键字?

a. 虚拟的

b. 密封的

c. 受保护的

练习 5.3

填空题:“一个 __________ 是运行 Web 服务的底层进程。它反过来又存在于 __________ 内。”

a. host

b. Tomcat

c. JVM

d. CLR

练习 5.4

真或假?在使用Startup类时,您需要将其注册到Host

练习 5.5

尝试自己来做:编写一个表达式体风格的方法,接受两个整数并返回它们的乘积。这是一个单行解决方案。提示:考虑 lambda。

练习 5.6

在仓库/服务模式的情况下,应该有多少个控制器、服务和仓库层?

摘要

  • 我们可以通过使用命令行中的预定义模板(如consolemvc)来创建 .NET 5 解决方案和项目。模板是轻松创建常见解决方案和项目风味的方法。

  • restore操作是获取项目编译所需的所有必要依赖项的操作。

  • 我们可以通过使用dotnet sln [SOLUTION] add [PROJECT]命令将一个项目添加到解决方案中。

  • Host 是一个在 CLR 内运行的进程,它运行 Web 应用程序,提供 CLR 和用户之间的接口。

  • 可以使用类似于 lambda 表达式的语法简洁地编写仅返回表达式值的函数。这被称为表达式体方法,可以使我们的代码更易读。

  • Startup类中,我们可以设置路由并允许使用控制器和端点。这对于 MVC 网络应用程序来说很重要,因为它允许我们调用端点并使用控制器概念。

  • 仓库/服务模式包含多个仓库、服务和控制器(每个实体一个)。这个易于遵循的范例帮助我们控制数据流。

  • Entity Framework Core 是一种强大的对象关系映射工具,可以通过构建脚本来逆向工程已部署的数据库。这为开发者节省了大量时间,并允许数据库和代码库之间实现近乎完美的同构关系。

  • 使用 partial 关键字来定义实现分散在多个字段中的类和方法。partial 关键字通常被自动代码生成器使用。

  • 当声明某个属性、字段或方法为 virtual 时,你表示这个属性、字段或方法可以被安全地重写。这在平衡扩展性和代码库的纯洁性需求时非常有用。

  • 通过在方法或属性签名中添加 new 关键字,你可以“隐藏”非虚拟属性和方法。

  • 当一个类被密封时,你不能从它继承。实际上,密封一个类阻止了任何类从它继承。当你确信你的类是继承链的最低级别,并且你想要防止对代码的篡改时,密封类变得很有用。

  • 环境变量是在操作系统中设置的键值对。它们可以存储敏感数据,例如连接字符串或密码。


^ (1.) Visual Studio 的安装说明可以在附录 C 中找到。如果你想了解更多关于 Visual Studio 的信息,请参阅 Bruce Johnson 的 Professional Visual Studio 2017 (Wrox, 2017) 和 Johnson 的 Essential Visual Studio 2019 (Apress, 2020)。免责声明:作者曾是 Essential Visual Studio 2019: Boosting Development Productivity with Containers, Git, and Azure Tools. 的技术审稿人。

第四部分 仓储层

在第三部分,我们开始了下一代飞荷兰人航空公司服务的实现。我们创建了一个新的.NET 5 项目并编写了一个数据库访问层。在本部分,我们将实现仓储层类。在接下来的章节中,你将了解测试驱动开发、自定义比较类、泛型、扩展方法以及更多内容。

6 测试驱动开发和依赖注入

本章涵盖

  • 使用锁、互斥锁和信号量

  • 在同步和异步方法之间进行转换

  • 使用单元测试进行依赖注入

在第三章和第四章中,我们审查了继承的代码库并讨论了潜在的改进。为了解决我们发现的问题,我们在第五章启动了新的飞剪航空公司服务,并实现了使用 Entity Framework Core 的数据库访问层。在本章中,我们将开始通过进入仓库层并创建CustomerRepository类来实现业务逻辑。图 6.1 显示了我们在本书结构中的位置。

图 6.1

图 6.1 在第五章实现了数据库访问层之后,我们将继续在本章实现CustomerRepository

仓库层是我们服务的关键部分。在仓库层,我们做以下两件事:

  1. 通过与数据库访问层通信查询和操作数据库

  2. 将请求的实体或信息返回到服务层

我们希望创建独立、小巧、清洁且易于阅读的方法,这些方法遵循单一职责原则(SRP)。遵循 SRP 使得测试和维护我们的代码更加容易,因为我们能够快速理解每个方法并修复任何潜在的错误。

点赞

单一职责原则

由罗伯特·马丁(Robert Martin)提出的清洁代码原则之一,单一职责原则(SRP)是清洁代码的和谐之音:容易演奏,但要精通则需要多年的实践。SRP 建立在 Edsger Dijkstra 在其论文《论科学思维的作用》中宣扬(并创造)的“关注点分离”概念之上。^a 在实践中,SRP 意味着一个方法应该只做一件事,并且要做好。这回到了我们在本书早期讨论的怪物方法。更正式地说,根据马丁在 2014 年发布的一篇博客文章(blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html),SRP 声明“每个软件模块应该只有一个且仅有一个改变的理由。”

回到实际术语,你如何知道你是否违反了 SRP?我发现最简单的方法是问问自己你是否在方法中做了多于一件事情。如果你需要在方法的解释或方法名称中使用“和”这个词,那通常意味着你违反了 SRP。SRP 与 Liskov 原则紧密相关,我将在第八章中讨论这一点。


^a 埃德加·迪杰斯特拉用钢笔写他的论文,并给它们编号“EWD [N]”,其中 N 是论文的编号(EWD 代表他的全名:Edsger Wybe Dijkstra)。《论科学思想的作用》是 EWD 447,可以在迪杰斯特拉的《关于计算的选择作品:个人视角》(Springer-Verlag,1982 年)中找到。

|

当我们考虑第三章和第四章中查看的现有代码库中的端点时,我们发现许多方法执行了多项操作。它们调用多个数据库表并执行多项处理任务。我们希望将这些操作分离成独立的方法,以便重用代码并确保代码质量。

6.1 测试驱动开发

使用测试驱动开发(TDD)来实现你的代码,这使你与许多开发者区分开来。你的代码将比那些不使用 TDD 的同事的代码更健壮、测试更完善。如果你从未使用过 TDD,我将指导你通过实际使用 TDD 的过程。测试驱动开发在最基本层面上,是在编写实现你试图测试的代码之前编写单元测试的实践。你同时更新测试和代码,并同步构建它们,这促进了良好的设计和坚实的代码,因为反馈循环紧密,你将敏锐地意识到任何破坏测试的代码。在本节中,我们将使用 TDD 为 CustomerRepository 编写单元测试。

注意:在这本书中,我实践了我喜欢称之为 TDD-light 的方法。从理论上讲,你应该在编写任何实际逻辑之前编写测试。然而,在实践中,人们通常不太这样做。你将在整本书中看到这种方法。它不是“纯 TDD”,但它是平衡 TDD 的工作量和快速迭代的一个实用解决方案。

要进行 TDD(或任何类型的测试),我们应该在我们的解决方案中创建一个测试项目。我们通过使用第五章中使用的模板,并将新的引用添加到解决方案中,该引用指向新的 csproj 文件来完成此操作,如下所示:

\> dotnet new mstest -n FlyingDutchmanAirlines_Tests
\> dotnet sln FlyingDutchmanAirlinesNextGen.sln add 
➥ FlyingDutchmanAirlines_Tests\FlyingDutchmanAirlines_Tests.csproj

现在,在我们的解决方案中,有一个在 MSTest 平台上运行的测试项目。除了 MSTest 之外,C# 还存在许多支持的测试框架,例如 xUnit 和 NUnit。本书中我们将使用 MSTest,因为 MSTest 是随 .NET 一起提供的。

新项目还包含一个自动生成的单元测试文件,名为 UnitTest1.cs,如下所示:

using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace FlyingDutchmanAirlines_Tests {
  [TestClass]
  public class UnitTest1 {
    [TestMethod]
    public void TestMethod1() { }
  }
}

注意:如果你的测试类没有public访问修饰符,MSTest 运行器将无法找到该类,因此类内的测试将不会运行。

在本章中,我们将使用 UnitTest1.cs 文件,并根据我们对第一个仓库的需求对其进行修改:CustomerRepository。为什么从CustomerRepository开始,而不是从FlightRepositoryBookingRepository开始呢?为了刷新我们的记忆,当我们完成它们后,我们将为每个数据库模型(客户、航班、机场和预订)有一个仓库。在这些仓库中,我们执行创建、读取、更新和删除(CRUD)操作,这些操作查询或操作数据库。Customer实体没有任何外键约束,所以我们不太可能陷入需要在我们完成真正想要工作的仓库之前创建不同实体的仓库的兔子洞。根据我的经验,从最低层(最嵌套/最少外键约束)的实体开始工作到最高层更容易。当你到达约束最多的实体时,你已经完成了所有的依赖。这与我们在第五章中为什么从数据库访问层而不是控制器层开始实现我们的下一代服务的原因相同。

在我们编写第一个单元测试之前,让我们创建仓库类和第一个方法CreateCustomer的框架:CreateCustomer方法接受一个类型为string的输入,表示客户的姓名,验证该输入,并将新实体插入到数据库中。CustomerRepository位于 FlyingDutchmanAirlines 项目中的新文件夹 RepositoryLayer 中,如下所示:

namespace FlyingDutchmanAirlines.RepositoryLayer {
  public class CustomerRepository {
    public void CreateCustomer(string name) { }
  }
}

目前CustomerRepository看起来并不多——只是一个类声明和一个方法,两者都具有public访问修饰符——但这足以让我们开始我们的第一个单元测试。遵循 TDD 传统,我们遵循类似于图 6.2 中红色-绿色交通灯模式的二进制策略。

图 6.2 测试驱动开发的交通灯。我们从红色(编译问题和测试失败)到绿色(所有测试通过且代码编译)再回到红色,形成一个恶性循环。这促进了迭代式开发生命周期。

使用 TDD 交通灯,我们不断地从“红色”阶段(我们的测试无法编译或通过)到“绿色”阶段(一切顺利,我们可以实现一些代码)。这种工作流程是测试驱动开发的核心优势。

让我们切换回我们的测试项目。如果我们想在FlyingDutchmanAirlines中调用任何方法,我们需要添加对该项目的引用。我们可以运行一个类似于我们在第五章中将 FlyingDutchmanAirlines.csproj 添加到 FlyingDutchmanAirlinesNextGen.sln 的命令,如下所示:

\> dotnet add 
➥ FlyingDutchmanAirlines_Tests/FlyingDutchmanAirlines_Tests.csproj 
➥ reference FlyingDutchmanAirlines/FlyingDutchmanAirlines.csproj

然后,我们可以将 UnitTest1.cs 重命名为 CustomerRepositoryTests.cs,并将命名空间和方法名更改为更合适的内容。现在我们可以实例化我们的CustomerRepository类,并调用我们新的CreateCustomer方法,如下所示:

using FlyingDutchmanAirlines.RepositoryLayer;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace FlyingDutchmanAirlines_Tests.RepositoryLayer {
  [TestClass]
  public class CustomerRepositoryTests {
    [TestMethod]
    public void CreateCustomer_Success() {
      CustomerRepository repository = new CustomerRepository();
    }
  }
}

这个测试实际上还没有测试任何东西,所以在我们继续之前,让我们添加所有断言中最简单的一个:即repository不应该为null。你可能会说这只是验证了一个语言特性,而不是我们的代码,因为我们只是调用默认构造函数,是的,你是正确的。然而,在我的想法中,测试构造函数仍然是有价值的,因为你永远不知道有人是否会将实现更改为没有参数的显式构造函数,或者做其他意想不到的事情。

要使用 MSTest 框架添加断言,使用以下模式Assert .Assertion.[TestMethod],如下所示:

public void CreateCustomer_Success() {
  CustomerRepository repository = new CustomerRepository();
  Assert.IsNotNull(repository);
}

要运行我们的单元测试,我们可以使用 Visual Studio 的单元测试资源管理器,或者我们可以通过命令行调用测试框架。无论如何,我们都需要先编译代码,然后执行测试。要在命令行中运行解决方案中的所有测试,请使用以下命令:

\> dotnet test

如果你通过 Visual Studio(如果你使用 Visual Studio)运行本书中的测试时遇到麻烦,请尝试使用dotnet test

一旦测试运行完成,我们看到我们的第一个测试通过了,如图 6.3 所示。然而,请注意,单元测试旨在与其他测试隔离,并且只测试单个方法。因此,MSTest 运行器执行测试的顺序不保证是顺序的或会从会话到会话保持一致。

图片

图 6.3 MSTest 框架运行我们的单元测试,并且通过了。确保每次更改后测试都通过,有助于尽早而不是更晚地捕捉到错误。

练习

练习 6.1

单一职责原则倡导的是什么?

a. 所有方法名应该只有一个单词长。

b. 不要在两个不同的地方执行相同的逻辑。

c. 让你的方法只做一件事。

练习 6.2

真的是假的?在使用测试驱动开发时,你会在实现前后编写测试。

练习 6.3

真的是假的?只要测试类有一个internal访问修饰符,测试运行器就可以看到你的测试类。

6.2 创建客户方法

在本节中,我们将实现CustomerRepository类中的CreateCustomer方法。我们希望接受一个表示客户名称的string类型的参数,并返回一个布尔值,指示客户是否成功添加到数据库中。

在现有的代码库(在第三章和第四章中讨论过),一个名为FlightController.Post的庞大方法将一个Customer实例添加到数据库中。Post方法大约有 80 行长,并且还执行了获取机场详情的逻辑。它还检索并预订了航班。在方法中做过多的事情违反了单一职责原则。FlightController.Post方法不仅仅做了一件事(正如原则所规定的那样);相反,它做了很多事情。实际创建客户的代码只有八行长,如下一列表所示。

列表 6.1 旧代码库处理在数据库中创建客户的方式

// Create new customer                                                ❶
cmd = new SqlCommand("SELECT COUNT(*) FROM Customer", connection);    ❷
var newCustomerID = 0;
using(var reader = cmd.ExecuteReader()) {                             ❸
  while (reader.Read()) {                                             ❹
    newCustomerID = reader.GetInt32(0);                               ❹
    break;                                                            ❹
  }                                                                   ❹
}

❶ 我们应该编写足够清晰的代码,无需注释。

❷ 在此代码片段之前实例化了连接变量。

❸ 使用 using 语句是一个很好的用法,但使用了隐式类型

❹ 将数据库的返回值读取到 ID 变量中

列表 6.1 中的代码片段并不是我们见过的最糟糕的,但我们确实可以做出以下改进:

  • 我们的代码应该是自文档化的。对于不熟悉逻辑的人来说,应该能够阅读代码并大致了解正在发生的事情。我们应该删除注释。

  • 使用硬编码的 SQL 语句可能会成为维护服务的潜在障碍。如果数据库模式发生变化,我们现在需要更改 SQL 查询。使用 ORM 工具(如 Entity Framework Core)来抽象 SQL 查询更安全。

确定了这些改进后,让我们从我们的新实现开始,创建新的 CreateCustomer 方法的签名:

public bool CreateCustomer(string name) {}

我们的 CreateCustomer 方法目前没有实现任何实际逻辑,所以让我们改变一下。要在我们的数据库中创建客户条目,我们需要做以下四件事:

  1. 验证 name 的输入参数。

  2. 实例化一个新的 Customer 对象。

  3. 将新的 Customer 对象添加到 Entity Framework Core 的内部 DbSet<Customer>

  4. 告诉 Entity Framework Core 我们想要提交我们的更改。

我们在所有仓库方法中都遵循这个通用模式。保持一致性使得我们的代码库更容易阅读和维护,因为开发者可以依赖看到这个模式的预期。

6.2.1 为什么你应该始终验证输入参数

在一个理想的世界里,人们永远不会将 null 或无效参数传递到你的方法中。不幸的事实是,我们并不生活在一个理想的世界里。为了对抗他人的不可靠性,我们能做的最好的事情就是以身作则。如果我们认为一个方法不过是一个数学函数,我们可以将其视为一个黑盒,我们可以输入任何信息并返回一个可接受的输出。如果我们向这个黑盒中传递一个无效的值,并且假设另一个开发者已经在上游处理了验证,那么我们就麻烦了,正朝着运行时异常的领域迈进。

让我们通过考虑代表客户名称的 string 必须遵守哪些标准来验证我们的输入。首先,我认为我们可以安全地假设我们永远不会希望我们的名称字符串为 null。在 null 的情况下,我们应该通过返回一个 false 布尔值退出方法,表示我们无法使用给定的输入参数成功将新的 Customer 对象添加到数据库中,如下所示:

public bool CreateCustomer(string name) {
  if (string.IsNullOrEmpty(name)) {
    return false;
  }

  return true;
}
灯泡 IsNullorempty 作为 string 类的一部分,.NET 提供了 IsNullOrEmpty 方法。此方法返回一个布尔值,指示给定的字符串是否为 null 或空。

我们添加第二个返回语句是为了满足方法签名。如果我们没有return true语句,编译器会抛出一个错误,表示CreateCustomer方法中并非所有代码路径都返回bool类型的值。如果我们只是在CreateCustomer方法中根据输入验证返回bool类型值,我们就可以直接返回string.IsNullOrEmpty的结果布尔值。但是,唉,我们还有其他逻辑要包括。让我们更新现有的单元测试,即成功场景,调用CreateCustomer方法并传递一个有效的名字字符串,然后检查方法是否返回了 true 值,如下所示:

[TestMethod]
public void CreateCustomer_Success() {
     CustomerRepository repository = new CustomerRepository();
  Assert.IsNotNull(repository);

  bool result = repository.CreateCustomer("Donald Knuth");
  Assert.IsTrue(result);
}

运行测试;它应该通过。我们为我们的方法引入了以下两个新的返回分支:

  • name参数为 null。

  • name参数是一个空字符串。

我们应该添加单元测试来覆盖这些可能性。

6.2.2 使用“安排、行动、断言”编写单元测试

在本节中,我们将通过检查其核心测试哲学来深入了解测试驱动开发。我们还将继续编写CreateCustomer_Success单元测试,遵循我们在本书中迄今为止遵循的相同测试模式:实例化一个对象,调用它,并断言输出是正确的。本节考察了测试的“三个 A”:安排、行动和断言,如图 6.4 所示。

图片

图 6.4 “测试的三个 A”:安排、行动和断言。使用它们可以使我们以有组织和可预测的方式编写测试。

[TestMethod]
public void CreateCustomer_Failure_NameIsNull() {
  CustomerRepository repository = new CustomerRepository();
  Assert.IsNotNull(repository);

  bool result = repository.CreateCustomer(null);
  Assert.IsFalse(result);
}

[TestMethod]
public void CreateCustomer_Failure_NameIsEmptyString() {
  CustomerRepository repository = new CustomerRepository();
  Assert.IsNotNull(repository);
  bool result = repository.CreateCustomer("");
  Assert.IsFalse(result);
}
灯泡 空字符串""string.Empty都是描述空字符串的有效方式。实际上,string.Empty在底层解析为""。你可以使用任何一个,我喜欢使用string.Empty因为它更明确。在这本书中,我两者都使用。

就这样,我们已经有了三个测试。现在,当我们对方法进行任何进一步的更改时,我们可以运行这些测试,并确信现有的代码没有出错。

6.2.3 验证无效字符

在验证输入的任务清单中,第二项是检查name字符串的实际内容是否有无效字符。我们预计一个名字不会包含特殊字符,如下所示:

  • 感叹号:!

  • 脚标符号:@

  • 英镑符号:#

  • 美元符号:$

  • 百分比符号:%

  • 和号:&

  • 星号:*

我们不可能仅基于允许的字符来限制我们的允许字符集。可能的 Unicode 字符列表是巨大的,尤其是当你考虑到像越南语和亚美尼亚语这样的语言中字母的特殊符号时。那么我们如何检查特殊字符呢?

我们可以创建一个字符数组并遍历字符串,然后对字符串中的每个字符进行遍历。这样会占用很多行,而且效率也不高。¹ 我们也可以使用正则表达式(regex)与正则字符串进行匹配,但这对于我们的问题来说可能有些过度。最容易且最干净的方法是确定一个字符串是否包含指定的字符,就是指定一个包含禁止字符的数组,然后使用 LINQ 的 Any 方法遍历源字符串,传入一个 Action 来检查集合中的任何元素是否包含禁止字符集合中的元素。Any 方法检查一个表达式(通过 Action)是否对集合中的任何元素有效。LINQ 在第一次查看时可能难以理解,所以让我们一步一步地分解我们的 LINQ 代码:

char[] forbiddenCharacters = {'!', '@', '#', '$', '%', '&', '*'};
bool containsInvalidCharacters = name.Any(x => 
➥ forbiddenCharacters.Contains(x));

虽然使用 Any LINQ 方法和字符数组方法的时间复杂度与前面描述的嵌套 for 循环相同(因为我们基本上是在其上添加了一些语法糖),但它更易于阅读,也更符合 C# 的习惯用法。LINQ(Language-Integrated Query)是 C# 中的一种编程语言,允许我们执行(并链式)操作来查询和更改集合。在这里,我们使用正常的 C# 语法调用 LINQ 库中的方法(Any)。

首先,我们声明、初始化并分配一个类型为 char 数组的变量,并称它为 forbiddenCharacters。这个数组包含我们不允许的字符。其次,我们初始化一个名为 containsInvalidCharacters 的布尔变量,并将其分配给我们的 LINQ 查询的结果。我们可以将 LINQ 查询读作一个叙述:“如果 name 字符串中的任何元素包含 forbiddenCharacters 集合中的字符,则返回 false,否则返回 true。”

Any 方法的调用如果传入的表达式对于集合中的任何值(在本例中为 name 字符串中的任何字符)结果为 true,则返回 true。我们通过 lambda 表达式传入一个要评估的表达式。我们使用 forbiddenCharacters 上的 Contains 方法来评估 forbiddenCharacters 集合是否包含传入的值。结合 Any 调用,这意味着如果我们评估 Contains 调用为 true(对于代表禁止字符的字符来说就是 true),这也意味着 Any 返回 true,这意味着字符串中存在禁止字符。

我们可以直接在我们的条件之后放置禁止字符代码来检查 name 字符串是否为 null 或空,甚至将其内联到条件中,但我倾向于采用不同的方法。如果我说这些是实现细节,对于普通读者来说没有必要深入了解,那么我们应该把代码放在哪里?没错,应该放在一个单独的 private 方法中。

我们应该将IsNullOrEmpty条件提取到它自己的方法中,并添加无效字符代码。我们可以将此方法命名为IsInvalidCustomerName,并使其返回一个布尔值(注意,我们还需要导入System.Linq命名空间以使用 LINQ 查询),如下一列表所示。

列出 6.2 CustomerRepository.cs 中提取的 IsInvalidCustomerName

using System.Linq;

namespace FlyingDutchmanAirlines.RepositoryLayer {
  public class CustomerRepository {
    public bool CreateCustomer(string name) {
      if (IsInvalidCustomerName(name)) {
        return false;
      }

      return true;
    }
  }

  private bool IsInvalidCustomerName(string name) {
    char[] forbiddenCharacters = {'!', '@', '#', '$', '%', '&', '*'   };
    return string.IsNullOrEmpty(name) || name.Any(x => 
➥ forbiddenCharacters.Contains(x));
  }
}

如列表 6.2 所示,我们将代码提取到它自己的独立方法中。我们还根据组合条件和 LINQ 查询的结果立即返回基于布尔值。|

灯泡 短路和逻辑运算符另一种方法是使用“排他或”运算符(XOR,^)而不是条件逻辑或运算符(&#124;&#124;)。XOR 运算符在只有一个选项为真时返回 true。如果IsNullOrEmptyAny Contains检查都有效,那么就真的有些奇怪了(一个字符串不能既为 null 或空又包含无效字符),因此使用 XOR 运算符可能对我们有用。因为 XOR 是一个“逻辑”运算符,它在返回 true 或 false 的判断之前会评估等式的两边。逻辑运算符可能比条件运算符(如&#124;&#124;)性能低,因为如果等式的右边是 false,逻辑运算符不会评估等式的左边。这也被称为“短路评估”。

回到forbiddenCharacters数组,一个注重内存的读者可能会反对并说:“当name可能为null时,你有可能会分配内存给forbiddenCharacters数组,但你可能永远不会使用它。”对于这个反对意见,我会同意你的事实陈述,但也会反驳说,这是为了可读性而付出的微小代价。

我们几乎实现了我们的第一个目标:验证name的输入参数。逻辑已经到位,但我们还没有为这个新的逻辑编写单元测试。这并不符合我们的 TDD(测试驱动开发)原则。我们如何为这个新的逻辑编写测试?我们是否只测试新的private方法,还是我们也想测试调用private方法的剩余CreateCustomer方法?

我们不想直接测试任何private方法。在一个理想的世界里,所有private方法都是由一个public方法调用的(这可能是直接或通过另一个private方法间接调用)并且通过那个public方法进行测试。因为我们已经通过我们的通用CreateCustomer成功情况测试测试了成功情况,所以我们不需要创建另一个成功情况(或“happy path”)测试。然而,我们需要一个测试来测试失败情况。

6.2.4 使用 [DataRow] 属性内联测试数据

我们希望测试所有无效字符,如果我们为每个字符都编写一个单元测试,那么就需要我们编写N个测试,其中N是无效字符的数量。这将是一项大量工作,但回报甚微。幸运的是,MSTest 有[DataRow]属性,我们可以与 MSTest 平台一起使用。我们可以使用[DataRow]来指定测试方法的输入参数,如下一列表所示。这允许我们只需添加大量[DataRow]属性到单个测试中。

列表 6.3 CreateCustomer_Failure_NameContainsInvalidCharacters 使用 [DataRow]

[TestMethod]
[DataRow('#')]
[DataRow('$')]
[DataRow('%')]
[DataRow('&')]
[DataRow('*')]
public void CreateCustomer_Failure_NameContainsInvalidCharacters(char 
➥ invalidCharacter) {
  CustomerRepository repository = new CustomerRepository();
  Assert.IsNotNull(repository);

  bool result = repository.CreateCustomer("Donald Knuth" + 
➥ invalidCharacter);
  Assert.IsFalse(result);
}

列表 6.3 中的测试在包含全名“Donald Knuth”后跟一个无效字符(如[DataRow]属性所规定的)的字符串中通过,例如:"Donald Knuth%"。将"Donald Knuth%"用作CreateCustomer方法的输入参数返回一个false布尔值,我们对它进行断言。如果我们现在运行测试,我们可以看到一切通过,我们回到了代码库的良好覆盖率。

当我在测试的上下文中谈论覆盖率时,我并不是指测试覆盖了你代码的百分比。有关代码覆盖和单元测试的更多信息,请参阅 Vladimir Khorikov 的《单元测试原则、实践和模式》(Manning,2020)²和 Roy Osherove 的《单元测试的艺术》(第 3 版;Manning,2020)。

6.2.5 对象初始化器和自动生成代码

回到CustomerRepository中的CreateCustomer方法,我们准备处理列表中的下一个项目:“实例化一个新的Customer对象”,如下一代码列表所示。

列表 6.4 CustomerRepository.cs CreateCustomer 创建新客户

Customer newCustomer = new Customer();
newCustomer.Name = name;

我们可以通过使用所谓的“对象初始化器”来稍微清理列表 6.4 中的代码。使用对象初始化器允许你在创建实例时直接设置字段值,如下所示:

Customer newCustomer = new Customer() {
  Name = name
};

对象初始化器非常适合需要手动设置值的情况,但如果新来的开发者不小心将代码更改得没有设置该名称值;或者,也许出于某种原因,某人在代码的其他地方创建了一个类型为Customer的实例,而不知道他们应该将该属性设置为有效值呢?

也许我们控制对象如何实例化会更好。我们可以通过强制使用一个接受类型为string的名称参数的构造函数来定义Customer的实例化方式。但首先我们需要通过查看下一个显示的Customer.cs类来验证我们是否可以无问题地添加一个新的构造函数:

using System;
using System.Collections.Generic;

namespace FlyingDutchmanAirlines.DatabaseLayer.Models {
  public partial class Customer {
    public Customer() {
      Booking = new HashSet<Booking>();
    }

    public int CustomerId { get; set; }
    public string Name { get; set; }

    public virtual ICollection<Booking> Booking { get; set; }
  }
}

Customer类完全由 Entity Framework Core 自动生成。它映射到我们数据库中的客户表,并包含一个Booking列表。Entity Framework Core 创建了这个列表,因为它在数据库中找到了相关的外键约束。在我的理想世界中,属性和字段应该在构造函数之前,这样在浏览类时就可以一目了然,但在 Entity Framework Core 自动生成的文件中并非如此。如果您愿意,您可以重新组织您的文件以反映该模式。在这本书中,我将所有模型重新组织为那种风格。我们可以在列表 6.5 中看到重新组织的结果。我们还从模型的相应类签名中移除了partial关键字。我们可以这样做,因为我们不会使用partial功能,养成移除已知不会使用的代码的习惯更安全。移除未使用的代码可以提高代码的整洁性,并且将来阅读您类的人会感谢您。许多开发者陷入了保留“他们可能以后会使用/需要”的代码的陷阱。在我看来,这只会使代码库变得杂乱。

列表 6.5 Customer.cs(EF Core 生成并重新组织)

using System;
using System.Collections.Generic;

namespace FlyingDutchmanAirlines.DatabaseLayer.Models {
  public class Customer {
    public int CustomerId { get; set; }
    public string Name { get; set; }

    public virtual ICollection<Booking> Booking { get; set; }

    public Customer() {
      Booking = new HashSet<Booking>();
    }
  }
}

6.2.6 构造函数、反射和异步编程

我们已经为Customer类提供了一个构造函数。它不接受任何参数,但将一个新的HashSetBooking实例分配给Booking属性。我们想要保留这个分配,因为引用类型不会默认为零值(在这种情况下是空集合)。相反,它默认为null

注意:您可以通过使用default关键字而不是值来显式地为任何类型分配默认值。这在处理非原始值类型时可能很有用,其中默认值可能对您来说是未知的。引用类型始终具有null的默认值。

我们不想传递一个类型为HashSet<Booking>的参数。我们希望让 Entity Framework Core 处理任何键约束。但是,我们确实希望有一个类型为string的参数,反映客户的姓名。此外,我们还应该确保没有人可以从我们的Customer对象继承,并因此使用多态将其添加到数据库中。因此,我们使用sealed关键字密封我们的类。密封一个类意味着我们需要从Booking属性中移除virtual关键字,因为在密封类中不能有虚拟成员或属性。我们还应该按照以下方式密封代码库中的其他模型:

using System;
using System.Collections.Generic;

namespace FlyingDutchmanAirlines.DatabaseLayer.Models {
  public sealed class Customer {
    public int CustomerId { get; set; }
    public string Name { get; set; }

    public ICollection<Booking> Booking { get; set; }

    public Customer(string name) {
      Booking = new HashSet<Booking>();
      Name = name;
    }
  }
}

当我们尝试编译代码时,我们得到一个编译错误,因为我们没有在CustomerRepository中实例化Customer对象时传递所需的参数。实际上,我们仍在使用对象初始化器。让我们按照以下方式修复它:

Customer newCustomer = new Customer(name);

我们现在可以编译,而且我们的测试仍然通过。我们列表上的第三项是将新的Customer对象添加到 Entity Framework Core 的内部DbSet<Customer>。为什么我们需要这样做?如前所述,Entity Framework Core 假设对数据库的任何更改首先是对内存数据集的更改。为了将类型为Customer的新对象添加到数据库,我们首先必须将其添加到内存中的DbSet<Customer>。要访问DbSet,我们需要创建数据库上下文类的新实例。

我们可以使用DbContext上的两个方法将模型添加到DbSetAdd[Entity].Add。如果我们调用通用的Add方法,C#将使用反射来确定实体类型并将其添加到正确的集合中。我更喜欢使用显式的[Entity].Add,因为它消除了歧义并节省了一些开销(反射非常昂贵!)。

反射

反射是 C#中一种强大的技术,用于在运行时访问有关程序集、类型和模块的信息。在实践中,这意味着你可以在执行代码时找出对象的类型或更改其一些属性。然而,你可以使用反射做更多的事情。机会是惊人的。

例如,你可以使用反射来创建自定义方法属性、创建新类型或在不知道文件名的情况下调用文件中的代码,所有这些都可以在运行时完成。你甚至可以访问外部类中的私有字段(但请勿这样做;尊重开发者的访问指南)。

如你所想,反射在内存和 CPU 周期方面并不是最便宜的执行方式。为了执行其一些操作,它必须在内存中加载并跟踪大量的元数据。想象一下在运行时检测未知对象类型所需的处理量。库和框架通常不能对其操作的对象类型做出假设,因此它们使用反射来收集元数据并根据这些数据做出决策。

在使用反射之前,先反思一下你的用例。

因为DbContext类实现了IDisposable接口,所以我们需要正确地处理它的释放。DbContext类需要是可释放的,因为它可以无限期地持有连接对象。最后,为了提交并保存我们对数据库的引用更改,我们在上下文中调用SaveChangesAsync方法,如下所示:

using (FlyingDutchmanAirlinesContext context = new 
➥ FlyingDutchmanAirlinesContext()) {
  context.Customer.Add(newCustomer);
  context.SaveChangesAsync();
}

这段小代码片段就是 Entity Framework Core 的核心所在。如果我们没有 Entity Framework Core(或不同的 ORM 工具)的抽象,我们就必须实例化一个 SQL 连接,打开它,编写实际的 SQL 来插入新的客户,然后执行该查询。完成这个任务的代码比我们现在写的要复杂和长。

然而,这段代码有一个小问题:我们调用了一个异步方法,但却是同步地执行它。对于这个特定的方法,构建代码时不会抛出编译错误,因为它同步地保存了更改。要将同步方法转换为异步方法,我们需要遵循三个步骤:

  1. 在我们执行并等待的方法调用上使用 await 关键字。

  2. 从方法中返回一个 Task 类型的对象。

  3. async 关键字添加到方法签名中。

要不等待(换句话说,异步执行)一个方法,C# 使用 await 关键字。人们经常将异步编程与多线程编程混淆。它们之间有一个很大的区别:异步编程允许我们同时执行多项任务,一旦执行完成就返回。多线程编程通常指的是并行运行多组逻辑,利用额外的线程来加快我们的代码。

6.2.7 锁、互斥锁和信号量

锁定资源和控制线程访问是许多软件工程师存在的噩梦。一旦处理多个线程,代码的复杂性就会激增,因为错误可能出现的地点数量急剧增加。为了减轻开发者的负担,C# 提供了一个语句(lock)和两种主要的同步原语类型来帮助你:互斥锁和信号量。

它们之间有什么区别,你会在什么情况下使用一个而不是另一个?最容易使用的是标准的 lock 语句。要使用 lock 语句锁定资源并允许一次只有一个线程操作它,请使用以下语法 lock([RESOURCE]){...}

decimal netWorth = 1000000;
lock(netWorth) {
  ...
}

netWorth 变量在锁代码块期间(代码块离开后,锁被释放)被锁定,并且一次只能由一个线程访问。还值得注意的是,lock 语句禁止两个线程在相同时刻锁定相同的资源。如果两个线程能够在同一时刻实例化一个锁,那么锁将无法履行其“一次一个线程”的承诺。这就是我们所说的死锁:两个线程持有相同的资源,等待对方释放该资源。显然,我们试图在我们的代码中避免死锁,因为它们难以调试。

我们可以将水闸锁做一个类比:为了在运河的标高变化中提升和降低船只,我们使用运河锁。当一艘船排在运河锁的第二个位置时,另一艘船会使用运河锁。因此,初始船只“拥有”并锁定了运河锁。只有当初始船只离开运河锁(资源)时,运河锁才会被释放并回到可用状态。第二艘船现在可以进入并使用运河锁。当模拟处理像运河锁这样的关键系统队列系统时,程序锁非常有用。

lock语句在锁定特定进程内的属性(例如,正在运行的程序)方面工作得非常好。如果您想要锁定跨多个进程的资源(例如,您的程序多个实例运行并与其实例交互),请使用互斥锁。当您完成使用互斥锁后,您必须显式地释放它。这个额外的冗余使得互斥锁比锁更容易开发。

使用互斥锁进行跨进程线程控制

与锁不同,我们不需要关键字来使用互斥锁。相反,我们实例化Mutex类的静态实例。为什么是静态的?互斥锁是跨进程和跨线程的,所以我们希望整个应用程序只有一个实例。锁和互斥锁之间的重要区别是我们不会在属性上放置互斥锁。相反,我们在方法中放置互斥锁,并使用它们来控制方法的执行。当一个线程遇到带有互斥锁的方法时,互斥锁会通过使用WaitOne方法告诉线程它必须等待它的轮次。要释放互斥锁,请使用ReleaseMutex方法,如下所示:

private static readonly Mutex _mutex = new Mutex();
public void ImportantMethod() {
  _mutex.WaitOne();

  ...

  _mutex.ReleaseMutex();
}

第一个调用ImportantMethod的线程可以无问题地进入并通过互斥门。当互斥锁允许线程进入时,线程将拥有Mutex实例对象的所有权。如果第二个线程在第一个线程拥有互斥锁的情况下尝试进入ImportantMethod,则第二个线程必须等待直到第一个线程释放互斥锁并放弃所有权。毕竟只有一个互斥锁可用,因为它在程序中是静态的。当第一个线程不再拥有互斥锁时,第二个线程将获得所有权,循环重复进行。

使用信号量允许多个并发线程访问

因此,我们可以通过使用锁来锁定资源,或者通过使用互斥锁来控制方法的执行。但如果我们想控制方法的执行,但又不想创建一个队列瓶颈,使得一次只有一个线程可以执行该方法,那该怎么办?这就是信号量的作用。人们有时将信号量解释为“广义的互斥锁”,因为信号量提供了类似于互斥锁的功能,但增加了一个额外的特性:它们允许指定数量的线程同时进入受控方法。要使用信号量,我们实例化Semaphore类的静态实例。Semaphore类的构造函数接受两个参数:方法内部线程的初始计数(通常为0)和方法中的最大并发线程数,如下所示:

private static readonly Semaphore _semaphore = new Semaphore(0, 3);
public void VeryImportantMethod() {
  _semaphore.WaitOne();

  ...
  _semaphore.Release();
}

当一个线程希望执行VeryImportantMethod方法时,信号量会检查其内部的线程计数器并决定是否允许线程进入。在这个例子中,信号量允许最多三个并发线程进入该方法。潜在的第四个线程必须等待直到信号量的内部线程计数器回到两个。释放信号量会减少其内部计数器。

6.2.8 同步到异步执行……继续

将同步方法转换为异步方法的第二步是将方法的返回类型更改为 Task<[type]> 类型,其中 [type] 是你想要返回的类型(如果你想不返回任何特定类型,可以使用 Task<void>)。Task 是围绕一个我们可以等待的操作单元的包装。我们使用 Task 类和异步方法,以便我们可以验证任务是否执行,并返回与任务元数据一起的信息。在 CreateCustomer 方法的例子中,我们在同步执行时返回了 bool 类型,所以在异步操作时应该返回 Task<bool>。当返回 Task<T> 时,我们只返回我们想要嵌入的类型。编译器会自动将返回值转换为 Task<T>。例如,要从返回类型为 Task<T> 的方法返回 Task<bool>,我们只需要做以下操作:

return myBool;

Task 完成其任务时,公共语言运行时通过其 CompletedTask 属性(类型为 bool)将 Task 返回给调用方法,并将其设置为 true

第三步,我们需要在方法签名中添加 async 关键字,如以下列表所示。async 关键字表示该方法是非阻塞的(因此应该返回 Task<T>)。如果你有一个没有 await 调用的异步方法,编译器会抛出一个警告。

列表 6.6 CustomerRepository.cs CreateCustomer 异步

public async Task<bool> CreateCustomer(string name) {    ❶
  if (IsInvalidCustomerName(name))  {   
    return false;
  }

  Customer newCustomer = new Customer(name);
using (FlyingDutchmanAirlinesContext context = new 
➥ FlyingDutchmanAirlinesContext())
  {
    context.Customer.Add(newCustomer);
    await context.SaveChangesAsync();                    ❷
  }

  return true;                                           ❸
}

CreateCustomer 方法的签名包含异步关键字,并返回 Task<bool> 类型。

context.SaveChangesAsync 调用被等待,直到更改被保存,当前线程被阻塞。

❸ 返回的 bool 类型会自动转换为 Task<bool> 类型。

最后一点需要注意:当你尝试运行你的测试时,你会在每个测试中遇到编译错误。这是因为它们现在调用了一个没有await或它们自身是异步的方法。我们需要修复这个问题。

使用你新获得的知识,将失败的测试从同步执行转换为异步执行,并等待 CreateCustomer 方法的调用。记住,单元测试方法在同步执行时返回 void。如果你遇到困难,可以在附录 A 中找到解决方案。

6.2.9 测试 Entity Framework Core

我们如何测试一个对象是否被添加到数据库中呢?当然,我们可以运行现有的测试,但这会与数据库交互——对于单元测试来说这是一个巨大的禁忌。但我们想验证测试的方法实际上是否向数据库添加了一个对象,我们没有代码来路由实际的 HTTP 请求到存储库。以下是我的建议:我们运行现有的成功情况单元测试一次,检查数据库中新创建的条目,然后找出解决单元测试连接问题的方案。

如果我们执行CreateCustomer_Success单元测试,我们可以使用数据库管理工具(如 SQL Server Manager)在我们的代码外部查询创建的客户实际部署的数据库("SELECT * FROM [dbo].[Customer]")。图 6.5 显示了结果客户条目。

图 6.5 查询数据库中所有客户的查询结果。由于数据库是在线部署的,数据库中客户的数量可能会有所不同。

但我们不想在每次运行单元测试时在实际数据库中创建新的条目。Entity Framework Core 有一个内存数据库的概念,它允许我们在运行测试时在我们的机器上内存中启动一个数据库(结构与我们的云或本地部署的数据库相同)。为了便于这样做,我们需要在 FlyingDutchmanAirlines_Tests 项目中安装Microsoft.EntityFramework.Core.InMemory包。我们还需要将Microsoft.EntityFrameworkCoreFlyingDutchmanAirlines.DatabaseLayer的命名空间导入到测试类中。

单元测试和依赖注入的方法属性

除了创建内存数据库之外,如果我们能够为每个测试使用相同的代码块创建一个新的具有适当内存选项的上下文,那将非常有用。如果告诉你有一个方法属性允许我们在每个测试之前创建一个方法并运行它,你会怎么想?

如表 6.1 所示,完成此操作的方法属性是[TestInitialize]。还有其他方法属性可以在每个测试之后运行一个方法([TestCleanup]),在测试套件开始之前运行一个方法([ClassInitialize]),以及在测试套件运行之后进行清理的一个方法属性。测试套件是一个类中所有测试的总和。

表 6.1 测试方法属性和方法的运行时间

方法属性 方法何时运行?
[ClassInitialize] 在类中的任何测试之前
[TestInitialize] 在类中的每个测试之前
[TestCleanup] 在类中的每个测试之后
[ClassCleanup] 在类中的所有测试之后

让我们在CustomerRepositoryTests类中使用[TestInitialize]方法属性添加一个TestInitialize方法,如下所示:

private FlyingDutchmanAirlinesContext _context;

[TestInitialize]
public void TestInitialize() {
  DbContextOptions<FlyingDutchmanAirlinesContext> dbContextOptions = new 
➥ DbContextOptionsBuilder<FlyingDutchmanAirlinesContext>()
➥ .UseInMemoryDatabase("FlyingDutchman").Options;
  _context = new FlyingDutchmanAirlinesContext(dbContextOptions);
}

我们创建一个类型为FlyingDutchmanAirlinesContextprivate字段,名为_context,以存储我们的数据库上下文,这样我们就可以在测试中使用它。然后我们提供初始化方法(TestInitialize)。在TestInitialize中,我们首先创建一个DbContextOptions<FlyingDutchmanAirlinesContext>的对象,该对象使用 Builder 模式创建DbContextBuilder,指定我们想要使用名为FlyingDutchman的内存数据库,并返回设置内存上下文的选项。

然后,我们将这些选项传递给我们的FlyingDutchmanAirlinesContext构造函数(由 Entity Framework Core 自动生成)。FlyingDutchmanAirlinesContext有两个构造函数:一个不带参数的构造函数(我们之前已经使用过)和一个接受类型为DbContextOptions<FlyingDutchmanAirlines>的参数的构造函数,它允许我们在这种情况下创建内存上下文。

通过使用这个上下文,我们可以对内存数据库而不是真实数据库运行单元测试。Entity Framework Core 创建了一个完美的数据库模式副本(没有现有数据)并模拟我们正在对已部署的数据库进行操作。这允许我们在不干扰实际数据库的情况下执行单元测试。

但等等!我们实际上是如何使用上下文的?我们没有将上下文传递给仓库层。实际上,它在CustomerRepository中创建了一个新的上下文。这就是依赖注入再次出现的地方。

6.2.10 使用依赖注入控制依赖

依赖注入(DI)这个术语是由马丁·福勒在 2004 年的一篇文章中提出的,这篇文章名为“控制反转容器和依赖注入模式”,但实际上它是依赖注入技术的一种演变,这种技术最初是由罗伯特·马丁(以编写清洁代码而闻名)在 1994 年发布到 comp.lang.c++ Usenet 论坛的一篇论文中描述的,这篇论文名为面向对象设计质量度量:依赖性分析。³

依赖注入,在最基本的术语中,是一种提供类所需的所有依赖项的技术,而不是在类中自己实例化它们。这意味着我们可以在运行时而不是在编译时解决依赖项。当与接口一起使用时,依赖注入也成为了一个强大的测试工具,因为我们可以随时传递模拟作为依赖项。

一个没有依赖注入的传统类可能依赖于一个 AWS(亚马逊网络服务)客户端对象(让我们称它为AwsClient,并且让它实现一个名为IAwsClient的接口)。这个对象是 AWS 和我们的代码库之间的通信者。我们可以在类的构造函数中创建这个类级对象,并将其分配给AwsClient类的新实例,如下所示:

public class AwsConnector {
  private AwsClient _awsClient;
  public AwsConnector() {
    _awsClient = new AwsClient();
  }
}

现在想象一下,我们想要测试这个类。我们如何测试_awsClient来控制其返回值?因为它是一个私有成员,所以我们不能直接访问它。我们可以使用反射通过一些聪明的代码魔法来访问私有成员,但这将是痛苦且计算成本高昂的,同时代码也非常不整洁且复杂。另一种选择是使用依赖注入。

使用依赖注入,我们不是在构造函数中将_awsClient赋值给AwsClient的新实例,而是将这个新实例传递给构造函数。我们需要确保依赖是一个接口,在这种情况下,是IAwsClient,如下一个代码示例所示。这样,我们可以创建继承自IAwSClient的新类,这使得测试变得容易得多。

public class AwsConnector {
  private readonly IAWSClient awsClient;
  public AwsConnector(IAWSClient injectedClient) {
    awsClient = injectedClient;
  }
}

每个想要实例化AwsConnector新副本的类现在都必须传递一个继承自IAwsClient的类的实例。为了防止_awsClient在别处被更改,它只能是只读的且私有的。依赖注入的力量在于它反转了依赖的控制权。不再是类控制依赖及其实例化方式,现在调用类拥有这种控制权。这就是我们所说的“控制反转”。

让我们将CustomerRepository改为使用FlyingDutchmanAirlinesContext的依赖注入。为此,我们需要做以下五件事:

  1. CustomerRepository中添加一个类型为FlyingDutchmanAirlinesContextprivate readonly成员。

  2. CustomerRepository构造函数创建一个非默认构造函数,该构造函数需要一个类型为FlyingDutchmanAirlinesContext的参数。

  3. 在新的构造函数中,将私有的FlyingDutchmanAirlinesContext赋值给注入的实例。

  4. 将类更改为使用私有成员,而不是在CreateCustomer方法中创建一个新的FlyingDutchmanAirlinesContext

  5. 更新我们的测试,将FlyingDutchmanAirlinesContext的实例注入到CustomerRepository中。

我们首先添加private readonly类型的FlyingDutchmanAirlinesContext成员和新的CustomerRepository构造函数。目前,我们只有一个默认(非显式)构造函数,因此我们必须创建一个新的构造函数来满足我们的需求,如下面的代码片段所示。这个构造函数取代了默认构造函数,因为我们不希望创建一个不带参数的重载构造函数。我们希望强制使用我们的 DI 构造函数。

private readonly FlyingDutchmanAirlinesContext _context;

public CustomerRepository(FlyingDutchmanAirlinesContext _context) {
  this._context = _context;
}

这就解决了我们列表上的前三个项目。这段代码确实包含了一个我在这本书中没有使用过的关键字:this关键字。

使用“this”关键字访问当前实例的数据

我们为什么必须使用this?想象一下如果我们没有这样做:我们会有一个赋值操作,将一个名为_context的变量赋值给另一个名为_context的变量。

_context = _context;

但我们到底在给什么赋值?类字段被称为_context(尽管命名约定不正确),传入的参数也是如此。有两种方法可以解决这个难题:要么我们重命名其中一个(可能的候选者是构造函数参数),要么我们找到一种方法来指定在什么时间我们指的是哪一个。this关键字指的是类的当前实例。所以当我们使用this._context时,我们真正说的是“在类的当前实例中名为_context的变量。”有了这个区分符,我们可以安全地将参数赋给字段。是否添加this关键字作为重命名变量、字段或成员的可接受的替代方案,取决于你。

我的试金石归结为以下几点:如果你必须更改名称,使其无法清楚地传达你的意图,请使用this关键字。否则,重命名它。

单元测试中的 try-catch

现在我们必须确保CreateCustomer方法使用我们新初始化的上下文,而不是在方法内部创建一个。为此,我们从方法中移除将context赋值给FlyingDutchmanAirlines新实例的赋值操作,并将context成员用using语句包裹,如下所示:

public async Task<bool> CreateCustomer(string name) {
  if (IsInvalidCustomerName(name)) {
    return false;
  }

  Customer newCustomer = new Customer(name);
  using (_context) {
    _context.Customer.Add(newCustomer);
    await _context.SaveChangesAsync();
  }

  return true;
}

你现在已经将一个现有方法更改为使用依赖注入。但如果SaveChangesAsync方法抛出错误怎么办?也许我们不能再连接到数据库了。或者部署的架构有问题?我们可以将数据库访问代码包裹在try-catch块中,捕获任何异常,这样我们就可以处理异常(通过返回false),而不是使服务崩溃,如下所示:

public async Task<bool> CreateCustomer(string name) {
  if (IsInvalidCustomerName(name)) {
    return false;
  }

  try {
    Customer newCustomer = new Customer(name);
    using (_context) {
      _context.Customer.Add(newCustomer);
      await _context.SaveChangesAsync();
    }
  } catch {
    return false;
  }

  return true;
}

最后要做的就是更新我们的测试以注入依赖,并为错误情况创建一个单元测试。

使用 try-catch 进行单元测试

为了在我们的现有测试中使用依赖注入和异步方法,我们首先必须确保所有调用异步方法(使用await)的测试方法都返回Task类型并且是异步的。继续更新所有测试。然后,我们需要将内存数据库上下文(_context)添加到CustomerRepository实例创建中,如下所示:

[TestMethod]
public async Task CreateCustomer_Success() {
  CustomerRepository repository = new CustomerRepository(_context);
  Assert.IsNotNull(repository);

  bool result = await repository.CreateCustomer("Donald Knuth");
  Assert.IsTrue(result);
}

我们所做的只是将_context实例添加到新的CustomerRepository构造函数调用中。为文件中的其他测试执行此操作,你应该在这方面准备好。

注意:我喜欢使用以下模板来命名我的测试:{方法名}_{预期结果}。它使用蛇形大小写来区分正在测试的方法和结果:CreateCustomer_Success

对于单元测试,我们可以采取两种方法来测试方法是否抛出Exception(通过断言方法返回了一个值为false的布尔值):

  • null代替正确实例化的_context

  • 模拟FlyingDutchmanAirlinesContext,并基于预定义的条件抛出错误。

对于这个测试,我们采用第一种方法:将 null 而不是 _context 传递给 CustomerRepository 构造函数。我们将在第八章讨论并使用存根。在 CustomerRepository 构造函数中传递 null 值意味着 CustomerRepository._context 被设置为 null,因此,在尝试添加新 Customer 时会导致空指针异常。这对我们来说足够测试 try-catch 失败情况,如下所示:

[TestMethod]
public async Task CreateCustomer_Failure_DatabaseAccessError() {
  CustomerRepository repository = new CustomerRepository(null);
  Assert.IsNotNull(repository);

  bool result = await repository.CreateCustomer("Donald Knuth");
  Assert.IsFalse(result);
}

如果我们运行所有测试,我们会看到它们都通过了。我们现在使用的是完全的内存数据库。在我们继续之前,我们能清理些什么吗?嗯,是的,我们可以。如果我们查看我们的单元测试,我们会注意到以下两行重复的代码:

CustomerRepository repository = new CustomerRepository(_context);
Assert.IsNotNull(repository);

这是一个应用 DRY 原则的绝佳时刻。我们是否可以将 CustomerRepository 的创建提取到我们之前创建的 TestInitialize 方法中,然后将其作为类上的私有成员暴露出来,以便测试使用,如下所示?在每次测试之前,它都会使用 CustomerRepository 的新实例进行更新,所以我们仍然保证了一个隔离的环境。

private FlyingDutchmanAirlinesContext _context;
private CustomerRepository _repository;

[TestInitialize]
public void TestInitialize() {
  DbContextOptions<FlyingDutchmanAirlinesContext> dbContextOptions = new 
➥ DbContextOptionsBuilder<FlyingDutchmanAirlinesContext>()
➥ .UseInMemoryDatabase(“FlyingDutchman”).Options;
  _context = new FlyingDutchmanAirlinesContext(dbContextOptions);

  _repository = new CustomerRepository(_context);
  Assert.IsNotNull(_repository);
}

现在将 CustomerRepository 的创建移至 TestInitialize 方法中,我们可以为每个测试移除它。例如,列表 6.7 展示了这对 CreateCustomer_Failure_NameIsNull 单元测试的影响。然而,我们不想对 CreateCustomer_Failure_DatabaseAccessError 做同样的事情,因为它依赖于使用 null 值作为输入参数来实例化存储库。

列表 6.7 客户存储库测试更新 CreateCustomer_Failure_NameIsNull

[TestMethod]
public void CreateCustomer_Failure_NameIsNull() {
  CustomerRepository repository = new CustomerRepository(context);
  Assert.IsNotNull(repository);

  bool result = _repository.CreateCustomer(null);
  Assert.IsFalse(result);
}

因此,总结一下:我们在 CustomerRepository 中创建了一个 CreateCustomer 方法(以及相应的单元测试)。CreateCustomer 方法允许我们将新的 Customer 对象添加到数据库中。但我们也想当给定 CustomerID 时返回 Customer 对象。那么,我们为什么不创建一个在下一章中执行此操作的方法呢?到现在为止,你知道 TDD 的技巧:我们将创建一个单元测试,直到我们卡住(也就是说,直到我们不能再编译或通过测试为止),然后我们添加下一块逻辑,然后重复这个过程。

练习

练习 6.4

填空:测试的三个 A 是 1. __________,2. __________,和 3. __________。

a. affirm; assert; align

b. affix; advance; await

c. arrange; act; assert

d. Iact; alter; answer

练习 6.5

对或错?使用语言集成查询,我们可以通过传递 C++ 代码来使用查询集合,该代码被升级为 C# 并执行。

练习 6.6

如果第一个条件评估为 false,条件逻辑或运算符 (||) 会进行多少次检查?

a. 一个

b. 两个

c. 三个

d. 取决于。

练习 6.7

如果第一个条件评估为 false,排他或运算符 (^) 会进行多少次检查?

a. 一个

b. 两个

c. 三个

d. 取决于。

练习 6.8

真或假?要将同步方法转换为异步方法,方法需要返回类型为Task<[original return type]>Task,在方法签名中包含async关键字,并且await任何异步调用。

练习 6.9

填空:在单元测试时,我们针对 _________ 数据库执行操作。

a. 一个内存中的

b. 一个部署的

c. 一个损坏的

练习 6.10

真或假?使用依赖注入,我们反转了依赖项的控制权,从类到调用者。

摘要

  • 单一职责原则告诉我们,在方法中只做一件事,并且要做好。如果我们遵循这个信条,最终我们会得到可维护和可扩展的代码。

  • 测试驱动开发有两个阶段:红色(测试失败或无法编译)和绿色(测试通过)。在两个阶段(红色和绿色)之间切换,使我们能够在编写功能的同时编写测试。在红色阶段,测试无法通过或代码无法编译。我们在红色阶段的任务是使测试通过并使代码编译。在绿色阶段,代码编译且测试通过。在绿色阶段,我们编写新的代码来实现我们功能的下一步。这使得测试失败,因此我们回到了红色阶段。

  • 语言集成查询(LINQ)允许我们对集合执行类似 SQL 的查询。我们可以使用它来极大地简化处理数据库时的代码。

  • 我们可以使用依赖注入(DI)与单元测试一起使用,以对依赖项的调用提供更细粒度的控制。在使用 DI 时,数据流是相反的,调用方法需要提供依赖项,而不是在原地实例化它们。


^(1.)遍历给定字符串中每个字符的字符集的运行时间复杂度为O(n²)。这是通过将遍历字符集的运行时间O(n)({N})乘以遍历字符串中每个字符的运行时间,也是{N}来计算的。这给我们O(n) * O(n),我们可以进一步将其组合为O(n * n),然后再将其组合为最终的运行时间O(n²)。总结一下:O(n) * O(n) = O(n * n) = O(n²)。有可能正则表达式实现使用相同的概念和运行时间复杂度进行处理。

^(2.)作者曾是 Vladimir Khorikov 的《单元测试原则、实践和模式》(Manning,2020)的技术审稿人。

^(3.)原始帖子可以在groups.google.com/forum/#!msg/comp.lang.c++/KU-LQ3hINks/ouRSXPUpybkJ找到。

7 比较对象

本章涵盖

  • 实现的 GetCustomerByName 方法

  • 通过 lambda 演算的视角查看方法

  • 使用可空类型

  • 使用自定义异常

  • 运算符重载和自定义等价比较

在上一章中,我们实现了 CustomerRepository,其中我们可以向数据库中添加客户。我们还学习了如何使用依赖注入来编写可测试的代码。这是一个出色的开始,但我们还没有完成。我们可以向数据库中添加一个 Customer 实例,但如何检索一个呢?见图 7.1 了解我们在本书方案中的位置。

图片

图 7.1 在本章中,我们将继续实现我们在第六章开始的 CustomerRepository 实现。这是实现 Flying Dutchman Airlines 服务所有存储库的第一步。

在本章中,我们将创建 GetCustomerByName 方法,当给定包含客户名称的字符串时,该方法将返回适当的 Customer 对象。实现此方法使我们能够触及一些我们可能否则会错过的技术概念。和之前一样,我们将使用测试驱动开发“轻量级”来确保我们的代码质量足够好。尽管 API 不需要端点从数据库中获取客户,但此方法在我们实现预订端点时将非常有用。

7.1 GetCustomerByName 方法

要开始,让我们创建以下新的单元测试,它除了尝试调用我们新的(尚未创建的)方法外,什么都不做:

[TestMethod]
public async Task GetCustomerByName_Success() {
  Customer customer = 
➥ await _repository.GetCustomerByName("Linus Torvalds");
}

在切换到 CustomerRepository 类后,让我们添加 GetCustomerByName 方法。我们还将向方法签名中添加一个 string 类型的参数,表示我们想要传递的 CustomerName。我们还将添加代码以返回 Customer 类型的新实例,以满足方法签名中 Task<Customer> 的返回类型。我们目前还没有任何 await 调用,因此编译器会警告我们(我们将在 7.1.1 节中处理它)。现在,我们可以同步执行 GetCustomerByName,如下所示:

public async Task<Customer> GetCustomerByName(string name) {
  return new Customer(name);
}

解决了编译警告后,我们可以尝试再次运行测试。当然,我们还没有进行任何测试。我们希望单元测试检查并断言以下内容:

  • 返回的 Customer 实例不为 null。

  • Customer 实例具有 CustomerIdNameBooking 字段的有效数据。

这些断言应该相当简单编写。让我们断言返回的 Customer 实例不为 null,如下所示:

[TestMethod]
public async Task GetCustomerByName_Success() {
  Customer customer = 
➥ await _repository.GetCustomerByName("Linus Torvalds");
  Assert.IsNotNull(customer);
}

如果我们还记得测试驱动开发的红绿灯模型,我们会看到我们已经从红色阶段(无法编译)过渡到了绿色阶段(编译且测试通过)。现在让我们再次将灯光调回红色。红绿灯即时反馈循环在整个过程中提供了小而令人满意的胜利,使得 TDD 非常令人满意地使用。

现在我们处于红色阶段,我们可以添加一些新的测试来基于我们尚未编写的代码进行断言。那么,我们在GetCustomerByName中想做什么呢?如图 7.2 所示:

  1. 验证输入参数(name,字符串)。

  2. 检查 Entity Framework Core 的内部数据库以找到适当的客户。

  3. 返回找到的客户或抛出未找到客户的异常。

图 7.2 实现GetCustomerByName的三个步骤是(1)验证输入参数,(2)检查数据库中是否存在客户,(3)返回找到的客户或抛出异常。

有很多要解释的内容,所以让我们从列表中最简单(也是第一个)的项目开始:验证我们的输入参数。你可能还记得我们在第 6.2.1 节中讨论了输入验证。

在我们继续之前,让我再提出一个关于为什么我们要验证所有输入参数的理由,无论它们应该在上游被验证和/或清理。如果我们通过 lambda 演算的透镜来审视方法的抽象概念,我们可以说任何方法(如果将其视为一个函数),在其最基本层面上,是一个输入、函数体和输出的独立结构。我们可以使用一些简单的语法来编写这个,其中 lambda(λ)被括号包裹,输入后面跟着一个点,然后是输出,如图 7.3 所示。

图 7.3 方法可以被视为一个 lambda 函数。它有一个输入,一些在该输入上操作的逻辑,以及一个结果输出。使用 lambda 函数有助于遵循“代码应该像叙述一样阅读”的原则,因为你可以只进行一个原子操作。

我们应该将这种方法视为所谓的黑盒。我们对函数的内部工作原理没有任何洞察,那么我们该如何知道某个输入是否正确呢?无论它如何处理数据,函数都应该为任何输入返回一个有效的输出。如果我们假设在代码中的某个早期方法中验证了一个参数,并且我们向该方法传递了一个无效的值,那么我们将导致我们的(程序性)代码方法硬崩溃。我们没有遵守我们正在进行的这个 lambda 演算黑盒事务。请注意,由于 lambda 演算处理数学函数,崩溃的不是函数本身,而是我们系统实现的代码有缺陷。

7.1.1 问号:可空类型及其应用

现在我们已经有了CustomerRepositoryTests.GetCustomerByName _Success单元测试和CustomerRepository.GetCustomerByName方法的框架,我们可以验证GetCustomerByName的输入。我们的输入参数需要遵守哪些规则呢?嗯,我们绝对不希望有一个 null 值,所以这应该是第一个检查。

在 C# 8 之前,任何引用类型都是可空的。这意味着你可以将 null 值赋给引用类型,并在编译器发现 null 值时防止其抛出错误。在实践中,这往往是我们在运行时看到空指针异常的原因。很多时候,你并不知道一个引用类型会是 null,但你仍然试图以某种方式使用它。为了应对运行时的空指针异常,C# 8 引入了显式可空引用类型,这允许我们通过显式地表示我们想要这样做来使引用类型为 null。可空引用类型的总体目标是消除 C# 中的意外空指针异常。如果引用类型只能在你显式允许其为 null 时为 null,那么你就处于控制之中。当你尝试使用引用类型时(或者挖掘大量代码以找出),你不需要猜测引用类型是否可能为 null(或者挖掘大量代码以找出),你可以查看其底层类型。如果它是可空的,假设它可以为 null 并执行空检查。

要启用可空引用类型,可以将 <Nullable>enable</Nullable> 标志添加到你的项目文件 (.csproj) 中,或者如果你不想为整个项目启用可空引用类型,可以为每个源文件添加 #nullable enable 标签。(你还可以在为整个项目启用可空引用类型时使用 #nullable disable 来禁用特定源文件的可空引用类型。)实际上,当使用 C# 8 或更高版本并启用可空引用类型支持时,如果你想使引用类型为 null,你需要通过在类型后添加问号来声明类型为可空的,例如,int?Customer?。可空值类型自 C# 2.0 以来就存在于 C# 中,并遵循相同的模式。提供的 name 总是必须有效的。有效的名称意味着一个非空或非空字符串。假设 name 是一个无效值——那么会怎样?我们可以抛出异常或返回一个 null 值到控制器。一般来说,限制任何 null 返回是一个好的心态。因为这是一个人们很少期望返回的值(除非返回类型是 Nullable<T> 或显式可空引用类型),我们不应该给接收方法一个惊喜。让我们使用自定义的 Exception。或者,你可以使用 .NET 提供给我们的一些异常。一些建议的选择是 ArgumentNullExceptionInvalidOperationException(在 14.1.1 节中也有讨论)。

7.1.2 自定义异常、LINQ 和扩展方法

C# 中的每个异常都继承自 Exception 类。当然,可以存在中间层的继承,但最终,所有这些都归结为 Exception 类。例如,InvalidCastException 类继承自 SystemException,而 SystemException 继承自 Exception 类,如图 7.4 所示。

图片

图 7.4 InvalidCastException 继承自 SystemException,而 SystemException 继承自 ExceptionException 类实现了 ISerializable 接口。

Exception 继承树意味着如果我们创建一个从 Exception 继承的类,我们可以像使用任何其他 Exception 实例一样使用 SystemExceptionInvalidCastException。我建议我们使用 Exception 类的继承来创建一个名为 CustomerNotFoundException 的类。在整个书中,所使用的异常处理策略归结为以下四个主要步骤:

  1. 检查是否需要抛出异常。

  2. 创建一个自定义异常的实例。

  3. 抛出自定义异常。

  4. 在抛出异常的上一层捕获异常,并决定是否在那里处理它或重新抛出它。

如果 customerName 是一个无效的名称(我们可以使用我们在第 6.2.3 节中创建的 IsInvalidName 方法),我们“抛出”我们的新异常。如果我们想抛出异常(或者像某些语言所说的“提升”它),我们使用 throw 关键字和我们要抛出的异常。而且为了保持组织性,我们还应该为自定义异常创建一个专门的文件夹(命名为“Exceptions”),如下所示:

namespace FlyingDutchmanAirlines.Exceptions {
  public class CustomerNotFoundException : Exception { }
}

由于我们不需要从 CustomerNotFoundException 中获取除了 Exception 类已经提供之外的功能,因此这段代码就是整个异常。在向 CustomerRepository 类添加适当的导入(using FlyingDutchmanAirlines.Exceptions)之后,我们可以使用我们新的异常,并如下验证 name 的输入:`

public async Task<Customer> GetCustomerByName(string name) {
  if (IsInvalidCustomerName(name)) {
    throw new CustomerNotFoundException();
  }

  return new Customer(name);
}

我们本可以使用 ArgumentException 而不是我们的自定义异常,但对我来说,一个从数据库检索客户名称的方法返回一个名为 CustomerNotFoundException 的异常是有意义的。通过运行我们的现有测试,我们验证新代码没有破坏任何现有功能。我们可以通过在 CustomerRepositoryTests 中创建一个新的测试,将负整数传递给 GetCustomerByName,然后在执行期间检查该方法是否抛出了 CustomerNotFoundException 类型的异常来测试无效输入。在第 4.1.2 节中,我们讨论了如何通过 typeof 关键字检查对象的类型。我们在失败情况测试中使用这一知识。我们可以使用以下方式在 MSTest 中检查抛出的异常:

  • 使用类型为 [ExpectedException (typeof([your exception]))] 的方法属性装饰测试方法。

  • 在代码中添加一个 try-catch 块,并断言异常是正确的类型。

对于我们来说,两种方法都会工作。第一种方法有一个小的限制:类型为 ExpectedException(typeof([your exception])) 的方法属性不允许我们访问抛出异常的任何属性。所以,如果你在异常中附加某种消息、自定义数据或堆栈跟踪,除非使用第二种方法,否则无法访问它。对我们来说,没有访问堆栈跟踪不是一个问题,所以让我们使用第一种方法,如下所示:

[TestMethod]
[DataRow("")]
[DataRow(null)]
[DataRow("#")]
[DataRow("$")]
[DataRow("%")]
[DataRow("&")]
[DataRow("*")]
[ExpectedException(typeof(CustomerNotFoundException))]
public async Task GetCustomerByName_Failure_InvalidName(string name) {
  await _repository.GetCustomerByName(name);
} 

运行测试;它应该通过。如果没有通过,请检查您的 ExpectedException 是否为正确的类型(CustomerNotFoundException)。

回到成功案例测试,我们可以继续到 GetCustomerByName 方法中的第二个项目:检查 Entity Framework Core 的内部数据库集合中是否有适当的客户。为了测试逻辑,我们首先需要一个 Customer 实例来检查。在我们能够访问 Customer 实例之前,我们需要将其添加到内存数据库中。随后,我们使用 GetCustomerByName 方法检索它。我们是否可以在 TestInitialize 方法中添加它,这样我们就可以在每次测试中访问数据库中的 Customer

我们已经在 CreateCustomer 方法中编写了将 Customer 实例添加到数据库的代码,所以让我们使用它(而不是在初始化方法中调用该方法本身)。要将 Customer 实例添加到内存数据库中,我们需要将一个新的 Customer 实例添加到 Customer 的内部数据库集合中,并通过 Entity Framework Core 保存更改,如下一列表所示。因为我们应该 await SaveChangesAsync 调用,所以我们需要将 TestInitialize 转换为异步方法。

列表 7.1 CustomerRepositoryTests.cs 中的 TestInitialize 与内存数据库

[TestInitialize]
public async Task TestInitialize() {
  DbContextOptions<FlyingDutchmanAirlinesContext> dbContextOptions = new  
➥ DbContextOptionsBuilder<FlyingDutchmanAirlinesContext>().UseInMemoryDatabase
➥ ("FlyingDutchman").Options;
  _context = new FlyingDutchmanAirlinesContext(dbContextOptions);

  Customer testCustomer = new Customer("Linus Torvalds");     ❶
  _context.Customer.Add(testCustomer);                        ❷
  await _context.SaveChangesAsync();                          ❸

  _repository = new CustomerRepository(_context);
  Assert.IsNotNull(_repository);
}

❶ 创建一个新的 Customer 实例。客户的姓名字段设置为 "Linus Torvalds"。

❷ 通过调用数据库上下文并访问 DbSet 将测试客户对象添加到 DbSet

❸ 将更改保存到内存数据库中

如列表 7.1 中的粗体代码所示,将新的 Customer 对象添加到数据库相当直接。让我们回到 GetCustomerByName_Success 测试,看看我们是否可以从 GetCustomerByName 方法中获取那个 Customer 对象。记住,尽管我们不可避免地从该方法返回的并不是我们在数据库中存储的相同实例,但它与该实例是一致的(关于一致性的更多内容请见第 7.2 节)。我们知道数据库中的 Customer 对象的 CustomerName"Linus Torvalds",因此我们不需要调整现有测试的这一部分。

我们希望 GetCustomerByName 方法能够搜索数据库以找到与输入参数匹配的现有 Customer 对象。我们需要将其更改为从数据库中获取正确的 Customer 对象。我们通过访问数据库上下文的 DbSet<Customer> 并请求具有给定 CustomerNameCustomer 实例来从数据库中获取正确的元素。在查询集合以获取元素时,我们可以使用 DbSet<Customer> 并以两种方式找到我们想要的 Customer 实例:

  • 我们可以使用 foreachwhilefor 循环遍历集合。

  • 我们可以使用 LINQ。

到目前为止,书中已经展示了这两个示例。让我们对比一下这些方法。要使用循环选择我们的客户,我们可能会得到以下代码:

foreach (Customer customer in _context.Customer) {
  if (customer.CustomerName == name) {
    return customer;
  }
}

throw new CustomerNotFoundException();

这段代码没有问题。它既易读又直接。然而,还有更好的方法来做这件事:使用 LINQ 命令查询集合,如下所示:

return _context.Customer.FirstOrDefault(c => c.Name == name) 
➥ ?? throw new CustomerNotFoundException();

这确实更短,但也看起来更令人畏惧。让我们来分解这一行代码做了什么。我们通过context.Customer访问DbSet<Customer>——这没有什么我们之前没见过的。但下一部分有点奇怪:FirstOrDefault(c => c.Name == name)。Lambda 表达式在name属性之间找到匹配项,但我们之前没有见过FirstOrDefaultFirstOrDefault是在System.Linq中定义的扩展方法。

扩展方法

扩展方法是静态方法,我们可以在特定类型上调用它们。例如,我们可以调用任何实现IQueryable接口的实例上的FirstOrDefault LINQ 扩展方法。我们如何知道扩展方法操作的是哪种类型,并且可以与之一起使用?看看扩展方法的签名:扩展方法始终有一个以this关键字开始的参数,后面跟着它们想要操作的具体类型(或接口)。例如,public static string MyExtensionMethod(this IDisposable arg)表示任何实现IDisposable的对象都可以调用的扩展方法,它返回一个字符串。

图片

FirstOrDefault LINQ 扩展方法从集合中选择与提供的谓词匹配的第一个元素。如果没有找到匹配的元素,FirstOrDefault方法返回返回类型的默认值。在这段代码中,我们想要找到context.Customer集中与我们的输入参数name匹配的Name的第一个元素。如果没有找到第一个匹配的CustomerFirstOrDefault返回一个 null 值(Customer的默认值)。

这引出了返回语句的第二部分不熟悉的部分:?? throw new CustomerNotFoundException();。如您从第 5.3.6 节中回忆的那样,我们将??运算符称为“空合并运算符”。空合并运算符允许我们说,“如果这个值是 null,则使用这个其他值。”因此,在我们的情况下,“如果FirstOrDefault返回一个 null 值(Customer的默认值),则抛出一个CustomerNotFoundException类型的异常。”FirstOrDefault的异步版本是FirstOrDefaultAsync,如下所示:

public async Task<Customer> GetCustomerByName(string name) {
  if (IsInvalidCustomerName(name)) {
    throw new CustomerNotFoundException();
  }

  return await _context.Customer.FirstOrDefaultAsync(c => c.Name == name) 
➥ ?? throw new CustomerNotFoundException();
} 

我们现在可以回到我们的成功案例测试,再次运行它,验证所有测试是否通过。

7.2 一致性:从中世纪到 C#

根据神话和传说,当地中世纪传说中的 Grutte Pier(一个身高七英尺的持剑叛乱领袖,如图 7.5 所示)使用弗里斯语谚语作为试金石来判断他面对的是敌人(通常是哈布斯堡王朝和萨克森人)还是真正的弗里斯人。因为弗里斯语是古英语(现代英语仍然是盎格鲁-弗里斯语族的一部分)最接近的语言亲属之一,看看你是否能找出它的意思并试一试:

“黄油、面包和绿色奶酪;谁如果说这不是一个真诚的弗里斯兰人,那他就说错了”

当翻译成英语时,那个看起来奇怪的文本意思是:“黄油、面包和绿色奶酪;谁如果说这不是一个真诚的弗里斯兰人,那他就说错了。”(绿色奶酪指的是嵌有孜然和丁香的一种弗里斯兰奶酪。奶酪的自然皮可以呈现出绿色。)那么,口令与 C#或编程有什么关系呢?嗯,关系不大,但我可以用它来向你展示以下关于相等性和相容性的例子:

你看,Grutte Pier 是在测试某人是否是弗里斯兰人。Pier 是在测试人A是否与人B相等(在相同属性、而非社会平等的意义上)吗?不是的。人A可能有一头金发,而人B可能没有。他测试了人A与弗里斯兰人民之间的相容性。如果你能说出他的口令,他就认为你与弗里斯兰人相容,因此你可以活下去。而且你说相容性从未救过任何人的命!如果我们使用数学集合符号,并声明集合A等于{黄油、面包、绿色奶酪},集合B等于{aachje ... zeppelin},代表某人词汇表中的所有弗里斯兰单词,我们可以说{∗x∣∗x∈∗A∧∗x∈∗B∗} ⇔ ∗frisian∗。

图 7.5 Pieter Feddes van Harlingen(1568–1623)绘制的 Grutte Pier。画底部的拉丁文铭文大致翻译为“我们断言 Pier 的伟大自由。”Pier 是弗里斯兰人民自由的守护者。

在第 7.1 节中,我们实现了并测试了CustomerRepository.GetCustomerByName方法,该方法接受一个表示客户名称的输入参数,并从数据库中返回适当的Customer实例。然而,在我们结束之前,我想稍微偏离一下,看看我们是否还能做些什么来改进CustomerRepositoryTests类中的单元测试。

我可以说,我们有以下更优雅的方式来检查数据库中的Customer实例与我们用于断言的GetCustomerByName_Success单元测试中的Customer实例之间的相等性(或者更确切地说,是相容性):

  • 使用EqualityComparer<T>(第 7.2.1 节)创建自定义比较器类。

  • 重写object.Equals(第 7.2.2 节)。

  • 运算符重载(第 7.2.3 节)。

在接下来的三个部分中,我们将这些方法结合成一种统一的测试相等性的方式。如果你只是想找到实现这一点的最简单方法,重写object.Equals是最简单(也是最常见的)的相等性检查方法。

7.2.1 使用 EqualityComparer创建“比较器”类

一个“比较器”类允许我们定义如何比较同一类型的两个实例,以及它们何时是“相等”的。我们之前已经讨论了相容性,在本节中,我们将相容性的概念应用于相等性比较。

在我看来,使用“相等”这个词是不幸的,因为我们实际上是在说某物与另一物是同构的,而不是相等的。但是,唉,这就是 .NET 5 和 C# 给我们出的牌。一个“比较器”类是从 EqualityComparer<T> 类派生出来的。EqualityComparer<T> 类是一个抽象类,它包含以下两个抽象方法,编译器强制我们重写并实现:

  • bool Equals(T argumentX, T argumentY)

  • int GetHashCode(T obj``)

通过重写和实现 EqualsGetHashCode 方法,我们遵循了 EqualityComparer<T> 基类的要求。起初,你可能觉得我们需要实现 GetHashCode 方法很奇怪。毕竟,我们不是只想确定某物等于另一物吗?是的,但 GetHashCode(以及 Equals)是 .NET 中 Object 类上的一个方法。因为 每个 .NET 中的类最终都派生自 Object 类,所以 GetHashCode 方法存在于每个类中,无论是显式还是通过继承到对象类。字典在底层使用哈希码来执行元素查找和相等比较。这意味着字典是一个概念上的哈希表。(C# 确实有一个显式的 Hashtable 实现。区别在于字典是泛型的,而 Hashtable 不是。)通过使用哈希码,我们得到了与普通列表(不使用哈希码)相比非常快速查找、插入和删除操作的好处。¹ 哈希码在假设相同的哈希码总是为相同的对象生成的情况下操作。因此,如果两个对象是相同的,就会生成两个相同的哈希码。如果两个对象不是不同的,但为它们都生成了相同的哈希码,我们称之为 哈希冲突。这意味着我们必须想出其他方法来将这些项目插入到数组中。²

因为 GetHashCode 存在于 .NET 中的每个对象上,我们可以通过使用另一个类的 GetHashCode 实现来提出一种相对动态的生成哈希码的方法。要生成哈希码,我们需要一些种子信息。Customer 对象只有两个字段我们可以用来基于我们的哈希码生成逻辑。我们可以使用 Customer.Name 的长度属性、Customer.CustomerID 属性以及一个“随机”生成的整数组合,如下一列表所示。请注意,关于何时以及何时不使用 GetHashCode 的讨论很多。我会建议您查阅微软文档以获取有关此的最新信息(以及使用 GetHashCode 时要牢记的大量警告)。

列表 7.2 CustomerEqualityComparer 的 GetHashCode 实现方法

internal class CustomerEqualityComparer : EqualityComparer<Customer> {
  public override int GetHashCode(Customer obj) {                           ❶
    int randomNumber = RandomNumberGenerator.GetInt32(int.MaxValue / 2);    ❷
    return (obj.CustomerId + obj.Name.Length + randomNumber).GetHashCode(); ❸
  }
}

❶ 重写抽象的 GetHashCode 方法

❷ 生成一个不超过最大整数值一半的随机数

❸ 连接变量和字段,并对结果值进行哈希处理

我们可以使用以下两种通用方法在 C#中生成“随机”数字:

  • 使用Random类。

  • 使用RandomNumberGenerator类。

初看之下,它们可能看起来很相似,但如果我们深入挖掘,我们会看到差异。Random类位于(根)System命名空间中,而RandomNumberGenerator类位于System.Security.Cryptography命名空间中。这些类所在的命名空间提供了它们为什么都存在的主要原因的提示:Random类是一个低开销的随机数选择器,它非常擅长根据时间种子数快速生成一个数字。RandomNumberGenerator类擅长通过各种密码学概念生成“随机”数字,确保数字在一段时间内的范围内是相当独特且大致均匀分布的。

换句话说,如果你在一个高吞吐量应用中使用Random类并且同时请求两个随机数,那么你很可能从“随机”生成器那里得到相同的数字。生成伪随机数对于许多应用来说是可行的,但对于一个我们不能预测系统可能承受什么负载的 Web 服务来说,这是不合适的。我们可能会遇到两个人同时想要获取同一航班信息的情况。现在我们有两个客户生成了相同的哈希码,我们手头出现了安全漏洞。这就是为什么我们应该使用RandomNumberGenerator类而不是Random类的原因。

灯泡 当你在 C#的世界中探索随机数和密码学时,你可能会遇到一些人推崇使用RNGCryptoServiceProvider类。RandomNumberGenerator类是RNGCryptoServiceProvider类的包装,并且使用起来更加简单。关于密码学的更多信息,一个好的资源是 David Wong 的《现实世界密码学》(Manning,2021)。

“随机”永远不会是随机的

想象一下,你想要通过你最喜欢的音乐流媒体应用听一些音乐。你可能有一个包含数千首歌曲的播放列表,但每次你想听音乐时都不想从顶部开始。很快你就会厌倦以同样的模式听同样的歌曲。所以,你按下“随机播放”按钮,假设你的应用会随机打乱你的播放列表并按新的顺序播放歌曲。不幸的是,我就在这里来打破这个幻想。随机播放播放列表很少能给你一个真正的随机音乐表示。像 Spotify 这样的应用使用的是一种随机算法,试图创建一种即时的播放列表随机播放体验,其中同一专辑或艺术家的歌曲不会连续播放。我们都有过这样的经历。为什么随机播放播放列表是一个如此棘手的问题呢?问题在于计算中的随机性永远不是完全随机的。

所有这些都归结为这样一个事实:计算机必须被告知如何选择一个随机数。随机数生成器使用一个算法,其起点基于一个“种子”数字,通常是当前的时间戳。一个 X 的种子数字总是返回相同的 Y。如果你使用当前时间作为种子数字,在相同时刻并行运行相同的算法,你会得到两个相同的(但“随机”的)输出。这使得选择随机数成为一个潜在的安全问题。如果你知道选择器的种子数字和算法,你可以预测下一个输出值。

黑客使用“随机数生成器攻击”来利用这个漏洞。计算中随机性问题的一个历史例子是索尼的 PlayStation 3 游戏机中椭圆曲线数字签名算法未能生成正确的随机值。通过利用这个错误,黑客可以告诉系统,自制应用程序(以及因此,盗版视频游戏)是有效应用程序,因此可以运行它们。

我将以下这句话留给你们,这是计算先驱约翰·冯·诺伊曼(John von Neumann)所说,旨在轻松地提醒大家不要误解计算中随机性的限制:

“任何认为可以通过算术方法产生随机数字的人,当然是在犯罪。”

我们已经重写并实现了 GetHashCode 方法,现在是时候为 Equals 方法做同样的事情了。

7.2.2 通过重写 Equals 方法来测试等价性

在我们重写 Equals 方法之前,让我们确定一下哪些属性需要相等,才能说 Customer 实例 XCustomer 实例 Y 是“相等”的。Customer 类没有很多属性需要检查:只有 CustomerIdName 是可用的。Booking 字段是一个集合,代表对 Customer 模型的任何外键约束。但是,对于我们的等价性检查,这些属性并不相关,因为我们不使用它们来建立等价性,所以我们不使用这个集合进行检查。如果 Customer XNameCustomerId 属性值与 Customer Y 相同,它们就是相等的(因此,我们在 Equals 方法中返回一个设置为 truebool,如下一列表所示)。

列表 7.3 CustomerEqualityComparer 的 Equals 方法实现

public override bool Equals(Customer x, Customer y) {
  return x.CustomerId == y.CustomerId                  ❶
    && x.Name == y.Name;                                ❷
}

❶ 验证两个 Customer 实例是否具有相同的 CustomerId 值

❷ 验证两个 Customer 实例是否具有相同的 Name 值

我们希望在比较两个 Customer 类型的实例时每次都调用我们的 Equals 方法。我们可以在 Customer 类中公开一个 Equals 方法并调用 CustomerEqualityComparer.Equals 方法。这将非常有效,因为 Equalsobject 的一部分,因此对大多数派生类型都是可用的。这可能是你现实生活中想要做的,我假设你可以自己实现这一点。然而,这为我提供了一个很好的机会来谈论其他事情:如果我们已经在实现“比较器”类的小径上走了一段,我们不妨一直走下去。检查两个对象相等性的最常见技术可能是使用赋值运算符:==

7.2.3 赋值运算符的重载

大多数时候,你不会对赋值运算符背后的功能进行二次思考。当然,你可能偶尔会打字错误,不小心写下赋值运算符(=),但赋值运算符的实际功能似乎已经固定。一般来说,你可以依赖赋值运算符进行引用类型相等性检查,但这个功能对我们来说不起作用。检查两个对象的引用指针不足以比较两个 Customer 对象,因为结果总是 false。怎么办?解决方案很简单:如果某个实现不能满足你对特定输入集的需求,就重载该实现。在 C# 中,我们可以重载运算符。

重载运算符的工作方式与重写(比重载更多)方法非常相似。在运算符的世界里,它们的程序化名称是它们的符号(例如,加号 + 是加法运算符)。然而,方法和运算符重载之间有一个巨大的区别,即运算符重载始终是静态和公共的。创建实例级别的运算符(静态)几乎没有意义。非实例级别的重写运算符会创建一个令人困惑的场景,其中同一类型的多个相同运算符在周围漂浮,对旁观者来说没有明确的界限。使用运算符的语法不允许使用 [实例].[运算符]string.+ 构造(这在 Scala 等语言中是允许的)。至于 public 访问修饰符,当你对一个特定类型使用运算符时,你并不在那个类型的类文件中进行操作。

要重载一个运算符,请使用以下语法:public static [返回类型] operator [运算符名称] (T x, T y) { ... },其中 T 代表你想要操作的类型。在我们对 Customer 类中赋值运算符的重载中,我们想要调用 CustomerEqualityOperatorEquals 方法并返回结果,如以下代码示例所示。

注意:当重载运算符时,如果运算符有一个匹配的运算符(例如,==!=),你必须重载这两个运算符。

public static bool operator == (Customer x, Customer y) {
  CustomerEqualityComparer comparer = new CustomerEqualityComparer();
  return comparer.Equals(x, y);
}

public static bool operator != (Customer x, Customer y) => !(x == y);

目前,每次调用重载运算符时,我们都创建一个新的比较器实例。在实际的生产代码库中,你应该考虑将实例化移动到实例级别。这样可以避免在每次调用重载运算符时产生实例化的开销。

!= 运算符的重载调用我们的等号运算符重载,并否定该方法执行的结果。现在到了有趣的部分:我们可以在 CustomerRepositoryTests.GetCustomerByName _Success 单元测试中使用重载的运算符,而不是通过比较对象字段来检查一致性。

要将数据库中的实例与 GetCustomerByName 方法返回的 Customer 对比,我们首先需要使用 LINQ 的 First 方法从内存数据库中获取 Customer,如下一个列表所示。

列表 7.4 在数据库的内部集合上使用 LINQ 的 First 方法(EF Core)

[TestMethod]
public async Task GetCustomerByName_Success() {
  Customer customer = 
➥ await _repository.GetCustomerByName("Linus Torvalds");   ❶
  Assert.IsNotNull(customer);                               ❶

  Customer dbCustomer = _context.Customer.First();          ❷

  bool customersAreEqual = customer == dbCustomer;          ❸
  Assert.IsTrue(customersAreEqual);                         ❹
}

❶ 从内存数据库中获取一个客户

❷ 直接从内存数据库中获取第一个(也是唯一一个)元素

❸ 使用重载的等号运算符

❹ 验证两个客户实例是否“相等”

在列表 7.4 中,我们使用重载的等号运算符来测试两个 Customer 实例之间我们定义的等价性。在我们结束本章之前,我要向你透露另一个秘密:我们可以进一步精简列表 7.4 中的代码,并使其更加符合惯例。Assert.AreEqual 方法调用对象的 Equal 方法,而该方法(根据提供的实现)会使用等号运算符。因为我们为 Customer 重载了等号运算符,所以当我们在两个 Customer 实例上使用 Assert.AreEqual 方法时,CLR(间接地)调用重载的等号运算符!让我们试试看:

[TestMethod]
public async Task GetCustomerByName_Success() {
  Customer customer = 
➥ await _repository.GetCustomerByName("Linus Torvalds");
  Assert.IsNotNull(customer); 

  Customer dbCustomer = _context.Customer.First(); 
  Assert.AreEqual(dbCustomer, customer);
}

我们现在实际运行 GetCustomerByName_Success 测试怎么样?它通过了吗?很好。现在运行所有其他单元测试。每个单元测试都应该通过,如图 7.6 所示。如果有一个没有通过,请回到相应的部分,看看出了什么问题,然后再继续。

图 7.6 我们所有的现有测试都通过了(如图所示在 Visual Studio 测试资源管理器中)。勾号代表已通过的测试。我们还可以看到测试的运行时间,无论是单独的还是组合的。

现在我们可以向数据库添加一个新客户,并在给定 ID 时检索它。这涵盖了我们从 CustomerRepository 需要的所有功能。我们在剩余的存储库中使用 CustomerRepository 中的模式。在第 6.2 节中,我们研究了继承的代码库是如何处理向数据库添加新客户的。

让我们回顾一下我们的发现。我们确定现有代码库在向数据库添加 Customer 对象时的主要设计缺陷如下:

  • 代码应该是自文档化的。

  • 我们不应该使用硬编码的 SQL 语句。

  • 我们应该使用显式类型而不是隐式类型(var 关键字)。

我认为我们在处理那些担忧方面做得很好。我们的代码可读且整洁。代码是自我文档化的,我们使用 Entity Framework Core 来抽象任何 SQL 查询。现有的代码是如何从数据库中检索 Customer 对象的?嗯,它根本就没有从数据库中检索任何 Customer 对象!一开始这似乎很奇怪。如果现有的代码库没有从 Customer 表中检索任何内容,那么我们的服务为什么还要检索 Customer 实体?记住,为了预订航班,我们需要访问最新的 Customer 对象。旧的服务每次有人通过其 API 预订航班时都会创建一个新的 Customer 对象,这可能导致数据库中同一 Customer 的重复条目。我们知道得更好,只要我们向我们的方法传递正确的信息,我们就不会有这个问题。在本章中,你还学习了如何使用等价“比较器”类和运算符重载来测试你的等价定义(或者更确切地说,等价性)。

练习

练习 7.1

目前,当从 GetCustomerByName 返回时,如果给定的名称与数据库中的 Customer 对象不匹配,没有围绕可能的空条件进行单元测试。我们将如何测试这一点?

练习 7.2

以下哪个是有效的可空类型?

a. Customer!

b. Customer?

c. ^Customer

练习 7.3

填空:自定义异常必须从 ________ 类继承,这样我们就可以在适当的位置抛出它。

练习 7.4

如果 LINQ 扩展方法 FirstOrDefault 在集合中找不到匹配项,它返回什么?

a. 一个空值

b. -1

c. 集合类型的默认值

练习 7.5

在此代码片段中,等号运算符测试什么,它的结论是什么?

int x = 0;
int y = 1;
x == y;

a. 等号运算符测试引用相等性。它返回 false

b. 等号运算符测试引用相等性。它返回 true

c. 等号运算符测试值相等性。它返回 false

d. 等号运算符测试值相等性。它返回 true

练习 7.6

在此代码片段中,等号运算符测试什么,它的结论是什么?

Scientist x = new Scientist("Alan Turing");
Scientist y = new Scientist("John von Neumann");
x == y;

a. 等号运算符测试引用相等性。它返回 false

b. 等号运算符测试引用相等性。它返回 true

c. 等号运算符测试值相等性。它返回 false

d. 等号运算符测试值相等性。它返回 true

练习 7.7

对或错?当我们重载等价比较运算符时,我们可以确定两个类型之间的等价性,并返回我们自己的等价定义。

练习 7.8

当我们重载等价比较运算符时,我们还需要重载

a. !=

b. ^=

c. ==

练习 7.9

对或错?通过使用 Random 类生成随机数,我们保证得到一个完美的随机数。

练习 7.10

对或错?通过使用 RandomNumberGenerator 类生成随机数,我们保证得到一个完美的随机数。

练习 7.11

对或错?在许多(伪)随机数生成算法中,使用相同的种子数字两次会产生相同的随机数。

摘要

  • 我们可以通过λ演算的视角来检查输入验证。如果我们把任何函数视为一个黑盒,只有一个输入和一个输出,与其他所有函数分离,我们就必须进行输入验证,否则可能会得到不良的结果。我们不应该依赖于其他地方的参数验证。

  • 可空引用类型允许我们明确指出哪些引用类型可能有空值。使用可空引用类型有助于避免意外的空指针异常。

  • 要指定一个类型为可空,你添加 ? 运算符:int? myNullableInt = 0;

  • 每个抛出的异常(最终)都源自 Exception 基类。这允许我们创建自己的(自定义)异常并将它们像其他任何异常一样抛出。自定义异常促进了原子错误处理。

  • 你可以在单元测试运行期间通过添加 [ExpectedException(typeof([your exception]))] 方法属性到你的单元测试中来检查一个方法是否抛出了特定的异常。这允许你测试代码的失败场景。

  • Entity Framework Core 可以在“内存”模式下运行。这允许你启动一个与真实数据库相同的本地内存数据库。因为我们不想针对实时数据进行单元测试,所以这为我们提供了所需的“假”数据库。

  • 如果标准的引用等价性检查不足以在两种类型之间创建等价性检查,你可以使用自定义的 Comparer 类。这允许你协调同一对象的两个不同实例,它们具有相同的值但不同的指针。

  • 我们可以重载像等号运算符这样的运算符,以提供我们自己的等性和同余定义。当想要比较两个引用类型以检查同余而不是纯等性(内存地址匹配)时,这很有用。


^ (1.) 哈希表的插入、查找和搜索操作(以及由此扩展的字典)的平均时间复杂度是 O(1)。这些操作的最坏情况是 O(n)。在 C#中,泛型列表(List<T>)充当动态数组。对于动态数组,搜索、插入和删除的平均和最坏情况时间复杂度是 O(n),其中 n 是动态数组中的元素数量。

(2.)哈希冲突是一个不希望出现的结果,但并非罕见事件。唐纳德·克努特在《计算机程序设计艺术 第 3 卷:排序与搜索》(第 2 版;Addison-Wesley,1998 年)中提到了“生日悖论”:给定一个基于个人生日的哈希函数和一个至少有 23 人的房间(将n个人映射到大小为 365 的表中,每人对应非闰年的一天,其中n ≥ 23),至少有两个人共享相同的生日(并生成相同的哈希码)的概率是 0.4927。

8 模拟、泛型和耦合

本章涵盖

  • 使用测试驱动开发创建Booking仓库类

  • 关注点和耦合分离

  • 使用泛型编程

  • 使用模拟进行单元测试

本章继续我们短期任务的一部分,即实现数据库中每个实体的仓库。如果我们从更大的角度来看,我们可以提醒自己为什么最初要实现这些仓库:飞利浦·范德梅伦,荷兰飞行的首席执行官,希望我们将他们的旧代码库带入现代时代。我们收到了一个 OpenAPI 规范以遵守(服务需要与航班搜索聚合器集成),并且我们决定在我们的新代码库中使用仓库/服务模式。图 8.1 显示了我们在本书方案中的位置。

图片

图 8.1 在本章中,我们将实现BookingRepository类。在第六章和第七章中,我们实现了CustomerRepository类。这就只剩下代码库的仓库部分中的AirportRepositoryFlightRepository类了。我们将在下一章中实现它们。

在第六章和第七章中,我们实现了Customer实体的仓库类。这次,我们将关注Booking实体。阅读完本章后,我希望你熟悉以下内容:

  • Liskov 替换原则

  • 关注点和耦合分离

  • 如何使用泛型

  • 如何编写严密的输入验证代码

  • 使用可选参数

当然,还有更多。

8.1 实现预订仓库

到目前为止,在我们的重构和实现 FlyingDutchmanAirlines 代码库新版本的过程中,我们已经设置了 Entity Framework Core,实现了数据库访问层(第五章)以及Customer仓库类(第六章和第七章)。在本节中,我们将开始编写BookingRepository类,如图 8.2 所示。回顾Booking模型,我们看到我们有三个字段:BookingID(主键)、FlightNumberCustomerID(外键,指向Customer.CustomerID)。整数是可空的,因为可能没有外键。当然,没有客户的预订是一种异常情况,所以这种情况不应该发生。

图片

图 8.2 Booking类和预订表。由于Booking类是从数据库模式逆向工程得到的,因此代码和数据库之间的同构关系很强。

我们只有一个端点用于处理预订,POST /Booking,它在数据库中创建一个新的预订。因为我们只做一件事,所以我们的新BookingRepository只需要一个公共方法:CreateBooking。但首先,我们应该在 RepositoryLayer 文件夹中创建BookingRepository类,以及在 FlyingDutchmanAirlines_Tests 项目中相应的测试类(包括成功测试用例的框架),如下面的代码示例所示。为了避免重复,计划是每个数据库实体(CustomerBookingAirportFlight)创建一个存储库。存储库类包含与数据库交互的小方法,通过数据库访问层进行操作。服务层类调用这些存储库,收集信息以返回给控制器类。我们在 5.2.4 节中讨论了存储库/服务模式。

namespace FlyingDutchmanAirlines.RepositoryLayer {
  public class BookingRepository {
    private readonly FlyingDutchmanAirlinesContext _context;

    public BookingRepository(FlyingDutchmanAirlinesContext _context) {
      this._context = _context;
    }
  }
}

如前几章所述,我们将使用测试驱动开发来确保我们的代码按预期工作,并在扩展代码时防止未来的回归。在第 6.1 节中,我介绍了测试驱动开发(轻量级)作为一种技术,以提高我们的代码正确性和可测试性的可能性。在测试驱动开发中,我们在编写逻辑实现之前创建单元测试。因为我们同时构建测试和实际逻辑,所以我们会在开发过程中不断验证代码是否符合我们的预期,从而避免在实现所有代码后编写单元测试时需要修复的 bug,如下面的列表所示。

列表 8.1 BookingRepositoryTests类的框架

namespace FlyingDutchmanAirlines_Tests.RepositoryLayer {
  [TestClass]
  public class BookingRepositoryTests {
    private FlyingDutchmanAirlinesContext _context;
    private BookingRepository _repository;

    [TestInitialize]
    public void TestInitialize() {                                    ❶
      DbContextOptions<FlyingDutchmanAirlinesContext> 
    ➥ dbContextOptions = 
    ➥ new DbContextOptionsBuilder<FlyingDutchmanAirlinesContext>()
    ➥ .UseInMemoryDatabase("FlyingDutchman").Options;                ❷
    _context = new FlyingDutchmanAirlinesContext(dbContextOptions);   ❷

    _repository = new BookingRepository(_context);                    ❸
    Assert.IsNotNull(_repository);                                    ❹
    }

    [TestMethod]
    public void CreateBooking_Success() { }
  }
}

TestInitialize方法在每次测试之前运行。

❷ 创建一个内存中的 SQL 数据库

❸ 使用依赖注入(DI)传递数据库上下文来创建BookingRepository实例

❹ 断言BookingRepository实例创建成功

在我们继续之前,让我们回顾一下旧代码是如何实现与 Booking 相关的代码以及我们确定的改进。旧代码将所有与每个实体相关的代码都挤在一个类中:FlightController。当你将实现细节放在控制器内部时,特别是那些与控制器处理的实体不同的实现细节,你会将数据库的实现细节与控制器紧密耦合。理想情况下,我们希望在控制器和数据库之间有一些抽象层(如服务、存储库和数据库访问层)。让我们想象一下,在开发代码库之后,你想将数据库供应商从 Microsoft Azure 更改为 Amazon AWS。如果你将控制器与数据库紧密耦合,那么在切换供应商时,你必须更改你拥有的每个控制器。如果你通过引入具有数据库访问层的存储库/服务模式来抽象数据库逻辑,松散数据库与控制器之间的耦合,那么你只需要在数据库访问层进行更改。对于我们来说,在 BookingRepository 的上下文中,我们希望提取出将新的 Booking 对象实际插入数据库的代码,如下所示:

cmd = new SqlCommand("INSERT INTO Booking (FlightNumber,     
➥ CustomerID) VALUES (" + flight.FlightNumber + ", ‘" +     
➥ customer.CustomerID + "’) ", connection);
cmd.ExecuteNonQuery();
cmd.Dispose();

原始代码的其余部分也手动获取了一些与外键约束相关联的数据。我们将在第 11.3 节中查看如何在服务层类中处理外键。

8.2 输入验证、关注点分离和耦合

在本节中,我们将模仿我们在第六章中添加客户到数据库时采取的方法,并将其应用于预订,如下所示:

  • 验证输入。

  • 创建一个 Booking 类型的新的实例。

  • 通过数据库上下文调用,将新实例添加到 Entity Framework Core 的 DbSet<Booking> 中。

CreateBooking 方法有两个输入:一个 customerID 和一个 flightNumber。它们都是 integer 类型,并且具有以下相同的验证规则:

  • customerIDflightNumber 必须是正整数。

  • 当与现有航班和客户匹配时,customerIDflightNumber 需要是有效的。

提出的验证规则意味着我们需要检查 DbSet 集合中的 CustomerFlight,以验证它们是否包含与输入信息匹配的条目。然而,问题在于,由于关注点的分离,我们不想在 BookingRepository 中处理除 Booking 之外的实体的 DbSet。此外,我们不想在存储库级别处理外键约束,而是在服务级别处理。对于存储库/服务架构,一个好的经验法则是这样的:让你的存储库保持简单,让你的服务保持智能。这意味着你的存储库方法应该是严格遵循单一职责原则(在第六章引言中讨论)的方法,而服务侧的这种遵循则稍微宽松一些。

服务可以调用它们完成任务所需的任何仓库方法。仓库方法不应该需要调用另一个仓库来完成它们的工作。如果你发现自己正在在仓库之间进行交叉调用,请退一步,重新阅读第 5.2.4 节关于仓库/服务模式的说明。在第十章和第十一章的BookingService中,我们将探讨如何编写一个管理这些关注点的服务,但到目前为止,理解我们为什么不想在BookingRepository中调用DbSet<Customer>DbSet<Flight>就足够了。最终,一切都归结为关注点分离和耦合。

点赞

关注点分离和耦合

“关注点分离”这个术语是由 Edsger Dijkstra 在他的论文《论科学思维的作用》(EWD 447,Springer-Verlag,1982 年)中提出的。在最基本层面上,这意味着一个“关注点”应该只做一件具体的事情。但什么是“关注点”呢?关注点是一个编程模块的心理模型,它可以表现为方法或类等形式。当我们把关注点分离提升到类级别,并将其应用于BookingRepository时,我们可能会说BookingRepository应该只关注对预订数据库表的操作。这意味着从客户表检索信息等操作并不在关注点的范围内。如果我们将其应用于方法,我们可以说一个方法需要做一件单一的事情,其他的事情都不做。这是非常重要的清洁代码原则,因为它帮助我们开发出可读性和可维护性强的代码。

我们之前讨论过使用小方法编写像叙事一样的代码的概念。这就是同一个概念。在罗伯特·C·马丁的巨著《Clean Code: A Handbook of Agile Software Craftsmanship》(Prentice-Hall,2008 年)中,罗伯特·C·马丁多次提到了这个主题。其中一个特别的场合是标题为“Do One Thing”的章节。他告诉我们:“函数应该只做一件事情。它们应该做得很好。它们应该只做这件事。”如果我们把这条信息记在心里编写代码,那么在编写出色代码方面,我们就已经领先一步了。我们在第六章讨论了单一职责原则,它关注于编写只做一件事情的清洁方法。

耦合是什么,它与关注点分离的理念有何关联?耦合是从另一个角度来处理关注点分离问题的一个度量。耦合是一个量化一个类与另一个类之间集成程度的指标。如果类之间高度耦合,这意味着它们高度依赖于彼此。我们称之为紧密耦合。我们不希望有紧密耦合。紧密耦合通常会导致方法在错误的结构级别调用很多其他方法:想想BookingRepository调用FlightRepository来检索航班信息。

松散耦合是指两个方法(或系统)之间相互依赖性不强,可以独立执行(因此,可以以最小的副作用进行更改)。拉里·康斯坦丁提出了“耦合”这个术语,该术语首次出现在康斯坦丁和爱德华·尤尔顿合著的书籍《结构化设计:计算机程序和系统设计学科的基础》(Prentice-Hall,1979 年)中。当试图确定两个事物之间的耦合程度时,可以提出康斯坦丁和尤尔顿在书中提出的问题:“为了理解另一个模块,必须了解多少关于一个模块的信息?”

|

为了理解CustomerRepositoryFlightRepository,需要了解多少内容?它们之间的相互连接有多强?如果我们处理服务层的耦合,则存储库应该具有非常松散的耦合和高度的关注点分离。

回到输入验证:尽管我们不必检查客户和航班数据库表之间的外键约束是否有效,但我们在保存数据库更改时隐式地检查它们。如果请求的更改违反了键约束,数据库将拒绝更改。

记得我们讨论过拥有可以接受任何输入(即使是错误的输入)并仍然返回适当结果的方法吗?如果我们得到一个坏的customerIDflightNumber输入,更新数据库的调用将抛出异常,我们将捕获它。通过捕获异常,我们可以控制数据和执行流程,并抛出我们自己的自定义异常来告诉用户出了问题,如下一列表所示。验证我们的输入变得简单:检查输入是否为正整数,然后我们就可以设置了。

列表 8.2 BookingRepository.CreateBooking 方法的基本输入验证逻辑

namespace FlyingDutchmanAirlines.RepositoryLayer {
  public class BookingRepository {
    public async Task CreateBooking(int customerID, int flightNumber) {   ❶
      if (customerID < 0 || flightNumber < 0) {                           ❷
        throw new ArgumentException("Invalid arguments provided");        ❸
      }
    }
  }
}

❶ CreateBooking 方法需要 customerID 和 flightNumber。

❷ 验证输入参数:customerID 和 flightNumber 需要是正整数。

❸ 如果输入参数无效,将抛出 ArgumentException 异常

C#提供了一种异常,我们可以在方法参数无效时使用:ArgumentException。当 customerID 或 flightNumber 不是正整数时,我们希望抛出 ArgumentException 类型的异常。为了抛出 ArgumentException,我们将错误消息(类型为 string)传递给 ArgumentException,并使用throw new模式实例化和抛出一个新的 ArgumentException 实例。

在 C# 中,某些类型有包装类型的类,这些类通过提供额外的功能来扩展它们。一个例子是 String 类和 string 类型。注意类和类型的大小写。C# 的约定是一个类以大写字母开头,而一个类型通常以小写字母开头。对于大多数类型及其包装类,你通常可以互换使用它们(直到你需要使用类公开的方法——对于 String,这可能是 IsNullOrEmpty)。请注意,Stringstring 通常解析为相同的底层中间语言代码。

在输入验证代码抛出 ArgumentException 之后,开发者可能会看到我们传递的消息并想知道出了什么问题。开发者们当然希望看到实际的参数值,但我们不希望在错误消息中返回输入参数并暴露给方法外部(或潜在地给最终用户)。你能想象使用一个应用程序并得到一个包含实际输入参数值的错误消息吗?当然,任何 UI 工程师或 UX 设计师都会对此提出异议。当然,总有例外(也许你也控制着这个服务唯一使用的客户端)。我们至少应该将这些参数记录到控制台,这样开发者就有更好的机会恢复这些值。一些公司使用像 Splunk 这样的技术来自动捕获写入控制台并存储在可搜索数据库中的日志。要写入控制台,我们使用 Console.WriteLine 方法,如下一列表所示。如果你不想写入控制台,ASP.NET 提供了专门的日志记录功能供你使用(有关更多信息,请参阅 MSDN ASP.NET 文档)。你也可以使用像 Log4net 或 NLog 这样的第三方日志库。我更喜欢使用最简单的日志记录类型,以完成任务。在这种情况下,将日志记录到控制台就足够了。

列表 8.3 使用字符串插值的 BookingRepository.CreateBooking 方法

public async Task CreateBooking(int customerID, int flightNumber) {
  if (customerID < 0 || flightNumber < 0) {
    Console.WriteLine($"Argument Exception in CreateBooking! CustomerID 
➥ = {customerID}, flightNumber = {flightNumber}");                        ❶
    throw new ArgumentException("Invalid arguments provided");
  }
}

❶ 通过字符串插值将无效的参数值记录到控制台

我们写入控制台的字符串是一个插值字符串。使用字符串插值,我们可以在字符串中插入(或内联)表达式和值,而无需显式地将多个字符串连接在一起(字符串连接仍然在底层发生)。我们通过在字符串本身前加上美元符号 $ 来创建插值字符串。然后,我们通过将它们包裹在花括号中来直接将值和表达式(甚至方法调用)插入到字符串中。字符串 {customerID} 插值了 customerID 的值,如图 8.3 所示。

图 8.3 字符串插值允许我们将变量值内联到字符串中。当使用字符串插值时,在字符串前加上美元符号($),并用花括号({[variable]})括住你想要使用的变量。

编译器将插值字符串转换为 C# 语句,该语句连接了一堆字符串。字符串插值提供的语法糖是构建可读字符串的极好且惯用的方式。因为字符串是不可变的,使用字符串插值并不会消除使用字符串连接的性能缺点。实际上,由于额外的开销,字符串插值可能比正常的字符串连接表现得更差,因为字符串是不可变的。我们还需要对输入验证逻辑进行单元测试。单元测试应根据无效输入参数进行断言,并验证当输入参数无效(负整数)时,输入验证逻辑抛出类型为 ArgumentException 的错误,如下一列表所示。

列表 8.4 使用 DataRow 方法属性

[TestMethod]
[DataRow(-1, 0)]                                                ❶
[DataRow(0, -1)]                                                ❶
[DataRow(-1, -1)]                                               ❶
[ExpectedException(typeof(ArgumentException))]                  ❷
public async Task CreateBooking_Failure_InvalidInputs(int customerID, 
➥ int flightNumber) {
  await _repository.CreateBooking(customerID, flightNumber);    ❸
}

[DataRow] 方法属性使用指定的测试数据运行测试。

❷ 这个测试期望抛出一个 ArgumentException 类型的异常。

❸ 调用 CreateBooking 方法并等待它

CreateBooking_Failure_InvalidInputs 单元测试结合了我们之前使用的一些不同技术,下面将进行描述:

  • 使用 [DataRow] 方法属性,我们为单元测试提供了测试数据,而无需为所有三个测试用例编写单独的单元测试。

  • 使用 [ExpectedException] 方法属性,我们告诉 MSTest 运行器,在测试执行期间,该方法应该抛出一个类型为 ArgumentException 的异常。

  • TestInitialize 方法中将 _repository 字段赋值给 BookingRepository 的新实例。

CreateBooking_Failure_InvalidInputs 单元测试运行时,我们使用 [DataRow] 方法属性,检查三个单独的测试用例,如表 8.1 所示。

表 8.1 在 BookingRepositoryTests.cs 中运行的三个不同测试用例 CreateBooking_Failure_InvalidInputs

customerID flightNumber
–1 0
0 –1
–1 –1

表 8.1 中的所有测试用例都是 CreateBooking 方法输入验证逻辑失败的输入参数集,导致 CreateBooking 方法抛出 ArgumentException

练习

练习 8.1

填空:在关注点分离的背景下,一个关注点指的是 __________。

a. 一个令人担忧的想法

b. 一个业务

c. 一个逻辑模块

练习 8.2

对或错?如果两个类高度依赖彼此,我们称之为松耦合。

练习 8.3

填空:在一个仓库/服务架构中,实际数据库查询应该在 __________ 级别进行,而调用执行数据库查询的方法应该在 __________ 级别进行。

a. c. 仓库;服务

b. 服务;仓库

c. 服务;服务

d. 仓库;仓库

练习 8.4

如果fruit是“奇异果”,这将打印到控制台的内容是什么?Console.WriteLine($"我喜欢吃 {fruit} ");

a. 什么也不做;它会导致编译错误。

b. “我喜欢吃{fruit}”

c. “我喜欢吃奇异果”

d. “$我喜欢吃奇异果”

练习 8.5

对或错?字符串是可变的。这意味着它是一个引用类型,其中对字符串的任何更改都会覆盖内存中的同一位置。

8.3 使用对象初始化器

在上一节中,我们验证了CreateBooking的输入参数,并了解了关注点分离、耦合和字符串插值。在本节中,我们将通过添加将新预订添加到数据库的逻辑来进一步扩展该方法。要将预订添加到数据库,我们需要执行以下四个主要步骤:

  1. CreateBooking方法中创建Booking类型的新实例。

  2. customerIDflightNumber参数填充新的Booking实例。

  3. 将新的Booking实例添加到 Entity Framework Core 的内部DbSet <Booking>

  4. 异步保存对数据库的更改。

首先,我们可以轻松地处理前两个步骤:在CreateBooking方法中创建Booking类型的新实例,并用customerIDflightNumber参数填充它,如下面的列表所示。

列表 8.5 BookingRepository.CreateBooking:创建并填充Booking实例

public async Task CreateBooking(int customerID, int flightNumber) {
  if (customerID < 0 || flightNumber < 0) {                              ❶
    Console.WriteLine($"Argument Exception in CreateBooking! CustomerID  ❶
➥ = {customerID}, flightNumber = {flightNumber}");                      ❶
    throw new ArgumentException("Invalid arguments provided");           ❶
  }                                                                      ❶

  Booking newBooking = new Booking();                                    ❷
  newBooking.CustomerId = customerID;                                    ❸
  newBooking.FlightNumber = flightNumber;                                ❹
}

❶ 验证输入参数

❷ 创建Booking类型的新实例

❸ 将 customerID 输入参数分配给 newBooking 中适当的属性

❹ 将 flightNumber 输入参数分配给 newBooking 中适当的属性

就像命令一样,我们得到了:一个Booking类型的实例。我们还用验证过的输入参数填充了CustomerIdFlightNumber属性。尽管如此,列表 8.5 中的代码让我感到有些烦恼:如果我们必须不断地输入[instance].[property] = [value],那么在新实例上填充字段可能会变得非常繁琐。还记得我们在 6.2.5 节中关于对象初始化器的讨论吗?这里语法上的微小变化(另一种语法糖的例子),在初始化具有大量需要设置的属性的对象时,可以产生显著的差异:

Booking newBooking = new Booking {
    CustomerId = customerID,
    FlightNumber = flightNumber
};

确实是将常规对象属性赋值代码“压缩”成一个块,如图 8.4 所示。对象初始化器在处理集合(如列表)时也有效(当在集合上使用时,它们被称为集合初始化器;当然,你可以在集合初始化器内部嵌套对象初始化器)。

图 8.4 对象初始化的对比方法:使用和不使用对象初始化器。约翰是一个不合格的动物园管理员,他照顾着一头雄伟的长颈鹿、一只愤怒的企鹅和一只危险的柯基。

我们剩下的任务就是尝试将 newBooking 添加到数据库中,并异步保存对数据库的更改。我们需要确保在将 Booking 实例添加到数据库时没有问题,因此我们将保存更改的逻辑包裹在 try-catch 语句中,当发生数据库错误时抛出一个自定义异常(CouldNotAddBookingToDatabaseException,它继承自 CouldNotAddEntityToDatabaseException,而 CouldNotAddEntityToDatabaseException 继承自 Exception)。当抛出此异常(与仓库层中的任何异常一样)时,我们在服务层再次捕获异常。我将创建自定义异常的任务留给你。

要使用 SaveChangesAsync 将新的预订保存到数据库,我们需要 await SaveChangesAsync 调用。等待 CreateBooking 方法意味着它必须异步执行。为了实现这一点,我们将方法的类型更改为 Task 并在方法签名中添加 async 关键字,如下面的代码示例所示。

列表 8.6 BookingRepository.cs CreateBooking 完整

public async Task CreateBooking(int customerID, int flightNumber) {
  if (customerID < 0 || flightNumber < 0) {                              ❶
    Console.WriteLine($"Argument Exception in CreateBooking! CustomerID  ❶
➥ = {customerID}, flightNumber = {flightNumber}");                      ❶
    throw new ArgumentException("invalid arguments provided");           ❶
  }                                                                      ❶

  Booking newBooking = new Booking {                                     ❷
 CustomerId = customerID,                                             ❷
 FlightNumber = flightNumber                                          ❷
 };                                                                     ❷

 try {
 _context.Booking.Add(newBooking);                                    ❸
 await _context.SaveChangesAsync();                                   ❹
 } catch (Exception exception) {                                        ❺
 Console.WriteLine($"Exception during database query:                 ❻
➥ {exception.Message}");                                                ❻
 throw new CouldNotAddBookingToDatabaseException();                   ❼
 }
}

❶ 验证输入参数

❷ 使用对象初始化器创建并初始化一个新的 Booking 实例

❸ 将新的预订添加到 EF Core 的内部 DbSet

❹ 异步保存更改到数据库

❺ 捕获在 try 块中抛出的任何异常

❻ 将开发人员信息写入控制台

❼ 抛出类型为 CouldNotAddBookingToDatabaseException 的异常

我们没有使用硬编码的 SQL 语句,也没有处理对象释放和语句使用的问题,而是使用了 Entity Framework Core 和简单的代码来实现相同的结果。

那么,接下来是什么?我相信你现在已经知道了:我们需要修复我们的成功用例单元测试。这应该足够简单。我们只需要发送有效的输入参数。但是,如果我们遇到数据库错误怎么办?即使我们有有效的 customerIDflightNumber,也可能因为数据库异常而失败。

8.4 使用模拟进行单元测试

在本节中,我将向您介绍模拟的奇妙世界。在单元测试的上下文中,模拟是指执行一个模拟类(充当某个类但覆盖实现)而不是原始类。我们留下上一节,因为我们正要测试 CreateBooking 方法可能因为数据库错误而抛出 CouldNotAddBookingToDatabase 异常的可能性。为了测试抛出的 Exception,我们需要暂时放下成功测试用例,并专注于模拟和新的测试方法:CreateBooking_Failure_DatabaseError。我更喜欢使用蛇形命名法为测试单元测试命名,如以下代码示例所示,但对此的看法各不相同:

[TestMethod]
public async Task CreateBooking_Failure_DatabaseError() { }

存根是一段代码(可能是一个完整的类),它在运行时替代了普通类。存根将针对原始类的调用重定向到自身,并执行那些方法的覆盖版本。方法重定向和覆盖在单元测试中特别有用,因为它们允许我们通过重定向方法调用并在需要时抛出异常来模拟错误条件。

查看 CreateBooking 方法,我们想要验证我们能否正确处理从数据库或 Entity Framework Core 内部传出的错误。但我们如何处理数据库异常呢?最简单的方法是扩展我们的依赖注入 FlyingDutchmanAirlinesContext 的重定向。我们仍然想使用内存数据库,但我们也想确保在调用某个方法时抛出异常。如果我们创建一个类(一个存根),它从 FlyingDutchmanAirlinesContext 继承,然后将其注入到 CreateBooking 中,如图 8.5 所示会怎样?

图 8.5 通过 FlyingDutchmanAirlinesContext_Stub 重定向 SaveChangesAsync。仓库在依赖注入 FlyingDutchmanAirlinesContext_Stub 上调用 SaveChangesAsync 方法。存根调用基类,并在 switch 语句中确定返回什么值。

让我们在 FlyingDutchmanAirlines_Tests 项目中创建一个新的文件夹名为 Stubs,以及一个名为 FlyingDutchmanAirlinesContext_Stub 的新类,如下所示。这个类从 FlyingDutchmanAirlinesContext 继承。

列表 8.7 FlyingDutchmanAirlinesContext 的存根骨架

namespace FlyingDutchmanAirlines_Tests.Stubs {
  class FlyingDutchmanAirlinesContext_Stub : 
➥ FlyingDutchmanAirlinesContext { }            ❶
}

❶ 该类从原始非存根类继承,允许我们用它来替代。

回到 BookingRepositoryTests,我们用存根替换上下文,并查看我们的测试是否仍然通过,如下面的代码示例所示。

列表 8.8 BookingRepositoryTests 初始化存根而不是原始类

namespace FlyingDutchmanAirlines_Tests.RepositoryLayer {
  [TestClass]
  class BookingRepositoryTests {
    private FlyingDutchmanAirlinesContext _context;        ❶
    private BookingRepository _repository;

    [TestInitialize]
    public void TestInitialize() {
      DbContextOptions<FlyingDutchmanAirlinesContext> 
➥ dbContextOptions = new     
➥ DbContextOptionsBuilder<FlyingDutchmanAirlinesContext>.UseInMemoryDatab    
➥ ase("FlyingDutchman").Options;                          ❷
      _context = new 
➥ FlyingDutchmanAirlinesContext_Stub(dbContextOptions);   ❸

      _repository = new BookingRepository(_context);       ❹
      Assert.IsNotNull(_repository);
    }
  }
}

❶ 存储字段的类型是非存根类。

DbContextBuilder 模式使用非存根类作为泛型类型。

❸ 由于多态性,我们可以为存储字段分配存根的新实例。

❹ 仓库接受我们的存根类代替非存根类。

列表 8.8 中的代码无法编译。因为我们没有为存根定义显式构造函数,当创建 FlyingDutchmanAirlines_Stub 的新实例时,CLR 创建了一个隐式的默认构造函数。但是 FlyingDutchmanAirlinesContext(基类/非存根类)有一个接受 DbContextOptions<FlyingDutchmanAirlinesContext> 类型的第二个构造函数,如果我们想使用内存数据库,就需要这个类型。因为存根从非存根继承,我们应该能够调用 FlyingDutchmanAirlinesContext_Stub 的父类构造函数,如图 8.6 所示。毕竟,父类是 FlyingDutchmanAirlinesContext

图 8.6 当派生类的构造函数包含对基类构造函数的调用时,基类的构造函数总是首先执行。在这里,RockAndRollHallOfFame的构造函数在RosettaTharpe的构造函数之前执行。

为了使代码能够编译,并且能够使用存根代替非存根类,我们需要创建一个构造函数,该构造函数可以提供一个带有所有钩子到基类(非存根)的存根实例。我们尚未在存根中重写任何方法,因此对存根的每次调用都应该自动转到非存根。为了将方法调用重定向到我们的存根逻辑,我们需要一个调用基类构造函数的构造函数。添加一个调用基类构造函数的构造函数可以保证我们得到一个基类实例,我们可以在重定向故事中使用它。

要调用基类的构造函数,在常规构造函数中添加: base ([arguments]),将[arguments]替换为你想要传递给基类构造函数的任何参数,如以下代码片段所示。这类似于在 Java 或 Python 中使用super关键字。请注意,CLR 始终在我们自己的(派生的)构造函数之前调用基类的构造函数。因此,如果你想在具有基类构造函数调用的自己的构造函数中执行处理,请注意这一点。

namespace FlyingDutchmanAirlines_Tests.Stubs {
  class FlyingDutchmanAirlinesContext_Stub : 
➥ FlyingDutchmanAirlinesContext {
    public FlyingDutchmanAirlinesContext_Stub
➥ (DbContextOptions<FlyingDutchmanAirlinesContext> options) 
➥ : base(options) { }
  }
}

明确定义此构造函数强制代码始终传递一个DbContextOptions<FlyingDutchmanAirlinesContext>类型的参数来实例化一个新的FlyingDutchmanAirlines_Stub。现在我们可以编译代码并运行所有测试。一切通过,因为除了重定向到存根之外,没有其他变化。由于继承和在存根中未重写方法,对存根的当前所有方法调用仍然转到FlyingDutchmanAirlinesContext的非存根版本。我们可以像使用FlyingDutchmanAirlinesContext的实例一样使用FlyingDutchmanAirlinesContext_Stub

Liskov 替换原则(多态)

这种类型的多态通常归功于计算机科学家 Barbara Liskov(同时也是约翰·冯·诺伊曼奖章和图灵奖的获得者),她与 Jeannette Wing 一起发表了一篇论文,描述了 Liskov 替换原则:

“设ø(x)是关于类型T的对象x的可证明属性。那么对于类型S的对象y,其中sT的子类型,ø(y)应该是真实的。”^a

你可能需要读两遍才能抓住其精髓。Liskov 和 Wing 告诉我们,当使用 Liskov 替换原则风格的泛型时,如果你有一个类型(Kit-Kat)是另一个类型(Candy)的子类型,类型 SKit-Kat)应该能够做类型 TCandy)能做的所有事情。可以对这样的对象应用鸭子类型测试——“如果它看起来像鸭子,游泳像鸭子,嘎嘎叫像鸭子,那么它可能就是鸭子”——以确定我们是否可以使用类型 S 而像使用类型 T 一样。Liskov 替换原则通常被认为是清洁代码的主要原则之一,它帮助我们编写具有适当抽象级别的可重用代码。


^a Barbara H. Liskov 和 Jeannette M. Wing 的 A Behavioral Notion of Subtyping(ACM Transactions on Programming Languages and Systems (TOPLAS),1994)。

在我们实现任何逻辑之前,根据 TDD 的神祇,我们需要在我们的单元测试中添加一个断言并使其失败。(如果我们还记得 TDD 的红绿灯阶段,我们现在处于绿色阶段,即将变为红色。)我们想要测试的代码路径(但我们还没有实现)会导致抛出类型为 CouldNotAddBookingToDatabaseException 的异常。在围绕将新预订添加到数据库的代码的 try-catch 块中,我们调用并等待 SaveChangesAsync 方法。如果我们能根据某个条件(例如,如果我们将新 Booking 对象的 customerID 设置为除了 1 以外的其他值)使该方法抛出异常会怎样?我们可以通过在我们的存根中重写 SaveChangesAsync 方法来实现这个目标。我们存根中重写的 SaveChangesAsync 方法的签名有点复杂(在重写方法时,必须保持相同的方法签名),但我们可以一步一步地完成它,如下所示:

public async override Task<int> SaveChangesAsync(CancellationToken 
➥ cancellationToken = default) {
  return await base.SaveChangesAsync(cancellationToken);
}

除了 override(你应该很熟悉)之外,我们还在重写的方法中看到了以下两个不熟悉的概念:

  • 泛型(我们之前已经看到过其应用)

  • 可选参数

坐下来吧,因为这两个话题都非常激动人心。

8.5 使用泛型编程

在本节中,我将揭开一个我们在书中已经多次见过但从未深入探讨的话题的神秘面纱:泛型。我们从一开始就使用了泛型。每次当你看到这个模式时,你就是在使用泛型:[类型]<[不同类型]>。泛型语法在实际应用中的例子有 List<string>DbSet<Customer>EntityEntry<TEntity>。如果你熟悉 Java 泛型或 C++ 模板,本节应该会感觉比较熟悉。

泛型是一个概念,它允许我们根据它们可以处理哪些类型来限制类、方法和集合。例如,一个集合通常有一个泛型版本和一个非泛型版本:List是非泛型的,而List<Tiger>List<Tiger>)是泛型的。List<Tiger>集合与任何可以转换为TigerTiger类本身或其派生类)的类一起工作。HashSet 是非泛型的,而HashSet<Baklava>HashSet<Baklava>)是泛型的。一个使用泛型参数的“野”方法的例子是Heapsort方法,它在Systems.Collections.Generic.ArraySortHelper类中的Sort方法中使用,以在输入上执行堆排序算法¹,如下所示:

private static void Heapsort(T[] keys, int lo, int hi,
➥ IComparer<T> comparer) {
  int n = hi - lo + 1;
  for (int i = n / 2; i >= 1; i = i - 1) {
    DownHeap(keys, i, n, lo, comparer);
  }

  for (int i = n; i > 1; i = i - 1) {
    Swap(keys, lo, lo + i - 1);
    DownHeap(keys, 1, i - 1, lo, comparer);
  }
}

Heapsort方法接受一个泛型参数T[],表示要排序的键数组,以及一个泛型参数IComparer<T>,表示比较对象。

尽管你通常在集合、方法和类中遇到泛型,但它们也不是不能使用泛型。那么,泛型类型看起来是什么样子呢?我们知道如何使用它们,但还没有学会如何创建它们。一个泛型类或方法使用“泛型类型”<T>。任何字母都可以,只要你在这个类或方法签名的作用域内保持一致。你可能想限制可以使用你的方法或类的类型。通过使用泛型约束将泛型的使用限制为特定类型或类型的子集。

要创建泛型约束,在相应的方法或类签名后添加一个子句,说“where T : [type]”。例如,如果我们想创建一个只接受类型Attribute实例的泛型类,我们会这样做:

public class MyGenericClass where T : Attribute

或者,当我们想创建一个只接受 16 位整数列表(表示为Int16short)的泛型方法时,我们可以这样说

public void MyGenericMethod<T>(T shorts) where T : List<short>

方法也可以有泛型类型的输入参数(无论是TXY还是你喜欢的任何字母)。最后,我们可以在一个类或方法中拥有多个泛型类型和多个泛型约束,如下所示:

public void MyGenericMethod<T, Y>(T key, Y encryption) where T : 
➥ List<Int16> where Y : RSA

在这里,MyGenericMethod有两个泛型类型:T,映射到List<Int16>,和Y,被约束为RSA类型。RSA类型是.NET 中所有 Rivest–Shamir–Adleman (RSA)加密功能的基础类。Y被约束为RSA并不意味着使用多态作为RSA类型的类是被禁止的;你可以使用它们而不会有问题。约束很少使用,但了解它们是好的。实际上,为了找到.NET 本身中合理良好的约束示例,我们必须深入挖掘。我们可以在WindowsIdentity类通过GetTokenInformation方法找到它,如下所示:

private T GetTokenInformation<T>(TokenInformationClass 
➥ tokenInformationClass) where T : struct {
  using (SafeLocalAllocHandle information = 
➥ GetTokenInformation(m_safeTokenHandle, tokenInformationClass)) {
    return information.Read<T>(0);
  }
}

GetTokenInformation方法从当前持有的 Windows 令牌返回请求的字段(由传入的TokenInformationClass实例确定)。请求的字段类型可以是任何类型,只要它是 struct。这由泛型T上的约束表示。

8.6 使用可选参数提供默认参数

在上一节中,我们通过查看泛型来开始对SaveChangesAsync方法签名中新概念的剖析。在本节中,我们将完成这次探险,并考虑可选参数。

可选参数是在方法签名中分配了默认值的参数(在签名内)。如果未传递适当的参数,CLR 将使用分配的默认值作为参数值。考虑以下方法签名:

public void BuildHouse(int width, int height, bool isPaid = true)

isPaid参数直接在方法签名中分配值。这就是我们所说的可选参数

如果我们没有传递匹配的参数,CLR 会将可选参数分配给方法签名中指定的默认值。参数确实是可选的,但仍然可以使用设置的值。我们可以以两种方式调用BuildHouse方法,如下一列表所示。

列表 8.9 在可选参数上调用方法

BuildHouse(10, 20);          ❶
BuildHouse(10, 20, false);   ❷

❶ 可选参数 isPaid 的值为 true。

❷ 可选参数 isPaid 的值为 false。

在第一个例子中,我们使用了可选参数(isPaid)的默认分配值true。在第二个例子中,我们分配了一个值false。在这两种情况下,方法逻辑都可以访问初始化后的isPaid版本。

方法可选参数重载 如果你只通过添加一个可选参数来重载方法,并且没有为可选参数提供参数,CLR 会忽略带有可选参数的重载并调用原始方法。例如,如果我们有两个名为BuildHouse(int width, int height)BuildHouse(int width, int height, bool isPaid = true)的方法,并且我们没有为isPaid传递参数,CLR 会调用BuildHouse(int width, int height)版本。

然而,有一个注意事项:我们绝不能在非可选参数后面跟可选参数。编译器要求可选参数必须是参数列表中的最后一个。编译器要求可选参数放在参数列表的最后意味着以下对BuildHouse方法签名的更改无法编译:

public void BuildHouse(int width, bool isPaid = true, int height)

在底层,一个可选参数作为一个普通参数使用,但会在生成的中间语言代码中添加 [opt] 关键字。因为 SaveChangesAsync 方法中的 CancellationToken 类型的参数是可选的,所以是否传递它取决于我们。记住,我们需要将其添加到我们的方法签名中,因为我们想覆盖包含可选 CancellationToken 参数的基类 SaveChangesAsync 方法。

CancellationToken

你可以通过使用 CancellationToken 的实例并调用 CancellationToken.Cancel() 方法来取消正在进行的数据库查询。取消令牌还用于通知代码的其他部分已取消请求。我们不在我们的代码中使用这些,因为我们的请求是简单的单条记录的插入和检索,并且具有有限的键约束。

如果你启动了一个可能需要几分钟才能执行的过程,你可能希望在某种边缘情况下取消它。在这种情况下,请使用一个取消令牌。如果我们不传递 CancellationToken 的实例,CLR 会自动为该参数分配一个新的实例。

我们存根覆盖的 SaveChangesAsync 方法目前包含以下一个语句:

return base.SaveChangesAsync(cancellationToken);

覆盖的 SaveChangesAsync 方法返回对基类(非存根)类 AddAsync 方法的调用。返回对基类方法的调用对我们不起作用。实际上,我们覆盖 SaveChangesAsync 方法只是为了让它表现得像未覆盖的版本一样。我们需要用我们的实现来替换对非存根基类 SaveChangesAsync 方法的调用。我们将在下一节中这样做。

8.7 条件语句、Func、switch 和 switch 表达式

让我们记住我们最初为什么要经历这个磨难:我们想要单元测试数据库异常代码路径,并确保代码能够优雅地处理异常。在本节中,我们将向前迈出一大步,并在存根 SaveChangesAsync 方法中实现逻辑。我们将讨论条件语句、Func 类型、switch 和 switch 表达式。

为了使用存根 SaveChangesAsync 方法,我们需要一种方法来区分成功和失败路径。我们绝对不希望在 CreateBooking 方法每次调用 SaveChangesAsync 时都抛出异常。因为 customerID 的值在我们控制之下,为什么我们不围绕它来构建我们的存根逻辑呢?如果我们将 entity.CustomerID 设置为任何正整数但不是“1”(一个任意数字——我们只需要一个数字来控制代码流程),我们就可以测试数据库异常代码分支,而不会破坏现有的测试。

我们可以使用各种方法来检查entity.CustomerID是否是值为1的正整数。我们可以编写一个简单的条件来检查CustomerID是否为1,并返回基类的AddAsync结果,或者抛出异常,如下一个列表所示。然而,我们需要从上下文中通过CreateBooking方法获取我们刚刚添加到内部DbSet<Booking>中的Booking对象,因为Booking对象没有被传递到SaveChangesAsync方法中。

列表 8.10 实现一个SaveChangesAsync的模拟版本

public async override Task<int> SaveChangesAsync(CancellationToken 
➥ cancellationToken = default) {                          ❶
 if (base.Booking.First().CustomerId != 1) {              ❷
 throw new Exception("Database Error!");                ❸
 }
  return await base.SaveChangesAsync(cancellationToken);   ❹
}

❶ 覆盖非模拟的SaveChangesAsync方法

❷ 检查实体的 CustomerID 是否为 1

❸ 如果 CustomerID 不是 1 则抛出异常

❹ 如果 CustomerID 是 1 则调用基的SaveChangesAsync

因为在基的Booking DbSet中只有一个Booking(我们在调用SaveChangesAsync之前在CreateBooking中添加了它),我们可以使用First LINQ 方法来选择预订。

8.7.1 三元条件运算符

为了进一步压缩代码,我们还可以将条件组合成一个简单的返回语句,并配以三元条件运算符(?:),如下面的代码列表所示。

列表 8.11 使用三元条件运算符来压缩条件返回块

public async override Task<int> SaveChangesAsync(CancellationToken 
➥ cancellationToken = default) {
  return base.Booking.First().CustomerId != 1            ❶
    ? throw new Exception("Database Error")              ❷
    : await base.SaveChangesAsync (cancellationToken);   ❸
} 

❶ 预订的 CustomerID 是否设置为 1?

❷ 正确条件:抛出异常

❸ 错误条件:调用非模拟的SaveChangesAsync

三元条件运算符助记符:如果你对三元条件运算符中的操作顺序感到困惑,一个好的助记符是“expression ? true : false。”

无论是使用if语句还是三元条件运算符方法都可以正常工作,但如果我们想扩展我们的条件分支怎么办?当然,我们可以创建无限多的else子句附加到条件上,但当条件子句的数量越来越多时,这会变得很麻烦。

8.7.2 使用函数数组进行分支

我们还可以做一些更技术性的事情,比如创建一个Func<Task<int>>对象的数组,使用 lambda 委托,并使用CustomerID的值作为列表的索引来调用它们,然后调用委托来执行适当的逻辑,如下一个代码示例所示。

列表 8.12 使用Func<Task<int>>并通过索引调用 lambda 委托

public async override Task<int> SaveChangesAsync(CancellationToken 
➥ cancellationToken = default) {
  Func<Task<int>>[] functions = {                                 ❶
    () => throw new Exception("Database Error!"),                 ❷
    async () => await base.SaveChangesAsync(cancellationToken)    ❸
 };

 return await 
➥ functions[(int)base.Booking.First().CustomerId].Invoke();      ❹
}

❶ 创建一个 Func 的 Task 的整数(Func<T>)数组

❷ 这个任务会抛出异常。

❸ 这个任务返回基调用SaveChangesAsync的整数结果。

❹ 使用 Booking.CustomerID 作为索引调用函数

使用Func<Task<int>>[]可能有点过度,并且仅适用于特定的索引值。如果functions数组只有两个元素,而CustomerID2(记住,在 C#中集合是零基的),我们会收到一个越界异常,因为请求的元素的索引高于集合中的最后一个元素。

8.7.3 switch语句和表达式

而不是使用简单的条件语句、三元条件运算符的条件语句,或者根据索引调用一个Task,我主张使用可靠的旧式switch语句,如下所示。

列表 8.13 使用常规switch语句进行代码分支

public async override Task<int> SaveChangesAsync(CancellationToken 
➥ cancellationToken = default) {
  switch(base.Booking.First().CustomerId) {                    ❶
 case 1:                                                    ❷
 return await base.SaveChangesAsync(cancellationToken);   ❷

 default:                                                   ❸
 throw new Exception("Database Error!");                  ❸
 }
}

❶ 根据 CustomerId 值切换逻辑分支

❷ 如果 CustomerId 为 1,则调用非存根的 SaveChangesAsync

❸ 如果没有其他情况匹配,则执行默认操作:抛出异常

在列表 8.13 的switch语句中,我们执行了针对CustomerId1的常规、非重写的SaveChangesAsync路径。在switch语句中,如果没有匹配的情况,它会查找default情况并执行它。如果你没有提供default情况,并且没有匹配的情况,则switch语句不会执行任何情况。在这里,default情况是当CustomerId不是1时。我们的default情况会抛出异常。

C# 8 引入了switch语句的新特性,称为switch 表达式。它允许我们通过使用类似于 lambda 表达式的语法来编写稍微简洁的switch语句,如下所示。

列表 8.14 使用switch表达式进行代码分支

public async override Task<int> SaveChangesAsync(CancellationToken 
➥ cancellationToken = default) {
  return base.Booking.First().CustomerId switch {           ❶
 1 => await base.SaveChangesAsync(cancellationToken),    ❷
 _ => throw new Exception("Database Error!"),            ❸
 };
}

❶ 返回switch语句的结果

❷ 当 CustomerID 为 1 时,调用非存根的 SaveChangesAsync

❸ 默认情况:抛出异常

使用switch表达式可以大大简化你的长switch语句。我们还应该查看代码是否抛出了CouldNotAddBookingToDatabaseException异常。为此,适当的单元测试必须使用以下[ExpectedException]方法属性:

[TestMethod]
[ExpectedException(typeof(CouldNotAddBookingToDatabaseException))]
public async Task CreateBooking_Failure_DatabaseError() {
  await _repository.CreateBooking(0, 1);
}

让我们运行测试。结果令人惊讶,它通过了!我们现在可以回到并完成对BookingRepository的最终测试:BookingRepository_Success。

如此看来,我们目前只有一个方法框架,但我们只需向CreateBooking传递有效的参数,如下一个列表所示。CreateBooking方法没有输出,因此我们进行的任何断言都需要在 Entity Framework Core 的内部DbSet<Booking>上完成。我们想要断言确实在内存数据库中创建了一个Booking,并且它有一个CustomerID1

列表 8.15 完成的BookingRepository.CreateBooking_Success单元测试

[TestMethod]
public async Task CreateBooking_Success() {
  await _repository.CreateBooking(1, 0);            ❶
 Booking booking = _context.Booking.First();       ❷

 Assert.IsNotNull(booking);                        ❸
 Assert.AreEqual(1, booking.CustomerId);           ❹
 Assert.AreEqual(0, booking.FlightNumber);         ❺
}

❶ 在内存数据库中创建预订

❷ 从内存数据库中检索预订

❸ 验证预订不为空

❹ 验证预订具有正确的 CustomerID

❺ 验证预订具有正确的 FlightNumber

让我们运行测试看看会发生什么。等等!CreateBooking_Success测试失败了。但是为什么?它说方法抛出了一个类型为CouldNotAddBookingToDatabaseException的异常。我们陷入了 Entity Framework Core 提供的最常见陷阱之一:在访问它之前,我们没有将更改保存到DbSet<Booking>(以及数据库)中。

8.7.4 在 Entity Framework Core 中查询挂起的更改

如果我们回顾模拟中的 SaveChangesAsync 方法,我们会看到在调用 base.SaveChangesAsync 之前访问 context.Booking。在将我们对内部 DbSet 所做的任何更改保存到数据库之前访问 Booking DbSet 意味着在预订集合中还没有任何内容,这会导致 NullReferenceException,我们在 CreateBooking 方法中捕获它。然后,CreateBooking 方法会抛出 CouldNotAddBookingToDatabaseException

解决方案很简单:在访问 context.Booking 之前调用 base.SaveChangesAsync。因为我们使用的是内存数据库,所以在失败路径单元测试期间我们可以将 Booking 对象提交到数据库,因为 TestInitialize 方法在下一个测试之前创建了一个新的数据库上下文实例(并且隐式地清除了数据库)。测试的重要部分是异常被抛出。这意味着我们不再需要在 switch 语句中包含 default 的情况。话虽如此,让我们将非 default 语句上执行的逻辑更改为返回值为 1 的整数。SaveChangesAsync 方法(在非模拟场景中)返回写入数据库的条目数。我认为在模拟中没有理由偏离这个模式。毕竟,我们是在模仿它的操作。

在这个场景中,非默认 switch 值的唯一目的是满足 Task<int> 所需的返回类型。通过返回一个值为 0 的值,我们完成方法并且没有造成伤害。我们仍然在 CustomerID 不是 1 的情况下抛出异常,如下一个列表所示。

列表 8.16 使用基类 SaveChangesAsync 调用的模拟 SaveChangesAsync 方法

public override async Task<int> SaveChangesAsync(CancellationToken 
➥ cancellationToken = default) {
  await base.SaveChangesAsync(cancellationToken);      ❶

  return base.Booking.First().CustomerId switch {      ❷
 1 => 1,                                            ❸
    _ => throw new Exception("Database Error!")        ❹
  };
}

❶ 调用非模拟的 SaveChangesAsync

❷ 根据 CustomerId 进行切换

❸ 如果 CustomerID 是 1,则返回 0

❹ 如果 CustomerID 不是 1,则抛出异常

如果我们现在运行测试,它会通过。然而,这里我们还有一个额外的问题需要考虑。目前,如果 CustomerId 不是 1,我们会抛出异常,但我们的更改已经保存到数据库中。我们实际上应该在保存到数据库之前测试 CustomerID。为此,我们需要能够访问数据库的挂起更改。Entity Framework Core 允许我们通过查询其内部的 StateTracker 来获取具有 EntityStateAdded 的实体,如下所示:

IEnumerable<EntityEntry> pendingChanges = 
➥ ChangeTracker.Entries().Where(e => e.State == EntityState.Added);

结果的 IEnumerable 集合仅包含我们挂起的更改。然而,我们真正想要的是挂起更改中的 Entity 对象。我们可以使用 LINQ 的 Select 语句来仅获取 Entity 对象,如下所示:

IEnumerable<object> entities = pendingChanges.Select(e => e.Entity);

从这里,我们可以将 EntityEntry 强制转换为我们的 Booking 类型并获取 CustomerId。为了将(并选择)映射到 Booking 的实体进行强制转换(和选择),我们可以在 IEnumerable 上使用 OfType<T> 方法,如下所示:

IEnumerable<Booking> bookings = 
➥ pendingChanges.Select(e => e.Entity).OfType<Booking>();

我们可以使用这些 Booking 实例来验证我们没有任何带有 CustomerId1 的挂起更改,如下所示:

bookings.Any(c => (b => b.CustomerId != 1)

我们所需要做的就是抛出一个异常,以防我们确实有这种挂起的更改。如果没有,我们就可以继续将更改保存到数据库中,如下面的代码示例所示。

列表 8.17 在保存到数据库之前在存根中检查有效的 CustomerID

public override async Task<int> 
➥ SaveChangesAsync(CancellationToken cancellationToken = default) {
 IEnumerable<EntityEntry> pendingChanges = ChangeTracker.Entries()
➥ .Where(e => e.State == EntityState.Added);
 IEnumerable<Booking> bookings = pendingChanges
➥ .Select(e => e.Entity).OfType<Booking>();
 if (bookings.Any(b => b.CustomerId != 1)) {
    throw new Exception("Database Error!");
 }

  await base.SaveChangesAsync(cancellationToken);
  return 1;
}

为了确保我们没有破坏任何东西,让我们在解决方案中运行所有的单元测试。似乎当单独运行时,CreateBooking_Success 测试用例通过,但与其他测试一起运行时则不通过。测试运行器报告说抛出了一个类型为 CouldNotAddBookingToDatabaseException 的异常。嗯,我们直接从 Entity Framework Core 的陷阱跳到了继承的陷阱:当创建一个新实例的类时,如果基类已经存在,则不会实例化基类。当我们请求 FlyingDutchmanAirlinesContext _Stub 的新实例时,如果其父类(FlyingDutchmanAirlinesContext)已经存在,CLR 不会实例化其父类。在实践中,这意味着我们在单元测试中处理的是同一个数据库上下文,因此内存数据库的内容在每次测试后不会清除。因此,当我们执行 CreateBooking_Success 测试用例时,有可能数据库中仍然存在一个残留的 Booking 实例。因为我们从上下文中在我们的重写 SaveChangesAsync 方法中请求第一个预订的 CustomerID,我们可能会得到一个错误的 CustomerID。我们能做什么呢?以下两种方法中的任何一种都可能对我们有效:

  • TestInitialize 方法中手动清除数据库的 DbSet<Booking>

  • 使用 Entity Framework Core 的 EnsureDeleted 方法,该方法检查数据库是否被删除。如果数据库仍然存在,EnsureDeleted 会毫不犹豫地删除数据库。

这两种方法效果相同,但为了保持有趣,让我们尝试使用 EnsureDeleted 方法。我们可以在 TestInitialize 方法或 FlyingDutchmanAirlines _Stub 的构造函数中调用 EnsureDeleted 方法。在我看来,我们最好将其放在存根的构造函数中,如下一列表所示。使用 EnsureDeleted 方法与我们使用存根非常相关,我喜欢在测试类之间保持 TestInitialize 方法尽可能相似。

列表 8.18 使用 EnsureDeleted 删除内存数据库

public 
➥ FlyingDutchmanAirlinesContext(DbContextOptions
➥ <FlyingDutchmanAirlinesContext> options) {
  base.Database.EnsureDeleted();                   ❶
}

❶ 删除非存根的内存数据库

如果我们现在运行所有的测试,我们会看到 CreateBooking_Success 测试用例运行得相当好。

有了这个,我们就完成了 BookingRepository。你现在对泛型、可选参数、switch 表达式和 Liskov 替换原则的世界了如指掌。

练习

练习 8.6

对或错?当使用存根时,你必须重载基类的方法。

练习 8.7

由于哪个原则,我们才能将 FlyingDutchmanContext_Stub 的一个实例用作 FlyingDutchmanContext 类型的实例?

a. Liskov 替换原则

b. DRY 原则

c. Phragmén–Lindelöf 原则

练习 8.8

在使用泛型编程时,我能否将 float 类型的实例添加到 List<bool> 中?

a. 是

b. 否

c. 让我帮你 Stack Overflow。

练习 8.9

这是什么例子?where T : Queue<int>

a. 一个泛型集合

b. 一个通用的泛化

c. 一个泛型约束

练习 8.10

真或假?我们只能与类一起使用泛型。

练习 8.11

真或假?具有泛型的类只能有一个泛型约束。

练习 8.12

真或假?你不能将泛型类型用作方法参数的类型。

练习 8.13

真或假?使用可选参数是可选的。

练习 8.14

在方法参数列表中,可选参数放在哪里?

a. 在开始时

b. 在末尾

c. 任何地方

练习 8.15

真或假?就像默认隐式构造函数一样,如果你在 switch 语句中没有声明 default 情况,编译器会为你生成它并执行你指定的第一个情况。

摘要

  • 关注点分离意味着我们希望将逻辑模块彼此隔离。这确保了我们的代码是可测试的和原子的。原子代码更易于阅读和扩展。

  • 耦合指的是两个“关注点”如何集成。紧密耦合可能意味着某个类深度依赖于另一个类,而两个类之间的松耦合则意味着一个类可以更改和扩展,而不会对另一个类造成问题。具有紧密耦合的类更难以维护,因为你不知道依赖关系的全部范围时,很容易引入副作用。

  • 你可以通过使用字符串插值将变量的值内联到字符串中。这使得在不使用显式连接运算符的情况下构建复杂字符串变得容易。

  • 字符串是不可变的。对字符串的任何操作(连接、删除、替换)都会导致 CLR 在内存中为字符串的新副本分配一个新的区域。这意味着如果你对字符串进行很多操作,每一步(连接、删除)都会导致新的内存分配。在处理许多连接或删除时,这一点很重要。

  • 在实例化新对象后向值分配多个属性时,使用对象初始化器允许使用更紧凑和更易读的语法。当没有提供适当的构造函数时,对象初始化器是实例化复杂对象的惯用方式。

  • Liskov 替换原则指出,一个类型的子类型应该能够做父类型能做的所有事情。这意味着我们可以像使用父类型一样使用子类型。Liskov 替换原则解释了为什么我们可以使用多态。

  • 模拟代码是一个使用 Liskov 替换原则来充当其父类(或功能)的代码片段,同时覆盖和重定向某些方法以确保特定功能。模拟代码在单元测试中非常有用,可以抛出异常并返回特定响应。

  • 泛型允许我们将类、方法或类型约束为使用特定类型。例如,Stack<MaineCoon>List<ISnack>使用泛型来约束其功能以适应特定类型或类型组。以这种方式约束代码是一种有用的技术,可以控制数据类型并确保它们在整个代码中符合预期。

  • 可选参数允许我们为方法定义非必需的参数。如果没有传递匹配的参数,可选参数将采用定义的值。当我们的代码依赖于多个可能的参数值集时,可选参数非常有用,即使我们没有传递特定参数,我们也可以继续处理。

  • Entity Framework Core 的EnsureDeleted方法检查数据库是否已被删除。如果没有,则将其删除。这在测试期间与内存数据库一起工作时非常有用,以确保没有之前测试数据的残留。


(1.)关于堆排序算法的详细信息,请参阅 A. K. Dewdney 的《新图灵 Omnibus》,第四十章,“堆和合并”(W.H. Freeman and Company,1993 年)或 Robert Sedgewick 和 Kevin Wayne 的《算法》,第 2.7 章,“堆排序”(第 4 版;Pearson Education, Inc.,2011 年)。

9 扩展方法、流和抽象类

本章涵盖

  • 使用流重定向控制台输出

  • 使用抽象类在派生类之间提供通用功能

  • 使用AddRange LINQ 方法一次性向集合中添加许多东西

  • 使用SortedList集合

  • 使用扩展方法扩展现有类型以添加新功能

  • 重构“魔法数字”

在 3.1 和 3.2 节中,飞剪航空公司(Flying Dutchman Airlines)的 CEO 委托我们创建现有 FlyingDutchmanAirlines 代码库的新版本。现有的代码库很旧,充斥着设计缺陷,并且与新签订的商业协议中搜索聚合商提出的新 API 要求不兼容。在第三章和第四章中,我们考虑了现有的代码库,并标记了潜在的提升点。在第五章中,我们开始了重构工作,并使用 Entity Framework Core 实现了数据库访问层。随后,在第五章至第八章中,我们从以下四个必需的类中实现了(并测试了)两个存储库:

  • CustomerRepository—我们在第六章和第七章实现了这个存储库类。

  • BookingRepository—我们在第八章实现了这个存储库类。

  • AirportRepository—我们在本章实现这个存储库类。

  • FlightRepository—我们在本章实现这个存储库类。

见图 9.1 了解我们在本书结构中的位置。

图片

图 9.1 在本章中,我们将实现AirportRepositoryFlightRepository类。这是完成重构中存储库部分的最后两个必需的存储库。

通过学习诸如测试驱动开发、DRY 原则、Liskov 替换原则和 LINQ 等内容,并且现在对这些存储库的整体结构和测试模式已经很熟悉,我们可以在本章通过实现AirportRepositoryFlightRepository来加快速度,完成存储库层的重构。我们还将了解抽象类(作为接口的替代品,它强制我们在所有派生类中实现相同的方法)并重新审视扩展方法(第 9.6 节),以便我们可以为现有类型提供新的功能。

9.1 实现机场存储库

在第六章至第八章中,我们在开始实现时遵循了一个基本的第一步:为存储库和单元测试创建骨架类。我在本章也遵循了同样的方法。

AirportRepository 模板使用依赖注入,将 FlyingDutchmanAirlinesContext 类的一个实例注入到 AirportRepository 的显式(非默认)构造函数中,如图 9.2 所示。构造函数将注入的 FlyingDutchmanAirlinesContext 分配给一个私有后置字段。此外,AirportRepository 类的访问修饰符是公共的。AirportRepositoryTest 类有一个 TestInitialize 方法,该方法初始化 FlyingDutchmanAirlinesContext 并将其分配给一个私有后置字段,这样我们就可以在每次单元测试中使用内存数据库的新实例。TestInitialize 方法还实例化并分配一个新的 AirportRepository 实例到私有后置字段。[TestClass] 属性注解了 AirportRepositoryTest 类。如果这些内容让你感到困惑,请回顾第六章和第七章,我在那里详细展示了如何设置这些模板类。

图 9.2 不论是创建一个常规类还是一个测试类,第一步都是创建实际的类文件。如果你正在处理一个常规类,请继续向类和构造函数中添加依赖注入。如果你创建了一个测试类,你也可以设置一个可选的 TestInitialize 方法。

当我们处理 Airport 实体时,我们需要支持哪些 HTTP 动作?传统观点认为,对于每个实体,我们需要与常见分组创建-读取-更新-删除(CRUD)操作相对应的逻辑。我不同意这种观点。我说,只暴露和实现你需要完成工作的内容。对于 Airport 来说,通过 API 暴露在 Airport 表中创建、更新或删除数据的能力几乎没有意义。我们需要的只是一个读取操作。

9.2 通过 ID 从数据库获取机场

AirportRepository 的上下文中,读取操作映射做了什么?我们需要“读取”数据库中的 Airport 实体,这意味着当给定其 ID 时,我们应该返回一个 Airport。我们在第 6.2 节中做了类似的事情,当时当给定其 ID 时,我们从数据库返回了一个 Customer 对象。在本节中,我们将开始实现从数据库返回 Airport 所需的方法:GetAirportByID

但,就像往常一样,测试驱动开发的第一步是红灯阶段——GetAirportByID 的成功用例单元测试,如下所示:

[TestMethod]
public void GetAirportByID_Success() {
  Airport airport = await _repository.GetAirportByID(0);
  Assert.IsNotNull(airport);
}

当我们尝试编译这段代码时,不仅会得到一个编译错误,表明编译器找不到 GetAirportByID 方法(因为我们还没有实现这个方法,所以这是预料之中的),而且还会出现另一个编译错误,如下所示:

"await 操作符只能在异步方法中使用。"

你无疑会在你的 C# 生涯中多次遇到这个错误,因为很容易忘记标记一个等待某个操作的方法为 async 并具有适当的返回值(Task)。

注意:如第 6.2.8 节所述,异步方法期望返回类型为Task。如果你想返回空值(void),请使用非泛型版本:Task。如果你想返回实际值,请使用泛型版本Task<T>,其中T是你的返回类型。例如,为了返回一个布尔值¹并伴随一个Task,请使用Task<bool>Task代表一个单独的工作单元(一个关注点)。

为了刷新我们的记忆,如果我们想将一个方法从同步执行转换为异步执行,我们需要在方法签名中使用async关键字和一个返回类型为TaskTask<T>(如果Task返回一些数据),如下所示:

[TestMethod]
public async Task GetAirportByID_Success() {
  Airport airport = await _repository.GetAirportByID(0);
  Assert.IsNotNull(airport);
}

要编译代码并通过成功案例单元测试(测试驱动开发的绿色阶段),我们需要在AirportRepository中创建一个接受integer类型参数的GetAirportByID方法,如下所示:

public async Task<Airport> GetAirportByID(int airportID) {
  return new Airport();
}

如果我们现在编译代码并运行GetAirportByID_Success测试用例,我们会看到它通过了。显然,GetAirportByID中的代码并没有真正为我们做很多事情。它只是返回一个新的Airport类型实例,而不是从数据库中返回特定的条目。我想和你,我亲爱的读者,做一个实验:我想让你在一分钟内思考从数据库中检索Airport对象所需的四个主要步骤。准备好了吗?开始!

一分钟过去了?你确定吗?好吧,那么,让我们继续。大致来说,我们需要执行的四个主要步骤如下,并在图 9.3 中展示:

  1. 验证给定的airportID

  2. 从数据库中检索正确的Airport

  3. 使用自定义异常处理数据库中可能出现的任何Exception

  4. 返回找到的Airport实例。

图 9.3 从数据库返回Airport所涉及的步骤。首先,我们检查给定的输入参数是否有效。如果不是,我们抛出并处理异常。如果输入有效,我们尝试从数据库中获取正确的Airport。如果数据库遇到问题,我们抛出并处理异常。如果没有遇到错误,我们返回找到的Airport实例。

如果你感到好奇,我邀请你按照以下大致步骤实现GetAirportByID方法。完成实现后,回到书中比较我的实现和你的实现。如果我的实现与你的不同,那也是可以的。如果你有测试来支持你的功能,并且测试通过,你可以确信你的代码是优秀的。(你的代码是否整洁完全是另一回事,并且不能通过测试来衡量。要检查代码的整洁性,请使用附录 B 中的整洁代码清单。)

在开始这次伟大冒险之前,我给你提供最后一个建议:保持简单。在我职业生涯的早期,我认为自己在使用编程语言的非常隐蔽的角落和奇特的算法时很聪明。这导致代码对任何人(包括我自己)都难以阅读,并且因此难以维护。通过保持简单(或者根据你对生活的看法,足够复杂)来变得聪明。

9.3 验证机场 ID 输入参数。

如第 9.2 节所述,从数据库获取机场的四个步骤如下:

  1. 验证给定的airportID

  2. 从数据库中检索正确的机场信息。

  3. 处理数据库中任何潜在的异常,使用自定义异常进行处理。

  4. 返回找到的机场实例。

在本节中,我们将解决四个问题中的第一个:验证用户输入。GetAirportByID方法接受一个类型为整数的参数。AirportID应该是一个正整数(一个大于或等于0的整数)。为了测试AirportID是否为正整数,我们使用与在GetCustomerByIDCreateBooking方法中编写的类似条件:如果参数的值无效,则将日志写入控制台并抛出ArgumentException异常,如下一列表所示。写入控制台日志使用字符串插值将airportID的值内联。

列表 9.1 验证GetAirportByID中的airportID参数。

public async Task<Airport> GetAirportByID(int airportID) {
  if (airportID < 0) {                                                  ❶
    Console.WriteLine($"Argument Exception in GetAirportByID! AirportID 
➥ = {airportID}");                                                     ❷
    throw new ArgumentException("invalid argument provided");           ❸
  }

  return new Airport();                                                 ❹
}

❶ 判断机场 ID 是否具有有效的值。

❷ 将机场 ID 值记录到控制台以供开发人员查看。

❸ 抛出ArgumentException类型的异常。

❹ 返回一个新的机场实例。我们将在本章中更改此实现。

这看起来不错。但你知道接下来会发生什么,不是吗?我们需要添加一个检查无效输入值的失败案例单元测试。我们知道我们可以使用[DataRow]方法属性为失败案例单元测试提供多种测试数据,但我们应该提供什么数据呢?嗯,我们只有一个无效输入数据点需要测试:一个负整数。

因为我们只需要测试一个数据点,所以我们不需要[DataRow]方法属性。我们可以使用[DataRow]方法属性与仅处理一个数据点的单元测试一起使用,但这将是过度设计。如果我们只测试一个数据点,那么不使用[DataRow]方法属性会更简洁,如下所示:

[TestMethod]
public async Task GetAirportByID_Failure_InvalidInput() {
  await _repository.GetAirportByID(-1);
}

GetAirportByID_Failure_InvalidInput单元测试将一个负数(因此是无效的)整数传递给GetAirportByID方法。我们期望GetAirportByID方法看到我们向它提供了一个无效的AirportID参数。随后,我们期望该方法将消息记录到控制台并抛出ArgumentException。我们如何验证GetAirportByID方法抛出了预期的ArgumentException异常?我们需要使用[ExpectedException(typeof(ArgumentException))]方法属性注释单元测试,如下所示:

[TestMethod]
[ExpectedException(typeof(ArgumentException))]
public async Task GetAirportByID_Failure_InvalidInput() {
  await _repository.GetAirportByID(-1);
}

我们运行了测试,一切顺利。我们还能测试哪些输入验证?使用GetCustomerByName,我们对GetCustomerByName中的输入验证代码在无效输入的情况下抛出ArgumentException类型的异常表示满意。但是GetAirportByName方法也向控制台记录了一条消息。我们可能需要检查这一点。

9.4 输出流及其具体抽象

为了验证我们是否向控制台记录了消息,我们需要访问控制台的内容。检索控制台输出的技巧是提供替代输出并将控制台设置为写入该输出。在本节中,我们将讨论如何绕过控制台输出到我们自己的数据流。

警告:C#中流的概念与 Java 中使用 Streams API 不同。在 Java 中,使用 Streams API 几乎类似于在 C#中使用 LINQ。本节解释了 C#中的流概念。

Console类是数据输入和输出流的包装器。流表示一系列数据,通常是字节。Console类处理以下三个数据流,如图 9.4 所示:

  • System.IO.TextReader,它表示输入流

  • System.IO.TextWriter,它表示输出流

  • System.IO.TextWriter,它表示错误流。

图 9.4

图 9.4 从输入到输出的可能生命周期。首先,键盘输入(1)被发送到输入流(2)。然后,在应用程序内部进行一些处理(这可以是任何你想要的内容)。处理之后,如果抛出了异常,错误被写入错误流(3)。如果没有异常,信息被写入输出流(3)。最后,错误和输出流在控制台中显示(4)。

Console应用程序的上下文中,输入流处理任何键盘输入。输出流是我们想要在输出中显示任何内容的地方。错误流是记录异常的地方。我们没有访问默认的TextReaderTextWriter,但我们可以通过使用ConsoleSetOutSetInSetError方法来指定自己的。

通过实例化我们的StringWriter类型实例(一种处理字符串的数据流)并在使用它作为Console的输出流的同时保留对该变量的引用,我们可以轻松地获取历史数据。与写入一些无形输出流不同,Console.WriteLine方法将写入我们的StringWriter,如图 9.5 所示。请注意,一些编程语言(如 Java)在类型级别上区分输入和输出流。C#不这样做。您可以使用任何Stream派生类作为输入或输出流。

图 9.5

图 9.5 当将控制台输出重定向到StringWriter实例时,输出和错误流将写入StringWriter实例而不是常规控制台输出。从图 9.4 的生命周期变化:首先,键盘输入(1)被发送到输入流(2)。然后,在应用程序内部进行一些处理(这可以是任何你想要的东西)。处理之后,如果抛出了异常,错误将被写入错误流(3)。如果没有抛出异常,信息将被写入输出流(3)。最后,错误和输出流被写入我们的StringWriter实例(4)。

Stream基类是所有数据流的基石。Stream是一个抽象类,也是许多派生类(如StringWriter)的基类,这些类处理一系列字节。StringWriter是一种处理一系列字节并基于这些字节表示字符串(因为,在底层,字符串是一个字符数组——因此,字符)的功能。由于所有从Stream派生的类都实现了IDisposable接口,所以我们一旦完成对其实例的处理,就需要清理实例化的Stream,否则我们可能会遇到内存泄漏。

抽象类

抽象类是一个不能直接实例化或静态的类。你可以实例化一个抽象类的具体子类,这间接实例化了抽象类。我们可以通过在类的签名中使用abstract关键字来使一个类成为抽象类。抽象类是支持继承和扩展多态的另一种方式。我们经常使用抽象类作为“基”类,位于继承链的顶部。与接口不同,抽象类可以提供方法体(只要方法本身不是抽象的)并使用访问修饰符。这意味着抽象类通常用于在派生类中传播特定方法的特定实现。抽象方法必须在具体实现中重写。抽象方法是隐式virtual的,可以说它们表示一个“不完整”的方法,因为它们不能包含方法体。抽象方法只能存在于抽象类中。派生类必须重写抽象方法并根据其需求扩展功能,或者自己被标记为抽象。

要使用我们的Console输出流,我们需要实例化一个StringWriter类型的实例,用using语句包装流,并将其设置为console的输出流。然后,一旦所有处理完成,我们检索StringWriter的内容,并断言输出符合我们的预期,如以下所示。

列表 9.2 定义我们的控制台输出流

[TestMethod]
[ExpectedException(typeof(ArgumentException))]
public async Task GetAirportByID_Failure_InvalidInput() {
  using (StringWriter outputStream = new StringWriter()) {    ❶
    Console.SetOut(outputStream);                             ❷
    await _repository.GetAirportByID(-1);                     ❸

    Assert.IsTrue(outputStream.ToString().Contains("Argument Exception in 
➥ GetAirportByID! AirportID = -1"));                         ❹
  }
}

❶ 创建一个StringWriter,并承诺安全地处理它

❷ 将我们的StringWriter实例设置为控制台的输出

GetAirportByID写入StringWriter并抛出异常。

❹ 断言输出流包含预期的日志输出

运行测试。你会看到它通过了。但如果我告诉你,测试通过是一个误导?测试通过是好事,但我们实际上测试了我们想要测试的所有内容吗?我认为不是。让我们按照以下步骤逐步执行代码:

  1. TestInitialize 方法执行。

  2. GetAirportByID_Failure_InvalidInput 方法开始执行。

  3. 单元测试创建一个 StringWriter,将其设置为控制台的输出流,并进入 GetAirportByID

  4. 我们检查传入的 AirportID 是否有效(它不是有效的)。

  5. 代码将错误日志写入我们的 StringWriter 流。

  6. 方法抛出一个类型为 ArgumentExceptionException

  7. 方法被终止。

  8. 测试停止执行,因为抛出了一个 Exception 而没有被捕获。

  9. 测试确定预期的 Exception 被抛出,并将测试标记为“通过”。

结果表明,我们没有基于控制台的输出流进行断言。因为我们没有捕获 GetAirportByID 中抛出的 ArgumentException,单元测试在代码到达 outputStream 断言之前就停止执行了。

为了修复这个问题,我们应该捕获 ArgumentException,执行输出流断言,然后在 GetAirportByID_Failure_InvalidInput 单元测试中抛出另一个类型为 ArgumentException 的异常,以满足 ExpectedException 方法属性,所有这些操作都在 GetAirportByID_Failure_InvalidInput 单元测试中完成,如下一列表所示。

列表 9.3 在单元测试中捕获抛出的 ArgumentException

[TestMethod]
[ExpectedException(typeof(ArgumentException))]
public async Task GetAirportByID_Failure_InvalidInput() {
  try  {
    using (StringWriter outputStream = new StringWriter()) {
      Console.SetOut(outputStream);
      await _repository.GetAirportByID(-1);
    }
  } catch (ArgumentException) {                                           ❶
    Assert.IsTrue(outputStream.ToString().Contains("Argument Exception in 
➥ GetAirportByID! AirportID = -1");                                      ❷
    throw new ArgumentException();                                        ❸
  }
}

❶ 捕获在 GetAirportByID 中抛出的 ArgumentException

❷ 断言 outputStream 的内容和记录的错误日志相等

❸ 为 ExpectedException 属性抛出新的 ArgumentException

虽然有一个 catch,但这段代码无法编译,因为一旦 GetAirportByID 抛出 ArgumentException 并且单元测试的 try-catch 块捕获了异常,outputStream 就超出了作用域,如图 9.6 所示。

图 9.6 在 catch 代码块中,outputStream 变量超出了作用域。outputStream 的作用域延伸到 using 代码块的末尾。

因为 outputStream 超出了作用域,所以我们不能再访问它或它的值。如果我们能扩展 outputStream 的作用域,同时正确地处理实例,那该多好。我们可以将整个 try-catch 放在 using 语句中,但我更喜欢让 using 语句包含尽可能少的代码。也许我们可以使用老式的方法,通过在 finally 块中添加对 outputStream.Dispose 的调用来手动处理 outputStream。我们还需要在 try-catch-finally 之外实例化 StringWriter,然后,如以下代码示例所示。

列表 9.4 修复 outputStream 的作用域问题

[TestMethod]
[ExpectedException(typeof(ArgumentException))]
public async Task GetAirportByID_Failure_InvalidInput() {
  StringWriter outputStream = new StringWriter();            ❶
  try {
    Console.SetOut(outputStream);                            ❷
    await _repository.GetAirportByID(-1);                    ❸
  } catch (ArgumentException) {                              ❹
    Assert.IsTrue(outputStream.ToString().Contains("Argument Exception in 
➥ GetAirportByID! AirportID = -1");                         ❺
    throw new ArgumentException();                           ❻
  } finally {
    outputStream.Dispose();                                  ❼
  }
}

❶ 创建我们的 outputStream

❷ 告诉控制台使用 outputStream 作为输出流

❸ 调用 GetAirportByID 方法

❹ 捕获在 GetAirportByID 中抛出的 ArgumentException

❺ 断言 outputStream 的内容与预期的错误日志匹配

❻ 为 ExpectedException 属性抛出新的 ArgumentException

❼ 释放 outputStream

现在,outputStream变量在作用域内,当我们断言outputStream的内容包含在GetAirportByID中记录的错误时,我们可以编译单元测试并运行它。它通过了。现在我们可以指向GetAirportByID_Failure_ InvalidInput并说我们知道我们的输入验证代码是有效的。

灯泡

重新抛出异常并保留你的堆栈跟踪

列表 9.4 中的代码让我们捕获一个类型为ArgumentException的异常,然后抛出一个相同类型的新的异常。这对于许多用例来说工作得很好,但如果你想要重新抛出相同的异常呢?你有两种简单的方法来做这件事:你可以使用带有或不带有捕获的异常变量引用的throw关键字,如下所示:

catch (Exception exception) {
  throw;
}
catch (Exception exception) {
  throw exception;
}

两种重新抛出异常的方法都有效。然而,有一个问题:重新抛出异常可能会导致与异常一起保留的堆栈跟踪信息丢失。为了确保在重新抛出异常后可以访问异常的堆栈跟踪,我们需要做些不同的事情,深入.NET 的一个黑暗角落:ExceptionDispatchInfo类。

ExceptionDispatchInfo类允许我们保存一个异常的特定状态,包括其堆栈帧。这样做可以防止在重新抛出异常时,异常的堆栈帧被新的堆栈帧覆盖。为了保存异常的状态,我们需要将异常的InnerException属性(它包含引发原始异常的状态)传递给ExceptionDispatchInfo.Capture方法。之后,我们可以调用Throw方法,如下所示,一切照旧:

catch (Exception exception) {
  ExceptionDispatchInfo.Capture(exception.InnerException).Throw();
}

通过ExceptionDispatchInfo类捕获异常的当前状态可以保护原始异常的内部信息,包括堆栈跟踪,不被覆盖。

|

9.5 查询机场对象数据库

在 9.4 节之后,我们有了AirportRepository.GetAirportByID方法的基础,以及AirportID参数的输入验证。我们知道什么是抽象类以及如何使用流。在本节中,我们将完成GetAirportByID的实现。为此,我们需要做以下几件事:

  • 查询 Entity Framework Core 的DbSet<Airport>以获取匹配的Airport对象。

  • 确保在数据库出现问题时,抛出适当的自定义异常。

  • 有单元测试覆盖成功和失败代码分支。

在 7.1.2 节中,我们要求 Entity Framework Core 在给定 ID 时给我们一个实体,所以我在实现以下代码时不会过于严格。实际上,如果你愿意的话,在继续之前先试一试。作为加分项,使用测试驱动开发来验证你的代码。

public async Task<Airport> GetAirportByID(int airportID) {
  if (airportID < 0) {
    Console.WriteLine($"Argument Exception in GetAirportByID! AirportID 
➥ = {airportID}");
    throw new ArgumentException("invalid argument provided");
  }

  return await _context.Airport.FirstOrDefaultAsync(a => a.AirportId == 
➥ airportID) ?? throw new AirportNotFoundException();
}

现在让我们快速看一下 return 语句,这是本节的核心所在:

return await _context.Airport.FirstOrDefaultAsync(a => a.AirportId == 
➥ airportID) ?? throw new AirportNotFoundException();

我们可以将返回语句分解为以下四个步骤:

  1. await—异步执行表达式并等待完成。

  2. _context.Airport.FirstOrDefaultAsync—异步检索第一个匹配项(基于第 3 步中的表达式)或实体的默认值(在 Airport 的情况下为 null)。

  3. a => a.AirportId == airportID—这是第 2 步的匹配表达式谓词。谓词指示返回与 AirportId 匹配的 Airport 集合中的第一个元素。

  4. ?? throw new AirportNotFoundException();—使用空合并运算符,如果第 2 步和第 3 步返回了 null 的默认值,我们抛出 AirportNotFoundException

在那个简短的 return 语句中,我们结合了六种不同的技术,使 C# 变得非常出色:异步编程用于获取表达式的完成和返回值;Entity Framework Core 允许我们查询其内部的 DbSets 以实体,保持数据库和运行代码之间的同构关系;FirstOrDefaultAsync LINQ 方法遍历集合并根据谓词返回一个值;我们使用 lambda 表达式作为谓词来匹配 Airport 对象与 AirportID;空合并运算符检查返回的空指针并执行其表达式;并且抛出一个使用继承的自定义异常。

我们在实现 GetAirportByID 时有点作弊,使用测试驱动开发:我们没有遵循红绿灯模式的最小细节。这是可以的。就像每一种技术(以及我告诉你们做的每一件事)一样,我们不应该被规则束缚,只要我们确保正确地交付一切。对我们来说,这意味着我们需要完成 GetAirportByID 的成功用例单元测试。

我们需要完成 GetAirportByID_Success 测试用例(以及 AirportRepository)需要什么?

  1. TestInitialize 方法中将 Airport 对象添加到内存数据库中。

  2. 尝试通过调用 GetAirportByID 并附带适当的 airportID 从数据库中检索新添加的 Airport 对象。

  3. 断言返回的对象与我们调用 GetAirportByID 之前存储在数据库中的 Airport 对象相同。

列表 9.5 基本的 TestInitialize 方法和一个 GetAirportByID 单元测试框架

[TestInitialize]
public async Task TestInitialize() {
  DbContextOptions<FlyingDutchmanAirlinesContext> dbContextOptions =
    ➥ new DbContextOptionsBuilder<FlyingDutchmanAirlinesContext>() 
    ➥ .UseInMemoryDatabase("FlyingDutchman").Options;
  _context = new FlyingDutchmanAirlinesContext_Stub(dbContextOptions);

  Airport newAirport = new Airport {         ❶
 AirportId = 0,                           ❶
 City = "Nuuk",                           ❶
 Iata = " GOH"                            ❶
 };                                         ❶

 _context.Airport.Add(newAirport);          ❷
 await _context.SaveChangesAsync();         ❸

  _repository = new AirportRepository(_context);
  Assert.IsNotNull(_repository);
}

[TestMethod]
public async Task GetAirportByID_Success() {
  Airport airport = await _repository.GetAirportByID(0);

  Assert.IsNotNull(airport);
  Assert.AreEqual(0, airport.AirportId);     ❹
 Assert.AreEqual("Nuuk", airport.City);     ❹
 Assert.AreEqual("GOH", airport.Iata);      ❹
}

❶ 创建一个新的机场实例(格陵兰的努克;GOH)

❷ 将机场实例添加到 EF Core 的内部数据库集中

❸ 将机场对象保存到内存数据库中

❹ 断言检索到的机场与保存的机场匹配

然而,当我们运行测试时,它并没有通过。编译器抛出异常,因为,如以下所示,我们使用了FlyingDutchmanAirlinesContext_Stub,它覆盖了SaveChangesAsync方法,并在数据库中没有具有CustomerId1Booking实例时抛出异常:

public override async Task<int> SaveChangesAsync(CancellationToken 
➥ cancellationToken = default) {
  IEnumerable<EntityEntry> pendingChanges = 
➥ ChangeTracker.Entries().Where(e => e.State == EntityState.Added);
  if (pendingChanges.Any(c => ((Booking) c.Entity).CustomerId != 1)) {
    throw new Exception("Database Error!");
  }

  await base.SaveChangesAsync(cancellationToken);
  return 1;
}

这就是我所说的“有控制的屁股咬”,我为故意引导你走错路而道歉。如果我们当时在写代码时花点时间反思我们的实现,我们就能预见这个问题,但那样的话,一个教学时刻就会丢失。

由于我们在AirportRepositoryTestTestInitialize方法中没有向数据库添加任何预订,SaveChangesAsync方法抛出异常。为了解决这个问题,让我们在存根的SaveChangesAsync方法中创建一个条件,检查我们是否有Booking DbSet中的实体。如果数据库中没有预订存在,代码将跳过Booking代码块。或者,你也可以为这个测试创建一个不同的存根。背后的想法是,存根应该始终只包含一个特定测试的逻辑。这是一个有效的方法,但为了简洁和简单,我们坚持使用一个存根。同样,我们也可以检查是否有对Airport模型的挂起更改,如下所示。

列表 9.6 在我们的存根中覆盖SaveChangesAsync

public override async Task<int> SaveChangesAsync(CancellationToken 
➥ cancellationToken = default) {
  await base.SaveChangesAsync(cancellationToken);
➥ cancellationToken = default) {
  IEnumerable<EntityEntry> pendingChanges = ChangeTracker.Entries()
➥ .Where(e => e.State == EntityState.Added);
  IEnumerable<Booking> bookings = pendingChanges
➥ .Select(e => e.Entity).OfType<Booking>();
  if (bookings.Any(b => b.CustomerId != 1)) {
    throw new Exception("Database Error!");
  }

 IEnumerable<Airport> airports = pendingChanges   ❶
➥ .Select(e => e.Entity).OfType<Airport>();       ❶
 if (!airports.Any()) {                           ❷
 throw new Exception("Database Error!");        ❸
 }

  await base.SaveChangesAsync(cancellationToken);
  return 1;
}

❶ 获取所有机场的挂起更改

❷ 检查是否找到机场的挂起更改

❸ 如果找不到机场的挂起更改,将抛出异常

通过在存根中添加额外的逻辑,测试通过了。一如既往,让我们问问自己,我们还能测试什么?嗯,我们已经覆盖了主要的代码分支,但如果我们想确保我们可以从一个包含多个Airport对象的数据库中获取机场,怎么办?到目前为止,我们在测试中使用的所有内存数据库都只包含我们正在测试的实体的一个记录。我们可以使用之前学到的技术,以及我将在下一秒介绍的新概念,将多个Airport实例添加到内存数据库中并对它们进行断言。

想想这个问题:将相同类型的多个对象添加到集合中的合适方法是什么?如果我说,为了做到这一点,我们需要在集合上操作,你希望你会从椅子上跳起来(或者如果你在晚上用这本书睡觉,从床上跳起来)并喊道,“我们可以使用 LINQ 方法!”

AddRange 和 SortedList

LINQ 方法 AddRange 允许你一次性向集合中添加多个条目。“范围”指的是一系列对象,通常存储在不同的集合中。因为这是一个 LINQ 方法,它不仅适用于 Entity Framework Core,还适用于整个 C# 环境。要使用 AddRange 功能,我们需要以下两个东西:

  1. 我们想要放入另一个集合中的对象集合。我们使用在TestInitialize方法中创建并填充的集合来完成此操作。

  2. 存储对象的集合——在这种情况下,EF Core 的DbSet<Airport>

首先,我们创建一个集合。System.CollectionsSystem.Collections.Generics命名空间包含许多我们可以采样和使用的集合。有常见的候选者,如List<T>ArrayList<T>LinkedList<T>Dictionary<T, X>,但我们也有更神秘的集合,如BitArraySynchronizedReadOnlyCollection<T>。我们可以使用 C#提供的任何集合(泛型或非泛型)上的AddRange方法。

为什么不让我们有点乐趣,走风景路线,并使用一个名为SortedList<T>的特殊集合呢?或者,你也可以将所有条目添加到一个泛型List<T>中,并调用它的Sort方法。因为SortedList<T>是一个泛型集合,所以我们可以在System.Collections.Generics命名空间中找到它。如果我们想使用System.Collections.Generics命名空间,这意味着我们还需要导入该命名空间。

SortedList允许对集合进行排序。要使用SortedList,我们只需添加一些数据,有时还需要指定我们想要如何排序元素。包含整数的SortedList按整数值排序元素,而包含字符串的SortedList按字母顺序排序元素。然而,如果我们想对一个对象(如Airport类型的实例)进行排序,那么有一个问题:当与非原始类型一起使用时,SortedList变成了SortedList<K, V>,其中K是可排序的原始类型,而V是我们的对象。

我们想对Airport类型的对象进行排序。让我们保持有趣,并按 IATA 代码的字母顺序对它们进行排序,而不是AirportID。这意味着我们在SortedList<K, V>中将字符串原始类型用作第一个泛型类型。

我们首先在TestInitialize方法中创建一个SortedList<string, Airport>,并用一些对象填充它。从我们在TestInitialize方法中已经添加的机场(GOH—Nuuk,格陵兰)开始,我们按照以下方式添加Airport元素:PHX(凤凰城,亚利桑那州),DDH(本宁顿,佛蒙特州),和 RDU(罗利-达勒姆,北卡罗来纳州)。

SortedList<string, Airport> airports = new SortedList<string, Airport> {
  {
    "GOH",
    new Airport
    {
      AirportId = 0,
      City = "Nuuk",
      Iata = "GOH"
    }
  },
  {
 "PHX",
 new Airport
 {
 AirportId = 1,
 City = "Phoenix",
 Iata = "PHX"
 }
 },
 {
 "DDH",
 new Airport
 {
 AirportId = 2,
 City = "Bennington",
 Iata = "DDH"
 }
 },
 {
 "RDU",
 new Airport
 {
 AirportId = 3,
 City = "Raleigh-Durham",
 Iata = "RDU"
 }
 }
};

在添加所有这些之后检查SortedList<string, Airport>,我们看到图 9.7 中显示的按字母顺序排序的集合:"DDH" -> "GOH" -> "PHX" -> "RDU"

图 9.7

图 9.7 SortedList接收数据并根据排序类型对数据进行排序。在这个例子中,我们根据字符串原始类型进行排序。这导致了一个按字母顺序排序的集合。

要将排序列表中的值添加到内存数据库中,我们使用contextDbSet<Airport>上的AddRange LINQ 方法,如下所示:

_context.Airport.AddRange(airports.Values);

使用 AddRangeSortedList<string, Airport> 中的所有值添加到 DbSet<Airport> 中轻而易举。回想一下,当我让你思考将一组元素添加到不同集合中的最佳方法时,你可能认为我们必须使用 foreach 循环并手动将所有元素添加到数据库中。实际上,当我们使用 AddRange 时,背后正是这样操作的,但我非常感激 LINQ 给我们的语法糖。它节省了很多打字,而且使用 AddRange 还提高了代码的可读性和简洁性。话虽如此,我们确实需要确保在 AddRange 调用中调用 SortedList<string, Airport>Value 属性,否则我们将会得到列表中的键值对而不是 Airport 实例。由于 DbSet<Airport>Airport 类型有泛型约束,我们不能将 string 类型的实例添加到集合中。

为了安全起见,让我们运行所有现有的测试并验证我们没有因为这种实现而破坏任何东西。看起来我们做得很好。现在轮到有趣的部分了:断言进入数据库的实体确实存在,并且我们可以检索它们。我们可以通过使用熟悉的 [DataRow] 方法属性内联 AirportIds。然后,我们调用 GetAirportByID 并将返回的 Airport 实例与 MSTest 运行器传入的 airportId 直接从上下文中检索到的结果进行对比,如下一个列表所示。

列表 9.7 使用 DataRow 属性测试 GetAirportByID 成功

[TestMethod]
[DataRow(0)]                                                    ❶
[DataRow(1)]                                                    ❶
[DataRow(2)]                                                    ❶
[DataRow(3)]                                                    ❶
public async Task GetAirportById_Success(int airportId) { 
  Airport airport = await _repository.GetAirportById(airportId);
  Assert.IsNotNull(airport);

  Airport dbAirport = 
➥ _context.Airport.First(a => a.AirportId == airportId);       ❷
  Assert.AreEqual(dbAirport.AirportId, airport.AirportId);      ❸
  Assert.AreEqual(dbAirport.City, airport.City);                ❸
  Assert.AreEqual(dbAirport.Iata, airport.Iata);                ❸
}

❶ 使用 [DataRow] 方法属性内联测试数据

❷ 从数据库中检索匹配的机场(基于 AirportId)

❸ 断言检索到的机场实例与数据库中的实例一致

我们也可以创建一个硬编码的 Airport 实例,将其添加到测试设置中的数据库中,并使用它来检查是否正确插入了 Airport。这种方法是可行的,但我更喜欢在每个测试中查询内存数据库。这更明确,因为你不需要依赖其他地方编写的代码来运行你正在查看的测试。在我们宣布 AirportRepository 完成之前,我们还需要为数据库异常逻辑分支编写单元测试。

使用存根测试数据库异常

在本节中,我们将测试数据库在调用 SaveChangesAsync 时遇到错误的逻辑分支。为了测试数据库异常逻辑路径,我们必须更新 FlyingDutchmanContext_Stub 中重写的 SaveChangesAsync 方法,根据机场的 ID 执行 switch 操作。如果 AirportID 评估结果不是 0123(因为我们使用了这些值作为成功测试用例中的 AirportId),则存根会抛出异常。我们是否可以使用下一个代码示例中的整数值 10 呢?这和任何数字一样好。

列表 9.8 将存根的 SaveChangesAsync 更改为测试 AirportRepository

public override async Task<int> SaveChangesAsync(CancellationToken 
➥ cancellationToken = default) {
  await base.SaveChangesAsync(cancellationToken);
➥ cancellationToken = default) {
  IEnumerable<EntityEntry> pendingChanges = ChangeTracker.Entries()
➥ .Where(e => e.State == EntityState.Added);
  IEnumerable<Booking> bookings = pendingChanges
➥ .Select(e => e.Entity).OfType<Booking>();
  if (bookings.Any(b => b.CustomerId != 1)) {
    throw new Exception("Database Error!");
  }

  IEnumerable<Airport> airports = pendingChanges    ❶
➥ .Select(e => e.Entity).OfType<Airport>();        ❶
  if (!airports.Any(a => a.AirportId == 10)) {      ❷
    throw new Exception("Database Error!");         ❸
  }

  await base.SaveChangesAsync(cancellationToken);
  return 1;
}

❶ 根据机场 ID 切换表达式

❷ 如果机场 ID 是 10,则抛出异常

❸ 默认情况:从方法中返回

现在,为了进行单元测试,让我们创建一个新的单元测试方法,名为 GetAirportByID_Failure_DatabaseException。因为当数据库发生错误时,GetAirportByID 方法会抛出类型为 AirportNotFoundException 的异常,单元测试需要预期这种情况。我们使用我们信任的 [ExpectedException] 方法属性来实现,如下所示:

[TestMethod]
[ExpectedException(typeof(AirportNotFoundException))]
public async Task GetAirportByID_Failure_DatabaseException() {
  await repository.GetAirportByID(10);
}

测试应该通过。就这样,我们完成了 AirportRepository 的实现。在我们实现服务层之前,我们只剩下一个要完成。

9.6 实现飞行仓库

虽然看起来可能不是这样,但实际上我们几乎完成了实现飞往明天航空公司下一代 API 所需的大部分繁重工作。因为我们大多数逻辑都在仓库层类中执行,所以服务和控制器更像是一个中继器和数据组合器。代码库中最复杂的逻辑通常发现自己位于仓库层,因为处理数据库的固有复杂性。在仓库/服务模式中,在实现所有仓库之后,你将拥有操作模型状态的逻辑封装起来。但我们还没有完全完成:在本节中,我们将实现 FlightRepository 以及相应的单元测试。

继续创建 FlightRepositoryFlightRepositoryTests 的骨架类。与 AirportRepository 一样,我们只需要在 FlightRepository 中实现一个方法:GetFlightByFlightNumber。在继续之前,请也在 FlightRepository 中创建一个空的 GetFlightByFlightNumber 方法。如果你遇到困难,请参阅第六章和第七章以获取更详细的说明。

GetFlightByFlightNumber 方法接受以下三个类型为 \1 的参数:

  • flightNumber

  • originAirportId

  • destinationAirportId

originAirportIddestinationAirportId 参数表示航班起飞的机场 (originAirportId) 和到达的机场 (destinationAirportId)。机场的 ID 在数据库中受到外键约束。这意味着在一个 Flight 实例中,originAirportIddestinationAirportId 会指向数据库中基于其 ID 匹配的特定 Airport 实例。所有三个输入参数都需要是非负整数。我们可以仅使用航班号来识别航班,而不必担心额外的机场详情。为了教您如何使用外键约束检索数据,我们将使用并检索机场 ID。之前,在 BookingRepository.CreateBooking 方法中,我们定义了一个条件代码块,该代码块检查 customerIDflightNumber 的输入参数是否是有效的参数,这些参数与我们对 originAirportIddestinationAirportId 的验证规则相同(它们需要是正整数),如下所示:

public async Task CreateBooking (int customerID, int flightNumber) {
  if (customerID < 0 || flightNumber < 0) {
    Console.WriteLine($"Argument Exception in CreateBooking! CustomerID = {
➥ customerID}, flightNumber = { flightNumber}");
    throw new ArgumentException("invalid arguments provided");
  }
    ...
}

我们可以使用此代码对 GetFlightByFlightNumberoriginAirportIddestinationAirportId 参数进行输入验证。但我们不想只是复制和粘贴代码:这将违反 DRY 原则,而且复制和粘贴通常是一种不好的做法。相反,我们应该将条件提取到一个方法中,该方法对 BookingRepositoryFlightRepository 都是可访问的。

我们可以将该方法命名为 IsPositive,它接受一个 integer 作为参数,检查它是否大于(或等于)零,并返回该结果。然后,我们可以在 FlightRepository 中实例化一个新的 BookingRepository 实例,并访问 IsPositive 方法,如下所示:

public class BookingRepository  {
  ....

  internal bool IsPositive(int toTest) {
 return toTest >= 0;
 }
}

public class FlightRepository {
  public async Task<Flight> GetFlightByFlightNumber(int flightNumber, int 
➥ originAirportId, int destinationAirportId) {
  BookingRepository bookingRepository = new BookingRepository(_context);
 if (!bookingRepository.IsPositive(originAirportId) || 
➥ !bookingRepository.IsPositive(destinationAirportId)) {
    ...
  }

  ...
}

这看起来很混乱,是糟糕耦合的一个好例子。如果 FlightRepository 调用 BookingRepository 的方法,我们将它们放在一起,共同生活。在这种情况下,修改 BookingRepository 可能会对 FlightRepository 产生意外的后果。相反,我们可以在 integer 类型上创建一个扩展方法,以确定一个整数是否为正(大于或等于 0)。

9.6.1 IsPositive 扩展方法和“魔法数字”

首先,我们想确保我们将扩展方法与其他代码分开。让我们创建一个新的类,称为 ExtensionMethods。我们将这个类放在 FlyingDutchmanAirlines 项目的根目录下,如图 9.8 所示,因为为包含单个类创建一个特殊的文件夹(也称为 ExtensionMethods)将是过度设计(除非你预计将来该文件夹中会有多个文件)。

图片

图 9.8 ExtensionMethods 类放置在 FlyingDutchmanAirlines 项目的根目录下。ExtensionMethods 不是一个架构层,我们也不会有多个,所以将类留在根目录下是可以的。

我们的ExtensionMethods类可以具有internal访问修饰符,因为我们不是专门为扩展方法编写单元测试。在这种情况下,internal访问修饰符对我们来说非常合适,因为我们可以将访问范围仅限于FlyingDutchmanAirlinesNextGen解决方案。ExtensionMethods类的单元测试覆盖率是隐式的,并通过覆盖调用相应扩展方法的单元测试来完成。ExtensionMethods类还应该是静态的,因为我们希望在代码库中跨实例使用同一个类的实例。每次我们想要检查一个整数是否为正时,没有必要实例化一个新的ExtensionMethods实例,我们编写的扩展方法也不会改变任何对象的状态。之前,我谈到了使用static的陷阱。旨在封装一组ExtensionMethods的类应该是静态的,如下所示:

internal static class ExtensionMethods { }

要创建扩展方法,如第 6.3.2 节所述,我们在参数列表中使用this关键字,后面跟着我们想要为它创建扩展方法的类型。你可以为任何类型(接口、类、原始类型)创建扩展方法,如下所示:

internal static bool IsPositive(this int input) { }

图 9.8

FlyingDutchmanAirlinesNextGen项目的作用域内(由于ExtensionMethodsIsPositiveinternal访问修饰符),我们现在可以在类型integer的每个实例上调用IsPositive,如图 9.9 所示。

图 9.9

图 9.9 IsPositive扩展方法对所有整数都可用。例如,airportID可以调用IsPositive方法。

编译扩展方法

扩展方法听起来很棒,但它们是如何执行的?对IsPositive扩展方法的调用是在编译时由编译器解析的。当编译器遇到IsPositive方法调用时,它首先检查调用类的作用域内是否存在该方法。如果没有,即我们目前所处的情形,编译器会检查静态类中是否存在具有相同名称的任何公共static方法。如果找到的static方法也操作正确的类型(通过在方法参数列表中使用this关键字),编译器就找到了匹配项,并生成调用该方法的中间语言代码。

请注意,与任何方法一样,如果你有两个具有相同名称和操作类型但位于不同类中的扩展方法,编译器无法解析调用哪一个。当这种情况发生时,编译器会抛出一个歧义编译错误:“CS0121 调用在以下方法或属性[方法/属性 1]和[方法/属性 2]之间是模糊的。”为了解决歧义错误,你需要给编译器足够的信息,以便它可以确定调用哪个方法。

关于IsPositive方法内部的实际逻辑,我们只需返回输入参数是否大于或等于零,如下所示:

internal static bool IsPositive(this int input) => input >= 0;

简单明了。你刚刚编写了你的第一个扩展方法!

在我们继续前进之前,我们需要做一些清理工作。我们需要移除验证 BookingRepository.CreateBooking 方法输入参数的条件代码块,用对我们的全新 IsPositive 扩展方法的调用来替换它。在输入验证代码中,我们必须对 IsPositive 的调用取反,如下所示,因为我们想知道输入参数不是正整数的情况。

public async Task CreateBooking(int customerID, int flightNumber) {

   if (customerID < 0 || flightNumber < 0)
   if (!customerID.IsPositive() || !flightNumber.IsPositive()) {
      Console.WriteLine($"Argument Exception in CreateBooking! CustomerID 
➥ = { customerID}, flightNumber = { flightNumber}");
      throw new ArgumentException("invalid arguments provided");
   }
   ...
}

AirportRepository.GetAirportByID 中的相同条件仍然留给你去移除和替换。我们不仅现在遵循 DRY 原则,而且对 IsPositive 扩展方法的调用比检查某个数是否大于或等于零更易读。一个新开发者可能不会直观地知道我们为什么检查某个数是否大于零。这样的随机硬编码数字就是我们所说的“魔法数字”。通过编写明确且不使用魔法数字的代码,任何开发者都可以看到我们正在检查 customerIDflightNumber 是否不是正整数。

点赞

魔法数字

假设我们正在编写处理汽车转向的代码。想象一下移动汽车前进的方法看起来是什么样子。看看下面的代码块。它有什么问题吗?

public double MoveCarForward(double direction) {
   if (direction == 0 &#124;&#124; direction == 360) {
      GoStraight();
   }

   if (direction > 0 && direction <= 90) {
      GoEast();
   }

   if (direction >= 270 && direction < 360) {
      GoWest();
   }
}

MoveCarForward 方法有两个有趣的特点:

  • 首先,我们知道我们可以使用 switch 或 switch 表达式来稍微压缩一下这段代码,但我们会放过这个。我们只想以最小的破坏性清理代码。

  • 其次,代码通过比较方向输入参数与预定义的数字来确定汽车移动的方向。这些数字代表在单位圆上映射出的度数上的基本方向。除非你有这方面的知识,否则这一点在当前代码中并不明显。代码中随机出现的数字,硬编码且没有上下文,就是我们所说的魔法数字。

  • 这些数字(0、90、270 和 360)如果没有我们知道它们应该代表什么上下文,就没有意义。我们可以做得更好。

当硬编码这样的数字时,你冒着开发者不了解你的意图而更改数字以“修复”某事的危险。如果你提供了更多关于它们代表什么的上下文,代码将更易读,开发者可能不会更改它们的值。为此,我建议将数字提取到私有常量中。常量的值在编译时定义,在运行时不能更改。这确保了值永远不会从你定义的值改变。

使用MoveCarForward,我们可以隔离四个潜在的常量:DEGREES_NORTH_LOWER_BOUNDDEGREES_NORTH_UPPER_BOUNDDEGREES_WESTDEGREES_EAST。我更喜欢始终使用蛇形大小写(所有字母都是大写,标点符号包括空格都替换为下划线),如下所示。这清楚地表明一个给定的变量有一个不可变的、预定义的值。

private const int DEGREES_NORTH_LOWER_BOUND = 0;
private const int DEGREES_NORTH_UPPER_BOUND = 360;
private const int DEGREES_WEST = 270;
private const int DEGREES_EAST = 90

public double MoveCarForward(double direction) {
   if (direction == DEGREES_NORTH_UPPER_BOUND &#124;&#124; direction == 
➥ DEGREES_NORTH_LOWER_BOUND){
      GoStraight();
   }

   if (direction > DEGREES_NORTH_LOWER_BOUND && direction <=
➥ DEGREES_EAST){
      GoEast();
   }

   if (direction >= DEGREES_WEST && direction < 
➥ DEGREES_NORTH_UPPER_BOUND)){
      GoWest();
   }
}

这段代码的可读性更强。我们现在确切地知道这些神奇数字代表什么。事实上,再也没有神奇数字了。每次你看到任何东西的硬编码数值表示时,问问自己,我应该将这个神奇数字重构为常量或局部变量吗?

|

当然,在我们继续之前,我们需要运行BookingRepository的单元测试。我建议每次你进行更改时都运行你的测试套件中的每个单元测试,而不仅仅局限于你正在弄乱的那个文件中的测试。幸运的是,它们都通过了。这是因为到目前为止,我们唯一做的事情就是将现有逻辑提取到扩展方法中。

让我们使用我们新的扩展方法来验证FlightRepository.GetFlightByFlightNumber中的originAirportIddestinationAirportId输入参数,如下所示的下一条列表。如果其中一个输入参数无效,我们抛出类型为ArgumentException的异常,并将消息记录到控制台。

列表 9.9 GetFlightByFlightNumber机场Ids输入验证

public class FlightRepository {
   public async Task<Flight> GetFlightByFlightNumber(int flightNumber, 
➥ int originAirportId, int destinationAirportId) {
      if (!originAirportId.IsPositive() || 
➥ !destinationAirportId.IsPositive())) {                            ❶
 Console.WriteLine($"Argument Exception in 
➥ GetFlightByFlightNumber! originAirportId = {originAirportId} : 
➥ destinationAirportId = {destinationAirportId}");                  ❷
 throw new ArgumentException("invalid arguments provided");     ❸
 }

    return new Flight();                                             ❹
 }
}

❶ 调用扩展方法来验证输入参数

❷ 将无效参数记录到控制台

❸ 如果输入无效则抛出ArgumentException

❹ 返回一个临时的新Flight实例。我们将在第 9.6.2 节中更改此实现。

为了证明我们的代码按预期工作,我们创建了以下两个单元测试:

  • GetFlightByFlightnumber_Failure_InvalidOriginAirport

  • GetFlightByFlightnumber_Failure_InvalidDestinationAirport

这两个单元测试都应该验证在执行相应的测试期间,GetFlightByFlightNumber方法抛出类型为FlightNotFoundException的异常,如下所示:

[TestMethod]
[ExpectedException(typeof(ArgumentException))] 
public async Task GetFlightByFlightNumber_Failure_InvalidOriginAirportId(){
  await _repository.GetFlightByFlightNumber(0, -1, 0); 
}

[TestMethod]
[ExpectedException(typeof(ArgumentException))] 
public async Task 
GetFlightByFlightNumber_Failure_InvalidDestinationAirportId(){
  await _repository.GetFlightByFlightNumber(0, 0, -1); 
}

这就处理了originAirportIddestinationAirportId输入参数的输入验证。但flightNumber怎么办呢?我们可以快速添加一个条件来检查flightNumber是否为正整数。如果flightNumber不是一个正整数,我们希望向控制台记录一条消息并抛出我们新异常类型的错误(我留给你来实现),称为FlightNotFoundException,如下所示:

if (flightNumber < 0) {
  Console.WriteLine($"Could not find flight in GetFlightByFlightNumber! 
➥ flightNumber = {flightNumber}");
  throw new FlightNotFoundException();
}

我们还需要一个单元测试来证明当flightNumber是一个无效的输入参数时,GetFlightByFlightnumber方法会抛出异常FlightNotFoundException,如下所示:

[TestMethod]
[ExpectedException(typeof(FlightNotFoundException))]
public async Task GetFlightByFlightNumber_Failure_InvalidFlightNumber() {
  await _repository.GetFlightByFlightNumber(-1, 0, 0);
}

9.6.2 从数据库中获取航班

让我们回顾一下到目前为止使用 FlightRepositoryFlightRepositoryTests 类所做的工作。在前面的章节中,我们在 FlightRepository 类中创建并部分实现了 GetFlightByFlightNumber 方法。当前的 GetFlightByFlightNumber 方法对输入参数(flightNumberoriginAirportIddestinationAirportID)进行输入验证,并返回一个占位符 Flight 实例。我们还在 FlightRepositoryTests 类中创建了三个单元测试,用于检查无效输入参数情况下的输入验证。

在本节中,我们实现实际逻辑以从数据库中检索给定航班号的 Flight 实例。为此,我们采取了与之前多次使用相同的方法。我们查询数据库的 DbSet<Flight> 以获取匹配的航班。如果数据库抛出异常,我们将问题记录到控制台,并使用开发者友好的消息抛出新的异常。如果一切顺利,我们返回找到的 Flight 类型的对象。但在做之前,让我们创建 TestInitialize 中显示的成功用例单元测试和成功设置代码。

列表 9.10 测试 GetFlight

[TestInitialize]
public async Task TestInitialize() {
  DbContextOptions<FlyingDutchmanAirlinesContext> dbContextOptions = new 
➥ DbContextOptionsBuilder<FlyingDutchmanAirlinesContext>().UseInMemoryDat
➥ abase("FlyingDutchman").Options;                                      ❶
  _context = new FlyingDutchmanAirlinesContext_Stub(dbContextOptions);   ❷

  Flight flight = new Flight {                                           ❶
 FlightNumber = 1,                                                      ❶
 Origin = 1,                                                          ❶
 Destination = 2                                                      ❶
 };                                                                     ❶

 context.Flight.Add(flight);                                            ❷
 await _context.SaveChangesAsync();                                     ❸

 _repository = new FlightRepository(_context);                           ❸
  Assert.IsNotNull(_repository);                                         ❸
} 

[TestMethod]
public async Task GetFlightByFlightNumber_Success() {
  Flight flight = await _repository.GetFlightByFlightNumber(1, 1, 2);    ❹
  Assert.IsNotNull(flight);

  Flight dbFlight = _context.Flight.First(f => f.FlightNumber == 1);     ❺
 Assert.IsNotNull(dbFlight);

 Assert.AreEqual(dbFlight.FlightNumber, flight.FlightNumber);           ❻
 Assert.AreEqual(dbFlight.Origin, flight.Origin);                       ❻
 Assert.AreEqual(dbFlight.Destination, flight.Destination);             ❻
}

❶ 创建并填充一个 Flight 实例

❷ 将航班对象添加到 EF Core 的内部 DbSet

❸ 将航班保存到内存数据库中

❹ 执行 GetFlightByFlightNumber

❺ 从数据库获取航班

❻ 将 GetFlightByFlightNumber 获取的航班与数据库中的航班进行比较

GetFlightByFlightNumber_Success 单元测试失败,因为我们正在 GetFlightByFlightNumber 中返回一个临时的新(空)Flight 实例。我们应该将其更改为在给定 flightNumber 时返回数据库中的第一个匹配项。我们可以使用与 AirportRepository.GetAirportByID 中使用的相同模式来返回数据库实体:使用 LINQ 的 FirstOrDefaultAsync 调用来选择实体或返回默认值(在这种情况下为 null),然后是空合并运算符,它在出现 null 的情况下抛出异常,如下所示:

public async Task<Flight> GetFlightByFlightNumber(int flightNumber, 
➥ int originAirportId, int destinationAirportId) {
  if (flightNumber < 0) {
  Console.WriteLine($"Could not find flight in GetFlightByFlightNumber! 
➥ flightNumber = {flightNumber}");
  throw new FlightNotFoundException();
}

  if (!originAirportId.IsPositive() || 
➥ !destinationAirportId.IsPositive()) {
    Console.WriteLine($"Argument Exception in GetFlightByFlightNumber! 
➥ originAirportId = {originAirportId} : destinationAirportId = 
➥ {destinationAirportId}");
    throw new ArgumentException("invalid arguments provided");
  }

  return await _context.Flight.FirstOrDefaultAsync(f => 
➥ f.FlightNumber == flightNumber) ?? throw new FlightNotFoundException();
}

此代码要么返回数据库中找到的正确 Airport 实例,要么抛出 AirportNotFoundException 类型的异常。但如果没有看到成功用例单元测试通过,并且有一个失败用例单元测试准备就绪,谁会相信我们呢?不用担心。随着这个代码更改,GetFlightByFlightNumber_Success 单元测试通过了。

在我们完成 FlightRepository 和本章之前,我们还需要做的是创建一个单元测试,以证明如果输入参数 flightNumberoriginAirportIddestinationAirportId 正确,但数据库错误抛出异常,则 GetFlightByFlightNumber 方法会抛出 FlightNotFoundException 类型的异常,如下所示。到目前为止,我们已经这样做了几次,所以如果你想在查看代码之前自己尝试一下:请继续,我会等待。

[TestMethod]
[ExpectedException(typeof(FlightNotFoundException))]
public async Task GetFlightByFlightNumber_Failure_DatabaseException() {
  await _repository.GetFlightByFlightNumber(2, 1, 2);
}

哇!就在那里:一个完成的 FlightRepository。现在在我们的 FlyingDutchmanAirlinesNextGen 代码库中有以下四个存储库:

  • AirportRepository

  • BookingRepository

  • CustomerRepository

  • FlightRepository

这意味着我们已经完成了重构的存储库部分。在下一章中,我们将提升架构的一个级别,并实现服务层。但是,这里有令人兴奋的消息:我们已经完成了繁重的工作。首先实现存储库方法保证了可用的且功能单一的方法(并且做得很好)。这有助于我们在服务层,我们可以说“给我 A、B 和 C”,并且有执行这些操作而不产生副作用的方法。

练习

练习 9.1

在测试驱动开发中,红色阶段表示

a. 你的代码编译并通过了测试。

b. 你的代码没有编译或测试没有通过。

练习 9.2

在测试驱动开发中,绿色阶段表示

a. 你的代码编译并通过了测试。

b. 你的代码没有编译或测试没有通过。

练习 9.3

对或错?如果你只有一个数据点进行测试,你不能使用 [DataRow] 属性。

练习 9.4

数据流通常将它们的数据存储为一系列什么?

a. 在景观中缓慢流动并产生涟漪的水

b. 字节

练习 9.5

对或错?没有派生类的类隐式地是抽象的。

练习 9.6

对或错?抽象类中的每个方法也必须是抽象的。

练习 9.7

对或错?抽象方法可以存在于非抽象类中。

练习 9.8

对或错?抽象方法不能包含方法体。它们应该被派生类覆盖。

摘要

  • 抽象类 Stream 被用作 StringWriterTextReader 等派生类的基类。我们可以使用流来处理连续的数据流,如字符串或整数。

  • 我们可以将控制台输出重定向到 StringWriter 类型的实例。这在测试控制台输出时很有用,因为我们可以检索 StringWriter 的内容并检查预期的日志数据。

  • 抽象类是一个带有 abstract 关键字的类。抽象类不能被实例化或声明为静态。抽象类支持具有方法体(假设这些方法不是抽象的)的方法。它们通常用作基类,为所有派生类提供特定方法的相同实现。

  • LINQ 的 AddRange 方法允许我们将一个集合(或“对象范围”)的内容添加到另一个集合中。这可以节省大量的手动输入和遍历集合的时间。

  • SortedList<T> 是一个泛型集合,它自动对输入数据进行排序。SortedList 在你需要有一个排序的集合且不希望进行手动排序时非常有用。

  • 扩展方法是扩展它们执行操作的类型的静态方法。扩展方法通常用于在原始类型上执行常用功能。这意味着扩展方法在修复 DRY 原则(Don't Repeat Yourself)违反方面通常很有用。

  • 魔数是硬编码的值,它们没有附加的上下文信息。你经常在算法或条件语句中找到它们。当看到没有解释的硬编码数字时,通常很难弄清楚它代表什么。考虑将其重构为一个类级别的常量的局部变量。


(1.)为什么布尔(Boolean)首字母大写而bool则不是?当我们谈论布尔值时,我们是通过布尔代数的视角来指代真值(真和假)。布尔代数是由英国数学家乔治·布尔(George Bool)发明的,首次出现在他的著作《逻辑数学分析:演绎推理微积分的论文》(The Mathematical Analysis of Logic: Being an Essay towards a Calculus of Deductive Reasoning,Bool, 1847)中。当我们提到一个bool时,我们指的是 C#编程语言中代表布尔真值的类型,并由System.Boolean支持。

第五部分 服务层

在第四部分,我们探讨了仓库层并实现了其类。我们涉及了异步编程、依赖注入、耦合、存根、流等概念。在本部分,我们上升一个架构层并实现服务层类。这些章节讨论了(包括其他内容)反射、模拟、yield return和错误处理等。

10 反思和模拟

本章涵盖

  • 使用存储库/服务模式和视图的复习

  • 使用 Moq 库进行模拟测试

  • 在多层测试架构中检测耦合

  • 使用预处理器指令

  • 使用反射在运行时检索程序集信息

在第六章到第九章中,我们实现了FlyingDutchmanAirlinesNextGen项目的存储库层。在本章中,我们将回顾存储库/服务模式的知识,并实现以下四个必需服务类中的两个(部分):

  • CustomerService(在本章实现)

  • BookingService(在本章和第十一章实现)

  • AirportService(在第十二章实现)

  • FlightService(在第十二章实现)

图 10.1 显示了我们在本书方案中的位置。

图 10.1 本章是服务层实现的开始。我们将在本章实现CustomerServiceBookingService。在接下来的章节中,我们将实现AirportServiceFlightService

如你所猜,与存储库类一样,我们希望每个数据库实体有一个服务类。我们将在 10.1 节中讨论原因,然后在 10.2 节中讨论CustomerService的实现,并在 10.3 节中开始BookingService的实现。

在接下来的几章中,一旦我们习惯了实现服务类,实现速度就会加快。我会逐节采取更少干预的方法,将越来越多的实现细节留给你去编码。如果你遇到困难,可以随时回顾相应的章节以获取更多详细信息。

10.1 再次探讨存储库/服务模式

在 5.2.4 节中,我们讨论了存储库/服务模式。我向你展示了在存储库/服务模式中,对于每个数据库实体(CustomerBookingAirportFlight),我们都有以下内容:

  • 控制器

  • 服务

  • 存储库

这些控制器、服务和存储库位于单个数据库访问层之上,我们在第五章中实现了它。我们的存储库/服务模式中的请求生命周期,如图 10.2 所示,是一个 HTTP 请求进入控制器。控制器调用其相应实体的服务类。实体的服务类调用它需要处理的任何存储库类,组织它需要返回给用户的信息。服务类返回到控制器,控制器随后将所有数据返回给用户。

图 10.2 控制器-服务-存储库生命周期。当请求进入时,它将进入控制器。控制器调用一个服务,该服务调用一个或多个存储库。数据从控制器流向存储库,并从存储库返回。

在第五章到第九章中,我们实现了我们架构所需的存储库。现在轮到为服务做同样的事情了。因为服务仅仅是存储库和控制层之间的中间人,所以服务在复杂性上相对较轻。然而,不要误解:服务层可能是所有层中最重要的一层。没有它,我们就不会有处理如何向客户表示数据的抽象层。服务类调用一系列存储库方法,并将数据的视图返回给控制器。服务类为控制器提供数据。

10.1.1 服务类的用途是什么?

在美国,购买一辆车需要应对众多热心的销售人员,随后还要花费数小时处理文件工作。在汽车经销商那里的经历往往是糟糕服务的例子。当我们审视软件架构时,我们同样可以在服务类中看到糟糕的服务。如果一个服务类返回用户未请求的内容,那么我们手头就有了一个糟糕服务的例子。

例如,让我们想象我们正在开发一个汽车经销商的 API。经销商希望向用户展示他们库存中的任何汽车,同时保留一些更有价值的信息,例如汽车的真实价值(通常远低于他们要求的售价)。是否保留信息以获取公司利益是糟糕的服务取决于你是谁。如果你是汽车经销商,这听起来非常合理。如果你是买家,你希望尽可能多地获取信息。那么你将如何实现这样的 API?

返回汽车信息的 API 包括以下常见元素:

  • 控制器接受特定汽车 ID 的 HTTP GET请求

  • 一个从存储库检索信息并将其呈现给控制器的服务层

  • 一个从数据库收集汽车实例的存储库

服务类调用存储库的方法,构建我们称之为视图的内容。我们在 3.3.2 节中讨论了视图,但这里是一个快速回顾:视图是一个“窗口”,可以让我们操纵和调整类的外观以满足我们的需求。

注意:有时人们会说 ReturnView 而不是 view。它们的意思相同,你可以互换使用。这本书尽量坚持使用 view。

Car类的视图可能包含诸如 VIN、汽车制造商、汽车制造年份和汽车型号等信息,如图 10.3 所示。但由于它只是一个“窗口”,我们可以选择不在视图中显示汽车的真实价值或过去六个月内汽车遭遇的总碰撞次数。

图 10.3

图 10.3 视图是从一个或多个模型中提取的元素集合,以对用户有意义的方式呈现。在这个例子中,我们从Car类中提取了YearBrandModel属性。

服务类也可能追踪外键约束,在需要时检索信息以编译所需数据的完整图像。我们将在整本书中看到外键约束的实际应用。我们还将更深入地探索单元测试的世界。是的,亲爱的读者:本章我们将再次使用测试驱动开发,但这次,我们将使用 TDD 结合一个新的测试概念:模拟。

练习

练习 10.1

在仓库/服务模式中,传入请求的正确数据流是什么?

a. 仓库 -> 服务 -> 控制器

b. 控制器 -> 服务 -> 仓库

练习 10.2

对或错?服务类(在仓库/服务模式中)通常直接与数据库交互。

练习 10.3

对或错?当使用视图时,我们可以返回多个不同数据源的统一表示。

练习 10.4

对或错?人们经常将视图和 ReturnView 互换使用来指代相同的概念。

10.2 实现 CustomerService

你准备好实现你的第一个服务了吗?在本节中,我们将实现CustomerService类。这个类允许我们将信息从控制器和CustomerRepository中传递过来和传过去。

10.2.1 为成功做准备:创建骨架类

当开始一个新的类的工作时,我们应该首先做什么?对,创建一个相应的单元测试类。这次我不会向你展示如何创建测试类;只需确保你创建一个具有公共访问修饰符和TestInitialize方法的测试类(目前不需要向TestInitialize方法中添加任何内容)。此外,为了保持一定的组织性,让我们模仿我们之前使用的仓库测试类的文件夹结构,在FlyingDutchmanAirlines_Tests项目中创建一个 ServiceLayer 文件夹,如图 10.4 所示。

图片

图 10.4 CustomerServiceTests 文件位于 FlyingDutchmanAirlines_Tests 项目中的 ServiceLayer 文件夹内。

在我们可以创建一个成功案例单元测试之前,我们需要弄清楚在CustomerService类中需要哪些方法。你可能记得,在第七章中,我们在CustomerRepository中实现了以下两个方法:

  • CreateCustomer

  • GetCustomerByName

我们是否应该在CustomerService类中模仿那些方法名?嗯,是的,在一定程度上。如果我们保持仓库和服务中方法名的同构关系,这将使我们的代码更易于阅读,因为我们建立了一定的预期。如果一个开发者看到服务类和仓库类中都存在名为GetCombineHarvester的方法,开发者会期望GetCombineHarvester方法的服务版本调用相同方法的仓库版本。让我们不要让我们的开发者同伴的直觉失望。当然,服务方法可能会调用多个仓库方法,所以选择最能反映你意图的那个。

灯泡 在为服务层方法命名时,考虑将方法命名为与您调用的主要仓库层方法相同的名称。这有助于建立直觉,并使您的代码更易于导航。

话虽如此,没有任何控制器直接调用CreateCustomerGetCustomerByName方法。相反,我们与Customer实体的唯一交互是通过其他服务。我们如何知道控制器不会直接调用服务?在图 10.5 中,我们看到以下三个由飞荷兰人航空公司与 FlyTomorrow 之间的合同规定的必需端点(在第 3.1 节和第 3.2 节中讨论过):

  • GET /Flight

  • GET /Flight/{FlightNumber}

  • POST /Booking/{FlightNumber}

图 10.5 FlyTomorrow 在第三章中讨论的三个必需端点。有两个GET端点和一个是POST端点。

这些端点中没有任何一个直接与Customer实体交互。在端点路径或路径参数中也没有出现“customer”这个词。我提出一个相当激进的看法:如果没有任何控制器直接调用服务,我们就不需要这个服务。实际上,我们甚至可以说,我们也不需要为该实体提供控制器,因为它永远不会被调用。换句话说,我们不需要CustomerService,也不需要CustomerController。嗯,这真是一件让人开心的事情,不是吗?我总是欣赏工作量减少。

10.2.2 如何删除自己的代码

到目前为止,我要求你做一件可能成为你一天中最亮点的事情:删除代码。我是认真的。如果你对删除代码感到不安,这一节就是为你准备的。我希望你将删除代码视为一个充满禅意的体验。当你找到更好的替代方案时,允许自己删除自己的代码是开发人员的一项关键技能,而且比你想象的要难。

在第 10.2.1 节中,我们创建了以下两个类:

  • CustomerService

  • CustomerServiceTests

CustomerServiceTests类包含一个未实现的TestInitialize方法。我们还确定我们不需要CustomerServiceCustomerServiceTests类,因为它们永远不会被控制器调用。在现实世界中,我坚决支持删除需要删除的代码。考虑一下空类、注释掉的代码和错误的实现。如果你担心破坏现有的代码(你应该担心),那么我希望你有一个完整的测试套件可以依赖来验证重构的正确性。你还应该使用源代码控制系统,这样你就可以在代码删除产生意外的副作用时恢复到之前的状态(你应该始终使用源代码控制系统)。

点赞

删除代码

删除代码令人害怕。对于犹豫删除自己的作品的常用说法是“杀死你的宝贝”。然而,为了交付尽可能好的工作,你必须吞下你的骄傲,删除你自己的(通常是美丽而优雅的)代码。如果你为了更好的实现而删除代码,那不是失败——恰恰相反,那是一种胜利。即使你没有自己编写新的实现,你也应该认为这是一个积极的改变。新的方法无疑是更易读和可维护的,这将在未来为你(和他人)节省很多痛苦。

我想提醒大家一个特殊情况,你应该无情地删除代码,无论是自己的设计还是别人的:被注释掉的代码。我要说的是,我敢打赌有些人会不同意我:被注释掉的代码在生产代码库中没有任何位置。就这样。你绝对不要将注释掉的代码合并到主分支中。想想代码为什么会被注释掉。它是解决方案的替代方法吗?它是旧实现吗?它是半吊子新实现吗?它是你未来可能需要的东西(不太可能)吗?在我看来,这些理由不足以证明你用一块丑陋的注释代码破坏了我的美丽代码库。如果你想在代码库中保留注释代码,你可以让它工作(并取消注释),或者你不必那么迫切地需要它。

例如,以下代码块包含一个有实现的方法,但有一个不同实现的注释:

// This code is too complicated! There is a better way.
// public bool ToggleLight() => _light = !_light;

public bit ToggleLight() => _light ^= 1;

现在,代码中的注释确实有一个合理的观点。IsToggleLight 方法运行时使用位异或运算符来翻转 _light位。注释中的实现确实更容易阅读。然而,它也带来了一些未知因素,因为它改变了ToggleLight方法的返回类型和_light 的底层类型(两者都从 bit 变为 bool),但我们可以处理这一点。那么,为什么这段代码从未被取消注释或实现呢?它没有通过代码审查吗?它不起作用吗?这是一个由不满的高级工程师或试图给某人留下深刻印象的新开发者留下的被动-aggressive “供未来参考”的注释吗?这无关紧要。

|

所以,拿起你最喜欢的破坏性删除方式(虚拟的;不要在你的笔记本电脑上使用冲击钻——出版商和我不对你的遗憾生活选择负责)。我偏爱图 10.6 中显示的古老命令行删除命令:Windows 中的 del /f [file] 和 macOS 中的 rm -rf [file]

图 10.6 在 Windows 命令行中删除文件,请使用 del /f [FilePath] 语法。感到一股力量涌动并尖叫“凭借神的力量!”是可选的。

那里,难道没有感觉到很强大吗?我确实从中得到了很大的提升,但这可能比你需要的关于我的信息更多。让我们继续前进,做一些实际的工作,好吗?

练习

练习 10.5

为什么我们想要使用与调用它的仓库方法相同的名称作为服务类方法名?

a. 这在两个方法之间建立了一种同构关系,并有助于为其他开发者创建有效的期望。

b. 我们不想这么做。如果服务和仓库类中包含具有相同名称的方法,则代码将无法编译。

c. 我们确实想这么做,但前提是方法名中必须有一个动词。

练习 10.6

你遇到了一行被注释掉的代码,这似乎表明了一种替代当前运行代码的方法。你该怎么办?

a. 保持原样。这不是你的问题。

b. 通过在原始评论中添加问题来请求澄清。

c. 找出为什么它在那里,并且在大多数情况下,删除被注释掉的代码。

练习 10.7

^运算符代表什么?

a. 逻辑或操作

b. 逻辑与操作

c. 逻辑与非操作

d. 逻辑异或操作

练习 10.8

使用^=运算符对布尔值有什么影响?

a. 布尔值翻转(true变为falsefalse变为true)。

b. 没有操作(true保持truefalse保持false)。

c. 布尔值翻转两次(true保持truefalse保持false)。

10.3 实现 BookingService

在第 10.1 节中复习了仓库/服务模式,并在第 10.2 节中尝试实现实际的服务类后,我们终于到了开始实际工作在服务类上的阶段——这次不是开玩笑。在本节中,我们将实现一个针对Booking实体的服务。

当我们在第 10.2 节中讨论服务类需求时,我们提到如果没有控制器类会调用相应的服务,则不需要专用服务层。即使我自己这么说,这也是一条很好的建议,所以让我们再次为BookingService类重复这个练习。是否有需要直接使用Booking实体的 API 端点?好吧,让我们再次查看 FlyTomorrow 合同中规定的以下三个必需端点:

  • GET /Flight

  • GET /Flight/{FlightNumber}

  • POST /Booking/{FlightNumber}

POST /Booking/{FlightNumber}端点直接处理Booking实体,正如路径所示。FlyTomorrow 使用POST端点在数据库中创建新的预订。由于我们需要有一个BookingController来接受 HTTP 请求,因此从该控制器调用BookingService是合理的。记住,服务层的目的是从仓库中收集和组织数据。因此,为了创建预订,控制器会调用BookingService类中的方法,该方法调用所需的仓库以执行其承诺的职责,如图 10.7 所示。

图 10.7 Booking实体的生命周期。请求通过BookingController(尚未编写)处理,它调用BookingService,然后BookingService调用BookingRepository。然后,路径回溯到调用者。

通过思考BookingService应该提供哪些功能,我们可以得出创建新预订所需的方法:一个异步的public方法,调用BookingRepository.CreateBooking并返回适当的信息给控制器。在这里,适当的信息可能是一个表示CreateBooking方法已执行并完成的Task<(bool, Exception)>。如果预订失败,我们得到一个假的布尔值以及CreateBooking方法抛出的异常:(false, thrownException)。如果预订成功,我们返回一个真的布尔值和一个空指针(如果你启用了可空引用类型,你可能需要通过后缀一个问号字符将Exception转换为可空类型:Exception?)。如果你不想定义布尔返回值,你可以改用Task的内部IsCompleted bool

我们还应该查看数据库模式(图 10.8)。Booking模型有以下两个外键约束:

  • 一个指向Customer.CustomerID的外键约束

  • 一个指向Flight.FlightNumber的外键约束

图片

图 10.8 Flying Dutchman Airlines 数据库模式。预订模型有两个外键约束:一个指向Customer.CustomerID,一个指向Flight.FlightNumber

作为我们输入验证的一部分,我们应该检查传入的表示CustomerIDFlightNumber的值是否有效。我们通过调用适当的存储库方法(在这种情况下,CustomerRepository.GetCustomerByIDFlightRepository.GetFlightByFlightNumber)来验证传入的值。验证输入参数也引发了一个问题:如果传入的CustomerIDFlightID在数据库中不存在,我们该怎么办?如果客户在数据库中不存在,这意味着他们之前没有通过 Flying Dutchman Airlines 预订过航班。我们不希望失去任何客户(因此,收入),所以我们调用CustomerRepository.CreateCustomer方法(在第十一章中实现)。如果航班不存在,预订将失败,因为我们没有权限随时添加新航班。

我们将我们的方法命名为CreateBooking,因为这就是我们在方法中做的事情,并且需要两个整数作为输入参数(customerIDflightNumber)。要调用BookingRepository.CreateBooking方法,我们首先需要实例化一个BookingRepository类型的实例。如果你还记得,当我们在第八章实现BookingRepository时,我们在仓库的构造函数中需要一个FlyingDutchmanAirlinesContext的实例。这样做是为了“注入”依赖关系,而不必担心它是如何实例化的。然而,我们现在必须担心这个问题,因为我们想要实例化一个BookingRepository并需要传递所需的FlyingDutchmanAirlinesContext依赖关系。也许我们可以将这个问题推迟一点。如果我们要求在BookingService的构造函数中注入BookingRepository的实例,如下一列表所示,我们的问题现在解决了……至少目前是这样。

列表 10.1 将BookingRepository注入到BookingService

public class BookingService {
  private readonly BookingRepository _repository;         ❶

  public BookingService(BookingRepository repository) {   ❷
    _repository = repository;                             ❸
  }
}

❶ 注入实例的支撑字段

❷ 注入一个BookingRepository实例

❸ 我们只能在构造函数中分配只读字段

那么,在运行时我们从哪里获取这个BookingRepository的实例?我们可能不希望控制器层去实例化它,因为这会将仓库层耦合到控制器层。这听起来像是不希望看到的紧密耦合,因为仓库层已经耦合到服务层,服务层又耦合到控制器层,如图 10.9 所示。

图片 10.9

图 10.9 如果我们在BookingController内部有一个BookingRepository的实例,这两个类之间就存在紧密耦合。如果BookingController通过BookingService间接调用BookingRepository,我们就有了较松的耦合。

我们如何避免在控制器中创建BookingRepository的实例,同时又不失去创建和使用BookingService实例的能力?答案就在眼前:依赖注入。当我们到达控制器层时,我们将一个BookingService的实例注入到BookingController中。这个BookingService是如何实例化的,这是一个留给你思考的谜题(我们将在第十三章讨论如何在服务启动时设置依赖注入)。现在,我们只需要理解依赖注入的基本原理以及我们如何与BookingService一起使用它。BookingService也应该有一个注入的CustomerRepository类型的实例,这样我们就可以在预订航班之前获取客户详情。这个任务留给你来完成。如果你遇到了困难,请参考前面的段落。当然,你可能在注入CustomerRepository类型之前将_repository变量重命名为类似_bookingRepository的名称,但这取决于你。考虑一下你最想看到什么。什么是最易读的?

在我们继续实际实现 BookingService.CreateBooking 方法之前,我们应该创建一个支持单元测试——我们应该至少尝试遵循测试驱动开发实践。如果你还没有这样做,请在名为 ServiceLayer 的文件夹中创建一个名为 BookingServiceTests 的测试文件(在 FlyingDutchmanAirlines_Tests 项目中),如图 10.10 所示。

图 10.10 BookingServiceTests 文件位于 FlyingDutchmanAirlines_Tests 项目中 ServiceLayer 文件夹内。

要开始我们的单元测试,创建一个名为 CreateBooking_Success 的单元测试方法,它实例化一个 BookingService 并调用(仍然是虚构的)CreateBooking 方法,如下所示。

列表 10.2 骨架 CreateBooking_Success 单元测试

[TestClass]
public class BookingServiceTests {
  private FlyingDutchmanAirlinesContext _context; 

  [TestInitialize]                                                          ❶
  public void TestInitialize() {
  DbContextOptions<FlyingDutchmanAirlinesContext> dbContextOptions = new    ❶
➥ DbContextOptionsBuilder<FlyingDutchmanAirlinesContext>()                 ❶
➥ .UseInMemoryDatabase("FlyingDutchman").Options;                          ❶

  _context = new FlyingDutchmanAirlinesContext_Stub(dbContextOptions);      ❶
  }                                                                         ❶

  [TestMethod]
  public async Task CreateBooking_Success() {
  BookingRepository repository = new BookingRepository(_context);           ❷
  BookingService service = new BookingService(repository);                  ❸
  (bool result, Exception exception) = 
➥ await service.CreateBooking("Leo Tolstoy", 0);
  }
}

❶ 设置内存数据库

❷ 创建 BookingRepository 实例,注入数据库上下文

❸ 创建一个 BookingService 实例,注入 BookingRepository 实例

表面上看,我们似乎只需要处理那个不可避免的编译错误,即编译器找不到 BookingService 中的 CreateBooking 方法。我们预料到这个错误并且可以处理它:在 BookingService 类中添加一个名为 CreateBooking 的骨架方法。我们将让 CreateBooking 方法接受两个参数:一个包含客户名称的整数和一个表示航班号的整数,如下所示:

public async Task<(bool, Exception)> 
➥ CreateBooking(int customerName, int flightNumber)  {
  return (true, null);
}

列表 10.2 中还有一个问题:一段逻辑上合理但不会完全做到的代码片段。我指的是我们在将 BookingService 分配给 service 变量时的实例化方式:

BookingService service = new BookingService(repository);

在下一节中,我们将进一步剖析这个作业的问题。

10.3.1 在架构层之间进行单元测试

在本节中,我将向您介绍将单元测试范围限制在您直接架构层的概念。本节包含了一本技术书中相对独特的元素:苏格拉底对话。

因为 BookingService 需要一个注入的实例 BookingRepository(通过其唯一可用的构造函数),所以我们简单地创建了 BookingRepository 的新实例,如列表 10.2 所示。这在语法上是完全合法的代码。但我想要说服你相反。让我们在(某种程度上不符合形式且受阿尔基比阿德斯二世启发)以下苏格拉底对话中进行实验:

对话人物:苏格拉底和菲德拉

环境:奥林匹斯山深处的一个隔间

SOCRATES:你在测试 BookingService,菲德拉吗?

PHAIDRA:是的,苏格拉底,我是。

SOCRATES:你似乎很烦恼,眼睛向下看。你在想什么吗?

PHAIDRA:我应该想些什么?

SOCRATES:哦,各种各样的事情,我想。也许是如何正确测试代码库,或者一只未迁徙的麻雀的飞行速度?

PHAIDRA:当然。

苏格拉底:那么,你不认为在测试之前确定你要测试的内容是最重要的吗?

菲德拉:当然,苏格拉底。但你说话像疯子一样;你肯定不会认为我不知道我在测试什么吧?

苏格拉底:那么,让我们讨论正确测试某事物意味着什么。测试牛车是否意味着你测试牛?测试里拉琴弦的拨动是否意味着你像玛尔斯亚斯、缪斯和尼塞恩仙女一样测试阿波罗的技艺?

菲德拉:当然不是。

苏格拉底:难道抄写员的笔迹的准确表示不反映对演说者嗓音的测试吗?

菲德拉:这是我的观点。

苏格拉底:那么,在处理服务时,是否需要测试并有一个准确的存储库表示?

菲德拉:苏格拉底,你狡猾而机智。

苏格拉底:所以,我们一致认为,如果你测试BookingService类,你是否也需要测试BookingRepository类?

菲德拉:我们意见一致。

即使在古希腊,如何正确测试代码也是一个热门话题!让我们自问一个问题:在BookingService单元测试中,我们想测试什么?我们应该验证当给BookingService适当的输入时,它是否返回正确的输出吗?是的,听起来很对。我们也应该测试BookingRepository是否做同样的事情吗?嗯,是的,在某种程度上。

如果BookingRepository不能正确工作,它会对BookingService产生不希望的结果。在测试BookingService时,我们不能假设BookingRepository工作正常,因为我们已经为该类设置了单元测试,对吗?嗯,是的,这有些道理。如果我们能以某种方式跳过BookingService代码,并在需要时返回有效信息,我们就可以在测试期间控制存储库层的所有代码执行。此外,如果我们实例化一个BookingRepository并将其注入到BookingService中,测试将操作实际的BookingRepository实例,因此也会在内存数据库上操作,如图 10.11 所示。

图 10.11 在多层架构中,我们只测试我们正在执行的代码所在的层,并模拟或存根下一层。因此,我们不会与更低的层交互。

当测试多层架构(如我们使用的存储库/服务模式)时,你通常不需要测试低于你正在工作的那一层的实际逻辑。如果你正在测试存储库层,你可以模拟或存根数据库访问层(这就是我们使用FlyingDutchmanAirlinesContext_Stub类所做的那样)。如果你正在测试服务层,你不需要验证存储库层的逻辑。

10.3.2 存根和模拟的区别

在整本书中,我们使用了FlyingDutchmanAirlinesContext_Stub来对FlyingDutchmanAirlines项目的仓库层进行单元测试。在本节(以及接下来的章节)中,我将向您介绍另一种在测试期间控制代码执行的方法:模拟。我们还将探讨存根和模拟之间的区别。

当我们想要执行与原始类不同的代码时,存根非常有用。例如,context.SaveChangesAsync方法将 Entity Framework Core 内部DbSets所做的更改保存到数据库中。在第 8.4 节中,我们想要执行方法的不同版本,因此我们创建了一个存根(FlyingDutchmanAirlinesContext_Stub)并重写了父类的SaveChangesAsync方法。

在模拟中,我们不对方法提供任何新的实现。当我们使用模拟时,我们告诉编译器实例化一个Mock<T>类型的类型,它伪装成T。由于 Liskov 替换原则,我们可以将模拟用作类型T。而不是实例化并注入实际的T类实例,我们实例化并注入模拟。

在我们的案例中,我们想要一个Mock<BookingRepository>。当在测试期间BookingService中的代码调用这个模拟的CreateBooking方法时,我们想要执行以下两个操作之一:

  • 当我们想要模拟成功条件时,立即从方法返回(实际上不在数据库中创建预订)。

  • 当我们想要模拟失败条件时,抛出Exception

因为我们只需要做这两件简单的事情,而且我们不需要在内存数据库中执行任何检查实体的逻辑(就像我们在存根中做的那样),所以使用模拟会更简单。您还没有被说服?好吧,请系好您的帽子,阅读下一节。

10.3.3 使用 Moq 库模拟类

在第 10.3.2 节中,我们简要讨论了模拟和存根之间的区别。现在是我向大家展示如何在实际中运用模拟以及我们需要做什么来实现这一点的时候了。首先,C#和.NET 5 都没有专门的模拟功能,因此我们需要使用第三方(开源)库来模拟我们的类:Moq。当然,您可以使用许多其他的模拟库或框架(Telerik JustMock、FakeItEasy 和 NSubstitute 是一些例子)。我选择 Moq 是因为它被广泛使用且易于使用。

要安装 Moq,您可以使用 Visual Studio 中的 NuGet 包管理器,或者在 FlyingDutchmanAirlines_Tests 文件夹中使用命令行,就像我们在第 5.2.1 节中做的那样,如下所示:

>\ dotnet add package Moq

此命令将 Moq 包添加到 FlyingDutchmanAirlines_Test 项目中。为了验证 Moq 包已被添加,您可以在 Visual Studio 中检查 Moq 引用,或者打开 FlyingDutchmanAirlines_Test.csproj 文件并查找 Moq 包引用,如图 10.12 所示。

图 10.12

图 10.12 将包引用添加到项目的 .csproj 文件中。Visual Studio 扫描此文件,并在解决方案资源管理器面板中显示添加的包。对 .csproj 或 Visual Studio 的编辑会自动传播到这两个地方。

在我们能够使用 Moq 之前,我们必须将其命名空间导入到 BookingServiceTests 类中。为了创建一个 BookingRepository 类型的模拟并从 CreateBooking 方法返回适当的输出(一个完成的 Task),我们需要执行以下操作:

  • 实例化一个 Mock<BookingRepository>

  • 设置 Mock<BookingRepository> 以在调用 CreateBooking 时返回一个完成的任务。

我们知道如何执行列表中的第一项——实例化一个 Mock<BookingRepository>——因为实例化模拟与实例化任何其他类没有区别。让我们在 CreateBooking_Success 单元测试中创建模拟的实例,如下所示:

Mock<BookingRepository> mockRepository = new Mock<BookingRepository>();

您可以使用 mock.Setup([lambda expression to call method])).[return] 语法设置模拟,当调用方法时返回特定值。因为我们想调用(并模拟)CreateBooking 方法,所以我们可以使用的 lambda 表达式是 repository => repository.CreateBooking(0, 0)。然后我们指定我们想要返回的内容:Returns(Task.CompletedTask),如下所示。

列表 10.3 设置 BookingRepository 的模拟并调用 CreateBooking

Mock<BookingRepository> mockRepository = new Mock<BookingRepository>();    ❶
mockRepository.Setup(repository => 
➥ repository.CreateBooking(0, 0)).Returns(Task.CompletedTask);            ❷

❶ 我们实例化一个新的 BookingRepository 模拟实例。

❷ 如果模拟调用 CreateBooking 并传递两个值为零的参数,则返回 Task.CompletedTask

不幸的是,列表 10.3 中的代码无法正确运行。Moq 抛出一个运行时异常,如图 10.13 所示,表示它无法从无法覆盖的类中实例化模拟。

图 10.13 Moq 抛出运行时异常,因为我们尝试模拟一个不可覆盖的类。

BookingRepository.CreateBooking 不是一个虚方法,所以 Moq 无法覆盖该方法以实现其新版本。Moq 还需要能够调用无参数的构造函数,而 BookingRepository 没有这样的构造函数。

为了解决这两个问题,我们首先将 BookingRepository.CreateBooking 方法设置为 virtual,如下所示:`

public virtual async Task CreateBooking(int customerID, int flightNumber)

然后,我们为 BookingRepository 创建一个无参数的构造函数,如下所示:`

public BookingRepository() {}

但如果我们的所有工作都白费了,确保开发人员通过构造函数使用注入的 FlyingDutchmanAirlinesContext 实例化 BookingRepository,那就太遗憾了。我真的希望新的构造函数具有 private 访问修饰符,但这样单元测试就无法访问它们(因为单元测试位于与存储库层不同的程序集)。这里有几个技巧可以帮助我们。以下是最常用的三个:

  • 使用 [assembly: InternalsVisibleTo([assembly name])] 属性。

  • 使用 #warning 预处理器指令生成编译器警告。

  • 验证正在执行的程序集与单元测试程序集不匹配。

让我们逐一解开它们。

InternalsVisibleTo 方法属性

首先,[assembly: InternalsVisibleTo([assembly name])] 属性,您只能将其应用于程序集,允许不同的程序集(在我们的例子中是 FlyingDutchmanAirlines _Tests.ServiceLayer 程序集)访问和操作带有内部访问修饰符的包含程序集(FlyingDutchmanAirlines)中的方法、属性和字段。当 CLR 看到内部可见性属性时,它会记录给定的程序集,并将其指定为尝试访问其内部程序集的“友元”程序集。在实践中,当 CLR 编译到中间语言时,它将友元程序集视为与包含程序集相同的程序集。

使用友元程序集和 InternalsVisibleTo 属性方法的问题在于,InternalsVisibleTo 属性非常挑剔。Stack Overflow 上有大量关于如何正确使用该属性的提问。除了可用性问题之外,我们也不太愿意测试私有方法。理想情况下,我们应该通过使用它们的公共方法来测试所有私有方法。测试不应该走任何普通用户不会走的路径。因为普通用户不会通过调用其私有方法与类进行交互,单元测试也不应该这样做。InternalsVisibleTo 方法属性是了解的好东西,但不是实际使用的好方法。关于 InternalsVisibleTo 的真正实用提示是,避免使用它,直接不使用即可。

提示:有关方法和成员访问权限的更多信息,请参阅 CLR 的“圣经”:Jeffrey Richter 的 CLR via C#(第 4 版;Microsoft Press,2012 年)。然而,请注意,这本书假设了本书中涵盖的大量知识。

预处理器指令(#warning 和 #error)

其次,我们可以在源代码中使用预处理器指令。预处理器指令是以 # 字符开头的命令,在编译之前解析。编译器扫描代码库中的预处理器指令,并在编译之前执行它们。为了处理编译警告和错误,我们可以使用 #warning#error 预处理器指令。#warning 抛出编译器警告,而 #error 在遇到警告和错误时抛出编译器错误。要通过 #warning 指令在我们的公共无参数构造函数中添加编译器警告,请将指令及其消息添加到构造函数中。值得注意的是,我们总是将预处理器指令插入到源代码中,不进行缩进(无论是空格还是制表符),如下所示:

  public BookingRepository() {
#warning Use this constructor only in testing
  }

使用 #warning 预处理器指令效果不错,但如果我们有大量的 #warning 指令,我们的编译过程将导致很多警告,这会降低它们的整体价值,并使得其他警告容易被忽视。另一个缺点是,仅仅因为有一个警告并不意味着开发者会注意到它。请参阅图 10.14 中的示例。

图 10.14 #warning 预处理器指令生成带有给定字符串的编译器警告。这里我们看到的是在 Visual Studio 2019 中显示的编译器警告。

匹配执行和调用汇编名称

第三,我提出了一种程序性黑客攻击的可能性:通过使用反射,我们可以访问执行或调用汇编的名称(有关汇编是什么的讨论,请参阅第 2.3.1 节)。当我们从 FlyingDutchmanAirlines 汇编内部调用 BookingRepository.CreateBooking 的无参构造函数时,调用汇编是 FlyingDutchmanAirlines。如果我们从不同的汇编中调用相同的构造函数,比如说,FlyingDutchmanAirlines_Tests 汇编,CLR 没有提供所需信息来提供执行汇编名称,因为它通常只能检索执行汇编的信息。

我们可以通过检查调用汇编是否等于当前 执行 汇编来利用这一点。如果是这样,有人正在偷偷摸摸地以错误的方式实例化 BookingRepository。当然,将汇编名称相互比较并不是万无一失的。有人可以创建一个新的汇编并使用错误的构造函数,但这样做需要付出的努力使得这种情况不太可能。我们通过使用 Assembly 类来访问调用和执行汇编的名称,如下一列表所示。

列表 10.4 比较执行和调用汇编名称

public BookingRepository() {
  if(Assembly.GetExecutingAssembly().FullName == 
➥ Assembly.GetCallingAssembly().FullName) {                     ❶
  throw new Exception("This constructor should only be used for 
➥ testing");                                                    ❷
  }
}

❶ 将执行汇编与调用汇编名称进行比较

❷ 如果构造函数被错误访问,则抛出异常

在列表 10.4 的代码中,如果开发者在 FlyingDutchmanAirlines 汇编内部尝试实例化 BookingRepository 的一个实例,并且没有使用适当的构造函数,那么代码在运行时会抛出一个 Exception,因为执行汇编和调用汇编的名称匹配。

使用反射获取调用汇编名称有一个注意事项:CLR 使用最后一个执行的堆栈帧来获取调用汇编名称,但如果一些代码被编译器内联了,那么这个堆栈帧可能不包含正确的信息。

编译器方法内联

在编译过程中,当编译器遇到对另一个类中方法的调用时,通常将方法调用替换为被调用方法的主体对性能有益。这减少了跨文件计算量,并且通常可以提高性能。然而,也存在收益递减的点。当被调用方法非常大且包含对其他大型方法的调用时,编译器可能会陷入一个死胡同。然后编译器会将深层嵌套方法的主体复制到原始调用类中,在你意识到之前,你的类的大小和复杂性就会急剧增加。现代编译器非常擅长检测这类问题,所以通常这不是你需要担心的事情。

此外,编译器通常不会尝试内联递归方法,因为这会导致编译器陷入一个无限循环,其中它试图将相同方法的主体永久地复制到自身中。有关编译器内联(以及编译器的一般信息),请参阅 Alfred V. Aho、Monica S. Lam、Ravi Sethi 和 Jeffrey D. Ullman 的 Compilers: Principles, Techniques & Tools(第 2 版;Pearson Education,2007)。

幸运的是,我们可以通过使用方法实现(MethodImpl)方法属性来告诉编译器不要在特定方法中内联代码。MethodImpl 方法属性允许我们指定编译器应该如何处理我们的方法,而且,令人惊讶的是,有一个选项是停止编译器内联给定方法。让我们将 MethodImpl 方法属性添加到构造函数中,并要求编译器不要内联该方法,如下所示:

[MethodImpl(MethodImplOptions.NoInlining)]
public BookingRepository() {
  if (Assembly.GetExecutingAssembly().FullName == 
➥ Assembly.GetCallingAssembly().FullName) {
  throw new Exception("This constructor should only be used for 
➥ testing");
  }
}

回到 CreateBooking_Success 单元测试,我们现在有一个可以注入到 BookingServiceMock<BookingRepository> 实例。将模拟实例注入到 BookingService 允许我们在不担心 BookingRepository 类的实现细节的情况下测试 BookingService。要注入 Mock<T>,我们需要使用模拟的底层对象,即实际模拟的对象:Object。如果我们不使用模拟的 Object 属性,我们将传递 Mock<T> 的实际实例,这不符合所需的依赖项。要使用模拟的 Object 属性,你调用 [mock].Object 属性,如下所示。

列表 10.5 将模拟实例注入到 RepositoryService

[TestMethod]
public async Task CreateBooking_Success() {
  Mock<BookingRepository> mockBookingRepository = new 
➥ Mock<BookingRepository>();                                      ❶
  mockBookingRepository.Setup(repository => 
➥ repository.CreateBooking(0, 0)).Returns(Task.CompletedTask);    ❷

  BookingService service = new 
➥ BookingService(mockBookingRepository.Object);                   ❸
  (bool result, Exception exception) = 
➥ await service.CreateBooking("Leo Tolstoy", 0);                  ❹

  Assert.IsTrue(result);
  Assert.IsNull(exception);
}

❶ 创建 BookingRepository 的模拟

❷ 设置模拟的 CreateBooking 方法的正确返回值

❸ 将模拟注入到 BookingService

❹ CreateBooking 方法返回一个命名元组。

我们还需要一个当调用 GetCustomerByName 时返回新 Customer 对象的 CustomerRepository 模拟。我们现在知道该怎么做。将 virtual 关键字添加到 GetCustomerByName 方法中,并确保我们可以模拟 CustomerRepository(添加一个类似于我们为 BookingRepository 做的构造函数),如下所示:

[TestMethod]
public async Task CreateBooking_Success() {
  Mock<BookingRepository> mockBookingRepository = new 
➥ Mock<BookingRepository>(); 
  Mock<CustomerRepository> mockCustomerRepository = new 
➥ Mock<CustomerRepository>();

  mockBookingRepository.Setup(repository => 
➥ repository.CreateBooking(0, 0)).Returns(Task.CompletedTask); 
  mockCustomerRepository.Setup(repository => 
➥ repository.GetCustomerByName("Leo 
➥ Tolstoy")).Returns(Task.FromResult(new Customer("Leo Tolstoy")));

  BookingService service = new 
➥ BookingService(mockBookingRepository.Object, 
➥ mockCustomerRepository.Object); 
  (bool result, Exception exception) = 
➥ await service.CreateBooking("Leo Tolstoy", 0);

  Assert.IsTrue(result);
  Assert.IsNull(exception);
}

在测试驱动开发方面,我们目前处于绿色阶段,并试图进入红色阶段。在我们继续之前,我们应该做一些快速的清理工作。因为我们使用了一个模拟,所以我们不需要在这个测试类中使用 FlyingDutchmanAirlinesContext 的存根或 dbContextOptions。我们现在应该从 TestInitialize 方法中移除存根的实例化、相应的后置字段和 dbContextOptions。我将这个任务留给你去做。

如果我们现在运行我们的测试,我们会看到它们通过了。不幸的是,它们通过的原因是错误的。在 10.3 节中,我们为 BookingService.CreateBooking 添加了一个骨架体以及一个硬编码的返回值。这就是使 CreateBooking_Success 单元测试通过的原因。在单元测试中要记住的一个重要教训是,一定要确保你的测试通过正确的原因。通过提供硬编码的返回值或对错误数据进行断言,很容易“伪造”一个成功的测试结果。我们如何确保 CreateBooking_Success 单元测试通过正确的原因?我们必须继续实现 CreateBooking 方法,我们将在 10.3.4 节中这样做。

10.3.4 从服务中调用仓库

我们在 10.3.3 节结束时得到了 BookingService.CreateBooking 的骨架实现以及通过 BookingServiceTests.CreateBooking_Success 的成功情况完成的单元测试。在本节中,我们将进一步实现 CreateBooking 方法,使其调用适当的仓库方法并返回正确的信息。

要完成 CreateBooking 方法,我们需要实现以下两个事项:

  • BookingRepository 的异步调用。在 try-catch 块内调用 GetCustomerByName 方法

  • 从方法中返回适当的元组值集合

try-catch 块内调用仓库方法允许我们进行错误处理。当在调用的仓库方法内部抛出异常时,try-catch 块会捕获该异常,如下所示:

public async Task<(bool, Exception)> 
➥ CreateBooking(string name, int flightNumber) {
  try {
    ...
  } catch (Exception exception) {
    ...
  }

  return (true, null);
}

try-catch代码块的try部分,我们希望使用包含对注入的CustomerRepositoryBookingRepository实例引用的类级私有属性:_customerRepository_bookingRepository(在执行我们的单元测试期间,这保留了对BookingRepository模拟版本的引用,如第 10.3.3 节所述)。我们使用_customerRepository实例来调用其GetCustomerByName方法。GetCustomerByName方法检索适当的Customer实例或抛出CustomerNotFoundException,这让我们知道客户未找到。如果不存在,我们调用CreateCustomer方法并创建它。之后,我们再次调用CreateBooking方法,返回其返回值。调用位于你所在的方法也是称为递归。因为GetCustomerByName方法抛出了我们实际上想要利用的异常,所以我们将其调用包裹在其自己的try-catch块中,如下一个代码示例所示。

定义:当方法调用自身时发生递归。当这种情况发生时,CLR 会暂停当前正在执行的方法以进入方法的新调用。递归通常伴随着沉重的性能和复杂性惩罚。在这里,它被用作教学工具,但在生产环境中,这通常不是解决特定问题的最佳(性能最佳)方式。

列表 10.6 对CreateBooking的递归调用

public async Task<(bool, Exception)> 
➥ CreateBooking(string name, int flightNumber) {
  try {
    Customer customer;
    try {
      customer =
➥ await _customerRepository.GetCustomerByName(name);    ❶
    } catch (CustomerNotFoundException) {                ❷
      await _customerRepository.CreateCustomer(name);    ❸
      return await CreateBooking(name, flightNumber);    ❹
    }
  ...
}

❶ 检查客户是否存在于数据库中,如果存在则获取其详细信息

❷ 客户在数据库中不存在。

❸ 将客户添加到数据库中

❹ 现在递归调用此方法,因为客户已在数据库中

我们现在可以使用_bookingRepository变量来调用BookingRepository中的CreateBooking方法。因为BookingRepository.CreateBooking方法应该异步执行,所以我们也在调用中await

Task完成时,因为try-catch代码块捕获了没有异常,并且代码返回了BookingRepository.CreateBooking方法,我们返回一组表示success变量为true布尔状态和null引用的元组。如果在BookingRepository.CreateBooking方法的执行过程中try-catch块捕获了Exception,我们将返回一组带有success变量设置为false状态以及捕获到的Exception引用的命名元组。通过在try-catch语句内部终止所有代码路径,我们不再需要如以下所示占位符返回(true, null)

列表 10.7 BookingService.CreateBooking方法

public async Task<(bool, Exception)> 
➥ CreateBooking(string name, int flightNumber){
  try {
    Customer customer;
    try {
      customer = await _customerRepository.GetCustomerByName(name);
    } catch (CustomerNotFoundException) {
      await _customerRepository.CreateCustomer(name);
      return await CreateBooking(name, flightNumber);
    }

    await _bookingRepository.CreateBooking(customer.CustomerId, 
➥ flightNumber);
    return (true, null);
  } catch(Exception exception) {
    return (false, exception);
  }
}

在我们正式完成BookingService类之前,我们只剩下以下几件事情要做:

  • customerNameflightNumber输入参数添加输入验证。

  • 验证请求的 Flight 是否存在于数据库中。如果不存在,我们需要优雅地退出方法。

  • 为输入验证、Customer 验证和创建以及 Flight 验证添加单元测试。

我们将在下一章中做这三件事,并完成 BookingService 的实现。在本章中,我们开始实现 BookingService,学习了如何使用模拟(使用 Moq 包),并更新了我们对仓储/服务模式的知识。

练习

练习 10.9

对或错?在单元测试多层架构时,我们可以将测试关注点立即下方的层替换为模拟或存根。

练习 10.10

假设你正在单元测试一个位于仓储/服务架构控制器层的类。以下哪种方法是正确的方法?

a. 模拟控制器层,存根服务,并使用仓储层。

b. 存根控制器层,不使用服务层,并模拟仓储层。

c. 使用控制器层,模拟服务层,不使用仓储层。

练习 10.11

对或错?通过使用服务层来控制对仓储/服务模式中各种仓储的访问,控制器和仓储之间的耦合度降低,因为控制器通过服务间接调用仓储。

练习 10.12

对或错?模拟用于提供现有方法的替代实现。要使用模拟,你需要提供一个新方法主体,并为覆盖的方法编写替代逻辑。

练习 10.13

对或错?InternalsVisibleTo 方法属性可以用来阻止其他程序集查看应用了该属性的程序集的内部结构。

练习 10.14

你可以使用哪个预处理器指令来生成编译器警告?

a. #error

b. ^&generate

c. #warning

d. ^&compiler::warning

练习 10.15

对或错?你可以通过使用反射和程序集命名空间内的方法在运行时请求执行和调用程序集的名称。

练习 10.16

当编译器内联一个方法时,调用该方法代码会发生什么?

a. 没有内容——内联意味着我们立即执行被调用方法。代码不会改变。

b. 编译器将方法调用替换为被调用方法的主体。

c. 编译器将方法调用替换为包含该方法的类的内容。

练习 10.17

如果我们将属性 [MethodImpl(MethodImplOptions.NoInlining)] 添加到属性中,会发生什么?

a. 我们会得到编译错误,因为你不能在属性上使用 MethodImpl 属性。

b. 属性调用会被内联。

c. 只有在可以获得显著性能提升的情况下,才会内联属性调用。

摘要

  • 仓储/服务模式将应用程序分为三个层:控制器、服务和仓储。这有助于我们控制数据流和分离关注点。

  • 在存储库/服务世界中,控制器持有服务的一个实例,服务持有存储库的一个实例。这是为了确保各个类之间的耦合尽可能松散。如果控制器要持有服务和存储库的实例,我们将对存储库有非常紧密的耦合。

  • 视图是用户返回的一个或多个模型的“窗口”。我们使用视图来收集并向用户展示信息。

  • 当测试遵循存储库/服务模式(或任何其他多层架构)的解决方案时,你只需要在想要测试的级别进行测试。例如,如果你正在测试控制器类,你可能需要模拟或存根服务层,但测试不需要执行服务层中的实际逻辑。因此,在这种情况下根本不会调用存储库层。这有助于我们只测试原子操作,而不是整个堆栈。如果我们想要跨层测试,我们需要一个集成测试。

  • 模拟是一个在调用方法或属性时返回特定返回值的类。它被用来代替原始类。这有助于我们专注于想要测试的层。

  • InternalsVisibleTo方法属性用于指定可以访问内部方法、字段和属性的“朋友”程序集。这在单元测试中很有帮助,因为通常测试代码位于我们想要测试的代码的单独程序集中。

  • 预处理器指令可以生成编译器警告(#warning)和编译器错误(#error)。我们还可以使用预处理器指令来控制我们的数据流,当访问修饰符和封装不足时。添加编译器警告可以让开发者知道在特定位置存在潜在的风险。

  • 编译器内联意味着编译器用一个被调用方法的主体替换方法调用。这对于性能很有用,因为它减少了跨文件调用。

  • 通过使用方法实现(MethodImpl)方法属性,我们可以控制编译器的内联偏好。我们可以通过添加[MethodImpl(MethodImplOptions.NoInlining)]作为方法属性来强制编译器不内联一个方法。这在重新抛出异常时保留堆栈跟踪非常有用。

11 重新审视运行时类型检查和错误处理

本章涵盖

  • 使用Assert.IsInstanceOfType测试断言

  • 从服务类调用多个存储库

  • 使用丢弃运算符

  • 使用多个catch

  • 使用isas运算符在运行时检查类型

在第五章实现了数据库访问层,在第 6 至 9 章实现了存储库层之后,我们开始在第十章实现BookingService。我还向您介绍了在单元测试中使用模拟的方法,并讨论了存储库/服务模式。在本章中,我们将使用这些概念,并借鉴我们对服务层知识,来完成BookingService的实现。图 11.1 显示了我们在本书方案中的位置。

图 11.1 在本章中,我们完成了BookingService类的实现。在下一章中,我们将通过实现AirportServiceFlightService类来完成服务层的封装。

当我们完成BookingService的实现时,本章还讨论了使用Assert.IsInstanceOfType测试断言来验证一个对象是否为特定类型(或派生自特定类型),丢弃(_)运算符及其对中间语言的影响,以及在try-catch代码块中使用多个catch块。

为了完成BookingService的实现,我们需要做以下几步:

  • 验证BookingService.CreateBooking方法的输入参数(第 11.1 节)。

  • 验证我们想要预订的航班是否存在于数据库中(第 11.3 节)。

11.1 验证服务层方法输入参数的有效性

许多时候,服务层类充当控制器类和存储库类之间的管道。尽管涉及的逻辑不多,但服务层仍然提供了一个重要的抽象层,以对抗紧密耦合。关于耦合的讨论,请参阅第 8.2 节。

在我们继续之前,我们应该回顾一下我们在BookingService.CreateBooking方法中留下的地方:

public async Task<(bool, Exception)> 
➥ CreateBooking(string name, int flightNumber) {
  try {
    Customer customer;
    try {
      customer = await _customerRepository.GetCustomerByName(name);
    } catch (FlightNotFoundException) {
      await _customerRepository.CreateCustomer(name);
      return await CreateBooking(name, flightNumber);
    }

    await _bookingRepository.CreateBooking(customer.CustomerId, flightNumber);
    return (true, null);
  } catch (Exception exception) {
    return (false, exception);
  }
}

为了执行所需的输入验证,我们可以使用我们在第 9.6 节中实现的IsPositiveInteger扩展方法和string.IsNullOrEmpty方法。如果客户的姓名是空或空字符串,或者航班号不是正整数,我们返回一组变量,表示(falseArgumentException),如下所示:

public async Task<(bool, Exception)> 
➥ CreateBooking(string name, int flightNumber) {
  if (string.IsNullOrEmpty(name) || !flightNumber.IsPositiveInteger()) {
    return (false, new ArgumentException());
  }

  try {
    Customer customer;
    try {
      customer = await _customerRepository.GetCustomerByName(name);
    } catch (FlightNotFoundException) {
      await _customerRepository.CreateCustomer(name);
      return await CreateBooking(name, flightNumber);
    }

  await _bookingRepository.CreateBooking(customer.CustomerId, 
➥ flightNumber);
    return (true, null);
  } catch (Exception exception) {
    return (false, exception);
  }
}

现在,我们应该添加一个单元测试,如下所示,包括使用[DataRow]方法属性内联测试数据,以检查当给定无效输入参数时,BookingService.CreateBooking方法返回的值是(falseArgumentException)。对于这个单元测试,我们不需要设置返回特定值的BookingRepository模拟,因为它永远不会被执行。

列表 11.1 测试BookingService.CreateCustomer的输入验证

[TestMethod]
[DataRow("", 0)]                                     ❶
[DataRow(null, -1)]                                  ❶
[DataRow("Galileo Galilei", -1)]                     ❶
public async Task CreateBooking_Failure_InvalidInputArguments(string name, 
➥ int flightNumber) {
  Mock<BookingRepository> mockBookingRepository = 
➥ new Mock<BookingRepository>();                    ❷
  Mock<CustomerRepository> mockCustomerRepository = 
➥ new Mock<CustomerRepository>();                   ❷
  BookingService service = 
➥ new BookingService(mockBookingRepository.Object, 
➥ mockCustomerRepository.Object);                   ❸
  (bool result, Exception exception) = await 
➥ service.CreateBooking(name, flightNumber);        ❸

  Assert.IsFalse(result);                            ❹
  Assert.IsNotNull(exception);                       ❹
}

❶ 内联测试数据

❷ 设置模拟

❸ 调用 CreateBooking 方法

❹ 结果应该是(false,Exception)。

对于无效输入参数的情况,应该就到这里了。但如果存储库层抛出了异常怎么办?我们希望 BookingService.CreateCustomer 方法中的 try-catch 块能够捕获到异常,但直到我们测试这一点,我们才确信。我不喜欢依赖我对代码应该做什么的解释。相反,最好是“证明”我们的假设并创建一个单元测试。我们可以创建一个名为 CreateBooking_Failure_RepositoryException 的单元测试,并设置一个模拟的 BookingRepository,当调用 BookingRepository.CreateBooking 时,它会返回一个 Exception

我们应该返回哪种类型的 Exception?存储库返回 ArgumentException(在无效输入时)或 CouldNotAddBookingToDatabaseException 异常。我们既可以检查是否抛出了这些特定的异常,也可以检查是否抛出了通用的 Exception

如果开发者在数据库错误发生时,将抛出的异常类型从 CouldNotAddBookingToDatabaseException 改为 AirportNotFoundException,而我们只测试是否抛出了基类 Exception,那么我们无法在最早的可能时刻捕获到 AirportNotFoundException 异常。这会导致测试错误地通过。正因为如此,我建议我们设置以下两个模拟返回实例:

  • 如果我们将参数集 {0, 1} 传递给 BookingService.CreateBooking 方法,则抛出 ArgumentException 异常。

  • 如果我们将参数集 {1, 2} 传递给 BookingService.CreateBooking 方法,则抛出 CouldNotAddBookingToDatabaseException 异常。

要在模拟上设置多个返回值,我们可以修改模拟逻辑以覆盖我们想要测试的所有不同情况。只要它们对于编译器来说都是可以区分的(就像任何重写的方法一样),就没有对可以添加到方法中的返回模拟数量的实际限制。

为了验证抛出的 Exception 是否为特定类型,我们可以使用 Assert.IsInstanceOfType 断言以及 typeof 操作符(在第 4.1.2 节中讨论),如下面的代码所示。

列表 11.2 CreateBooking_Failure_RepositoryException

[TestMethod]
public async Task CreateBooking_Failure_RepositoryException() {
  Mock<BookingRepository> mockBookingRepository = 
➥ new Mock<BookingRepository>();
  Mock<CustomerRepository> mockCustomerRepository = 
➥ new Mock<CustomerRepository>();

  mockBookingRepository.Setup(repository => 
➥ repository.CreateBooking(0, 1)).Throws(new ArgumentException());       ❶
  mockBookingRepository.Setup(repository => 
➥ repository.CreateBooking(1, 2))
➥ .Throws(new CouldNotAddBookingToDatabaseException());                  ❷

  mockCustomerRepository.Setup(repository => 
➥ repository.GetCustomerByName("Galileo Galilei"))
➥ .Returns(Task.FromResult(
➥ new Customer("Galileo Galilei") { CustomerId = 0 }));
  mockCustomerRepository.Setup(repository => 
➥ repository.GetCustomerByName("Eise Eisinga"))
➥ .Returns(Task.FromResult(new Customer("Eise Eisinga") { CustomerId = 1 
➥ }));

  BookingService service = new BookingService(mockBookingRepository.Object, 
➥ mockCustomerRepository.Object);
  (bool result, Exception exception) = 
➥ await service.CreateBooking("Galileo Galilei", 1);                     ❸

  Assert.IsFalse(result);
  Assert.IsNotNull(exception);
  Assert.IsInstanceOfType(exception, typeof(ArgumentException));          ❹

  (result, exception) = await service.CreateBooking("Eise Eisinga", 2);   ❺

  Assert.IsFalse(result);
  Assert.IsNotNull(exception);
  Assert.IsInstanceOfType(exception, 
➥ typeof(CouldNotAddBookingToDatabaseException));   
}

❶ 设置逻辑路径以抛出 ArgumentException 异常

❷ 设置逻辑路径以抛出 CouldNotAddBookingToDatabaseException 异常

❸ 调用 CreateBooking 方法,参数为(“Galileo Galilei”,1)

❹ 断言返回的异常类型为 CouldNotAddBookingToDatabaseException

❺ 调用 CreateBooking 方法,参数为(“Eise Eisinga”,2)

Assert.IsInstanceOfType 是你工具箱中的一个非常有价值的断言。你可以在测试中使用这个断言,而不是通过常规代码(使用 typeof 操作符)断言对象的类型。或者,你也可以通过将 is 语法(如下一节所述)添加到 Assert.IsTrue 检查中,来模拟 Assert.IsInstanceType 的功能。

11.1.1 使用 is 和 as 操作符进行运行时类型检查

Assert.IsInstanceOfType 在失败时抛出 Exception。这在单元测试中工作得非常好,因为断言失败意味着测试失败。在生产代码中,情况可能不同。有时,当我们遇到意外类型的对象时,我们不想抛出 Exception。我们已经了解了 typeof 操作符。如果我们 需要 在生产代码中确保一个对象是特定类型,我们可以采取以下两种其他方法:

  • 通过使用 is 操作符检查我们是否可以将类型 T 转换为类型 Y

  • 使用 as 操作符将类型 T 转换为类型 Y,并处理可能的 null 返回值。

isas 操作符都是使用 Liskov 原则进行运行时类型检查的方法。而 typeof 操作符仅在编译时工作,我们可以使用 isas 操作符在运行时动态确定某个对象是什么类型。

在表 11.1 中,我们可以看到 isas 操作符的比较,以及它们的使用案例和语法示例。

表 11.1 比较 isas 操作符

操作符 用例 语法
is 检查类型 T 是否为类型 Y apple is Fruit
as 判断类型 T 是否可以被转换为类型 Y Peugeot as Car

让我们进一步分析这个表格,并对这两个操作符进行更深入的研究。

11.1.2 使用 is 操作符进行类型检查

首先是:is 操作符。当我们想要进行类似于 GetType(在第 4.1.2 节中讨论)的运行时类型检查时,我们经常使用 is。假设我们正在编写一个(非常天真的)洲际互联网包交换系统的实现。我们可能有一个包含节点(交换机)的树,其中包含一个根节点(洲际交换机)、一个洲或地区交换机,以及专门的国家级交换机。我在图 11.2 中展示了这样的设置。

图 11.2 一个可能且简化的网络交换树。该树包含一个作为洲际交换机的根节点,三个作为洲际交换机的子节点,以及六个特定国家的交换机。

假设还有两种类型从基类 Packet 继承而来:ExternalPacketLocalPacket,其中 ExternalPacket 表示任何需要根据特定目的地前往不同洲的数据包。例如,从叶 3(巴拿马)到叶 4(巴西)的数据包是 LocalPacket 类型,因为它只需要通过南/中美洲交换机。从叶 6(肯尼亚)出发并前往叶 1(卢森堡)的数据包是 ExternalPacket,因为它需要通过洲际交换机。

我们如何编写代码来将这些数据包导向正确的交换机?一种可能的实现方案是假设我们有一个PacketTransfer类,该类尝试为我们处理路由。在PacketTransfer中,我们可能有一个名为DetermineNextDestination的方法,该方法返回一个InternetSwitch类型的对象。InternetSwitch类也可以有两个派生类型:ContinentalSwitchGlobalSwitch

为了知道将数据包路由到何处,我们需要确定数据包是ExternalPacket还是LocalPacket。在列表 11.3 中,您可以看到路由外部数据包逻辑的潜在实现。

列表 11.3 使用 is 操作符进行数据包路由

public InternetSwitch DetermineNextDestination(Packet packet)  {
  if (packet is ExternalPacket) {                                     ❶
    if (packet.CurrentLocation is ContinentalSwitch) {                ❷
      return packet.CurrentLocation == PacketIsInDestinationRegion() 
 ➥ ? packet.Destination : GetGlobalSwitch();                    ❸
    }

  return GetContinentalSwitch(packet.Destination);                    ❹
  }

  ...
}

❶ 检查数据包对象是否可以强制转换为 ExternalPacket 类型

❷ 检查数据包目的地对象是否可以强制转换为 ContinentalSwitch 类型

❸ 前往数据包目的地或全局交换机,具体取决于当前位置

❹ 如果数据包是 ExternalPacket 且不在 ContinentalSwitch 上,则将其发送到其中一个

通过使用多态和is操作符,我们可以轻松推断出正在路由的数据包是否为ExternalPacket类型。所以,这就是is操作符,那么as操作符又如何呢?

11.1.3 使用 as 操作符进行类型检查

让我们想象我们已经将数据包路由到其目的地。现在,目标交换机想要接受这个数据包,但这个特定的交换机只接受本地数据包(不通过GlobalSwitch对象)。我们可以尝试将接收到的数据包用作LocalPacket,如下所示,并看看会发生什么。

列表 11.4 使用 as 操作符进行数据包接受

public void AcceptPacket(Packet packet) {
  LocalPacket receivedPacket = packet as LocalPacket;    ❶
  if (receivedPacket != null) {                          ❷
    ProcessPacket(receivedPacket);                       ❸
  } else {
    RejectPacket(packet);                                ❹
  }
}

❶ 尝试将数据包变量用作 LocalPacket 实例

❷ 验证 as 操作符没有返回空指针

❸ 处理 LocalPacket 实例

❹ 拒绝非 LocalPacket 实例

当使用as操作符时,如果变量无法转换为请求的类型,CLR 会将空指针分配给该变量。使用as操作符是一个强大的工具,在处理未知输入时可能很有用。

现在,到了高潮部分:我们通过在下一个代码示例中使用模式匹配来结合两种方法。

列表 11.5 使用模式匹配进行数据包接受

public void AcceptPacket(Packet packet) {
  if (packet is LocalPacket receivedPacket) {    ❶
    ProcessPacket(receivedPacket);               ❶
  } else {                                       ❷
    RejectPacket(packet);                        ❷
  }
}

❶ 如果数据包可以用作 LocalPacket,则将其分配给 receivedPacket 并处理。

❷ 如果数据包不能用作 LocalPacket,则调用 RejectPacket 方法

11.1.4 在 11.1 节中我们做了什么?

CreateBooking_Failure_RepositoryException 单元测试中,我们测试并验证了我们能够优雅地处理在存储库层抛出的异常,并且符合预期。我们以与 CreateBooking_SuccessCreateBooking_Failure_InvalidInputs 单元测试相同的方式实例化了 Mock<BookingRepository>。也许我们可以将模拟的初始化提取到 TestInitialize 方法中,并将 CreateBooking_Failure_RepositoryExceptions 分解为两个测试。我们还学习了使用 isas 运算符进行运行时类型检查。

11.2 清理 BookingServiceTests

在总结第 11.1 节时,我们确定了 BookingServiceTests 类的以下两个清理区域:

  • Mock<BookingRepository> 的初始化提取到 TestInitialize 方法中。当前的实现方式是在每个测试中实例化 BookingRepository 的模拟,违反了 DRY 原则。

  • CreateBooking_Failure_RepositoryException 分解为两个单元测试:一个用于 ArgumentException,另一个用于 CouldNotAddBookingToDatabaseException 异常。

让我们首先将 Mock<BookingRepository> 的初始化提取到下面的 TestInitialize 方法中。我们还想添加一个私有后置字段,以便我们存储对 Mock<BookingRepository> 的引用。

[TestClass]
public class BookingServiceTests {
  private Mock<BookingRepository> _mockBookingRepository;

  [TestInitialize]
  public void TestInitialize() {
    _mockBookingRepository = new Mock<BookingRepository>();
  }

  ...
}

我们需要做的就是将现有的单元测试修改为使用 _mockBookingRepository 字段而不是实例化它们自己的模拟。例如:

[TestMethod]
public async Task CreateBooking_Success()  {
  Mock<BookingRepository> mockRepository = new Mock<BookingRepository>();
  _mockBookingRepository.Setup(repository => 
➥ repository.CreateBooking(0, 0)).Returns(Task.CompletedTask);

  BookingService service = 
➥ new BookingService(mockBookingRepository .Object);

  (bool result, Exception exception) = await service.CreateBooking(0, 0);

  Assert.IsTrue(result);
  Assert.IsNull(exception);
}

我们仍然想要设置任何模拟的返回值,因为每次运行新的测试时,现有的模拟实例都会被重置。在 TestInitialize 方法中不初始化任何模拟允许我们根据每个测试设置不同的返回模拟实例。

我们确定的第二个改进是将 CreateBooking_Failure_RepositoryException 单元测试分解为以下单独的单元测试:

  • CreateBooking_Failure_RepositoryException_ArgumentException

  • CreateBooking_Failure_RepositoryException_CouldNotAddBookingToDatabase

这两个新的单元测试各自测试一个抛出相应 Exception 的逻辑分支。在列表 11.6 中,你可以看到 CreateBooking_Failure_RepositoryException_ArgumentException 单元测试。我将 CreateBooking_Failure_CouldNotAddBookingToDatabase 留给你来实现。如果你遇到困难,可以模仿列表 11.6 中的模式。这两个单元测试的版本都包含在本书的源文件中。

列表 11.6 CreateBooking_Failure_RepositoryException_ArgumentException 单元测试

[TestMethod]
public async Task 
➥ CreateBooking_Failure_RepositoryException_ArgumentException() {
  _mockBookingRepository.Setup(repository => 
➥ repository.CreateBooking(0, 1)).Throws(new ArgumentException());

  _mockCustomerRepository.Setup(repository => 
➥ repository.GetCustomerByName("Galileo Galilei")) 
➥ .Returns(Task.FromResult(
➥ new Customer("Galileo Galilei") { CustomerId = 0 }));

  BookingService service = 
➥ new BookingService(_mockBookingRepository.Object, 
➥ _mockFlightRepository.Object, _mockCustomerRepository.Object);
  (bool result, Exception exception) = 
➥ await service.CreateBooking("Galileo Galilei", 1);

  Assert.IsFalse(result);
  Assert.IsNotNull(exception);
  Assert.IsInstanceOfType(exception, 
➥ typeof(CouldNotAddBookingToDatabaseException));
} 

在本节中我们取得了哪些成果?我们实现了 BookingService 并为我们的服务类提供了以下三个单元测试以支持其功能:

  • CreateBooking_Success—这个单元测试验证了我们的“快乐路径”场景,并调用模拟的 BookingRepository 来模拟数据库操作。

  • CreateBooking_Failure_RepositoryException_ArgumentException—这个单元测试告诉BookingRepository模拟抛出ArgumentException。我们验证我们的服务方法是否能够适当地处理抛出的ArgumentException

  • CreateBooking_Failure_CouldNotAddBookingToDatabase—这个单元测试告诉BookingRepository模拟抛出CouldNotAddBookingToDatabaseException异常。我们验证我们的服务方法是否能够适当地处理抛出的CouldNotAddBookingToDatabaseException异常。

11.3 服务类中的外键约束

在第 10.3 节中,我们确定BookingService必须处理以下两个外键约束(如图 11.3 所示):

  • 一个指向Customer.CustomerID的外键约束

  • 一个指向Flight.FlightNumber的外键约束

图 11.3 飞行荷兰人航空公司数据库模式。预订模型有两个外键约束:一个指向Customer.CustomerID,一个指向Flight.FlightNumber

我们如何“处理”这些外键约束?我们还在第 10.3 节中确定,我们想要使用CustomerRepository.GetCustomerByName方法来验证数据库中是否存在具有传入Name值的客户。如果找到,该方法将返回一个包含适当CustomerID值的Customer对象。如果没有找到Customer对象,我们想要使用CustomerRepository.CreateCustomer方法来创建它。对于flightNumber参数:如果没有在数据库中找到匹配的航班,我们应该从服务方法中返回,而不在数据库中创建预订(或新航班)。

这就是服务层力量的开始展现。因为我们允许服务层(仅限于服务层!)调用与其直接模型不直接相关的存储库,我们可以收集一组信息以返回给控制器,如图 11.4 所示。在BookingService的情况下,其直接模型是Booking实体。然而,为了正确地在数据库中创建新的预订,我们需要使用CustomerFlight的存储库层类。

图 11.4 BookingService跨存储库层的调用。它调用BookingRepository(其直接关注点),FlightRepositoryCustomerRepository

11.3.1 从服务类调用航班存储库

由于Flight模型上的业务逻辑比Customer模型上的更严格,我们在输入验证之后应该做的第一个检查是确保请求的Flight实例存在于数据库中。让我们看看我们能够走多远而不被卡住:

public async Task<(bool, Exception)> 
➥ CreateBooking(string name, int flightNumber) {
  ...

  FlightRepository flightRepository = new FlightRepository();

  ...
}

嗯,这并不远。FlightRepository构造函数要求我们传递(或注入)一个FlyingDutchmanAirlinesContext实例。我们在服务层没有访问该实例。我们可以实例化一个FlyingDutchmanAirlinesContext实例,但我们也可以采取与BookingRepository相同的方法:使用依赖注入为BookingService类提供一个现成的FlightRepository实例。

要将注入的实例添加到消费类中,我们需要执行以下操作,如图 11.5 和列表 11.7 所示:

  1. 添加一个类型为T的后置字段,其中T是注入的类型。

  2. 向消费类的构造函数中添加一个类型为T的参数。

  3. 在构造函数内部,将步骤 1 中创建的私有后置字段注入的类型T的实例赋值。

图片 11_05

图 11.5 要使用依赖注入,首先添加一个后置字段。然后注入所需类型。最后,将注入的参数赋值给后置字段。

列表 11.7 BookingService注入FlightRepository的实例

public class BookingService {
  private readonly BookingRepository _bookingRepository;
  private readonly FlightRepository _flightRepository;
  private readonly CustomerRepository _customerRepository;

  public BookingService(BookingRepository bookingRepository, 
➥ FlightRepository flightRepository, CustomerRepository 
➥ customerRepository) {
    _bookingRepository = bookingRepository;
    _flightRepository = flightRepository;
    _customerRepository = customerRepository;
  }
}

现在,我们有一个注入的FlightRepository实例,已赋值给后置字段,我们可以在CreateBooking方法中使用它。但有一个问题:列表 11.7 中的代码无法编译。编译器抛出一个异常,如图 11.6 所示,表示在单元测试中对BookingService构造函数的调用中参数不足。

图片 11_06

图 11.6 如果没有足够的参数来调用给定方法,编译器会抛出异常。在这种情况下,我们没有提供足够的参数来调用BookingService的构造函数(我们缺少customerRepository参数)。

为了解决编译器错误,我们需要在BookingServiceTests类中现有的单元测试中添加一个Mock<FlightRepository>实例。继续添加模拟实例到单元测试中。如果你模仿实例化Mock <BookingRepository>对象时使用的模式,你应该没问题。如果你卡住了,提供的源代码中包含了答案。你不需要为模拟的FlightRepository类设置任何返回调用。最后一点提示:你必须为FlightRepository创建一个无参构造函数。如果你需要更多关于为什么需要创建一个无参虚拟构造函数的信息,请参阅第 10.3.3 节。

我们现在的代码可以编译,现有的单元测试也通过了。我们可以继续验证航班是否存在于数据库中。我们的第一步,一如既往,是添加一个单元测试。

BookingServiceTests中添加一个名为CreateBooking_Failure_FlightNotInDatabase的单元测试。成功情况已在CreateBooking_Success单元测试中覆盖,只要我们向FlightRepository.GetFlightByFlightNumber方法添加一个模拟设置调用,如下所示:

_mockFlightRepository.Setup(repository => 
➥ repository.GetFlightByFlightNumber(0))
➥ .Returns(Task.FromResult(new Flight()));

现在,对于失败路径,我们实现CreateBooking_Failure_FlightNotInDatabase单元测试,如下所示:

[TestMethod]
public async Task CreateBooking_Failure_FlightNotInDatabase() {
  BookingService service = 
➥ new BookingService(_mockBookingRepository.Object,
➥ _mockFlightRepository.Object, _mockCustomerRepository.Object);
  (bool result, Exception exception) = 
➥ await service.CreateBooking("Maurits Escher", 19);

  Assert.IsFalse(result);
  Assert.IsNotNull(exception);
  Assert.IsInstanceOfType(exception, 
➥ typeof(CouldNotAddBookingToDatabaseException));
}

CreateBooking_Failure_FlightNotInDatabase单元测试可以编译但无法通过。然而,这正是我们目前想要的。记住,在测试驱动开发中,我们从无法编译或通过测试开始,只实现足够的代码来使测试通过。

BookingService.CreateBooking中,我们想要确保我们不预订一个不存在的航班给客户。查看FlightRepository.GetFlightByFlightID,我们注意到该方法接受以下三个参数:

  • flightNumber

  • originAirportId

  • desinationAirporId

不幸的是,这已经不再适用于我们了。幸运的是,我们不应该害怕改变(或删除)自己的代码。我想给你一个任务:让FlightRepository.GetFlightByFlightID只接受flightNumber参数并返回正确的航班。这允许我们在服务层中使用该方法,并迫使你亲自动手。如果你遇到了困难,请参阅本章的源代码。示例实现如列表 11.8 所示。同时,确保你更新了单元测试。

现在,FlightRepository.GetFlightByFlightNumber方法只接受flightNumber,我们实际上可以使用它。列表 11.8 显示了我的实现。你可以看到,该方法只提供两种可能的返回值:要么方法返回一个Flight实例,要么它抛出一个FlightNotFoundException

列表 11.8 FlightRepository.GetFlightByFlightNumber

public async Task<Flight> GetFlightByFlightNumber(int flightNumber) {
  if (!flightNumber.IsPositiveInteger()) {
    Console.WriteLine($"Could not find flight in 
➥ GetFlightByFlightNumber! flightNumber = {flightNumber}");
    throw new FlightNotFoundException();
  }

  return await _context.Flight.FirstOrDefaultAsync(f => 
➥ f.FlightNumber == flightNumber) ?? throw new FlightNotFoundException();
}

飞行验证逻辑的可能实现包括对GetFlightByFlightNumber的调用,如下所示。如果在BookingService.CreateBooking中的try-catch没有捕获到异常,那么一切正常,我们可以继续进行。

await _flightRepository.GetFlightByFlightNumber(flightNumber);

这段代码将完美地工作,直到有人决定更改FlightRepository.GetFlightByFlightNumber的实现。如果方法在数据库中找不到匹配的航班时突然返回一个null指针而不是抛出异常,代码将像什么都没发生一样执行,并允许客户被预订到一个不存在的航班上。

相反,让我们在这里做一些尽职调查,并检查GetFlightByFlightNumber的输出,如下面的代码示例所示。如果它是null,我们也抛出一个Exception

列表 11.9:飞行验证代码的更好实现

public async Task<(bool, Exception)> 
➥ CreateBooking(string name, int flightNumber) {
  if (string.IsNullOrEmpty(name) || !flightNumber.IsPositiveInteger()) {
    return (false, new ArgumentException());
  }

  try {
    __ = await _flightRepository.GetFlightByFlightNumber(flightNumber) 
➥ ?? throw new Exception();

    ...
 }
 ...
}

列表 11.9 通过抛出Exception主动处理GetFlightByFlightNumbernull情况。代码还使用了丢弃操作符(_)。你可以使用丢弃操作符来“丢弃”返回值,但仍然使用依赖于值赋值的操作符(例如空合并操作符)。

丢弃操作符和中间语言

丢弃操作符(_)是一个值得思考的有趣案例。使用丢弃操作符是否意味着我们不会将方法的返回值赋给任何东西?我们是不是立即丢弃了赋值变量?我们可以通过检查丢弃操作符是如何编译成中间语言的来找到答案。

让我们看看列表 11.9 中的FlightRepository.GetFlightByFlightNumber方法调用,并移除空合并操作符,这样我们就可以专注于丢弃操作符:

_ = await _flightRepository.GetFlightByFlightNumber(flightNumber);

这会编译成一系列的 MSIL 操作码,但赋值部分以以下内容结束:

stloc.3

stloc.3命令将信息存储到堆栈上的第 3 个位置。看起来使用丢弃操作符仍然会导致一些内存分配。当然,分配的内存空间会尽快被垃圾回收器回收,因为没有对它的调用。

那么,为了回答我们最初的问题:是的,丢弃操作符会分配内存。但是,因为我们不能直接指向一个丢弃操作符并像使用任何其他变量一样使用它,所以我们仍然有性能上的优势。

使用丢弃操作符的另一个好处是代码整洁。将值赋给未使用的变量通常非常令人困惑。通过使用丢弃操作符,你明确地表示,“我不会使用这个方法返回的值。”

列表 11.9 中的代码比列表 11.8 中的代码有所改进,但我们还可以更进一步。在第 4.2 节中,我们讨论了像叙事一样阅读代码。列表 11.9 中的代码似乎是一个很好的机会,通过将飞行验证逻辑提取到它自己的独立私有方法中来实现这一点。我们可以调用该方法FlightExistsInDatabase,并根据FlightRepository.GetFlightByFlightNumber的返回值是否为null返回一个布尔值,如下所示。

列表 11.10 在CreateBooking中使用FlightExistsInDatabase

public async Task<(bool, Exception)> 
➥ CreateBooking(string name, int flightNumber) {
  if (string.IsNullOrEmpty(name) || !flightNumber.IsPositiveInteger()) {
    return (false, new ArgumentException());
  }

  try {
    if (!await FlightExistsInDatabase(flightNumber)) 
      throw new CouldNotAddBookingToDatabaseException();             ❶
    }

    ...
  } 

    ... 
}

private async Task<bool> FlightExistsInDatabase(int flightNumber) {
  try {                                                              ❷
    return await                                                     ❷
➥ _flightRepository.GetFlightByFlightNumber(flightNumber) != null;  ❷
  } catch (FlightNotFoundException) {                                ❸
      return false;                                                  ❸
  }                                                                  ❸
} 

❶ 如果给定的航班在数据库中不存在,则抛出异常

❷ 如果GetFlightByFlightNumber返回一个 null 值,则返回 false;否则,返回 true

❸ 如果GetFlightByFlightNumber抛出FlightNotFoundException,则返回 false

这样就完成了飞行验证代码的实际实现。然而,我们仍然需要更新我们的单元测试,因为我们没有准备好在模拟的FlightRepositoryGetFlightByFlightNumber方法被调用时返回正确的值。

到现在为止,你应该已经熟悉了如何设置模拟的返回值,所以我将向你展示如何为CreateBooking_Failure_FlightNotInDatabaseCreateBooking_Success单元测试设置返回值,你可以尝试修复其他单元测试。如果你遇到了困难,提供的源代码中包含了答案。

为了告诉Mock<FlightRepository>,当我们看到flightNumber-1时,我们希望抛出一个FlightNotFound类型的异常(这是真实代码的逻辑),我们使用与第 10.3.3 节中描述的相同语法,如下一列表所示:[MOCK].Setup([*调用方法时的参数谓词*]).Throws(new [*异常类型*])

注意:如第 10.3.3 节所述,为了模拟特定的方法调用,我们需要将原始方法设置为virtual。将方法设置为virtual允许 Moq 库覆盖该方法。关于虚拟方法的讨论,请参阅第 5.3.2 节。

列表 11.11 设置Mock<FlightRepository>异常返回值

[TestMethod]
public async Task CreateBooking_Failure_FlightNotInDatabase() {
  _mockFlightRepository.Setup(repository => 
➥ repository.GetFlightByFlightNumber(-1))
➥ .Throws(new FlightNotFoundException());

  BookingService service = new 
➥ BookingService(_mockBookingRepository.Object,     
➥  mockFlightRepository.Object, _mockCustomerRepository.Object);
  (bool result, Exception exception) = 
➥ await service.CreateBooking("Maurits Escher", 1);

  Assert.IsFalse(result);
  Assert.IsNotNull(exception);
  Assert.IsInstanceOfType(exception, 
➥ typeof(CouldNotAddBookingToDatabaseException));
}

如列表 11.11 所示,当模拟的GetFlightByFlightNumber方法被调用,并且传入的输入参数值为-1时,该方法会抛出一个FlightNotFoundException类型的异常(模拟现有代码)。BookingService.FlightExistsInDatabase方法(调用了GetFlightByFlightNumber方法)检查该方法返回的值是否为null(在这种情况下因为抛出了异常,所以是null),并返回该表达式的值。基于这个结果,BookingService会抛出一个CouldNotAddBookingToDatabaseException类型的异常。

为了修复CreateBooking_Success单元测试,我们需要设置我们的FlightRepository模拟,以便在调用GetFlightByFlightNumber方法时返回一个Flight实例。

为了向GetFlightByFlightNumber方法添加一个模拟的返回类型为Task<Flight>的值,我们需要使用[MOCK].Setup语法的异步版本,如下一代码片段所示。如果我们使用同步版本,模拟将尝试返回一个Flight实例而不是Task<Flight>实例,这会导致编译器错误,如图 11.7 所示。

图片

图 11.7 当尝试返回未包装在Task类型中的类型时,编译器会抛出一个错误,表示无法将T转换为Task<T>

[TestMethod]
public async Task CreateBooking_Success() {
  _mockBookingRepository.Setup(repository => repository.CreateBooking(0, 
➥ 0)).Returns(Task.CompletedTask);
  _mockFlightRepository.Setup(repository => 
➥ repository.GetFlightByFlightNumber(0)).ReturnsAsync(new Flight());

  BookingService service = 
➥ new BookingService(_mockBookingRepository.Object,     
➥ _mockFlightRepository.Object, _mockCustomerRepository.Object);

  (bool result, Exception exception) = await service.CreateBooking(0, 0);

  Assert.IsTrue(result);
  Assert.IsNull(exception);
}

调用客户仓库

我们需要验证的第二个输入参数是name。为了验证name参数,BookingService必须调用CustomerRepositoryGetCustomerByName方法,以及(如果客户不在数据库中)CreateCustomer方法。在第 10.3.4 节中,我们实现了这个逻辑的一个版本。自从我们查看这些方法以来已经有一段时间了(我们在第七章中实现了它们),所以让我们通过下一个代码示例来刷新我们的记忆:

public virtual async Task<Customer> GetCustomerByName(string name) {
  if (IsInvalidCustomerName(name)) {
    throw new CustomerNotFoundException();
  }

  return await _context.Customer.FirstOrDefaultAsync(c => c.Name == name) 
➥ ?? throw new CustomerNotFoundException();
}
public async Task<bool> CreateCustomer(string name) {
  if (IsInvalidCustomerName(name)) {
    return false;
  }

  try {
    Customer newCustomer = new Customer(name);
    using (_context) {
      _context.Customer.Add(newCustomer);
      await _context.SaveChangesAsync();
    }
  } catch {
      return false;
  }

  return true;
}

我们现在的单元测试状况良好……那么,让我们再次破坏它们!记住,当一切顺利时,测试驱动开发中的下一个阶段是再次破坏测试。在这种情况下,让我们添加一个新的单元测试来测试客户不在数据库中的逻辑:CreateBooking_Success_CustomerNotInDatabase。为什么这个单元测试是一个成功案例?客户验证没有失败吗?是的,但这只是意味着客户不是预先存在的。在这种情况下,我们只需将客户添加到数据库中,并按常规进行,如图 11.8 所示。要从 BookingService 中调用 CustomerRepository 的任何方法,我们使用注入的 CustomerRepository 实例如下:

private readonly BookingRepository _bookingRepository;
private readonly FlightRepository _flightRepository;
private readonly CustomerRepository _customerRepository;

public BookingService(BookingRepository bookingRepository, FlightRepository 
➥ flightRepository, CustomerRepository customerRepository {
  _bookingRepository = bookingRepository;
  _flightRepository = flightRepository;
  _customerRepository = customerRepository;
}

CustomerRepository 构造函数中的 CustomerRepository 参数添加到现有单元测试中会破坏它们。你知道该怎么做:在单元测试中向构造函数调用添加 Mock<CustomerRepository>。我将这个任务留给你去做。如果你卡住了,请参阅本章提供的源代码。你将不得不设置一个无参数构造函数的 CustomerRepository 测试,并使适当的方法虚拟化,以便 Moq 实例化和使用 Mock<CustomerRepository>

图片

图 11.8 如果客户不存在于数据库中,我们将客户添加到数据库中。在这两种情况下,我们都会创建一个预订。

使用注入的 CustomerRepository 实例,我们首先创建两个私有方法,用于检查客户是否存在于数据库中,如果不存在则将其添加到数据库中。CustomerRepository.GetCustomerByName 方法返回一个类型为 CustomerNotFoundExceptionException。我们可以在 catch 代码块中捕获这个特定的错误,并创建客户,如下一列表所示。如果抛出了不同类型的 Exception,那么我们知道出了问题,因此我们重新抛出异常(CreateBooking 方法会捕获并处理异常)。在第 9.4 节中,我们讨论了如何在保留原始问题堆栈跟踪的同时重新抛出异常。

列表 11.12 GetCustomerFromDatabaseAddCustomerToDatabase 方法

private async Task<Customer> GetCustomerFromDatabase(string name) {
  try {
    return await _customerRepository.GetCustomerByName(name);        ❶
  } catch (CustomerNotFoundException) {                              ❷
    return null;                                                     ❷
  } catch (Exception exception){                                     ❸
    ExceptionDispatchInfo.Capture(exception.InnerException           ❸
➥ ?? new Exception()).Throw();                                      ❸
    return null;                                                     ❸
  }                                                                  ❸
}

private async Task<Customer> AddCustomerToDatabase(string name) {    ❹
  await _customerRepository.CreateCustomer(name);                    ❹
  return await _customerRepository.GetCustomerByName(name);          ❹
}

❶ 尝试从数据库中检索客户

❷ 如果抛出了 CustomerNotFoundException,则表示客户不存在于数据库中。

❸ 如果抛出了不同的异常,则表示出了问题。重新抛出异常。

❹ 向数据库添加客户

GetCustomerFromDatabaseAddCustomerToDatabase 方法尚未在任何地方被调用,这给了我们一个很好的机会来思考如何测试它们的功能。我们知道在每次执行 CreateBooking 时,我们至少会调用 GetCustomerFromDatabase,所以让我们从它开始。GetCustomerFromDatabase 可以确定以下三种潜在状态:

  1. 客户存在于数据库中。

  2. 客户不存在于数据库中。

  3. CustomerRepository.GetCustomerByName 中抛出了除 CustomerNotFoundException 之外的 Exception

就成功案例而言,路径 1 和 2 是相关的。如果客户在数据库中找不到,我们就通过 AddCustomerToDatabase 方法添加他们。该路径涉及一些额外的逻辑,所以,现在让我们专注于快乐路径(1)。在处理快乐路径之后,我们将处理路径编号 3(完全失败的情况)。

然而,在我们测试任何状态之前,我们需要将客户数据库逻辑添加到 CreateBooking 方法中,如下所示:

public async Task<(bool, Exception)> CreateBooking(string name,
➥ int flightNumber) {
  if (string.IsNullOrEmpty(name) || !flightNumber.IsPositiveInteger()) {
    return (false, new ArgumentException());
  }

  try {
    Customer customer = await GetCustomerFromDatabase(name) 
➥ ?? await AddCustomerToDatabase(name); 

    if (!await FlightExistsInDatabase(flightNumber)) {
      return (false, new CouldNotAddBookingToDatabaseException());
    }

    await 
➥ _bookingRepository.CreateBooking(customer.CustomerId, flightNumber);
    return (true, null);
  } catch (Exception exception) {
    return (false, exception);
  }
}

要测试客户在数据库中不存在时的逻辑路径,我们需要设置 Mock<CustomerRepository>,在调用 GetCustomerByName 方法时抛出 CustomerNotFoundException 类型的异常,如下所示:

[TestMethod]
public async Task CreateBooking_Success_CustomerNotInDatabase() {
  _mockBookingRepository.Setup(repository => 
➥ repository.CreateBooking(0, 0)).Returns(Task.CompletedTask);
  _mockCustomerRepository.Setup(repository => 
➥ repository.GetCustomerByName("Konrad Zuse"))
➥ .Throws(new CustomerNotFoundException());

  BookingService service = 
➥ new BookingService(_mockBookingRepository.Object, 
➥ _mockFlightRepository.Object, _mockCustomerRepository.Object);

  (bool result, Exception exception) = 
➥ await service.CreateBooking("Konrad Zuse", 0);

  Assert.IsFalse(result);
  Assert.IsNotNull(exception);
  Assert.IsInstanceOfType(exception, 
➥ typeof(CouldNotAddBookingToDatabaseException ));
} 

在我们完成 BookingService 实现之前,我们只需要提供以下两个代码路径的测试:

  • GetCustomerByName 方法抛出了除 CustomerNotFoundException 之外的其他异常。

  • CreateCustomer 方法返回了一个 false 布尔值。

幸运的是,这两个都是容易添加的单元测试。如果 BookingRepository.CreateBooking 抛出 Exception 会怎样?BookingService.CreateBooking 代码 应该 返回 {false, CouldNotAddBookingToDatabaseException},但它真的这样做了吗?只有一个方法可以找到答案:

[TestMethod]
public async Task 
➥ CreateBooking_Failure_CustomerNotInDatabase_RepositoryFailure() {
  _mockBookingRepository.Setup(repository => 
➥ repository.CreateBooking(0, 0))
➥ .Throws(new CouldNotAddBookingToDatabaseException());
  _mockFlightRepository.Setup(repository => 
➥ repository.GetFlightByFlightNumber(0))
➥ .ReturnsAsync(new Flight());
  _mockCustomerRepository.Setup(repository => 
➥ repository.GetCustomerByName("Bill Gates"))
➥ .Returns(Task.FromResult(new Customer("Bill Gates")));

  BookingService service = 
➥ new BookingService(_mockBookingRepository.Object, 
➥ _mockFlightRepository.Object, _mockCustomerRepository.Object);

  (bool result, Exception exception) = 
➥ await service.CreateBooking("Bill Gates", 0);

  Assert.IsFalse(result);
  Assert.IsNotNull(exception);
  Assert.IsInstanceOfType(exception, 
➥ typeof(CouldNotAddBookingToDatabaseException));
} 

结果证明一切顺利。因此,我们可以完成 BookingServiceBookingServiceTests 的实现。在本节中,我们学习了更多关于在单元测试中使用模拟以及如何实现一个调用存储库层的依赖注入的服务层类。

练习

练习 11.1

真的是假的?存储库在控制器和服务之间充当传递者。

练习 11.2

填空:要将注入的依赖项添加到类中,您必须添加一个类作用域的私有 __________,并将其分配给一个需要注入 __________ 的 __________ 中。

a. 方法;构造函数;属性

b. 类;抽象方法;变量

c. 字段;构造函数;参数

练习 11.3

假设我在一个名为 Apple 和 Banana 的数据集模式中有两个模型。Apple.ID 列与 Banana.TastyWith 列有一个外键关系。哪个服务允许调用什么存储库?

a. 允许 Apple 服务调用 Banana 存储库。

b. 允许 Banana 存储库调用 Apple 存储库。

c. Kiwi 存储库被注入到 AppleBanana 服务中,并从那里获取。

练习 11.4

真的是假的?只要服务类有合法的理由调用每一个,它就可以调用无限数量的存储库。

练习 11.5

如果您尝试实例化一个类型而不提供其构造函数所需的任何参数,您会得到什么?

a. 参与奖

b. 编译错误

c. 运行时错误

练习 11.6

真的是假的?丢弃操作符确保您永远不会为存储表达式的返回值分配任何内存。

练习 11.7

在一个try-catch代码块中,您有两个catch块。第一个是针对Exception类的catch;第二个是针对ItemSoldOutException类的catch。如果在try-catch代码块的try部分抛出了ItemSoldOutException,哪个catch块会被进入?

a. catch(Exception exception) {...}

b. catch(ItemSoldOutException exception) {...}

练习 11.8

在一个try-catch代码块中,您有两个catch块。第一个是针对ItemSoldOutException类的catch;第二个是针对Exception类的catch。如果在try-catch代码块的try部分抛出了ItemSoldOutException,哪个catch块会被进入?

a. catch(ItemSoldOutException exception) {...}

b. catch(Exception exception) {...}

摘要

  • 您可以使用Assert.IsInstanceOfType对对象执行测试断言,以检查它是否为某种类型(或可以使用多态强制转换为该类型)。在单元测试中检查类型,如果您需要确保返回了特定类型,可能会很有用;例如,检查从方法返回的Exception的类型。

  • 您可以使用isas运算符进行运行时类型检查。这在处理可能不知道确切类型的对象时非常有用。

  • 在适当的情况下,服务类可以调用存储库类。您使用服务类将多个数据流组织成一个视图。从服务中调用存储库类还可以让您追踪外键约束。

  • 抛弃运算符(_)允许您明确指出方法返回值是一个可丢弃的值。有时使用抛弃运算符可以提高代码的可读性。

  • 抛弃运算符确实会分配内存块,但由于没有指向该内存块的指针,垃圾收集器可以尽快收集它们。这有助于提高性能。

  • 您可以在try-catch代码块中拥有多个catch块。只有第一个匹配的catch块会被进入。当处理多个Exception的派生类,并且您的逻辑基于特定类而有所不同时,这非常有用。

12 使用 IAsyncEnumerable和 yield return

本章涵盖

  • 使用通用的Queue数据结构

  • 使用yield returnIAsyncEnumerable<T>

  • 创建视图

  • 使用具有自动属性的私有获取器和设置器

  • 结构体与类有何不同

  • 使用checkedunchecked关键字

在前面的章节中,我们检查了我们继承的代码库,并记录了我们可以进行改进的地方。然后,我们部分实现了我们版本的代码库,遵循 FlyTomorrow 的 OpenAPI 规范。在第十章和第十一章中,我们实现了BookingService类,并决定不需要CustomerService类。图 12.1 显示了我们在本书结构中的位置。

图 12.1 在本章中,我们通过实现AirportServiceFlightService类来封装服务层。通过实现这些类,我们完成了飞荷兰人航空公司服务层的重写。

如果我们看看我们需要实现哪些类来完成我们的服务层,一个鼓舞人心的画面随之而来:

  • CustomerService (第十章)

  • BookingService (第十章和第十一章)

  • AirportService (本章)

  • FlightService (本章)

我们已经完成了服务层类的一半。在本章中,我们将通过编写AirportServiceFlightService类的代码来封装服务层的实现。在本章之后,我们将处于一个极佳的位置,可以继续到最后一个架构层:控制器层。

12.1 我们是否需要 AirportService 类?

在第 10.2 节中,我们确定如果一个服务类永远不会被控制器类调用,我们不需要实现服务类。我们还看到,你可以通过将控制器模型名称与 OpenAPI 规范进行比较来确定是否需要特定的控制器。如果没有控制器需求,就没有必要实现服务类。作为习惯性生物,让我们为AirportService类重复这个过程。

OpenAPI 规范(如图 12.2 所示)告诉我们我们需要实现以下三个端点:

  • GET /Flight

  • GET /Flight/{FlightNumber}

  • POST /Booking/{FlightNumber}

图 12.2 FlyTomorrow 的 OpenAPI 规范。我们需要实现三个端点:两个GET和一个POST

这些端点中有哪些与Airport模型相关的控制器?如图 12.3 所示,我看到两个Flight控制器和一个Booking控制器,但没有需要Airport控制器的端点。那么,事情就这样定了:我们不需要实现AirportService类。

图 12.3 机场表有两个外键约束。这两个都来自航班表,检索Airport.AirportID。这些外键约束可以用来检索特定Airport的信息。

另一方面,我们确实有一个用例需要保留AirportRepository类。如果我们查看已部署数据库的数据库模式,我们会看到机场表有以下两个外键约束:

  • Flight.OriginAirport.AirportID

  • Flight.DestinationAirport.AirportID

在 12.2 节中,我们将更深入地探讨这些外键约束并实现它们。从第十一章的经验中,我们知道我们需要使用接收表的重构库来追踪这些外键约束。

12.2 实现 FlightService 类

到目前为止,我们已经实现了BookingService并决定不实现AirportCustomer实体服务。在本节中,我们将通过实现FlightService类来完成服务层的实现。与前面的章节一样,让我们问问自己,我们需要实现这个类吗?我们有两个端点需要一个Flight控制器。GET /FlightGET /Flight/{FlightNumber}端点都向Flight控制器发出请求。太好了——这意味着我们需要实现一个FlightService类。这两个端点返回的数据已经在数据库中,并且它们的复杂性相对简单。让我们从第一个开始:GET /Flight.

12.2.1 从 FlightRepository 获取特定航班的详细信息

在本节中,我们将实现 12.1 节中讨论的GET /Flight端点。FlyTomorrow 使用GET /Flight端点来查询我们的服务以获取所有可用的航班。我们不需要考虑(或验证)任何特殊输入参数,但我们有一些外键限制需要追踪。我们还将为Flight创建一个View类,这样我们就可以返回来自FlightAirport表的组合数据。

但首先,我们所有努力的起点:我们需要为FlightServiceFlightServiceTests类创建骨架类,如图 12.4 所示。你知道该怎么做。

图 12.4

图 12.4 要开始实现FlightService,创建两个骨架类:FlightServiceFlightServiceTests。这些类构成了我们FlightServiceFlightServiceTests实现的基础。

现在我们已经在项目中有了所需的类,我们可以考虑我们的方法需要做什么。我们的方法——让我们称它为GetFlights——必须返回数据库中每个航班的详细信息。为此,我们应该使用FlightRepository类的注入实例。然而,FlightRepository类没有返回数据库中所有航班的函数,因此我们需要添加这个功能。

FlightRepository 中,让我们添加一个名为 GetFlights 的虚拟方法。我们不需要使该方法异步,因为我们不需要查询实际数据库以获取所需信息。即使我们想要从数据库中的特定表中获取所有数据,请记住,Entity Framework Core 在内存中存储了大量的元数据。这让我们想到了使用 ORM 的一个缺点:在规模上的性能。如果你有一个包含数百万条记录的数据库,Entity Framework Core 仍然会在本地存储大量数据。另一方面,这也意味着我们可以查询 Entity Framework Core 的内部 DbSet<Flight> 并查看当前数据库中的所有航班。

GetFlights 方法应该返回一个 Flight 集合,但应该使用哪个集合呢?我们不需要通过某种键或索引访问元素,所以 ArrayDictionaryList 都是不必要的。也许一个简单的 Queue<Flight> 就足够了。

队列是一种“先进先出”的数据结构(通常缩写为 FIFO)。首先进入队列的元素是第一个出来的,如图 12.1 所示。在我们的情况下,有一个 FIFO 结构是有帮助的,因为我们能确保我们在数据结构中表示航班的方式与数据库中表示的方式之间有一个同构关系。

列表 12.1 FlightRepository.GetFlights

public virtual Queue<Flight> GetFlights() {
  Queue<Flight> flights = new Queue<Flight>();    ❶
  foreach (Flight flight in _context.Flight) {    ❷
    flights.Enqueue(flight);                      ❷
  }

  return flights;                                 ❸
}

❶ 创建一个队列来存储航班

❷ 将每个航班按顺序添加到队列中

❸ 返回队列

EF Core FOREACH 当处理 Entity Framework Core DbSet<T> 集合时,foreach 循环的一个替代实现是使用 EF Core 的 ForEachAsync 方法:_context.Flight.ForEachAsync(f => flights.Enqueue(f));。根据你的可读性偏好和异步需求,这可能是一个不错的选择。

对于 FlightRepository.GetFlights 方法,这就是全部内容,但我们还需要单元测试来支持它。我将向您介绍成功案例的单元测试,但我希望您考虑一些潜在的失败情况并编写针对它们的测试。如果您发现出于任何原因需要更改 FlightRepository.GetFlights 方法,请这样做!

如果我们查看现有的 FlightRepositoryTest 类的 TestInitialize 方法,我们会看到在每次测试之前只向内存数据库中添加了一个航班。在一个理想的世界里,我们希望内存数据库中至少有两个航班,这样我们就可以断言返回的 Queue<Flight> 中的顺序,如下所示:

[TestInitialize]
public async Task TestInitialize() {
  DbContextOptions<FlyingDutchmanAirlinesContext> dbContextOptions = 
➥ new DbContextOptionsBuilder<FlyingDutchmanAirlinesContext>()
➥ .UseInMemoryDatabase("FlyingDutchman").Options;
  _context = new FlyingDutchmanAirlinesContext_Stub(dbContextOptions);

  Flight flight = new Flight {
    FlightNumber = 1,
    Origin = 1,
    Destination = 2
  };

  Flight flight2 = new Flight {
    FlightNumber = 10,
    Origin = 3,
    Destination = 4
  };

  _context.Flight.Add(flight);
  _context.Flight.Add(flight2);
  await _context.SaveChangesAsync();

  _repository = new FlightRepository(_context);
  Assert.IsNotNull(_repository);
}
YIELD RETURN 关键字 如果你愿意使用泛型类而不是 QueueListDictionary 这样的具体集合类型,一个整洁的概念是使用 yield return 关键字。

当处理实现IEnumerable<T>接口的集合时,我们可以返回IEnumerable<T>类型,而不需要在方法内部声明一个实际的集合。这听起来可能有些令人困惑,所以让我在下一个代码示例中展示如果使用这种方法,列表 12.1 中的代码会是什么样子。

列表 12.2 使用yield returnIEnumerable<Flight>

public virtual IEnumerable<Flight> GetFlights() {
  foreach (Flight flight in _context.Flight) { 
    yield return flight;
  }
}

列表 12.2 中的代码没有明确声明一个用于存储Flight对象的集合。相反,通过使用yield return关键字,我们抽象掉了集合的初始化,让编译器发挥其魔力。(这是一个简单的例子,列表 12.2 中的代码在这种情况下也可以简单地返回现有的_context.Flight集合。)这种编译器魔力包括编译器在幕后生成一个实现IEnumerable接口的类,并返回该类。语法暗示我们直接使用了IEnumerable接口,但实际上,我们使用的是编译器生成的包装类。

你有时也会在延迟评估的上下文中听到yield return关键字。延迟评估意味着我们推迟所有处理/迭代,直到绝对必要。这与贪婪评估相反,贪婪评估在所有处理都完成并获取所有信息后,让我们迭代结果。通过使用yield return关键字,我们可以提出一种延迟逻辑,它不会在返回结果之前对它们进行操作。这将在本节稍后的IAsyncEnumerable<T>讨论中进一步解释。

现在我们可以通过调用FlightRepository.GetFlights方法来获取数据库中所有航班的队列,然后我们可以开始组装要返回给控制器的视图。默认情况下,Flight对象有三个属性:FlightNumberOriginIDDestinationID。这些信息对客户至关重要,因此我们希望返回它们。然而,仅仅返回起点和目的地的机场 ID 并不很有用。如果我们查看数据库模式,我们会发现我们可以使用外键来获取更多关于起点和目的地机场的信息。

如图 12.5 所示,Flight表有以下两个外键约束:

  • Flight.Origin映射到Airport.AirportID

  • Flight.Destination映射到Airport.AirportID

图片

图 12.5 航班表有两个外键约束。此图未显示任何其他外键约束(入站或出站)。我们将在 12.2.2 节中使用这些外键约束来创建视图。

如果我们追踪那些外键约束,我们可以根据它们的 ID 获取Airport信息。AirportRepository类有以下方法可以帮助我们:GetAirportByIDGetAirportByID方法接受一个机场 ID,并返回相应的机场(如果数据库中存在)。我们知道Airport模型有一个城市名称属性,因此我们可以将出发地和目的地城市名称以及航班号一起返回给控制器。这种将两个数据源合并的思考方式是我们尚未创建的FlightView类背后的理念。

12.2.2 将两个数据流合并到视图中

在 10.1.1 节中,我们讨论了视图。我们讨论了视图如何为我们提供一个窗口来查看模型,并从各种数据源中组合数据。在本节中,我们将创建FlightView类,并用来自Flight模型和Airport模型的数据填充它。

我们可以轻松地创建FlightView类。它是一个公共类,具有以下三个公共属性:

  • FlightNumber的类型为string

  • 包含OriginCity(类型string)和Code(类型string)的Airport对象

  • 包含DestinationCity(类型string)和Code(类型string)的Airport对象

FlightNumber的数据来自Flight模型,而Airport对象以及OriginCityDestinationCity属性的数据来自Airport模型,如图 12.6 所示。这些信息(对于数据库中的每架航班)是我们最终返回给 FlyTomorrow 在查询GET /Flight端点时所需的数据。

12_06.png

图 12.6 FlightView类将航班表中的FlightNumber数据与机场表中的城市和代码数据相结合。这使得我们能够向最终用户展示来自多个来源的精确信息。

为了保持整洁,让我们为FlightView类创建一个新的文件夹,命名为 Views。这个文件夹位于FlyingDutchmanAirlines项目中。尽管我们预计这个项目中不会有太多视图,但保持一定的组织性总是好的。

灯泡 结构体我们如何处理我们想要添加到FlightView中的这个Airport对象呢?当然,我们可以添加Airport的实例并忽略一些字段,但我觉得这样做有点过于强硬。这是一个使用结构体类型的绝佳机会。许多语言支持结构体或类,但 C#两者都支持。我们可以在 C#的上下文中将结构体视为轻量级的类,用于存储简单的信息。结构体比完整的类开销更小,所以当你只想存储少量信息时,使用结构体。

让我们在 FlightView.cs 文件中添加一个名为 AirportInfo 的结构体(注意:不是在 FlightView 类内部),如下面的代码示例所示。AirportInfo 类型应存储有关目的地 CityCode 的信息。我们可以使用 IATA 代替 Code 并反映数据库。然而,因为这是一个视图,我们可以根据它们是否更好地表示数据来更改事物的名称。IATA 是正确的,但 Code 对于不熟悉航空术语的人来说更容易理解。

public struct AirportInfo {
  public string City { get; set; }
  public string Code { get; set; }

  public AirportInfo((string city, string code) airport) {
    City = airport.city;
    Code = airport.code;
  }
}

AirportInfo 构造函数接受一个包含两个字段的元组:citycode。这让我们想到了使用结构体的第二个酷点:当向结构体添加构造函数时,你需要为每个属性分配一个值。在类中,你不需要为所有属性分配值,但结构体不适用这一点!如果我们有一个只分配 City 属性值的 AirportInfo 构造函数,编译器会发出警告。通过向结构体添加构造函数,你确保了相应结构体的完整设置。我们可以利用这一点来防止(有良好意图的)开发者没有完全初始化结构体。

回到 FlightView 类,我们也可以在其中的属性上做一些酷的事情。我们可以使用私有设置器来确保只有结构体内部的代码可以更改值。我们知道一旦我们从数据库中检索数据,我们就不需要更改数据,所以让我们尽可能让属性反映这一点。我们也不想让任何人随意进来尝试设置这些属性。

访问修饰符和自动属性:当使用自动属性时,我们可以为设置和获取属性使用不同的访问修饰符。

让我们看看 FlightView 类在具有分离的 getset 系统时的样子,如下所示:

public class FlightView {
  public string FlightNumber { get; private set; }
  public AirportInfo Origin { get; private set; }
  public AirportInfo Destination { get; private set; }
}

在这种情况下,只有可以通过它们的私有访问修饰符访问属性的代码可以设置新值,但 get 仍然是公共的。那么我们把这些值设置在哪里呢?何不在构造函数中设置?使用私有设置器的另一种方法是将其属性设置为 readonly,因为在构造函数中只能设置 readonly 属性。

让我们创建一个构造函数,它访问属性的私有设置器并接受参数来设置它们,如下所示:

public FlightView(string flightNumber, (string city, string code) origin, 
➥ (string city, string code) destination) {
  FlightNumber = flightNumber;

  Origin = new AirportInfo(origin);
  Destination = new AirportInfo(destination);
}

我们还应该在传入的参数上进行一些输入验证。我们可以使用 String.IsNullOrEmpty 方法来检查任何输入参数是否为空指针或空字符串。或者,你可以使用 String.IsNullOrWhitespace,它检查字符串是否为空、空字符串或仅由空白字符组成。如果是这样,我们将它们设置为适当的值。我们同样使用三元条件运算符,如下所示:

public class FlightView {
  public string FlightNumber { get; private set; }
  public AirportInfo Origin { get; private set; }
  public AirportInfo Destination { get; private set; }

  public FlightView(string flightNumber, 
➥ (string city, string code) origin,
➥ (string city, string code) destination) {
    FlightNumber = string.IsNullOrEmpty(flightNumber) ? 
     ➥ "No flight number found" : flightNumber;

    Origin = new AirportInfo(origin);
    Destination = new AirportInfo(destination);
  }
}

public struct AirportInfo {
  public string City { get; private set; }
  public string Code { get; private set; }

  public AirportInfo ((string city, string code) airport) {
    City = string.IsNullOrEmpty(airport.city) ? 
➥ "No city found" : airport.city;
    Code = string.IsNullOrEmpty(airport.code) ? 
➥ "No code found" : airport.code;
  }
}

注意:技术上,我们可以将FlightNumberOriginDestinationCityCode属性设置为“只读”的,并且完全移除私有的设置器。编译器足够聪明,能够意识到我们想在构造函数中私有地设置属性。不过,我喜欢私有设置器的详尽性。你的体验可能会有所不同。

当然,我们也应该创建一个测试类和一些单元测试来验证FlightView构造函数的逻辑。图 12.7 显示了新创建的文件。

图 12.7 创建了两个新文件:在FlyingDutchmanAirlines/Views中的FlightView和在FlyingDutchmanAirlines_Tests/Views中的FlightViewTests。将类存储在单独的Views文件夹中有助于我们组织代码库。

在测试构造函数方面有不同的观点。有些人说测试构造函数只是测试新对象的实例化,因此是测试语言特性。其他人说测试构造函数是有用的,因为你永远不知道代码会发生什么。我属于后一种观点。特别是当测试构造函数代表最小努力时,拥有如以下代码所示的测试套件来支持你的代码是正确的做法:

[TestClass]
public class FlightViewTests {
  [TestMethod]
  public void Constructor_FlightView_Success() { 
    string flightNumber = "0";
    string originCity = "Amsterdam";
    string originCityCode = "AMS";
    string destinationCity = "Moscow";
    string destinationCityCode = "SVO";

    FlightView view = 
➥ new FlightView(flightNumber, (originCity, originCityCode), 
➥ (destinationCity, destinationCityCode));
    Assert.IsNotNull(view);

    Assert.AreEqual(view.FlightNumber, flightNumber);
    Assert.AreEqual(view.Origin.City, originCity);
    Assert.AreEqual(view.Origin.Code, originCityCode);
    Assert.AreEqual(view.Destination.City, destinationCity);
    Assert.AreEqual(view.Destination.Code, destinationCityCode);
    }

    [TestMethod]
    public void Constructor_FlightView_Success_FlightNumber_Null() {
      string originCity = "Athens";
      string originCityCode = "ATH";
      string destinationCity = "Dubai";
      string destinationCityCode = "DXB";
      FlightView view = 
➥ new FlightView(null, (originCity, originCityCode), 
➥ (destinationCity, destinationCityCode));
      Assert.IsNotNull(view);

      Assert.AreEqual(view.FlightNumber, "No flight number found");
      Assert.AreEqual(view.Origin.City, originCity);
      Assert.AreEqual(view.Destination.City, destinationCity);
    }

    [TestMethod]
    public void Constructor_AirportInfo_Success_City_EmptyString() {
      string destinationCity = string.Empty;
      string destinationCityCode = "SYD";

      AirportInfo airportInfo = 
➥ new AirportInfo((destinationCity, destinationCityCode));
      Assert.IsNotNull(airportInfo);

      Assert.AreEqual(airportInfo.City, "No city found");
      Assert.AreEqual(airportInfo.Code, destinationCityCode);
    }

    [TestMethod]
    public void Constructor_AirportInfo_Success_Code_EmptyString() {
      string destinationCity = "Ushuaia";
      string destinationCityCode = string.Empty;

      AirportInfo airportInfo = 
➥ new AirportInfo((destinationCity, destinationCityCode));
      Assert.IsNotNull(airportInfo);

      Assert.AreEqual(airportInfo.City, destinationCity);
      Assert.AreEqual(airportInfo.Code, "No code found");
    }
} 

现在我们可以放心了,无论FlightView类和AirportInfo结构体中的代码发生什么变化,我们都有测试来捕捉任何破坏现有功能的变化。现在我们可以继续为从FlightRepository获取的每趟航班填充FlightView。对于FlightView所需的五项数据(航班号、目的地城市、目的地代码、出发城市和出发代码),我们知道如何获取航班号。我们只需要调用FlightRepository.GetFlights方法。当然,我们首先需要在FlightService中有一个GetFlights方法。

GetFlights方法返回一个被IAsyncEnumerable包装的FlightView实例。我们之前讨论了IEnumerable以及如何使用yield return关键字。IAsyncEnumerable返回类型允许我们返回一个实现IEnumerable接口的异步集合。因为它已经是异步的,所以我们不需要将其包装在Task中。

首先,让我们调用FlightRepository.GetFlights方法,并为数据库返回的每趟航班构造一个FlightView,如下一列表所示。为此,我们还需要将FlightRepository的一个实例注入到FlightService类中。这个任务留给你们。你们知道该怎么做。如果你卡住了,请参阅提供的源代码。注意,列表 12.3 中的代码无法编译,正如列表之后所解释的。

列表 12.3 FlightService.GetFlights请求数据库中的所有航班

public async Task<IAsyncEnumerable<FlightView>> GetFlights() {
  Queue<Flight> flights = _flightRepository.GetFlights();       ❶
  foreach (Flight flight in flights) {                          ❷
    FlightView view = 
➥ new FlightView(flight.FlightNumber.ToString(), ,);           ❸
  }
}

❶ 请求数据库中的所有航班

❷ 遍历返回的航班

❸ 为每趟航班创建一个FlightView实例

花一分钟时间阅读列表 12.3,看看你是否能找出为什么这段代码无法编译(除了没有返回正确的类型)。你看到了吗?编译器抛出一个错误,因为我们没有为每个航班实例化 FlightView 对象时提供足够的参数。我们甚至没有提供正确的信息来给出视图。视图希望我们传递航班号、出发城市和目的城市的值。我们传递了航班号,但没有传递任何城市。我们拥有的最接近城市名称的东西是返回的 Flight 对象上的 originAirportIDdestinationAirportID 属性。我们知道如何获取这些属性并获取机场城市名称和代码:我们调用 AirportRepository.GetAirportByID 方法并获取 Airport.City 属性(我们还需要一个注入的 AirportRepository 实例),如下所示:

public async IAsyncEnumerable<FlightView> GetFlights() {
     Queue<Flight> flights = _flightRepository.GetFlights();
  foreach (Flight flight in flights) {
  Airport originAirport = 
➥ await _airportRepository.GetAirportByID(flight.Origin);
  Airport destinationAirport = 
➥ await _airportRepository.GetAirportByID(flight.Destination);

  FlightView view = 
➥ new FlightView(flight.FlightNumber.ToString(), 
  ➥ (originAirport.City, originAirport.Code), 
  ➥ (destinationAirport.City, destinationAirport.Code)); 
  }
}

现在,这里就是真正的魔法发生的地方。因为我们返回的是 IAsyncEnumerable<FlightView> 类型,我们可以使用 yield return 关键字自动将创建的 FlightView 实例添加到编译器生成的列表中,如下所示:

public async IAsyncEnumerable<FlightView> GetFlights() {
     Queue<Flight> flights = _flightRepository.GetFlights();
  foreach (Flight flight in flights) {
  Airport originAirport = 
➥ await _airportRepository.GetAirportByID(flight.Origin);
  Airport destinationAirport = 
➥ await _airportRepository.GetAirportByID(flight.Destination);

  yield return new FlightView(flight.FlightNumber.ToString(), 
➥ (originAirport.City, originAirport.Code), 
➥ (destinationAirport.City, destinationAirport.Code)); 
  }
}

我们还应该在 FlightServiceTests 中添加一个单元测试来验证我们是否做得很好。记住,当我们测试服务层方法时,我们不需要测试仓库层。相反,我们可以使用 Mock<FlightRepository>Mock<AirportRepository> 的实例作为注入到 FlightService 类中的依赖项。为了模拟 AirportRepository 类,将适当的方法设置为虚拟的,并添加一个无参构造函数,如下所示。我们已经这样做了几次,所以我就留给你了。

列表 12.4 单元测试返回 IAsyncEnumerable<T> 的方法

[TestMethod]
public async Task GetFlights_Success() {
  Flight flightInDatabase = new Flight {                           ❶
    FlightNumber = 148,                                            ❶
    Origin = 31,                                                   ❶
    Destination = 92                                               ❶
  };                                                               ❶

  Queue<Flight> mockReturn = new Queue<Flight>(1);                 ❶
  mockReturn.Enqueue(flightInDatabase);                            ❶

  _mockFlightRepository.Setup(repository =>                        ❶
➥ repository.GetFlights()).Returns(mockReturn);                   ❶

  _mockAirportRepository.Setup(repository => 
➥ repository.GetAirportByID(31)).ReturnsAsync(new Airport         ❷
    {                                                              ❷
      AirportId = 31,                                              ❷
      City = "Mexico City",                                        ❷
      Iata = "MEX"                                                 ❷
    });                                                            ❷

  _mockAirportRepository.Setup(repository =>                       ❷
➥ repository.GetAirportByID(92)).ReturnsAsync(new Airport         ❷
    {                                                              ❷
      AirportId = 92,                                              ❷
      City = "Ulaanbaataar",                                       ❷
      Iata = "UBN"                                                 ❷
    });                                                            ❷
  FlightService service = new FlightService(_mockFlightRepository.Object, 
➥ _mockAirportRepository.Object);                                 ❸

  await foreach (FlightView flightView in service.GetFlights()) {  ❹
    Assert.IsNotNull(flightView);                                  ❺
    Assert.AreEqual(flightView.FlightNumber, "148");               ❺
    Assert.AreEqual(flightView.Origin.City, "Mexico City");        ❺
    Assert.AreEqual(flightView.Origin.Code, "MEX");                ❺
    Assert.AreEqual(flightView.Destination.City, "Ulaanbaatar");   ❺
    Assert.AreEqual(flightView.Destination.Code, "UBN");           ❺
  }
}

❶ 设置 FlightRepository.GetAllFlights 模拟返回

❷ 设置 AirportRepository.GetAirportByID 模拟返回

❸ 注入模拟依赖项,并创建一个新的 FlightService 实例

❹ 在 GetFlights 方法中(本例中为一个)构建 flightViews

❺ 确保我们收到了正确的 flightView

在列表 12.4 中,我们第一次看到了如何使用返回的 IAsyncEnumerable 类型,并可以拼凑出为什么它是一个如此出色的功能。我们不是一次调用 FlightService.GetFlights 方法,等待所有数据返回,然后再对其进行操作,IAsyncEnumerable 类型允许我们在 foreach 循环上 await,并随着数据的到来对其进行操作。

12.2.3 使用 try-catch 代码块与 yield return 关键字

在 12.2.2 节中,我们实现了FlightService.GetFlights方法。然而,我们没有处理来自AirportRepository.GetAirportByID方法的任何异常。不幸的是,我们无法简单地添加一个try-catch代码块并将整个方法包裹在其中,因为我们不能在这样一个代码块中使用yield return关键字。不允许在try-catch块中使用yield语句一直是 C#语言社区讨论的焦点。因为允许在仅包含try代码块(没有catch)中添加yield语句,而添加对try-catch代码块yield语句支持的唯一障碍是由于垃圾回收困难而增加的编译器复杂性,我们可能会看到这个功能在未来被添加。解决方案是在try-catch块中仅添加对AirportRepository.GetAirportByID方法的调用,这样我们就可以捕获任何传出的异常,然后像往常一样继续操作,如下所示:

public async IAsyncEnumerable<FlightView> GetFlights() {
     Queue<Flight> flights = _flightRepository.GetFlights();
  foreach (Flight flight in flights) {
    Airport originAirport;
    Airport destinationAirport;

    try {
      originAirport = 
➥ await _airportRepository.GetAirportByID(flight.Origin);
        destinationAirport = 
➥ await _airportRepository.GetAirportByID(flight.Destination);
    } catch (FlightNotFoundException) {
      throw new FlightNotFoundException();
    } catch (Exception) {
      throw new ArgumentException();
    }

    yield return new FlightView(flight.FlightNumber.ToString(), 
➥ (originAirport.City, originAirport.Code), 
➥ (destinationAirport.City, destinationAirport.Code)); 
  }
}

注意:我们已经看到了IAsyncEnumerableTask<IEnumerable>作为返回类型。IAsyncEnumerable在从异步方法返回时不需要包裹在Task<T>中,因为IAsyncEnumerable本身就是异步的。使用具有泛型Task<T>的类型允许我们从异步方法返回同步类型。

此代码允许我们捕获来自AirportRepository.GetAirportByID方法的任何异常。如果服务类发现存储库方法抛出了类型为FlightNotFoundException的异常,它将抛出一个新的FlightNotFoundException实例。如果代码抛出不同类型的异常,将进入第二个catch块,并抛出ArgumentException。调用服务层的控制器处理此异常。

我们服务层实现中的最后一部分是编写一个单元测试来验证我们刚刚编写的异常处理代码。让我们看看下面的单元测试。它应该相当直接。

列表 12.5 在FlightService中测试异常

[TestMethod]
[ExpectedException(typeof(FlightNotFoundException))]          ❶
public async Task GetFlights_Failure_RepositoryException() {
  Flight flightInDatabase = new Flight {                      ❷
    FlightNumber = 148,                                       ❷
    Origin = 31,                                              ❷
    Destination = 92                                          ❷
  };                                                          ❷

  Queue<Flight> mockReturn = new Queue<Flight>(1);            ❷
  mockReturn.Enqueue(flightInDatabase);                       ❷

  _mockFlightRepository.Setup(repository =>                   ❷
➥ repository.GetFlights()).Returns(mockReturn);              ❷

  _mockAirportRepository.Setup(repository => 
➥ repository.GetAirportByID(31))
➥ .ThrowsAsync(new FlightNotFoundException());               ❸

  FlightService service = new FlightService(_mockFlightRepository.Object, 
➥ _mockAirportRepository.Object);                            ❹

  await foreach (FlightView _ in service.GetFlights()) {      ❺
    ;                                                         ❻
  }
}

[TestMethod]
[ExpectedException(typeof(ArgumentException))]                ❼
public async Task GetFlights_Failure_RegularException() {
  Flight flightInDatabase = new Flight {                      ❽
    FlightNumber = 148,                                       ❽
    Origin = 31,                                              ❽
    Destination = 92                                          ❽
  };                                                          ❽

  Queue<Flight> mockReturn = new Queue<Flight>(1);            ❽
  mockReturn.Enqueue(flightInDatabase);                       ❽

  _mockFlightRepository.Setup(repository =>                   ❽
➥ repository.GetFlights()).Returns(mockReturn);              ❽

  _mockAirportRepository.Setup(repository => 
➥ repository.GetAirportByID(31))
➥ .ThrowsAsync(new NullReferenceException());                ❾

  FlightService service = new FlightService(_mockFlightRepository.Object, 
➥ _mockAirportRepository.Object);                            ❿

  await foreach (FlightView _ in service.GetFlights()) {      ⓫
    ;                                                         ❻
  }
}

❶ 预期此测试中执行的逻辑会抛出异常

❷ 从 FlightRepository.GetAllFlights 模拟返回值开始(与列表 12.4 相同)

❸ 设置 AirportRepository.GetAirportByID 模拟返回值(与列表 12.4 相同)

❹ 创建一个新的 FlightService 实例(与列表 12.4 相同)

❺ 调用 GetFlights 方法,使用丢弃操作符进行返回赋值

❻ 空语句

❼ 预期此测试中执行的逻辑会抛出异常

❽ 从 FlightRepository.GetAllFlights 模拟返回值开始(与列表 12.4 相同)

❾ 设置 AirportRepository.GetAirportByID 模拟返回值(与列表 12.4 相同)

❿ 创建一个新的 FlightService 实例(与列表 12.4 相同)

⓫ 调用 GetFlights 方法,使用丢弃操作符进行返回赋值

总体来说,列表 12.5 中的代码应该不会带来任何挑战。值得注意的是,通过在 foreach 中使用 discard 操作符,我们告诉其他开发者我们不需要使用返回的值。同样,在 foreach 循环内部,我们添加了一个空语句(;)。这绝对没有任何作用,但提供了更易读的代码。通过添加空语句,我们表明在 foreach 循环内部没有逻辑并不是一个错误。

我们可以进行一些进一步的清理:我确信你已经注意到了,在两个单元测试中,Mock<Flight>Mock<Airport> 实例的设置代码是相同的。因为这也违反了 DRY 原则,我们应该重构这两个单元测试,并在 TestInitialize 方法中完成这个初始化。这将显著缩短我们的测试方法,如下所示:

[TestClass]
public class FlightServiceTests {
  private Mock<FlightRepository> _mockFlightRepository;
  private Mock<AirportRepository> _mockAirportRepository;

  [TestInitialize]
  public void Initialize() {
    _mockFlightRepository = new Mock<FlightRepository>();
    _mockAirportRepository = new Mock<AirportRepository>();

    Flight flightInDatabase = new Flight {
      FlightNumber = 148,
      Origin = 31,
      Destination = 92
    };

    Queue<Flight> mockReturn = new Queue<Flight>(1);
    mockReturn.Enqueue(flightInDatabase);

    _mockFlightRepository.Setup(repository => 
➥ repository.GetFlights()).Returns(mockReturn);
  }

  [TestMethod]
  public async Task GetFlights_Success() {
    _mockAirportRepository.Setup(repository => 
➥ repository.GetAirportByID(31)).ReturnsAsync(new Airport
    {
      AirportId = 31,
      City = "Mexico City",
      Iata = "MEX"
    });

    _mockAirportRepository.Setup(repository => 
➥ repository.GetAirportByID(92)).ReturnsAsync(new Airport
    {
      AirportId = 92,
      City = "Ulaanbaatar",
      Iata = "UBN"
    });

    FlightService service = 
➥ new FlightService(_mockFlightRepository.Object, 
➥ _mockAirportRepository.Object);

    await foreach (FlightView flightView in service.GetFlights()) {
      Assert.IsNotNull(flightView);
      Assert.AreEqual(flightView.FlightNumber, "148");
      Assert.AreEqual(flightView.Origin.City, "Mexico City");
      Assert.AreEqual(flightView.Origin.Code, "MEX");
      Assert.AreEqual(flightView.Destination.City, "Ulaanbaatar");
      Assert.AreEqual(flightView.Destination.Code, "UBN");
    }
  }

  [TestMethod]
  [ExpectedException(typeof(FlightNotFoundException))]
  public async Task GetFlights_Failure_RepositoryException() {
    _mockAirportRepository.Setup(repository => 
➥ repository.GetAirportByID(31)).ThrowsAsync(new Exception());

    FlightService service = 
➥ new FlightService(_mockFlightRepository.Object, 
➥ _mockAirportRepository.Object);
    await foreach (FlightView _ in service.GetFlights()) {
      ;
    }
  }
}

这样,GetFlights 方法就完成了!

12.2.4 实现 GetFlightByFlightNumber

剩下的只是添加一个类似的方法,当给定一个航班号时,只检索单个航班的详细信息。这些模式现在应该对你来说非常熟悉,如下所示:

public virtual async Task<FlightView> 
➥ GetFlightByFlightNumber(int flightNumber) {
  try {
    Flight flight = await 
➥ _flightRepository.GetFlightByFlightNumber(flightNumber);
    Airport originAirport = await 
➥ _airportRepository.GetAirportByID(flight.Origin);
    Airport destinationAirport = await 
➥ _airportRepository.GetAirportByID(flight.Destination);

    return new FlightView(flight.FlightNumber.ToString(),
    ➥ (originAirport.City, originAirport.Iata),
    ➥ (destinationAirport.City, destinationAirport.Iata));
  } catch (FlightNotFoundException) {
    throw new FlightNotFoundException();
  } catch (Exception) {
    throw new ArgumentException();
  }
}

我们还应该添加一些单元测试来验证我们能否从数据库中获取正确的航班并处理 FlightNotFoundExceptionException 错误路径。为此,我们首先必须向 TestInitalize 方法添加一个新的设置调用。我们的模拟在调用 FlightRepository.GetFlightByFlightNumber 时目前不返回任何数据。让我们按照以下方式修复它:

[TestInitialize]
public void Initialize()  {
  ...

  _mockFlightRepository.Setup(repository => 
➥ repository.GetFlights()).Returns(mockReturn);
  _mockFlightRepository.Setup(repository => 
➥ repository.GetFlightByFlightNumber(148))
➥ .Returns(Task.FromResult(flightInDatabase));
}

当模拟的 GetFlightByFlightNumber 返回数据时,我们返回之前创建的航班实例。有了这个,我们可以添加 GetFlightByFlightNumber_Success 测试用例,如下所示:

[TestMethod]
public async Task GetFlightByFlightNumber_Success() {
  _mockAirportRepository.Setup(repository => 
➥ repository.GetAirportByID(31)).ReturnsAsync(new Airport
      {
      AirportId = 31,
      City = "Mexico City",
      Iata = "MEX"
    });

  _mockAirportRepository.Setup(repository => 
➥ repository.GetAirportByID(92)).ReturnsAsync(new Airport
    {
      AirportId = 92,
      City = "Ulaanbaatar",
      Iata = "UBN"
    });

  FlightService service = new FlightService(_mockFlightRepository.Object, 
➥ _mockAirportRepository.Object);
  FlightView flightView = await service.GetFlightByFlightNumber(148);

  Assert.IsNotNull(flightView);
  Assert.AreEqual(flightView.FlightNumber, "148");
  Assert.AreEqual(flightView.Origin.City, "Mexico City");
  Assert.AreEqual(flightView.Origin.Code, "MEX");
  Assert.AreEqual(flightView.Destination.City, "Ulaanbaatar");
  Assert.AreEqual(flightView.Destination.Code, "UBN");
}

单元测试相当简单。我们模仿(即:复制粘贴)了机场设置代码,因此添加了一个航班用于内存数据库中。然后我们调用 FlightService.GetFlightByFlightNumber 来检查我们的服务层逻辑。最后,我们验证了返回的数据。现在,当你从复制粘贴的代码中看到 GetFlights_Success 单元测试中的机场设置时,你的心中应该开始响起警钟。显然,这种重复是严重违反 DRY 原则的,我们应该重构测试类,在 TestInitialize 方法中完成这个数据库设置,如下所示:

[TestInitialize]
public void Initialize() {
  _mockFlightRepository = new Mock<FlightRepository>();
  _mockAirportRepository = new Mock<AirportRepository>();

  _mockAirportRepository.Setup(repository => 
➥ repository.GetAirportByID(31)).ReturnsAsync(new Airport
    {
      AirportId = 31,
      City = "Mexico City",
      Iata = "MEX"
    });

  _mockAirportRepository.Setup(repository => 
➥ repository.GetAirportByID(92)).ReturnsAsync(new Airport
    {
      AirportId = 92,
      City = "Ulaanbaatar",
      Iata = "UBN"
    });

  ...
}

这显著缩短了 GetFlights_SuccessGetFlightByFlightNumber_Success 单元测试,如下所示:

[TestMethod]
public async Task GetFlights_Success() {
  _mockAirportRepository.Setup(repository => 
➥ repository.GetAirportByID(31)).ReturnsAsync(new Airport
    {
      AirportId = 31,
      City = "Mexico City",
      Iata = "MEX"
    });

  _mockAirportRepository.Setup(repository => 
➥ repository.GetAirportByID(92)).ReturnsAsync(new Airport
    {
      AirportId = 92,
      City = "Ulaanbaatar",
      Iata = "UBN"
    });

  FlightService service = new FlightService(_mockFlightRepository.Object, 
➥ _mockAirportRepository.Object);

  await foreach (FlightView flightView in service.GetFlights()) {
    Assert.IsNotNull(flightView);
    Assert.AreEqual(flightView.FlightNumber, "148");
    Assert.AreEqual(flightView.Origin.City, "Mexico City");
    Assert.AreEqual(flightView.Origin.Code, "MEX");
    Assert.AreEqual(flightView.Destination.City, "Ulaanbaatar");
    Assert.AreEqual(flightView.Destination.Code, "UBN");
  }
}

[TestMethod]
public async Task GetFlightByFlightNumber_Success() {
  _mockAirportRepository.Setup(repository => 
➥ repository.GetAirportByID(31)).ReturnsAsync(new Airport
    {
      AirportId = 31,
      City = "Mexico City",
      Iata = "MEX"
    });

  _mockAirportRepository.Setup(repository => 
➥ repository.GetAirportByID(92)).ReturnsAsync(new Airport
    {
      AirportId = 92,
      City = "Ulaanbaatar",
      Iata = "UBN"
    });

  FlightService service = new FlightService(_mockFlightRepository.Object, 
➥ _mockAirportRepository.Object);
  FlightView flightView = await service.GetFlightByFlightNumber(148);

  Assert.IsNotNull(flightView);
  Assert.AreEqual(flightView.FlightNumber, "148");
  Assert.AreEqual(flightView.Origin.City, "Mexico City");
  Assert.AreEqual(flightView.Origin.Code, "MEX");
  Assert.AreEqual(flightView.Destination.City, "Ulaanbaatar");
  Assert.AreEqual(flightView.Destination.Code, "UBN");
}

当然,所有单元测试仍然通过。这让我们有信心知道我们没有破坏任何东西。让我们为 GetFlightByFlightNumber 方法添加一些失败案例单元测试,然后我们就可以结束了。

从服务层抛出类型为 FlightNotFoundException 的异常的失败路径开始,我们期望服务层会再次抛出这样的异常,如下所示:

[TestMethod]
[ExpectedException(typeof(FlightNotFoundException))]
public async Task 
➥ GetFlightByFlightNumber_Failure_RepositoryException
➥ _FlightNotFoundException() {
  _mockFlightRepository.Setup(repository => 
➥ repository.GetFlightByFlightNumber(-1))
➥ .Throws(new FlightNotFoundException());
  FlightService service = new FlightService(_mockFlightRepository.Object, 
➥ _mockAirportRepository.Object);

  await service.GetFlightByFlightNumber(-1);
}

GetFlightByFlightNumber_Failure_RepositoryException_Exception 单元测试再次看到了我们熟悉的老朋友 ExpectedException 方法属性。现在我们已经非常清楚它的有用性,并在单元测试中使用它来检查下一个(也是最后一个)异常路径:仓库层抛出除 FlightNotFoundException 之外任何类型的异常。FlightService.GetFlightByFlightNumber 方法捕获抛出的异常并抛出一个新的 ArgumentException。或者至少它说是这样。让我们看看它实际上是否真的这样做:

[TestMethod]
[ExpectedException(typeof(ArgumentException))]
public async Task 
➥ GetFlightByFlightNumber_Failure_RepositoryException_Exception() {
  _mockFlightRepository.Setup(repository => 
➥ repository.GetFlightByFlightNumber(-1))
➥ .Throws(new OverflowException());
  FlightService service = new FlightService(_mockFlightRepository.Object, 
➥ _mockAirportRepository.Object);

  await service.GetFlightByFlightNumber(-1);
}

GetFlightByFlightNumber_Failure_RepositoryException_Exception 单元测试指示 Mock<FlightRepository> 在我们调用 FlightRepository.GetFlightByFlightNumber 并传入输入参数 -1 时抛出 OverflowException 类型的异常。在这里,我们可以使用任何异常类,因为它们都派生自基类 Exception,这正是方法中的 catch 块所寻找的。这也是为什么测试名称在异常类型方面没有更具体的原因。我们正在测试如果抛出任何类型的 Exception 会发生什么逻辑,而不是特定的一个。因为 Exception 是所有异常的基类,所以我们只需测试它。

溢出和下溢(已检查和未检查)

当你将两个整数相加时,你会得到什么?比如说 2147483647 和 1?你会得到一个负数。同样,当你从 -2147483647 减去 1 时,你会得到一个正数。这就是我们所说的溢出和下溢。当你超过原始类型的最大值或低于原始类型的最低值时,你会得到一个“环绕”的值。为什么会这样,我们如何才能防止这种情况发生?

当一个类型中可用的二进制位不足以表示你请求的值时,该类型会环绕并翻转(如果它是一个无符号整数)。这取决于上下文,我们称之为溢出和下溢。例如(尽管这是一个简化的例子):整数是一个四字节的数据类型。这意味着我们有 32 位可以操作(一个字节包含八个位,8 × 4 = 32)。因此,如果我们声明一个变量,将所有 32(如果是有符号整数则为 31)位设置为“开启”值,我们就有了在 32 位(或四字节)类型中可以表示的最大值(在 C# 中,我们可以在代码中直接使用十进制、十六进制或二进制表示;这是二进制表示):

int maxVal = 0b11111111_11111111_11111111_1111111;
int oneVal = 0b00000000_00000000_00000000_0000001;

int overflow = maxVal + oneVal;

在 C# 中,当使用直接二进制表示时,你必须使用 0b 或 0B(对于十六进制,使用 0x 或 0X)来前缀你的值。你可以选择,如代码片段所示,在二进制表示中包含下划线以提高可读性。我们使用这些前缀是为了让编译器知道如何处理这些值。在这个代码片段中,我们做了相当于将 1 加到最大值 2147483647 上的操作。那么,溢出变量解析成什么?它解析为 –2147483648。如果我们从那个值中减去 1,我们就会得到一个正值:2147483647。通常,当你知道你正在处理超过特定类型容量的值时,你会使用不同的类型。例如,你可能使用 long 而不是整数,或者使用 BigInteger 而不是 long。但是,如果你因为某种原因被限制在特定类型上,同时又能看到溢出和下溢作为一个现实场景,该怎么办呢?

灯泡 BIGINTEGER 是一个不可变、非原始的“类型”,随着你的数据增长而增长,并且实际上仅受限于你的内存。BigInteger 起整数的作用,但实际上是一个巧妙设计的结构。Java 开发者可能熟悉 BigInteger

C# 提供了一个关键字和编译模式,可以在一定程度上防止意外的溢出和下溢:checked。默认情况下,C# 以 unchecked 模式编译。这意味着 CLR 在算术溢出和下溢时不会抛出任何异常。这对于大多数用例来说是可以的,因为我们有一些额外的开销来检查这种可能性,而且在很多程序中这种情况并不常见。但是,如果我们使用 checked 模式,当 CLR 检测到下溢或溢出时,它会抛出一个异常。要使用 checked 模式,我们可以通过在构建指令中添加 -checked 编译器选项来编译整个代码库,或者我们可以使用 checked 关键字。

要让 CLR 在特定代码块中看到下溢或溢出时抛出异常,我们可以将代码包裹在一个 checked 块中,如下所示:

checked {
  int maxVal = 0b_11111111_11111111_11111111_1111111;
  int oneVal = 0b_00000000_00000000_00000000_0000001;

  int overflow = maxVal + oneVal;
}

现在,当我们添加 maxValoneVal 变量时,CLR 会抛出一个 OverflowException!因此,如果你在 checked 模式下编译了整个代码库,你可以使用 unchecked 代码块来告诉 CLR 不要为该代码块的范围内抛出任何 OverflowExceptions

那就是服务层类的全部内容了。我希望你学到了一些有价值的东西,如果没有,书的结尾就在眼前。在第十三章,我们将探讨实现控制层和集成测试。

练习

练习 12.1

正误判断?对于端点 GET /Band/Song,我们需要实现 BandService 类。

练习 12.2

正误判断?对于端点 POST /Inventory/SKU,我们需要实现 SKUService 类。

练习 12.3

以下哪个最能描述与 Queue<T> 数据结构的交互?

a. 先入后出(FILO)

b. 先入先出(FIFO)

c. 后入先出(LIFO)

d. 后入后出(LILO)

练习 12.4

如果我们在一个返回类型为 IEnumerable<T> 的方法中嵌入的 foreach 循环中使用 yield return 关键字,我们期望从方法中返回什么值?

a. 一个实现 IEnumerable 接口且包含 foreach 循环中所有数据的集合。

b. 一个实现 IEnumerable 接口且只包含 foreach 循环中要处理的第一条数据的集合。

c. 一个未实现 IEnumerable 接口且返回原始集合引用的集合。

练习 12.5

想象有一个名为 Food 的类,它有一个布尔属性 IsFruit。这个属性有一个公共的获取器和受保护的设置器。从 Food 类派生的 Dragonfruit 类能否设置 IsFruit 的值?

练习 12.6

这会评估成什么?string.IsNullOrEmpty(string.empty``);

练习 12.7

这会评估成什么?string.IsNullOrWhitespace(" ");

练习 12.8

对或错?如果你向结构体添加一个构造函数,你只能设置一个属性。其他属性必须保持未设置状态。

摘要

  • 为了确定我们是否需要实现特定的服务,我们可以查看所需的 API 端点。如果没有必要为特定模型创建控制器,那么我们也不需要为该模型创建服务。这可以避免我们实现不必要的代码。

  • Queue<T> 是一个“先进先出”(FIFO)的数据结构。当我们想要保持顺序并像处理排队的人一样处理信息时,队列非常有用。第一个到达的是第一个被处理的,或者说,“早起的鸟儿有虫吃。”

  • 如果我们在一个返回类型为 IEnumerable<T> 的方法中嵌入的 foreach 循环中使用 yield return 关键字,我们可以异步返回一个 IEnumerable<T> 实现。这可以使我们的代码更易于阅读和简洁。

  • 结构体可以被看作是一个“轻量级”类。我们经常使用它们来存储少量信息,并且在结构体中通常不会进行数据处理。结构体是向我们的开发者伙伴表明这段代码作为数据存储设备的好方法。

  • 当向结构体添加构造函数时,编译器要求我们为结构体中的每个属性分配一个值。这是为了防止结构体只部分初始化,并阻止我们意外忘记设置值。

  • 在自动属性中,我们可以为获取器和设置器使用不同的访问修饰符。这允许我们创建一个可以公开访问但只能在相应类内部设置的属性(私有)。允许任何访问修饰符的组合。因为封装通常是我们的目标,通过使用这些访问修饰符,我们可以更好地控制封装的故事。

  • 我们只能在声明或构造函数中设置 readonly 值。因为我们只能设置一次 readonly 值,而声明一个字段意味着编译器会自动为其在内存中的位置分配一个默认值,所以我们需要在尽可能早的时刻设置它。readonly 字段可以大大减少他人对我们代码的数据操作量。

  • 通过使用 IAsyncEnumerable<T> 以及 yield return 关键字,我们可以创建异步等待数据并按接收数据时处理数据的代码。这在处理外部交互,例如数据库查询时非常有用。

  • 当我们尝试表示一个需要比特定类型可访问的位数更多的值时,会发生溢出和下溢。当这种情况发生时,变量的值会突然变得不正确,这可能会产生意外的副作用。

  • 默认情况下,C# 代码使用 unchecked 模式编译。这意味着 CLR 在遇到溢出或下溢时不会抛出 OverflowException。同样,checked 模式意味着 CLR 会抛出这样的异常。

  • 我们可以使用 checkedunchecked 代码块来按代码块更改编译模式。当想要控制异常故事时,这非常有用。

  • 在 C# 中,我们可以用十进制、十六进制或二进制表示整数。当使用十六进制时,我们需要在值前加上 0x 或 0X。对于二进制表示,使用 0b 或 0B。这些不同的表示方式使我们能够选择最适合代码可读性的表示方法。

第六部分 控制器层

在第五部分,我们创建了服务层类。这些类是存储库和控制器架构层之间的粘合剂。我们还探讨了运行时类型检查和IAsyncEnumerable<T>的使用。在本部分,我们将通过实现控制器层类来完成对飞荷兰人航空公司服务的重写。其他考虑的主题包括 ASP.NET 中间件、自定义模型绑定以及 JSON 序列化/反序列化。

13 中间件、HTTP 路由和 HTTP 响应

本章节涵盖了

  • 将 HTTP 请求路由到控制器和端点

  • 使用 HttpAttribute 方法属性声明 HTTP 路由

  • 使用中间件注入依赖

  • 使用 IActionResult 接口返回 HTTP 响应

我们几乎到达了旅程的终点。在过去的章节中,我们实现了数据库访问层、仓库层和服务层。我们的服务几乎实现了,但尚未可供 FlyTomorrow(我们的客户)使用。为了与我们的服务交互,我们需要提供接受 HTTP 请求并启动必要处理的控制器。

在 13.1 节中,我们将讨论控制器在我们仓库/服务架构中的位置。随后,在 13.2 节中,我们将确定需要实现哪些控制器。在接下来的章节中,我们将开始实现 FlightController(13.3 节)并探讨如何将 HTTP 请求路由到我们的端点(13.4 节)。

图 13.1 展示了我们在本书架构中的位置。

图 13.1

图 13.1 在前面的章节中,我们实现了数据库访问、仓库和服务层。在本章中,我们将开始实现我们服务所需的所有控制器。

在本章节之后,我们只需再有一章就能拥有一个完全实现且遵循 FlyTomorrow 给出的 API 规范的服务。在下一章中,我们将通过封装我们的控制器并使用 Swagger 进行验收测试来完成工作,以证明我们正确地完成了工作。

13.1 仓库/服务模式中的控制器类

在 5.2.4 节中,我向您介绍了仓库/服务模式。我们一直在本书中使用这个模式,成功地实现了新的 FlyingDutchmanAirlines 服务。但现在我们到了控制器层,你可能想知道控制器如何融入这个模式?毕竟,这是仓库/服务模式,而不是控制器/服务/仓库模式。

有时候一个名称可能会误导人。我最痛恨的事情之一就是当某个东西(一个方法名称或一个架构)被错误或不完整地命名。不幸的是,我没有命名这个模式,但如果我命名了,它将是“控制器/仓库/服务模式”。嘿,也许甚至是控制器/仓库/服务/数据库层模式,但这更让人难以吞咽。那么控制器层在仓库/服务模式中是如何定位的呢?

快速回答是:控制器通常是仓库/服务模式中面向公众、最顶层的一层。这并不奇怪:控制器通常是服务最顶层,因为它通常是唯一暴露给客户端的点,如图 13.2 所示。外部系统的例子包括 FlyTomorrow 网站、一个请求信息以进行进一步处理的微服务,或者一个尝试加载数据库信息的桌面应用程序。任何位于你的代码库之外的消费者都是一个外部系统。这里有一个注意事项:这假设我们生活在一个我们的服务作为外部系统调用我们的服务的“服务器”的世界。如果你需要在这个服务的工作中调用任何外部 HTTP 服务,你可能会在服务或仓库层中这样做。

图片

图 13.2 控制器是我们架构的最外层,如果服务作为服务器,则与任何潜在的外部系统交互。有了这个模型在心中,我们可以轻松地建模我们的仓库、服务和控制器。

到目前为止,我们已经实现了我们服务的内层圈。然而,现在如果 FlyTomorrow 要发送一个关于数据库中所有航班的请求,我们将无法接受这个请求。因此,如果没有完全实现的控制器,没有人会使用我们的服务。你可以拥有最干净、性能最高、最安全的服务,但如果没有人使用(或能够使用)你的产品,那就还不够好。

控制器公开了我们称之为端点方法的方法。这些方法接受 HTTP 请求并返回 HTTP 响应。HTTP 响应通常包含以下三个关键项,如图 13.3 所示:

  • 一个 HTTP 状态码,如 200(OK)、404(未找到)或 500(内部服务器错误) ——控制器根据处理请求后的服务状态确定这个状态码。

  • 头部 ——这是一个键值对的集合,通常包括返回数据的类型以及是否有任何跨源资源共享(CORS)指令。除非你需要传递一个奇怪的头部,否则 ASP.NET 通常可以自动为你处理这一步骤。

  • 一个主体 ——在适当的情况下,你可以向消费者返回数据。通常,这些数据以 JSON 值的形式返回,并伴随 200(OK)状态码。某些 HTTP 状态码不允许返回数据(例如,201 状态码,表示“无内容”)。这些数据在“主体”部分返回。

图片

图 13.3 HTTP 响应通常包含 HTTP 状态码、头部和主体。我们使用这些字段向调用者返回适当的信息。

关于 HTTP 和 Web 服务交互的更多信息,请参阅 Barry Pollard 的 HTTP/2 in Action(Manning,2019)。如果您想了解更多关于创建多个服务相互交互作为外部服务的架构开发,请参阅 Chris Richardson 的 Microservices Patterns(Manning,2018),Sam Newman 的 Building Microservices: Designing Fine-Grained Systems(O'Reilly Media,2015),或 Christian Harsdal Gammelgaard 的 Microservices in .NET Core(第 2 版;Manning,2020)。

13.2 确定要实现的控制器

在我们实现服务层类时,我们讨论了如何确定是否需要服务层类。我们意识到我们需要弄清楚是否需要一个调用该服务层的控制器。因此,我们可以再次进行这项练习,并快速确定我们需要实现哪些控制器。

再次查看 FlyTomorrow 和 Flying Dutchman Airlines 之间的合同中指定的端点(首次在 3.1 和 3.2 节中介绍,并在图 13.4 中显示):

  • GET /Flight

  • GET /Flight/{FlightNumber}

  • POST /Booking/{FlightNumber}

图 13.4 FlyTomorrow 合同所需的端点。我们需要实现我们的控制器来反映这些端点。

这些端点构成了我们迄今为止所做一切的基础。在数据库访问、存储库和服务层中,我们实际上并不需要与端点进行太多操作,但当谈到控制器时,情况就不同了。

要确定我们需要实现哪些控制器,我们问自己,在所需的端点中我们可以看到哪些实体,如图 13.5 所示?记住,当我们谈论实体时,我们是在谈论数据库实体(通过代码库中的模型类反映)。花几分钟时间查看端点,看看你能得出什么结论。我们之前已经做过这个练习,所以这不应该太具挑战性。

图 13.5 确定所需端点及其潜在控制器。我们可以通过查看所需端点中出现的实体来确定需要实现哪些控制器。

如果我们查看第一个端点(GET /Flight),我们会看到路径中的 Flight 实体。这是一个明确的迹象,表明我们应该实现一个 FlightController 类。同样,当我们查看 GET /Flight/{FlightNumber} 端点时,我们意识到我们也需要一个 FlightController 类来处理它。这留下了 POST /Booking/{FlightNumber},它表明我们需要一个 BookingController。在本章的其余部分,我们将实现 FlightController。在下一章中,我们将完全实现 BookingController

但关于 AirportCustomer 实体的控制器呢?因为不需要端点路径指向 AirportCustomer 实体的控制器,所以我们不需要它们。

13.3 实现 FlightController

在 13.1 节中,我们讨论了我们的架构中的控制器层。在 13.2 节中,我们利用了这些知识,讨论了我们需要实现哪些控制器。在本节中,我们将实际实现一个控制器。如何实现控制器层类?嗯,你知道的:我们首先创建我们的两个骨架类。在本节中,我们将实现 FlightController 类,所以让我们创建 FlightControllerFlightControllerTests 类,如图 13.6 所示。

图 13_06

图 13.6 我们添加了两个骨架类:FlightControllerFlightControllerTests。这些构成了我们 FlightController 实现的基础。

在设置好骨架类之后,在我们可以讨论如何创建外部系统可访问的控制器方法之前,我们还需要做一件额外的事情:FlightController 需要从 Controller 类派生。这个基类,在接下来的代码示例中展示,为我们提供了标准方法,我们可以使用这些方法向消费者返回 HTTP 数据,并允许我们设置路由到我们的端点:

public class FlightController : Controller

现在,我们需要实现以下三个部分,以便在运行时通过外部系统访问我们的端点。

  • IActionResult 接口(13.3.1 节)

  • 中间件中的依赖注入(13.3.2 节)

  • 路由端点(13.4 节)

在本章之后,我们将实现 FlightController 以及相应的单元测试,并将能够通过外部系统模拟器(如 Postman 或 cURL)来访问端点。

13.3.1 使用 IActionResult 接口返回 HTTP 响应(GetFlights)

在 13.2 节中,我们讨论了典型 HTTP 响应的组成。大多数情况下,HTTP 响应包含一个 HTTP 状态码、头部和一个包含一些数据的主体。想想我们如何从一个方法中返回这样的内容。没有原始数据类型可以持有这些信息。我们可以使用 C# 中任何类型的最低共同分母——object——但这将是一个懒惰的解决方案,并且处理起来有些棘手,因为它仍然不是 HTTP 传输可接受的格式。

解决方案是 ASP.NET 的 IActionResult 接口。IActionResult 接口由 ActionResultContentResult 等类实现,但在实践中,我们可以将确定使用哪个具体类的决定留给 ASP.NET。这是多态的另一个例子,我们称之为“编码到接口”。

点赞

编码到接口

在 8.4 节中,我们讨论了使用多态和 Liskov 替换原则。这些原则允许我们编写通用的代码,而不是局限于特定的实现。为了说明这一点,让我给你举一个例子。

让我们想象你正在为 2005 年左右的图书出版社编写一个服务。电子书的时代正在兴起,但你的代码没有考虑到这一点。结果,你的代码与Book类紧密耦合。在下面的代码片段中,作者完成了书籍的写作,我们想要将其发送到打印机:^a

public void BookToPrinter(Book book) {
  if (book.IsApproved()) {
    BookPrinter printer = 
➥ ExternalCompanyManager.SelectPrintingCompany(book);
    printer.Print();
  }
}

当仅处理常规、纸质书籍时,这段代码运行正常。但如果我们想“打印”一本电子书会发生什么?嗯,该方法不接受EBook类型的输入参数。如果我们使用参数类型的接口而不是具体类型来编写BookToPrinter方法,我们的工作会更容易,如下所示:

public void BookToPrinter(IWork book) {
  if (book.IsApproved()) {
    BookPrinter printer = 
➥ ExternalCompanyManager.SelectPrintingCompany(book);
    printer.Print();
  }
}

现在,打印电子书并没有太多意义。我们可能想要更进一步,将书籍的实际“打印”过程泛化,无论其介质类型如何,如下所示:

public void ProduceWork(IWork work) {
  if (work.IsApproved()) {
    work.Produce();
  }
}

这样,我们将实现细节抽象到IWork接口的派生类中。ProduceWork方法不关心书籍的介质是纸张还是电子书。在对象内部实现改变对象状态的逻辑是面向对象设计的一个重要原则,这使得代码更易于阅读和维护。关于这一点及其如何与开放/封闭原则联系起来的精彩讨论,请参阅 Robert C. Martin 和 Micah Martin 的《敏捷原则、模式与实践》(Prentice Hall,2006 年)。

^a 在撰写本文时,这个愉快的时刻似乎对这个书来说还非常遥远。

|

让我们开始我们的第一个端点:/GET Flight。我们知道我们的返回类型是IActionResult,但访问修饰符、名称和参数应该是什么?因为 ASP.NET 使用反射来调用方法,所以访问修饰符应该是 public。至于名称,一个给端点方法命名的好方法是取 HTTP 动作(在这种情况下是GET)并附加实体(对我们来说是Flight),在必要时使术语变为复数:GetFlights。这样我们就剩下了输入参数。这是一个GET动作,所以我们不需要输入参数,如下一代码片段所示。根据 HTTP 规范,不允许GET动作向特定方法传递任何数据,这使得我们在这个阶段的生活变得容易一些。

public IActionResult GetFlights()  { ... }

我们需要做什么来返回一些 JSON 数据?如第 13.1 节所述,在大多数情况下,我们不需要显式指定任何头信息,这样就留下了状态码和任何正文数据。ASP.NET 库为我们提供了一个易于使用的静态类,我们可以返回并使用它作为IActionResultStatusCode。这个类位于FlightController继承的Controller基类中。乍一看(并且根据其名称),你会认为StatusCode只允许我们返回状态码而没有正文,但事实并非如此!为了说明这一点,让我们从GetFlights方法返回 HTTP 状态码 200(OK)和字符串“Hello, World!”,如下所示:

public IActionResult GetFlights()  {
  return StatusCode(200, "Hello, World!");
}

这段代码可以编译并返回我们想要的结果。这里有一个额外的技巧需要理解:我们不应该使用硬编码的 200 这个状态码,而应该使用HttpStatusCode枚举并将其值转换为整数。这会稍微多写一点代码,但它消除了硬编码的数字,如下面的示例所示。有关魔法数字及其为什么不好的更多信息,请参阅第 9.6.1 节。

public IActionResult GetFlights() {
  return StatusCode((int) HttpStatusCode.OK, "Hello, World!");
}

不幸的是,这段代码无法满足我们的需求。我们需要从这个端点方法返回数据库中所有航班的详细信息集合。我们已经实现了服务层方法来支持这项工作,并创建了FlightView类。在FlightController.GetFlights方法中,我们希望调用这个服务层方法,并返回集合以及状态码 200(OK)。如果出现问题,服务层抛出Exception,我们希望返回状态码 500(内部服务器错误)且不返回其他数据。

在我们继续之前,让我们添加一个单元测试,如下一列表所示,以验证我们的预期。

列表 13.1 GetFlights_Success单元测试,第 1 次迭代

[TestMethod]
public void GetFlights_Success() {
  FlightController controller = new FlightController();             ❶
  ObjectResult response = 
➥ controller.GetFlights() as ObjectResult;                         ❷

  Assert.IsNotNull(response);                                       ❸
  Assert.AreEqual((int) HttpStatusCode.OK, response.StatusCode);    ❹
  Assert.AreEqual("Hello, World!", response.Value);                 ❺
}

❶ 实例化一个FlightController对象

❷ 模拟对/Flight 的 HTTP GET 调用,并将返回值转换为ObjectResult

❸ 确保 HTTP 响应不为空

❹ 验证 HTTP 响应的状态码为 200

❺ 验证预期的内容存在于 HTTP 响应中

因为FlightController.GetFlights方法返回的是IActionResult类型,我们不能直接从接口访问状态码和正文值,所以我们将响应转换为ObjectResult类型。ObjectResult类实现了IActionResult接口,因此我们可以将返回值向下转换为派生类。当我们进行向下转换时,我们使用两个类之间的多态关系,并使用父类作为派生类。这是 Liskov 替换原则的逆过程。

为了调整GetFlights方法中的逻辑,以便我们可以使用FlightService类获取数据库中所有航班的详细信息,我们需要访问FlightService的一个实例。我们再次使用依赖注入应该不会让人感到惊讶!

13.3.2 使用中间件将依赖项注入到控制器中

在前面的章节中,我们使用了依赖注入来推迟创建这种依赖项的新实例。对于仓储层,我们使用依赖注入来不必担心实例化FlyingDutchmanAirlinesContext类型的实例。同样,在服务层,我们注入了仓储层类的实例。最后,在本章中我们实现的控制器中,我们需要使用注入的FlightService实例。但这些实例从哪里来呢?

我们最终到了必须实际设置这些依赖项的阶段。我们通过向所谓的中间件中添加一些逻辑来实现这一点。中间件 是任何可以帮助处理 HTTP 请求的代码。你可以将中间件视为一系列单独的中间件组件,如图 13.7 所示,这些组件被串联在一起。

图片 13_07

图 13.7 多个中间件组件及其执行方式的示例。中间件组件是线性执行的,并且通常被链在一起以创建所需的处理故事。

在 HTTP 请求进入控制器(并继续向下通过架构层)之前,CLR 会执行任何提供的中间件,如图 13.8 所示。中间件组件的例子包括路由(我们将在第 13.4 节中看到更多关于路由的内容)、身份验证和依赖注入。

图片 13_08

图 13.8 中间件在接收到 HTTP 请求后执行控制器(以及后续的服务和存储库)代码之前发生。中间件组件的例子包括路由、身份验证和依赖注入。

通常,我们会在 ASP.NET 服务的 Startup 类中找到中间件代码。在第 5.2 节(以及如图 13.2 所示),我们在 Startup 类中添加了代码,使我们能够使用控制器和端点路由。这些都是中间件代码的例子。

列表 13.2 Startup

class Startup {
  public void Configure(IApplicationBuilder app, 
➥ IWebHostEnvironment env) {
    app.UseRouting();
    app.UseEndpoints(endpoints => endpoints.MapControllers());
  }

  public void ConfigureServices(IServiceCollection services) {
    services.AddControllers();
  }
}

注意:通过编写中间件来注入依赖项不是在 C# 中实现依赖注入的唯一方法。市面上有大量的第三方(开源)C# 依赖注入框架,例如 Autofac、Castle Windsor 和 Ninject。有关这些外部框架的更多信息,请参阅 Mark Seemann 的 Dependency Injection in .NET(Manning,2011)。

我们可以通过以下三种方式在 ConfigureServices 方法中添加要注入的依赖项:

  • 单例 —在整个服务生命周期中只有一个实例

  • 作用域 —在整个请求生命周期中只有一个实例

  • 瞬态 —每次使用依赖项时都会创建一个新的实例

使用单例依赖项来保证每次都使用相同的实例

使用单例选项添加注入的依赖项模仿了单例设计模式。在单例设计模式中,每个应用程序只有一个实例。CLR 会重复使用此实例,直到应用程序运行结束。实例可能最初是一个空指针,但在第一次使用时,代码会实例化它。

当我们使用单例依赖项进行依赖注入时,注入的实例总是相同的,无论何时何地注入。例如,如果我们添加一个类型为 BookingRepository 的注入单例,我们将在每个通过我们服务的请求中始终使用相同的实例。¹

使用作用域依赖项来保证每个请求的实例相同

使用作用域依赖项,每个 HTTP 请求都会实例化其自己的依赖项版本,需要注入。ASP.NET 在整个请求生命周期中使用此实例,但为每个进入服务神圣殿堂的新请求实例化一个新的实例。

例如,如果我们实例化一个FlightRepository实例,并在两个服务层类中注入FlightRepository类型,只要我们处理的是同一个 HTTP 请求,这两个服务层类都会接收到(并操作)同一个FlightRepository实例。

使用瞬态依赖项(DI)始终获取新实例

在 DI 中,瞬态依赖项可能是处理依赖注入最常见的方式。当我们添加瞬态依赖项时,每次该依赖项需要注入时,ASP.NET 都会实例化一个新的实例。这保证了我们始终在注入类的全新副本上工作。

因为瞬态依赖项是使用依赖注入最常见的、最容易使用的方式,我们将遵循这一做法。要在Startup类的ConfigureServices方法中添加瞬态依赖项,使用services.dependencyType语法。

让我们看看我们是如何为FlightController类的FlightService依赖项实现这一点的:

public void ConfigureServices(IServiceCollection services) {
  services.AddControllers();
  services.AddTransient(typeof(FlightService), typeof(FlightService));
}

我们将FlightService的类型作为AddTransient调用的两个参数。这告诉我们,每当请求注入FlightService类型时,我们希望将FlightService类型添加到内部实例集合中。这有点绕,但这是我们必须要做的。这就是你需要做的所有事情,以确保 CLR 在你需要时可以提供注入的实例。当然,我们还想添加FlightService类本身期望的依赖项——FlightRepositoryAirportRepository——如下所示:

public void ConfigureServices(IServiceCollection services) {
  services.AddControllers();

  services.AddTransient(typeof(FlightService), typeof(FlightService));
  services.AddTransient(typeof(FlightRepository), 
➥ typeof(FlightRepository));
  services.AddTransient(typeof(AirportRepository), 
➥ typeof(AirportRepository));
}

在此之后,我们需要为FlightRepositoryAirportRepository类提供哪些依赖项?这两个类都需要相同的依赖项——FlyingDutchmanAirlinesContext类的一个实例,如下所示:

public void ConfigureServices(IServiceCollection services) {
  services.AddControllers();

  services.AddTransient(typeof(FlightService), typeof(FlightService));
  services.AddTransient(typeof(FlightRepository), 
➥ typeof(FlightRepository));
  services.AddTransient(typeof(AirportRepository), 
➥ typeof(AirportRepository));
  services.AddTransient(typeof(FlyingDutchmanAirlinesContext), 
➥ typeof(FlyingDutchmanAirlinesContext));
}

现在我们可以将注入的依赖项添加到FlightController中,并按如下方式调用FlightService

public class FlightController : Controller {
  private readonly FlightService _service;

  public FlightController(FlightService service) {
    _service = service;
  }

  ...
}

我们使用GetFlights方法试图实现什么?我们希望返回给调用者一个包含所有航班信息的 JSON 响应,对吗?让我们通过 FlyTomorrow 提供的 OpenAPI 规范来双重检查,如图 13.9 所示。在那里,我们看到GET /Flight端点有以下三个返回路径:

  • 成功情况返回 HTTP 代码 200,并包含数据库中所有航班的详细信息

  • 如果没有找到航班,返回状态码 404

  • 对于所有其他错误,返回状态码 500

图 13.9 GET /Flight端点所需响应。这是从生成的 OpenAPI 规范中截取的屏幕截图。

让我们先处理成功的情况,如下面的列表所示,并使用注入的FlightService遍历FlightService.GetFlights方法返回的数据,用try-catch块包装起来,这样我们就可以捕获任何潜在的抛出错误。

列表 13.3 GetFlights调用FlightService

public async Task<IActionResult> GetFlights() {
  try {
    Queue<FlightView> flights = new Queue<FlightView>();            ❶
    await foreach (FlightView flight in _service.GetFlights()) {    ❷
      flights.Enqueue(flight);                                      ❸
    }

    ...
  }
  catch(FlightNotFoundException exception) {
    ...
  } catch (Exception exception) {
    ...
  }
}

❶ 创建一个 Queue来存储返回的 FlightView 实例

❷ 处理从服务类接收到的每个 FlightView

❸ 将航班添加到队列中

因为FlightService.GetFlights方法返回一个IAsyncEnumerable<FlightView>并使用yield return关键字,所以我们不需要等待所有处理完成才能看到我们的劳动成果。随着数据库返回航班,服务层填充FlightView,控制层接收这些实例并将它们添加到Queue数据结构中。

我们如何构建这个FlightView实例队列,以便我们可以返回其内容以及 HTTP 状态码 200 给用户?ASP.NET、C#和.NET 的魔力使得这变得非常简单。记得我们在 13.3.1 节中如何通过将两个参数值添加到StatusCode构造函数中,简单地返回 HTTP 状态码 200 以及一个读取为“Hello, World!”的正文吗?我们可以重复这个练习,将“Hello, World!”字符串替换为我们的队列,如下所示:

public async Task<IActionResult> GetFlights() {
  try {
    Queue<FlightView> flights = new Queue<FlightView>();            ❶
    await foreach (FlightView flight in _service.GetFlights()) {    ❷
      flights.Enqueue(flight);                                      ❸
    }

    return StatusCode((int)HttpStatusCode.OK, flights);
  } catch(FlightNotFoundException) {
    ...
  } catch (Exception) {
    ...
  }
}

❶ 创建一个 Queue来存储返回的 FlightView 实例

❷ 将航班添加到队列中

❸ 处理从服务类接收到的每个 FlightView

但你会接受我的话,认为这有效吗?当然不会。我们应该更新我们的单元测试来验证这个假设。为此,我们暂时需要添加一些返回值,以便GetFlights方法可以编译。我会把这个留给你,因为你的返回值不重要,只要它符合基于方法签名的返回类型要求即可。

为了添加一个验证FlightController.GetFlights方法的单元测试,我们需要模拟FlightService类(因此,我们还需要为FlightService设置一个无参构造函数,并确保FlightService.GetFlights方法返回一个正确的响应)。首先,我们需要将FlightService.GetFlights设置为虚拟的,这样 Moq 框架才能覆盖它。但我们要如何返回一个类型为IAsyncEnumerable<FlightView>的实例呢?我们不能简单地实例化这个类型,因为你不能仅基于接口来实例化类型。这里的技巧是在测试类内部创建一个测试辅助方法,该方法返回一个包含一些模拟数据的IAsyncEnumerable<FlightView>,如下所示。

列表 13.4 完成的GetFlights_Success单元测试

[TestClass]
public class FlightControllerTests {

  [TestMethod]
  public async Task GetFlights_Success() {
    Mock<FlightService> service = new Mock<FlightService>();              ❶

    List<FlightView> returnFlightViews = new List<FlightView>(2) {        ❷
            new FlightView("1932",                                        ❷
➥ ("Groningen", "GRQ"), ("Phoenix", "PHX")),                             ❷
            new FlightView("841",                                         ❷
➥ ("New York City", "JFK"), ("London", "LHR"))                           ❷
    };                                                                    ❷

    service.Setup(s => 
➥ s.GetFlights()).Returns(FlightViewAsyncGenerator(returnFlightViews));  ❸

    FlightController controller = new FlightController(service.Object);
    ObjectResult response = 
➥ await controller.GetFlights() as ObjectResult;

    Assert.IsNotNull(response);
    Assert.AreEqual((int)HttpStatusCode.OK, response.StatusCode);

    Queue<FlightView> content = response.Value as Queue<FlightView>;      ❹
    Assert.IsNotNull(content);                                            ❹

    Assert.IsTrue(returnFlightViews.All(flight => 
➥ content.Contains(flight)));                                            ❺
  }

  private async IAsyncEnumerable<FlightView> 
➥ FlightViewAsyncGenerator(IEnumerable<FlightView> views) {              ❻
    foreach (FlightView flightView in views) {                            ❻
      yield return flightView;                                            ❻
    }                                                                     ❻
  }                                                                       ❻
}

❶ 创建 FlightService 的模拟实例

❷ 定义模拟中使用的 FlightViews

❸ 设置模拟以返回 FlightView 列表

❹ 安全地将返回的数据转换为 Queue,并检查是否为 null

❺ 对于 FlightView 列表中的所有条目,检查返回的数据是否包含该条目(LINQ)

❻ 返回一个包含传入的 FlightView 对象的 IAsyncEnumerable

太好了,这个测试通过了。在完成这个方法之前,我们只剩下处理异常情况。在本节前面,我们识别(并添加)了两种错误条件:服务层抛出类型为 FlightNotFoundException 的异常,以及服务层抛出异常。查看 FlyTomorrow OpenAPI 规范(如图 13.10 所示),我们看到当找不到航班时,我们应该返回 HTTP 状态码 404(未找到),在其他所有错误上返回 HTTP 状态码 500(内部服务器错误)。

让我们从 404 开始,并添加以下单元测试来检查这一点:

[TestMethod]
public async Task GetFlights_Failure_FlightNotFoundException_404() {
  Mock<FlightService> service = new Mock<FlightService>();
  service.Setup(s => s.GetFlights())
➥ .Throws(new FlightNotFoundException());
  FlightController controller = new FlightController(service.Object);
  ObjectResult response = await controller.GetFlights() as ObjectResult;

  Assert.IsNotNull(response);
  Assert.AreEqual((int)HttpStatusCode.NotFound, response.StatusCode);
  Assert.AreEqual("No flights were found in the database", 
➥ response.Value);
}

GetFlights_Failure_FlightNotFoundException_404 单元测试目前没有通过。记住,在使用测试驱动开发时,我们通常希望在实现实际方法逻辑之前创建单元测试。这给了我们思考我们希望代码如何被调用的机会,进一步将新功能与其他代码库的片段解耦。在我们的情况下,我们需要添加一些逻辑,如下一代码示例所示,当控制器捕获到 FlightNotFoundException 实例时,返回正确的 StatusCode 对象:

public async Task<IActionResult> GetFlights() {
  try {
    Queue<FlightView> flights = new Queue<FlightView>();  
    await foreach (FlightView flight in _service.GetFlights()) { 
      flights.Enqueue(flight); 
    }

    return StatusCode((int)HttpStatusCode.OK, flights);
  } catch(FlightNotFoundException) {
    return StatusCode((int) HttpStatusCode.NotFound, 
➥ "No flights were found in the database");
  } catch (Exception) {
    ...
  }
}

我们的 GetFlights_Failure_FlightNotFoundException_404 单元测试现在通过了。我确信你可以想象接下来会发生什么:500 错误情况。我们模仿了处理 404 的方法,并添加了以下单元测试:

[TestMethod]
public async Task GetFlights_Failure_ArgumentException_500() {
  Mock<FlightService> service = new Mock<FlightService>();
  service.Setup(s => s.GetFlights())
➥ .Throws(new ArgumentException());

  FlightController controller = new FlightController(service.Object);
  ObjectResult response = await controller.GetFlights() as ObjectResult;

  Assert.IsNotNull(response);
  Assert.AreEqual((int)HttpStatusCode.InternalServerError, 
➥ response.StatusCode);
  Assert.AreEqual("An error occurred", response.Value);
}

为了使 GetFlights_Failure_ArgumentException_500 单元测试通过,我们在 GetFlights try-catch 中添加了适当的返回值如下:

public async Task<IActionResult> GetFlights() {
  try {
    Queue<FlightView> flights = new Queue<FlightView>(); 
    await foreach (FlightView flight in _service.GetFlights()) { 
      flights.Enqueue(flight); 
    }

    return StatusCode((int)HttpStatusCode.OK, flights);
  } catch(FlightNotFoundException) {
    return StatusCode((int) HttpStatusCode.NotFound, 
➥ "No flights were found in the database");
  } catch (Exception) {
    return StatusCode((int) HttpStatusCode.InternalServerError, 
➥ "An error occurred");
  }
}

这样就使单元测试通过,并且完成了 GET /Flight 端点的逻辑实现。当然,我们目前还不能从外部系统调用此端点,但我们将查看在 13.3.5 节中设置此路由。

13.3.3 实现 GET /Flight/{FlightNumber} 端点

到目前为止,在本章中,你学习了如何使用中间件进行依赖注入,以及如何在控制器层调用服务层的同时处理错误和提供单元测试。在 13.3.2 节中,我们实现了 GET /Flight 端点。现在我们来到 GET /Flight/{FlightNumber} 端点。

此端点应在提供航班号时返回单个航班的详细信息。为了完成这个任务,我们需要做以下四件事:

  1. 从路径参数中获取提供的航班号。

  2. 调用服务层以请求航班信息。

  3. 处理服务层可能抛出的任何潜在异常。

  4. 将正确的信息返回给调用者。

要获取路径参数的值,我们需要进行一些路由魔术,并将 FlightNumber URL 路径参数作为方法参数添加。在第 13.4 节中,我们将查看路由部分,但就目前而言,我们只需要在 FlightController 中创建一个新的方法 GetFlightByFlightNumber,该方法需要一个表示航班号的参数,如下所示:

public async Task<IActionResult> GetFlightByFlightNumber(int flightNumber){
  return StatusCode((int)HttpStatusCode.OK,"Hello from  
➥ GetFlightByFlightNumber");
}

这使我们能够调用 FlightServiceGetFlightByFlightByNumber 方法,并传递 flightNumber 参数。在我们继续之前,让我们回顾一下,通过添加一个我们可以构建的单元测试来重新获得测试驱动开发之神(更具体地说,是 Kent Beck)的青睐,如下所示:

[TestMethod]
public async Task GetFlightByFlightNumber_Success() {
  Mock<FlightService> service = new Mock<FlightService>();
  FlightController controller = new FlightController(service.Object);

  await controller.GetFlightByFlightNumber(0);
}

GetFlightByFlightNumber_Success 单元测试在其当前状态下运行良好。毕竟,这个单元测试只是检查它是否能够调用 FlightController 类上的名为 GetFlightByFlightNumber 的方法,并传入一个类型为 integer 的输入参数。

为了进一步实现我们的方法,让我们在单元测试中添加以下预期行为:

public async Task GetFlightByFlightNumber_Success() {
  Mock<FlightService> service = new Mock<FlightService>();

  FlightView returnedFlightView = new FlightView("0", ("Lagos", "LOS"), 
➥ ("Marrakesh", "RAK"));
  service.Setup(s => 
➥ s.GetFlightByFlightNumber(0))
➥ .Returns(Task.FromResult(returnedFlightView));

  FlightController controller = new FlightController(service.Object);

  ObjectResult response = 
➥ await controller.GetFlightByFlightNumber(0) as ObjectResult;
  Assert.IsNotNull(response);
  Assert.AreEqual((int)HttpStatusCode.OK, response.StatusCode);

  FlightView content = response.Value as FlightView;
  Assert.IsNotNull(content);

  Assert.AreEqual(returnedFlightView, content);
}

如预期的那样,GetFlightByFlightNumber_Success 单元测试现在没有通过。从 FlightController.GetFlightByFlightNumber 方法调用返回的数据是不正确的。但我们可以修复它。对于实际的方法实现,我们可以使用与 GetFlights 方法相同的 try-catch 模式,并用对服务 GetFlightByFlightNumber 方法的调用(它返回一个 FlightView 实例)替换掉调用服务 FlightService.GetFlights 返回的 IAsyncEnumerable 的异步 foreach 循环,如下一个代码示例所示:

public async Task<IActionResult> GetFlightByFlightNumber(int flightNumber){
  try {
    FlightView flight = await 
➥ _service.GetFlightByFlightNumber(flightNumber);
    return StatusCode((int)HttpStatusCode.OK, flight);
  } catch (FlightNotFoundException) {
      return StatusCode((int)HttpStatusCode.NotFound, 
➥ "The flight was not found in the database");
  } catch (Exception) {
    return StatusCode((int)HttpStatusCode.InternalServerError, 
➥ "An error occurred");
  }
}

如果我们现在再次运行 GetFlightByFlightNumber_Success 单元测试,我们会看到它通过了。这非常快!在不到一页半的时间里,我们创建了一个全新的端点,并有一个成功路径单元测试来支持预期的功能。我们正在取得进展,所以让我们也添加两个失败情况。再次强调,它们应该与我们之前对 GetFlights 单元测试所做的工作非常相似,如下所示:

[TestMethod]
public async Task
➥ GetFlightByFlightNumber_Failure_FlightNotFoundException_404() {
  Mock<FlightService> service = new Mock<FlightService>();
  service.Setup(s => s.GetFlightByFlightNumber(1))
➥ .Throws(new FlightNotFoundException());

  FlightController controller = new FlightController(service.Object);
  ObjectResult response = 
➥ await controller.GetFlightByFlightNumber(1) as ObjectResult;

  Assert.IsNotNull(response);
  Assert.AreEqual((int)HttpStatusCode.NotFound, response.StatusCode);
  Assert.AreEqual("The flight was not found in the database", 
➥ response.Value);
}

[TestMethod]
public async Task GetFlightByFlightNumber_Failure_ArgumentException_500() {
  Mock<FlightService> service = new Mock<FlightService>();
  service.Setup(s => s.GetFlightByFlightNumber(1))
➥ .Throws(new ArgumentException());

  FlightController controller = new FlightController(service.Object);
  ObjectResult response = 
➥ await controller.GetFlightByFlightNumber(1) as ObjectResult;

  Assert.IsNotNull(response);
  Assert.AreEqual((int)HttpStatusCode.InternalServerError, 
➥ response.StatusCode);
  Assert.AreEqual("An error occurred", response.Value);
}

继续运行所有测试;它们应该通过。那么,我们还需要为这个端点做些什么呢?让我们快速查看图 3.10 中显示的 OpenAPI 规范,并验证我们已经完成了所有需要做的事情。

图 13.10 GET /flight/{flightNumber} 端点的 OpenAPI 规范。这是从生成的 OpenAPI 规范中截取的屏幕截图。

通过查看 OpenAPI 规范,我们看到我们需要接受一个名为 flightNumber 的参数:检查!我们还有三个返回值:一个返回我们构建的 FlightView 的 200 状态码,一个当找不到航班时的 404 状态码,以及一个当提供的航班号无效时的 400 状态码。

好吧,我们已经完成了三分之二。我们只需要将我们的 500 内部错误更改为 400 状态码(Bad Request),并验证传入的 flightNumber 是否是一个有效的数字。对于一个有效的 flightNumber(就我们的目的而言)来说,任何正整数都是有效的。

让我们先跳到我们的单元测试中,并按照以下方式做出这些更改:

[TestMethod]
[DataRow(-1)]
[DataRow(1)]
public async Task 
➥ GetFlightByFlightNumber_Failure_ArgumentException_400(int 
➥ flightNumber){
  Mock<FlightService> service = new Mock<FlightService>();
  service.Setup(s => s.GetFlightByFlightNumber(1))
➥ .Throws(new ArgumentException());

  FlightController controller = new FlightController(service.Object);
  ObjectResult response = 
➥ await controller.GetFlightByFlightNumber(flightNumber) as ObjectResult;

  Assert.IsNotNull(response);
  Assert.AreEqual((int)HttpStatusCode.BadRequest, response.StatusCode);
  Assert.AreEqual("Bad request", response.Value);
}

当然,单元测试不再通过了。我们需要修改 FlightController.GetFlightByFlightNumber 方法,如下所示:

public async Task<IActionResult> GetFlightByFlightNumber(int flightNumber){
  try {
 if (!flightNumber.IsPositiveInteger()) {
 throw new Exception();
 }

    FlightView flight = await 
➥ _service.GetFlightByFlightNumber(flightNumber);
    return StatusCode((int)HttpStatusCode.OK, flight);
  } catch (FlightNotFoundException) {
    return StatusCode((int)HttpStatusCode.NotFound, 
➥ "The flight was not found in the database");
  } catch (Exception) {
    return StatusCode((int)HttpStatusCode.BadRequest, 
➥ "Bad request");
  }
}

我们学到了什么?始终要对照我们收到的规范检查我们的代码和测试。

因此,现在我们已经在 FlightController 中有了 GetFlightsGetFlightByFlightNumber 方法,是时候将它们暴露给外部系统了。毕竟,当前的代码是不可用的,所以它有些无用。为此,我们需要一种方法让我们的服务接受传入的 HTTP 请求并将该请求路由到适当的控制器和方法。在下一节中,我们将探讨如何做到这一点。

13.4 将 HTTP 请求路由到控制器和方法

现在你有一系列仓库、服务和控制器。它们都充满了可以实现你想要做的一切的精彩方法。但如何使用这些功能呢?与桌面应用程序不同,桌面应用程序中你会在业务逻辑旁边提供一个图形用户界面(GUI),我们处理的是一个存在于部署环境中的网络服务。我们如何请求或告诉服务器做什么?我们使用 HTTP 请求。

我们的服务如何接受这样的请求?目前,FlyingDutchmanAirlines 服务表现得有点像一堵砖墙。如果你向它发送 HTTP 请求,服务将不知道如何处理它。但如果我们引入路由的概念,如图 13.11 所示,情况就改变了。

图 13.11 带和不带路由的 HTTP 请求进入服务。当我们没有设置任何路由时,HTTP 请求在服务中无法解决而弹回。如果我们路由到端点,服务可以执行适当的逻辑。

路由使我们能够将 URL 映射到特定的控制器和端点。请求与控制器端点之间的映射使得在 FlightController 中执行 GET /Flight 方法时,当你向 [ServiceAddress]/flight URL 发送 HTTP GET 请求。为了添加路由支持,我们需要做什么?在 12.3.2 节中,我们讨论了中间件。路由只是我们可以添加到我们的服务中的另一块中间件。实际上,我们需要的很多东西已经准备好了。

在 5.2 节中,我们构建了内部路由表,其中包含服务可以路由到的端点列表。要开始路由,我们只需要告诉 CLR 将请求路由到何处。我们通过在两步过程中给端点方法和控制器提供“路由”来实现这一点。首先,我们像下面这样在 FlightController 类中添加一个 [Route] 属性:

[Route("{controller}")]
public class FlightController : Controller { ... }

[Route] 属性接受硬编码的路由或模板。在这里,我选择了 "{{controller}}" 模板。

路由中的控制器名称 当在route属性中使用"{controller}"模板时,路由会被解析为你的控制器类名,去掉实际的单词controller。所以,在我们的例子中,我们的类被命名为FlightController,所以路由是/Flight

下一步是定义特定方法的路由。为此,我们使用以下映射到 HTTP 操作的属性集合:

  • [HttpGet]

  • [HttpPost]

  • [HttpPut]

  • [HttpDelete]

  • [HttpHead]

  • [HttpPatch]

所有这些属性都生成映射到它们对应 HTTP 操作的路由。我们可以以两种方式在这些方法上使用这些属性:直接使用,或者提供额外的路由。为了说明,让我们在FlightController.GetFlights方法上使用[HttpGet]属性,如下所示:

[HttpGet]
public async Task<IActionResult> GetFlights() { ... }

方法路由被添加到控制器路由中。使用GetFlights方法上的[HttpGet]属性生成一个GET /Flight的路由,这与 FlyTomorrow OpenAPI 规范要求我们做的相符。为了测试我们的端点,我们可以使用 cURL 命令行工具(包含在 Windows、macOS 和 Linux 中)或专门的 HTTP 工具,如 Postman。我不会偏袒哪一个更好:它们各有优缺点。对于本书中的大多数命令,我在 Windows 上使用 cURL。cuRL 的使用在各个平台之间应该是相同的(或非常相似)。

要达到我们的目标端点,我们首先需要启动它。通常,我们会在本地端口 8080 上启动服务(提供的源代码就是这种情况)。这对于大多数用例来说都很好,但有时你可能会在那个端口上遇到冲突,需要使用不同的端口。如果你发现你无法访问服务,而你正在端口 8080 上提供服务,请在Startup.cs中将端口更改为其他值。在这个例子中,我使用了端口 8081。要启动我们的服务,打开一个命令行窗口,将其指向 FlyingDutchmanAirlines 文件夹,并输入以下命令:

>\ dotnet run

一旦服务启动并运行(命令行会告诉我们是否启动),我们就可以使用 cURL 来“curl”我们的端点。要 curl 一个端点,在另一个命令行窗口中,使用[curl] -v [address]语法(-v标志告诉 cURL 提供更多详细信息或详细程度),如下所示:

\> curl -v http://localhost:8081/flight

如果你的服务正在运行,你将收到一个包含数据库中所有航班的响应,如图 13.12 所示。

图 13.12 我们的服务对GET HTTP /Flight 请求的响应:一个包含数据库中所有航班的巨大 JSON 数组。FlyTomorrow 可以使用这些数据来向客户展示所有飞利浦荷兰航空公司的航班。

如您在图 13.12 中所见,cURL 工具不会格式化返回的 JSON 信息。它以未格式化的形式显示数据,这使得阅读变得困难。在图 13.13 中,您可以看到 Postman 中格式化后的响应的一部分,它格式化了返回的 JSON。好消息是我们的端点工作正常!我们从 HTTP 请求到数据库,再到命令行的完整往返都成功了。我们在前几章所做的所有艰苦工作终于得到了回报。

图 13.13

图 13.13 与图 13.12 中相同的 JSON 响应数据,但已格式化。格式化的 JSON 更易于阅读,我们可以轻松地发现任何问题。

我们如何到达我们的另一个端点:/GET /Flight/{FlightNumber}?毕竟,我们正在使用包含航班号的路径参数。当使用 HttpAttribute 方法属性(如 [HttpGet])时,我们可以提供额外的路由指令,如下一个代码片段所示。这在我们需要提供更多的路由嵌套(例如,有一个前往 /Flight/AmazingFlights/ 的端点)或接收路径参数(如 {flightNumber})时非常有用。

[HttpGet("{flightNumber}")]
public async Task<IActionResult> GetFlightByFlightNumber(int flightNumber){
  ...
}

使用 GetFlightByFlightNumber 方法,[HttpGet] 中指定的模板将 {flightNumber} 路径参数指向方法输入参数 flightNumber。我们现在可以使用路径参数并请求特定航班的详细信息。例如,我们可以使用 cURL 轻松检索萨尔茨堡飞往格罗宁根的航班 23 的信息,如下所示:

\> curl -v http://localhost:8081/flight/23

端点返回了 FlightView 类的序列化(将数据结构转换为二进制或 JSON 格式)版本,用于航班 23。图 13.14 展示了响应数据。我们还可以在图 13.15 中看到,如果我们传递一个无效的航班号,例如 -1(不是一个正整数)或 93018(航班在数据库中不存在),会发生什么。

图 13.14

图 13.14 当调用 GET /Flight/23 端点时,服务返回的数据。通过传递适当的航班参数值,我们可以使用 GET /Flight/{FlightNumber} 端点查询航班 23。

图 13.15

图 13.15 我们在 /GET /Flight/{FlightNumber} 端点错误条件下收到 HTTP 400 和 HTTP 404 错误。这些错误对于确定问题是在客户端还是服务器端非常有用。

因此,总结一下:我们现在已经完全实现了 FlightController 以及单元测试。我们可以访问 GET /FlightGET /Flight/{FlightNumber} 端点并成功从数据库中检索数据。在下一章中,我们将完成我们的重构之旅并实现最后一个控制器和端点:BookingControllerPOST /Booking 端点。

练习

练习 13.1

对或错?在存储库/服务模式架构中,控制器是唯一应该接受来自外部系统的 HTTP 请求的层。

练习 13.2

一个典型的 HTTP 响应包含以下三个属性:

a. 发送者信息,路由信息,IP 目标

b. 发送者名称,服务中使用的编程语言,原始国家

c. 状态码,头部,主体

练习 13.3

对于此路由,你应该实现哪个控制器:GET /Books/Fantasy

a. BookController

b. FantasyController

c. BookShopController

练习 13.4

对或错?中间件是在任何端点方法逻辑之前执行的。

练习 13.5

哪种注入依赖项类型允许我们在每次请求时都获得一个新的依赖项实例,无论我们是否仍在处理相同的 HTTP 请求?

a. 单例

b. 范围限定

c. 瞬态

练习 13.6

哪种注入依赖项类型允许我们在服务中仅在 HTTP 请求的生命周期内使用依赖项的同一实例?

a. 单例

b. 范围限定

c. 瞬态

摘要

  • 在考虑存储库/服务模式时,控制器层是架构的外层。控制器可以接受 HTTP 请求并与外部系统通信。如果我们不能接受或与外部系统交谈,没有人能够使用我们的服务。

  • HTTP 请求始终包含头部信息(CORS、身份验证等),有时还包含主体(JSON 或 XML)。

  • HTTP 响应始终包含头部信息、HTTP 状态码(200 OK、404 Not Found 等),有时还包含主体。

  • ASP.NET 的IActionResult接口允许我们轻松地从方法中返回 HTTP 响应。这使我们能够编写清晰简洁的代码,任何人都能理解。

  • 编码到接口是一种干净的代码原则,它促进了使用泛型结构而不是限制具体类。这使我们能够遵循开/闭原则,并轻松扩展我们的代码而无需更改现有类。

  • 中间件是我们执行在控制器端点方法处理提供的 HTTP 请求之前的任何代码。我们可以使用中间件来执行诸如身份验证检查、依赖注入和路由等操作。

  • 当在中间件中注入依赖项时,你有三种类型的注入依赖项可供选择:单例、范围限定和瞬态。单例依赖项模仿单例设计模式,并保证所有请求都在注入依赖项的单个实例上操作。范围限定时,注入的依赖项在同一请求内共享,但不在多个请求之间共享。瞬态时,每次构造函数请求依赖项时都会实例化一个新的依赖项实例。

  • 要将 HTTP 请求路由到端点,我们必须在中间件中设置路由,并将路由属性添加到控制器类和方法中。这允许我们对我们的路由应该是什么样子进行细粒度控制。

  • 对于大多数常见的 HTTP 操作,都有HttpAttribute路由方法属性。你可以直接使用它们,或者提供额外的路由并使用路径参数。


(1.)有关单例模式的更多信息,请参阅罗伯特·C·马丁和迈克·马丁的《敏捷原则、模式与实践:C#》第二十四章,“单例与单态”(普伦蒂斯·霍尔,2006 年);或者,如果您想了解一个也详细涵盖依赖注入的资源,请参阅斯蒂芬·范·德尔斯和马克·塞门恩的《依赖注入:原则、实践与模式》(曼宁,2019 年)。

14 JSON 序列化/反序列化和自定义模型绑定

本章涵盖

  • 序列化和反序列化 JSON 数据

  • 使用 [FromBody] 参数属性神奇地反序列化 JSON 数据

  • 使用IModelBinder接口实现自定义模型绑定器

  • 在运行时动态生成 OpenAPI 规范

这就是了。这是最后一章重构。在这本书中,我们从零开始重构了一个现有的代码库。我们学习了测试驱动开发、如何编写干净的代码以及 C#的技巧和窍门。图 14.1 显示了我们共同旅程的进展。

图片

图 14.1 在前面的章节中,我们实现了数据库访问、仓库和服务层,以及FlightController类。在本章中,我们将完成这项工作并实现BookingController类。

在本章中,我们将实现最后一个控制器:BookingController(第 14.1 节)。之后,我们将根据 FlyTomorrow 的 OpenAPI 规范进行一些手动测试和验收测试。我们还将设置 Swagger 中间件以动态生成 OpenAPI 规范(第 14.2 节)。这是一个可选但非常有用的技术,因为 Swagger 帮助我们进行验收测试。

14.1 实现 BookingController 类

在第十三章中,我们学习了如何实现控制器(FlightController)并添加了一些 HTTP GET 方法(GET /FlightGET Flight/{FlightNumber})。在本节中,我们将在此基础上构建并实现BookingControllerBookingController是 FlyTomorrow 与飞荷兰人航空公司创建预订的入口和网关。通过这个控制器,我们将完成 FlyingDutchmanAirlinesNextGen 服务的实现,并开始为公司提供一些实际的收入价值。毕竟,如果人们不能在我们的航班上预订座位,我们就无法从超大的行李、零食和船上的彩票中赚钱。

让我们再次看看飞荷兰人航空公司与 FlyTomorrow 之间的合同,看看BookingController类应该有哪些端点,如图 14.2 所示。

图片

图 14.2 现在,众所周知的 FlyTomorrow 与飞荷兰人航空公司之间的合同。端点 1 和 2 在第十三章中实现。在本章中,我们将实现端点 3。

如您所见,我们需要实现以下三个端点:

  • GET /Flight

  • GET /Flight/{FlightNumber}

  • POST /Booking/{FlightNumber}

在第十三章中,我们实现了端点 1 和 2。现在只剩下第三个端点等待我们去实现。前两个端点位于FlightController类中,但第三个需要我们实现BookingController类。

前两个端点也没有要求我们处理提供的 JSON 体。当然,在 GET /Flight/{FlightNumber} 端点中,我们有一个路径参数,但它将航班的数字数据限制为路径参数可以接受的任何内容。对于 POST,我们需要接受发送到端点的数据。我们将在第 14.1.2 节中查看如何做到这一点。

然而,在我们这样做之前,让我们创建我们的(到目前为止)标准骨架类:BookingController。根据第 13.3 节,为了 CLR 能够将我们的控制器类作为可行的路由端点识别,我们需要让 BookingController 继承自 Controller 类,并添加以下 [Route] 类属性:

[Route("{controller}")]
public class BookingController : Controller { }

14.1.1 数据反序列化简介

让我们详细分析 POST /Booking/{flightNumber} 端点,并查看我们可以期望传递给我们的服务的数据(图 14.3)。

图片 14_03

图 14.3 POST /Booking/{flightNumber} 端点接受包含想要预订给定航班的客户姓名的 HTTP 体。它返回 201 或 500。这是从生成的 OpenAPI 规范中截取的屏幕截图。

POST /Booking/{flightNumber} 结合了两种向控制器提供数据的方式:路径参数(flightNumber)和包含两个字符串(姓名和姓氏)的 JSON 体。我们可以在 JSON 中如下建模此数据:

{
  "firstName" : "Frank",
  "lastName"  : "Turner”
}

当然,用户无法填写字段并错误地提供如这里所示的两个字段中的全名:

{
  "firstName" : "Pete Seeger",
  "lastName"  : "Jonathan Coulton”
}

我们在收到数据之前无法检查其正确性,所以让我们假设一个(非常,非常天真的)验证规则:firstNamelastName 都需要填写。

现在,你可能会问,“Jort,这确实很棒。但我们如何在方法内部访问这样的数据?”对于这个问题,我说,“这是一个非常好的问题。”与路径参数不同,我们无法简单地将 firstNamelastName 参数添加到方法参数列表中。我们需要将传入的数据反序列化为我们可以理解的数据结构。如图 14.4 所示的反序列化是将数据流(通常是字节或 JSON 字符串)转换为内存或磁盘上的可消费数据结构的过程。其逆过程(将对象转换为字节或 JSON 字符串,以便我们可以通过 HTTP 发送或将其写入二进制文件)称为 序列化

图片 14_04

图 14.4 反序列化将 XML、JSON 和二进制文件等数据流转换为可消费数据,通常存储在数据结构中。这允许我们处理序列化数据。

因为 HTTP 请求的体通过线缆序列化(在我们的例子中是 JSON 字符串),我们需要访问其体信息,所以我们必须将体反序列化为某种定义的结构。

为了反序列化数据,我们使用以下两个概念:

  • 一个具有适当结构以反序列化数据的(通常是类)数据结构

  • 使用 [FromBody] 参数属性(模型绑定也称为数据绑定)

图 14.5 BookingData 类被添加到 ControllerLayer 文件夹中的新 JsonData 文件夹。将 JsonData 类保持在 ControllerLayer 文件夹内有助于我们确保代码库是有组织的。

首先,我们需要为 ASP.NET 提供一个数据结构来反序列化提供的主体。最有效的方法(因为它是最有序的)是创建一个类或结构来存储我们的数据。尽管我们只想存储数据,但我们还希望对提供的数据进行一些验证,因此,我们使用一个类。我们将这个新类存储在一个新的文件夹中,ControllerLayer/JsonData,并将文件命名为 BookingData.cs,如图 14.5 和下一代码片段所示。

public class BookingData {
  public string FirstName { get; set; }
  public string LastName { get; set; }
}

当一切完成后,BookingData 类应该被外部系统调用端点提供的数据填充。我们还想对属性进行一些验证:如果提供的字符串为空或为空,不要将属性设置为提供的字符串,而是抛出 InvalidOperationException(或者,使用 ArgumentNullException 也会是合适的)。我们还会在异常上设置一个消息,告诉人们我们无法做什么:设置 FirstNameLastName。为了避免在两个属性的设置器中重复相同的验证(一次为每个属性的设置器),我们可以创建一个私有方法来进行验证,并只需调用它。为了向设置器添加一个体,我们还需要为获取器提供一个。这导致需要创建一个后置字段,如下一列表所示。

列表 14.1 BookingData.cs

private string _firstName;                                                 ❶
public string FirstName {
  get => _firstName;                                                       ❷
 set => _firstName = ValidateName(value, nameof(FirstName));}             ❸
}

private string _lastName;
public string LastName {
  get => _lastName;
 set => _lasttName = ValidateName(value, nameof(LastName));}}

private string ValidateName(string name, string propertyName) =>           ❹
 string.IsNullOrEmpty(name)                                               ❹
 ? throw new InvalidOperationException("could not set " + propertyName)   ❹
 : name;                                                                  ❹

FirstName 属性的后置字段

❷ 返回后置字段的值

❸ 将值设置为后置字段

❹ 验证输入值

在列表 14.1 中,我们传递属性的名称来帮助我们动态构建错误消息。为此,我们使用 nameof 表达式,它在我们编译时获取变量、类型或成员的名称作为字符串。到这一点,列表应该很容易理解,您应该能够解释自动属性和具有后置字段的全属性之间的区别。如果您对区别有困难,请重新阅读第 3.3.3 节。

条件大括号 列表 14.1 中唯一奇怪的部分是 if(IsValidName(value)) 条件之后缺少大括号。在 C# 中,如果您在条件之后省略大括号,CLR 假设下一个语句是条件的主体并执行它。请注意,这仅限于一个执行的语句。如果您有一个或多个语句组成条件的主体,您需要使用大括号。

对于BookingData类,我们最后要做的就是提供一些单元测试来验证我们对刚刚实现的功能的假设。这些单元测试非常直接,你应该能够自己编写它们。如果你遇到困难,以下是一些你可以使用的实现(我们将测试文件添加到新的 FlyingDutchmanAirlines_Test/ControllerLayer/JsonData 文件夹中):

[TestClass]
public class BookingDataTests {
  [TestMethod]
  public void BookingData_ValidData() {
    BookingData bookingData = new BookingData {FirstName = "Marina", 
➥ LastName = "Michaels"};
    Assert.AreEqual("Marina", bookingData.FirstName);
    Assert.AreEqual("Michaels", bookingData.LastName);
  }

  [TestMethod]
  [DataRow("Mike", null)]
  [DataRow(null, "Morand")]
  [ExpectedException(typeof(InvalidOperationException))]
  public void BookingData_InvalidData_NullPointers(string firstName, 
➥ string lastName) {
    BookingData bookingData = new BookingData { FirstName = firstName, 
➥ LastName = lastName };
    Assert.AreEqual(firstName, bookingData.FirstName);
    Assert.AreEqual(lastName, bookingData.LastName);
  }
  [TestMethod]
  [DataRow("Eleonor", "")]
  [DataRow("", "Wilke")]
[ExpectedException(typeof(InvalidOperationException))]
  public void BookingData_InvalidData_EmptyStrings(string firstName, 
➥ string lastName) {
    BookingData bookingData = new BookingData { FirstName = firstName, 
➥ LastName = lastName };
    Assert.AreEqual(firstName, bookingData.FirstName ?? "");
    Assert.AreEqual(lastName, bookingData.LastName ?? "");
  }
}

在收到 HTTP POST请求后,我们如何填充BookingData类?这就是[FromBody]属性发挥作用的地方。

14.1.2 使用[FromBody]属性反序列化传入的 HTTP 数据

在 14.1.1 节中,我们创建了一个数据结构来存储反序列化的信息。在 HTTP POST请求的上下文中,我们通常可以期望有效信息遵循我们提供的 OpenAPI 规范。在这种情况下,为了使POST请求有效,JSON 数据需要反序列化到BookingData类的属性:firstNamelastName。如果我们最终得到一个不完整的请求(BookingClass的属性有 null 指针),我们返回 HTTP 状态码 500。在这种情况下,你也可以返回 HTTP 状态码 400(Bad Request),这可能是正确的代码,但让我们坚持使用提供的 OpenAPI 规范(如图 14.3 所示)。

但首先,最重要的是:我们如何将数据反序列化到BookingData类中?我们不可能仅仅将BookingData类型添加到参数列表中并期望它自动工作。这听起来可能有些疯狂,但实际上这非常接近现实!ASP.NET 的[FromBody]属性可以应用于一个参数,以告诉 ASP.NET 我们想要对这个类型执行模型绑定。当 CLR 将有效载荷路由到具有此类参数的端点时,它会获取有效载荷的Body元素并尝试将其反序列化到给定的数据类型。

要请求这种模型绑定,只需将“FromBody type argumentName”添加到方法参数列表中(在我们的例子中,让我们在BookingController类中创建一个新的方法,称为CreateBooking,并带有 HTTP 属性[HttpPost]),如下所示:

[HttpPost]
public async Task<IActionResult> CreateBooking([FromBody] BookingData body)

通过将[FromBody]属性添加到可以通过body变量访问的BookingData类型,我们现在可以使用 HTTP 请求中的数据,如图 14.6 所示。

图片

图 14.6 使用[FromBody]属性。当使用[FromBody]属性时,你可以将 HTTP JSON 数据反序列化到特定的数据结构并访问它。

这就是如此简单。现在,有些人可能不喜欢在他们的代码库中使用“魔法”,这是完全可以理解的。要知道,默认情况下,ASP.NET 被设置为序列化 JSON 数据。如果你想使用 XML,你必须将以下行添加到 global.asax.cs 文件中(当存在此文件时,它包含你的服务的全局配置详细信息):

XmlFormatter xmlFormatter = 
➥ GlobalConfiguration.Configuration.Formatters.XmlFormatter;
xmlFormatter.UseXmlSerializer = true;

在我们继续之前,让我们快速看一下在继续使用[FromBody]之前解析 HTTP 数据到数据结构的另一种方法。

14.1.3 使用自定义模型绑定器和方法属性进行模型绑定

我们可以不使用[FromBody]属性自动将 HTTP 数据绑定到数据结构,也可以揭开 ASP.NET 魔法的面纱并实现我们自己的模型绑定器。人们经常反对使用[FromBody]属性,因为它似乎在幕后神秘地执行操作而没有解释。本节旨在解释这种魔法。

作为自定义模型绑定器,我们的BookingModelBinder包含了如何将给定数据绑定到我们类中的信息。使用自定义模型绑定器有些繁琐,但可以为我们提供对数据绑定过程更大的控制。首先,让我们添加一个新的类作为我们的模型绑定器,如下所示。这个类,BookingModelBinder,需要实现IModelBinder接口。IModelBinder接口允许我们使用BookingModelBinder将数据绑定到模型,我们将在稍后进行操作。

列表 14.2 自定义模型绑定器的开始

class BookingModelBinder : IModelBinder {                                 ❶
  public async Task BindModelAsync(ModelBindingContext bindingContext){   ❷
    throw new NotImplementedException();                                  ❷
  }                                                                       ❷
}

❶ 要提供自定义模型绑定,我们需要实现 IModelBinder 接口。

❷ IModelBinder 接口要求我们实现 BindModelAsync。

我们的BookingModelBinder类的实现包含以下四个主要部分,如图 14.7 所示:

  1. 验证bindingContext输入参数。

  2. 将 HTTP 正文读取到可解析的格式。

  3. 将 HTTP 正文数据绑定到BookingData类的属性。

  4. 返回绑定后的模型。

图片

图 14.7 当使用自定义模型绑定器反序列化数据时,我们需要在返回绑定模型的数据之前进行验证、解析和绑定。这个工作流程让我们对反序列化过程有更细粒度的控制。

第一步也是最简单的:我们只想确保bindingContext参数没有与 null 值相关联,如下所示:

public async Task BindModelAsync(ModelBindingContext bindingContext) {
  if (bindingContext == null) {
    throw new ArgumentException();
  }
}

对于步骤 2(将 HTTP 正文读取到可解析的格式),我们需要访问并处理 HTTP 正文信息。

幸运的是,我们可以通过提供的ModelBindingContext实例访问关于传入 HTTP 请求的所有需要的信息。我们正在寻找的类是HttpContextHttpRequest。它们包含与所有预期元素(正文、头信息等)相关的属性。Request类为我们提供了一个PipeReader实例,它有权访问序列化的正文元素。PipeReader类是System.IO.Pipelines命名空间的一部分。System.IO.Pipelines包含帮助进行高性能输入/输出(IO)操作的类(最重要的是PipePipeWriterPipeReader)。

为了检索和使用PipeReader以便我们更接近正文数据,我们使用Request.BodyReader属性并调用其ReadAsync方法,如下所示:

ReadResult result = await 
➥ bindingContext.HttpContext.Request.BodyReader.ReadAsync();

ReadAsync 方法返回一个 Task<ReadResult> 实例。该对象包含三个属性:IsCompletedIsCanceledBuffer。前两个用于检查提供的数据读取是否完成或取消。第三个是我们数据所在的地方。因为我们处理的是序列化数据和异步过程,所以数据存储在类型为 ReadOnlySequence<byte> 的缓冲区中。正是这个缓冲区包含了表示正文数据的实际字节。通常,缓冲区只包含一个“数据段”,因此我们可以检索第一个 SpanSpan 表示连续的数据块)。然后,我们需要将那些数据反序列化回可读的 JSON 字符串。我们通过使用 Encoding.UTF8 类来完成这项操作,如下所示:

ReadOnlySequence<byte> buffer = result.Buffer;
string body = Encoding.UTF8.GetString(buffer.FirstSpan);

现在我们有了 JSON 字符串,我们可以将 JSON 字符串反序列化到我们的模型中(步骤 3:将 HTTP 正文数据绑定到 BookingData 类的属性)。C# 通过 System.Text.Json 命名空间提供了一些可靠的 JSON 功能,该命名空间在 .NET 5 中引入(并且默认安装)。要将 JSON 字符串反序列化为 BookingData 结构体,我们只需调用 JsonSerializer.Deserialize<T> 并将其类型作为泛型类型参数(BookingData)以及要反序列化的 JSON 字符串(body)传递,如下所示:

BookingData data = JsonSerializer.Deserialize<BookingData>(body);

这将 body 中出来的值反序列化为 BookingData 结构体相应属性的适当类型。

最后一步(步骤 4)是返回绑定后的模型。你可能已经注意到 BindModelAsync 方法的返回类型是 Task。我们不能将返回类型更改为 Task<BookingData>,因为我们必须实现 IModelBinder 接口。但是,我们还有另一种方法将新的 BookingModel 实例传递到端点方法:通过使用 ModelBindingContext 类的 Result 属性,如下所示:

bindingContext.Result = ModelBindingResult.Success(data);

如果我们将此添加到方法末尾,我们可以确信我们的 BookingData 实例被传递到控制器——这是另一件魔法般的事情。随着你继续你的 C# 之旅,你将遇到许多这样的魔法。但是,如果你深入挖掘,你通常可以弄清楚底层发生了什么。正如哈利·波特的弗农·德思礼所说:“根本就没有魔法!”

这就完成了 BookingModelBinder 类,但端点方法怎么办?因为我们不能使用 [FromBody] 属性,我们该怎么办?实际上,这非常相似。我们向参数添加一个 [ModelBinder(typeof([custom binder]))] 属性,如下所示:

[HttpPost]
public async Task<IActionResult> 
➥ CreateBooking([ModelBinder(typeof(BookingModelBinder))] 
➥ BookingData body, int flightNumber)

虽然这肯定比简单地添加 [FromBody] 属性要复杂得多,但我们可以通过我们对 [FromBody] 的了解来理解这个参数属性。请参见下一列表以获取完整的代码。

列表 14.3 完成的 BookingModelBinder 自定义模型绑定器类

class BookingModelBinder : IModelBinder {
  public async Task BindModelAsync(ModelBindingContext bindingContext) {
    if (bindingContext == null) {
      throw new ArgumentException();
    }

    ReadResult result = await 
➥ bindingContext.HttpContext.Request.BodyReader.ReadAsync();
    ReadOnlySequence<byte> buffer = result.Buffer;

    string bodyJson = Encoding.UTF8.GetString(buffer.FirstSpan);
    JObject bodyJsonObject = JObject.Parse(bodyJson);

    BookingData boundData = new BookingData {
      FirstName = (string) bodyJsonObject["FirstName"],
      LastName = (string) bodyJsonObject["LastName"]
    };

    bindingContext.Result = ModelBindingResult.Success(boundData);
  }
}

在本节中看到的代码在后续操作中不是必需的(实际上也没有使用)。它仅仅是一个很好的工具,让你知道,但对于我们的用例来说有点过度。

14.1.4 实现创建预订端点方法的逻辑

在处理完模型绑定(并回到使用 [FromBody] 属性)之后,我们现在可以专注于 CreateBooking 方法的核心:调用必要的服务方法在数据库中创建预订的逻辑。让我们回顾一下创建预订的一般步骤,如图 14.8 所示:

  1. 验证我们的数据绑定。

  2. 确保数据库中存在提供的客户。如果没有,将客户添加到数据库中。

  3. 确保客户想要预订的航班存在。

  4. Booking 表中请求一个新的条目,包含新的预订。

图 14.8

图 14.8 在数据库中创建新的预订涉及验证我们的模型绑定,检索(如果需要则添加)客户,检索航班,然后在数据库中创建预订。使用此工作流程,我们始终在数据库中拥有所需的所有信息。

由于我们已经实现了服务层和仓储层的方法,所有这些项目都应该很容易实现。让我们从唯一稍微有点棘手的一个开始:验证我们的数据绑定。为了确保我们的 BookingData 实例处于有效状态,我们需要定义这意味着什么。如果 FirstNameLastName 属性都设置为有效的非空字符串,则实例被认为是有效的。如果不是这种情况,我们不想进行任何处理。BookingData 类中已经存在逻辑,确保我们只将有效的名称分配给属性。如果传入的名称无效,则属性保持未设置。在这种情况下,我们不想使用该实例。

ASP.NET 给我们提供了访问 IValidatableObject 接口的权限。此接口允许我们为 CLR 在实例创建时运行定义验证规则。如果发现验证规则被破坏,ASP.NET 将 ControllerBase 类上的布尔属性 ModelState.IsValid 设置为 false。我们可以在我们的控制器中检查该属性,以确保我们使用的对象是有效的。要实现 IValidatableObject 接口,我们需要执行以下操作:

  • IValidatableObject 接口添加到 BookingData 类中。

  • 实现所需的 Validate 方法以验证属性值并处理任何错误。

这听起来并不太糟糕。将接口添加到类中很容易,如下所示:

public class BookingData : IValidatableObject

由于 BookingData 类现在声明它实现了 IValidatableObject 接口,我们应该实际上这样做。该接口告诉我们我们需要实现一个名为 Validate 的方法,所以让我们直接按照以下方式实现它:

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) {}

记住,当我们实现一个接口时,我们必须在我们的实现类中实现该接口上的任何方法。我们不能更改方法的签名,因为这会打破我们对编译器和接口的承诺,即实现接口上的所有方法。那么,我们该如何处理 Validate 方法?CLR 在对象实例化时调用 Validate 方法,并根据提供的验证规则确定如何设置 ModelState.IsValid 属性。返回类型 (IEnumerable<ValidationResult>) 允许我们返回一个数据结构(实现 IEnumerable 接口,包含 ValidationResult 实例),其中可能没有、一个或多个错误。我们可以在控制器中访问这些错误并将它们返回给客户。

这看起来像什么?嗯,我们需要实例化一个新的 IEnumerable<ValidationResult> 类型,验证我们的属性是否设置到了合适的值(我们在模型绑定时已经通过属性的设置器检查了它们设置的名称是否有效,但它们仍然可能是空值),如果出现问题,向返回的数据结构中添加错误,并返回错误列表,如下一列表所示。

列表 14.4 BookingDataValidate 方法

public IEnumerable<ValidationResult> Validate(ValidationContext 
➥ validationContext) {
  List<ValidationResult> results = new List<ValidationResult>();    ❶
  if (FirstName == null && LastName == null) {                      ❷
    results.Add(
➥ new ValidationResult("All given data points are null"));         ❸
  } else if (FirstName == null || LastName == null) {               ❹
    results.Add(
➥ new ValidationResult("One of the given data points is null"));   ❺
  }

  return results;                                                   ❻
}

❶ 创建一个空的错误列表

❷ 检查 FirstNameLastName 是否都是空值

❸ 如果两个属性都是空值,则向列表中添加一个错误

❹ 如果它们不是两个都是空值,可能只有一个。

❺ 如果只有一个属性是空值,则向列表中添加一个错误

❻ 返回包含错误列表(如果有)

我们如何实际使用这些错误?回到控制器方法中,我们应该添加一个检查来查看 ModelState.IsValid 属性是否设置为 true。如果是,我们可以继续我们的工作。但如果不是,我们应该返回一个 HTTP 状态码 500,以及找到的错误,如下所示:

[HttpPost]
public async Task<IActionResult> 
➥ CreateBooking([FromBody] BookingData body) {
  if (ModelState.IsValid) {
    ...
  }

  return StatusCode((int) HttpStatusCode.InternalServerError, 
➥ ModelState.Root.Errors.First().ErrorMessage);
}

如果我们使用无效的 JSON 有效负载查询 CreateBooking 端点,我们会得到一个 HTTP 状态码 500 以及找到的验证错误。我们现在有了将提供的 JSON 数据绑定到模型并将结果模型进行验证的代码。我们现在要做的就是请求 BookingService 为我们创建一个预订,并传递适当的信息。为此,我们首先需要添加一个后置字段和一个 BookingService 类型的注入实例,并设置中间件在运行时为我们提供这个实例。

首先,让我们在 BookingController 中添加一个后置字段和注入实例(通过构造函数),如下所示:

[Route("{controller}")]
public class BookingController : Controller {
  private BookingService _bookingService;

  public BookingController(BookingService bookingService) {
    _bookingService = bookingService;
  }

  ...
}

现在,让我们在 Startup 中添加依赖注入中间件。BookingService 类需要注入的类型为 BookingRepositoryFlightRepositoryCustomerRepository 的依赖项。幸运的是,我们已经有了一个注入的(瞬时的)类型为 FlightRepository 的依赖项,所以我们只需要将(除了 BookingService 之外)的 BookingRepositoryCustomerRepository 瞬时实例添加到 Startup.ConfigureServices 方法中,如下所示:

public void ConfigureServices(IServiceCollection services) {
  services.AddControllers();

  services.AddTransient(typeof(FlightService), typeof(FlightService));
  services.AddTransient(typeof(BookingService), typeof(BookingService));
  services.AddTransient(typeof(FlightRepository), 
➥ typeof(FlightRepository));
  services.AddTransient(typeof(AirportRepository), 
➥ typeof(AirportRepository));
  services.AddTransient(typeof(BookingRepository), 
➥ typeof(BookingRepository));
  services.AddTransient(typeof(CustomerRepository), 
➥ typeof(CustomerRepository));
  services.AddDbContext<FlyingDutchmanAirlinesContext>
➥ (ServiceLifetime.Transient);
  services.AddTransient(typeof(FlyingDutchmanAirlinesContext), 
➥ typeof(FlyingDutchmanAirlinesContext));
}

在我们可以请求创建新预订之前,我们还需要端点的路径参数,如下一个代码示例所示。该参数映射到 POST /Booking/{flightNumber} 端点的 {flightNumber} 部分。

[HttpPost("{flightNumber}")]
public async Task<IActionResult> CreateBooking([FromBody] BookingData body, 
➥ int flightNumber) {
  if (ModelState.IsValid) {
    ...
  }

  return StatusCode((int) HttpStatusCode.InternalServerError, 
➥ ModelState.Root.Errors.First().ErrorMessage);
}

让我们也在 flightNumber 参数上做一些快速输入验证。我们可以使用 IsPositiveInteger 扩展方法来确保航班号不是负整数,如下所示:

[HttpPost("{flightNumber}")]
public async Task<IActionResult> CreateBooking([FromBody] BookingData body, 
➥ int flightNumber) {
  if (ModelState.IsValid && flightNumber.IsPositiveInteger()) {
    ...
  }

  return StatusCode((int) HttpStatusCode.InternalServerError, 
➥ ModelState.Root.Errors.First().ErrorMessage);
}

这样一来,我们几乎可以调用 BookingService.CreateBooking 方法并在数据库中创建一个预订。我们只需要连接 FirstNameLastName 字符串(之间有一个空格),因为 BookingService.CreateBooking 只需要一个表示客户名称的 string 类型的单个参数。我们应该能够使用字符串插值来完成这个任务。在连接之后,我们最终可以按照以下方式调用服务的 CreateBooking 方法:

[HttpPost("{flightNumber}")]
public async Task<IActionResult> CreateBooking([FromBody] BookingData body, 
➥ int flightNumber)  {
  if (ModelState.IsValid && flightNumber.IsPositiveInteger()) {
    string name = $"{body.FirstName} {body.LastName}";
    (bool result, Exception exception) = 
➥ await _bookingService.CreateBooking(name, flightNumber);
  }
  return StatusCode((int) HttpStatusCode.InternalServerError, 
➥ ModelState.Root.Errors.First().ErrorMessage);
}

BookingService.CreateBooking 方法返回一个包含一个表示预订创建是否成功的布尔值和一个设置为抛出的任何异常值的元组。基于这些返回值,我们可以确定我们想要返回给用户的内容如下:

  • 如果布尔值设置为 true,并且异常为空,则返回 HTTP 状态码 201(已创建)。

  • 如果布尔值设置为 false,并且异常不为空,则根据异常类型返回 HTTP 状态码 500 或 404。

  • 如果布尔值设置为 false,并且异常为空,则返回 HTTP 状态码 500。

我们可以轻松地添加以下几个条件:

[HttpPost("{flightNumber}")]
public async Task<IActionResult> CreateBooking([FromBody] BookingData body, 
➥ int flightNumber) {
  if (ModelState.IsValid && flightNumber.IsPositiveInteger()) {
    string name = $"{body.FirstName} {body.LastName}";
    (bool result, Exception exception) = 
➥ await _bookingService.CreateBooking(name, flightNumber);

    if (result && exception == null) {
      return StatusCode((int)HttpStatusCode.Created);
    }

    return exception is CouldNotAddBookingToDatabaseException   
      ? StatusCode((int)HttpStatusCode.NotFound)
      ? StatusCode((int)HttpStatusCode.InternalServerError, 
➥ exception.Message);
  }

  return StatusCode((int) HttpStatusCode.InternalServerError, ModelState.Root.Errors.First().ErrorMessage);
}

因为当找不到航班时,BookingService 返回 CouldNotAddBookingToDatabaseException 类型的异常,我们可以利用这一点将我们的返回状态码转换为 404。

到目前为止,我有一些非常激动人心的消息:我们已经完成了对飞荷兰人航空公司服务的重写实施!给自己鼓掌,并反思一下在过程中(希望)学到的许多东西。虽然这不是真正面向生产的实际世界的反映,但这个过程突显了许多现实世界的场景和决策。在下一节中,我们将通过进行一些验收测试来验证我们的工作。

14.2 验收测试和 Swagger 中间件

有许多方法可以验证您的代码按预期工作。在整个书中,我们使用单元测试作为衡量功能预期的手段。但您在走到尽头时该怎么办?您使用 TDD-light(我们在书中有些作弊)实现了所有代码,现在您想验证整个系统。您可以做一些像自动化集成测试(在生产代码库中运行整个工作流程的测试;它们通常是 CI/CD 系统的一部分,并在夜间运行)。您也可能非常幸运,有一位 QA 工程师可供您使用。但我想向您展示一种简单的方法来验证您的代码是否有效:验收测试。

当我们谈论验收测试时,我们真正说的是,“将需求与我们的功能相匹配。”我们从用户那里得到的需求是以 OpenAPI 规范的形式出现的,但它们可以以很多形式出现(用户故事是另一种值得注意的需求格式)。因此,在本节中,我们将以以下两种方式执行验收测试:

  • 我们将使用 FlyTomorrow 提供的 OpenAPI 规范手动测试我们的端点(第 14.2.1 节)。

  • 我们将在我们的服务中添加一个可选的 Swagger 中间件,以动态生成 OpenAPI 规范。我们将比较这个生成的规范与提供的规范。它们应该匹配(第 14.2.2 节)。

在将您的产品交付给客户之前进行验收测试非常重要且有用。您不想在客户之前捕捉到任何错误或不正确的功能吗?因为我们是在针对生产(已部署)数据库进行测试,¹,我们只能测试快乐路径和非数据库异常场景。我们不希望在生产环境中强制出现故障。这就是我们为什么需要对失败路径进行单元测试,因为我们可以确保它们仍然有效。

14.2.1 使用 OpenAPI 规范进行手动验收测试

在我们开始测试之前,让我们制定一个方法论和一些测试步骤,我们可以遵循所有端点。我们期望所有功能都能正常工作,特别是因为我们已经在实现后测试了代码,但我们永远不能太过自信!对于我们的手动测试,我建议我们使用以下步骤:

  1. 确定输入需求。

  2. 确定快乐路径和非数据库异常情况。

  3. 测试!

我们需要测试的端点如下:

  • GET /flight

  • GET /flight/{flightNumber}

  • POST /booking/{flightNumber}

因此,无需多言,让我们从图 14.9 中所示的GET /flight端点开始。如果我们查看 OpenAPI 规范,我们会看到这个端点可以返回 HTTP 状态 200(附带flightView数据)、404 和 500。

图 14.9 GET /flight端点的 OpenAPI 规范。此端点用于获取数据库中所有航班的详细信息。这是从生成的 OpenAPI 规范中截取的屏幕截图。

因为这只是一个GET调用,没有路径参数或其他需要验证的输入,所以唯一的幸福路径(或非数据库相关异常)情况是成功情况。如果我们查询GET /flight端点,我们应该得到数据库中每架航班的详细信息,如图 14.10 所示。

图片

图 14.10 GET /flight端点的查询返回数据。数据库中的所有航班都以 JSON 形式返回。这使用户能够快速处理数据。

如您所见,端点返回了数据库中航班信息的详细列表。这就是GET /flight端点的情况。让我们继续到下一个(更有趣的)端点:GET /flight/{flightNumber},其规范如图 14.11 所示。

图片

图 14.11 GET /flight/{flightNumber}端点的 OpenAPI 规范。当提供一个航班号时,此端点允许用户获取特定航班的详细信息。这是从生成的 OpenAPI 规范中截取的屏幕截图。

我们可以看到GET /flight/{flightNumber}使用路径参数,可以返回 200(附带一些数据)、400 或 404。我们可以通过请求一个有效的航班、一个无效的航班号和一个有效但不在数据库中的航班号来测试所有这些场景,如表 14.1 所示。

表 14.1 GET /flight/{flightNumber}的手动测试返回数据

飞行号 返回状态 返回数据
19 201
{
    "flightNumber":"19",
    "origin":{"city":"Lyon","code":"LYS"},
    "destination":{"city":"Groningen",
        "code":"GRQ"}
}

|

–1 400 (Bad Request) N/A
500 404 (Flight Not Found) N/A

在表 14.1 中,端点返回的所有数据都得到了展示。看起来我们手头又有一个通过端点。现在,对于最后一个端点:POST /booking/{flightNumber},其规范如图 14.12 所示。

图片

图 14.12 POST /booking/{flightNumber}端点的 OpenAPI 规范。此端点允许用户在提供客户姓名和航班号的情况下预订航班。这是从生成的 OpenAPI 规范中截取的屏幕截图。

POST /booking/{flightNumber} 只有两个潜在的返回状态(201 和 500),但这有些误导。我们可以通过以下方式从这个端点强制引发错误:

  • 提交一个包含空字符串的 JSON 体作为姓名。

  • 提交一个缺少一个或两个所需属性(firstNamelastName)的 JSON 体。

  • 使用无效的飞行号。

  • 使用一个不存在的航班的飞行号。

表 14.2 显示了GET /flight/{flightNumber}提供的输入和输出。根据表 14.2 中的数据,我们可以说所有我们的手动测试都通过了。我们没有看到任何意外的输出,可以安全地继续到最后一个测试:根据服务动态生成 OpenAPI 文件,并将其与 FlyTomorrow 的版本进行比较。

表 14.2 POST /booking/{flightNumber}的所有成功和失败响应

端点飞行号 主体 返回状态 返回数据
1 firstName : "Alan"``lastName: "Turing" 201 (Created) N/A
-1 firstName : "Alan"``lastName: "Turing" 400 (Bad Request) N/A
999 firstName : "Alan"``lastName: "Turing" 404 (Not Found) N/A
1 firstName : "Alan"``lastName: "" 500 (Internal Server Error) “One of the given data points is null”
1 firstName : ""``lastName: "Turing" 500 (Internal Server Error) “One of the given data points is null”
1 firstName : "Alan" 500 (Internal Server Error) “One of the given data points is null”
1 lastName: "Turing" 500 (Internal Server Error) “One of the given data points is null”
1 firstName : ""``lastName: "" 500 “All given data points are null”
1 N/A 500 “All given data points are null”

14.2.2 在运行时生成 OpenAPI 规范

在第 13.3 节中,我们讨论了中间件及其使用方法。我们探讨了路由和依赖注入。但如果我告诉你,我们可以通过 Swagger 中间件选项(Swagger 是 OpenAPI 的前身)生成 OpenAPI 规范呢?通过 ASP.NET 创建的 CLR 在运行时创建此 OpenAPI 规范,因此它始终反映了端点的最新和最佳状态。本节的目标是生成这样的动态 OpenAPI 规范,并将其与从 FlyTomorrow 获得的 OpenAPI 规范进行比较。

注意:本节是可选的,需要安装第三方 C#库。生成 OpenAPI 规范不是大多数应用程序的功能性要求。如果你跳过本节,你可以在总结部分继续阅读。

因为.NET 5 没有提供添加 Swagger 中间件的功能,所以我们不得不安装一个名为 Swashbuckle 的第三方库。请继续通过 NuGet 包管理器安装Swashbuckle.AspNetCore包(参见第 5.2.1 节)。一旦我们安装了Swashbuckle.AspNetCore包,我们就可以添加中间件配置。

我们通过更改Startup.cs文件中的Configure方法和ConfigureServices方法来向 Startup.cs 文件添加中间件。设置很简单,并且开箱即用,如以下列表所示。

列表 14.5 带有 Swashbuckle 中间件的 Startup

class Startup {
  public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {
    app.UseRouting();
    app.UseEndpoints(endpoints => { endpoints.MapControllers(); });

    app.UseSwagger();                                   ❶
    app.UseSwaggerUI(swagger => 
➥ swagger.SwaggerEndpoint("/swagger/v1/swagger.json", 
➥ "Flying Dutchman Airlines"));                        ❷
  }

  public void ConfigureServices(IServiceCollection services) {
    services.AddControllers();

    services.AddTransient(typeof(FlightService), 
➥ typeof(FlightService));
    services.AddTransient(typeof(BookingService), 
➥ typeof(BookingService));
    services.AddTransient(typeof(FlightRepository), 
➥ typeof(FlightRepository));
    services.AddTransient(typeof(AirportRepository), 
➥ typeof(AirportRepository));
    services.AddTransient(typeof(BookingRepository), 
➥ typeof(BookingRepository));
    services.AddTransient(typeof(CustomerRepository), 
➥ typeof(CustomerRepository));

    services.AddDbContext<FlyingDutchmanAirlinesContext>
➥ (ServiceLifeTime.Transient);

    services.AddTransient(typeof(FlyingDutchmanAirlinesContext), 
➥ typeof(FlyingDutchmanAirlinesContext));

    services.AddSwaggerGen();                           ❸
  }
}

❶ 在默认位置生成 Swagger 文件

❷ 提供一个交互式 GUI,指向生成的 Swagger 文件

❸ 将 Swagger 添加到中间件

通过将 Swagger 设置添加到ConfigureServicesConfigure方法中,CLR 知道在启动时扫描服务并请求基于该信息生成 Swagger 文件。为了测试这一点,我们只需要启动服务并导航到SwaggerUI端点:[service]/swagger

在图 14.13 中,你可以看到由 Swagger 中间件生成的 Swagger UI。

图 14.13 展示了飞荷兰人航空公司服务的自动生成的 OpenAPI 规范。我们可以使用这个规范来对照 FlyTomorrow 的 OpenAPI 规范进行双重检查。

表面上看起来相当不错,尽管有点简略。让我们进一步调查,看看是否遗漏了任何信息。通过展开GET /{controller}/[flightNumber]部分,我们可以在图 14.14 中看到它只生成了状态码 200 的返回信息。

图 14.14 展示了在服务启动时生成的扩展的 GET /Flight/{FlightNumber} OpenAPI 信息。这似乎遗漏了一些我们添加到控制器中的返回信息。

问题是,我们知道我们确实在适当端点方法中添加了逻辑来返回不仅仅是 200 的状态码。这里发生了什么?你经常会遇到这种情况:由于某种原因,CLR 无法自动确定所有返回状态码。但幸运的是,我们可以向适当的方法添加一个方法属性,告诉 CLR 该方法返回哪些返回码,如下所示:

[HttpGet("{flightNumber}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> GetFlightByFlightNumber(int flightNumber) { ... }

如果我们现在再次编译并启动服务,我们会看到 Swagger UI 已经改变(如图 14.15 所示)。

图 14.15 展示了在服务启动时生成的扩展的 GET /Flight/{FlightNumber} OpenAPI 信息,其中包含了正确的返回状态。在 OpenAPI 规范中真实地反映你的 API 非常重要,这样你才不会引导人们走上错误的道路。

看起来不错。为了确保其他两个端点(GET /FlightPOST /Booking/{flightNumber})有正确的信息,请继续为它们各自的端点方法添加适当的方法属性。之后,我们可以将我们生成的 OpenAPI 与 FlyTomorrow 提供的 OpenAPI 进行比较。

比较 OpenAPI 规范:get /flight

在 OpenAPI 规范方面,也许最容易比较的端点是GET /flight端点,如图 14.16 所示。它不接收正文(GET请求不能包含正文),并返回 200 状态码以及找到的任何数据,如果没有找到数据则返回 404,如果出现问题时则返回 500。

图 14.16 清楚地显示,在自动生成的 OpenAPI 规范中,对于GET /flight端点,所有返回代码都已考虑在内。

图 14.16 比较了 FlyTomorrow 和自动生成的 OpenAPI 规范中的GET /Flight。这是验证我们的工作是否符合客户规范的一种方法。

比较 openapi 规范:get /flight/{flightNumber}

我们要查看的第二个端点是GET /Flight/{flightNumber}端点。这个端点与GET /flight端点非常相似,但引入了路径参数的概念。让我们在图 14.17 中看看我们的生成的 OpenAPI 规范与 FlyTomorrow 规范相比如何。

图 14.17 比较 FlyTomorrow 和自动生成的 OpenAPI 规范中的GET /Flight/{flightNumber}。通过比较这两个规范,我们可以确信我们做得很好。

再次强调,返回的状态在 FlyTomorrow 和自动生成的 OpenAPI 规范中看起来是相同的。太好了,让我们继续到最后一个端点。

比较 openapi 规范:post /Booking/{flightNumber}

我们实现的最后一个端点是POST /Booking/{flightNumber}。这个端点结合了一个带有路径参数的POST请求和一个正文。端点方法必须对进入和离开服务的数据进行 JSON 反序列化和序列化。让我们看看我们是如何做到的(图 14.18)。

图 14.18 比较 FlyTomorrow 和自动生成的 OpenAPI 规范中的POST /Booking/{flightNumber}。如果我们没有比较这两个规范,我们可能会错过 404 Not Found 返回的需求,并向客户发送了错误的代码。

图 14.18 中的图像令人鼓舞,但在这个阶段我们看到的并不完全是我们想要的。我们可以看到 201 和 500 状态码映射正确,但结果是我们实现了 404 返回状态。根据 FlyTomorrow OpenAPI 规范,这种返回是不必要的。现在,保留这种返回状态有一些道理,因为 FlyTomorrow 的开发者可能希望拥有它。另一方面,通常最好严格遵循客户需求。在这个意义上,这本书中留给你的最后一个任务是修改BookingController以不返回 404(如果你卡住了,请查看源代码)。作为额外挑战:Swagger 具有指定与返回代码一起的描述的功能。研究和实现这一点。

14.3 路的尽头

恭喜!你做到了。你到达了这本书的结尾。我希望你充分享受了这些材料,并学到了一些新东西。如果你想继续你的 C#之旅,我建议你看看 Jon Skeet 的《C#深度》(第 4 版;Manning,2019 年),Dustin Metzgar 的《.NET Core 实战》(Manning,2018 年),Andrew Lock 的《ASP.NET Core 实战》(第 2 版;Manning,2021 年),以及 Jeffrey Richter 的《CLR via C#》(第 4 版;Microsoft Press,2012 年)。附录 E 包含了一本推荐的各种资源(书籍、网站、文章)的列表。

最后,我想给你留下一个来自著名人物 Donald Knuth 的引言:²

如果你发现自己几乎把所有时间都花在理论上,开始把一些注意力转向实际事物;这将改善你的理论。如果你发现自己几乎把所有时间都花在实践上,开始把一些注意力转向理论事物;这将改善你的实践

摘要

  • 来自 HTTP 请求的 JSON 数据是序列化的。这意味着数据不是我们可以直接使用的格式。在我们能够操作它之前,我们需要反序列化这些数据。

  • 要反序列化 JSON 数据,我们可以使用 [FromBody] 参数属性或实现自定义模型绑定器。反序列化数据使我们能够将传入的 JSON 或 XML 数据放入可用的数据结构中。

  • 您可以使用 IModelBinder 接口来实现自定义模型绑定器。当您想要对数据序列化到模型中的方式有更多控制时,这很有用。

  • 通过使用 ModelState.IsValid 检查,我们可以验证在模型绑定过程中没有发现错误。当与自定义模型绑定器结合使用时,这最有用,因为您可以在那种情况下精确地定义何时模型无效。

  • 您可以在启动时通过向配置中添加 Swagger 中间件来生成服务的 OpenAPI 规范。这有助于验收测试并确保您正在实现正确的端点。


^(1.)您通常不想对生产数据库进行测试。我们在本书中这样做的原因是,这允许我为您提供公开部署的数据库供您使用。

^(2.)唐纳德·克努特是一位美国计算机科学家,以其《计算机程序设计艺术》系列书籍而闻名。他是 1974 年 ACM 图灵奖(计算机世界的奥斯卡奖/普利策奖/诺贝尔奖)的获得者,对推广渐近符号学起到了关键作用,并在斯坦福大学担任荣誉教授。他的(出色的)个人网站是 www-cs-faculty.stanford.edu/~knuth/

附录 A 练习题答案

本附录提供了书中练习的答案以及答案的解释。

第二章:.NET 和它的编译方式

练习题号 答案 解释
2.1 d AmigaOS 是最初为 Amiga PC 开发的操作系统。它的最后一个主要版本发布于 2016 年。它不支持 .NET 5。
2.2 c
2.3 d
2.4 a
2.5 b, a
2.6 e
2.7 c
2.8 a

第四章:管理你的非托管资源!

练习题号 答案 解释
4.1 错误 属性可以应用于方法、类、类型、属性和字段。
4.2 正确
4.3 错误 枚举是用 enum 关键字创建的。
4.4 a, d 只有 Hufflepuff 才会在便利贴上写连接字符串。
4.5 b
4.6 a
4.7 c
4.8 正确
4.9 错误

第五章:使用 Entity Framework Core 设置项目和数据库

练习题号 答案 解释
5.1 b
5.2 a
5.3 a, d Tomcat 是 Java Servlet 的开源实现(类似于 WebHost);JVM 是 Java 虚拟机(类似于 CLR)。
5.4 正确
5.5 查看下表以获取答案
5.6 每个数据库实体一个

练习题 5.5 的答案:解决这个练习有多种方法,可以是一行代码。你可以从各种访问修饰符、方法名称和变量名称中选择。然而,有两个常数:返回类型需要是 integer 类型,并且我们需要返回两个整数输入参数的乘积,如下所示:

public int Product(int a, int b) => a * b;

第六章:测试驱动开发和依赖注入

练习题号 答案 解释
6.1 c 答案 B(“不要在两个不同的地方执行相同的逻辑”)描述了 Don’t Repeat Yourself(DRY)原则。
6.2 正确 然而,在书中,我们使用 TDD-lite,有时会打破这个规则。
6.3 错误 测试类必须具有 public 访问修饰符,才能被测试运行器使用。
6.4 c
6.5 错误 LINQ 允许我们使用类似 SQL 的语句和方法对集合进行查询。
6.6 a
6.7 b
6.8 正确
6.9 a
6.10 正确

6.2.8 节中找到的练习的解决方案:

[TestMethod]
public async Task CreateCustomer_Success()
{
    CustomerRepository repository = new CustomerRepository();
    Assert.IsNotNull(repository);

    bool result = await repository.CreateCustomer("Donald Knuth");
    Assert.IsTrue(result);
}

[TestMethod]
public async Task CreateCustomer_Failure_NameIsNull()
{
    CustomerRepository repository = new CustomerRepository();
    Assert.IsNotNull(repository);

    bool result = await repository.CreateCustomer(null);
    Assert.IsFalse(result);
}

[TestMethod]
public async Task CreateCustomer_Failure_NameIsEmptyString()
{
    CustomerRepository repository = new CustomerRepository();
    Assert.IsNotNull(repository);
    bool result = await repository.CreateCustomer(string.Empty);
    Assert.IsFalse(result);
}

[TestMethod]
[DataRow('#')]
[DataRow('$')]
[DataRow('%')]
[DataRow('&')]
[DataRow('*')]
public async Task CreateCustomer_Failure_NameContainsInvalidCharacters(char
➥ invalidCharacter)
{
    CustomerRepository repository = new CustomerRepository();
    Assert.IsNotNull(repository);

    bool result = await repository.CreateCustomer("Donald Knuth" + invalidCharacter);
    Assert.IsFalse(result);
}

第七章:比较对象

练习题号 答案 解释
7.1 编写一个使用异常发现方法属性的单元测试。
7.2 b
7.3 Exception 您也可以从一个不同的 Exception(自定义或非自定义)派生一个自定义异常,该异常继承自 Exception 类。
7.4 c A 和 B 可以是集合底层类型的默认值,因此在某些情况下是正确的。
7.5 c 当比较值类型时,相等运算符比较它们的值。
7.6 a 当比较引用类型时,相等运算符比较它们的内存地址。
7.7 正确
7.8 a 当重载运算符时,你还需要重载其逆运算符。
7.9 错误 计算机中不存在完美的随机性。
7.10 错误 计算机中不存在完美的随机性。
7.11 正确

第八章:存根、泛型和耦合

练习编号 答案 说明
8.1 c
8.2 错误 两个高度相互依赖的类表示紧密耦合。
8.3 a
8.4 c
8.5 错误 字符串是不可变的。对字符串所做的任何更改都会导致新的内存分配,并将结果字符串存储在内存中的相应位置。
8.6 错误 你必须 重写 基类的方法。
8.7 a DRY 原则代表“不要重复自己”原则。Phragmén–Lindelöf 原理处理在无界域上全纯函数的有界性。
8.8 b
8.9 c
8.10 错误 泛型可以与类、方法和集合一起使用。
8.11 错误
8.12 错误
8.13 正确
8.14 b
8.15 错误 如果你没有在 switch 语句中声明 default 情况,并且没有其他情况匹配,则 switch 语句内不会执行任何操作。

第九章:扩展方法、流和抽象类

练习编号 答案 说明
9.1 b
9.2 a
9.3 错误 你可以使用 [DataRow] 属性方法与任意数量的数据点一起使用。
9.4 b
9.5 错误
9.6 错误 抽象类可以包含抽象方法和常规方法。
9.7 错误
9.8 正确

第十章:反射和模拟

练习编号 答案 说明
10.1 b
10.2 错误 当使用 ORM 时,仓库层通常通过数据库访问层与数据库交互。
10.3 正确
10.4 正确
10.5 a
10.6 c 我们永远不希望在不担心副作用的情况下删除代码。当遇到注释掉的代码时,要尽职尽责地找出它存在的原因。最好有一个非常好的理由,否则就删除它。绝大多数情况下,你可以无忧地删除代码。
10.7 d
10.8 a
10.9 正确
10.10 c
10.11 正确 虽然控制器和仓库之间仍然存在一些耦合,但与控制器直接调用仓库相比,这是一种更松散的耦合。
10.12 错误 这就是存根的功能。
10.13 错误 InternalsVisibleTo 允许你将程序集的内部结构暴露给另一个程序集。
10.14 c
10.15 正确
10.16 b
10.17 a [MethodImpl(MethodImplOptions.NoInlining)] 只能应用于方法。

第十一章:重新审视运行时类型检查和错误处理

练习编号 答案 说明
11.1 错误
11.2 c
11.3 a 只允许服务调用存储库。存储库不应该调用另一个存储库。
11.4 正确
11.5 b
11.6 错误 抛弃操作符仍然可能导致内存分配。
11.7 a 第一个catch块被进入,因为ItemSoldOutException可以用作Exception类型。
11.8 a

第十二章:使用 IAsyncEnumerable和 yield return

练习题号 答案 说明
12.1 正确
12.2 错误 尽管如此,我们可能需要实现InventoryService
12.3 b
12.4 a
12.5 Dragonfruit类可以设置IsFruit属性,因为IsFruit属性有一个protected访问修饰符。具有protected访问修饰符的属性可以被拥有它的类及其子类(派生类)访问。Dragonfruit类是从Fruit类派生出来的。
12.6 正确
12.7 正确
12.8 错误 当你向结构体添加构造函数时,需要设置结构体上存在的所有属性,否则编译器将不会编译你的代码。

第十三章:中间件、HTTP 路由和 HTTP 响应

练习题号 答案 说明
13.1 正确
13.2 c
13.3 a
13.4 正确
13.5 c
13.6 b

附录 B 清洁代码检查清单

当你遇到不熟悉的代码或编写新代码时,可以使用这个简短的检查清单。这个清单并不全面,只是你自己的研究的一个起点。

一般

  • 我的代码读起来像叙述。我为人类编写代码,而不是为机器。

  • 只有在必要时,我才记录我的代码。我的代码应该能够自我表达。

  • 我提供了关于如何构建和发布我的代码库的明确说明。在适当的地方,我提供了工作构建脚本/Makefile 或 CI/CD 设置说明。

  • 除非有非常好的理由,否则我使用原生功能而不是实现自己的库。

  • 我的代码在设计模式、文档和命名约定上保持一致。我不会在开发过程中改变事物并违反既定模式。

  • 我已经给我的应用程序添加了日志记录功能,这样当事情出错时,我或其他开发者可以调试。

  • 我的类具有最严格的访问修饰符。

  • 我的班级命名准确。

  • 我的类只对特定对象执行操作,因此遵循单一职责原则。

  • 我的类位于我的项目中的正确文件夹中。

  • 如果我在实现我的类时遇到困难,我会退一步,简要描述一下类的功能和预期功能。这种重新聚焦可以帮助编写更干净的代码。如果我的类应该做多件事,我会将其拆分。

方法

  • 我的函数具有最严格的访问修饰符。

  • 我的函数命名准确,并且正确地描述了其中的逻辑(不遗漏任何内容)。

  • 我的函数只执行一个通用操作或从与其操作相关的其他函数收集信息。它遵循单一职责原则。

  • 如果我的函数有公共访问修饰符,我不会在该函数内执行任何操作。公共方法调用其他较小的方法并组织输出。

  • 我的方法有单元测试支持。单元测试应该覆盖主要成功和失败逻辑分支。

变量、字段和属性(VFP)

  • 我的 VFP 类型是最抽象的类型。如果我可以使用接口而不是具体类型,我就使用接口。这促进了多态性和 Liskov 替换原则的使用。

  • 我没有将任何“魔法数字”分配给变量。

  • 在可能的情况下,我将我的 VFP 限制在尽可能紧密的访问修饰符上。如果一个 VFP 可以被设置为只读,我就将其设置为只读。如果一个 VFP 可以被设置为常量,我就将其设置为常量。

  • 我总是验证我的输入参数。这可以保护我不受不想要的空指针异常和操作无效状态的数据的影响。

  • 在适当的情况下,我使用枚举和常量而不是字符串字面量。

测试

  • 我总是给我的代码提供适当的单元测试。

  • 在可能的情况下,我遵循测试驱动开发。

  • 我并不关注代码覆盖率。我的测试目标是防止意外副作用,并验证我对需求和现有代码的假设。

  • 如果我的某个更改破坏了测试,我会修复测试。

  • 我总是编写最少的代码来满足所有测试。任何多余的行都会增加维护代码的量。

附录 C 安装指南

本附录包含以下内容的快速安装指南:

  • .NET Framework 4.x

  • .NET 5

  • Visual Studio

  • Visual Studio for Mac

  • Visual Studio Code

.NET Framework 4.x(仅限 Windows)

.NET Framework 仅在 Windows 上受支持。要安装 .NET Framework 4 的最新版本,请访问 dotnet.microsoft.com/download/dotnet-framework,并从发布列表中选择顶部选项。请注意,你需要运行至少 .NET Framework 4.8 才能支持本书提供的源代码。当你点击一个发布版时,你将被带到该发布版的下载链接页面。点击“下载 .NET Framework 4.[版本] 开发者包”。这将下载一个安装程序,你可以运行它来在你的机器上安装 .NET Framework。

.NET 5 (Windows, Linux 和 macOS)

.NET 5 支持 Windows、Linux 和 macOS(仅限 64 位)。要安装 .NET 5 的最新版本,请导航至 dotnet.microsoft.com/download/dotnet/5.0,并选择最新的 SDK 发布版。在撰写本文时,最新发布版是 SDK 5.0.203。当你点击适当的发布版时,你将被引导到相应发布版的下载页面。如果你希望这样做,还有一个选项可以下载所有平台的二进制文件。运行下载的安装程序将在你的平台上安装 .NET 5。

Visual Studio (Windows)

Visual Studio 是 Windows 上开发 C# 的首选 IDE。Visual Studio 有以下三种版本:

  • 社区版

  • 专业版

  • 企业版

社区版是免费的,允许你开发(商业)软件,除非你是拥有超过 250 台电脑或年收入超过 100 万美元的组织(并为该组织工作)。三个版本之间有一些功能差异,但本书中我们做的所有事情都可以使用 Visual Studio 社区版完成。

要下载 Visual Studio Community,请访问 visualstudio.microsoft.com/vs/,并在“下载 Visual Studio”下拉列表中选择 Visual Studio Community。请确保版本至少为 Visual Studio 2019 v16.7,因为 .NET 5 在旧版本的 Visual Studio(包括前一年的版本,如 Visual Studio 2017)上无法正确运行。当你启动安装程序时,你会看到一系列 Visual Studio 安装选项。这些选项被称为“工作负载”,对于本书,你需要安装以下这些:

  • ASP.NET 和 Web 开发

  • .NET Core 跨平台开发

通过选择工作负载,右下角将启用一个下载按钮。点击此按钮,Visual Studio 将安装所选的工作负载。请注意,Visual Studio 通常需要超过 8 GB 的空间来安装。

Visual Studio for Mac

Visual Studio for Mac 是 Visual Studio 的一个独立产品。它是微软将 Visual Studio 体验带到 macOS 的一次尝试。要安装 Visual Studio for Mac,请访问visualstudio.microsoft.com/vs/mac/。点击下载 Visual Studio for Mac 按钮,并运行下载的安装程序。现在您可以使用 Visual Studio for Mac 了。在 macOS 上还可以考虑的其他 IDE 有 VS Code 和 JetBrains 的 Rider。请确保您始终使用最新版本的 Visual Studio for Mac。

Visual Studio Code (Windows, Linux, macOS)

您不需要使用 Visual Studio 来阅读这本书或进行日常工作。从理论上讲,您可以使用任何支持 C#的编辑器并通过命令行进行编译。在实践中,这可能会有些痛苦。微软在 Visual Studio Code 中开发了一个轻量级的 Visual Studio 替代品。它是免费的,并且更像是一个文本编辑器而不是一个完整的 IDE。要下载 Visual Studio Code,请访问code.visualstudio.com/,并点击[平台]的下载按钮。运行安装程序,然后 Visual Studio Code 就准备好使用了。当您在 Visual Studio Code 中第一次编写 C#代码(或打开 C#解决方案)时,它将提示您下载 C#包。接受此提示,您将能够使用 Visual Studio Code(或 VS Code)进行 C#开发。

在您的本地机器上运行飞剪航空公司数据库

如果您不想(或不能)在书中使用飞剪航空公司项目的部署数据库,您可以在本地 SQL Server 实例中运行 SQL 数据库。

要这样做,您需要安装以下软件:

  • SQL Server Developer Edition

  • Microsoft SQL Server Management Studio

要下载 SQL Server,请访问www.microsoft.com/en-us/sql-server/sql-server-downloads,并下载 SQL Server 的开发者版本。安装 SQL Server 后,您可以继续从docs.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms?view=sql-server-ver15安装 SQL Server Management Studio (SSMS)。SQL Server Developer 和 SSMS 都是免费的。

在安装 SSMS 之后,如果您还没有这样做,请通过 SQL Server 创建一个新的本地 SQL Server 实例。要安装一个新的本地 SQL Server 实例,打开与您的 SQL Server Developer Edition 应用程序一起安装的 SQL Server 安装中心。在安装中心中,点击安装 > 新建 SQL Server 独立安装或向现有安装添加功能。

按照安装向导进行操作,注意您为 SQL Server 实例提供的登录凭证。您需要这些信息来连接到 SQL Server 实例。

现在启动 SSMS。你会看到一个连接对话框,你可以在这里浏览你的 SQL 实例并填写你的连接信息。连接后,你会看到 SSMS 的主屏幕。在这里,在对象资源管理器中,右键单击数据库,然后选择导入数据层应用程序。

点击导入数据层应用程序的上下文菜单选项会弹出一个向导,允许你导入提供的飞剪航空公司数据库。在导入设置窗口中,选择从本地磁盘导入,并浏览到数据库文件(FlyingDutchmanAirlinesDatabase.bacpac)。在以下窗口中,如果你愿意,可以重命名数据库。完成向导后,数据库应该已导入并准备好与本书中的代码一起使用。

有可能你的数据库导入会失败。这通常是因为 Microsoft Azure 中包含的数据库设置与 SSMS 不匹配。如果导入失败,请在你的 SQL 实例(为你自动生成)的主要数据库上运行以下命令:

sp_configure 'contained database authentication', 1; GO RECONFIGURE; GO;

有些人报告说,围绕 GO 关键字的语法问题有时会出现。如果你遇到了上一个命令的问题,这个下一个命令应该对你有效:

EXEC sp_configure 'contained database authentication', 1; RECONFIGURE;

最后一点注意事项:无论何时你在书中遇到连接字符串,请确保将其替换为你的本地 SQL 实例数据库的正确连接字符串。

附录 D OpenAPI FlyTomorrow

本附录中的 OpenAPI 规范反映了我们从 FlyTomorrow 收到的 OpenAPI 规范。此 OpenAPI 规范指导了全书对飞荷兰人航空公司服务的重构和重写。

OpenAPI FlyTomorrow.com
openapi: 3.0.1
info:
  title: FlyTomorrow required endpoints
  description: This OpenAPI file specifies the required endpoints as per the contract
    between FlyTomorrow.com and Flying Dutchman Airlines
  version: 1.0.0
servers:
- url: https://zork.flyingdutchmanairlines.com/v1
tags:
- name: flight
  description: Access to available flights
- name: booking
  description: Request bookings for available flights
paths:
  /flight:
    get:
      tags:
      - flight
      summary: Get all available flights
      description: Returns all available flights
      operationId: getFlights
      responses:
        200:
          description: ""
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Flight'
        404:
          description: No flights found
          content: {}
        500:
          description: Internal error
          content: {}
  /flight/{flightNumber}:
    get:
      tags:
      - flight
      summary: Find flight by flight number
      description: Returns a single flight
      operationId: getFlightByFlightNumber
      parameters:
      - name: flightNumber
        in: path
        description: Number of flight to return
        required: true
        schema:
          type: integer
          format: int32
      responses:
        200:
          description: ""
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Flight'
        400:
          description: Invalid flight number supplied
          content: {}
        404:
          description: Flight not found
          content: {}
  /booking/{flightNumber}:
    post:
      tags:
      - booking
      summary: requests a booking for a flight
      description: Request for a flight to be booked
      operationId: bookFlight
      parameters:
      - name: flightNumber
        in: path
        description: Number of flight to book
        required: true
        schema:
          type: integer
          format: int64
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Customer'
        required: true
      responses:
        201:
          description: successful operation
        500:
          description: Internal error
          content: {}
components:
  schemas:
    Airport:
      type: object
      properties:
        city:
          type: string
        code:
          type: string
    Customer:
      type: object
      properties:
        firstName:
          type: string
        lastName:
          type: string
    Flight:
      type: object
      properties:
        flightNumber:
          type: integer
          format: int32
        origin:
          $ref: '#/components/schemas/Airport'
        destination:
          $ref: '#/components/schemas/Airport'

附录 E 阅读清单

.NET Core

  • 梅茨加,达斯汀,.NET Core 实战(Manning,2018 年)。

.NET Standard

ASP.NET

  • 洛克,安德鲁,ASP.NET 实战(第 2 版;Manning,2020 年)。

C#

  • 标准 ECMA-334 C#语言规范。ECMA 标准规范总是比最新发布的语言版本落后几个版本。可以在ecma-international.org/publications/standards/Ecma-334.htm找到。

  • 沃格纳,比尔,有效的 C#(第 2 版;Microsoft Press,2016 年)。

  • 斯基特,乔恩,C#深入(第 4 版;Manning,2019 年)。

COM/Interop

常见语言运行时(CLR)

  • 里希特,杰弗里,CLR 通过 C#(第 4 版;Microsoft Press,2012 年)。

编译器

  • 阿霍,阿尔弗雷德·V.,莫妮卡·S.拉姆,拉维·塞西,以及杰弗里·D.乌尔曼,编译器:原理、技术和工具(第 2 版;Pearson Education,2007 年)。

并发编程

  • 达菲,乔,Windows 上的并发编程(Addison-Wesley,2008 年)。

数据库和 SQL

  • 康奈尔大学,关系数据库虚拟研讨会cvw.cac.cornell.edu/databases/

  • 高桥,真奈美,浅间伸子,以及 Trend-Pro Co., Ltd.,数据库漫画指南(No Starch Press,2009 年)。

  • 汉特,安德鲁,以及戴夫·托马斯,实用程序员(Addison Wesley,1999 年)。

依赖注入

设计模式

  • 高曼,埃里克,理查德·赫尔姆,拉尔夫·约翰逊,以及约翰·弗利斯,设计模式:可重用面向对象软件元素(Addison-Wesley,1994 年)。

  • 马丁,罗伯特·C.,以及迈克·马丁,敏捷原则、模式和 C#实践(Prentice Hall,2006 年)。

  • Freeman, Eric,Elisabeth Robson,Kathy Sierra,和 Bert Bates, 《Head First:设计模式》(O’Reilly,2004 年)。

ENIAC

  • Dyson, George, 《图灵的 cathedral:数字宇宙的起源》(Vintage,2012 年)。

泛型

  • Skeet, Jon, 《C#深入》(第 4 版;Manning,2019 年)。

图论

  • Trudeau, Richard J., 《图论导论》(第 2 版;Dover Publications,1994 年)。

哈希

  • Wong, David, 《现实世界密码学》(Manning,2021 年)。

  • Knuth, Donald, 《计算机编程艺术第 3 卷:排序与搜索》(第 2 版;Addison-Wesley,1998 年)。

HTTP

  • Pollard, Barry, 《HTTP/2 实战》(Manning,2019 年)。

  • Berners-Lee, Tim, 《信息管理:一个提案》(法国欧洲核子研究中心;CERN,1990 年)。

  • Berners-Lee, Tim,Roy Fielding,和 Henrik Frystyk, 《超文本传输协议—HTTP/1.0》(互联网工程任务组;IETF,1996 年)。

Kubernetes 和 Docker

  • Lukša, Marko, 《Kubernetes 实战》(第 2 版;Manning,2021 年)。

  • Stoneman, Elton, 《一个月午餐时间学会 Docker》(Manning,2020 年)。

  • Davis, Ashley, 《使用 Docker、Kubernetes 和 Terraform 启动微服务》(Manning,2020 年)。

数学

  • Knuth, Donald, 《计算机编程艺术,第 1 卷:基础算法》(Addison Wesley Longman,1977 年)。

  • Hofstadter, Douglas R., 《哥德尔、艾舍尔、巴赫:永恒的金色螺旋》(Basic Books,1977 年)。

  • Alama, Jesse,和 Johannes Korbmacher, 《斯坦福哲学百科全书,Lambda 演算》(plato.stanford.edu/entries/lambda-calculus/)。

  • Conery, Rob, 《冒牌者手册:自学程序员 CS 入门》(Rob Conery,2017 年)。

MATLAB

  • Hamming, Richard, 《科学家和工程师的数值方法》(Dover Publications,1987 年)。

  • Gilat, Amos, 《MATLAB:应用导论》(第 6 版;Wiley,2016 年)。

微服务

  • Gammelgaard, Christian Horsdal, 《.NET Core 中的微服务》(Manning,2020 年)。

  • Newman, Sam, 《构建微服务:设计细粒度系统》(O’Reilly Media,2015 年)。

  • Richardson, Chris, 《微服务模式》(Manning,2018 年)。

  • Siriwardena, Prabath,和 Nuwan Dias, 《微服务安全实战》(Manning,2019 年)。

指令集和汇编

红黑树

  • Cormen, Thomas H.,Charles E. Leiserson,Ronald L. Rivest,和 Clifford Stein, 《算法导论》,第十三章,“红黑树”(第 3 版;麻省理工学院,2009 年)。

  • Galles, David,红黑树可视化;www.cs.usfca.edu/~galles/ visualization/RedBlack.html(旧金山大学)。

  • Wilt, Nicholas, 《C++经典算法:排序、搜索和选择的全新方法》(Wiley,1995 年)。

重构

  • Fowler, Martin,《重构:现有代码设计改进》 (Addison-Wesley, 1999).

关注点分离

  • Dijkstra, Edsger,《在《计算:个人视角选集》中科学思想的作用》 (Springer-Verlag, 1982).

  • Martin, Robert C.,《代码整洁之道:敏捷软件开发工艺手册》 (Prentice-Hall, 2008).

  • Constantine, Larry,以及 Edward Yourdon,《结构化设计:计算机程序和系统设计学科的基础》 (Prentice-Hall, 1979).

单一职责原则

Liskov 原则

  • Liskov, Barbara H.,以及 Jeannette M. Wing,《子类型的行为概念》 (ACM Transactions on Programming Languages and Systems [TOPLAS], 1994).

单元测试

  • Khorikov, Vladimir,《单元测试原则、实践和模式》 (Manning, 2020).¹

  • Osherove, Roy,*《单元测试的艺术》(第 2 版;Manning, 2013)。

  • Kaner, Cem,James Bach,以及 Bret Pettichord,《软件测试经验教训:情境驱动方法》 (Wiley, 2008).

Visual Studio

  • Johnson, Bruce,《专业 Visual Studio 2017》 (Wrox, 2017).

  • ——— 《Visual Studio 2019 必备:使用容器、Git 和 Azure 工具提升开发效率》 (Apress, 2020).²


^(1.)作者曾是 Vladimir Khorikov 的《单元测试原则、实践和模式》的技术审稿人之一。

^(2.)作者曾是 Bruce Johnson 的《Visual Studio 2019 必备:使用容器、Git 和 Azure 工具提升开发效率》 (Apress, 2020)的技术审稿人。

posted @ 2025-11-09 18:03  绝不原创的飞龙  阅读(12)  评论(0)    收藏  举报