Skip to content

应用架构 · 设计总览

这一组设计文档回答的核心问题不是「forge 怎么用」,而是「forge 为什么这样组织一个应用」。如果你只想把宠物商店跑起来,请先读对应的使用指南;如果你想知道一行 @Model 注解从被扫描到落地成表,中间究竟经过了哪些环节、为什么是这个顺序,那么本篇就是入口。

forge 的应用架构可以浓缩成一句话:以模块为编排单元,在启动早期一次性完成「扫描 — 解析 — 校验 — 注册」,再以拓扑顺序驱动建表与元数据落库。围绕这句话,本篇先给出整体分层,再把设计篇的三条主线串起来。

三条设计主线

主线解决的问题关键载体
架构总览模块如何声明、模型如何归属、注册表如何共享@ModuleModuleDefinitionModelRegistry
启动生命周期编排(★)扫描与建表为何分两段、谁先谁后、dry-run 如何退出ModelMetaBootstrapPostProcessorLifecycleOrchestrator
SPI 聚合机制框架模块如何在不改启动器代码的前提下贡献扫描包@SPISpiderScanPackageProvider

阅读顺序建议

先看本页的「分层与启动两段论」建立全局心智模型,再深入「启动生命周期编排」理解时序,最后用「SPI 聚合机制」理解可扩展性。三篇互为补充,不必一次读完。

分层与职责

forge 把「描述应用」与「驱动应用」拆成两类构件:

  • 声明层:业务用 @Module 描述模块边界,用 @Model/@Field 描述实体。这些注解是纯粹的元数据,不含运行逻辑。
  • 编排层:框架在启动期把声明层翻译成内存中的 ModuleRegistryModelRegistry,再据此生成 DDL、落库系统表。

声明层只表达「是什么」,编排层独占「怎么落地」。这条分界线让业务实体类保持极简——以宠物商店的 Pet 为例,它只声明字段与关系,不感知扫描、校验、建表的任何一步。

启动两段论:为什么扫描与建表要分开

forge 的启动被刻意切成两个互不重叠的阶段,这是整个架构最重要的取舍。

设计取舍选项选择理由
元数据何时构建A. Bean 实例化后用监听器构建;B. BFPP 在实例化前构建B元数据是后续所有 Bean(Mapper、Repository)的前提,必须最早就绪
校验时机A. 用到时报错;B. 启动期全量校验B隐式约束改启动期 fail-fast,多违规一次性汇总报告,避免上线后才暴露
建表与扫描是否合并A. 扫描完立即建表;B. 扫描归 BFPP、建表归 StartedEventB建表依赖数据源连接,而数据源 Bean 在 BFPP 阶段尚未就绪

两段不可颠倒

阶段一在 BeanDefinitionRegistryPostProcessor 中执行,此时数据源尚未初始化,因此不能建表;阶段二在 ApplicationStartedEvent 中执行,此时元数据注册表早已就绪,可以放心驱动 DDL。把建表塞进阶段一会导致拿不到数据源连接。

阶段一由 ModelMetaBootstrapPostProcessor 承担,它实现 BeanDefinitionRegistryPostProcessorPriorityOrderedgetOrder() 返回 PriorityOrdered.HIGHEST_PRECEDENCE,确保自己在所有 Bean 定义后处理器中最先运行。阶段二由 LifecycleOrchestrator 承担,监听 ApplicationStartedEvent@Order(0)。两者的详细时序在启动生命周期编排中展开。

模块:编排的最小单位

@Module 不是装饰性标记,它是拓扑排序与元数据归属的基准。宠物商店的业务模块声明形如:

java
@Module(
    code = BizModule.MODEL_CODE,
    name = "测试业务工程",
    version = 1,
    versionName = "1.0.0",
    priority = 100,
    dependencies = {SystemModule.MODULE_CODE}
)
public final class BizModule {
    public static final String MODEL_CODE = "biz";
}

几个字段直接决定编排行为:

  • dependencies 描述模块间依赖,priority 描述同层先后(数值越小越先处理),二者共同喂给 ModuleSortHelper 得到拓扑序。
  • scanPackages 与源类所在包共同构成有效扫描范围,由 ModuleDefinitioneffectivePackages() 返回。
  • 模型按「最长包前缀」匹配到所属模块,匹配不到则在校验阶段作为 unowned 违规抛出。

框架自带的 SystemModulepriority = 0)与 AuthModulepriority = 10, dependencies = {"system"})是这套规则的内置示范:系统模块永远最先建表,权限模块紧随其后,业务模块最后。这正是日志里那行拓扑序的来源:

[lifecycle] Module topology order: [system, auth, biz]

模块定义模型、注册表与排序器的细节,见架构总览

SPI 聚合:可扩展性从何而来

阶段一的第一步是「收集扫描包」。业务包由 Spring Boot 的 AutoConfigurationPackages 提供,而框架内置模块(forge-bootforge-auth 等)的包则通过 SPI 聚合贡献:

这里用的是聚合贡献模式:Spider.getAllExtensions(ScanPackageProvider.class) 遍历所有实现,把每个 contributePackages() 的返回值汇总进同一个包集合。它与「单选互斥」模式(如方言、鉴权实现用 getDefaultExtension() 取唯一实现)形成对照。

设计取舍选项选择理由
框架模块如何被扫描A. 启动器硬编码包列表;B. SPI 聚合贡献B新框架模块只需添加 META-INF/services 贡献,无需修改启动器代码,践行开闭原则
包声明职责归属A. 集中在启动器;B. 各模块自洽声明B每个模块声明自身包,符合单一职责,利于独立发布

这意味着新增一个框架模块时,既不需要改 BFPP,也不需要改任何中央清单——只要实现 ScanPackageProvider 并在 META-INF/services/cn.cvking.forge.module.spi.ScanPackageProvider 里登记即可。完整机制与新增步骤见 SPI 聚合机制,其底层依赖的反射与包扫描能力见前置知识

串起来:一次完整启动

把三条主线合到一张时序图上,就是 forge 应用从进程拉起到对外服务的全过程。

任何一篇深入文档,最终都能落回这张图上的某个环节。建议带着「这一步发生在 BFPP 还是 StartedEvent」「这是聚合还是单选」两个问题去读后续各篇。

下一步