搜索 K
Appearance
Appearance
forge 框架要在「一行业务代码都还没跑」之前,就把所有 @Module 与 @Model 解析成内存中的元数据注册表,并在数据库里完成建表、改表、系统表落库。这一整条链路由两个时机串起来:
ModelMetaBootstrapPostProcessor(一个 BeanDefinitionRegistryPostProcessor)里完成,发生在任何 Bean 实例化之前。LifecycleOrchestrator 监听 Spring Boot 启动事件时完成,发生在容器就绪之后。本篇解释这两段为什么要拆在两个时机、为什么用 BFPP 而不用更轻量的包标记机制、以及校验为什么要 fail-fast 一次性汇总。配套的操作步骤见使用指南 模块化与启动生命周期;涉及反射与包扫描的前置知识见 包扫描与启动钩子。
整条启动链路按时间先后分为「元数据相生命周期」两段。下图把它画成一张流程图,左半部分跑在 Bean 工厂后处理阶段,右半部分跑在应用启动事件阶段。
把链路拆成两段,是这套设计最核心的取舍:元数据必须在 Bean 实例化前就位(否则后续依赖元数据的 Bean 无从构造),而 DDL 必须等数据源等基础设施 Bean 都就绪后才能执行。下面分别展开。
元数据解析的载体是 ModelMetaBootstrapPostProcessor,它同时实现 BeanDefinitionRegistryPostProcessor 与 PriorityOrdered,且 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 之前先把 modelRegistry 与 moduleRegistry 注册进容器,后来者一律能注入到完整的元数据。
业务应用包仍走 Spring Boot 标准的 AutoConfigurationPackages 获取,框架内置模块的包则通过 SPI 聚合补齐——这是阶段 A 的第一步。
框架不把内置模块的包名硬编码进启动器,而是让每个模块各自实现 ScanPackageProvider(@SPI 接口),通过 Spider.getAllExtensions 聚合所有贡献者的包列表。
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 实现各报各的包,框架汇总后统一扫描。与之相对的是单选互斥模式(如 SqlDialect、AuthProvider 用 getDefaultExtension / getExtension 取唯一实现)。两种模式的取舍见 SPI 扩展机制。
| 维度 | SPI 聚合方案 | 硬编码方案 |
|---|---|---|
| 可扩展性 | 新模块加一份 META-INF/services 贡献即可,核心启动代码不动 | 每加一个模块都要改 BFPP 代码 |
| 包管理职责 | 各模块自洽声明自身包,单一职责 | 包列表集中在启动器,职责跨界 |
| 模块隔离 | 框架模块可独立发布,SPI 自动发现 | 依赖启动器同步更新,不利分发 |
| 集合性质 | 多贡献者汇聚所有包,统一扫描 | 固定列表,无法动态聚合 |
结论很直接:SPI 聚合是开闭原则的践行,避免启动器变成所有模块的中央枢纽。
收齐包列表后,分两轮扫描:
buildModuleRegistry:用 ModuleClassScanHelper 扫出所有 @Module 类,ModuleAnnoScanHelper 把注解解析成 ModuleDefinition,逐个 registry.register(def)。buildModelRegistry:用 ClazzScanner 扫出所有 @Model 类,对每个类依次 ModelParser.parse 解析、resolveLogicDelete 固化逻辑删除生效值、getModuleByPackage 按最长前缀匹配归属模块、finalizeNaming 固化模型名(module.name 形态)与表名索引名。ModuleDefinition.effectivePackages() 返回的有效扫描包 = 标注 @Module 的 sourceClass 所在包 + 显式 scanPackages,这是模型按包归属模块的依据。模型与字段注解的解析细节见 元数据建模。
单个模型解析完后,还要做跨模型级别的校验。这一步把所有违规收集起来,最后一次性 throwIfAny 抛出,而不是发现第一个错就崩。校验覆盖的违规类型如下:
| 违规类型 | 含义 |
|---|---|
| unowned | 模型没有任何模块覆盖 |
| ambiguous | 模型的包被多个模块等长匹配,归属不明 |
| tableViolations | STORE 模型表名为空或重复 |
| indexViolations | 索引引用了不存在的字段 |
| logicDeleteViolations | 逻辑删除列与字段冲突 |
| enumViolations | 枚举字段配置非法 |
| relationViolations | 关系字段配置非法 |
fail-fast 为什么要一次性汇总
若发现一个错就抛,开发者改一个、重启一次、再撞下一个,体验是「打地鼠」。一次性汇总让所有违规在一轮启动里全部暴露,符合启动期主动校验的设计取向——隐式约束统统提前到启动期显式报错。
校验全过的前提下,步骤 4 把两个注册表注册为单例:modelRegistry、moduleRegistry。至此阶段 A 结束,Bean 进入正常实例化。
[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)LifecycleOrchestrator.onStarted 监听 Spring Boot 的 ApplicationStartedEvent,以 @Order(0) 最高优先级执行。这一段干的是 DDL 与系统表落库,必须等容器就绪——数据源、方言、systemMetaManager 等基础设施 Bean 都装配完成后才能跑。这正是它不能塞进阶段 A 的原因:BFPP 阶段连数据源都还没初始化。
阶段 B 的编排顺序由 Lifecycle 枚举与 dry-run 标志共同决定:
public enum Lifecycle {
INSTALL, // 完整安装(建表 + 改表 + 系统表落库)
RELOAD, // 跳过 DDL(仅系统表元数据落库)
DDL; // 仅输出 SQL(dry-run,输出后退出 JVM)
}配置前缀为 app.:app.lifecycle 缺省 INSTALL,app.dryRunOutputDir 缺省 target。
下面的时序图把阶段 B 的协作对象与分支讲透。
ModuleSortHelper.sort 按依赖拓扑排序,被依赖模块排在前面,同层级再按 priority 升序。这保证建表顺序合理——system 的系统表先于依赖它的 auth、biz 建立。@Module 的 priority、dependencies 等参数语义见 模块化建模。
| 模式 | DDL | 系统表落库 | 退出行为 | 适用场景 |
|---|---|---|---|---|
| INSTALL | 建表 + 改表 | 落库 | 正常启动 | 首次部署、结构演进 |
| RELOAD | 跳过 | 落库 | 正常启动 | 表结构已就位,仅刷新元数据 |
| DDL | 仅收集 SQL 写文件 | 不落库 | 退出 JVM | 评审 SQL、交 DBA 审核 |
DDL 模式把 SQL 写盘后主动 SpringApplication.exit 退出,是为了 dry-run 语义的纯粹:它绝不触碰真实库,只产出可供人工审核的 SQL 脚本。
[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_1717881600000dry-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 一次性汇总换取顺畅的纠错体验、拓扑排序换取可靠的建表顺序。落地操作请转 模块化与启动生命周期使用指南。