Skip to content

启动生命周期编排

forge 框架要在「一行业务代码都还没跑」之前,就把所有 @Module@Model 解析成内存中的元数据注册表,并在数据库里完成建表、改表、系统表落库。这一整条链路由两个时机串起来:

  • 元数据解析与注册:在 ModelMetaBootstrapPostProcessor(一个 BeanDefinitionRegistryPostProcessor)里完成,发生在任何 Bean 实例化之前。
  • 数据库 DDL 与落库:在 LifecycleOrchestrator 监听 Spring Boot 启动事件时完成,发生在容器就绪之后。

本篇解释这两段为什么要拆在两个时机、为什么用 BFPP 而不用更轻量的包标记机制、以及校验为什么要 fail-fast 一次性汇总。配套的操作步骤见使用指南 模块化与启动生命周期;涉及反射与包扫描的前置知识见 包扫描与启动钩子

全景:两个时机一条链路

整条启动链路按时间先后分为「元数据相生命周期」两段。下图把它画成一张流程图,左半部分跑在 Bean 工厂后处理阶段,右半部分跑在应用启动事件阶段。

把链路拆成两段,是这套设计最核心的取舍:元数据必须在 Bean 实例化前就位(否则后续依赖元数据的 Bean 无从构造),而 DDL 必须等数据源等基础设施 Bean 都就绪后才能执行。下面分别展开。

阶段 A:为什么是 BFPP,而且要最高优先级

元数据解析的载体是 ModelMetaBootstrapPostProcessor,它同时实现 BeanDefinitionRegistryPostProcessorPriorityOrdered,且 getOrder() 返回 PriorityOrdered.HIGHEST_PRECEDENCE(即 0,最高优先级)。它通过 MetadataBootstrapAutoConfiguration 里的静态 Bean 工厂方法注入。

选 BFPP 而非更省事的 @AutoConfigurationPackage,是因为后者只能「标记业务包」,拿不到容器引用、做不了元数据注册。两者的差异如下表。

维度BFPP 方案@AutoConfigurationPackage 方案
触发时机Bean 工厂后处理器,最高优先级,在 Bean 实例化前执行仅作为自动配置扫描包标记
容器访问拿到 BeanDefinitionRegistry,可直接注册单例无法访问容器,仅标记包
执行可靠性Spring 显式调用,顺序保证,可 fail-fast框架隐式处理,时序难控
元数据操作完全掌控解析、校验、注册全流程无法进行复杂元数据操作
依赖关系支持依赖解析、拓扑排序、循环检测无此能力
实例共享注册为 modelRegistry / moduleRegistry,全容器可注入需额外机制共享

为什么必须是 HIGHEST_PRECEDENCE

框架内有不少 Bean 在创建时就要读元数据(如动态 Mapper、Repository 注册)。把解析器排到最高优先级,保证它在所有其他后处理器、所有 Bean 之前先把 modelRegistrymoduleRegistry 注册进容器,后来者一律能注入到完整的元数据。

业务应用包仍走 Spring Boot 标准的 AutoConfigurationPackages 获取,框架内置模块的包则通过 SPI 聚合补齐——这是阶段 A 的第一步。

步骤 1:SPI 聚合扫描包

框架不把内置模块的包名硬编码进启动器,而是让每个模块各自实现 ScanPackageProvider@SPI 接口),通过 Spider.getAllExtensions 聚合所有贡献者的包列表。

java
List<String> packages = new ArrayList<>(AutoConfigurationPackages.get(beanFactory));
// 此时 packages 含业务应用包,如 [cn.cvking.samples, ...]

for (ScanPackageProvider provider : Spider.getAllExtensions(ScanPackageProvider.class)) {
    // SysScanPackageProvider       -> [cn.cvking.forge.boot.sys]
    // ForgeScanPackageProviderImpl -> [cn.cvking.forge.auth]
    packages.addAll(provider.contributePackages());
}
// 最终 packages = [cn.cvking.samples, cn.cvking.forge.boot.sys, cn.cvking.forge.auth, ...]

这是聚合贡献模式:多个 SPI 实现各报各的包,框架汇总后统一扫描。与之相对的是单选互斥模式(如 SqlDialectAuthProvidergetDefaultExtension / getExtension 取唯一实现)。两种模式的取舍见 SPI 扩展机制

维度SPI 聚合方案硬编码方案
可扩展性新模块加一份 META-INF/services 贡献即可,核心启动代码不动每加一个模块都要改 BFPP 代码
包管理职责各模块自洽声明自身包,单一职责包列表集中在启动器,职责跨界
模块隔离框架模块可独立发布,SPI 自动发现依赖启动器同步更新,不利分发
集合性质多贡献者汇聚所有包,统一扫描固定列表,无法动态聚合

结论很直接:SPI 聚合是开闭原则的践行,避免启动器变成所有模块的中央枢纽。

步骤 2-3:扫描建表,逐个解析

收齐包列表后,分两轮扫描:

  • 步骤 2 — buildModuleRegistry:用 ModuleClassScanHelper 扫出所有 @Module 类,ModuleAnnoScanHelper 把注解解析成 ModuleDefinition,逐个 registry.register(def)
  • 步骤 3 — buildModelRegistry:用 ClazzScanner 扫出所有 @Model 类,对每个类依次 ModelParser.parse 解析、resolveLogicDelete 固化逻辑删除生效值、getModuleByPackage 按最长前缀匹配归属模块、finalizeNaming 固化模型名(module.name 形态)与表名索引名。

