Skip to content

元数据卡

  • 前置知识:第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  |                     |
|        +-----------+                      |
+-------------------------------------------+

核心思想:

  1. 核心业务逻辑在中间——纯 Java/Python 代码,不 import 任何框架类。
  2. 端口是接口——定义核心需要什么("我需要保存一个 Tournament")和核心提供什么("我可以处理 MatchFinishedEvent")。
  3. 适配器是实现——PostgreSQL 实现 TournamentRepository,REST 控制器将 HTTP 请求转换为对核心的调用。

核心对适配器一无所知。适配器知道核心的端口接口。

第一层的代码体现。这是核心逻辑——纯得只有 JDK:

java
// 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; }
}

这是端口——核心要求的:

java
// ch12/core/port/ForStoringMatches.java
// 核心定义了"我需要什么东西"
package com.tournament.core.port;

public interface ForStoringMatches {
    void save(Match match);
    Optional<Match> findById(MatchId id);
}
java
// ch12/core/port/ForSendingNotifications.java
package com.tournament.core.port;

public interface ForSendingNotifications {
    void notify(PlayerId player, String message);
}

这是核心的应用服务:

java
// 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 使用了 ForStoringMatchesForSendingNotifications——两个接口。它完全不关心"存到 PostgreSQL 还是 MongoDB"、"发通知用邮件还是短信"。这就是端口。

适配器在核心之外

java
// 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 实现...
    }
}
java
// 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 容器中完成):

java
// 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 外部包)。

java
// 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 测试核心逻辑——不需要启动应用服务器:

java
// 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 是线外的满足。


下一站预告

六边形架构关注的是"单个"应用的内外边界。但当一个系统膨胀到不能再放在一个部署单元里时——你需要拆。下一章:单体与微服务迁移。

Built with VitePress | Software Systems Atlas