元数据卡
- 前置知识:第11章(分层架构与 MVC)
- 预计时间:45 分钟
- 核心难度:深入
- 完成标志:能设计具有清晰端口和适配器的六边形架构
你的进度
你的机器按工匠之都的标准分了四层。但拆开检修时你发现一个问题——核心传动结构里,到处直接焊着外接设备的接口:锅炉的对接法兰、魔力驱动器的接线端子、操作台的手柄尺寸。
你想换一个品牌的外部引擎,结果发现核心齿轮箱的螺栓位置和新的引擎不匹配——你需要拆掉整个核心重新设计。核心不应该依赖外围。 你的任务
分层架构有一个内在倾向:上层依赖下层,下层通常是数据库、Web 框架这些"外部设备"。结果是你的核心业务逻辑在依赖链的最底层——但它却因为"框架兼容性"而变得不纯净。六边形架构(Ports & Adapters)要求你把依赖方向反转——核心逻辑在最中心,所有外部系统通过端口(接口)和适配器(实现)与核心交互。这一章就是教你怎么画这个六边形。
本章分层
- 必读:六边形架构核心思想、端口与适配器
- 选读:洋葱架构、Clean Architecture
- 进阶:依赖注入在六边形架构中的角色
本章不会要求你掌握
- 完全的 DDD 战略设计
- 模块化单体与六边形的协同
破局 · 溯源
你的擂台系统已经稳定运行。但有一天老板说:"我们要从 PostgreSQL 切到 MySQL。"你改了 10 个 Repository 文件。老板又说:"我们要把 REST API 换成 gRPC。"你改了 15 个 Controller 文件。老板又说:"我们要从单体服务拆成微服务。"——你开始看招聘网站。
问题出在哪里?你的业务逻辑和外部世界的耦合度太高了。
你需要的架构:核心业务逻辑不依赖任何框架或外部系统。框架和外部系统是"插上去的插件"——可以随时拔掉换一个。
第一层:六边形架构(Ports & Adapters)
六边形架构由 Alistair Cockburn 提出。核心思想:
+-------------------------------------------+
| +-----------+ 适配器 |
| | Web 适配器| |
| +-----+------+ |
| | |
| +-----------+------------+ |
| | 端口(接口) | |
| | +-------------------+ | |
| | | 核心业务逻辑 | | |
| | | (无框架依赖) | | |
| | +-------------------+ | |
| | 端口(接口) | |
| +-----------+------------+ |
| | |
| +-----+------+ |
| | DB 适配器 | |
| | PostgreSQL | |
| +-----------+ |
+-------------------------------------------+核心思想:
- 核心业务逻辑在中间——纯 Java/Python 代码,不 import 任何框架类。
- 端口是接口——定义核心需要什么("我需要保存一个 Tournament")和核心提供什么("我可以处理 MatchFinishedEvent")。
- 适配器是实现——PostgreSQL 实现
TournamentRepository,REST 控制器将 HTTP 请求转换为对核心的调用。
核心对适配器一无所知。适配器知道核心的端口接口。
第一层的代码体现。这是核心逻辑——纯得只有 JDK:
// ch12/core/domain/Match.java
package com.tournament.core.domain;
public class Match {
private final MatchId id;
private PlayerId playerA;
private PlayerId playerB;
private MatchStatus status;
private PlayerId winner;
public Match(PlayerId a, PlayerId b) {
this.id = MatchId.generate();
this.playerA = a;
this.playerB = b;
this.status = MatchStatus.READY;
}
public void start() {
if (status != MatchStatus.READY) {
throw new IllegalStateException("只能从 READY 开始");
}
status = MatchStatus.IN_PROGRESS;
}
public void finish(PlayerId winnerId) {
if (status != MatchStatus.IN_PROGRESS) {
throw new IllegalStateException("比赛未进行");
}
if (!winnerId.equals(playerA) && !winnerId.equals(playerB)) {
throw new IllegalArgumentException("胜者不在这场比赛里");
}
this.winner = winnerId;
this.status = MatchStatus.COMPLETED;
}
public boolean isCompleted() { return status == MatchStatus.COMPLETED; }
// 没有 getter/setter——通过方法暴露行为
public MatchId getId() { return id; }
public MatchStatus getStatus() { return status; }
public PlayerId getWinner() { return winner; }
}这是端口——核心要求的:
// ch12/core/port/ForStoringMatches.java
// 核心定义了"我需要什么东西"
package com.tournament.core.port;
public interface ForStoringMatches {
void save(Match match);
Optional<Match> findById(MatchId id);
}// ch12/core/port/ForSendingNotifications.java
package com.tournament.core.port;
public interface ForSendingNotifications {
void notify(PlayerId player, String message);
}这是核心的应用服务:
// ch12/core/usecase/MatchUseCase.java
package com.tournament.core.usecase;
public class MatchUseCase {
private final ForStoringMatches matchStore;
private final ForSendingNotifications notifier;
public MatchUseCase(ForStoringMatches matchStore,
ForSendingNotifications notifier) {
this.matchStore = matchStore;
this.notifier = notifier;
}
public void finishMatch(MatchId matchId, PlayerId winnerId) {
Match match = matchStore.findById(matchId)
.orElseThrow(() -> new IllegalArgumentException("比赛不存在"));
match.finish(winnerId);
matchStore.save(match);
notifier.notify(winnerId, "你赢得了比赛!");
}
}注意 MatchUseCase 使用了 ForStoringMatches 和 ForSendingNotifications——两个接口。它完全不关心"存到 PostgreSQL 还是 MongoDB"、"发通知用邮件还是短信"。这就是端口。
适配器在核心之外:
// ch12/adapter/db/PostgresMatchRepository.java
package com.tournament.adapter.db;
// 适配器实现了核心定义的端口
public class PostgresMatchRepository implements ForStoringMatches {
private final DataSource dataSource;
public PostgresMatchRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void save(Match match) {
// SQL 实现...
}
@Override
public Optional<Match> findById(MatchId id) {
// SQL 实现...
}
}// ch12/adapter/web/MatchController.java
// 适配器从 Web 进入核心
@RestController
public class MatchController {
private final MatchUseCase matchUseCase;
@PostMapping("/matches/{id}/finish")
public ResponseEntity<Void> finishMatch(@PathVariable String id,
@RequestBody FinishRequest req) {
matchUseCase.finishMatch(new MatchId(id), new PlayerId(req.winnerId()));
return ResponseEntity.ok().build();
}
}装配(在 main 或 DI 容器中完成):
// ch12/bootstrap/Application.java
@Configuration
public class Application {
@Bean
public MatchUseCase matchUseCase(DataSource ds) {
ForStoringMatches store = new PostgresMatchRepository(ds);
ForSendingNotifications notifier = new SmsNotificationService();
return new MatchUseCase(store, notifier);
}
}现在如果你要切换数据库,你改的是 PostgresMatchRepository——核心代码一个字不动。切通知方式——改 SmsNotificationService。切 API 协议——改 MatchController。
第二层:洋葱架构
六边形有一个更形象的演化:洋葱架构(Onion Architecture)。它把层次画成同心圆:
+---------------------------------------+
| UI / API |
| +---------------------------------+ |
| | Application Service | |
| | +-------------------------+ | |
| | | Domain Service | | |
| | | +-----------------+ | | |
| | | | Domain Model | | | |
| | | | (最内层) | | | |
| | | +-----------------+ | | |
| | +-------------------------+ | |
| +---------------------------------+ |
+---------------------------------------+依赖方向永远向内——外层依赖内层,内层对外层一无所知。这和六边形架构本质相同,只是画成了同心圆更直观地表"核心在最中心"。
第三层:Clean Architecture
Robert C. Martin(Uncle Bob)提出了 Clean Architecture,大同小异:
内部控制方向:
外 → 内 → 内 → 最内
框架层 实体层
接口适配器 用例层Clean Architecture 依赖规则:源代码依赖只能指向内层。外层可以包含框架、驱动器、数据库、Web 等"细节"。内层不能包含任何外层的名字(不能 import 外部包)。
// Clean Architecture 中典型的用例(Use Case)实现
public class CreateTournamentUseCase {
private final TournamentRepository repository;
// 输入对象——纯数据
public record Input(String name, int maxPlayers) {}
// 输出对象——纯数据
public record Output(String id) {}
public Output execute(Input input) {
Tournament tournament = Tournament.create(input.name, input.maxPlayers);
repository.save(tournament);
return new Output(tournament.getId().toString());
}
}用 JDK 测试核心逻辑——不需要启动应用服务器:
// ch12/test/MatchUseCaseTest.java
class MatchUseCaseTest {
@Test
void finishMatch_savesMatchAndNotifies() {
ForStoringMatches store = new InMemoryMatchStore();
ForSendingNotifications notifier = mock(ForSendingNotifications.class);
MatchUseCase useCase = new MatchUseCase(store, notifier);
MatchId matchId = createMatchInStore(store);
PlayerId winnerId = new PlayerId("steven");
useCase.finishMatch(matchId, winnerId);
Match stored = store.findById(matchId).orElseThrow();
assertTrue(stored.isCompleted());
assertEquals(winnerId, stored.getWinner());
verify(notifier).notify(winnerId, "你赢得了比赛!");
}
}InMemoryMatchStore 是一个测试用的内存实现——不是 Mock,是一个真正的 ForStoringMatches 实现,只在测试中存在。
常见陷阱
陷阱一:过度设计,小项目也用六边形。 如果你的项目只有三个 CRUD 接口,for 循环不超过十行,六边形架构提供的好处有限。它的开销(接口、适配器、依赖注入配置)在项目足够大时才能体现。建议:项目超过 5000 行或需要切换数据库/UI 时再引入。
陷阱二:核心层仍然依赖了框架注解。 "我就用一个 @Entity,没别的。"——错了,这个注解就让你的核心类依赖了 JPA 框架。用 JPA 注解的类不能算"纯净的核心"。考虑用 XML 或单独的映射文件,或者在适配器层进行 ORM 映射。
陷阱三:端口太细或太粗。 一个端口只包含一个方法("我需要保存")——太细,管理成本高。一个端口包含所有可能的方法——太粗,适配器被迫实现不需要的方法。合理的粒度:按领域功能分组。
陷阱四:适配器很薄,但不能没有。 不要在核心层面直接写 "try (Connection conn = dataSource.getConnection())"——那是适配器的工作。核心不能用任何"外部 I/O"。
通关挑战
- 热身:在你的项目里找一个 Service 类,提取其核心逻辑(无框架依赖的部分)到一个纯类。把外部的依赖抽象为接口。
- 挑战:在六边形架构下重构你项目的一个完整模块——核心包 (
core/)、适配器包 (adapter/db/、adapter/web/)。确保核心 package 没有任何框架 import。 - 观察:开源项目中有哪些用了六边形架构?查找 Spring PetClinic 或类似项目,看它的包结构是否有"端口-适配器"的影子。
旅人笔记
六边形架构不是魔法——它的核心思想很简单:把核心逻辑圈起来,画一条清晰的分界线,线内是所有"为什么"(业务规则),线外是所有"怎么做"(技术实现)。Ports 是线内的需求,Adapters 是线外的满足。
下一站预告
六边形架构关注的是"单个"应用的内外边界。但当一个系统膨胀到不能再放在一个部署单元里时——你需要拆。下一章:单体与微服务迁移。