本文最初发布于2022年6月3日,后来进行了重大更新。
动机 / 本文适合谁?
Hexagonal Architecture 作为一种架构模式并不过于复杂。但是,我记得过去我花了一些时间找到“东西该放在哪里”。
如果你还不熟悉 Hexagonal Architecture,你应该阅读Alistair Cockburn的原始文章!这也将解释以下术语!
如果你想深入了解细节,我只能推荐阅读Juan Manuel Garrido de Paz 网站上的精彩文章。
Thomas Pierrain也写了很多关于这个主题的有趣的内容。
一段时间以前,Alistair 发推文说:
重新阅读文章后,我不得不得出结论,“一切都在那里”。但是,我自己也花了很多时间研究这个主题,所以我想我对它有了一些了解。此外,我怎么能知道其他人遇到了什么问题呢?
不过,这让我更深入地思考了一下我过去的困惑,我记得,尽管这个模式是对称的,但在某些地方它有时会感觉到不对称,主要的一侧和次要的一侧在某些方面感觉不同。
根据我的观察,相当多的开发人员不确定如何构建他们的六边形应用程序。如果你过去花了一些时间思考这个问题,或者你计划第一次深入研究 Hexagonal Architecture,本文可能会帮助你更快地找到答案!
Hexagonal Architecture 基础知识
以防万一,我会简要介绍 Hexagonal Architecture 和一些最重要的术语。
其核心原则是将业务逻辑与技术关注点(如存储、HTTP API、消息传递)清晰地分离,并能够为开发/测试和生产插入不同的适配器。
为此,六边形(内部部分,业务逻辑/领域模型所在的地方)定义了必须由适配器使用或实现的端口。端口只是一个合同,实现有点依赖于编程语言。通常,它将是 OOP 意义上的接口,由类实现。
有驱动器和驱动的端口和适配器。
驱动器端口/适配器也称为主要或左侧。
驱动的端口/适配器也可以称为次要或右侧。
以下将使用主要/次要的名称,因为它更容易在视觉上区分!
主要端口由至少两个主要适配器使用,一个是测试适配器(例如集成测试),一个是生产适配器,例如 HTTP 请求处理程序。也可以有更多的适配器,例如事件/消息处理程序、cron 作业等。
主要端口的演员在主要一侧启动对话。例如,一个人使用发送 HTTP 请求的网站(主要适配器)。
通过主要端口调用六边形(业务逻辑),并最终可能通过它们实现的次要端口使用一个或多个次要适配器。例如,数据存储在数据库中(次要演员),并通过某个消息总线发送事件(次要演员)。
定义主要端口是使 Hexagonal Architecture 特殊的原则之一。由接口的消费者定义次要端的接口(例如存储)不是 Hexagonal Architecture 特有的,但仍然是其不可或缺的一部分(还请参阅控制反转)。
它看起来是对称的,是吗?
请不要太认真对待这一部分!
```
我提到了对称性和不对称性,让我们来看看这个。
如果你以强大的六边形(Hexi)的视角来看,周围的世界是美丽对称的。
左边有1到N个使用主要端口的适配器。
右边有1到N个实现次要端口的适配器。
好极了!
猜猜看,我喜欢在Miro上画有趣的图表;-)
开玩笑!如果你按照六边形架构实现应用程序,并尝试将所有这些端口和适配器放置在你的代码架构/结构中,你可能会遇到一些问题。
粒度和基数
如上所述,六边形应用程序的构件——从左到右——是主适配器、主端口、实现这些端口的六边形、次要端口和次要适配器。每个都可以是粗粒度或细粒度的,这会导致不同的基数。
作为入门,通常对主端口的切片是通过查看应用程序中的参与者及其角色来完成的。在我的示例应用程序中,我们可以有一个玩家和一个管理员,每个人都可以得到一个端口——ForPlayingTheGame,ForBuildingDeckSets。这些端口是粗粒度的,因为它们每个包含多个用例。
如果我们更喜欢使用用例驱动设计或通常更喜欢在应用程序中使用更小、独立的内容,我们也有更多的可能性来构造应用程序中的代码。
以下是有关示例的说明
这些示例来自我们在MaibornWolff(我正在工作的美妙IT咨询公司)正在构建的一个小型内部社交应用程序。
要了解有关此应用程序的更多信息,我建议你查看我以前的博客文章:Domain-Driven Design vs. “functional core, imperative shell”
作为参考,这些是我们的应用程序提供的用例(以命令方式表示):
- EnrollPlayer
- SignOutPlayer
- PurgeInactivePlayers
- SelectDeck
- UnselectDeck
- FinishCard
- RejectCard
未来可能会有一些管理员用例:
AddCard,RemoveCard,AddDeck,RemoveDeck,AddCardToDeck,RemoveCardFromDeck等
在当前实现中,卡和牌组仅以JSON文件的形式存储。
我们还有一些查询用例,为了简洁起见,我将跳过它们。
示例是针对Go(语言)的,并且我正在使用ForDoingSomething表示端口。一些人,尤其是在C#中,更喜欢使用IDoSomething表示法。在惯用的Go中,一种方法的接口名称应以“-er”结尾,因此应该是SomethingDoer。
我决定使用_ForDoingSomething_符号表示六边形架构端口,因为它非常明确——这是Hexagonal Architecture端口,而不是普通接口——并且因为它非常能够表达这样的意思:这个端口是用于选择牌组的。
端口的粒度
主端口的示例
对于对Go感兴趣的读者,我通常不使用接口,而是使用函数类型进行细粒度的端口。我以前写过一些关于它们的内容,例如:Go Bits: Magic with functions
在本文中我将继续使用常规接口,但会在此处留下这个示例:
# With function types instead of interfaces all ports are automatically fine-grained
type ForSelectingDecks func(command SelectDeckCommand) error
type ForUnselectingDecks func(command UnselectDeckCommand) error
次要端口的示例
##### the coarse-grained version #####
type ForStoringEvents interface {
CreateEventStream(streamID lib.StreamID, event lib.Event) error
AppendEventsToStream(
streamID lib.StreamID,
expectedRevision uint64,
recordedEvents ...lib.Event,
) error
ReadEventStream(streamID lib.StreamID) (lib.EventStream, error)
}
type ForReadingDeckSets interface {
Read() (game.DeckSet, error)
}
type ForReadingProjections interface {
ReadActivePlayersProjection() (game.ActivePlayersView, error)
ReadLeaderboardProjection() (game.LeaderboardView, error)
}
##### the fine-grained version #####
type ForCreatingEventStreams interface {
Create(streamID lib.StreamID, event lib.Event) error
}
type ForAppendingEventsToStreams interface {
Append(
streamID lib.StreamID,
expectedRevision uint64,
recordedEvents ...lib.Event,
) error
}
type ForReadingEventStreams interface {
Read(streamID lib.StreamID) (lib.EventStream, error)
}
###
type ForReadingDeckSets interface {
Read() (game.DeckSet, error)
}
###
type ForReadingActivePlayersProjections interface {
Read() (game.ActivePlayersView, error)
}
type ForReadingLeaderboardProjections interface {
Read() (game.LeaderboardView, error)
}
该应用程序是事件驱动的,因此我们没有“存储库”,而是需要提供用于读取事件流、创建事件流和追加到现有事件流的功能的“事件存储”。
一些人试图通过一个“存储库”来隐藏他们的应用程序的事件源本质。我认为这非常多余。ES的本质在六边形内是可见的,它不会为我解决任何现有问题,反而会创建另一层间接性。出于本例的考虑,使用具有创建、读取和更新语义的存储库不会改变任何内容。
在我们的实现中,PlayerEventStore装饰了一个通用的EventStore。PlayerEventStore添加了一些功能,并为玩家提供了特定的错误处理。
适配器的粒度及其与端口的映射
与端口一样,适配器可以是细粒度、粗粒度或具有一些切片的粗粒度。粗粒度适配器可以使用或实现一对多端口,而细粒度适配器只能使用或实现一个端口——在主要和次要方面都是如此。
六边形内的粒度
六边形在主要方面实现(或提供)端口。同样,我们可以构建粗粒度或细粒度的服务(例如,命令处理程序、查询处理程序等)。
而且,我们不要忘记,一个用例通常需要使用多个次要端口,例如_ForStoringSomething_和_ForNotifyingSomeone_。
我将入口点称为六边形实现中的 服务,以便为其提供一些名称。
无限的组合
想象一下在所有可能的主适配器、主端口、六边形服务、次要端口和次要适配器的切片之间的所有可能的连接线。我甚至没有尝试画它们,因为有很多可能的组合,即使并非所有组合都是现实的。
# 不同粒度的架构示例
举个例子胜过千言万语,因此让我展示四个从粗粒度到细粒度的示例,每个示例都包括一个请求流程图和样例代码结构。
为简洁起见,我只展示了这些目录树中最重要的文件。在实际应用程序中,想象一下会有更多的文件和包。
我只展示了这两个用例的请求流程:
- 选择卡组
- 取消选择卡组
启动对话的主要适配器始终是一个HTTP处理程序(在这些示例中),因此使用它的参与者是一个带有移动应用程序或Web应用程序的玩家。
请注意图表中事物的字体大小,字体越大表示粗粒度越大!
非常粗粒度和面向参与者
game
├── application
│ ├── deck_builder_command_handler.go # hexagon implementation
│ ├── for_building_deck_sets.go # primary port
│ ├── for_playing_the_game.go # primary port
│ ├── for_reading_active_players_projections.go # secondary port
│ ├── for_reading_deck_sets.go # secondary port
│ ├── for_storing_deck_sets.go # secondary port
│ ├── for_storing_events.go # secondary port
│ └── player_command_handler.go # hexagon implementation
├── domain
│ ├── deck_set.go # aggregate
│ ├── deck_set_test.go # unit test
│ ├── player.go # aggregate
│ └── player_test.go # unit test
└── infrastructure
└── adapter
├── secondary
│ ├── forreadingdecksets
│ │ └── file_deck_set_reader.go # secondary adapter
│ └── forstoringevents
│ ├── esdb_event_store.go # generic
│ └── player_event_store.go # secondary adapter
└── primary
├── forbuildingdecksets
│ ├── deck_builder_command_handler_test.go # primary adapter (unit test)
│ ├── deck_builder_http_handler.go # primary adapter
│ └── integration_test.go # primary adapter
└── forplayingthegame
├── integration_test.go
├── player_command_handler_test.go # primary adapter (unit test)
├── player_http_handler.go # primary adapter
└── scheduler.go # primary adapter
在此示例中,我包括了其他参与者的一些部分——ForBuildingDeckSets。
正如我们所看到的,选择主要端口以适应主要参与者(玩家和管理员/卡组构建器)。
这样的结构在六边形架构和OOP语言中似乎相当惯用。顶层文件夹是应用程序、域和基础设施。当人们具有领域驱动设计背景时,我经常看到这种情况。这可能受到Vaughn Vernon的书《实现领域驱动设计》的影响。
所有端口、适配器和服务都是粗粒度的,这也使它们大(它们包含很多代码)。所有属于一个用例的组件都相当遥远。目录结构,特别是适配器的目录结构,是深层次的。
通用的ESDBEventStore有点混在了适配器中,它可能更适合放在基础设施下的某种共享或库包中。
Scheduler类似于cron作业,它调度清除不活跃玩家的任务。
粗粒度的逻辑分组
game
├── application
│ ├── card_play_command_handler.go # hexagon impl.
│ ├── deck_selection_command_handler.go # hexagon impl.
│ ├── for_enrolling_players.go # primary port
│ ├── for_playing_cards.go # primary port
│ ├── for_reading_active_players_projections.go # secondary port
│ ├── for_reading_deck_sets.go # secondary port
│ ├── for_selecting_decks.go # primary port
│ ├── for_storing_events.go # secondary port
│ └── player_enrollment_command_handler.go # hexagon impl.
├── domain
│ ├── deck_set.go # aggregate
│ ├── deck_set_test.go # unit test
│ ├── player.go # aggregate
│ └── player_test.go # unit test
└── infrastructure
└── adapter
├── secondary
│ ├── forreadingdecksets
│ │ └── file_deck_set_reader.go # secondary adapter
│ └── forstoringevents
│ ├── esdb_event_store.go # generic
│ └── player_event_store.go # secondary adapter
└── primary
├── forenrollingplayers
│ ├── http_handler.go # primary adapter
│ ├── integration_test.go # primary adapter
│ ├── player_enrollment_command_handler_test.go # primary adapter (unit test)
│ └── scheduler.go # primary adapter
├── forplayingcards
│ ├── card_play_command_handler_test.go # primary adapter (unit test)
│ ├── http_handler.go # primary adapter
│ └── integration_test.go # primary adapter
└── forselectingdecks
├── deck_selection_command_handler_test.go # primary adapter (unit test)
├── http_handler.go # primary adapter
└── integration_test.go # primary adapter
此示例仅包含玩家端口_ForEnrollingPlayers_、ForSelectingDecks_和_ForPlayingCards,它们仍然是粗粒度的(包含多个用例),但已经被逻辑组的功能划分,而不是在一个端口中。命令处理程序同样被切片。主要适配器也是如此——每个端口切片有一个HTTPHandler和一个集成测试。
细粒度用例导向切片
game
├── hexagon
│ ├── forenrollingplayers
│ │ ├── command_handler.go # hexagon impl.
│ │ ├── command_handler_test.go # primary adapter (unit test)
│ │ ├── enroll_player.go # aggregate logic
│ │ ├── enroll_player_test.go # unit test
│ │ ├── http_handler.go # primary adapter
│ │ └── integration_test.go # primary adapter
│ ├── forfinishingcards
│ │ ├── command_handler.go # hexagon impl.
│ │ ├── command_handler_test.go # primary adapter (unit test)
│ │ ├── finish_card.go # aggregate logic
│ │ ├── finish_card_test.go # unit test
│ │ ├── http_handler.go # primary adapter
│ │ └── integration_test.go # primary adapter
│ ├── forpurginginactiveplayers
│ │ ├── command_handler.go # hexagon impl.
│ │ ├── command_handler_test.go # primary adapter (unit test)
│ │ ├── integration_test.go # primary adapter
│ │ └── scheduler.go # primary adapter
│ ├── forrejectingcards
│ │ ├── command_handler.go # hexagon impl.
│ │ ├── command_handler_test.go # primary adapter (unit test)
│ │ ├── http_handler.go # primary adapter
│ │ ├── integration_test.go # primary adapter
│ │ ├── reject_card.go # aggregate logic
│ │ └── reject_card_test.go # unit test
│ ├── forselectingdecks
│ │ ├── command_handler.go # hexagon impl.
│ │ ├── command_handler_test.go # primary adapter (unit test)
│ │ ├── http_handler.go # primary adapter
│ │ ├── integration_test.go # primary adapter
│ │ ├── select_deck.go # aggregate logic
│ │ └── select_deck_test.go # unit test
│ ├── forsigningoutplayers
│ │ ├── command_handler.go # hexagon impl.
│ │ ├── command_handler_test.go # primary adapter (unit test)
│ │ ├── http_handler.go # primary adapter
│ │ ├── integration_test.go # primary adapter
│ │ ├── sign_out_player.go # aggregate logic
│ │ └── sign_out_player_test.go # unit test
│ ├── forunselectingdecks
│ │ ├── command_handler.go # hexagon impl.
│ │ ├── command_handler_test.go # primary adapter (unit test)
│ │ ├── http_handler.go # primary adapter
│ │ ├── integration_test.go # primary adapter
│ │ ├── unselect_deck.go # aggregate logic
│ │ └── unselect_deck_test.go # unit test
│ ├── for_appending_events_to_streams.go # secondary port
│ ├── for_enrolling_players.go # primary port
│ ├── for_finishing_cards.go # primary port
│ ├── for_purging_inactive_players.go # primary port
│ ├── for_reading_active_players_projections.go # secondary port
│ ├── for_reading_deck_sets.go # secondary port
│ ├── for_reading_event_streams.go # secondary port
│ ├── for_rejecting_cards.go # primary port
│ ├── for_selecting_decks.go # primary port
│ ├── for_signing_out_players.go # primary port
│ └── for_unselecting_decks.go # primary port
└── infrastructure
├── esdb
│ ├── event_store.go # generic
│ └── player_event_store.go # secondary adapter
└── file
└── deck_set_reader.go # secondary adapter
如果我们采用按用例切片的方式,它可能会像这样。
每个用例都有自己的包,位于六边形下(而不是应用程序)。
六边形还包含所有端口定义,包括主要和次要方面的端口。
聚合逻辑也被分为每个用例,这在语言(如Go)中是可能的,因为函数是一等公民。请注意,这里仍然有一些共享的领域对象,这里没有展示。我喜欢将它们放在Go的根包(game)中。
基础设施是按技术组织的,而不是具有适配器/端口语义。在我们的真实应用程序中,有许多更多的技术包和更多的文件包。按技术组织结果更容易找到(对我们来说),但这可能只是品味问题,它导致了更平的结构。
最后,_有争议的_部分:所有主要适配器都存在于用例包中,它们与六边形没有结构上的分离!这绝对不是防御性编程,可能会让人感到困惑,因此整个团队都应该充分了解并同意这样的策略!最大的优点是属于用例的所有内容(除了次要适配器和一些领域对象之类的共享代码)都非常接近。
这样的结构用更少的但更大的文件/对象替换了更多的但更小的、独立的文件/对象。代码量大致相同,尤其是在测试中有一些额外的样板代码。
细粒度,但具有一些逻辑分组
与前面的示例相比,这个版本只是以不同的方式组织(和命名)文件。
game
├── hexagon
│ ├── deckselection
│ │ ├── integration_test.go # primary adapter
│ │ ├── select_deck_command_handler.go # primary port + hexagon impl.
│ │ ├── select_deck_command_handler_test.go # primary adapter (unit test)
│ │ ├── select_deck.go # aggregate logic
│ │ ├── select_deck_http_handler.go # primary adapter
│ │ ├── select_deck_test.go # unit test
│ │ ├── unselect_deck_command_handler.go # primary port + hexagon impl.
│ │ ├── unselect_deck_command_handler_test.go # primary adapter (unit test)
│ │ ├── unselect_deck.go # aggregate logic
│ │ ├── unselect_deck_http_handler.go # primary adapter
│ │ └── unselect_deck_test.go # unit test
│ ├── enrollment
│ │ ├── enroll_player_command_handler.go # primary port + hexagon impl.
│ │ ├── enroll_player_command_handler_test.go # primary adapter (unit test)
│ │ ├── enroll_player.go # aggregate logic
│ │ ├── enroll_player_http_handler.go # primary adapter
│ │ ├── enroll_player_test.go # unit test
│ │ ├── integration_test.go # primary adapter
│ │ ├── purge_inactive_players_command_handler.go # primary port + hexagon impl.
│ │ ├── purge_inactive_players_command_handler_test.go # primary adapter (unit test)
│ │ ├── sign_out_player_command_handler.go # primary port + hexagon impl.
│ │ ├── sign_out_player_command_handler_test.go # primary adapter (unit test)
│ │ ├── sign_out_player.go # aggregate logic
│ │ ├── sign_out_player_http_handler.go # primary adapter
│ │ └── sign_out_player_test.go # unit test
│ ├── gameplay
│ │ ├── finish_card_comand_handler.go # primary port + hexagon impl.
│ │ ├── finish_card_comand_handler_test.go # primary adapter (unit test)
│ │ ├── finish_card.go # aggregate logic
│ │ ├── finish_card_http_handler.go # primary adapter
│ │ ├── finish_card_test.go # unit test
│ │ ├── integration_test.go # primary adapter
│ │ ├── reject_card_command_handler.go # primary port + hexagon impl.
│ │ ├── reject_card_command_handler_test.go # primary adapter (unit test)
│ │ ├── reject_card.go # aggregate logic
│ │ ├── reject_card_http_handler.go # primary adapter
│ │ └── reject_card_test.go # unit test
│ ├── scheduler
│ │ └── scheduler.go # primary adapter
│ └── dependencies.go # all secondary ports
└── infrastructure
├── esdb
│ ├── event_store.go # generic
│ └── player_event_store.go # secondary adapter
└── file
└── deck_set_reader.go # secondary adapter
这个实现将一些用例分组到包中,这与第二个示例(“粗粒度的逻辑切片”)采用的策略类似。
(数量)代码相同,只是文件数量稍微减少了一些,因为所有次要端口定义都在一个文件(dependencies.go)中,而主要端口在命令处理程序中实现时被定义。
看待这三个主要包_enrollment_、_deckselection_和_gameplay_的一种方式是将它们视为参与者扮演的角色:加入游戏(和退出)、选择卡组和玩游戏。我不想隐藏_PurgeInactivePlayers_用例与这个角色视角有点不匹配,因为它甚至不是玩家而是参与者关心的事情。我只是试图给你提供一些选择,一些细节可能不太确定。 ;-)
粒度——品味问题?
尽管我很想给出严格的建议,但许多决策都是品味问题!这些决策应该由整个团队做出并达成共识,这并不奇怪。
你可以在以下找到一些更具体的启发式。
参与者和角色
我建议首先确定应用程序中的参与者及其角色。正如上面提到的那样,这是默认的。如果你没有更细粒度的好理由,那么你就找到了你的粗粒度主要端口!
用例驱动设计
这不是一个_已经建立_的术语,我从Thomas Pierrain那里借来的。这是我的“tick”,它与我喜欢和经常在Go中使用的函数式编程范例非常吻合。如果你也这样想,你可以考虑使用细粒度的结构。# 基数
你的应用程序中有多少个端口?
你更喜欢许多小东西还是较少的大东西?
结构关注分离和防御式编程
在Java或C#中,可在类级别上控制可见性,而且更细粒度。
在Go中,包中的所有内容都可以被该包中的所有其他构件看到/访问,无论是否导出。
通常,语言提供哪些单元来组织代码?类、命名空间、模块、包,它们的机制和限制是什么?
代码是否应该更加“防御”,通过“位置”分离关注点(基础设施,六边形)并使意外混淆关注点变得更加困难,甚至可以启用一些 Lint 工具来禁止某些依赖项?团队有多成熟,多少人/团队将在该代码上工作,他们对六边形架构了解多少,等等?
语言惯用语
无论你使用的语言可以做什么,这种语言的惯用方式是什么?
包、模块、命名空间的大小等。
将多个“东西”放在一个文件中是否正常,还是你有一个“一个类一个文件”的规则?
测试通常放在哪里?
“最小惊讶原则”不仅适用于接口,还适用于代码/架构!
特定的语言特性
如前所述,我在Go中使用函数类型作为一种一方法接口的即插即用替代品。这使得测试双倍非常便宜,我所需要的就是一个函数,将其注入为真实实现的双倍。如果你有兴趣,可以在此处查看更多详细信息:Go Bits: Magic with functions
其他语言可能具有类似或其他促进细粒度端口的特性。
语言限制
面向对象语言还是函数式语言?
如前所述,在Go中,包之间的循环依赖关系是不允许的。
在技术上,将多个“东西”放在一个文件中是否可能?
结论
我希望我能够展示一些遵循六边形架构约定的项目如何构建的无限可能性。正如你所看到的,没有现成的解决方案-决策在你自己手中。我建议你考虑你想要的粒度以及你想要达到的功能内聚度。然后,如果可以的话,请尝试各种可能性。我很少能够在尝试了许多可能性之前做出这样的决定。
最后,但并非最不重要的,我要重申核心原则:六边形内部和外部的是什么?技术问题在外部,业务逻辑在内部。其余的或多或少只是实现细节。
不要在这些细节中迷失,从你给定的上下文中开始使用最简单的解决方案!
评论(0)