768 lines
34 KiB
Markdown
768 lines
34 KiB
Markdown
<!-- WEHUB_ZH_README -->
|
||
> [!NOTE]
|
||
> 本文档由 WeHub 基于上游 README 翻译整理,属于社区翻译,非官方中文文档。
|
||
> [English](./README.en.md) · [原始项目](https://github.com/ddd-by-examples/library) · [上游 README](https://github.com/ddd-by-examples/library/blob/HEAD/README.md)
|
||
> 原作者、版权与许可证归属以原始项目及本仓库 LICENSE 文件为准。
|
||
|
||
[](https://circleci.com/gh/ddd-by-examples/library)
|
||
[](https://codecov.io/gh/ddd-by-examples/library)
|
||
|
||
# 目录
|
||
|
||
1. [关于](#about)
|
||
2. [领域描述](#domain-description)
|
||
3. [总体假设](#general-assumptions)
|
||
3.1 [流程发现](#process-discovery)
|
||
3.2 [项目结构与架构](#project-structure-and-architecture)
|
||
3.3 [聚合(Aggregates)](#aggregates)
|
||
3.4 [事件(Events)](#events)
|
||
3.4.1 [仓储中的事件](#events-in-repositories)
|
||
3.5 [ArchUnit](#archunit)
|
||
3.6 [函数式思维](#functional-thinking)
|
||
3.7 [不使用 ORM](#no-orm)
|
||
3.8 [架构与代码的差距](#architecture-code-gap)
|
||
3.9 [模型与代码的差距](#model-code-gap)
|
||
3.10 [Spring](#spring)
|
||
3.11 [测试](#tests)
|
||
4. [如何贡献](#how-to-contribute)
|
||
5. [参考资料](#references)
|
||
|
||
## About
|
||
|
||
这是一个图书馆(library)项目,由真实的[业务需求](#domain-description)驱动。
|
||
我们采用与领域驱动设计(Domain Driven Design)、行为驱动开发(Behavior-Driven Development)、
|
||
事件风暴(Event Storming)、用户故事地图(User Story Mapping)紧密相关的技术。
|
||
|
||
## Domain description
|
||
|
||
公共图书馆允许读者在其各分馆为图书办理预约(hold)。
|
||
在任意时刻,可预约的图书只能被一位读者预约。
|
||
图书分为流通型(circulating)或限制型(restricted),并可能收取取书费或使用费。
|
||
限制型图书只能由研究员读者(researcher patron)预约。普通读者在任意时刻最多只能有五笔预约,
|
||
而研究员读者可拥有不限数量的预约。开放式预约(open-ended book hold)在读者借出图书之前一直有效,
|
||
借出时即告完成。若在申请后固定天数内未完成的封闭式预约(closed-ended book hold)将过期。
|
||
该检查在每天开始时进行,方法是查看包含即将过期预约的日报(daily sheet)。
|
||
只有研究员读者可以申请开放式预约期限。若某读者在某分馆有超过两笔逾期借出(overdue checkouts),
|
||
则在该分馆尝试预约时会被拒绝。图书最多可借出 60 天。逾期借出的检查通过查看
|
||
包含逾期借出的日报来完成。读者通过查看读者档案(patron profile)来了解自己的当前预约、借出等情况。
|
||
读者档案类似日报,但信息仅限于一位读者,且不一定是按日汇总的。目前读者可以查看当前预约
|
||
(未取消且未过期)和当前借出(含逾期)。此外,他/她还可以预约图书和取消预约。
|
||
|
||
读者实际上如何知道有哪些书可借?图书馆拥有图书目录(catalogue),
|
||
图书及其具体实例会一并加入其中。只有当目录中已存在 ISBN 匹配的图书时,
|
||
才能添加该图书的具体实例。图书必须具有非空标题和价格。在添加实例时,
|
||
我们决定它是流通型(Circulating)还是限制型(Restricted)。这使我们可以
|
||
让同一 ISBN 的图书同时存在流通型和限制型(例如,有一本作者签名的书,我们希望将其保留为限制型)。
|
||
|
||
## General assumptions
|
||
|
||
### Process discovery
|
||
|
||
我们首先借助 Big Picture EventStorming 进行领域探索。
|
||
你在上一章看到的描述,被贴在了我们的虚拟墙上:
|
||

|
||
EventStorming 会议引导我们做出了诸多发现,并用便利贴进行建模:
|
||

|
||
在会议期间,我们发现了以下定义:
|
||

|
||
|
||
这促使我们思考现实生活中可能发生的情景。我们借助
|
||
**Example mapping** 对它们进行了描述:
|
||

|
||
|
||
这进而成为我们 *Design Level* 会议的基础,我们在其中分析了每个示例:
|
||

|
||
|
||
请通过以下链接了解上述各步骤的更多细节:
|
||
- [Big Picture EventStorming](./docs/big-picture.md)
|
||
- [Example Mapping](docs/example-mapping.md)
|
||
- [Design Level EventStorming](docs/design-level.md)
|
||
|
||
### Project structure and architecture
|
||
在项目伊始,为避免过度复杂化,我们决定将每个限界上下文(bounded context)
|
||
分配到独立的包中,这意味着系统是一个模块化单体(modular monolith)。不过,
|
||
将上下文拆分为 maven 模块乃至最终拆分为微服务,并无障碍。
|
||
|
||
限界上下文应当(除其他外)在架构意义上引入自治。因此,封装各上下文的模块
|
||
拥有与其问题复杂度相匹配的本地架构。对于识别出真正业务逻辑(**lending**)的上下文,
|
||
我们引入了领域模型——这是对现实的一种简化(为项目目的而简化)抽象,并采用了
|
||
六边形架构(hexagonal architecture)。对于在 Event Storming 中证明缺乏复杂
|
||
领域逻辑的上下文,我们采用了类 CRUD 的本地架构。
|
||
|
||

|
||
|
||
若谈及六边形架构,它使我们能够将领域逻辑和应用逻辑与
|
||
框架(及基础设施)分离。采用这种方式我们能获得什么?首先,我们可以对应用程序最重要的部分——**业务逻辑**——进行单元测试,
|
||
通常无需 stub 任何依赖。其次,我们为自己创造了调整基础设施层的机会,而无需担心
|
||
破坏核心功能。在基础设施层中,我们大量使用 Spring Framework,
|
||
它可能是目前最成熟、最强大且具备出色测试支持的应用框架。
|
||
关于我们如何使用 Spring 的更多信息,请见[此处](#spring)。
|
||
|
||
如前所述,架构由 Event Storming 会议驱动。除识别上下文及其复杂度外,
|
||
我们还可以决定分离读写模型(CQRS)。例如,可以查看 **Patron Profiles** 和 *Daily Sheets*。
|
||
|
||
### Aggregates
|
||
在 Event Storming 会议中发现的聚合(Aggregates)通过事件相互通信。不过存在争议:
|
||
它们应当立即保持一致,还是最终保持一致?由于聚合总体上界定了业务边界,
|
||
最终一致性(eventual consistency)听起来是更好的选择,但软件中的选择从无免费午餐。
|
||
提供最终一致性需要一些基础设施工具,例如消息代理(message broker)
|
||
或事件存储(event store)。因此我们可以(也确实)从立即一致性开始。
|
||
|
||
> 好的架构会推迟所有重要决策
|
||
|
||
……正因如此,我们使一致性模型易于变更,并为每种选项提供测试,包括基于 **DomainEvents** 接口的基础实现,
|
||
未来可根据我们的需求和工具集进行调整。请看以下示例:
|
||
|
||
* 立即一致性(Immediate consistency)
|
||
```groovy
|
||
def 'should synchronize Patron, Book and DailySheet with events'() {
|
||
given:
|
||
bookRepository.save(book)
|
||
and:
|
||
patronRepo.publish(patronCreated())
|
||
when:
|
||
patronRepo.publish(placedOnHold(book))
|
||
then:
|
||
patronShouldBeFoundInDatabaseWithOneBookOnHold(patronId)
|
||
and:
|
||
bookReactedToPlacedOnHoldEvent()
|
||
and:
|
||
dailySheetIsUpdated()
|
||
}
|
||
|
||
boolean bookReactedToPlacedOnHoldEvent() {
|
||
return bookRepository.findBy(book.bookId).get() instanceof BookOnHold
|
||
}
|
||
|
||
boolean dailySheetIsUpdated() {
|
||
return new JdbcTemplate(datasource).query("select count(*) from holds_sheet s where s.hold_by_patron_id = ?",
|
||
[patronId.patronId] as Object[],
|
||
new ColumnMapRowMapper()).get(0)
|
||
.get("COUNT(*)") == 1
|
||
}
|
||
```
|
||
_请注意,此处我们是在事件发布后立即从数据库读取_
|
||
|
||
事件总线的简单实现基于 Spring 应用事件(Spring application events):
|
||
```java
|
||
@AllArgsConstructor
|
||
public class JustForwardDomainEventPublisher implements DomainEvents {
|
||
|
||
private final ApplicationEventPublisher applicationEventPublisher;
|
||
|
||
@Override
|
||
public void publish(DomainEvent event) {
|
||
applicationEventPublisher.publishEvent(event);
|
||
}
|
||
}
|
||
```
|
||
|
||
* 最终一致性(Eventual consistency)
|
||
```groovy
|
||
def 'should synchronize Patron, Book and DailySheet with events'() {
|
||
given:
|
||
bookRepository.save(book)
|
||
and:
|
||
patronRepo.publish(patronCreated())
|
||
when:
|
||
patronRepo.publish(placedOnHold(book))
|
||
then:
|
||
patronShouldBeFoundInDatabaseWithOneBookOnHold(patronId)
|
||
and:
|
||
bookReactedToPlacedOnHoldEvent()
|
||
and:
|
||
dailySheetIsUpdated()
|
||
}
|
||
|
||
void bookReactedToPlacedOnHoldEvent() {
|
||
pollingConditions.eventually {
|
||
assert bookRepository.findBy(book.bookId).get() instanceof BookOnHold
|
||
}
|
||
}
|
||
|
||
void dailySheetIsUpdated() {
|
||
pollingConditions.eventually {
|
||
assert countOfHoldsInDailySheet() == 1
|
||
}
|
||
}
|
||
```
|
||
_请注意,该测试与之前的测试看起来完全相同,但现在我们利用了 Groovy 的
|
||
**PollingConditions** 来执行异步功能测试_
|
||
|
||
事件总线的示例实现如下:
|
||
|
||
```java
|
||
@AllArgsConstructor
|
||
public class StoreAndForwardDomainEventPublisher implements DomainEvents {
|
||
|
||
private final JustForwardDomainEventPublisher justForwardDomainEventPublisher;
|
||
private final EventsStorage eventsStorage;
|
||
|
||
@Override
|
||
public void publish(DomainEvent event) {
|
||
eventsStorage.save(event);
|
||
}
|
||
|
||
@Scheduled(fixedRate = 3000L)
|
||
@Transactional
|
||
public void publishAllPeriodically() {
|
||
List<DomainEvent> domainEvents = eventsStorage.toPublish();
|
||
domainEvents.forEach(justForwardDomainEventPublisher::publish);
|
||
eventsStorage.published(domainEvents);
|
||
}
|
||
}
|
||
```
|
||
|
||
需要澄清的是,我们应始终追求能够原子性地(若你愿意,也可以说是事务性地)处理业务操作的聚合,因此每个聚合应尽可能独立,并与其他聚合解耦。由此,最终一致性(eventual consistency)受到推崇。如前所述,这会带来一些权衡;从务实角度看,即时一致性(immediate consistency)同样是一种选择。
|
||
你现在可能会问自己一个问题:_如果我还没有任何事件呢?_ 那么,一种务实的方法是将聚合之间的通信封装在一个类似 _Service_ 的类中,在其中可以按行显式调用相应的聚合。
|
||
|
||
### Events
|
||
谈到聚合间通信,我们必须记住:事件能降低耦合,但并不能完全消除耦合。因此,只共享(发布)其他聚合存在和运行所必需的事件,这一点至关重要。否则,耦合程度可能会上升,并引入 **feature envy(特性依恋)**,因为其他聚合可能开始利用这些事件去执行本不该由它们执行的操作。解决该问题的一种方案是区分领域事件(domain events)与集成事件(integration events),相关内容将很快在此说明。
|
||
|
||
### Events in Repositories
|
||
仓储(Repository)是最常见的设计模式之一。它将领域模型与数据层解耦。
|
||
换言之,它处理的是状态。也就是说,常见用例是我们将新状态传给仓储,以便持久化。可能看起来像这样:
|
||
|
||
```java
|
||
public class BusinessService {
|
||
|
||
private final PatronRepository patronRepository;
|
||
|
||
void businessMethod(PatronId patronId) {
|
||
Patron patron = patronRepository.findById(patronId);
|
||
//do sth
|
||
patronRepository.save(patron);
|
||
}
|
||
}
|
||
```
|
||
|
||
从概念上讲,在该业务方法第 1 行与第 3 行之间,我们将 Patron 的状态从 A 变更为 B。
|
||
这一变更可能通过脏检查(dirty checking)计算得出,也可能只是在数据库中整体覆盖 Patron 状态。
|
||
第三种选择是 _让隐式变为显式_,并将这次 A->B 的状态变更真正称为一个 **event(事件)**。
|
||
毕竟,事件驱动架构的核心,就是推动将状态变更提升为领域事件(domain events)。
|
||
|
||
得益于此,我们的领域模型可以变为不可变的,并在调用命令后仅返回事件,例如:
|
||
|
||
```java
|
||
public BookPlacedOnHold placeOnHold(AvailableBook book) {
|
||
...
|
||
}
|
||
```
|
||
|
||
而我们的仓储可以直接基于事件进行操作,例如:
|
||
|
||
```java
|
||
public interface PatronRepository {
|
||
void save(PatronEvent event) {
|
||
}
|
||
```
|
||
|
||
### ArchUnit
|
||
成功项目的主要组成部分之一,是能够引导团队朝正确方向前进的技术领导力。尽管如此,仍有一些工具可以帮助团队保持代码整洁、守护架构,使项目不会沦为 Big Ball of Mud(大泥球),从而令开发与维护都更加愉快。我们提出的第一种选择是 [ArchUnit](https://www.archunit.org/))——一个 Java 架构测试工具。ArchUnit 允许你为架构编写单元测试,从而使其始终与最初愿景保持一致。Maven 模块也可以作为替代方案,但此处我们聚焦前者。
|
||
|
||
就六边形架构(hexagonal architecture)而言,确保我们不混用不同抽象层级(hexagon levels)至关重要:
|
||
```java
|
||
@ArchTest
|
||
public static final ArchRule model_should_not_depend_on_infrastructure =
|
||
noClasses()
|
||
.that()
|
||
.resideInAPackage("..model..")
|
||
.should()
|
||
.dependOnClassesThat()
|
||
.resideInAPackage("..infrastructure..");
|
||
```
|
||
并确保框架不会影响领域模型
|
||
```java
|
||
@ArchTest
|
||
public static final ArchRule model_should_not_depend_on_spring =
|
||
noClasses()
|
||
.that()
|
||
.resideInAPackage("..io.pillopl.library.lending..model..")
|
||
.should()
|
||
.dependOnClassesThat()
|
||
.resideInAPackage("org.springframework..");
|
||
```
|
||
|
||
### Functional thinking
|
||
查看代码时,你可能会嗅到一丝函数式编程(functional programming)的气息。尽管我们并未遵循 _纯粹的_ FP,但我们尝试将业务流程视为流水线或工作流,并通过以下概念以函数式风格加以运用。
|
||
|
||
_请注意,本项目并非 FP 的参考项目。_
|
||
|
||
#### Immutable objects
|
||
每个表示业务概念的类都是不可变的,由此我们可以:
|
||
* 提供完整封装并保护对象状态,
|
||
* 保障对象在多线程访问下的安全性,
|
||
* 更清晰地控制所有副作用。
|
||
|
||
#### Pure functions
|
||
我们将设计层事件风暴(Design Level Event Storming)中发现的领域操作建模为纯函数(pure functions),并在领域层与应用层中以 Java 函数式接口的形式声明它们。其实现则作为带有副作用的普通方法放在基础设施层。借助这一做法,我们可以显式遵循通用语言(ubiquitous language)的抽象,并保持该抽象与具体实现无关。例如,你可以查看 `FindAvailableBook` 接口及其实现:
|
||
|
||
```java
|
||
@FunctionalInterface
|
||
public interface FindAvailableBook {
|
||
|
||
Option<AvailableBook> findAvailableBookBy(BookId bookId);
|
||
}
|
||
```
|
||
|
||
```java
|
||
@AllArgsConstructor
|
||
class BookDatabaseRepository implements FindAvailableBook {
|
||
|
||
private final JdbcTemplate jdbcTemplate;
|
||
|
||
@Override
|
||
public Option<AvailableBook> findAvailableBookBy(BookId bookId) {
|
||
return Match(findBy(bookId)).of(
|
||
Case($Some($(instanceOf(AvailableBook.class))), Option::of),
|
||
Case($(), Option::none)
|
||
);
|
||
}
|
||
|
||
Option<Book> findBy(BookId bookId) {
|
||
return findBookById(bookId)
|
||
.map(BookDatabaseEntity::toDomainModel);
|
||
}
|
||
|
||
private Option<BookDatabaseEntity> findBookById(BookId bookId) {
|
||
return Try
|
||
.ofSupplier(() -> of(jdbcTemplate.queryForObject("SELECT b.* FROM book_database_entity b WHERE b.book_id = ?",
|
||
new BeanPropertyRowMapper<>(BookDatabaseEntity.class), bookId.getBookId())))
|
||
.getOrElse(none());
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 类型系统
|
||
_类型系统(type system)——与建模类似——_ 我们将 EventStorming 过程中发现的每个领域对象状态建模为独立的
|
||
类:`AvailableBook`、`BookOnHold`、`CheckedOutBook`。采用这种方式,我们比使用单一 `Book` 类配合基于枚举的状态管理提供了更清晰的抽象。将逻辑迁移到这些特定类中,把单一职责原则(Single Responsibility Principle)提升到了新的层次。此外,我们不再在每个业务方法中检查不变量,而是将这一职责交给编译器。例如,请考虑以下场景:_只有当前可借阅的图书才能被预约(place on hold)_。我们可以这样实现:
|
||
```java
|
||
public Either<BookHoldFailed, BookPlacedOnHoldEvents> placeOnHold(Book book) {
|
||
if (book.status == AVAILABLE) {
|
||
...
|
||
}
|
||
}
|
||
```
|
||
但我们使用_类型系统_,并声明具有如下签名的方法
|
||
```java
|
||
public Either<BookHoldFailed, BookPlacedOnHoldEvents> placeOnHold(AvailableBook book) {
|
||
...
|
||
}
|
||
```
|
||
在编译期发现的错误越多越好。
|
||
|
||
应用此类类型系统的另一项优势是,我们能够用函数更轻松地表达业务流程与状态转换。例如,以下函数:
|
||
```
|
||
placeOnHold: AvailableBook -> BookHoldFailed | BookPlacedOnHold
|
||
cancelHold: BookOnHold -> BookHoldCancelingFailed | BookHoldCanceled
|
||
```
|
||
比下面这些更加简洁且富有表达力:
|
||
```
|
||
placeOnHold: Book -> BookHoldFailed | BookPlacedOnHold
|
||
cancelHold: Book -> BookHoldCancelingFailed | BookHoldCanceled
|
||
```
|
||
因为在后者中,许多约束都隐藏在函数实现内部。
|
||
|
||
此外,若将领域视为在一组业务对象(聚合,aggregates)上执行的一组操作(函数),你就不必考虑任何执行模型(例如异步处理)。这完全合理,因为你无需考虑。领域函数不受 I/O 操作、异步以及其他易产生副作用的操作的影响,这些都被放在基础设施层。得益于此,我们可以在不进行 mock 的情况下轻松测试它们。
|
||
|
||
#### Monads
|
||
业务方法可能产生不同的结果。一种可能返回值或 `null`,在发生意外时抛出异常,或在不同情况下返回不同对象。这些情况在 Java 等面向对象语言中很常见,却不符合函数式风格。我们通过 monad(由 [Vavr](https://www.vavr.io)): 提供的单子容器)来解决这些问题
|
||
* 当方法返回可选值时,我们使用 `Option` monad:
|
||
|
||
```java
|
||
Option<Book> findBy(BookId bookId) {
|
||
...
|
||
}
|
||
```
|
||
|
||
* 当方法可能返回两种可能值之一时,我们使用 `Either` monad:
|
||
|
||
```java
|
||
Either<BookHoldFailed, BookPlacedOnHoldEvents> placeOnHold(AvailableBook book) {
|
||
...
|
||
}
|
||
```
|
||
|
||
* 当可能发生异常时,我们使用 `Try` monad:
|
||
|
||
```java
|
||
Try<Result> placeOnHold(@NonNull PlaceOnHoldCommand command) {
|
||
...
|
||
}
|
||
```
|
||
|
||
得益于此,我们可以遵循函数式编程风格,同时丰富领域语言,使代码对调用方而言更具可读性。
|
||
|
||
#### 模式匹配(Pattern Matching)
|
||
根据给定图书对象的类型,我们经常需要执行不同的操作。一连串 if/else 或 switch/case 语句固然可行,但模式匹配能提供最佳的简洁性与灵活性。借助如下代码,我们可以针对对象检查多种模式并访问其组成部分,从而使代码中的语言构造噪声降到最低:
|
||
```java
|
||
private Book handleBookPlacedOnHold(Book book, BookPlacedOnHold bookPlacedOnHold) {
|
||
return API.Match(book).of(
|
||
Case($(instanceOf(AvailableBook.class)), availableBook -> availableBook.handle(bookPlacedOnHold)),
|
||
Case($(instanceOf(BookOnHold.class)), bookOnHold -> raiseDuplicateHoldFoundEvent(bookOnHold, bookPlacedOnHold)),
|
||
Case($(), () -> book)
|
||
);
|
||
}
|
||
```
|
||
|
||
### (无)ORM
|
||
如果运行 `mvn dependency:tree`,你不会找到任何 JPA 实现。尽管我们认为 ORM 解决方案(如 Hibernate)
|
||
非常强大且实用,但我们决定不使用它们,因为我们无法充分利用其特性。我们指的是哪些特性?懒加载(lazy loading)、缓存(caching)、脏检查(dirty checking)。为什么不需要?我们希望对 SQL 查询拥有更多控制权,
|
||
并自行尽量减小对象-关系阻抗不匹配(object-relational impedance mismatch)。此外,得益于相对较小的聚合——
|
||
其中仅包含保护不变量所需的最少数据——我们也不需要
|
||
懒加载机制。
|
||
借助六边形架构(Hexagonal Architecture),我们能够分离领域模型与持久化模型,并
|
||
独立地对它们进行测试。此外,我们还可以为不同聚合引入不同的持久化策略。
|
||
在本项目中,我们同时使用原生 SQL 查询与 `JdbcTemplate`,并采用了崭新且极具前景的
|
||
项目 Spring Data JDBC,它摆脱了前述与 JPA 相关的开销。
|
||
下面是仓储(repository)的一个示例:
|
||
|
||
```java
|
||
interface PatronEntityRepository extends CrudRepository<PatronDatabaseEntity, Long> {
|
||
|
||
@Query("SELECT p.* FROM patron_database_entity p where p.patron_id = :patronId")
|
||
PatronDatabaseEntity findByPatronId(@Param("patronId") UUID patronId);
|
||
|
||
}
|
||
```
|
||
|
||
同时,我们还提出了另一种持久化聚合的方式,即结合原生 SQL 查询与 `JdbcTemplate`:
|
||
|
||
```java
|
||
@AllArgsConstructor
|
||
class BookDatabaseRepository implements BookRepository, FindAvailableBook, FindBookOnHold {
|
||
|
||
private final JdbcTemplate jdbcTemplate;
|
||
|
||
@Override
|
||
public Option<Book> findBy(BookId bookId) {
|
||
return findBookById(bookId)
|
||
.map(BookDatabaseEntity::toDomainModel);
|
||
}
|
||
|
||
private Option<BookDatabaseEntity> findBookById(BookId bookId) {
|
||
return Try
|
||
.ofSupplier(() -> of(jdbcTemplate.queryForObject("SELECT b.* FROM book_database_entity b WHERE b.book_id = ?",
|
||
new BeanPropertyRowMapper<>(BookDatabaseEntity.class), bookId.getBookId())))
|
||
.getOrElse(none());
|
||
}
|
||
|
||
...
|
||
}
|
||
```
|
||
_请注意,尽管可以为聚合选择不同的持久化实现,
|
||
仍建议在应用/团队内坚持使用同一种方案。_
|
||
|
||
### 架构与代码的鸿沟
|
||
我们十分注重保持整体架构(包括各类图表)
|
||
与代码结构之间的一致性。在识别出有界上下文(bounded contexts)后,我们可以将它们组织为模块(更准确地说,是包,package)。
|
||
借此,我们在单体应用中也能获得众所周知的微服务自治性。每个包都有定义清晰的公共 API,并通过
|
||
包级保护或私有作用域封装所有实现细节。
|
||
|
||
仅看包结构:
|
||
|
||
```
|
||
└── library
|
||
├── catalogue
|
||
├── commons
|
||
│ ├── aggregates
|
||
│ ├── commands
|
||
│ └── events
|
||
│ └── publisher
|
||
└── lending
|
||
├── book
|
||
│ ├── application
|
||
│ ├── infrastructure
|
||
│ └── model
|
||
├── dailysheet
|
||
│ ├── infrastructure
|
||
│ └── model
|
||
├── librarybranch
|
||
│ └── model
|
||
├── patron
|
||
│ ├── application
|
||
│ ├── infrastructure
|
||
│ └── model
|
||
└── patronprofile
|
||
├── infrastructure
|
||
├── model
|
||
└── web
|
||
```
|
||
你就能看出架构在大声宣告它包含两个有界上下文:**catalogue**(目录)
|
||
与 **lending**(借阅)。此外,**lending context** 围绕五个业务对象构建:**book**(图书)、
|
||
**dailysheet**、**librarybranch**、**patron** 和 **patronprofile**,而 **catalogue** 没有子包,
|
||
这表明它可能只是一个内部没有复杂逻辑的 CRUD。架构图见下。
|
||
|
||

|
||
|
||
与按层分包等方式相比,该方案的又一优势在于:为了
|
||
交付某项功能,你通常只需在一个包内完成,这正是前述的
|
||
自治性。一旦我们将 _context-packages_(上下文包)拆分为独立的微服务,这种自治性便可延伸至应用层面。基于上述考量,自治性可以下放给
|
||
能够端到端负责整个业务领域的产品团队。
|
||
|
||
### 模型与代码的差距(model-code gap)
|
||
在本项目中,我们尽力将 _model-code gap_ 降至最低。这意味着我们同等重视模型与代码,并努力保持二者一致。下方是一些示例。
|
||
|
||
#### 预约(Placing on hold)
|
||

|
||
|
||
先从最简单的部分说起,下方是与图中命令和事件相对应的模型类:
|
||
|
||
```java
|
||
@Value
|
||
class PlaceOnHoldCommand {
|
||
...
|
||
}
|
||
```
|
||
```java
|
||
@Value
|
||
class BookPlacedOnHold implements PatronEvent {
|
||
...
|
||
}
|
||
```
|
||
```java
|
||
@Value
|
||
class MaximumNumberOfHoldsReached implements PatronEvent {
|
||
...
|
||
}
|
||
```
|
||
```java
|
||
@Value
|
||
class BookHoldFailed implements PatronEvent {
|
||
...
|
||
}
|
||
```
|
||
|
||
我们知道它现在可能看起来并不起眼,但如果你查看聚合(aggregate)的实现,
|
||
你会发现代码不仅体现了聚合名称,还完整反映了 `PlaceOnHold`
|
||
命令处理的整个场景。下面来揭示其中的细节:
|
||
|
||
```java
|
||
public class Patron {
|
||
|
||
public Either<BookHoldFailed, BookPlacedOnHoldEvents> placeOnHold(AvailableBook book) {
|
||
return placeOnHold(book, HoldDuration.openEnded());
|
||
}
|
||
|
||
...
|
||
}
|
||
```
|
||
|
||
`placeOnHold` 方法的签名一目了然:只有在图书可借时才能预约
|
||
(关于如何通过编译器保护不变量(invariants)的更多信息,请参阅[类型系统章节](#type-system))。
|
||
此外,若你尝试预约一本可借的图书,它**要么**失败(`BookHoldFailed`),**要么**产生某些事件——
|
||
会产生哪些事件?
|
||
|
||
```java
|
||
@Value
|
||
class BookPlacedOnHoldEvents implements PatronEvent {
|
||
@NonNull UUID eventId = UUID.randomUUID();
|
||
@NonNull UUID patronId;
|
||
@NonNull BookPlacedOnHold bookPlacedOnHold;
|
||
@NonNull Option<MaximumNumberOfHoldsReached> maximumNumberOfHoldsReached;
|
||
|
||
@Override
|
||
public Instant getWhen() {
|
||
return bookPlacedOnHold.when;
|
||
}
|
||
|
||
public static BookPlacedOnHoldEvents events(BookPlacedOnHold bookPlacedOnHold) {
|
||
return new BookPlacedOnHoldEvents(bookPlacedOnHold.getPatronId(), bookPlacedOnHold, Option.none());
|
||
}
|
||
|
||
public static BookPlacedOnHoldEvents events(BookPlacedOnHold bookPlacedOnHold, MaximumNumberOfHoldsReached maximumNumberOfHoldsReached) {
|
||
return new BookPlacedOnHoldEvents(bookPlacedOnHold.patronId, bookPlacedOnHold, Option.of(maximumNumberOfHoldsReached));
|
||
}
|
||
|
||
public List<DomainEvent> normalize() {
|
||
return List.<DomainEvent>of(bookPlacedOnHold).appendAll(maximumNumberOfHoldsReached.toList());
|
||
}
|
||
}
|
||
```
|
||
|
||
`BookPlacedOnHoldEvents` 是 `BookPlacedOnHold` 事件的容器,并且——若读者已预约了 5 本书——
|
||
还会包含 `MaximumNumberOfHoldsReached`(请注意 `Option` 单子(monad))。你现在可以看到代码与模型是多么完美地对应。
|
||
|
||
但这还不是全部。在上图中,你还能看到一张大的矩形黄色卡片,上面有规则(policies),
|
||
这些规则定义了要获得给定结果所需满足的条件。所有这些规则都实现为
|
||
**要么**允许、**要么**拒绝预约的函数:
|
||
|
||

|
||
```java
|
||
PlacingOnHoldPolicy onlyResearcherPatronsCanHoldRestrictedBooksPolicy = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> {
|
||
if (toHold.isRestricted() && patron.isRegular()) {
|
||
return left(Rejection.withReason("Regular patrons cannot hold restricted books"));
|
||
}
|
||
return right(new Allowance());
|
||
};
|
||
```
|
||
|
||

|
||
|
||
```java
|
||
PlacingOnHoldPolicy overdueCheckoutsRejectionPolicy = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> {
|
||
if (patron.overdueCheckoutsAt(toHold.getLibraryBranch()) >= OverdueCheckouts.MAX_COUNT_OF_OVERDUE_RESOURCES) {
|
||
return left(Rejection.withReason("cannot place on hold when there are overdue checkouts"));
|
||
}
|
||
return right(new Allowance());
|
||
};
|
||
```
|
||
|
||

|
||
|
||
```java
|
||
PlacingOnHoldPolicy regularPatronMaximumNumberOfHoldsPolicy = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> {
|
||
if (patron.isRegular() && patron.numberOfHolds() >= PatronHolds.MAX_NUMBER_OF_HOLDS) {
|
||
return left(Rejection.withReason("patron cannot hold more books"));
|
||
}
|
||
return right(new Allowance());
|
||
};
|
||
```
|
||
|
||

|
||
|
||
```java
|
||
PlacingOnHoldPolicy onlyResearcherPatronsCanPlaceOpenEndedHolds = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> {
|
||
if (patron.isRegular() && holdDuration.isOpenEnded()) {
|
||
return left(Rejection.withReason("regular patron cannot place open ended holds"));
|
||
}
|
||
return right(new Allowance());
|
||
};
|
||
```
|
||
|
||
#### Spring
|
||
Spring Framework 似乎是有史以来最流行的 Java 框架。遗憾的是,在业务代码中过度使用其功能的情况也相当普遍。
|
||
在本项目中你会发现,领域包完全专注于业务问题的建模,且没有任何 DI(依赖注入),
|
||
这使得单元测试变得容易,对代码可靠性和可维护性而言弥足珍贵。不过,这并不意味着
|
||
我们不使用 Spring Framework——我们确实在使用。以下是一些细节:
|
||
- 每个限界上下文(bounded context)都有自己独立的 application context。这意味着我们消除了运行时
|
||
耦合,这是向提取模块(以及微服务)迈出的一步。我们是如何做到的?让我们
|
||
看一看:
|
||
```java
|
||
@SpringBootConfiguration
|
||
@EnableAutoConfiguration
|
||
public class LibraryApplication {
|
||
|
||
public static void main(String[] args) {
|
||
new SpringApplicationBuilder()
|
||
.parent(LibraryApplication.class)
|
||
.child(LendingConfig.class).web(WebApplicationType.SERVLET)
|
||
.sibling(CatalogueConfiguration.class).web(WebApplicationType.NONE)
|
||
.run(args);
|
||
}
|
||
}
|
||
```
|
||
- 如上所示,我们也尽可能避免使用 component scan。取而代之的是,我们使用
|
||
`@Configuration` 类,在基础设施层定义模块特定的 bean。这些
|
||
配置类在主编应用类中显式声明。
|
||
|
||
### 测试
|
||
测试以 BDD 方式编写,表达通过 Example Mapping 定义的用户故事。
|
||
这意味着我们同时运用了 TDD 以及通过 Event Storming 发现的领域语言(Domain Language)。
|
||
|
||
我们还努力展示如何创建 DSL,使测试读起来就像取自领域描述的句子。请
|
||
在下方查看一个示例:
|
||
|
||
```groovy
|
||
def 'should make book available when hold canceled'() {
|
||
given:
|
||
BookDSL bookOnHold = aCirculatingBook() with anyBookId() locatedIn anyBranch() placedOnHoldBy anyPatron()
|
||
and:
|
||
PatronEvent.BookHoldCanceled bookHoldCanceledEvent = the bookOnHold isCancelledBy anyPatron()
|
||
|
||
when:
|
||
AvailableBook availableBook = the bookOnHold reactsTo bookHoldCanceledEvent
|
||
then:
|
||
availableBook.bookId == bookOnHold.bookId
|
||
availableBook.libraryBranch == bookOnHold.libraryBranchId
|
||
availableBook.version == bookOnHold.version
|
||
}
|
||
```
|
||
_另请注意 **when** 代码块,其中我们体现了图书会对取消事件做出响应这一事实_
|
||
|
||
## 如何参与贡献
|
||
|
||
项目仍在建设中,因此如果你足够喜欢并愿意合作,请告知我们
|
||
或直接创建 Pull Request。
|
||
|
||
|
||
## 如何构建
|
||
|
||
### 要求
|
||
|
||
* Java 11
|
||
* Maven
|
||
|
||
### 快速开始
|
||
|
||
只需输入以下命令即可运行图书馆应用:
|
||
|
||
```console
|
||
$ mvn spring-boot:run
|
||
...
|
||
...
|
||
2019-04-03 15:55:39.162 INFO 18957 --- [ main] o.s.b.a.e.web.EndpointLinksResolver : Exposing 2 endpoint(s) beneath base path '/actuator'
|
||
2019-04-03 15:55:39.425 INFO 18957 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
|
||
2019-04-03 15:55:39.428 INFO 18957 --- [ main] io.pillopl.library.LibraryApplication : Started LibraryApplication in 5.999 seconds (JVM running for 23.018)
|
||
|
||
```
|
||
|
||
### 构建 Jar 包
|
||
|
||
你可以使用 Maven 这样构建 jar:
|
||
|
||
```console
|
||
$ mvn clean package
|
||
...
|
||
...
|
||
[INFO] Building jar: /home/pczarkowski/development/spring/library/target/library-0.0.1-SNAPSHOT.jar
|
||
[INFO] ------------------------------------------------------------------------
|
||
[INFO] BUILD SUCCESS
|
||
[INFO] ------------------------------------------------------------------------
|
||
```
|
||
|
||
### 使用 Docker 构建
|
||
|
||
如果你已经构建好了 jar 文件,可以运行:
|
||
|
||
```console
|
||
docker build -t spring/library .
|
||
```
|
||
|
||
否则,你可以使用多阶段 Dockerfile 来构建 jar 文件:
|
||
|
||
```console
|
||
docker build -t spring/library -f Dockerfile.build .
|
||
```
|
||
|
||
无论哪种方式,构建完成后你都可以这样运行:
|
||
|
||
```console
|
||
$ docker run -ti --rm --name spring-library -p 8080:8080 spring/library
|
||
```
|
||
|
||
### 生产就绪的指标与可视化
|
||
|
||
要运行应用程序以及用于可视化指标的 Prometheus 和 Grafana 仪表板,你可以启动所有服务:
|
||
|
||
```console
|
||
$ docker-compose up
|
||
```
|
||
|
||
如果一切顺利,你可以在以下地址访问相关服务:
|
||
* http://localhost:8080/actuator/prometheus - 已发布的 Micrometer 指标
|
||
* http://localhost:9090 - Prometheus 仪表板
|
||
* http://localhost:3000 - Grafana 仪表板
|
||
|
||
要查看指标,你必须创建一个仪表板。前往 `Create` -> `Import`,并选择附带的 `jvm-micrometer_rev8.json`。该文件摘自 `https://grafana.com/grafana/dashboards/4701`。
|
||
|
||
请注意,应用程序将使用 `local` Spring profile 运行,以设置一些初始数据。
|
||
|
||
## 参考资料
|
||
|
||
1. [Introducing EventStorming](https://leanpub.com/introducing_eventstorming) by Alberto Brandolini
|
||
2. [Domain Modelling Made Functional](https://pragprog.com/book/swdddf/domain-modeling-made-functional) by Scott Wlaschin
|
||
3. [Software Architecture for Developers](https://softwarearchitecturefordevelopers.com) by Simon Brown
|
||
4. [Clean Architecture](https://www.amazon.com/Clean-Architecture-Craftsmans-Software-Structure/dp/0134494164) by Robert C. Martin
|
||
5. [Domain-Driven Design: Tackling Complexity in the Heart of Software](https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215) by Eric Evans
|