使用战术 DDD 和 MonoRepos 实现可持续的 Angular 架构?
商业和工业应用通常寿命较长。这些应用包括面向客户的应用程序,其中包含复杂的后端服务和系统。如今,许多此类应用都使用 JavaScript 通过 Web 前端实现。
那么我们如何构建和维护这样的软件系统呢?
领域驱动设计 (DDD)提供了答案!最重要的是,它特别适合复杂的 Angular 解决方案。
在本文中我们将了解:
- 战术设计 (DDD)如何帮助将我们的源代码组织为更小、更易于管理、更连贯的部分。
- Monorepos如何帮助实现它们
- Facades如何帮助隔离域逻辑
- 客户端 DDD 如何为微前端铺平道路
所有这些主题也都是我们高级Angular 架构研讨会的一部分。如果您正在使用 Angular 实现商业或工业软件,不妨看看这个!
与往常一样,所使用的示例可以在我的GitHub 帐户中找到。
非常感谢Thomas Burleson对本文的深入审阅和贡献。
域分解
假设我们正在构建一个消费者旅游 Web 应用程序。我们有几个可以识别的域名:
为了管理这些领域的复杂性,我们应该利用领域驱动设计和 Angular 社区的最佳实践。
领域驱动设计有两种设计类型
- 战略领域设计
- 战术设计
前者涉及识别领域及其之间的通信,而战术设计则涉及构建这些领域。
我已经写过关于在 Angular 应用程序中使用战略设计的文章。您可以阅读这篇文章来了解基础知识。因此,在这里,我将重点介绍战术领域设计。
应用战术设计
现在让我们讨论如何用战术设计来构建我们的应用程序。
使用层来组织域
对于域,我们使用列细分(“泳道”)。此外,这些域可以通过库的层级来组织,从而实现行细分:
注意每个层可以包含一个或多个库
虽然使用层是组织域的一种相当传统的方式,但也有其他选择,例如六边形架构或清洁架构。
那么共享功能怎么样?
对于需要跨域共享和使用的功能,shared
可以使用额外的泳道。共享库可能非常有用。例如,用于身份验证或日志记录的共享库。
注:
shared
泳道对应DDD提出的Shared Kernel,也包含需要共享的技术库。
如何防止高耦合?
访问约束定义了哪些库可以使用/依赖其他库。通常,每个层只允许与底层通信。跨域访问也只允许与特定shared
区域进行。使用这些限制的好处是可以实现松耦合,从而提高可维护性。
为了避免在该领域投入过多的逻辑
shared
,本文提出的方法还使用了为其他领域发布构建块的 API。这呼应了 DDD 中“开放服务”的理念。
对于共享部分,可以看到以下两个特点:
- 如灰色块所示,大多数
util
库都位于该shared
区域,特别是跨系统使用身份验证或日志记录等方面。 - 这同样适用于确保系统范围外观和感觉的通用 UI 库。
特定功能怎么样?
但需要注意的是,特定领域的feature
库不在此shared
范围内。功能相关的代码应该放在其自己的领域内。
虽然开发人员可以选择在域之间共享功能代码,但这种做法可能会导致责任共担、协调工作量增加以及重大变更。因此,应谨慎共享。
代码组织
根据 Nrwl.io 的企业 MonoRepository 模式,我区分了五类层或库:
类别 | 描述 | 示例内容 |
---|---|---|
特征 | 包含用例的组件。 | 搜索飞行部件 |
用户界面 | 包含所谓的“哑组件”,这些组件与用例无关,因此可重复使用。 | 日期时间组件、地址组件、地址管道 |
API | 从当前子域导出构建块供其他人使用。 | 航班 API |
领域 | 包含域(泳道)使用的域模型(类、接口、类型) | |
实用程序 | 包括通用实用功能 | 日期格式 |
这种完整的架构矩阵乍一看让人眼花缭乱。但简单回顾一下之后,几乎所有与我合作过的开发人员都认为,这种代码组织方式有利于代码复用和未来功能的实现。
隔离域
为了隔离域逻辑,我们将其隐藏在以特定于用例的方式表示它的外观后面:
这个 Facade 还可以处理状态管理。有两篇很棒的文章可以深入探讨这些领域:
虽然 Facades 目前在 Angular 环境中非常流行,但这个想法也与 DDD(在其中它们被称为应用服务)有着很好的关联。
从架构上将基础设施需求与实际领域逻辑分开非常重要。
在 SPA 中,基础设施关注点(至少在大多数情况下)是与服务器的异步通信和数据交换。保持这种分离会产生三个额外的层:
- 应用服务/外观,
- 实际领域层,以及
- 基础设施层。
当然,这些层现在也可以打包到各自的库中。为了简单起见,也可以将它们存储在一个库中,并进行相应的细分。如果这些层通常一起使用,并且只需要在单元测试中交换,那么这种做法就很有意义。
Monorepos 中的实现
一旦确定了架构的组件,接下来的问题是如何在 Angular 中实现它们。一种非常常见的方法,Google 自己也使用过,那就是使用 monorepo。它是一个包含软件系统所有库的代码仓库。
虽然使用 Angular CLI 创建的项目如今可以用作单一仓库 (monorepo),但流行的工具Nx提供了一些额外的可能性,这些可能性对于大型企业解决方案尤其有价值。其中包括之前讨论过的在库之间引入访问限制的方法。这可以防止每个库相互访问,从而导致整个系统高度耦合。
要在 monorepo 中创建库,一条指令就足够了:
ng generate library domain --directory boarding
由于您只是使用
ng generate library
而不是ng generate module
,因此您无需付出更多努力。但是,您将获得更清晰的结构、更好的可维护性和更少的耦合。
Nx 提供的开关directory
指定了放置库的可选子目录。这样,它们就可以按域分组:
库的名称也反映了层级。
如果一个层级包含多个库,则使用这些库的名称作为前缀是合理的。这样就会出现类似feature-search
或 这样的名称feature-edit
。
为了隔离实际的领域模型,此处显示的示例将领域库划分为下面提到的三个层:
在 Monorepo 中构建
通过查看 git 提交日志,Nx 还可以识别哪些库受到最新代码更改的影响。
这些变更信息用于仅重新编译受影响的库或仅运行受影响的测试。显然,这对于存储在一个完整存储库中的大型系统来说,可以节省大量时间。
实体和你的战术设计
战术设计为构建领域层提供了许多思路。领域层的核心是反映现实世界领域和结构的实体。
以下清单显示了一个枚举和两个实体,符合 Java 或 C# 等面向对象语言的通常做法。
public enum BoardingStatus {
WAIT_FOR_BOARDING,
BOARDED,
NO_SHOW
}
public class BoardingList {
private int id;
private int flightId;
private List<BoardingListEntry> entries;
private boolean completed;
// getters and setters
public void setStatus (int passengerId, BoardingStatus status) {
// Complex logic to update status
}
}
public class BoardingListEntry {
private int id;
private boarding status status;
// getters and setters
}
与面向对象领域中常见的情况一样,这些实体使用信息隐藏来确保其状态保持一致。您可以通过私有字段和操作它们的公共方法来实现这一点。
这些实体不仅封装了数据,还封装了业务规则。至少该方法setStatus
表明了这种情况。只有在业务规则无法有效地容纳在实体中的情况下,DDD 才会定义所谓的领域服务。
在 DDD 中,仅表示数据结构的实体是不受欢迎的。社区称它们为“贫血的”。
战术 DDD 与函数式编程
从面向对象的角度来看,前面的方法确实有道理。然而,对于 JavaScript 和 TypeScript 这样的语言来说,面向对象就没那么重要了。
Typescript 是一种多范式语言,其中函数式编程扮演着重要的角色。您可以在这里找到关于函数式 DDD 的书籍:
因此,使用函数式编程,先前考虑的实体模型将被拆分为数据部分和逻辑部分。领域驱动设计精粹 (Domain-Driven Design Distilled) 是 DDD 的标准著作之一,主要依赖于面向对象编程 (OOP),它也承认在函数式编程 (FP) 的世界中,这一规则的改变是必要的:
export type BoardingStatus = 'WAIT_FOR_BOARDING' | 'BOARDED' | 'NO_SHOW' ;
export interface BoardingList {
readonly id: number;
readonly flightId: number;
readonly entries: BoardingListEntry [];
readonly completed: boolean;
}
export interface BoardingListEntry {
readonly passengerId: number;
readonly status: BoardingStatus;
}
export function updateBoardingStatus (
boardingList: BoardingList,
passengerId: number,
status: BoardingStatus): Promise <BoardingList> {
// Complex logic to update status
}
这里的实体也使用了公共属性。这种做法在函数式编程中相当常见;过度使用 getter 和 setter(它们只会委托给私有属性)常常遭到嘲笑!
然而,更有趣的问题是函数式编程如何避免不一致的状态。答案出奇地简单:数据结构最好是immutable 的readonly
。示例中的关键字强调了这一点。
程序中想要改变此类对象的部分必须克隆它,并且如果程序的其他部分已经首先为自己的目的验证了对象,那么它们可以假定它仍然有效。
使用不可变数据结构的一个好处是,变更检测的性能得到了优化。不再需要深度比较。相反,更改的对象实际上是一个新的实例,因此对象引用将不再相同。
带有聚合的战术 DDD
为了跟踪领域模型的组件,战术 DDD 将实体组合成聚合。在上一个示例中,BoardingList
和BoardingListEntry
构成了这样的聚合。
聚合中所有组件的状态必须整体一致。例如,在上面的例子中,可以指定只有当 no的状态为时, completed
in才可以设置为。BoardingList
true
BoardingListEntry
WAIT_FOR_BOARDING
此外,不同的聚合不能通过对象引用相互引用,而是可以使用 ID。这可以避免聚合之间不必要的耦合。大型域因此可以分解为较小的聚合组。
领域驱动设计精粹 (Domain-Driven Design Distilled)建议将聚合体设计得尽可能小。首先,将每个实体视为一个聚合体,然后将需要更改的聚合体立即合并在一起。
立面
Facades(又称应用服务)用于以特定于用例的方式表示领域逻辑。它具有以下几个优点:
- 封装复杂性
- 关注状态管理
- 简化的 API
独立于 DDD,这个想法在 Angular 世界中已经流行了一段时间。
对于我们的示例,我们可以创建以下 Facade:
@Injectable ({providedIn: 'root'})
export class FlightFacade {
private notifier = new BehaviorSubject<Flight[]>([]);
public flights$ = this.notifier.asObservable();
constructor(private flightService: FlightService) { }
search(from: string, to: string, urgent: boolean): void {
this.flightService
.find(from, to, urgent)
.subscribe (
(flights) => this.notifier.next(flights),
(err) => console.error ('err', err);
);
}
}
请注意外观模块中 RxJS 和可观察对象的使用。这意味着外观模块可以在情况发生变化时自动提供更新的航班信息。外观模块的另一个优势是能够透明地引入 Redux,并@ngrx/store
在需要时进行后续操作。这不会影响任何外部应用程序组件。
对于 Facade 的消费者来说,它自己管理状态还是委托给状态管理库来管理状态并不重要。
鼓励开发人员阅读/观看 Thomas Burleson 关于这个主题的作品:
- 博客文章使用 RxJS + Facades 的基于推送的架构,或
- YouTube 视频使用 RxJS 的基于推送的架构
无状态外观
虽然使服务器端服务无状态是一种很好的做法,但对于 Web/客户端层的服务来说,这一目标往往并不高效。
Web SPA 具有状态,这使其变得用户友好!
为了避免用户体验问题,Angular 应用程序不希望一遍又一遍地从服务器重新加载所有信息。因此,概述的 Facade 保存了已加载的航班信息(包含在之前讨论过的可观察对象中)。
领域事件
除了性能提升之外,使用 Observable 还提供了另一个优势。Observable 允许进一步解耦,因为发送者和接收者不必直接了解彼此。
这也与 DDD 完美契合,领域事件的使用如今已成为架构的一部分。如果应用程序的某个部分发生了一些有趣的事情,它会发送一个领域事件,其他应用程序部分就可以对此做出反应。
在所示的示例中,领域事件可能指示乘客现已登机。如果系统的其他部分对此感兴趣,则可以执行特定的逻辑。
对于熟悉 Redux 或 Ngrx 的 Angular 开发人员:领域事件可以表示为分派的动作!
领域驱动设计和微前端?
众所周知,领域驱动设计的思想为微服务架构铺平了道路。因此,客户端 DDD 可以作为微前端的基础。
无论是部署单体应用、微前端,还是两者之间的任何组合,都取决于 Monorepo 的使用情况。如果 Monorepo 为每个域都创建了专属的应用,那么就向微前端迈出了一大步:
上面讨论的访问限制确保了松散耦合,甚至允许稍后拆分为多个存储库。这样,您就可以按照经典的微服务架构来讨论微前端了。然而,在这种情况下,团队必须负责版本控制和共享库的分发,这在微服务中很常见。
在我的博客文章中可以找到有关此主题的更多想法,这是关于切割您的域,而不是(首先)关于微前端!。
结论
现代单页应用程序 (SPA) 通常不仅仅是数据传输对象 (DTO) 的接收者。它们通常包含重要的领域逻辑,这增加了复杂性。DDD 的理念可以帮助开发人员管理和扩展由此产生的复杂性。
由于 TypeScript 的对象函数式特性以及普遍的惯例,一些规则的改变是必要的。例如,我们通常使用不可变变量,并将数据结构与操作它们的逻辑分开。
这里概述的实施方案基于以下想法:
- 使用按域分组的多个库的 monorepos 有助于构建基本结构。
- 库之间的访问限制可防止域之间的耦合。
- 外观为各个用例准备领域模型并负责维护状态。
- 如果需要,可以在外观后面使用 Redux,而不会引起应用程序其余部分注意。
此外,团队还利用客户端DDD为微前端创造了条件。
文章来源:https://dev.to/angular/sustainable-angular-architectures-with-tropical-ddd-and-monorepos-c61如果您想了解所有这些主题的实际应用,请查看我们的Angular Architecture 研讨会。