搜索 K
Appearance
Appearance
forge 的数据建模能力建立在一条「启动期一次性扫描、解析、注册」的链路之上:应用启动时,框架在容器装配的极早阶段把 classpath 上所有 @Model 类找出来,逐个解析成 ModelDefinition,再统一注册进一张全局表,供后续的建表、元数据落库、关系读写消费。本篇解释这条链路「为什么这样设计」,并把扫描、类型推导、注册三段的取舍逐一摊开。需要可复制的上手示例,见 快速开始。
这条链路的核心约束
解析发生在 Bean 工厂后置处理阶段(BeanDefinitionRegistryPostProcessor),此时容器尚未实例化业务 Bean。换言之,模型元数据必须在「没有 Spring 上下文可用」的早期就备齐。这一约束塑造了下面几乎所有的设计选择。
整条链路的入口是 ModelMetaBootstrapPostProcessor,它实现 BeanDefinitionRegistryPostProcessor 并叠加 PriorityOrdered,确保自己在其他后置处理器之前先跑。它依次完成「收集扫描包 → 扫描类 → 解析模型 → 校验 → 注册并注入容器」。
这里有两个值得注意的设计动作:
第一,扫描包来源是「应用包 + SPI 贡献包」的并集。应用自身的基础包通过 AutoConfigurationPackages.get 取得;框架各内置模块要被一并扫到,则通过 ScanPackageProvider 这一 SPI 扩展点贡献自己的包名。这样业务方无需手写任何包配置,内置模块也能搭车进入同一轮扫描。SPI 聚合机制本身的细节属于另一条线,此处不展开。
第二,整条链路是「先全量解析、再统一校验、最后才注册」。跨模型关系(如 Pet.category 指向 Category)的补全与校验必须等所有模型都解析完才能做,因此注册被刻意推迟到校验通过之后——注册表里要么是一套自洽的完整定义,要么什么都没有。
ClazzScanner 是一个纯工具类,不依赖 Spring 容器,可在启动最早期的 BFPP 阶段直接调用。它没有自己写文件遍历或正则匹配,而是直接包装 Spring 的 ClassPathScanningCandidateComponentProvider,并挂上一个 AnnotationTypeFilter 只筛 @Model。
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 表示不立即初始化类,避免在这么早的阶段触发静态块。
| 选项 | 选择 | 理由 |
|---|---|---|
| 自写文件遍历 + 正则匹配类名 | 复用 ClassPathScanningCandidateComponentProvider | Spring 已处理好 jar/目录/嵌套包/类元数据读取,自写易漏边界 |
| 注册成 Spring 组件后再反查 | 纯静态工具类,BFPP 期直接调 | 模型解析必须早于 Bean 实例化,此时容器还没东西可查 |
用 HashSet 收集结果 | LinkedHashSet | 保留扫描顺序,使逐类校验的报错顺序可复现 |
拿到一批 @Model 类后,每个类交给 ModelParser.parse 单独解析。它先读类级注解定出模型名、表名、模型类型,再把字段解析委托给 parseFields,最后连同索引一起组装成 ModelDefinition。
parseFields 调用 getAllDeclaredFields 沿继承链一路向上收集到 Object 之前,把父类字段也纳进来——这正是宠物商店里 Pet extends IdModel<Pet> 能自动带上 id、createTime、updateTime 三个基类字段的原因。收集后跳过所有静态字段,再逐个构建 FieldDefinition。
解析阶段还顺手做了一项局部校验:单个模型最多只能有一个 @PrimaryKey 字段,多于一个直接抛 IllegalStateException。
字段的业务类型 FieldType 由 FieldTypeHelper 两段式推导,核心是两张映射表:
ANNOTATION_TO_TYPE:把类型子注解映射到 FieldType,如 @Field.String → STRING、@Field.BigDecimal → BIG_DECIMAL、@Field.M2O → M2O、@Field.M2M → M2M。JAVA_TYPE_TO_TYPE:把 Java 类精确等值映射到 FieldType,如 String → STRING、Long → LONG、BigDecimal → BIG_DECIMAL、LocalDateTime → DATE。推导顺序是「注解优先」:先 findByAnnotation,命中就用;没命中才退回 inferTypeFromJavaType,它先判断是否为枚举字段(isEnumField),否则 findByJavaType 查表;两条路都走不通就抛 Unsupported field type 异常,绝不静默放过。这就是为什么宠物商店里给 name 标 @Field.String、给 price 标 @Field.BigDecimal 是显式且推荐的写法——注解既给了类型也给了长度、精度等参数。
FieldType fieldType = annotatedType != null
? annotatedType
: inferTypeFromJavaType(reflectField);注解优先的一个推论
显式类型子注解一旦存在,Java 类型推导就被完全短路。这意味着注解与 Java 类型不一致时,以注解为准——所以请确保 @Field.Long 不会误标在一个 String 字段上。
关系字段与多选枚举都涉及集合,必须从泛型实参里取出元素类型,这一步靠反射读 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 → STRING、Integer → INT、Long → LONG。宠物商店的 PetStatus implements ValueEnum<Integer>,于是被推断为 INT 存储。若枚举没实现 ValueEnum 或 T 不在上述三类,推断返回 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。
relationFields、referenceFields 等在解析阶段照搬注解值,而 referenceFields 的缺省补全(如 M2O 缺省取目标主键)被放在更后面的 checkRelations 里做,因为它依赖「所有模型都已解析」这一前提。
解析与校验全部通过后,ModelMetaBootstrapPostProcessor 才逐个调用 ModelRegistry.register。注册表内部就是一张 Map<String, ModelDefinition>,实现选了 ConcurrentHashMap。
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: ...");
}
}注册本身发生在启动期单线程内,写入并不存在并发。真正的并发场景在「读」侧:注册表注入容器后,运行期会被关系读写、元数据查询等多线程并发读取(如 getModelD、getAllModels、findPrimaryKeyField)。ConcurrentHashMap 为这种「启动期写一次、运行期高频并发读」的访问模式提供了无锁的安全读,避免在每个读路径上加同步。
注册用 putIfAbsent 而非直接 put,并据返回值分三种情况处理:
Class:判定为重复注册,幂等忽略——这给「同一类可能被扫到两次」留了安全网。Class:判定为真正的命名冲突,立即抛 Duplicate model registration 并带上双方类名。这套语义保证了「模型名」作为全局唯一键的强约束:要么唯一,要么启动失败,不存在悄悄覆盖。
注册表对外暴露只读视图,getAllModels 返回 Collections.unmodifiableCollection 包裹的集合,杜绝调用方在运行期篡改注册结果。读接口分两类索引:
getModel),命中 Map 的 key,O(1)。Class 查(getModelByClass / getModelD),遍历比对 sourceClass;getModelD 在未注册时抛出带「请确认该类已被 @Model 注解并位于扫描路径下」提示的异常,是运行期按 Class 解析元数据(如关系字段填充)的统一入口。| 选项 | 选择 | 理由 |
|---|---|---|
普通 HashMap + 外部加锁 | ConcurrentHashMap | 启动写一次、运行期多线程高频读,无锁安全读最划算 |
put 直接覆盖 | putIfAbsent + 冲突抛错 | 模型名是全局唯一键,重名应是构建期错误而非静默覆盖 |
| 重复注册即报错 | 同类幂等、异类才报错 | 容忍同一类被扫两次,又不放过真正的命名冲突 |
| 返回内部集合引用 | unmodifiableCollection 只读视图 | 防止运行期篡改启动期固化的注册结果 |
扫描范围由 AutoConfigurationPackages 决定,它对应启动类(或其所在包)。框架不要求业务方为模型单独配置扫描包,而是复用 Spring Boot 自动装配既定的基础包,再叠加 SPI 贡献包。
| 选项 | 选择 | 理由 |
|---|---|---|
自定义 @ForgeScan(basePackages=...) 注解 | 复用 AutoConfigurationPackages | 与 Spring Boot 启动类约定一致,零额外配置即覆盖业务包 |
| 各内置模块硬编码进扫描列表 | ScanPackageProvider SPI 贡献 | 模块可插拔,新增模块只需实现 SPI,不改扫描器 |
| 解析后立即注册 | 全量解析 → 统一校验 → 再注册 | 跨模型关系补全依赖全量定义,注册表保持全有或全无 |
关于 basePackageClasses
若启动类不在业务模型的父包下,模型会扫不到。此时应让启动类上移到能覆盖 com.demo.petshop.model 的公共父包,或通过自动装配包机制确保该包在 AutoConfigurationPackages 之内——这与 Spring Boot 的 @SpringBootApplication 默认扫描语义一致。相关机制见 前置知识 Spring Boot 自动装配。
把日志级别开到 debug,启动时即可看到扫描与注册的逐项痕迹:
logging:
level:
cn.cvking.forge: debug代表性输出(来自 ModelMetaBootstrapPostProcessor 的注册日志):
[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)、关系字段的读填充与写维护都从这张表取定义。要看怎么用注解写出第一个模型并跑通存查,请移步 快速开始。