Skip to content

启动扫描与模型解析

forge 的数据建模能力建立在一条「启动期一次性扫描、解析、注册」的链路之上:应用启动时,框架在容器装配的极早阶段把 classpath 上所有 @Model 类找出来,逐个解析成 ModelDefinition,再统一注册进一张全局表,供后续的建表、元数据落库、关系读写消费。本篇解释这条链路「为什么这样设计」,并把扫描、类型推导、注册三段的取舍逐一摊开。需要可复制的上手示例,见 快速开始

这条链路的核心约束

解析发生在 Bean 工厂后置处理阶段(BeanDefinitionRegistryPostProcessor),此时容器尚未实例化业务 Bean。换言之,模型元数据必须在「没有 Spring 上下文可用」的早期就备齐。这一约束塑造了下面几乎所有的设计选择。

一、全链路概览

整条链路的入口是 ModelMetaBootstrapPostProcessor,它实现 BeanDefinitionRegistryPostProcessor 并叠加 PriorityOrdered,确保自己在其他后置处理器之前先跑。它依次完成「收集扫描包 → 扫描类 → 解析模型 → 校验 → 注册并注入容器」。

这里有两个值得注意的设计动作:

第一,扫描包来源是「应用包 + SPI 贡献包」的并集。应用自身的基础包通过 AutoConfigurationPackages.get 取得;框架各内置模块要被一并扫到,则通过 ScanPackageProvider 这一 SPI 扩展点贡献自己的包名。这样业务方无需手写任何包配置,内置模块也能搭车进入同一轮扫描。SPI 聚合机制本身的细节属于另一条线,此处不展开。

第二,整条链路是「先全量解析、再统一校验、最后才注册」。跨模型关系(如 Pet.category 指向 Category)的补全与校验必须等所有模型都解析完才能做,因此注册被刻意推迟到校验通过之后——注册表里要么是一套自洽的完整定义,要么什么都没有。

二、扫描:复用 Spring 而非重造轮子

ClazzScanner 是一个纯工具类,不依赖 Spring 容器,可在启动最早期的 BFPP 阶段直接调用。它没有自己写文件遍历或正则匹配,而是直接包装 Spring 的 ClassPathScanningCandidateComponentProvider,并挂上一个 AnnotationTypeFilter 只筛 @Model

java
public static Set<Class<?>> scan(Class<? extends Annotation> annotationType, String... packages) {
    Set<Class<?>> result = new LinkedHashSet<>();
    if (packages == null || packages.length == 0) {
        log.warn("Scan packages empty, skip scanning");
        return result;
    }
    ClassPathScanningCandidateComponentProvider provider = buildProvider(annotationType);
    for (String pkg : packages) {
        // ... findCandidateComponents(pkg) 后用 Class.forName(name, false, ...) 装载
    }
    return result;
}

前置知识

扫描底层依赖 Spring 的 classpath 候选组件机制,类装载用到反射的 Class.forName。不熟悉的话可先看 前置知识 Spring 包扫描Java 反射

返回值用 LinkedHashSet,既去重又保留扫描顺序——下游按这个顺序解析,能让校验时的报错位置稳定可复现。类装载时传入 false 表示不立即初始化类,避免在这么早的阶段触发静态块。

设计取舍:扫描器为何独立于容器

选项选择理由
自写文件遍历 + 正则匹配类名复用 ClassPathScanningCandidateComponentProviderSpring 已处理好 jar/目录/嵌套包/类元数据读取,自写易漏边界
注册成 Spring 组件后再反查纯静态工具类,BFPP 期直接调模型解析必须早于 Bean 实例化,此时容器还没东西可查
HashSet 收集结果LinkedHashSet保留扫描顺序,使逐类校验的报错顺序可复现

三、解析:ModelParser.parse 的字段流水线

拿到一批 @Model 类后,每个类交给 ModelParser.parse 单独解析。它先读类级注解定出模型名、表名、模型类型,再把字段解析委托给 parseFields,最后连同索引一起组装成 ModelDefinition

字段收集:沿父类链向上

parseFields 调用 getAllDeclaredFields 沿继承链一路向上收集到 Object 之前,把父类字段也纳进来——这正是宠物商店里 Pet extends IdModel<Pet> 能自动带上 idcreateTimeupdateTime 三个基类字段的原因。收集后跳过所有静态字段,再逐个构建 FieldDefinition

