C#9 和 .NET5 软件架构(四)
原文:
zh.annas-archive.org/md5/83D8F5A1D11ACA866E980121BEEF9AAA译者:飞龙
第十二章:理解软件解决方案中的不同领域
本章专门介绍了一种称为领域驱动设计(DDD)的现代软件开发技术,这是由 Eric Evans 首次提出的。虽然 DDD 已经存在了 15 年以上,但由于其解决了两个重要问题的能力,它在过去几年取得了巨大成功。
第一个问题是对复杂系统进行建模。没有一个专家对整个领域有深入的了解;相反,这种知识分散在几个人之间。正如我们将看到的,DDD 通过将整个 CI/CD 周期分成独立的部分分配给不同的团队来解决这个问题。这样,每个团队可以专注于特定的知识领域,只与该领域的专家进行互动。
DDD 很好地解决了第二个问题,即涉及多个开发团队的大型项目。项目被分成几个团队有很多原因,最常见的原因是团队的规模以及所有成员具有不同的技能和/或不同的地点。事实上,经验已经证明,超过 6-8 人的团队并不高效,而不同的技能和地点阻止了紧密的互动。团队分裂阻止了所有参与项目的人之间的紧密互动。
反过来,上述两个问题的重要性在过去几年中不断增加。软件系统一直占据着每个组织内大量的空间,并且变得越来越复杂和地理分布。与此同时,对频繁更新的需求增加,以便这些复杂的软件系统能够适应快速变化的市场需求。
这些问题导致了更复杂的 CI/CD 周期的构想和复杂分布式架构的采用,这些架构可以利用可靠性、高吞吐量、快速更新以及逐渐演变传统子系统的能力。是的——我们谈论的是微服务和基于容器的架构,我们在第五章、将微服务架构应用于企业应用程序中进行了分析。
在这种情况下,常见的做法是实现与快速 CI/CD 周期相关的复杂软件系统,这些系统始终需要���多的人来进行演进和维护。反过来,这就产生了对适用于高复杂性领域以及对几个松散耦合的开发团队进行合作的技术的需求。
在本章中,我们将分析与 DDD 相关的基本原则、优势和常见模式,以及如何在我们的解决方案中使用它们。更具体地,我们将涵盖以下主题:
-
什么是软件领域?
-
理解领域驱动设计
-
使用 SOLID 原则来映射您的领域
-
用例——理解用例的领域
让我们开始吧。
技术要求
本章需要安装了所有数据库工具的 Visual Studio 2019 免费社区版或更高版本。
本章中的所有代码片段都可以在与本书相关的 GitHub 存储库中找到,github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5。
什么是软件领域?
正如我们在第二章、非功能性需求和第三章、使用 Azure DevOps 记录需求中讨论的,从领域专家到开发团队的知识传递在软件设计中起着基础性作用。开发人员试图与专家沟通,并用领域专家和利益相关者能够理解的语言描述他们的解决方案。然而,通常情况下,同一个词在组织的不同部分有不同的含义,在不同的上下文中,看似相同的概念实体在不同的情境中完全不同。
例如,在我们的 WWTravelClub 用例中,订单支付和包裹处理子系统使用完全不同的客户模型。订单支付通过支付方式和货币、银行账户和信用卡来描述客户,而包裹处理更关注过去访问和/或购买的位置和包裹、用户的偏好以及其地理位置。此外,订单支付涉及各种概念,使用的语言可以粗略地定义为银行语言,而包裹处理使用的语言则是旅行社/运营商的典型语言。
应对这些差异的经典方式是使用一个称为客户的唯一抽象实体,它投影到两个不同的视图——订单-支付视图和包裹处理视图。每个投影操作都从客户抽象实体中获取一些操作和一些属性,并更改它们的名称。由于领域专家只给我们提供了投影视图,作为系统设计师的主要任务是创建一个能够解释所有视图的概念模型。下图显示了如何处理不同的视图:
图 12.1:创建一个唯一模型
经典方法的主要优势在于我们对领域数据有一个独特和一致的表示。如果成功构建了这个概念模型,所有操作都将有一个正式的定义和目的,整个抽象将是整个组织应该工作的合理化,可能会突出和纠正错误,并简化一些程序。
然而,这种方法有什么不足之处呢?
对于一个小组织来说,快速采用新的单体数据模型可能会产生可接受的影响,当软件面向整个组织的一小部分,或者当软件自动化了数据流的足够小的百分比时。然而,如果软件成为复杂的地理分布式组织的支柱,急剧的变化变得不可接受和不可行。复杂结构的公司需要从旧组织逐渐过渡到新组织。反过来,只有在旧数据模型可以与新数据模型共存,并且组织的各个组成部分都被允许以自己的速度变化时,逐渐过渡才是可能的,也就是说,组织的每个组成部分都可以独立于其他部分发展。
此外,随着软件系统复杂性的增加,还会出现其他一些问题,例如:
-
一致性问题:当我们将这些任务分解为更小、松散耦合的任务时,要达到数据的统一一致视图变得更加困难,因为我们无法保留复杂性。
-
更新困难:随着复杂性的增长,系统需要频繁变更,但更新和维护一个唯一的全局模型是相当困难的。此外,由于系统中某些小部分的变更引入的错误可能通过唯一共享的模型传播到整个组织。
-
团队组织问题:系统建模必须分配给几个团队,并且只能给予分离的松散耦合任务;如果两个任务紧密耦合,它们需要分配给同一个团队。
-
并行问题:需要转向基于微服务的架构,这使得唯一数据库的瓶颈变得更加不可接受。
-
语言问题:随着系统的增长,我们需要与更多的领域专家进行沟通,每个人说着不同的语言,对数据模型有不同的看法。因此,我们需要将我们唯一模型的属性和操作从更多的语言翻译成/翻译出来,以便与他们进行沟通。
随着系统的增长,处理具有数百/数千个字段的记录变得更加低效。这种低效性源于数据库引擎对具有多个字段的大记录的低效处理(内存碎片化,与太多相关索引的问题等)。然而,主要的低效性发生在对象关系映射(ORMs)和业务层,它们被迫在其更新操作中处理这些大记录。事实上,虽然查询操作通常只需要从存储引擎中检索出的少数字段,但更新和业务处理涉及整个实体。
随着数据存储子系统中的流量增长,我们需要在所有数据操作中实现读取和更新/写入并行性。正如我们在第九章中讨论的如何在云中选择数据存储,读取并行性可以通过数据复制轻松实现,而写入并行性则需要分片,对于一个独特的单片和紧密连接的数据模型来说,分片是困难的。
这些问题是 DDD 在过去几年取得成功的原因,因为它们被更复杂的软件系统所特征,这些系统成为整个组织的支柱。DDD 的基本原则将在下一节中详细讨论。
理解领域驱动设计
DDD 是关于构建一个独特的领域模型,将所有视图保持为单独的模型。因此,整个应用程序领域被分割成更小的领域,每个领域都有一个单独的模型。这些单独的领域被称为有界上下文。每个领域都以专家所使用的语言为特征,并用于命名所有领域概念和操作。因此,每个领域定义了一个专家和开发团队都使用的通用语言,称为普遍语言。不再需要翻译,如果开发团队使用接口作为代码的基础,领域专家能够理解和验证它们,因为所有操作和属性都用专家使用的相同语言表达。
在这里,我们摆脱了繁琐的唯一抽象模型,但现在我们有几个分离的模型,我们需要以某种方式进行关联。DDD 建议将处理所有这些分离的模型,即所有有界上下文,如下所示:
-
每当语言术语的含义发生变化时,我们需要添加有界上下文边界。例如,在 WWTravelClub 的用例中,订单支付和套餐处理属于不同的有界上下文,因为它们赋予了客户这个词不同的含义。
-
我们需要明确表示有界上下文之间的关系。不同的开发团队可能在不同的有界上下文上工作,但每个团队必须清楚地了解其所在的有界上下文与所有其他模型之间的关系。因此,这些关系在一个共享给每个团队的唯一文档中表示。
-
我们需要保持所有有界上下文与 CI 保持一致。会议被组织起来,简化的系统原型被构建,以验证所有有界上下文是否一致地发展,即所有有界上下文是否能够集成到所需的应用程序行为中。
以下图表显示了我们在前一节中讨论的 WWTravelClub 示例在采用 DDD 后的变化:
图 12.2:DDD 有界上下文之间的关系
每个有界上下文的客户实体之间存在关系,而套餐处理有界上下文的购买实体与支付相关。确定在各个有界上下文中映射到彼此的实体是正式定义代表上下文之间所有可能通信的接口的第一步。
例如,从前面的图表中,我们知道购买后会进行付款,因此我们可以推断订单-付款有界上下文必须有一个为特定客户创建付款的操作。在此领域中,如果新客户不存在,则会创建新客户。购买后立即触发付款创建操作。由于购买商品后会触发多个操作,我们可以使用我们在第十一章,设计模式和.NET 5 实现中解释的发布者/订阅者模式来实现与购买事件相关的所有通信。这在 DDD 中被称为领域事件。使用事件来实现有界上下文之间的通信非常常见,因为它有助于保持有界上下文之间的松耦合。
一旦在有界上下文界面中定义的事件或操作的实例跨越上下文边界,它立即被转换为接收上下文的普遍语言。在输入数据开始与其他领域实体交互之前执行此转换非常重要,以避免其他领域的普遍语言被额外的上下文术语污染。
每个有界上下文实现必须包含一个完全以有界上下文普遍语言(类和接口名称以及属性和方法名称)表达的数据模型层,不受其他有界上下文普遍语言的污染,也不受编程技术内容的污染。这对于确保与领域专家的良好沟通以及确保领域规则被正确地转换为代码以便领域专家轻松验证是必要的。
当通信语言与目标普遍语言之间存在严重不匹配时,会在接收有界上下文边界添加一个反腐层。这个反腐层的唯一目的是执行语言翻译。
包含所有有界上下文的表示,以及有界上下文的相互关系和接口定义的文档称为上下文映射。上下文之间的关系包含组织约束,指定了在不同有界上下文上工作的团队之间所需的合作方式。这些关系不限制有界上下文的接口,但会影响它们在软件 CI/CD 周期中可能发展的方式。它们代表了团队合作的模式。最常见的模式如下:
-
合作伙伴:这是 Eric Evans 建议的最初模式。其想法是两个团队在交付方面相互依赖。换句话说,他们共同决定,并在软件 CI/CD 周期中如有需要,更改有界上下文的相互通信规范。
-
客户/供应商开发团队:在这种情况下,一个团队充当客户,另一个充当供应商。两个团队定义了有界上下文客户端的接口以及一些自动化验收测试来验证它。之后,供应商可以独立工作。当客户的有界上下文是唯一激活的部分,调用其他有界上下文公开的接口方法时,此模式适用。这对于订单-付款和包裹处理上下文之间的交互是足够的,其中订单-付款充当供应商,因为其功能从属于包裹处理的需求。当可以应用此模式时,它完全解耦了两个有界上下文。
-
顺从者:这类似于客户/供应商,但在这种情况下,客户方接受了供应方强加的接口,没有谈判阶段。这种模式对其他模式没有优势,但有时我们被迫处于模式所描述的情况,因为供应方的有界上下文是在无法进行太多配置/修改的现有产品中实现的,或者因为它是我们不想修改的遗留子系统。
值得指出的是,如果结果的有界上下文松散耦合,那么有界上下文的分离才是有效的;否则,通过将整个系统分解为子部分而获得的复杂性减少将被协调和通信过程的复杂性所淹没。
然而,如果有界上下文是根据语言标准来定义的,也就是说,每当普遍语言发生变化时,就会添加有界上下文边界,那么实际情况应该是这样。事实上,不同的语言可能会由于组织子部分之间的松散交互而产生,因为每个子部分内部的紧密交互越多,与其他子部分的松散交互越多,每个子部分最终就会定义和使用自己的内部语言,这与其他子部分使用的语言不同。
此外,所有人类组织都可以通过演变成松散耦合的子部分来增长,原因是复杂软件系统可以作为松散耦合的子模块的合作来实现:这是人类应对复杂性的唯一方式。由此,我们可以得出结论,复杂的组织/人工系统总是可以分解成松散耦合的子部分。我们只需要了解如何。
除了我们迄今提到的基本原则之外,DDD 还提供了一些基本原语来描述每个有界上下文,以及一些实现模式。虽然有界上下文原语是 DDD 的一个组成部分,但这些模式是我们在实现中可以使用的有用启发式方法,因此一旦我们选择 DDD 采用,它们在一些或所有有界上下文中的使用并不是强制性的。
在下一节中,我们将描述原始和模式。
实体和值对象
DDD 实体代表具有明确定义身份的领域对象,以及定义在它们上面的所有操作。它们与其他更经典的方法中的实体��没有太大的区别。此外,DDD 实体是存储层设计的起点。
主要区别在于 DDD 强调它们的面向对象性质,而其他方法主要将它们用作记录,其属性可以在不受太多约束的情况下被编写/更新。另一方面,DDD 强制将强大的 SOLID 原则应用于它们,以确保只有特定信息封装在其中,并且只有特定信息可以从外部访问,以规定允许对其进行哪些操作,并设置哪些业务级别的验证标准适用于它们。
换句话说,DDD 实体比基于记录的方法中的实体更丰富。在其他方法中,操作操纵实体的操作是在代表业务和/或领域操作的类之外定义的。在 DDD 中,这些操作被移动到实体定义中作为它们的类方法。原因是它们提供更好的模块化,并将相关的软件块放在同一个地方,以便可以轻松地进行维护和测试。
出于同样的原因,业务验证规则被移至 DDD 实体内部。DDD 实体验证规则是业务级别的规则,因此不应与数据库完整性规则或用户输入验证规则混淆。它们通过编码所代表的对象必须遵守的约束,有助于实体表示领域对象的方式。在.NET(Core)中,可以使用以下技术进行业务验证:
-
在修改实体的所有类方法中调用验证方法
-
将验证方法挂钩到所有属性设置器
-
使用自定义验证属性装饰类和/或其属性,然后在每次修改实体时调用
System.ComponentModel.DataAnnotations.Validator类的TryValidateObject静态方法
一旦检测到验证错误,必须以某种方式处理;也就是说,当前操作必须中止,并且错误必须报告给适当的错误处理程序。处理验证错误的最简单方法是抛出异常。通过这种方式,可以轻松实现两个目的,并且可以选择在何处拦截和处理它们。不幸的是,正如我们在第二章 非功能需求的C#编程中需要考虑的性能问题部分中讨论的那样,异常会带来很大的性能损失,因此通常会考虑不同的选项。在控制流的正常处理中处理错误会通过在引起错误的方法堆栈中传播所需的处理错误的代码来破坏模块化,代码中到处都是永无止境的条件。因此,需要更复杂的选项。
一个很好的替代方案是将错误通知到依赖注入引擎中定义的错误处理程序,而不是使用异常。由于作用域限定,当每个请求被处理时,相同的服务实例被返回,以便控制整个调用堆栈的处理程序在控制流返回时检查可能的错误,并适当地处理它们。不幸的是,这种复杂的技术无法立即中止操作的执行或将其返回给控制处理程序。这就是为什么在这种情况下建议使用异常,尽管它们存在性能问题。
业务级别验证不应与输入验证混淆,输入验证将在第十五章 介绍 ASP.NET Core MVC中更详细地讨论,因为这两种验证类型具有不同且互补的目的。虽然业务级别验证规则编码领域规则,但输入验证强制执行每个输入的格式(字符串长度,正确的电子邮件和 URL 格式等),确保提供了所有必要的输入,强制执行所选择的用户-机器交互协议,并提供快速和即时的反馈,以驱使用户与系统进行交互。
由于 DDD 实体必须具有明确定义的标识,它们必须具有充当主键的属性。通常会以这样一种方式重写所有 DDD 实体的Object.Equals方法,即当两个对象具有相同的主键时,它们被视为相等。这很容易通过让所有实体继承自一个抽象的Entity类来实现,如下面的代码所示:
public abstract class Entity<K>: IEntity<K>
where K: IEquatable<K>
{
public virtual K Id { get; protected set; }
public bool IsTransient()
{
return Object.Equals(Id, default(K));
}
public override bool Equals(object obj)
{
return obj is Entity<K> entity &&
Equals(entity);
}
public bool Equals(IEntity<K> other)
{
if (other == null ||
other.IsTransient() || this.IsTransient())
return false;
return Object.Equals(Id, other.Id);
}
int? _requestedHashCode;
public override int GetHashCode()
{
if (!IsTransient())
{
if (!_requestedHashCode.HasValue)
_requestedHashCode = HashCode.Combine(Id);
return _requestedHashCode.Value;
}
else
return base.GetHashCode();
}
public static bool operator ==(Entity<K> left, Entity<K> right)
{
if (Object.Equals(left, null))
return (Object.Equals(right, null));
else
return left.Equals(right);
}
public static bool operator !=(Entity<K> left, Entity<K> right)
{
return !(left == right);
}
}
值得指出的是,一旦我们在Entity类中重新定义了Object.Equals方法,我们还可以重写==和!=运算符。
IsTransient谓词在实体最近创建并且尚未记录在永久存储中时返回true,因此其主键仍未定义。
在.NET 中,一个良好的做法是,每当重写类的Object.Equals方法时,也要重写其Object.GetHashCode方法,以便类实例可以有效地存储在诸如字典和集合之类的数据结构中。这就是为什么Entity类对其进行了重写。
此外,值得实现一个定义Entity<K>的所有属性/方法的IEntity<K>接口。每当我们需要在接口后面隐藏数据类时,这个接口就很有用。
另一方面,值对象代表无法用数字或字符串编码的复杂类型。因此,它们没有身份和主键。它们没有在其上定义的操作,并且是不可变的;也就是说,一旦它们被创建,所有字段都可以被读取,但不能被修改。因此,它们通常被编码为具有受保护/私有设置器的类。当所有独立属性都相等时,两个值对象被认为是相等的(有些属性不是独立的,因为它们只显示以不同方式由其他属性编码的数据,就像DateTime的刻度和其日期和时间字段的表示方式一样)。
由于所有record类型都会自动重写Equals方法,因此很容易使用 C# 9 的record类型来实现值类型,以便进行逐属性比较。此外,record类型的行为类似于structs,在每次赋值时都会创建一个新实例。但是,record类型也是不可变的;也就是说,一旦初始化,改变它们的值的唯一方法是创建一个新实例。以下是如何修改record的示例:
var modifiedAddress = myAddress with {Street = "new street"}
以下是如何定义record的示例:
public record Address
{
public string Country {get; init;}
public string Town {get; init;}
public string Street {get; init;}
}
init关键字使record类型的属性不可变,因为它意味着它们只能被初始化。
典型的值对象包括以数字和货币符号表示的成本,以经度和纬度表示的位置,地址和联系信息。当存储引擎的接口是 Entity Framework 时,我们在第八章《使用 C#与数据交互-Entity Framework Core》和第九章《如何选择云中的数据存储》中进行了分析,值对象通过OwnsMany和OwnsOne关系与使用它们的实体相连。事实上,这样的关系也接受没有主键定义的类。
当存储引擎是 NoSQL 数据库时,值对象存储在使用它们的实体的记录中。另一方面,在关系数据库的情况下,它们可以通过分离的表来实现,其主键由 Entity Framework 自动处理并对开发人员隐藏(没有属性被声明为主键),或者在OwnsOne的情况下,它们被展开并添加到使用它们的实体相关的表中。
使用 SOLID 原则来映射您的领域
在接下来的小节中,我们将描述一些常用的 DDD 模式。其中一些可以在所有项目中采用,而另一些只能用于特定的 Bounded Contexts。总体思想是业务层分为两层:
-
应用层
-
领域层
在这里,领域层是基于普遍语言对数据层的抽象。这是定义 DDD 实体和值对象以及检索和保存它们的操作的地方。这些操作在接口中定义,并在底层数据层(在我们的案例中是 Entity Framework)中实现。
应用层定义了使用领域层接口的操作,以获取 DDD 实体和值对象,并对它们进行操作以实现应用程序业务逻辑。
正如我们将在本章后面看到的那样,通常只使用领域层中的接口来实现领域层。因此,数据层必须引用领域层,因为它必须实现其接口,而应用层是通过应用层依赖注入引擎的记录将每个领域层接口与其实现连接起来的地方。更具体地说,应用层引用的唯一数据层对象是仅在依赖注入引擎中引用的这些接口实现。
每个应用层操作都需要从依赖引擎中获取所需的接口,使用它们获取 DDD 实体和值对象,对它们进行操作,并可能通过相同的接口保存它们。下面是一个显示本节讨论的三个层之间关系的图表:
图 12.3:层之间的关系
因此,领域层包含领域对象的表示、在其上使用的方法、验证约束以及与各种实体的关系。为了增加模块化和解耦,实体之间的通信通常使用事件进行编码,即发布者/订阅者模式。这意味着实体更新可以触发已连接到业务操作的事件,并且这些事件会对其他实体进行操作。
这种分层架构使我们能够更改整个数据层而不影响领域层,后者仅依赖于领域规范和语言,不依赖于数据处理的技术细节。
应用层包含可能影响多个实体的所有操作的定义,以及应用程序所需的所有查询的定义。业务操作和查询都使用领域层中定义的接口与数据层交互。
然而,虽然业务操作通过这些接口操作和交换实体,查询则向它们发送查询规范,并从它们接收通用的数据传输对象(DTOs)。事实上,查询的目的只是向用户显示数据,而不是对其进行操作;因此,查询操作不需要整个实体及其所有方法、属性和验证规则,而只需要属性元组。
业务操作可以由其他层(通常是表示层)或通信操作调用。业务操作也可以连接到由其他操作修改实体时触发的事件。
总之,应用层在领域层中定义的接口上操作,而不是直接与数据层实现交互,这意味着应用层与数据层解耦。更具体地说,数据层对象仅在依赖注入引擎定义中提到。所有其他应用层组件都引用领域层中定义的接口,依赖注入引擎注入适当的实现。
应用层通过以下一种或多种模式与其他应用组件通信:
-
它在通信端点上公开业务操作和查询,比如 HTTP Web API(参见第十四章,使用.NET Core 应用服务导向架构)。在这种情况下,表示层可以连接到这个端点,或者连接到其他端点,这些端点反过来从这个和其他端点获取信息。从几个端点收集信息并在一个唯一端点中公开它们的应用组件称为网关。它们可以是自定义的,也可以是通用的,比如 Ocelot。
-
它被应用程序作为库引用,直接实现表示层,比如 ASP.NET Core MVC Web 应用程序。
-
它不通过端点公开所有信息,并将其处理/创建的一些数据传递给其他应用程序组件,这些组件反过来公开端点。这种通信通常使用发布者/订阅者模式来增加模块化。
在描述这些模式之前,我们需要了解聚合的概念。
聚合
到目前为止,我们已经谈到了实体作为基于 DDD 的业务层处理的单元。然而,可以操作并组成单个实体的多个实体。一个例子是采购订单及其所有项目。事实上,独立处理单个订单项而不考虑其所属的订单是毫无意义的。这是因为订单项实际上是订单的子部分,而不是独立的实体。
没有任何交易可能会影响单个订单项而不影响该项所在的订单。想象一下,同一家公司的两个不同的人试图增加水泥的总数量,但一个人增加了 1 型水泥(项目 1)的数量,而另一个人增加了 2 型水泥(项目 2)的数量。如果每个项目都被处理为独立实体,那么两个数量都将增加,这可能导致不一致的采购订单,因为水泥的总数量将增加两次。
另一方面,如果整个订单及其所有订单项都在每个交易中加载和保存,那么两者中的一个将覆盖另一个的更改,因此最终进行更改的人将满足其要求。在 Web 应用程序中,不可能锁定用户查看和修改订单的整个时间,因此使用乐观并发策略。如果数据层基于Entity Framework(EF)Core,我们可以使用 EF 并发检查属性。如果我们使用[ConcurrencyCheck]属性修饰属性,当 EF 保存更改时,如果数据库中的属性值与检索实体时的值不同时,事务将中止并生成并发异常。
例如,只需为每个采购订单添加一个带有[ConcurrencyCheck]修饰的版本号,并执行以下操作:
-
在不打开任何交易的情况下阅读订单,并更新它。
-
在保存更新后的采购订单之前,我们会递增计数器。
-
当我们保存所有更改时,如果其他人在我们能够保存更改之前递增了此计数器,则会生成并发异常并中止操作。
-
重复从步骤 1开始,直到不再发生并发异常。
也可以使用自动生成的TimeStamp代替计数器。然而,正如我们将很快看到的,我们需要计数器来实现命令查询责任分离(CQRS)模式。
采购订单及其所有子部分(订单项)称为聚合,而订单实体称为聚合的根。聚合始终具有根,因为它们是由子部分关系连接的实体的层次结构。
由于每个聚合表示单个复杂实体,因此必须通过唯一接口公开对其进行的所有操作。因此,聚合根通常表示整个聚合,聚合上的所有操作都被定义为根实体的方法。
当使用聚合模式时,业务层和数据层之间传输的信息单元称为聚合、查询和查询结果。因此,聚合取代了单个实体。
那么我们在第八章《使用 C#与数据交互-Entity Framework Core》和第九章《如何选择云中的数据存储》中看到的 WWTravelClub 位置和套餐实体呢?套餐是否是根植于其关联位置的唯一聚合的一部分?不是!实际上,位置很少更新,对套餐所做的更改对其位置或与同一位置关联的其他套餐都没有影响。
仓库和工作单元模式
仓库模式是领域层接口定义的以实体为中心的方法:每个聚合都有自己的仓库接口,定义了如何检索和保存它,并定义了涉及聚合中实体的所有查询。每个仓库接口的数据层实现称为仓库。
使用仓库模式,每个操作都有一个易于找到的位置来定义:操作所在的聚合的接口,或者在查询的情况下,包含查询根实体的聚合。
通常,跨多个聚合的应用层操作,因此使用多个不同的仓库接口,必须在唯一事务中执行。工作单元模式是保持领域层独立于底层数据层的解决方案。它规定每个仓库接口还必须包含对表示当前事务标识的工作单元接口的引用。这意味着具有相同工作单元引用的多个仓库属于同一事务。
仓库模式和工作单元模式都可以通过定义一些种子接口来实现:
public interface IUnitOfWork
{
Task<bool> SaveEntitiesAsync();
Task StartAsync();
Task CommitAsync();
Task RollbackAsync();
}
public interface IRepository<T>: IRepository
{
IUnitOfWork UnitOfWork { get; }
}
所有仓库接口都继承自IRepository<T>,并将T绑定到它们关联的聚合根,而工作单元只是实现IUnitOfWork。在使用 Entity Framework 时,IUnitOfWork通常与DBContext一起实现,这意味着SaveEntitiesAsync()可以执行其他操作,然后调用DBContext的SaveChangeAsync方法,以便将所有未决更改保存在单个事务中。如果需要启动从存储引擎检索数据时开始的更广泛的事务,则必须由应用程序层处理程序启动并提交/中止,该处理程序借助IUnitOfWork的StartAsync、CommitAsync和RollbackAsync方法来处理整个操作。IRepository<T>继承自一个空的IRepository接口,以帮助自动发现仓库。与本书相关联的 GitHub 仓库包含一个RepositoryExtensions类,其AddAllRepositories IServiceCollection扩展方法会自动发现包含在程序集中的所有仓库实现,并将它们添加到依赖注入引擎中。
以下是基于仓库和工作单元模式的应用层/领域层/数据层架构的图表:
图 12.4:层责任和相互引用
避免直接引用仓库实现的主要优势是,如果我们模拟这些接口,各个模块可以很容易地进行测试。领域层中提到的领域事件是实现理解领域驱动设计部分中提到的不同边界上下文之间通信的事件。
领域驱动设计实体和 Entity Framework Core
DDD 要求以一种与我们在第八章 在 C#中与数据交互-Entity Framework Core中定义实体的方式不同的方式定义实体。实际上,Entity Framework 实体是类似记录的公共属性列表,几乎没有方法,而 DDD 实体应该具有编码领域逻辑、更复杂的验证逻辑和只读属性的方法。虽然可以在不破坏 Entity Framework 操作的情况下添加进一步的验证逻辑和方法,但添加不能映射到数据库属性的只读属性可能会产生必须适当处理的问题。防止属性映射到数据库非常容易——我们只需要用NotMapped属性装饰它们。
只读属性存在的问题有点复杂,可以通过三种基本方式解决:
-
将 EF 实体映射到不同的类。将 DDD 实体定义为不同的类,并在实体返回/传递给存储库方法时复制数据。这是最简单的解决方案,但它需要编写一些代码,以便可以在两种格式之间转换实体。DDD 实体在领域层中定义,而 EF 实体继续在数据层中定义。这是更干净的解决方案,但它会在编写和维护代码方面产生非常大的开销。我建议在有多个复杂聚合和多个复杂方法时使用它。
-
将表字段映射到私有属性。让 Entity Framework Core 将字段映射到私有类字段,以便您可以决定如何通过编写自定义 getter 和/或 setter 将它们暴露为属性。只需给这些私有字段命名为
_<属性名称>或_<属性名称的驼峰命名法>,Entity Framework 就会使用它们而不是它们关联的属性。在这种情况下,在领域层中定义的 DDD 实体也用作数据层实体。这种方法的主要缺点是我们不能使用数据注释来配置每个属性,因为 DDD 实体不能依赖底层数据层的实现方式。因此,我们必须在OnModelCreatingDbContext方法中配置所有数据库映射。这是更简单的解决方案,但它会生成不可读且难以维护的代码,因此我不建议完全采用它。 -
定义 DDD 接口。使用接口隐藏每个 Entity Framework 类及其所有公共属性,当需要时,只暴露属性的 getter。接口在领域层中定义,而实体继续在数据层中定义。在这种情况下,存储库必须暴露一个
Create方法,该方法返回接口的实现;否则,更高层将无法创建可以添加到存储引擎的新实例,因为接口不能使用new创建。当存在多个简单实体时,这是我更喜欢的解决方案。
例如,假设我们想要为第八章 在 C#中与数据交互-Entity Framework Core的定义 DB 实体子部分中定义的Destination类定义一个名为IDestination的 DDD 接口,并且假设我们想要将Id、Name和Country属性暴露为只读,因为一旦创建了目的地,就不能再修改了。在这里,让Destination实现IDestination并在IDestination中将Id、Name和Country定义为只读就足够了:
public interface IDestination
{
int Id { get; }
string Name { get; }
string Country { get; }
string Description { get; set; }
...
}
现在我们已经讨论了 DDD 的基本模式以及如何调整 Entity Framework 以满足 DDD 的需求,我们可以讨论更高级的 DDD 模式。在下一节中,我们将介绍 CQRS 模式。
命令查询职责分离(CQRS)模式
在其一般形式中,使用此模式非常简单:使用不同的结构存储和查询数据。在这里,关于如何存储和更新数据的要求与查询的要求不同。在 DDD 的情况下,存储单元是聚合,因此添加、删除和更新涉及聚合,而查询通常涉及从多个聚合中获取的属性的更多或更少复杂的转换。
此外,通常情况下,我们不会对查询结果执行业务操作。我们只是用它们来计算其他数据(平均值、总和等)。因此,虽然更新需要具有完整面向对象语义(方法、验证规则、封装信息等)的实体,但查询结果只需要一组属性/值对,因此只具有公共属性而没有方法的数据传输对象(DTOs)可以很好地工作。
在其常见形式中,该模式可以描述如下:
图 12.5:命令和查询处理
从中可以得出的主要观点是,提取查询结果不需要经过实体和聚合的构造,而是必须从存储引擎中提取查询中显示的字段,并投影到特定的 DTO 中。如果使用 LINQ 实现查询,我们需要使用Select子句将必要的属性投影到 DTO 中:
ctx.MyTable.Where(...)....Select(new MyDto{...}).ToList();
然而,在更复杂的情况下,CQRS 可以以更强大的形式实现。换句话说,我们可以使用不同的有界上下文存储预处理的查询结果。当查询涉及存储在不同有界上下文中的数据,并由不同的分布式微服务处理时,这种方法很常见。
事实上,另一个选择是使用聚合器微服务查询所有必要的微服务,以组装每个查询结果。然而,对其他微服务进行递归调用以构建答案可能会导致无法接受的响应时间。此外,分解一些预处理可以更好地利用可用资源。该模式的实现如下:
-
查询处理被委托给专门的微服务。
-
每个查询处理微服务为其必须处理的每个查询使用一个数据库表。在那里,它存储查询返回的所有字段。这意味着查询不会在每个请求时计算,而是预先计算并存储在特定的数据库表中。显然,具有子集合的查询需要额外的表,每个子集合一个。
-
所有处理更新的微服务都将所有更改转发给感兴趣的查询处理微服务。记录被版本化,因此接收更改的查询处理微服务可以按正确顺序将其应用于其查询处理表。实际上,由于通信是异步的以提高性能,无法保证更改会按照发送的顺序接收。
-
每个查询处理微服务接收的更改在等待应用更改时被缓存。每当一个更改具有紧随最后应用的更改的版本号时,它就会被应用到正确的查询处理表中。
使用这种更强大形式的 CQRS 模式将通常的本地数据库事务转换为复杂耗时的分布式事务,因为单个查询预处理微服务的失败应该使整个事务无效。正如我们在第五章中解释的,将分布式事务通常不可接受,因为性能原因,有时根本不支持,因此常见的解决方案是放弃立即一致的整体数据库的想法,并接受每次更新后整体数据库最终一致。瞬态故障可以通过我们在第五章中分析的重试策略来解决,而永久故障则通过对已提交的本地事务执行纠正操作来处理,而不是假装实现整体全局分布式事务。
正如我们在第五章中讨论的,微服务之间的通信通常使用发布者/订阅者模式来实现,以改善微服务的分离。
此时,您可能会问以下问题:
“一旦我们拥有了所有预处理的查询结果,为什么我们还需要保留原始数据呢?我们永远不会用它来回答查询!”
对这个问题的一些答案如下:
-
它们是我们可能需要从失败中恢复的真相来源。
-
我们需要它们来在添加新查询时计算新的预处理结果。
-
我们需要它们来处理新的更新。实际上,处理更新通常需要从数据库中检索一些数据,可能向用户显示,然后进行修改。例如,要修改现有采购订单中的项目,我们需要整个订单,以便我们可以向用户显示它并计算更改,以便我们可以将其转发给其他微服务。此外,每当我们修改或向存储引擎添加数据时,我们必须验证整体数据库的一致性(唯一键约束,外键约束等)。
在下一节中,我们将描述用于处理跨多个聚合和多个有界上下文的操作的常见模式。
命令处理程序和领域事件
通常,为了保持聚合分离,与其他聚合和其他有界上下文的交互是通过事件完成的。在处理每个聚合期间创建事件时,最好将所有事件存储起来,而不是立即执行它们,以防止事件执行干扰正在进行的聚合处理。这可以通过将以下代码添加到本章实体和值对象子部分中定义的抽象Entity类中轻松实现,如下所示:
public List<IEventNotification> DomainEvents { get; private set; }
public void AddDomainEvent(IEventNotification evt)
{
DomainEvents ??= new List<IEventNotification>();
DomainEvents.Add(evt);
}
public void RemoveDomainEvent(IEventNotification evt)
{
DomainEvents?.Remove(evt);
}
在这里,IEventNotification是一个空接口,用于将类标记为事件。
事件处理通常是在更改存储在存储引擎中之前立即执行的。因此,执行事件处理的好地方是在命令处理程序调用每个IUnitOfWork实现的SaveEntitiesAsync()方法之前(请参阅存储库和工作单元模式子部分)。同样,如果事件处理程序可以创建其他事件,它们必须在完成处理所有聚合后处理它们。
对事件T的订阅可以作为IEventHandler<T>接口的实现提供:
public interface IEventHandler<T>: IEventHandler
where T: IEventNotification
{
Task HandleAsync(T ev);
}
类似地,业务操作可以由command对象描述,该对象包含操作的所有输入数据,而实现实际操作的代码可以通过实现ICommandHandler<T>接口提供:
public interface ICommandHandler<T>: ICommandHandler
where T: ICommand
{
Task HandleAsync(T command);
}
在这里,ICommand是一个空接口,用于将类标记为命令。ICommandHandler<T>和IEventHandler<T>是我们在第十一章《设计模式和.NET 5 实现》中描述的命令模式的例子。
每个ICommandHandler<T>都可以在依赖注入引擎中注册,以便需要执行命令T的类可以在其构造函数中使用ICommandHandler<T>。这样,我们将命令的抽象定义(command类)与其执行方式解耦。
同样的构造不能应用于事件T及其IEventHandler<T>,因为当事件被触发时,我们需要检索多个IEventHandler<T>实例,而不仅仅是一个。我们需要这样做,因为每个事件可能有多个订阅。然而,几行代码可以轻松解决这个困难。首先,我们需要定义一个类,用于承载给定事件类型的所有处理程序:
public class EventTrigger<T>
where T: IEventNotification
{
private IEnumerable<IEventHandler<T>> handlers;
public EventTrigger(IEnumerable<IEventHandler<T>> handlers)
{
this.handlers = handlers;
}
public async Task Trigger(T ev)
{
foreach (var handler in handlers)
await handler.HandleAsync(ev);
}
}
这个想法是,每个需要触发事件T的类都需要EventTrigger<T>,然后将要触发的事件传递给它的Trigger方法,该方法依次调用所有处理程序。
然后,我们需要在依赖注入引擎中注册EventTrigger<T>。一个好主意是定义依赖注入扩展,我们可以调用它来声明每个事件,如下所示:
service.AddEventHandler<MyEventType, MyHandlerType>()
这个AddEventHandler扩展必须自动为EventTrigger<T>生成一个依赖注入定义,并且必须处理使用AddEventHandler声明的每种类型T的所有处理程序。
以下扩展类为我们执行此操作:
public static class EventDIExtensions
{
public static IServiceCollection AddEventHandler<T, H>
(this IServiceCollection services)
where T : IEventNotification
where H: class, IEventHandler<T>
{
services.AddScoped<H>();
services.TryAddScoped(typeof(EventTrigger<>));
return services;
}
...
...
}
传递给AddEventHandler的H类型被记录在依赖注入引擎中,第一次调用AddEventHandler时,EventTrigger<>也被添加到依赖注入引擎中。然后,当依赖注入引擎需要EventTrigger<T>实例时,所有添加到依赖注入引擎中的IEventHandler<T>类型都会被创建、收集,并传递给EventTrigger(IEnumerable<IEventHandler<T>> handlers)构造函数。
当程序启动时,所有ICommandHandler<T>和IEventHandler<T>的实现都可以通过反射自动检索和注册。为了帮助自动发现,它们继承自ICommandHandler和IEventHandler,这两个都是空接口。EventDIExtensions类,该类在本书的 GitHub 存储库中可用,包含了用于自动发现和注册命令处理程序和事件处理程序的方法。GitHub 存储库还包含了IEventMediator接口及其EventMediator实现,其TriggerEvents(IEnumerable<IEventNotification> events)方法从依赖注入引擎中检索与其参数中接收到的事件相关的所有处理程序,并执行它们。只需将IEventMediator注入到一个类中,就可以触发事件。EventDIExtensions还包含一个扩展方法,用于发现实现空IQuery接口的所有查询,并将它们添加到依赖注入引擎中。
MediatR NuGet 包提供了更复杂的实现。下一小节将专门介绍 CQRS 模式的极端实现。
事件溯源
事件溯源是更强的 CQRS 的极端实现。当原始有界上下文数据库根本不用于检索信息,而仅用作真相的来源时,即用于从故障中恢复和进行软件维护时,它是有用的。在这种情况下,我们不是更新数据,而是简单地添加描述已执行操作的事件:删除记录 Id 15,将名称更改为Id 21 中的 John等。这些事件立即发送到所有依赖的有界上下文,而在故障和/或添加新查询的情况下,我们只需重新处理其中的一些事件。如果事件是幂等的,即多次处理相同的事件与处理一次具有相同的效果,那么事件重新处理不会引起问题。
正如在第五章 将微服务架构应用于企业应用程序中讨论的那样,幂等性是通过事件通信的微服务的标准要求。
尽管我们到目前为止描述的所有技术都可以在每种类型的项目中使用,只要进行一些小的修改,但事件溯源需要进行深入分析才能被采用,因为在几种情况下,它可能会带来比它能解决的问题更大的问题。要了解它被滥用时可能引起的问题,可以想象我们将其应用于已被多个用户修改和验证后才被批准的采购订单。由于需要在更新/验证之前检索采购订单,采购订单的有界上下文不仅仅用作真相的来源,因此不应将事件溯源应用于它。如果不是这种情况,那么我们可以将事件溯源应用于它,在这种情况下,我们的代码将被迫每次更新订单时从记录的事件中重建整个订单。
我们在第五章 将微服务架构应用于企业应用程序的结尾描述了其使用示例是收入记录系统。单个收入使用事件溯源记录,然后发送到我们在第五章 将微服务架构应用于企业应用程序中描述的微服���,该微服务又用于预处理未来的查询,即计算每日收入。
在接下来的部分中,我们将学习如何应用 DDD 来定义本书 WWTravelClub 用例的有界上下文。本书中描述的大部分模式和代码的有界上下文的完整示例可以在第十五章 介绍 ASP.NET Core MVC的用例-在 ASP.NET Core MVC 中实现 Web 应用程序部分找到。
用例-理解用例的领域
根据第一章 理解软件架构的重要性中列出的要求以及第九章 如何在云中选择数据存储中的用例-存储数据部分的分析,我们知道 WWTravelClub 系统由以下部分组成:
-
有关可用目的地和套餐的信息。我们在第九章 如何在云中选择数据存储中实现了该子系统数据层的第一个原型。
-
预订/购买订单子系统。
-
与专家/评论子系统的通信。
-
支付子系统。我们在本章的理解领域驱动设计部分开始时简要分析了该子系统的特性以及与预订购买子系统的关系。
-
用户帐户子系统。
-
统计报告子系统。
上述子系统代表不同的有界上下文吗?一些子系统可以分成不同的有界上下文吗?这些问题的答案取决于每个子系统使用的语言。
-
子系统 1 中使用的语言是旅行社的语言。没有客户的概念,只有位置、套餐及其特点。
-
子系统 2 中使用的语言适用于所有服务购买,如可用资源、预订和购买订单。这是一个独立的有界上下文。
-
子系统 3 中使用的语言与子系统 1 的语言有很多共同之处。然而,也有典型的社交媒体概念,如评分、聊天、帖子分享、媒体分享等。该子系统可以分为两部分:一个具有新有界上下文的社交媒体子系统和一个属于子系统 1 有界上下文的可用信息子系统。
-
正如我们在理解领域驱动设计部分中指出的,子系统 4 中我们使用银行的语言。该子系统与预订购买子系统通信,并执行执行购买所需的任务。从这些观察中,我们可以看到它是一个不同的有界上下文,并且与购买/预订系统有客户/供应商关系。
-
子系统 5 绝对是一个独立的有界上下文(几乎所有 Web 应用程序都是如此)。它与所有具有用户概念或客户概念的有界上下文都有关系,因为用户账户的概念总是映射到这些概念。但是如何呢?简单——当前登录的用户被假定为社交媒体有界上下文的社交媒体用户,预订/购买有界上下文的客户,以及支付有界上下文的付款人。
-
仅查询子系统,即 6,使用分析和统计的语言,与其他子系统使用的语言有很大不同。然而,它与几乎所有有界上下文都有连接,因为它的所有输入都来自它们。前面的约束迫使我们采用 CQRS 的强形式,因此将其视为一个仅查询的独立有界上下文。我们在第五章 将微服务架构应用于企业应用程序中实现了部分内容,使用了符合 CQRS 强形式的微服务。
总之,列出的每个子系统都定义了一个不同的有界上下文,但必须将与专家/评论子系统的通信部分包含在可用目的地和套餐有界上下文的信息中。
随着分析的继续和原型的实施,一些有界上下文可能会分裂,而其他一些可能会被添加,但是立即开始对系统进行建模,并立即开始分析有界上下文之间的关系是至关重要的,因为这将推动进一步的调查,并帮助我们定义所需的通信协议和普遍语言,以便我们可以与领域专家进行交互。
以下是领域地图的基本初步草图:
图 12.6:WWTravelClub 领域地图
为简单起见,我们省略了统计报告有界上下文。在这里,我们假设用户账户和社交有界上下文与所有与它们通信的其他有界上下文具有顺从关系,因为它们是使用已经存在的软件实现的,所以所有其他组件必须适应它们。
正如我们之前提到的,预订和支付之间的关系是客户/供应商,因为支付提供用于执行预订任务的服务。所有其他关系都被归类为合作伙伴。大多数有界上下文具有的各种客户/用户概念由用户账户授权令牌协调,间接地负责在所有有界上下文之间映射这些概念。
Packages/locations子系统不仅传达了执行预订/购买所需的包裹信息,还负责通知待处理的购买订单可能的价格变化。最后,我们可以看到社交互动是从现有的评论或位置开始的,从而与Packages/locations Bounded Context 进行通信。
摘要
在本章中,我们分析了采用 DDD 的主要原因,以及它为什么以及如何满足市场需求。我们描述了如何识别领域以及如何协调在同一应用程序的不同领域上工作的团队与领域映射。然后,我们分析了 DDD 如何用实体、值对象和聚合表示数据,并提供建议和代码片段,以便我们可以在实践中实现它们。
我们还介绍了一些与 DDD 一起使用的典型模式,即仓库和工作单元模式、领域事件模式、CQRS 和事件溯源。然后,我们学习了如何在实践中实现它们。我们还向您展示了如何实现领域事件和命令模式,以及解耦处理,以便我们可以将代码片段添加到实际项目中。
最后,我们在实践中使用 DDD 原则来定义领域,并为本书的 WWTravelClub 用例创建了第一个领域映射的草图。
在下一章中,您将学习如何在项目中最大程度地重用代码。
问题
-
是什么提供了主要提示,以便我们可以发现领域边界?
-
用于协调单独 Bounded Context 开发的主要工具是什么?
-
每个组成聚合的条目是否都通过自己的方法与系统的其余部分进行通信?
-
为什么只有一个聚合根?
-
有多少个仓库可以管理一个聚合?
-
仓库如何与应用层交互?
-
为什么需要工作单元模式?
-
轻量级 CQRS 的原因是什么?最强形式的原因呢?
-
允许我们将命令/领域事件与其处理程序耦合的主要工具是什么?
-
事件溯源是否可以用于实现任何 Bounded Context?
进一步阅读
-
有关 DDD 的更多资源可以在这里找到:
domainlanguage.com/ddd/ -
有关 CQRS 设计原则的详细讨论可以在这里找到:
udidahan.com/2009/12/09/clarified-cqrs/ -
有关 MediatR 的更多信息可以在 MediatR 的 GitHub 仓库中找到:
github.com/jbogard/MediatR -
马丁·福勒在以下博客文章中对事件溯源进行了很好的描述,并提供了一个示例:
martinfowler.com/eaaDev/EventSourcing.html
第十三章:在 C# 9 中实现代码重用
代码重用是软件架构中最重要的主题之一。本章旨在讨论如何启用代码重用的方法,以及帮助你了解.NET 5 如何朝着解决管理和维护可重用库的问题的方向发展。
这一章将涵盖以下主题:
-
理解代码重用的原则
-
使用.NET 5 与.NET Standard 的优势
-
使用.NET Standard 创建可重用的库
尽管代码重用是一种非常好的实践,作为软件架构师,你必须意识到这对你正在处理的场景非常重要。许多优秀的软件架构师都同意,由于试图使事物可重用,往往会出现过度设计或者单次使用或者理解不足。
技术要求
这一章需要以下内容:
-
你需要免费的 Visual Studio 2019 社区版或更高版本,并安装所有数据库工具。
-
你可以在
github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5找到本章的示例代码。
理解代码可重用性的原则
你可以用来证明代码重用的唯一理由是-如果在其他场景中已经运行良好,你就不必花费宝贵的时间重新发明轮子。这就是为什么大多数工程领域都基于可重用性原则。想想你家里的开关。
你能想象可以用相同的接口组件制作多少应用程序吗?代码重用的基本原则是相同的。再次强调,这是一个规划一个好的解决方案的问题,这样一部分可以在以后重用。
在软件工程中,代码重用是可以为软件项目带来许多优势的技术之一,例如以下:
-
考虑到重用的代码片段已经在另一个应用程序中经过测试,因此对软件有信心。
-
软件架构师和高级团队的使用更好,因为他们可以专注于解决这种问题。
-
有可能将市场上已经接受的模式引入项目中。
-
由于已经实现的组件,开发速度加快。
-
维护更容易。
这些方面表明,只要可能,应该进行代码重用。作为软件架构师,你有责任确保利用前述优势,并且更重要的是,你要鼓励团队在他们创建的软件中启用重用。
什么不是代码重用?
你必须明白的第一件事是,代码重用并不意味着从一个类中复制和粘贴代码到另一个类中。即使这段代码是由另一个团队或项目编写的,这也不表示你正在正确地遵循可重用性原则。让我们想象一种场景,我们将在本书的用例中找到,WWTravelClub 评估。
在这个项目场景中,你可能想评估不同种类的主题,比如Package,DestinationExpert,City,Comments等等。无论你参考哪个主题,获取评估平均值的过程都是相同的。因此,你可能想通过复制和粘贴每个评估的代码来启用重用。结果(不好的)将会是这样:
图 13.1:糟糕的实现-这里没有代码重用
在前面的图表中,计算评估平均值的过程是分散的,这意味着相同的代码将在不同的类中重复。这将带来很多麻烦,特别是如果相同的方法在其他应用程序中也被使用。例如,如果有关如何计算平均值的新规范,或者如果计算公式中出现错误,你将不得不在所有代码实例中修复它。如果你忘记在所有地方更新它,你可能最终会得到一个不一致的实现。
什么是代码重用?
在前一节提到的问题的解决方案非常简单:你必须分析你的代码,并选择其中需要从你的应用程序中解耦的部分。
你应该解耦它们的最大原因与你如何确信这段代码可以在应用程序的其他部分甚至其他应用程序中重用有关:
图 13.2:专注于代码重用的实现
代码的集中化给你作为软件架构师带来了不同的责任。你必须记住,这段代码中的一个错误或不兼容性可能会对应用程序的许多部分或不同的应用程序造成损害。另一方面,一旦你测试并运行了这段代码,你就可以毫无顾虑地传播它的使用。此外,如果你需要改进平均计算过程,你只需要更改单个类中的代码。
值得一提的是,你使用相同的代码越多,这种开发就会变得越便宜。成本需要提到,因为一般来说,可重用软件的构思在开始阶段成本更高。
在开发生命周期中的可重用性
如果你明白了可重用性将带你到另一个代码实现层次,你应该一直在考虑如何在你的开发生命周期中使用这种技术。
事实上,创建和维护一个组件库并不容易,因为你将承担的责任和缺乏支持搜索现有组件的好工具。
另一方面,有一些东西你可能要考虑在每次启动新开发时在你的软件开发过程中实现:
-
使用已经在用户库中实现的组件,选择软件需求规格中需要它们的功能。
-
识别软件需求规格中作为库组件设计候选的功能。
-
修改规格,考虑使用可重用组件开发这些功能。
-
设计可重用组件,并确保它们具有适当的接口,可以在许多项目中使用。
-
构建具有新组件库版本的项目架构。
-
记录组件库版本,以便每个开发人员和团队都知道它。
使用-识别-修改-设计-构建过程是一种你可能要考虑在每次需要启用软件重用时实施的技术。一旦你有了为这个库编写的组件,你就需要决定提供这些组件的技术。
在软件开发的历史中,有许多方法来做到这一点;其中一些在第五章,将微服务架构应用于企业应用程序中讨论过,在微服务和模块概念的演变部分。
使用.NET 5 或.NET 标准进行代码重用
.NET 自第一个版本以来已经发展了很多。这种发展不仅与命令数量和性能问题有关,还与支持的平台有关。正如我们在第一章中讨论的理解软件架构的重要性,即使在运行 Linux、Android、macOS 或 iOS 的数十亿设备上,您也可以运行 C# .NET。因此,.NET Standard 是首次与.NET Core 1.0 一起宣布的,但.NET Standard 在.NET Standard 2.0 时变得特别重要,当时.NET Framework 4.6、.NET Core 和 Xamarin 与之兼容。
关键点是,.NET Standard 不仅仅是一种 Visual Studio 项目。更重要的是,它是一个对所有.NET 实现都可用的正式规范。正如您在下表中所看到的,它涵盖了从.NET Framework 到 Unity 的所有内容:
| .NET Standard | 1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 2.0 | 2.1 |
|---|---|---|---|---|---|---|---|---|---|
| .NET Core 和.NET 5 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 | 2.0 | 3.0 |
| .NET Framework | 4.5 | 4.5 | 4.5.1 | 4.6 | 4.6.1 | 4.6.1 | 4.6.1 | 4.6.1 | N/A |
您可以在docs.microsoft.com/en-us/dotnet/standard/net-standard找到完整的.NET Standard 概述。
前面的表格表明,如果您构建一个符合这一标准的类库,您将能够在所呈现的任何平台上重用它。想象一下,如果您计划在所有项目中都这样做,您的开发过程会变得多么快速。
显然,一些组件不包括在.NET Standard 中,但它的发展是持续的。值得一提的是,微软的官方文档指出版本越高,可用的 API 就越多。
拥有一个适用于所有平台的单一框架的倡议使我们走向了.NET 5。微软指出,从现在开始,net5.0 或更高版本将在任何地方运行。作为软件架构师,您可能会问的下一个问题是:.NET Standard 会发生什么?
对这个问题的答案在 dotnet 博客上由 Immo Landwerth 做了很好的解释:devblogs.microsoft.com/dotnet/the-future-of-net-standard/。基本答案是,.NET 5.0(以及未来的版本)需要被视为未来共享代码的基础。
创建一个.NET Standard 库
创建一个与.NET Standard 兼容的类库非常简单。基本上,您需要在创建库时选择以下项目:
图 13.3:创建一个.NET Standard 库
一旦您完成了这一部分,您会注意到一个普通类库和您创建的类库之间唯一的区别是项目文件中定义的目标框架:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
</Project>
一旦您的项目加载完成,您就可以开始编写您打算重用的类。使用这种方法构建可重用类的优势是,您将能够在我们之前检查过的所有项目类型中重用编写的代码。另一方面,您会发现在这种类型的项目中,一些在.NET Framework 中可用的 API 在这里是不存在的。
C#如何处理代码重用?
C#帮助我们处理代码重用的方法有很多。正如我们在前一节中所做的那样,构建库的能力是其中之一。最重要的是,这种语言是面向对象的。此外,值得一提的是泛型为 C#语言带来的便利。本节将讨论我们提到的最后两个。
面向对象分析
面向对象的分析方法使我们能够以不同的方式重用代码,从继承的便利性到多态的可变性。完全采用面向对象编程将让您实现抽象和封装。
以下图表显示了使用面向对象方法使重用变得更容易。正如你所看到的,有不同的方法来计算评估的等级,考虑到你可以是系统的基本用户或高级用户:
图 13.4:面向对象的案例分析
在这个设计中,代码重用有两个方面需要分析。第一个是在每个子类中不需要声明属性,因为继承已经为你做了这件事。
第二个是使用多态的机会,使相同方法产生不同的行为。
public class PrimeUsersEvaluation : Evaluation
{
/// <summary>
/// The business rule implemented here indicates that grades that
/// came from prime users have 20% of increase
/// </summary>
/// <returns>the final grade from a prime user</returns>
public override double CalculateGrade()
{
return Grade * 1.2;
}
}
在前述代码中,你可以看到多态原则的使用,高级用户的评估计算会增加 20%。现在,看看通过相同类继承的不同对象调用是多么容易。由于集合内容实现了相同的接口IContentEvaluated,它也可以有基本用户和高级用户。
public class EvaluationService
{
public IContentEvaluated content { get; set; }
/// <summary>
/// No matter the Evaluation, the calculation will always get
/// values from the method CalculateGrade
/// </summary>
/// <returns>The average of the grade from Evaluations</returns>
public double CalculateEvaluationAverage()
{
return content.Evaluations
.Select(x => x.CalculateGrade())
.Average();
}
}
面向对象的采用在使用 C#时可以被视为强制性的。然而,更具体的用法需要学习和实践。作为软件架构师,你应该始终鼓励你的团队学习面向对象的分析。他们拥有更多的抽象能力,代码重用就会变得更容易。
泛型
泛型在 C# 2.0 版本中引入,被认为是一种增加代码重用的方法。它还能最大化类型安全性和性能。
泛型的基本原则是,你可以在接口、类、方法、属性、事件,甚至委托中定义一个占位符,当使用前述实体之一时,它将在以后被特定类型替换。你可以使用这个特性的机会是不可思议的,因为你可以使用相同的代码来运行类型的不同版本,通用地。
以下代码是对前一节中介绍的EvaluationService的修改。这里的想法是使服务的泛化,让我们有机会自定义评估的目标:
public class EvaluationService<T> where T: IContentEvaluated
这个声明表明,任何实现了IContentEvaluaded接口的类都可以用于这个服务。此外,服务将负责创建评估内容。
以下代码实现了自从服务构建以来创建的评估内容。这段代码使用了System.Reflection和类的泛型定义:
public EvaluationService()
{
var name = GetTypeOfEvaluation();
content = (T)Assembly.GetExecutingAssembly().CreateInstance(name);
}
值得一提的是,这段代码将会工作,因为所有的类都在同一个程序集中。此外,在使用泛型时,反射并不是必需的。这个修改的结果可以在服务的实例创建中检查到:
var service = new EvaluationService<CityEvaluation>();
好消息是,现在你有了一个通用的服务,它将自动用你需要的内容的评估实例化列表对象。值得一提的是,泛型显然需要更多的时间用于第一个项目的构建。然而,一旦设计完成,你将拥有良好、快速和易于维护的代码。这就是我们所说的重用!
如果代码不能被重用怎么办?
事实上,任何代码都可以重用。关键在于你打算重用的代码是否写得好,并且遵循了良好的重用模式。有几个原因可以说明为什么代码不应该被考虑为可重用:
-
代码在重用之前没有经过测试:在重用代码之前,保证它能够正常工作是一个很好的方法。
-
代码是重复的:如果你有重复的代码,你需要找到它被使用的每个地方,这样你就只有一个版本的代码被重用。
-
代码过于复杂,难以理解:在许多地方重用的代码需要简单编写,以便易于理解。
-
代码耦合度太高:这是一个关于组合与继承的讨论,当构建独立的类库时。类(带接口)通常比可以被继承的基类更容易重用。
在任何这些情况下,考虑到重构策略可能是一个很好的方法。当你重构代码时,你正在以更好的方式编写它,同时尊重这段代码将处理的输入和输出数据。这将使得在改变代码时更全面、成本更低。Martin Fowler 指出了一些我们应该考虑重构的原因:
-
它改善软件设计:你的团队变得越来越专业,设计就会变得越来越好。更好的软件设计不仅会带来更快的编码,还会为我们提供在更短的时间内处理更多任务的机会。
-
它使软件更易理解:无论是初级开发人员还是高级开发人员,好的软件都需要被团队中的每个开发人员理解。
-
它帮助我们找到错误:在重构时,你会发现可能没有被良好编程的业务规则,所以你会发现错误。
-
它让我们更快地编程:重构的结果将是能够在未来实现更快开发的代码。
重构的过程取决于我们将要遵循的一些步骤,以确保良好的结果并在旅程中尽量减少错误:
-
确保你有一套测试来保证正确处理:你拥有的测试集将消除清理代码的恐惧。
-
消除重复:重构是消除代码重复的好机会。
-
最小化复杂性:考虑到你的目标是使代码更易理解,遵循编程的最佳实践,如第十七章中提到的C# 9 编码最佳实践,将减少代码的复杂性。
-
清理设计:重构是重新组织你的库设计的好时机。不要忘记也要更新它们。这是消除错误和安全问题的好方法。
作为软件架构师,你会收到团队的许多重构需求。持续做这件事的动力必须是持续的。但你必须提醒你的团队,没有遵循先前步骤的重构可能是有风险的。因此,你有责任以一种既能够实现快速编程又能够减少影响的方式来实现它,从而提供真正的商业价值。
我有我的库。我该如何推广它们?
考虑到你已经做出了所有必要的努力来确保你有好的库,这些库必须在你的许多项目中被重复使用,当启用可重用性时,你会发现另一个困难的情况出现了:让程序员知道你已经准备好可以重用的库并不简单。
有一些简单的方法来记录一个库。正如我们在谈论开发生命周期时提到的,文档是帮助开发人员注意到他们拥有的库的好方法。这里有两个关于记录可重用代码的例子我们想提一下。
使用 DocFX 记录.NET 库
这个工具是一个很好的选择,可以使用代码中的注释来记录库。通过简单地添加 NuGet 包docfx.console,该工具允许你创建一个任务,一旦你的库构建完成就会运行:
图 13.5:docfx.console NuGet 库
这个编译的输出是一个时尚的静态网站,包含了你的代码文档:
图 13.6:DocFx 结果
这个网站很有用,因为你可以把文档分发给你的团队,这样他们就可以搜索你拥有的库。你可以检查输出的自定义,并在dotnet.github.io/docfx/找到更多信息。
使用 Swagger 记录 Web API
毫无疑问,Web API 是促进代码重用的技术之一。因此,做好它的文档工作,更重要的是,遵守标准,是一个良好的做法,并表明你对这种方法是跟上了时代的。
OpenAPI 规范被认为是描述现代 API 的标准。在 ASP.NET Core Web API 中,最常用的用于对其进行文档化的工具之一是Swashbuckle.AspNetCore。
实现Swashbuckle.AspNetCore库的好处在于,你可以为你的 Web API 设置 Swagger UI 查看器,这是一种良好的图形方式来分发 API。
我们将在下一章学习如何在 ASP.NET Core Web API 中使用这个库。在那之前,重要的是要理解,这份文档不仅会帮助你的团队,还会帮助任何可能使用你正在开发的 API 的开发人员。
用例 - 将代码重用作为快速交付良好和安全软件的途径
WWTravelClub 评估内容的最终设计可以如下所示。这种方法包括了本章讨论的许多主题。首先,所有的代码都放在一个.NET 标准类库中。这意味着你可以将这些代码添加到不同类型的解决方案中,比如.NET Core web 应用程序和用于 Android 和 iOS 平台的 Xamarin 应用程序:
图 13.7:WWTravelClub 重用方法
这种设计利用了面向对象的原则,比如继承,因此你不需要在多个类中重复编写属性和方法;以及多态性,这样你可以改变代码的行为而不改变方法的名称。
最后,这个设计通过引入泛型来抽象内容的概念,泛型可以简化对类似类的对象的处理,比如我们在 WWTravelClub 中用来评估城市、评论、目的地专家和旅行套餐的类。
一个鼓励代码重用的团队和一个不鼓励的团队之间的最大区别是向最终用户交付良好软件的速度。当然,开始这种方法并不容易,但请放心,在一段时间后你将会取得良好的结果。
总结
本章旨在帮助你了解代码重用的优势。它还让你了解了什么才是正确的代码重用。本章还介绍了代码重用和重构的方法。
考虑到没有流程的技术是无法带你走向任何地方的,因此提出了一个流程,帮助实现代码重用。这个流程涉及到使用已经完成的组件库中的组件;识别软件需求规范中可以设计为库组件的特性;修改规范以考虑这些特性;设计可重用组件;并使用新的组件库版本构建项目架构。
最后,本章介绍了.NET 标准库作为在不同的 C#平台上重用代码的一种方法,表明.NET 5 和新版本应该用于在不同平台上重用代码。本章还强调了在重用代码时面向对象编程的原则,并介绍了泛型作为简化具有相同特征的对象处理的一种复杂实现。在下一章中,我们将学习如何在.NET Core 中应用面向服务的架构(SOA)。
值得一提的是,SOA 被认为是在复杂环境中实现代码重用的一种方式。
问题
-
复制和粘贴可以被视为代码重用吗?这种方法的影响是什么?
-
如何在不复制和粘贴代码的情况下实现代码重用?
-
有没有一个流程可以帮助实现代码重用?
-
.NET Standard 和.NET Core 之间有什么区别?
-
创建.NET Standard 库的优势是什么?
-
面向对象分析如何帮助代码重用?
-
泛型如何帮助代码重用?
-
.NET Standard 将被.NET 5 取代吗?
-
与重构相关的挑战是什么?
进一步阅读
这些是一些书籍和网站,您将在其中找到有关本章的更多信息:
-
《干净的代码:敏捷软件工艺的手册》(Martin,Robert C. Pearson Education,2012 年)。
-
《干净的架构:软件结构和设计的工匠指南》(Martin,Robert C. Pearson Education,2018 年)。
-
《设计模式:可重用面向对象软件的元素》(Erica Gamma 等人著,Addison-Wesley,1994 年)。
-
《设计原则和设计模式》(Robert C. Martin,2000 年)。
-
《重构》(Martin Fowler,2018 年)。
-
www.packtpub.com/application-development/net-standard-20-cookbook -
docs.microsoft.com/pt-br/dotnet/csharp/programming-guide/generics/ -
docs.microsoft.com/en-us/aspnet/core/tutorials/web-api-help-pages-using-swagger
第十四章:使用.NET Core 应用面向服务的架构
“面向服务的架构”(SOA)一词指的是通过通信实现系统组件之间交互的模块化架构。SOA 允许来自不同组织的应用程序自动交换数据和交易,并允许组织在互联网上提供服务。
此外,正如我们在《微服务和模块概念的演变》章节中讨论的那样,《将微服务架构应用于企业应用程序》第五章,基于通信的交互解决了复杂系统中不可避免出现的模块共享相同地址空间的二进制兼容性和版本不匹配问题。此外,使用 SOA,您无需在使用它的各个系统/子系统中部署相同组件的不同副本-每个组件只需在一个地方部署。这可以是单个服务器,位于单个数据中心的集群,或地理分布的集群。在这里,您的组件的每个版本只部署一次,服务器/集群逻辑会自动创建所有必要的副本,从而简化整个持续集成/持续交付(CI/CD)周期。
如果新版本符合向客户端声明的通信接口,则不会发生不兼容性。另一方面,对于 DLLs/软件包,当保持相同接口时,可能会出现不兼容性,因为库模块可能与其客户端共享的其他 DLLs/软件包的依赖关系可能存在版本不匹配的情况。
在《将微服务架构应用于企业应用程序》第五章中讨论了组织协作服务的集群/网络。在本章中,我们将主要关注每个服务提供的通信接口。更具体地,我们将讨论以下主题:
-
理解 SOA 方法的原则
-
SOAP 和 REST Web 服务
-
.NET 5 如何处理 SOA?
-
用例-公开 WWTravelClub 套餐
通过本章结束时,您将了解如何通过 ASP.NET Core 服务公开 WWTravelClub 书籍用例中的数据。
技术要求
本章需要安装了所有数据库工具的 Visual Studio 2019 免费社区版或更高版本。
本章中的所有概念都将通过本书的 WWTravelClub 书籍用例的实际示例加以阐明。您可以在以下网址找到本章的代码:github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5。
理解 SOA 方法的原则
与面向对象架构中的类一样,服务是接口的实现,而接口又来自系统的功能规范。因此,“服务”设计的第一步是定义其“抽象接口”。在此阶段,您将定义所有服务操作,作为操作您喜欢的语言类型(C#,Java,C ++,JavaScript 等)的接口方法,并决定使用同步通信实现哪些操作,使用异步通信实现哪些操作。
在这个初始阶段定义的接口不一定会在实际服务实现中使用,它们只是有用的设计工具。一旦我们决定了服务的架构,通常会重新定义这些接口,以便我们可以将它们适应架构的特殊性。
值得指出的是,SOA 消息必须保持与方法调用/回答相同类型的语义;也就是说,对消息的反应不能依赖于先前接收到的任何消息。在这里,消息必须彼此独立,服务不能记住任何先前接收到的消息。这就是我们所说的无状态开发。
例如,如果消息的目的是创建新的数据库条目,这种语义不能随着其他消息的上下文而改变,创建数据库条目的方式必须取决于当前消息的内容,而不是先前接收到的其他消息。因此,客户端不能创建会话,也不能登录到服务,执行一些操作,然后注销。认证令牌必须在每条消息中重复。
这种约束的原因是模块化、可测试性和可维护性。事实上,基于会话的服务将非常难以测试和修改,因为这些交互是隐藏在会话数据中的。
一旦您决定要由服务实现的接口,您必须决定采用哪种通信堆栈/SOA 架构。通信堆栈必须是某些官方或事实标准的一部分,以确保服务的互操作性。互操作性是 SOA 规定的主要约束:服务必须提供一个通信接口,不依赖于特定的通信库、实现语言或部署平台。
考虑到您已经决定了通信堆栈/架构,您需要根据架构的特点调整先前的接口(有关更多详细信息,请参阅本章的REST Web 服务子部分)。然后,您必须将这些接口翻译成所选择的通信语言。这意味着您必须将所有编程语言类型映射到所选择的通信语言中可用的类型。
数据的实际翻译通常由您的开发环境使用的 SOA 库自动执行。然而,可能需要一些配置,无论如何,我们必须意识到在每次通信之前我们的编程语言类型是如何转换的。例如,一些数字类型可能会被转换为精度较低或具有不同值范围的类型。
互操作性约束在微服务的情况下可以以较轻的形式解释,因为这些微服务无法在其集群之外访问,因此它们需要与属于同一集群的其他微服务进行通信。在这种情况下,这意味着通信堆栈可能是特定于平台的,以提高性能,但必须是标准的,以避免与随着应用程序发展可能添加到集群中的其他微服务的兼容性问题。
我们谈论的是通信堆栈而不是通信协议,因为 SOA 通信标准通常定义了消息内容的格式,并为用于嵌入这些消息的特定协议提供了不同的可能性。例如,SOAP 协议只是定义了各种消息的基于 XML 的格式,但 SOAP 消息可以通过各种协议传递。通常,用于 SOAP 的最常见协议是 HTTP,但您可以决定跳转到 HTTP 级别,并直接通过 TCP/IP 发送 SOAP 消息以获得更好的性能。
您应该采用的通信堆栈的选择取决于几个因素:
-
兼容性约束:如果您的服务必须对业务客户在互联网上公开可用,那么您必须遵守最常见的选择,这意味着使用 SOAP over HTTP 或 JSON REST 服务。如果您的客户不是业务客户而是物联网(IoT)客户,则最常见的选择是不同的。此外,在物联网中,不同应用领域使用的协议可能不同。例如,海洋车辆状态数据通常不与Signal K交换。
-
开发/部署平台:并非所有通信堆栈都适用于所有开发框架和所有部署平台,但幸运的是,所有主要的开发/部署平台上都可以使用在公共业务服务中使用的所有最常见的通信堆栈,例如 SOAP 和基于 JSON 的 REST 通信。
-
性能:如果您的系统不向外部公开,而是微服务集群的私有部分,性能考虑将具有更高的优先级。在这种情况下,我们将在本章后面讨论的 gRPC 可以被提及为一个不错的选择。
-
团队中工具和知识的可用性:在选择可接受的通信堆栈时,团队/组织中的知识和工具的可用性具有重要的影响。然而,这种约束始终比兼容性约束的优先级低,因为构想一个对团队易于实现但几乎没有人能使用的系统是毫无意义的。
-
灵活性与可用功能:一些通信解决方案虽然不够完整,但提供了更高程度的灵活性,而另一些解决方案虽然更完整,但提供的灵活性较少。在过去几年中,对灵活性的需求促使从基于 SOAP 的服务转向更灵活的 REST 服务。在本节的其余部分中,我们将更详细地讨论 SOAP 和 REST 服务。
-
服务描述:当服务必须在互联网上公开时,客户端应用程序需要一个公开可用的服务规范描述,以设计其通信客户端。一些通信堆栈包括用于描述服务规范的语言和约定。以这种方式公开的正式服务规范可以被处理,以便它们自动创建通信客户端。SOAP 进一步允许通过包含每个 Web 服务可以执行的任务信息的公共基于 XML 的目录来发现服务。
选择要使用的通信堆栈后,必须使用开发环境中可用的工具来实现符合所选通信堆栈的服务。有时,开发工具会自动确保通信堆栈的符合性,但有时可能需要一些开发工作。例如,在.NET 世界中,如果使用 WCF,则开发工具会自动确保 SOAP 服务的符合性,而 REST 服务的符合性则由开发人员负责。SOA 解决方案的一些基本特性如下:
-
身份验证:允许客户端进行身份验证以访问服务操作。
-
授权:处理客户端的权限。
-
安全性:这是通信如何保持安全的方式,即如何防止未经授权的系统读取和/或修改通信内容。通常,加密可以防止未经授权的修改和阅读,而电子签名算法只能防止修改。
-
异常:向客户返回异常。
-
消息可靠性:确保在可能的基础设施故障的情况下,消息能够可靠地到达目的地。
虽然有时是可取的,但以下特性并不总是必要的:
-
分布式事务:处理分布式事务的能力,因此当分布式事务失败或中止时,撤消您所做的所有更改。
-
支持发布者/订阅者模式:事件和通知的支持方式。
-
寻址:引用其他服务和/或服务/方法的支持方式。
-
路由:消息如何通过服务网络进行路由。
本节的其余部分致力于描述 SOAP 和 REST 服务,因为它们是在集群/服务器之外公开的业务服务的事实标准。出于性能原因,微服务使用其他协议,在第五章,将微服务架构应用于企业应用程序;第六章,Azure 服务布局;和第七章,Azure Kubernetes 服务中进行了讨论。对于集群间通信,使用高级消息队列协议(AMQP),在进一步阅读部分给出了链接。
SOAP Web 服务
简单对象访问协议(SOAP)允许单向消息和答复/响应消息。通信可以是同步的也可以是异步的,但是,如果底层协议是同步的,比如在 HTTP 的情况下,发送者会收到一个确认消息,说明消息已经被接收(但不一定被处理)。当使用异步通信时,发送者必须监听传入的通信。通常,异步通信是使用我们在第十一章,设计模式和.NET 5 实现中描述的发布者/订阅者模式来实现的。
消息被表示为称为信封的 XML 文档。每个信封包含一个头部,一个主体和一个故障元素。主体是消息的实际内容所在。故障元素包含可能的错误,因此在通信发生时,这是交换异常的方式。最后,头部包含丰富协议的任何辅助信息,但不包含域数据。例如,头部可能包含身份验证令牌,和/或如果消息被签名的话。
用于发送 XML 信封的底层协议通常是 HTTP,但 SOAP 规范允许任何协议,因此我们可以直接使用 TCP/IP 或 SMTP。事实上,更为普遍的底层协议是 HTTP,因此,如果您没有选择其他协议的充分理由,应该使用 HTTP 以最大化服务的互操作性。
SOAP 规范包含消息交换的基础知识,而其他辅助功能则在称为WS- *的单独规范文档中描述,并通常通过在 SOAP 头部添加额外信息来处理。WS-*规范处理我们之前列出的 SOA 的所有基本和理想特性。例如,WS-Security负责安全性,包括身份验证、授权和加密/签名;WS-Eventing和WS-Notification是实现发布者/订阅者模式的两种替代方式;WS-ReliableMessaging关注在可能出现故障时消息的可靠传递;WS-Transaction关注分布式事务。
前述的WS-*规范并不是详尽无遗的,但是它们是更相关和受支持的特性。实际上,各种环境中的实际实现(如 Java 和.NET)提供了更相关的WS-*服务,但没有一种实现支持所有的WS-*规范。
SOAP 协议涉及的所有 XML 文档/文档部分在 XSD 文档中得到正式定义,这些文档是特殊的 XML 文档,其内容提供了 XML 结构的描述。此外,所有自定义数据结构(面向对象语言中的类和接口)必须被转换为 XSD,如果它们将成为 SOAP 信封的一部分。
每个 XSD 规范都有一个关联的命名空间,用于标识规范和可以找到规范的物理位置。命名空间和物理位置都是 URI。如果 Web 服务只能从内部网络访问,位置 URI 不需要公开访问。
服务的整个定义是一个可能包含对其他命名空间的引用的 XSD 规范,也就是说,对其他 XSD 文档的引用。简而言之,SOAP 通信的所有消息必须在 XSD 规范中定义。然后,如果服务器和客户端引用相同的 XSD 规范,它们可以进行通信。这意味着,例如,每次向消息添加另一个字段时,您需要创建一个新的 XSD 规范。之后,您需要更新所有引用旧消息定义的 XSD 文件到新消息定义,通过创建它们的新版本。反过来,这些修改需要为其他 XSD 文件创建其他版本,依此类推。因此,保持与先前行为兼容的简单修改(客户端可以简单地忽略添加的字段)可能会导致指数级的版本更改链。
在过去几年中,处理修改的困难以及处理所有WS-*规范的配置和性能问题,导致了逐渐向我们将在接下来的部分中描述的更简单的 REST 服务的转变。这一转变始于从 JavaScript 调用的服务,因为在 Web 浏览器中实现完整的 SOAP 客户端的困难。此外,复杂的 SOAP 机制对于运行在浏览器中的典型客户端的简单需求来说过于庞大,可能导致开发时间的完全浪费。
因此,面向非 JavaScript 客户端的服务开始大规模转向 REST 服务,如今首选的选择是 REST 服务,而 SOAP 则用于与遗留系统兼容或者在需要 REST 服务不支持的功能时使用。继续偏好 SOAP 系统的典型应用领域是支付/银行系统,因为这些系统需要WS-Transaction SOAP 规范提供的事务支持。在 REST 服务领域没有相应的功能。
REST 网络服务
REST 服务最初被构想为避免在简单情况下使用 SOAP 的复杂机制,比如从网页的 JavaScript 代码调用服务。然后,它们逐渐成为复杂系统的首选。REST 服务使用 HTTP 以 JSON 或者更少见的 XML 格式交换数据。简而言之,它们用 HTTP 主体替换 SOAP 主体,用 HTTP 头部替换 SOAP 头部,HTTP 响应代码替换故障元素,并提供有关执行的操作的进一步辅助信息。
REST 服务成功的主要原因是,HTTP 本身已经提供了大部分 SOAP 功能,这意味着我们可以避免在 HTTP 之上构建 SOAP 级别。此外,整个 HTTP 机制比 SOAP 更简单:编程更简单,配置更简单,而且更容易高效地实现。
此外,REST 服务对客户端的约束更少。服务器和客户端之间的类型兼容性符合更灵活的 JavaScript 类型兼容性模型,因为 JSON 是 JavaScript 的子集。此外,当使用 XML 代替 JSON 时,它保持相同的 JavaScript 类型兼容性规则。不需要指定 XML 命名空间。
在使用 JSON 和 XML 时,如果服务器在响应中添加了一些新字段,同时保持与先前客户端的所有其他字段兼容的语义,他们可以简单地忽略新字段。因此,在服务器中引起实际不兼容行为的破坏性更改的情况下,对 REST 服务定义所做的更改只需要传播到先前的客户端。
此外,由于类型兼容性不要求引用特定类型在唯一共享的位置定义,并且只需要类型的形状是兼容的,因此更改可能是自限制的,不会导致指数级的更改链。
服务类型兼容性规则
让我们通过一个例子来澄清 REST 服务类型兼容性规则。假设有几个服务使用包含“姓名”、“姓氏”和“地址”字符串字段的Person对象。这个对象由S1提供:
{
Name: string,
Surname: string,
Address: string
}
确保类型兼容性,如果服务和客户端引用前面定义的不同副本。客户端使用字段较少的定义也是可以接受的,因为它可以简单地忽略所有其他字段:
{
Name: string,
Surname: string,
}
您只能在“自己”的代码中使用字段较少的定义。尝试在没有预期字段的情况下将信息发送回服务器可能会导致问题。
现在,想象一下这样的情景:您有一个S2服务,它从S1获取Person对象,并将它们添加到其某些方法返回的响应中。假设处理Person对象的S1服务用复杂对象替换了“地址”字符串:
{
Name: string,
Surname: string,
Address:
{
Country: string,
Town: string,
Location: string
}
}
在破坏性更改之后,S2服务将不得不调整其调用S1服务的通信客户端以适应新格式。然后,它可以在使用Person对象在其响应中之前将新的Person格式转换为旧的格式。这样,S2服务避免了传播S1的破坏性更改。
总的来说,基于对象形状(嵌套属性树)而不是对相同正式类型定义的引用来确定类型兼容性,增加了灵活性和可修改性。我们为这种增加的灵活性付出的代价是,类型兼容性不能通过比较服务器和客户端接口的正式定义来自动计算。事实上,在没有明确规范的情况下,每次发布服务的新版本时,开发人员必须验证客户端和服务器共有的所有字段的语义是否与上一个版本保持不变。
REST 服务背后的基本思想是放弃严格的检查和复杂的协议,以换取更大的灵活性和简单性,而 SOAP 恰恰相反。
Rest 和本机 HTTP 功能
REST 服务宣言指出,REST 使用本机 HTTP 功能来实现所有所需的服务功能。因此,例如,身份验证将直接使用 HTTP 的Authorization字段进行,加密将通过 HTTPS 实现,异常将使用 HTTP 错误状态代码处理,路由和可靠的消息传递将由 HTTP 协议依赖的机制处理。通过使用 URL 来引用服务、它们的方法和其他资源来实现寻址。
由于 HTTP 是同步协议,因此没有原生支持异步通信。也没有原生支持发布者/订阅者模式,但两个服务可以通过各自公开一个端点来与发布者/订阅者模式进行交互。更具体地,第一个服务公开一个订阅端点,而第二个服务公开一个端点,用于接收其通知,这些通知通过在订阅期间交换的共同秘密进行授权。这种模式非常常见。GitHub 还允许我们将我们的 REST 服务发送到存储库事件。
REST 服务在实现分布式事务时没有简单的选项,这就是为什么支付/银行系统仍然更喜欢 SOAP。幸运的是,大多数应用领域不需要分布式事务所确保的强一致性。对于它们来说,轻量级的一致性形式,如最终一致性,已经足够,并且出于性能原因更受欢迎。请参阅第九章,如何在云中选择您的数据存储,讨论各种一致性类型。
REST 宣言不仅规定了在 HTTP 中使用预定义的解决方案,还规定了使用类似 Web 的语义。更具体地说,所有服务操作必须被构想为对由 URL 标识的资源进行 CRUD 操作(同一资源可以由多个 URL 标识)。事实上,REST 是表现状态转移的缩写,意味着每个 URL 都是某种对象的表示。每种服务请求都需要采用适当的 HTTP 动词,如下所示:
-
GET(读取操作):URL 代表读取操作返回的资源。因此,GET操作模拟指针解引用。在操作成功的情况下,将返回 200(OK)状态码。 -
POST(创建操作):包含在请求体中的 JSON/XML 对象被添加为操作 URL 所代表的对象的新资源。如果新资源立即成功创建,将返回 201(已创建)状态码,以及取决于操作的响应对象和关于可以从哪里检索到创建的资源的指示。响应对象应包含标识创建的资源的最具体 URL。如果创建推迟到以后的时间,将返回 202(已接受)状态码。 -
PUT(编辑操作):请求体中包含的 JSON/XML 对象替换了请求 URL 引用的对象。在操作成功的情况下,将返回 200(OK)状态码。这个操作是幂等的,也就是说重复相同的请求两次会导致相同的修改。 -
PATCH:请求体中包含的 JSON/XML 对象包含如何修改请求 URL 引用的对象的指令。这个操作不是幂等的,因为修改可能是对数值字段的增量。在操作成功的情况下,将返回 200(OK)状态码。 -
DELETE:删除请求 URL 引用的资源。在操作成功的情况下,将返回 200(OK)状态码。
如果资源已从请求 URL 移动到另一个 URL,将返回重定向代码:
-
301(永久移动),以及我们可以找到资源的新 URL -
307(临时移动),以及我们可以找到资源的新 URL
如果操作失败,将返回取决于失败类型的状态码。一些失败代码的示例如下:
-
400(错误的请求):发送到服务器的请求格式不正确。 -
404(未找到):当请求 URL 不引用任何已知对象时。 -
405(方法不允许):当 URL 引用的资源不支持请求动词时。 -
401(未经授权):操作需要身份验证,但客户端未提供任何有效的授权头。 -
403(禁止):客户端已正确进行身份验证,但没有执行操作的权限。
上述状态码列表并非详尽无遗。详尽列表的参考将在进一步阅读部分提供。
需要指出的是,POST/PUT/PATCH/DELETE操作可能具有对其他资源的副作用,通常也会有副作用。否则,将无法编写同时对多个资源进行操作的操作。
换句话说,HTTP 动词必须符合在请求 URL 引用的资源上执行的操作,但该操作可能会影响其他资源。同一操作可能会使用不同的 HTTP 动词在其他涉及的资源中执行。开发人员有责任选择以哪种方式执行相同的操作来实现服务接口。
由于 HTTP 动词的副作用,REST 服务可以将所有这些操作编码为 URL 表示的资源上的 CRUD 操作。
通常,将现有服务转换为 REST 需要我们在请求 URL 和请求主体之间分割各种输入。更具体地说,我们提取唯一定义方法执行中涉及的对象之一的输入字段,并使用它们创建一个唯一标识该对象的 URL。然后,我们根据在所选对象上执行的操作选择要使用的 HTTP 动词。最后,我们将其余的输入放在请求主体中。
如果我们的服务是以面向业务域对象的面向对象架构设计的(例如 DDD,如第十二章中所述,理解软件解决方案中的不同领域),那么所有服务方法的 REST 翻译应该是相当直接的,因为服务应该已经围绕领域资源组织起来。否则,转移到 REST 可能需要重新定义一些服务接口。
采用完整的 REST 语义的优势在于服务可以在不对现有操作定义进行小修改的情况下进行扩展。事实上,扩展主要表现为某些对象的附加属性和一些相关操作的附加资源 URL。因此,现有客户端可以简单地忽略它们。
REST 语言中方法的示例
现在,让我们通过一个简单的银行内部转账的示例来学习如何在 REST 语言中表达方法。银行账户可以通过以下 URL 表示:
https://mybank.com/bankaccounts/{bank account number}
转账可以表示为一个PATCH请求,其主体包含一个表示金额、转账时间、描述和接收资金的账户的属性对象。
该操作修改了 URL 中提到的账户,同时也会影响到接收账户。如果账户没有足够的资金,将返回 403(禁止)状态码,以及一个包含所有错误细节的对象(错误描述、可用资金等)。
然而,由于所有的银行操作都记录在账单中,为与银行账户相关的bank account operations集合创建和添加一个新的转账对象是更好的表示转账的方式。在这种情况下,URL 可能是以下内容:
https://mybank.com/bankaccounts/{bank account number}/transactions
在这里,HTTP 动词是POST,因为我们正在创建一个新对象。主体内容是相同的,如果资金不足,则返回422状态码。
转账的两种表示都会导致数据库中的相同更改。此外,一旦输入从不同的 URL 和可能不同的请求主体中提取出来,后续处理是相同的。在这两种情况下,我们有相同的输入和相同的处理 - 只是这两个请求的外观不同。
然而,引入虚拟的operations集合使我们能够通过几种特定于operations集合的方法来扩展服务。值得指出的是operations集合不需要与数据库表或任何物理对象连接:它存在于 URL 的世界中,并为我们建模转账提供了便利的方式。
REST 服务的增加使用导致了 REST 服务接口的描述被创建,就像为 SOAP 开发的那样。这个标准称为 OpenAPI。我们将在下一小节中讨论这个问题。
OpenAPI 标准
OpenAPI 是用于描述 REST API 的标准。目前是版本 3。整个服务由 JSON 端点描述,即用 JSON 对象描述服务的端点。这个 JSON 对象有一个适用于整个服务的一般部分,包含服务的一般特性,如其版本和描述,以及共享定义。
然后,每个服务端点都有一个特定部分,描述端点的 URL 或 URL 格式(如果 URL 中包含一些输入),所有的输入,所有可能的输出类型和状态码,以及所有的授权协议。每个特定端点部分可以引用一般部分中包含的定义。
本书不涵盖 OpenAPI 语法的描述,但在“进一步阅读”部分提供了参考资料。各种开发框架通过处理 REST API 代码自动生成 OpenAPI 文档,并由开发人员提供更多信息,因此您的团队不需要深入了解 OpenAPI 语法。其中一个例子是我们将在本章中介绍的Swashbuckle.AspNetCore NuGet 包。
“.NET 5 如何处理 SOA?”部分解释了我们如何在 ASP.NET Core REST API 项目中自动生成 OpenAPI 文档,而本章末尾的用例提供了其使用的实际示例。
我们将通过讨论如何处理 REST 服务中的身份验证和授权来结束本小节。
REST 服务授权和身份验证
由于 REST 服务是无状态的,当需要身份验证时,客户端必须在每个请求中发送身份验证令牌。该令牌通常放在 HTTP 授权标头中,但这取决于您使用的身份验证协议的类型。通过显式传输共享密钥是进行身份验证的最简单方式。可以使用以下代码来实现:
Authorization: Api-Key <string known by both server and client>
共享密钥称为 API 密钥。由于在撰写本文时,尚无关于如何发送 API 密钥的标准,因此 API 密钥也可以在其他标头中发送,如下面的代码所示:
X-API-Key: <string known by both server and client>
值得一提的是,基于 API 密钥的身份验证需要使用 HTTPS 来阻止共享密钥被窃取。API 密钥非常简单易用,但它们不传达有关用户授权的信息,因此当客户端允许的操作非常标准且没有复杂的授权模式时,可以采用它们。此外,在请求中交换 API 密钥时,API 密钥容易受到服务器或客户端的攻击。缓解这种情况的常见模式是创建一个“服务账户”用户,并将其授权限制在所需的范围内,并在与 API 交互时使用该特定账户的 API 密钥。
更安全的技术使用长期有效的共享密钥,用户登录后即可使用。然后,登录返回一个短期令牌,该令牌在所有后续请求中用作共享密钥。当短期密钥即将过期时,可以通过调用续订端点来进行续订。
整个逻辑与短期令牌身份验证逻辑完全解耦。登录通常基于接收长期凭据并返回短期令牌的登录端点。登录凭据可以是传递给登录方法的常规用户名密码对,也可以是其他类型的授权令牌,这些令牌被转换为由登录端点提供的短期令牌。登录还可以通过基于 X.509 证书的各种身份验证协议实现。
最常见的短寿命令牌类型是所谓的持有者令牌。每个持有者令牌都编码了它的持续时间以及一系列断言,称为声明,可用于授权目的。持有者令牌由登录操作或续订操作返回。它们的特征是它们不与接收它们的客户端或任何其他特定客户端绑定。
无论客户端如何获得持有者令牌,这都是客户端需要被其声明隐含的所有权利授予的。只需将持有者令牌转让给另一个客户端,即可授予该客户端所有持有者令牌声明隐含的所有权利,因为基于持有者令牌的授权不需要身份的证明。
因此,一旦客户端获得了一个持有者令牌,它可以通过将其持有者令牌转让给第三方来委托一些操作。通常,在必须使用持有者令牌进行委托时,在登录阶段,客户端指定要包含的声明以限制令牌可以授权的操作。
与 API 密钥身份验证相比,基于持有者令牌的身份验证受到标准的约束。它们必须使用以下Authorization标头:
Authorization: Bearer <bearer token string>
持有者令牌可以以多种方式实现。REST 服务通常使用 JWT 令牌,该令牌是由 JSON 对象的 Base64URL 编码串联而成。更具体地说,JWT 的创建始于 JSON 标头,以及 JSON 负载。JSON 标头指定了令牌的类型以及如何签名,而负载由一个包含所有声明的 JSON 对象作为属性/值对组成。以下是一个示例标头:
{
"alg": "RS256",
"typ": "JWT"
}
以下是一个示例负载:
{
"iss": "wwtravelclub.com"
"sub": "example",
"aud": ["S1", "S2"],
"roles": [
"ADMIN",
"USER"
],
"exp": 1512975450,
"iat": 1512968250230
}
然后,标头和负载被 Base64URL 编码,并且相应的字符串连接如下:
<header BASE64 string>.<payload base64 string>
然后,使用标头中指定的算法对前面的字符串进行签名,例如,在我们的示例中,是 RSA + SHA256,并将签名字符串与原始字符串连接如下:
<header BASE64 string>.<payload base64 string>.<signature string>
前面的代码是最终的持有者令牌字符串。可以使用对称签名代替 RSA,但在这种情况下,JWT 颁发者和所有使用它进行授权的服务必须共享一个公共密钥,而在 RSA 中,JWT 颁发者的私钥不需要与任何人共享,因为签名可以仅通过颁发者公钥进行验证。
一些负载属性是标准的,比如以下内容:
-
iss:JWT 的颁发者。 -
aud:受众,即可以使用令牌进行授权的服务和/或操作。如果服务在此列表中看不到其标识符,则应拒绝令牌。 -
sub:标识 JWT 颁发给的主体(即用户)的字符串。 -
iat,exp和nbf:这些是 JWT 颁发的时间,其过期时间,以及如果设置了,令牌有效的时间之后。所有时间都表示为从 1970 年 1 月 1 日 UTC 午夜开始的秒数。在这里,所有天都被认为是确切地有 86400 秒。
如果我们用唯一的 URI 表示,其他声明可以被定义为公共的;否则,它们被认为是颁发者和已知服务的私有声明。
.NET 5 如何处理 SOA?
WCF 技术尚未移植到.NET 5,并且没有计划对其进行完全移植。相反,微软正在投资于 gRPC,谷歌的开源技术。此外,.NET 5 通过 ASP.NET Core 对 REST 服务有出色的支持。
.NET 5 放弃 WCF 的主要原因如下:
-
正如我们已经讨论过的,SOAP 技术在大多数应用领域已被 REST 技术取代。
-
WCF 技术严格绑定在 Windows 上,因此在.NET 5 中从头开始重新实现其所有功能将非常昂贵。由于对完整.NET 的支持将继续,需要 WCF 的用户仍然可以依赖它。
-
作为一般策略,微软更倾向于投资于可以与其他竞争对手共享的开源技术。这就是为什么微软在.NET Core 3.0 开始提供了 gRPC 实现,而不是投资于 WCF。
下面的小节将介绍 Visual Studio 为我们提到的每种技术提供的支持。
SOAP 客户端支持
在 WCF 中,服务规范是通过.NET 接口定义的,实际的服务代码是在实现这些接口的类中提供的。端点、底层协议(HTTP 和 TCP/IP)以及任何其他特性都在配置文件中定义。配置文件可以通过易于使用的配置工具进行编辑。因此,开发人员只需提供标准的.NET 类作为服务行为,并以声明方式配置所有服务特性。这样,服务配置完全与实际的服务行为解耦,每个服务都可以重新配置,以适应不同的环境,而无需修改其代码。
虽然.NET 5 不支持 SOAP 技术,但它支持 SOAP 客户端。更具体地说,在 Visual Studio 中为现有的 SOAP 服务创建 SOAP 服务代理非常容易(请参阅第十一章,设计模式和.NET 5 实现,讨论代理是什么以及代理模式)。
在服务的情况下,代理是实现服务接口的类,其方法通过调用远程服务的类似方法来执行它们的工作。
要创建服务代理,在解决方案资源管理器中的项目中右键单击依赖项,然后选择添加连接的服务。然后,在出现的表单中,选择Microsoft WCF 服务引用提供程序。在那里,您可以指定服务的 URL(包含 WSDL 服务描述的位置)、要添加代理类的命名空间等。在向导结束时,Visual Studio 会自动添加所有必要的 NuGet 包并生成代理类。这就足以创建此类的实例并调用其方法,以便与远程 SOAP 服务进行交互。
还有第三方,如 NuGet 包,提供了对 SOAP 服务的有限支持,但目前它们并不是很有用,因为这种有限支持不包括在 REST 服务中不可用的功能。
gRPC 支持
Visual Studio 2019 支持 gRPC 项目模板,可以为 gRPC 服务器和 gRPC 客户端生成脚手架。gRPC 实现了远程过程调用模式,提供了同步和异步调用,减少了客户端和服务器之间的消息流量。
尽管在撰写本书时,gRPC 在 Azure 的 IIS 和应用服务中不可用,但与此相关的伟大倡议。其中之一是 gRPC-Web(devblogs.microsoft.com/aspnet/grpc-web-for-net-now-available/)。
它的配置方式类似于 WCF 和.NET 远程调用,正如我们在第六章,Azure Service Fabric末尾所描述的那样。也就是说,服务是通过接口定义的,它们的代码是在实现这些接口的类中提供的,而客户端通过实现相同的服务接口的代理与这些服务进行交互。
gRPC 是微服务集群内部通信的一个很好的选择,特别是如果集群不完全基于 Service Fabric 技术,并且不能依赖.NET 远程调用。由于所有主要语言和开发框架都有 gRPC 库,因此它可以在基于 Kubernetes 的集群中使用,以及在托管了在其他框架中实现的 Docker 镜像的 Service Fabric 集群中使用。
由于 gRPC 对数据的更紧凑表示以及更易于使用,因为协议的所有内容都由开发框架处理,所以 gRPC 比 REST 服务协议更有效。然而,在撰写本文时,它的特性都不依赖于成熟的标准,因此不能用于公开的端点 - 它只能用于集群内部通信。因此,我们不会详细描述 gRPC,但本章的进一步阅读部分包含了对 gRPC 的一般参考以及其.NET Core 实现的引用。
使用 gRPC 非常简单,因为 Visual Studio 的 gRPC 项目模板会自动生成 gRPC 服务和其客户端的所有内容。开发人员只需定义特定于应用程序的 C#服务接口和实现它的类。
您可以在docs.microsoft.com/en-us/aspnet/core/tutorials/grpc/grpc-start?view=aspnetcore-5.0上查看有关此实现的详细信息。
本节的其余部分专门介绍了.NET Core 对 REST 服务的支持,包括服务器端和客户端。
ASP.NET Core 简介
ASP.NET Core 应用程序是基于我们在“使用通用主机”子章节中描述的主机概念的.NET Core 应用程序,该子章节位于第五章的将微服务架构应用于企业应用程序中。每个 ASP.NET 应用程序的program.cs文件都会创建一个主机,构建它,并使用以下代码运行它:
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host
.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
CreateDefaultBuilder设置了一个标准主机,而ConfigureWebHostDefaults配置了它,以便它可以处理 HTTP 管道。更具体地说,它为当前目录设置了IWebHostEnvironment接口的ContentRootPath属性。
然后,它从appsettings.json和appsettings.[EnvironmentName].json中加载配置信息。一旦加载,JSON 对象属性中包含的配置信息可以使用 ASP.NET Core 选项框架映射到.NET 对象属性。更具体地说,appsettings.json和appsettings.[EnvironmentName].json被合并,文件的特定于环境的信息会覆盖相应的appsettings.json设置。
EnvironmentName取自ASPNETCORE_ENVIRONMENT环境变量。反过来,当应用程序在 Visual Studio 中运行时,它在Properties\launchSettings.json文件中定义,位于解决方案资源管理器上方。在此文件中,您可以定义几个可以使用下拉菜单选择的环境,该下拉菜单位于 Visual Studio 运行按钮IIS Express旁边。默认情况下,IIS Express设置将ASPNETCORE_ENVIRONMENT设置为Development。以下是一个典型的launchSettings.json文件:
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:2575",
"sslPort": 44393
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
...
...
}
}
}
在应用程序发布时,可以在 Visual Studio 创建的发布 XML 文件中添加要使用的ASPNETCORE_ENVIRONMENT值。该值为<EnvironmentName>Staging</EnvironmentName>。它也可以在您的 Visual Studio ASP.NET Core 项目文件(.csproj)中指定:
<PropertyGroup>
<EnvironmentName>Staging</EnvironmentName>
</PropertyGroup>
随后,应用程序配置主机日志记录,以便可以将日志写入控制台和调试输出。此设置可以通过进一步的配置进行更改。然后,它设置/连接 Web 服务器到 ASP.NET Core 管道。
当应用程序在 Linux 上运行时,ASP.NET Core 管道连接到.NET Core Kestrel Web 服务器。由于 Kestrel 是一个最小的 Web 服务器,您需要负责从完整的 Web 服务器(如 Apache 或 NGINX)进行反向代理请求,以添加 Kestrel 没有的功能。当应用程序在 Windows 上运行时,默认情况下,ConfigureWebHostDefaults将 ASP.NET Core 管道直接连接到Internet Information Services(IIS)。但是,您也可以在 Windows 中使用 Kestrel,并且可以通过更改 Visual Studio 项目文件的AspNetCoreHostingModel设置将 IIS 请求反向代理到 Kestrel。
<PropertyGroup>
...
<AspNetCoreHostingModel>OutOfProcess</AspNetCoreHostingModel>
</PropertyGroup>
UseStartup<Startup>()允许主机服务(参见第五章的使用通用主机子章节,将 ASP.NET Core 管道的定义从项目的Startup.cs类的方法中获取。更具体地说,服务在其ConfigureServices(IServiceCollection services)方法中定义,而 ASP.NET Core 管道在Configure方法中定义。以下代码显示了使用 API REST 项目生成的标准Configure方法:
public void Configure(IApplicationBuilder app,
IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseHsts();
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
管道中的每个中间件都由一个app.Use<something>方法定义,通常接受一些选项。它们中的每一个都处理请求,然后要么将修改后的请求转发到管道中的下一个中间件,要么返回 HTTP 响应。当返回 HTTP 响应时,它将按相反的顺序由所有先前的中间件处理。
模块按照它们被app.Use<something>方法调用定义的顺序插入到管道中。前面的代码在ASPNETCORE_ENVIRONMENT为Development时添加了一个错误页面;否则,UseHsts与客户端协商安全协议。最后,UseEndpoints添加了创建实际 HTTP 响应的 MVC 控制器。ASP.NET Core 管道的完整描述将在第十五章的理解 Web 应用程序的表示层部分中给出。
在下一小节中,我们将解释 MVC 框架如何让您实现 REST 服务。
使用 ASP.NET Core 实现 REST 服务
今天,我们可以保证 MVC 和 Web API 的使用已经得到巩固。在 MVC 框架中,HTTP 请求由称为控制器的类处理。每个请求都映射到调用控制器公共方法。所选的控制器和控制器方法取决于请求路径的形状,并且由路由规则定义,对于 REST API,通常通过与Controller类及其方法相关联的属性提供。
处理 HTTP 请求的Controller方法称为操作方法。当选择控制器和操作方法时,MVC 框架会创建一个控制器实例来处理请求。控制器构造函数的所有参数都通过Startup.cs类的ConfigureServices方法中定义的类型进行依赖注入解析。
有关如何在.NET Core 主机中使用依赖注入的描述,请参阅第五章的应用微服务架构到企业应用程序的使用通用主机子章节,并参阅第十一章的设计模式和.NET 5 实现的依赖注入模式子章节,以获取有关依赖注入的一般讨论。
以下是一个典型的 REST API 控制器及其控制器方法定义:
[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
// GET api/values/5
[HttpGet("{id}")]
public ActionResult<string> Get(int id)
{
...
[ApiController]属性声明控制器是一个 REST API 控制器。[Route("api/[controller]")]声明控制器必须在以api/<controller name>开头的路径上进行选择。控制器名称是控制器类的名称,不包括Controller后缀。因此,在这种情况下,我们有api/values。
[HttpGet("{id}")]声明该方法必须在api/values/<id>类型的 GET 请求上调用,其中id必须是作为方法调用参数传递的数字。可以使用Get(int id)来实现。每个 HTTP 动词也有一个Http<verb>属性:HttpPost和HttpPatch。
我们还可以定义另一个方法如下:
[HttpGet]
public ... Get()
这种方法是在api/values类型的GET请求上调用的,也就是在控制器名称后没有id的GET请求上调用的。
多个操作方法可以具有相同的名称,但只有一个应与每个请求路径兼容;否则,将抛出异常。换句话说,路由规则和Http<verb>属性必须明确定义每个请求的哪个控制器及其哪个操作方法。
默认情况下,参数根据以下规则传递给 API 控制器的操作方法:
-
如果路由规则将简单类型(
整数、浮点数和日期时间)指定为参数,它们将从请求路径中获取,就像前面示例的[HttpGet("{id}")]属性一样。如果它们在路由规则中找不到,MVC 框架将查找具有相同名称的查询字符串参数。因此,例如,如果我们用[HttpGet]替换[HttpGet("{id}")],MVC 框架将查找类似api/values?id=<整数>的内容。 -
复杂类型是由格式化程序从请求正文中提取的。根据请求的
Content-Type标头的值选择正确的格式化程序。如果未指定Content-Type标头,则采用 JSON 格式化程序。JSON 格式化程序尝试解析请求正文作为 JSON 对象,然后尝试将此 JSON 对象转换为.NET Core 复杂类型的实例。如果 JSON 提取或随后的转换失败,将抛出异常。默认情况下,只支持 JSON 输入格式化程序,但您还可以添加一个 XML 格式化程序,当Content-Type指定 XML 内容时可以使用。只需添加Microsoft.AspNetCore.Mvc.Formatters.XmlNuGet 软件包,并在Startup.cs的ConfigureServices方法中用services.AddControllers().AddXmlSerializerFormatters()替换services.AddControllers()即可。
您可以通过使用适当的属性为操作方法参数添加前缀来自定义用于填充操作方法参数的源。以下代码显示了一些示例:
...MyActionMethod(....[FromHeader] string myHeader....)
// x is taken from a request header named myHeader
...MyActionMethod(....[FromServices] MyType x....)
// x is filled with an instance of MyType through dependency injection
Action方法的返回类型必须是IActionResult接口或实现该接口的类型。反过来,IActionResult只有以下方法:
Task ExecuteResultAsync(ActionContext context);
这种方法在正确的时间由 MVC 框架调用,以创建实际的响应和响应头。当传递给方法时,ActionContext对象包含 HTTP 请求的整个上下文,其中包括一个包含有关原始 HTTP 请求的所有必要信息的请求对象(标头、正文和 cookie),以及一个收集正在构建的响应的所有部分的响应对象。
您不必手动创建IActionResult的实现,因为ControllerBase已经有了创建IActionResult实现的方法,以便生成所有必要的 HTTP 响应。其中一些方法如下。
-
OK:这返回一个 200 状态代码,以及一个可选的结果对象。它可以作为return OK()或return OK(myResult)使用。 -
BadRequest:这返回一个 400 状态代码,以及一个可选的响应对象。 -
Created(string uri, object o):这返回一个 201 状态代码,以及一个结果对象和创建的资源的 URI。 -
Accepted:这返回一个 202 状态结果,以及一个可选的结果对象和资源 URI。 -
Unauthorized:这返回一个 401 状态结果,以及一个可选的结果对象。 -
Forbid:这返回一个 403 状态结果,以及一个可选的失败权限列表。 -
StatusCode(int statusCode, object o = null):这返回一个自定义状态码,以及一个可选的结果对象。
操作方法可以直接返回一个结果对象,如 return myObject。这相当于返回 OK(myObject)。
当所有结果路径返回相同类型的结果对象,比如 MyType,操作方法可以声明为返回 ActionResult<MyType>。您也可以返回诸如 NotFound 的响应,但是通过这种方法肯定会得到更好的类型检查。
默认情况下,结果对象以 JSON 格式序列化在响应主体中。然而,如果在 MVC 框架处理管道中添加了 XML 格式化程序,如前所示,结果的序列化方式取决于 HTTP 请求的 Accept 标头。更具体地说,如果客户端明确要求 XML 格式的 Accept 标头,对象将以 XML 格式序列化;否则,它将以 JSON 格式序列化。
作为输入传递给操作方法的复杂对象可以使用验证属性进行验证,如下所示:
public record MyType
{
[Required]
public string Name{get; set;}
...
[MaxLength(64)]
public string Description{get; set;}
}
如果控制器已经使用了 [ApiController] 属性进行装饰,并且验证失败,MVC 框架会自动创建一个包含所有检测到的验证错误的字典的 BadRequest 响应,而不执行操作方法。因此,您无需添加进一步的代码来处理验证错误。
操作方法也可以声明为异步方法,如下所示:
public async Task<IActionResult>MyMethod(......)
{
await MyBusinessObject.MyBusinessMethod();
...
}
public async Task<ActionResult<MyType>>MyMethod(......)
{
...
本章的用例部分将展示控制器/操作方法的实际示例。在下一小节中,我们将解释如何处理 JWT 令牌的授权和身份验证。
ASP.NET Core 服务授权
在使用 JWT 令牌时,授权是基于 JWT 令牌中包含的声明。任何操作方法中的所有令牌声明都可以通过 User.Claims 控制器属性访问。由于 User.Claims 是一个 IEnumerable<Claim>,它可以使用 LINQ 处理以验证声明的复杂条件。如果授权基于 角色 声明,您可以简单地使用 User.IsInRole 函数,如下所示:
If(User.IsInRole("Administrators") || User.IsInRole("SuperUsers"))
{
...
}
else return Forbid();
然而,权限通常不是从操作方法内部检查的,而是由 MVC 框架自动检查,根据装饰整个控制器或单个操作方法的授权属性。如果操作方法或整个控制器使用 [Authorize] 装饰,那么只有在请求具有有效的身份验证令牌时才能访问操作方法,这意味着我们不必对令牌声明进行检查。还可以使用以下代码检查令牌是否包含一组角色:
[Authorize(Roles = "Administrators,SuperUsers")]
对声明的复杂条件需要在 Startup.cs 的 ConfigureServices 方法中定义授权策略,如下所示:
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
...
services.AddAuthorization(options =>
{
options.AddPolicy("CanDrive", policy =>
policy.RequireAssertion(context =>
context.User.HasClaim(c =>c.Type == "HasDrivingLicense"));
});
}
之后,您可以使用 [Authorize(Policy = "Father")] 装饰操作方法或控制器。
在使用基于 JWT 的授权之前,您必须在 Startup.cs 中进行配置。首先,您必须在 Configure 方法中定义的 ASP.NET Core 处理管道中添加处理身份验证令牌的中间件,如下所示:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
...
app.UseAuthorization();
...
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
然后,您必须在 ConfigureServices 部分配置身份验证服务。在这里,您定义将通过依赖注入注入到身份验证中间件中的身份验证选项:
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => {
options.TokenValidationParameters =
new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "My.Issuer",
ValidAudience = "This.Website.Audience",
IssuerSigningKey = new
SymmetricSecurityKey(Encoding.ASCII.GetByte
("MySecret"))
};
});
上述代码为身份验证方案提供了一个名称,即默认名称。然后,它指定了 JWT 身份验证选项。通常,我们要求身份验证中间件验证 JWT 令牌是否未过期(ValidateLifetime = true),它具有正确的发行者和受众(请参阅本章的REST 服务授权和身份验证部分),以及其签名是否有效。
前面的示例使用了从字符串生成的对称签名密钥。这意味着相同的密钥用于签名和验证签名。如果 JWT 令牌是由使用它们的同一个网站创建的,这是一个可以接受的选择,但如果有一个唯一的 JWT 发行者控制对多个 Web API 站点的访问,这是一个不可接受的选择。
在这里,我们应该使用非对称密钥(通常是RsaSecurityKey),因此 JWT 验证只需要知道与实际私钥相关联的公钥。Identity Server 4 可以用于快速创建一个作为身份验证服务器的网站。它发出带有通常的用户名/密码凭据或转换其他身份验证令牌的 JWT 令牌。如果您使用诸如 Identity Server 4 之类的身份验证服务器,您不需要指定IssuerSigningKey选项,因为授权中间件能够自动从授权服务器检索所需的公钥。
只需提供身份验证服务器的 URL,如下所示:
.AddJwtBearer(options => {
options.Authority = "https://www.MyAuthorizationserver.com";
options.TokenValidationParameters =...
...
另一方面,如果决定在 Web API 站点中发出 JWT,可以定义一个接受包含用户名和密码的对象的Login操作方法,并且在依赖于数据库信息的同时,使用类似以下代码构建 JWT 令牌:
var claims = new List<Claim>
{
new Claim(...),
new Claim(...) ,
...
};
var token = new JwtSecurityToken(
issuer: "MyIssuer",
audience: ...,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(expiryInMinutes),
signingCredentials:
new SymmetricSecurityKey(Encoding.ASCII.GetBytes("MySecret"));
return OK(new JwtSecurityTokenHandler().WriteToken(token));
在这里,JwtSecurityTokenHandler().WriteToken(token)从JwtSecurityToken实例中包含的令牌属性生成实际的令牌字符串。
在下一小节中,我们将学习如何通过 OpenAPI 文档端点增强我们的 Web API,以便可以自动生成用于与我们的服务通信的代理类。
ASP.NET Core 对 OpenAPI 的支持
通过反射,大部分填写 OpenAPI JSON 文档所需的信息可以从 Web API 控制器中提取,即输入类型和来源(路径、请求体和标头)以及端点路径(这些可以从路由规则中提取)。一般来说,返回的输出类型和状态码不能轻松计算,因为它们可以动态生成。
因此,MVC 框架提供了ProducesResponseType属性,以便我们可以声明可能的返回类型 - 状态码对。只需为每个操作方法装饰上与可能的类型相同数量的ProducesResponseType属性,即可能的状态码对,如下面的代码所示:
[HttpGet("{id}")]
[ProducesResponseType(typeof(MyReturnType), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(MyErrorReturnType), StatusCodes.Status404NotFound)]
public IActionResult GetById(int id)...
如果沿着某个路径没有返回对象,我们可以只声明状态码,如下所示:
[ProducesResponseType(StatusCodes.Status403Forbidden)]
当所有路径返回相同类型且该类型在操作方法返回类型中指定为ActionResult<CommonReturnType>时,我们也可以只指定状态码。
一旦所有操作方法都已记录,要为 JSON 端点生成任何实际的文档,我们必须安装Swashbuckle.AspNetCore NuGet 软件包,并在Startup.cs文件中放置一些代码。更具体地说,我们必须在Configure方法中添加一些中间件,如下所示:
app.UseSwagger(); //open api middleware
...
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
然后,我们必须在ConfigureServices方法中添加一些配置选项,如下所示:
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("MyServiceName", new OpenApiInfo
{
Version = "v1",
Title = "ToDo API",
Description = "My service description",
});
});
SwaggerDoc方法的第一个参数是文档端点名称。默认情况下,文档端点可以通过<webroot>//swagger/<endpoint name>/swagger.json路径访问,但可以通过多种方式进行更改。Info类中包含的其余信息是不言自明的。
我们可以添加多个SwaggerDoc调用来定义多个文档端点。然而,默认情况下,所有文档端点将包含相同的文档,其中包括项目中包含的所有 REST 服务的描述。可以通过在services.AddSwaggerGen(c => {...})中调用c.DocInclusionPredicate(Func<string, ApiDescription> predicate)方法来更改此默认设置。
DocInclusionPredicate必须传递一个函数,该函数接收一个 JSON 文档名称和一个操作方法描述,并且必须在该 JSON 文档中包含操作的文档时返回true。
要声明您的 REST API 需要 JWT 令牌,您必须在services.AddSwaggerGen(c => {...})中添加以下代码:
var security = new Dictionary<string, IEnumerable<string>>
{
{"Bearer", new string[] { }},
};
c.AddSecurityDefinition("Bearer", new ApiKeyScheme
{
Description = "JWT Authorization header using the Bearer scheme.
Example: \"Authorization: Bearer {token}\"",
Name = "Authorization",
In = "header",
Type = "apiKey"
});
c.AddSecurityRequirement(security);
您可以使用从三斜杠注释中提取的信息来丰富 JSON 文档端点,这些注释通常用于生成自动代码文档。以下代码显示了一些示例。以下代码片段显示了如何添加方法描述和参数信息:
/// <summary>
/// Deletes a specific TodoItem.
/// </summary>
/// <param name="id">id to delete</param>
[HttpDelete("{id}")]
public IActionResultDelete(long id)
以下代码片段显示了如何添加使用示例:
/// <summary>
/// Creates an item.
/// </summary>
/// <remarks>
/// Sample request:
///
/// POST /MyItem
/// {
/// "id": 1,
/// "name": "Item1"
/// }
///
/// </remarks>
以下代码片段显示了如何为每个 HTTP 状态代码添加参数描述和返回类型描述:
/// <param name="item">item to be created</param>
/// <returns>A newly created TodoItem</returns>
/// <response code="201">Returns the newly created item</response>
/// <response code="400">If the item is null</response>
要从三斜杠注释中提取信息,我们必须通过在项目文件(.csproj)中添加以下代码来启用代码文档创建:
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
然后,我们必须通过添加以下代码来从services.AddSwaggerGen(c => {...})中启用代码文档处理:
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
c.IncludeXmlComments(xmlPath);
一旦我们的文档端点准备就绪,我们可以添加一些中间件,该中间件包含在相同的Swashbuckle.AspNetCore NuGet 包中,以生成一个友好的用户界面,我们可以在其上测试我们的 REST API:
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/<documentation name>/swagger.json", "
<api name that appears in dropdown>");
});
如果您有多个文档端点,您需要为每个端点添加一个SwaggerEndpoint调用。我们将使用此接口来测试本章中定义的 REST API。
一旦您有一个可用的 JSON 文档端点,您可以使用以下方法之一自动生成代理类的 C#或 TypeScript 代码:
-
NSwagStudio Windows 程序,可在
github.com/RicoSuter/NSwag/wiki/NSwagStudio上获得。 -
如果您想自定义代码生成,可以使用
NSwag.CodeGeneration.CSharp或NSwag.CodeGeneration.TypeScriptNuGet 包。 -
如果您想将代码生成与 Visual Studio 构建操作绑定在一起,可以使用
NSwag.MSBuildNuGet 包。有关此的文档可以在github.com/RicoSuter/NSwag/wiki/MSBuild找到。
在下一小节中,您将学习如何从另一个 REST API 或.NET Core 客户端调用 REST API。
.Net Core HTTP 客户端
System.Net.Http命名空间中的HttpClient类是一个.NET 标准 2.0 内置的 HTTP 客户端类。虽然它可以直接使用,但在重复创建和释放HttpClient实例时会出现一些问题,如下所示:
-
它们的创建是昂贵的。
-
例如,当
HttpClient在using语句中被释放时,底层连接不会立即关闭,而是在第一次垃圾回收会话时关闭,这是一个重复的创建。释放操作会迅速耗尽操作系统可以处理的最大连接数。
因此,要么重用单个HttpClient实例,比如单例,要么以某种方式对HttpClient实例进行池化。从.NET Core 2.1 版本开始,引入了HttpClientFactory类来对 HTTP 客户端进行池化。更具体地说,每当需要为HttpClientFactory对象创建新的HttpClient实例时,都会创建一个新的HttpClient。但是,昂贵创建的底层HttpClientMessageHandler实例会在其最大生命周期到期之前被池化。
HttpClientMessageHandler实例必须具有有限的持续时间,因为它们缓存可能随时间变化的 DNS 解析信息。HttpClientMessageHandler的默认生命周期为 2 分钟,但可以由开发人员重新定义。
使用HttpClientFactory允许我们自动将所有 HTTP 操作与其他操作进行管道化。例如,我们可以添加 Polly 重试策略来处理所有 HTTP 操作的失败。有关 Polly 的介绍,请参阅第五章的将微服务架构应用于企业应用程序的弹性任务执行子部分。
利用HttpClientFactory类提供的优势的最简单方法是添加Microsoft.Extensions.Http NuGet 包,然后按照以下步骤操作:
-
定义一个代理类,比如
MyProxy,以与所需的 REST 服务进行交互。 -
让
MyProxy在其构造函数中接受一个HttpClient实例。 -
使用注入到构造函数中的
HttpClient来实现所有必要的操作。 -
在主机的服务配置方法中声明您的代理,在 ASP.NET Core 应用程序的情况下,这是
Startup.cs类的ConfigureServices方法,而在客户端应用程序的情况下,这是HostBuilder实例的ConfigureServices方法。在最简单的情况下,声明类似于services.AddHttpClient<MyProxy>()。这将自动将MyProxy添加到可用于依赖注入的服务中,因此您可以轻松地将其注入到控制器的构造函数中。此外,每次创建MyProxy的实例时,HttpClientFactory都会返回一个HttpClient并自动注入到其构造函数中。
在需要与 REST 服务进行交互的类的构造函数中,我们可能还需要一个接口,而不是具体的代理实现类型的声明:
services.AddHttpClient<IMyProxy, MyProxy>()
可以应用 Polly 弹性策略(请参阅第五章的将微服务架构应用于企业应用程序的弹性任务执行子部分)到我们代理类发出的所有 HTTP 调用,如下所示:
var myRetryPolicy = Policy.Handle<HttpRequestException>()
...//policy definition
...;
services.AddHttpClient<IMyProxy, MyProxy>()
.AddPolicyHandler(myRetryPolicy );
最后,我们可以预先配置传递给我们代理的所有HttpClient实例的一些属性,如下所示:
services.AddHttpClient<IMyProxy, MyProxy>(clientFactory =>
{
clientFactory.DefaultRequestHeaders.Add("Accept", "application/json");
clientFactory.BaseAddress = new Uri("https://www.myService.com/");
})
.AddPolicyHandler(myRetryPolicy );
这样,传递给代理的每个客户端都预先配置,以便它们需要 JSON 响应并且必须与特定服务一起工作。一旦定义了基本地址,每个 HTTP 请求都需要指定要调用的服务方法的相对路径。
以下代码显示了如何执行对服务的POST。这需要一个额外的包System.Net.Http.Json。在这里,我们声明注入到代理构造函数中的HttpClient已存储在webClient私有字段中:
//Add a bearer token to authenticate the call
webClient.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);
...
//Call service method with a POST verb and get response
var response = await webClient.PostAsJsonAsync<MyPostModel>("my/method/relative/path",
new MyPostModel
{
//fill model here
...
});
//extract response status code
var status = response.StatusCode;
...
//extract body content from response
string stringResult = await response.Content.ReadAsStringAsync();
如果您使用 Polly,则无需拦截和处理通信错误,因为 Polly 会执行此任务。首先,您需要验证状态代码以决定下一步该做什么。然后,您可以解析响应主体中包含的 JSON 字符串,以获得一个.NET 类型的实例,通常取决于状态代码。执行解析的代码基于System.Text.Json NuGet 包的JsonSerializer类,如下所示:
var result =
JsonSerializer.Deserialize<MyResultClass>(stringResult);
执行 GET 请求类似,但是,而不是调用PostAsJsonAsync,您需要调用GetAsync,如下所示。使用其他 HTTP 动词完全类似:
var response =
await webClient.GetAsync("my/getmethod/relative/path");
正如您可以在本主题中检查的那样,访问 HTTP API 非常简单,并且需要实现一些.NET 5 库。自.NET Core 开始,微软一直在努力改进框架的性能和简单性。您需要与他们不断实施的文档和设施保持联系。
使用情况 - 暴露 WWTravelClub 套餐
在本节中,我们将实现一个 ASP.NET REST 服务,列出给定假期开始和结束日期可用的所有套餐。出于教学目的,我们不会按照第十二章中描述的最佳实践结构化应用程序;相反,我们将简单地使用 LINQ 查询生成结果,并直接放置在控制器操作方法中。一个良好结构化的 ASP.NET Core 应用程序将在第十五章中介绍,介绍 ASP.NET Core MVC,该章节专门介绍 MVC 框架。
让我们复制WWTravelClubDB解决方案文件夹,并将新文件夹重命名为WWTravelClubREST。WWTravelClubDB 项目是在第八章的各个部分逐步构建的,在 C#中与实体框架核心交互。让我们打开新解决方案,并向其中添加一个名为WWTravelClubREST的新 ASP.NET Core API 项目(与新解决方案文件夹同名)。为简单起见,选择不进行身份验证。右键单击新创建的项目,选择设置为启动项目,使其成为运行解决方案时启动的默认项目。
最后,我们需要向 WWTravelClubDB 项目添加引用。
ASP.NET Core 项目将配置常量存储在appsettings.json文件中。让我们打开这个文件,并向其中添加我们在 WWTravelClubDB 项目中创建的数据库连接字符串,如下所示:
{
"ConnectionStrings": {
"DefaultConnection": "Server=
(localdb)\\mssqllocaldb;Database=wwtravelclub;
Trusted_Connection=True;MultipleActiveResultSets=true"
},
...
...
}
现在,我们必须在Startup.cs的ConfigureServices方法中添加 WWTravelClubDB 实体框架数据库上下文,如下所示:
services.AddDbContext<WWTravelClubDB.MainDBContext>(options =>
options.UseSqlServer(
Configuration.GetConnectionString("DefaultConnection"),
b =>b.MigrationsAssembly("WWTravelClubDB")));
传递给AddDbContext的选项对象设置指定了使用从appsettings.json配置文件的ConnectionStrings部分提取的连接字符串的 SQL 服务器,使用Configuration.GetConnectionString("DefaultConnection")方法。b =>b.MigrationsAssembly("WWTravelClubDB") lambda 函数声明了包含数据库迁移的程序集的名称(参见第八章,在 C#中与实体框架核心交互),在我们的情况下,这是由 WWTravelClubDB 项目生成的 DLL。为了使前面的代码编译,您应该添加Microsoft.EntityFrameworkCore。
由于我们希望使用 OpenAPI 文档丰富我们的 REST 服务,让我们添加对Swashbuckle.AspNetCore NuGet 包的引用。现在,我们可以向ConfigureServices方法添加以下非常基本的配置:
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("WWWTravelClub", new OpenAPIInfo
{
Version = "WWWTravelClub 1.0.0",
Title = "WWWTravelClub",
Description = "WWWTravelClub Api",
TermsOfService = null
});
});
然后,我们可以添加 OpenAPI 端点的中间件,并为 API 文档添加用户界面,如下所示:
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint(
"/swagger/WWWTravelClub/swagger.json",
"WWWTravelClub Api");
});
app.UseEndpoints(endpoints => //preexisting code//
{
endpoints.MapControllers();
});
现在,我们准备编码我们的服务。让我们删除 Visual Studio 自动生成的ValuesController。然后,右键单击Controller文件夹,选择添加 | 控制器。现在,选择一个名为PackagesController的空 API 控制器。首先,让我们修改代码,如下所示:
[Route("api/packages")]
[ApiController]
public class PackagesController : ControllerBase
{
[HttpGet("bydate/{start}/{stop}")]
[ProducesResponseType(typeof(IEnumerable<PackagesListDTO>), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(500)]
public async Task<IActionResult> GetPackagesByDate(
[FromServices] WWTravelClubDB.MainDBContext ctx,
DateTime start, DateTime stop)
{
}
}
Route属性声明了我们服务的基本路径将是api/packages。我们实现的唯一操作方法是GetPackagesByDate,它在HttpGet请求的路径上调用bydate/{start}/{stop}类型的路径,其中start和stop是作为输入传递给GetPackagesByDate的DateTime参数。ProduceResponseType属性声明如下:
-
当请求成功时,将返回 200 代码,并且响应体包含
PackagesListDTO(我们将很快定义)类型的IEnumerable,其中包含所需的包信息。 -
当请求格式不正确时,将返回 400 代码。我们不指定返回的类型,因为坏请求会通过
ApiController属性自动由 MVC 框架处理。 -
在出现意外错误的情况下,将返回 500 代码并带有空的响应体。
现在,让我们在一个新的DTOs文件夹中定义PackagesListDTO类:
namespace WWTravelClubREST.DTOs
{
public record PackagesListDTO
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int DurationInDays { get; set; }
public DateTime? StartValidityDate { get; set; }
public DateTime? EndValidityDate { get; set; }
public string DestinationName { get; set; }
public int DestinationId { get; set; }
}
}
最后,让我们将以下using子句添加到我们的控制器代码中,以便我们可以轻松地引用我们的 DTO 和 Entity Framework LINQ 方法:
using Microsoft.EntityFrameworkCore;
using WWTravelClubREST.DTOs;
现在,我们准备使用以下代码填充GetPackagesByDate方法的主体:
try
{
var res = await ctx.Packages
.Where(m => start >= m.StartValidityDate
&& stop <= m.EndValidityDate)
.Select(m => new PackagesListDTO
{
StartValidityDate = m.StartValidityDate,
EndValidityDate = m.EndValidityDate,
Name = m.Name,
DurationInDays = m.DurationInDays,
Id = m.Id,
Price = m.Price,
DestinationName = m.MyDestination.Name,
DestinationId = m.DestinationId
})
.ToListAsync();
return Ok(res);
}
catch (Exception err)
{
return StatusCode(500, err);
}
LINQ 查询类似于我们在第八章中测试的WWTravelClubDBTest项目中包含的查询,即在 C#中与数据交互 - Entity Framework Core。一旦结果计算完成,就会通过OK调用返回。该方法的代码通过捕获异常并返回 500 状态代码来处理内部服务器错误,因为坏请求会在Controller方法被ApiController属性调用之前自动处理。
让我们运行解决方案。当浏览器打开时,无法从我们的 ASP.NET Core 网站接收任何结果。让我们修改浏览器 URL,使其为https://localhost:<previous port>/swagger。OpenAPI 文档的用户界面将如下所示:
图 14.1:Swagger 输出
PackagesListDTO是我们定义的用于列出包的模型,而ProblemDetails是在发生坏请求时用于报告错误的模型。通过单击GET按钮,我们可以获取有关我们的GET方法的更多详细信息,并且还可以测试它,如下面的屏幕截图所示:
图 14.2:GET 方法详细信息
在插入数据库中由包覆盖的日期时要注意;否则,将返回一个空列表。在前面的屏幕截图中显示的应该可以工作。
日期必须以正确的 JSON 格式输入;否则,将返回 400 Bad Request 错误,就像下面的代码中所示的那样:
{
"errors": {
"start": [
"The value '2019' is not valid."
]
},
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "80000008-0000-f900-b63f-84710c7967bb"
}
如果插入正确的输入参数,Swagger UI 将以 JSON 格式返回满足查询的包。
就是这样!您已经实现了您的第一个带有 OpenAPI 文档的 API!
总结
在本章中,我们介绍了 SOA 及其设计原则和约束。其中,值得记住的是互操作性。
然后,我们专注于业务应用程序的成熟标准,以实现公开服务所需的互操作性。因此,SOAP 和 REST 服务以及从 SOAP 服务过渡到 REST 服务的细节被详细讨论,这在过去几年中在大多数应用领域都已经发生。然后,更详细地描述了 REST 服务原则、验证/授权和其文档。
最后,我们看了一下在.NET 5 中可用的工具,我们可以使用这些工具来实现和交互服务。我们看了一系列用于集群内通信的框架,如.NET remoting 和 gRPC,以及用于 SOAP 和基于 REST 的公共服务的工具。
在这里,我们主要关注 REST 服务。它们的 ASP.NET Core 实现被详细描述,以及我们可以用来验证/授权它们和它们的文档的技术。我们还专注于如何实现高效的.NET Core 代理,以便我们可以与 REST 服务交互。
在下一章中,我们将学习如何在 ASP .NET Core MVC 上构建应用程序时使用.NET 5。
问题
-
服务可以使用基于 cookie 的会话吗?
-
使用自定义通信协议实现服务是一个好的做法吗?为什么是或者为什么不是?
-
POST请求到 REST 服务会导致删除吗? -
JWT 承载令牌中包含多少个点分隔的部分?
-
默认情况下,REST 服务的操作方法的复杂类型参数来自哪里?
-
如何声明控制器作为 REST 服务?
-
ASP.NET Core 服务的主要文档属性是什么?
-
ASP.NET Core REST 服务路由规则如何声明?
-
如何声明代理以便我们可以利用.NET Core 的
HttpClientFactory类的特性?
进一步阅读
本章主要关注更常用的 REST 服务。如果您对 SOAP 服务感兴趣,可以从维基百科关于 SOAP 规范的页面开始:en.wikipedia.org/wiki/List_of_web_service_specifications。另外,如果您对用于实现 SOAP 服务的 Microsoft .NET WCF 技术感兴趣,可以参考 WCF 的官方文档:docs.microsoft.com/en-us/dotnet/framework/wcf/。
本章提到了 AMQP 协议作为集群内通信的一种选择,但没有进行描述。有关该协议的详细信息可在 AMQP 的官方网站上找到:www.amqp.org/。
gRPC 的更多信息可在 Google gRPC 的官方网站上找到:grpc.io/。有关 Visual Studio gRPC 项目模板的更多信息可以在这里找到:docs.microsoft.com/en-US/aspnet/core/grpc/。您还可以查看devblogs.microsoft.com/aspnet/grpc-web-for-net-now-available/上的 gRPC-Web。
ASP.NET Core 服务的更多详细信息可在官方文档中找到:docs.microsoft.com/en-US/aspnet/core/web-api/。有关.NET Core 的 HTTP 客户端的更多信息,请访问这里:docs.microsoft.com/en-US/aspnet/core/fundamentals/http-requests。
有关 JWT 令牌认证的更多信息,请访问这里:jwt.io/。如果您想要使用 Identity Server 4 生成 JWT 令牌,可以参考其官方文档页面:docs.identityserver.io/en/latest/。
有关 OpenAPI 的更多信息,请访问swagger.io/docs/specification/about/,而有关 Swashbuckle 的更多信息可以在其 GitHub 存储库页面上找到:github.com/domaindrivendev/Swashbuckle。