大家好,我是张飞洪,感谢您的阅读,我会不定期和你分享学习心得,希望我的文章能成为你成长路上的垫脚石,让我们一起精进。
关于玩转ABP框架相关的文章,之前在博客园陆续写了《ABP vNext系列文章和视频》,大家可以跳转过去看,后续文章首发主要以CSDN为主。
言归正传,ABP 框架的主要目标是为应用程序开发引入一种架构方法,并提供必要的基础设施和工具。
领域驱动设计(DDD) 是 ABP 产品架构的核心内容之一。ABP 代码脚手架是基于 DDD 进行逻辑分层的。包括它的实体、应用服务、存储库、领域服务、领域事件、规约等。
由于 DDD 是 ABP 应用开发架构的核心,因此除了理论部分,我们有必要对实现细节进行深入分析。
一、DDD 核心概念
在我们介绍实现细节之前,让我们先了解 DDD 的核心概念和构建模块。让我们从 DDD 的定义开始。
1.什么是领域驱动设计?
DDD 是一种针对复杂需求的软件开发方法,它适用于复杂领域和大规模应用。对于简单的增删改查(CRUD),您通常不需要遵循所有 DDD 原则。但是,在复杂的应用中遵循 DDD 原则和模式可以帮助您构建灵活、模块化和可维护的代码库。
DDD 关注核心领域逻辑而不是基础设施细节,这些细节通常与业务代码隔离。
DDD 与面向对象编程(OOP) 原则密切相关。本书并未涵盖这些基本原则,但对 OOP 和单一职责、开闭、Liskov 替换、接口隔离和依赖倒置(SOLID) 原则的良好理解仍然会对您有很大帮助。
以上提供了简短的定义,我们先探索 DDD 的基本分层。
上图显示了各层及其关系:
- 领域层包含基本的业务对象,它是独立的可重用的领域逻辑。该层不依赖于任何其他层,但所有其他层直接或间接依赖于它。
- 应用层实现应用操作,通常是用户通过 UI 执行的操作。应用层调用领域层的对象来执行这些操作。
- 表示层包含应用的UI 组件,例如 Web 应用的视图、JavaScript 和 CSS 文件。它不直接调用领域层或数据库对象。相反,它调用应用层。通常,对于在 UI 上执行的每个用例/操作,应用层都有相应的功能/方法。
- 基础设施层依赖于所有其他层并实现这些层定义的抽象。它有助于优雅地将您的业务逻辑与第三方库和系统(例如数据库或缓存提供程序)分开。
以上模型的每一层都有一个职责,并包含各种构建模块。
1.1 DDD相关概念澄清
从技术角度来看,DDD 是主要与您的业务代码有关。业务逻辑分为两层——领域层和应用层。其他层(表示层和基础设施)被视为实现细节。
领域层包含的概念有如下:
- 实体:实体是具有状态(属性)和业务逻辑的业务对象。一个实体具有一个唯一标识符 (ID),用于将该实体与其他实体区分开来。这意味着具有不同标识符的两个实体被视为不同的实体,即使所有其他属性都相同。
- 值对象:值对象是另一种类型的业务对象。值对象由它们的状态(属性)标识,它们没有标识符 (ID)。这意味着如果两个值对象的所有属性都相同,则它们被认为是相同的。值对象通常比实体更简单,并且通常不可变的。例如地址、货币或日期。
- 聚合和聚合根:聚合是由聚合根组织起来的一组对象(实体和值对象)集合。聚合根负责管理和协调实体对象。
- 存储库:存储库是一个类集合的接口,领域和应用层使用它来访问持久化系统。它隐藏了数据库提供者的复杂性。
- 领域服务:领域服务是实现核心业务规则的无状态服务(类)。它的实现依赖于多种聚合(当这些聚合都不能实现逻辑的时候)或外部服务。
- 规约:规约是一个可重用、可测试和可组合的Lamda过滤器,用于业务规则的封装和抽象。
- 领域事件:领域事件是一种以松散耦合的方式通知其他服务的通知。它对于连接跨多个聚合很有用。
应用层包含以下概念:
- 应用服务:应用服务是实现业务应用的无状态服务(类)。它通常获取和返回数据传输对象,其方法被表示层调用。它通过编排领域层对象来执行特定的业务。业务通常表现为事务(原子)过程。
- 数据传输对象(DTO):DTO 用于在表示层和应用层之间传输数据(状态)。它不包含任何业务逻辑。
- 工作单元(UOW):UOW 是事务边界。UOW 中的所有状态更改(通常是数据库操作)必须以原子方式实现,成功时一起提交,失败时一起回滚。
了解并熟悉 DDD 的核心概念很重要,这也是我在这里简要介绍它们的原因。
2.构建基于 DDD 的 解决方案
我们已经介绍了基于 DDD 的分层和解决方案的核心模块。接下来我们了解如何基于 DDD 对 .NET 解决方案进行分层。先从最简单的解决方案结构开始。然后解释 ABP 解决方案的启动模板是如何演变成现在的结构的。最后,您将了解为什么 ABP 启动解决方案内部有这么多项目以及每个项目的用途。
2.1 创建一个简单的基于 DDD 的 解决方案
让我们从头开始,让我们看一下Visual Studio 中基于 DDD 的简单 .NET 解决方案,如以下屏幕截图所示:
假设我们正在构建客户关系管理(CRM) 解决方案,Acme是我们的公司名称,Crm是本示例中的产品名称。我为每一层创建了一个单独的 C# 项目。.NET 项目非常适合分层,因为它们可以将代码库物理分离到不同的包中。同一个项目中的类/类型可以相互引用。但是,跨项目则不行,除非您引用另一个项目来明确定义依赖关系。
下图展示了项目之间依赖关系
图中实线表示开发时依赖关系,而虚线表示运行时依赖关系。我将在本节后面解释差异。
要理解这些依赖关系,我们需要知道这些项目可能包含什么类型的组件。
- Acme.Crm.Domain项目包含一个
Product
类(聚合根实体)和一个IProductRepository
接口(存储库抽象)。Product
表示一个产品,并具有一些属性,例如Id
、Name
和Price
。IProductRepository
有一些方法可以对产品执行数据库操作,例如Insert
、Delete
和GetList
。 - Acme.Crm.Infrastructure项目包含将实体
CrmDbContext
映射到数据库表的类(EF Core 数据上下文) 。Product
它还包含实现IproductRepository
接口的EfProductRepository
类。 - Acme.Crm.Application项目包含
ProductAppService
(应用服务),以及一些用于增删改查的方法。该服务在内部使用IProductRepository
接口和Product
实体。 - [Acme.Crm.Web]是一个 [ASP.NET] Core MVC (Razor Pages) Web 应用。它有一个
Products.cshtml
页面(和一个相关的 JS 文件),负责在 UI 上呈现和管理(增删改查)产品。
Acme.Crm.Web项目还有一个依赖项:Acme.Crm.Infrastructure。它不直接使用该项目中的任何类,因此开发时不需要直接依赖。但是,在运行时需要基础设施层才能使用数据库。
以上是基于 DDD 的解决方案的简约分层。接下来,我们将使用该解决方案并解释 ABP 的启动解决方案是如何演变的。
2.2 ABP启动方案的演进
ABP 默认的启动解决方案比上图所示的解决方案更复杂。如下截图:
我们从头开始梳理,中间是怎么一步步演化过来的:
2.1.1 EntityFrameworkCore 项目介绍
简约版的 DDD 解决方案包含Acme.Crm.Infrastructure项目,它实现了所有基础设施抽象和集成。而ABP 解决方案有一个专用的基础设施项目 Acme.Crm.EntityFrameworkCore,因为我们认为为对于数据库集成,单独分离出来是一种更好的设计。
当然,基础设施层可以拆分为多个项目。目前ABP 启动模板唯一的基础设施项目是Acme.Crm.EntityFrameworkCore。随着解决方案增长,您可以创建其他额外的基础设施项目。
随着这一变化,最初的基于 DDD 的极简解决方案将如下所示:
就基础设施层来说,目前的这种改变是微不足道的。
2.1.2 应用层介绍
Acme.Crm.Application项目包含应用服务类,Acme.Crm.Web项目通过引用Acme.Crm.Application来消费这些服务。
大家思考一下这样引用有没有什么问题?
这种设计有一个问题:Acme.Crm.Web间接引用了Acme.Crm.Domain(通过Acme.Crm.Application)。间接依赖具体实现会将领域层中的业务对象(如实体、领域服务和存储库)暴露给表示层,这打破了抽象和实现真正的分层。
所以,ABP 启动模板将应用层分为两个项目:
- Acme.Crm.Application.Contracts,其中包含应用服务接口(例如
IProductAppService
)和相关的 DTO(例如ProductCreationDto
)。 - Acme.Crm.Application,其中包含应用服务的实现(例如
ProductAppService
)。
为应用服务引入合约(接口)有两个优点:
- UI 层(Acme.Crm.Web)依赖于服务契约而不依赖于实现,因此也无需依赖于领域层。
- 可以与客户端程序共享Acme.Crm.Application.Contracts项目,依赖相同的服务接口并重用相同的 DTO 类,而无需共享您的业务层。
官方的 EventHub 解决方案采用了这种设计,并在 UI 和 HTTP API 应用之间重用了Application.Contracts项目,通过这种方式,它可以轻松设置分层架构,其中应用层和表示层托管在不同的应用程序中,但共享服务契约接口。
分离后,当前的解决方案结构将如下图所示:
采用这种新设计,项目依赖关系图将如下图所示:
Acme.Crm.Web项目现在只依赖于Acme.Crm.Application.Contracts项目,并且应该始终使用应用服务接口来执行用户交互。
目前,Acme.Crm.Web仍然依赖于Acme.Crm.Application和Acme.Crm.EntityFrameworkCore,因为我们在运行时需要它们。我用虚线绘制了这些依赖关系,以表明这些依赖关系不是最佳设计,但现在是必要的。
大家可以思考以下如何摆脱上面的这种依赖,实现更好的设计?
我们将在后面的“将宿主(Hosting)与 UI 分离”部分中介绍我们如何摆脱这些依赖。
2.1.3 领域共享项目介绍
一旦我们分离出契约,我们就不能再在契约项目中使用领域层的对象,因为它们没有对领域层的引用。乍一看,这似乎不是问题,无论如何,我们不应该在应用服务契约中使用这些实体和其他业务对象——我们应该使用 DTO。
但是,请大家思考:假如我们仍然希望重用领域层中的某些类型或值呢?
例如,我们可能希望在 DTO 类中重用枚举ProductType
或常量值。但我们也不想从 Acme.Crm.Application.Contracts 项目中添加对Acme.Crm.Domain项目的引用。
解决方案是:引入一个新项目来存放此类类型和值。我们将这个新项目命名为Acme.Crm.Domain.Shared,因为这个项目将成为领域层的一部分并与其他项目共享。这个项目在项目中可能不会包含这么多类型,但我们仍然不想复制代码。
随着Acme.Crm.Domain.Shared项目的引入,新的解决方案结构如下:
下图显示项目之间的依赖关系:
Acme.Crm.Domain和Acme.Crm.Application.Contracts项目共享新的Acme.Crm.Domain.Shared项目。解决方案中的其他项目也都可以直接或间接地使用该新项目中的类型。
至此,ABP 启动解决方案的基础分层已经完成。接下来我们继续探讨剩下的三个项目。
2.1.4 HTTP API 层介绍
ABP 启动解决方案有两个和HTTP 相关的项目。
第一个是Acme.Crm.HttpApi项目,包含API 控制器(即 REST API)。这个项目将 API 与 UI 分离,同时方便它们在其他场景中被重用。
第二个是Acme.Crm.HttpApi.Client,您可以使用此项目来从客户端应用程序(可以是自己的或第三方 .NET 客户端)使用您的 HTTP API。它使用 ABP 的动态 C# 客户端代理系统,这个在后续会专题讨论。
通过为 HTTP API 层添加两个新项目,我们现在在解决方案中有八个项目,如下图所示:
下图显示了添加这些新项目后的新依赖关系图:
Acme.Crm.HttpApi和Acme.Crm.HttpApi.Client项目依赖于Acme.Crm.Application.Contracts项目,因为服务器和客户端共享相同的契约接口。Acme.Crm.Web项目依赖于Acme.Crm.HttpApi项目,因为它在运行时提供 API。
废弃 HTTP API 层
并非每个应用程序都需要 HTTP API(即 REST API)。在这种情况下,您甚至可以从解决方案中删除该项目。此外,如果您愿意,可以将 API 控制器移至Acme.Crm.Web项目并丢弃Acme.Crm.HttpApi项目。
下一节将解释解决方案中的最后一个项目。
2.1.5 了解数据库迁移项目
上图中,还有一个名为Acme.Crm.DbMigrator的项目。这是一个控制台应用程序,可用于将实体迁移应用到数据库。它是一个工具项目,而不是基本解决方案的一部分,因此无需在此处研究其详细信息。
2.1.6 测试项目
test
除了这九个项目之外,该文件夹下的解决方案中还有六个项目。它们是为每一层单独配置的单元/集成测试项目。其中之一 (Acme.Crm.HttpApi.Client.ConsoleTestApp) 演示了如何使用Acme.Crm.HttpApi.Client调用 HTTP API。其他可以自行探索它们。
3 将宿主与 UI 分离
启动模板的架构模型中有一件令人讨厌的事情是Web项目引用了Application和EntityFramework项目。实际上,Web项目中的所有页面/类都没有直接使用这些项目中的类。但是,由于Web项目是运行应用程序的项目,因此我们需要引用这些项目以使它们在运行时可用。
这种结构不是什么大问题,只要你不泄露你的领域和数据库层对象到表示(Web)层即可。
如果您担心泄露并且不想在运行时设置开发时的依赖项,该怎么办?
可以再添加一个项目Acme.Crm.Web.Host,如下图所示:
通过此更改,[Acme.Crm.Web] 项目成为类库项目,而不是最终应用程序。它仅包含应用程序的表示层页面/组件;它不包含Startup.cs
、Program.cs
和appsettings.json
文件。Acme.Crm.Web.Host项目通过在运行时将所有项目组合在一起来负责托管。它不包含任何应用程序 UI 页面或组件。
我觉得这个设计更好。它从 UI 层优雅地提取托管配置详细信息,删除运行时依赖项,并使其更加专注。目前,我们没有在 ABP 启动模板中分离托管应用程序,因为大多数开发人员已经发现 ABP 启动模板很复杂。我相信让项目职责更单一,代码更少,比将所有东西都放在一个地方的单个项目更好。
最后总结下,在本文中,我们了解了每个项目在 ABP 启动模板中的角色,相信您在开发解决方案时会更加自如。在下一篇中,我们将从 DDD 的角度简要回顾 EventHub 解决方案。