解析阶段还顺手做了一项局部校验:单个模型最多只能有一个 @PrimaryKey 字段,多于一个直接抛 IllegalStateException

类型推导:注解优先,Java 类型兜底

字段的业务类型 FieldTypeFieldTypeHelper 两段式推导,核心是两张映射表:

  • ANNOTATION_TO_TYPE:把类型子注解映射到 FieldType,如 @Field.String → STRING@Field.BigDecimal → BIG_DECIMAL@Field.M2O → M2O@Field.M2M → M2M
  • JAVA_TYPE_TO_TYPE:把 Java 类精确等值映射到 FieldType,如 String → STRINGLong → LONGBigDecimal → BIG_DECIMALLocalDateTime → DATE

推导顺序是「注解优先」:先 findByAnnotation,命中就用;没命中才退回 inferTypeFromJavaType,它先判断是否为枚举字段(isEnumField),否则 findByJavaType 查表;两条路都走不通就抛 Unsupported field type 异常,绝不静默放过。这就是为什么宠物商店里给 name@Field.String、给 price@Field.BigDecimal 是显式且推荐的写法——注解既给了类型也给了长度、精度等参数。

java
FieldType fieldType = annotatedType != null
        ? annotatedType
        : inferTypeFromJavaType(reflectField);

注解优先的一个推论

显式类型子注解一旦存在,Java 类型推导就被完全短路。这意味着注解与 Java 类型不一致时,以注解为准——所以请确保 @Field.Long 不会误标在一个 String 字段上。

集合泛型解析:M2M 与多选枚举的关键

关系字段与多选枚举都涉及集合,必须从泛型实参里取出元素类型,这一步靠反射读 getGenericType 并判定 ParameterizedType

  • 关系字段:resolveCollectionElementType 解出 List<Tag> 的元素 Tag,作为 M2M/O2M 的目标模型类(当 @Field.Relation.targetModel 缺省时)。
  • 多选枚举:resolveEnumElementType 解出集合的枚举元素类型;若是裸泛型集合(拿不到具体元素类型),fillEnumAttributes 直接抛 Enum collection field must declare a concrete generic element type

枚举的入库存储类型 EnumStoreType 不再由注解声明,而是 resolveEnumStoreType 沿继承链解析枚举所实现的 ValueEnum<T> 的泛型实参 T 推断而来:String → STRINGInteger → INTLong → LONG。宠物商店的 PetStatus implements ValueEnum<Integer>,于是被推断为 INT 存储。若枚举没实现 ValueEnumT 不在上述三类,推断返回 null,留给启动期的统一校验聚合报错——解析阶段本身不抛,以便一次性汇总所有违规。枚举落库契约的完整说明见 快速开始

关系字段的两个解析特例

fillRelationAttributes 处理 O2O/M2O/O2M/M2M 时有两点要点:

第一,关系字段一律被强制置为 store(false),覆盖前面按 @Field.Advanced.store 算出的值——关系字段是虚拟字段,本身不落库,对应 SysField 里关系字段 columnType 为空。

第二,目标模型类按 getTargetModel 推断:注解显式给了 targetModel 就用它;否则 to-one(M2O/O2O)取字段自身类型,to-many(O2M/M2M)取集合元素类型。这就是为什么 Pet.category@Field.M2O 而不必再写 targetModel = Category.class——字段类型本身已是 Category

relationFieldsreferenceFields 等在解析阶段照搬注解值,而 referenceFields 的缺省补全(如 M2O 缺省取目标主键)被放在更后面的 checkRelations 里做,因为它依赖「所有模型都已解析」这一前提。

四、注册:ConcurrentHashMap 与读写视图

解析与校验全部通过后,ModelMetaBootstrapPostProcessor 才逐个调用 ModelRegistry.register。注册表内部就是一张 Map<String, ModelDefinition>,实现选了 ConcurrentHashMap

java
public class ModelRegistry {
    private final Map<String, ModelDefinition> registry = new ConcurrentHashMap<>();

    public void register(ModelDefinition definition) {
        // ... null 校验
        ModelDefinition previous = registry.putIfAbsent(definition.getModelName(), definition);
        if (previous == null) {
            return;
        }
        if (previous.getSourceClass() == definition.getSourceClass()) {
            return; // 同一类重复注册,幂等忽略
        }
        throw new IllegalStateException("Duplicate model registration: ...");
    }
}

