跳到内容

第 6 卷 / 第 13 章 / 第 1 课

13.1 模块化单体:先建立边界,再增加部署单元 ​

报名页面改了一个字段,计分模块的测试却挂了:两个模块共用同一个可变实体,还各自往里面塞字段。此时把它们拆成两个服务,只会让这个实体学会坐网络专车。

这里单体指应用作为一个部署单元交付,可以启动多个副本,不等于永远只有一个进程。它的内部也不必然混乱。模块化单体把业务能力划成受约束的模块,同时保留进程内调用、单一部署和本地事务的简单性。

如果一个团队不能在同一进程内守住模块边界,网络通常也救不了它;接口会变成远程耦合,数据库仍被共享,故障处理却更复杂。

先区分三种结构 ​

结构部署内部边界主要风险
大泥球1模糊、任意调用改动影响不可预测
模块化单体1业务模块拥有 API 和数据需要持续治理边界
微服务多个独立部署与数据契约,可独立演进分布式故障和运维成本

从大泥球直接拆微服务,经常只是把未理解的耦合搬到网络上。更稳妥的路径是先在单体内发现、修复和验证边界。

模块围绕业务能力 ​

text
tournament/
├── registration/
│   ├── api/
│   └── internal/
├── scheduling/
│   ├── api/
│   └── internal/
├── scoring/
│   ├── api/
│   └── internal/
└── bootstrap/

一个模块应拥有:

  • 对外公开的命令、查询和事件;
  • 实现这些能力的应用与领域代码;
  • 自己的数据写入权;
  • 明确的维护责任。

模块不应公开内部实体供其他模块任意修改。跨模块共享的首先是标识和契约,不是可变对象图。

同步调用与模块事件 ​

下面 Java 21 片段假设已有项目的 ID 与积分摘要类型。需要立即得到结果的交互,可以通过公开 API 同步调用;是否参与同一本地事务,还取决于连接资源、传播方式和线程,不能由“同步调用”四个字推出:

java
public interface ScoringApi {
    ScoreSnapshot scoreFor(PlayerId playerId);
}

一个事实发生后,多个模块各自响应,适合发布模块事件:

java
public record MatchSettled(MatchId matchId, PlayerId winnerId, java.time.Instant occurredAt) {}

进程内事件不是天然可靠队列。必须明确:

  • 处理器在提交前还是提交后运行;
  • 一个处理器失败是否回滚发起事务;
  • 是否允许异步;
  • 事件是否需要持久化和重放。

不要用“事件驱动”隐藏本应直接表达的强一致命令。

数据所有权比包名更重要 ​

同一数据库可以为不同模块使用独立 schema 或表集合。本例约定每张业务表有一个写入责任模块;该模块的多个实例、迁移工具和运维任务都要遵守它的契约,并非只许一个线程写。schema 名本身也不是权限隔离,应结合数据库授权与代码规则约束访问。其他模块通过 API、只读投影或已发布事件获得数据。

禁止跨模块 JOIN 不是绝对教条。对只读报表,可以在明确的分析边界建立投影;对在线业务写入,跨模块直接改表会绕过拥有者的不变式,未来也难以提取。

用工具约束边界 ​

构建模块 ​

Gradle/Maven 子模块只有在不把实现依赖泄露到消费者 classpath 时,才能让相应引用编译失败;把所有 impl 都塞进共享依赖,目录拆开也没有帮助:

text
registration-api
registration-impl  -> registration-api
scoring-api
scoring-impl       -> scoring-api
application        -> 各模块 impl,负责组装

JPMS ​

这是放在命名模块根目录 module-info.java 中的 Java 21 声明,需要按 module path 构建运行。模块名不必与 Maven artifactId 相同:

java
module tournament.scoring {
    exports example.tournament.scoring.api;
}

例如 tournament.registration 声明 requires tournament.scoring 后,可以引用导出包里的公开 ScoringApi,却不能直接导入 example.tournament.scoring.internal.ScoreCalculator。仅有 exports 也不会把包私有类型变成公开类型。

requires 声明可读依赖,exports 控制普通访问,opens 服务于深反射;不要为了框架扫描就把整个模块开放。类放到普通 classpath 后不会获得这套命名模块边界,--add-exports 等显式覆写也能放宽访问。JPMS 检查可见性,业务边界仍需团队决定。

架构测试 ​

将此方法加入 12.2 的完整 ArchitectureTest,沿用 JUnit、ArchUnit 依赖及 classes 导入范围。组合根 bootstrap 被明确允许装配内部实现,其余业务模块只能访问 API:

java
@Test
void otherModulesUseOnlyScoringApi() {
    noClasses().that().resideOutsideOfPackages("..scoring..", "..bootstrap..")
            .should().dependOnClassesThat()
            .resideInAPackage("..scoring.internal..")
            .check(classes);
}

规则只保护 scoring,一套完整约束还要逐一覆盖其他模块,并另查循环依赖。新增一个 registration → scoring.internal 引用应使测试失败;不要把 bootstrap 例外复制成“每个包都能装配”。

模块化单体的运行优势 ​

  • 跨模块调用没有网络超时和序列化;
  • 一个进程便于本地调试与端到端追踪;
  • 单一部署降低环境、发布和回滚组合数;
  • 本地事务仍可用于少量确实需要原子性的跨模块操作。

这些优势不是“过渡期的落后特征”,而是真实的复杂度预算。只有当独立部署、扩缩或隔离的收益足够大时,才值得放弃其中一部分。

何时说明边界正在失效 ​

  • 一个功能必须同时修改多个模块的内部代码;
  • 多个模块共同写一张核心表;
  • 公共模块堆满业务对象,任何变化都影响全站;
  • 模块之间出现调用环;
  • 发布事件没有所有者、语义和兼容规则;
  • 所谓 API 只是把内部实体原样暴露出去。

先修复这些问题,再讨论服务拆分。下一课给出从证据到迁移的完整步骤。

参考资料 ​

Built with VitePress | Software Systems Atlas