精通-ABP-框架-全-

精通 ABP 框架(全)

原文:zh.annas-archive.org/md5/d540de15a7926853ea19e1c3da18a51f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

ABP 框架是一个完整的现代 Web 应用基础设施,通过遵循软件开发最佳实践和约定来创建。ABP 提供了一个高级框架和生态系统,帮助您实现“不要重复自己”(DRY)原则,并专注于您的业务代码。

本书由 ABP 框架的创造者撰写,将帮助您从头开始全面理解 ABP 框架和现代 Web 应用开发技术。通过逐步解释基本概念和实际示例,您将了解现代 Web 解决方案的需求以及 ABP 框架如何使开发自己的解决方案变得愉快。您将发现企业 Web 应用开发的常见需求,并探索 ABP 框架提供的基础设施。在本书中,您将掌握构建可维护和模块化 Web 解决方案的软件开发最佳实践。

本书结束时,您将能够创建一个完整的 Web 解决方案,该解决方案易于开发、维护和测试。

本书面向对象

本书面向希望学习软件架构和最佳实践,使用 Microsoft 技术和 ABP 框架构建可维护的 Web 解决方案的 Web 开发者。要开始阅读本书,需要具备 C#和 ASP.NET Core 的基本知识。

本书涵盖内容

第一章**,现代软件开发与 ABP 框架,讨论了开发商业应用时面临的常见挑战,并解释了 ABP 如何解决这些挑战。

第二章**,ABP 框架入门,解释了您如何使用 ABP 框架创建和运行新的解决方案。

第三章**,逐步应用开发,是本书中最长的章节,通过一个广泛的示例应用展示了使用 ABP 框架进行应用开发。这是将所有内容整合在一起的主要章节。在本章之后,您可能不会完全理解所有 ABP 功能,但将能够使用 ABP 的基本概念创建自己的应用。您将在这里了解整体情况。然后,您将在下一章中填补空白,了解所有细节。

第四章**,理解参考解决方案,解释了参考解决方案 EventHub 的架构和结构,该解决方案作为本书读者的一个大示例应用被创建。建议您阅读本章,并在您的环境中使解决方案工作。

第五章**,探索 ASP.NET Core 和 ABP 基础设施,解释了一些基本概念,如依赖注入、基本模块化、配置和日志。这些主题对于理解使用 ABP 和 ASP.NET Core 进行开发至关重要。

第六章**,与数据访问基础设施协同工作,介绍了实体、仓储和单元工作概念,并展示了如何与 Entity Framework Core 和 MongoDB 协同工作。你将了解查询和操作数据以及控制数据库事务的不同方法。

第七章**,探索横切关注点,重点关注你在应用程序中需要的三项重要关注点:授权、验证和异常处理。这些关注点在应用程序的每个部分都得到了实现。你将学习如何定义和使用基于权限的授权系统,验证用户输入,并处理异常和异常消息。

第八章**,使用 ABP 的功能和服务,涵盖了 ABP 的一些常用功能,例如与当前用户协同工作、使用数据过滤和审计日志系统、缓存数据和本地化用户界面。

第九章**,理解领域驱动设计,是 DDD 相关章节的第一部分。它首先定义了 DDD 并基于 DDD 构建.NET 解决方案的结构。你将了解 ABP 的启动模板是如何从 DDD 的标准四层解决方案模型演变而来的。你还将了解 DDD 的构建块和原则。

第十章**,DDD – 领域层,继续探讨 DDD 与领域层的关系。它首先解释了 EventHub 领域对象,因为本章和下一章的示例将基于这些对象。你将学习如何设计聚合;实现领域服务、仓储和规范;并使用事件总线发布领域事件。

第十一章**,DDD – 应用层,重点关注应用层。你将了解设计、验证数据传输对象和实现应用程序服务的最佳实践。你还将在本章中找到讨论和示例,帮助你理解领域层和应用层的责任。

第十二章**,与 MVC/Razor Pages 协同工作,涵盖了开发在服务器端生成 HTML 的 MVC(Razor Pages)应用程序。你将了解 ABP 的主题方法,并学习基本方面,如捆绑和压缩、标签助手、表单、菜单和模态。你还将学习如何进行客户端到服务器的 API 调用,并使用 ABP 框架提供的 JavaScript 实用 API 来显示通知、消息框等。

第十三章**,使用 Blazor WebAssembly UI 进行工作,与上一章类似,解释了使用微软的新 Blazor SPA 框架和 ABP 框架进行 UI 开发。Blazor 是一个让开发者能够在浏览器中使用现有.NET 技能的出色框架。ABP 通过提供内置的解决方案来进一步推进,包括消费 HTTP API、实现主题化和提供实用服务,以简化常见的 UI 任务。

第十四章**,构建 HTTP API 和实时服务,解释了如何使用经典 ASP.NET 方法以及 ABP 的自动 API 控制器系统创建 API 控制器,并讨论了何时需要手动定义控制器。在本章中,您还将了解动态和静态 C#代理,以自动化.NET 客户端到您的 ABP 基于 HTTP 服务的 API 调用。本章还涵盖了使用 SignalR 与 ABP 框架。

第十五章**,使用模块化进行工作,通过一个示例案例解释了可重用应用程序模块的开发。我们将为 EventHub 解决方案创建一个支付模块,并在本章中解释该模块的结构。通过这种方式,您将了解如何开发可重用模块并将它们安装到应用程序中。

第十六章**,实现多租户,专注于另一个基本的 ABP 架构,多租户,这是一种构建软件即服务SaaS)解决方案的架构模式。您将了解多租户是否是您解决方案的正确架构,并学习如何编写与 ABP 多租户系统兼容的代码。本章还涵盖了 ABP 的功能系统,该系统用于将应用程序功能定义为特性,并将它们分配给多租户解决方案中的租户。

第十七章**,构建自动化测试,解释了 ABP 的测试基础设施以及如何使用xUnit作为测试框架为您的应用程序构建单元和集成测试。您还将学习自动化测试的基础知识,例如断言、模拟和替换服务,以及处理异常。

为了充分利用这本书

要开始阅读这本书,需要具备 C#和 ASP.NET Core 的基本知识。

如果您正在使用这本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Mastering-ABP-Framework。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表的彩色 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781801079242_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“如果您想指定数据库连接字符串,您也可以像以下示例中那样传递--connection-string参数:”

代码块设置如下:

"ConnectionStrings": {
  "Default": "Server=(LocalDb)\\MSSQLLocalDB;Database=ProductManagement;Trusted_Connection=True"
}

任何命令行输入或输出都应如下编写:

dotnet tool install -g Volo.Abp.Cli

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“ABP 框架提供了一个预构建的应用程序启动模板。”

小贴士或重要提示

看起来是这样的。

联系我们

我们始终欢迎读者的反馈。

在邮件主题中提及书名,并发送至customercare@packtpub.com

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您向我们报告。请访问www.packtpub.com/support/errata并填写表格。

通过链接到材料发送至copyright@packt.com

如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

第一部分:简介

本书的第一部分介绍了现代网络应用开发的挑战,并解释了 ABP 如何解决问题。它还展示了如何使用 ABP 框架创建新的解决方案,并构建一个完全工作、可用于生产的页面来管理实体。最后,它探讨了 EventHub 项目,这是一个使用 ABP 框架构建的真实世界参考解决方案。

在本部分,我们包括以下章节:

  • 第一章, 现代软件开发与 ABP 框架

  • 第二章, ABP 框架入门

  • 第三章, 逐步应用开发

  • 第四章, 理解参考解决方案

第一章:第一章:现代软件开发与 ABP 框架

构建软件系统一直都很复杂。特别是在这些现代时期,即使创建一个基本的企业解决方案也会遇到许多挑战。你常常发现自己正在实现标准非业务需求,而不是实现你试图构建的系统中的真正有价值的业务代码,而是深入到基础设施问题中。

ABP 框架通过提供强大的软件架构、自动化重复的细节并提供构建现代 Web 解决方案所需的基础设施,帮助你专注于为利益相关者增加价值的代码。它提供了一个端到端的、一致的开发体验,并提高了你的生产力。ABP 通过预先应用所有现代软件开发最佳实践,让你和你的团队能够迅速跟上。

这本书是使用现代软件开发方法和最佳实践通过 ABP 框架开发 Web 应用程序和系统的终极指南。

这第一章介绍了构建良好架构的企业解决方案的挑战,并解释了 ABP 框架如何解决这些挑战。我还会解释这本书的目的和结构。

在本章中,我们将涵盖以下主题:

  • 开发企业级 Web 解决方案的挑战

  • 理解 ABP 框架提供的内容

开发企业级 Web 解决方案的挑战

在深入研究 ABP 框架之前,我想先介绍开发现代企业级 Web 解决方案的挑战,以便理解为什么我们需要像 ABP 框架这样的应用程序框架。让我们从大局开始:架构。

设置架构

在你开始编写代码之前,你需要为你的解决方案创建一个基础。这是构建软件系统中最具挑战性的阶段之一。你有许多选择,需要做出一些基本的决定。在这个阶段你做出的任何决定都可能影响你的应用程序在其整个生命周期中的表现。

有一些常见的、众所周知的系统级架构模式,例如单体架构模块化架构微服务架构。应用这些架构之一决定了你如何开发、部署和扩展你的解决方案,并且应该基于你的需求来决定。

除了这些系统级模式之外,软件开发模型如命令查询责任分离CQRS)、领域驱动设计DDD)、分层架构整洁架构决定了你的代码库是如何形成的。

一旦你决定了你的架构,你应该创建一个基本解决方案结构,以便使用该架构开始开发。在这个阶段,你还需要决定你将使用哪种语言、框架、工具和库。

所有这些决策都需要丰富的经验,因此最好由经验丰富的软件架构师和开发者来完成。然而,并非所有团队成员都有相同经验和知识水平。你需要对他们进行培训并确定正确的编码标准。

在设置好架构和准备基本解决方案之后,你的团队可以开始开发过程。下一节将讨论每个软件解决方案都会重复的常见方面以及如何在开发中避免重复。

不要重复自己!

不要重复自己DRY)是软件开发的一个关键原则。计算机通过自动化现实世界的重复性任务来使人们的生活更轻松。那么,为什么我们在构建软件解决方案时还要重复自己呢?

认证是每个软件解决方案都非常常见的问题——单点登录、Active Directory 集成、基于令牌的认证、社交登录、双因素认证、忘记/重置密码、电子邮件激活等等。这些要求你熟悉吗?你不是一个人!几乎所有的软件项目都有或多或少的类似认证要求。与其从头开始构建所有这些,不如使用现有的解决方案,如库或云服务,这样更好。这些预先构建的解决方案是成熟且经过实战检验的,这对于安全性来说很重要。

一些非功能性需求,如异常处理、验证、授权、缓存、审计日志和数据库事务管理,是代码重复的另一个来源。这些问题被称为横切关注点,应该在每个网络请求中处理。在一个架构良好的软件解决方案中,这些问题应该通过代码库中的约定在中央位置自动处理,或者你应该有服务来使它们更容易实现。

当你集成第三方系统,如 RabbitMQ 和 Redis 时,你通常希望在这些与系统交互的代码周围创建抽象和包装。这样,你的业务逻辑就可以与这些基础设施组件隔离。此外,你也不需要在解决方案的每个地方重复相同的连接、重试、异常处理和日志记录逻辑。

拥有一个预先构建的基础设施来自动化这些重复性工作可以节省你的开发时间,这样你就可以专注于你的业务逻辑。下一节将讨论另一个占据每个业务应用时间的主题——用户界面。

构建 UI 基础

应用程序的一个基本方面是其用户界面UI)。一个界面过时且难以使用的应用程序,即使在其内部具有出色的业务价值,也不会一开始就吸引人。

虽然每个应用程序的 UI 功能和需求各不相同,但一些基本结构是共同的。大多数应用程序需要基本元素,如警报、按钮、卡片、表单元素、标签页和数据表。您可以使用 HTML/CSS 框架,如 Bootstrap、Bulma 和 Ant Design,而不是为每个应用程序创建一个设计系统。

几乎每个 Web 应用程序都有一个响应式布局,包括主菜单、工具栏、页眉和页脚,具有自定义颜色和品牌。您需要确定所有这些,并为您的应用程序页面和组件实现一个基本的 UI 工具包。这样,UI 开发者可以创建一个一致的 UI,而无需处理常见的结构。

到目前为止,我已经介绍了一些常见的基础设施要求,这些要求大多独立于任何业务应用程序。下一节将讨论大多数企业系统的常见业务需求。

实现常见业务需求

虽然每个应用程序和系统都是独特的,它们的独特性是其价值的来源,但每个企业系统都有一些基本的支持性要求。

基于权限的授权系统是这些基本要求之一。它用于控制应用程序的用户和客户端的权限。如果您想自己实现,您应该创建一个端到端解决方案,包括数据库表、授权逻辑、权限缓存、API 和 UI 页面,以便将这些权限分配给您的用户并在需要时进行检查。然而,这样的系统相当通用,可以作为一个共享的身份管理功能(一个可重用的模块)来开发,并由多个应用程序使用。

就像身份管理一样,许多系统需要诸如审计日志报告、租户和订阅管理(对于 SaaS 应用程序)、语言管理、文件上传和共享、多语言管理和时区管理等功能。除了预构建的应用程序功能(模块)之外,还可能有低级要求,例如实现软删除模式以及在您的应用程序中存储二进制大对象BLOB)数据。

所有这些常见要求都可以从头开始构建,这可能是某些企业系统的唯一解决方案。然而,如果这些功能不是您应用程序提供的主要价值,您可以考虑使用可用的预构建模块和库,并根据您的需求进行定制。

在下一节中,您将了解 ABP 框架如何帮助我们实现本节讨论的常见基础设施和基本要求。

了解 ABP 框架提供的功能

ABP 框架提供了一个有观点的架构,以帮助您在.NET 和 ASP.NET Core 平台上构建企业级软件解决方案,并在此基础上采用最佳实践。它提供了基本的基础设施、生产就绪的模块、主题、工具、指南和文档,以正确实施该架构,并在可能的情况下自动化细节和重复性工作。

在接下来的几个子部分中,我将解释 ABP 是如何做到这些的,从架构开始。

ABP 架构

我提到 ABP 提供了一个有观点的架构。换句话说,它是一个有观点的框架。因此,我首先应该解释什么是无观点的框架,以及什么是有观点的框架。

如我在设置架构部分所述,为软件解决方案打下基础需要做出许多决定;您应该决定使用哪种系统架构、开发模型、技术、模式、工具和库。

无观点的框架,如 ASP.NET Core,对这些决定说得不多,主要留给您自己决定。例如,您可以通过将 UI 层与数据访问层分离来创建分层解决方案,或者您可以直接从您的 UI 页面/视图中访问数据库来创建单层解决方案。只要它与 ASP.NET Core 兼容,您就可以使用任何库,并且您可以使用任何架构模式。无观点性使得 ASP.NET Core 灵活且适用于不同的场景。然而,它将责任分配给您,让您做出所有这些决定,建立正确的架构,并准备基础设施以实施该架构。

我并不是说 ASP.NET Core 没有任何观点。它假设您正在基于 HTTP 规范构建 Web 应用程序或 API。它明确定义了您的 UI 和 API 层应该如何开发。它还提供了一些低级基础设施组件,例如依赖注入、缓存和日志(实际上,这些组件在任何.NET 应用程序中都是可用的,并不特定于 ASP.NET Core,但它们主要是与 ASP.NET Core 一起开发的)。然而,它并没有太多关于您的业务代码如何构建以及您将使用哪种架构模式的内容。

ABP 框架,另一方面,是一个有偏见的框架。它认为某些软件开发的方法本质上更好,因此引导开发者沿着这些路径前进。它对你的解决方案中使用的架构、模式、工具和库有自己的看法。尽管 ABP 框架足够灵活,可以使用不同的工具和库,并改变你的架构决策,但当你遵循它的看法时,你会获得最佳的价值。但不用担心;它提供了良好的、行业认可的解决方案,以帮助您使用最佳实践构建可维护的软件解决方案。它所做的决策将节省您的时间,提高您的生产力,并使您专注于业务代码而不是基础设施问题。

在接下来的几节中,我将介绍 ABP 所依赖的四个基本架构。

领域驱动设计

ABP 的主要目标是提供一个模型,以使用干净的代码原则构建可维护的解决方案。ABP 提供了一个基于 DDD 模式和实践的分层架构。它提供了一个分层启动模板(见“启动模板”部分),必要的基础设施以及如何正确应用该架构的指导。

由于 ABP 是一个软件框架,它专注于 DDD 的技术实现。本书的第三部分,“实现领域驱动设计”,解释了使用 ABP 框架构建基于 DDD 的解决方案的最佳实践。

模块化

在软件开发中,模块化是一种将系统分解为隔离部分(称为模块)的技术。最终目标是减少复杂性,提高可重用性,并使不同的团队能够并行工作在不同的功能集上,而不会相互影响。

模块化有两个主要挑战,ABP 框架简化了这些挑战:

  • 第一个挑战是隔离模块。ASP.NET Core 有一些功能(如 Razor 组件库)来支持模块化应用程序。然而,它非常有限,因为它是一个无偏见的框架,并且只有对 UI 和 API 部分的看法。另一方面,ABP 框架提供了一致的模式和基础设施,以使用其数据库、领域、应用和 UI 层构建完全隔离、可重用的应用程序模块。

  • 模块化的第二个挑战是处理这些隔离的模块如何在运行时进行通信并成为一个单一、统一的应用程序。ABP 为模块化系统的常见需求提供了具体的模型,例如在模块之间共享数据库,通过事件或 API 调用进行模块间的通信,以及在应用程序中安装模块。

ABP 提供了许多预构建的开源应用程序模块,可以在任何应用程序中使用。一些例子包括身份模块,它提供用户、角色和权限管理,以及账户模块,它为你的应用程序提供登录和注册页面。重用和定制这些模块可以节省你的时间。此外,ABP 还提供了一个模块启动模板,帮助你构建可重用的应用程序模块。一个例子可以在第十五章中找到,与模块化一起工作

模块化对于管理大型单体系统的复杂性来说非常好。然而,ABP 还可以帮助你创建微服务解决方案。

微服务

微服务和分布式架构是构建可扩展软件系统的公认方法。它允许不同的团队在不同的服务上工作,并独立地版本控制、部署和扩展他们的服务。

然而,构建微服务系统在开发、部署、微服务间通信、数据一致性、监控等方面存在一些重要的挑战。

微服务架构不是单个软件框架可以解决的问题。微服务系统是一个将许多不同的学科、方法、技术和工具结合在一起来解决独特问题的解决方案。每个微服务系统都有其特定的需求和限制。每个团队都有其专业水平、知识和技能。

ABP 框架从一开始就被设计成与微服务兼容。它提供了一个分布式事件总线,用于支持事务的微服务之间的异步通信(如第十章中“发布领域事件”部分所述,领域驱动设计 – 领域层)。它还提供了 C# 客户端代理,以便轻松消费远程服务的 REST API(如第十四章中“消费 HTTP API”部分所述,构建 HTTP API 和实时服务)。

所有的预构建 ABP 应用程序模块都设计得可以转换为微服务。ABP 还提供了一个详细的指南(docs.abp.io/en/abp/latest/Best-Practices/Index),解释了如何创建这样的微服务兼容模块。这样,你可以从一个模块化的单体开始,然后稍后将其转换为微服务解决方案。

核心 ABP 团队已经准备了一个使用 ABP 框架构建的开源微服务参考解决方案。它展示了如何使用 API 网关、微服务间通信、分布式事件、分布式缓存、多个数据库提供者和多个具有单点登录的 UI 应用程序来创建解决方案。它还包括在容器上运行解决方案的 Kubernetes 和 Helm 配置。请参阅github.com/abpframework/eShopOnAbp以了解该解决方案的所有详细信息。

下一节介绍了 ABP 框架提供的最后一个基本架构——多租户架构。

SaaS/multi-tenancy

软件即服务SaaS)是构建和销售软件产品的流行方法。多租户是构建 SaaS 系统的广泛使用的架构模式。以下是多租户系统的典型特征:

  • 在租户之间共享硬件和软件资源。

  • 每个租户都有用户、角色和权限。

  • 在租户之间隔离数据库、缓存和其他资源。

  • 可以按租户启用/禁用应用程序功能。

  • 可以按租户自定义应用程序配置。

ABP 框架涵盖了所有这些要求以及更多。它帮助您在大多数代码库不了解多租户的情况下构建多租户系统。

第十六章实现多租户,解释了使用 ABP 框架的多租户和多租户应用程序开发。

到目前为止,我已经介绍了 ABP 提供的作为预构建解决方案的基本架构模式。然而,ABP 还提供了启动模板,以帮助您轻松地开始一个新的解决方案。

启动模板

当您使用 ASP.NET Core 的标准启动模板创建新解决方案时,您将获得一个单项目解决方案,具有最小依赖项和无层结构,但这并不那么适合生产环境。您通常需要花费相当多的时间来设置解决方案结构以正确实现软件架构,以及安装和配置基本工具和库。

ABP 框架提供了一个结构良好、分层、预先配置和现成的启动解决方案模板。以下截图显示了使用 ABP 框架直接运行创建的启动模板时的初始 UI:

图 1.1 – ABP 应用程序启动模板

图 1.1 – ABP 应用程序启动模板

让我们更详细地谈谈这个启动模板:

  • 解决方案是分层的。它清晰明了,告诉您如何组织代码库。

  • 一些预构建的模块已经安装,例如账户身份模块。您已经有了登录注册用户和角色管理以及一些其他标准功能已经实现。

  • 单元测试集成测试项目已预先配置并准备好编写您的第一个测试代码。

  • 它包含一些实用应用程序来管理您的数据库迁移以及消费和测试您的 HTTP API。

ABP 的应用程序启动模板提供了多个UI 框架数据库提供者选项。你可以选择AngularBlazorMVCRazor Pages)作为 UI 框架,并使用Entity Framework Core(与任何数据库管理系统)或MongoDB作为数据库提供者。你将在第二章“使用 ABP 框架入门”中学习如何创建一个新的解决方案并运行它。

在下一节中,我将介绍一些 ABP 的基础设施组件。

ABP 的基础设施

ABP 基于你已知的熟悉工具和库。虽然它是一个全栈应用程序框架,但它不引入新的对象关系映射器ORM),而是使用 Entity Framework Core。同样,它使用 Serilog、AutoMapper、IdentityServer 和 Bootstrap,而不是自己创建类似的功能。它提供了一个集成这些工具、填补空白并实现常见业务应用程序需求的解决方案。

ABP 通过约定自动化异常处理、验证、授权、缓存、审计日志和数据库事务管理,简化了这些操作,并允许你在需要时进行精细控制。因此,你不需要为这些横切和常见问题重复自己。

ABP 与 IdentityServer 良好集成,用于基于 cookie 和令牌的认证以及单点登录。它还提供了一个基于权限的详细授权系统,帮助你控制应用程序的用户和客户的权限。

除了基础知识之外,后台作业、BLOB 存储、文本模板、审计日志和本地化组件为常见的业务需求提供了内置解决方案。

在 UI 方面,ABP 提供了一个完整的 UI 主题系统,帮助你开发无主题和模块化的应用程序,并轻松地为应用程序安装主题。它还在 UI 方面提供了大量的功能和辅助工具,以消除重复代码并提高生产力。

下一节将讨论社区,这对于开源项目来说非常重要。

社区

当你在公司设置解决方案架构时,除了正在工作的开发者外,没有人知道你的结构。然而,ABP 有一个庞大且活跃的社区。他们使用相同的架构和基础设施,应用类似的最佳实践,并以类似的方式开发他们的应用程序。当你遇到基础设施问题或想要获得实现业务问题的想法或建议时,这具有很大的优势。由于 ABP 开发者应用相同的或类似的模式,因此理解另一个解决方案中的代码也更容易。

ABP 框架自 2016 年以来一直存在并不断发展。截至 2021 年底,它拥有 7,000+星标,220+贡献者,22,000+提交,GitHub 上有 5,700 个已关闭的问题,并在 NuGet 上有超过 4,000,000 次下载,拥有超过 110+个主要和次要版本。我的意思是,它是一个成熟、被接受和受信任的开源项目。

核心 ABP 团队和社区贡献者不断撰写文章,准备视频教程,并在 ABP 社区网站上分享:community.abp.io。以下截图来自 ABP 社区网站:

图 1.2 – ABP 社区网站

图 1.2 – ABP 社区网站

查看 ABP 社区网站,了解其他人如何使用 ABP 框架,并密切关注 ABP 框架的发展。

摘要

在本章中,我们介绍了构建业务解决方案的问题,并解释了 ABP 如何为这些常见问题提供解决方案。ABP 还通过提供预构建的架构解决方案和实现该架构所需的基础设施来提高开发者的生产力。

到这本书的结尾,你将熟悉 ABP 框架,并将学习到许多关于企业级软件开发的最佳实践和技术。

在下一章中,你将学习如何使用 ABP 的命令行界面CLI)工具创建一个新的解决方案,并在你的开发环境中运行它。

第二章:第二章:ABP 框架入门

ABP 框架以大量 NuGet 和 Node 包管理器 (NPM) 包的形式分发。它具有模块化设计,因此您可以添加和使用您需要的包。然而,也有一些预构建的解决方案模板,您通常希望从它们开始。

我们将了解如何准备我们的开发环境并使用 ABP 的启动模板创建解决方案。到本章结束时,您将拥有一个使用 ABP 框架构建的运行解决方案。

本章包括以下主题:

  • 安装 ABP CLI

  • 创建新的解决方案

  • 运行解决方案

  • 探索预构建模块

技术要求

在开始使用 ABP 框架之前,您需要在您的计算机上安装一些工具。

IDE/编辑器

本书假设您正在使用 Visual Studio 2022(v10.0,支持 .NET 6.0)或更高版本。如果您尚未安装它,社区版visualstudio.microsoft.com 上免费提供。但是,您可以使用您喜欢的 集成开发环境IDE)或编辑器,只要它支持使用 C# 开发 .NET 应用程序即可。

.NET 6 SDK

如果您已安装 Visual Studio,您将已经安装了 .NET 软件开发工具包 (SDK)。否则,请从 dotnet.microsoft.com/download 安装 .NET 6.0 或更高版本。

数据库管理系统

ABP 框架可以与任何数据源一起工作。然而,有两个主要提供程序是预集成的:Entity Framework CoreEF Core)和 MongoDB。对于 EF Core,可以使用所有 数据库管理系统DBMS),例如 SQL Server、MySQL、PostgreSQL、Oracle 等。

在本章中,我将使用 SQL Server 作为数据库管理系统 (DBMS)。启动解决方案使用 LocalDB,这是一个与 Visual Studio 一起安装的简单 SQL Server 实例。但是,您可能想使用 SQL Server 的完整版本。在这种情况下,您可以从 www.microsoft.com/sql-server/sql-server-downloads 下载 SQL Server 开发者版

安装 ABP CLI

许多现代框架都提供 CLI,ABP 框架也不例外。ABP CLI 是一个命令行实用程序,用于执行 ABP 应用的一些常见任务。它用于创建一个新的解决方案,其中 ABP 框架作为基本功能。

使用以下命令在终端安装它:

dotnet tool install -g Volo.Abp.Cli

如果您已经安装了它,您可以使用以下命令将其更新到最新版本:

dotnet tool update -g Volo.Abp.Cli

我们现在已准备好创建新的 ABP 解决方案。

创建新的解决方案

ABP 框架提供了一个预构建的应用程序启动模板。使用此模板创建新解决方案(项目)有两种方式,我们将现在探讨。

下载启动解决方案

你可以直接从abp.io/get-started创建并下载一个解决方案。在此页面上,如图所示,你可以轻松选择用户界面UI)框架、数据库提供者和其他可用选项:

图 2.1 – 下载新解决方案

图 2.1 – 下载新解决方案

值得注意的是此页面上的选项,因为它们直接影响你的解决方案的架构、结构和工具。

.sln文件和你的代码库的根命名空间中。

对于项目类型,有两个选项,如下所示:

  • 模块模板用于创建可重用的应用程序模块。

  • 应用程序模板用于使用 ABP 框架构建 Web 应用程序。

使用模块模板将在第十五章使用模块化中介绍。在这里,我选择了应用程序模板,因为我想要创建一个将在下一章中使用的新的 Web 应用程序。

在撰写本书时,有四个UI 框架选项可用,如下所示:

  • MVC/Razor Page

  • Angular

  • Blazor WebAssembly

  • Blazor Server

你可以选择最适合你的应用程序需求和个人或团队技能的选项。本书的第四部分用户界面和 API 开发,将涵盖MVC/Razor PageBlazor选项。你可以在 ABP 的文档中了解更多关于 Angular UI 的信息。在这里,我选择了MVC/Razor Page选项,因为我们将在下一章中使用它。

在撰写本书时,有两个数据库提供者选项可用,如下所示:

  • Entity Framework Core

  • MongoDB

如果你选择Entity Framework Core选项,你可以使用 EF Core 支持的任何数据库管理系统(DBMS)。我在这里选择了带有SQLServer选项的 EF Core。

ABP 还提供了一个基于 Facebook 提供的流行单页应用程序SPA)框架React Native的移动启动模板。如果你选择它,它为你的移动应用程序与相同的后端集成提供了一个良好的起点。本书不涵盖移动开发,所以我将其留为

最后,如果你想将你的 UI 应用程序从物理上与HTTP API分离,可以勾选分层选项。在这种情况下,UI 应用程序将不会直接连接到数据库,而是通过 HTTP API 执行所有操作。你可以将 UI 和 HTTP API 应用程序部署到不同的服务器。我没有勾选它以保持简单,并专注于 ABP 功能而不是分布式系统的复杂性。然而,ABP 也支持这样的分布式场景。你可以从 ABP 的文档中了解更多信息。

当您选择选项时,ABP 会创建一个完全工作、生产就绪的解决方案,您可以在其上开始构建您的应用程序。如果您稍后想更改选项(例如,如果您想使用 MongoDB 而不是 EF Core),您应该重新创建您的解决方案或手动更改和配置 NuGet 包。在创建和自定义您的解决方案之后,没有 自动魔法 的方法来更改这些选项。

从网站下载您的解决方案可以轻松查看和选择选项。然而,对于那些喜欢命令行工具的用户来说,还有一个替代方法。

使用 ABP CLI

或者,您可以使用 ABP CLI 中的 new 命令创建新的解决方案。打开命令行终端,在空目录中输入以下命令:

abp new ProductManagement

ProductManagement 是这里的解决方案名称。此命令使用 EF Core 和 SQL Server LocalDb 创建一个新的 Web 应用程序,因为这些都是默认选项。如果我想指定所有内容,我可以重写相同的命令,如下所示:

abp new ProductManagement -t app -u mvc -d ef -dbms SqlServer --mobile none

如果您想指定数据库连接字符串,您也可以传递 --connection-string 参数,如下例所示:

abp new ProductManagement -t app -u mvc -d ef -dbms SqlServer --mobile none --connection-string "Server=(LocalDb)\\MSSQLLocalDB;Database=ProductManagement;Trusted_Connection=True"

此示例中的连接字符串已经是默认连接字符串值,并使用 LocalDb。如果您需要稍后更改连接字符串,请参阅本章中的 连接字符串 部分。

请参阅 ABP CLI 文档以获取所有可能的选项和值:docs.abp.io/en/abp/latest/CLI

关于示例应用程序

在下一章中,我们将构建一个名为 ProductManagement 的示例应用程序。您可以将您目前正在创建的解决方案作为下一章的起点。

我们现在有一个结构良好、生产就绪的解决方案。下一节将展示如何运行此解决方案。

运行解决方案

我们可以使用 IDE 或代码编辑器打开解决方案,创建数据库,并运行 Web 应用程序。在 Visual Studio 或您喜欢的 IDE 中打开 ProductManagement.sln 解决方案。您将看到如下所示的解决方案结构:

图 2.2 – Visual Studio 中的 ProductManagement 解决方案

img/Figure_2.2_B17287.jpg

图 2.2 – Visual Studio 中的 ProductManagement 解决方案

该解决方案分层,包含多个项目。测试文件夹包含用于测试这些层的项目。其中大部分是类库,而少数是可执行应用程序。这些将在以下内容中描述:

  • ProductManagement.Web 是解决方案的主要 Web 应用程序。

  • ProductManagement.DbMigrator 用于应用数据库迁移并初始化数据。

该解决方案使用数据库。在创建数据库之前,您可能想要检查和更改数据库连接字符串。

连接字符串

连接字符串用于连接到数据库,通常包括服务器、数据库名称和凭据。连接字符串在ProductManagement.WebProductManagement.DbMigrator项目的appsettings.json文件中定义,如下面的代码片段所示:

"ConnectionStrings": {
  "Default": "Server=(LocalDb)\\MSSQLLocalDB;Database=ProductManagement;Trusted_Connection=True"
}

默认连接字符串使用LocalDb,这是一个轻量级、兼容 SQL Server 的数据库,用于开发目的。它安装在 Visual Studio 中。如果您想连接到另一个 SQL Server 实例,您可以更改它。如果更改了它,请同时在两个地方更改。

在下一节创建数据库时,将使用此连接字符串。

创建数据库

该解决方案使用 EF Core 代码优先数据库迁移。因此,我们可以使用标准的Add-MigrationUpdate-Database命令通过代码来管理数据库模式变更。

ProductManagement.DbMigrator是一个控制台应用程序,它简化了在开发和生产中创建和迁移数据库的过程。它还初始化数据,创建一个admin角色和用户以登录到应用程序。

右键单击ProductManagement.DbMigrator项目,并选择设置为启动项目命令。然后,使用Ctrl + F5运行项目以不调试的方式运行。

关于初始迁移

如果您使用的是 Visual Studio 以外的 IDE(例如,JetBrains Rider),第一次运行时可能会有问题,因为它会添加初始迁移并编译项目。在这种情况下,在ProductManagement.DbMigrator项目的目录中打开一个命令行终端,并执行dotnet run命令。对于下一次运行,您可以在 IDE 中像平常一样运行它。

数据库已准备就绪,因此我们可以最终运行应用程序以探索用户界面。

运行 Web 应用程序

ProductManagement.Web设置为启动项目,并使用Ctrl + F5(不调试启动)运行它。

小贴士:启动时无需调试

强烈建议除非您需要调试,否则请以不调试的方式运行应用程序,因为这将会更快。

这将打开一个着陆页,您可以在其中删除内容并构建自己的应用程序主页。当您点击登录按钮时,您将被重定向到登录页面,如下面的截图所示:

![图 2.3 – 应用程序的登录页面图 2.3 – 应用程序的登录页面

图 2.3 – 应用程序的登录页面

默认用户名是admin,默认密码是1q2w3E*。您可以在登录应用程序后更改它。

ABP 是一个模块化框架,启动解决方案已安装了基本模块。在开始构建您的应用程序之前,探索预构建模块的功能是很好的。

探索预构建模块

本节将探讨启动解决方案中预安装的基本模块:账户身份租户管理

这些模块的源代码默认不包含在下载解决方案中,但它们在 GitHub 上免费提供。它们作为 NuGet 包使用,并在发布新的 ABP 版本时易于升级。它们被设计为高度可定制的,无需接触它们的代码。但是,如果您需要,可以将它们的源代码包含在您的解决方案中,根据您独特的需求自由更改它们。

让我们从提供用户认证功能的 账户 模块开始。

账户模块

图 2.3 所示的登录页面来自 账户 模块。此模块实现了登录、注册、忘记密码功能、社交登录以及一些其他常见需求。它还显示了一个租户选择区域,以便在多租户应用程序的开发环境中切换租户。多租户将在 第十六章 实现多租户 中介绍,因此我们将再次回到这个屏幕。

当您登录时,您将看到一个 管理 菜单项,其中包含几个子菜单项。这些菜单项包含 ABP 的预构建 身份租户管理 模块。

身份模块

身份 模块用于管理您应用程序中的用户、角色及其权限。它在 管理 菜单下添加了一个 身份管理 菜单项,其中 角色用户 作为其子菜单项,如下面的截图所示:

图 2.4 – 身份管理菜单

图 2.4 – 身份管理菜单

如果您点击 角色 菜单项,将打开角色管理页面,如下面的截图所示:

图 2.5 – 角色管理页面

图 2.5 – 角色管理页面

在此页面上,您可以管理您应用程序中的角色及其权限。在 ABP 中,角色是一组权限。角色被分配给用户以授权他们。图 2.5 中的 默认 徽章表示默认角色。默认角色在用户注册系统时自动分配给新用户。我们将在 第七章 探索横切关注点与授权和权限系统一起工作 部分返回到 角色 页面。

另一方面,用户页面用于管理您应用程序中的用户。一个用户可以有零个或多个角色。

角色和用户在几乎所有业务应用中都是相当标准的,而 租户管理 页面模块仅用于多租户系统。

租户管理模块

租户管理模块是您在多租户系统中创建和管理租户的地方。在多租户应用程序中,租户是一个拥有自己的数据——包括角色、用户和权限——并且与其他租户隔离的客户。这是构建软件即服务SaaS)解决方案的一种高效且常见的方式。如果您的应用程序不是多租户的,您只需从您的解决方案中移除此模块即可。

租户管理模块和多租户将在第十六章 实现多租户中介绍。

摘要

在本章中,我们安装了一些必要的工具来准备我们的开发环境。然后,我们看到了如何使用直接下载和 CLI 选项创建一个新的解决方案。最后,我们配置并运行了应用程序,以探索预构建的功能。

在下一章中,我们将学习如何通过理解解决方案结构来添加我们自己的功能到这个创业解决方案中。

第三章:第三章:逐步应用程序开发

本章通过构建一个示例应用程序来介绍 ABP 框架的基本知识。示例应用程序用于在典型的 CRUD 页面上管理产品(请注意,CRUD 页面用于 创建读取(查看)、更新删除实体)。

本章中提供的示例比简单的 CRUD 页面更高级。它实现了应用开发中的许多方面,具有生产质量。到本章结束时,您将了解基础知识,并准备好使用 ABP 框架开始开发。

我将按照构建真实世界项目的顺序逐步进行。本章包括以下主题;每个主题代表此过程中的一个步骤:

  • 创建解决方案

  • 定义领域对象

  • Entity Framework (EF) Core 和数据库映射

  • 列出产品数据

  • 创建产品

  • 编辑产品

  • 删除产品

    用户界面(UI)和数据库偏好

    我更喜欢 Razor Pages (MVC) 作为 UI 框架和 EF Core 作为数据库提供者。我们将在单独的章节中介绍其他 UI 框架和数据库提供者。

技术要求

我们将构建一个应用程序,因此您需要安装 .NET 运行时、ABP CLI 以及一个 IDE/编辑器来构建 ASP.NET Core 项目。

请参阅 第二章使用 ABP 框架入门,了解如何准备您的开发环境,以及创建和运行解决方案。

您可以从 GitHub 仓库 github.com/PacktPublishing/Mastering-ABP-Framework 下载最终应用程序的源代码。

创建解决方案

第一步是为产品管理应用程序创建一个解决方案。如果您在 第二章 中创建了 ProductManagement 解决方案,使用 ABP 框架入门,您可以使用它。否则,在您的计算机中创建一个空文件夹,在此文件夹中打开一个命令行终端,并运行以下 ABP CLI 命令以创建一个新的 Web 应用程序:

abp new ProductManagement -t app

在您最喜欢的 IDE 中打开解决方案,创建数据库,并运行 Web 项目。如果您在运行解决方案时遇到问题,请参阅上一章。

现在我们有一个正在运行的解决方案。我们可以通过定义解决方案的领域对象来开始开发。

定义领域对象

在本节中,您将学习如何使用 ABP 框架定义实体。对于此应用程序,领域很简单。我们有 ProductCategory 实体以及一个 ProductStockState 枚举,如图 3.1 所示:

图 3.1 – 一个示例产品管理领域

图 3.1 – 一个示例产品管理领域

实体在解决方案的 领域层 中定义,领域层在解决方案中分为两个项目:

  • ProductManagement.Domain用于定义你的实体、值对象、领域服务、存储库接口以及其他核心领域相关的类。

  • ProductManagement.Domain.Shared用于定义一些原始的共享类型。在此项目中定义的类型对所有其他层都是可用的。通常,我们在这里定义枚举和一些常量。

因此,我们可以从创建CategoryProduct实体以及ProductStockState枚举开始。

Category

Category实体用于对产品进行分类。在ProductManagement.Domain项目内创建一个Categories文件夹,并在其中创建一个Category类:

using System;
using Volo.Abp.Domain.Entities.Auditing;
namespace ProductManagement.Categories
{
    public class Category : AuditedAggregateRoot<Guid>
    {
        public string Name { get; set; }
    }
}

Category是一个从AuditedAggregateRoot<Guid>派生的类。在这里,Guid是实体的主键(Id)类型。只要你的数据库管理系统支持,你可以使用任何类型的键(如intlongstring)。

AggregateRoot是一种特殊类型的实体,用于创建聚合的根实体类型。聚合是领域驱动设计DDD)的一个概念,我们将在接下来的章节中详细讨论。现在,请考虑我们从这个类继承主要实体。

AuditedAggregateRoot类向AggregateRoot类添加了一些额外的属性:CreationTime作为DateTimeCreatorId作为GuidLastModificationTime作为DateTime,以及LastModifierId作为Guid

ABP 会自动设置这些属性。例如,当你将实体插入数据库时,CreationTime会被设置为当前时间,而CreatorId会自动设置为当前用户的Id属性。

审计日志系统和基础Audited类将在第八章中介绍,使用 ABP 的功能和服务

关于丰富的领域模型

在本章中,我保持了实体的简单性,具有公共的获取器和设置器。如果你想创建丰富的领域模型并应用 DDD 原则和其他最佳实践,我们将在接下来的章节中讨论它们。

ProductStockState

ProductStockState是一个简单的枚举,用于设置和跟踪库存中产品的可用性。

ProductManagement.Domain.Shared项目内创建一个Products文件夹,并在其中创建一个ProductStockState枚举:

namespace ProductManagement.Products
{
    public enum ProductStockState : byte
    {
        PreOrder,
        InStock,
        NotAvailable,
        Stopped
    }
}

我们在ProductManagement.Domain.Shared项目中定义这个enum,因为我们将在数据传输对象DTOs)和 UI 层中重用它。

Product

Product类代表一个真实的产品。我故意添加了不同类型的属性来展示它们的用法。在ProductManagement.Domain项目内创建一个Products文件夹,并在其中创建一个Product类:

using System;
using Volo.Abp.Domain.Entities.Auditing;
using ProductManagement.Categories;
namespace ProductManagement.Products
{
    public class Product : FullAuditedAggregateRoot<Guid>
    {
        public Category Category { get; set; }
        public Guid CategoryId { get; set; }
        public string Name { get; set; }
        public float Price { get; set; }
        public bool IsFreeCargo { get; set; }
        public DateTime ReleaseDate { get; set; }
        public ProductStockState StockState { get; set; }
    }
}

这次,我继承了FullAuditedAggregateRoot,它除了AuditedAggregateRoot类用于Category类外,还添加了IsDeleted作为boolDeletionTime作为DateTime,以及DeleterId作为Guid属性。

FullAuditedAggregateRoot 实现了 ISoftDelete 接口,这使得实体可以进行 软删除。这意味着它不会被从数据库中删除,而是仅仅 标记为已删除。ABP 自动处理所有软删除逻辑。您像平常一样删除实体,但实际上它并没有被删除。下次查询时,已删除的实体将自动过滤,除非您故意请求它们,否则您不会在查询结果中看到它们。我们将在 第八章使用 ABP 的功能和服务使用数据过滤系统 部分中回到这个功能。

关于导航属性

在这个例子中,Product.CategoryCategory 实体的导航属性。如果您使用 MongoDB 或想真正实现 DDD,您不应向其他聚合添加导航属性。然而,对于关系数据库,它工作得非常好,并为我们的代码提供了灵活性。我们将在 第十章DDD – 领域层 中讨论替代方法。

解决方案中的新文件应类似于 图 3.2

图 3.2 – 将领域对象添加到解决方案中

图 3.2 – 将领域对象添加到解决方案中

我们已经创建了领域对象。此外,我们还将创建一些 const 值,以便在应用程序的后续部分使用。

常量

我们需要为实体的属性定义常量值。然后,我们将在输入验证和数据库映射阶段使用它们。

首先,在 ProductManagement.Domain.Shared 项目的内部创建一个 Categories 文件夹,并在其中添加一个 CategoryConsts 类:

namespace ProductManagement.Categories
{
    public static class CategoryConsts
    {
        public const int MaxNameLength = 128;
    }
}

在这里,MaxNameLength 值将被用于实现 Category 实例的 Name 属性的约束。

然后,在 ProductManagement.Domain.Shared 项目的 Products 文件夹内创建一个 ProductConsts 类:

namespace ProductManagement.Products
{
    public static class ProductConsts
    {
        public const int MaxNameLength = 128;
    }
}

MaxNameLength 值将被用于实现 Product 实例的 Name 属性的约束。

ProductManagement.Domain.Shared 项目应类似于 图 3.3

图 3.3 – 添加常量类

图 3.3 – 添加常量类

现在领域层已经完成,我们现在可以配置 EF Core 的数据库映射。

EF Core 和数据库映射

在这个应用程序中,我们使用 EF Core。EF Core 是由 Microsoft 提供的一个 对象关系映射 (ORM) 提供商。ORM 提供了抽象,使您感觉像是在应用程序代码中操作对象,而不是数据库表。我们将在 第六章使用数据访问基础设施 中介绍 ABP 的 EF Core 集成。然而,现在让我们专注于如何实际使用它。

首先,我们将向DbContext类添加实体并定义实体与数据库表之间的映射。然后,我们将使用 EF Core 的代码优先迁移方法来构建创建数据库表的必要代码。在此之后,我们将查看 ABP 的数据初始化系统,以将一些初始数据插入数据库。最后,我们将应用迁移和种子数据到数据库中,为应用程序做准备。

首先,让我们从定义实体的DbSet属性开始。

将实体添加到DbContext类中

EF 的DbContext类是用于定义实体与数据库表之间映射的主要类。此外,它还用于访问数据库并为相关实体执行数据库操作。

ProductManagement.EntityFrameworkCore项目中打开ProductManagementDbContext类,并在其中添加以下DbSet属性(您需要导入ProductCategory对象的命名空间):

public DbSet<Product> Products { get; set; }
public DbSet<Category> Categories { get; set; }

为实体添加DbSet属性将实体与DbContext类相关联。然后,我们可以使用该DbContext类来对实体执行数据库操作。EF Core 可以通过基于属性名称和类型的约定来执行大部分映射。如果您想自定义默认映射配置或执行其他配置,您有两个选项:数据注解(属性)和流畅式 API

在数据注解方法中,您将[Required][StringLength]等属性添加到实体属性中。它非常实用且易于使用。它还使阅读实体源代码时更容易理解。

数据注解属性的一个问题是它们有限制(与流畅式 API 相比),并且当您需要使用 EF Core 的自定义属性,如[Index][Owned]时,会使您的领域层依赖于 EF Core 的 NuGet 包。如果您不介意这个问题,您可以使用数据注解属性,并在它们不足够的地方结合使用流畅式 API。

在本章中,我将优先选择流畅式 API 方法,这种方法可以使实体更加简洁,并将所有 ORM 逻辑放置在基础设施层中。

将实体映射到数据库表

ProductManagementDbContext类(在ProductManagement.EntityFrameworkCore项目中)包含一个OnModelCreating方法来配置实体到数据库表的映射。当您首次创建解决方案时,此方法看起来如下:

protected override void OnModelCreating(ModelBuilder builder)
{
    base.OnModelCreating(builder);
    builder.ConfigurePermissionManagement();
    builder.ConfigureSettingManagement();
    builder.ConfigureIdentity();
    ...configuration of the other modules
    /* Configure your own tables/entities here */
}

在前面的注释之后添加以下代码:

builder.Entity<Category>(b =>
{
      b.ToTable("Categories");
      b.Property(x => x.Name)
            .HasMaxLength(CategoryConsts.MaxNameLength)
            .IsRequired();
      b.HasIndex(x => x.Name);
});
builder.Entity<Product>(b =>
{
      b.ToTable("Products");
      b.Property(x => x.Name)
            .HasMaxLength(ProductConsts.MaxNameLength)
            .IsRequired();
      b.HasOne(x => x.Category)
           .WithMany()
           .HasForeignKey(x => x.CategoryId)
           .OnDelete(DeleteBehavior.Restrict)
           .IsRequired();
b.HasIndex(x => x.Name).IsUnique();
});

这段代码部分定义了CategoryProduct的映射配置。

关于命名空间

您可能需要为Product类、Category类以及代码中使用的任何其他类的命名空间添加using语句。如果您遇到麻烦,您始终可以参考本章技术要求部分中我分享的 GitHub 仓库中的源代码。

Category实体映射到Categories数据库表。我们使用之前定义的CategoryConsts.MaxNameLength来设置数据库中Name字段的最大长度。Name字段也是一个必需属性。最后,我们为Name属性定义了一个唯一数据库索引,因为它有助于通过Name字段搜索类别。

Product映射类似于Category映射。此外,它定义了Category实体和Product实体之间的关系;一个Product实体属于一个Category实体,而一个Category实体可以拥有多个相关的Product实体。

EF Core 流畅映射

您可以参考 EF Core 文档来了解关于 Fluent Mapping API 的所有细节和其他选项。

映射配置已完成。现在是时候创建一个数据库迁移来更新新添加实体的数据库架构了。

Add-Migration 命令

当您创建一个新的实体或修改现有的实体时,您还应该在数据库中创建或修改相关的表。EF Core 的Code First Migration系统是保持数据库架构与应用程序代码一致的理想方式。通常,您会生成迁移并将它们应用到数据库中。迁移是数据库的增量架构更改。当您更新数据库时,所有自上次更新以来的迁移都会被应用,数据库就会与应用程序代码保持一致。

有两种方法可以生成新的迁移。

使用 Visual Studio

如果您正在使用 Visual Studio,请从视图 | 其他窗口 | 包管理控制台菜单打开包管理控制台(PMC)

![Figure 3.4 – 包管理控制台

![img/Figure_3.4_B17287.jpg]

图 3.4 – 包管理控制台

ProductManagement.EntityFrameworkCore项目作为默认项目类型选择。确保ProductManagement.Web项目被选为启动项目。您可以在ProductManagement.Web项目上右键单击,然后单击设置为启动项目操作。

现在,您可以在 PMC 中输入以下命令以添加一个新的迁移类:

Add-Migration "Added_Categories_And_Products"

此命令的输出应类似于图 3.5

![Figure 3.5 – Add-Migration 命令的输出

![img/Figure_3.5_B17287.jpg]

图 3.5 – Add-Migration 命令的输出

如果您遇到诸如在程序集...中没有找到 DbContext之类的错误,请确保您已将默认项目类型设置为ProductManagement.EntityFrameworkCore项目。

如果一切顺利,新的迁移类应该被添加到ProductManagement.EntityFrameworkCore项目的Migrations文件夹中。

在命令行中

如果你没有使用 Visual Studio,你可以使用 EF Core 命令行工具。如果你还没有安装它,请在命令行终端中执行以下命令:

dotnet tool install --global dotnet-ef

现在,在ProductManagement.EntityFrameworkCore项目的根目录下打开一个命令行终端,并输入以下命令:

dotnet ef migrations add "Added_Categories_And_Products"

应在ProductManagement.EntityFrameworkCore项目的Migrations文件夹内添加一个新的迁移类。

在应用新创建的迁移到数据库之前,我想提及 ABP 框架的数据初始化功能。

初始化数据

数据初始化系统用于在迁移数据库时添加一些初始数据。例如,身份模块在数据库中创建了一个拥有所有权限的 admin 用户,以便登录应用程序。

在我们的场景中,数据初始化不是必需的,但我想要在数据库中添加一些示例类别和产品,以便更容易地进行开发和测试。

关于 EF Core 数据初始化

本节使用 ABP 的数据初始化系统,而 EF Core 有自己的数据初始化功能。ABP 数据初始化系统允许你在数据初始化代码中注入运行时服务并实现高级逻辑,适用于开发、测试和生产环境。然而,对于更简单的开发测试场景,你可以使用 EF Core 的数据初始化系统。请查阅官方文档docs.microsoft.com/en-us/ef/core/modeling/data-seeding

ProductManagement.Domain项目的Data文件夹中创建一个ProductManagementDataSeedContributor类:

using ProductManagement.Categories;
using ProductManagement.Products;
using System;
using System.Threading.Tasks;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;
namespace ProductManagement.Data
{
    public class ProductManagementDataSeedContributor :
           IDataSeedContributor, ITransientDependency
    {
        private readonly IRepository<Category,                           Guid>_categoryRepository;
        private readonly IRepository<Product,                           Guid>_productRepository;
        public ProductManagementDataSeedContributor(
            IRepository<Category, Guid> categoryRepository,
            IRepository<Product, Guid> productRepository)
        {
            _categoryRepository = categoryRepository;
            _productRepository = productRepository;
        }
        public async Task SeedAsync(DataSeedContext                     context)
        {
            /***** TODO: Seed initial data here *****/
        }
    }
}

这个类实现了IDataSeedContributor接口。当你想要初始化数据库时,ABP 会自动发现并调用它的SeedAsync方法。你可以在类中实现构造函数注入并使用任何服务(例如,本例中的存储库)。

然后,在SeedAsync方法内部编写以下代码:

if (await _categoryRepository.CountAsync() > 0)
{
    return;
}
var monitors = new Category { Name = "Monitors" };
var printers = new Category { Name = "Printers" };
await _categoryRepository
    .InsertManyAsync(new[] { monitors, printers });
var monitor1 = new Product
{
    Category = monitors,
    Name = "XP VH240a 23.8-Inch Full HD 1080p IPS LED               Monitor",
    Price = 163,
    ReleaseDate = new DateTime(2019, 05, 24),
    StockState = ProductStockState.InStock
};
var monitor2 = new Product
{
    Category = monitors,
    Name = "Clips 328E1CA 32-Inch Curved Monitor, 4K UHD",
    Price = 349,
    IsFreeCargo = true,
    ReleaseDate = new DateTime(2022, 02, 01),
    StockState = ProductStockState.PreOrder
};
var printer1 = new Product
{
    Category = monitors,
    Name = "Acme Monochrome Laser Printer, Compact All-In           One",
    Price = 199,
    ReleaseDate = new DateTime(2020, 11, 16),
    StockState = ProductStockState.NotAvailable
};
await _productRepository
    .InsertManyAsync(new[] { monitor1, monitor2, printer1 });

我们已经创建了两个类别,并添加了三个产品到数据库中。这个类在每次运行DbMigrator应用程序时都会执行(请参阅以下章节)。此外,我们检查了if (await _categoryRepository.CountAsync() > 0)以防止每次运行时插入相同的数据。

现在我们已经准备好迁移数据库,这将更新数据库架构并初始化初始数据。

迁移数据库

ABP 应用程序启动模板包括一个非常有用的 DbMigrator 控制台应用程序,它在开发和生产环境中都很有用。当您运行它时,所有挂起的迁移都会在数据库中应用,并且会执行数据生成器类。它支持多租户、多数据库场景,如果您使用标准的 Update-Database 命令,这是不可能的。此应用程序可以在生产环境中部署和执行,通常作为您 持续部署 (CD) 管道的一部分。将迁移与主应用程序分离是一种很好的方法,因为在这种情况下主应用程序不需要更改数据库模式的权限。此外,如果您在主应用程序中应用迁移并运行多个应用程序实例,您还可以消除任何并发问题。

运行 ProductManagement.DbMigrator 应用程序以迁移数据库(即将其设置为启动项目,然后按 Ctrl + F5)。一旦应用程序退出,您就可以检查数据库以查看 CategoriesProducts 表已插入初始数据(如果您使用的是 Visual Studio,您可以使用 SQL Server Object Explorer 连接到 LocalDB 并探索数据库)。

EF Core 配置已完成,数据库已准备好开发。我们将继续在 UI 上显示产品数据。

列出产品数据

我更喜欢按功能特性开发应用程序功能。本节将解释如何在 UI 上以数据表的形式显示产品列表。

我们将首先定义一个 ProductDto,用于 Product 实体。然后,我们将创建一个应用程序服务方法,该方法将产品列表返回到表示层。此外,我们还将学习如何自动将 Product 实体映射到 ProductDto

在创建 UI 之前,我将向您展示如何为应用程序服务编写 自动化测试。这样,我们就可以在开始 UI 开发之前确保应用程序服务正常工作。

在整个开发过程中,我们将探讨 ABP 框架的一些好处,例如自动 API 控制器和动态 JavaScript 代理系统。

最后,我们将创建一个新页面,在其中添加一个数据表,从服务器获取产品列表,并在 UI 上显示。

在下一节中,我们将首先创建一个 ProductDto 类。

ProductDto 类

DTOs 用于在应用程序和表示层之间传输数据。最佳实践是将 DTOs 返回到表示层(UI),而不是实体。DTOs 允许您以受控的方式公开数据,并将实体从表示层抽象出来。直接将实体公开给表示层也可能导致序列化和安全问题。我们将在 第十一章 中讨论使用 DTOs 的好处,领域驱动设计 – 应用层

DTOs 在Application.Contracts项目中定义,以便在 UI 层中使用。因此,我们首先在ProductManagement.Application.Contracts项目的Products文件夹中创建一个ProductDto类:

using System;
using Volo.Abp.Application.Dtos;
namespace ProductManagement.Products
{
    public class ProductDto : AuditedEntityDto<Guid>
    {
        public Guid CategoryId { get; set; }
        public string CategoryName { get; set; }
        public string Name { get; set; }
        public float Price { get; set; }
        public bool IsFreeCargo { get; set; }
        public DateTime ReleaseDate { get; set; }
        public ProductStockState StockState { get; set; }
    }
}

ProductDto类与Product实体类似,但有以下区别:

  • 它从AuditedEntityDto<Guid>派生,该类定义了IdCreationTimeCreatorIdLastModificationTimeLastModifierId属性(我们不需要删除审计属性,如DeletionTime,因为已删除的实体不会从数据库中读取)。

  • 我们没有在Category实体中添加导航属性,而是使用了一个string类型的CategoryName属性,这对于在 UI 上显示是足够的。

我们将使用ProductDto类从IProductAppService接口返回产品列表。

IProductAppService

应用服务实现了应用程序的使用案例。用户界面使用它们来执行用户交互的业务逻辑。通常,应用程序服务方法获取并返回 DTOs。

应用服务与 API 控制器比较

你可以在 ASP.NET Core MVC 应用程序中将应用服务与 API 控制器进行比较。虽然它们在某些用例中具有相似性,但应用服务是更适合 DDD 的纯类。它们不依赖于特定的 UI 技术。此外,ABP 可以自动将你的应用服务作为 HTTP API 公开,正如我们在本章的自动 API 控制器和 Swagger UI部分中所将发现的。

我们在解决方案的Application.Contracts项目中定义应用服务的接口。在ProductManagement.Application.Contracts项目的Products文件夹中创建一个IProductAppService接口:

using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
namespace ProductManagement.Products
{
    public interface IProductAppService :                           IApplicationService
    {
        Task<PagedResultDto<ProductDto>>
            GetListAsync(PagedAndSortedResultRequestDto                     input);
    }
}

你可以在前面的代码块中看到一些预定义的 ABP 类型:

  • IProductAppService是从IApplicationService接口派生的。通过这种方式,ABP 可以识别应用服务。

  • GetListAsync方法获取PagedAndSortedResultRequestDto,这是 ABP 框架的一个标准 DTO 类,它定义了MaxResultCount(整型)、SkipCount(整型)和Sorting(字符串)属性。

  • GetListAsync方法返回PagedResultDto<ProductDto>,它包含一个TotalCount(长整型)属性和一个Items集合,其中包含ProductDto对象。这是使用 ABP 框架返回分页结果的一种便捷方式。

你可以使用自己的 DTOs 而不是这些预定义的 DTO 类型。然而,当你在标准化某些常见模式并希望到处使用相同的命名时,它们非常有用。

异步方法

将所有应用服务方法定义为异步是一种最佳实践。如果你定义了同步的应用服务方法,在某些情况下,某些 ABP 功能(如工作单元)可能无法按预期工作。

现在,我们可以实现IProductAppService接口以执行用例。

ProductAppService

ProductManagement.Application 项目的 Products 文件夹中创建一个 ProductAppService 类:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Dynamic.Core;
using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Domain.Repositories;
namespace ProductManagement.Products
{
    public class ProductAppService :
        ProductManagementAppService, IProductAppService
    {
        private readonly IRepository<Product, Guid>                     _productRepository;
        public ProductAppService(
            IRepository<Product, Guid> productRepository)
        {
            _productRepository = productRepository;
        }
        public async Task<PagedResultDto<ProductDto>>                   GetListAsync(
            PagedAndSortedResultRequestDto input)
        {
            /* TODO: Implementation */
        }
    }
}

ProductAppService 类继承自 ProductManagementAppService,该类在启动模板中定义,可以用作应用程序服务的基类。它实现了之前定义的 IProductAppService 接口。它注入了 IRepository<Product, Guid> 服务。这被称为 默认仓储。仓储是一个类似集合的接口,允许你在数据库上执行操作。ABP 自动为所有聚合根实体提供默认仓储实现。

我们可以实现 GetListAsync 方法,如下面的代码块所示:

public async Task<PagedResultDto<ProductDto>> GetListAsync(
    PagedAndSortedResultRequestDto input)
{
    var queryable = await _productRepository
        .WithDetailsAsync(x => x.Category);
    queryable = queryable
        .Skip(input.SkipCount)
        .Take(input.MaxResultCount)
        .OrderBy(input.Sorting ?? nameof(Product.Name));
    var products = await                                             AsyncExecuter.ToListAsync(queryable);
    var count = await _productRepository.GetCountAsync();
    return new PagedResultDto<ProductDto>(
        count,
        ObjectMapper.Map<List<Product>, List<ProductDto>>                (products)
    );
}

在这里,_productRepository.WithDetailsAsync 通过包含类别(WithDetailsAsync 方法类似于 EF Core 的 Include 扩展方法,它将相关数据加载到查询中)返回一个 IQueryable<Product> 对象。我们可以在查询对象上使用标准的 SkipTakeOrderBy

AsyncExecuter 服务(在基类中预先注入)用于执行 IQueryable 对象以异步执行数据库查询。这使得可以在应用层不依赖于 EF Core 包的情况下使用异步 LINQ 扩展方法。

最后,我们使用 ObjectMapper 服务(在基类中预先注入)将 Product(实体)对象列表映射到 ProductDto(DTO)对象列表。在下一节中,我们将解释对象映射是如何配置的。

仓储和异步查询执行

我们将在 第六章与数据访问基础设施一起工作 中更详细地探讨 IRepositoryAsyncExecuter

对象到对象映射

ObjectMapperIObjectMapper 服务)自动化类型转换,并默认使用 AutoMapper 库。它要求你在使用之前定义映射。启动模板包含一个配置类,你可以在其中创建映射。

ProductManagement.Application 项目的 ProductManagementApplicationAutoMapperProfile 类中打开,并将其更改为以下内容:

using AutoMapper;
using ProductManagement.Products;
namespace ProductManagement
{
    public class ProductManagementApplicationAutoMapperProfile
        : Profile
    {
        public ProductManagementApplicationAutoMapperProfile()
        {
            CreateMap<Product, ProductDto>();
        }
    }
}

在这里,CreateMap 定义了映射。然后,你可以在需要的地方自动将 Product 对象转换为 ProductDto 对象。

AutoMapper 的一个有趣特性是 Product 类有一个 Category 属性,而 Category 类有一个 Name 属性。所以,如果你想访问产品的类别名称,你应该使用 Product.Category.Name 表达式。然而,ProductDto 有一个直接的 CategoryName 属性,可以使用 ProductDto.CategoryName 表达式访问。AutoMapper 自动通过将 Category.Name 展平到 CategoryName 来映射这些表达式。

应用层已完成。在开始 UI 之前,我想向你展示如何为应用层编写自动化测试。

测试 ProductAppService 类

启动模板附带使用 xUnitShouldlyNSubstitute 库正确配置的测试基础设施。它使用 SQLite 内存数据库 来模拟数据库。为每个测试创建一个单独的数据库。测试结束时,数据库将被初始化和销毁。这样,测试不会相互影响,并且你的真实数据库保持不变。

第十七章构建自动化测试 将探讨测试的所有细节。然而,在这里,我想向你展示如何轻松编写 ProductAppService 类的 GetListAsync 方法的自动化测试代码。在 UI 上使用之前编写应用程序服务的测试代码是很好的。

ProductManagement.Application.Tests 项目中创建一个 Products 文件夹,并在其中创建一个 ProductAppService_Tests 类:

using Shouldly;
using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Xunit;
namespace ProductManagement.Products
{
    public class ProductAppService_Tests
        : ProductManagementApplicationTestBase
    {
        private readonly IProductAppService                             _productAppService;
        public ProductAppService_Tests()
        {
            _productAppService =
                GetRequiredService<IProductAppService>();
        }
        /* TODO: Test methods */
    }
}

此类继承自 ProductManagementApplicationTestBase 类(包含在你的解决方案中),该类集成了 ABP 框架和其他基础设施库,使我们能够编写测试。由于测试中不可能使用构造函数注入,我们使用 GetRequiredService 方法在测试代码中解析依赖项。

现在,我们可以编写第一个测试方法。在 ProductAppService_Tests 类中添加以下方法:

[Fact]
public async Task Should_Get_Product_List()
{
    //Act
    var output = await _productAppService.GetListAsync(
        new PagedAndSortedResultRequestDto()
    );
    //Assert
    output.TotalCount.ShouldBe(3);
    output.Items.ShouldContain(
        x => x.Name.Contains("Acme Monochrome Laser                     Printer")
    );
}

此方法调用 GetListAsync 方法并检查结果是否正确。如果你打开 测试资源管理器 窗口(在 Visual Studio 的 视图 | 测试资源管理器 菜单下),你可以看到我们添加的测试方法。测试资源管理器 用于显示和运行解决方案中的测试:

图 3.6 – 测试资源管理器窗口

图 3.6 – 测试资源管理器窗口

运行测试以检查它是否按预期工作。如果 GetListAsync 方法工作正常,你将在测试方法名称的左侧看到绿色图标,如图 图 3.6 所示。单元测试和集成测试将在 第十七章构建自动化测试 中介绍。

自动 API 控制器和 Swagger UI

Swagger 是一个流行的工具,用于探索和测试 HTTP API。它随启动模板预安装。

运行 ProductManagement.Web 项目以启动 Web 应用程序(如果尚未设置,请将其设置为启动项目,然后按 Ctrl + F5)。一旦应用程序启动,输入如图 图 3.7 所示的 /swagger URL:

图 3.7 – Swagger UI

图 3.7 – Swagger UI

你将看到来自应用程序中安装的模块的大量 API 端点。如果你向下滚动,你将看到一个 产品 端点。你可以测试它以获取产品列表:

图 3.8 – 产品端点

图 3.8 – 产品端点

我们还没有创建 ProductController 端点。那么这个端点是如何在这里可用的呢?这被称为 ABP 框架的 Auto API 控制器 功能。它根据命名约定和配置自动将您的应用程序服务公开为 HTTP API。通常,我们不手动编写控制器。

Auto API 控制器功能将在 第十四章构建 HTTP API 和实时服务中详细介绍。

因此,我们有 HTTP API 来获取产品列表。下一步是从客户端代码中消费这个 API。

动态 JavaScript 代理

通常,您从 JavaScript 代码中调用 HTTP API 端点。ABP 动态为所有 HTTP API 创建客户端代理。然后,您可以使用这些动态 JavaScript 函数从客户端应用程序中消费您的 API。

再次运行 ProductManagement.Web 项目,并在您位于应用程序的着陆页时打开浏览器的 开发者控制台。开发者控制台在任何现代浏览器中都是可用的,通常使用 F12 快捷键(在 Windows 上)打开。它用于通过开发者探索、跟踪和调试应用程序。

打开 控制台 选项卡,并输入以下 JavaScript 代码:

productManagement.products.product.getList({}).then(function(result) {
    console.log(result);
});

一旦您执行此代码,就会向服务器发出请求,并将返回的结果记录在 控制台 选项卡中,如图 3.9 所示:

图 3.9 – 使用动态 JavaScript 代理

图 3.9 – 使用动态 JavaScript 代理

我们可以看到产品列表已记录在 控制台 选项卡中。这意味着我们可以轻松地从 JavaScript 代码中消费服务器端 API,而无需处理底层细节。

如果您想知道那个 getList JavaScript 定义在哪里,您可以在应用程序中的 /Abp/ServiceProxyScript 端点查看 ABP 框架动态创建的 JavaScript 代理函数。

在下一节中,我们将创建一个 Razor 页面来在 UI 上显示产品表。

创建产品页面

Razor Pages 是在 ASP.NET Core MVC 框架中创建 UI 的推荐方式。

首先,在 ProductManagement.Web 项目的 Pages 文件夹下创建一个 Products 文件夹。然后,通过右键单击 Products 文件夹并选择 Index.cshtml 来添加一个新的空 razor 页面。图 3.10 显示了我们添加的页面位置:

图 3.10 – 创建 Razor 页面

图 3.10 – 创建 Razor 页面

编辑 Index.cshtml 内容,如下面的代码块所示:

@page
@using ProductManagement.Web.Pages.Products
@model IndexModel
<h1>Products Page</h1>

在这里,我刚刚放置了一个h1元素作为页面标题。当我们创建一个页面时,通常,我们希望向主菜单添加一个项目以打开此页面。

添加新的菜单项

ABP 提供了一个动态和模块化的菜单系统。每个模块都可以向主菜单添加项目。

打开 ProductManagement.Web 项目的 Menus 文件夹中的 ProductManagementMenuContributor 类,并在 ConfigureMainMenuAsync 方法的末尾添加以下代码:

context.Menu.AddItem(
    new ApplicationMenuItem(
        "ProductManagement",
        l["Menu:ProductManagement"],
        icon: "fas fa-shopping-cart"
            ).AddItem(
        new ApplicationMenuItem(
            "ProductManagement.Products",
            l["Menu:Products"],
            url: "/Products"
        )
    )
);

此代码添加了一个包含 产品 菜单项的 产品管理 主菜单项。它使用我们应定义的本地化键(使用 l["…"] 语法)。打开 ProductManagement.Domain.Shared 项目的 Localization/ProductManagement 文件夹中的 en.json 文件,并在 texts 部分的末尾添加以下条目:

"Menu:ProductManagement": "Product Management",
"Menu:Products": "Products"

本地化键是任意的,这意味着你可以使用任何字符串值作为本地化键。我更喜欢使用 Menu: 前缀作为菜单项的本地化键,例如本例中的 Menu:Products。我们将在 第八章使用 ABP 的功能和服务 中回到本地化的话题。

现在,你可以重新运行应用程序,并使用新的 产品管理 菜单项打开 产品 页面,如图 3.11 所示:

图 3.11 – 产品页面

图 3.11 – 产品页面

因此,我们已经创建了一个页面,并且可以使用菜单元素打开该页面。我们现在准备好创建一个数据表来显示产品列表。

创建产品数据表

我们将创建一个数据表来显示带有分页和排序的产品列表。ABP 启动模板预安装并配置了 Datatables.net JavaScript 库。这是一个灵活且功能丰富的库,用于显示表格数据。

打开 Pages/Products 文件夹中的 Index.cshtml 页面,并将其内容更改为以下内容:

@page
@using ProductManagement.Web.Pages.Products
@using Microsoft.Extensions.Localization
@using ProductManagement.Localization
@model IndexModel
@inject IStringLocalizer<ProductManagementResource> L
@section scripts
{
    <abp-script src="img/Index.cshtml.js" />
}
<abp-card>
    <abp-card-header>
        <h2>@L["Menu:Products"]</h2>
    </abp-card-header>
    <abp-card-body>
        <abp-table id="ProductsTable" striped-rows="true" />
    </abp-card-body>
</abp-card>

在这里,abp-script 是一个 ABP 标签助手,用于向页面添加带有自动捆绑、压缩和版本支持功能的脚本文件。abp-card 是另一个标签助手,以类型安全和简单的方式渲染卡片组件(它渲染一个 Bootstrap 卡片)。

我们可以使用标准的 HTML 标签。然而,ABP 标签助手极大地简化了 MVC/Razor Page 应用程序中的 UI 创建。此外,它们通过 IntelliSense 和编译时类型检查来防止错误。我们将在 第十二章使用 MVC/Razor Pages 中研究标签助手。

Pages/Products 文件夹下创建一个新的 JavaScript 文件,命名为 Index.cshtml.js(你可能更喜欢不同的命名风格,例如 index.js;这没关系,只要你在 abp-script 标签中使用相同的文件名即可),内容如下:

$(function () {
    var l = abp.localization.getResource('ProductManagement');
    var dataTable = $('#ProductsTable').DataTable(
        abp.libs.datatables.normalizeConfiguration({
            serverSide: true,
            paging: true,
            order: [[0, "asc"]],
            searching: false,
            scrollX: true,
            ajax: abp.libs.datatables.createAjax(
                productManagement.products.product.getList),
            columnDefs: [
                /* TODO: Column definitions */
            ]
        })
    );
});

ABP 允许你在 JavaScript 代码中重用本地化文本。这样,你可以在服务器端定义它们,并在两边使用。abp.localization.getResource 返回一个用于本地化值的函数。

ABP 简化了数据表库的配置,并提供了内置集成:

  • abp.libs.datatables.normalizeConfiguration 是 ABP 框架定义的辅助函数。它通过为缺失的选项提供常规默认值来简化数据表的配置。

  • abp.libs.datatables.createAjax 是另一个辅助函数,它将 ABP 的动态 JavaScript 客户端代理适配到数据表的参数格式。

  • productManagement.products.product.getList 是之前介绍过的动态 JavaScript 代理函数。

columnDefs 数组内部定义数据表的列:

{
    title: l('Name'),
    data: "name"
},
{
    title: l('CategoryName'),
    data: "categoryName",
    orderable: false
},
{
    title: l('Price'),
    data: "price"
},
{
    title: l('StockState'),
    data: "stockState",
    render: function (data) {
        return l('Enum:StockState:' + data);
    }
},
{
    title: l('CreationTime'),
    data: "creationTime",
    dataFormat: 'date'
}

通常,列定义有一个 title 字段(显示名称)和一个 data 字段。数据字段与 ProductDto 类中的属性名称匹配,格式为 camelCase(一种命名风格,其中每个单词的首字母大写,除了第一个单词;它通常用于 JavaScript 语言中)。

可以使用 render 选项来精细控制如何显示列数据。我们提供了一个函数来自定义库存状态列的渲染。

在这个页面上,我们使用了一些本地化键。我们应该在本地化资源中定义它们。打开 ProductManagement.Domain.Shared 项目的 Localization/ProductManagement 文件夹中的 en.json 文件,并在 texts 部分的末尾添加以下条目:

"Name": "Name",
"CategoryName": "Category name",
"Price": "Price",
"StockState": "Stock state",
"Enum:StockState:0": "Pre-order",
"Enum:StockState:1": "In stock",
"Enum:StockState:2": "Not available",
"Enum:StockState:3": "Stopped",
"CreationTime": "Creation time"

您可以再次运行 Web 应用程序以查看产品数据表的实际效果:

Figure 3.12 – 产品数据表

Figure 3.12 – The Products data table

图 3.12 – 产品数据表

我们已经创建了一个完全工作的页面,该页面列出了具有分页和排序支持的产品。在接下来的章节中,我们将添加创建、编辑和删除产品的功能。

创建产品

在本节中,我们将创建添加新产品的必要功能。一个产品应该有一个类别。因此,在添加新产品时,我们应该选择一个类别。我们将定义新的应用程序服务方法来获取类别和创建产品。在 UI 部分,我们将使用 ABP 的动态表单功能自动生成基于 C# 类的产品创建表单。

应用程序服务合约

让我们从向 IProductAppService 接口添加两个新方法开始:

Task CreateAsync(CreateUpdateProductDto input);
Task<ListResultDto<CategoryLookupDto>> GetCategoriesAsync();

我们将使用 GetCategoriesAsync 方法在创建产品时显示类别下拉列表。我们已经介绍了两个新的 DTO,我们应该定义它们。

CreateUpdateProductDto 用于创建和更新产品(我们将在 编辑产品 部分重用它)。在 ProductManagement.Application.Contracts 项目的 Products 文件夹中定义它:

using System;
using System.ComponentModel.DataAnnotations;
namespace ProductManagement.Products
{
    public class CreateUpdateProductDto
    {
        public Guid CategoryId { get; set; }
        [Required]
        [StringLength(ProductConsts.MaxNameLength)]
        public string Name { get; set; }
        public float Price { get; set; }
        public bool IsFreeCargo { get; set; }
        public DateTime ReleaseDate { get; set; }
        public ProductStockState StockState { get; set; }
    }
}

接下来,在 ProductManagement.Application.Contracts 项目的 Categories 文件夹中定义一个 CategoryLookupDto 类:

using System;
namespace ProductManagement.Categories
{
    public class CategoryLookupDto
    {
        public Guid Id { get; set; }
        public string Name { get; set; }
    }
}

我们已经创建了合约,现在我们可以在应用程序层实现这些合约。

应用程序服务实现

ProductAppService(在 ProductManagement.Application 项目中)中实现 CreateAsyncGetCategoriesAsync 方法,如下面的代码块所示:

public async Task CreateAsync(CreateUpdateProductDto input)
{
    await _productRepository.InsertAsync(
        ObjectMapper.Map<CreateUpdateProductDto, Product>                (input)
    );
}
public async Task<ListResultDto<CategoryLookupDto>>
       GetCategoriesAsync()
{
    var categories = await _categoryRepository.GetListAsync();
    return new ListResultDto<CategoryLookupDto>(
        ObjectMapper
        .Map<List<Category>, List<CategoryLookupDto>>                    (categories)
    );
}

在这里,_categoryRepository 是一种 IRepository<Category, Guid> 服务类型。你就像之前对 _productRepository 所做的那样注入它。我认为方法实现相当简单,不需要额外的解释。

我们在两个地方使用了对象映射,现在我们必须定义映射配置。打开 ProductManagement.Application 项目中的 ProductManagementApplicationAutoMapperProfile.cs 文件,并添加以下代码:

CreateMap<CreateUpdateProductDto, Product>();
CreateMap<Category, CategoryLookupDto>();

此代码设置了对象映射的 AutoMapper 配置。

自动化测试

我不会在本章中展示更多的自动化测试;然而,我已经将它们添加到解决方案中。你可以检查 GitHub 仓库中的源代码。

现在,我们可以去 UI 层调用这些方法。

UI

ProductManagement.Web 项目的 Pages/Products 文件夹下创建一个新的 CreateProductModal.cshtml Razor 页面。打开 CreateProductModal.cshtml.cs 文件,并使用以下代码更改 CreateProductModalModel 类:

using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using ProductManagement.Products;
namespace ProductManagement.Web.Pages.Products
{
    Public class CreateProductModalModel:
        ProductManagementPageModel
    {
        [BindProperty]
        public CreateEditProductViewModel Product { get;                 set; }
        public SelectListItem[] Categories { get; set; }
        private readonly IProductAppService                             _productAppService;
        public CreateProductModalModel(
            IProductAppService productAppService)
        {
            _productAppService = productAppService;
        }
        public async Task OnGetAsync()
        {
            // TODO
        }
        public async Task<IActionResult> OnPostAsync()
        {
            // TODO
        }
    }
}

在这里,ProductManagementPageModel 是在启动模板中定义的一个基类。你可以继承它来创建 PageModel 类。Categories 将用于在下拉列表中显示类别列表。[BindProperty] 是一个标准的 ASP.NET Core 属性,用于将请求数据绑定到 HTTP Post 请求上的 Product 属性。我们注入了 IProductAppService 接口来使用之前定义的方法。

我们已经使用了 CreateEditProductViewModel,因此需要定义它。在 CreateProductModal.cshtml 文件所在的同一文件夹中定义它:

using ProductManagement.Products;
using System;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Form;
namespace ProductManagement.Web.Pages.Products
{
    public class CreateEditProductViewModel
    {
        [SelectItems("Categories")]
        [DisplayName("Category")]
        public Guid CategoryId { get; set; }
        [Required]
        [StringLength(ProductConsts.MaxNameLength)]
        public string Name { get; set; }
        public float Price { get; set; }
        public bool IsFreeCargo { get; set; }
        [DataType(DataType.Date)]
        public DateTime ReleaseDate { get; set; }
        public ProductStockState StockState { get; set; }
    }
}

SelectItems 告诉我们 CategoryId 属性将从 Categories 列表中选择。我们将在这个编辑模态对话框中重用这个类。这就是为什么我将其命名为 CreateEditProductViewModel

DTO 与 ViewModel 的比较

由于它与 DTO (CreateUpdateProductDto) 非常相似,因此定义视图模型 (CreateEditProductViewModel) 可能看起来是不必要的。然而,它只是多了几个属性。这些属性可以轻松地添加到 DTO 中,并且我们可以在视图端重用 DTO。这取决于你的设计决策,你可以这样做。然而,考虑到这些类具有不同的目的,并且随着时间的推移会向不同的方向发展,我认为将每个关注点分开是更好的实践。例如,[SelectItems("Categories")] 属性指的是 Razor 页面模型,在应用层没有意义。

现在,我们可以在 CreateProductModalModel 类中实现 OnGetAsync 方法:

public async Task OnGetAsync()
{
    Product = new CreateEditProductViewModel
    {
        ReleaseDate = Clock.Now,
        StockState = ProductStockState.PreOrder
    };

    var categoryLookup =
        await _productAppService.GetCategoriesAsync();
    Categories = categoryLookup.Items
        .Select(x => new SelectListItem(x.Name,                         x.Id.ToString()))
                .ToArray();
}

我们使用默认值创建 Product 类,然后使用产品应用服务填充 Categories 列表。Clock 是 ABP 框架提供的一个服务,用于获取当前时间,无需处理时区以及本地/UTC 时间。我们用它代替 DateTime.Now。这将在 第八章使用 ABP 的功能和服务 中解释。

我们可以像下面这样实现 OnPostAsync

public async Task<IActionResult> OnPostAsync()
{
    await _productAppService.CreateAsync(
        ObjectMapper
            .Map<CreateEditProductViewModel,CreateUpdateProductDto>                   (Product)
    );
    return NoContent();
}

由于我们将 CreateEditProductViewModel 映射到 CreateProductDto,我们需要定义映射配置。在 ProductManagement.Web 项目中打开 ProductManagementWebAutoMapperProfile 类,并使用以下代码块更改内容:

public class ProductManagementWebAutoMapperProfile : Profile
{
    public ProductManagementWebAutoMapperProfile()
    {
        CreateMap<CreateEditProductViewModel,                                   CreateUpdateProductDto>();
    }
}

此类定义了 AutoMapper 库的对象映射。

我们已经完成了产品创建 UI 的 C# 代码部分。

现在,我们可以开始构建 UI 标记和 JavaScript 代码。为此,打开 CreateProductModal.cshtml 文件,并按如下方式更改内容:

@page
@using Microsoft.AspNetCore.Mvc.Localization
@using ProductManagement.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@model ProductManagement.Web.Pages.Products.CreateProductModalModel
@inject IHtmlLocalizer<ProductManagementResource> L
@{
    Layout = null;
}
<abp-dynamic-form abp-model="Product"
                  asp-page="/Products/CreateProductModal">
    <abp-modal>
        <abp-modal-header title="@L["NewProduct"].Value"></abp-                  modal-header>
        <abp-modal-body>
            <abp-form-content />
        </abp-modal-body>
        <abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer>
    </abp-modal>
</abp-dynamic-form>

在这里,abp-dynamic-form 会根据 C# 模型类自动创建表单元素。abp-form-content 是表单元素渲染的地方。abp-modal 用于创建模态对话框。

您可以使用标准的 Bootstrap HTML 元素和 ASP.NET Core 的绑定来创建表单元素。然而,ABP 的 Bootstrap 和动态表单标签助手大大简化了 UI 代码。我们将在 第十二章使用 MVC/Razor Pages 中介绍 ABP 标签助手。

我们已经完成了产品创建模态代码。现在,我们将在 Pages/Products 文件夹中添加一个 Index.cshtml 文件,并按如下方式更改 abp-card-header 部分:

<abp-card-header>
    <abp-row>
        <abp-column size-md="_6">
            <abp-card-title>@L["Menu:Products"]</abp-card-                  title>
        </abp-column>
        <abp-column size-md="_6" class="text-end">
            <abp-button id="NewProductButton"
                        text="@L["NewProduct"].Value"
                        icon="plus"
                        button-type="Primary"/>
        </abp-column>
    </abp-row>
</abp-card-header>

我添加了 2 列,每列都有一个 size-md="_6" 属性(即 12 列 Bootstrap 网格的一半)。然后,我在左侧保留卡片标题的同时,在右侧放置了一个按钮。

在此之后,我在 Index.cshtml.js 文件的末尾添加了以下代码(在最后的 }); 代码部分之前):

var createModal = new abp.ModalManager(abp.appPath +                     'Products/CreateProductModal');
createModal.onResult(function () {
    dataTable.ajax.reload();
});
$('#NewProductButton').click(function (e) {
    e.preventDefault();
    createModal.open();
});

abp.ModalManager 用于在客户端管理模态对话框。内部,它使用 Twitter Bootstrap 的标准模态组件,但通过提供简单的 API 抽象了许多细节。createModal.onResult() 是当模态框保存时被调用的回调。createModal.open(); 用于打开模态对话框。

最后,我们需要在 ProductManagement.Domain.Shared 项目的 Localization/ProductManagement 文件夹中的 en.json 文件中定义一些本地化文本:

"NewProduct": "New Product",
"Category": "Category",
"IsFreeCargo": "Free Cargo",
"ReleaseDate": "Release Date"

您可以再次运行 Web 应用程序并尝试创建一个新的产品:

图 3.13 – 新产品模态框

图 3.13 – 新产品模态框

ABP 已经根据 C# 类模型自动创建了表单字段。本地化和验证也会通过读取属性和使用约定自动完成。尝试留空名称字段并保存模态框,以查看验证错误消息的示例。我们将在 第十二章使用 MVC/Razor Pages 中更详细地介绍验证和本地化。

现在,我们可以在 UI 上创建产品。现在,让我们看看如何编辑产品。

编辑产品

编辑产品与添加新产品类似。这次,我们需要获取要编辑的产品并准备编辑表单。

应用程序服务合约

让我们从为 IProductAppService 接口定义两个新方法开始:

Task<ProductDto> GetAsync(Guid id);
Task UpdateAsync(Guid id, CreateUpdateProductDto input);

第一个方法将用于通过 ID 获取产品数据。我们在 UpdateAsync 方法中重用了之前定义的 CreateUpdateProductDto

我们还没有引入新的 DTO,因此我们可以直接进入实现。

应用服务实现

实现这些新方法相当简单。将以下方法添加到 ProductAppService 类中:

public async Task<ProductDto> GetAsync(Guid id)
{
    return ObjectMapper.Map<Product, ProductDto>(
        await _productRepository.GetAsync(id)
    );
}
public async Task UpdateAsync(Guid id, CreateUpdateProductDto input)
{
    var product = await _productRepository.GetAsync(id);
    ObjectMapper.Map(input, product);
}

GetAsync 方法使用 productRepository.GetAsync 从数据库中获取产品,并将其映射到 ProductDto 对象后返回。UpdateAsync 方法获取产品并将给定的输入属性映射到产品的属性。这样,我们就用新值覆盖了产品属性。

对于这个示例,我们不需要调用 _productRepository.UpdateAsync,因为 EF Core 有一个更改跟踪系统。ABP 的 工作单元 系统在请求结束时自动保存更改(如果没有抛出异常)。我们将在 第六章与数据访问基础设施一起工作 中介绍工作单元系统。

应用层现在已完成。在下一节中,我们将创建产品编辑用户界面。

用户界面

ProductManagement.Web 项目的 Pages/Products 文件夹下创建一个新的 EditProductModal.cshtml Razor 页面。打开 EditProductModal.cshtml.cs,并使用以下代码更改内容:

using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using ProductManagement.Products;
namespace ProductManagement.Web.Pages.Products
{
    public class EditProductModalModel :                             ProductManagementPageModel
    {
        [HiddenInput]
        [BindProperty(SupportsGet = true)]
        public Guid Id { get; set; }
        [BindProperty]
        public CreateEditProductViewModel Product { get; set; }
        public SelectListItem[] Categories { get; set; }
        private readonly IProductAppService _productAppService;
        public EditProductModalModel(IProductAppService                         productAppService)
        {
            _productAppService = productAppService;
        }
        public async Task OnGetAsync()
        {
            // TODO
        }
        public async Task<IActionResult> OnPostAsync()
        {
            // TODO
        }
    }
}

Id 属性将在表单中作为一个隐藏字段。它也应该支持 HTTP GET 请求,因为 GET 请求打开这个模态,我们需要产品的 ID 来准备编辑表单。ProductCategories 属性与创建模态类似。我们还在构造函数中注入了 IProductAppService 接口。

我们可以像以下代码块所示实现 OnGetAsync 方法:

public async Task OnGetAsync()
{
    var productDto = await _productAppService.GetAsync(Id);
    Product = ObjectMapper.Map<ProductDto,                                   CreateEditProductViewModel>(productDto);

    var categoryLookup = await                                               _productAppService.GetCategoriesAsync();
    Categories = categoryLookup.Items
        .Select(x => new SelectListItem(x.Name, x.Id.ToString()))
        .ToArray();
}

首先,我们正在获取要编辑的产品(ProductDto)。我们将其转换为 CreateEditProductViewModel,然后在 UI 中用于创建编辑表单。然后,我们获取表单上要选择的类别,就像我们之前为创建表单所做的那样。

我们已经将 ProductDto 映射到 CreateEditProductViewModel,因此现在我们需要在 ProductManagementWebAutoMapperProfile 类(在 ProductManagement.Web 项目中)中定义映射配置,就像我们之前做的那样:

CreateMap<ProductDto, CreateEditProductViewModel>();

OnPostAsync 方法很简单;我们通过将 CreateEditProductViewModel 转换为 CreateUpdateProductDto 来调用 UpdateAsync 方法:

public async Task<IActionResult> OnPostAsync()
{
    await _productAppService.UpdateAsync(Id,
        ObjectMapper.Map<CreateEditProductViewModel,                             CreateUpdateProductDto>(Product)
    );
    return NoContent();
}

现在,我们可以切换到 EditProductModal.cshtml,并按照以下方式更改其内容:

@page
@using Microsoft.AspNetCore.Mvc.Localization
@using ProductManagement.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@model ProductManagement.Web.Pages.Products.EditProductModalModel
@inject IHtmlLocalizer<ProductManagementResource> L
@{
    Layout = null;
}
<abp-dynamic-form abp-model="Product"
                  asp-page="/Products/EditProductModal">
    <abp-modal>
        <abp-modal-header title="@Model.Product.Name"></abp-modal-              header>
        <abp-modal-body>
            <abp-input asp-for="Id" />
            <abp-form-content/>
        </abp-modal-body>
        <abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer>
    </abp-modal>
</abp-dynamic-form>

这个页面与 CreateProductModal.cshtml 非常相似。我只是向表单中添加了一个 Id 字段(作为一个隐藏输入),用于存储正在编辑的产品 Id 属性。

最后,我们可以添加一个 Index.cshtml.js 文件,并在 dataTable 初始化代码上方添加一个新的 ModalManager 对象:

var editModal = new abp.ModalManager(abp.appPath + 'Products/EditProductModal');

然后,在 dataTable 初始化代码中的 columnDefs 数组的第一项添加一个新的列定义:

{
    title: l('Actions'),
    rowAction: {
        items:
            [
                {
                    text: l('Edit'),
                    action: function (data) {
                        editModal.open({ id: data.record.id });
                    }
                }
            ]
    }
},

这段代码添加了一个新的 rowAction,这是 ABP 框架提供的一个特殊选项。它用于在表格的某一行添加一个或多个操作。

最后,在 dataTable 初始化代码之后添加以下代码:

editModal.onResult(function () {
    dataTable.ajax.reload();
});

此代码在保存产品编辑对话框后刷新数据表,这样我们可以在表格上看到最新的数据。最终的 UI 看起来类似于 图 3.14

图 3.14 – 编辑现有产品

图 3.14 – 编辑现有产品

我们现在可以看到产品,创建新产品,以及编辑现有产品。最后一节将添加一个新操作来删除现有产品。

删除产品

与创建或编辑操作相比,删除产品操作相当简单,因为在这种情况下,我们不需要构建表单。首先,向 IProductAppService 接口添加一个新方法:

Task DeleteAsync(Guid id);

然后,在 ProductAppService 类中实现它:

public async Task DeleteAsync(Guid id)
{
    await _productRepository.DeleteAsync(id);
}

我们现在可以向产品数据表添加一个新操作。打开 Index.cshtml.js,并在 rowAction.items 数组之后添加以下定义):

{
    text: l('Delete'),
    confirmMessage: function (data) {
        return l('ProductDeletionConfirmationMessage',                           data.record.name);
    },
    action: function (data) {
        productManagement.products.product
            .delete(data.record.id)
            .then(function() {
                abp.notify.info(l('SuccessfullyDeleted'));
                dataTable.ajax.reload();
            });
    }
}

在这里,confirmMessage 是一个在执行操作之前从用户那里获取确认的函数。productManagement.products.product.delete 函数是由 ABP 框架动态创建的,如前所述。这样,你可以在 JavaScript 代码中直接调用服务器端方法。我们只是传递当前记录的 ID。它返回一个承诺,这样我们就可以将回调注册到 then 函数。最后,我们使用 abp.notify.info 向用户发送通知,然后刷新数据表。

我们使用了一些本地化文本,因此需要将以下行添加到本地化文件中(位于 ProductManagement.Domain.Shared 项目的 Localization/ProductManagement 文件夹中的 en.json 文件):

"ProductDeletionConfirmationMessage": "Are you sure to delete this book: {0}",
"SuccessfullyDeleted": "Successfully deleted!"

你可以再次运行 Web 项目以查看结果:

图 3.15 – 删除操作

图 3.15 – 删除操作

由于我们现在有两个操作,编辑按钮自动变成了操作下拉按钮。当你点击删除操作时,你会看到一个确认消息来删除产品:

图 3.16 – 删除确认消息

图 3.16 – 删除确认消息

如果你点击按钮,你将在页面上看到一个通知,数据表将被刷新。

实现产品删除功能相当简单。ABP 内置的功能通过实现常见的模式,如客户端到服务器的通信、确认对话框和 UI 通知,帮助我们完成了这一功能。

注意,Product实体是从FullAuditedAggregateRoot类继承的,这使得它支持软删除。在删除一个产品后检查数据库。你会发现它并没有真正被删除,而是IsDeleted字段被设置为true。将IsDeleted设置为true使得产品实体软删除(即逻辑上删除但物理上未删除)。下次你查询产品时,已删除的产品将自动过滤,不包括在查询结果中。这是通过 ABP 框架的数据过滤系统完成的,将在第六章中介绍,与数据访问基础设施一起工作

摘要

在本章中,我们创建了一个完全工作的 CRUD 页面。我们走过了应用程序的所有层,并看到了基于 ABP 的应用程序开发的根本方法。

你已经接触到了许多不同的概念,例如实体、存储库、数据库映射和迁移、自动化测试、API 控制器、动态 JavaScript 代理、对象到对象映射、软删除等。如果你正在构建一个严肃的软件解决方案,你将使用所有这些,无论是否使用 ABP。ABP 是一个全栈应用程序框架,它帮助你以最佳实践实现这些概念。它提供了必要的基础设施,使你的日常开发更加容易。

你可能现在无法理解所有细节。这不是问题,因为剩余章节的目的是深入探讨这些概念,并展示它们的细节和不同的用例。

这个示例应用程序相对简单。它不包含任何重要的业务逻辑,因为我在其中引入了许多概念,并试图保持应用程序简单,以便专注于这些概念而不是业务复杂性。在这个示例中,我忽略了授权。授权将在第七章中解释,探索横切关注点

在书中展示具有真实世界复杂性的示例应用程序并不容易。然而,我为本书的读者准备了一个具有真实世界质量和复杂性的完整参考应用程序。这个参考应用程序是开源的,可在 GitHub 上找到。此外,它是一个实时应用程序,所以你可以直接尝试它。

下一章将介绍这个参考应用程序,并展示参考解决方案的功能、层和代码结构。剩余章节经常引用该应用程序的源代码。

第四章:第四章:理解参考解决方案

在上一章中,我们构建了一个简单的全栈 Web 应用程序,用于管理具有分类的产品。我们看到了使用 ABP 框架开发应用程序的典型流程。你现在可以创建自己的应用程序,并具备基本功能。在下一章中,你将更好地理解 ABP 功能并创建更高级的应用程序。

在书中用具有现实复杂性的例子来说明并不容易。经过反思,我们准备了一个完整的、现实世界的参考应用程序,该应用程序是用 ABP 框架构建的:EventHub。它是开源的,可以在 GitHub 上免费获取。

EventHub 解决方案被视为一个可用的实时系统,位于openeventhub.com.上。你可以尝试它来探索它。我们已经建立了持续集成/持续开发CI/CD)管道,我们在开发它并获得社区贡献的同时更新网站。请随意查看其源代码,提交错误报告或功能请求,甚至发送你的 pull 请求来贡献!正如其名所示,这是一个开放平台。

本书是唯一解释 EventHub 解决方案的文档来源,因为我们主要为此书的读者准备了它。我将在本书的下一章中提及该解决方案,特别是在第三部分实现领域驱动设计

在本章中,我们将以下节中调查 EventHub 解决方案:

  • 介绍应用程序

  • 理解架构

  • 运行解决方案

技术要求

你可以从 GitHub 上克隆或下载 EventHub 项目的源代码,在github.com/volosoft/eventhub

如果你想在本地开发环境中运行该解决方案,你需要有一个集成开发环境IDE)/编辑器(如 Visual Studio)来构建和运行 ASP.NET Core 解决方案。你还需要在计算机上安装Docker。你可以通过遵循docs.docker.com/get-docker上的文档来下载并安装Docker Desktop用于开发环境。

介绍应用程序

EventHub 是一个用于创建组织以组织活动的平台。你创建活动,无论是线上还是线下,然后人们注册它们。以下截图来自openeventhub.com网站的首页

图 4.1 – EventHub 首页

图 4.1 – EventHub 首页

你可以在首页上的即将到来的事件部分进行探索。点击一个活动以获取详细信息并注册该活动。活动开始前或活动时间更改时,你会收到电子邮件通知。

这里是来自创建新事件页面的另一个截图:

图 4.2 – 创建新事件页面

图 4.2 – 创建新事件页面

你可以在这一页上选择你拥有的一个组织,设置 标题、时间、描述,选择 封面图片,并确定你组织的其他事件细节。

如果你想了解更多信息,请注册 openeventhub.com 并探索该平台。在这本书中,我想讨论技术细节,而不是应用程序的功能。让我们从整体图景开始,了解解决方案的架构。

理解架构

这里是解决方案内部应用程序的整体图:

图 4.3 – EventHub 解决方案的应用

图 4.3 – EventHub 解决方案的应用

图 4.3 中显示了六个应用程序和一个数据库,关于它们更详细的信息如下:

  • IdentityServer 库。这是一个 单点登录SSO) 服务器,这意味着如果你登录到其中一个应用程序,你将登录到所有应用程序(反之亦然,即如果你从其中一个应用程序注销,你将从所有应用程序注销)。这是一个 ASP.NET Core Razor Pages 应用程序,它直接连接到 数据库

  • 主网站:这是用户创建新事件和注册事件的平台的基本网站 (www.openeventhub.com)。它是一个使用 ASP.NET Core Razor Pages 的应用程序,作为后端使用 主 HTTP API

  • 管理应用程序:此应用程序允许 管理员用户 管理组织、事件和系统。它使用 管理 HTTP API 进行所有操作,这是一个在浏览器中运行的 Blazor WebAssembly 应用程序。

  • 主 HTTP API:暴露 超文本传输协议HTTP) 应用程序编程接口 (API) 以供主网站消费。

  • 管理 HTTP API:暴露 HTTP API 以供管理应用程序消费。

  • 后台服务:一个运行系统后台工作者和后台作业的控制台应用程序。

  • 数据库:这是一个关系型 PostgreSQL 数据库,存储系统中所有的数据。

由于它是一个分布式系统,它使用 Redis 作为分布式缓存服务器。

首先理解认证流程是一个好主意,然后才能理解系统。

认证流程

如前所述,认证服务器是一个用于认证用户和客户端的单点登录 (SSO) 服务器。主网站管理应用程序 使用 OpenID ConnectOIDC) 协议在用户想要或需要登录应用程序时将用户重定向到 认证服务器。以下图表显示了登录过程:

图 4.4 – 认证流程

图 4.4 – 认证流程

图 4.4 中,逻辑流程按以下顺序发生:

  • 当用户想要访问需要身份验证 (1) 的页面或用户明确点击登录链接时,主网站将用户 (2) 重定向到认证服务器

  • 认证服务器有一个登录页面,用户可以输入用户名和密码或注册为新用户。一旦登录过程完成,用户将带着授权码 (3)(4) 被重定向回主网站

  • 主网站随后使用获取的授权码向服务器发起一个标记请求 (5)

  • 认证服务器返回一个标识符(ID)令牌(包含一些用户信息,如用户名、ID、电子邮件等)和一个访问令牌 (6)

  • 主网站将访问令牌存储在 cookie 中,以便在后续请求中获取。在后续请求中,它从 cookie 中获取访问令牌并将其添加到 HTTP 请求头,在执行对主 HTTP API应用 (7) 的 HTTP 请求时。

  • 主 HTTP API应用验证访问令牌 (8) 并授权请求。

正如之前提到的,主网站使用 cookie 来存储访问令牌。另一方面,管理员(Blazor WebAssembly)应用将访问令牌存储在浏览器的本地存储中,并在每次向服务器发送请求时将其添加到 HTTP 请求头。

所有这些过程都是由 ABP 的AccountIdentityServer模块以及应用中的某些配置完成的。我不会在这里展示详细的配置,以保持本章专注于整体解决方案结构和架构(更多细节请查看源代码)。

在下一节中,我们将探讨 EventHub .NET 解决方案及其内部的项目。

探索解决方案

EventHub .NET 解决方案由几个项目组成,按应用类型分组,如下截图所示:

![Figure 4.5 – EventHub .NET solution in Visual Studio]

![img/Figure_4.05_B17287.jpg]

Figure 4.5 – EventHub .NET solution in Visual Studio

该解决方案包含一个单一领域层、两个应用层以及相应的 HTTP API 和用户界面(UI)层。两个应用使用单一领域层,但它们具有不同的应用逻辑,因此它们是分开的。我们将在第九章处理多个应用部分中回到这个话题(多个应用层),即理解领域驱动设计

让我们从核心部分,即common文件夹开始解释项目。该文件夹包含常见的库和服务,如下所述:

  • EventHub.Domain项目是包含实体、领域服务和其它领域对象的领域层。EventHub.Domain.Shared项目包含常量和一些其他类,这些类在解决方案的所有层和应用中共享。

  • EventHub.EntityFrameworkCore 项目包含定义 DbContext、映射、数据库迁移、存储库实现以及其他与 EF Core 相关的代码。

  • EventHub.DbMigrator 项目是一个控制台应用程序,您可以通过它应用挂起的数据库迁移并初始化数据(例如管理员用户/角色及其权限)。它适合在开发和生产环境中使用。

  • EventHub.BackgroundServices 项目是另一个控制台应用程序,它在系统上运行后台工作者和作业,并且应该始终运行。

www 文件夹包含 主网站www.openeventhub.com)应用程序的组件,如下所示:

  • EventHub.Application 项目是包含应用程序服务实现的应用层,而 EventHub.Application.Contracts 项目包括与应用程序服务接口和与 UI 层共享的 数据传输对象DTOs)。

  • EventHub.HttpApi 项目包含由 UI(Web)层消费的 API 控制器。该项目中的控制器是围绕应用程序服务的简单包装。

  • EventHub.HttpApi.Host 项目托管 HTTP API 层。这样,托管逻辑就与包含 API 控制器的项目(这使得可以将 EventHub.HttpApi 项目作为库重用)分离了。

  • EventHub.HttpApi.Client 项目是一个库,可以由 .NET 应用程序引用以轻松消费 API 控制器。UI(Web)层使用该项目调用 HTTP API。此项目使用 ABP 的动态 C# 代理功能,将在 第十四章构建 HTTP API 和实时服务 中介绍。这样,我们就不需要处理 HTTP 客户端和底层细节,从 UI 层调用 HTTP API。

  • EventHub.Web 项目是应用程序的 UI 层。这是一个典型的 Razor Pages 应用程序,在服务器上渲染 超文本标记语言HTML)。它没有数据库连接,但使用 主 HTTP API 应用程序进行所有操作。

  • EventHub.Web.Theme 项目是应用程序的定制主题。ABP 有一个主题系统,您可以使用它来构建自己的主题并在任何应用程序中重用它们。EventHub.Web 项目使用这个主题。主题系统将在 第四部分用户界面和 API 开发 中介绍。

admin 文件夹包含由维护系统的用户使用的管理员应用程序,并在此处进行更详细的解释:

  • EventHub.Admin.Application 项目是管理员侧的应用层,包含应用程序服务的实现,而 EventHub.Admin.Application.Contracts 项目包括与应用程序服务接口和与 UI 层共享的 DTOs。

  • EventHub.Admin.HttpApi 项目包含由 UI(Web)层消费的 API 控制器。

  • EventHub.Admin.HttpApi.Host 项目托管 HTTP API 层。这样,托管逻辑就与包含 API 控制器的项目分离了。

  • EventHub.Admin.HttpApi.Client 项目是一个库,.NET 应用程序可以通过它轻松地引用并消费 API 控制器。UI(Web)层使用该项目来调用 HTTP API。此项目使用了 ABP 的动态 C# 代理功能,该功能将在 第十四章 中介绍,即 构建 HTTP API 和实时服务。通过这种方式,我们不需要处理 HTTP 客户端和底层细节,就可以从 UI 层调用 HTTP API。

  • EventHub.Admin.Web 项目是应用程序的 UI 层。这是一个 Blazor WebAssembly 应用程序,它在浏览器中运行并执行对服务器的 HTTP API 调用。

最后,account 文件夹包含 EventHub.IdentityServer,它被其他应用程序用于用户认证。

我已经简要地解释了解决方案中的所有项目。了解项目之间的关系和依赖也很重要。

项目依赖关系

将解决方案拆分为多个项目,使得在运行时可以拥有多个应用程序,同时在必要时在应用程序之间共享代码库。

在接下来的几节中,我将展示每个应用程序的依赖关系图,以便您了解代码库是如何组织的。我们首先从 主网站,这个基本的应用程序开始。

主网站

记住 Web

图 4.6 – 主网站项目依赖关系

图 4.6 – 主网站项目依赖关系

Web 项目依赖于 Web.Theme,该组件实现了 EventHub 应用程序的 UI 主题。Web.Theme 是一个独立的项目,因为它是从 认证服务器 应用程序中复用的。这是一个在多个应用程序之间复用 UI 主题的例子。

Web项目也依赖于HttpApi项目。这样,HTTP API 控制器就可以在 Web 应用程序中使用,我们可以从客户端(JavaScript)代码中调用这些 API。然而,当你调用此应用程序的 HTTP API 控制器时,请求会被重定向到HttpApi.Client包。请注意,HttpApiHttpApi.Client项目都引用了Application.Contacts项目。HttpApi项目中的 API 控制器使用应用程序服务接口,而HttpApi.Client包实现这些接口(使用 ABP 的动态 C#代理系统,将在第十四章构建 HTTP API 和实时服务)以执行对主 HTTP API应用程序的远程 HTTP 调用。因此,此应用程序成为客户端(JavaScript)和 HTTP API 服务器之间直接 API 调用的代理。应用程序服务接口的实际实现运行在主 HTTP API应用程序中,将在下一节中解释。

主 HTTP API

HttpApi.Host项目和其直接及间接依赖:

图 4.7 – 主 HTTP API 项目依赖关系

图片

图 4.7 – 主 HTTP API 项目依赖关系

通过引用(添加对HttpApi项目的依赖)HttpApi项目(其中包含 API 控制器),我们可以响应 HTTP API 调用。API 控制器使用在Application.Contracts项目中定义的应用程序服务接口。这些接口由Application项目实现。这就是为什么我们需要从HttpApi.Host项目引用Application项目的原因。Application项目使用Domain项目来执行应用程序的业务逻辑。

HttpApi.Host项目还引用了EntityFrameworkCore项目,因为我们需要在运行时有一个数据层。EntityFrameworkCore项目将实体映射到数据库中的表,并实现了在Domain项目中定义的存储库。

注意到Application.Contracts项目(以及间接的Domain.Shared项目)被客户端应用程序和主网站共享,因此它们可以依赖于相同的应用程序服务接口进行通信。

我们现在已经探讨了主网站应用程序组件。下一节将从管理端继续。

管理应用程序

管理应用程序是一个在浏览器上运行的 Blazor WebAssembly 应用程序,可以通过以下统一资源定位符URL)访问:admin.openeventhub.com。它被维护系统的用户使用。此应用程序有一套不同的 API、UI 页面、授权规则、缓存需求等。因此,我们为该应用程序创建了一个不同的应用程序和 HTTP API 层。尽管如此,它共享相同的领域层,因此它使用相同的领域逻辑和相同的数据库。

让我们从以下前端(Blazor WebAssembly)应用程序的图开始:

图 4.8 – 管理网站项目依赖关系

图 4.8 – 管理网站项目依赖关系

与之前的图相比,此图较为简单。Admin.Web 项目(Blazor WebAssembly 应用程序)引用了 Admin.HttpApi.Client 项目,因为它需要调用远程 HTTP API。ABP 的动态 C#客户端代理系统(在第第十四章构建 HTTP API 和实时服务中介绍)使得在 Blazor WebAssembly 应用程序中使用应用服务接口来轻松消费服务器上的 Admin HTTP API 成为可能。Admin.HttpApi.Client 项目依赖于 Admin.Application.Contracts 项目(该项目内部依赖于 Domain.Shared 项目),以便能够使用在该项目中定义的应用服务接口。

管理 HTTP API

Admin.HttpApi.Host 项目及其直接和间接依赖:

图 4.9 – Admin HTTP API 项目依赖关系

图 4.9 – 管理 HTTP API 项目依赖关系

EntityFrameworkCore 层的图非常相似,以共享相同的核心领域规则和相同的 数据库。我将在第九章理解领域驱动设计处理多个应用程序部分中回到这个话题。

所有应用程序都使用 认证服务器 应用作为 SSO 服务器,下一节将讨论。

认证服务器

IdentityServer 项目及其依赖关系如以下图所示:

图 4.10 – 认证服务器项目依赖关系

图 4.10 – 认证服务器项目依赖关系

IdentityServer 项目引用了 Web.Theme 项目,这是与 EntityFrameworkCore 项目共享的 UI 主题,以便能够使用 EntityFrameworkCore 项目,我们也有对 DomainDomain.Shared 项目的间接引用。

下一节将展示解决方案中最终应用的依赖关系。

后台服务

BackgroundServices 项目具有以下图所示的依赖关系:

图 4.11 – BackgroundServices 项目依赖关系

图 4.11 – BackgroundServices 项目依赖关系

BackgroundServices 项目使用 EntityFrameworkCore 项目,以便能够与 数据库 一起工作。它还可以使用 领域 对象(实体、领域服务)来执行后台任务。

我们已经探讨了解决方案中的所有项目。现在,我们准备在本地开发环境中运行它们。

运行解决方案

如果您想在本地环境中运行解决方案,请遵循下一节中的步骤。

克隆 GitHub 仓库

首先,您需要在本地计算机上克隆 GitHub 仓库。仓库位于github.com/volosoft/eventhub,可以使用以下命令克隆(需要安装 Git 工具):

git clone https://github.com/volosoft/eventhub.git

或者,导航到github.com/volosoft/eventhub,点击Code按钮,然后点击Download ZIP,如图所示:

图 4.12 – 从 GitHub 下载 EventHub 仓库

图 4.12 – 从 GitHub 下载 EventHub 仓库

您应该将 ZIP 文件提取到一个空文件夹中。

运行基础设施

EventHub 解决方案需要在etc/docker文件夹中包含docker-compose文件。如果您在计算机上安装了 Docker,您可以在该文件夹中执行up.ps1文件来运行这些服务器。如果您无法在计算机上使用 PowerShell,您可以直接在文本编辑器中打开它,复制内容,然后在etc/docker目录中的命令行终端中执行它。在第一次运行时,可能需要几分钟来下载 Docker 镜像。如果您不想使用 Docker,您需要在计算机上安装RedisPostgreSQL服务器。

打开解决方案

克隆或下载的仓库在根目录中包含一个EventHub.sln文件。如果您想开发或调试解决方案,请在 Visual Studio 或另一个.NET 兼容的 IDE 中打开它。

创建数据库

解决方案中有一个名为EventHub.DbMigrator的控制台应用程序,如图 4.5 所示。运行此应用程序(对于 Visual Studio,右键单击它并选择设置为启动项目,然后按Ctrl + F5)。它将创建一个数据库并初始化一些初始数据。

运行应用程序

我们现在已准备好启动实际的应用程序。您可以按以下顺序运行项目(对于 Visual Studio,右键单击每个项目,选择设置为启动项目,然后按Ctrl + F5):

  • EventHub.IdentityServer

  • EventHub.HttpApi.Host

  • EventHub.Web

  • EventHub.Admin.HttpApi.Host

  • EventHub.Admin.Web

  • EventHub.BackgroundServices

要登录到应用程序之一,请使用admin作为用户名,1q2w3E*作为密码。当然,您可以在 UI 中创建更多用户。

注意,当运行多个应用程序时,Visual Studio 可能会出现一些问题。有时,之前运行的应用程序可能会停止。在这种情况下,请重新运行已停止的应用程序。然而,Microsoft 的Tye项目使得运行多个应用程序变得更加容易。

使用 Tye 项目

如果您不需要开发或调试解决方案,只想运行它,可以使用 Microsoft 的Tye项目来运行它,而无需打开 IDE。Tye是一个.NET 全局工具,用于通过简单的配置文件轻松运行此类分布式应用程序。EventHub 解决方案已配置为使用Tye运行。您需要做的只是安装Tye并运行它。

在使用 Tye 之前,您仍然需要运行基础设施(请参阅 运行基础设施 部分),然后使用 EventHub.DbMigrator 应用程序创建数据库。如果您之前没有这样做,请在 src/EventHub.DbMigrator 目录中打开命令行终端并运行以下命令:

dotnet run

数据库准备就绪后,您可以在命令行终端中使用以下命令安装 Tye

dotnet tool install -g Microsoft.Tye

在撰写本书时,Tye 项目仍在预览阶段。您可能需要指定最新的预览版本(您可以在 NuGet 上找到它,在 www.nuget.org/packages/Microsoft.Tye)。例如,请参阅以下代码片段:

dotnet tool install -g Microsoft.Tye --version "0.10.0-alpha.21420.1"

查看以下链接了解如何安装 Tyegithub.com/dotnet/tye/blob/main/docs/getting_started.md

Tye 需要在您的计算机上安装 Docker。如果您还没有安装,也需要这样做。安装完成后,您可以使用以下命令启动应用程序(如果 IDE 已经打开,建议先关闭它):

tye run

第一次运行可能需要一些时间。一旦完成,您就可以打开浏览器并导航到 http://127.0.0.1:8000 来打开 Tye 仪表板,您可以在以下屏幕截图中看到:

图 4.13 – Tye 仪表板

图 4.13 – Tye 仪表板

web 是系统的 主网站

当您有一个需要一起运行多个应用程序的解决方案时,Tye 是一个方便的工具。您还可以为项目配置 dotnet watch,以便在更改项目时自动重新加载(或使用 .NET 6.0 热加载)。有关更多信息,请参阅 Microsoft 的文档:github.com/dotnet/tye/tree/main/docs

摘要

EventHub 是基于 ABP 框架构建的完整、真实世界质量的应用程序。它不仅是一个示例,也是一个在 openeventhub.com 上发布的实时项目,并在 github.com/volosoft/eventhub 上积极开发。请随时发送错误报告、功能请求和拉取请求。

在本章中,我的目的不是详细解释代码库。我解释了解决方案的整体架构和结构,以便您了解如何探索代码库并运行解决方案。下一章将参考该解决方案,同时介绍一些 ABP 功能和概念。

EventHub 是一个使用多个应用程序构建的系统的良好示例。它也是一个理解 ABP 分层模型目的以及如何在不同的应用程序中重用这些层的清晰示例。

你现在可能无法完全理解 EventHub 解决方案的所有细节,因为我们还没有解释模块系统、数据库集成、动态 C# 客户端代理以及所有其他 ABP 功能。下一部分中的章节将探讨 ABP 框架和 ASP.NET Core 框架的基本构建块,以便你开始理解所有细节。

在下一章中,我们将探讨 ASP.NET Core 和 ABP 框架的基本构建块,以了解应用程序是如何配置和初始化的。

第二部分:ABP 框架基础

在本部分,你将了解 ABP 框架提供的用于实现常见软件开发需求的基础设施。你将看到 ABP 如何通过使用约定来自动化常见任务,帮助你实现DRY(不要重复自己)原则,从而节省你的时间。

在本部分,我们包括以下章节:

  • 第五章, 探索 ASP.NET 和 ABP 基础设施

  • 第六章, 与数据访问基础设施一起工作

  • 第七章, 探索横切关注点

  • 第八章, 使用 ABP 的功能和服务

第五章:第五章: 探索 ASP.NET Core 和 ABP 基础设施

ASP.NET Core 和 ABP 框架都为现代应用程序开发提供了许多构建块和功能。本章将探索最基本的构建块,以便你了解应用程序是如何配置和初始化的。

我们将从 ASP.NET Core 的 Startup 类开始,了解为什么我们需要模块化系统以及 ABP 如何提供一种模块化方式来配置和初始化应用程序。然后我们将探索 ASP.NET Core 的依赖注入系统以及 ABP 使用预定义规则自动注册依赖注入的方式。我们将继续探讨配置和选项模式,以了解 ASP.NET Core 配置 ASP.NET Core 和其他库选项的方式。

在本章中,我们将涵盖所有以下主题:

  • 理解模块化

  • 使用依赖注入系统

  • 配置应用程序

  • 实现选项模式

  • 记录日志

技术要求

如果你想要跟踪和尝试示例,你需要安装一个 IDE/编辑器(如 Visual Studio)来构建 ASP.NET Core 项目。

你可以从以下 GitHub 仓库下载代码示例:github.com/PacktPublishing/Mastering-ABP-Framework

理解模块化

模块化是将大型软件的功能分解成更小部分的设计技术,并允许每个部分通过标准化的接口按需与其他部分进行通信。模块化有以下主要优点:

  • 当每个模块都设计为与其他模块隔离,并且模块间通信定义良好且有限时,它降低了复杂性。

  • 当你设计模块以实现松散耦合时,它提供了灵活性。你可以在未来重构或甚至替换一个模块。

  • 当你设计模块以实现与应用程序无关时,它允许跨应用程序重用模块。

大多数企业级软件系统都是模块化的。然而,实现模块化并不容易,纯 ASP.NET Core 并没有提供太多帮助。ABP 框架的主要目标之一是提供基础设施和工具来开发真正的模块化系统。我们将在 第十五章 与模块化一起工作 中介绍模块化应用程序开发,但本节介绍了 ABP 模块的基本知识。

Startup

在定义模块类之前,最好记住 ASP.NET Core 中的 Startup 类,以了解模块类的需求。以下代码块展示了简单 ASP.NET Core 应用程序中的 Startup 类:

public class Startup
{
    public void ConfigureServices(IServiceCollection                 services)
    {
        services.AddMvc();
        services.AddTransient<MyService>();
    }
    public void Configure(
        IApplicationBuilder app, IWebHostEnvironment env)
    {
        app.UseRouting();
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
}

ConfigureServices 方法用于配置其他服务并将新服务注册到依赖注入系统中。另一方面,Configure 方法用于配置 ASP.NET Core 请求管道,该管道通过中间件组件处理 HTTP 请求。

一旦你有了Startup类,通常在配置主机构建器时将其注册到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>();
            });
}

这些代码部分已经包含在 ASP.NET Core 的启动模板中,所以你通常不需要手动编写它们。

Startup类的问题在于它是唯一的。这意味着你只有一个配置和初始化所有应用程序服务的点。然而,在模块化应用程序中,你期望每个模块配置和初始化与该特定模块相关的服务。此外,一个模块使用或依赖于其他模块是典型的,因此模块应该按正确的顺序配置和初始化。这就是 ABP 的模块定义类发挥作用的地方。

定义模块类

ABP 模块是一组(如类或接口)一起开发和发布的类型。它是一个由AbpModule派生的模块类组成的组件(在 Visual Studio 中是一个项目)。模块类负责配置和初始化该模块,并在必要时配置任何依赖模块。

这里是一个简单的短信发送模块的模块定义类:

using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.Modularity;
namespace SmsSending
{
    public class SmsSendingModule : AbpModule 
    {
        public override void ConfigureServices(
            ServiceConfigurationContext context)
        {
            context.Services.AddTransient<SmsService>();
        }
    }
}

每个模块都可以覆盖ConfigureServices方法,以便将其服务注册到依赖注入系统中并配置其他模块。在这个例子中,该模块将SmsService注册到具有瞬态生命周期的依赖注入系统中。我编写这个例子是为了展示与上一节中Startup类中相同的注册代码。然而,大多数时候,你不需要手动注册你的服务,这得益于本章中使用依赖注入系统部分解释的 ABP 框架的约定注册系统。

AbpModule类定义了在服务注册阶段完成后、应用程序准备运行时执行的OnApplicationInitialization方法。使用此方法,你可以在应用程序启动时执行任何需要的操作。例如,你可以初始化一个服务:

public class SmsSendingModule : AbpModule 
{
    //...
    public override void OnApplicationInitialization(
        ApplicationInitializationContext context)
    {
        var service = context.ServiceProvider
            .GetRequiredService<SmsService>();
        service.Initialize();
    }
}

在这个代码块中,我们使用context.ServiceProvider从依赖注入系统中请求一个服务并初始化该服务。我们可以在此时请求服务,因为依赖注入系统已经准备好了。

你也可以将OnApplicationInitialization方法视为Startup类的Configure方法。因此,你可以在这里构建 ASP.NET Core 请求管道。然而,通常你会在启动模块中配置请求管道,如下一节所述。

模块依赖和启动模块

一个业务应用程序通常由多个模块组成,ABP 框架允许你声明模块之间的依赖关系。应用程序应该始终有一个启动模块。启动模块可以依赖于某些模块,而这些模块可以依赖于其他模块,依此类推。

以下图显示了简单的模块依赖关系图:

图 5.1 – 示例模块依赖关系图

图 5.1 – 示例模块依赖关系图

ABP 尊重模块依赖关系,并根据依赖关系图初始化模块。如果模块 A 依赖于模块 B,则模块 B 总是在模块 A 之前初始化。这允许模块 A 使用、设置、更改或覆盖由模块 B 定义的配置和服务。

对于图 5.1中的示例图,模块初始化的顺序将是:G、F、E、D、B、C、A。你不必知道确切的初始化顺序;只需知道如果你的模块依赖于模块X,则模块X将在你的模块之前初始化。

使用模块的[DependsOn]属性声明模块依赖关系:

[DependsOn(typeof(ModuleB), typeof(ModuleC))]
public class ModuleA : AbpModule
{    
}

在前面的代码块中,ModuleA通过声明[DependsOn]属性依赖于ModuleBModuleC

对于 ASP.NET Core 应用程序,启动模块(本例中的ModuleA)负责设置 ASP.NET Core 请求管道:

[DependsOn(typeof(ModuleB), typeof(ModuleC))]
public class ModuleA : AbpModule
{
    //...
    public override void OnApplicationInitialization(
        ApplicationInitializationContext context)
    {
        var app = context.GetApplicationBuilder();
        var env = context.GetEnvironment();

        app.UseRouting();
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
}

通过此代码块,我们构建了与之前在启动类部分中构建的相同的 ASP.NET Core 请求管道。context.GetApplicationBuilder()context.GetEnvironment()只是从依赖注入系统中获取标准IApplicationBuilderIWebHostEnvironment服务的快捷方式。

然后,我们可以在 ASP.NET Core 的Startup类中使用此模块来集成 ABP 框架与 ASP.NET Core:

public class Startup
{
    public void ConfigureServices(IServiceCollection                 services)
    {
        services.AddApplication<ModuleA>();
    }
    public void Configure(IApplicationBuilder app)
    {
        app.InitializeApplication();
    }
}

services.AddApplication()方法由 ABP 框架定义,用于配置模块。它基本上按照模块依赖关系的顺序执行所有模块的ConfigureServices方法。app.InitializeApplication()方法也是由 ABP 框架定义的;同样,它按照模块依赖关系的顺序执行所有模块的OnApplicationInitialization方法。

ConfigureServicesOnApplicationInitialization方法是模块类中最常用的方法;下文将解释更多方法。

模块生命周期方法

AbpModule类定义了一些有用的方法,你可以覆盖这些方法以在应用程序启动和关闭时执行代码。我们在前文看到了ConfigureServicesOnApplicationInitialization;以下是所有生命周期方法的列表:

  • PreConfigureServices:此方法在ConfigureServices方法之前被调用。它允许你编写在依赖模块的ConfigureServices之前执行的代码。

  • ConfigureServices:这是配置模块和注册服务的主要方法,如前文所述。

  • PostConfigureServices:此方法在所有模块(包括依赖于你的模块的模块)的ConfigureServices方法之后被调用,因此你可以执行最终配置。

  • OnPreApplicationInitialization:此方法在 OnApplicationInitialization 方法之前被调用。在这个阶段,您可以从依赖注入中解析服务。

  • OnApplicationInitialization:此方法允许您的模块配置 ASP.NET Core 请求管道并初始化您的服务,如前所述。

  • OnPostApplicationInitialization:此方法在初始化阶段被调用。

  • OnApplicationShutdown:如果需要,您可以实现您模块的关闭逻辑。

Pre…Post… 方法(如 PreConfigureServicesPostConfigureServices)与原始方法具有相同的目的。它们很少使用,并提供了一种在所有其他模块之前或之后执行某些配置/初始化代码的方式。

异步生命周期方法

本节中解释的生命周期方法是同步的。在撰写本书时,ABP 框架团队正在努力在 ABP 框架 5.1 版本中引入异步生命周期方法。您可以查看 github.com/abpframework/abp/pull/10928 获取详细信息。

如前所述,模块类主要包含与该模块相关的服务的注册和配置代码。在下一节中,我们将看到如何使用 ABP 框架注册服务。

使用依赖注入系统

依赖注入是一种获取类依赖项的技术。它将创建类和使用类分离。

假设我们有一个名为 UserRegistrationService 的类,该类使用 SmsService 发送验证短信,如下面的代码块所示:

public class UserRegistrationService
{
    private readonly SmsService _smsService;
    public UserRegistrationService(SmsService smsService)
    {
        _smsService = smsService;
    }
    public async Task RegisterAsync(
        string username,
        string password,
        string phoneNumber)
    {
        //...save user in the database
        await _smsService.SendAsync(
            phoneNumber,
            "Your verification code: 1234"
        );
    }
}

在这里,SmsService 是通过 SmsService 获取的,在这个例子中,它被用于 RegisterAsync 方法,在将用户保存到数据库后发送验证码。

ASP.NET Core 本地提供依赖注入基础设施,ABP 利用这个基础设施而不是使用第三方依赖注入框架。一旦将所有服务注册到依赖注入系统中,任何服务都可以构造函数注入依赖服务,而无需处理它们的创建(及其依赖项)。

在设计服务时,您应该考虑的最重要的事情是服务的生命周期。

服务生命周期

ASP.NET Core 在服务注册上提供了不同的生命周期选项,因此我们应该为每个服务选择一个生命周期。在 ASP.NET Core 中有三个生命周期:

  • 瞬时的:瞬时的服务每次注入时都会创建。每次请求/注入服务时,都会创建一个新的实例。

  • 作用域的:作用域服务是按作用域创建的。这通常被认为是请求生命周期,因为在 ASP.NET Core 中,每个 HTTP 请求都会创建一个新的作用域。在相同的作用域中,您共享相同的实例,而在不同的作用域中,您将获得不同的实例。

  • 单例:单例服务在应用程序中只有一个实例。所有请求和客户端都使用相同的实例。对象是在第一次请求时创建的。然后,在后续请求中重复使用相同的对象实例。

以下模块注册了两个服务,一个为瞬态,另一个为单例:

public class MyModule : AbpModule
{
    public override void ConfigureServices(
        ServiceConfigurationContext context)
    {
        context.Services.AddTransient<ISmsService,                       SmsService>();
        context.Services.AddSingleton<OtherService>();
    }
}

context.ServicesIServiceCollection 类型,可以使用所有 ASP.NET Core 扩展方法手动注册和配置你的服务。

在第一个示例中,AddTransient<ISmsService, SmsService>(),我已经将 SmsService 类注册为 ISmsService 接口。这样,每次我注入 ISmsService 时,依赖注入系统都会为我创建一个 SmsService 对象。对于第二个示例,AddSingleton<OtherService>(),我已经将 OtherService 注册为单例,并使用类引用。要使用此服务,我应该注入 OtherService 类引用。

作用域依赖和 ASP.NET Core 的依赖注入文档

如前所述,默认情况下,ASP.NET Core 应用程序会为每个 HTTP 请求创建作用域服务。对于非 ASP.NET Core 应用程序,你可能需要自己管理作用域。请参阅 ASP.NET Core 的文档以了解依赖注入系统的所有详细信息:docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection

当你使用 ABP 框架时,由于 ABP 框架的常规和声明性服务注册系统,你不必过多考虑服务注册。

传统的服务注册

在 ASP.NET Core 中,你应该明确地将所有服务注册到 IServiceCollection 中,如前所述。然而,这些注册中的大多数都是重复的代码,并且可以自动化。

ABP 会自动为以下类型注册依赖注入服务:

  • MVC 控制器

  • Razor 页面模型

  • 视图组件

  • Razor 组件

  • SignalR 中心

  • 应用程序服务

  • 领域服务

  • 仓储

所有这些服务都注册为瞬态生命周期。因此,你不需要关心这些类型的服务注册。如果你有其他类类型,你可以使用下一节中解释的依赖接口或 Dependency 属性。

依赖接口

你可以实现 ITransientDependencyIScopedDependencyISingletonDependency 接口来注册你的服务以进行依赖注入。例如,在这个代码块中,我们已经将服务注册为单例,因此在应用程序的生命周期中只创建了一个共享实例:

public class UserPermissionCache : ISingletonDependency
{ }

依赖接口易于使用,并且是大多数情况下的推荐方式,但与 Dependency 属性相比,它们有限。

依赖属性

Dependency 属性提供了以下属性来对依赖注册进行精细控制:

  • Lifetime (enum): 服务的生命周期:SingletonTransientScoped

  • TryRegister (bool): 只有在服务尚未注册时才注册服务

  • ReplaceServices (bool): 如果服务已经注册,则替换之前的注册

这里是使用Dependency属性进行服务注册的一个示例:

using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.DependencyInjection;
namespace UserManagement
{
    [Dependency(ServiceLifetime.Transient, TryRegister =         true)]
    public class UserPermissionCache
    { }
}

在这里,我使用了具有Transient生命周期的[Dependency]属性,并且还使用了TryRegister选项将类注册到依赖注入系统中。

依赖属性与依赖接口

Dependency属性可以与上一节中引入的依赖接口一起使用。如果它定义了Lifetime属性,则Dependency属性比依赖接口具有更高的优先级。

将类注册到依赖注入系统中使其在应用程序中可用。然而,一个类可能被不同类型的类或接口引用注入,这取决于该类公开的服务类型。

暴露服务

当一个类没有实现接口时,它只能通过类引用进行注入。上一节中的UserPermissionCache类是通过直接注入类类型来使用的。然而,为服务实现接口是很常见的。

假设我们有一个用于抽象短信发送的接口:

public interface ISmsService
{
    Task SendAsync(string phoneNumber, string message);
}

这是一个相当简单的接口,它只有一个发送短信的方法。假设你想通过 Azure 实现ISmsService接口:

public class AzureSmsService : ISmsService, ITransientDependency
{
    public async Task SendAsync(string phoneNumber, string message)
    {
        //TODO: ...
    }
}

AzureSmsService类实现了ISmsServiceITransientDependency接口。ITransientDependency接口仅用于注册此服务以进行依赖注入,如前节所述。

你通常想通过注入ISmsService接口来使用AzureSmsService类。ABP 足够智能,能够理解你的目的,并自动将AzureSmsService类注册为ISmsService接口。你可以通过注入ISmsService接口或AzureSmsService类引用来使用AzureSmsService类。通过ISmsService接口注入AzureSmsService类是可能的,因为它遵循命名约定:ISmsService接口是AzureSmsService类的默认接口,因为它以SmsService后缀结尾。

假设我们有一个实现多个接口的类,如下面的代码块所示:

public class PdfExporter: IExporter, IPdfExporter, ICanExport, ITransientDependency
{ }

可以通过注入IPdfExporterIExporter接口或直接使用PdfExporter类引用来使用PdfExporter服务。但是,你不能使用ICanExport接口来注入它,因为PdfExporter的名字不以CanExport结尾。

如果需要更改默认行为,可以使用ExposeServices属性,如下面的代码块所示:

[ExposeServices(typeof(IPdfExporter))]
public class PdfExporter: IExporter, IPdfExporter, ICanExport, ITransientDependency
{ }

现在,你只能通过注入IPdfExporter接口来使用PdfExporter类。

问题:我应该为每个服务定义接口吗?

你可能会问的一个潜在问题是,你是否应该为你的服务定义接口并使用接口进行注入。ABP 不强迫你在这里做任何事情,并且通用的接口最佳实践是适用的:如果你想松散耦合你的服务、有多个服务实现、在单元测试中轻松模拟,或者物理上分离接口和实现(例如,我们在 Application.Contracts 项目中定义应用程序服务接口并在 Application 项目中实现它们,或者我们在领域层中定义仓储接口但在基础设施层中实现它们),等等。

我们已经看到了如何注册和消费服务。一些服务或库有选项,在使用它们之前你可能需要配置它们。接下来的两个部分解释了配置这些服务库提供的选项的标准基础设施和模式。

配置应用程序

ASP.NET Core 的 配置 系统为应用程序提供了读取基于键值配置的便捷方式。它是一个可扩展的系统,可以从各种资源中读取键值对,例如 JSON 设置文件、环境变量、命令行参数和 Azure Key Vault。

ABP 框架与 ASP.NET Core 的配置系统比较

ABP 框架没有为 ASP.NET Core 的配置系统添加特定功能。然而,理解它是正确使用 ASP.NET Core 和 ABP 框架的关键。本书将涵盖基础知识。请参阅 ASP.NET Core 的文档以获取完整参考:docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration

设置配置值

设置配置值的最简单方法是默认使用 appsettings.json 文件。假设我们正在构建一个使用 Azure 发送短信的服务,我们需要以下配置值:

  • Sender:显示给目标用户的发送者号码

  • ConnectionString:你的 Azure 资源连接字符串

我们可以在 appsettings.json 文件的配置部分中定义这些设置:

{
  ...
  "AzureSmsService": {
    "Sender": "+901112223344",
    "ConnectionString": "..."
  }
}

配置部分名称(此处为 AzureSmsService)和键名称是完全任意的。只要你在代码中使用相同的键,你可以设置任何名称。

一旦你在设置文件中设置了值,你就可以轻松地从你的应用程序代码中读取它们。

读取配置值

你可以在需要读取配置值时注入并使用 IConfiguration 服务。例如,我们可以在 AzureSmsService 类中获取 Azure 配置以发送短信:

using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Volo.Abp.DependencyInjection;
namespace SmsSending
{
    public class AzureSmsService : ISmsService,                     ITransientDependency
    {
        private readonly IConfiguration _configuration;
        public AzureSmsService(IConfiguration configuration)
        {
            _configuration = configuration;
        }

        public async Task SendAsync(
            string phoneNumber, string message)
        {
            string sender = _configuration["AzureSmsService:Sender"];
            string ConnectionString = _configuration["AzureSmsService:ConnectionString"];
            //TODO: Use Azure to send the SMS message
        }
    }
}

此类从 IConfiguration 服务获取配置值,并使用 : 符号来访问嵌套部分中的值。在这个例子中,AzureSmsService:Sender 用于获取 AzureSmsService 部分内的 Sender 值。

IConfiguration服务也可以在你的模块的ConfigureServices中使用:

public override void ConfigureServices(
    ServiceConfigurationContext context)
{
    IConfiguration configuration =                                   context.Services.GetConfiguration();
    string sender =                                                 configuration["AzureSmsService:Sender"];
}

这样,你可以在依赖注入注册阶段完成之前访问配置的值。

配置系统是配置和获取应用程序键值样式设置的一个完美方式。然而,如果你正在构建一个可重用的库,选项模式可能是定义库中类型安全选项的更好方式。

实现选项模式

使用选项模式,我们使用一个普通的类(有时称为POCOPlain Old C# Object)来定义一组相关的选项。让我们从如何使用选项模式定义、配置和使用配置开始。

定义选项类

选项类是一个简单的普通 C#类。我们可以为 Azure SMS 服务定义一个选项类,如下面的代码块所示:

public class AzureSmsServiceOptions
{
    public string Sender { get; set; }
    public string ConnectionString { get; set; }
}

在选项类中添加Options后缀是一种约定。一旦你定义了这样的类,任何使用此服务的模块都可以轻松地配置选项。

配置选项

如在ABP 模块部分所述,你可以在你的模块的ConfigureServices方法中配置依赖模块的服务。我们使用IServiceCollection.Configure扩展方法为任何选项类设置值。你可以像以下代码块所示配置AzureSmsServiceOptions

[DependsOn(typeof(SmsSendingModule))]
public class MyStartupModule : AbpModule
{
    public override void ConfigureServices(
        ServiceConfigurationContext context)
    {
        context.Services
            .Configure<AzureSmsServiceOptions>(options =>
        {
            options.Sender = "+901112223344";
            options.ConnectionString = "...";
        });
    }
}

context.Services.Configure方法是一个泛型方法,它将选项类作为泛型参数。它还接受一个委托(一个操作)来设置选项值。在这个例子中,我们通过在指定的 lambda 表达式中设置SenderConnectionString属性来配置了AzureSmsServiceOptions

AbpModule基类提供了一个Configure方法,作为context.Services.Configure方法的快捷方式,因此你可以将代码重写如下:

public override void ConfigureServices(
    ServiceConfigurationContext context)
{
    Configure<AzureSmsServiceOptions>(options =>
    {
        options.Sender = "+901112223344";
        options.ConnectionString = "...";
    });
}

我们刚刚将context.Services.Configure<…>调用替换为Configure<…>快捷方法。

配置选项很简单。现在,我们可以看到如何使用配置的值。

多次配置操作

你可以在应用程序中多次配置相同的选项。相同的实例被发送到所有委托,这样你就可以更改之前配置的值。如果有多个模块配置了相同的值,则最后一个配置的值生效。请记住,模块是按照依赖顺序初始化的。

使用配置的选项值

ASP.NET Core 提供了一个IOptions<T>接口,用于注入选项类以读取配置的值。我们可以重写AzureSmsService类,使用AzureSmsServiceOptions而不是IConfiguration服务,如下面的代码块所示:

public class AzureSmsService : ISmsService, ITransientDependency
{
    private readonly AzureSmsServiceOptions _options;
    public AzureSmsService(IOptions<AzureSmsServiceOptions>         options)
    {
        _options = options.Value;
    }

    public async Task SendAsync(string phoneNumber, string message)
    {
        string sender = _options.Sender;
        string ConnectionString = _options.ConnectionString;
        //TODO...
    }
}

注意,我们正在注入 IOptions<AzureSmsServiceOptions> 并使用其 Value 属性来获取 AzureSmsServiceOptions 实例。IOptions<T> 接口由 Microsoft.Extensions.Options 包定义,是注入选项类的标准方式。它内部执行所有 Configure 方法,并为您提供一个配置好的选项类实例。如果您错误地直接注入 AzureSmsServiceOptions 类,则会得到一个依赖注入异常。因此,始终以 IOptions<AzureSmsServiceOptions> 的形式注入。

我们已经简单地定义、配置并使用了选项。如果我们想使用配置系统来设置使用选项模式定义的选项怎么办?

通过配置设置选项

选项模式允许我们以任何方式设置选项值。这意味着我们可以使用 IConfiguration 服务来读取应用程序配置并设置选项值。以下代码块通过从配置服务获取值来设置 AzureSmsServiceOptions

[DependsOn(typeof(SmsSendingModule))]
public class MyStartupModule : AbpModule
{
    public override void ConfigureServices(
        ServiceConfigurationContext context)
    {
        var configuration =                                            context.Services.GetConfiguration();        
        Configure<AzureSmsServiceOptions>(options =>
        {
            options.Sender =                                                 configuration["AzureSmsService:Sender"];
            options.ConnectionString = configuration["AzureSmsService:ConnectionString"];
        });
    }
}

我们通过 context.Services.GetConfiguration() 获取 IConfiguration 接口,然后使用配置值来设置选项值。

然而,由于这种用法相当常见,有一个快捷方式。我们可以将代码重写如下所示:

public override void ConfigureServices(
    ServiceConfigurationContext context)
{
    var configuration = context.Services.GetConfiguration();    
    Configure<AzureSmsServiceOptions>(
        configuration.GetSection("AzureSmsService"));
}

使用这种方式,Configure 方法获取一个配置部分而不是一个委托操作。它自动通过命名约定将配置键与选项类的属性匹配。如果配置中没有定义 AzureSmsService 部分,此代码不会影响选项。

选项模式为应用程序开发者提供了更多的灵活性;他们可以从 IConfiguration 或他们喜欢的任何其他来源设置这些选项。

小贴士:默认通过配置设置选项

如果您正在构建一个可重用的模块,尽可能从配置中设置选项是一个好习惯。也就是说,您可以将前面的代码写入您的模块中。这样,应用程序开发者可以直接从 appsettings.json 文件中配置他们的模块。

ASP.NET Core 和 ABP 选项

ASP.NET Core 和 ABP 框架在它们的配置选项中大量使用选项模式。

以下示例展示了在 ABP 框架中配置一个选项:

Configure<AbpAuditingOptions>(options =>
{
    options.IgnoredTypes.Add(typeof(ProductDto));
});

AbpAuditingOptions 是由 ABP 框架的审计日志系统定义的。我们正在添加一个类型 ProductDto,在审计日志中将其忽略。

以下示例展示了在 ASP.NET Core 中配置一个选项:

Configure<MvcOptions>(options =>
{
    options.RespectBrowserAcceptHeader = true;
});

MvcOptions 是由 ASP.NET Core 定义的,用于自定义 ASP.NET Core MVC 框架的行为。

选项类中的复杂类型

注意,AbpAuditingOptions.IgnoredTypes 是一个 Type 列表,它不是一个可以在 appsettings.json 文件中定义的简单原始类型。这是选项模式的一个好处之一:您可以定义具有复杂类型或甚至操作回调的属性。

配置系统和选项模式提供了一种方便的方式来配置和自定义正在使用的服务的行为。您可以配置 ASP.NET Core 和 ABP 框架,并为您自己的服务定义配置选项。

下一个部分将解释日志记录,这是您将在应用程序代码中频繁使用的另一个基本系统。

日志记录

日志记录是每个应用程序中常用的一个方面。ASP.NET Core 提供了一个简单而高效的日志系统。它可以与流行的日志库(如 NLog、Log4Net 和 Serilog)集成。

Serilog 是一个广泛使用的库,提供了许多日志目标的选项,包括控制台、文本文件和 Elasticsearch。ABP 启动模板预装并配置了 Serilog 库。它将日志写入应用程序的 Logs 文件夹中的日志文件。因此,您可以直接在您的服务中使用日志系统。如果需要,您可以配置 Serilog 将日志写入不同的目标。请参阅 Serilog 的文档以配置 Serilog 选项。Serilog 不是 ABP 框架的核心依赖项。所有配置都包含在启动模板中。因此,如果您愿意,可以轻松地使用另一个提供者进行更改。

ILogger<T> 接口用于在 ASP.NET Core 中写入日志,其中 T 通常代表您的服务类型。

这里是一个写入日志的示例服务:

public class AzureSmsService : ISmsService,                     ITransientDependency
{
    private readonly ILogger<AzureSmsService> _logger;
    public AzureSmsService(ILogger<AzureSmsService> logger)
    {
        _logger = logger;
    }
    public async Task SendAsync(string phoneNumber, string           message)
    {
        _logger.LogInformation(
            $"Sending SMS to {phoneNumber}: {message}");
        //TODO...
    }
}

AzureSmsService 类在其构造函数中注入了 ILogger<AzureSmsService> 服务,并使用 LogInformation 方法将信息级别的日志文本写入日志系统。

ILogger 接口提供了更多方法来以不同的严重级别写入日志,例如 LogError 和 LogDebug。请参阅 ASP.NET Core 的文档以获取日志系统的所有详细信息:docs.microsoft.com/en-us/aspnet/core/fundamentals/logging

摘要

本章已涵盖 ASP.NET Core 和 ABP 框架的核心构建块。

当您在应用程序启动时配置 ASP.NET Core 和 ABP 框架服务,并在需要时实现您自己的配置选项时,您已经了解了如何使用 Startup 类、配置系统和选项模式。

ABP 提供了一个模块化系统,它将 ASP.NET Core 的初始化和配置系统进一步发展,以创建多个模块,每个模块初始化其服务并配置其依赖项。这样,您可以按更好的方式组织代码库或将应用程序拆分为可以在不同应用程序中重用的模块。

依赖注入系统是 ASP.NET Core 应用程序最基本的基础设施。一个服务通过依赖注入系统消费其他服务。我已经介绍了依赖注入系统的基本方面,并解释了 ABP 如何简化服务注册。

下一章重点介绍数据访问基础设施,这是企业应用的一个基本方面。我们将了解 ABP 框架如何标准化定义实体和使用存储库来抽象和执行数据库操作,同时自动化数据库连接和事务管理。

第六章:第六章: 与数据访问基础设施协同工作

几乎所有业务应用程序都使用某种数据库系统。我们通常实现数据访问逻辑以从数据库中读取数据并向数据库写入数据。我们还需要处理数据库事务以确保数据源的一致性。

在本章中,我们将学习如何与 ABP 框架的数据访问基础设施协同工作,该基础设施通过实现仓储工作单元UoW)模式来提供数据访问的抽象。仓储提供了一种标准方式来执行针对你的实体的常见数据库操作。UoW 系统自动化数据库连接和事务管理,以确保用例(通常是超文本传输协议HTTP)请求)是原子的;这意味着请求中执行的所有操作要么一起成功,要么在出现任何错误时一起回滚。

你将了解如何根据 ABP 框架预构建的基类定义你的实体。然后,你将学习如何使用仓储在数据库中插入、更新、删除和查询实体。你还将了解 UoW 系统,以控制应用程序中的事务作用域。

ABP 框架可以与任何数据库系统协同工作,同时它提供了内置的DbContext类集成包,将你的实体映射到数据库表,实现你的仓储,并在拥有实体时部署不同的加载相关实体的方式。你还将了解如何使用MongoDB作为第二个数据库提供者选项。

本章涵盖了 ABP 的基本数据访问基础设施,以下是一些主题:

  • 定义实体

  • 与仓储协同工作

  • EF Core 集成

  • MongoDB 集成

  • 理解 UoW 系统

技术要求

如果你想要跟随并尝试示例,你需要安装一个集成开发环境IDE)/编辑器(例如,Visual Studio)来构建 ASP.NET Core 项目。

你可以从以下 GitHub 仓库下载代码示例:github.com/PacktPublishing/Mastering-ABP-Framework

定义实体

实体是定义你的领域模型的主要类。如果你使用的是关系型数据库,实体通常映射到数据库表。对象关系映射器ORM),如 EF Core,提供抽象,让你感觉在应用程序代码中与对象协同工作,而不是与数据库表协同工作。

ABP 框架通过提供一些接口和基类来标准化实体的定义。在接下来的章节中,你将了解 ABP 框架的AggregateRootEntity基类(及其变体),使用这些类使用单个主键PK)和复合主键CPK),以及与全局唯一标识符GUID)PK 协同工作。

AggregateRoot 类

聚合是一组由聚合根对象绑定在一起的对象(实体和值对象)的集合。

关系型数据库没有物理的聚合概念。每个实体都与一个单独的数据库表相关联,而聚合则分散在多个表中。你通过 外键FKs)定义关系。然而,在文档/对象数据库(如 MongoDB)中,聚合作为一个单独的文档(类似于 JavaScript 对象表示法JSON)的对象)进行序列化并保存到单个集合中。聚合根映射到集合,子实体在聚合根对象中进行序列化。这意味着子实体没有自己的集合,并且总是通过聚合根进行访问。

聚合概念

我们将在 第十章DDD – 领域层中涵盖聚合概念,并详细介绍这一点。现在,你可以将聚合根视为你领域中的主要(根)实体。

在 ABP 框架中,你可以通过从 AggregateRoot 类之一派生来定义主要实体和聚合根。BasicAggregateRoot 是定义聚合根的最简单类。

以下示例实体类是从 BasicAggregateRoot 类派生出来的:

using System;
using System.Collections.Generic;
using Volo.Abp.Domain.Entities;
namespace FormsApp
{
    public class Form : BasicAggregateRoot<Guid>
    {
        public string Name { get; set; }
        public string Description { get; set; }
        public bool IsDraft { get; set; }
        public ICollection<Question> Questions { get; set; }
    }
}

BasicAggregateRoot 仅定义了一个 Id 属性作为主键,并将主键类型作为泛型参数。在这个例子中,Form 的主键类型是 Guid。你可以使用任何类型作为主键(例如,intstring 等),只要底层数据库提供程序支持即可。

这里有一些其他的基础类,你可以从中派生出你的聚合根,具体如下:

  • AggregateRoot 类具有额外的属性以支持乐观并发和对象扩展功能。

  • CreationAuditedAggregateRoot 类继承自 AggregateRoot 类,并添加了 CreationTime (DateTime) 和 CreatorId (Guid) 属性以存储创建审计信息。

  • AuditedAggregateRoot 类继承自 CreationAuditedAggregateRoot 类,并添加了 LastModificationTime (DateTime) 和 LastModifierId (Guid) 属性以存储修改审计信息。

  • FullAuditedAggregateRoot 类继承自 AuditedAggregateRoot 类,并添加了 DeletionTime (DateTime) 和 DeleterId (Guid) 属性以存储删除审计信息。它还通过实现 ISoftDelete 接口添加了 IsDeleted (bool),这使得实体可以进行软删除。

    乐观并发和对象扩展功能

    这些主题在本书中没有涉及。如需使用,请查阅 ABP 框架文档。

ABP 自动设置审计属性。我们将在 第八章使用 ABP 的功能和服务中返回审计日志和软删除主题。

实体类

Entity基类类似于AggregateRoot类,但它们用于子集合实体而不是主(根)实体。例如,上一节中的Form聚合根示例有一个问题集合。Question类是从Entity类派生的,并在以下代码片段中显示:

public class Question : Entity<Guid>
{
    public Guid FormId { get; set; }
    public string Title { get; set; }
    public bool AllowMultiSelect { get; set; }
    public ICollection<Option> Options { get; set; }
}

AggregateRoot类一样,Entity类也定义了一个给定类型的Id属性。在这个例子中,Question实体还有一个选项集合,其中Option是另一种实体类型。

还有一些其他预定义的基本实体类,例如CreationAuditedEntityAuditedEntityFullAuditedEntity。它们与上一节中解释的已审计聚合根类类似。

具有 CPK 的实体

关系型数据库支持 CPKs,其中您的 PK 由多个值的组合组成。复合键对于具有多对多关系的关联表特别有用。

假设您想为表单对象设置多个管理器,并将集合属性添加到Form类中,如下所示:

public class Form : BasicAggregateRoot<Guid>
{
    ...
    public ICollection<FormManager> Managers { get; set; }
}

然后,您可以定义一个从非泛型的Entity类派生的FormManager类,如下所示:

public class FormManager : Entity
{
    public Guid FormId { get; set; }
    public Guid UserId { get; set; }
    public Guid IsOwner { get; set; }
    public override object[] GetKeys()
    {
        return new object[] {FormId, UserId};
    }
}

当您从非泛型的Entity类继承时,您必须实现GetKeys方法以返回键的数组。这样,ABP 可以在需要的地方使用 CPK 的值。例如,在这个例子中,FormIdUserId是其他表的 FK,它们构成了FormManager实体的 CPK。

聚合根的 CPK

AggregateRoot类也有非泛型的 CPK 版本,而对于聚合根实体设置 CPK 并不常见。

GUID PK

ABP 主要使用 GUIDs 作为预建实体的 PK 类型。GUIDs 通常与自增 ID(如intlong,由关系型数据库支持)进行比较。以下是使用 GUIDs 作为 PK 与自增键相比的一些常见好处:

  • GUIDs 自然是唯一的。如果您正在构建分布式系统,使用非关系型数据库,并且需要拆分或合并表或集成外部系统,这效果很好。

  • GUIDs 可以在客户端生成,无需数据库往返。这样,客户端代码可以在保存实体之前知道 PK 值。

  • GUIDs 无法猜测,因此在某些情况下可以更安全(例如,如果最终用户看到实体的 ID,他们无法找到另一个实体的 ID)。

与自增整数值相比,GUIDs 也有一些缺点,如下所示:

  • GUID 在存储中占用 16 字节,高于int(4 字节)和long(8 字节)。

  • GUIDs 在本质上不是顺序的,这会导致在聚类索引上出现性能问题。然而,ABP 提供了解决这个问题的方案。

ABP 提供了IGuidGenerator服务,该服务默认生成顺序的Guid值。虽然它生成顺序值,但算法生成的值仍然是通用和随机的。生成顺序值解决了聚集索引性能问题。

如果你手动设置实体的Id值,始终使用IGuidGenerator服务;永远不要使用Guid.NewGuid()。如果你没有为新的实体设置Id值并使用仓库将其插入数据库,仓库将自动使用IGuidGenerator服务设置它。

GUID 与自增的比较

在软件开发中,GUID 与自增 PK 的讨论很热烈,但没有明确的赢家。ABP 与任何 PK 类型都兼容,因此你可以根据自己的需求做出选择。

我们现在已经学习了实体定义的基础知识,并将探讨在第十章领域驱动设计 - 领域层中的实体最佳实践。但现在,让我们继续探讨仓库,了解如何与数据库一起工作以持久化我们的实体。

与仓库一起工作

仓库模式是一种常见的抽象数据访问代码的方法,从应用程序的其他服务中分离出来。在接下来的章节中,你将学习如何使用 ABP 框架的通用仓库为你的实体查询或操作数据库中的数据,使用预定义的仓库方法。你还将看到如何在需要扩展通用仓库并添加自己的仓库方法以封装你的数据访问逻辑时创建自定义仓库。

集成数据库提供者

为了使用仓库,需要完成数据库提供者的集成。我们将在本章的EF Core 集成MongoDB 集成部分完成此操作。

通用仓库

一旦你有一个实体,你可以直接注入并使用该实体的通用仓库。以下是一个使用仓库的示例类:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;
namespace FormsApp
{
    public class FormService : ITransientDependency
    {
        private readonly IRepository<Form, Guid>                         _formRepository;
        public FormService(IRepository<Form, Guid>                       formRepository)
        {
            _formRepository = formRepository;
        }
        public async Task<List<Form>> GetDraftForms()
        {
            return await _formRepository
                .GetListAsync(f => f.IsDraft);
        }
    }
}

在本例中,我们注入了IRepository<Form, Guid>,这是Form实体的默认通用仓库。然后,我们使用了GetListAsync方法从数据库中获取表单的筛选列表。通用的IRepository接口有两个泛型参数:实体类型(在本例中为Form)和 PK 类型(在本例中为Guid)。

非聚合根实体的仓库

默认情况下,通用仓库仅适用于聚合根实体,因为这是一种最佳实践,通过聚合根对象访问聚合。然而,如果你使用的是关系数据库,你可以为其他实体类型启用通用仓库。我们将在EF Core 集成部分看到配置点。

通用仓库提供了许多内置方法来查询、插入、更新和删除实体。

插入、更新和删除实体

以下方法可用于在数据库中操作数据:

  • InsertAsync用于插入新实体。

  • InsertManyAsync用于在一次调用中插入多个实体。

  • UpdateAsync用于更新现有实体。

  • UpdateManyAsync用于在一次调用中更新多个实体。

  • DeleteAsync用于删除现有实体。

  • DeleteManyAsync用于在一次调用中插入多个实体。

    关于异步编程

    所有仓库方法都是异步的。在.NET 中,强烈建议尽可能使用async/await模式编写应用程序代码,因为在.NET 中,将异步代码与同步代码混合会导致潜在的死锁、超时和可伸缩性问题,这些问题难以检测和解决。

如果您使用 EF Core,这些方法可能不会立即执行实际的数据库操作,因为 EF Core 使用更改跟踪系统。它仅在您调用DbContext.SaveChanges方法时保存更改。ABP 框架的 UoW 系统会在当前 HTTP 请求成功完成后自动调用SaveChanges方法。如果您想立即将更改保存到数据库中,可以将autoSave参数传递给仓库方法并设置为true

以下示例在InsertAsync方法中创建一个新的Form实体,并将其立即保存到数据库中:

var form = new Form(); // TODO: set the form properties
await _formRepository.InsertAsync(form, autoSave: true);

即使您将更改保存到数据库中,这些更改可能也不会立即可见,这取决于事务隔离级别,如果当前事务失败,则更改将被回滚。我们将在本章的理解 UoW 系统部分中介绍 UoW 系统。

DeleteAsync方法有一个额外的重载版本,用于删除满足给定条件的所有实体。以下示例展示了如何删除数据库中所有草稿表单:

await _formRepository.DeleteAsync(form => form.IsDraft);

您也可以使用逻辑运算符,如&&||,来设置复杂条件。

关于取消令牌

所有仓库方法都接受一个可选的CancellationToken参数。取消令牌用于在需要时取消数据库操作。例如,如果用户关闭浏览器窗口,就没有必要继续长时间运行的数据库查询操作。大多数情况下,您不需要手动传递取消令牌,因为 ABP 框架在您没有明确传递取消令牌时,会自动捕获并使用 HTTP 请求中的取消令牌。

查询单个实体

以下方法可以用来获取单个实体:

  • GetAsync:通过其Id值或谓词表达式返回单个实体。如果请求的实体未找到,则抛出EntityNotFoundException

  • FindAsync:通过其Id值或谓词表达式返回单个实体。如果请求的实体未找到,则返回null

如果您有自定义逻辑或回退代码,并且给定的实体在数据库中不存在,则应仅使用FindAsync方法。否则,请使用GetAsync,它会在 HTTP 请求中抛出一个已知的异常,导致返回404状态码给客户端。

以下示例使用 GetAsync 方法查询具有其 Id 值的 Form 实体:

public async Task<Form> GetFormAsync(Guid formId)
{
    return await _formRepository.GetAsync(formId);
}

两种方法都有重载,可以传递一个谓词表达式来查询具有给定条件的实体。以下示例使用 GetAsync 方法获取具有其唯一名称的 Form 实体:

public async Task<Form> GetFormAsync(string name)
{
    return await _formRepository
        .GetAsync(form => form.Name == name);
}

仅在你期望单个实体时使用这些重载。如果你的查询返回多个实体,则它们会抛出 InvalidOperationException。例如,如果你的系统中表单名称总是 唯一的,你可以按名称查找表单,如下例所示。然而,如果你的查询可能返回多个实体,请使用返回实体列表的查询方法。

查询实体列表

通用存储库提供了许多从数据库查询实体的选项。以下方法可以直接用于获取实体列表:

  • GetListAsync:返回满足给定条件的所有实体或实体列表

  • GetPagedListAsync:用于分页查询实体

以下代码块显示了如何通过给定的名称获取过滤后的表单列表:

public async Task<List<Form>> GetFormsAsync(string name)
{
    return await _formRepository
        .GetListAsync(form => form.Name.Contains(name));
}

我已将一个 lambda 表达式传递给 GetListAsync 方法,以获取所有具有给定 name 参数值包含在其名称中的 Form 实体。

这些方法简单但有限。如果你想编写高级查询,你可以在存储库上使用 语言集成查询LINQ)。

在存储库上使用 LINQ

存储库提供了 GetQueryableAsync() 方法,它返回一个 IQueryable<TEntity> 对象。然后你可以使用此对象在数据库中的实体上执行 LINQ。

以下示例使用 LINQ 操作在 Form 实体上获取按名称过滤和排序的表单列表:

public class FormService2 : ITransientDependency
{
    private readonly IRepository<Form, Guid>                         _formRepository;
    private readonly IAsyncQueryableExecuter                         _asyncExecuter;
    public FormService2(
        IRepository<Form, Guid> formRepository,
        IAsyncQueryableExecuter asyncExecuter)
    {
        _formRepository = formRepository;
        _asyncExecuter = asyncExecuter;
    }

    public async Task<List<Form>>                                   GetOrderedFormsAsync(string name)
    {
        var queryable = await                                           _formRepository.GetQueryableAsync();
        var query = from form in queryable
            where form.Name.Contains(name)
            orderby form.Name
            select form;
        return await _asyncExecuter.ToListAsync(query);
    }
}

我们首先获取了一个 IQueryable<Form> 对象,然后编写了一个 LINQ 查询,最后使用 IAsyncQueryableExecuter 服务执行了查询。

另一种编写先前查询的替代方法可以是使用 LINQ 扩展方法,如下所示:

var query = queryable
    .Where(form => form.Name.Contains(name))
    .OrderBy(form => form.Name);

拥有一个 IQueryable 对象为你提供了 LINQ 的所有功能。你甚至可以在来自不同存储库的多个 IQueryable 对象之间进行连接。

使用 IAsyncQueryableExecuter 服务可能对你来说有些奇怪。你可能期望直接在查询对象上调用 ToListAsync 方法,如下所示:

return await query.ToListAsync();

不幸的是,ToListAsync 是由 EF Core(或如果你使用 MongoDB,则位于)定义的扩展方法,位于 Microsoft.EntityFrameworkCore NuGet 包内。如果你从应用程序层引用该包没有问题,那么你可以在代码中直接使用这些异步扩展方法。然而,如果你想保持应用程序层 ORM 依赖性,ABP 的 IAsyncQueryableExecuter 服务提供了必要的抽象。

IRepository 异步扩展方法

ABP 框架为 IRepository 接口提供了所有标准的异步 LINQ 扩展方法:AllAsync, AnyAsync, AverageAsync, ContainsAsync, CountAsync, FirstAsync, FirstOrDefaultAsync, LastAsync, LastOrDefaultAsync, LongCountAsync, MaxAsync, MinAsync, SingleAsync, SingleOrDefaultAsync, SumAsync, ToArrayAsync, 和 ToListAsync。您可以直接在存储库对象上使用这些方法中的任何一个。

以下示例使用 CountAsync 方法获取以 "A" 开头的表单的数量:

public async Task<int> GetCountAsync()
{
    return await _formRepository
        .CountAsync(x => x.Name.StartsWith("A"));
}

注意,这些扩展方法仅在 IRepository 接口上可用。如果您想使用可查询的扩展,您仍然应该遵循上一节中解释的方法。

具有复合主键(CPK)的实体的泛型存储库

如果您的实体有一个复合主键(CPK),您不能使用 IRepository<TEntity, TKey> 接口,因为它获取一个单一的主键(Id)类型。在这种情况下,您可以使用 IRepository<TEntity> 接口。

例如,您可以使用 IRepository<FormManager> 获取给定表单的管理员,如下所示:

public class FormManagementService : ITransientDependency
{
    private readonly IRepository<FormManager>                       _formManagerRepository;
    public FormManagementService(
        IRepository<FormManager> formManagerRepository)
    {
        _formManagerRepository = formManagerRepository;
    }
    public async Task<List<FormManager>>                             GetManagersAsync(Guid formId)
    {
        return await _formManagerRepository
            .GetListAsync(fm => fm.FormId == formId);
    }
}

在此示例中,我使用了 IRepository<FormManager> 接口对 FormManager 实体执行查询。

非聚合根实体的存储库

如本章中“泛型存储库”部分所述,默认情况下不能使用 IRepository<FormManager>,因为 FormManager 不是一个聚合根实体。通常,您想要获取 Form 聚合根并访问其 Managers 集合以获取表单管理器。但是,如果您使用 EF Core,可以为不是聚合根的实体创建默认的泛型存储库。请参阅“EF Core 集成”部分了解如何这样做。

没有提供 TKey 泛型参数的泛型存储库有一个限制,就是它们没有获取 Id 参数的方法,因为它们不知道 Id 的类型。然而,您仍然可以使用 LINQ 编写任何需要的查询。

其他泛型存储库类型

您通常想要使用上一节中解释的存储库接口,因为它们是最功能丰富的存储库类型。然而,还有一些更有限的存储库类型在某些场景中可能很有用,例如以下内容:

  • IBasicRepository<TEntity, TPrimaryKey>IBasicRepository<TEntity> 提供了基本的存储库方法,但它们不支持 LINQ 和 IQueryable 功能。如果您的底层数据库提供程序不支持 LINQ 或您不想将 LINQ 查询泄露到应用程序层,则可以使用这些存储库。在这种情况下,您可能需要通过继承这些接口并使用自定义方法实现查询来编写自定义存储库。

  • IReadOnlyRepository<TEntity, TKey>, IReadOnlyRepository<TEntity>, IReadOnlyBasicRepository<Tentity, TKey>, 和 IReadOnlyBasicRepository<TEntity, TKey> 提供了获取数据的方法,但不包括任何操作数据库的方法。

通用仓库方法对于大多数情况已经足够。然而,您可能仍然需要向您的仓库中添加自定义方法。

自定义仓库

您可以创建自定义仓库接口和类来访问底层数据库提供者的应用程序编程接口API),封装您的 LINQ 表达式,调用存储过程,等等。

要创建一个自定义仓库,首先,定义一个新的仓库接口。仓库接口在启动模板提供的Domain项目中定义。您可以从一个通用仓库接口继承以将标准方法包含在您的仓库接口中。以下代码片段展示了如何实现:

public interface IFormRepository : IRepository<Form, Guid>
{
    Task<List<Form>> GetListAsync(
        string name,
        bool includeDrafts = false
    );
}

IFormRepository继承自IRepository<Form, Guid>并添加了一个新方法来获取具有某些筛选条件的表单列表。然后,您可以将IFormRepository注入到您的服务中,而不是使用通用仓库,并使用您自定义的方法。如果您不想包含标准仓库方法,只需从IRepository(不带任何泛型参数)接口派生您的接口。这是一个空接口,用于标识您的接口作为仓库。

当然,我们必须在我们的应用程序的某个地方实现IFormRepository接口。ABP 启动模板为底层数据库提供者提供了集成项目,因此我们可以在数据库集成项目中实现自定义仓库接口。在下一节中,我们将为 EF Core 和 MongoDB 实现该接口。

EF Core 集成

微软的 EF Core 是.NET 的事实上的 ORM,您可以使用它与主要的数据库提供者一起工作,例如 SQL Server、Oracle、MySQL、PostgreSQL 和 Cosmos DB。当您使用 ABP 命令行界面CLI)创建新的 ABP 解决方案时,它是默认的数据库提供者。

启动模板默认使用SQL Server。如果您在创建新解决方案时更喜欢另一个-dbms参数,如下所示:

abp new DemoApp -dbms PostgreSQL

SqlServerMySQLSQLiteOraclePostgreSQL直接支持。

其他数据库

您可以参考 ABP 的文档来了解最新的支持数据库选项以及如何切换到 ABP CLI 默认不支持的另一个数据库提供者:docs.abp.io/en/abp/latest/Entity-Framework-Core-Other-DBMS

在下一节中,您将学习如何配置 DBMS(尽管它已经在启动模板中完成),定义一个DbContext类,并将其注册到依赖注入DI)系统中。然后,您将看到如何使用 Code First Migrations 将您的实体映射到数据库表中,为您的实体创建自定义仓库。最后,我们将探讨为实体加载相关数据的不同方法。

配置 DBMS

我们使用 AbpDbContextOptions 在模块的 ConfigureServices 方法中配置 DBMS。以下示例配置使用 SQL Server 作为 DBMS:

Configure<AbpDbContextOptions>(options =>
{
    options.UseSqlServer();
});

当然,如果您选择了不同的 DBMS,UseSqlServer() 方法调用将会有所不同。我们不需要设置连接字符串,因为它会自动从 ConnectionStrings:Default 配置中获取。您可以在项目的 appsettings.json 文件中查看并更改连接字符串。

我们已经配置了 DBMS,但尚未定义 DbContext 对象,这是在 EF Core 中与数据库工作所必需的。

定义 DbContext

DbContext 是 EF Core 中您与之交互数据库的主要对象。您通常创建一个继承自 DbContext 的类来创建自己的 DbContext。在 ABP 框架中,我们继承自 AbpDbContext 而不是 DbContext

这里是一个使用 ABP 框架的 DbContext 类定义示例:

using Microsoft.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore;
namespace FormsApp
{
    public class FormsAppDbContext :                                 AbpDbContext<FormsAppDbContext>
    {
        public DbSet<Form> Forms { get; set; }
        public FormsAppDbContext(
            DbContextOptions<FormsAppDbContext> options)
            : base(options)
        {
        }
    }
}

FormsAppDbContext 继承自 AbpDbContext<FormsAppDbContext>AbpDbContext 是一个泛型类,它将 DbContext 类型作为泛型参数。它还强制我们创建一个构造函数,如下所示。然后我们可以为我们的实体添加 DbSet 属性。添加 DbSet 属性是必要的,因为 ABP 只能为具有定义了 DbSet 属性的实体创建默认的泛型仓储。

一旦我们定义了 DbContext,我们就应该将其注册到 DI 系统中,以便在应用程序中使用它。

将 DbContext 注册到 DI

使用 AddAbpDbContext 扩展方法将 DbContext 类注册到 DI 系统中。您可以在模块的 ConfigureServices 方法中使用此方法(它位于启动解决方案中的 EntityFrameworkCore 项目内),如下面的代码块所示:

public override void ConfigureServices(
    ServiceConfigurationContext context)
{
    context.Services.AddAbpDbContext<FormsAppDbContext>              (options =>
    {
        options.AddDefaultRepositories();
    });
}

使用 AddDefaultRepositories() 启用与该 DbContext 相关的实体的默认泛型仓储。默认情况下,它只为聚合根实体启用泛型仓储,因为如果您想为其他实体类型也使用仓储,可以将 includeAllEntities 参数设置为 true,如下所示:

options.AddDefaultRepositories(includeAllEntities: true);

使用此选项,您可以在应用程序代码中注入任何实体的 IRepository 服务。

启动模板中的 includeAllEntities 选项

ABP 启动模板将 includeAllEntities 选项设置为 true,因为从事关系数据库开发的开发者习惯于查询所有数据库表。如果您想严格应用 DDD 原则,您应该始终使用聚合根来访问子实体。在这种情况下,您可以从 AddDefaultRepositories 方法调用中删除此选项。

我们已经看到了如何注册 DbContext 类。我们可以在 DbContext 类中注入并使用所有实体的 IRepository 接口。然而,我们首先应该配置实体的 EF Core 映射。

配置实体映射

EF Core 是一个对象关系映射器,它将你的实体映射到数据库表。我们可以通过以下两种方式配置这些映射的细节,如下所述:

  • 在你的实体类上使用数据注释属性

  • 通过重写 OnModelCreating 方法使用 Fluent API

使用数据注释属性会使你的领域层依赖于 EF Core。如果你不介意这个问题,你可以简单地遵循 EF Core 的文档来使用这些属性。在这本书中,我将使用 Fluent API 方法。

要使用 Fluent API 方法,你可以在你的 DbContext 类中重写 OnModelCreating 方法,如下面的代码块所示:

public class FormsAppDbContext : AbpDbContext<FormsAppDbContext>
{
    ...
    protected override void OnModelCreating(ModelBuilder             builder)
    {
        base.OnModelCreating(builder);
        // TODO: configure entities...
    }
}

当你重写 OnModelCreating 方法时,始终要调用 base.OnModelCreating(),因为 ABP 也会在该方法内部执行默认配置,这对于正确使用 ABP 功能(如审计日志和数据过滤器)是必要的。然后,你可以使用 builder 对象来执行你的配置。

例如,我们可以配置本章中定义的 Form 类的映射,如下所示:

builder.Entity<Form>(b =>
{
    b.ToTable("Forms");
    b.ConfigureByConvention();
    b.Property(x => x.Name)
        .HasMaxLength(100)
        .IsRequired();
    b.HasIndex(x => x.Name);
});

在重写 OnModelCreating 方法时调用 b.ConfigureByConvention() 方法很重要。如果实体是从 ABP 预定义的 EntityAggregateRoot 类派生的,它将配置实体的基本属性。剩余的配置代码相当干净和标准,你可以从 EF Core 的文档中学习所有详细信息。

这里还有一个配置实体之间关系的示例:

builder.Entity<Question>(b =>
{
    b.ToTable("FormQuestions");
    b.ConfigureByConvention();
    b.Property(x => x.Title)
        .HasMaxLength(200)
        .IsRequired();
    b.HasOne<Form>()
        .WithMany(x => x.Questions)
        .HasForeignKey(x => x.FormId)
        .IsRequired();
});

在这个示例中,我们正在定义 FormQuestion 实体之间的关系:一个表可以有多个问题,而一个问题始终属于单个表。

我们所做的配置确保了 EF Core 知道如何读取和写入数据库表中的实体。然而,数据库中的相关表也应该可用。你当然可以手动创建数据库及其内部的表。然后,在实体发生每次变化时,你都需要手动在数据库架构中反映相关的更改。然而,以这种方式保持实体和数据库表的一致性是困难的。如果拥有多个环境(如开发和生产),手动进行所有这些操作既繁琐又容易出错。

幸运的是,有一种更好的方法:Code First Migrations。EF 的 Code First Migrations 系统提供了一种有效的方法,可以增量更新数据库架构,使其与你的实体模型保持同步。我们已经在 第三章逐步应用开发 中使用了 Code First Migration 系统。你可以参考该章节来学习如何添加新的数据库迁移并在数据库中应用它。

实现自定义仓储

我们在本章 与仓储一起工作 部分的 自定义仓储 部分创建了一个 IFormRepository 接口。现在,是时候使用 EF Core 来实现这个仓储接口了。

你可以在你的解决方案的 EF Core 集成项目中实现仓库,如下所示:

public class FormRepository :
    EfCoreRepository<FormsAppDbContext, Form, Guid>,
    IFormRepository
{
    public FormRepository(
        IDbContextProvider<FormsAppDbContext>                           dbContextProvider)
        : base(dbContextProvider)
    { }
    public async Task<List<Form>> GetListAsync(
        string name, bool includeDrafts = false)
    {
        var dbContext = await GetDbContextAsync();
        var query = dbContext.Forms
            .Where(f => f.Name.Contains(name));
        if (!includeDrafts)
        {
            query = query.Where(f => !f.IsDraft);
        }
        return await query.ToListAsync();
    }
}

这个类是从 ABP 的 EfCoreRepository 类派生出来的。这样,我们就继承了所有标准仓库方法。EfCoreRepository 类获取三个泛型参数:DbContext 类型、实体类型和实体类的 PK 类型。

FormRepository 还实现了 IFormRepository,它定义了一个自定义的 GetListAsync 方法。我们在该方法中使用 DbContext 实例来利用 EF Core API 的所有功能。

关于 WhereIf 的提示

条件过滤是一个广泛使用的模式,ABP 提供了一个很好的 WhereIf 扩展方法,可以简化我们的代码。

我们可以重写 GetListAsync 方法,如下面的代码块所示:

var dbContext = await GetDbContextAsync();
return await dbContext.Forms
    .Where(f => f.Name.Contains(name))
    .WhereIf(!includeDrafts, f => !f.IsDraft)
    .ToListAsync();

由于我们有 DbContext 实例,我们可以使用它来执行 结构化查询语言SQL)命令或存储过程。以下方法执行一个原始 SQL 命令来删除所有草稿表单:

public async Task DeleteAllDraftsAsync()
{
    var dbContext = await GetDbContextAsync();
    await dbContext.Database
        .ExecuteSqlRawAsync("DELETE FROM Forms WHERE                     IsDraft = 1");
}

执行存储过程和函数

您可以参考 EF Core 的文档(docs.microsoft.com/en-us/ef/core)来了解如何执行存储过程和函数。

一旦实现了 IFormRepository,你就可以注入并使用它,而不是使用 IRepository<Form, Guid>,如下所示:

public class FormService : ITransientDependency
{
    private readonly IFormRepository _formRepository;
    public FormService(IFormRepository formRepository)
    {
        _formRepository = formRepository;
    }

    public async Task<List<Form>> GetFormsAsync(string               name)
    {
        return await _formRepository
            .GetListAsync(name, includeDrafts: true);
    }
}

这个类使用了 IFormRepository 的自定义 GetListAsync 方法。

即使你为 Form 实体实现了自定义仓库类,仍然可以注入并使用该实体的默认泛型仓库(例如,IRepository<Form, Guid>)。这是一个很好的特性,特别是如果你从泛型仓库开始,然后决定稍后创建自定义仓库。你不需要更改使用泛型仓库的现有代码。

如果你在你的仓库中覆盖了 EfCoreRepository 类的基方法并进行了自定义,可能会出现一个潜在问题。在这种情况下,使用泛型仓库引用的服务将继续使用未覆盖的方法。为了防止这种碎片化,在将 DbContext 注册到 DI 时使用 AddRepository 方法,如下所示:

context.Services.AddAbpDbContext<FormsAppDbContext>(options =>
{
    options.AddDefaultRepositories();
    options.AddRepository<Form, FormRepository>();
});

使用此配置,AddRepository 方法将泛型仓库重定向到你的自定义仓库类。

加载相关数据

如果你的实体有指向其他实体的导航属性或具有其他实体的集合,那么在处理主实体时,你将经常需要访问这些相关实体。例如,之前引入的 Form 实体有一个 Question 实体的集合,你可能需要在处理 Form 对象时访问这些问题。

有多种方式访问相关实体:显式加载延迟加载贪婪加载

显式加载

仓库提供了 EnsurePropertyLoadedAsyncEnsureCollectionLoadedAsync 扩展方法来显式加载导航属性或子集合。

例如,我们可以显式地加载表单的问题,如下面的代码块所示:

public async Task<IEnumerable<Question>> GetQuestionsAsync(Form form)
{
    await _formRepository
        .EnsureCollectionLoadedAsync(form, f =>                         f.Questions);
    return form.Questions;
}

如果我们不在这里使用EnsureCollectionLoadedAsync,那么form.Questions集合可能会为空。如果我们不确定它是否已填充,我们可以使用EnsureCollectionLoadedAsync来确保它被加载。如果相关的属性或集合已经加载,EnsurePropertyLoadedAsyncEnsureCollectionLoadedAsync方法不会做任何事情,因此多次调用它们对性能没有问题。

懒加载

懒加载是 EF Core 的一个功能,当你第一次访问它们时,它会加载相关的属性和集合。懒加载默认是禁用的。如果你想为你的DbContext启用它,请按照以下步骤操作:

  1. 在你的 EF Core 层中安装Microsoft.EntityFrameworkCore.Proxies NuGet 包。

  2. 在配置AbpDbContextOptions时使用UseLazyLoadingProxies方法,如下所示:

    Configure<AbpDbContextOptions>(options =>
    {
        options.PreConfigure<FormsAppDbContext>(opts =>
        {
            opts.DbContextOptions.UseLazyLoadingProxies();
        });
        options.UseSqlServer();
    });
    
  3. 确保你的实体中的导航属性和集合属性是虚拟的,如下所示:

    public class Form : BasicAggregateRoot<Guid>
    {
        ...
        public virtual ICollection<Question> Questions {           get; set; }
        public virtual ICollection<FormManager> Owners {            get; set; }
    }
    

当你启用懒加载时,你不再需要使用显式加载。

懒加载是 ORMs 中讨论的一个概念。一些开发者认为它有用且实用,而另一些开发者建议根本不要使用它。我倾向于不使用它,因为它有一些潜在的问题,例如这些:

  • 懒加载不能使用异步编程,因为没有方法可以通过async/await模式访问属性。因此,它会阻塞调用线程,这对吞吐量和可扩展性来说是不良的做法。

  • 如果你忘记在使用foreach循环之前预先加载相关数据,你可能会遇到1+N加载问题。1+N加载意味着你通过单个数据库操作(1)从数据库中查询一系列实体,然后执行一个循环,访问这些实体的导航属性(或集合)。在这种情况下,它会为每个循环(N = 第一次数据库操作中查询到的实体的数量)懒加载相关的属性。因此,你进行了1+N次数据库调用,这会极大地降低你的应用程序性能。在这种情况下,你应该预先加载相关的实体,以便总共进行一次数据库调用。

  • 它使得对你的代码进行预测和优化变得困难,因为你可能不容易看到相关数据何时从数据库中加载。

我建议采取更受控的方法,并在可能的情况下使用预加载。

预加载

预加载是在首先查询主实体时加载相关数据的一种方式。

假设你已经创建了一个自定义仓库方法,在从数据库获取Form对象的同时加载相关的问题,如下所示:

public async Task<Form> GetWithQuestions(Guid formId)
{
    var dbContext = await GetDbContextAsync();
    return await dbContext.Forms
        .Include(f => f.Questions)
        .SingleAsync(f => f.Id == formId);
}

如果你创建了这样的自定义存储库方法,你可以使用完整的 EF Core API。然而,如果你正在使用 ABP 的存储库,并且不希望在应用程序层依赖于 EF Core,你不能使用 EF Core 的 Include 扩展方法(用于预加载相关数据)。在这种情况下,你有两种选择,下一节将讨论。

IRepository.WithDetailsAsync

IRepositoryWithDetailsAsync 方法通过包含给定的属性或集合,返回一个 IQueryable 实例,如下所示:

public async Task EagerLoadDemoAsync(Guid formId)
{
    var queryable = await _formRepository
        .WithDetailsAsync(f => f.Questions);
    var query = queryable.Where(f => f.Id == formId);
    var form = await                                                 _asyncExecuter.FirstOrDefaultAsync(query);
    foreach (var question in form.Questions)
    {
        //...
    }
}

WithDetailsAsync(f => f.Questions) 返回包含问题的 IQueryable<Form>,因此我们可以安全地遍历 form.Questions 集合。IAsyncQueryableExecuter 在本章的 通用存储库 部分中已经解释过。如果需要包含多个属性,WithDetailsAsync 方法可以获取多个表达式。如果需要嵌套包含(EF Core 中的 ThenInclude 扩展方法),则不能使用 WithDetailsAsync。在这种情况下,创建一个自定义存储库方法。

聚合模式

聚合模式将在第十章领域层中进行深入探讨。然而,为了提供一些简要信息,聚合被认为是一个单一单元;它作为一个单元读取和保存,包括所有子集合。这意味着你始终在加载表单时加载相关的问题。

ABP 很好地支持聚合模式,并允许你在全局点为实体配置预加载。我们可以在我们的模块类(在解决方案中的 EntityFrameworkCore 项目)的 ConfigureServices 方法中编写以下配置:

Configure<AbpEntityOptions>(options =>
{
    options.Entity<Form>(orderOptions =>
    {
        orderOptions.DefaultWithDetailsFunc = query =>                   query
            .Include(f => f.Questions)
            .Include(f => f.Owners);
    });
});

建议包含所有子集合。一旦你配置了 DefaultWithDetailsFunc 方法,如下所示,那么以下情况将会发生:

  • 返回单个实体(例如 GetAsync)的存储库方法默认会预加载相关实体,除非你通过在方法调用中指定 includeDetails 参数为 false 来显式禁用该行为。

  • 返回多个实体(例如 GetListAsync)的存储库方法将允许预加载相关实体,但默认情况下不会进行预加载。

这里有一些示例。

获取包含子集合的单个表单如下:

var form = await _formRepository.GetAsync(formId);

获取不带子集合的单个表单如下:

var form = await _formRepository.GetAsync(formId, includeDetails: false);

获取不带子集合的表单列表如下:

var forms = await _formRepository.GetListAsync(f => f.Name.StartsWith("A"));

获取包含子集合的表单列表如下:

var forms = await _formRepository.GetListAsync(f => f.Name.StartsWith("A"), includeDetails: true);

聚合模式在大多数情况下简化了你的应用程序代码,同时你仍然可以针对需要性能优化的情况进行微调。请注意,如果你真正实现了聚合模式,则不会使用导航属性(到其他聚合)。我们将在第十章领域层中再次讨论这个话题。

我们已经涵盖了使用 ABP 框架的 EF Core 的基本知识。下一节将解释 MongoDB 集成,这是 ABP 框架的另一个内置数据库提供程序。

MongoDB 集成

MongoDB 是一种流行的非关系型 文档数据库,它以类似于 JSON 的文档形式存储数据,而不是传统的基于行/列的表。

ABP CLI 提供了一个选项,可以创建使用 MongoDB 的新应用程序,如下所示:

abp new FormsApp -d mongodb

如果你想要检查和更改数据库连接字符串,你可以查看应用程序的 appsettings.json 文件。

MongoDB 客户端包

ABP 使用官方的 MongoDB.Driver NuGet 包来实现 MongoDB 集成。

在接下来的章节中,你将学习如何使用 ABP 的 AbpMongoDbContext 类来定义 DbContext 对象,执行对象映射配置,将 DbContext 对象注册到 DI 系统中,并在需要扩展实体通用存储库时实现自定义存储库。

我们通过定义一个 DbContext 类来开始 MongoDB 集成。

定义 DbContexts

MongoDB 驱动程序包没有像 EF Core 那样的 DbContext 概念。然而,ABP 引入了 AbpMongoDbContext 类,以提供一个定义和配置 MongoDB 集成的标准方式。我们需要定义一个从 AbpMongoDbContext 基类派生的类,如下所示:

public class FormsAppDbContext : AbpMongoDbContext
{
    [MongoCollection("Forms")]
    public IMongoCollection<Form> Forms =>                           Collection<Form();
}

MongoCollection 属性在数据库侧设置集合名称。它是可选的,如果你没有指定它,则使用驱动程序的默认值。在 FormsAppDbContext 类上定义一个集合属性是使用默认泛型存储库所必需的。

配置对象映射

虽然 MongoDB C# 驱动程序不是一个 ORM,但它仍然将你的实体映射到数据库中的集合,你可能想要自定义映射配置。在这种情况下,像这样覆盖你的 DbContext 类中的 CreateModel 方法:

protected override void CreateModel(IMongoModelBuilder builder)
{
    builder.Entity<Form>(b =>
    {
        b.BsonMap.UnmapProperty(f => f.Description);
    });
}

在本例中,我已经配置了 MongoDB,使其在保存和检索数据时忽略 Form 实体的 Description 属性。请参阅 MongoDB.Driver NuGet 包的文档,了解所有配置选项。

将 DbContext 注册到 DI

一旦创建并配置了你的 DbContext 类,它就会在模块类的 ConfigureServices 方法中注册到 DI 系统中(通常在解决方案的 MongoDB 集成项目中)。以下代码片段说明了这一点:

public override void ConfigureServices(
    ServiceConfigurationContext context)
{
    context.Services.AddMongoDbContext<FormsAppDbContext>(
        options =>
            {
                options.AddDefaultRepositories();
            });
}

AddDefaultRepositories() 用于为与该 DbContext 相关的实体启用默认泛型存储库。然后,你可以将 IRepository<Form> 注入到你的类中,并开始使用你的 MongoDB 数据库。

AddDefaultRepositories方法仅对聚合根实体(从AggregateRoot类派生的实体类)启用默认仓储。将includeAllEntities设置为true以启用所有实体类型的默认仓储。然而,在处理 MongoDB 时,强烈建议应用聚合模式。聚合模式将在第十章中深入探讨,领域层 – DDD

在大多数情况下,默认的泛型仓储就足够了,但你可能需要访问 MongoDB API 或将查询抽象到自定义仓储方法中。

实现自定义仓储

我们在本章“与仓储一起工作”部分的“自定义仓储”部分创建了一个IFormRepository接口。我们可以使用 MongoDB 实现这个仓储接口。

你可以在你的解决方案的 MongoDB 集成项目中实现仓储,如下所示:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MongoDB.Driver;
using MongoDB.Driver.Linq;
using Volo.Abp.Domain.Repositories.MongoDB;
using Volo.Abp.MongoDB;
namespace FormsApp
{
    public class FormRepository : 
        MongoDbRepository<FormsAppDbContext, Form, Guid>, 
        IFormRepository
    {
        public FormRepository(
            IMongoDbContextProvider<FormsAppDbContext>                       dbContextProvider)
            : base(dbContextProvider)
        { }
        // TODO: implement the GetListAsync method
    }
}

FormRepository类是从 ABP 的MongoDbRepository类派生的。这样,我们就继承了所有标准仓储方法。MongoDbRepository类获取三个泛型参数:DbContext类型、实体类型和实体类的 PK 类型。

FormRepository类应实现由IFormRepository接口定义的GetListAsync方法,如下所示:

public async Task<List<Form>> GetListAsync(
    string name, bool includeDrafts = false)
{
    var queryable = await GetMongoQueryableAsync();
    var query = queryable.Where(f =>                                 f.Name.Contains(name));
    if (!includeDrafts)
    {
        query = queryable.Where(f => !f.IsDraft);
    }
    return await query.ToListAsync();
}

在这个例子中,我使用了 MongoDB 驱动程序的 LINQ API,但你可以通过获取IMongoCollection对象来使用其他 API,如下面的代码片段所示:

IMongoCollection<Form> formsCollection = await GetCollectionAsync();

现在,你可以将IFormRepository注入到你的服务中,而不是泛型的IRepository<Form, Guid>仓储,并使用所有标准和自定义仓储方法。

即使为Form实体实现了自定义仓储类,仍然可以为此实体注入和使用默认的泛型仓储(例如IRepository<Form, Guid>)。如果你实现了自定义仓储,建议在DbContext注册代码中使用AddRepository方法,如下面的代码片段所示:

context.Services.AddMongoDbContext<FormsAppDbContext>(options =>
{
    options.AddDefaultRepositories();
    options.AddRepository<Form, FormRepository>();
});

这样,泛型默认仓储将重定向到你的自定义仓储类。如果你在自定义仓储中重写了基类方法,它们也将使用你的重载而不是基类方法。

我们已经学习了如何使用 EF Core 和 MongoDB 作为数据库提供者。在下一节中,我们将理解 UoW 系统,使其能够连接这些数据库并应用事务。

理解 UoW 系统

UoW 是 ABP 用来启动、管理和释放数据库连接和事务的主要系统。UoW 系统是按照环境上下文模式设计的。这意味着当我们创建一个新的 UoW 时,它会创建一个范围上下文,该上下文由当前范围内执行的、共享相同上下文的全部数据库操作参与,并被视为一个单独的事务边界。在 UoW 中执行的所有操作要么(在成功时)提交,要么(在异常时)回滚。

虽然你可以手动创建 UoW 范围并控制事务属性,但大多数时候,它将无缝地按照你的期望工作。然而,如果你更改默认行为,它提供了一些选项。

UoW 和数据库操作

所有数据库操作必须在 UoW 范围内执行,因为 UoW 是管理 ABP 框架中数据库连接和事务的方式。否则,你会得到一个异常指示。

在接下来的章节中,你将了解 UoW 系统的工作原理以及如何通过配置选项来自定义它。我还会解释如何在传统系统不适合你的用例时手动控制 UoW 系统。

配置 UoW 选项

在默认设置下,在 ASP.NET Core 应用程序中,一个 HTTP 请求被视为 UoW 范围。ABP 在请求开始时启动 UoW,如果请求成功完成,则将更改保存到数据库。如果请求因异常失败,则回滚 UoW。

ABP 根据 HTTP 请求类型确定数据库事务的使用。HTTP GET请求不创建数据库事务。UoW 仍然工作,但在这种情况下不使用数据库事务。如果你没有其他配置,所有其他 HTTP 请求类型(POSTPUTDELETE和其他)都将使用数据库事务。

HTTP GET 请求和事务

不在GET请求中更改数据库是一个最佳实践。如果你在GET请求中执行多个写操作,并且请求以某种方式失败,你的数据库状态可能会处于不一致的状态,因为 ABP 不会为GET请求创建数据库事务。在这种情况下,你可以使用AbpUnitOfWorkDefaultOptionsGET请求启用事务,或者像下一节中描述的那样手动控制 UoW。

如果你想更改 UoW 选项,请在你的模块(数据库集成项目中)的ConfigureServices方法中使用AbpUnitOfWorkDefaultOptions,如下所示:

public override void ConfigureServices(
    ServiceConfigurationContext context)
{
    Configure<AbpUnitOfWorkDefaultOptions>(options =>
    {
        options.TransactionBehavior =                                   UnitOfWorkTransactionBehavior.Enabled;
        options.Timeout = 300000; // 5 minutes
        options.IsolationLevel =                                      IsolationLevel.Serializable;
    });
}

TransactionBehavior可以取以下三个值:

  • Auto (默认): 自动确定是否使用数据库事务(非GET HTTP请求启用事务)

  • Enabled: 总是使用数据库事务,即使是HTTP GET请求

  • Disabled: 从不使用数据库事务

Auto行为是默认值,适用于大多数应用。IsolationLevel仅对关系型数据库有效。如果你没有指定,ABP 将使用底层提供者的默认值。最后,Timeout选项允许你将事务的默认超时值设置为毫秒。如果 UoW 操作在给定超时值内未完成,将抛出超时异常。

在本节中,我们学习了如何配置所有 UoW 的默认选项。如果你手动控制,也可以为单个 UoW 配置这些值。

手动控制 UoW

对于 Web 应用,你很少需要手动控制 UoW 系统。然而,对于后台工作者或非 Web 应用,你可能需要自己创建 UoW 作用域。你可能还需要控制 UoW 系统以创建内部事务作用域。

创建 UoW 作用域的一种方式是在你的方法上使用[UnitOfWork]属性,如下所示:

[UnitOfWork(isTransactional: true)]
public async Task DoItAsync()
{
    await _formRepository.InsertAsync(new Form() { ... });
    await _formRepository.InsertAsync(new Form() { ... });
}

UoW 系统使用环境上下文模式。如果已经存在一个周围的 UoW,你的UnitOfWork属性将被忽略,你的方法将参与周围的 UoW。否则,ABP 在进入DoItAsync方法之前启动一个新的事务性 UoW,如果没有抛出异常,则提交事务。如果该方法抛出异常,则回滚事务。

如果你想要精细控制 UoW 系统,你可以注入并使用IUnitOfWorkManager服务,如下面的代码块所示:

public async Task DoItAsync()
{
    using (var uow = _unitOfWorkManager.Begin(
        requiresNew: true,
        isTransactional: true,
        timeout: 15000))
    {
        await _formRepository.InsertAsync(new Form() { });
        await _formRepository.InsertAsync(new Form() { });
        await uow.CompleteAsync();
    }
}

在此示例中,我们以 15 秒作为timeout参数值的值启动一个新的事务性 UoW 作用域。使用此用法(requiresNew: true),即使存在周围的 UoW,ABP 也会始终启动一个新的 UoW。如果一切顺利,始终调用uow.CompleteAsync()方法。如果你想回滚当前事务,可以使用uow.RollbackAsync()方法。

如前所述,UoW 使用环境作用域。你可以使用IUnitOfWorkManager.Current属性在任何此作用域中访问当前 UoW。如果没有正在进行的 UoW,它可以是null

以下代码片段使用SaveChangesAsync方法与IUnitOfWorkManager.Current属性:

await _unitOfWorkManager.Current.SaveChangesAsync();

我们已将所有挂起的更改保存到数据库中。然而,如果这是一个事务性 UoW,如果你回滚 UoW 或在该作用域中抛出任何异常,这些更改也将被回滚。

摘要

在本章中,我们学习了如何使用 ABP 框架与数据库交互。ABP 通过提供基类来标准化定义实体。它还帮助自动跟踪更改时间和更改实体的用户,当你从审计实体类派生时。

仓储系统提供了读取和写入实体的基本功能。您可以使用 LINQ 在仓储上进行高级查询。此外,您还可以创建自定义仓储类,直接与底层数据提供程序一起工作,将复杂查询隐藏在简单的仓储接口后面,调用存储过程等。

ABP 是数据库无关的,但它提供了与 EF Core 和 MongoDB 的集成包,开箱即用。ABP 应用程序启动模板包含这些提供程序之一,您可以选择您喜欢的。

EF Core 是.NET 平台上的事实上的 ORM,ABP 将其作为一等公民支持。应用程序启动模板经过精心调整,以配置您的映射并管理您的数据库模式迁移,同时支持模块化应用程序结构。

最后,UoW 系统为我们提供了一个无缝的方式来管理数据库连接和事务。它通过自动化这些重复性任务,使应用程序代码保持整洁。

数据访问是任何业务应用的核心需求,理解其细节至关重要。下一章将继续介绍每个应用所需的横切关注点,例如授权、验证和异常处理。

第七章:第七章:探索横切关注点

横切关注点,如授权、验证、异常处理和日志记录,是任何严肃系统的基本组成部分。它们对于使您的系统安全并良好运行至关重要。

实现横切关注点的一个问题是,您应该在应用程序的每个地方实现这些关注点,这会导致代码库重复。此外,缺少一个授权或验证检查可能会导致整个系统崩溃。

ABP 框架的主要目标之一是帮助您应用不要重复自己DRY)原则!ASP.NET Core 已经为一些横切关注点提供了一个良好的基础设施,但 ABP 将其进一步自动化或使它们对您来说更容易实现。

本章探讨了 ABP 以下横切关注点的架构:

  • 与授权和权限系统一起工作

  • 验证用户输入

  • 异常处理

技术要求

如果您想跟随示例进行尝试,您需要安装一个集成开发环境IDE)/编辑器(例如,Visual Studio)来构建 ASP.NET Core 项目。

您可以从以下 GitHub 仓库下载代码示例:github.com/PacktPublishing/Mastering-ABP-Framework

本章还引用了EventHub项目的一些代码示例。该项目在第四章,“理解参考解决方案”中介绍,您可以从以下 GitHub 仓库访问其源代码:github.com/volosoft/eventhub

与授权和权限系统一起工作

身份验证授权是软件安全中的两个主要概念。身份验证是识别当前用户的过程。另一方面,授权用于允许或禁止用户在应用程序中执行特定操作。

ASP.NET Core 的授权系统提供了一种高级且灵活的方式来授权当前用户。ABP 框架的授权基础设施与 ASP.NET Core 的授权系统 100%兼容,并通过引入权限系统对其进行扩展。ABP 允许权限轻松地授予角色和用户。它还允许在客户端检查相同的权限。

我将通过指出 ABP 框架添加的部分,将授权系统解释为 ASP.NET Core 和 ABP 基础设施的混合。让我们从最简单的授权检查开始。

简单授权

在最简单的情况下,您可能只想允许已登录到应用程序的用户执行某些操作。没有参数的[Authorize]属性仅检查当前用户是否已通过身份验证(登录)。

请参阅以下模型-视图-控制器MVC)示例:

public class ProductController : Controller
{
    public async Task<List<ProductDto>> GetListAsync()
    {
    }
    [Authorize]
    public async Task CreateAsync(ProductCreationDto input)
    {
    }    
    [Authorize]
    public async Task DeleteAsync(Guid id)
    {
    }
}

在此示例中,CreateAsyncDeleteAsync 操作仅对经过身份验证的用户可用。假设一个匿名用户(未登录到应用程序的用户,因此我们无法识别他们)试图执行这些操作。在这种情况下,ASP.NET Core 会向客户端返回一个授权错误响应。然而,GetListAsync 方法对所有用户都可用,即使是匿名用户。

[Authorize] 属性可以在控制器类级别使用,以授权该控制器内的所有操作。在这种情况下,我们可以使用 [AllowAnonymous] 属性来允许特定操作对匿名用户。因此,我们可以重写相同的示例,如下面的代码块所示:

[Authorize]
public class ProductController : Controller
{
    [AllowAnonymous]
    public async Task<List<ProductDto>> GetListAsync()
    {
    }
    public async Task CreateAsync(ProductCreationDto input)
    {
    }

    public async Task DeleteAsync(Guid id)
    {
    }
}

在这里,我在类上使用了 [Authorize] 属性,并将 [AllowAnonymous] 添加到 GetListAsync 方法中。这使得也可以为尚未登录到应用程序的用户消费该特定操作。

虽然 [Authorize] 属性(无参数)有一些用例,但您通常希望在您的应用程序中定义特定的权限(或策略),以便所有经过身份验证的用户都不具有相同的权限。

使用权限系统

ABP 框架为 ASP.NET Core 最重要的授权扩展是权限系统。权限是一个简单的策略,它为特定的用户或角色授予或禁止。然后它与您的应用程序的特定功能相关联,并在用户尝试使用该功能时进行检查。如果当前用户拥有相关的权限授予,则用户可以使用应用程序功能。否则,用户不能使用该功能。

ABP 为您提供了在应用程序中定义、授予和检查权限的所有功能。

定义权限

在使用之前,我们应该定义权限。要定义权限,创建一个继承自 PermissionDefinitionProvider 类的类。当您创建一个新的 ABP 解决方案时,一个空的权限定义提供者类会包含在解决方案的 Application.Contracts 项目中。请参阅以下示例:

public class ProductManagementPermissionDefinitionProvider
    : PermissionDefinitionProvider
{
    public override void Define(
        IPermissionDefinitionContext context)
    {
        var myGroup = context.AddGroup(
            "ProductManagement");
        myGroup.AddPermission(
            "ProductManagement.ProductCreation");
        myGroup.AddPermission(
            "ProductManagement.ProductDeletion");
    }
}

ABP 框架在应用程序启动时调用 Define 方法。在此示例中,我创建了一个名为 ProductManagement 的权限组,并在其中定义了两个权限。组用于在 string 值上分组权限(建议使用 const 字段而不是使用魔法字符串)。

这是一个最小配置。您还可以指定用于组的本地化字符串显示名称,以及用于在用户界面中以用户友好的方式显示权限名称。以下代码块使用本地化系统在定义组和权限时指定显示名称:

public class ProductManagementPermissionDefinitionProvider
    : PermissionDefinitionProvider
{
    public override void Define(
        IPermissionDefinitionContext context)
    {
        var myGroup = context.AddGroup(
            «ProductManagement»,
            L("ProductManagement"));
        myGroup.AddPermission(
            "ProductManagement.ProductCreation",
            L("ProductCreation"));
        myGroup.AddPermission(
            "ProductManagement.ProductDeletion",
            L("ProductDeletion"));
    }

    private static LocalizableString L(string name)
    {
        return LocalizableString
            .Create<ProductManagementResource>(name);
    }
}

我定义了一个 L 方法来简化本地化。本地化系统将在第八章中介绍,使用 ABP 的功能和服务

多租户应用程序中的权限定义

对于多租户应用程序,您可以为AddPermission方法的multiTenancySide参数指定,以定义仅主机或仅租户的权限。我们将在第十六章 实现多租户中回到这个话题。

一旦定义了权限,在下次应用程序启动后,它就会在权限管理对话框中可用。

管理权限

默认情况下,可以为用户或角色授予权限。例如,假设您已创建一个管理员角色并希望为该角色授予产品权限。当您运行应用程序时,如果之前没有创建该角色,请导航到manager角色;要这样做,请单击操作按钮并选择权限操作,如图图 7.1所示:

![图 7.1 – 在角色管理页面上选择权限操作图片

图 7.1 – 在角色管理页面上选择权限操作

单击权限操作将打开一个模态对话框以管理所选角色的权限,如图所示:

![图 7.2 – 权限管理模态框图片

图 7.2 – 权限管理模态框

图 7.2中,您可以看到左侧的权限组,而该组中的权限在右侧可用。权限组和我们所定义的权限可以在该对话框中无需额外努力即可使用。

所有具有管理员角色的用户都继承该角色的权限。用户可以有多个角色,并且继承所有分配角色的所有权限的并集。您还可以在用户管理页面上直接授予用户权限,以获得更多灵活性。

我们已经定义了权限并将它们分配给了角色。下一步是检查当前用户是否有请求的权限。

检查权限

您可以使用声明方式,使用[Authorize]属性,或使用IAuthorizationService以编程方式检查权限。

我们可以将ProductController类(在简单授权部分介绍)重写为在特定操作上请求产品创建和删除权限,如下所示:

public class ProductController : Controller
{
    public async Task<List<ProductDto>> GetListAsync()
    {
    }
    [Authorize("ProductManagement.ProductCreation")]
    public async Task CreateAsync(ProductCreationDto input)
    {
    }    
    [Authorize("ProductManagement.ProductDeletion")]
    public async Task DeleteAsync(Guid id)
    {
    }
}

使用此用法,[Authorize]属性接受一个字符串参数作为策略名称。ABP 将权限定义为自动策略,因此您可以在需要指定策略名称的地方使用权限名称。

声明式授权简单易用,在可能的情况下推荐使用。然而,当您想要条件性地检查权限或执行未经授权的情况的逻辑时,它是有局限性的。对于此类情况,您可以注入并使用IAuthorizationService,如下面的示例所示:

public class ProductController : Controller
{
    private readonly IAuthorizationService 
        _authorizationService;
    public ProductController(
        IAuthorizationService authorizationService)
    {
        _authorizationService = authorizationService;
    }

    public async Task CreateAsync(ProductCreationDto input)
    {
        if (await _authorizationService.IsGrantedAsync(  
            "ProductManagement.ProductCreation"))
        {
            // TODO: Create the product
        }
        else
        {
            // TODO: Handle unauthorized case
        }
    }
}

IsGrantedAsync 方法检查给定的权限,如果当前用户(或用户的角色)已被授予当前权限,则返回 true。这在您有针对未经授权情况的自定义逻辑时很有用。然而,如果您只想简单地检查权限并在未经授权的情况下抛出异常,则 CheckAsync 方法更为实用:

public async Task CreateAsync(ProductCreationDto input)
{
    await _authorizationService
        .CheckAsync("ProductManagement.ProductCreation");
    //TODO: Create the product
}

如果用户没有权限执行该操作,CheckAsync 方法将抛出 AbpAuthorizationException 异常,由 ABP 框架处理以返回适当的 IsGrantedAsyncCheckAsync 方法。这些是 ABP 框架定义的有用扩展方法。

小贴士:从 AbpController 继承

建议从 AbpController 类而不是标准 Controller 类派生您的控制器类。这扩展了标准 Controller 类并定义了一些有用的基本属性。例如,它有 AuthorizationService 属性(IAuthorizationService 类型),您可以直接使用它而不是手动注入 IAuthorizationService 接口。

在服务器上检查权限是一种常见方法。但是,您可能还需要在客户端检查权限。

在客户端使用权限

ABP 提供了一个标准 HTTP API,URL 为 /api/abp/application-configuration,它返回包含本地化文本、设置、权限等 JSON 数据。然后,客户端应用程序可以消费该 API 来检查权限或执行客户端的本地化。

不同的客户端类型可能提供不同的服务来检查权限。例如,在一个 MVC/Razor Pages 应用程序中,您可以使用 abp.auth JavaScript API 来检查权限,如下所示:

abp.auth.isGranted('ProductManagement.ProductCreation');

这是一个全局函数,如果当前用户具有给定的权限,则返回 true。否则,返回 false

在一个 Blazor 应用程序中,您可以重用相同的 [Authorize] 属性和 IAuthorizationService

我们将在 第四部分用户界面和 API 开发 中回到客户端权限检查。

子权限

在一个复杂的应用程序中,您可能需要创建一些依赖于其父权限的子权限。只有当父权限已被授予时,子权限才有意义。参见 图 7.3

图 7.3 – 父子权限

图 7.3 – 父子权限

图 7.3 中,角色管理权限有一些子权限,例如创建编辑删除角色管理权限用于允许用户进入角色管理页面。如果用户无法进入该页面,那么授予角色创建权限就没有意义,因为实际上无法在不进入该页面的情况下创建新角色。

在权限定义类中,AddPermission 方法返回创建的权限,这样您就可以将其赋给一个变量,并使用 AddChild 方法创建子权限,如下面的代码块所示:

public override void Define(IpermissionDefinitionContext
                            context)
{
    var myGroup = context.AddGroup(
        "ProductManagement",
        L("ProductManagement"));
    var parent = myGroup.AddPermission(
        "MyParentPermission");
    parent.AddChild("MyChildPermission");
}

在这个例子中,我们创建了一个名为 MyParentPermission 的权限,然后创建了一个名为 MyChildPermission 的权限作为子权限。

子权限也可以有子权限。您可以将 parent.AddChild 方法的返回值赋给一个变量,并调用其 AddChild 方法。

定义和使用权限是一种简单而强大的方式,可以通过简单的开/关式策略来授权应用程序。然而,ASP.NET Core 允许创建完整的自定义逻辑来定义策略。

基于策略的授权

ASP.NET Core 的基于策略的授权系统允许您授权应用程序中的某些操作,就像使用权限一样,但这次是通过代码表达的自定义逻辑。实际上,权限是 ABP 框架提供的一个简化和自动化的策略。

假设您想使用自定义代码授权一个产品创建操作。首先,您需要定义一个您稍后将要检查的要求(我们可以在解决方案的应用层中定义这些类,而无需遵循严格的规则)。以下代码片段展示了代码的示例:

public class ProductCreationRequirement : 
    IAuthorizationRequirement
{ }

ProductCreationRequirement 是一个空类,它仅实现了 IAuthorizationRequirement 标记接口。然后,您应该为该要求定义一个授权处理器,如下所示:

public class ProductCreationRequirementHandler 
    : AuthorizationHandler<ProductCreationRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        ProductCreationRequirement requirement)
    {
        if (context.User.HasClaim(c => c.Type == 
            "productManager"))
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

处理器类必须从 AuthorizationHandler<T> 派生,其中 T 是您的要求类类型。在这个例子中,我简单地检查当前用户是否有 productManager 断言,这是我自定义的断言(断言是存储在身份验证票据中的简单命名值)。您可以构建自己的自定义逻辑。您要做的只是调用 context.Succeed,如果您想允许当前用户拥有该要求。

一旦定义了要求和处理器,您需要将它们注册到您的模块类的 ConfigureServices 方法中,如下所示:

public override void ConfigureServices(
    ServiceConfigurationContext context)
{
    Configure<AuthorizationOptions>(options =>
    {
        options.AddPolicy(
            "ProductManagement.ProductCreation",
            policy => policy.Requirements.Add(
                new ProductCreationRequirement()
            )
        );
    });
    context.Services.AddSingleton<IAuthorizationHandler, 
        ProductCreationRequirementHandler>();
}

我使用了 AuthorizationOptions 来定义一个名为 ProductManagement.ProductCreation 的策略,并带有 ProductCreationRequirement 要求。然后,我将 ProductCreationRequirementHandler 注册为一个单例服务。

现在,假设我在控制器或操作上使用 [Authorize("ProductManagement.ProductCreation")] 属性,或者使用 IAuthorizationService 来检查策略。在这种情况下,我的自定义授权处理器逻辑将允许我完全控制策略检查逻辑。

权限与自定义策略的比较

一旦您实现了自定义策略,您就不能使用权限管理对话框来授予用户和角色的权限,因为它不是一个简单的开启/关闭权限,您可以启用/禁用。然而,客户端策略检查仍然有效,因为 ABP 与 ASP.NET Core 的策略系统很好地集成。

如您所见,如果您只需要开启/关闭风格的策略,ABP 的权限系统就简单得多,功能也更强大;而自定义策略允许您使用自定义逻辑动态检查策略。

基于资源的授权

ASP.NET Core 的授权系统比这里所涵盖的功能更多。基于资源的授权是一个允许您根据对象(如实体)来控制策略的功能。例如,您可以控制删除特定产品的访问权限,而不是为所有产品设置一个通用的删除权限。ABP 与 ASP.NET Core 授权系统 100% 兼容,因此我建议您查看 ASP.NET Core 的文档以了解更多关于授权的信息:docs.microsoft.com/en-us/aspnet/core/security/authorization

到目前为止,我们已经看到了 [Authorize] 属性在 MVC 控制器上的用法。然而,这个属性和 IAuthorizationService 并不仅限于控制器。

控制器之外的授权

ASP.NET Core 允许您在 Razor Pages、Razor 组件和 Web 层的一些其他点上使用 [Authorize] 属性和 IAuthorizationService。您可以参考 ASP.NET Core 的文档来了解这些标准用法:docs.microsoft.com/en-us/aspnet/core/security/authorization

ABP 框架更进一步,允许在应用程序服务类和方法上使用 [Authorize] 属性,而无需依赖于网络层,即使在非网络应用程序中也是如此。因此,这种用法是完全有效的,如下所示:

public class ProductAppService
    : ApplicationService, IProductAppService
{
    [Authorize("ProductManagement.ProductCreation")]
    public Task CreateAsync(ProductCreationDto input)
    {
        // TODO
    }
}

CreateAsync 方法只能在当前用户具有 ProductManagement.ProductCreation 权限/策略的情况下执行。实际上,[Authorize] 可以在任何注册了 依赖注入DI)的类中使用。然而,由于授权被认为是一个应用层方面,建议在应用层而不是领域层使用授权。

动态代理/拦截器

ABP 使用拦截器通过动态代理来实现方法调用上的授权检查。如果您通过类引用(而不是接口引用)注入服务,动态代理系统会使用动态继承技术。在这种情况下,您的方法必须使用 virtual 关键字定义,以便动态代理系统可以覆盖它并执行授权检查。

授权系统确保只有授权用户才能使用你的服务。这是你需要用来保护应用程序的系统之一,另一个是输入验证。

验证用户输入

验证确保你的数据安全和一致性,并帮助你的应用程序正常运行。验证是一个广泛的话题,有一些常见的验证级别,如下所述:

  • 客户端验证用于在将数据发送到服务器之前预先验证用户输入。这对于用户体验UX)非常重要,你应该在可能的情况下始终实现它。然而,它不能保证安全性——即使是经验不足的黑客也可以绕过它。例如,检查必填的文本框字段是否为空是一种客户端验证。我们将在第四部分用户界面和 API 开发中介绍客户端验证。

  • 服务器端验证由服务器执行,以防止不完整、格式错误或恶意请求。它为你的应用程序提供了一定程度的安全性,通常在第一次接触客户端发送的数据时执行。例如,在服务器端检查必填的输入字段是否为空是此类验证的一个例子。

  • 业务验证也在服务器端执行;它实现了你的业务规则并保持你的业务数据一致性。它在你业务代码的每个级别上执行。例如,在转账前检查用户的余额是一种业务验证。我们将在第十章领域驱动设计(DDD)- 领域层中介绍业务验证。

    关于 ASP.NET Core 验证系统

    ASP.NET Core 提供了许多输入验证选项。本书通过关注 ABP 框架添加的功能来介绍基础知识。有关所有验证可能性的详细信息,请参阅 ASP.NET Core 的文档:docs.microsoft.com/en-us/aspnet/core/mvc/models/validation

本节重点介绍服务器端验证,展示如何以不同的方式执行输入验证。它还探讨了控制验证过程和处理验证异常的方法。

让我们从最简单的方式进行验证的方法开始——使用数据标注属性。

使用数据标注属性

使用数据标注属性是执行用户输入正式验证的最简单方法。请参阅以下应用服务方法:

public class ProductAppService
    : ApplicationService, IProductAppService
{
    public Task CreateAsync(ProductCreationDto input)
    {
        // TODO
    }
}

ProductAppService 是一个应用服务,在 ABP 框架中,应用服务的输入会自动进行验证,就像在 ASP.NET Core MVC 框架中的控制器一样。ProductAppService 服务接受一个输入参数,如下面的代码块所示:

public class ProductCreationDto
{
    [Required]
    [StringLength(100)]
    public string Name { get; set; }

    [Range(0, 999.99)]
    public decimal Price { get; set; }

    [Url]
    public string PictureUrl { get; set; }
    public bool IsDraft { get; set; }
}

ProductCreationDto 有三个属性被验证属性装饰。ASP.NET Core 有许多内置的验证属性,包括以下内容:

  • [Required]:验证属性不为空

  • [StringLength]:验证字符串属性的最大(和可选的最小)长度

  • [Range]:验证属性值是否在指定的范围内

  • [Url]:验证属性值具有正确的 URL 格式

  • [RegularExpression]:允许指定一个自定义正则表达式regex)来验证属性值

  • [EmailAddress]:验证属性具有正确格式的电子邮件地址值

ASP.NET Core 还允许你通过从ValidationAttribute类继承并重写IsValid方法来定义自定义验证属性。

数据注释属性非常易于使用,建议用于对数据传输对象DTOs)和模型进行正式验证。然而,当需要执行自定义代码逻辑来验证输入时,它们是有限的。

使用 IValidatableObject 接口进行自定义验证

模型或 DTO 对象可以实现IValidatableObject接口,使用自定义代码块进行验证。请参阅以下示例:

public class ProductCreationDto : IValidatableObject
{
    ...
    [Url]
    public string PictureUrl { get; set; }    
    public bool IsDraft { get; set; }    
    public IEnumerable<ValidationResult> Validate(
        ValidationContext context)
    {
        if (IsDraft == false &&
            string.IsNullOrEmpty(PictureUrl))
        {
            yield return new ValidationResult(
                "Picture must be provided to publish a
                 product",
                new []{ nameof(PictureUrl) }
            );
        }
    }
}

在此示例中,ProductCreationDto有一个自定义规则:如果IsDraftfalse,则必须提供个人资料图片。因此,我们检查条件并在这种情况下添加一个验证错误。

如果你需要从 DI 系统中解析服务,你可以使用context.GetRequiredService方法。例如,如果我们想本地化错误消息,我们可以重写Validate方法,如下面的代码块所示:

public IEnumerable<ValidationResult> Validate(
    ValidationContext context)
{
    if (IsDraft == false &&
        string.IsNullOrEmpty(PictureUrl))
    {
        var localizer = context.GetRequiredService
            <IStringLocalizer<ProductManagementResource>
            >();

        yield return new ValidationResult(
            localizer["PictureIsMissingErrorMessage"],
            new []{ nameof(PictureUrl) }
        );
    }
}

在这里,我们从 DI 中解析一个IStringLocalizer<ProductManagementResource>实例,并使用它向客户端返回本地化错误消息。我们将在第八章中介绍本地化系统,使用 ABP 的功能和服务

正式验证与业务验证

作为最佳实践,仅在 DTO/模型类中实现正式验证(例如,如果 DTO 属性未填写或未按预期格式化),并仅使用 DTO/模型类上已存在的数据。在你的应用程序或领域层服务中实现你的业务验证逻辑。例如,如果你想检查给定的产品名称是否已在数据库中存在,不要尝试在Validate方法中实现此逻辑。

使用验证属性或自定义验证逻辑,ABP 框架处理验证结果,并在执行你的方法之前抛出异常。

理解验证异常

如果用户输入无效,ABP 框架会自动抛出AbpValidationException类型的异常。异常在以下情况下抛出:

  • 输入对象是null,所以你不需要检查它是否为null

  • 输入对象在任何方面都是无效的,因此你不需要在你的 API 控制器中检查Model.IsValid

在这些情况下,ABP 不会调用你的服务方法(或控制器操作)。如果你的方法正在执行,你可以确信输入不是 null 且有效。

如果你想在服务内部执行额外的验证并抛出与验证相关的异常,你也可以抛出AbpValidationException,如下面的代码片段所示:

public async Task CreateAsync(ProductCreationDto input)
{
    if (await HasExistingProductAsync(input.Name))
    {
        throw new AbpValidationException(
            new List<ValidationResult>
            {
                new ValidationResult(
                    "Product name is already in use!",
                    new[] {nameof(input.Name)}
                )
            }
        );
    }
}

在这里,我们假设HasExistingProductAsync方法在存在具有给定名称的产品时返回true。在这种情况下,我们通过指定验证错误抛出AbpValidationExceptionValidationResult代表一个验证错误;其第一个构造函数参数是验证错误消息,第二个参数(可选)是导致验证错误的 DTO 属性名称。

一旦你或 ABP 验证系统抛出AbpValidationException异常,ABP 异常处理系统会捕获并正确处理它,正如我们将在下一个章节中看到的。

ABP 验证系统大多数时候都按你的预期工作,但有时你可能需要绕过它并应用你自定义的逻辑。

禁用验证

使用[DisableValidation]属性,可以在方法或类级别绕过 ABP 验证系统,如下面的例子所示:

[DisableValidation]
public async Task CreateAsync(ProductCreationDto input)
{
}

在这个例子中,CreateAsync方法被装饰了[DisableValidation]属性,因此 ABP 不会对input对象执行任何自动验证。

如果你为类使用[DisableValidation]属性,则所有方法都将禁用验证。在这种情况下,你可以为方法使用[EnableValidation]属性,仅为此特定方法启用验证。

当你禁用方法的自动验证时,你仍然可以执行你自定义的验证逻辑并抛出AbpValidationException,正如前一个章节所解释的。

在其他类型中的验证

ASP.NET Core 为控制器操作和 Razor 页面处理程序执行验证。除了 ASP.NET Core 之外,ABP 默认情况下还会为应用程序服务方法执行验证。

除了默认行为之外,ABP 允许你在应用程序中的任何类型的类上启用自动验证功能。你需要做的就是实现IValidationEnabled标记接口,如下面的例子所示:

public class SomeServiceWithValidation
    : IValidationEnabled, ITransientDependency
{
    ...
}

然后,ABP 使用本章中解释的验证系统自动验证这个类的所有输入。

动态代理/拦截器

ABP 使用拦截器进行动态代理以在方法调用上完成验证。如果你通过类引用(而不是接口引用)注入服务,动态代理系统使用动态继承技术。在这种情况下,你的方法必须使用virtual关键字定义,以便动态代理系统可以覆盖它并执行验证。

到目前为止,我们已经解释了与 ASP.NET Core 验证基础设施直接兼容的 ABP 验证系统。下一节将介绍FluentValidation库集成,它允许你将验证逻辑与验证对象分离。

集成 FluentValidation 库

内置的验证系统对于大多数情况来说已经足够,并且使用它来定义正式的验证规则非常容易。我个人认为它没有问题,并且发现将数据验证逻辑嵌入 DTO/模型类中是实用的。然而,一些开发者认为,即使在只有正式验证的情况下,DTO/模型类中的验证逻辑也是一种不良实践。在这种情况下,ABP 提供了一个与流行的FluentValidation库集成的包,它将验证逻辑与 DTO/模型类解耦,并提供了比标准数据注释方法更强大的功能。

如果你想要使用FluentValidation库,首先需要将其安装到你的项目中。你可以使用ABP 命令行界面(ABP CLI)的add-package命令轻松地为项目安装它,如下所示:

abp add-package Volo.Abp.FluentValidation

一旦安装了包,你就可以创建你的验证器类并设置你的验证规则,如下面的代码块所示:

public class ProductCreationDtoValidator
    : AbstractValidator<ProductCreationDto>
{
    public ProductCreationDtoValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
        RuleFor(x => x.Price).ExclusiveBetween(0, 1000);
        //...
    }
}

请参考FluentValidation文档了解如何定义高级验证规则:fluentvalidation.net

ABP 自动发现验证器类并将它们集成到验证过程中。这意味着你甚至可以将标准验证逻辑与FluentValidation验证器类混合使用。

授权和验证异常是定义良好的异常类型,ABP 会自动处理它们。下一节将探讨 ABP 异常处理系统,并解释如何处理不同类型的异常。

异常处理

应用程序最重要的质量指标之一是它如何响应错误和异常情况。一个好的应用程序应该处理错误,向客户端返回适当的响应,并优雅地通知用户问题。

在一个典型的 Web 应用程序中,我们应该关注每个客户端请求中的异常,这使得对于开发者来说,它变成了一项重复且繁琐的任务。

ABP 框架完全自动化了应用程序每个方面的错误处理。大多数时候,你不需要在应用程序代码中编写任何try-catch语句,因为它会执行以下操作:

  • 处理所有异常,记录它们,并在 API 请求中向客户端返回标准格式的错误响应,或在服务器渲染的页面中显示标准错误页面

  • 在隐藏内部基础设施错误的同时,允许你在需要时返回用户友好的、本地化的错误消息

  • 理解标准异常,如验证和授权异常,并向客户端发送适当的 HTTP 状态码

  • 在客户端处理所有错误并向最终用户显示有意义的消息

当 ABP 处理异常时,您可以抛出异常以返回用户友好的消息或业务特定的错误代码给客户端。

用户友好的异常

ABP 提供了一些预定义的异常类来定制错误处理行为。其中之一是UserFriendlyException类。

首先,为了理解UserFriendlyException类的需求,看看如果从服务器端 API 抛出一个任意异常会发生什么。以下方法抛出一个带有自定义消息的异常:

Public async Task ExampleAsync()
{
    throw new Exception("my error message...");
}

假设一个浏览器客户端通过 AJAX 请求调用该方法。它将向最终用户显示以下错误消息:

图 7.4 – 默认错误消息

图 7.4 – 默认错误消息

如您在图 7.4中看到的那样,ABP 显示了一个关于内部问题的标准错误消息。实际的错误消息被写入日志系统。服务器对于此类通用错误向客户端返回 HTTP 500 状态码。

这是一种良好的行为,因为向最终用户显示原始异常消息是没有用的。甚至可能很危险,因为它可能包含一些关于您内部系统的敏感信息,例如数据库表名和字段。

然而,您可能希望在某些特定情况下向最终用户返回一个用户友好的、信息丰富的消息。对于此类情况,您可以抛出一个UserFriendlyException异常,如下面的代码块所示:

public async Task ExampleAsync()
{
    throw new UserFriendlyException(
        "This message is available to the user!");
}

目前,ABP 没有隐藏错误消息,正如我们在这里看到的那样:

图 7.5 – 自定义错误消息

图 7.5 – 自定义错误消息

UserFriendlyException类不是唯一的。任何继承自UserFriendlyException类或直接实现IUserFriendlyException接口的异常类都可以用来返回用户友好的异常消息。当您抛出一个用户友好的异常时,ABP 向客户端返回 HTTP 403(禁止)状态码。有关所有 HTTP 状态码映射,请参阅本章的控制 HTTP 状态码部分。

在多语言应用程序中,您可能希望返回本地化消息。在这种情况下,使用本地化系统,该系统将在第八章使用 ABP 的功能和服务中介绍。

UserFriendlyException是一种特殊的业务异常,其中您直接向用户返回一条消息。

业务异常

在业务应用程序中,您将有一些业务规则,并且当根据这些规则在当前条件下请求的操作不适当执行时,您需要抛出异常。ABP 中的业务异常是 ABP 框架识别和处理的特殊类型的异常。

在最简单的情况下,您可以直接使用BusinessException类来抛出业务异常。请参见以下来自EventHub项目的示例:

public class EventRegistrationManager : DomainService
{
    public async Task RegisterAsync(
        Event @event,
        AppUser user)
    {
        if (Clock.Now > @event.EndTime)
        {
            throw new BusinessException(EventHubErrorCodes
                .CantRegisterOrUnregisterForAPastEvent);
        }
        ...
    }
}

EventRegistrationManager 是一个用于执行事件注册业务规则的领域服务。RegisterAsync 方法检查事件时间,并防止注册过去的事件,在这种情况下会抛出业务异常。

BusinessException 的构造函数接受一些参数,所有这些参数都是可选的。这些参数在此列出:

  • code:用作异常自定义错误代码的字符串值。客户端应用程序可以在处理异常时检查它,并轻松跟踪错误类型。您通常为不同的异常使用不同的错误代码。错误代码也可以用于本地化异常,正如我们将在 本地化业务异常 部分中看到的那样。

  • message:如果需要,一个字符串异常消息。

  • details:如果需要,一个详细的解释消息字符串。

  • innerException:如果有的话,一个内部异常。如果您已缓存一个异常并基于该异常抛出业务异常,可以在这里传递。

  • logLevel:此异常的日志级别。它是一个 LogLevel 类型的枚举,默认值为 LogLevel.Warning

您通常只传递 code,这在日志中更容易找到。它也用于将错误消息本地化到客户端。

本地化业务异常

如果您使用 UserFriendlyException,您必须自己本地化消息,因为异常消息会直接显示给最终用户。如果您抛出 BusinessException,除非您明确本地化它,否则 ABP 不会将异常消息显示给最终用户。它使用错误代码命名空间来达到这个目的。

假设您已使用 EventHub:CantRegisterOrUnregisterForAPastEvent 作为错误代码。在这里,EventHub通过冒号的使用成为错误代码命名空间。我们必须将错误代码命名空间映射到本地化资源,以便 ABP 能够知道为这些错误消息使用哪个本地化资源。以下代码片段展示了这一过程:

Configure<AbpExceptionLocalizationOptions>(options =>
{
    options.MapCodeNamespace(
        "EventHub", typeof(EventHubResource));
});

在此代码片段中,我们将 EventHub 错误代码命名空间映射到 EventHubResource 本地化资源。现在,您可以在本地化文件中将错误代码定义为键,包括命名空间,如下所示:

{
  "culture": "en",
  "texts": {
    "EventHub:CantRegisterOrUnregisterForAPastEvent": 
        "You can not register to or unregister from an 
         event in the past, sorry!"
  }
}

在该配置之后,每当您使用该错误代码抛出 BusinessException 异常时,ABP 都会向用户显示本地化消息。

在某些情况下,您可能希望在错误消息中包含一些额外的数据。请参阅以下代码片段:

throw new BusinessException(
    EventHubErrorCodes.OrganizationNameAlreadyExists
).WithData("Name", name);

在这里,我们使用 WithData 扩展方法在错误消息中包含组织名称。然后,我们可以定义本地化字符串,如下面的代码片段所示:

"EventHub:OrganizationNameAlreadyExists": "The organization {Name} already exists. Please use another name."

在此示例中,{Name} 是组织名称的占位符。ABP 会自动将其替换为给定的名称。

我们将在 第八章 中介绍本地化系统,使用 ABP 的功能和服务

我们已经看到了如何抛出BusinessException异常。如果您想创建专门的异常类怎么办?

自定义业务异常类

也可以创建自定义异常类,而不是直接抛出BusinessException异常。在这种情况下,您可以创建一个新的类,从BusinessException类继承,如下面的代码块所示:

public class OrganizationNameAlreadyExistsException
    : BusinessException
{
    public string Name { get; private set; }
    public OrganizationNameAlreadyExistsException(
        string name) : base(EventHubErrorCodes
        .OrganizationNameAlreadyExists)
    {
        Name = name;
        WithData("Name", name);
    }
}

在此示例中,OrganizationNameAlreadyExistsException是一个自定义业务异常类。它在构造函数中接受组织的名称。它设置"Name"数据,以便 ABP 可以在本地化过程中使用组织名称。抛出此异常非常直接,如下所示:

throw new OrganizationNameAlreadyExistsException(name);

这种用法比抛出带有自定义数据的BusinessException异常简单,开发者可能会忘记设置这些数据。它还减少了在代码库的多个地方抛出相同异常时的重复。

控制异常记录

如在异常处理部分开头所述,ABP 会自动记录所有异常。业务异常、授权和验证异常以Warning级别记录,而其他错误默认以Error级别记录。

您可以实现IHasLogLevel接口为异常类设置不同的日志级别。请参见以下示例:

public class MyException : Exception, IHasLogLevel
{
    public LogLevel LogLevel { get; set; } =
        LogLevel.Warning;
    //...
}

MyException类使用Warning级别实现了IHasLogLevel接口。如果您抛出MyException类型的异常,ABP 将写入警告日志。

还可以为异常写入额外的日志。您可以通过实现IExceptionWithSelfLogging接口来写入额外的日志,如下面的示例所示:

public class MyException
    : Exception, IExceptionWithSelfLogging
{
    public void Log(ILogger logger)
    {
        //...log additional info
    }
}

在此示例中,MyException类实现了IExceptionWithSelfLogging接口,该接口定义了一个Log方法。ABP 将记录器传递到这里,以便您在需要时写入额外的日志。

控制 HTTP 状态码

ABP 会尽力为已知异常类型返回适当的 HTTP 状态码,如下所示:

  • 如果用户未登录,则对于AbpAuthorizationException返回401(未授权)

  • 如果用户已登录,则对于AbpAuthorizationException返回403(禁止)

  • 对于AbpValidationException返回400(错误请求)

  • 对于EntityNotFoundException返回404(未找到)

  • 对于业务和用户友好的异常返回403(禁止)

  • 对于NotImplementedException返回501(未实现)

  • 对于其他异常(假设为基础设施错误)返回500(内部服务器错误)

如果您想为自定义异常返回另一个 HTTP 状态码,可以将您的错误代码映射到 HTTP 状态码,如下面的配置所示:

services.Configure<AbpExceptionHttpStatusCodeOptions>(
    options =>
{
    options.Map(
        EventHubErrorCodes.OrganizationNameAlreadyExists,
        HttpStatusCode.Conflict);
});

建议在解决方案的 Web 或 HTTP API 层进行此配置。

摘要

在本章中,我们探讨了在每个严肃的商业应用程序中都应该实现的三项基本横切关注点。

授权是系统安全的关键关注点。你应该仔细控制应用程序每个操作中的授权规则。ABP 简化了 ASP.NET Core 授权基础设施的使用,并添加了一个灵活的权限系统,这对于企业应用程序来说是一个非常常见的模式。

另一方面,验证通过优雅地阻止格式错误或恶意请求来支持系统安全并提高用户体验。ABP 通过允许你在应用程序的任何服务中实现验证并将其集成到FluentValidation库以进行高级使用,增强了标准的 ASP.NET Core 验证。

最后,ABP 的异常处理系统工作无缝,并在服务器端和客户端自动处理异常。它还允许你将本地化错误消息与抛出异常的代码中的 HTTP 状态代码解耦并映射。

下一章将继续通过介绍一些有趣的 ABP 功能,如自动审计日志和数据过滤,来探索 ABP 框架服务。

第八章:第八章:使用 ABP 的功能和服务

ABP 框架是一个全栈应用程序开发框架,因此它为企业的每个方面提供了许多构建块。在前三章中,我们已经探讨了 ABP 框架提供的核心服务、数据访问基础设施和横切关注点解决方案。

在本节“第二部分”的最后一章,“ABP 框架基础”中,我们将继续探讨在业务应用程序中经常使用的 ABP 功能,顺序如下:

  • 获取当前用户

  • 使用数据过滤系统

  • 控制审计日志系统

  • 缓存数据

  • 本地化用户界面(UI

技术要求

如果您想跟随并尝试这些示例,您需要安装一个集成开发环境IDE)/编辑器(例如 Visual Studio)来构建 ASP.NET Core 项目。

您可以从以下 GitHub 仓库下载代码示例:github.com/PacktPublishing/Mastering-ABP-Framework.

获取当前用户

如果您的应用程序需要某些功能进行用户身份验证,通常您需要获取当前用户的信息。ABP 提供了ICurrentUser服务来获取当前登录用户的详细信息。对于 Web 应用程序,ICurrentUser的实现完全集成到 ASP.NET Core 的认证系统中,因此您可以轻松获取当前用户的声明。

以下代码块展示了ICurrentUser服务的简单用法:

using System;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Users;
namespace DemoApp
{
    public class MyService : ITransientDependency
    {
        private readonly ICurrentUser _currentUser;
        public MyService(ICurrentUser currentUser)
        {
            _currentUser = currentUser;
        }
        public void Demo()
        {
            Guid? userId = _currentUser.Id;
            string userName = _currentUser.UserName;
            string email = _currentUser.Email;
        }
    }
}

在此示例中,MyService构造函数注入了ICurrentUser服务,然后获取当前用户的唯一IdUsernameEmail值。

下面是ICurrentUser接口的属性:

  • IsAuthenticatedbool):如果当前用户已登录(认证),则返回true

  • IdGuid?):如果当前用户未登录,则为null

  • UserNamestring):当前用户的用户名。如果当前用户未登录,则返回null

  • TenantIdGuid?):当前用户的租户 ID。对于多租户应用程序是可用的。如果当前用户与租户无关,则返回null

  • Emailstring):当前用户的电子邮件地址。如果当前用户未登录或未设置电子邮件地址,则返回null

  • EmailVerifiedbool):如果当前用户的电子邮件地址已验证,则返回true

  • PhoneNumberstring):当前用户的电话号码。如果当前用户未登录或未设置电话号码,则返回null

  • PhoneNumberVerifiedbool):如果当前用户的电话号码已验证,则返回true

  • Rolesstring[]):当前用户的所有角色作为一个字符串数组。

    注入 ICurrentUser 服务

    ICurrentUser是一个广泛使用的服务。因此,一些基础 ABP 类(如ApplicationServiceAbpController)预先注入了它。在这些类中,你可以直接使用CurrentUser属性,而不是手动注入此服务。

ABP 可以与任何身份验证提供者一起工作,因为它使用 ASP.NET Core 提供的当前声明。声明是在用户登录时发放的键值对,并存储在身份验证票据中。如果你使用基于 cookie 的身份验证,它们存储在 cookie 中,并在每次请求中发送到服务器。如果你使用基于令牌的身份验证,它们由客户端在每次请求中发送,通常在超文本传输协议HTTP)头中。

ICurrentUser服务从当前声明中获取所有信息。如果你想直接查询当前声明,可以使用FindClaimFindClaimsGetAllClaims方法。如果你创建了自定义声明,这些方法特别有用。

定义自定义声明

ABP 提供了一种简单的方法将你的自定义声明添加到身份验证票据中,以便你可以在同一用户的下一个请求中安全地获取这些自定义值。你可以实现IAbpClaimsPrincipalContributor接口,将自定义声明添加到身份验证票据中。

在以下示例中,我们正在将社会保险号码信息——一个自定义声明——添加到身份验证票据中:

public class SocialSecurityNumberClaimsPrincipalContributor 
    : IAbpClaimsPrincipalContributor, ITransientDependency
{
    public async Task ContributeAsync(
        AbpClaimsPrincipalContributorContext context)
    {
        ClaimsIdentity identity = context.ClaimsPrincipal
            .Identities.FirstOrDefault();
        var userId = identity?.FindUserId();
        if (userId.HasValue)
        {
            var userService = context.ServiceProvider
              .GetRequiredService<IUserService>();            
            var socialSecurityNumber = await userService
              .GetSocialSecurityNumberAsync(userId.Value);
            if (socialSecurityNumber != null)
            {
                identity.AddClaim(new Claim
                  ("SocialSecurityNumber",   
                    socialSecurityNumber));
            }
        }
    }
}

在此示例中,我们首先获取ClaimsIdentity并找到当前用户的 ID。然后,我们从IUserService获取社会保险号码,这是一个你应该自己开发的自定义服务。你可以从ServiceProvider获取任何服务来查询所需的数据。最后,我们向identity添加一个新的ClaimSocialSecurityNumberClaimsPrincipalContributor随后在用户登录应用程序时使用。

你可以使用自定义声明来授权当前用户满足特定业务需求、过滤数据或仅显示在 UI 上。请注意,除非你使身份验证票据无效并强制用户重新认证,否则身份验证票据的声明不能更改,因此不要在声明中存储频繁更改的数据。如果你的目的是存储可以在以后快速访问的用户数据,你可以使用缓存系统(将在数据缓存部分介绍)。

ICurrentUser是在你的应用程序代码中频繁使用的核心服务。下一节将介绍数据过滤系统,它在大多数情况下可以无缝工作。

使用数据过滤系统

在数据库操作中过滤数据是非常常见的。如果你使用WHERE子句。如果你在 C#中使用Where扩展方法。虽然这些过滤条件在查询中可能有所不同,但如果你实现了如软删除和多租户等模式,一些表达式将应用于你运行的每个查询。

ABP 自动化数据过滤过程,帮助你避免在应用程序代码的每个地方重复相同的过滤逻辑。

在本节中,我们将首先了解 ABP 框架的预构建数据过滤器,然后学习如何在需要时禁用这些过滤器。最后,我们将看到如何实现我们自己的自定义数据过滤器。

我们通常使用简单的接口来为实体启用过滤功能。ABP 定义了两个预定义的数据过滤器来实现软删除和多租户模式。

软删除数据过滤器

如果你为实体使用软删除模式,你永远不会在数据库中物理删除该实体。相反,你将其标记为 已删除

ABP 定义了 ISoftDelete 接口,以标准化标记实体为软删除的属性。你可以为实体实现该接口,如下面的代码块所示:

public class Order : AggregateRoot<Guid>, ISoftDelete
{
    public bool IsDeleted { get; set; }
    //...other properties
}

在这个例子中,Order 实体有一个由 ISoftDelete 接口定义的 IsDeleted 属性。一旦你实现了该接口,ABP 会为你自动完成以下任务:

  • 当你删除一个订单时,ABP 会识别出 Order 实体实现了软删除模式,阻止删除操作,并将 IsDeleted 设置为 true。因此,订单在数据库中不会被物理删除。

  • 当你查询订单时,ABP 会自动过滤已删除的实体(通过在查询中添加 IsDeleted == false 条件),以避免意外从数据库中检索已删除的订单。

数据过滤与查询相关,因此,第一个任务与数据过滤没有直接关系,而是由 ABP 框架实现的支持逻辑。

数据过滤限制

数据过滤自动化仅在您使用存储库或 DbContext(对于 DELETESELECT 命令,您应该自己处理,因为 ABP 在这些情况下无法拦截您的操作)时才有效。

软删除过滤器是 ABP 数据过滤器中内置的一种。另一种内置过滤器用于多租户。

多租户数据过滤器

多租户是一种广泛使用的模式,用于在软件即服务(SaaS)解决方案中共享租户之间的资源。在多租户应用程序中隔离不同租户之间的数据至关重要。一个租户不能读取或写入另一个租户的数据,即使它们位于同一个物理数据库中。

ABP 拥有一个完整的多租户系统,这将在第十六章中详细解释,实现多租户。然而,在这里提及多租户过滤器也是好的,因为它与数据过滤系统相关。

ABP 定义了 IMultiTenant 接口,以启用实体的多租户数据过滤器。我们可以为实体实现该接口,如下面的代码块所示:

public class Order : AggregateRoot<Guid>, IMultiTenant
{
    public Guid? TenantId { get; set; }
    //...other properties
}

IMultiTenant 接口定义了 TenantId 属性,如下面的示例所示。ABP 使用 Guid 值作为租户 ID。

一旦我们实现了IMultiTenant接口,ABP 将自动使用当前租户的 ID 对所有Order实体的查询进行过滤。当前租户的 ID 是从ICurrentTenant服务中获取的,这将在第十六章中解释,实现多租户

多个数据过滤器的使用

对于同一实体,可以启用多个数据过滤器。例如,本节中定义的Order实体可以同时实现ISoftDeleteIMultiTenant接口。

如您所见,为实体实现数据过滤器相当简单——只需实现与数据过滤器相关的接口。除非您明确禁用它们,否则所有数据过滤器默认启用。

禁用数据过滤器

在某些情况下,禁用自动过滤器可能是必要的——例如,您可能想要禁用软删除过滤器以从数据库中读取已删除的实体,或者您可能希望允许用户恢复已删除的实体。您可能想要禁用多租户过滤器以查询多租户系统中的所有租户的数据。无论出于何种原因,ABP 都提供了一个简单且安全的方法来禁用数据过滤器。

以下示例展示了如何通过使用IDataFilter服务禁用ISoftDelete数据过滤器来从数据库中获取所有订单,包括已删除的订单:

public class OrderService : ITransientDependency
{
    private readonly IRepository<Order, Guid> 
    _orderRepository;
    private readonly IdataFilter _dataFilter;
    public OrderService(
        Irepository<Order, Guid> orderRepository,
        IdataFilter dataFilter)
    {
        _orderRepository = orderRepository;
        _dataFilter = dataFilter;
    }
    public async Task<List<Order>> GetAllOrders()
    {
        using (_dataFilter.Disable<IsoftDelete>())
        {
            return await _orderRepository.GetListAsync();
        }
    }
}

在本例中,OrderService注入了Order存储库和IdataFilter服务。然后它使用_dataFilter.Disable<IsoftDelete>()表达式来禁用软删除过滤器。在using语句中,过滤器被禁用,我们也可以查询已删除的订单。

总是使用 using 语句

Disable方法返回一个可处置的对象,这样我们就可以在using语句中使用它。一旦using块结束,过滤器将自动恢复到之前的状态,这意味着如果它在using块之前已启用,它将返回到启用状态。如果它在using语句之前已经禁用,则Disable方法不会影响它,并且它在using语句之后保持禁用状态。这个系统允许我们安全地禁用过滤器,而不会影响调用GetAllOrders方法的任何逻辑。始终建议在using语句中禁用过滤器。

IdataFilter服务提供了另外两个方法:

  • Enable<Tfilter>:启用数据过滤器。您可以使用此方法在禁用过滤器的范围内临时启用数据过滤器。如果过滤器已经启用,则此方法没有效果。始终建议在using语句中启用过滤器,就像使用Disable方法一样。

  • IsEnabled<Tfilter>:如果给定的过滤器当前已启用,则返回true。您通常不需要此方法,因为EnableDisable在两种情况下都按预期工作。

我们已经学习了如何使用预构建的DisableEnable数据过滤器。下一节将展示如何创建自定义数据过滤器。

定义自定义数据过滤器

正如与预构建的数据过滤器一样,你可能想定义自己的过滤器。数据过滤器由一个接口表示,因此第一步是为你的过滤器定义一个接口。

假设你想要存档你的实体,并自动过滤存档数据,以便默认情况下不将它们检索到应用程序中。对于这个例子,我们可以定义这样一个简单的接口(你可以在你的领域层中定义这个接口),如下所示:

public interface Iarchivable
{
    bool IsArchived { get; }
}

IsArchived 属性将用于过滤实体。默认情况下,具有 IsArchivedtrue 的实体将被排除。一旦我们定义了这样的接口,我们就可以为可以存档的实体实现它。请看以下示例:

public class Order : AggregateRoot<Guid>, Iarchivable
{
    public bool IsArchived { get; set; }
    //...other properties
}

在这个例子中,Order 实体实现了 Iarchivable 接口,这使得可以在该实体上应用数据过滤器。

注意,Iarchivable 接口没有为 IsArchived 定义设置器,但 Order 实体定义了它。这是我的设计决策;我们不需要在接口上设置 IsArchived,但需要在实体上设置它。

由于数据过滤是在数据库提供程序级别完成的,因此自定义过滤器实现也取决于数据库提供程序。本节将展示如何为 EF Core 提供程序实现 Iarchivable 过滤器。如果你在寻找 MongoDB,请参阅 ABP 的文档:docs.abp.io/en/abp/latest/Data-Filtering

ABP 使用 EF Core 的 DbContext 类。

第一步是在你的 DbContext 类中定义一个属性,该属性将用于过滤表达式,如下所示:

protected bool IsArchiveFilterEnabled => DataFilter?.IsEnabled<Iarchivable>() ?? false;

此属性直接使用 IdataFilter 服务来获取过滤状态。DataFilter 属性来自基 AbpDbContext 类,如果 DbContext 实例未从 null 检查中解析出来,则它可以是 null

下一步是重写 ShouldFilterEntity 方法以决定是否应该过滤给定的实体类型:

protected override bool ShouldFilterEntity<Tentity>(
    ImutableEntityType entityType)
{
    If (typeof(IArchivable) 
        .IsAssignableFrom(typeof(TEntity)))
    {
        return true;
    }

    return base.ShouldFilterEntity<TEntity>(entityType);
}

ABP 框架为这个 DbContext 类中的每个实体类型调用此方法(它只调用一次——在应用程序启动后第一次使用 DbContext 类时)。如果此方法返回 true,则启用该实体的 EF Core 全局过滤器。在这里,我只是检查了给定的实体是否实现了 IArchivable 接口,并在该情况下返回 true。否则,调用 base 方法,以便它检查其他数据过滤器。

ShouldFilterEntity 只决定是否启用过滤。实际的过滤逻辑应该通过重写 CreateFilterExpression 方法来实现:

protected override Expression<Func<TEntity, bool>> CreateFilterExpression<TEntity>()
{
    var expression = 
        base.CreateFilterExpression<Tentity>();
    if (typeof(Iarchivable)  
        .IsAssignableFrom(typeof(TEntity)))
    {
        Expression<Func<TEntity, bool>> archiveFilter =
            e => !IsArchiveFilterEnabled ||
                 !EF.Property<bool>(e, "IsArchived");
        expression = expression == null 
            ? archiveFilter 
            : CombineExpressions(expression, 
                archiveFilter);
    }
    return expression;
}

实现似乎有点复杂,因为它创建了并组合了表达式。重要的是如何定义archiveFilter表达式。!IsArchiveFilterEnabled检查过滤器是否已禁用。如果过滤器已禁用,则不会评估其他条件,并且将检索所有实体而不进行过滤。!EF.Property<bool>(e, "IsArchived")检查该实体的IsArchived值是否为false,因此消除了IsArchived值为true的实体。

如您从前面的代码块中看到的,我未在过滤器实现中使用Order实体。这意味着实现是通用的,可以与任何实体类型一起工作——您只需要为要应用过滤器的实体实现IArchivable接口。

总结来说,ABP 使我们能够轻松创建和控制全局查询过滤器。它还使用该系统实现两种流行的模式——软删除和多租户。下一节将介绍审计日志系统,这是 ABP 的另一个在企业级软件解决方案中非常常见的功能。

控制审计日志系统

ABP 的审计日志系统跟踪所有请求和实体更改,并将它们写入数据库。然后,您可以获取关于在您的应用程序中做了什么、何时做的以及谁做的报告。

当您从启动模板创建新解决方案时,审计日志系统已安装并正确配置。大多数时候,您无需任何配置即可使用它。然而,ABP 允许您控制、自定义和扩展审计日志系统。但首先,让我们了解审计日志对象是什么。

审计日志对象

审计日志对象是一组在有限的作用域内(通常是在 Web 应用的 HTTP 请求中)一起执行的动作和相关实体更改。我们将在下一节中更多地讨论审计日志作用域。

图 8.1 中的图表示审计日志对象:

图 8.1 – 审计日志对象

图 8.1 – 审计日志对象

让我们从根对象开始解释该图,如下所示:

  • AuditLogInfo: 在每个作用域(通常是一个 Web 请求)中,都有一个包含有关当前用户、当前租户、HTTP 请求、客户端和浏览器详细信息以及操作执行时间和持续时间的AuditLogInfo对象。

  • AuditLogActionInfo: 在每个审计日志中,可能有零个或多个动作。动作通常是控制器动作调用、页面处理程序调用或应用程序服务方法调用。它包括调用中的类名、方法名和方法参数。

  • EntityChangeInfo: 审计日志对象可能包含零个或多个对数据库中实体的更改。每个实体更改包含更改类型(创建、更新或删除)、实体类型(完整类名)以及更改实体的 ID。

  • EntityPropertyChangeInfo:对于每个实体变化,它会在属性(数据库中的字段)上记录变化。此对象包含受影响属性的名字、类型、旧值和新值。

  • Exception:在这次审计日志作用域中发生的异常列表。

  • Comment:与这个审计日志相关的附加评论/日志。

审计日志对象被保存到关系数据库的多个表中:AbpAuditLogsAbpAuditLogActionsAbpEntityChangesAbpEntityPropertyChanges。我在前面的列表中已经写出了审计日志对象的基本属性。你可以检查这些数据库表或调查 AuditLogInfo 对象以查看所有详细信息。

MongoDB 限制

由于 ABP 使用 EF Core 的更改跟踪系统来获取实体更改信息,而 MongoDB 驱动程序没有这样的更改跟踪系统,因此不会为 MongoDB 记录实体更改。

如本节开头所述,每个审计日志作用域都会创建一个审计日志对象。

审计日志作用域

审计日志作用域使用 环境上下文模式。当你创建一个新的审计日志作用域时,在这个作用域中进行的所有操作和更改都会保存为一个单独的审计日志对象。

有几种方法可以建立审计日志作用域。

审计日志中间件

创建审计日志作用域的第一种和最常见的方式是在 ASP.NET Core 管道配置中使用审计日志中间件:

app.UseAuditing();

这通常放置在 app.UseEndpoints()app.UseConfiguredEndpoints() 端点配置之前。当你使用这个中间件时,每个 HTTP 请求都会写入一个单独的审计日志记录,这在大多数情况下是期望的行为,并且默认情况下已经在启动模板中配置好了。

审计日志拦截器

如果你没有使用审计日志中间件,或者如果你的应用程序不是请求/回复风格的 ASP.NET Core 应用程序(例如,桌面或 Blazor Server 应用程序),那么 ABP 会为每个应用程序服务方法创建一个新的审计日志作用域。

手动创建审计作用域

你通常不需要这样做,但如果你想手动创建审计作用域,可以使用 IAuditingManager 服务,如下面的代码块所示:

public class MyServiceWithAuditing : ITransientDependency
{
    //...inject IAuditingManager _auditingManager;
    public async Task DoItAsync()
    {
        using (var auditingScope = 
            _auditingManager.BeginScope())
        {
            try
            {
                //TODO: call other services...
            }
            catch (Exception ex)
            {  _auditingManager.Current.Log.Exceptions.Add(ex);
                throw;
            }
            finally
            {
                await auditingScope.SaveAsync();
            }
        }
    }
}

一旦注入了 IAuditingManager 服务,你可以使用 BeginScope 方法创建一个新的作用域。然后,创建一个 try-catch 块来保存审计日志,包括异常情况。在 try 部分中,你只需执行你的逻辑,调用其他服务等。所有这些操作以及这些操作中的更改都作为单个审计日志对象保存在 finally 块中。

在审计日志作用域内(无论是由 ABP 创建还是您手动创建),可以使用 _auditingManager.Current.Log 来获取当前的审计日志对象以进行调查或操作它(例如,添加注释行或附加信息)。如果您不在审计日志作用域内,则 _auditingManager.Current 返回 null,因此如果您不确定是否存在周围的审计日志作用域,请检查 null

我已经介绍了审计日志对象和审计日志作用域,它们默认情况下可以无缝工作。现在,让我们看看选项,以了解默认值和审计日志系统的全局配置可能性。

审计选项

AbpAuditingOptions 类用于配置审计系统的默认选项。它可以使用标准的 options 模式进行配置,如下面的示例所示:

Configure<AbpAuditingOptions>(options =>
{
    options.IsEnabled = false;
});

您可以在模块的 ConfigureServices 方法中配置 options。以下列表显示了审计系统的主要选项:

  • IsEnabled (bool; 默认: true): 完全禁用审计系统的主要点。

  • IsEnabledForGetRequests (bool; 默认: false): ABP 默认不保存 HTTP GET 请求的审计日志,因为 GET 请求不应该更改数据库。但是,您可以将其设置为 true,这样也可以为 GET 请求启用审计日志。

  • IsEnabledForAnonymousUsers (bool; 默认: true): 如果您只想为认证用户写入审计日志,请将其设置为 false。如果您为匿名用户保存审计日志,您将看到这些用户的 UserId 值为 null

  • AlwaysLogOnException (bool; 默认: true): 如果您的应用程序代码中发生异常,ABP 默认会保存审计日志,而不考虑 IsEnabledForGetRequestsIsEnabledForAnonymousUsers 选项。将此设置为 false 以禁用该行为。

  • hideErrors (bool; 默认: true): ABP 在将审计日志对象保存到数据库时忽略异常。将此设置为 false 以抛出异常而不是隐藏它们。

  • ApplicationName (string; 默认: null): 如果多个应用程序使用相同的数据库来保存审计日志,您可以在每个应用程序中设置此选项,以便可以根据应用程序名称过滤日志。

  • IgnoredTypes (List<Type>): 您可以在审计日志系统中忽略一些特定的类型,包括实体类型。

除了这些简单的全局选项之外,您还可以为实体启用/禁用更改跟踪。

启用实体历史记录

审计日志对象包含具有属性详细信息的实体更改。然而,默认情况下它对所有实体都是禁用的,因为它可能会将过多的日志写入数据库,这可能会迅速增加数据库的大小。建议以受控的方式为要跟踪的实体启用它。

为实体启用实体历史记录有两种方式,如下所述:

  • 使用 [Auditing] 属性为单个实体启用它。它将在下一节中解释。

  • EntityHistorySelectors 选项用于为多个实体启用它。

在以下示例中,我为所有实体启用了 EntityHistorySelectors 选项:

Configure<AbpAuditingOptions>(options =>
{
    options.EntityHistorySelectors.AddAllEntities();
});

AddAllEntities 方法是一个快捷方式。EntityHistorySelectors 是一个命名选择器的列表,你可以添加一个 lambda 表达式来选择你想要的实体。以下代码与前面的配置代码等效:

Configure<AbpAuditingOptions>(options =>
{
    options.EntityHistorySelectors.Add(
        new NamedTypeSelector("MySelectorName", type => 
            true)
    );
});

NamedTypeSelector 的第一个参数是选择器名称——在这个例子中是 MySelectorName。选择器名称是任意的,并且可以在以后用来在选择器列表中查找或删除选择器。通常你不会使用它;只需给它一个独特的名称。NamedTypeSelector 的第二个参数接受一个表达式。它为你提供一个实体 type 并等待 truefalse。如果你想为给定的实体类型启用实体历史记录,则返回 true。因此,你可以传递一个如 type => type.Namespace.StartsWith("MyRootNamespace") 的表达式来选择所有具有命名空间的实体。你可以添加你需要的任意数量的选择器。所有选择器都会被测试。如果其中任何一个返回 true,则实体将被选中以记录属性更改。

除了这些全局选项和选择器之外,还有方法可以按类、方法和属性级别启用/禁用审计日志。

详细禁用和启用审计日志

当你使用审计日志系统时,通常你想要记录每一次访问。然而,在某些情况下,你可能想要禁用某些特定操作或实体的审计日志。以下是一些可能的原因:操作参数可能对写入日志是危险的(例如,它可能包含用户的密码),操作调用或实体更改可能超出用户控制,因此不值得为了审计目的记录,或者操作可能是一个大量操作,它写入过多的审计日志并降低性能。

ABP 定义了 [DisableAuditing][Audited] 属性来声明性地控制记录的对象。你可以控制审计日志的两个目标:服务调用和实体历史记录。

控制服务调用的审计日志

应用服务方法、Razor 页面处理程序以及类或方法级别的 [DisableAuditing] 属性。

以下示例在应用服务类上使用了 [DisableAuditing] 属性:

[DisableAuditing]
public class OrderAppService : ApplicationService, IOrderAppService
{
    public async Task CreateAsync(CreateOrderDto input)
    {
    }
    public async Task DeleteAsync(Guid id)
    {
    }
}

使用这种用法,ABP 不会将这些方法的执行包括在审计日志对象中。如果你只想禁用其中一个方法,你可以在方法级别使用它:

public class OrderAppService : ApplicationService, IOrderAppService
{
    [DisableAuditing]
    public async Task CreateAsync(CreateOrderDto input)
    {
    }
    public async Task DeleteAsync(Guid id)
    {
    }
}

在这种情况下,CreateAsync 方法调用不包括在审计日志中,而 DeleteAsync 方法调用被写入审计日志对象。同样的行为可以使用以下代码实现:

[DisableAuditing]
public class OrderAppService : ApplicationService, IOrderAppService
{
    public async Task CreateAsync(CreateOrderDto input)
    {
    }
    [Audited]
    public async Task DeleteAsync(Guid id)
    {
    }
}

我禁用了所有方法(除了 DeleteAsync 方法),因为 DeleteAsync 方法声明了 [Audited] 属性。

[Audited]属性可以用于任何类(与 DI 系统一起使用)以在该类上启用审计日志,即使该类默认不进行审计日志记录。此外,您可以在任何类的任何方法中使用它,只为该特定方法调用启用它。如果您在类上使用[Audited]属性,然后可以使用[DisableAuditing]属性禁用特定方法。

当 ABP 在审计日志对象中包含方法调用信息时,它也会包含执行方法的所有参数。这对于了解您的系统中哪些更改非常有用;然而,在某些情况下,您可能想排除输入的一些属性。考虑一个场景,您从用户那里获取信用卡信息。您可能不想将其包含在审计日志中。在这种情况下,您可以在输入对象的任何属性上使用[DisableAuditing]属性。请参阅以下示例,它从Dto输入的属性中排除了审计日志:

public class CreateOrderDto
{
    public Guid CustomerId { get; set; }
    public string DeliveryAddress { get; set; }
    [DisableAuditing]
    public string CreditCardNumber { get; set; }
}

对于此示例,ABP 不会将CreditCardNumber值写入审计日志。

禁用方法调用审计日志不会影响实体历史记录。如果一个实体被更改并且它被选中进行审计日志记录,更改仍然会被记录。下一节将解释如何控制实体历史记录的审计日志系统。

控制实体历史记录的审计日志

启用实体历史记录部分,我们看到了如何通过定义选择器来为一个或多个实体启用实体历史记录。然而,如果您只想为单个实体启用实体历史记录,有一个替代且更简单的方法:只需在您的实体类上方添加[Audited]属性:

[Audited]
public class Order : AggregateRoot<Guid>
{
}

在此示例中,我向Order实体添加了[Audited]属性,以配置审计日志系统为该实体启用实体历史记录。

假设您已使用选择器为许多或所有实体启用了实体历史记录,但想为特定实体禁用它们。在这种情况下,您可以使用该实体类的[DisableAuditing]属性。

[DisableAuditing]属性也可以用于实体的属性,以排除此属性从审计日志中,如下例所示:

[Audited]
public class Order : AggregateRoot<Guid>
{
    public Guid CustomerId { get; set; }
    [DisableAuditing]
    public string CreditCardNumber { get; set; }
}

对于那个例子,ABP 不会将CreditCardNumber值写入审计日志。

存储审计日志

ABP 框架的核心设计是通过在需要接触数据源的地方引入抽象,不假设任何数据存储。审计日志系统也不例外。它定义了IAuditingStore接口来抽象审计日志对象保存的位置。该接口只有一个方法:

Task SaveAsync(AuditLogInfo auditInfo);

您可以实现此接口以将审计日志保存到您想要的位置。如果您使用 ABP 的启动模板创建新解决方案,它已配置为将审计日志保存到应用程序的主要数据库中,因此您通常不需要手动实现IAuditingStore接口。

我们已经看到了控制和管理审计日志系统的方法。审计日志是企业系统跟踪和记录系统更改的必要系统。下一节将介绍缓存系统,这是 Web 应用程序的另一个基本功能。

缓存数据

缓存是提高应用程序性能和可伸缩性的最基本系统之一。ABP 扩展了 ASP.NET Core 的分布式缓存系统,并使其与 ABP 框架的其他功能兼容,如多租户。

如果您运行多个应用程序实例或拥有分布式系统,如微服务解决方案,分布式缓存是必不可少的。它提供了不同应用程序之间的一致性,并允许共享缓存值。分布式缓存通常是一个外部独立的应用程序,例如 Redis 和 Memcached。

即使您的应用程序只有一个运行实例,也建议使用分布式缓存系统。无需担心性能,因为分布式缓存的默认实现是在内存中工作的。这意味着除非您明确配置一个真实的分布式缓存提供者,如 Redis,否则它不是分布式的。

ASP.NET Core 中的分布式缓存

本节重点介绍 ABP 的缓存功能,并不涵盖所有 ASP.NET Core 的分布式缓存系统功能。您可以参考 Microsoft 的文档来了解有关 ASP.NET Core 中分布式缓存的更多信息:docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed

在本节中,我将向您展示如何使用IDistributedCache<T>接口,配置选项,并处理错误处理和批量操作。我们还将了解如何使用 Redis 作为分布式缓存提供者。最后,我将讨论如何使缓存值无效。

让我们从基础开始——IDistributedCache<T>接口。

使用 IDistributedCache接口

ASP.NET Core 定义了一个IDistributedCache接口,但它不是类型安全的。它设置和获取byte数组而不是对象。ABP 的IDistributedCache<T>接口,另一方面,被设计为泛型,具有类型安全的参数方法(T代表存储在缓存中的项的类型)。它内部使用标准的IDistributedCache接口,以确保与 ASP.NET Core 的缓存系统 100%兼容。ABP 的IDistributedCache<T>接口有两个主要优势,如下所示:

  • 自动将对象序列化和反序列化为byte数组。因此,您无需处理序列化和反序列化。

  • 它自动将缓存名称前缀添加到缓存键中,以便可以使用相同的键用于不同类型的缓存对象。

使用IDistributedCache<T>接口的第一步是定义一个类来表示缓存中的项。我已经定义了以下类来在缓存中存储用户信息:

public class UserCacheItem
{
    public Guid Id { get; set; }
    public string UserName { get; set; }
    public string EmailAddress { get; set; }
}

这是一个普通的 C# 类。唯一的限制是它应该是可序列化的,因为它在保存到缓存时被序列化为 JSON,在从缓存读取时被反序列化(例如,不要添加引用到其他对象,这些对象不应该或不能存储在缓存中;保持简单)。

一旦我们定义了缓存项类,我们就可以注入 IDistributedCache<T> 接口,如下面的代码块所示:

public class MyUserService : ITransientDependency
{
    private readonly IDistributedCache<UserCacheItem> 
        _userCache;
    public MyUserService(IDistributedCache<UserCacheItem> 
        userCache)
    {
        _userCache = userCache;
    }
}

我已注入了 IDistributedCache<UserCacheItem> 服务以处理 UserCacheItem 对象的分布式缓存。以下代码块显示了我们可以如何使用它来获取缓存的用户信息,如果给定的用户未在缓存中找到,则回退到数据库查询:

public async Task<UserCacheItem> GetUserInfoAsync(Guid userId)
{
    return await _userCache.GetOrAddAsync(
        userId.ToString(), 
        async () => await GetUserFromDatabaseAsync(userId),
        () => new DistributedCacheEntryOptions
        {
            AbsoluteExpiration = 
                DateTimeOffset.Now.AddHours(1)
        }
    );
}

我已向 GetOrAddAsync 方法传递了三个参数:

  • 第一个参数是缓存键,它应该是一个字符串值,因此我将 Guid userId 值转换为字符串值。

  • 第二个参数是一个工厂方法,如果给定的键未在缓存中找到,则会执行。我在这里传递了 GetUserFromDatabaseAsync 方法。在该方法中,您应从其数据源构建缓存项。

  • 最后一个参数是一个工厂方法,它返回一个 DistributedCacheEntryOptions 对象。这是可选的,并配置了缓存项的过期时间。只有当 GetOrAddAsync 方法添加条目时,才会调用工厂方法。

缓存键默认为 string 数据类型。然而,ABP 定义了另一个接口 IDistributedCache<TCacheItem, TCacheKey>,允许您指定缓存键,这样您就不需要手动将您的键转换为 string 数据类型。我们可以注入 IDistributedCache<UserCacheItem, Guid> 服务,并移除此示例中第一个参数的 ToString() 使用。

DistributedCacheEntryOptions 提供以下选项来控制缓存项的生存周期:

  • AbsoluteExpiration:您可以设置一个绝对时间,就像我们在本例中所做的那样。在该时间,项目将从缓存中自动删除。

  • AbsoluteExpirationRelativeToNow:设置绝对过期时间的另一种方法。我们可以将本例中的选项重写为 AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)。结果将是相同的。

  • SlidingExpiration:设置缓存项在移除之前可以不活跃(未访问)多长时间。这意味着如果您继续访问缓存项,则过期时间会自动延长。

如果您未传递过期时间参数,则使用默认值。您可以使用下一节中解释的 AbpDistributedCacheOptions 类配置默认值和一些其他全局选项。在此之前,让我们看看 IDistributedCache<UserCacheItem> 服务的其他方法,如下所示:

  • GetAsync 用于使用缓存键从缓存中读取数据。

  • SetAsync 用于将项保存到缓存中。如果可用,它将覆盖现有值。

  • RefreshAsync 用于重置给定键的滑动过期时间。

  • RemoveAsync 用于从缓存中删除一个项。

    关于同步缓存方法

    所有方法也有同步版本,例如 GetAsync 方法的 GET 方法。然而,建议尽可能使用异步版本。

这些方法是 ASP.NET Core 的标准方法。ABP 为每个方法添加了处理多个项的方法,例如 GetManyAsync 对应 GetAsync。如果你有很多项需要读取或写入,使用 Many 方法会有显著的性能提升。GetOrAddAsync 方法(在本节中 GetUserInfoAsync 示例中使用)也是由 ABP 框架定义的,用于安全地读取缓存值,回退到原始数据源,并在单个方法调用中设置缓存值。

配置缓存选项

AbpDistributedCacheOptions 是配置缓存系统的主要选项类。你可以在模块类的 ConfigureServices 方法中配置它(你可以在领域层或应用层中这样做),如下所示:

Configure<AbpDistributedCacheOptions>(options =>
{
    options.GlobalCacheEntryOptions
        .AbsoluteExpirationRelativeToNow = 
            TimeSpan.FromHours(2);
});

我已在此代码块中将 GlobalCacheEntryOptions 属性配置为将默认缓存过期时间设置为 2 小时。

AbpDistributedCacheOptions 还有一些其他属性,如下所述:

  • KeyPrefix (string; 默认: null): 添加到该应用程序所有缓存键开头的前缀值。此选项可用于在使用多个应用程序共享的分布式缓存时隔离你的应用程序的缓存项。

  • hideErrors (bool; 默认: true): 用于控制缓存服务方法上错误处理的默认值。

正如你在前面的示例中所看到的,这些选项可以通过传递参数到 IDistributedCache 服务的 GetAsync 方法的参数来覆盖。

错误处理

当我们使用外部进程(如 Redis)进行分布式缓存时,在从缓存中读取数据或写入数据时可能会遇到问题。缓存服务器可能离线,或者我们可能遇到暂时的网络问题。这些临时问题通常可以忽略,尤其是在尝试从缓存中读取数据时。如果缓存服务当前不可用,你可以安全地尝试从原始数据源读取。这可能会慢一些,但比抛出异常并使当前请求失败要好。

所有 IDistributedCache<T> 方法都有一个可选的 hideErrors 参数来控制异常处理行为。如果你传递 false,则所有异常都会抛出。如果你传递 true,则 ABP 隐藏与缓存相关的错误。如果你没有指定值,则使用上一节中解释的默认值。

在多租户应用程序中使用缓存

如果你的应用程序是多租户的,ABP 会自动将当前租户的 ID 添加到缓存键中,以区分不同租户的缓存值。这样,它提供了租户之间的隔离。

如果您想创建一个在租户之间共享的缓存,您可以使用 [IgnoreMultiTenancy] 属性为缓存项类,如下面的代码块所示:

[IgnoreMultiTenancy]
public class MyCacheItem
{ /* ... */ }

对于这个例子,MyCacheItem 的值可以被不同的租户访问。

使用 Redis 作为分布式缓存提供者

Redis 是一个流行的工具,用作分布式缓存。ASP.NET Core 为 Redis 提供了一个缓存集成包。您可以通过遵循 Microsoft 的文档(docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed)来使用它,并且它运行得非常好。

ABP 还提供了一个 Redis 集成包,它扩展了 Microsoft 的集成以支持批量操作(如 Using the IDistributedCache interface 部分中提到的 GetManyAsync)。因此,建议使用 ABP 的集成 Volo.Abp.Caching.StackExchangeRedis NuGet 包来使用 Redis 作为缓存提供者。您可以使用以下命令在您想要使用的项目的目录中使用 ABP 命令行界面CLI)来安装它:

abp add-package Volo.Abp.Caching.StackExchangeRedis

安装完成后,您只需将配置添加到 appsettings.json 文件中,以连接到 Redis 服务器,如下所示:

"Redis": {
  "Configuration": "127.0.0.1"
}

您将服务器地址和端口(一个连接字符串)写入到 Configuration 选项中。请参阅 Microsoft 的文档以获取配置的详细信息:docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed

缓存值失效

有一个流行的说法,缓存失效是计算机科学中两个难题之一(另一个是命名事物)。缓存的值通常是原始数据的一个副本,原始数据位于经常需要读取且成本较高的位置,或者是一个计算成本较高的值。在这种情况下,它可以提高性能和可伸缩性,但当原始数据发生变化并使缓存值过时时,问题就开始了。我们应该仔细观察这些变化,并从缓存中删除或刷新相关数据。这被称为缓存失效。

缓存失效过程在很大程度上取决于缓存数据和您的应用程序逻辑。然而,有一些特定的情况,ABP 可以帮助您失效缓存数据。

一个具体的情况是我们可能希望在实体发生变化(被更新或删除)时使缓存项失效。对于这种情况,我们可以注册 ABP 框架发布的事件。以下代码在相关用户实体发生变化时使用户缓存项失效:

public class MyUserService : 
    ILocalEventHandler<EntityChangedEventData<IdentityUser>>,
    ITransientDependency
{
    private readonly IDistributedCache<UserCacheItem> 
        _userCache;
    private readonly IRepository<IdentityUser, Guid> 
         _userRepository;
    //...omitted other code parts 
    public async Task HandleEventAsync(
        EntityChangedEventData<IdentityUser> data)
    {
        await _userCache.RemoveAsync 
            (data.Entity.Id.ToString());
    }
}

MyUserService注册了一个EntityChangedEventData<IdentityUser>本地事件。当创建一个新的IdentityUser实体或更新或删除现有的IdentityUser实体时,将触发此事件。在这种情况下,会调用HandleEventAsync方法,并将相关实体在data.Entity属性中。此方法简单地从缓存中删除具有更改实体Id值的用户。

本地事件在当前进程中工作。这意味着处理类(这里为MyUserService)应该与实体变更在同一个进程中。

关于事件总线系统

本地和分布式事件是 ABP 框架的有趣特性,但本书中没有包括。如果你想了解更多关于它们的信息,请参阅 ABP 文档:docs.abp.io/en/abp/latest/Event-Bus

在本节中,我们学习了如何与分布式缓存系统协同工作,配置选项,并处理错误处理。我们还介绍了 Redis 缓存提供程序的安装。最后,我们介绍了可以帮助我们使缓存值无效的自动 ABP 事件。

下一个部分将涉及 UI 本地化,这是我在本章中将要介绍的 ABP 的最后一个功能。

本地化用户界面

如果你正在构建一个全球产品,你可能希望根据当前用户的语言显示本地化的 UI。ASP.NET Core 提供了一个系统来本地化你的应用程序的 UI。ABP 增加了一些有用的功能和约定,使其更加容易和灵活。

本节解释了如何定义你想要支持的语言,为不同的语言创建文本,并获取当前用户的正确文本。你将了解本地化资源概念和嵌入式本地化资源文件。

我们可以先定义你的应用程序支持的语言。

配置支持的语言

关于本地化的第一个问题是这样的:你希望在 UI 上支持哪些语言? ABP 提供了一个简单的配置来定义语言,使用AbpLocalizationOptions,如下面的代码块所示:

Configure<AbpLocalizationOptions>(options =>
{
    options.Languages.Add(new LanguageInfo("en", "en", 
        "English"));
    options.Languages.Add(new LanguageInfo("tr", "tr", 
        "Türkçe"));
    options.Languages.Add(new LanguageInfo("es", "es", 
        "Español"));
});

你可以将这段代码写入你的模块类的ConfigureServices方法中。实际上,当你使用 ABP 应用程序启动模板创建新解决方案时,这个配置(以及许多语言)已经完成了。你只需根据需要编辑列表即可。

LanguageInfo构造函数接受几个参数:

  • cultureName:语言的文化名称(代码),在运行时设置为CultureInfo.CurrentCulture

  • uiCultureName:语言的 UI 文化名称(代码),在运行时设置为CultureInfo.CurrentUICulture

  • displayName:在用户选择此语言时显示的语言名称。建议用其原始语言书写该名称。

  • flagIcon:一个字符串值,UI 可以使用它来在语言名称附近显示国家国旗。

ABP 根据当前的 HTTP 请求确定这些语言之一。

确定当前语言

ABP 通过使用 AbpRequestLocalizationMiddleware 类来确定当前语言。这是一个 ASP.NET Core 中间件,通过以下代码行添加到 ASP.NET Core 请求管道中:

app.UseAbpRequestLocalization();

当请求通过此中间件时,将选择配置的语言之一并将其设置为 CultureInfo.CurrentCultureCultureInfo.CurrentUICulture。这是 .NET 中设置和获取当前文化定位的标准系统。

当前语言的选择基于以下 HTTP 请求参数,按照以下优先级顺序:

  1. 如果设置了 culture 查询字符串参数,它将用于确定当前语言。一个例子是 http://localhost:5000/?culture=en-US

  2. 如果设置了 .AspNetCore.Culture cookie 的值,则它将用作当前语言。

  3. 如果设置了 Accept-Language HTTP 头,它将用作当前语言。浏览器通常默认发送最后一个。

    关于 ASP.NET Core 的本地化系统

    本节中解释的行为是默认行为。然而,ASP.NET Core 的语言确定系统更加灵活和可定制。请参阅 Microsoft 的文档以获取更多信息:docs.microsoft.com/en-us/aspnet/core/fundamentals/localization.

在定义我们想要支持的语言之后,我们可以定义我们的本地化资源。

定义本地化资源

ABP 与 ASP.NET Core 的本地化系统 100% 兼容。因此,你可以通过遵循 Microsoft 的文档使用 .resx 文件作为本地化资源:docs.microsoft.com/en-us/aspnet/core/fundamentals/localization。然而,ABP 提供了一种轻量级、灵活且可扩展的方式来定义本地化文本,使用简单的 JSON 文件。

当你使用 ABP 启动模板创建一个新的解决方案时,Domain.Shared 项目包含应用程序的本地化资源以及本地化 JSON 文件:

图 8.2 – 本地化资源和本地化 JSON 文件

图 8.2 – 本地化资源和本地化 JSON 文件

对于此示例,DemoAppResource 类代表本地化资源。一个应用程序可以拥有多个本地化资源,每个资源定义其自己的 JSON 文件。你可以将本地化资源视为一组本地化文本。它有助于构建模块化系统,其中每个模块都有自己的本地化资源。

本地化资源类是一个空类,如下面的代码所示:

[LocalizationResourceName("DemoApp")]
public class DemoAppResource
{ }

当您想要在该本地化资源中使用文本时,此类引用相关资源。LocalizationResourceName属性为资源设置一个字符串名称。每个本地化资源都有一个唯一的名称,该名称在客户端代码中用于引用资源。我们将在在客户端使用本地化部分中探讨客户端本地化。

应用程序的默认本地化资源

在您的应用程序中,通常只有一个(默认)本地化资源,该资源在创建新的 ABP 解决方案时随启动模板一起提供。默认本地化资源类的名称以项目名称开头——例如,如果您将项目名称指定为ProductManagement,则类名为ProductManagementResource

一旦我们有了本地化资源,我们就可以为每个我们支持的语言创建一个 JSON 文件。

与本地化 JSON 文件一起工作

本地化文件是一个简单的 JSON 格式文件,如下面的代码块所示:

{
  "culture": "en",
  "texts": {
    "Home": "Home",
    "WelcomeMessage": "Welcome to the application."
  }
}

该文件中有两个主要的根元素,如下所述:

  • culture:相关语言的区域代码。它与在配置支持的语言部分中引入的区域代码相匹配。

  • texts:包含本地化文本的键值对。键用于访问本地化文本,应在所有不同语言的 JSON 文件中相同。值是当前文化(语言)的本地化文本。

在为每种语言定义了本地化文本之后,我们可以在运行时请求本地化文本。

获取本地化文本

ASP.NET Core 定义了一个IStringLocalizer<T>接口,用于获取当前文化的本地化文本,其中T代表本地化资源类。您可以将该接口注入到您的类中,如下面的代码块所示:

public class LocalizationDemoService : ITransientDependency
{
    private readonly IStringLocalizer<DemoAppResource> 
        _localizer;
    public LocalizationDemoService(
        IStringLocalizer<DemoAppResource> localizer)
    {
        _localizer = localizer;
    }
    public string GetWelcomeMessage()
    {
        return _localizer["WelcomeMessage"];
    }
}

在前面的代码块中,LocalizationDemoService类注入了IStringLocalizer<DemoAppResource>服务,该服务用于访问DemoAppResource类的本地化文本。在GetWelcomeMessage方法中,我们简单地获取WelcomeMessage键的本地化文本。如果当前语言是英语,它将返回我们在上一节中定义的 JSON 文件中的Welcome to the application.

我们可以在本地化文本时传递参数。

参数化文本

本地化文本可以包含参数,如下面的示例所示:

"WelcomeMessageWithName": "Welcome {0} to the application."

可以将参数传递给本地化器,如下面的代码块所示:

public string GetWelcomeMessage(string name)
{
    return _localizer["WelcomeMessageWithName", name];
}

本例中给出的名称替换了{0}占位符。

回退逻辑

当请求的文本在当前文化的 JSON 文件中找不到时,本地化系统会使用父级或默认文化进行回退。

例如,假设您请求获取WelcomeMessage文本,而当前区域代码(CultureInfo.CurrentUICulture)为de-DE(德国-德国)。在这种情况下,以下情况之一会发生:

  • 如果您没有定义具有 "culture": "de-DE" 的 JSON 文件,或者您已经定义了一个 JSON 文件但它不包含 WelcomeMessage 键,那么它将回退到父文化("de"),尝试在该文化中找到给定的键,如果可用则返回它。

  • 如果在父文化中找不到,它将回退到本地化资源的默认文化(请参阅下一节以配置默认文化)。

  • 如果在默认文化中找不到,则返回给定的键(例如本例中的 WelcomeMessage)作为响应。

配置本地化资源

在使用之前,应将本地化资源添加到 AbpLocalizationOptions 中。此配置已在启动模板中完成,如下所示:

Configure<AbpVirtualFileSystemOptions>(options =>
{
    options.FileSets.AddEmbedded<DemoAppDomainSharedModule>(); 
    });
Configure<AbpLocalizationOptions>(options =>
{
    options.Resources
        .Add<DemoAppResource>("en")
        .AddBaseTypes(typeof(AbpValidationResource))
        .AddVirtualJson("/Localization/DemoApp");
    options.DefaultResourceType = typeof(DemoAppResource);
});

本地化 JSON 文件通常定义为嵌入式资源。我们正在配置 ABP 的虚拟文件系统(使用 AbpVirtualFileSystemOptions),将此程序集中的所有嵌入式文件添加到虚拟文件系统中,以便本地化文件也被添加。

然后,在第二部分中,我们将 DemoAppResource 添加到 Resources 字典中,以便 ABP 能够识别它。在这里,"en" 参数设置了该本地化资源的默认文化。

ABP 的本地化系统相当先进。它允许您通过从另一个本地化资源继承本地化资源来重用本地化资源的文本。在本例中,我们正在继承 AbpValidationResource,它由 ABP 框架定义,并包含标准的验证错误消息。

AddVirtualJson 方法用于通过虚拟文件系统设置与该资源相关的 JSON 文件。

最后,DefaultResourceType 设置了该应用程序的默认本地化资源。您可以在未指定本地化资源的地方使用默认资源。下一节将解释此配置的主要使用点。

在特殊服务中进行本地化

在每个地方注入 IStringLocalizer<T> 服务可能会很繁琐。ABP 预先将这些本地化器注入到一些特殊的基类中。当您从这些类继承时,您可以直接使用 L 短路属性来本地化文本。

以下示例展示了如何在应用程序服务方法中本地化文本:

public class MyAppService : ApplicationService
{
    public async Task FooAsync()
    {
        var str = L["WelcomeMessage"];
    }
}

在本例中,L 属性由 ApplicationService 基类定义,因此您不需要手动注入 IStringLocalizer<T> 服务。您可能会想知道,因为我们没有指定本地化资源,这里使用的是哪一个。答案是上一节中解释的 DefaultResourceType 选项。

如果您想为特定应用程序服务指定另一个本地化资源,则请在服务的构造函数中设置 LocalizationResource 属性:

public class MyAppService : ApplicationService
{
    public MyAppService()
    {
        LocalizationResource = typeof(AnotherResource);
    }
    //...
}

除了 ApplicationService 类之外,一些其他常见的基类,如 AbpControllerAbpPageModel,也提供了相同的 L 属性,作为注入 IStringLocalizer<T> 服务的快捷方式。

在客户端使用本地化

ABP 的本地化系统的一个优点是所有本地化资源都可以直接在客户端代码中使用。

例如,以下代码在 ASP.NET Core MVC/Razor Pages 应用程序的 JavaScript 代码中将 WelcomeMessage 键本地化:

var str = abp.localization.localize('WelcomeMessage', 'DemoApp');

DemoApp 是本地化资源名称,而 WelcomeMessage 是这里的本地化键。客户端本地化将在本书的 第四部分用户界面和 API 开发 中介绍。

摘要

在本章中,我们学习了您几乎在所有 Web 应用程序中都需要的一些基本功能。

ICurrentUser 服务允许您获取您应用程序中当前用户的信息。您可以使用标准声明(例如用户名和 ID)并根据您的需求定义自定义声明。

我们探讨了数据过滤系统,该系统在从数据库查询数据时自动过滤数据。这样,我们可以轻松实现一些模式,如软删除和多租户。我们还学习了如何定义自定义数据过滤器,并在必要时禁用过滤器。

我们已经了解了审计日志系统是如何跟踪和保存用户执行的所有操作的。我们可以通过属性和选项声明性和约定性地控制审计日志系统。

缓存数据是提高系统性能和可扩展性的另一个基本概念。我们学习了 ABP 的 IDistributedCache<T> 服务,它提供了一种类型安全的方式与缓存提供程序交互,并自动化了一些常见任务,例如序列化和异常处理。

最后,我们探讨了 ASP.NET Core 和 ABP 框架的本地化基础设施,以便我们可以在应用程序中轻松定义和消费本地化文本。

现在我们已经到达本章的结尾,我们已经完成了本书的 第二部分ABP 框架基础,涵盖了 ABP 框架和 ASP.NET Core 基础设施的基本知识。下一部分是使用 ABP 框架实现 领域驱动设计DDD)的实用指南。DDD 是 ABP 基于的核心概念之一。它包括构建可维护业务解决方案的原则、模式和最佳实践。

第三部分:实现领域驱动设计

本部分专注于领域驱动设计(DDD)。它首先介绍 DDD 的整体概念,然后通过展示和解释明确的规则和示例,深入探讨基于 ABP 框架的实现方法。

在本部分,我们包括以下章节:

  • 第九章理解领域驱动设计

  • 第十章DDD – 领域层

  • 第十一章DDD – 应用层

第九章:第九章:理解领域驱动设计

ABP 框架项目的主要目标是介绍一种应用程序开发架构方法,并提供必要的基础设施和工具,以最佳实践实施该架构。

领域驱动设计DDD)是 ABP 框架架构提供的核心部分之一。ABP 的启动模板基于 DDD 原则和模式分层。ABP 的实体、仓储、领域服务、领域事件、规范以及许多其他概念都直接映射到 DDD 的战术模式。

由于 DDD 是 ABP 应用程序开发架构的核心部分,因此本书有一个专门的章节,第三部分实施领域驱动设计,其中包含三个章节,专门介绍 DDD。在本书中,我将侧重于实际实施细节,而不是 DDD 的理论、战略方法和概念。示例将主要基于在 第四章理解参考解决方案 中介绍的 EventHub 项目。此外,我还会展示一些 EventHub 项目没有适当示例的场景的不同示例。

接下来的两章将向您展示实施 DDD 的明确规则和具体代码示例,以帮助您学习如何使用 ABP 框架实施 DDD。

然而,在本章的第一部分,我们将一般性地探讨 DDD,并按以下顺序探讨后续章节中的核心技术概念:

  • 介绍 DDD

  • 基于 DDD 构建.NET 解决方案

  • 处理多个应用程序

  • 理解执行流程

  • DDD 的常见原则

技术要求

您可以从 GitHub 克隆或下载 EventHub 项目的源代码:github.com/volosoft/eventhub

如果您想在本地开发环境中运行解决方案,您需要一个 IDE/编辑器(如 Visual Studio)来构建和运行 ASP.NET Core 解决方案。此外,如果您想创建 ABP 解决方案,您需要安装 ABP CLI,如 第二章开始使用 ABP 框架 中所述。

介绍 DDD

在我们介绍实现细节之前,让我们定义 DDD 的核心概念和构建块。让我们从 DDD 的定义开始。

什么是领域驱动设计?

DDD 是一种针对复杂需求的软件开发方法,其中将软件的实现与不断发展的模型相连接。

DDD 适用于复杂领域和大型应用程序。在简单、短期存在的创建、读取、更新、删除CRUD)应用程序的情况下,通常不需要遵循所有 DDD 原则。幸运的是,ABP 不会强迫你在每个应用程序中实施所有 DDD 原则;你可以只使用最适合你应用程序的原则。然而,在复杂应用程序中遵循 DDD 原则和模式有助于你构建一个灵活、模块化和易于维护的代码库。

DDD 关注核心领域逻辑,而不是基础设施细节,这些细节通常与业务代码隔离。

实施领域驱动设计(DDD)与面向对象编程OOP)原则密切相关。本书不涵盖这些基本原理,但仍然,对 OOP 以及单一职责、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则SOLID)的良好理解将有助于你在塑造和组织代码库以及实际实施 DDD 时。

现在我们已经提供了这个简要的定义,我们可以探索 DDD 的基本层。

DDD 层

分层是组织软件解决方案以减少复杂性和提高可重用性的常见原则。DDD 提供了一个四层模型来帮助你组织业务逻辑,并将基础设施从业务逻辑中抽象出来,如下面的图所示:

图 9.1 – DDD 的层

图 9.1 – DDD 的层

上述图显示了层及其关系:

  • 领域层包含基本业务对象,并实现了解决方案的核心、用例无关的、可重用的领域逻辑。这一层不依赖于任何其他层,但所有其他层直接或间接地依赖于它。

  • 应用层实现了应用程序的用例。用例通常是用户通过 UI 执行的操作。应用层使用领域层的对象来执行这些用例。

  • 表示层包含应用程序的 UI 组件,例如 Web 应用程序的视图、JavaScript 和 CSS 文件。它不直接使用领域层或数据库对象。相反,它使用应用层。通常,对于在 UI 上执行的所有用例/操作,应用层都有一个相应的功能/方法。

  • 基础设施层依赖于所有其他层,并实现了这些层定义的抽象。它有助于优雅地将业务逻辑与第三方库和系统(如数据库或缓存提供者)分离。

该模型中的每一层都有其职责,并包含各种构建块,这些构建块将在下一节中介绍。

构建块

从技术角度来看,领域驱动设计(DDD)主要与通过关注你正在工作的领域来设计你的业务代码相关。业务逻辑被分为两层——领域层和应用层。其他层(表示层和基础设施层)被视为实现细节,应根据你使用的特定技术的最佳实践来实现,例如 Entity Framework。

领域层通过以下基本构建块来实现核心领域逻辑:

  • 事件组织实体。

  • 值对象: 值对象是另一种业务对象。值对象通过其状态(属性)来识别,并且没有标识符。这意味着如果两个值对象的属性都相同,则认为它们是相同的。值对象通常比实体简单,并且通常作为不可变对象实现。例如,我们可以创建地址、货币或日期等值对象。

  • EventHub 解决方案中的事件实体是事件聚合的根实体,它包含跟踪和会话作为子集合。

  • 仓储: 仓储是一个类似于集合的接口,由领域层和应用层用来访问持久化系统。它隐藏了数据库提供者的复杂性,使其从业务代码中分离出来。

  • 领域服务: 领域服务是一个无状态的服务(类),它实现了核心业务规则。它对于实现依赖于多个聚合类型(因此这些聚合中的任何一个都不应负责实现该逻辑)或外部服务的领域逻辑非常有用。领域服务获取/返回领域对象,通常由应用服务或其他领域服务消费。

  • 规范: 规范是一个命名、可重用、可测试和可组合的过滤器,它应用于业务对象,根据特定的业务规则来选择它们。

  • 领域事件: 领域事件是一种以松耦合方式通知其他服务在发生特定领域事件时的方式。它在实现跨多个聚合的副作用时非常有用。

应用层通过以下构建块来实现应用的使用案例:

  • 应用服务: 应用服务是一个无状态的服务(类),它实现了应用的使用案例。它通常获取和返回数据传输对象,并且其方法被表示层使用。它使用和编排领域层对象以执行特定的使用案例。一个使用案例通常被实现为一个事务性(原子)过程。

  • 数据传输对象(DTO): DTO 用于在表示层和应用层之间传输数据(状态)。它不包含任何业务逻辑。

  • 工作单元UOW):UOW 是一个事务边界。UOW 中的所有状态变化(通常是数据库操作)都必须实现为原子操作,在成功时一起提交,在失败时一起回滚。

看到整体图景并熟悉 DDD 的核心构建块非常重要,这就是为什么我在这里简要介绍了它们。在接下来的几章中,我们将实际使用它们并了解它们的实现细节。然而,在本章中,我将继续从整体图景出发,解释 ABP 如何将层和构建块放入 .NET 解决方案中。

根据 DDD 结构化 .NET 解决方案

到目前为止,我们已经介绍了基于 DDD 的软件解决方案的层和核心构建块。在本节中,我们将学习如何根据 DDD 对 .NET 解决方案进行分层。我将从最简单的解决方案结构开始。然后,我将解释 ABP 的启动解决方案模板是如何演变成其当前结构的。最后,您将了解为什么 ABP 启动解决方案内部有那么多项目,以及每个项目的目的。

创建一个简单的基于 DDD 的 .NET 解决方案

让我们从零开始,通过在我们的 .NET 解决方案中创建四个项目来保持事情简单,如下截图所示:

图 9.2 – Visual Studio 中基于 DDD 的简单 .NET 解决方案

图 9.2 – Visual Studio 中基于 DDD 的简单 .NET 解决方案

假设我们正在构建一个 客户关系管理CRM)解决方案,Acme 是我们的公司名称,而 Crm 是本例中的产品名称。我为每个层创建了一个单独的 C# 项目。.NET 项目完美地适应层,因为它们可以将代码库物理地分离到不同的包中。项目中的一个类/类型可以直接使用同一项目中的其他类/类型。然而,一个类/类型不能使用另一个项目中的类/类型,除非您通过引用其他项目显式定义依赖关系。

图 9.2 展示了 Visual Studio 中解决方案中的项目以及这些项目之间的依赖关系:

图 9.3 – 简单基于 DDD 的 .NET 解决方案的项目依赖关系

图 9.3 – 简单基于 DDD 的 .NET 解决方案的项目依赖关系

在前面的图中,实线表示开发时依赖(项目引用),而虚线表示运行时依赖。我将在本节后面解释这种差异。

要理解这些依赖关系,我们需要知道这些项目可能包含哪些类型的组件。我们在 构建块 部分中看到了哪些组件位于领域和应用层。在这里,我将提到一些包含在该 CRM 解决方案项目中的示例组件:

  • Product类(聚合根实体)和IProductRepository接口(存储库抽象)。Product类代表一个产品,并具有一些属性,如IdNamePriceIProductRepository有一些方法用于对产品执行数据库操作,例如InsertDeleteGetList

  • CrmDbContext类(EF Core 数据上下文),它将Product实体映射到数据库表。它还包含EfProductRepository类,该类实现了IproductRepository接口。

  • ProductAppService(应用程序服务),以及一些用于创建、更新、删除和获取产品列表的方法。此服务内部使用IProductRepository接口和Product实体(领域对象)。

  • Products.cshtml页面(以及一个相关的 JavaScript 文件),它用于在 UI 上渲染产品数据,并允许您管理(创建、编辑和删除)产品。它内部使用ProductAppService来执行实际操作。

现在我们已经了解了这些项目的目的和内容,让我们看看为什么这些项目有这些依赖项:

  • Acme.Crm.Domain没有依赖项。一般来说,领域层具有最小依赖性,并且从基础设施细节中抽象出来。

  • Product类映射到数据库表,并实现了IProductRepository接口。

  • 使用IProductRepository存储库和Product实体来执行用例。

  • 最后,ProductAppService).

Acme.Crm.Web项目还有一个依赖项:它引用了Acme.Crm.Infrastructure项目。它不直接使用该项目中的任何类,因此不需要直接依赖。然而,Acme.Crm.Web也是运行应用程序的项目,应用程序在运行时需要基础设施层来使用数据库。在将托管与 UI 分离这一节中,我们将讨论一种替代结构,以便您可以消除这个依赖项。

这是一种基于 DDD 的解决方案的最小化分层。在下一节中,我们将使用该解决方案并解释 ABP 的启动解决方案是如何演变的。

ABP 启动解决方案的演变

ABP 的启动解决方案比图 9.2中显示的解决方案更复杂。以下截图显示了使用 ABP 启动模板创建的相同解决方案,但这次使用的是abp new Acme.Crm CLI 命令:

图 9.4 – 使用 ABP 启动模板创建的 CRM 解决方案

图 9.4 – 使用 ABP 启动模板创建的 CRM 解决方案

让我们解释一下这个解决方案是如何从上一节中解释的四个项目解决方案中演变而来的。

介绍 EntityFrameworkCore 项目

最小化 DDD 解决方案包含 Acme.Crm.Infrastructure 项目,该项目假定实现所有基础设施抽象和集成。另一方面,ABP 解决方案有一个专门的 Entity Framework Core 集成项目(Acme.Crm.EntityFrameworkCore),因为我们认为为这样的主要依赖创建单独的项目是好的,尤其是对于数据库集成。

基础设施层可以拆分为多个项目。ABP 启动模板没有这样的主要依赖。唯一的 Infrastructure 项目是 Acme.Crm.EntityFrameworkCore 项目。如果您的解决方案增长,您可以创建额外的基础设施项目。

通过这个变更,最初的基于 DDD 的最小化解决方案将如下所示:

![图 9.5 – 介绍 Entity Framework Core 集成项目]

图片

图 9.5 – 介绍 Entity Framework Core 集成项目

这个变更微不足道。它可以被认为是将 Acme.Crm.Infrastructure 项目的名称更改为 Acme.Crm.EntityFrameworkCore。下一节将介绍一个新的项目到解决方案中。

介绍应用程序合约

目前,Acme.Crm.Application 项目包含应用程序服务类。因此,Acme.Crm.Web 项目引用 Acme.Crm.Application 项目以使用这些服务。

这种设计有一个问题:Acme.Crm.Web 项目间接引用了 Acme.Crm.Domain 项目(通过 Acme.Crm.Application 项目)。这暴露了领域层中的业务对象(如实体、领域服务和存储库)给表示层,打破了抽象和真正的分层。

ABP 启动模板将应用层拆分为两个项目:

  • IProductAppService 和相关的 DTO(例如 ProductCreationDto)。

  • ProductAppService

为应用程序服务引入(接口)合约有两个重要的优点:

  • UI 层(此处为 Acme.Crm.Web 项目)可以依赖服务合约,而不依赖于实现,因此不依赖于领域层。

  • 您可以将 Acme.Crm.Application.Contracts 项目与客户端应用程序共享,以便依赖相同的接口并重用相同的 DTO 类,而无需共享您的业务层。

EventHub 引用解决方案(在第 第四章理解参考解决方案)利用了这种设计,并在 UI 和 HTTP API 应用程序之间重用了 Application.Contracts 项目。这样,它可以轻松地设置一个分层架构,其中应用层和表示层托管在不同的应用程序中,但共享服务合约。

通过分离应用程序合约项目,当前解决方案的结构将类似于以下图示:

![图 9.6 – 介绍应用程序合约项目]

图 9.06_B17287.jpg

图 9.6 – 介绍应用合同项目

使用这种新的设计,项目依赖关系图将如下所示:

![图 9.7 – 应用合同项目的项目依赖关系图 9.07_B17287.jpg

图 9.7 – 应用合同项目的项目依赖关系

Acme.Crm.Web 项目现在只依赖于 Acme.Crm.Application.Contracts 项目,并且应该始终使用应用服务接口来执行用户交互。

Acme.Crm.Web 项目仍然依赖于 Acme.Crm.ApplicationAcme.Crm.EntityFrameworkCore 项目,因为我们需要在运行时使用它们。我用虚线画出了这些依赖关系,以表明这些项目依赖关系在理想设计中不应存在,但现在却是必要的。我将在 将托管与 UI 分离 部分解释我们如何消除这些依赖关系。

将应用合同与实现分离带来了一些小问题,我们将在下一节中解决。

介绍域共享项目

一旦我们将合同分离出来,我们就不能再在合同项目中使用域层的对象,因为它们没有对域层的引用,如前节所示。乍一看这似乎不是一个问题。我们无论如何都不应该使用这些实体和其他业务对象在应用服务合同中 – 我们应该使用 DTO。然而,我们仍然可能想要重用域项目中定义的一些类型或值。

例如,我们可能想在 DTO 类中重用 ProductType 枚举,或者依赖于产品名称最大长度的相同常量值。我们不希望重复这样的代码部分,但我们也不能从 Acme.Crm.Application.Contracts 项目中添加对 Acme.Crm.Domain 项目的引用。解决方案是引入一个新的项目来声明这样的类型和值。

我们将把这个新项目命名为 Acme.Crm.Domain.Shared,因为这个项目将是域层的一部分,并与解决方案的其他部分共享。实际上,这个项目不会包含很多类型,但我们仍然不想重复这些类型。

随着 Acme.Crm.Domain.Shared 项目的引入,新的解决方案结构如下所示:

![图 9.8 – 介绍域共享项目图 9.08_B17287.jpg

![图 9.8 – 介绍域共享项目以下图表显示了解决方案中项目之间的依赖关系:图 9.09 – 域共享项目的项目依赖关系

图 9.09_B17287.jpg

图 9.9 – 域共享项目的项目依赖关系

新的 Acme.Crm.Domain.Shared 项目被 Acme.Crm.DomainAcme.Crm.Application.Contracts 项目使用。这样,直接或间接地,解决方案中的所有其他项目都可以使用该新项目中的类型。

到目前为止,ABP 启动解决方案的基本层已经完成。然而,如果你查看 图 9.4,你会看到 ABP 启动解决方案还有三个额外的项目。我们将在接下来的小节中讨论这些内容。

介绍 HTTP API 层

图 9.4 中,你可以看到 ABP 启动解决方案有两个与 HTTP 相关的项目。

首先,Acme.Crm.HttpApi 项目包含解决方案的 API 控制器(即 REST API)。这个项目是在分离 API 和 UI 的想法下引入的,这样可以更好地组织和开发解决方案。

将 HTTP API 层作为一个类库项目分离,通过允许它们被重用,使得一些高级场景成为可能。EventHub 解决方案通过在 UI 层(在该解决方案中 UI 和 HTTP API 在不同的应用程序中托管)中使用 HTTP API 层作为代理来利用这种分离。参见 第四章 理解参考解决方案主网站主 HTTP API 部分,了解它是如何工作的。

第二个与 HTTP API 相关的项目是 Acme.Crm.HttpApi.Client。这是一个类库项目,在这个示例解决方案中未使用,但在更高级的场景中可以使用。你可以从客户端应用程序(可以是你的应用程序或第三方 .NET 客户端)中使用这个库来轻松消费你的 HTTP API。它使用 ABP 的动态 C# 客户端代理系统,正如将在 第十四章 构建 HTTP API 和实时服务 中解释的那样。大多数时候,你不需要对这个项目进行任何更改,但它 自动 工作。EventHub 解决方案使用这种技术从 UI 应用程序执行 HTTP API 请求。

通过为 HTTP API 层添加两个新项目,我们现在在解决方案中拥有八个项目,如下截图所示:

图 9.10 – 将 HTTP API 项目添加到解决方案中

图 9.10 – 将 HTTP API 项目添加到解决方案中

下面的图表显示了添加这些新项目后的新依赖关系图(这次,我已经从项目名称中移除了 Acme.Crm. 前缀,以便它们适合图表):

图 9.11 – HTTP API 层的项目依赖关系

图 9.11 – HTTP API 层的项目依赖关系

Acme.Crm.HttpApiAcme.Crm.HttpApi.Client项目依赖于Acme.Crm.Application.Contracts项目,因为服务器和客户端共享相同的契约(应用程序服务接口)。Acme.Crm.Web项目依赖于Acme.Crm.HttpApi项目,因为它在运行时提供 API。这个示例解决方案在运行时只有一个应用程序。你可以回顾一下在第四章中提供的 EventHub 解决方案结构,以在具有多个运行时应用程序的更复杂环境中查看这些项目。

丢弃 HTTP API 层

并非每个应用程序都需要有 HTTP API(即 REST API)。在这种情况下,你甚至可以从解决方案中移除这个项目。此外,如果你愿意,你可以将你的 API 控制器移动到Acme.Crm.Web项目,并丢弃Acme.Crm.HttpApi项目。

下一节将解释解决方案中的最后一个项目。

理解数据库迁移项目

图 9.4中,还有一个名为Acme.Crm.DbMigrator的额外项目。这是一个控制台应用程序,可以用来将 EF Core 代码首先迁移应用到数据库中。它是一个实用程序应用程序,不是基本解决方案的一部分,因此在这里不需要调查其细节。

解决方案中的测试项目

除了这九个项目之外,在test文件夹下还有六个更多项目。它们是为每个层分别配置的单元/集成测试项目。其中之一(Acme.Crm.HttpApi.Client.ConsoleTestApp)演示了如何使用Acme.Crm.HttpApi.Client项目来消费 HTTP API。你可以自行探索它们。

这些都是 ABP 启动解决方案中的所有项目。提供的解决方案结构是架构模型,随后是所有预构建的官方 ABP 应用程序模块。这种模型由于其灵活性和模块化,使得在各种场景中重用应用程序模块成为可能。

在下一节中,我们将讨论一个可以用来将托管与 UI 应用程序分离的额外项目。

将托管与 UI 分离

图 9.11中展示的架构模型中有一个令人烦恼的事情是,Web项目引用了ApplicationEntityFramework项目。Web项目中的任何页面/类都没有直接使用这些项目中的类。然而,由于Web项目是运行应用程序的项目,我们需要引用这些项目以使它们在运行时可用。

这种结构本身并没有太大问题,只要你没有不小心将你的域名和数据层对象泄露到表示层(Web 层)。然而,如果你担心,并且不想为这些运行时依赖项设置开发时间依赖项,你可以在下面的屏幕截图中添加一个额外的项目,Acme.Crm.Web.Host

![图 9.12 – 添加单独的托管项目图片

图 9.12 – 添加单独的托管项目

通过这个变更,Startup.csProgram.csappsettings.json 文件。Acme.Crm.Web.Host 项目通过在运行时将所有项目组合在一起,负责托管。它不包含任何应用 UI 页面或组件。

我认为这个设计更好。它优雅地从 UI 层提取托管配置细节,移除了运行时依赖,并使其更加专注。然而,我们没有在 ABP 启动模板中分离托管应用,因为大多数开发者已经觉得 ABP 启动模板很复杂(与单项目 ASP.NET Core 启动模板相比)。这是因为其中有很多项目,我们不想再添加一个。我相信,具有多个项目且每个项目代码更少的解决方案,比所有内容都在一个地方的单个项目方案更好。

你可以在本书的 GitHub 仓库中找到具有单独托管项目的解决方案,网址为 github.com/PacktPublishing/Mastering-ABP-Framework/tree/main/Samples/Chapter-09/SeparateHosting,并探索提供的结构。

在本节中,你了解了 ABP 启动模板中每个项目的角色,因此在开发你的解决方案时应该更加得心应手。在下一节中,我们将从 DDD 视角简要回顾 EventHub 引用解决方案。

处理多个应用

因此,我们已经了解了 ABP 启动解决方案中每个项目的目的。这是一个良好架构的软件解决方案的良好起点。它正确设置了层,只有一个领域层和一个应用层(由单个 Web 应用使用)。然而,在现实世界中,软件解决方案可能更复杂。你可能有多个应用(在同一个系统上)或者可能需要将领域分成多个子领域以降低每个子领域的复杂性。

DDD 解决方案处理复杂软件设计。将业务逻辑分离成领域逻辑和应用逻辑的主要目的是,在解决方案中有多个应用时,正确组织你的代码库。当你有多个应用时,你会有多个应用层。这些层中的每一个都实现了相关应用的应用特定业务逻辑,同时通过使用相同的领域层,仍然共享相同的核心领域逻辑。

EventHub 项目(在第四章理解参考解决方案)有两个 Web 应用程序。其中一个是供最终用户使用的网站。另一个是管理员(后台)应用程序,供系统管理员使用。这些应用程序具有不同的用户界面、不同的用例、不同的授权规则以及不同的性能、本地化、缓存和扩展需求。将这些差异分离到两个应用程序层有助于我们将这些特定于应用程序的业务和基础设施需求相互隔离。这些应用程序共享我们不希望在应用程序之间重复的核心业务逻辑。这意味着两个应用程序层使用相同的领域层,如下面的图所示:

![Figure 9.13 – EventHub – multiple application layers and a single domain layer

![img/Figure_9.13_B17287.jpg]

图 9.13 – EventHub – 多个应用程序层和单个领域层

当我们拥有多个应用程序时,在应用程序和领域层之间分离业务逻辑变得更加重要。将领域逻辑泄漏到应用程序层会导致其重复。另一方面,将特定于应用程序的逻辑放置在领域层会使你耦合不同应用程序的业务逻辑,并编写许多条件语句以使领域层可用于这些应用程序。这两种情况都会使你的代码库出现错误且难以维护。

领域逻辑与应用程序逻辑的分离很重要。在理解领域层和应用层构建块之后,我们将在第十一章DDD – 应用程序层中回到这个话题。但在那之前,让我们继续从大局出发,了解在基于 DDD 的应用程序中如何执行 Web 请求。

理解执行流程

我们已经介绍了许多构建块及其描述,以及这些构建块如何在.NET 解决方案的层中放置。在本节中,我们将探讨在基于 DDD 分层的一个典型 Web 应用程序中如何执行 HTTP 请求。以下图显示了层的实际操作:

![Figure 9.14 – Execution flow through the layers

![img/Figure_9.14_B17287.jpg]

图 9.14 – 通过层的执行流程

一个请求从客户端应用程序发起。客户端可以是一个期望获取 HTML 页面(及其 CSS/JavaScript 文件)的浏览器,或者是一个数据结果(例如 JSON)。在这种情况下,Razor 页面可以处理请求并返回一个 HTML 页面。如果发起请求的应用程序是另一种类型的客户端(例如控制台应用程序),你可能需要从 HTTP API(API 控制器)端点响应请求并返回一个纯数据结果。

MVC 页面(在表示层)处理 UI 逻辑,可能执行一些数据转换,并将实际操作委托给应用层中应用程序的一个方法。应用服务可能接受一个 DTO,实现用例逻辑,并将结果 DTO 返回给表示层。

应用服务内部使用领域对象(实体、存储库、领域服务等)来协调业务操作。业务操作应该是一个工作单元。这意味着它应该是原子的。在一个用例(通常是应用程序方法)中的所有数据库操作都应该一起提交或回滚。

表示层和应用层通常实现横切关注点,例如授权、验证、异常处理、缓存、审计日志等。

正如您在前几章中学到的,ABP 框架为所有这些横切关注点提供了一个完整的架构,并在可能的情况下自动化它们。它还提供了适当的基类和实用的约定,以帮助您构建业务组件,并使用最佳实践实现 DDD。

作为本章的最后一部分,我们将在下一节中看到 DDD 的一些常见原则。

理解通用原则

DDD 关注的是您如何设计业务代码。它关心状态变化以及业务对象之间的交互——如何创建一个实体,如何通过应用(甚至强制)业务规则和约束来更改其属性,以及如何保持数据的有效性和完整性。

DDD 不关心报告或大量查询。您可以使用报告工具的强大功能为您的应用程序创建酷炫的仪表板。您可以充分利用底层数据库提供商的功能以实现高性能。您甚至可以在另一个数据库提供商中复制数据,用于只读报告目的。您可以自由地做任何事情,只要您不将基础设施细节与业务代码混合。所有这些是我们作为开发者应该关心的问题,但 DDD 并不关心。

DDD 也不关心基础设施细节;您应该使用适当的抽象来隔离业务代码与这些细节。其中两个抽象特别重要,因为它们在您的代码库中占据了很大的空间:表示技术和数据库提供商。在接下来的几节中,我将解释这两个原则,并讨论我们是否需要实现它们。

数据库提供商独立性

在基于 DDD 的软件解决方案中抽象数据库集成是一种良好的实践。在理论上,您的领域和应用层应该是数据库和甚至 ORM 独立的。这个建议背后有一些很好的理由。如果您实施它,以下情况将会发生:

  • 您的数据库提供商(ORM 或 DBMS)在未来可能会发生变化,而不会影响您的业务代码。这使得您的业务代码具有更长的生命周期。

  • 通过在仓储后面隐藏数据访问逻辑,你的领域层和应用层将更加专注于业务代码。

  • 你可以更有效地模拟数据库层以进行自动化测试。

ABP 启动模板遵循这一原则——它不包含来自领域和应用层的数据库提供者引用。ABP 框架已经提供了实现仓储模式的简单基础设施。ABP 启动模板还附带数据库层,该层使用内存数据库实例进行自动化测试。

这两个原因中的最后一个是重要的,并且很容易与 ABP 框架一起应用。然而,第一个原因并不那么容易。一开始,当你将数据访问逻辑放在仓储后面时,你可能觉得你的业务代码是 ORM/数据库无关的。然而,事情并不那么简单。让我们假设你目前正在使用 EF Core 和 SQL Server(一个关系型数据库)来设计你的业务代码和实体,以便你可以轻松地切换到 MongoDB(一个文档数据库)。如果你想实现这一点,你必须考虑以下因素:

  • 你不能假设你有 EF Core 的变更跟踪系统,因为 MongoDB .NET 驱动程序不提供该功能。因此,你应该始终在业务逻辑的末尾手动更新已更改的实体。

  • 你不能将导航或集合属性添加到实体中,这些属性是其他聚合的类型。你必须严格实现聚合模式(如将在第十章领域层 DDD中解释),并尊重聚合边界。这种限制深刻地影响了你的实体设计和在实体上工作的业务代码。

正如你所见,要实现数据库无关性,在设计实体时需要小心,这会影响你的代码库。

你可能会想,你需要它吗?你将来会更改数据库提供者吗?如果你以后更改,你需要付出多少努力?这比使它数据库无关的努力要多吗?即使你尝试这样做,它将真正实现数据库无关(你可能不知道在尝试切换之前)?

所有 ABP 预构建的应用程序模块都设计为独立于数据库提供者,相同的业务代码在 EF Core 和 MongoDB 上都能运行。这是必要的,因为它们是可重用模块,不能假设有数据库提供者。另一方面,最终应用程序可以做出这种假设。我仍然建议将数据访问代码隐藏在仓储后面,ABP 使这一点变得非常简单。然而,如果你想要使用 EF Core 依赖项,我看不到有什么问题。

展示技术无关

UI 框架是软件行业中最为动态的系统。有大量的替代方案,趋势的方法和工具正在迅速变化。将您的业务代码与 UI 代码耦合将是一个糟糕的主意。

实施这一原则更为重要且相对容易,尤其是在使用 ABP 框架的情况下。ABP 启动模板自带了适当的分层。ABP 框架提供了许多抽象,您可以在应用程序和领域层中使用,而无需依赖于 ASP.NET Core 或任何其他 UI 框架。

摘要

在本关于 DDD 的第一章中,我们探讨了四个基本层以及这些层中的核心构建块。ABP 启动模板比这四层结构更为复杂。您学习了启动模板是如何通过一次改变而逐步演变的,并且理解了这些改变背后的原因。

关于 DDD,您了解到业务逻辑被分为两层:应用层和领域层。我们讨论了如何通过引用 EventHub 示例解决方案来处理共享相同领域逻辑的多个应用程序。

然后,我们了解了在典型的基于 DDD 的软件中,HTTP 请求是如何执行并通过各层的。最后,我们讨论了如何将应用程序和领域层与基础设施细节(尤其是数据库提供者和 UI 框架)隔离开来。

本章旨在展示 DDD 的整体图景和基本概念。下一章将专注于实现领域层构建块,例如聚合、仓储和领域服务。

第十章:第十章:DDD – 领域层

上一章是对领域驱动设计DDD)的整体概述,其中您学习了 DDD 的基本层、构建块和原则。您还了解了 ABP 解决方案的结构及其与 DDD 的关系。

本章完全专注于领域层的实现细节,包含大量代码示例和最佳实践建议。以下是本章我们将涵盖的主题:

  • 探索示例领域

  • 设计聚合和实体

  • 实现领域服务

  • 实现仓储

  • 构建规范

  • 发布领域事件

技术要求

您可以从 GitHub 克隆或下载EventHub项目的源代码:github.com/volosoft/eventhub

如果您想在本地开发环境中运行解决方案,您需要有一个 IDE/编辑器(例如 Visual Studio)来构建和运行 ASP.NET Core 解决方案。此外,如果您想创建 ABP 解决方案,您需要安装 ABP CLI,如第二章中所述,ABP 框架入门

探索示例领域

本章和下一章中的示例将主要基于 EventHub 解决方案。因此,首先理解领域至关重要。第四章理解参考解决方案已经解释了该解决方案。如果您想熟悉应用程序和解决方案结构,可以查看它。在这里,我们将探讨技术细节和领域对象。

以下列表介绍了并解释了领域的主要概念:

  • 事件是表示在线或现场活动的根对象。事件具有标题、描述、开始时间、结束时间、注册容量(可选)和语言(可选)作为主要属性。

  • 事件由组织创建(组织)。应用程序中的任何用户都可以创建组织并在该组织内组织活动。

  • 事件可以有零个或多个轨道,每个轨道都有一个轨道名称(通常是一个简单的标签,如 1、2、3 或 A、B、C)。轨道是一系列会议。具有多个轨道的事件使得组织并行会议成为可能。

  • 一个轨道包含一个或多个会议。会议是活动的一部分,参与者通常会在一定时间内聆听演讲者。

  • 最后,一个会议可以有一个或多个演讲者。演讲者是会议中发言并做展示的人。通常,每个会议都会有一个演讲者。但有时可能会有多个演讲者,或者会议可能没有与演讲者相关联。图 10.1显示了事件与其轨道、会议和演讲者之间的关系。

  • 应用程序中的任何用户都可以注册事件。注册用户在事件开始前或事件时间更改时会被通知。

你已经了解了 EventHub 应用程序中的基本对象。下一节将解释 DDD 的第一个构建块:聚合。

设计聚合和实体

设计你的实体和聚合边界非常重要,因为解决方案的其他组件将基于该设计。在本节中,我们首先了解什么是聚合。然后我们将看到一些聚合设计的关键原则。最后,我将介绍一些明确的规则和代码示例,以了解我们应该如何实现聚合。

什么是聚合根?

聚合是一组由聚合根对象绑定在一起的对象。聚合根对象负责实现与聚合相关的业务规则和约束,保持聚合对象的有效状态并保持数据完整性。聚合根和相关对象有方法来实现这一责任。

下图所示的事件聚合是一个聚合的好例子:

图 10.1 – 事件聚合

图 10.1 – 事件聚合

本章的示例将主要基于事件聚合,因为它代表了 EventHub 解决方案的基本概念。因此,我们应该理解其设计:

  • 在这里,事件对象是聚合根,具有GUID主键。它包含一个Track对象集合(一个事件可以有零个或多个轨道)。

  • Track是一个具有GUID主键的实体,包含一个Session对象列表(一个轨道应该有一个或多个会话)。

  • 会话也是一个具有GUID主键的实体,包含一个Speaker对象列表(一个会话可以有零个或多个演讲者)。

  • 演讲者是一个具有复合主键的实体,由SessionIdUserId组成。

事件是一个相对复杂的聚合。应用程序中的大多数聚合将只包含一个实体,即聚合根实体。

聚合根也是一个在聚合中具有特殊角色的实体:它是聚合的根实体,负责子集合。我将使用术语实体来指代聚合根和子集合实体。因此,实体规则适用于这两种对象类型,除非我明确提到其中之一。

在接下来的章节中,我将介绍聚合的两个基本属性:单个单元和序列化对象。

单个单元

聚合作为一个单一单元检索(从数据库中)和存储(在数据库中),包括所有属性和子集合实体。例如,如果你想向一个事件添加一个新的会话,你应该执行以下操作:

  1. 从数据库中读取包含所有TrackSessionSpeaker对象的相关的事件对象。

  2. 使用 Event 类的方法将新的 Session 对象添加到 EventTrack 中。

  3. Event 聚合体连同新更改一起保存到数据库中。

这可能对习惯于使用关系数据库和 ORM(如 EF Core)的开发者来说似乎效率不高。然而,这是必要的,因为这是通过实现业务规则来保持聚合对象的一致性和有效性的方式。

这里是一个实现该过程的简化示例应用程序服务方法:

public class EventAppService
    : EventHubAppService, IEventAppService
{
    //...
    public async Task AddSessionAsync(Guid eventId,
                                      AddSessionDto input)
    {
        var @event = 
            await _eventRepository.GetAsync(eventId);
        @event.AddSession(input.TrackId, input.Title,
            input.StartTime, input.EndTime);
        await _eventRepository.UpdateAsync(@event);
    }
}

对于这个例子,event.AddSession 方法内部会检查新会话的开始时间和结束时间是否与同一轨道上的另一个会话冲突。此外,会话的时间范围不应超出活动的时间范围。我们可能还有其他业务规则。我们可能希望限制活动中的会话数量或检查会话的演讲者是否在同一时间范围内有其他演讲。

记住,领域驱动设计(DDD)是用于状态变化的。如果你需要进行大量查询或准备报告,你可以尽可能优化你的数据库查询。然而,对于任何对聚合体的更改,我们需要在该聚合体上的所有对象上应用与该更改相关的业务规则。如果你担心性能,请参阅保持聚合体小部分。

在方法末尾,我们使用存储库的 UpdateAsync 方法更新了 Event 实体。如果你使用 EF Core,你不需要显式调用 UpdateAsync 方法,因为 EF Core 的更改跟踪系统会为你调用 DbContext.SaveChangesAsync() 方法。然而,例如,MongoDB .NET 驱动程序没有更改跟踪系统,如果你使用 MongoDB,你应该显式调用 UpdateAsync 方法到 Event 对象。

关于 IRepository.GetAsync 方法

存储库的 GetAsync 方法(在先前的示例代码块中使用)将 Event 对象作为一个聚合体(带有所有子集合)作为一个单元检索。对于 MongoDB,它默认工作,但你需要配置你的聚合体以启用 EF Core 的该行为。请参阅第六章**,使用数据访问基础设施中的聚合模式部分,以记住如何配置它。

作为单个单元检索和保存聚合体为我们提供了机会,可以对单个聚合体的对象进行多项更改,并使用单个数据库操作将它们全部保存。这样,该聚合体中的所有更改在本质上都是原子的,无需显式数据库事务。

工作单元系统

如果你需要更改多个聚合(相同或不同类型),你仍然需要一个数据库事务。在这种情况下,ABP 的单元工作系统(在第六章与数据访问基础设施一起工作)会自动按照惯例处理数据库事务。

一个可序列化的对象

一个聚合应该可序列化和可传输,作为一个单独的单元,包括其所有属性和子集合。这意味着你可以将其转换为字节数组或 XML 或 JSON 值,然后从序列化值反序列化(重新构造)它。

EF Core 不会序列化你的实体,但文档数据库,如 MongoDB,可能会将你的聚合序列化为 BSON/JSON 值以存储在数据源中。

这个原则不是聚合的设计要求,但在确定聚合边界时是一个很好的指南。例如,你不能有引用其他聚合实体的属性。否则,引用的对象也将作为你聚合的一部分被序列化。

让我们看看一些更多的原则。下一节中引入的第一条规则是使聚合可序列化的关键实践。

通过 ID 引用其他聚合

第一条规则指出,一个聚合(包括聚合根和其他类)不应该有导航属性到其他聚合,但在必要时可以存储它们的 ID 值。

这个规则使聚合成为一个自包含、可序列化的单元。它还有助于通过隐藏聚合细节来防止聚合的业务逻辑泄露到另一个聚合中。

请参阅以下示例代码块:

public class Event : FullAuditedAggregateRoot<Guid>
{
    public Organization Organization { get; private set; }
    public string Title { get; private set; }
    ...
}

Event类有一个导航属性到Organization聚合,这违反了该规则。如果我们将Event对象序列化为 JSON 值,相关的Organization对象也会被序列化。

在适当的实现中,Event类可以有一个OrganizationId属性来关联Organization

public class Event : FullAuditedAggregateRoot<Guid>
{
    public Guid OrganizationId { get; private set; }
    public string Title { get; private set; }
    ...
}

一旦我们有一个Event对象并且需要访问相关的组织详情,我们应该使用OrganizationId(或执行一个JOIN查询以在开始时一起加载它们)从数据库中查询Organization对象。

如果你使用的是文档数据库,如 MongoDB,这个规则对你来说将显得很自然。因为如果你向 Organization 聚合添加导航属性,那么相关的 Organization 对象将被序列化并保存在数据库中 Event 对象的集合中,这会重复组织数据并将其复制到所有事件中。然而,在使用关系数据库时,EF Core 等 ORM 允许你使用这样的导航属性并处理关系而不会出现任何问题。我仍然建议实施这个规则,因为它可以使你的聚合更简单,并减少加载相关数据的复杂性。如果你不想应用这个规则,可以参考 第九章数据库提供者独立性 部分,理解领域驱动设计

下一节表达了一个最佳实践:保持你的聚合小!

保持聚合小

一旦我们将一个聚合作为一个单一单元加载和保存,如果聚合太大,我们可能会遇到性能和内存使用问题。保持聚合简单和小巧是一个基本原则,这不仅关乎性能,还关乎降低复杂性。

使聚合变大的主要方面是子集合实体可能的数量。如果一个聚合根的子集合包含数百个项目,这是一个设计不良的迹象。在一个好的聚合设计中,子集合中的项目不应超过几十个,并且在边缘情况下应保持在 100-150 以下。

请参见以下代码块中的 Event 聚合:

public class Event : FullAuditedAggregateRoot<Guid>
{
  ...
  public ICollection<Track> Tracks { get; set; }
  public ICollection<EventRegistration> Registrations { 
      get; set; }
}
public class EventRegistration : Entity
{
    public Guid EventId { get; set; }
    public Guid UserId { get; set; }
}

在这个例子中,Event 聚合有两个子集合:TracksRegistrations

Tracks 子集合是事件中的并行轨道集合。它通常包含少量项目,因此在加载 Event 实体时加载轨道没有问题。

Registrations 子集合是事件的注册记录集合。成千上万的人可以为单个事件注册,如果我们每次加载事件时都加载所有注册的人,这将是一个重大的性能问题。而且,在操作 Event 对象时,大多数时候我们不需要所有注册用户。因此,最好不在 Event 聚合中包含注册人员的集合。在这个例子中,EventRegistration 类是一个子集合实体。为了更好的设计,我们应该将其作为一个独立的聚合根类。

在确定聚合边界时,有三个主要考虑因素:

  • 相关并一起使用的对象

  • 数据完整性、有效性和一致性

  • 聚合的加载和保存性能(作为一个技术考虑因素)

在现实生活中,大多数聚合根不会有任何子集合。当你考虑向一个聚合添加子集合时,要考虑对象大小作为一个技术因素。

并发控制

大聚合对象的一个问题是它们增加了并发更新问题的概率,因为大对象更有可能被多个用户同时更改。ABP 框架提供了一个标准的并发控制模型。请参阅文档:docs.abp.io/en/abp/latest/Concurrency-Check

在下一节中,我们将讨论实体的单键和复合键。

确定实体的主键

实体通过其 ID(一个唯一标识符或预构建模块的实体的 Guid 类型。它还假设用户 ID 和租户 ID 类型是 Guid。我们已在 第六章“使用数据访问基础设施” 部分中讨论了此主题)来确定。

ABP 还允许您为实体使用复合主键。复合主键由两个或多个属性(实体的属性)组成,这些属性组合成一个唯一值。

作为最佳实践,为聚合根使用单个主键(一个 Guid 值、一个递增整数值或您想要的任何值)。您可以为子集合实体使用单个或复合主键。

非关系型数据库中的复合键

子集合实体的复合主键通常用于关系型数据库,因为子集合在关系型数据库中有自己的表。然而,在文档数据库(如 MongoDB)中,您不需要为子集合实体定义主键,因为它们没有自己的数据库集合。相反,它们作为聚合根的一部分存储。

EventHub 项目中,Event 是具有 Guid 主键的聚合根。TrackSessionSpeaker 作为 Event 聚合的一部分是子集合实体。TrackSession 实体具有 Guid 主键,但 Speaker 实体具有复合主键。

Speaker 实体类在以下代码块中显示:

public class Speaker : Entity
{
    public Guid SessionId { get; private set; }
    public Guid UserId { get; private set; }
    public Speaker(Guid sessionId, Guid userId)
    {
        SessionId = sessionId;
        UserId = userId;
    }
    public override object[] GetKeys()
    {
        return new object[] {SessionId, UserId};
    }
}

SessionIdUserId 构成了 Speaker 实体的唯一标识符。Speaker 类是从 Entity 类(没有泛型参数)派生的。当您从非泛型 Entity 类派生时,ABP 框架会强制您定义 GetKeys 方法以获取复合键的组件。如果您想使用复合键,请参考您数据库提供者的文档(如 EF Core)以了解如何配置它们。

从下一节开始,我们将探讨聚合和实体的实现细节。

实现实体构造函数

构造函数方法用于创建对象。当我们没有显式地将构造函数添加到类中时,编译器会创建一个默认的无参数构造函数。定义构造函数是确保对象正确创建的好方法。

实体的构造函数负责创建一个有效的实体。它应该获取作为构造函数参数的必需值,以强制我们在对象创建期间提供这些值,以便新创建的对象在创建后即可使用。它应该检查(验证)这些参数并设置实体的属性。它还应该初始化子集合,并在必要时执行其他初始化逻辑。

下面的代码块展示了来自EventHub项目的实体(一个聚合根实体):

public class Country : BasicAggregateRoot<Guid>
{
    public string Name { get; private set; }
    private Country() { } // parameterless constructor
    public Country(Guid id, string name) 
        //primary constructor
        : base(id)
    {
        Name = Check.NotNullOrWhiteSpace(
               name, nameof(name),
               CountryConsts.MaxNameLength);
    }
}

Country是一个非常简单的实体,它只有一个属性:NameName属性是必需的,因此Country的主要构造函数(实际构造函数,旨在由应用程序开发者使用)通过定义一个name参数并检查它是否为空或超过最大长度约束,强制开发者设置一个有效的值到该属性。Check是 ABP 框架的一个静态类,具有各种用于验证方法参数并抛出ArgumentException错误的ArgumentException错误。

Name属性有一个私有设置器,因此创建对象后无法更改此值。我们可以假设在这个例子中,国家不会更改其名称。

Country类的主要构造函数接受另一个参数Guid id。我们不在构造函数中使用Guid.NewGuid(),因为我们想使用 ABP 框架的IGuidGenerator服务,该服务生成顺序 GUID 值(见第六章GUID PK部分,与数据访问基础设施一起工作)。我们直接将id值传递给基类构造函数(在这个例子中是BasicAggregateRoot<Guid>),该构造函数内部设置实体的Id属性。

无参数构造函数的需求

Country类还定义了一个私有、无参数的构造函数。这个构造函数仅用于 ORM,以便它们可以在从数据库读取时构建对象。应用程序开发者不使用它。

让我们看看一个更复杂的例子,展示Event实体的主要构造函数:

internal Event(
    Guid id,
    Guid organizationId,
    string urlCode,
    string title,
    DateTime startTime,
    DateTime endTime,
    string description)
    : base(id)
{
    OrganizationId = organizationId;
    UrlCode = Check.NotNullOrWhiteSpace(urlCode, urlCode,
              EventConsts.UrlCodeLength,
              EventConsts.UrlCodeLength);

    SetTitle(title);
    SetDescription(description);
    SetTimeInternal(startTime, endTime);    
    Tracks = new Collection<Track>();
}

Event类的构造函数接受作为参数的最小必需属性,并检查并设置它们为属性。所有这些属性都有私有设置器(见源代码),并且通过构造函数或Event类的某些方法设置。构造函数使用这些方法来设置TitleDescriptionStartTimeEndTime属性。

让我们看看SetTitle方法的具体实现:

public Event SetTitle(string title)
{
    Title = Check.NotNullOrWhiteSpace(title, nameof(title),
            EventConsts.MaxTitleLength,
            EventConsts.MinTitleLength);
    Url = EventUrlHelper.ConvertTitleToUrlPart(Title) + "-" 
          + UrlCode;
    return this;
}

SetTitle方法通过检查约束将给定的title值分配给Title属性。然后它设置Url属性,这是一个基于Title属性的计算值,以及UrlCode属性。此方法为public,以便在需要更改Event实体的Title属性时使用。

UrlCode是一个八位随机唯一值,它被发送到构造函数,并且永远不会改变。让我们看看构造函数调用的另一个方法:

private Event SetTimeInternal(DateTime startTime, 
                              DateTime endTime)
{
    if (startTime > endTime)
    {
        throw new BusinessException(EventHubErrorCodes
            .EventEndTimeCantBeEarlierThanStartTime);
    }
    StartTime = startTime;
    EndTime = endTime;
    return this;
}

在这里,我们有一个业务规则:StartTime值不能晚于EndTime值。

EventHub构造函数是internal,以防止在领域层之外创建Event对象。应用程序层应始终使用EventManager领域服务来创建一个新的Event实体。在下一节中,我们将看到为什么我们这样设计。

使用服务创建聚合

创建和初始化新实体的最佳方式是使用其公共构造函数,因为这是最简单的方式。然而,在某些情况下,创建一个对象需要一些更复杂的业务逻辑,这些逻辑在构造函数中无法实现。对于这种情况,我们可以在领域服务上使用工厂方法来创建对象。

Event类的主要构造函数是internal,因此上层不能直接创建一个新的Event对象。我们应该使用EventManagerCreateAsync方法来创建一个新的Event对象:

public class EventManager : DomainService
{
    ...
    public async Task<Event> CreateAsync(
        Organization organization,
        string title,
        DateTime startTime,
        DateTime endTime,
        string description)
    {
        return new Event(
            GuidGenerator.Create(),
            organization.Id,
            await _eventUrlCodeGenerator.GenerateAsync(),
            title,
            startTime,
            endTime,
            description
        );
    }
}

我们将在本章的“实现领域服务”部分稍后回到领域服务。通过这个简单的CreateAsync方法,我们创建了一个有效的Event对象,并返回了新对象。我们需要这样的工厂方法,因为我们使用了eventUrlCodeGenerator服务来为新事件生成 URL 代码。eventUrlCodeGenerator服务内部为新事件创建一个随机的、八位的代码,并检查该代码是否被另一个事件使用过(如果你想了解更多,请查看其源代码)。这就是为什么它是async:它执行数据库操作。

我们使用领域服务的工厂方法创建了一个新的Event对象,因为Event类的构造函数不能使用eventUrlCodeGenerator服务。因此,如果你在创建新实体时需要外部服务/对象,你可以创建工厂方法。

工厂服务与领域服务

另一种方法是创建一个专门用于工厂方法的类。这意味着我们可以创建一个EventFactory类,并将CreateAsync方法移入其中。我更喜欢使用领域服务方法来创建实体,以保持构建逻辑与其他与实体相关的领域逻辑紧密相连。

不要在Factory方法中将新实体保存到数据库中,并将其留给客户端代码(通常是一个应用程序服务方法)。Factory方法的责任是创建对象,不再有其他责任(把它想象成一个高级构造函数——实体构造函数不能将实体保存到数据库中,对吧?)。客户端代码可能需要在保存实体之前对实体执行额外的操作。我们将在下一章回到这个话题。

不要过度使用工厂方法,尽可能保持使用简单的公共构造函数。创建一个有效的实体很重要,但这只是实体生命周期的开始。在下一节中,我们将看到如何以受控的方式更改实体的状态。

实现业务逻辑和约束

实体负责始终保持自身有效。除了确保实体在首次创建时有效和一致性的构造函数之外,我们还可以在实体类上定义方法,以受控的方式更改其属性。

作为一条简单的规则,如果更改属性值的操作有先决条件,我们应该将其设置器 private,并提供一个方法来通过实现必要的业务逻辑和验证提供的值来更改其值。

看一下 Event 类的 Description 属性:

public class Event : FullAuditedAggregateRoot<Guid>
{
    ...
    public string Description { get; private set; }

    public Event SetDescription(string description)
    {
        Description = Check.NotNullOrWhiteSpace(
            description, nameof(description),
            EventConsts.MaxDescriptionLength,
            EventConsts.MinDescriptionLength);
        return this;
    }
}

Description 属性的设置器是 private 的。我们提供 SetDescription 方法作为更改其值的唯一方式。在这个方法中,我们验证 description 值:它应该是一个长度超过 50 (MinDescriptionLength) 且小于 2000 (MaxDescriptionLength) 的字符串。这些常量定义在 *EventHub.Domain.Shared* 项目中,因此我们可以在 DTO 中重用它们,正如我们将在下一章中看到的。

实体属性上的数据注释属性

你可能会问,我们是否可以在 Description 属性上使用 [Required][StringLength] 属性,而不是创建一个 SetDescription 方法并手动执行验证。这些属性需要另一个执行验证的系统。例如,EF Core 可以在将实体保存到数据库时根据这些数据注释属性验证属性。然而,这还不够,因为以这种方式,实体可能在我们尝试将其保存到数据库之前就是无效的。实体应该始终是有效的!

让我们来看一个更复杂的例子,再次来自 Event 类:

public Event AddSession(Guid trackId, Guid sessionId,
    string title, DateTime startTime, DateTime endTime,
    string description, string language)
{
    if (startTime < this.StartTime || this.EndTime < 
        endTime)
    {
        throw new BusinessException(EventHubErrorCodes
            .SessionTimeShouldBeInTheEventTime);
    }
    var track = GetTrack(trackId);
    track.AddSession(sessionId, title, startTime, endTime,
                     description, language);
    return this;
}
private Track GetTrack(Guid trackId)
{
    return Tracks.FirstOrDefault(t => t.Id == trackId) ??
        throw new EntityNotFoundException(typeof(Track),
                                          trackId);
}

AddSession 方法接受一个 trackId 参数,因为一个会话应该属于一个轨道。它还接受新会话的 sessionId(作为参数获取,以便客户端可以使用 IGuidGenerator 服务来创建值)。其余参数是新会话的必需属性。

AddSession 方法首先检查新会话是否在活动的时间范围内,然后找到正确的轨道(否则抛出异常),并将剩余的工作委托给 track 对象。让我们看看 track.AddSession 方法:

internal Track AddSession(Guid sessionId, string title,
    DateTime startTime, DateTime endTime,
    string description, string language)
{
    if (startTime > endTime)
    {
        throw new BusinessException(EventHubErrorCodes
            .EndTimeCantBeEarlierThanStartTime);
    }
    foreach (var session in Sessions)
    {
      if (startTime.IsBetween(session.StartTime,
          session.EndTime) ||
          endTime.IsBetween(session.StartTime, 
          session.EndTime))
      {
        throw new BusinessException(EventHubErrorCodes
            .SessionTimeConflictsWithAnExistingSession);
      }
    }    
    Sessions.Add(new Session(sessionId, Id, title, 
                 startTime, endTime, description));    
    return this;
}

首先,这个方法是 internal 的,以防止在领域层之外使用它。它总是由本节前面展示的 Event.AddSession 方法使用。

Track.AddSession 方法遍历所有当前会话,以检查是否有任何会话时间与新会话冲突。如果没有问题,它将会将会话添加到轨道中。

从设置器方法返回this(事件对象)是一种良好的实践,因为它允许我们链式调用设置器,例如,eventObject.SetTime(…).SetDescription(…)

这两个示例方法都使用了事件对象上的属性,并且没有依赖于任何外部对象。如果我们需要使用外部服务或存储库来实现业务规则怎么办?

在实体方法中使用外部服务

有时,你想应用的业务规则需要使用外部服务。由于技术和设计限制,实体不能注入服务依赖。如果你需要在实体方法中使用服务,正确的方式是作为参数获取该服务。

假设我们有一个关于事件容量的业务规则:你不能将容量降低到当前注册用户数以下。null容量值表示没有注册限制。

以下是在Event类上的实现示例:

public async Task SetCapacityAsync(
    IRepository<EventRegistration, Guid>
        registrationRepository, int? capacity)
{
    if (capacity.HasValue)
    {
        var registeredUserCount = await 
            registrationRepository.CountAsync(x =>
                x.EventId == @event.Id);
        if (capacity.Value < registeredUserCount)
        {
            throw new BusinessException(
            EventHubErrorCodes
            .CapacityCanNotBeLowerThanRegisteredUserCount);
        }
    }
    this.Capacity = capacity;
}

SetCapacityAsync方法使用存储库对象执行数据库查询以获取当前注册用户数。如果计数高于新容量值,则抛出异常。由于它执行异步数据库调用,SetCapacityAsync方法是异步的。客户端(通常是一个应用程序服务方法)负责向此方法注入和传递存储库服务。

SetCapacityAsync方法保证了业务规则的实现,因为Capacity属性的设置器是private的,这是唯一更改它的方式。

你可以将外部服务作为参数传递给方法,如本例所示。然而,这种方法使得实体依赖于外部服务,使得其变得复杂且难以测试。它还违反了单一职责原则,并混合了不同聚合体的业务逻辑(EventRegistration是另一个聚合根)。

实现依赖于外部服务或作用于多个聚合体的业务逻辑有更好的方法:领域服务。

实现领域服务

领域服务是另一个类,我们在其中实现领域规则和约束。通常在需要与多个聚合体一起工作时,需要领域服务,并且业务逻辑不适合这些聚合体中的任何一个。当需要消费其他服务和存储库时,也使用领域服务,因为它们可以使用依赖注入系统。

让我们重新实现(在上一节中)的SetCapacityAsync方法作为领域服务方法:

public class EventManager : DomainService
{
    ...
    public async Task SetCapacityAsync(Event @event, 
                                       int? capacity)
    {
        if (capacity.HasValue)
        {
            var registeredUserCount = await
                _eventRegistrationRepository.CountAsync(
                    x => x.EventId == @event.Id);
            if (capacity.Value < registeredUserCount)
            {
                throw new BusinessException(
                EventHubErrorCodes.CapacityCanNotBeLower
                ThanRegisteredUserCount);
            }
        }
        @event.Capacity = capacity;
    }
}

在这种情况下,我们将IRepository<EventRegistration, Guid>注入到EventManager领域服务中(有关所有详细信息的源代码请参阅),并将Event对象作为参数获取。现在Event.Capacity属性的设置器是internal,这样它就只能在内层领域,即EventManager类中设置。

领域服务方法应该是细粒度的:它应该对聚合体进行小的(但有意义且一致的)更改。然后应用层将这些小的更改组合起来以执行不同的用例。

我们将在下一章探讨应用服务。然而,我认为在这里展示一个示例应用服务方法是有用的,该方法在一个请求中更新事件上的多个属性:

public async Task UpdateAsync(Guid id, 
                              UpdateEventDto input)
{
    var @event = await _eventRepository.GetAsync(id);
O
    @event.SetTitle(input.Title);
    @event.SetTime(input.StartTime, input.EndTime);
    await _eventManager.SetCapacityAsync(@event,
                                         input.Capacity);
    @event.Language = input.Language;
    await _eventRepository.UpdateAsync(@event);
}

UpdateAsync方法接受一个包含要更新属性的 DTO。它首先从数据库中检索Event对象作为一个单一单元,然后使用Event对象上的SetTitleSetTime方法。这些方法内部验证提供的值,并适当地更改属性值。

UpdateAsync方法随后使用领域服务方法eventManager.SetCapacity来更改容量值。

我们直接设置Language属性,因为它有一个public的设置器,并且没有业务规则(它甚至接受null值)。如果没有业务规则或约束,不要创建设置器方法。同样,也不要仅仅为了改变实体属性而不涉及任何业务逻辑就创建领域服务方法。

UpdateAsync方法最终使用存储库来更新数据库中的Event实体。

领域服务接口

由于它们是领域的基本部分,不应该被抽象化,因此不需要为领域服务引入接口(如IEventManager)。然而,如果你想对领域服务进行单元测试,你可能仍然想创建接口。

根据一个普遍原则,领域服务方法不应该更新实体。在这个例子中,我们在调用SetCapacityAsync方法之后设置了Language属性。如果SetCapacityAsync更新了实体,我们最终会有两次数据库更新操作,这将是不高效的。

作为另一个良好的实践,接受实体对象作为参数(就像我们在SetCapacityAsync方法中所做的那样),而不是它的id值。如果你接受它的id值,你需要在领域服务内部从数据库中检索实体。这种方法使得应用代码在同一个请求(用例)中的不同地方多次加载相同的实体,这是低效的,并可能导致错误。将这项责任留给应用层。

领域服务方法的一种特定类型是创建聚合的工厂方法,这在使用服务创建聚合部分中解释。只有当聚合根的公共构造函数无法实现业务约束时,才声明工厂方法。这可能是在检查业务约束需要使用外部服务的情况下。

我们到目前为止已经在许多地方使用了存储库。下一节将解释存储库的实现细节。

实现存储库

为了记住定义,存储库是一个类似于集合的接口,用于访问存储在数据持久化系统中的领域对象。它隐藏了数据访问逻辑的复杂性,提供了一个简单的抽象。

实现存储库有一些主要规则:

  • 存储库接口在领域层定义,因此领域和应用层可以使用它们。它们在基础设施(或数据库提供者集成)层实现。

  • 存储库是为聚合根实体创建的,而不是为子集合实体创建的。这是因为子集合实体应该通过聚合根来访问。通常,你为每个聚合根有一个存储库。

  • 存储库与领域对象协同工作,而不是与 DTO 协同工作。

  • 在理想的设计中,存储库接口应该独立于数据库提供者。因此,不要获取或返回 EF Core 对象,例如DbContextDbSet

  • 不要在存储库类中实现业务逻辑。

ABP 提供了一种开箱即用的存储库模式实现。我们在第六章“与数据访问基础设施协同工作”中探讨了如何使用通用存储库和实现自定义存储库。在这里,我将讨论一些最佳实践。

在前面列表中的最后一条规则“不要在存储库类中实现业务逻辑”是最重要的规则,因为其他规则都很容易理解。在存储库中实现业务逻辑通常是由于错误地考虑业务逻辑。

请看以下示例存储库接口:

public interface IEventRepository : IRepository<Event,
                                                Guid>
{
    Task UpdateSessionTimeAsync(
        Guid sessionId, DateTime startTime, DateTime
            endTime);
    Task<List<Event>> GetNearbyEventsAsync();
}

初看之下,似乎没有问题;这些方法只是执行一些数据库操作。然而,问题出在细节上。

第一个方法UpdateSessionTimeAsync更改事件中会话的时间。如果你还记得,我们有一个业务规则:会话的时间不能与同一轨道上的另一个会话重叠。它也不能超出事件时间范围。如果我们在这个存储库方法中实现这个规则,我们就会重复业务验证,因为这项验证已经在Event聚合体内部实现了。如果我们不实现这个验证,显然是一个错误。在真正的实现中,这个逻辑应该在聚合体内部完成。存储库应该只查询和更新聚合体作为一个单一单元。

第二个方法 GetNearbyEventsAsync 获取与当前用户在同一城市的活动列表。这个方法的问题在于 当前用户 是一个应用层概念,需要活跃的用户会话。仓储不应该与当前用户一起工作。如果我们想在没有当前用户的当前上下文中重用相同的 附近 逻辑在后台服务中怎么办?最好是将城市、日期范围和其他参数传递给方法,让它简单地获取事件。实体属性只是仓储的值。仓储不应该有任何领域知识,也不应该使用应用层功能。

仓储主要用于创建、更新、删除和查询实体。ABP 的通用仓储实现提供了大多数常见的操作。它还提供了一个 IQueryable 对象,您可以使用它来构建和执行使用 LINQ 的查询。然而,在应用层构建复杂的查询会将您的应用逻辑与理想中应在基础设施层的数据查询逻辑混合在一起。

请看以下示例方法,它使用 IRepository<Event, Guid> 获取给定用户演讲的事件列表:

public async Task<List<Event>> GetSpokenEventsAsync(Guid
                                                    userId)
{
    var queryable = 
        await _eventRepository.GetQueryableAsync();
    var query = queryable.Where(x => x.Tracks
        .Any(track => track.Sessions
          .Any(session => session.Speakers
            .Any(speaker => speaker.UserId == userId))));
    return await AsyncExecuter.ToListAsync(query);
}

在第一行,我们正在获取一个 IQueryable<Event> 对象。然后我们使用 Where 方法来过滤事件。最后,我们执行查询以获取事件列表。

将此类查询写入应用程序服务的问题是将查询逻辑泄露到应用层,使得当我们需要在其他地方重用查询逻辑时变得不可能。为了克服这个问题,我们通常创建一个自定义仓储方法来查询事件:

public interface IEventRepository : IRepository<Event,
                                                Guid>
{
    Task<List<Event>> GetSpokenEventsAsync(Guid userId);
}

现在,我们可以在需要获取用户曾经演讲的事件的任何地方使用这个自定义仓储方法。

创建自定义仓储方法是一个好方法。但随着应用的扩展,我们会有很多类似的方法。假设我们想在指定日期范围内获取事件列表,我们已经添加了一个新方法:

public interface IEventRepository : IRepository<Event,
                                                Guid>
{
    Task<List<Event>> GetSpokenEventsAsync(Guid userId);
    Task<List<Event>> GetEventsByDateRangeAsync(DateTime
        minDate, DateTime maxDate);
}

如果我们想按日期范围和演讲者过滤器查询事件呢?创建另一个方法,如下面的代码块所示:

Task<List<Event>> GetSpokenEventsByDateRangeAsync(Guid userId, DateTime minDate, DateTime maxDate)

实际上,ABP 提供了 GetListAsync 方法,它接受一个表达式。因此,我们可以移除所有这些方法,并使用带有任意谓词的 GetListAsync 方法。

以下示例使用 GetListAsync 方法获取用户在接下来 30 天内作为演讲者的事件列表:

public async Task<List<Event>> GetSpokenEventsAsync(Guid
                                                    userId)
{
    var startTime = Clock.Now;
    var endTime = Clock.Now.AddDays(30);
    return await _eventRepository.GetListAsync(x =>
        x.Tracks
        .Any(track => track.Sessions
            .Any(session => session.Speakers
                .Any(speaker => speaker.UserId == userId)))
        && x.StartTime > startTime && x.StartTime <= 
            endTime    
    );
}

然而,我们又回到了之前的问题:将查询复杂性与应用代码混合。此外,查询不是变得越来越难以理解吗?你知道,在现实生活中,我们有很多更复杂的查询。

完全消除复杂查询可能是不可能的,但下一节提供了一个有趣的解决方案:规范模式!

构建规范

规范是一个命名的、可重用、可组合和可测试的类,用于根据业务规则过滤域对象。在实践中,我们可以轻松地将过滤表达式封装为可重用的对象。

在本节中,我们将从最简单的无参数规范开始。然后我们将看到更复杂的参数化规范。最后,我们将学习如何组合多个规范以创建更复杂的规范。

无参数规范

让我们从一个非常简单的规范类开始:

public class OnlineEventSpecification :
    Specification<Event>
{
    public override Expression<Func<Event, bool>>
        ToExpression()
    {
        return x => x.IsOnline == true;
    }
}

OnlineEventSpecification 用于过滤线上事件,这意味着它选择一个事件,如果它是线上事件。它是从 ABP 框架提供的基类 Specification<T> 派生出来的,以轻松创建规范类。我们重写 ToExpression 方法来过滤事件对象。此方法应返回一个 lambda 表达式,如果给定的 Event 实体(在这里,是 x 对象)满足条件(我们可以简单地写 return x => x.IsOnline)。

现在,如果我们想获取线上事件的列表,我们只需使用带有规范对象的存储库的 GetListAsync 方法:

var events = _eventRepository
    .GetListAsync(new OnlineEventSpecification());

规范隐式转换为表达式(记住,GetListAsync 方法可以获取表达式)。如果您想显式转换它们,可以调用 ToExpression 方法:

var events = _eventRepository
    .GetListAsync(
        new OnlineEventSpecification().ToExpression());

因此,我们可以在可以使用表达式的任何地方使用规范。这样,我们可以将表达式封装为命名的、可重用的对象。

Specification 类提供了一个名为 IsSatisfiedBy 的方法,用于测试单个对象。如果您有一个 Event 对象,您可以轻松地检查它是否是线上活动:

Event evnt = GetEvent();
if (new OnlineEventSpecification().IsSatisfiedBy(evnt))
{
    // ...
}

在这个例子中,我们以某种方式获得了一个 Event 对象,我们想检查它是否是线上的。IsSatisfiedBy 接收一个 Event 对象,如果该对象满足条件,则返回 true。我接受这个例子看起来很荒谬,因为我们可以直接写 if(evnt.IsOnline)。这样的简单规范是不必要的。然而,在下一节中,我们将看到更复杂的例子,以使其更加清晰。

参数化规范

规范可以具有用于过滤表达式的参数。请参见以下示例:

public class SpeakerSpecification : Specification<Event>
{
    public Guid UserId { get; }
    public SpeakerSpecification(Guid userId)
    {
        UserId = userId;
    }

    public override Expression<Func<Event, bool>>
        ToExpression()
    {
        return x => x.Tracks
            .Any(t => t.Sessions
                .Any(s => s.Speakers
                    .Any(sp => sp.UserId == UserId)));
    }
}

我们创建了一个参数化规范类,用于检查给定的用户是否在活动中是演讲者。一旦我们有了这个规范类,我们就可以像以下代码块所示那样过滤事件:

public async Task<List<Event>> GetSpokenEventsAsync(Guid
                                                    userId)
{
    return await _eventRepository.GetListAsync(
        new SpeakerSpecification(userId));
}

在这里,我们只是通过提供一个新的 SpeakerSpecification 对象来重用了存储库的 GetListAsync 方法。从现在起,如果我们需要在应用程序的另一个地方稍后重用相同的表达式,我们可以重用这个规范类,而无需复制/粘贴表达式。如果我们稍后需要更改条件,所有这些地方都将使用更新的表达式。

如果我们需要检查用户是否在给定的 Event 中是演讲者,我们可以通过调用其 IsSatisfiedBy 方法来重用 SpeakerSpecification 类:

Event evnt = GetEvent();
if (new SpeakerSpecification(userId).IsSatisfiedBy(evnt))
{
    // ...
}

规范非常强大,可以创建命名和可重用的过滤器,但它们还有另一个功能:将规范组合起来创建一个组合规范对象。

组合规范

可以使用类似操作符的 AndOrAndNot 方法组合多个规范,或者使用 Not 方法反转一个规范。

假设我想找到给定用户是演讲者且活动是线上的事件:

var events = _eventRepository.GetListAsync(
    new SpeakerSpecification(userId)
        .And(new OnlineEventSpecification())
        .ToExpression()
);

在这个例子中,我结合了 SpeakerSpecificationOnlineEventSpecification 对象来创建一个组合规范对象。在这种情况下,显式调用 ToExpression 类是必要的,因为 C# 不支持从接口(And 方法返回 ISpecification<T> 引用)隐式转换。

以下示例查找在接下来的 30 天内给定用户作为演讲者的现场(离线)活动:

var events = _eventRepository.GetListAsync(
    new SpeakerSpecification(userId)
        .And(new DateRangeSpecification(Clock.Now,
             Clock.Now.AddDays(30)))
        .AndNot(new OnlineEventSpecification())
        .ToExpression()
);

在这个例子中,我们使用 AndNot 方法反转了 OnlineEventSpecification 对象的过滤逻辑。我们还使用了一个尚未定义的 DateRangeSpecification 对象。自己实现它是一个很好的练习。

一个有趣的例子是将 AndSpecification 类扩展以创建一个组合两个规范的规范类:

public class OnlineSpeakerSpecification : 
    AndSpecification<Event>
{
    public OnlineSpeakerSpecification(Guid userId)
        : base(new SpeakerSpecification(userId),
               new OnlineEventSpecification())
    {
    }
}

在这个例子中,OnlineSpeakerSpecification 类结合了 SpeakerSpecification 类和 OnlineEventSpecification 类,并且可以在你想使用规范对象时使用。

何时使用规范

如果它们基于可以未来更改的领域规则过滤对象,规范特别有用,因此你不想在各个地方重复它们。你不需要为仅用于报告目的的表达式定义规范。

下一节将解释如何使用领域事件来发布通知。

发布领域事件

领域事件用于通知其他组件和服务关于领域对象的重要更改,以便它们可以采取行动。

ABP 框架提供了两种类型的事件总线来发布领域事件,每种类型都有不同的用途:

  • 本地事件总线用于通知同一进程中的处理器。

  • 分布式事件总线用于通知同一或不同进程中的处理器。

使用 ABP 框架发布和处理事件非常简单。下一节将展示如何使用本地事件总线,然后我们将探讨分布式事件总线。

使用本地事件总线

本地事件处理器在同一个工作单元(同一个本地数据库事务)中执行。如果你正在构建单体应用程序或想在同一个服务中处理事件,本地事件总线快速且安全,因为它在同一个进程中工作。

假设你想在事件的时间改变时发布一个本地事件,并且你有一个事件处理器,它会向注册用户发送关于更改的电子邮件。

请参阅 Event 类的 SetTime 方法的简化实现:

public void SetTime(DateTime startTime, DateTime endTime)
{
    if (startTime > endTime)
    {
        throw new BusinessException(EventHubErrorCodes     
            .EndTimeCantBeEarlierThanStartTime);
    }
    StartTime = startTime;
    EndTime = endTime;
    if (!IsDraft)
    {
        AddLocalEvent(new EventTimeChangedEventData(this));
    }
}

在这个例子中,我们正在添加一个本地事件,该事件将在更新实体时发布。ABP 框架覆盖了 EF Core 的 SaveChangesAsync 方法来发布事件(对于 MongoDB,这是在仓储的 UpdateAsync 方法中完成的)。

在这里,EventTimeChangedEventData 是一个简单的类,用于存储事件数据:

public class EventTimeChangedEventData
{
    public Event Event { get; }
    public EventTimeChangedEventData(Event @event)
    {
        Event = @event;
    }
}

可以通过创建一个实现 ILocalEventHandler<TEventData> 接口的类来处理发布的事件:

public class UserEmailingHandler :
    ILocalEventHandler<EventTimeChangedEventData>,
    ITransientDependency
{
    public async Task HandleEventAsync(
        EventTimeChangedEventData eventData)
    {
        var @event = eventData.Event;
        // TODO: Send email to the registered users!
    }
}

UserEmailingHandler 类可以注入任何服务(或仓储)以获取注册用户的列表,然后发送电子邮件通知他们时间变更。对于同一事件,你可能会有多个处理器。如果任何处理器抛出异常,由于事件处理器是在同一数据库事务中执行的,因此主要数据库事务将被回滚。

事件可以在实体中发布,如前例所示。它们也可以使用 ILocalEventBus 服务发布。

假设我们不想在 Event 类内部发布 EventTimeChangedEventData 事件,而想在可以利用依赖注入系统的任意类中发布它。请看以下示例应用服务:

public class EventAppService : EventHubAppService, 
    IEventAppService
{
    private readonly IRepository<Event, Guid> 
        _eventRepository;
    private readonly ILocalEventBus _localEventBus;
    public EventAppService(
        IRepository<Event, Guid> eventRepository,
        ILocalEventBus localEventBus)
    {
        _eventRepository = eventRepository;
        _localEventBus = localEventBus;
    }
    public async Task SetTimeAsync(
        Guid eventId, DateTime startTime, DateTime endTime)
    {
        var @event = 
            await _eventRepository.GetAsync(eventId);
        @event.SetTime(startTime, endTime);
        await _eventRepository.UpdateAsync(@event);
        await _localEventBus.PublishAsync(
            new EventTimeChangedEventData(@event));
    }
}

EventAppService 类注入了仓储和 ILocalEventBus 服务。在 SetTimeAsync 方法中,我们使用本地事件总线发布相同的事件。

ILocalEventBus 服务的 PublishAsync 方法立即执行事件处理器。如果有任何事件处理器抛出异常,你会直接得到异常,因为 PublishAsync 方法不处理异常。所以,如果你没有捕获异常,整个工作单元将被回滚。

在实体或领域服务中发布事件会更好。如果我们把 EventTimeChangedEventData 事件发布在 Event 类的 SetTime 方法中,那么在任何情况下都会保证发布该事件。然而,如果我们像在最后一个例子中那样在应用服务中发布它,我们可能会忘记在改变事件时间的另一个地方发布事件。即使我们没有忘记,我们也会产生难以维护且容易出错的重复代码。

本地事件特别适用于实现副作用,例如在对象状态改变时采取额外行动。它非常适合解耦和集成系统的不同方面。在本节中,我们已用它来在事件的时间改变时向注册用户发送通知电子邮件。然而,不应滥用它,将业务逻辑流程分布到事件处理器中,从而使整个过程难以追踪。

在下一节中,我们将看到第二种类型的事件总线。

使用分布式事件总线

在分布式事件总线中,事件通过消息代理服务(如 RabbitMQ 或 Kafka)发布。如果你正在构建微服务/分布式解决方案,分布式事件总线可以异步地通知其他服务中的处理程序。

使用分布式事件总线与本地事件总线非常相似,但理解它们之间的差异和限制是很重要的。

假设我们想在Event类的SetTime方法中事件时间变化时发布分布式事件:

public void SetTime(DateTime startTime, DateTime endTime)
{
    if (startTime > endTime)
    {
        throw new BusinessException(EventHubErrorCodes 
            .EndTimeCantBeEarlierThanStartTime);
    }
    StartTime = startTime;
    EndTime = endTime;
    if (!IsDraft)
    {
        AddDistributedEvent(new EventTimeChangedEto
        {
            EventId = Id, Title = Title,
            StartTime = StartTime, EndTime = EndTime
        });
    }
}

在这里,我们调用AddDistributedEvent方法来发布事件(当实体在数据库中更新时发布)。与本地事件的一个重要区别是,我们不是将实体(this)对象作为事件数据传递,而是将一些属性复制到一个新对象中。这个新对象将在进程之间传输。它将在当前进程中序列化,并在目标进程中反序列化(ABP 框架为你处理序列化和反序列化)。因此,创建一个只携带所需属性而不是完整对象的 DTO-like 对象更好。我们建议使用Eto事件传输对象)后缀作为命名约定,但这不是必需的。

AddDistributedEvent(以及AddLocalEvent)方法仅在聚合根实体中可用,而不是在子集合实体中。然而,使用IDistributedEventBus服务在任意服务中发布分布式事件仍然是可能的:

await _distributedEventBus.PublishAsync(
    new EventTimeChangedEto
    {
        EventId = @event.Id,
        Title = @event.Title,
        StartTime = @event.StartTime,
        EndTime = @event.EndTime
    });

注入IDistributedEventBus服务并使用PublishAsync方法——这就足够了。

想要接收通知的应用程序/服务可以创建一个实现IDistributedEventHandler<T>接口的类,如下面的代码块所示:

public class UserEmailingHandler :
    IDistributedEventHandler<EventTimeChangedEto>,
    ITransientDependency
{
    public Task HandleEventAsync(EventTimeChangedEto 
                                 eventData)
    {
        var eventId = eventData.EventId;
        // TODO: Send email to the registered users! 
    }
}

事件处理程序可以使用EventTimeChangedEto类的所有属性。如果需要更多数据,可以将其添加到ETO类中。或者,在分布式场景中,你可以从数据库查询详细信息或对相应的服务执行 API 调用。

摘要

本章介绍了实现 DDD 的第一部分。我们探讨了领域层构建块,并了解了使用 ABP 框架的设计和实现实践。

聚合是 DDD 最基础的构建块,我们改变聚合状态的方式非常重要,需要小心处理。聚合应通过实现业务规则来保持其有效性和一致性。正确绘制聚合边界是至关重要的。

另一方面,领域服务对于实现涉及多个聚合或外部服务的领域逻辑非常有用。它们与领域对象一起工作,而不是与 DTOs 一起工作。

仓储模式抽象了数据访问逻辑,并为领域层和应用层中的其他服务提供了一个易于使用的接口。重要的是不要将业务逻辑泄露到仓储中。规范模式是一种封装数据过滤逻辑的方法。当你想要选择业务对象时,你可以重用和组合它们。

最后,我们探讨了如何使用 ABP 框架发布和订阅领域事件。领域事件用于以松耦合的方式响应领域对象的变更。

下一章将继续介绍构建块,这次是在应用层。它还将通过实际示例讨论领域层和应用层之间的差异。

第十一章:第十一章:DDD – 应用层

前一章详细解释了领域层构建块。领域层用于实现解决方案的核心、与应用程序无关的领域逻辑。然而,我们还需要一些应用程序与该领域逻辑进行交互,例如一个 Web 或移动应用程序。应用层负责实现这些应用程序的业务逻辑,而不依赖于表示层中使用的用户界面UI)技术。我们通过将应用服务封装起来,将领域层与表示技术隔离开来。

在本章中,我们将学习如何使用 ABP 框架设计和实现应用服务和数据传输对象DTOs)。我们还将了解领域层和应用层职责之间的区别。

本章涵盖了以下主题:

  • 实现应用服务

  • 设计 DTOs

  • 理解各层的职责

技术要求

您可以从 GitHub 克隆或下载EventHub项目的源代码:github.com/volosoft/eventhub

如果您想在本地开发环境中运行解决方案,您需要一个集成开发环境IDE)/编辑器(例如 Visual Studio)来构建和运行 ASP.NET Core 解决方案。此外,如果您想创建 ABP 解决方案,您需要安装 ABP 命令行界面CLI),如第二章开始使用 ABP 框架中所述。

实现应用服务

应用服务是一个无状态的类,由表示层使用以执行应用程序的用例。它协调领域对象以实现业务操作。应用服务获取和返回 DTO 而不是实体。

应用服务方法被视为一个工作单元(意味着所有数据库操作——要么全部成功,要么全部失败作为一个组,如在第第六章与数据访问基础设施一起工作中所述),ABP 框架会自动处理。一个典型应用服务方法的流程包括以下步骤:

  1. 使用输入参数和当前上下文从仓储获取必要的聚合。

  2. 通过协调聚合、领域服务和其他领域对象,并委托工作给它们来实现用例。

  3. 使用仓储更新数据库中已更改的聚合。

  4. 可选地,向客户端返回一个结果 DTO(通常,返回给表示层)。

    关于更新更改对象

    实际上,如果你使用 Entity Framework Core (EF Core),则 步骤 3 是不必要的,因为 EF Core 有一个更改跟踪系统,可以自动确定更改的对象,并在 工作单元 (UoW) 的末尾将它们更新到数据库中。所以,如果你没有问题依赖 EF Core 的功能,你可以跳过 步骤 3

让我们看看以下示例中的 AddSessionAsync 应用服务方法:

public class EventAppService : ApplicationService,
    IEventAppService
{
    ...
    [Authorize]
    public async Task AddSessionAsync(Guid id,
                                      AddSessionDto input)
    {
        var @event = await _eventRepository.GetAsync(id);
        @event.AddSession(
            input.TrackId, GuidGenerator.Create(),
            input.Title,
            input.StartTime, input.EndTime,
            input.Description, input.Language
        );
        await _eventRepository.UpdateAsync(@event);
    }
}

此方法是一个简单的应用服务方法。它用于向事件添加一个新的会话。它首先从数据库中获取相关的 Event 聚合。然后,它使用 Event 类的 AddSession 方法将实际的业务操作委派给域层。最后,它更新数据库中的更改后的 Event 对象。我们将在本章的 设计 DTOs 部分看到 AddSessionDto 类。

让我们看看一个更复杂的例子,如下创建一个新事件:

[Authorize]
public async Task<EventDto> CreateAsync(CreateEventDto 
                                        input)
{
    var organization = await _organizationRepository
        .GetAsync(input.OrganizationId);
    if (organization.OwnerUserId != CurrentUser.GetId())
    {
        throw new AbpAuthorizationException(
        L[“EventHub:
          NotAuthorizedToCreateEventInThisOrganization”,
          organization.DisplayName]
        );
    }
    var @event = await _eventManager.CreateAsync(
        organization, input.Title,
        input.StartTime, input.EndTime, input.Description);
    await _eventManager.SetLocationAsync(@event,
        input.IsOnline, input.OnlineLink, input.CountryId,
        input.City);
    await _eventManager.SetCapacityAsync(@event,
                                         input.Capacity);
    @event.Language = input.Language;
    if (input.CoverImageContent != null &&
        input.CoverImageContent.Length > 0)
    {
        await SaveCoverImageAsync(
            @event.Id, input.CoverImageContent);
    }
    await _eventRepository.InsertAsync(@event);
    return ObjectMapper.Map<Event, EventDto>(@event);
}

CreateAsync 方法从 UI 层获取一个 CreateEventDto 对象,该对象携带新事件数据,是创建新实体的一个好例子。让我们调查它是如何实现的。

它首先从数据库中获取 organization 对象,并比较其所有者的 AbpAuthorizationException 异常,如果用户不满足条件。授权是应用层的责任,不同的应用中授权规则可能不同。例如,在管理应用中,管理员用户可以代表任何用户创建事件,而不检查组织的所有权。

CreateAsync 方法随后使用 eventManager 域服务创建一个新的 Event 对象,并带有所需的最小属性。

我们已经创建了一个 Event 对象,但我们的工作还没有完成。CreateEventDto 有一些可选属性,用户可能设置。我们再次使用 eventManager 域服务来设置事件的地点。然后,我们直接设置 EventLanguage 属性,因为没有业务规则来设置它;它有一个公共设置器,其值甚至可以是 null

CreateAsync 方法继续使用 eventManager 类通过检查核心域规则来设置事件容量。如果提供了事件封面图像,它也会保存该图像。

到那时,Event 对象还没有保存到数据库中。所有操作都是在内存对象上执行的。域服务不会将更改保存到数据库中,因为这是应用层的责任。如果域服务方法保存了它们的更改,我们最终会在数据库中得到一个插入操作和三个更新操作。根据当前实现,CreateAsync 方法使用存储库的 InsertAsync 方法,并在方法末尾通过单个数据库操作保存对象。

如您在示例应用程序服务定义中看到的,应用程序服务方法使用 DTO 类从上层(通常是表示层)获取数据,并将数据返回到上层。下一节介绍了 DTO 设计考虑因素和最佳实践。

设计 DTO

DTO 是一个简单的对象,用于在表示层和应用层之间传输数据。让我们先看看设计 DTO 类的基本原则。

设计 DTO 类

在定义 DTO 类时,有一些基本的原则需要遵循,如下所述:

  • DTOs 不应该包含任何业务逻辑;它们只是用于数据传输。

  • DTO 对象应该是可序列化的,因为它们大多数时候都是通过网络传输的。通常,它们有一个无参构造函数,并且所有属性都有公共的 getter 和 setter。

  • DTO 类不应该从实体继承,也不应该使用实体类型作为它们的属性。

以下 DTO 类用于在现有事件的轨迹中添加新会话时存储数据:

public class AddSessionDto
{
    [Required]
    [StringLength(SessionConsts.MaxTitleLength,
        MinimumLength = SessionConsts.MinTitleLength)]
    public string Title { get; set; }
    [Required]
    [StringLength(SessionConsts.MaxDescriptionLength,
        MinimumLength = 
            SessionConsts.MinDescriptionLength)]
    public string Description { get; set; }
    public Guid TrackId { get; set; }
    public DateTime StartTime { get; set; }
    public DateTime EndTime { get; set; }
    public string Language { get; set; }
}

AddSessionDto类没有方法,因此没有业务逻辑。它所有的属性都有公共的 getter 和 setter。AddSessionDto类没有定义任何构造函数,因此它有一个隐式的公共无参构造函数。

AddSessionDto类的TitleDescription属性有验证属性,例如RequiredStringLength

下一节讨论了验证输入 DTO。

验证输入 DTO

当 DTO 对象作为应用程序服务方法参数使用时,有几种方式可以验证 DTO 对象,如下所述:

  • 我们可以使用数据注释属性,例如RequiredStringLengthRange

  • 我们可以为 DTO 类实现IValidatableObject接口,并在Validate方法中执行额外的验证逻辑。

  • 我们可以使用第三方库来验证 DTO 对象。例如,ABP 集成了FluentValidation库,将验证逻辑从 DTO 类中分离出来,并执行高级验证逻辑。

无论您采用哪种方法(您可以为 DTO 类同时使用所有方法),ABP 都会自动检查这些验证规则,并在值无效的情况下抛出验证异常。因此,您的应用程序服务方法始终使用有效的 DTO 对象执行。有关 ABP 验证基础设施的所有详细信息,请参阅第七章验证用户输入部分,探索横切关注点

在 DTO 类(或 FluentValidation 验证器类中)上的验证逻辑应该是正式验证。这意味着你可以检查给定的输入是否提供并且格式正确。然而,它不应包含领域验证。例如,不要尝试检查给定的开始和结束日期是否与同一轨道上的另一个会话冲突。此类验证逻辑应在领域层实现,通常在实体或领域服务类中。

与 DTO 相关的另一个常见任务是将其映射到其他对象。

对象到对象映射

我们在领域和应用层内部使用实体,并使用 DTO 与上层进行通信。这种方法使我们创建与实体类相似的 DTO 类,并将实体对象转换为 DTO 对象。如果实体类只有少量属性,则可以通过逐个复制属性手动创建相应的 DTO 对象。然而,实体类会随着时间的推移而增长,编写和维护手动映射代码变得繁琐且容易出错。

ABP 框架提供了一个 IObjectMapper 服务,用于将相似的对象相互转换。请参阅以下应用程序服务方法:

public async Task<EventDto> GetAsync(Guid id)
{
    Event eventEntity = 
        await _eventRepository.GetAsync(id);
    return ObjectMapper.Map<Event, EventDto>(eventEntity);
}

此方法简单地通过使用 IObjectMapper 服务将 Event 对象转换为 EventDto 对象来返回一个 EventDto 对象。EventDto 有很多属性,手动创建它会导致代码块变长。IObjectMapper 是一个抽象,在创建新的 ABP 解决方案时使用 AutoMapper 库实现。如果你想要使用前面的代码,你应该首先定义 AutoMapper 映射配置。

对象到对象映射文档

对象到对象映射这一主题并未包含在本书中。然而,在创建示例应用程序时,我们确实在 第三章逐步应用开发 中使用了它。请参考 ABP 的文档以全面了解对象到对象映射系统:docs.abp.io/en/abp/latest/Object-To-Object-Mapping

虽然使用对象映射器相当简单,但我们仍应谨慎使用。对象映射库主要依赖于命名约定。它们会自动映射同名属性,而我们可以手动配置映射。

当你重构实体但未更新相应的 DTO 或映射代码时,可能会出现一个问题。AutoMapper 库有一个名为配置验证的概念。它在应用程序启动时验证映射配置,并在检测到映射配置问题时抛出异常。我建议为你的应用程序启用它。有关配置验证的更多信息,请参阅 AutoMapper 文档:docs.automapper.org/en/stable/Configuration-validation.html

当你将实体映射到 DTO 时,对象到对象的映射非常有用。然而,不要将输入 DTO 映射到实体。这个建议背后有一些技术和设计原因,如下所述:

  • 你还记得上一章的 实现实体构造函数 部分吗?实体类通常具有主构造函数来获取所需的属性并创建一个有效的实体。自动映射操作通常需要在目标类上有一个空构造函数,因此映射会失败。

  • 实体上的一些属性设计为具有私有设置器。你应该使用实体方法来更改这些属性值以应用一些业务规则。直接从 DTO 对象复制它们的值可能会违反业务规则。

  • 你应该仔细验证和处理用户输入,而不是盲目地将它们映射到实体上。

实现应用程序服务 节中解释的 CreateAsync 方法是使用输入 DTO 创建实体的一个好例子。它不将 DTO 映射到实体,而是使用领域服务创建一个有效的实体并设置可选属性。

在下一节中,我们将讨论一些 DTO 的设计实践。

DTO 设计最佳实践

创建 DTO 在一开始看起来很简单——它们确实是简单的。然而,一旦应用程序增长,你将有许多 DTO 类,了解如何组织这些类变得很重要。在接下来的几节中,我将提供一些关于 DTO 的建议,以使你的代码库更易于维护和更少出错。

不要在输入 DTO 中定义未使用的属性

谁会在输入 DTO 中定义一个未使用的属性,对吧?不幸的是,事实并非如此。我见过很多代码库存在这个问题。

在应用程序服务方法中未使用的 DTO 类属性是混淆使用该应用程序服务方法的开发者的完美方式,并可能导致构建有缺陷的代码库。

在 DTO 类中未使用的属性的一个可能原因是,它曾经被使用过,但应用程序服务方法已更改,开发者忘记将其删除。我们应该关注这一点,并始终删除未使用的属性。如果你关心向后兼容性,因为你不是构建客户端应用程序的人,那么请在该属性上声明 [过时] 属性,记录破坏性更改,并在提供值的情况下尝试保留旧行为。

如果你违反了下一节中的规则,未使用的属性可能是不可避免的。

不要重用输入 DTO

当你有太多的应用程序服务方法时,你可能会认为使用一些 DTO 为多个应用程序服务方法服务是一个好主意,以减少 DTO 的数量。然而,如果你这样做,一些属性将在某些方法中使用,但在其他方法中则不会使用。

为每个应用服务方法定义一个专门的输入 DTO 是一个好的实践。有时,由于两个方法几乎相同,似乎重用相同的 DTO 类是实用的。然而,应用服务方法会随着时间的推移而变化,需求也会不同。从另一个 DTO 继承 DTO 是重用 DTO 的另一种方式,但问题仍然是相同的。

在许多场景中,代码重复比耦合用例是一个更好的实践。请看以下示例:

public interface IEventAppService : IApplicationService
{
    Task<EventDto> GetAsync(Guid id);
    Task CreateAsync(EventDto input);
    Task UpdateEventTimeAsync(EventDto input);
}

在这个例子中,GetAsync方法返回一个存储几乎所有事件属性的EventDto对象。CreateAsync方法重用了相同的EventDto类。一般来说,将输出 DTO 作为输入 DTO 重用并不好,因为一些EventDto属性(如IdUrlCodeRegisteredUserCountCreationTime)在事件创建时不应由客户端应用程序发送,而是在服务器端计算。最后,在UpdateEventTimeAsync方法中重用相同的EventDto类更糟糕,因为此方法仅使用IdStartTimeEndTime属性。

真正的 DTO 设计在以下示例中展示:

public interface IEventAppService : IApplicationService
{
    Task<EventDto> GetAsync(Guid id);
    Task CreateAsync(EventCreationDto input);
    Task UpdateEventTimeAsync(EventTimeUpdateDto input);
}

我们为CreateAsyncUpdateEventTimeAsync方法定义了单独的 DTO 类。这样,一个 DTO 的任何变化都不会影响其他方法。

在前两个部分中的设计建议是针对输入 DTO 的。下一部分将解释输出 DTO 的情况。

关于输出 DTO

在实践中,输出 DTO 与输入 DTO 不同。输入 DTO 的未使用属性问题在输出 DTO 中不存在。让我们尝试理解为什么未使用的属性对输入 DTO 是一个问题。想象一下,我们正在调用一个方法并在输入 DTO 上设置一个属性。我们期望它正在被方法处理并改变行为。如果方法没有使用该属性,并且我们没有看到任何行为上的差异,无论我们为该属性设置了什么,我们都会感到困惑。如果一个方法参数(或参数的属性)存在,它应该像预期的那样工作。

然而,对于输出 DTO,情况并非如此。一个应用服务可能返回比客户端当前需要的更多属性。因此,我的意思是应用服务方法填充了输出 DTO 的所有属性;如果 DTO 中存在某个属性,它应该始终被填充,无论客户端是否使用它。

这种方法有一个优点——当客户端稍后需要那些属性时,我们不需要更改服务类,这使得扩展 UI 更容易。如果客户端不是你的应用程序或者你希望将你的应用程序编程接口API)向第三方客户端开放,他们的需求可能不同,这尤其有用。

由于输出 DTO 可能包含一些客户端尚未使用的属性,我们可以减少输出 DTO 的数量,并在多个情况下重用一些 DTO 类。

请看以下示例应用程序服务定义:

public interface IEventAppService : IApplicationService
{
    Task<EventDto> CreateAsync(CreateEventDto input);
    Task<EventDto> GetAsync(Guid id);
    Task<List<EventDto>> GetListAsync(PagedResultRequestDto 
                                      input);
    Task<EventDto> AddSessionAsync(Guid id, 
                                  AddSessionDto input);
}

在此示例中,所有方法都使用相同的输出 DTO 类 EventDto 来表示事件,而它们获取不同的输入 DTO 对象。

性能考虑

返回经过微调和最小输出的 DTO 对象可能因性能要求而需要,尤其是在返回大量结果集时。在这种情况下,你可以定义不同的 DTO 类,只包含相关用例所需的属性。

我们已经介绍了使用 ABP 框架实现 DDD 的基本构建块。下一节将演示一些示例,以了解各层的角色和职责。

理解各层的职责

将你的业务逻辑分离到应用层和领域层,允许你在同一领域上创建多个应用程序,如在第九章的 处理多个应用程序 部分中解释的,理解领域驱动设计。大型系统通常有多个应用程序,将核心领域与应用特定逻辑隔离是避免不同应用程序逻辑混合的关键原则。为每个应用程序创建一个单独的应用层,使我们能够设计最适合不同应用程序要求的应用服务方法。

为了成功地将应用层和领域层分离,我们应该对每一层的职责有良好的理解。在最后三章中,我已经在解释 DDD 构建块时提到了这些职责。在下一节中,我将总结这些职责,以便更好地理解它们。

授权用户

授权用于允许或阻止用户(在 UI 上)或客户端应用程序(在机器到机器通信中)使用某些应用程序功能。

ABP 提供了使用 [Authorize] 属性进行声明性检查用户权限和通过 IAuthorizationService 服务进行命令性检查的方式。你可以使用这些功能来限制对应用程序中所需功能的访问。

授权是应用层和上层(如表示层)的责任,因为它高度依赖于客户端和应用程序的用户。

例如,在 EventHub 项目中,公共网络应用中的用户只能编辑自己的事件。然而,在管理应用中的管理员用户,如果他们拥有所需的权限,可以编辑任何事件而无需检查所有权。另一方面,后台服务可能在没有任何授权规则的情况下更改事件的状态。这些应用程序使用相同的领域层,因此相同的领域规则,但实现了不同的授权规则。因此,最好不要在领域层中包含授权逻辑,以便在不同情况下可重用。

控制事务

UoW 系统的责任是为用例(通常是 Web 请求)创建事务范围,并确保在该用例中进行的所有更改都一起提交。UoW 系统在第六章与数据访问基础设施一起工作理解 UoW 系统部分中进行了介绍。

用例的范围是应用程序服务方法。应用程序服务方法可能与多个领域服务和聚合一起工作,并可能在聚合上做出更改。确保所有更改一起提交的唯一方法是控制应用程序层中的应用程序服务级别的 UoW 系统。因此,UoW 是一个应用层概念。

验证用户输入

第七章探索横切关注点部分的验证用户输入所述,一个典型应用程序有三个验证级别,如下所示:

  • 客户端验证用于在将数据发送到服务器之前预先验证用户输入。此类验证是表示层的责任。

  • 服务器端验证由服务器执行,以防止不完整、格式错误或恶意请求。我们通常使用数据注释属性和其他功能来验证 DTO 对象。此类验证是应用层的责任。

  • 业务验证也在服务器上执行,实现您的业务规则,并保持业务数据的一致性。业务验证通常在领域层执行,以确保每个应用程序中都有相同的业务规则。

ABP 提供了良好的基础设施,并优雅地集成到 ASP.NET Core 服务中,以便轻松执行正式验证逻辑。您可以在聚合构造函数、方法和领域服务中实现业务验证规则和约束,如第十章中所述,领域驱动设计 – 领域层

与当前用户一起工作

ABP 的ICurrentUser服务用于获取当前用户的信息。当前用户逻辑需要一个有状态的会话系统,该系统存储用户信息并在每个 Web 请求中使其可用。

对于 ASP.NET Core 应用程序,ABP 使用基于身份验证票据的当前原则。当用户登录应用程序时创建身份验证票据。它保存在 cookie 中,以便在后续请求中读取。对于单页应用程序SPA),它可以存储在本地存储中,并在每个请求的超文本传输协议HTTP)头中发送到服务器。

会话/当前用户是一个通常在表示层中实现的概念。它可以在应用层中使用,因为应用层是为了被表示层使用而设计的,所以它可以假设在当前上下文中存在一个 当前用户。然而,领域层应该设计为独立于任何应用,因此它不应该与当前用户一起工作。在某些应用程序中,例如后台服务或集成应用程序,可能根本不存在用户。

以下来自 EventHub 项目的应用程序服务方法,用于当前用户加入指定的组织:

[Authorize]
public async Task JoinAsync(Guid organizationId)
{
    var organization = await _organizationRepository
        .GetAsync(organizationId);
    var user = await
        _userRepository.GetAsync(CurrentUser.GetId());    
    await _organizationMembershipManager.JoinAsync(
        organization, user);
}

首先,这种方法是经过授权的。因此,可以保证该方法是由已经登录到应用程序的用户调用的,并且 CurrentUser.GetId() 返回一个有效的用户 ID。

我们不接受用户的 ID 作为方法参数;否则,任何经过身份验证的用户都可以使任何用户成为任何组织的成员。但我们希望每个用户都能自己加入组织。

我们从存储库中获取组织和用户聚合,并将工作委托给领域服务(organizationMembershipManager)。这样,领域服务就独立于当前用户概念,并且更具可重用性:它可以与任何用户一起工作,而不仅仅是当前用户。

摘要

在本章中,你学习了如何正确实现应用程序服务和设计 DTO。我详细介绍了 DTO 设计,例如验证输入 DTO 和将实体映射到 DTO,并基于最佳实践和我的经验提供了建议。

我们已经了解到,混合层的责任会使分层变得没有意义。我们已经调查了一些基本责任,以了解我们应该在哪个层实现这些责任。

随着本章的结束,我们已经完成了本书的第三部分。这一部分的目的在于展示如何使用 ABP 框架实现 领域驱动设计DDD)的构建块。我提供了规则、最佳实践和建议,以便你在遵循它们时可以使代码库更易于维护。

本书下一部分将探讨使用 ABP 框架进行 UI 和 API 开发。在下一章中,我们将学习 ABP 框架 MVC/Razor Pages UI 的架构结构和基本功能。

第四部分:用户界面和 API 开发

本部分解释了如何使用 MVC(Razor Pages)和 Blazor UI 选项创建用户界面,以及为远程客户端创建 HTTP API。

在本部分中,我们包括以下章节:

  • 第十二章使用 MVC/Razor Pages 进行操作

  • 第十三章使用 Blazor WebAssembly UI 进行操作

  • 第十四章构建 HTTP API 和实时服务

第十二章:第十二章:与 MVC/Razor Pages 一起工作

ABP 框架被设计成模块化、分层且与 UI 框架无关。它非常适合服务器-客户端架构,从理论上讲,它可以与任何类型的 UI 技术一起工作。服务器端使用标准的身份验证协议并提供符合标准的 HTTP API。您可以使用您喜欢的 SPA 框架并轻松地消费服务器端 API。这样,您就可以利用 ABP 框架的整个服务器端基础设施。

然而,ABP 框架也帮助您进行 UI 开发。它提供系统,使您可以构建模块化用户界面、UI 主题、布局、导航菜单和工具栏。它使您在处理数据表、模态框和表单或与服务器进行身份验证和通信时的工作过程更加容易。

ABP 框架与以下 UI 框架良好集成,并提供启动解决方案模板:

  • ASP.NET Core MVC/Razor Pages

  • Blazor

  • Angular

在本书的第四部分,我将介绍如何使用 MVC/Razor Pages 和 Blazor UI 选项。在本章中,您将学习 ABP 框架的 MVC/Razor Page 基础设施是如何设计的,以及它如何帮助您完成常规的 UI 开发周期。

我将这种 UI 类型称为 MVC/Razor Pages,因为它支持 MVC 和 Razor Pages 两种方法。你甚至可以在单个应用程序中使用两者。然而,由于 Razor Pages(自 ASP.NET Core 2.0 以来引入)是微软推荐的新应用程序的方法,所有预构建的 ABP 模块、示例和文档都使用 Razor Pages 方法。

本章涵盖了以下主题:

  • 理解主题系统

  • 使用打包和压缩

  • 与菜单一起工作

  • 与 Bootstrap 标签助手一起工作

  • 创建表单并实现验证

  • 与模态框一起工作

  • 使用 JavaScript API

  • 消费 HTTP API

技术要求

如果您想跟随本章中的示例,您将需要一个支持 ASP.NET Core 开发的 IDE/编辑器。在某些时候,我们将使用 ABP CLI,因此您需要安装 ABP CLI,如第二章中所述,ABP 框架入门。最后,您需要安装 Node.js v14+ 以能够安装 NPM 包。

您可以从本书的 GitHub 仓库下载示例应用程序:github.com/PacktPublishing/Mastering-ABP-Framework。它包含本章提供的一些示例。

理解主题系统

UI 样式是应用程序中最具定制性的部分,您有很多选择。您可以从 Bootstrap、Tailwind CSS 或 Bulma 等 UI 工具包之一作为您应用程序 UI 的基础开始。然后,您可以构建一个设计语言或从主题市场购买一个预构建的、价格低廉的 UI 主题。如果您正在构建一个独立的应用程序,您可以根据这些选择进行选择,并基于这些选择创建您的 UI 页面和组件。您的页面和样式不需要与另一个应用程序兼容。

另一方面,如果您想构建一个模块化应用程序,其中每个模块的 UI 都是独立开发的(可能由不同的团队完成),模块在运行时作为一个单一的应用程序组合在一起,您需要确定一个设计标准,所有模块开发者都应该实施,以便您有一个一致的用户界面。

由于 ABP 框架提供了一个模块化基础设施,它提供了一个主题系统,该系统确定了一组基础库和标准。这有助于确保应用程序和模块开发者可以构建 UI 页面和组件,而无需依赖于特定的主题或样式集。一旦模块/应用程序代码与主题无关且主题标准明确,您就可以构建不同的主题,并可以通过简单的配置轻松地为应用程序使用该主题。

ABP 框架提供了两个免费的预构建 UI 主题:

  • 基本主题是一个基于纯 Bootstrap 样式的简约主题。如果您想从头开始构建样式,它是最理想的。

  • LeptonX主题是由 ABP 框架团队构建的现代且适用于生产的 UI 主题。

本书在所有示例中都使用基本主题。以下是 LeptonX 主题的截图:

图 12.1 – LeptonX 主题和应用程序布局

图 12.1 – LeptonX 主题和应用程序布局

预构建的 UI 主题作为 NuGet 和 NPM 包部署,因此您可以轻松安装和切换它们。

接下来的两节将介绍这些主题共享的基本基础库和布局。

基础库

为了使模块/应用程序独立于特定的主题,ABP 确定了一些基础 CSS 和 JavaScript 库,我们的模块/应用程序可以依赖这些库。

ABP 框架 MVC/Razor Pages UI 的第一个和最基本依赖是Twitter Bootstrap框架。从 ABP 框架版本 5.0 开始,使用 Bootstrap 5.x。

除了 Bootstrap 之外,还有一些其他的核心库依赖,例如 Datatables.Net、JQuery、JQuery Validation、FontAwesome、Sweetalert、Toastr、Lodash 等。如果您想在模块或应用程序中使用这些标准库,不需要额外的设置。

下一节将解释所需的布局系统,以便理解网页是如何构建的。

布局

一个典型的网络页面由两部分组成——布局和页面内容。布局塑造了整个页面的形状,通常包括主要标题、公司/产品标志、主要导航菜单、页脚和其他标准组件。以下截图显示了示例布局中的这些部分:

图 12.2 – 页面布局的组成部分

图 12.2 – 页面布局的组成部分

在现代网络应用中,布局被设计成响应式的,这意味着它们会根据当前用户使用的设备改变形状和位置,以适应该设备。

布局的内容在不同页面之间几乎保持不变——只有页面内容会变化。页面内容通常是布局的主要部分,如果内容大于屏幕高度,则可能会滚动。

一个网络应用在不同的部分/页面可能会有不同的布局要求。在 ABP 框架中,一个主题可以定义一个或多个布局。每个布局都有一个唯一的 string 名称,ABP 框架定义了四个标准布局名称:

  • Application:为具有标题、菜单、工具栏、页脚等后台办公风格网络应用设计。图 12.1 展示了一个示例。

  • Account:为登录、注册和其他与账户相关的页面设计。

  • Public:为面向公众的网站设计,例如你产品的着陆页。

  • Empty:一个没有实际布局的布局。页面内容覆盖整个屏幕。

这些字符串定义在 Volo.Abp.AspNetCore.Mvc.UI.Theming.StandardLayouts 类中。每个主题都必须定义 ApplicationAccountEmpty 布局,因为它们对大多数应用来说是通用的。Public 布局是可选的,如果没有实现,将回退到 Application 布局。一个主题可以定义更多具有不同名称的布局。

Application 布局是默认的,除非你更改它。你可以按页面/视图或文件夹更改它。如果你为文件夹更改它,该文件夹下的所有页面/视图都将使用所选布局。

要更改页面/视图的布局,请注入 IThemeManager 服务并使用带有布局名称的 CurrentTheme.GetLayout 方法:

@inject IThemeManager ThemeManager
@{
    Layout = ThemeManager.CurrentTheme
             .GetLayout(StandardLayouts.Empty);
}

在这里,你可以使用 StandardLayouts 类来获取标准布局名称。对于这个例子,我们可以使用 GetLayout("Empty"),因为 StandardLayouts.Empty 的值是一个常量 string,其值为 Empty。这样,你可以通过它们的 string 名称获取你主题的非标准布局。

如果你想更改文件夹中所有页面/视图的布局,你可以在该文件夹中创建一个 _ViewStart.cshtml 文件,并将以下代码放入其中:

@using Volo.Abp.AspNetCore.Mvc.UI.Theming
@inject IThemeManager ThemeManager
@{
    Layout = ThemeManager.CurrentTheme
             .GetLayout(StandardLayouts.Account);
}

如果你将那个 _ViewStart.cshtml 文件放在 Pages 文件夹中(或 MVC 视图的 Views 中),除非你为子文件夹或特定页面/视图选择了另一个布局,否则所有页面都将使用所选布局。

我们可以轻松选择一个布局来放置页面内容。下一节将解释如何将脚本/样式文件导入到我们的页面中,并利用打包和压缩系统。

使用打包和压缩系统

ABP 提供了一站式解决方案,用于安装客户端包、将脚本/样式文件添加到页面中,并在开发和生产环境中打包和压缩这些文件。

让我们从安装应用程序的客户端包开始。

安装 NPM 包

NPM 是 JavaScript/CSS 库的事实上的包管理器。当您使用 MVC/Razor Pages UI 创建新解决方案时,您将在 Web 项目的根文件夹中看到一个package.json文件。package.json文件的初始内容可能看起来像这样:

{
  ...
  "dependencies": {
    "@abp/aspnetcore.mvc.ui.theme.basic": "⁵.0.0"
  }
}

初始时,我们有一个名为@abp/aspnetcore.mvc.ui.theme.basic的单个 NPM 包依赖项。此包依赖于所有必要的基 CSS/JavaScript 库,以支持基本主题。如果我们想安装另一个 NPM 包,我们可以使用标准的npm install(或yarn add)命令。

假设我们想在应用程序中使用Vue.js库。我们可以在 Web 项目的根目录下运行以下命令:

npm install vue

此命令将vue NPM 包安装到node_modules/vue文件夹中。然而,我们无法使用node_modules文件夹下的文件。我们应该将必要的文件复制到 Web 项目的wwwroot文件夹中,以便将它们导入到页面中。

您可以手动复制必要的文件,但这不是最佳方式。ABP 提供了一个install-libs命令,通过映射文件来自动化此过程。在 Web 项目下打开abp.resourcemapping.js文件,并将以下代码添加到mappings字典中:

"@node_modules/vue/dist/vue.min.js": "@libs/vue/"

abp.resourcemapping.js文件的最终内容应如下所示:

module.exports = {
    aliases: { },
    mappings: {
        "@node_modules/vue/dist/vue.min.js": "@libs/vue/"
    }
};

现在,我们可以在命令行终端中,在 Web 项目的根目录下运行以下命令:

abp install-libs

应将vue.min.js文件复制到wwwroot/libs/vue文件夹下:

![图 12.3 – 将 Vue.js 库添加到 Web 项目中

![img/Figure_12.03_B17287.jpg]

图 12.3 – 将 Vue.js 库添加到 Web 项目中

映射支持 glob 通配符模式。例如,您可以使用以下映射复制vue包中的所有文件:

"@node_modules/vue/dist/*": "@libs/vue/"

默认情况下,libs文件夹被提交到源代码控制系统(如 Git)。这意味着如果您的队友从您的源代码控制系统中获取代码,他们不需要运行npm installabp install-libs命令。如果您愿意,可以将libs文件夹添加到源代码控制系统的忽略文件中(如 Git 的.gitignore)。在这种情况下,您需要在运行应用程序之前运行npm installabp install-libs命令。

下一节将解释标准 ABP NPM 包。

使用标准包

构建模块化系统还有一个挑战——所有模块都应该使用相同(或兼容)版本的同一 NPM 包。ABP 框架提供了一套标准 NPM 包,以便 ABP 生态系统可以使用这些 NPM 包的相同版本,并自动映射到将资源复制到libs文件夹。

@abp/vue是这些标准包之一,可以用来在你的项目中安装 Vue.js 库。你可以安装这个包而不是vue包:

npm install @abp/vue

现在,你可以运行abp install-libs命令将vue.min.js文件复制到wwwroot/libs/vue文件夹。注意,你不需要在abp.resourcemapping.js文件中定义映射,因为@abp/vue包已经包含了必要的映射配置。

建议当它们可用时使用标准的@abp/*包。这样,你可以依赖相关库的标准版本,而且你不需要手动配置abp.resourcemapping.js文件。

然而,当你在项目中安装库时,你需要将其导入页面才能在应用程序中使用它。

导入脚本和样式文件

一旦我们安装了 JavaScript 或 CSS 库,我们就可以将其包含在任何页面或捆绑包中。让我们从最简单的情况开始——你可以使用以下代码将vue.min.js导入 Razor 页面或查看它:

@section scripts {
    <abp-script src=»/libs/vue/vue.min.js» />
}

在这里,我们正在将 JavaScript 文件导入到scripts部分,因此主题将它们放置在 HTML 文档的末尾,在全局脚本之后。abp-script是 ABP 框架定义的一个标签助手,用于将脚本包含到页面/视图中。它被渲染如下:

<script src=»/libs/vue/vue.min.js?_v=637653840922970000»></script>

我们可以使用标准的script标签,但abp-script有以下优点:

  • 如果给定的文件尚未压缩,它会在生产(或预发布)环境中自动压缩该文件。如果文件未压缩且 ABP 在原始文件附近找到压缩文件,它将使用预压缩文件而不是在运行时动态压缩。

  • 它添加了一个查询字符串参数来添加版本信息,这样当文件更改时,浏览器不会缓存它。这意味着当你重新部署应用程序时,浏览器不会意外地缓存你的脚本文件的老版本。

  • ABP 确保文件只添加到页面一次,即使你多次包含它。如果你希望构建模块化系统,这是一个很好的特性,因为不同的模块组件可能包含独立的相同库,而 ABP 框架消除了这种重复。

一旦我们在页面中包含了 Vue.js,我们就可以利用其力量创建高度动态的页面。以下是一个名为VueDemo.cshtml的 Razor 页面示例:

@page
@model MvcDemo.Web.Pages.VueDemoModel
@section scripts {
    <abp-script src=»/libs/vue/vue.min.js» />
    <script>
        var app = new Vue({
            el: '#app',
            data: {
                message: 'Hello Vue!'
            }
        })
    </script>
}
<div id="app">
    <h2>{{ message }}</h2>
</div>

如果你运行这个页面,UI 上会显示一个Hello Vue!消息。我建议在你需要构建复杂和动态用户界面的 MVC/Razor Pages 应用程序的一些页面中使用 Vue.js。

让我们进一步分析这个例子,并将自定义 JavaScript 代码移动到一个单独的文件中。在同一文件夹中创建一个名为 VueDemo.cshtml.js 的 JavaScript 文件:

![Figure 12.4 – 添加 JavaScript 文件

![Figure 12.04_B17287.jpg]

图 12.4 – 添加 JavaScript 文件

我更喜欢这种命名约定,但你可以为 JavaScript 文件设置任何名称。

Pages 文件夹下的 JavaScript/CSS 文件

在常规 ASP.NET Core 应用程序中,你应该将所有 JavaScript/CSS 文件放置在 wwwroot 文件夹下。ABP 允许你将 JavaScript/CSS 文件添加到 PagesViews 文件夹,靠近相应的 .cshtml 文件。我发现这种方法非常实用,因为我们把相关的文件放在一起。

新的 JavaScript 文件的内容如下所示:

var app = new Vue({
    el: '#app',
    data: {
        message: 'Hello Vue!'
    }
});

现在,我们可以更新 VueDemo.cshtml 文件的内容,如下所示:

@page
@model MvcDemo.Web.Pages.VueDemoModel
@section scripts {
    <abp-script src=»/libs/vue/vue.min.js» />
    <abp-script src=»/Pages/VueDemo.cshtml.js» />
}
<div id="app">
    <h2>{{ message }}</h2>
</div>

将 JavaScript 代码保存在单独的文件中,并将其作为外部文件包含在页面上,就像前面的例子一样,这是一个好习惯。

与样式(CSS)文件一起工作与脚本文件一起工作非常相似。以下示例使用 styles 部分 和 abp-style 标签助手在页面上导入一个样式文件:

@section styles {
    <abp-style src="img/VueDemo.cshtml.css" />
}

我们可以将多个脚本或样式文件导入到页面中。下一节将展示如何在生产中将这些文件捆绑成一个单一的、压缩的文件。

创建页面 bundle

当我们在页面上使用多个 abp-script(或 abp-style)标签时,ABP 会单独包含页面上的文件,并在生产中包含压缩版本。然而,我们通常希望在生产中创建一个单一的捆绑和压缩文件。我们可以使用 abp-script-bundleabp-style-bundle 标签助手为页面创建 bundle,如下例所示:

@section scripts {
    <abp-script-bundle>
        <abp-script src=»/libs/vue/vue.min.js» />
        <abp-script src=»/Pages/VueDemo.cshtml.js» />
    </abp-script-bundle>
}

在这里,我们正在创建一个包含两个文件的 bundle。ABP 会自动压缩这些文件,并将它们捆绑成一个文件,然后在生产环境中对这个单一文件进行版本控制。ABP 在第一次请求页面时执行捆绑操作,并将捆绑的文件缓存到内存中。它使用缓存的捆绑文件来处理后续请求。

你可以在 bundle 标签内使用条件逻辑或动态代码,如下例所示:

<abp-script-bundle>
    <abp-script src="img/validator.js" />
    @if (System.Globalization.CultureInfo
         .CurrentUICulture.Name == "tr")
    {
        <abp-script src="img/validator.tr.js" />
    }
    <abp-script src="img/some-other.js" />
</abp-script-bundle>

此示例向 bundle 添加了一个示例验证库,并条件性地添加了土耳其本地化脚本。如果用户的语言是土耳其语,则将土耳其本地化添加到 bundle 中。否则,不会添加。ABP 可以理解这种差异——它创建并缓存两个单独的 bundle,一个用于土耳其用户,另一个用于其他人。

有了这些,我们已经学会了如何为单个页面创建 bundle。在下一节中,我们将解释如何配置全局 bundle。

配置全局 bundle

Bundling 标签助手对于页面 bundle 非常有用。如果你正在创建自定义布局,你也可以使用它们。然而,当我们使用主题时,布局由主题控制。

假设我们已经决定在所有页面上使用 Vue.js 库,并希望将其添加到全局捆绑中,而不是逐页添加。为此,我们可以在模块的ConfigureServices中配置AbpBundlingOptions(在 Web 项目中),如下面的代码块所示:

Configure<AbpBundlingOptions>(options =>
{
    options.ScriptBundles.Configure(
        StandardBundles.Scripts.Global,
        bundle =>
        {
            bundle.AddFiles(«/libs/vue/vue.min.js»);
        }
    );
    options.StyleBundles.Configure(
        StandardBundles.Styles.Global,
        bundle =>
        {
            bundle.AddFiles("/global-styles.css");
        }
    );
});

options.ScriptBundles.Configure方法用于操作具有给定名称的捆绑。第一个参数是捆绑的名称。StandardBundles.Scripts.Global是一个常量字符串,其值是全局脚本捆绑的名称,由所有布局导入。前面的示例还向全局样式捆绑添加了一个 CSS 文件。

全局捆绑只是命名的捆绑。我们将在下一节中解释这些。

创建命名的捆绑

基于页面的捆绑是创建单个页面捆绑的简单方法。然而,在某些情况下,您将需要定义一个捆绑并在多个页面上重用它。如前所述,全局样式和脚本捆绑是命名的捆绑。我们也可以定义自定义命名的捆绑,并在任何页面或布局中导入捆绑。

以下示例定义了一个命名的捆绑,并在其中添加了三个 JavaScript 文件:

Configure<AbpBundlingOptions>(options =>
{
    options
        .ScriptBundles
        .Add("MyGlobalScripts", bundle => {
            bundle.AddFiles(
                "/libs/jquery/jquery.js",
                "/libs/bootstrap/js/bootstrap.js",
                "/scripts/my-global-scripts.js"
            );
        });                
});

我们可以在模块类(通常是 Web 层的模块类)的ConfigureServices中编写此代码。options.ScriptBundlesoptions.StyleBundles是两种捆绑类型。在这个例子中,我们使用了ScriptBundles属性来创建一个包含一些 JavaScript 文件的捆绑。

一旦我们创建了一个命名的捆绑,我们就可以使用abp-script-bundleabp-style-bundle标签助手在页面/视图中使用它,如下面的示例所示:

<abp-script-bundle name="MyGlobalScripts" />

当我们在页面或视图中使用此代码时,所有脚本文件在开发时间单独添加到页面中。默认情况下,它们在生产环境中自动捆绑和压缩。下一节将解释如何更改此默认行为。

控制捆绑和压缩行为

我们可以使用AbpBundlingOptions选项类来更改捆绑和压缩系统的默认行为。请参见以下配置:

Configure<AbpBundlingOptions>(options =>
{
    options.Mode = BundlingMode.None;
});

此配置代码禁用了捆绑和压缩逻辑。这意味着即使在生产环境中,所有脚本/样式文件也是单独添加到页面中,而不进行捆绑和压缩。options.Mode可以取以下值之一:

  • Auto(默认):在生产环境和预发布环境中捆绑和压缩,但在开发时间禁用捆绑和压缩。

  • Bundle: 将文件捆绑(为每个捆绑创建一个文件)但不会压缩样式/脚本。

  • BundleAndMinify:始终捆绑和压缩文件,即使在开发时间也是如此。

  • None:禁用捆绑和压缩过程。

在这本书中,我已经解释了捆绑和最小化系统的基本用法。然而,它具有高级功能,例如创建捆绑贡献者类、从另一个捆绑继承、扩展和操作捆绑等。这些功能在你想创建可重用 UI 模块时特别有帮助。请参阅 ABP 框架文档以了解所有功能:docs.abp.io/en/abp/latest/UI/AspNetCore/Bundling-Minification

在下一节中,你将学习如何与导航菜单一起工作。

与菜单一起工作

菜单由当前主题渲染,因此最终的应用程序或模块不能直接更改菜单项。你可以在 图 12.1 的左侧看到主菜单。ABP 提供了一个菜单系统,因此模块和最终应用程序可以动态添加新的菜单项或删除/更改由这些模块添加的项目。

我们可以使用 AbpNavigationOptions 来向菜单系统添加贡献者。ABP 会执行所有贡献者以动态构建菜单,如下面的示例所示:

Configure<AbpNavigationOptions>(options =>
{
    options.MenuContributors.Add(new MyMenuContributor());
});

在这里,MyMenuContributor 应该是一个实现了 IMenuContributor 接口的类。ABP 启动解决方案模板已经包含了一个可以直接使用的菜单贡献者类。IMenuContributor 定义了 ConfigureMenuAsync 方法,我们应该像这样实现它:

public class MvcDemoMenuContributor : IMenuContributor
{
    public async Task ConfigureMenuAsync(
        MenuConfigurationContext context)
    {
        if (context.Menu.Name == StandardMenus.Main)
        {
            //TODO: Configure the main menu
        }
    }
}

我们首先应该考虑的是菜单的名称。在 StandardMenus 类(在 Volo.Abp.UI.Navigation 命名空间中)中定义了两个标准菜单名称作为常量:

  • Main:应用程序的主菜单。它在 图 12.1 的左侧显示。

  • User:用户上下文菜单。当你点击页眉上的用户名时,它会打开。

因此,前面的示例检查了菜单的名称,并且只向主菜单添加了项目。下面的示例代码块添加了一个 客户关系管理CRM)菜单项和两个子菜单项:

var l = context.GetLocalizer<MvcDemoResource>();
context.Menu.AddItem(
    new ApplicationMenuItem("MyProject.Crm", l["CRM"])
        .AddItem(new ApplicationMenuItem(
            name: "MyProject.Crm.Customers", 
            displayName: l["Customers"], 
            url: "/crm/customers")
        ).AddItem(new ApplicationMenuItem(
            name: "MyProject.Crm.Orders", 
            displayName: l["Orders"],
            url: "/crm/orders")
        )
);

在这个示例中,我们获取一个 IStringLocalizer 实例(l)以本地化菜单项的显示名称。context.GetLocalizer 是获取本地化服务的一个快捷方式。你可以使用 context.ServiceProvider 来解析任何服务并将你的自定义逻辑应用于构建菜单。

每个菜单项都应该有一个唯一的 name(例如本例中的 MyProject.Crm.Customers)和一个 displayName。有 urliconorder 以及一些其他选项可用于控制菜单项的外观和行为。

基本主题渲染了示例菜单,如下面的屏幕截图所示:

![图 12.5 – 由基本主题渲染的菜单项

![图 12.05_B17287.jpg]

图 12.5 – 由基本主题渲染的菜单项

重要的是要理解,每次我们渲染菜单时都会调用 ConfigureMenuAsync 方法。对于一个典型的 MVC/Razor Pages 应用程序,此方法在每次页面请求时都会被调用。这样,你可以动态地塑造菜单,并条件性地添加或删除项目。你通常需要在添加菜单项时检查权限,如下面的代码块所示:

if (await context.IsGrantedAsync("MyPermissionName"))
{
    context.Menu.AddItem(...);
}

context.IsGrantedAsync 是检查当前用户权限名称的快捷方式。如果我们想手动解析和使用 IAuthorizationService,我们可以重写相同的代码,如下面的代码块所示:

var authorizationService = context
    .ServiceProvider.GetRequiredService<IAuthorizationService>();
if (await authorizationService.IsGrantedAsync(
    "MyPermissionName"))
{
    context.Menu.AddItem()
}

在此示例中,我使用了 context.ServiceProvider 来解析 IauthorizationService。然后,我像上一个示例一样使用了它的 IsGrantedAsync 方法。你可以安全地从 context.ServiceProvider 解析服务,并让 ABP 框架在菜单构建过程的末尾释放这些服务。

你也可以在 context.Menu.Items 集合中找到现有的菜单项(由依赖模块添加),以修改或删除它们。

在下一节中,我们将继续探讨 Bootstrap 标签辅助器,并学习如何以类型安全的方式渲染常见的 Bootstrap 组件。

使用 Bootstrap 标签辅助器

Bootstrap 是世界上最受欢迎的 UI(HTML/CSS/JS)库之一,它是所有 ABP 主题使用的根本 UI 框架。作为使用此类库作为标准库的好处,我们可以基于 Bootstrap 构建我们的 UI 页面和组件,并让主题为其添加样式。这样,我们的模块甚至应用程序都可以与主题无关,并与任何 ABP 兼容的 UI 主题一起工作。

Bootstrap 是一个文档齐全且易于使用的库。然而,在编写基于 Bootstrap 的 UI 代码时存在两个问题:

  • 一些组件需要大量的样板代码。这些代码的大部分都是重复的,编写和维护都很繁琐。

  • 在 MVC/Razor Pages 网络应用程序中编写纯 Bootstrap 代码并不非常类型安全。我们可能会在类名和 HTML 结构中犯错误,这些错误在编译时无法捕获。

ASP.NET Core MVC/Razor Pages 有一个 标签 辅助器 系统来定义可重用的组件,并将它们用作我们页面/视图中的其他 HTML 标签。ABP 利用标签辅助器的力量,并为 Bootstrap 库提供了一套标签辅助器组件。这样,我们可以用更少的代码并以类型安全的方式构建基于 Bootstrap 的 UI 页面和组件。

使用 ABP 框架仍然可以编写原生 Bootstrap HTML 代码,并且 ABP 的 Bootstrap 标签辅助器并不涵盖 Bootstrap 100%。然而,我们建议尽可能使用 Bootstrap 标签辅助器。请参见以下示例:

<abp-button button-type="Primary" text="Click me!" />

在这里,我使用了 abp-button 标签辅助器来渲染 Bootstrap 按钮。我使用了具有编译时检查支持的 button-typetext 属性。此示例代码在运行时渲染如下:

<button class="btn btn-primary" type="button">
    Click me!
</button>

ABP 框架中有很多 Bootstrap 标签辅助工具,所以在这里我不会解释它们全部。请参考 ABP 的文档来学习如何使用它们:docs.abp.io/en/abp/latest/UI/AspNetCore/Tag-Helpers/Index

在接下来的两个部分中,我们将使用一些这些 Bootstrap 标签辅助工具来构建表单项和打开模态。

创建表单和实现验证

ASP.NET Core 提供了良好的基础设施来准备表单,并在服务器端提交、验证和处理它们。然而,它仍然需要编写一些样板代码和重复的代码。ABP 框架通过提供标签辅助工具并在可能的情况下自动化验证和本地化来简化与表单的工作。让我们从如何使用 ABP 的标签辅助工具渲染表单元素开始。

渲染表单元素

abp-input 标签辅助工具用于渲染给定属性的适当 HTML 输入元素。最好通过一个完整的示例来展示其用法。

假设我们需要构建一个表单来创建一个新的 电影 实体,并且已经创建了一个名为 CreateMovie.cshtml 的新 Razor 页面。首先,让我们看看代码后置文件:

public class CreateMovieModel : AbpPageModel
{
    [BindProperty]
    public MovieViewModel Movie { get; set; }

    public void OnGet()
    {
        Movie = new MovieViewModel();
    }
    public async Task OnPostAsync()
    {
        // TODO: process the form (using the Movie object)
    }
}

页面模型通常是从 PageModel 类派生的。然而,我们是从 ABP 的 AbpPageModel 基类派生的,因为它提供了一些预注入的服务和辅助方法。这是一个简单的页面模型类。在这里,我们在 OnGet 方法中创建一个新的 MovieViewModel 实例,并将其绑定到表单元素。我们还有一个 OnPostAsync 方法,我们可以用它来处理提交的表单数据。[BindProperty] 告诉 ASP.NET Core 将请求数据绑定到 Movie 对象。

为了探索这个示例,让我们看看 MovieViewModel 类:

public class MovieViewModel
{
    [Required]
    [StringLength(256)]
    public string Name { get; set; }
    [Required]
    [DataType(DataType.Date)]
    public DateTime ReleaseDate { get; set; }
    [Required]
    [TextArea]
    [StringLength(1000)]
    public string Description { get; set; }
    public Genre Genre { get; set; }
    public float? Price { get; set; }
    public bool PreOrder { get; set; }
}

此对象用于渲染表单元素并在用户提交表单时绑定请求数据。请注意,一些属性具有数据注释验证属性,可以自动验证这些属性的值。在这里,Genre 属性是一个 enum,如下所示:

public enum Genre
{
    Classic, Action, Fiction, Fantasy, Animation
}

现在,我们可以切换到视图部分,尝试渲染一个表单来获取用户的电影信息。

首先,我将向您展示在没有 ABP 框架的情况下如何做到这一点,以便理解使用 ABP 框架的好处。首先,我们必须打开一个 form 元素,如下面的代码块所示:

<form method="post">
    <-- TODO: FORM ELEMENTS -->
    <button class="btn btn-primary" type="submit">
        Submit
    </button>
</form>

form 块中,我们为每个 form 元素编写代码,然后添加一个 submit 按钮来提交表单。展示完整的 form 代码对于这本书来说会太长,所以我会只展示渲染 Movie.Name 属性输入元素的必要代码:

<div class="form-group">
    <label asp-for="Movie.Name" class="control-label">
    </label>
    <input asp-for="Movie.Name" class="form-control"/>
    <span asp-validation-for="Movie.Name" 
        class="text-danger"></span>
</div>

如果您曾经使用 ASP.NET Core Razor Pages/MVC 和 Bootstrap 创建过表单,前面的代码块应该非常熟悉。它通过将它们包裹在 form-group 中来放置 label、实际的输入元素和验证消息区域。以下截图显示了渲染的表单:

![图 12.6 – 带有一个单行文本输入的简单表单图 12.06 – B17287.jpg

图 12.6 – 带有一个文本输入的简单表单

当前表单只为 Name 属性包含一个单独的文本输入。你可以为 Movie 类的每个属性编写类似的代码,这将导致代码庞大且重复。让我们看看如何使用 ABP 框架的 abp-input 标签助手渲染相同的输入:

<abp-input asp-for="Movie.Name" />

这很简单。现在,我们可以渲染所有表单元素。以下是将最终代码:

<form method="post">
    <abp-input asp-for="Movie.Name" />
    <abp-select asp-for="Movie.Genre" />
    <abp-input asp-for="Movie.Description" />
    <abp-input asp-for="Movie.Price" />
    <abp-input asp-for="Movie.ReleaseDate" />
    <abp-input asp-for="Movie.PreOrder" />
    <abp-button type="submit" button-type="Primary"
        text="Submit"/>
</form>

与标准的 Bootstrap 表单代码相比,前面的代码块显著更短。我使用了 abp-select 标签助手来处理 Genre 属性。它理解 Genre 是一个 enum,并使用 enum 成员创建下拉元素。以下是被渲染的表单:

![图 12.7 – 创建新电影的完整表单图 12.07 – B17287.jpg

图 12.7 – 创建新电影的完整表单

ABP 自动在必填表单字段的标签附近添加 *****。它读取类属性的类型和属性,并确定表单字段。

如果你只想按顺序渲染输入元素,你可以将最后一个代码块替换为以下代码:

<abp-dynamic-form abp-model="Movie" submit-button="true" />

abp-dynamic-form 标签助手获取一个模型并自动创建整个表单!

abp-inputabp-selectabp-radio 标签助手映射到类属性并渲染相应的 UI 元素。如果你想控制表单布局并在表单控件之间放置自定义 HTML 元素,可以使用它们。另一方面,abp-dynamic-form 在你较少控制表单布局的情况下使创建表单变得非常简单。无论你如何创建表单,ABP 都会为你自动化验证和本地化过程,我将在接下来的几节中解释。

验证用户输入

如果你尝试不填写必填字段提交表单,表单将不会提交到服务器,并且每个无效表单元素都会显示错误消息。以下截图显示了当你留空 Name 属性并提交表单时的错误消息:

![图 12.8 – 无效的用户输入图 12.08 – B17287.jpg

图 12.8 – 无效的用户输入

客户端验证是自动基于 MovieViewModel.Name 属性中的数据注释属性完成的。因此,你不需要为标准检查编写任何验证代码。用户必须确保所有字段有效后才能提交表单。

客户端验证只是为了用户体验。很容易绕过客户端验证并将无效的表单提交到服务器(通过在浏览器的开发者工具中操作或禁用 JavaScript 代码)。因此,你应该始终在服务器端验证用户输入,这应该在页面模型类的 OnPostAsync 方法中完成。以下代码块显示了处理表单提交时使用的常见模式:

public async Task OnPostAsync()
{
    if (ModelState.IsValid)
    {
        //TODO: Create a new movie
    }
    else
    {
        Alerts.Danger("Please correct the form fields!");
    }
}

如果任何表单字段无效,ModelState.IsValid 返回 false。这是 ASP.NET Core 的一个标准功能。你应该始终在 if 语句中处理输入。可选地,你可以在 else 语句中添加逻辑。在这个例子中,我使用了 ABP 的 Alerts 功能向用户显示客户端警告消息。以下截图显示了提交无效表单的结果:

![图 12.9 – 服务器端无效表单结果图片

图 12.9 – 服务器端无效表单结果

如果你查看 MovieViewModel 类的 IValidatableObject 接口下的验证错误消息,如下面的代码块所示:

public class MovieViewModel : IValidatableObject
{
    // ... properties omitted
    public IEnumerable<ValidationResult> Validate(
        ValidationContext validationContext)
    {
        if (PreOrder && Price > 999)
        {
            yield return new ValidationResult(
                "Price should be lower than 999 for 
                 pre-order movies!",
                new[] { nameof(Price) }
            );
        }
    }
}

我在 Validate 方法中执行复杂的自定义验证逻辑。你可以参考 第七章探索横切关注点中的 验证用户输入 部分,了解更多关于服务器端验证的信息。在这里,我们应该理解我们可以在服务器端使用自定义逻辑,并在客户端显示验证消息。

在下一节中,我们将学习如何本地化验证错误以及表单标签。

本地化表单

ABP 框架根据当前语言自动本地化验证错误消息。尝试切换到另一种语言,并提交表单而不提供电影名称。以下截图显示了土耳其语言的情况:

![图 12.10 – 自动本地化的验证错误消息图片

图 12.10 – 自动本地化的验证错误消息

错误文本已更改。然而,你仍然可以看到 名称 作为字段名称,因为这是我们自定义的字段名称,我们还没有对其进行本地化。

ABP 为表单字段提供了一个基于约定的本地化系统。你只需在你的本地化 JSON 文件中定义一个本地化条目,键的格式为 DisplayName:<属性名>。我可以在 Domain.Shared 项目中的 en.json 文件中添加以下行以本地化电影创建表单的所有字段:

"DisplayName:Name": "Name",
"DisplayName:ReleaseDate": "Release date",
«DisplayName:Description»: «Description»,
«DisplayName:Genre»: «Genre»,
"DisplayName:Price": "Price",
"DisplayName:PreOrder": "Pre-order"

然后,我可以在 tr.json 文件中使用以下条目将这些内容本地化为土耳其语:

"DisplayName:Name": "İsim",
"DisplayName:ReleaseDate": "Yayınlanma tarihi",
"DisplayName:Description": "Açıklama",
"DisplayName:Genre": "Tür",
"DisplayName:Price": "Ücret",
"DisplayName:PreOrder": "Ön sipariş"

现在,我们有一个本地化的标签和一个更本地化的验证错误消息:

![图 12.11 – 完全本地化的验证错误消息和字段标签图片

图 12.11 – 完全本地化的验证错误消息和字段标签

DisplayName: 前缀添加到属性名是 form 字段的建议约定,但实际上并非必需。如果 ABP 找不到 DisplayName:Price 条目,它将搜索不带前缀的 Price 键的条目。如果你想指定属性的本地化键,你可以在属性顶部添加 [DisplayName] 属性,如下面的示例所示:

[DisplayName("MoviePrice")]
public float? Price { get; set; }

使用这种设置,ABP 将尝试使用 "MoviePrice" 键来本地化字段名称。

abp-select 标签根据约定将 enum 类型的下拉列表项本地化。你可以在本地化文件中添加条目,例如 <enum-type>.<enum-member>。对于 Genre 枚举类型的 Action 成员,我们可以添加一个带有 Genre.Action 键的本地化条目。如果找不到 Genre.Action 键,它将回退到 Action 键。

在下一节中,我们将讨论如何将标准表单转换为完全的 AJAX 表单。

实现 AJAX 表单

当用户提交标准表单时,会执行全页面的 POST 操作,服务器重新渲染整个页面。另一种方法是将表单作为 AJAX 请求发送,并在 JavaScript 代码中处理响应。这种方法比常规的 POST 请求快得多,因为浏览器不需要重新加载整个页面和页面的所有资源。在许多情况下,这也能提供更好的用户体验,因为你可以显示一些等待时的动画。此外,这样你不会丢失页面的状态,可以在 JavaScript 代码中执行智能操作。

你可以手动处理所有的 AJAX 事务,但 ABP 框架提供了内置的方式来处理这些常见模式。你可以在任何 form 元素(包括 abp-dynamic-form 元素)上添加 data-ajaxForm="true" 属性,使其通过 AJAX 请求发送。

以下示例为 abp-dynamic-form 添加了 AJAX 功能:

<abp-dynamic-form abp-model="Movie"
                  submit-button="true" 
                  data-ajaxForm="true"
                  id="MovieForm" />

当我们将表单转换为 AJAX 表单时,服务器端应该正确实现 POST 处理器。以下代码块展示了实现 POST 处理器的常见模式:

public async Task<IActionResult> OnPostAsync()
{
    ValidateModel();    
    //TODO: Create a new movie
    return NoContent();
}

第一行验证用户输入,如果输入模型无效,则抛出 AbpValidationExceptionValidateModel 方法来自基类 AbpPageModel。如果你不想使用它,你可以检查 if (ModelState.IsValid) 并执行所需的任何操作。如果表单有效,你通常会将新电影保存到数据库中。最后,你可以将结果数据返回给客户端。对于这个例子,我们不需要返回响应,所以 NoContent 结果是合适的。

当你将表单转换为 AJAX 表单时,你通常希望在表单成功提交时采取行动。以下示例处理了表单的 abp-ajax-success 事件:

$(function (){
    $('#MovieForm').on('abp-ajax-success', function(){
        $('#MovieForm').slideUp();
        abp.message.success('Successfully saved, thanks
            :)');
    });
});

在这个例子中,我为表单的 abp-ajax-success 事件注册了一个回调函数。在这个回调中,你可以执行任何需要的操作。例如,我使用了 slideUp JQuery 函数来隐藏表单,然后使用了 ABP 的成功 UI 消息。我们将在本章的 使用 JavaScript API 部分回到 abp.message API。

对于 AJAX 请求,异常处理逻辑是不同的。ABP 处理所有异常,向客户端返回适当的 JSON 响应,然后自动在客户端处理错误。例如,假设表单有一个在服务器端确定的验证错误。在这种情况下,服务器返回一个验证错误消息,客户端显示一个消息框,如下面的截图所示:

![图 12.12 – AJAX 表单提交时的服务器端验证错误图 12.12_B17287.jpg

图 12.12 – AJAX 表单提交时的服务器端验证错误

消息框在任何异常中都会显示,包括你的自定义异常和UserFriendlyException。前往第七章异常处理部分,探索横切关注点,以了解更多关于异常处理系统信息。

除了将表单转换为 AJAX 表单和处理异常之外,ABP 还防止在提交按钮的data-busy-text属性上双击以使用另一段文本。

在下一节中,我们将学习 ABP 框架如何帮助我们处理模态对话框。

与模态对话框一起工作

当你想要创建交互式用户界面时,模态对话框是基本组件之一。它提供了一种方便的方式,可以在不改变当前页面布局的情况下获取用户的响应或显示一些信息。

Bootstrap 有一个模态组件,但它需要一些样板代码。ABP 框架提供了abp-modal标签助手来渲染模态组件,这简化了在大多数用例中模态的使用。模态的另一个问题是将模态代码放在打开模态的页面中,这使得模态难以重用。ABP 在 JavaScript 端提供了一个模态 API,用于动态加载和控制这些模态。它也与模态内的表单很好地协同工作。让我们从最简单的用法开始。

理解模态的基础知识

ABP 建议将模态定义为独立的 Razor 页面(或者如果你使用 MVC 模式,则是视图)。因此,作为第一步,我们应该创建一个新的 Razor 页面。假设我们在Pages文件夹下创建了一个名为MySimpleModal.cshtml的新 Razor 页面。后端代码很简单:

public class MySimpleModalModel : AbpPageModel
{
    public string Message { get; set; }

    public void OnGet()
    {
        Message = "Hello modals!";
    }
}

我们在模态对话框中只显示一个Message属性。让我们看看视图方面:

@page
@model MvcDemo.Web.Pages.MySimpleModalModel
@{
    Layout = null;
}
<abp-modal>
    <abp-modal-header title="My header"></abp-modal-header>
    <abp-modal-body>
        @Model.Message
    </abp-modal-body>
    <abp-modal-footer buttons="Close"></abp-modal-footer>
</abp-modal>

这里的Layout = null语句是关键的。因为这个页面是通过 AJAX 请求加载的,结果应该只包含模态的内容,而不是标准布局。abp-modal是渲染模态对话框 HTML 的主要标签助手。abp-modal-headerabp-modal-bodyabp-modal-footer是模态的主要部分,并且有不同的选项。在这个例子中,模态体非常简单;它只是在模型上显示Message

我们已经创建了模态,但我们应该创建一种打开它的方法。ABP 在 JavaScript 端提供了 ModalManager API 来控制模态。在这里,我们需要在想要打开模态的页面上创建一个 ModalManager 对象:

var simpleModal = new abp.ModalManager({
    viewUrl: '/MySimpleModal'
});

abp.ModalManager 有几个选项,但最基本的是 viewUrl,它指示模态内容将被加载的 URL。一旦我们有一个 ModalManager 实例,我们就可以调用它的 open 方法来打开模态:

$(function (){
    $('#Button1').click(function (){
        simpleModal.open();
    });
});

此示例假设页面上有一个 ID 为 Button1 的按钮。当用户点击按钮时,我们将打开模态。以下截图显示了打开的模态:

图 12.13 – 一个简单的模态对话框

图 12.13 – 一个简单的模态对话框

图 12.13 – 一个简单的模态对话框

通常,我们在模态中创建动态内容,因此需要在打开模态对话框时传递一些参数。为此,您可以将一个包含模态参数的对象传递给 open 方法,如下例所示:

simpleModal.open({
    productId: 42
});

在这里,我们向模态传递了一个 productId 参数,因此它可能会显示给定产品的详细信息。您可以将相同的参数添加到 MySimpleModalModel 类的 OnGet 方法中,以获取值并在方法内部进行处理:

public void OnGet(int productId)
{
    ...
}

您可以从数据库中获取产品信息,并在模态体中渲染产品详情。

在下一节中,我们将学习如何将表单放置在模态中,以从用户那里获取数据。

在模态中处理表单

模态被广泛用于向用户显示表单。ABP 的 ModalManager API 优雅地为您处理一些常见任务:

  • 它将焦点放在表单的第一个输入上。

  • 当您按下 Enter 键或点击 保存 按钮时,它会触发验证检查。除非表单完全有效,否则不允许提交表单。

  • 它通过 AJAX 请求提交表单,禁用模态按钮,并在保存操作完成前显示进度图标。

  • 如果您已输入一些数据并点击 取消 按钮或关闭模态,它会警告您有关未保存的更改。

假设我们想要显示一个用于创建新电影的模态对话框,并且我们已经创建了一个名为 ModalWithForm.cshtml 的新 Razor 页面。代码隐藏文件与我们在 实现 AJAX 表单 部分中看到的内容类似:

public class ModalWithForm : AbpPageModel
{
    [BindProperty]
    public MovieViewModel Movie { get; set; }

    public void OnGet()
    {
        Movie = new MovieViewModel();
    }
    public async Task<IActionResult> OnPostAsync()
    {
        ValidateModel();
        //TODO: Create a new movie
        return NoContent();
    }
}

OnPostAsync 方法首先验证用户输入。如果表单无效,将抛出异常,并由 ABP 框架在服务器端和客户端处理。您可以向客户端返回一个响应,但在此示例中,我们返回一个 NoContent 响应。

由于我们混合了表单和模态,模态的视图侧略有不同:

@page
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@model MvcDemo.Web.Pages.ModalWithForm
@{
    Layout = null;
}
<form method="post" asp-page="/ModalWithForm">
    <abp-modal>
        <abp-modal-header title="Create new movie">
        </abp-modal-header>
        <abp-modal-body>
            <abp-input asp-for="Movie.Name" />
            <abp-select asp-for="Movie.Genre" />
            <abp-input asp-for="Movie.Description" />
            <abp-input asp-for="Movie.Price" />
            <abp-input asp-for="Movie.ReleaseDate" />
            <abp-input asp-for="Movie.PreOrder" />
        </abp-modal-body>
        <abp-modal-footer buttons="@(
            AbpModalButtons.Cancel|AbpModalButtons.Save)">
        </abp-modal-footer>
    </abp-modal>
</form>

abp-modal 标签被 form 元素包裹。我们不会将 form 标签放在 abp-modal-body 元素内部,因为 form。因此,作为解决方案,我们将 form 作为此视图的最高级元素放置。其余的代码块应该很熟悉;我们使用 ABP 输入标签助手来渲染表单元素。

现在,我们可以在我们的 JavaScript 代码中打开模态:

var newMovieModal = new abp.ModalManager({
    viewUrl: '/ModalWithForm'
});
$(function (){
    $('#Button2').click(function (){
        newMovieModal.open();
    });
});

打开的对话框如下截图所示:

图 12.14 – 模态内的表单

图 12.14 – 模态内的表单

在模态内也可以使用 abp-dynamic-form 标签助手。我们可以像这样重写模态的视图:

<abp-dynamic-form abp-model="Movie" 
    asp-page="ModalWithForm">
    <abp-modal>
        <abp-modal-header title="Create new movie!">
        </abp-modal-header>
        <abp-modal-body>
            <abp-form-content/>
        </abp-modal-body>
        <abp-modal-footer buttons="@(
            AbpModalButtons.Cancel|AbpModalButtons.Save)">
        </abp-modal-footer>
    </abp-modal>
</abp-dynamic-form>

在这里,我像在上一节中一样将 abp-modal 包裹在 abp-dynamic-form 元素中。这个示例的主要点是我在 abp-modal-body 元素中使用了 <abp-form-content/> 标签助手。abp-form-content 是一个可选的标签助手,用于将 abp-dynamic-form 标签助手的表单输入放置在所需的位置。

通常,你希望在模态表单保存后采取行动。为此,你可以将回调函数注册到 ModalManageronResult 事件,如下面的代码块所示:

newMovieModal.onResult(function (e, data){
    console.log(data.responseText);
});

如果服务器发送任何结果,data.responseText 将是数据。例如,你可以从 OnPostAsync 方法返回一个 Content 响应,如下面的示例所示:

public async Task<IActionResult> OnPostAsync()
{
    ...
    return Content("42");
}

ABP 简化了所有这些常见任务。否则,你将需要编写大量的样板代码。

在下一节中,我们将学习如何向我们的模态对话框添加客户端逻辑。

为模态添加 JavaScript

如果你的模态需要一些高级客户端逻辑,你可能需要为你的模态编写一些自定义 JavaScript 代码。你可以在打开模态的页面中编写你的 JavaScript 代码,但这不是非常模块化和可重用的。最好将你的模态 JavaScript 代码写在单独的文件中,理想情况下靠近模态的 .cshtml 文件(记住 ABP 允许你将 JavaScript 文件放在 Pages 文件夹下)。

为了此,我们可以创建一个新的 JavaScript 文件并在 abp.modals 命名空间中定义一个函数,如下面的代码所示:

abp.modals.MovieCreation = function () {
     this.initModal = function(modalManager, args) {
        var $modal = modalManager.getModal();
        var preOrderCheckbox =
            $modal.find('input[name="Movie.PreOrder"]');
        preOrderCheckbox.change(function(){
            if (this.checked){
               alert('checked pre-order!'); 
            }
        });
        console.log('initialized the modal...');
    }
};

一旦我们创建了这样的 JavaScript 类,我们可以在创建 ModalManager 实例时将其与模态关联起来:

var newMovieModal = new abp.ModalManager({
    viewUrl: '/ModalWithForm',
    modalClass: 'MovieCreation'
});

ModalManager 在每次打开模态时都会创建 abp.modals.MovieCreation 类的新实例,如果你定义了 initModal 函数,它将调用该函数。initModal 函数接受两个参数。第一个参数是与模态关联的 ModalManager 实例,这样你就可以使用它的函数。第二个参数是你打开模态时传递给 open 函数的参数。

initModal 函数是准备模态内容和将一些回调注册到模态组件事件的完美位置。在先前的示例中,我获取了模态实例和一个 JQuery 对象,找到了 Movie.PreOrder 复选框,并注册了其 change 回调,以便在用户勾选它时得到通知。

这个示例仍然不起作用,因为我们还没有将 JavaScript 文件添加到页面中。有两种方法可以将它添加到页面中:

  • 我们可以使用 abp-script 标签将模态的 JavaScript 文件包含在我们打开模态的页面中。

  • 我们可以设置 ModalManager 以使其懒加载 JavaScript 文件。

第一个选项很简单——只需在你想使用模态的页面中包含以下行:

<abp-script src="img/ModalWithForm.cshtml.js" />

如果我们想懒加载模态的脚本,我们可以这样配置ModalManager

var newMovieModal = new abp.ModalManager({
    viewUrl: '/ModalWithForm',
    scriptUrl: '/Pages/ModalWithForm.cshtml.js',
    modalClass: 'MovieCreation'
});

在这里,我添加了scriptUrl选项作为模态的 JavaScript 文件的 URL。ModalManager在第一次打开模态时懒加载 JavaScript 文件。如果你第二次打开模态(不刷新整个页面),则不会再次加载脚本。

在本节中,我们学习了如何处理表单、验证和模态。它们是典型 Web 应用的必要部分。在下一节中,我们将了解一些在每项应用中都需要的有用 JavaScript API。

使用 JavaScript API

在本节中,我们将探索 ABP 框架的一些有用的客户端 API。其中一些 API 提供了使用服务器端定义的功能(如身份验证和本地化)的简单方法,而其他 API 则提供了常见 UI 模式(如消息框和通知)的解决方案。

所有客户端 JavaScript API 都是声明在abp命名空间下的全局对象和函数。让我们从在你的 JavaScript 代码中访问当前用户信息开始。

访问当前用户

我们在服务器端使用ICurrentUser服务来获取当前登录用户的信息。在 JavaScript 代码中,我们可以使用全局的abp.currentUser对象,如下所示:

var userId = abp.currentUser.id;
var userName = abp.currentUser. userName;

通过这样做,我们可以获取用户的 ID 和用户名。以下 JSON 对象是abp.currentUser对象的示例:

{
  isAuthenticated: true,
  id: "813108d7-7108-4ab2-b828-f3c28bbcd8e0",
  tenantId: null,
  userName: "john",
  name: "John",
  surName: "Nash",
  email: "john.nash@abp.io",
  emailVerified: true,
  phoneNumber: "+901112223342",
  phoneNumberVerified: true,
  roles: ["moderator","manager"]
}

如果当前用户尚未登录,所有这些值都将为nullfalse,正如你所期望的那样。abp.currentUser对象提供了一种简单的方法来获取有关当前用户的信息。在下一节中,我们将学习如何检查当前用户的权限。

检查用户权限

ABP 的授权和权限管理系统是一种强大的方式,可以在运行时定义权限并检查当前用户的权限。使用abp.auth API 在你的 JavaScript 代码中检查这些权限非常容易。

以下示例检查当前用户是否有DeleteProduct权限:

if (abp.auth.isGranted('DeleteProduct')) {
  // TODO: Delete the product
} else {
  abp.message.warn("You don't have permission to delete
                    products!");
}

abp.auth.isGranted如果当前用户已授予权限或策略,则返回true。如果用户没有权限,我们将使用 ABP 消息 API 显示警告消息,这将在本章后面的显示消息框部分进行解释。

虽然这些 API 很少需要,但在你需要获取所有可用权限/策略的列表时,可以使用abp.auth.policies对象;如果你需要获取当前用户所有已授予权限/策略的列表,可以使用abp.auth.grantedPolicies对象。

基于权限隐藏 UI 部分

客户端权限检查的一个典型用例是根据用户的权限隐藏一些 UI 部分(例如操作按钮)。虽然abp.auth API 提供了动态的方式来做到这一点,但我建议尽可能在你的 Razor Pages/views 中使用标准的IAuthorizationService来条件性地渲染 UI 元素。

注意,在客户端检查权限只是为了用户体验,并不能保证安全性。你应该始终在服务器端检查相同的权限。

在下一节中,我们将学习如何在多租户应用程序中检查当前租户的功能权限。

检查租户功能

功能系统用于根据当前租户限制应用程序的功能/特性。我们将在第十六章中探索 ABP 的多租户基础设施,实现多租户。然而,我们将在这里介绍如何检查 ASP.NET Core MVC/Razor Pages UI 的租户功能。

abp.features API 用于检查当前租户的功能值。假设我们有一个从 Mailchimp(一个云电子邮件营销平台)导入电子邮件列表的功能,并且我们已经定义了一个名为MailchimpImport的功能。我们可以轻松地检查当前租户是否启用了该功能:

if (abp.features.isEnabled('MailchimpImport'))
{
  // TODO: Import from Mailchimp
}

abp.features.isEnabled仅在给定的功能值是true时返回true。ABP 的功能系统还允许你定义非布尔功能。在这种情况下,你可以使用abp.features.get(…)函数来获取当前租户给定功能的值。

在客户端检查功能使得执行动态客户端逻辑变得容易,但请记住,为了确保应用程序的安全,也要在服务器端检查功能。

在下一节中,我们将继续在你的 JavaScript 代码中使用本地化系统。

本地化字符串

ABP 本地化系统的一个强大之处在于你可以在客户端重用相同的本地化字符串。这样,你就不必在 JavaScript 代码中处理另一种本地化库。

abp.localization API 在你的 JavaScript 代码中可用,以帮助你利用本地化系统。让我们从最简单的情况开始:

var str = abp.localization.localize('HelloWorld');

在这种用法中,localize函数接受一个本地化键,并根据当前语言返回本地化值。它使用默认的本地化资源。如果你需要,你可以将本地化资源作为第二个参数指定:

var str = abp.localization.localize('HelloWorld', 'MyResource');

在这里,我们已将MyResource指定为本地化资源。如果你想从同一资源中本地化大量字符串,有一个更简短的方法来做这件事:

var localizer = abp.localization.getResource('MyResource');
var str = localizer('HelloWorld');

在这里,你可以使用localizer对象从相同的资源获取文本。

JavaScript 本地化 API 将相同的回退逻辑应用于服务器端 API;如果找不到本地化值,它将返回给定的键。

如果本地化字符串包含占位符,你可以将占位符值作为参数传递。假设我们在本地化 JSON 文件中有以下条目:

"GreetingMessage": "Hello {0}!"

我们可以将参数传递给 localizerabp.localization.localize 函数,如下例所示:

var str = abp.localization.localize('GreetingMessage', 'John');

对于此示例,结果 str 值将是 Hello John!。如果你有多个占位符,你可以按相同顺序将值传递给 localizer 函数。

除了本地化文本外,你可能还需要知道当前的文化和语言,以便你可以采取额外的行动。abp.localization.currentCulture 对象包含有关当前语言和文化的详细信息。除了当前语言外,abp.localization.languages 值是当前应用程序中所有可用语言的数组。大多数时候,你不会直接使用这些 API,因为你所使用的主题负责向用户显示语言列表并允许你在它们之间切换。然而,了解你可以在需要时访问语言数据是很好的。

到目前为止,你已经学习了如何在客户端使用一些 ABP 服务器端功能。在下一节中,你将学习如何向用户显示消息和确认框。

显示消息框

在应用程序中向用户显示阻止消息框以通知他们发生的重要事情是非常常见的。在本节中,你将学习如何在应用程序中显示漂亮的消息框和确认对话框。

abp.message API 用于显示消息框,以便轻松通知用户。有四种类型的消息框:

  • abp.message.info:显示信息消息

  • abp.message.success:显示成功消息

  • abp.message.warn:显示警告消息

  • abp.message.error:显示错误消息

让我们看看以下示例:

abp.message.success('Your changes have been successfully
                     saved!', 'Congratulations');

在此示例中,我使用了 success 函数来显示成功消息。第一个参数是消息文本,而可选的第二个参数是消息标题。此示例的结果如下截图所示:

![图 12.15 – 成功消息框图片

图 12.15 – 成功消息框

消息框被阻止,这意味着页面被阻止(不可点击),直到用户点击确定按钮。

另一种类型的消息框用于确认目的。abp.message.confirm 函数显示一些对话框以从用户那里获取响应:

abp.message.confirm('Are you sure to delete this product?')
.then(function(confirmed){
  if(confirmed){
    // TODO: Delete the product!
  }
});

confirm 函数返回一个承诺,因此我们可以将其与 then 回调链式调用,以便在用户通过接受或取消对话框关闭后执行一些代码。以下截图显示了为此示例创建的确认对话框:

![图 12.16 – 确认对话框图片

图 12.16 – 确认对话框

消息框是吸引用户注意的好方法。然而,还有另一种方法可以做到这一点,我们将在下一节中看到。

显示通知

通知是非阻塞的方式,用于通知用户某些事件。它们显示在屏幕的右下角,并在几秒钟后自动消失。就像消息框一样,有四种类型的通知:

  • abp.notify.info: 显示信息通知

  • abp.notify.success: 显示成功通知

  • abp.notify.warn: 显示警告通知

  • abp.notify.error: 显示错误通知

以下示例展示了信息通知:

abp.notify.info(
    'The product has been successfully deleted.',
    'Deleted the Product'
);

第二个参数是通知标题,是可选的。以下截图显示了此示例代码的结果:

图 12.17 – 通知消息

图 12.17 – 通知消息

使用通知 API,我们正在关闭 JavaScript API。

在这里,我介绍了最常用的 API。然而,您可以在 JavaScript 代码中使用更多 API,所有这些您都可以通过阅读 ABP 框架文档来了解:docs.abp.io/en/abp/latest/UI/AspNetCore/JavaScript-API/Index。在下一节中,我们将学习如何从 JavaScript 代码中消费服务器端 API。

消费 HTTP API

您可以使用任何工具或技术从 JavaScript 代码中消费 HTTP API。然而,ABP 提供了以下作为完全集成解决方案的方法:

  • 您可以将abp.ajax API 作为jQuery.ajax API 的扩展使用。

  • 您可以使用动态 JavaScript 客户端代理来调用服务器端 API,就像使用 JavaScript 函数一样。

  • 您可以在开发时生成静态 JavaScript 客户端代理。

让我们从第一个开始 – abp.ajax API。

使用 abp.ajax API

abp.ajax API 是标准jQuery.ajax API 的包装器。它在发生错误的情况下自动处理所有错误,并向用户显示本地化消息。它还向 HTTP 头中添加了反伪造令牌,以满足服务器端的跨站请求伪造CSRF)保护。

以下示例使用abp.ajax API 从服务器获取用户列表:

abp.ajax({
  type: 'GET',
  url: '/api/identity/users'
}).then(function(result){
  // TODO: process the result
});

在此示例中,我们已将GET指定为请求的type。您可以指定所有jQuery.ajax(或$.ajax)的标准选项来覆盖默认值。abp.ajax返回一个承诺对象,因此我们可以添加then回调来处理服务器发送的结果。我们还可以使用catch回调来处理错误,以及使用always回调在请求结束时执行操作。

以下示例展示了如何手动处理错误:

abp.ajax({
  type: 'GET',
  url: '/api/identity/users',
  abpHandleError: false
}).then(function(result){
  // TODO: process the result
}).catch(function(){
  abp.message.error("request failed :(");
});

在这里,我在 then 函数之后添加了一个 catch 回调函数。你可以在那里执行你的错误逻辑。我还指定了 abpHandleError: false 选项来禁用 ABP 的自动错误处理逻辑。否则,ABP 将处理错误并向用户显示错误消息。

abp.ajax 是一个低级 API。你通常使用动态或静态客户端代理来消费自己的 HTTP API。

使用动态客户端代理

如果你应用了示例应用程序的 第三章 步骤-by-步骤应用程序开发,你应该已经使用了动态 JavaScript 客户端代理系统。ABP 框架在运行时生成 JavaScript 函数,以便轻松消费应用程序的所有 HTTP API。

下面的代码块显示了在 第三章 步骤-by-步骤应用程序开发 中定义的两个 IProductAppService 样式方法:

namespace ProductManagement.Products
{
    public interface IProductAppService :
        IApplicationService
    {
        Task CreateAsync(CreateUpdateProductDto input);
        Task<ProductDto> GetAsync(Guid id);
    }
}

所有这些方法都在客户端的相同命名空间中可用。例如,我们可以通过其 ID 获取一个产品,如下面的代码块所示:

productManagement.products.product
  .get('1b8517c8-2c08-5016-bca8-39fef5c4f817')
  .then(function (result) {
    console.log(result);
  });

productManagement.products 是 C# 代码中 ProductManagement.Products 命名空间的驼峰式等效。productIProductAppService 的传统名称。I 前缀和 AppService 后缀已被移除,剩余的名称被转换为驼峰式。然后,我们可以使用不带 Async 后缀的驼峰式方法名称。因此,GetAsync 方法在 JavaScript 代码中用作 get 函数。get 函数接受与 C# 方法相同的参数。它返回一个 Deferred 对象,这样我们就可以使用 thencatchalways 回调函数来链式调用,类似于 abp.ajax API 可以做到的那样。它内部使用 abp.ajax API。在这个例子中,then 函数的 result 参数是服务器发送的 ProductDto 对象。

其他方法以类似的方式进行使用。例如,我们可以使用以下代码创建一个新产品:

productManagement.products.product.create({
  categoryId: '5f568193-91b2-17de-21f3-39fef5c4f808',
  name: 'My product',
  price: 42,
  isFreeCargo: true,
  releaseDate: '2023-05-24',
  stockState: 'PreOrder'
});

在这里,我们使用 JSON 对象格式传递了 CreateUpdateProductDto 对象。

在某些情况下,我们可能需要为 HTTP API 调用传递额外的 AJAX 选项。你可以将一个对象作为每个代理函数的最后一个参数传递:

productManagement.products.product.create({
  categoryId: '5f568193-91b2-17de-21f3-39fef5c4f808',
  name: 'My product',
  //...other values
}, {
  url: 'https://localhost:21322/api/my-custom-url'
  headers: {
    'MyHeader': 'MyValue'
  }
});

在这里,我传递了一个对象来更改 URL 并向请求添加一个自定义头。你可以参考 jQuery 的文档 (api.jquery.com/jquery.ajax/) 了解所有可用选项。

动态 JavaScript 客户端代理函数由应用程序的 /Abp/ServiceProxyScript 端点在运行时生成。这个 URL 由主题添加到布局中,这样你就可以直接在你的页面中使用任何代理函数,而无需导入任何脚本。

在下一节中,你将了解消费 HTTP API 的另一种替代方法。

使用静态客户端代理

与在运行时生成的动态客户端代理不同,静态代理是在开发时生成的。我们可以使用 ABP CLI 来生成代理脚本文件。

首先,我们需要运行提供 HTTP API 的应用程序,因为 API 端点数据是从服务器请求的。然后,我们可以使用 generate-proxy 命令,如下例所示:

abp generate-proxy -t js -u https://localhost:44349

generate-proxy 命令可以接受以下参数:

  • -t(必需):代理的类型。在这里我们使用 js 表示 JavaScript。

  • -u(必需):API 端点的根 URL。

  • -m(可选):生成代理的模块名称。默认值为 app,用于生成应用程序的代理。在模块化应用程序中,你可以在此处指定模块名称。

静态 JavaScript 代理在 wwwroot/client-proxies 文件夹下生成,如下截图所示:

![图 12.18 – 静态 JavaScript 代理文件img/Figure_12.18_B17287.jpg

图 12.18 – 静态 JavaScript 代理文件

然后,你可以将代理脚本文件导入任何页面,并像使用动态代理一样使用静态代理函数。

当你使用静态代理时,不需要动态代理。默认情况下,ABP 为你的应用程序创建动态代理。你可以配置 DynamicJavaScriptProxyOptions 来禁用它,如下例所示:

Configure<DynamicJavaScriptProxyOptions>(options => {
    options.EnabledModules.Remove("app");
});

EnabledModules 列表默认包含 app。如果你正在构建模块化应用程序并希望为你的模块启用动态 JavaScript 代理,你需要将其显式添加到 EnabledModules 列表中。

摘要

在本章中,我们介绍了 ABP 框架 MVC/Razor Pages UI 的基本设计要点和基本功能。

主题系统允许你构建与主题/样式无关的模块和应用程序,并轻松地在 UI 主题之间切换。它是通过定义一组基本库和标准布局来实现这一点的。

你还学习了捆绑和压缩系统,它涵盖了在应用程序中导入和使用客户端依赖项的整个开发周期,并在生产环境中优化资源使用。

ABP 使得使用标签辅助器和预定义约定创建表单以及实现验证和本地化变得简单。你还学习了如何将标准表单转换为 AJAX 提交的表单。

我们还介绍了一些可以用于客户端并利用 ABP 功能的 JavaScript API,例如授权和本地化,以及轻松显示美观的消息框和通知。

最后,你了解了从 JavaScript 代码中消费 HTTP API 的替代方法。

在下一章中,你将学习关于 ABP 框架的 Blazor UI,使用 C# 而不是 JavaScript 来构建交互式 Web UI。

第十三章:第十三章:使用 Blazor WebAssembly UI

Blazor 是一个相对较新的使用 C#而不是 JavaScript 构建交互式网页应用的单页应用程序(SPA)框架。Blazor 是 ABP 框架提供的内置 UI 选项之一。

在本章中,我将简要讨论 Blazor 是什么以及使用这个新框架的主要优缺点。然后,我将继续解释如何使用 Blazor UI 选项创建新的 ABP 解决方案。到本章结束时,你将了解 ABP Blazor 集成的架构和设计,并了解你将在应用程序中使用的基本 ABP 服务。

本章包括以下主题:

  • 什么是 Blazor?

  • 开始使用 ABP Blazor UI

  • 验证用户身份

  • 理解主题系统

  • 使用菜单

  • 使用基本服务

  • 使用 UI 服务

  • 消费 HTTP API

  • 使用全局脚本和样式

技术要求

如果你想跟随本章中的示例,你需要一个支持 ASP.NET Core 开发的 IDE/编辑器。在某些地方,我们将使用 ABP CLI,因此你需要安装 ABP CLI,具体请参考第二章,“ABP 框架入门”。

您可以从以下 GitHub 仓库下载示例应用程序:github.com/PacktPublishing/Mastering-ABP-Framework。它包含本章中给出的一些示例。

什么是 Blazor?

如我在引言中指出的,Blazor 是一个用于构建交互式网页应用的 SPA 框架,就像其他 SPA 框架(如 Angular、React 和 Vue.js)一样。然而,它有一个重要的区别——我们可以使用 C#来构建应用而不是 JavaScript,这意味着我们可以在浏览器中运行.NET。Blazor 使用.NET 核心运行时在浏览器中执行.NET 代码(对于 Blazor WebAssembly)。

在浏览器中运行.NET 并不是一个新想法。微软之前已经通过 Silverlight 做到了这一点。要运行 Silverlight 应用程序,我们不得不在浏览器上安装一个插件。另一方面,Blazor 通过WebAssembly技术原生地在浏览器上运行,该技术定义如下webassembly.org

"WebAssembly(简称 Wasm)是一种基于栈的虚拟机的二进制指令格式。Wasm 被设计为编程语言的便携式编译目标,使得客户端和服务器应用程序能够在网络上部署。"

一种高级语言,如 C#,可以被编译成 WebAssembly 并在浏览器中本地运行。WebAssembly 被所有主流的网页浏览器支持,因此我们不需要安装任何自定义插件。如果你想知道 Blazor 是否是新的 Silverlight,我可以简单地说,不是的。

作为.NET 开发者,Blazor 为我们带来了难以置信的机会:

  • 我们可以利用语言和运行时的全部功能,使用我们现有的 C#技能来开发应用程序。

  • 我们可以使用现有的.NET 库,例如我们最喜欢的 NuGet 包。

  • 我们可以在服务器和客户端之间共享代码(例如 DTO 类、应用程序服务合约、本地化和验证代码)。

  • 我们可以使用熟悉的 Razor 语法来构建 UI 页面和组件。

除了使用 C#,Blazor 还提供了 JavaScript 互操作性,可以从 C#调用 JavaScript 代码,反之亦然。这意味着我们可以在需要时使用现有的 JavaScript 库并编写我们的 JavaScript 代码。

在服务器和客户端应用程序之间编写 C#和共享代码对于.NET 开发者来说是一个巨大的优势。ABP 也利用了这一点,尽可能地在 MVC/Razor Pages UI 和 Blazor UI 之间共享基础设施。您会发现许多服务与 MVC/Razor Pages UI 非常相似。

作为.NET 开发者和软件公司经理,我对 Blazor 印象深刻,并将将其用于未来的项目。然而,这并不意味着它没有缺点:

  • 打包大小、初始加载时间和运行时性能比其 JavaScript 竞争对手,如 Angular 和 React,更差。然而,微软正在投资 Blazor 并努力提高其性能。例如,.NET 6.0 引入了即时编译AOT)。

  • 由于 Blazor 还处于早期阶段,UI 组件和生态系统尚未成熟。

  • 调试目前还不是那么直接。

如果这些缺点对您的项目可以容忍,您今天就可以开始使用 Blazor。

有趣的是,Blazor 有两种运行时模型。到目前为止,我主要谈论的是Blazor WebAssembly。第二种模型称为Blazor Server。虽然组件开发模型是相同的,但托管逻辑和运行时模型完全不同。

使用 Blazor WebAssembly,.NET 代码在浏览器上的 Mono 运行时中运行,我们不必在服务器端运行.NET。一小段初始化 JavaScript 代码下载标准的.NET 动态链接库DLL)并在浏览器中运行。这种模式类似于,并且是 Angular 和 React 的直接竞争对手,因为它在浏览器中完全运行客户端逻辑。

另一方面,Blazor Server 在服务器上完全运行.NET 代码。它建立了客户端和服务器之间的实时 SignalR 连接。浏览器运行 JavaScript 并通过该 SignalR 连接与服务器通信。它向服务器发送事件,服务器执行必要的.NET 代码并将文档对象模型DOM)更改发送到浏览器。最后,浏览器将 DOM 更改应用到 UI 上。

与 Blazor WebAssembly 相比,Blazor Server 模型具有更快的初始加载时间。然而,它需要与服务器通信以处理所有事件和 DOM 更改,因此我们需要服务器和客户端之间有一个良好且稳定的连接。

我在这本书中的目的不是提供 Blazor 的完整介绍、概述和使用案例,而是提供一个足够简短的介绍,以便理解它是什么。此外,本章将重点介绍 Blazor WebAssembly,但大多数主题都适用于 Blazor Server。

现在,我们可以开始 ABP 的 Blazor 集成。

开始使用 ABP Blazor UI

使用 ABP 的启动解决方案模板启动新项目有两种方式。您可以从abp.io/get-started下载它,或者使用 ABP CLI 创建它。本书中我将使用 CLI 方法。如果您尚未安装它,请打开命令行终端并执行以下命令:

dotnet tool install -g Volo.Abp.Cli

现在,我们可以使用abp new命令创建一个新的解决方案:

abp new DemoApp -u blazor

DemoApp是本例中的解决方案名称。我已通过传递-u blazor参数来指定 Blazor WebAssembly。如果您想使用 Blazor Server,可以将参数指定为-u blazor-server

我尚未指定数据库提供程序,因此默认使用 Entity Framework Core(如果想要使用 MongoDB,请指定-d mongodb参数)。在创建解决方案后,我们需要创建初始数据库迁移。作为第一步,我们应该在src/DemoApp.DbMigrator目录中执行以下命令:

dotnet run

此命令创建初始的代码优先迁移并将其应用于数据库。

该解决方案包含两个应用程序:

  • 第一个是一个服务器(后端)应用程序,它托管 HTTP API 并提供身份验证 UI。

  • 第二个应用程序是包含应用程序 UI 并与服务器通信的前端 Blazor WebAssembly 应用程序。

因此,我们首先运行此示例的DemoApp.HttpApi.Host服务器应用程序。然后,我们可以运行DemoApp.Blazor Blazor 应用程序来运行 UI。您可以使用admin作为用户名和1q2w3E*作为密码登录到应用程序。

我不会深入探讨应用程序的细节,因为我们已经在第二章中做了介绍,即使用 ABP 框架入门。下一节将解释用户是如何进行身份验证的。

用户身份验证

OpenID ConnectOIDC)是 Microsoft 建议用于身份验证 Blazor WebAssembly 应用程序的方法。ABP 遵循这一建议,并在启动解决方案中预先配置了它。

Blazor 应用程序不包含登录、注册或其他与身份验证相关的 UI 页面。它使用启用了证明密钥用于代码交换PKCE)的授权代码流将用户重定向到服务器应用程序。服务器处理所有身份验证逻辑并将用户重定向回 Blazor 应用程序。

身份验证配置存储在 Blazor 应用程序的wwwroot/appsettings.json文件中。请参阅以下示例配置:

  "AuthServer": {
    "Authority": "https://localhost:44306",
    "ClientId": "DemoApp_Blazor",
    "ResponseType": "code"
  }

在这里,Authority是后端服务器应用程序的根 URL。ClientId是服务器所知的 Blazor 应用程序的名称。最后,ResponseType指定了授权代码流。

此配置在模块类中使用,例如本例中的DemoAppBlazorModule类,如下面的代码块所示:

private static void ConfigureAuthentication(
    WebAssemblyHostBuilder builder)
{
    builder.Services.AddOidcAuthentication(options =>
    {
        builder.Configuration.Bind(
            "AuthServer", options.ProviderOptions);
        options.UserOptions.RoleClaim = JwtClaimTypes.Role;
        options.ProviderOptions.DefaultScopes.Add(
            "DemoApp");
        options.ProviderOptions.DefaultScopes.Add("role");
        options.ProviderOptions.DefaultScopes.Add("email");
        options.ProviderOptions.DefaultScopes.Add("phone");
    });
}

AuthServer是匹配配置密钥的关键。如果您想自定义身份验证选项,这些就是您需要从这些点开始的地方。例如,您可以修改请求的作用域或更改 OIDC 配置。有关 Blazor WebAssembly 身份验证的更多信息,请参阅 Microsoft 的文档:docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/

在下一节中,我将介绍 Blazor UI 的主题系统。

理解主题系统

ABP 为 Blazor UI 提供了一套主题系统,正如我们在介绍 MVC/Razor Pages UI 时所述第十二章使用 MVC/Razor Pages。主题系统带来了灵活性,因此我们可以开发我们的应用程序和模块,而无需依赖于特定的 UI 主题/样式。

所有 ABP Blazor UI 主题都使用一组基础库。基本基础库是 Bootstrap,其组件旨在与 JavaScript 一起使用。幸运的是,一些组件库封装了 Bootstrap 组件,并提供了一个更简单的.NET API,这更适合在 Blazor 应用程序中使用。

这些组件库之一是Blazorise。实际上,它是一个抽象库,可以与多个提供者(如 Bootstrap、Bulma 和 Ant Design)一起工作。ABP 启动模板使用 Blazorise 库的 Bootstrap 提供者。

您可以在其网站上了解更多关于 Blazorise 的信息,并查看组件的实际应用:blazorise.com。以下图是表单组件演示的截图:

图 13.1 – Blazorise 演示:表单组件

图 13.1 – Blazorise 演示:表单组件

除了 Blazorise 库之外,ABP Blazor UI 还使用Font Awesome作为 CSS 字体图标库。因此,任何模块或应用程序都可以在其页面上使用这些库,而无需显式依赖。

UI 主题负责渲染布局,包括页眉、菜单、工具栏、页面警报和页脚。

在下一节中,我们将看到如何向主菜单添加新项目。

使用菜单

ABP Blazor UI 中的菜单管理与 ABP MVC/Razor Pages UI 中的菜单管理非常相似,这在第十二章中有所介绍,即使用 MVC/Razor Pages

我们使用AbpNavigationOptions向菜单系统添加贡献者。ABP 执行所有贡献者以动态构建菜单。启动解决方案包括一个菜单贡献者,并按照以下示例添加到AbpNavigationOptions中:

Configure<AbpNavigationOptions>(options =>
{
    options.MenuContributors.Add(new
        DemoAppMenuContributor(
        context.Services.GetConfiguration()));
});

DemoAppMenuContributor是一个实现IMenuContributor接口的类。IMenuContributor接口定义了ConfigureMenuAsync方法,我们应该按照以下示例实现:

public class DemoAppMenuContributor : IMenuContributor
{
    public async Task ConfigureMenuAsync(
        MenuConfigurationContext context)
    {
        if (context.Menu.Name == StandardMenus.Main)
        {
            //TODO: Configure the main menu
        }
    }
}

StandardMenus类(在Volo.Abp.UI.Navigation命名空间中)中定义了两个标准菜单名称作为常量:

  • Main:应用程序的主菜单。

  • User:用户上下文菜单。当您在页眉上点击您的用户名时,它会打开。

因此,前面的示例检查菜单名称,并且只向主菜单添加项目。以下代码块向主菜单添加一个新菜单项:

var l = context.GetLocalizer<DemoAppResource>();
context.Menu.AddItem(
    new ApplicationMenuItem(
        DemoAppMenus.Home,
        l["Menu:Home"],
        "/home",
        icon: "fas fa-home"
    )
);

您可以使用context.ServiceProvider对象从依赖注入中解析服务。context.GetLocalizer方法是一个用于解析IStringLocalizer<T>实例的快捷方式。同样,我们可以使用context.IsGrantedAsync快捷方法来检查当前用户的权限,如以下代码块所示:

if (await context.IsGrantedAsync("MyPermissionName"))
{
    context.Menu.AddItem(...);
}

菜单项可以嵌套。以下示例在Crm菜单项下添加了一个Orders菜单项:

context.Menu.AddItem(
    new ApplicationMenuItem(
        DemoAppMenus.Crm,
        l["Menu:Identity"]
    ).AddItem(new ApplicationMenuItem(
        DemoAppMenus.Orders,
        l["Menu:Orders"],
        url: "/crm/orders")
    )
);

我在第一个ApplicationMenuItem对象上调用AddItem以添加子项。您可以为Orders菜单项做同样的事情以构建更深的菜单。

我们在创建菜单项时使用了本地化和授权服务。在下一节中,我们将看到如何在 Blazor 应用程序的其他部分中使用这些服务。

使用基本服务

在本节中,我将向您展示如何在 Blazor 应用程序中使用一些基本服务。正如您将看到的,它们几乎与我们在早期章节中介绍的服务器端服务相同。让我们从授权服务开始。

授权用户

我们通常在 Blazor 应用程序中使用授权来隐藏/禁用一些页面、组件和功能。虽然服务器始终检查相同的授权规则以确保安全,但客户端授权检查提供了更好的用户体验。

IAuthorizationService用于以编程方式检查权限/策略,就像在服务器端一样。您可以根据以下示例注入和使用其方法:

public partial class Index
{
    protected override async Task OnInitializedAsync()
    {
        if (await AuthorizationService
                 .IsGrantedAsync("MyPermission"))
        {
            // TODO: ...
        }
    }
}

AuthorizationService有不同的工作方式。请参阅第七章中关于与授权和权限系统一起工作的部分,以了解更多关于授权系统的信息。

在前面的示例中,该组件是从AbpComponentBase类继承的。由于AbpComponentBase类为我们预先注入了它,我们可以直接使用AuthorizationService属性而无需手动注入。AuthorizationService属性类型是IAuthorizationService

如果你没有从AbpComponentBase类继承,你可以使用[Inject]属性来注入它:

[Inject]
private IAuthorizationService AuthorizationService { get;
                                                     set; }

当你需要时,你可以在 Razor 组件的视图侧使用相同的IAuthorizationService。然而,有一些替代方法可以使你的应用程序代码更简洁。例如,你可以在组件上使用[Authorize]属性,使其仅对认证用户可用:

@page "/"
@attribute [Authorize]
<p>This page is visible only if you've logged in</p>.

[Authorize]属性与服务器端类似。你可以在以下示例中传递策略/权限名称来检查特定权限:

@page "/order-management"
@attribute [Authorize("CanManageOrders")]
<p>You can only see this if you have the necessary
    permission.</p>

如果用户具有特定的权限,通常会显示 UI 的一部分。以下示例使用AuthorizeView元素,如果当前用户有权限编辑订单,则显示消息:

<AuthorizeView Policy="CanEditOrders">
    <p>You can only see this if you can edit the 
        orders.</p>
</AuthorizeView>

这样,你可以有条件地渲染操作按钮或其他 UI 部分。

ABP 与 Blazor 的授权系统 100%兼容,因此你可以参考 Microsoft 的文档来查看更多示例和详细信息:docs.microsoft.com/en-us/aspnet/core/blazor/security

在下一节中,我们将学习如何使用本地化系统,这是另一个常见的 UI 服务。

本地化用户界面

Blazor 应用程序使用相同的 API 进行文本本地化。我们可以注入并使用IStringLocalizer<T>服务来获取当前语言的本地化文本。

以下 Razor 组件使用了IStringLocalizer<T>服务:

@using DemoApp.Localization
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<DemoAppResource> L
<h3>@L["HelloWorld"]</h3>

我们使用标准的@inject指令,并在泛型IStringLocalizer<T>接口中指定本地化资源类型。相同的接口也可以注入并用于应用程序中的任何服务。请参阅第八章本地化用户界面部分,使用 ABP 的功能和服务,了解如何与本地化系统一起工作。

接下来,我们将在下一节学习如何获取当前用户的信息。

访问当前用户

你有时可能需要在应用程序中知道当前用户的用户名、电子邮件地址和其他详细信息。我们使用ICurrentUser服务来访问当前用户,就像在服务器端一样。以下示例组件通过当前用户的名称渲染欢迎信息:

@using Volo.Abp.Users
@inject ICurrentUser CurrentUser
<h3>Welcome @CurrentUser.Name</h3>

除了NameSurnameUserNameEmail等标准属性外,你还可以使用ICurrentUser.FindClaimValue(...)方法来获取服务器颁发的自定义声明。

我已经介绍了所有应用程序通常使用的 ABP Blazor 基本服务。我使它们保持简短,因为 API 几乎与服务器端相同,而且我们已经在之前的章节中详细介绍了它们。在下一节中,我将继续介绍用于通知用户的 UI 服务。

使用 UI 服务

在每个应用程序中,向用户显示消息、通知和警报以通知或警告他们是很常见的。在接下来的部分中,我将介绍 ABP 为这些服务内置的 API。

显示消息框

消息框用于向用户显示阻塞消息或确认对话框。用户点击确定按钮来禁用消息,或者点击取消按钮来对配置对话框做出决定。

有五种类型的消息 – 信息成功警告错误确认。以下示例显示了发送给用户的成功消息:

@page "/"
@inherits DemoAppComponentBase
<Button Color="Color.Primary"
        Clicked="ShowSuccess">Click me!</Button>
@code
{
    private async Task ShowSuccess()
    {
        await Message.Success("This is a success
                               message!");
    }
}

在此示例中,Message属性来自AbpComponentBase类(DemoAppComponentBase继承自它),其类型为IUiMessageService。或者,您可以为您的组件、页面或服务手动注入IUiMessageService。所有IUiMessageService方法都可以接受额外的title参数和options操作来自定义对话框。

以下图显示了前面示例的结果:

图 13.2 – 一个没有标题的简单成功消息

图 13.2 – 一个没有标题的简单成功消息

以下示例显示了发送给用户的确认对话框,如果用户点击按钮,则采取行动:

@page "/"
@inherits DemoAppComponentBase
<Button Color="Color.Primary" 
        Clicked="ShowQuestion">Click me!</Button>
@code
{
    private async Task ShowQuestion()
    {
        var result = await Message.Confirm(
            "Are you sure to delete the product?");
        if (result == true)
        {
            //TODO: ...
        }
    }
}

Confirm方法返回一个bool值,因此您可以看到用户是否接受了对话框消息。以下图显示了此示例的结果:

图 13.3 – 确认对话框

图 13.3 – 确认对话框

下一个部分将解释如何向用户显示非阻塞的信息消息。

显示通知

消息框将用户的注意力集中在消息上。他们应该点击确定按钮返回到应用程序 UI。另一方面,通知是非阻塞的信息消息。它们显示在屏幕的右下角,并在几秒钟后自动消失。

有四种类型的通知 – 信息成功警告错误。以下示例显示了一个确认对话框,如果用户接受确认消息,则显示成功通知:

@page "/"
@inherits DemoAppComponentBase
<Button Color="Color.Primary" 
        Clicked="ShowQuestion">Click me!</Button>
@code
{
    private async Task ShowQuestion()
    {
        var confirmed = await Message.Confirm(
            "Are you sure to delete the product?");
        if (confirmed)
        {
            //TODO: Delete the product
            await Notify.Success("Successfully deleted the
                                  product!");
        }
    }
}

Notify属性来自AbpComponentBase基类。您可以将IUiNotificationService接口注入并用于任何地方来在 UI 上显示通知。所有通知方法都可以接受额外的title参数和options操作来自定义对话框。以下图显示了在前面代码块中使用的Notify.Success方法的结果:

图 13.4 – 一个示例通知消息

图 13.4 – 一个示例通知消息

下一个部分介绍了警报,这是向用户显示消息的另一种方式。

显示警报

使用警报是一种粘性的方式来向用户显示非阻塞消息。用户可以选择忽略警报。

有四种类型的提示信息 – InfoSuccessWarningDanger。以下示例展示了发送给用户的Success提示信息:

@page "/"
@inherits DemoAppComponentBase
<Button Color="Color.Primary" 
        Clicked="DeleteProduct">Click me!</Button>
@code
{
    private async Task DeleteProduct()
    {
        //TODO: Delete the product
        Alerts.Success(
            text: "Successfully deleted the product.", 
            title: "Deleted!", 
            dismissible: true);
    }
}

在这个例子中,我使用了来自基类的Alerts属性。你总是可以注入IAlertManager服务并像IAlertManager.Alerts.Success(…)一样使用它。

所有的提示方法都接受text(必需)、title(可选)和dismissible(可选,默认为true)参数。如果一个提示信息是可关闭的,那么用户可以通过点击X按钮使其消失。以下图显示了前面例子中创建的提示信息:

图 13.5 – 成功提示消息

图 13.5 – 成功提示消息

提示信息是通过主题在页面内容上方渲染的。除了标准的InfoSuccessWarningDanger方法外,你可以通过指定AlertType来使用所有 Bootstrap 样式,如PrimarySecondaryDark

你现在已经学会了三种向用户展示信息消息的方法。在下一节中,我们将探讨 Blazor 应用程序如何消费服务器的 HTTP API。

消费 HTTP API

你可以使用标准的HttpClient手动设置和执行对服务器的 HTTP 请求。然而,ABP 提供了 C#客户端代理,可以轻松调用 HTTP API 端点。你可以直接从 Blazor UI 消费你的应用服务,并让 ABP 框架为你处理 HTTP API 调用。

假设我们有一个应用服务接口,如下例所示:

public interface ITestAppService : IApplicationService
{
    Task<int> GetDataAsync();
}

应用服务接口定义在Application.Contracts项目中(对于我创建的示例解决方案,是DemoApp.Application.Contracts项目)。Blazor 应用程序引用了这个项目。这样,我们就可以在客户端使用ITestAppService接口。

应用服务在Application项目中实现(对于我创建的示例解决方案,是DemoApp.Application项目)。我们可以简单地实现ITestAppService接口,如下面的代码块所示:

public class TestAppService : ApplicationService,
    ITestAppService
{
    public async Task<int> GetDataAsync()
    {
        return 42;
    }
}

现在,我们可以直接将ITestAppService注入到任何页面/组件中,就像注入任何其他本地服务一样,并调用其方法,就像标准的函数调用一样:

public partial class Index
{
    [Inject]
    private ITestAppService TestAppService { get; set; }
    private int Value { get; set; }

    protected override async Task OnInitializedAsync()
    {
        Value = await TestAppService.GetDataAsync();
    }
}

在这个例子中,我在TestAppService属性上方使用了标准的[Inject]属性来告诉 Blazor 为我注入它。然后,我在OnInitializedAsync方法中重写了它来调用GetDataAsync方法。正如我们所知,OnInitializedAsync方法是在组件/页面最初渲染并准备好工作后立即被调用的。

简单到这种程度。当我们调用 GetDataAsync 方法时,ABP 实际上通过处理所有复杂性(包括身份验证、错误处理和 JSON 序列化)向服务器发出 HTTP API 调用。它从 Blazor 项目的 wwwroot/appsettings.json 文件中的 RemoteServices 配置中读取服务器的根 URL。以下是一个示例配置代码块:

"RemoteServices": {
  "Default": {
    "BaseUrl": "https://localhost:44306"
  }
}

在本节中,我使用了 ABP 的动态 C# 客户端代理方法来从 Blazor 应用程序中消费 HTTP API。我们将在第十四章 构建 HTTP API 和实时服务中回到这个话题,同时介绍静态 C# 客户端代理。

下一节将探讨我们如何将脚本和样式文件添加到我们的 Blazor 应用程序中。

处理全局脚本和样式

导入 Blazor Server UI 的脚本和样式文件与 ABP 框架的 MVC/Razor Pages UI 相同。您可以参考第十二章 使用 MVC/Razor Pages来了解如何使用它。本节基于 Blazor WebAssembly。

Blazor WebAssembly 是一个单页应用程序,默认情况下只有一个入口点。index.html 文件位于 wwwroot 文件夹中,如下所示:

![图 13.6 – wwwroot 文件夹中的 index.html 文件图片

图 13.6 – wwwroot 文件夹中的 index.html 文件

index.html 是一个纯 HTML 文件。服务器在未经任何处理的情况下将其发送到浏览器。请记住,一个简单的静态文件服务器可以提供 Blazor WebAssembly 应用程序。浏览器首先加载 index.html 文档,然后加载此文档导入的样式和脚本。

如果您打开 index.html 文档,您将看到 ABP:Styles 注释内的一部分,如下所示:

<!--ABP:Styles-->
<link href="global.css?_v=637649661149948696"
    rel="stylesheet"/>
<link href="main.css" rel="stylesheet"/>
<!--/ABP:Styles-->

这段代码(包括注释)是由 ABP CLI 在您在 Blazor 项目的根目录中执行以下命令时自动创建(并更新)的:

abp bundle

当您执行此命令时,它创建(或重新生成)全局样式包。这个包包含所有必要的样式,包括 .NET 运行时、Blazor 和其他使用的库,以压缩格式。每次您将新的与 Blazor 相关的 ABP NuGet 包/模块添加到您的应用程序中时,您都需要重新运行 abp bundle 命令,并包含必要的依赖项重新生成包。

ABP 的 bundle 命令做得很好。当安装一个模块时,您不需要知道它的全局脚本文件或额外依赖项。只需运行此命令,您就有了一个更新后的、生产就绪的全局包文件。每个模块都会将其自己的依赖项贡献到该包中,然后 ABP 通过尊重模块依赖顺序来生成包。要操作包,您应该定义一个实现 IBundleContributor 接口的类。启动解决方案模板中的 Blazor 项目已经包含了一个包贡献者,如下面的代码块所示:

public class DemoAppBundleContributor : IBundleContributor
{
    public void AddScripts(BundleContext context)
    {
    }
    public void AddStyles(BundleContext context)
    {
        context.Add("main.css", excludeFromBundle: true);
    }
}

AddScriptsAddStyles 方法用于向全局包添加 JavaScript 和 CSS 文件。您还可以使用 context.BundleDefinitions 集合删除或更改现有文件(由您的应用程序依赖的包添加),但这很少需要。在这里,excludeFromBundle 参数将 main.css 文件单独添加到全局包之外。您可以移除该参数以将其包含在 global.css 包文件中。

类似于样式包,index.html 文件包含一个 ABP:Scripts 部分,如下面的代码块所示:

<!--ABP:Scripts-->
<script src="img/global.js?_v=637680281013693676"></script>
<!--/ABP:Scripts-->

再次强调,这段代码部分是由 ABP CLI 使用 abp bundle 命令创建(并更新)的。如果您想包含文件,可以在您的包贡献者类的 AddScripts 方法中完成。文件的路径被认为是相对于 wwwroot 文件夹的相对路径。

摘要

本章简要介绍了 ABP 框架的 Blazor UI,以了解其架构以及您将在应用程序中频繁使用的服务。

认证是应用程序中最具挑战性的方面之一,ABP 提供了一个行业标准解决方案,您可以直接在您的应用程序中使用。

我们已经学习了获取当前用户身份信息的服务、检查用户的权限以及本地化用户界面的服务。我们还探索了向用户显示消息框、通知和警报的服务。

ABP 的动态 C# 客户端代理系统使得消费服务器端 HTTP API 变得非常容易。最后,您已经学习了如何使用全局打包系统来处理 Blazor 应用程序中的打包和压缩。

我故意在本章中没有涵盖两个主题。第一个是 Blazor 本身。在本书的单章中涵盖这个非常详细的主题是不够的。如果您是 Blazor 框架的新手,我建议您阅读微软的文档(docs.microsoft.com/en-us/aspnet/core/blazor)或购买一本专门的书籍。您可以查看由 Packt Publishing 出版的由 Jimmy Engström 编写的《Web Development with Blazor》一书。

本章中我尚未涉及的第二主题是复杂的用户界面组件,例如数据表、模态框和标签页。这些组件非常特定于你使用的 UI 工具包。ABP 自带 Blazorise 库,你可以参考其文档来学习其组件:blazorise.com/docs。我还建议你通过 ABP 框架的 Blazor UI 教程来了解使用最常用组件(数据表和模态框)的基本开发模型:docs.abp.io/en/abp/latest/Getting-Started

在下一章中,我们将专注于构建 HTTP API 并在客户端应用程序中消费它们。我们还将探讨使用 SignalR 库来实现客户端和服务器之间的实时通信。

第十四章:第十四章: 构建 HTTP API 和实时服务

暴露 HTTP API 端点是允许客户端应用程序消费你的应用程序功能的一种相当常见的方式。构建 HTTP API 使你的应用程序对任何客户端都开放,因为几乎所有连接到网络的设备都已经实现了 HTTP 协议。

在本章中,你将了解为你的解决方案创建 HTTP API 的选项。你还将看到 ABP 如何通过使用 ABP 的动态和生成的客户端代理,使客户端应用程序轻松消费你的 HTTP API。最后,我们将解释如何在 ABP 应用程序中使用 Microsoft 的SignalR库来实现实时服务器-客户端通信。以下是本章涵盖的主题列表:

  • 构建 HTTP API

  • 消费 HTTP API

  • 在 ABP 框架中使用 SignalR

技术要求

如果你想跟随本章中的示例,你需要有一个支持 ASP.NET Core 开发的 IDE/编辑器。在某些地方,我们将使用 ABP CLI,因此你需要安装 ABP CLI,如第二章中所述,ABP 框架入门

你可以从以下 GitHub 仓库下载示例应用程序:github.com/PacktPublishing/Mastering-ABP-Framework。它包含本章中给出的一些示例。

构建 HTTP API

在本节中,我们将从 ASP.NET Core 创建 HTTP API 的标准方法开始。然后我们将看到 ABP 如何自动将标准应用程序服务转换为 HTTP API 端点。但首先,让我们看看如何使用 ABP 框架创建仅 API 的解决方案。

创建 HTTP API 项目

当你使用 ABP 框架的启动解决方案模板创建一个新的应用程序或模块时,它已经包含了应用程序提供的所有功能的 HTTP API。然而,如果你想要的话,也可以创建一个没有应用程序 UI 的 HTTP API 端点。

当你使用 ABP 框架创建新的解决方案时,可以使用-u none参数,如下例所示:

abp new ApiDemo -u none

ApiDemo是我们这里的解决方案名称。这样,我们就有一个带有 HTTP API 端点但没有 UI 的解决方案。以下图显示了在 Visual Studio 中打开的解决方案:

图 14.1 – 由 ABP CLI 创建的 HTTP API 解决方案

图 14.1 – 由 ABP CLI 创建的 HTTP API 解决方案

我们应该首先运行ApiDemo.DbMigrator应用程序来创建数据库,这样 HTTP API 才能正常工作。为此,右键单击ApiDemo.DbMigrator项目,点击ApiDemo.DbMigrator项目并执行dotnet run命令。

现在,你可以运行ApiDemo.HttpApi.Host项目来启动 HTTP API 应用程序。默认情况下,HTTP API 应用程序显示 Swagger UI,如下图所示:

图 14.2 – Swagger UI

图 14.2 – Swagger UI

Swagger UI 是一个非常有用的工具,可以探索和测试我们的 HTTP API 端点。我们可以使用 admin,默认密码是 1q2w3E*),因此我们也可以测试需要授权的 API。

例如,我们可以使用 /api/identity/roles 端点来获取系统中定义的角色列表。此端点需要授权,因此首先使用 Role 组下的 /api/identity/roles 端点登录,点击展开它,点击 Try it out 按钮,然后点击 Execute 按钮来调用端点。当您调用它时,服务器返回如下示例所示的 JSON 值:

{
  "totalCount": 1,
  "items": [
    {
      "name": "admin",
      "isDefault": false,
      "isStatic": true,
      "isPublic": true,
      "concurrencyStamp": 
          "1f23ae3a-85d8-4656-b094-00e605e28e4e",
      "id": "92692d73-4acb-ca9f-4838-39ff4cdf25e4",
      "extraProperties": {}
    }
  ]
}

因此,我们已经学习了如何使用 ABP 框架创建和启动 HTTP API 解决方案。现在,让我们看看如何使用 ASP.NET Core 的标准控制器添加新的 API。

创建 ASP.NET Core 控制器

ASP.NET Core 的控制器提供了一个方便的基础设施来创建 HTTP API。以下示例公开了获取产品列表和更新产品的 HTTP 端点:

[ApiController]
[Route("products")]
public class ProductController : ControllerBase
{
    [HttpGet]
    public async Task<ProductDto> GetListAsync()
    {
        // TODO: implement
    }
    [HttpPut]
    [Route("{id}")]
    public async Task UpdateAsync(Guid id, ProductUpdateDto
                                  input)
    {
        // TODO: implement
    }
}

ProductController 类继承自 ControllerBase 类。建议将您的 API 控制器类从 ControllerBase 类继承,而不是从 Controller 类继承,因为 Controller 类包含一些对于 API 控制器不必要的视图相关功能。或者,您也可以从 AbpControllerBase 类继承您的 API 控制器类,它为您提供了作为预注入属性的某些常见 ABP 服务。

在控制器类顶部添加 [ApiController] 属性启用了 ASP.NET Core 的默认 API 特定行为(例如自动 HTTP 400 响应和属性路由要求),因此也建议这样做。

在此示例中,[Route] 属性定义了 API 的 URL,而 HttpGetHttpPut 属性确定了与 API 端点关联的 HTTP 方法。

ABP 与 ASP.NET Core 的标准结构 100% 兼容,因此您可以参考 Microsoft 的文档来学习创建 API 控制器的所有细节:docs.microsoft.com/en-us/aspnet/core/web-api

当您在解决方案中实现分层时,您通常会发现自己正在创建控制器类,这些类封装了您的应用程序服务。例如,假设您有 IProductAppService,它已经实现了与产品相关的用例,并且您希望将其方法公开为 HTTP API 端点。以下示例定义了一个控制器,它将所有请求重定向到底层应用程序服务:

[ApiController]
[Route("products")]
public class ProductController : ControllerBase
{
    private readonly IProductAppService _productAppService;
    public ProductController(
        IProductAppService productAppService)
    {
        _productAppService = productAppService;
    }
    [HttpGet]
    public async Task<ProductDto> GetListAsync()
    {
        return await _productAppService.GetListAsync();
    }
    [HttpPut]
    [Route("{id}")]
    public async Task UpdateAsync(Guid id, 
                                  ProductUpdateDto input)
    {
        await _productAppService.UpdateAsync(id, input);
    }
}

如果我们没有使用 ABP 框架,我们需要编写这样的控制器来定义端点的路由、HTTP 方法和其他 HTTP 相关的细节。然而,ABP 框架可以自动将您的应用程序服务公开为 HTTP API 端点,如下一节所述。

理解自动 API 控制器

ABP 的 Auto API Controller 系统通过约定将你的应用程序服务转换为 API 控制器。要启用 Auto API 控制器,我们应该按照以下代码块所示配置AbpAspNetCoreMvcOptions

Configure<AbpAspNetCoreMvcOptions>(options =>
{
    options.ConventionalControllers.Create(
        typeof(ApiDemoApplicationModule).Assembly);
});

该配置代码位于解决方案的 UI 或 HTTP API 层(options.ConventionalControllers.Create方法的ApiDemoHttpApiHostModule类中,它接受一个Assembly对象,在该Assembly中查找所有应用程序服务类,并使用预定义的约定将它们公开为控制器。当你从启动模板创建一个新的 ABP 解决方案时,你的解决方案中已经包含了该配置,因此你不需要自己进行配置。

假设我们已经定义了一个应用程序服务,如下例所示:

public class ProductAppService
    : ApiDemoAppService, IProductAppService
{
    public Task<ProductDto> GetListAsync()
    {
        // TODO: implement
    }
    public Task UpdateAsync(Guid id, 
                            ProductUpdateDto input)
    {
        // TODO: implement
    }
}

请记住,ProductAppService类定义在ApiDemo.Application项目中,而IProductAppService接口定义在ApiDemo.Application.Contracts项目中。我们可以不进行任何额外配置就运行应用程序,以在 Swagger UI 上查看新的 HTTP API 端点:

![图 14.3 – Swagger UI 上的 Auto API Controller

![img/Figure_14.03_B17287.jpg]

图 14.3 – Swagger UI 上的 Auto API Controller

ABP 框架配置了 ASP.NET Core,使ProductAppService成为控制器。ABP 框架通过相关 C#方法的名称自动确定 HTTP 方法。例如,以Get前缀开头的方法被认为是 HTTP GET 方法。路由也通过约定自动确定。你可以参考 ABP 文档来了解 HTTP 方法和路由确定的所有约定和自定义选项:docs.abp.io/en/abp/latest/API/Auto-API-Controllers

何时手动定义控制器

当你使用 ABP 框架时,通常不需要手动定义 API 控制器。然而,如果你想这样做,你仍然可以以标准方式编写控制器。编写手动控制器的一个优点是,你可以充分利用 HTTP 层的能力来定义和塑造你的 API。

ABP 框架将配置的程序集中的所有应用程序服务转换为 API 控制器。如果你想要为特定的应用程序服务禁用此功能,可以使用带有false参数的[RemoteService]属性,如下例所示:

[RemoteService(false)]
public class ProductAppService
     : ApiDemoAppService, IProductAppService
{ /* ... */ }

ABP 框架还启用了 ASP.NET Core 的应用程序服务 API 探索器功能。这样,你的 API 端点就变得可发现,并在 Swagger UI 上显示。如果你想公开 HTTP 端点但禁用 API 探索器,可以将[RemoteService]属性的IsMetadataEnabled参数设置为false,例如,[RemoteService(IsMetadataEnabled = false)]

正如我们在本节所学,ABP 可以自动化将你的应用程序服务暴露给远程客户端,同时你仍然可以使用现有的技能在需要时创建标准的 ASP.NET Core 控制器。在下一节中,我们将探讨从客户端应用程序消费 HTTP API 的方法。

消费 HTTP API

从你的客户端应用程序消费 HTTP API 通常需要大量的常见和重复的逻辑来应用。你需要在每个发送到服务器的 HTTP 请求中处理授权、对象序列化、异常处理等。ABP 框架可以通过动态和生成的(静态)客户端代理完全自动化这个过程。

我们已经在 第十二章消费 HTTP API 部分、与 MVC/Razor Pages 一起工作 以及在 第十三章消费 HTTP API 部分中介绍了 ABP 客户端代理系统的实际用法,与 Blazor WebAssembly UI 一起工作。因此,这里不再重复,但会将所有内容汇总在一起,并填补缺失的要点。

让我们从动态客户端代理开始。

使用 ABP 的动态客户端代理

动态代理系统允许我们通过简单的配置来消费服务器端 HTTP API。动态 名称表明代理代码是在运行时动态生成的。

ABP 的动态客户端代理系统支持两种类型的客户端应用程序:.NET 和 JavaScript。

使用动态 .NET 客户端代理

ABP 的启动解决方案将应用程序层分为两个项目。以 Application.Contracts 结尾的项目包含接口,而 Application 包含这些接口的实现。以下图显示了示例解决方案中 Application.ContractsApplication 项目内的 IProductAppService 接口和 ProductAppService 类:

Figure 14.4 – The application layer, separated into two projects

Figure 14.04_B17287.jpg

图 14.4 – 将应用程序层分离成两个项目

将契约与实现分离具有优势:我们可以从 .NET 客户端应用程序重用 ApiDemo.Application.Contracts 项目,而无需让客户端应用程序引用应用程序服务的实现。

一个 .NET 客户端应用程序可以引用 ApiDemo.Application.Contracts 项目,并配置 ABP 的动态 .NET 客户端代理系统,以便能够消费 HTTP API,就像消费本地服务一样。以下示例显示了这种配置,该配置在客户端应用程序中完成(该配置存在于 ApiDemo.HttpApi.Client 项目的 ApiDemoHttpApiClientModule 类中):

public override void ConfigureServices(
    ServiceConfigurationContext context)
{
    context.Services.AddHttpClientProxies(
        typeof(ApiDemoApplicationContractsModule).Assembly
    );
}

AddHttpClientProxies方法接受一个Assembly,并为该Assembly中的所有应用程序服务接口创建动态代理。在这里,我们通过使用其中的模块类来传递ApiDemo.Application.Contracts项目的程序集。当你创建一个新的 ABP 解决方案时,你将在HttpApi.Client项目中看到这个配置。因此,任何引用了HttpApi.Client项目的.NET 客户端应用程序都可以直接消费你的 HTTP API,而无需任何配置。

使用如此简单的单一配置,我们就可以将任何应用程序服务接口注入到ApiDemo.Application.Contracts项目中,并像使用本地服务一样使用它。例如,在第十三章使用 Blazor WebAssembly UI部分,可以看到在 Blazor WebAssembly 客户端应用程序中的使用示例。

一旦我们配置并使用了动态.NET 客户端代理,ABP 将执行所有繁重的逻辑,并为我们向服务器发起 HTTP 请求。当然,ABP 应该知道服务器的根 URL 来发起请求。我们可以在客户端应用程序的appsettings.json文件中定义它,如下面的示例所示(在ApiDemo.HttpApi.Client.ConsoleTestApp项目中有一个如何操作的示例):

{
  "RemoteServices": {
    "Default": {
      "BaseUrl": "http://localhost:53929/"
    } 
  } 
}

如你从本例中可以理解的,我们实际上可以定义多个服务器端点。这样,客户端应用程序就可以从多个服务器消费 API。默认情况下使用的是Default配置。你可以在以下示例中添加第二个远程服务配置:

{
  "RemoteServices": {
    "Default": {
      "BaseUrl": "http://localhost:53929/"
    },
    "BookStore": {
      "BaseUrl": "http://localhost:48392/"
    } 
  } 
}

然后,你应该将remoteServiceConfigurationName参数指定给AddHttpClientProxies方法以映射配置:

context.Services.AddHttpClientProxies(
    typeof(BookStoreApplicationContractsModule).Assembly,
    remoteServiceConfigurationName: "BookStore"
);

你可以在动态客户端代理的失败时添加重试逻辑。请参阅文档以获取更多配置选项:docs.abp.io/en/abp/latest/API/Dynamic-CSharp-API-Clients

ABP 框架从服务器应用程序提供了一个特殊的 API 端点,该端点将 API 定义暴露给客户端。该端点包含应用程序服务合约与 HTTP API 端点之间的映射。该端点的 URL 在服务器上为/api/abp/api-definition。客户端应用程序首先读取该 API 定义端点,以了解如何向服务器发起 HTTP 调用。

正如你所看到的,ABP 使得从.NET 客户端消费 HTTP API 变得极其简单。在下一节中,我们将探讨在 JavaScript 客户端中消费 HTTP API。

使用动态 JavaScript 客户端代理

与.NET 动态客户端代理类似,ABP 动态创建代理以从 JavaScript 应用程序中消费您的 HTTP API 端点。ABP 框架提供了一个特殊的端点,该端点返回一个包含所有 HTTP API 端点代理函数的 JavaScript 文件。该端点的 URL 是/Abp/ServiceProxyScript。这个 URL 已经被当前主题添加到应用程序布局中,因此您可以直接消费 HTTP API。

以下代码块是服务代理脚本端点的一部分,其中包含我们之前在本章理解自动 API 控制器部分创建的ProductAppService类的代理函数:

apiDemo.products.product.getList = function(ajaxParams) {
  return abp.ajax($.extend(true, {
    url: abp.appPath + 'api/app/product',
    type: 'GET'
  }, ajaxParams));
};
apiDemo.products.product.update = 
    function(id, input, ajaxParams) {
  return abp.ajax($.extend(true, {
    url: abp.appPath + 'api/app/product/' + id + '',
    type: 'PUT',
    dataType: null,
    data: JSON.stringify(input)
  }, ajaxParams));
};

正如您在示例中看到的,ABP 框架为ProductAppService类的每个方法创建了两个 JavaScript 函数。例如,我们可以调用getList函数来获取产品列表,如下面的示例所示:

apiDemo.products.product.getList()
  .then(function(result){
    // TODO: Process the result...
  });

这就这么简单!授权、验证、异常处理、CSRF跨站请求伪造)以及其他细节都由 ABP 框架处理。结果值将是服务器返回的产品列表(数组)。您可以在第十二章使用 MVC/Razor 页面部分查看使用动态客户端代理部分,以获取更多示例和相关信息。

在下一节中,我们将探讨使用动态客户端代理的另一种方法。

使用 ABP 的静态(生成)客户端代理

动态代理系统完全自动化代理生成,以从客户端应用程序中消费 HTTP 端点。它根据动态获取的端点配置在运行时生成代码。

另一方面,ABP v5.0 附带的静态客户端代理系统不需要在运行时获取 API 定义,因为它在开发时生成客户端代理代码。静态代理系统的缺点是,每当服务器 API 发生变化时,您需要重新生成客户端代理代码。然而,由于代码生成是在开发时完成的,并且不需要运行时信息,因此静态代理比动态代理稍微快一些。

在某些场景下,例如当您的客户端消费位于 API 网关后面的多个微服务的 HTTP API 时,动态客户端代理系统无法直接工作,因为 API 网关无法从单个端点组合并返回所有微服务的 API 定义。在这种情况下,使用在开发时生成的静态客户端代理可以节省我们很多麻烦。

在任何情况下,如果您想使用静态客户端代理,您可以使用 ABP CLI 生成客户端代码。下一节将展示如何使用 ABP CLI 生成静态 C#客户端代理代码。

生成静态 C#客户端代理

为了创建静态代理,客户端应用程序/项目应该引用服务器定义的应用程序服务接口,因为客户端代理实现了相同的接口,并且像动态代理一样使用。因此,在实际操作中,客户端应用程序应该引用目标应用程序的Application.Contracts项目。

当我们使用 ABP CLI 生成代理类时,服务器应用程序应该正在运行,因为 ABP CLI 从服务器获取 API 定义。一旦服务器启动并运行,请在客户端应用程序/项目的根目录中使用generate-proxy命令,如下例所示:

abp generate-proxy -t csharp -u https://localhost:44367

https://localhost:44367是本例中服务器应用程序的 URL。-t参数指定客户端语言,本例中为csharp

以下图显示了运行generate-proxy命令后新添加的项目文件:

![图 14.5 – 生成的客户端代理文件

![图 14.5 – 生成的客户端代理文件

图 14.5 – 生成的客户端代理文件

首先,ABP CLI 添加了app-generate-proxy.json文件,该文件包含从https://localhost:44367/api/abp/api-definition端点获取的 API 定义(本例中)。然后 ABP 框架使用此文件获取有关 API 端点的信息并执行适当的 HTTP 调用。

ProductClientProxy.Generated.cs文件包含代理类,该类实现了本例中的IProductAppService接口。这样,我们可以将IProductAppService接口注入到任何类中,并像本地服务一样使用它。ABP 为我们执行必要的 HTTP API 调用。

ProductClientProxy.cs是一个部分类,用于添加您的附加方法和自定义类。每当您执行generate-proxy命令时,ProductClientProxy.Generated.cs文件都会被重新生成,因此如果您编辑该类,您的更改将被覆盖。然而,ProductClientProxy.cs文件可以安全地编辑,因为 ABP 不会再修改它。它留给了您来自定义类。

在下一节中,我们将生成 JavaScript 代理以从浏览器应用程序消费 HTTP API。

生成静态 JavaScript 客户端代理

ABP CLI 可以为 JavaScript 客户端生成 HTTP API 客户端代理,就像.NET 客户端一样。我们可以将-t参数指定为js以生成 JavaScript 代码:

abp generate-proxy -t js -u https://localhost:44367

JavaScript 客户端代理系统基于 jQuery,与 ABP 的 MVC/Razor Pages UI 兼容。我们已经在第十二章使用静态客户端代理部分中看到了 JavaScript 客户端代码生成的用法,与 MVC/Razor Pages 一起工作。请参考该章节以记住其用法。

生成静态 Angular 客户端代理

虽然本书没有涵盖,但 ABP 提供了一流的 Angular UI 集成选项。ABP CLI 的 generate-proxy 命令也原生支持 Angular UI。您可以将 -t 参数指定为 ng 以生成 Angular 的 TypeScript 代理代码:

abp generate-proxy -t ng -u https://localhost:44367

ABP CLI 在 Angular 端创建服务和 DTO 类,因此您可以直接注入代理并消费 HTTP API,而无需处理低级 HTTP 细节。请参阅 ABP 文档了解有关 Angular 客户端代理的更多信息:docs.abp.io/en/abp/latest/UI/Angular/Service-Proxies

为其他客户端类型生成代理

ABP 为其支持的客户端类型提供客户端代理生成。建议使用 ABP CLI 的代码生成功能来生成支持的客户端类型的代码。然而,您可能在客户端使用另一种类型的语言、框架或库,并可能希望生成客户端代理而不是手动编写它们。在这种情况下,您可以使用支持您平台的其他工具,因为 ABP 启动解决方案与 Swagger/OpenAPI 规范兼容。有许多工具可以读取 Swagger/OpenAPI 规范并为您生成客户端代理代码。例如,NSwag 工具可以为许多不同的语言生成客户端代理。

我们已经学习了如何使用 ABP 框架从我们的客户端应用程序中消费服务器端 HTTP API。在下一节中,我们将学习如何使用微软的 SignalR 库与服务器建立实时通信通道。

使用 ABP 框架与 SignalR 一起使用

构建 REST 风格的 HTTP API 便于从客户端应用程序消费服务器端功能。然而,它有一定的局限性——只有客户端应用程序可以调用服务器 API,而服务器通常不能在客户端启动操作。WebSocket 技术使得在浏览器和服务器之间建立双向通信通道成为可能,以便独立地相互发送消息。因此,使用 WebSocket,服务器可以通知浏览器,发送数据,并在应用程序上触发操作。

SignalR 是微软的一个库,它运行在 WebSocket 技术上,并通过抽象 WebSocket 细节简化了服务器和客户端之间的通信。您可以直接从服务器调用客户端上定义的方法,反之亦然。

ABP 框架对 SignalR 的贡献不大,因为使用它已经很方便了。然而,它提供了一个简单的集成包,可以自动化一些常见的任务。在接下来的两个部分中,我们将了解如何在解决方案中安装和配置 SignalR。让我们从 ABP 的服务器端 SignalR 集成包开始。

使用 ABP SignalR 集成包

Volo.Abp.AspNetCore.SignalR 是将 SignalR 库添加到您的服务器端 ABP 应用程序的 NuGet 包。您可以使用 ABP CLI 安装它。在您想要添加服务器端 SignalR 端点的项目的根目录中打开命令行终端,并执行以下命令:

abp add-package Volo.Abp.AspNetCore.SignalR

ABP CLI 会为您安装 NuGet 包并添加 ABP 模块依赖项。它还将 SignalR 添加到依赖注入并配置网关端点。因此,安装后不需要额外的配置。接下来的几节将解释如何创建 SignalR 网关以及当需要时如何进行额外配置。

创建网关

SignalR 网关用于创建一个高级管道来处理客户端-服务器通信。您应该定义至少一个网关来使用 SignalR。创建网关相当简单;只需定义一个从 Hub 基类派生的新的类:

public class MessagingHub : Hub
{
}

ABP 会自动将网关注册到依赖注入系统中并配置端点映射。此示例网关的 URL 将是 /signalr-hubs/messaging。网关 URL 以 /signalr-hubs/ 开头,接着是去掉 Hub 后缀的网关类名转换为 kebab-case。您可以在网关类顶部使用 [HubRoute] 属性来指定不同的 URL,如下面的示例所示:

[HubRoute("/the-messaging-hub")]
public class MessagingHub : Hub
{
    //...
}

作为 Hub 类的替代方案,您可以从 AbpHub 类继承您的网关。AbpHub 类提供了一些常见服务(如 ICurrentUserILoggerIAuthorizationService)作为预注入的基本属性,因此您不需要手动注入它们。

配置网关

ABP 会自动映射您的网关并执行基本配置。如果您想自定义网关配置,您可以在模块类的 ConfigureServices 方法中完成,如下面的示例所示:

Configure<AbpSignalROptions>(options =>
{
    options.Hubs.AddOrUpdate(
        typeof(MessagingHub),
        config => //Additional configuration
        {
            config.RoutePattern = "/the-messaging-hub";
            config.ConfigureActions.Add(hubOptions =>
            {
                hubOptions.LongPolling.PollTimeout =
                    TimeSpan.FromSeconds(30);
            });
        }
    );
});

此示例配置了 MessagingHub,设置了自定义路由,并更改了 LongPolling 选项。

使用 ABP SignalR 集成包并不会增加太多价值,但它简化了在 ABP 应用程序的服务器端集成和配置 SignalR 库。在下一节中,我们将看到从客户端应用程序连接到 SignalR 网关的方法。

配置 SignalR 客户端

从客户端应用程序连接到 SignalR 网关取决于您的客户端类型。在本节中,我将解释如何将 SignalR 客户端库安装到具有 ASP.NET Core MVC UI 的 ABP 应用程序中。有关其他客户端类型(如 TypeScript 或 .NET 客户端)的说明,请参阅 Microsoft 的文档:docs.microsoft.com/en-us/aspnet/core/signalr

要在具有 ASP.NET Core MVC UI 的 ABP 应用程序中安装 SignalR,首先,使用以下命令将 @abp/signalr NPM 包添加到您的 Web 项目中:

npm install @abp/signalr

此命令将安装包并更新 Web 项目的 package.json 文件。然后,你应该运行 ABP CLI 的 install-libs 命令,将 SignalR 的 JavaScript 文件复制到你的项目 wwwroot/libs 文件夹下。

安装完成后,你可以通过导入 SignalR 的 JavaScript 文件在你的页面中使用 SignalR。你可以使用 ABP 的 abp-script 标签助手与预定义的 SignalR 包贡献者一起使用,如下例所示:

@using Volo.Abp.AspNetCore.Mvc.UI.Packages.SignalR
@section scripts {
    <abp-script type=
        "typeof(SignalRBrowserScriptContributor)" />
}

使用 SignalRBrowserScriptContributor 是建议的方法,因为它总是从正确的路径添加正确的版本的脚本文件,所以当你升级 SignalR 包时,你不需要更改它。

使用 ABP 框架与 SignalR 没有区别于在常规 ASP.NET Core 应用程序中使用它。所以,如果你是 SignalR 的新手,请参阅 Microsoft 的文档:docs.microsoft.com/en-us/aspnet/core/signalr。你还可以在 ABP 的官方示例中找到一个完全工作的示例:docs.abp.io/en/abp/latest/Samples/Index

摘要

在本章中,你已经学习了使用 ABP 框架和 ASP.NET Core 的不同服务器-客户端通信方法。ABP 框架尽可能自动化这种通信。

我们首先使用标准的 ASP.NET Core 控制器创建了 REST 风格的 HTTP API,并学习了 ABP 如何使用应用程序服务自动创建这样的控制器。

然后,我们探讨了从不同客户端消费 HTTP API 的各种方法。当你使用 ABP 框架的动态或静态客户端代理时,从客户端应用程序调用服务器端 API 变得非常简单。虽然你可以始终走自己的路,但使用完全集成的客户端代理是消费你自己的 HTTP API 的最佳方式。

最后,我们看到了如何使用预构建的集成包在你的 ABP 应用程序中安装 SignalR。SignalR,结合 WebSocket 技术,使得在服务器和客户端之间建立双向通信通道成为可能,因此服务器也可以在需要时向客户端发送消息。

在下一章中,我们将学习 ABP 框架中最强大的结构之一:模块化应用程序开发。

第五部分:杂项

本部分包含各种主题,包括 ABP 框架提供的基础设施。您将了解 ABP 如何处理模块化,以及如何通过参考示例案例来创建自己的模块。您将探索并理解 ABP 的多租户基础设施,该基础设施用于创建 SaaS 应用程序。在最后一章中,您将学习如何使用 ABP 框架创建单元和集成测试。

在本部分中,我们包括以下章节:

  • 第十五章与模块化协作

  • 第十六章实现多租户

  • 第十七章构建自动化测试

第十五章:第十五章:使用模块化

让我在本章的开头声明——模块化应用程序开发是一项艰巨的工作!我们希望将大型系统拆分成更小的模块并将它们彼此隔离。然而,在集成这些模块并使它们相互通信时,我们将会遇到困难。

ABP 框架的一个基本设计目标是模块化。它提供了构建真正模块化系统的必要基础设施。

本章将从模块化的含义和 .NET 平台的模块化级别开始。在章节的大部分内容中,我们将探讨我为 EventHub 参考解决方案构建的 Payment 模块。我们将学习模块的结构、应用程序模块开发的关键点以及如何将模块安装到主应用程序中。

本章包括以下主要主题:

  • 理解模块化

  • 构建 Payment 模块

  • 将 Payment 模块安装到 EventHub

技术要求

您可以从 GitHub 上克隆或下载 EventHub 项目的源代码:github.com/volosoft/eventhub.

如果你想跟随本章的示例,你需要有一个支持 ASP.NET Core 开发的 IDE/编辑器。

最后,如果你想使用 ABP CLI 创建模块,你应该按照 第二章安装 ABP CLI 部分的说明,在你的计算机上安装它。

理解模块化

可重用性:构建一个模块并在多个应用程序中重用它,可以减少代码重复并节省时间。

模块化是一种软件设计技术,用于将大型解决方案的代码库分解成更小、独立的模块,然后可以独立开发。模块化应用程序开发背后的两个主要原因是:

  • 降低复杂性:将大型代码库拆分成更小、独立的模块集合,使得开发和维护解决方案变得容易。

  • 模块是软件行业中用得最多、最复杂的概念之一。在本节中,我想解释在 .NET 和 ABP 框架中我所说的模块化是什么。

在接下来的几节中,我将从技术和设计角度讨论两种不同的模块化级别:类库(NuGet 包)和应用模块。让我们从类库开始。

类库和 NuGet 包

大多数编程语言和框架都有模块的概念。一般来说,模块是一组代码文件(类和其他资源),它们被开发和一起分发(部署)。

一个模块为更大的应用程序提供一些组件和服务。一个模块可能依赖于其他模块,并可以使用依赖模块提供的组件和服务。

在.NET 中,组件(assembly)是创建模块的一种典型方式。我们可以创建一个类库项目,然后在其他库和应用程序中使用它。我们可以为类库创建 NuGet 包,并在NuGet.org上公开发布。如果库不是公开的,我们可以在自己的公司中托管一个私有 NuGet 服务器。NuGet 包系统使得将库添加到项目中变得极其容易。在NuGet.org上已经发布了成千上万的包。

ABP 框架本身被设计成模块化的。它由数百个 NuGet 包组成;每个包都为您的应用程序提供不同的基础设施功能。一些示例包包括Volo.Abp.ValidationVolo.Abp.AuthorizationVolo.Abp.CachingVolo.Abp.EntityFrameworkCoreVolo.Abp.BlobStoringVolo.Abp.AuditingVolo.Abp.Emailing。您可以在应用程序中使用您需要的任何包。

您可以参考第五章理解模块化部分,探索 ASP.NET Core 和 ABP 基础设施,以了解基于包的 ABP 模块。下一节将讨论应用程序模块,通常由多个包(类库项目)组成。

应用程序模块

我们可以将应用程序模块视为应用程序的一个垂直切片。应用程序模块具有以下属性:

  • 定义一些业务对象(例如,聚合、实体和值对象)

  • 实现它定义的业务对象所用的业务逻辑

  • 为业务对象提供数据库集成和映射

  • 包含应用程序服务、数据传输对象和 HTTP API(控制器)

  • 可以包含与它提供的功能相关的用户界面组件和页面

  • 可能需要在 UI 的应用程序菜单、布局或工具栏中添加新项目

  • 发布和消费分布式事件

  • 可能具有更多功能和您期望从常规应用程序中获得的其他详细信息

根据您的需求和目标,应用程序模块有几种隔离级别。以下列出了四个常见示例:

  • 紧密耦合的模块:一个模块可以是具有单个数据库的大型单体应用程序的一部分。您可以使用该模块的实体和服务在其他模块中使用,并通过连接该模块的表执行数据库查询。这样,您的模块就紧密耦合在一起了。

  • 边界上下文:一个模块可以是大型单体应用程序的一部分,但它隐藏其内部领域对象和数据库表,使其对其他模块不可见。其他模块只能使用其集成服务并订阅该模块发布的事件。它们不能在 SQL 查询中使用该模块的数据库表。该模块甚至可能使用不同类型的 DBMS 来满足其特定需求。这就是领域驱动设计中的边界上下文模式。如果将来您想将单体应用程序转换为微服务解决方案,这样的模块是转换为微服务的良好候选。

  • 通用模块:通用模块被设计为与应用程序无关。它们可以集成到不同类型的应用程序中。使用通用模块的应用程序可能依赖于该模块的一些功能,并且可能需要一些集成代码。通用模块可能提供一些选项和自定义点,但不对最终应用程序做出假设。例如,身份管理和多语言模块等基础设施模块属于此类。此外,在构建支付模块部分中解释的支付模块也是一个通用模块。

  • 插件模块:插件模块是一个完全隔离且可重用的应用程序模块。其他模块对该模块没有直接依赖。您可以轻松地将此模块添加到现有解决方案中或从现有解决方案中移除,而不会影响其他模块和您的应用程序。如果其他模块需要使用该模块,它们将使用共享库中提供的某些标准抽象。在这种情况下,该模块实现了这些抽象,可以被实现相同抽象的另一个模块所替换。即使其他模块以某种方式使用该模块,在移除该模块时,它们也可以继续按预期工作。这意味着该模块应该是可选的并且可以移除的,以便于应用程序。

ABP 框架的主要目标之一是提供一个方便的基础设施来开发任何类型的应用程序模块。它提供了构建真正模块化系统所需的基础设施详细信息。它还提供了一些预构建的应用程序模块,您可以直接在您的应用程序中使用。以下是一些示例:

  • 账户模块提供认证功能,例如登录、注册、忘记密码和社交登录集成。

  • 身份模块管理您系统中的用户、角色及其权限。

  • 租户管理模块允许您在 SaaS/多租户系统中创建和管理租户。

  • CMS Kit模块可用于将基本内容管理系统(CMS)功能添加到您的应用程序中,例如页面、标签、评论和博客。

账户、身份、租户管理和一些其他模块在创建新的 ABP 解决方案时预安装(作为 NuGet 包)。

所有预构建的模块都设计为可扩展和可定制的。然而,如果你需要根据你的需求完全更改一个模块,总是可以下载模块的源代码并将其包含在你的解决方案中。

在下一节中,我们将看到如何使用自己的实体、服务和页面构建一个新的应用程序模块。

构建Payment模块

你已经可以调查预构建的 ABP 模块的源代码,看看它们是如何构建和在你的应用程序中使用的。我建议这样做,因为你可以看到模块化开发的不同的实现细节。然而,本节将探讨Payment模块,它已被创建为本书的一个简单但真实的示例。它被 EventHub 解决方案用于当组织想要升级到高级账户时接收支付。在书中不可能展示该模块的逐步开发过程。我们将研究基本点,以便你能够理解模块结构并构建你自己的模块。让我们从创建一个新的应用程序模块开始。

创建一个新的应用程序模块

ABP CLI 的new命令提供了一个选项来创建一个新的解决方案以构建可重用的应用程序模块。请看以下示例:

abp new Payment -t module

我指定使用模块模板(-t module),模块名称为Payment。如果你打开解决方案,你会看到解决方案结构,如下面的图所示:

图 15.1 – 由 ABP CLI 创建的新应用程序模块

图 15.1 – 由 ABP CLI 创建的新应用程序模块

模块启动模板包含太多的项目,因为它支持多个 UI 和数据库选项,并包含一些测试/演示项目。让我们删除一些项目:

  • host文件夹中的项目是一些演示应用程序,用于在不同的架构选项中运行模块。这些项目不是模块的一部分,只是用于手动测试。我们将把这个模块安装到 EventHub 解决方案中并在那里测试它,所以我删除了所有主机项目。

  • 我删除了Blazor.*项目,因为我的主要 UI 将是 MVC/Razor Pages。

  • 我删除了与 MongoDB 相关的项目,因为我只想用我的模块支持 EF Core。

  • 最后,我删除了angular文件夹(在图 15.1中没有显示),因为我不想为这个模块使用 Angular UI。

清理后,模块解决方案中有 12 个项目。其中四个用于单元和集成测试,因此该模块由八个将部署的项目组成。这八个项目是类库项目,因此它们不能单独运行。它们需要由一个可执行应用程序使用,例如 EventHub:

图 15.2 – 清理后的模块

图 15.2 – 清理后的Payment模块

这种解决方案结构和层在第九章理解领域驱动设计根据 DDD 结构化.NET 解决方案部分中已经解释过了。所以,这里我不会重复全部内容。然而,我们将改变这个结构,因为我们想为支付模块提供多个应用层。

重新构建支付模块解决方案

我们将把这个支付模块安装到 EventHub 解决方案中。记得从第四章理解参考解决方案,中了解到 EventHub 解决方案有两个 UI 应用程序:

  • 一个面向最终用户的公共网站,用户可以通过它创建和参加活动。该应用程序具有 MVC/Razor Pages UI。

  • 一个由 EventHub 系统的管理用户使用的管理 Web 应用程序。该应用程序是一个 Blazor WebAssembly 应用程序。

为了支持相同的架构,我们将为支付模块提供两个 UI 应用层:

  • 一个包含 MVC/Razor Pages UI 的应用层,该 UI 被 EventHub 公共网站使用。最终用户将通过该 UI 进行支付。

  • 一个包含 Blazor WebAssembly UI 的应用层,该 UI 被 EventHub 管理应用程序使用。管理用户将通过该 UI 查看支付报告。

以下图显示了我在添加了管理端层并组织了解决方案文件夹后的最终支付解决方案结构:

图 15.3 – 带有管理端面的支付模块解决方案

图 15.3 – 带有管理端面的支付模块解决方案

我添加了Payment.Admin.ApplicationPayment.Admin.Application.ContractsPayment.Admin.BlazorPayment.Admin.HttpApiPayment.Admin.HttpApi.Client项目。我还添加了Payment.BackgroundServices项目以执行一些周期性后台工作。

解决方案文件夹反映了整体结构——admin应用程序(带有 Blazor UI)和www(公共)应用程序(带有 MVC/Razor Pages UI)。common文件夹在两个应用程序中都被使用,所以我们共享相同的领域层和数据库集成代码。

我们已经了解了支付解决方案的整体结构。在下一节中,你将了解支付过程的细节。

理解支付过程

支付模块的唯一责任是从用户那里收取支付。它内部使用 PayPal 作为支付网关。支付模块是通用的,可以被任何类型的应用程序使用。使用支付模块的应用程序应包含一些集成逻辑,以启动支付过程并处理支付结果。在本节中,我将基于 EventHub 集成来解释这个过程。

EventHub 应用程序使用支付模块从用户那里获取支付,以将免费组织账户升级为高级组织账户。正如你所猜到的,高级组织在应用程序中拥有更多权利。

如果您是组织的所有者并访问组织详情页面,您将在页面上看到一个升级至高级版按钮,如图所示:

![图 15.4 – EventHub 组织详情页面图片

图 15.4 – EventHub 组织详情页面

当您点击升级至高级版按钮时,您将被重定向到定价页面:

![图 15.5 – EventHub 定价页面图片

图 15.5 – EventHub 定价页面

在这里,我们可以看到账户类型及其差异。当我们在这里点击升级至高级版按钮时,我们将被重定向到预结账页面,该页面由支付模块定义:

![图 15.6 – 预结账页面图片

图 15.6 – 预结账页面

预结账页面通常位于支付模块内部,并开发成与应用程序无关。我们可以通过一个如/Payment/PreCheckout?paymentRequestId=3a002186-cb04-eb46-7310-251e45fc6aed的 URL 将用户重定向到预结账页面。然而,我们首先需要使用IPaymentRequestAppService服务的CreateAsync方法获取一个支付请求 ID。这是在EventHub.Web项目的Pages/Pricing.cshtml.cs文件中完成的。

EventHub 应用程序覆盖视图(UI)部分以更好地适应 EventHub 的 UI 设计。这是在最终应用程序中自定义模块的一个示例。EventHub 应用程序在Pages/Payment文件夹下定义了PreCheckout.cshtmlPostCheckout.cshtml文件,如图所示:

![图 15.7 – 覆盖支付模块的结账视图图片

图 15.7 – 覆盖支付模块的结账视图

它们自动覆盖相应的支付页面(因为它们正好位于支付模块定义的相同路径中)。这些页面没有.cshtml.cs文件,因为我们不想改变页面的行为;我们只想改变视图方面。

下图显示了支付过程中使用的主要组件和流程:

![图 15.8 – 支付流程图片

图 15.8 – 支付流程

当我们在定价页面(如图 15.5 所示)上点击升级至高级版按钮时,它将重定向到支付模块的结账页面。当我们点击该页面上的结账按钮(如图 15.6 所示)时,我们将被重定向到 PayPal,这是支付模块使用和集成的支付系统。一旦我们在 PayPal 上完成支付,我们将被重定向回应用程序的结账后页面,该页面会向用户显示感谢信息。

当支付过程成功时,支付模块发布一个名为 PaymentRequestCompletedEto 的分布式事件(在 Payment.Domain.Shared 项目中定义)。EventHub 应用程序订阅此事件(在 EventHub.Domain 项目中的 PaymentRequestEventHandler 类内部)。它找到与完成支付相关的用户和组织,升级组织,并发送电子邮件感谢用户升级账户。

在某些罕见情况下,从 PayPal 返回到我们的应用程序时可能会出现错误,我们无法知道支付过程是否成功。对于此类情况,支付模块提供了一个 PaymentRequestController(位于 Payment.HttpApi 项目中)。如果操作成功,将发布相同的 PaymentRequestCompletedEto 事件,以便 EventHub 应用程序可以异步升级组织账户。

在下一节中,我们将看到支付模块如何提供配置选项。

提供配置选项

支付模块使用 PayPal,因此它需要应用程序必须配置的 PayPal 账户信息。它遵循选项模式(见 第五章,*探索 ASP.NET Core 和 ABP 基础设施)中的 实现选项模式 部分),并提供了一个 PayPalOptions 类,该类可以被应用程序配置,如下例所示:

Configure<PayPalOptions>(options =>
{
    options.ClientId = "...";
    options.Secret = "...";
});

我们通常从配置(appsettings.json 文件)中获取值。如果你已经定义了它,支付模块可以从 Payment:PayPal 键获取选项值,如下例所示:

"Payment": {
  "PayPal": {
    "ClientId": "...",
    "Secret": "...",
    "Environment": "Sandbox"
  }
}

这可以通过以下代码实现,该代码位于 Payment.Domain 项目的 PaymentDomainModule 类中:

Configure<PayPalOptions>(configuration.GetSection("Payment:
                         PayPal"));

默认情况下从配置中获取值是一种良好的做法。

我已经介绍了支付模块结构的主要要点。其代码库与典型的 ABP 应用程序没有太大区别。你可以探索其源代码来了解其内部工作原理。在下一节中,我们将看到它如何在 EventHub 应用程序中安装。

将支付模块安装到 EventHub

模块本身不是一个可运行的项目。它应该安装到更大的应用程序中,并作为其一部分工作。在本节中,我们将看到支付模块是如何在 EventHub 解决方案中安装的。

设置项目依赖项

支付模块在其解决方案中包含超过 10 个项目(见 图 15.3)。同样,EventHub 解决方案包含许多项目,有三个应用程序——管理端、公共端和账户(IdentityServer)应用程序。

我想将支付模块集成到 EventHub 解决方案的所有层中。通常,EventHub 解决方案中的每一层都应该依赖于(使用)支付模块的相应层。以下表格显示了 EventHub 项目对支付模块项目的所有依赖关系:

![Figure 15.9 – EventHub and Payment module project dependencies]

![img/Figure_15.9_B17287.jpg]

图 15.9 – EventHub 和支付模块项目依赖

因此,我们应该逐个添加项目引用。例如,我们将Payment.Domain项目依赖项添加到EventHub.Domain项目中。这样,我们就可以在我们的应用程序域层中使用支付模块的实体。

Visual Studio 不支持从解决方案外部(我称之为外部项目依赖)向项目添加本地项目依赖项。然而,我们可以手动将ProjectReference元素添加到目标项目的csproj文件中。因此,我们可以将以下行添加到EventHub.Domain.csproj文件中:

<ProjectReference Include=
    "..\..\modules\payment\src\Payment.Domain\Payment
     .Domain.csproj" />

当我们添加这样的外部项目依赖项时,Visual Studio 无法自动解析它。我们应该打开命令行终端并运行dotnet restore命令。此命令仅在您添加新依赖项或删除现有依赖项时需要。此外,如果您想使用支付模块构建 EventHub 解决方案,可以使用dotnet build /graphBuild命令。虽然这很少需要,但它可以在 Visual Studio 无法解析依赖模块中的某些类型时救命。

一旦添加了项目引用,我们也应该添加 ABP 模块依赖。以下代码块显示了EventHubDomainModule类的PaymentDomainModule依赖项:

[DependsOn(
    ...,
    typeof(PaymentDomainModule)
)]
public class EventHubDomainModule : AbpModule
{ ... }

我们应手动设置所有项目依赖项,如这里所述。下一步是配置支付模块的数据库表格。

配置数据库集成

支付模块需要一些数据库表格才能正常工作。我们可以使用主 EventHub 数据库来存储支付模块的表格。采用这种方法,我们将为系统拥有一个单一数据库。或者,我们可以为支付模块创建一个单独的数据库,这样我们就有两个数据库。EventHub 解决方案更喜欢第一种方法,因为它更容易实现和管理。然而,我还会展示我们如何实现单独的数据库方法。让我们从单一数据库方法开始。

使用单个数据库

在本节中,我将向您展示我们如何在 EventHub 应用程序的主数据库中创建支付表格。

EventHub 解决方案在EventHub.EntityFrameworkCore项目中包含一个EventHubDbContext类,这是将实体映射到数据库表格的主要类。支付模块定义了一个ConfigurePayment扩展方法,我们从DbContext类的OnModelCreating方法中调用它,以将支付数据库映射模型包含到我们的主数据库模型中(请参阅EventHub.EntityFrameworkCore项目中的EventHubDbContext类):

protected override void OnModelCreating(ModelBuilder
                                        builder)
{
    base.OnModelCreating(builder);
    ...
    builder.ConfigurePayment(); // ADDED THIS LINE
    builder.ConfigureEventHub();
}

在这里,builder.ConfigurePayment() 由支付模块定义(在 Payment.EntityFrameworkCore 项目的 PaymentDbContextModelCreatingExtensions 类中)。在 OnModelCreating 方法内添加此行后,我们可以使用以下命令行终端中的命令将新的数据库迁移添加到 EventHub 解决方案中(我们在 EventHub.EntityFrameworkCore 项目的 root 文件夹中运行此命令):

dotnet ef migrations add Added_Payment_Module

此命令创建一个新的迁移文件。然后,我们可以使用以下命令对数据库应用新的迁移:

dotnet ef database update

那就结束了。支付模块将使用主 EventHub 数据库来存储其数据。这样,我们将有一个包含应用程序所有表的单一数据库。在下一节中,我们将讨论单独的数据库方法。

使用单独的数据库

在本节中,我们将看到如何将 EventHub 解决方案更改为为支付模块使用单独的数据库。EventHub 解决方案使用 PostgreSQL 作为数据库提供者。我们将使用 Microsoft 的 SQL Server 作为支付模块。这样,你将学习如何在单个应用程序中与多个数据库提供者一起工作。

我在一个单独的分支中做出了这些更改,并在 GitHub 上创建了一个草稿 Pull RequestPR),这样你就可以看到所有更改:

在这里,我将指出我做出的基本更改。你可以在 GitHub 上的 PR 中查看所有更改。

首先,我在 EventHub.EntityFrameworkCore 项目中创建了一个名为 EventHubPaymentDbContext 的第二个 DbContext 类,以管理数据库迁移:

[ReplaceDbContext(typeof(IPaymentDbContext))]
[ConnectionStringName(
    PaymentDbProperties.ConnectionStringName)]
public class EventHubPaymentDbContext
    : AbpDbContext<EventHubPaymentDbContext>,
    IPaymentDbContext
{
    public DbSet<PaymentRequest> PaymentRequests { get;
                                                   set; }
    public EventHubPaymentDbContext(
        DbContextOptions<EventHubPaymentDbContext> options) 
        : base(options)
    { }
    protected override void OnModelCreating(
        ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        modelBuilder.ConfigurePayment();
    }
}

这个类使用 [ReplaceDbContext] 属性替换了由支付模块定义的 IPaymentDbContext,并实现了 IPaymentDbContext 接口。它还声明了 [ConnectionStringName] 属性,在 appsettings.json 文件中使用 Payment 连接字符串名称而不是 Default。最后,它调用了支付模块的 modelBuilder.ConfigurePayment() 扩展方法来配置数据库映射。

支付模块被设计成独立于任何特定的 Volo.Abp.EntityFrameworkCore 包,它是数据库管理系统无关的。由于我想使用 SQL Server 数据库,我已经将 Volo.Abp.EntityFrameworkCore.SqlServer 包依赖项添加到 EventHub.EntityFrameworkCore 项目中。我还将 AbpEntityFrameworkCoreSqlServerModule 添加到 EventHubEntityFrameworkCoreModule 类的 DependsOn 属性中,因为 ABP 需要它。

EF Core 的命令行工具需要创建一个 DbContext 工厂类,以便在运行其命令时创建相关 DbContext 类的实例。您可以在源代码中看到 EventHubPaymentDbContextFactory 类。它使用 Payment 连接字符串和 UseSqlServer 扩展方法来配置 SQL Server 作为数据库提供程序。通过这个更改,我们应该在 EventHub.DbMigrator 项目的 appsettings.json 文件中添加 Payment 连接字符串:

"ConnectionStrings": {
  "Default": "Host=localhost;
              Database=EventHub;
              Username=root;
              Password=root;
              Port=5432",
  "Payment": "Server=(LocalDb)\\MSSQLLocalDB;
              Database=EventHubPayment;
              Trusted_Connection=True"
},

ABP 将自动获取新 DbContext 类的 Payment 连接字符串,因为它具有 ConnectionStringName 属性(PaymentDbProperties.ConnectionStringName 的值是 Payment)。我还需要将 Payment 连接字符串添加到所有我已经定义了 Default 连接字符串的 appsettings.json 文件中。

我应该将新的 EventHubPaymentDbContext 类注册到依赖注入系统中并配置它。为此,我已更改 EventHubEntityFrameworkCoreModule 类的 ConfigureServices 方法,如下所示:

public override void ConfigureServices(
    ServiceConfigurationContext context)
{
    context.Services.AddAbpDbContext<EventHubDbContext>(
        options =>
    {
        options.AddDefaultRepositories();
    });
    context.Services.AddAbpDbContext<
        EventHubPaymentDbContext>();
    Configure<AbpDbContextOptions>(options =>
    {
        options.UseNpgsql();        
        options.Configure<EventHubPaymentDbContext>(opts =>
        {
            opts.UseSqlServer();
        });
    });
}

AddAbpDbContext<EventHubPaymentDbContext>() 调用注册了新的 DbContext 类。我还添加了 Configure<EventHubPaymentDbContext>(…) 块来为这个 DbContext 类使用 SQL Server。其他 DbContext 类将继续使用 PostgreSQL(全局配置所有 DbContext 类的 UseNpgsql() 调用)。

EventHub.DbMigrator 应用程序为主数据库执行数据库迁移。现在,我们有了第二个数据库,我们想要更改 EventHub.DbMigrator 应用程序,使其也执行支付模块数据库的数据库迁移。更改很简单;我在 EntityFrameworkCoreEventHubDbSchemaMigrator 类的 MigrateAsync 方法内部添加了以下代码块:

await _serviceProvider
    .GetRequiredService<EventHubPaymentDbContext>()
    .Database
    .MigrateAsync();

这个类在 EventHub.DbMigrator 应用程序迁移数据库时使用。因此,通过添加这个代码块,当运行 EventHub.DbMigrator 应用程序时,新的数据库也会被迁移。

作为最后的更改,我将从主 EventHub 数据库中移除支付表格,并从 EventHubDbContext 类中移除以下行:

builder.ConfigurePayment();

然后,我可以使用 EF Core 的命令行工具创建数据库迁移(在 EventHub.EntityFrameworkCore 项目的 root 文件夹中):

dotnet ef migrations add "Remove_Payment_From_Main_Database" --context EventHubDbContext

与标准用法不同,我添加了 --context EventHubDbContext 参数。我指定 DbContext 类型,因为 EventHub.EntityFrameworkCore 项目中有两个 DbContext 类。一旦创建迁移(删除支付表格),我就可以使用以下命令对数据库应用更改:

dotnet ef database update --context EventHubDbContext

现在,主数据库没有支付表格。但我们还没有创建支付数据库。为此,我可以使用 EF Core 的命令行工具为支付数据库创建数据库迁移(在 EventHub.EntityFrameworkCore 项目的 root 文件夹中):

dotnet ef migrations add "Initial_Payment_Database" --context EventHubPaymentDbContext --output-dir "MigrationsPayment"

这次,除了指定EventHubPaymentDbContext类型的context参数之外,我还设置了output-dir参数以指定创建迁移类的文件夹。默认文件夹名称是Migrations,但这个名称被EventHubDbContext类使用,所以我不能使用它。我将文件夹名称指定为MigrationsPayment。以下图显示了EventHub.EntityFrameworkCore项目中的新迁移文件夹:

图 15.10 – 支付模块的迁移文件夹

图 15.10 – 支付模块的迁移文件夹

现在,我可以在EventHub.EntityFrameworkCore项目的root文件夹中使用以下命令:

dotnet ef database update --context EventHubPaymentDbContexts

如果我检查数据库,我可以看到支付模块的表(它只有一个数据库表):

图 15.11 – 支付模块在其自己的数据库中的表

图 15.11 – 支付模块在其自己的数据库中的表

单独的数据库配置已完成。现在,支付模块将使用新的 SQL Server 数据库,而应用程序的其他部分将继续与主 PostgreSQL 数据库一起工作。

使用 DbMigrator

我已经使用dotnet ef命令行工具更新了数据库模式。然而,我们也可以运行DbMigrator应用程序以将更改应用到数据库中。由于我们还将EntityFrameworkCoreEventHubDbSchemaMigrator类更改为支持第二个数据库,因此DbMigrator可以迁移两个数据库的模式。

通过创建新的迁移DbContext类,如这里所述,您可以设置其他模块使用它们自己的数据库。我在同一个EventHub.EntityFrameworkCore项目中添加了新的DbContext类;然而,我们也可以为新的DbContext类创建一个新的项目,并在其中管理迁移。在这种情况下,我们不需要为 EF Core 命令指定上下文和output-dir参数。然而,我建议使用单个项目以最小化解决方案中的项目数量,因为它已经有很多了。

摘要

在本章中,我首先解释了模块化的含义以及边界上下文、紧密耦合、通用和插件模块是什么。我们学习了如何使用 ABP CLI 创建一个新的模块。

然后,我们探讨了支付模块的结构,并了解了它是如何集成到 EventHub 解决方案中的。我们学习了通过设置项目依赖关系手动将支付模块安装到 EventHub 解决方案中的步骤。

最后,我们看到了两种使用支付数据库表的方法。单一数据库方法简单,EventHub 应用程序和支付模块共享相同的数据库。另一方面,单独的数据库方法允许我们为支付表使用专用数据库,使得可以在支付模块中使用与主应用程序不同的 DBMS。

我建议您检查支付模块和 EventHub 解决方案的源代码,以了解它们结构的所有细节。我还建议您查阅 ABP 文档,以更好地理解模块化,并学习构建可重用、通用应用模块的最佳实践:docs.abp.io/en/abp/latest/Best-Practices/Index.

在下一章中,我们将探讨多租户的概念,这是构建 SaaS 应用的关键。

第十六章:第十六章:实现多租户

多租户是创建 软件即服务SaaS)解决方案的常见模式,其中单个部署可以同时为多个客户提供服务。多租户是 ABP 框架的基本设计原则之一,因此所有其他框架功能都是多租户兼容的。

在本章中,我们将从了解什么是多租户系统以及 ABP 如何为我们提供多租户解决方案开始。然后,我们将继续了解 ABP 基础设施,以理解、构建和控制我们应用程序中的多租户。我们还将学习设计特定的应用程序功能,并使不同的租户使用不同的应用程序功能。在本章结束时,您将了解多租户的基本知识,并能够使用 ABP 框架构建多租户应用程序。

下面是本章涵盖的主要主题列表:

  • 理解多租户

  • 与 ABP 多租户基础设施协同工作

  • 使用功能系统

  • 何时使用多租户

技术要求

如果您想跟随本章中的示例,您需要一个支持 ASP.NET Core 开发的 IDE/编辑器。

您可以从以下 GitHub 仓库下载示例应用程序:github.com/PacktPublishing/Mastering-ABP-Framework。它包含本章中给出的一些示例。

理解多租户

在本节中,您将了解 SaaS 和多租户概念、创建 SaaS 解决方案的主要好处以及 ABP 作为多租户感知框架为我们提供的内容。让我们首先了解 SaaS 系统提供的内容。

什么是 SaaS?

使用 SaaS 模型构建、部署和许可软件解决方案已经变得相当流行。客户通常以订阅模式购买 SaaS 解决方案,并在网上使用它,无需他们自己下载和安装在自己的服务器上,这被称为本地部署。

在托管时构建 SaaS 解决方案具有以下好处:

  • 由于客户可以共享服务器、数据库和其他资源,您可以充分利用您的资源。

  • 向系统中添加新客户(租户)非常简单,通常也是自动化的。

  • 与为每个客户进行单独部署相比,维护和升级系统更容易。

另一方面,使用 SaaS 解决方案对客户也有利。与本地部署相比,他们为软件和托管支付的费用更少。他们可以根据使用量付费。只要他们支付服务费用,他们也不必担心维护和升级。

虽然 SaaS 解决方案对托管有利,但创建 SaaS 解决方案也伴随着一些开发成本和运行时考虑。

SaaS 解决方案通常在客户之间共享资源。一些主要的共享资源包括数据库、缓存和应用服务器。数据隔离、安全和性能是我们应该在客户之间共享资源时应关注的主要问题。

除了共享资源外,应用程序设置、功能和权限应根据客户进行定制,而不会相互影响。

既然我们现在理解了构建 SaaS 解决方案的好处和挑战,让我们简单谈谈多租户。

什么是多租户?

多租户是一种创建 SaaS 解决方案的建筑模式。它定义并控制客户如何安全有效地访问资源,以及如何轻松地为每个客户定制应用程序。

ABP 框架提供了一个完整的多租户基础设施。它定义了你的应用程序和领域代码应该如何设计,你如何访问共享资源(如数据库和缓存),你如何根据客户定制应用程序配置,等等。它不仅定义了,而且在可能的情况下还实现了自动化。

多租户系统有两个方面:

  • 租户:使用系统并为其付费的客户。客户有自己的用户和数据,这些用户和数据与其他租户隔离。

  • 主机:管理和租户的系统公司。

你可以为租户和主机用户拥有单独的应用程序,或者你可以构建一个单一的应用程序,其中一些应用程序功能仅对租户用户或仅对主机用户可用。ABP 启动解决方案模板采用第二种方法,因为它更容易开发和部署。

下一个部分将讨论如何为不同租户共享或分离数据库。

数据库架构

多租户系统最基本的设计决策之一是如何共享或分离不同租户的数据库。有三种常见的方法:

  • 单数据库:所有租户的所有数据都存储在一个单一、共享的数据库中。在这种情况下,你应该在隔离不同租户的数据时格外小心,因为数据库表是共享的。

  • 数据库(或模式)每个租户:每个租户都有自己的专用数据库。你应该动态连接到当前用户的租户数据库。

  • 混合:一种混合方法,其中一些租户拥有自己的数据库,而其他租户则被分组在一个或多个数据库中。

ABP 通过允许每个租户拥有单独的数据库连接字符串,在框架级别支持混合方法。然而,启动模板和开源租户管理模块都附带单数据库模型。如果你想使用每个租户的数据库或混合方法,你应该定制租户管理模块。

下图展示了 ABP 多租户基础设施的主要组件:

![图 16.1 – ABP 框架多租户概述

![img/Figure_16.1_B17287.jpg]

图 16.1 – ABP 框架多租户概述

ABP 框架的目标是尽可能自动化与多租户相关的逻辑,并使您的应用程序代码不感知多租户。ABP 从 HTTP 请求中解析当前租户。它可以从域名(或子域名)、cookie、HTTP 头和其他参数中确定租户。然后,它使用当前租户信息自动选择正确的连接,如果租户有单独的连接字符串。如果租户使用共享数据库,它将自动过滤数据,以确保租户不会意外访问另一个租户的数据。

现在我们可以开始使用 ABP 多租户基础设施了,因为我们已经了解了多租户和 ABP 的基本多租户逻辑。

与 ABP 多租户基础设施一起工作

在本节中,我们将探讨 ABP 多租户系统的基本基础设施和功能。您将了解 ABP 如何理解当前租户并隔离租户数据,如何获取有关当前租户的信息,以及如何在租户之间切换。但首先,我们将从如何在不使用它的情况下禁用多租户开始。

启用和禁用多租户

ABP 启动解决方案模板默认启用多租户。启动解决方案有一个单点,您可以使用它轻松地启用或禁用多租户。在.Domain.Shared项目中找到MultiTenancyConsts类:

public static class MultiTenancyConsts
{
    public const bool IsEnabled = true;
}

您可以将IsEnabled值设置为false来禁用多租户功能。此常量在解决方案的几个地方被使用。它用于在.Domain项目模块类中设置AbpMultiTenancyOptions.IsEnabled选项:

Configure<AbpMultiTenancyOptions>(options =>
{
    options.IsEnabled = MultiTenancyConsts.IsEnabled;
});

ABP 使用AbpMultiTenancyOptions.IsEnabled来启用或禁用与多租户相关的功能、页面和组件。如果您将MultiTenancyConsts.IsEnabled设置为false并运行应用程序,您将不再在登录表单上看到租户切换框和在主菜单上的租户管理页面。然而,与多租户相关的数据库表不会被删除。下一节将解释如何进行删除。

删除多租户表

禁用多租户不会从数据库中删除与多租户相关的数据库表。您可以保持原样(它们已经为空/未使用)。这样,您可以轻松地在以后为您的应用程序启用它。

如果您不想在数据库中保留与多租户相关的表,请在.EntityFramework项目的DbContext类中找到以下行并将其删除:

builder.ConfigureTenantManagement();

然后,从您的DbContext类中删除ITenantManagementDbContext接口的实现。您需要从类中删除TenantsTenantConnectionStrings DbSet属性。最后,从DbContext类声明中删除[ReplaceDbContext(typeof(ITenantManagementDbContext))]属性。这些更改将租户管理模块的表从您的数据库模式中删除。

您可以在.EntityFramework项目的根目录中运行以下命令来添加一个新的数据库迁移,以从数据库中删除表:

dotnet ef migrations add Removed_TenantManagement

然后,运行以下命令以将更改应用到数据库:

dotnet ef database update

这样,您的数据库将不会包含与多租户相关的表。您还可以从解决方案中的项目以及使用这些包的代码部分中删除Volo.Abp.TenantManagement.* NuGet 包。然而,这些都是可选的。我建议您保留它们,如果您认为您可能以后会为您的应用程序启用多租户,因为只要将AbpMultiTenancyOptions.IsEnabled选项设置为false,它们就没有任何功能。

正如您所看到的,使用 ABP 框架启用/禁用多租户只需一行更改。如果您决定将应用程序作为多租户启用来开发,您可以继续下一节,了解 ABP 如何从 HTTP 请求中确定当前租户。

确定当前租户

如果您再次查看图 16.1,您将看到所有来自用户的请求在执行应用程序代码之前都通过了租户解析组件。这样,当前租户就可在您的应用程序中知道了。

使用 ABP 的多租户中间件组件拦截传入的请求。启动解决方案模板中的所有托管项目在 ABP 模块类的OnApplicationInitialization方法中都包含以下行:

if (MultiTenancyConsts.IsEnabled)
{
    app.UseMultiTenancy();
}

此中间件是在身份验证中间件之后(因为用户身份验证票据用于租户解析)和授权中间件之前(因为 ABP 根据用户的租户授权用户)添加的。

多租户中间件从 HTTP 请求中解析当前租户,并设置用于获取当前租户信息的ICurrentTenant属性。ICurrentTenant接口将在下一节中解释,但我们应该首先了解 ABP 如何从 HTTP 请求中确定当前租户。

当前租户信息是通过以下顺序使用当前 HTTP 请求中的请求参数获得的:

  1. 如果用户(或客户端)已通过身份验证,则当前租户的 ID 和名称将从身份验证票据中的声明中提取(根据身份验证方法,可能在 cookie 中或在 header 中)。

  2. 如果已配置AbpTenantResolveOptions,则从域名(或子域名)中确定租户的名称。

  3. 如果当前 HTTP 请求包含该参数,则使用__tenant查询字符串参数来获取租户的名称或 ID。

  4. 如果当前 HTTP 请求包含该参数,则使用__tenant路由参数来获取租户的名称或 ID。

  5. 如果当前 HTTP 请求包含该参数,则使用__tenant HTTP 头获取租户的名称或 ID。

  6. 如果当前 HTTP 请求包含该参数,则使用__tenant cookie 的值获取租户的名称或 ID。

如果 ABP 在之前的任何步骤中确定了租户,它不会像您预期的那样继续到其他步骤。如果 HTTP 请求中没有找到任何信息,那么就假设当前用户是主机用户。所有选项在您创建新解决方案时都已经预先配置并正常工作,所以您通常不需要为您的解决方案进行很多配置。您应该只关注域名解析,这在生产环境中是建议的。

以下示例展示了如何在模块类的ConfigureServices方法中配置域名解析器:

Configure<AbpTenantResolveOptions>(options =>
{
    options.AddDomainTenantResolver("{0}.yourdomain.com");
});

AddDomainTenantResolver方法接受一个域名格式,其中{0}部分与租户名称匹配。这意味着如果您的租户名称(Tenant类的Name属性)是acme,那么acme用户应该使用acme.yourdomain.com URL 来进入应用程序。

一旦 ABP 解决了租户问题,我们就可以像下一节中解释的那样与当前租户一起工作。

与当前租户一起工作

ABP 在执行我们的应用程序代码之前就确定了租户,正如我们在上一节中学到的。我们可以使用ICurrentTenant服务来获取当前租户的信息。以下示例演示了如何在任意类中使用ICurrentTenant服务:

public class MyService : ITransientDependency
{
    private readonly ICurrentTenant _currentTenant;
    public MyService(ICurrentTenant currentTenant)
    {
        _currentTenant = currentTenant;
    }

    public async Task DoItAsync()
    {
        Guid? tenantId = _currentTenant.Id;
        string tenantName = _currentTenant.Name;
    }
}

在示例方法中,我们已经注入了ICurrentTenant服务并访问了IdName属性。如果当前用户是主机用户(这意味着租户不可用),IdName属性将返回null。一些 ABP 基类已经预先注入了ICurrentTenant服务,因此您可以直接使用CurrentTenant属性,如下面的示例所示:

public class MyAppService : ApplicationService
{
    public async Task DoItAsync()
    {
        Guid? tenantId = CurrentTenant.Id;
    }
}

由于ApplicationService基类已经具有CurrentTenant属性(ICurrentTenant类型),我们可以直接使用它,无需手动注入。

ICurrentTenant没有更多重要的属性。如果您需要获取当前租户的更多信息/数据,您可以使用租户的Id属性从数据库中查询。

大多数时候,您的应用程序代码将与当前租户一起工作。但有时,您可能需要更改当前租户,下一节将解释这一点。

在租户之间切换

ICurrentTenant服务也被 ABP 框架用于自动隔离当前租户的数据,这样您就不会意外地访问其他租户的数据。然而,在某些情况下,您可能需要在同一 HTTP 请求中处理另一个租户的数据并临时切换租户。ICurrentTenant服务不仅用于获取当前租户的信息,还用于切换到所需的租户。请参阅以下示例:

public class MyAppService : ApplicationService
{
    public async Task DoItAsync(Guid tenantId)
    {
        // Before the using block
        using (CurrentTenant.Change(tenantId))
        {
            // Inside the using block
            // CurrentTenant.Id equals to tenantId 
        }
        // After the using block
    }
}

如果在using块之前使用CurrentTenant.Id属性,您将获得已解析的租户 ID,如“确定当前租户”部分中所述。CurrentTenant.Change方法将当前租户更改为给定值,因此在using块内部使用CurrentTenant.Id属性时,您将获得所需的租户 ID。例如,如果您在using块内部从共享数据库中执行数据库查询,ABP 将检索所需的租户数据而不是多租户中间件解析的租户数据。一旦using块完成,CurrentTenant.Id将自动恢复到之前的值。当您很少需要时,可以安全地以嵌套方式使用CurrentTenant.Change方法。如果您想切换到主机上下文,可以将null值传递给Change方法。始终像在这个示例中一样使用using块与Change方法一起使用,以避免影响您的方法的周围上下文。

除了切换到所需的租户之外,还可以完全禁用租户隔离。

禁用数据隔离

数据隔离在多租户应用程序中至关重要。它保证了只查询当前租户的数据。然而,在某些情况下,您的应用程序可能需要查询整个数据库,包括所有租户的数据。

我们在第八章的“使用数据过滤系统”部分中探讨了 ABP 的数据过滤系统,使用 ABP 的功能和服务。ABP 使用相同的数据过滤系统来过滤当前租户的数据。因此,我们可以使用相同的数据过滤 API 临时禁用多租户过滤器:

public class ProductAppService : ApplicationService
{
    private readonly IRepository<Product, Guid>
        _productRepository;
    public ProductAppService(
        IRepository<Product, Guid> productRepository)
    {
        _productRepository = productRepository;
    }
    public async Task<long> GetTotalProductCountAsync()
    {
        using (DataFilter.Disable<IMultiTenant>())
        {
            return await
                _productRepository.GetCountAsync();
        }
    }
}

在这个示例中,我们正在获取数据库中所有租户拥有的产品总数。在using块中禁用了多租户数据过滤器,因此存储库与数据库中的所有记录一起工作。

虽然禁用多租户过滤器相对简单,但存在一个重要的限制——它仅适用于单数据库方法。如果租户有专用数据库,则无法查询租户数据。目前,没有直接的方法可以对多个数据库执行查询并将查询结果聚合为单个结果集。

除了技术限制之外,查询所有租户数据的设计也存在问题。理想情况下,多租户软件应该设计成每个租户都有自己的本地部署,包括独立的数据库和应用服务器。我们将在本章的“何时使用多租户”部分稍后回到这个讨论。

我们已经学习了访问和更改当前租户的方法。在下一节中,我们将看到如何设计我们的实体以实现多租户兼容性。

将域设计为多租户

ABP 旨在使你的应用程序代码不感知多租户,并在可能的情况下自动化处理。将实体类设计为多租户非常简单。只需为你的实体实现IMultiTenant接口,如下面的示例所示:

public class Product : AggregateRoot<Guid>, IMultiTenant
{
    public Guid? TenantId { get; set; }
    public string Name { get; set; }
}

本例中的Product聚合根实体实现了IMultiTenant接口,并定义了一个TenantId属性。在 ABP 框架中,租户标识类型始终是GuidTenantId属性是可空的,这使得Product实体既可用于租户端,也可用于主机端。如果TenantId属性为null,则该实体属于主机端。这也允许我们轻松地将我们的应用程序转换为单租户、本地应用程序,其中TenantId属性始终为null

当你创建一个新的实体对象(例如,一个Product对象)时,ABP 会自动使用ICurrentTenant.Id属性设置TenantId值。ABP 还负责将其保存到正确的数据库,或从正确的数据库查询,或者如果你使用的是单个数据库,则过滤租户数据。

我们已经学习了使用 ABP 框架构建多租户解决方案的基本要点。下一节介绍了 ABP 特性系统,它可以用来限制租户的应用程序功能。

使用特性系统

大多数 SaaS 解决方案为顾客提供不同的套餐。每个套餐都有一组不同的应用程序特性,并且以不同的价格订阅。ABP 提供了一个用于定义此类应用程序特性的特性系统,然后为单个租户禁用或启用这些特性。让我们先定义一个特性。

定义特性

在使用之前需要定义一个特性。创建一个新的类,从FeatureDefinitionProvider类派生(通常在启动解决方案中的.Application.Contracts项目中),并重写Define方法,如下面的示例所示:

public class MyAppFeatureDefinitionProvider :
    FeatureDefinitionProvider
{
    public override void Define(
        IFeatureDefinitionContext context)
    {
        var myGroup = context.AddGroup("MyApp");        
        myGroup.AddFeature(
            "MyApp.StockManagement",
            defaultValue: "false",
            displayName: L("StockManagement"),
            isVisibleToClients: true);        
        myGroup.AddFeature(
            "MyApp.MaxProductCount", 
            defaultValue: "100",
            displayName: L("MaxProductCount"));
    }
    private ILocalizableString L(string name)
    {
        return 
            LocalizableString.Create<MtDemoResource>(name);
    }
}

特性被分组以创建更模块化的系统(其中每个模块定义其自己的组)。在这个例子中,我为最终应用程序创建了一个特性组。然后,我在该组下定义了两个特性。本例定义了这两个特性:

  • 第一个用于为租户启用或禁用库存管理特性。

  • 第二个用于限制产品实体的数量。

特性值实际上是字符串,例如本例中的false100。然而,根据惯例,布尔值(truefalse)可以用于条件检查。

ABP 会自动发现从FeatureDefinitionProvider类派生的类,因此你不需要在某个地方注册它。定义特性后,我们可以检查当前租户的特性值(我们将在管理租户特性部分中看到如何将特性分配给租户)。

检查特性

我将在 管理租户功能 部分向您展示如何为租户启用、禁用或设置功能的值。但首先,我想向您展示我们如何检查租户的功能值。

有几种方法可以检查当前租户的功能值。最简单的方法是使用可以用于方法或类的 RequiresFeature 属性。

使用 RequiresFeature 属性

以下示例使用 RequiresFeature 属性来限制当前租户对类的使用:

[RequiresFeature("MyApp.StockManagement")]
public class StockAppService : ApplicationService,
    IStockAppService
{
}

这样,MyApp.StockManagement 功能的值会在每次调用 StockAppService 服务的 StockAppService 方法时自动检查,并且对于未经授权的访问会抛出异常。

RequiresFeature 属性也可以用于方法上。请参见以下示例:

public class ProductAppService : ApplicationService
{
    ...    
    [RequiresFeature("MyApp.StockManagement")]
    public async Task<long> GetStockCountAsync()
    {
        return await _productRepository.GetCountAsync();
    }
}

在这种情况下,只有 GetStockCountAsync 方法受到限制,而 ProductAppService 的其他没有 RequiresFeature 属性的方法不受影响。

RequiresFeature 属性易于使用,但仅限于布尔功能(具有 truefalse 值)。对于详细使用,我们应该使用 IFeatureChecker 服务。

使用 IFeatureChecker 服务

IFeatureChecker 服务允许我们以编程方式获取和检查功能值。您可以像注入任何其他服务一样注入它。以下示例检查 MyApp.StockManagement 功能是否为当前租户启用:

public async Task<long> GetStockCountAsync()
{
    if (await FeatureChecker
             .IsEnabledAsync("MyApp.StockManagement"))
    {
        return await _productRepository.GetCountAsync();
    }
    // TODO: Your fallback logic or error message
}

IsEnabled 方法仅在功能值为 true(作为 string)时返回 true。如果您有回退逻辑(当租户未启用该功能时),则使用 IsEnabledAsync 是一个好的方法。但是,如果您只想检查功能是否启用,并在其他情况下抛出异常,请使用 CheckEnabledAsync 方法,如下所示:

public async Task<long> GetStockCountAsync()
{
    await FeatureChecker.CheckEnabledAsync("MyApp.StockManagement");
    return await _productRepository.GetCountAsync();
}

CheckEnabledAsync 方法如果给定的功能对于当前租户未启用,则会抛出 AbpAuthorizationException。然而,如果您需要在方法开始时简单地检查一个功能是否启用或禁用,使用 RequiresFeature 属性会更简单。

使用 IFeatureChecker 服务在您想获取非布尔功能的值时特别有用。例如,在 定义功能 部分中引入的 MyApp.MaxProductCount 功能是一个数值功能。我们无法简单地检查它是否启用或禁用。我们需要知道当前用户的功能值。

以下示例在创建新产品之前检查当前租户允许的最大产品数量:

public async Task CreateAsync(string name)
{
    var currentProductCount = await
        _productRepository.GetCountAsync();
    var maxProductCount = await
           FeatureChecker.GetAsync<int>(
               "MyApp.MaxProductCount");
    if (currentProductCount >= maxProductCount)
    {
        // TODO: Throw a business exception
    }    
    // TODO: Continue to create the product
}

FeatureChecker.GetAsync<T> 方法通过转换为给定的泛型类型参数来返回给定功能的值。在这里,MyApp.MaxProductCount 是一个数值功能,所以我将其转换为 int 并与当前租户的产品数量进行比较。IFeatureChecker 还定义了 GetOrNullAsync 方法,该方法返回功能的字符串值,如果该功能未为当前租户定义值,则返回 null

检查其他租户的功能

IFeatureChecker 服务适用于当前租户。如果你有其他租户的 ID,并且想要检查该租户的功能值,首先切换到目标租户,如 在租户之间切换 部分所述,然后正常使用 IFeatureChecker 服务。

RequiresFeature 属性和 IFeatureChecker 服务在服务器端使用,但我们也需要在我们的客户端应用程序中获取和检查功能值。

在客户端检查功能

当你定义一个功能时,你需要知道它在客户端的值。例如,如果 MyApp.StockManagement 功能对当前租户未启用,你通常希望从应用程序页面中隐藏相关的 UI 元素,并禁用针对此功能的客户端到服务器的 HTTP API 调用。

ABP 提供了多个 UI 选项,每个选项都提供了一个不同的 API 来在客户端检查功能。例如,ABP MVC/Razor Pages UI 提供了全局的 abp.features JavaScript API 来检查功能,如下面的代码块所示:

if (abp.features.isEnabled('MyApp.StockManagement'))
{
  // TODO: ...
}

请参阅 第十二章检查租户功能 部分,使用 MVC/Razor Pages,以了解更多关于 abp.features JavaScript API 的详细信息。

另一方面,ABP Blazor UI 在客户端使用相同的 IFeatureChecker 服务。对于其他 UI 类型,请参阅 ABP 的文档:docs.abp.io/en/abp/latest/Features

你现在已经学会了如何获取和检查当前租户的功能值。下一节将解释如何为租户设置功能的值。

管理租户功能

在框架层面,ABP 不关心功能值存储在哪里以及如何更改。它只定义了一个接口,IFeatureStore,可以用来获取功能的当前值。然而,将其实现留给每个开发者并不是一个好的选择,因为大多数情况下,实现将是相似的,我们不希望浪费时间一次又一次地重新实现它。

ABP 框架提供了IFeatureStore接口,并提供了 UI 和 API 来修改租户的特征值。当您使用 ABP 的启动解决方案模板创建新解决方案时,特征管理模块已经安装。以下部分解释了特征管理 UI 模态和用于管理特征值的 API。

使用特征管理 UI 模态

特征管理模块可以自动创建设置特征值的模态对话框。然而,我们需要为每个特征定义值类型。返回到MyFeatureDefinitionProvider并更新特征定义如下:

myGroup.AddFeature(
    "MyApp.StockManagement",
    defaultValue: "false",
    displayName: L("StockManagement"),
    isVisibleToClients: true,
    valueType: new ToggleStringValueType());
myGroup.AddFeature(
    "MyApp.MaxProductCount", 
    defaultValue: "100",
    displayName: L("MaxProductCount"),
    valueType: new FreeTextStringValueType(
                   new NumericValueValidator()));

我在AddFeature方法中添加了valueType参数。第一个是ToggleStringValueType,表示该特征具有开/关风格的(布尔)值。第二个是FreeTextStringValueType,表示该特征具有应通过文本框更改的值。NumericValueValidator指定了值的验证规则。

一旦我们正确地定义了值类型,特征管理模块可以自动渲染设置特征值所需的 UI。要打开特征管理对话框,以授权主机用户身份登录应用程序,从主菜单导航到租户管理页面,点击操作按钮,然后选择特征操作,如图所示:

![Figure 16.2 – The Features action on the tenant management page

![img/Figure_16.2_B17287.jpg]

图 16.2 – 租户管理页面上的特征操作

此操作将打开一个模态对话框,如图所示:

![Figure 16.3 – The Feature Management dialog

![img/Figure_16.3_B17287.jpg]

图 16.3 – 特征管理对话框

我们可以在左侧看到组名(您也可以本地化组的显示名称)。当我们点击MyApp组时,我们可以看到设置特征值的表单元素。UI 界面是由特征管理模块动态创建的。

MyApp.StockManagement特征在 UI 上显示为一个复选框,而MyApp.MaxProductCount特征则显示为数字文本框。这样,您可以轻松地为任何租户设置特征值。除了 UI 之外,还可以使用特征管理 API 编程设置特征值。

使用特征管理 API

特征管理模块提供了IFeatureManager服务,可以通过编程方式为租户设置特征值。以下示例为指定的租户启用了MyApp.StockManagement特征:

public class MyCustomerService : DomainService
{
    private readonly IFeatureManager _featureManager;
    public MyCustomerService(IfeatureManager
                             featureManager)
    {
        _featureManager = featureManager;
    }

    public async Task EnableStockManagementAsync(Guid
                                                 tenantId)
    {
        await _featureManager.SetForTenantAsync(
            tenantId,
            "MyApp.StockManagement",
            "true"
        );
    }
}

我们在我们的类构造函数中注入了IFeatureManager服务,就像注入任何其他服务一样。然后,我们使用SetForTenantAsync方法将给定租户的值设置为true

何时使用多租户

多租户是一个创建 SaaS 解决方案的优秀模式,ABP 框架提供了一个完整的基础设施来创建多租户应用程序。然而,并非所有应用程序都应该是 SaaS,也并非所有 SaaS 应用程序都应该是多租户。ABP 的多租户系统有一些假设,我们在构建它时做出了一些设计决策。在本节中,我想讨论这些假设和决策,以帮助你决定 ABP 的多租户系统是否适合你的解决方案。

ABP 的多租户应用程序应该假设每个租户都将有一个分离和隔离的生产环境来开发。如果你做出这个假设,那么你将有一些限制。以下是一些示例限制:

  • 你不应该同时从多个租户执行数据库查询。如果你这样做,你假设你将有一个共享的租户数据库,因为从不同(可能隔离的)环境中查询多个数据库在技术上并不直接。

  • 租户的用户不能使用另一个租户登录到系统中。这意味着你不能将多个租户分配给单个用户,因为用户是完全隔离的。ABP 允许在不同的租户中使用相同的电子邮件地址或用户名,但实际上它们将是数据库中具有不同密码和标识符的不同用户,没有任何关联。

  • 你不能在不同租户之间共享角色(及其权限)。

如果你假设两个租户有不同的生产环境并且不能访问彼此的环境,这些限制是自然的。ABP 假设同一个应用程序可以在不进行任何代码更改的情况下(除了AbpMultiTenancyOptions.IsEnabled选项)在客户的本地部署上运行。

这些假设并不意味着租户不能共享数据。如果一个实体没有实现IMultiTenant接口,它将自然地在所有租户之间共享,并且始终存储在中央(主机)数据库中。此外,你可以切换租户以临时访问另一个租户用户的数据。然而,你应该考虑这种逻辑如何在本地环境中工作,或者你可以从你的解决方案中删除本地部署支持。

大多数混淆都来自于只从技术角度考虑多租户,而没有考虑其设计目的。例如,考虑一个电子市场,其中卖家管理和销售他们的产品。个人客户列出和搜索产品,添加到购物车,并完成支付。如果你假设卖家有自己的产品,并且卖家后台用户管理这些产品,这个应用程序可能看起来像是一个多租户系统。如果你使用 ABP 的多租户系统,所有的隔离都将自动完成,对吗?

虽然从技术角度来看,它有一些类似于多租户系统的要求,但市场可能包含作为统一平台集成的部分。在多租户系统中,客户(租户)表现得好像拥有整个系统。在市场中,供应商不是租户。它不会像在本地系统那样独立使用应用程序。因此,如果您从多租户开始,您将不得不后来处理数据共享和集成的问题,因为共享/集成部分比这种系统中的隔离部分要多得多。

摘要

在本章中,我们探讨了 ABP 框架提供的根本基础设施。我们学习了 ABP 如何确定当前租户并隔离租户之间的数据。我们还学习了如何在需要时切换到另一个租户或完全禁用数据隔离。

另一个伟大的 ABP 功能是功能系统。我们通过创建功能提供者类来定义功能,并学习了检查当前用户功能值的多种方法。

现在,您能够从事多租户应用程序的开发工作,其中租户可以对应用程序功能拥有不同的权限。

下一章介绍了不同级别的自动化测试,并解释了您如何为基于 ABP 的解决方案创建单元和集成测试。

第十七章:第十七章:构建自动化测试

构建自动化测试是创建可维护的软件解决方案的必要GetRegistrationOrNull实践,并且是一种快速且可重复的验证软件的方法。ABP 框架和 ABP 启动解决方案模板都是考虑到可测试性而设计的。我们已经在第三章,“逐步应用开发”中看到了使用 ABP 框架编写简单集成测试的例子。

在本章中,你将了解 ABP 测试基础设施,并为基于 ABP 的解决方案构建单元和集成测试。你将学习测试的数据初始化、模拟数据库、测试不同类型的对象。你还将学习自动化测试的基础,如断言、模拟和替换服务,以及处理异常。

下面是本章涵盖的主要主题列表:

  • 理解 ABP 测试基础设施

  • 构建单元测试

  • 构建集成测试

技术要求

如果你想遵循本章中的示例,你需要有一个支持 ASP.NET Core 开发的 IDE/编辑器。

本章中的示例大多基于我在第四章,“理解参考解决方案”中介绍的 EventHub 解决方案。请参考该章节以了解如何下载 EventHub 解决方案的源代码。

理解 ABP 测试基础设施

ABP 的启动解决方案模板包括预配置的测试项目,用于为你的解决方案构建单元和集成测试。虽然你可以在不理解完整结构的情况下编写测试,但我认为探索这一点是值得的,这样你可以了解它是如何工作的,并在需要时进行自定义。我们将从探索test项目开始。

探索测试项目

以下截图显示了创建新的 ABP 解决方案时创建的test项目:

![Figure 17.1 – ABP 启动解决方案中的测试项目]

![img/Figure_17.01_B17287.jpg]

图 17.1 – ABP 启动解决方案中的测试项目

上一张截图显示了名为ProductManagement的解决方案的test项目,如果你使用不同的 UI 或数据库提供者,test项目列表可能会有所不同,但基本逻辑是相同的。以下列表对项目进行了概括说明:

  • ProductManagement.HttpApi.Client.ConsoleTestApp:一个用于手动测试应用程序 HTTP API 端点的非常简单的控制台应用程序。因此,这并不是我们自动化测试基础设施的一部分,你可以在本章中忽略它。

  • ProductManagement.TestBase:一个由其他测试项目共享的项目。它引用了基础测试库,包括数据初始化和一些其他基础配置代码。通常不包含任何测试类。

  • ProductManagement.EntityFrameworkCore.Tests:您可以在该项目中构建 EF Core 集成代码的测试,例如您的自定义仓储。该项目还为您测试配置了一个 SQLite 内存数据库。

  • ProductManagement.Domain.Tests:使用此项目构建您的领域层的测试。

  • ProductManagement.Application.Tests:使用此项目构建您的应用层的测试。

  • ProductManagement.Web.Tests:使用此项目构建您的 MVC/Razor Pages UI 的测试。

该解决方案使用一些库作为测试基础设施,如下一节所述。

探索测试库

ProductManagement.TestBase项目引用了以下 NuGet 包:

  • xunit:xUnit 是.NET 中最受欢迎的测试框架之一。

  • Shouldly:一个用于以简单和可读的格式编写断言代码的库。

  • NSubstitute:一个用于单元测试中模拟对象的库。

  • Volo.Abp.TestBase:ABP 的包,用于轻松创建 ABP 集成测试类。

我们将在构建单元测试构建集成测试部分中看到如何使用这些库的基本功能。在我们开始编写测试之前,让我们看看如何运行测试。

运行测试

在本节中,我将展示两种运行测试的方法。第一种方法是使用支持运行测试执行的开发环境。我将使用 Visual Studio 作为示例。您可以从主菜单的测试 | 测试资源管理器项打开测试资源管理器窗口:

图 17.2 – Visual Studio 中的测试资源管理器

图 17.2 – Visual Studio 中的测试资源管理器

第三章中构建的ProductManagement应用程序,逐步应用开发,源代码可以在github.com/PacktPublishing/Mastering-ABP-Framework找到。

Visual Studio 默认逐个运行测试,因此运行所有测试需要很长时间。您可以通过点击文本探索器中齿轮图标附近的向下箭头图标,选择并行运行测试选项(见图 17.3)来并行运行测试,这样运行所有测试的时间将显著减少:

图 17.3 – 在 Visual Studio 中并行运行测试

图 17.3 – 在 Visual Studio 中并行运行测试

ABP 框架和启动解决方案模板已被设计为支持并行运行测试,这样测试不会相互影响。

运行测试的另一种方法是使用解决方案根目录中的 dotnet test 命令。它自动发现并运行所有测试,并在命令行终端中报告测试结果。如果所有测试都成功,则此命令以 0(成功)返回代码退出;如果任何测试失败,则它以 1 返回代码退出。此命令在构建 持续集成CI)管道时特别有用,其中可以自动运行测试。

你已经学习了 ABP 启动解决方案的测试结构并运行了自动化测试。现在,我们可以开始构建我们的测试。

构建单元测试

在本节中,我们将看到不同类型的单元测试。我们将从测试一个静态类开始,然后为没有依赖关系的类编写测试。我们将继续测试具有依赖服务的类,并学习如何模拟这些依赖以对那个类进行单元测试。我们将通过示例学习编写自动化测试代码的基础。

让我们从最简单的情况开始——测试静态类。

测试静态类

没有状态和外部依赖的静态类是最容易测试的类。EventUrlHelper 是一个静态类(位于 EventHub 解决方案的 EventHub.Domain 项目中),用于将事件标题转换为合适的 URL 部分。以下测试类(位于 EventHub 解决方案的 EventHub.Domain.Tests 项目中)测试了 EventUrlHelper 类:

public class EventUrlHelper_Tests
{
    [Fact]
    public void Should_Convert_Title_To_Proper_Urls()
    {
        var url = EventUrlHelper.ConvertTitleToUrlPart(
                  "Introducing ABP Framework!");
        Assert.Equal("introducing-abp-framework", url);
    }
}

第一条规则是测试类应该是 public 的。否则,你无法在由 xUnit 库定义的 [Fact] 属性中看到它。任何带有 [Fact] 属性的公共方法都被视为测试用例,并由 Should_Convert_Url_To_Kebab_Case 自动发现,并且只测试与 kebab-case 相关的功能性。

本例中的测试代码非常简单。我们调用静态 EventUrlHelper.ConvertTitleToUrlPart 方法,并使用一个示例标题值,然后比较结果与我们期望的值。Assert 类由 xUnit 定义,具有许多方法来定义我们的期望。只有当给定的值相等时,测试用例才成功。否则,我们在 Test Explorer 中看到测试用例的红色图标,并显示一个错误消息,指出测试的问题。

你可以在 Test Explorer 中的特定测试上右键单击以运行它并查看结果,如下面的截图所示:

![图 17.4 – 在 Visual Studio 的 Test Explorer 中运行特定测试

![图 17.4 – 在 Visual Studio 的 Test Explorer 中运行特定测试

图 17.4 – 在 Visual Studio 的 Test Explorer 中运行特定测试

另一个常见的 xUnit 属性是 [Theory],它为测试方法提供参数,并对每个参数集进行测试。假设我们想要使用不同的活动 URL 运行测试,我们可以重写测试方法,如下面的代码块所示:

public class EventUrlHelper_Tests
{
    [Theory]
    [InlineData("Introducing ABP Framework!",
                "introducing-abp-framework")]
    [InlineData("Blazor: UI Messages", 
                "blazor-ui-messages")]
    [InlineData("What's new in .NET 6", 
                "whats-new-in-net-6")]
    public void Should_Convert_Title_To_Proper_Urls(
        string title, string url)
    {
        var result = 
            EventUrlHelper.ConvertTitleToUrlPart(title);
        result.ShouldBe(url);
    }
}

xUnit为每个[InlineData]集合单独运行此测试方法,并将titleurl参数作为给定数据传递。如果您再次查看Test Explorer,您将看到这三个测试案例在那里:

![Figure 17.5 – Using the [Theory] attribute for unit tests

![img/Figure_17.05_B17287.jpg]

图 17.5 – 使用[Theory]属性进行单元测试

我还在这个例子中使用了Shouldly库进行断言。result.ShouldBe(url)表达式比Assert.Equal(url, result)表达式更容易编写和阅读。Shouldly库与这样的扩展方法一起工作,我将在未来的示例中使用它。

测试无状态和外部依赖的静态类(类)很容易。我们还学习了一些xUnitShouldly功能。下一节将继续测试没有服务依赖的简单类。

测试无依赖关系的类

一些类,如实体,可能没有对其他服务的依赖。测试这些类相对容易,因为我们不需要准备依赖来使类正常工作。

以下测试方法测试Event类的构造函数:

public class Event_Tests
{
    [Fact]
    public void Should_Create_A_Valid_Event()
    {
        new Event(
            Guid.NewGuid(),
            Guid.NewGuid(),
            "1a8j3v0d",
            "Introduction to the ABP Framework",
            DateTime.Now,
            DateTime.Now.AddHours(2),
            "In this event, we will introduce the ABP 
             Framework..."
        );
    }
}

在这个例子中,我传递了一个有效的参数列表,这样它就不会抛出异常,测试成功。以下示例测试异常情况:

[Fact]
public void 
    Should_Not_Allow_End_Time_Earlier_Than_Start_Time()
{
    var exception = Assert.Throws<BusinessException>(() =>
    {
        new Event(
            Guid.NewGuid(),
            Guid.NewGuid(),
            "1a8j3v0d",
            "Introduction to the ABP Framework",
            DateTime.Now, // Start time
            DateTime.Now.AddDays(-2), // End time
            "In this event, we will introduce the ABP
             Framework..."
        );
    });
    exception.Code.ShouldBe(EventHubErrorCodes
        .EventEndTimeCantBeEarlierThanStartTime);
}

我故意将结束时间设置为比开始时间早 2 天。我期望构造函数通过使用Assert.Throws<T>方法抛出BusinessException异常。如果Throws方法内部的代码块抛出BusinessException类型的异常,则测试通过;否则,测试将失败。我还使用ShouldBe扩展方法检查错误代码。

让我们编写一个测试Event类另一个方法的函数。以下示例创建了一个有效的Event对象,然后更改其开始和结束时间,并最终检查时间是否已更改:

[Fact]
public void Should_Update_Event_Time()
{
    // ARRANGE
    var evnt = new Event(
        Guid.NewGuid(),
        Guid.NewGuid(),
        "1a8j3v0d",
        "Introduction to the ABP Framework",
        DateTime.Now,
        DateTime.Now.AddHours(2),
        "In this event, we will introduce the ABP
         Framework..."
    );
    var newStartTime = DateTime.Now.AddHours(1);
    var newEndTime = DateTime.Now.AddHours(2);
    //ACT
    evnt.SetTime(newStartTime, newEndTime);
    //ASSERT
    evnt.StartTime.ShouldBe(newStartTime);
    evnt.EndTime.ShouldBe(newEndTime);
    evnt.GetLocalEvents()
        .ShouldContain(x => x.EventData
                       is EventTimeChangingEventData);
}

此示例完全实现了常见的Arrange-Act-AssertAAA)测试模式,详细说明如下:

  • Arrange部分准备我们需要工作的对象。

  • Act部分执行我们想要测试的实际代码。

  • Assert部分检查期望是否得到满足。

我建议使用这些注释行分隔测试方法的主体,以使您正在测试和断言的内容明确。在这个例子中,我们使用了Event类的SetTime方法来更改事件时间。SetTime方法还发布了一个本地事件,因此我在Assert部分也检查了它。

如您在示例中看到的,如果我们想要测试的类没有外部依赖,我们可以简单地创建一个实例并在其上执行方法。在下一节中,我们将看到如何处理外部依赖。

测试具有依赖关系的类

大多数服务都依赖于其他服务。我们使用依赖注入DI)系统将这些依赖项注入到服务的构造函数中。单元测试的目的是测试一个类尽可能独立于其他类,因为单元测试通常只有一个失败的理由。我们应该在测试目标类时排除这些依赖项。这样,我们的测试会受到目标类变化的影响,但不会受到其他类变化的影响。

模拟是单元测试中用来用假实现替换目标类依赖项的技术,这样测试就不会受到目标类依赖项的影响。

我将以EventRegistrationManager类的IsPastEvent方法为例进行测试。IsPastEvent方法获取一个事件。

EventRegistrationManager是一个领域服务,并在其构造函数中接受三个外部服务,如下面的简化代码块所示:

public class EventRegistrationManager : IDomainService
{
    ...
    public EventRegistrationManager(
        IEventRegistrationRepository 
            eventRegistrationRepository,
        IGuidGenerator guidGenerator,
        IClock clock)
    {
        _eventRegistrationRepository = 
            eventRegistrationRepository;
        _guidGenerator = guidGenerator;
        _clock = clock;
    }
    public bool IsPastEvent(Event @event)
    {
        return _clock.Now > @event.EndTime;
    }
}

我们应该传递这三个外部服务的实例,以便能够创建一个EventRegistrationManager对象。下面的代码块显示了我是如何为该类的IsPastEvent方法编写测试方法的:

public class EventRegistrationManager_UnitTests
{
    [Fact]
    public void IsPastEvent()
    {
        var clock = Substitute.For<IClock>();
        clock.Now.Returns(DateTime.Now);
        var registrationManager = new 
            EventRegistrationManager(null, null, clock
        );
        var evnt = new Event(
            Guid.NewGuid(),
            Guid.NewGuid(),
            "1a8j3v0d",
            "Introduction to the ABP Framework",
            DateTime.Now.AddDays(-10), // Start time
            DateTime.Now.AddDays(-9), // End time
            "In this event, we will introduce the ABP
             Framework..."
        );
        registrationManager.IsPastEvent(evnt)
            .ShouldBeTrue();
    }
}

测试代码首先使用NSubstitute库的Substitute.For<T>实用方法创建了一个假的IClock对象。clock.Now.Returns(DateTime.Now)语句配置了假对象,使其在调用clock.Now属性时返回DateTime.Now。我们这样做是因为IsPastEvent方法将调用clock.Now属性。这意味着我们应该了解单元测试方法的内部实现细节,以便正确测试它。

由于我知道IsPastEvent方法不会使用IEventRegistrationRepositoryIGuidGenerator服务,我可以在EventRegistrationManager类的构造函数中将它们传递为null

最后,我使用一个示例事件调用了EventRegistrationManager类的IsPastEvent方法并检查了结果。

让我们看看一个更复杂的例子。这次,我们正在测试EventRegistrationManager类的RegisterAsync方法。代码如下所示:

[Fact]
public async Task 
    Valid_Registrations_Should_Be_Inserted_To_Db()
{
    var evnt = new Event(/* some valid arguments */);
    var user = new IdentityUser(/* some valid arguments 
                                */);
    var repository =
        Substitute.For<IEventRegistrationRepository>();
    repository
        .ExistsAsync(evnt.Id, user.Id)
        .Returns(Task.FromResult(false));
    var clock = Substitute.For<IClock>();
    clock.Now.Returns(DateTime.Now);
    var guidGenerator = SimpleGuidGenerator.Instance;
    var registrationManager = new EventRegistrationManager(
        repository, guidGenerator, clock
    );
    await registrationManager.RegisterAsync(evnt, user);
    await repository
        .Received()
        .InsertAsync(
            Arg.Is<EventRegistration>(
            er => er.EventId == evnt.Id && er.UserId == 
                user.Id)
    );
}

首先,我创建了一个Event对象和一个IdentityUser对象,因为RegisterAsync方法需要这些参数。然后,我模拟了EventRegistrationManager的依赖项。由于RegisterAsync方法使用了所有依赖项,我不得不模拟它们所有。看看我是如何配置假仓库在调用ExistsAsync方法时返回false的。RegisterAsync方法使用ExistsAsync方法来检查是否已经存在具有相同事件和用户的注册。

执行RegisterAsync方法后,我应该以某种方式检查注册是否完成。我可以使用NSubstituteReceived方法来检查仓库的InsertAsync方法是否被调用,并带有指定的事件和用户标识符UIDs)的EventRegistration对象。

在本节中,我已经介绍了单元测试的基础知识。与集成测试相比,单元测试有两个主要优势,如下所述:

  • 它们运行速度快,因为只有被测试的类真正工作。所有其他类都是模拟的,通常没有执行成本。

  • 它们使问题调查变得更容易。如果一个类工作不正常,只有针对该类的测试会失败,因此你可以轻松地找到问题的根源。

然而,当你的类有依赖关系时,编写和维护单元测试是困难的。单元测试也无法充分说明你的类在与其他服务集成运行时是否能够正常工作。这引出了集成测试的概念。

构建集成测试

在本节中,我们将了解如何为集成到 ABP 框架和其他基础设施组件的服务构建自动化测试。我们将从理解 ABP 集成开始,了解集成测试中数据库的使用方式,以及如何创建初始测试数据。然后,我们将编写存储库、领域和应用程序服务的示例测试。让我们从 ABP 集成开始。

理解 ABP 集成

ABP 提供了Volo.Abp.TestBase NuGet 包,其中包含用于集成测试的AbpIntegratedTest<TStartupModule>基类。我们可以从该类继承以编写与 ABP 框架完全集成的测试。以下示例显示了此类测试的主要部分:

public class SampleTestClass
    : AbpIntegratedTest<MyTestModule>
{
    private IMyService _myService;
    public SampleTestClass()
    {
        _myService = GetRequiredService<IMyService>();
    }

    [Fact]
    public async Task TestMethod()
    {
        await _myService.DoItAsync();
    }
}

在这个例子中,我继承了AbpIntegratedTest<MyTestModule>类,其中MyTestModule是我的启动模块类。MyTestModule应该依赖于AbpTestBaseModule,如下面的示例所示:

[DependsOn(typeof(AbpTestBaseModule))]
public class MyTestModule : AbpModule
{
}

SampleTestClass的构造函数中,我使用GetRequiredService方法解析了一个示例服务,并将其分配给一个类字段。由于所有基础设施都可用,我们可以从 DI 系统中解析服务,就像在运行时一样。我不需要关心服务的依赖关系。最后,我在测试方法中调用了示例服务的方法。

虽然编写集成测试如此简单,但启动模板中的测试项目还有一些额外的内容。检查EventHubTestBaseModule类(位于 EventHub 解决方案的EventHub.TestBase项目中)。你会看到它禁用了后台作业和授权,播种了一些测试数据,并进行了其他配置。

我们已经在测试类中学习了与 ABP 集成的基础知识。在下一节中,你将学习如何处理测试中的数据库。

模拟数据库

数据库是构建集成测试时最基本的部分之一。假设你在你的解决方案中使用 SQL Server。使用真实的 SQL Server 数据库有一些基本问题;由于它们将在同一个数据库上工作,因此测试会相互影响。数据库中的测试更改可能会破坏后续的测试。你可能无法并行运行测试。由于你的应用程序将作为外部进程与 SQL Server 通信,因此测试执行速度会较慢。无需提及 SQL Server 应该在测试环境中安装并可用。

EF Core 提供了一个内存数据库选项,但它非常有限。例如,它不支持事务,并且无法执行 SQL 命令。因此,我建议根本不要使用它。

ABP 启动模板已经配置为使用 SQLite 内存数据库进行 EF Core(它还使用Mongo2Go库为 MongoDB 提供内存数据库)。SQLite 是一个真正的关系型数据库管理系统,对于大多数应用程序来说将足够使用。

检查EventHubEntityFrameworkCoreTestModule类(位于 EventHub 解决方案的EventHub.EntityFrameworkCore.Tests项目中),以查看 SQLite 的设置。它为每个测试用例创建一个单独的内存 SQLite 数据库,在数据库中创建表,并初始化测试数据。这样,每个测试方法都以相同的初始状态开始,并且不会影响其他测试。我们将在下一节中看到测试数据的初始化。

初始化测试数据

在空数据库上编写测试并不那么实用。假设你想要查询数据库中的事件或者想要测试事件注册代码是否工作。你首先需要将一些实体插入到数据库中。为每个测试准备数据库可能会很繁琐。相反,我们可以在数据库中创建一些初始实体,这些实体对每个测试都是可用的。

ABP 启动解决方案模板使用 ABP 的数据初始化系统将一些初始数据填充到数据库中。查看 EventHub 解决方案的EventHub.TestBase项目中的EventHubTestDataSeedContributor类。它在数据库中创建了一些用户、组织和事件,因此我们可以直接编写假设初始数据存在的测试。

我们已经讨论了 ABP 的集成测试基础设施,包括数据库的模拟和初始化。现在,我们可以从存储库开始编写一些集成测试。

测试存储库

让我们以 EventHub 解决方案的EventHub.Domain.Tests项目中的EventRegistrationRepository_Tests类为例:

public class EventRegistrationRepository_Tests
    : EventHubDomainTestBase
{
    private readonly IEventRegistrationRepository 
        _repository;
    private readonly EventHubTestData _testData;
    public EventRegistrationRepository_Tests()
    {
        _repository = GetRequiredService<
                      IEventRegistrationRepository>();
        _testData = GetRequiredService<EventHubTestData>();
    }
    // TODO: Test methods come here...
}

此类继承自 EventHubDomainTestBase 类,该类间接继承自我们在 Understanding the ABP integration 部分中探讨的 AbpIntegratedTest<T> 类。因此,在构造函数中,我们可以从 DI 系统解析 IEventRegistrationRepositoryEventHubTestData 服务。你可以自己调查 EventHubTestData 类(在 EventHub 解决方案的 EventHub.TestBase 项目中)。它基本上存储了最初种入数据库的实体的 Id 值,以便在测试中访问它们。

让我们看看 EventRegistrationRepository_Tests 类的第一个测试方法。如下所示:

[Fact]
public async Task
    Exists_Should_Return_False_If_Not_Registered()
{
    var exists = await _repository.ExistsAsync(
        _testData.AbpMicroservicesFutureEventId, 
        _testData.UserJohnId);
    exists.ShouldBeFalse();
}

此测试简单地执行 ExistsAsync 方法并检查结果为 false。它应该返回 false,因为我们知道用户 John 没有注册给定的活动。我们知道这一点是因为我们在数据库中编写了初始数据(见 Seeding the test data 部分)。让我们再写一个测试,如下所示:

[Fact]
public async Task Exists_Should_Return_True_If_Registered()
{
    await _repository.InsertAsync(
        new EventRegistration(
            Guid.NewGuid(),
            _testData.AbpMicroservicesFutureEventId,
            _testData.UserJohnId));

    var exists = await _repository.ExistsAsync(
        _testData.AbpMicroservicesFutureEventId, 
        _testData.UserJohnId);
    exists.ShouldBeTrue();
}

这次,我们在数据库中创建注册记录,因此我们期望相同的 ExistsAsync 调用返回 true。这样,我们可以为特定的测试准备数据库以获得预期的结果。

ABP 的存储库提供了 GetQueryableAsync 方法,因此我们可以在测试中直接使用 queryable):

[Fact]
public async Task Test_Querying()
{
    var queryable = await _repository.GetQueryableAsync();
    var exists = await queryable.Where(
        x => x.EventId ==
             _testData.AbpMicroservicesFutureEventId &&
             x.UserId == _testData.UserJohnId
        ).FirstOrDefaultAsync();
    exists.ShouldBeNull();
}

此方法使用 WhereFirstOrDefaultAsync LINQ 扩展方法查询相同的注册。如果你尝试运行此测试,你会看到它抛出一个异常(类型为 ObjectDisposedException),因为 GetQueryableAsync 方法需要一个活动的 WithUnitOfWorkAsync 方法来在 UoW 中执行代码,因此我们可以像以下代码块中所示的那样修复测试代码:

[Fact]
public async Task Test_Querying_With_Uow()
{
    await WithUnitOfWorkAsync(async () =>
    {
        var queryable = 
            await _repository.GetQueryableAsync();
        var exists = await queryable.Where(
            x => x.EventId ==
                 _testData.AbpMicroservicesFutureEventId &&
                 x.UserId == _testData.UserJohnId
        ).FirstOrDefaultAsync();
        exists.ShouldBeNull();
    });
}

你可以看到 WithUnitOfWorkAsync 方法的源代码。它只是使用 IUnitOfWorkManager 创建 UoW 范围。

我们为存储库创建了一些测试方法。你可以以相同的方式测试任何服务(已注册到 DI 系统)。我将在下一节展示一些领域服务和应用服务的示例测试。

测试领域服务

测试领域服务与测试存储库类似,因为你也应该关心领域服务的 UoW。以下代码块显示了来自 EventManager_Tests 类(在 EventHub 解决方案的 EventHub.Domain.Tests 项目中)的示例测试用例:

[Fact]
public async Task Should_Update_The_Event_Capacity()
{
    const int newCapacity = 42;
    await WithUnitOfWorkAsync(async () =>
    {
        var @event = await _eventRepository.GetAsync(
            _testData.AbpMicroservicesFutureEventId);
        await _eventManager.SetCapacityAsync(
            @event,
            newCapacity
        );
    });

    var @event = await _eventRepository.GetAsync(
        _testData.AbpMicroservicesFutureEventId);
    @event.Capacity.ShouldBe(newCapacity);
}

测试的目的是使用 EventManager 领域服务增加活动的容量,并查看它是否工作。它使用 WithUnitOfWorkAsync 方法调用 SetCapacityAsync 方法,因为 SetCapacityAsync 方法内部执行 CountAsync LINQ 扩展方法,需要一个活动的 UoW。如果你不想在每种情况下都检查领域服务的内部,我建议在测试中使用领域服务或存储库时始终启动 UoW。在 UoW 之后,我重新从数据库查询了相同的活动以检查容量是否已更新。

你可以探索EventManager_Tests类和其他在EventHub.Domain.Tests项目中的测试类,以获取所有详细信息以及更复杂的测试用例。在下一节中,我将展示测试应用服务。

测试应用服务

在本节中,我们将检查一个针对EventRegistrationAppService类(在 EventHub 解决方案的EventHub.Application项目中定义)的测试。EventRegistrationAppService_Tests(在 EventHub 解决方案的EventHub.Application.Tests项目中定义)是包含该应用服务测试的测试类。你可以在解决方案中探索这个类。在这里,我将部分展示它以解释其工作原理。

让我们从当前用户注册事件的测试方法开始。你可以在这里看到它:

[Fact]
public async Task Should_Register_To_An_Event()
{
    Login(_testData.UserAdminId);
    await _eventRegistrationAppService.RegisterAsync(
        _testData.AbpMicroservicesFutureEventId
    );
    var registration = await GetRegistrationOrNull(
        _testData.AbpMicroservicesFutureEventId,
        _currentUser.GetId()
    );    
    registration.ShouldNotBeNull();
}

第一行将当前用户设置为管理员用户,这是必需的,因为EventRegistrationAppService.RegisterAsync方法适用于当前用户。让我们看看Login方法的实现,如下所示:

private void Login(Guid userId)
{
    _currentUser.Id.Returns(userId);
    _currentUser.IsAuthenticated.Returns(true);
}

它配置了_currentUser对象,当使用其Id属性时返回给定的userId值。正如你可能猜到的,_currentUser是类型为ICurrentUser的模拟(伪造)对象。模拟对象在AfterAddApplication方法中配置,如下面的代码片段所示:

protected override void AfterAddApplication(
    IServiceCollection services)
{
    _currentUser = Substitute.For<ICurrentUser>();
    services.AddSingleton(_currentUser);
}

此方法覆盖了AbpIntegratedTest<T>基类的AfterAddApplication方法。我们可以覆盖此方法,在初始化阶段完成之前对 DI 配置进行最后的调整。在这里,我使用NSubstitute库创建了一个模拟对象,并将其作为单例服务(记住,最后注册的类/对象用于服务)。这样,我可以更改其值,并且所有使用ICurrentUser的服务都会受到影响。

设置当前用户后,测试方法会像通常一样调用EventRegistrationAppService.RegisterAsync方法。最后,我检查了数据库以查看是否保存了注册记录。GetRegistrationOrNull方法的实现如下所示:

private async Task<EventRegistration>
    GetRegistrationOrNull(Guid eventId, Guid userId)
{
    return await WithUnitOfWorkAsync(async () =>
    {
        return await _eventRegistrationRepository
           .FirstOrDefaultAsync(
                x => x.EventId == eventId && x.UserId == 
                    userId
        );
    });
}

我在这里再次使用了WithUnitOfWorkAsync,因为FirstOrDefaultAsync方法需要一个活动的 UoW。

正如我们在示例中看到的,使用 ABP 框架编写集成测试既简单又直接。我们很少需要模拟服务并处理我们针对测试的目标服务的依赖关系。

集成测试的运行速度比单元测试慢,但它们允许你测试组件之间的集成,并且可以以你无法使用单元测试的方式测试数据库查询。我建议平衡且实际——为你的解决方案构建单元测试和集成测试。

摘要

准备测试是构建任何类型软件解决方案的基本实践。正如我们在本章中看到的,ABP 提供了基本的基础设施来帮助你为你的应用程序编写测试。

我们已经通过示例探索了使用 ABP 框架的单元和集成测试。我从 EventHub 解决方案中选择了示例。该解决方案还包含更复杂的测试,我建议您探索它们。

到现在为止,您应该已经开始编写自动化测试来覆盖您的服务器端代码。您已经看到了 ABP 启动解决方案的结构以及数据库是如何被模拟的。您已经学习了如何处理异常、UoWs、数据初始化、对象模拟和其他常见的测试模式。

这本书的最后一章。如果您已经读到这儿,并跟随了示例,您已经学到了使用 ABP 框架的基础知识、特性和最佳实践。您已经准备好构建基于 ABP 的解决方案来实现您的软件想法。

您可以在开发过程中参考这本书,并在需要更多详细信息和最新信息时查看 ABP 框架的文档:docs.abp.io

最后,如果您有任何问题,请随意在 ABP 框架的 GitHub 仓库中创建问题:github.com/abpframework/abp。我将继续成为这个伟大项目的活跃贡献者,并尝试回答您的问题。

我是 Halil İbrahim Kalkan,ABP 框架精通一书的作者。我真心希望您喜欢阅读这本书,并发现它对提高您在 ABP 框架中的生产力和效率有所帮助。

如果您能在亚马逊上留下对《ABP 框架精通》的评价,分享您的想法,这将对我(以及其他潜在读者!)非常有帮助。

前往以下链接或扫描二维码留下您的评价:

packt.link/r/1801079242

二维码

您的评价将帮助我了解这本书中哪些地方做得好,以及未来版本中哪些地方可以改进,所以这真的非常感谢。

祝好运,

作者签名作者照片

Halil İbrahim Kalkan

Packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及领先的行业工具,帮助你规划个人发展并推进职业生涯。有关更多信息,请访问我们的网站。

第十八章:为什么订阅?

  • 通过来自超过 4,000 位行业专业人士的实用电子书和视频,节省学习时间,更多时间编码

  • 通过为你量身定制的技能计划提高你的学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于快速访问关键信息

  • 复制粘贴、打印和收藏内容

你知道 Packt 为每本书提供电子书版本,包括 PDF 和 ePub 文件吗?你可以在packt.com升级到电子书版本,并且作为印刷书客户,你有权获得电子书副本的折扣。有关更多信息,请联系我们customercare@packtpub.com

www.packt.com,你还可以阅读一系列免费的技术文章,注册各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

你可能还会喜欢的其他书籍

如果你喜欢这本书,你可能还会对 Packt 的其他书籍感兴趣:

包含文本、猫、树、哺乳动物的图片自动生成的描述](https://www.packtpub.com/product/web-development-with-blazor/9781800208728)

使用 Blazor 进行 Web 开发

Jimmy Engström

ISBN: 978-1-80020-872-8

了解可以与 Blazor 一起使用的不同技术,例如 Blazor Server 和 Blazor WebAssembly。

  • 了解如何构建简单和高级的 Blazor 组件。

  • 探索 Blazor Server 和 Blazor WebAssembly 项目之间的差异

  • 了解 Entity Framework 的工作原理,并构建一个简单的 API。

  • 熟悉组件,并了解如何创建基本和高级组件。

图形用户界面自动生成的描述,中等置信度](https://www.packtpub.com/product/customizing-asp-net-core-6-0-second-edition/9781803233604)

定制 ASP.NET Core 6.0

Jürgen Gutsch

ISBN: 978-1-80323-360-4

  • 探索 ASP.NET Core 6 中的各种应用程序配置和提供者。

  • 启用并使用缓存来提高应用程序的性能。

  • 了解.NET 中的依赖注入,并学习如何添加第三方 DI 容器。

  • 探索中间件的概念,并为 ASP.NET Core 应用编写中间件。

  • 在你的 API 驱动型项目中创建各种 API 输出格式。

Packt 正在寻找像你这样的作者

如果你有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球技术社区。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

你可能还会喜欢的其他书籍

你可能还会喜欢的其他书籍

posted @ 2025-10-26 08:51  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报