为什么是 ConcurrentHashMap

注册本身发生在启动期单线程内,写入并不存在并发。真正的并发场景在「读」侧:注册表注入容器后,运行期会被关系读写、元数据查询等多线程并发读取(如 getModelDgetAllModelsfindPrimaryKeyField)。ConcurrentHashMap 为这种「启动期写一次、运行期高频并发读」的访问模式提供了无锁的安全读,避免在每个读路径上加同步。

写入语义:putIfAbsent + 幂等 + 冲突即抛

注册用 putIfAbsent 而非直接 put,并据返回值分三种情况处理:

  • 模型名首次出现:正常写入。
  • 同名且来源是同一个 Class:判定为重复注册,幂等忽略——这给「同一类可能被扫到两次」留了安全网。
  • 同名但来源是不同 Class:判定为真正的命名冲突,立即抛 Duplicate model registration 并带上双方类名。

这套语义保证了「模型名」作为全局唯一键的强约束:要么唯一,要么启动失败,不存在悄悄覆盖。

读视图:对外只读、按名按类双索引

注册表对外暴露只读视图,getAllModels 返回 Collections.unmodifiableCollection 包裹的集合,杜绝调用方在运行期篡改注册结果。读接口分两类索引:

  • 按模型名直接查(getModel),命中 Map 的 key,O(1)。
  • Class 查(getModelByClass / getModelD),遍历比对 sourceClassgetModelD 在未注册时抛出带「请确认该类已被 @Model 注解并位于扫描路径下」提示的异常,是运行期按 Class 解析元数据(如关系字段填充)的统一入口。

设计取舍:注册环节

选项选择理由
普通 HashMap + 外部加锁ConcurrentHashMap启动写一次、运行期多线程高频读,无锁安全读最划算
put 直接覆盖putIfAbsent + 冲突抛错模型名是全局唯一键,重名应是构建期错误而非静默覆盖
重复注册即报错同类幂等、异类才报错容忍同一类被扫两次,又不放过真正的命名冲突
返回内部集合引用unmodifiableCollection 只读视图防止运行期篡改启动期固化的注册结果

五、扫描包来源与 basePackageClasses 取舍

扫描范围由 AutoConfigurationPackages 决定,它对应启动类(或其所在包)。框架不要求业务方为模型单独配置扫描包,而是复用 Spring Boot 自动装配既定的基础包,再叠加 SPI 贡献包。

选项选择理由
自定义 @ForgeScan(basePackages=...) 注解复用 AutoConfigurationPackages与 Spring Boot 启动类约定一致,零额外配置即覆盖业务包
各内置模块硬编码进扫描列表ScanPackageProvider SPI 贡献模块可插拔,新增模块只需实现 SPI,不改扫描器
解析后立即注册全量解析 → 统一校验 → 再注册跨模型关系补全依赖全量定义,注册表保持全有或全无

关于 basePackageClasses

若启动类不在业务模型的父包下,模型会扫不到。此时应让启动类上移到能覆盖 com.demo.petshop.model 的公共父包,或通过自动装配包机制确保该包在 AutoConfigurationPackages 之内——这与 Spring Boot 的 @SpringBootApplication 默认扫描语义一致。相关机制见 前置知识 Spring Boot 自动装配

六、启动验证

把日志级别开到 debug,启动时即可看到扫描与注册的逐项痕迹:

yaml
logging:
  level:
    cn.cvking.forge: debug

代表性输出(来自 ModelMetaBootstrapPostProcessor 的注册日志):

text
[bootstrap] registered module: pet
[bootstrap] registered model: pet.Pet -> module=pet (pkg=com.demo.petshop.model)
[bootstrap] registered model: pet.Category -> module=pet (pkg=com.demo.petshop.model)

可以看到模型名已是「模块编码 + . + 原始名」固化后的形态(pet.Pet),且每个模型都关联到了所属模块。若某个模型没被扫到,最直接的现象就是这里缺少它对应的 registered model 行。

七、链路衔接

本篇止于「注册表已就绪并注入容器」。注册完成后,注册表被下游消费:建表 DDL、系统元数据落库(SysModule / SysModel / SysField)、关系字段的读填充与写维护都从这张表取定义。要看怎么用注解写出第一个模型并跑通存查,请移步 快速开始