本文件供 AI(Code Agent / Copilot / Claude / Cursor 等)在本仓库内持续开发时阅读。 深入设计细节见
docs/AI/ARCHITECTURE.md,编码规范见docs/AI/CONVENTIONS.md。
StructUtil 是一个「结构化数据 → Java Bean」的映射加载框架:把 Excel(.xls/.xlsx)、CSV、JSON、XML、Protobuf 等表格式数据文件,依据注解元数据映射成 Java 对象,支持基础类型、枚举、日期、数组、集合、Map、嵌套引用与聚合;并可无缝接入 Spring / Spring Boot,作为「配置表缓存(StructStore)」自动装配、懒加载与文件热重载。
典型场景:游戏/后端配置表(策划 Excel)→ 内存中的强类型缓存。
| 项 | 值 | 备注 |
|---|---|---|
| 构建 | Gradle 9.2.0(wrapper) | Windows 用 gradlew.bat |
| JDK | 25(JavaLanguageVersion.of(25)) |
代码大量使用 Java 21+ 语法 |
| group / version | org.structutil / 5.0.0.beta-SNAPSHOT |
定义在根 build.gradle |
| 编码 | UTF-8(options.encoding) |
中文注释合法且常见 |
| 模块系统 | JPMS,struct-core、struct-spring 均有 module-info.java |
新增可导出包必须同步改 module-info.java |
关键依赖版本集中在根 build.gradle 的 ext.version_options:
SPRING_BOOT_VERSION: "4.0.0", SPRING_VERSION: "7.0.1", JUNIT_VERSION: "6.0.1",
MOCKITO_VERSION: "5.20.0", LOG4J2_VERSION: "2.25.2", GSON_VERSION: "2.13.2",
POI_VERSION: "5.2.2", PROTOBUF_VERSION: "4.29.3"语言级别硬要求:源码使用 record 模式匹配、instanceof 模式匹配(如 if (fieldOrRc instanceof RecordComponent rc))、List.of、文本块、Optional 等。
禁止为了兼容性把这些降级成 Java 8 写法。
禁止让 Gradle 常驻 Daemon。这是强制约定,不是偏好。
# ✅ 正确:显式 --no-daemon
.\gradlew.bat --no-daemon :struct-core:test
# ✅ 也可以:项目已全局禁用,不加参数同样是 no-daemon(见下)
.\gradlew.bat :struct-core:test
# ❌ 严禁:任何会拉起/复用常驻 daemon 的用法
.\gradlew.bat --daemon ...
gradle ... # 直接用 gradle 命令而非 wrapper根目录 gradle.properties 已设置 org.gradle.daemon=false 作为兜底。
但 AI 仍应显式写出 --no-daemon:不要依赖配置文件,别假设它一定存在或没被改回 true。
为什么强制:
- AI 会导致 daemon 进程溢出(首要原因)。AI 一轮会话中可能触发几十次构建,且不同调用的参数 / JVM 参数 / toolchain 各异,Gradle 会为不同配置各自拉起一个新 daemon(默认单 daemon 空闲 3 小时才退出)。这些进程常驻后台持续吃内存,很快堆积到十几个 GiB,最终拖垮机器或 OOM。 no-daemon(single-use daemon)在构建结束时立即退出,进程数恒为 0。
- Windows 文件锁。常驻 daemon 会长期持有
build/与 Gradle user home 的锁,导致clean、 IDE 同步、以及并发构建失败。
代价是每次构建重付 JVM 启动 + 配置成本(本机约 6~13s)。这个代价必须接受。
AI 自检:若怀疑已有 daemon 堆积,执行
.\gradlew.bat --stop清理; 或jps -l | Select-String GradleDaemon查看。 不要把--stop写进常规构建命令里,只在排障时用。实测证据:仅经过本轮十余次构建,机器上就残留了 2 个常驻
GradleDaemon进程 (jps -l可见),gradlew --stop后归零。这正是本条强制要求的原因。
# 全量编译 + 测试
.\gradlew.bat --no-daemon build
# 只编译单个模块(最快反馈)
.\gradlew.bat --no-daemon :struct-core:compileJava
.\gradlew.bat --no-daemon :struct-spring:compileJava
.\gradlew.bat --no-daemon :struct-spring-boot-starter:compileJava
# 跑测试(加 cleanTest 强制重跑,避免 UP-TO-DATE 跳过)
.\gradlew.bat --no-daemon :struct-core:cleanTest :struct-core:test
# 发布到本地仓库
.\gradlew.bat --no-daemon publishToMavenLocal
# 覆盖率报告(struct-core / struct-spring / struct-spring-boot-starter)
.\gradlew.bat --no-daemon codeCoverageReport注意 UP-TO-DATE:即使 no-daemon,增量构建状态仍保存在磁盘,未变化的 task 会显示 UP-TO-DATE
而不真正执行。想确认测试真的跑了,用上面的 cleanTest test 组合,或加 --rerun-tasks。
所有 Gradle 命令必须加
--no-daemon(详见 §2.1)。AI 反复触发构建会让 daemon 进程堆积溢出。根
build.gradle中设置了test { ignoreFailures = true },测试失败不会让构建失败。 你不能用BUILD SUCCESSFUL判断改动是否安全。必须亲自检查:
struct-core/build/test-results/test/*.xml(机器可读)struct-core/build/reports/tests/test/index.html当前基线(2026-09-04):
struct-core共 242 个测试,其中 1 个失败 (BomInputStreamTest,属测试数据缺陷,见 §9.2)。改动后失败数 > 1 即为你引入的回归。
⚠️ 另有struct-spring-boot-starter的StructAutoConfigurationTest#testAutoConfiguredMapperScannerRegistrar也是既有失败(9 个测试中 1 个失败),不是你的回归 —— 已通过将生产文件回滚到 HEAD 复跑证实。 根因:该测试 stub 的是BeanFactory#getBean(String)(单参),而 Spring Boot 4 的AutoConfigurationPackages#get走的是getBean(String, Class)(双参),未 stub 故返回 null → NPE。 这是 Spring Boot 4 升级后的既有测试缺陷,与 struct-core 无关,修它需要动 starter 的测试。
当前行覆盖率:94.99%(目标 ≥ 90%)。测量命令:
.\gradlew.bat --no-daemon codeCoverageReport- 报告 XML:
build/reports/report.xml(html已被关闭,只生成 XML) - 覆盖
struct-core/struct-spring/struct-spring-boot-starter三个模块 - 必须先
cleanTest再跑:JaCoCo 的.exec会跨次累积。若先跑过--tests Xxx的 单测再跑全量,结果会虚高(曾观测到 93.49% 实为 92.52%)。 - 修改涉及
StructConfig/ConverterRegistry等全局单例的测试时,注意测试间状态污染 (见 §9.2)。
- JDK 25 已就位并已登记(2026-09-04):JDK 装在
E:\Program Files\Java\jdk-25, 且gradle.properties里已写入org.gradle.java.installations.paths=E:/Program Files/Java/jdk-25。 因此直接跑gradlew.bat即可,不要再传-I xxx.gradle的 init script。 ✅ 已实测:.\gradlew.bat --no-daemon :struct-core:cleanTest :struct-core:test通过, 产物 class 文件 major version = 69(Java 25),struct-core230 个测试 / 1 失败 (BomInputStreamTest基线失败)。⚠️ 不要再把 toolchain 降级到 21:本机 JDK 21 (21.0.7) 仍在,但项目要求就是 25, 降级会掩盖 Java 22–25 语法问题。
struct-core/ 核心引擎(无 Spring 依赖),module-info: struct.core
org/struct/annotation/ 用户 API 注解:@StructSheet @StructField @StructOptional
org/struct/core/ 引擎主体:StructWorker StructDescriptor FieldDescriptor...
/core/handler/ 数据源处理器(SPI):Excel/CSV/JSON/Protobuf...
/core/converter/ 类型转换器(SPI):ConverterRegistry + 内置转换器
/core/factory/ Bean 结构解析工厂(SPI):JdkStructFactory
/core/matcher/ 文件匹配与优先级:WorkerMatcher / FileExtensionMatcher
/core/filter/ Bean 级过滤器:StructBeanFilter
org/struct/spi/ 自研 SPI 加载器(非 JDK ServiceLoader)
org/struct/util/ 工具类:Reflects WorkerUtil ConverterUtil Strings BomInputStream
org/struct/support/ FileWatcherService(文件变更监听,热重载基础)
org/struct/exception/ 6 个异常类型
struct-spring/ Spring 集成(ClassPath 扫描 + StructStore),module-info: struct.spring
org/struct/spring/annotation/ @StructScan @AutoStruct @StructStoreOptions
org/struct/spring/support/ ClassPathStructScanner StructStore(接口) MapStructStore ListStructStore
org/struct/spring/handler/ XmlStructHandler(XML 数据源处理器)
struct-spring-boot-starter/ Spring Boot 自动装配
StructAutoConfiguration / StructProperties / StructServiceProperties
StructStoreService(BeanPostProcessor,负责注入与生命周期)
StructStoreServiceHealthContributorAutoConfiguration(Actuator 健康检查)
struct-all/ BOM 聚合模块(pom packaging,无源码)
struct-examples/ Spring Boot 示例应用(MyApplication + beans.xml + 数据文件)
依赖方向:struct-examples → struct-spring-boot-starter → struct-spring → struct-core。严禁反向依赖。
StructWorker.toList()/toMap()
│
├─1. checkStructFactory()
│ WorkerUtil.structFactory(clz, worker) ← SPI: StructFactoryBean
│ └─ JdkStructFactory.parseStruct() ← 扫描字段,建 FieldDescriptor
│ └─ 对每个字段 createFieldDescriptor()
│ ├─ @StructOptional → OptionalDescriptor(内含多个 SingleFieldDescriptor)
│ └─ 否则 → SingleFieldDescriptor
│ └─ worker.handleReferenceFieldValue(...) ← 递归预加载被引用表
│
├─2. handleDataFile(Consumer<T>)
│ WorkerUtil.resolveFilePath(workspace, fileName) ← classpath: / file: / 裸路径
│ WorkerUtil.lookupStructHandler(descriptor, file) ← 按 matcher + order 排序
│ for handler in handlers: try { handler.handle(...) ; return } catch { 下一个 }
│ StructHandler 解析一行/一条 → StructImpl(字段名→值)
│ → worker.createInstance(StructImpl)
│ → JdkStructFactory.newStructInstance()
│ → 逐字段 handleInstanceFieldValue()(required 校验 + 类型转换 + 引用注入)
│ ↓
│ 描述符.getFilter() 不为空 → 用 StructBeanFilter 包装 Consumer(过滤掉的行不进入结果)
│
└─3. 收集到 Collection / Map(toList / toMap / toListWithGroup / toMapWithGroup)
| SPI 接口 | 注册文件 | 作用 | 内置实现 |
|---|---|---|---|
StructHandler |
META-INF/struct/org.struct.core.handler.StructHandler |
解析一种数据格式 | JsonStructHandler CsvStructHandler XlsEventStructHandler XlsxSaxStructHandler ExcelUMStructHandler ProtobufStructHandler(core);XmlStructHandler(spring) |
Converters |
META-INF/struct/org.struct.core.converter.Converters |
批量注册类型转换器 | EmbeddedConverters(order=0) |
StructFactoryBean |
META-INF/struct/...StructFactoryBean |
创建 StructFactory(Bean 结构解析器) |
DefaultStructFactoryBean → JdkStructFactory |
SPI 加载器是自研的
org.struct.spi.EnhancedServiceLoader,不是 JDK 的ServiceLoader。 它会同时扫描META-INF/services/<接口全名>和META-INF/struct/<接口全名>两个目录。 实现类用@SPI(name=..., order=...)标注;order升序排序(load()取最后一个,loadAll()返回全部)。
| 概念 | 含义 |
|---|---|
| Struct Bean | 被 @StructSheet 标注的目标 Java 类(POJO 或 record) |
| StructDescriptor | 类级元数据:fileName / sheetName / startOrder / endOrder / matcher / filter |
| FieldDescriptor | 字段元数据抽象基类,实现 Comparable(排序决定 record 构造参数顺序) |
| SingleFieldDescriptor | 单个字段:name reference refGroupBy refUniqueKey aggregateBy aggregateType required cached converter |
| OptionalDescriptor | @StructOptional 多候选字段,按顺序取第一个解析出非 null 值的候选 |
| StructImpl | 一行数据的临时中间表示(HashMap<字段名, 值>),add() 会忽略 null/空串 |
| StructWorker | 一次加载任务的驱动者,持有 workspace / descriptor / factory / 引用值缓存 |
| WorkerMatcher | 决定某个 Handler 能否处理该文件 + 优先级(HIGHEST=MIN_VALUE,LOWEST=MAX_VALUE) |
| StructStore | Spring 侧的「配置表缓存」抽象(get/getAll/lookup/reload/dispose) |
| Aggregate(聚合) | @StructField(aggregateBy="父字段"),父记录的数组/集合字段值作为 key 批量取子表数据 |
| 需求 | 落点 |
|---|---|
| 支持新的数据格式(YAML、DB…) | 新建 XxxStructHandler implements StructHandler → @SPI → 写进 META-INF/struct/org.struct.core.handler.StructHandler → 同步 module-info.java 的 provides |
| 新增/修改类型转换规则 | struct-core/.../converter/;单类型 → ConverterRegistry.register();批量 → 实现 Converters SPI |
| 调整全局行为 | StructConfig(枚举单例,可运行时改)+ StructInternal(静态字段,由系统属性初始化) |
| 新增类级注解属性 | annotation/StructSheet.java → core/StructDescriptor.java → 各 Handler 消费 |
| 新增字段级注解属性 | annotation/StructField.java → core/SingleFieldDescriptor.java → factory/JdkStructFactory.java 消费 |
| 改 Spring 扫描/注册规则 | spring/support/ClassPathStructScanner.java(include/exclude filter + BeanDefinition 生成) |
| 改 Spring Boot 默认配置 | spring-boot-starter/.../StructProperties.java / StructServiceProperties.java / StarterConstant |
| 改热重载 | support/FileWatcherService.java + StructAutoConfiguration#fileWatcherService |
- 🔴 先读
docs/AI/KNOWN-BUGS.md:其中 3 个 P0 是静默的数据正确性错误。若你的改动触及converter/或handler/,先确认没有踩到或加重它们。 - 🔴 所有 Gradle 命令带
--no-daemon(§2.1)。AI 反复构建会让常驻 daemon 堆积直至内存溢出 —— 这是最容易犯且后果最严重的一条。 - 版权头:每个新 Java 文件顶部必须有 Apache-2.0 头(模板见
idea_copyright_header.xml,年份用Copyright (c) 2024. - TinyZ.)。 - 包名:新增类必须落在
module-info.java已exports的包中;若新增包,需同步exports。 - SPI 双注册:新增 SPI 实现时,
META-INF/struct/...文件与module-info.java的provides必须同时更新(classpath 模式走前者,module 模式走后者)。漏掉provides不只是"模块模式下加载不到"——会直接导致compileJava失败(module-info的provides要求实现类与接口在同一模块内可解析)。同理新依赖需在requires中声明。 - 序列化:
FieldDescriptor及其子类实现了Serializable,新增字段时保持@Serial private static final long serialVersionUID。改字段会影响equals/hashCode/toString—— 三者需同步更新(项目风格是全部手写 +Objects.hash)。 - 测试:新增/修改行为必须有 JUnit 6 测试;
struct-core现有 45 个测试类可参考。测试数据文件放struct-core/src/test/resources/,通过new StructWorker<>("classpath:/org/struct/core/", Xxx.class)加载。 - 不要把 Spring 依赖引入
struct-core;不要让struct-core依赖struct-spring。 - 不要改动
test { ignoreFailures = true },改为自己核查测试结果。 - 不要把
gradle.properties里的org.gradle.daemon改回true。 - 修改公开 API 时同步更新
README.md/README_en.md/FAQ.md。
- Handler 降级链:
StructWorker#handleDataFile会按顺序尝试所有匹配的 Handler,前一个抛异常就试下一个,全部失败才抛IllegalArgumentException。因此 Handler 内部"解析失败"最好抛出明确异常,不要静默返回空数据。 - 引用字段递归:
JdkStructFactory#createSingleFieldDescriptor里对每个字段都会调用worker.handleReferenceFieldValue(),该方法会递归创建子 Worker 并完整加载被引用表,结果缓存在tempRefFieldValueMap(key =ref类全名:字段名)。 循环引用由StructConfig.INSTANCE.isAllowCircularReferences()控制,默认允许(发现环时直接 return,字段值为 null)。 - 字段顺序敏感:
JdkStructFactory#newStructInstance对record走getDeclaredConstructor(argTypes),参数顺序 =beanFieldsList顺序 =FieldDescriptor.compareTo的排序结果(普通字段 < 引用字段 < 带自定义 converter 的字段;OptionalDescriptor排最后)。改动compareTo会破坏 record 反序列化。 - required 语义:
required只对非引用字段在JdkStructFactory#handleInstanceFieldValue中校验;引用字段的 required 在handleReferenceFieldValue中校验。默认值来自StructConfig.INSTANCE.isStructRequiredDefault()(默认false)。 - 空值被吞:
StructImpl#add会忽略null与空字符串,导致StructImpl.get()返回null;这对"字段值为空字符串"的场景是有意设计。 ArrayConverter默认分隔符是正则\\|,且 trim 默认开启、ignoreBlank 默认关闭。修改默认值通过StructInternal的静态字段或系统属性(见下)。waitForInit是忙等:AbstractStructStore#waitForStatus是 无 sleep 的自旋循环(for(;;))。不要在生产路径上依赖它,也不要"顺手改成阻塞"而不评估影响。- Options 是包级私有:
org.struct.spring.support.Options是class Options(非 public),外部不可直接引用。
可通过系统属性调整的行为(org.struct.core.StructInternal 静态块读取):
| 系统属性 | 默认值 | 作用 |
|---|---|---|
struct.core.internFieldName |
true |
字段名 String.intern(),省内存 |
struct.handler.xlsx.UMThreshold |
1572864(1.5MB) |
超过该大小禁用 Excel usermode Handler |
struct.array-converter.stringSeparator |
\| |
数组字符串分隔符(正则) |
struct.array-converter.stringTrim |
true |
数组元素是否 trim |
struct.array-converter.ignoreBlank |
false |
是否丢弃空元素 |
git status(branch gitbutler/workspace)中的未提交改动:
M build.gradle、M struct-core/build.gradleM struct-core/src/main/java/org/struct/core/matcher/FileExtensionMatcher.java—— 新增了.proto/.protobuf扩展名常量M struct-core/src/main/resources/META-INF/struct/org.struct.core.handler.StructHandler—— 注册了ProtobufStructHandler?? struct-core/src/main/java/org/struct/core/handler/ProtobufStructHandler.java—— 新增的 Protobuf Handler
本次新增的文件:
?? gradle.properties—— 关闭 Gradle Daemon(org.gradle.daemon=false)?? AGENTS.md、?? docs/、?? .github/copilot-instructions.md—— AI 协作文档M struct-core/src/main/java/module-info.java—— 补requires com.google.protobuf;与 Protobuf Handler 的provides
本次已修复的 3 个阻断编译的问题(修复后 struct-core / struct-spring / struct-spring-boot-starter 全部编译通过):
| # | 文件 | 问题 | 修复 |
|---|---|---|---|
| 1 | core/handler/ProtobufStructHandler.java:27 |
import org.slfj.LoggerFactory; 包名拼写错误 |
→ org.slf4j.LoggerFactory |
| 2 | module-info.java |
缺 requires com.google.protobuf;,且 provides ... StructHandler 未含 ProtobufStructHandler |
补 requires + provides |
| 3 | core/handler/ProtobufStructHandler.java:181 |
DynamicMessage.newParser(Descriptor) 在 protobuf-java 4.x 不存在 |
→ DynamicMessage.getDefaultInstance(descriptor).getParserForType() |
| 测试 | 现象 | 性质 |
|---|---|---|
BomInputStreamTest#test |
expected: <abc> but was: <鎱㈡實> |
测试数据缺陷:用 UTF-16BE/LE 的 BOM 前缀 + 单字节 ASCII 内容(0x61 0x62 0x63),按 UTF-16 解码得到汉字而非 abc。第 36–37 行的 UTF-32 同类用例已被注释掉。未修 —— 修它需要重写测试数据为对应编码的真实内容,属独立任务 |
原基线中的
StringToArrayConverterTest两条失败已在 P1-5 修复中消除(见 §9.4)。
StructConfig 是进程级单例,JUnit 不保证测试顺序。任何修改它的测试必须在
@AfterEach 中恢复原值,否则会污染后续测试 —— 表现为「单跑通过、全量跑失败」。
曾有 StructConfigTest 把 allowCircularReferences 设为 false 且从不恢复,
导致后续的循环引用测试在全量运行时失败。已修复。
编写规则:
- 修改
StructConfig/ConverterRegistry/StructInternal等全局状态的测试,一律@BeforeEach保存 +@AfterEach恢复。 - 不依赖全局状态的默认值做断言;需要特定值时显式设置(参见
StructWorkerTest#withCircularReferencesAllowed)。
FileExtensionMatcher已定义FILE_PROTO = ".proto",但ProtobufStructHandler的 Matcher 只用了.protobuf/.bin/.pbf,未使用.proto。需确认是否有意。ProtobufStructHandler目前只有单元级测试(用编程构造的 protobuf 描述符), 缺端到端测试(真实.protobuf二进制文件 + 生成的 Message 类)。 本模块没有 protoc 步骤,加端到端测试需要引入 protoc Gradle 插件或提交生成代码 —— 需作者决策。struct-examples/src/test目录不存在,示例模块无测试。- P2 级问题(见
KNOWN-BUGS.md)尚未处理。
测试规模 155 → 182(新增 27),失败数 3 → 1。
| Bug | 修复文件 | 新增测试 |
|---|---|---|
| P0-1 枚举空值 → 第一个常量 | EnumConverter |
EnumConverterTest +3 |
| P0-2 日期空值 NPE | LocalDateConverter、LocalDateTimeConverter |
各 +1 |
| P0-3 公式单元格值丢失 | ExcelUMStructHandler |
ExcelUMStructHandlerTest +3 |
| P1-1 JSON 绕过 Converter | JsonStructHandler |
JsonStructHandlerTest +3、新资源 tpl_types.json |
| P1-2 负缓存污染 / 非线程安全 | ProtobufStructHandler |
ProtobufStructHandlerTest +7(新文件) |
| P1-3 嵌套消息递归 / repeated | 同上 | 同上 |
| P1-4 未按 messageName 查找 | 同上 | 同上 |
P1-5 ArrayConverter NPE |
ArrayConverter |
ArrayConverterTest +8(新文件) |
行为变更请注意(均为修复所需,非随意改动):
- 枚举/日期的空值现在为
null(原:枚举变第一个常量、日期抛 NPE)。 - 数组未改:空值仍返回空数组;仅「非数组 targetType」由 NPE 变为返回
null。 JsonStructHandler现在走完整的 Converter 管线,LocalDate、自定义 converter 等从失效变为生效。 若你的 JSON 此前依赖 Gson 原生反序列化行为,需复核。
| 文件 | 内容 |
| docs/AI/KNOWN-BUGS.md | 🔴 Bug 清单(含实机证据、严重度分级;P0/P1 已修复并标注改动,P2 待办)。改动相关代码前必读 |
| AGENTS.md | 本文件:项目总览、命令、扩展点、坑位 |
| docs/AI/ARCHITECTURE.md | 深入架构:加载时序、SPI 机制、类型转换、引用/聚合、Spring 集成、线程安全 |
| docs/AI/CONVENTIONS.md | 编码规范、版权头、测试规范、新增扩展的 Step-by-Step 模板 |
| .github/copilot-instructions.md | Copilot 入口(指向本文件) |
| README.md / README_en.md | 用户文档(中文 / 英文) |
| FAQ.md | 常见问题 |