ModuleDefinition.effectivePackages() 返回的有效扫描包 = 标注 @ModulesourceClass 所在包 + 显式 scanPackages,这是模型按包归属模块的依据。模型与字段注解的解析细节见 元数据建模

跨模型校验与 fail-fast

单个模型解析完后,还要做跨模型级别的校验。这一步把所有违规收集起来,最后一次性 throwIfAny 抛出,而不是发现第一个错就崩。校验覆盖的违规类型如下:

违规类型含义
unowned模型没有任何模块覆盖
ambiguous模型的包被多个模块等长匹配,归属不明
tableViolationsSTORE 模型表名为空或重复
indexViolations索引引用了不存在的字段
logicDeleteViolations逻辑删除列与字段冲突
enumViolations枚举字段配置非法
relationViolations关系字段配置非法

fail-fast 为什么要一次性汇总

若发现一个错就抛,开发者改一个、重启一次、再撞下一个,体验是「打地鼠」。一次性汇总让所有违规在一轮启动里全部暴露,符合启动期主动校验的设计取向——隐式约束统统提前到启动期显式报错。

校验全过的前提下,步骤 4 把两个注册表注册为单例:modelRegistrymoduleRegistry。至此阶段 A 结束,Bean 进入正常实例化。

阶段 A 代表性日志
[bootstrap] registered module: system
[bootstrap] registered module: auth
[bootstrap] registered module: biz
[bootstrap] registered model: sys_module -> module=system (pkg=cn.cvking.forge.boot.sys)
[bootstrap] registered model: user -> module=auth (pkg=cn.cvking.forge.auth.entity)
[bootstrap] registered model: biz_order -> module=biz (pkg=cn.cvking.samples.entity)

阶段 B:落库为何放在 ApplicationStartedEvent

LifecycleOrchestrator.onStarted 监听 Spring Boot 的 ApplicationStartedEvent,以 @Order(0) 最高优先级执行。这一段干的是 DDL 与系统表落库,必须等容器就绪——数据源、方言、systemMetaManager 等基础设施 Bean 都装配完成后才能跑。这正是它不能塞进阶段 A 的原因:BFPP 阶段连数据源都还没初始化。

阶段 B 的编排顺序由 Lifecycle 枚举与 dry-run 标志共同决定:

java
public enum Lifecycle {
    INSTALL,  // 完整安装(建表 + 改表 + 系统表落库)
    RELOAD,   // 跳过 DDL(仅系统表元数据落库)
    DDL;      // 仅输出 SQL(dry-run,输出后退出 JVM)
}

配置前缀为 app.app.lifecycle 缺省 INSTALLapp.dryRunOutputDir 缺省 target

下面的时序图把阶段 B 的协作对象与分支讲透。

拓扑排序:被依赖者优先

ModuleSortHelper.sort 按依赖拓扑排序,被依赖模块排在前面,同层级再按 priority 升序。这保证建表顺序合理——system 的系统表先于依赖它的 authbiz 建立。@Moduleprioritydependencies 等参数语义见 模块化建模

三种生命周期的设计意图

模式DDL系统表落库退出行为适用场景
INSTALL建表 + 改表落库正常启动首次部署、结构演进
RELOAD跳过落库正常启动表结构已就位,仅刷新元数据
DDL仅收集 SQL 写文件不落库退出 JVM评审 SQL、交 DBA 审核

DDL 模式把 SQL 写盘后主动 SpringApplication.exit 退出,是为了 dry-run 语义的纯粹:它绝不触碰真实库,只产出可供人工审核的 SQL 脚本。

阶段 B 代表性日志
[lifecycle] Module topology order: [system, auth, biz]
[lifecycle] Lifecycle=INSTALL, 模块 3 个, 扫描模型 12 个, 耗时 456ms
[bootstrap] RepositoryRegistry initialized with 5 repository(ies)
[bootstrap] TableInfo patched for 10/12 STORE model(s)
[bootstrap] Registered 8 dynamic MapperFactoryBean(s)
[lifecycle] 物理删表 DROP TABLE old_table(allow-drop=true)
[lifecycle] 逻辑删表 vanished_table -> _d_vanished_table_1717881600000

dry-run 模式:

[lifecycle] Module topology order: [system, auth, biz]
[DDL] Lifecycle=DDL completed, dry-run file: /path/to/target/ddl-2024-06-04.sql, JVM will exit now.

小结

forge 的启动编排把「元数据解析」与「数据库落库」刻意拆到两个时机:前者用最高优先级的 BFPP,在 Bean 实例化前就把注册表准备好;后者用 ApplicationStartedEvent 监听,等基础设施就绪后再动数据库。两段之间靠 modelRegistry / moduleRegistry 两个单例衔接。三个贯穿全局的设计取向是:SPI 聚合换取开闭原则、fail-fast 一次性汇总换取顺畅的纠错体验、拓扑排序换取可靠的建表顺序。落地操作请转 模块化与启动生命周期使用指南