面向需要修改/扩展本框架的 AI。阅读前请先读根目录
AGENTS.md。 所有路径相对于仓库根目录。
| 目标 | 实现手段 | 代价 / 限制 |
|---|---|---|
| 数据源无关 | StructHandler SPI + WorkerMatcher 匹配与降级 |
每种格式需单独实现 Handler |
| 内存友好 | .xlsx 默认走 SAX 事件模型(XlsxSaxStructHandler);大文件自动禁用 usermode |
SAX 模式不支持公式求值 |
| 表达力强 | @StructField(ref/ refUniqueKey / refGroupBy / aggregateBy),支持嵌套引用、分组、聚合 |
引用关系在解析期递归展开,配置错误会抛运行期异常 |
| 零 Spring 依赖 | 核心引擎 struct-core 只依赖 slf4j + POI + Gson + protobuf |
Spring 侧另建模块 |
| 可扩展 | 自研 SPI(EnhancedServiceLoader)支持 name + order |
需同时维护 META-INF/struct/* 与 module-info.java |
| 省内存 | StructInternal.INTERN_FIELD_NAME(字段名 intern)、@StructField(cached)(值 intern) |
滥用 intern 会撑大字符串常量池 |
org.struct.annotation 用户 API(RUNTIME 保留,@Inherited,支持 FIELD / RECORD_COMPONENT)
├─ @StructSheet 类级:fileName / sheetName / startOrder / endOrder / matcher / filter
├─ @StructField 字段级:name / ref / refGroupBy / refUniqueKey / aggregateBy /
│ aggregateType / required / cached / converter
└─ @StructOptional 字段级:StructField[] value(),按顺序尝试,取第一个解析成功的
org.struct.core 引擎主体
├─ StructWorker 一次加载任务的驱动者(入口 API)
├─ StructDescriptor 类级元数据(由 @StructSheet 生成)
├─ FieldDescriptor 字段元数据抽象(abstract,Serializable + Comparable)
│ ├─ SingleFieldDescriptor 单个字段
│ └─ OptionalDescriptor 多候选字段
├─ StructImpl 一行数据的临时中间表示
├─ StructConfig 全局配置(enum 单例,可运行时改)
├─ StructInternal 内部静态开关(由系统属性初始化)
├─ ArrayKey 多字段组合 key(refUniqueKey/refGroupBy 多列时)
├─ TypeRefFactory<T> 函数式接口,用于保留泛型(如 HashMap::new)
│
├─ core/handler/ 数据源解析层(SPI)
├─ core/converter/ 类型转换层(SPI)
├─ core/factory/ Bean 结构解析层(SPI)
├─ core/matcher/ 文件匹配 + 优先级
└─ core/filter/ Bean 级过滤
org.struct.spi 自研 SPI:EnhancedServiceLoader / ServiceLoader / SPI / ExtensionDefinition
org.struct.util Reflects WorkerUtil ConverterUtil AnnotationUtils Strings BomInputStream ExcelUtil
org.struct.support FileWatcherService(WatchService 封装,热重载基础)
org.struct.exception 6 个异常(见 §11)
见 §9。
StructWorker<MyBean> worker = new StructWorker<>("classpath:/org/struct/core/", MyBean.class);
List<MyBean> list = worker.toList(ArrayList::new);StructWorker 构造时立即创建 StructDescriptor(clzOfStruct):从类上查找 @StructSheet,缺失即抛异常。
public StructDescriptor(Class<?> clzOfStruct) {
this(Objects.requireNonNull(AnnotationUtils.findAnnotation(StructSheet.class, clzOfStruct),
"clazz:" + clzOfStruct.getName() + " must be annotated by @StructSheet"));
}StructWorker#checkStructFactory
└─ WorkerUtil#structFactory(clzOfStruct, worker) // 遍历 SPI StructFactoryBean,第一个成功者胜出
└─ DefaultStructFactoryBean#newInstance → new JdkStructFactory(clz, worker)
└─ JdkStructFactory#parseStruct()
├─ record:遍历 getRecordComponents(),顺序 = 声明顺序
└─ POJO :Reflects#resolveAllFields(clz, true)(含父类),跳过 static,setAccessible(true)
├─ 每个字段 → createFieldDescriptor()
│ ├─ 有 @StructOptional → OptionalDescriptor(内部对每个 StructField 建 SingleFieldDescriptor)
│ └─ 否则 → SingleFieldDescriptor(fieldOrRc, @StructField)
│ └─ worker.handleReferenceFieldValue(this, descriptor) // ★ 递归预加载引用表
└─ beanFieldsList = map.values().stream().sorted().toList() // ★ FieldDescriptor#compareTo
要点:
parseStruct()会递归加载所有被引用表,并把结果放进StructWorker#tempRefFieldValueMap(key = ref类全名 + ":" + 字段名)。beanFieldsList的顺序对record是构造参数顺序,由FieldDescriptor#compareTo决定。
@Override
public int compareTo(FieldDescriptor o) {
// 优先SingleFieldDescriptor > OptionalFieldDescriptor
if (this instanceof OptionalDescriptor
&& o instanceof OptionalDescriptor) {
return this.getName().compareTo(o.getName());
} else if (this instanceof OptionalDescriptor) {
return 1;
} else if (o instanceof OptionalDescriptor) {
return -1;
} else if (this instanceof SingleFieldDescriptor
&& o instanceof SingleFieldDescriptor) {
// 优先级 field > ref field > custom converter field
... public void handleDataFile(Consumer<T> cellHandler) {
String filePath = WorkerUtil.resolveFilePath(this.workspace, this.descriptor.getFileName());
File file = new File(filePath);
if (!file.exists()) {
throw new IllegalArgumentException("file not exists. path: " + filePath);
}
List<StructHandler> collected = WorkerUtil.lookupStructHandler(this.descriptor, file);
for (StructHandler handler : collected) {
try {
handler.handle(this, this.clzOfStruct, wrapCellHandler(this.descriptor, cellHandler), file);
return;
} catch (Exception e) {- 路径解析:
classpath:/file:/ 裸路径三种前缀;classpath:找不到时原样返回拼接路径(随后new File()不存在即报错)。 - Handler 降级链:匹配到的 Handler 按
matcher().order()升序尝试,前一个抛异常就试下一个,全失败抛IllegalArgumentException("unknown data file extension...")。 wrapCellHandler:若@StructSheet(filter=...)指定了具体类,用getConstructor(Consumer.class)反射包一层StructBeanFilter。
StructHandler 负责:读一行 → new StructImpl() → struct.add(列名, 单元格值)
↓
worker.createInstance(StructImpl)
↓
JdkStructFactory#newStructInstance(structImpl)
├─ record:forEachBeanFields 收集 args[]/argTypes[] → getDeclaredConstructor(argTypes).newInstance(args)
└─ POJO :Reflects#newInstance(clz) → 逐字段 sfd.setFieldValue(instance, v)
↓(每个字段)
JdkStructFactory#handleInstanceFieldValue(structImpl, sfd)
├─ value = sfd.getFieldValueFrom(structImpl) // StructImpl → get(name);POJO → Field.get / RecordComponent accessor
├─ required 校验(仅非引用字段;null 或空串视为非法)
├─ 分流:
│ ├─ sfd.getConverter() != null → converter.convert(ctx, value, fieldType)
│ ├─ sfd.isReferenceField()
│ │ ├─ isBasicTypeCollection() → ConverterRegistry#convertCollection(...)
│ │ └─ 否则 → handleReferenceFieldValue(structImpl, sfd)
│ └─ 否则 → ConverterRegistry#convert(ctx, value, fieldType)
└─ sfd.isCached() && value is String → str.intern()
OptionalDescriptor 的处理在 forEachBeanFields:
if (fd instanceof OptionalDescriptor ofd) {
for (SingleFieldDescriptor sfd : ofd.getDescriptors()) {
Object value = this.handleInstanceFieldValue(structImpl, sfd);
if (value != null) {
consumer.accept(i, sfd, value);
break;
}
}| Handler | SPI name | order | Matcher | 说明 |
|---|---|---|---|---|
ExcelUMStructHandler |
excel-user |
0 | FileExtensionMatcher(1.5MB 阈值, HIGHEST, .xlsx, .xls) |
POI usermode;支持公式求值;文件 ≥1.5MB 时不匹配 |
XlsxSaxStructHandler |
xlsx |
—(默认0) | FileExtensionMatcher(.xlsx) |
SAX 事件模型;不支持公式;用 EndOfExcelSheetException 提前终止 |
XlsEventStructHandler |
— | — | .xls |
.xls 的事件模型实现 |
CsvStructHandler |
csv |
0 | .csv |
继承 RowWithSeparatorStructHandler,分隔符 , |
JsonStructHandler |
json |
—(默认0) | .json |
Gson streaming + 自定义 StructImpl 反序列化器 |
ProtobufStructHandler |
protobuf |
—(默认0) | .protobuf / .bin / .pbf |
新增。以 StructDescriptor#getSheetName() 作为 message 名;按 length-delimited 流逐条读取;解析器优先取 clz.parser(),否则反射 getDescriptor()/descriptor() 构造 DynamicMessage 解析器 |
XmlStructHandler(spring 模块) |
— | — | .xml |
通过 module-info 的 provides 注册 |
Excel 列 → 字段的映射:首行(表头)作为列名。
- usermode:
resolveExcelColumnToField(headRow),表头行 =max(0, startOrder-1)。 - SAX:
isFirstRow标记的那一行的单元格值写入headRowMap。
startOrder 语义(@StructSheet 默认 1):
- Excel:0-based 行号,默认 1 → 从第 2 行开始(第 1 行是表头)。
- JSON / CSV:作为行/记录序号下限。
public static List<StructHandler> lookupStructHandler(StructDescriptor descriptor, File file) {
List<StructHandler> handlers = HANDLERS_HOLDER.get();
Stream<StructHandler> stream = handlers.stream();
if (WorkerMatcher.class != descriptor.getMatcher()) {
stream = stream.filter(handler -> handler.matcher().getClass().isAssignableFrom(descriptor.getMatcher()));
} else {
stream = stream.filter(handler -> handler.matcher().matchFile(file));
}
return stream.sorted(Comparator.comparingInt(o -> o.matcher().order())).collect(Collectors.toList());
}- 用户在
@StructSheet(matcher = XxxMatcher.class)指定时,按类型可赋值过滤(注意方向:handler.matcher().getClass().isAssignableFrom(descriptor.getMatcher()))。 - 未指定(默认
WorkerMatcher.class)时,走matchFile(file)。 WorkerMatcher.HIGHEST = Integer.MIN_VALUE、LOWEST = Integer.MAX_VALUE。
ConverterRegistry 是静态注册表(ConcurrentHashMap<Class<?>, Converter>),静态块里通过 SPI 加载所有 Converters 实现并合并。
public static Object convert(ConvertContext ctx, Object originValue, Class<?> requiredType) {
if (Object.class == requiredType
|| requiredType.isInstance(originValue)) {
return originValue;
}
// try lookup user's converter first.
Converter converter = lookup(requiredType);
if (null == converter) {
if (requiredType.isEnum()) {
converter = lookup(Enum.class);
} else if (requiredType.isArray()) {
converter = lookup(Array.class);
}
}
if (null != converter) {
return converter.convert(ctx, originValue, requiredType);
}
return originValue;
}查找顺序:精确类型 → 若是 enum 回落到 Enum.class → 若是数组回落到 Array.class → 都找不到就原样返回(不报错!这是常见的"值没转换"问题来源)。
| Key | Converter | 行为要点 |
|---|---|---|
int/Integer, long/Long, short/Short, byte/Byte |
Integer/Long/Short/Byte Converter |
支持十六进制字符串(0x..,ConverterUtil#isHexNumber);溢出抛异常;null → 0 |
float/Float, double/Double |
Float/Double Converter |
null → 0.0 |
boolean/Boolean |
BooleanConverter |
ConverterUtil#isBooleanTrue;数字按 ==1 |
BigInteger / BigDecimal |
BigInteger/BigDecimal Converter |
null → ZERO |
Enum.class(统一入口) |
EnumConverter |
先试 ordinal(int),再试 name(含大小写/大写回退、忽略大小写比较),最后 enum→enum 按 ordinal |
String |
StringConverter |
toString() |
Array.class(统一入口) |
ArrayConverter |
按正则分隔符切分(默认 \|),逐元素递归 ConverterRegistry#convert |
Date |
DateConverter |
|
LocalDate |
LocalDateConverter |
先按 Strings.DATE_FORMAT_PATTERN 解析,失败再按时间戳(< Integer.MAX_VALUE 视为秒,否则毫秒) |
LocalDateTime |
LocalDateTimeConverter |
同上,模式 Strings.DATE_TIME_FORMAT_PATTERN |
ConverterRegistry#convertCollection(ctx, originValue, collectType, requiredType):
- 要求
collectType是Collection且requiredType是基础类型(否则原样返回)。 - 根据接口类型选实现:
SortedSet→TreeSet、Set→HashSet、其他→ArrayList;具体类则反射无参构造。 - 内部先转成数组(
Array.class的 converter),再逐元素 add。
典型用法:@StructField(ref = Integer.class) private List<Integer> list; → SingleFieldDescriptor#isBasicTypeCollection() 为 true。
- 字段级:
@StructField(converter = MyConverter.class)。SingleFieldDescriptor构造时通过ConverterRegistry.lookupOrDefault(c, c)拿到实例(会顺带注册进全局表)。 - 全局级:
ConverterRegistry.register(targetType, converterOrClass)。 - SPI 批量:实现
Converters,注册到META-INF/struct/org.struct.core.converter.Converters(+module-info的provides)。 注意EmbeddedConverters用putIfAbsent,不会覆盖同类型的已注册项;而ConverterRegistry#register是put,会覆盖。
ConvertContext 默认实现 DefaultConvertContext(structImpl, fieldDescriptor),把当前行数据和字段元数据传给转换器。
public void handleReferenceFieldValue(StructFactory structFactory, SingleFieldDescriptor descriptor) throws RuntimeException {
if (descriptor == null || !descriptor.isReferenceField() || descriptor.isBasicTypeCollection()) {
return;
}
String clzFieldUrl = descriptor.getRefFieldUrl();
if (tempRefFieldValueMap.containsKey(clzFieldUrl)) {
LOGGER.debug("Struct circular references, clzFieldUrl:{}, prev:{}", clzFieldUrl, descriptor.getName());
if (!StructConfig.INSTANCE.isAllowCircularReferences())
throw new RuntimeException("loop dependent with key:" + clzFieldUrl + ", prev:" + descriptor.getName());
return;
}
...目标类型 → 子 Worker 产出形态:
| 字段类型 | 子 Worker 调用 | 缓存 value 形态 |
|---|---|---|
| 数组 | toListWithGroup(ArrayList::new, refGroupBy) |
Map<groupKey, T[]> |
Collection |
toListWithGroup(targetType, refGroupBy) |
Map<groupKey, Collection<T>> |
Map |
toMapWithGroup(targetType, refUniqueKey, refGroupBy) |
Map<groupKey, Map<uniqueKey, T>> |
| 其它(单值) | toMap(HashMap::new, refUniqueKey) |
Map<uniqueKey, T> |
全部以 Collections.unmodifiableMap 存进 tempRefFieldValueMap。
JdkStructFactory#handleReferenceFieldValue(structImpl, fd):
refKeys = fd.getRefGroupBy().length > 0
? fd.getRefGroupBy()
: fd.getRefUniqueKey();
keys = this.getFieldValuesArray(structImpl, refKeys);
val = map.get(keys);refGroupBy优先级高于refUniqueKey。- 多列 key →
ArrayKey;单列 → 直接该值。 - 结果若是数组,会
Arrays.copyOf(..., fieldType)修正数组类型(避免Object[]赋给String[]的ArrayStoreException)。
与普通 ref 的区别:key 来自父记录的数组/集合字段,一次性取出多条。
if (fd.isAggregateField()) {
refKeys = new String[]{fd.getAggregateBy()};
keys = this.getFieldValuesArray(structImpl, refKeys);
// key value's type
Class<?> targetFieldType = fd.getFieldType();
if (keys.getClass().isArray()) {
int length = Array.getLength(keys);
List<Object> list = new ArrayList<>(length);
for (int i = 0; i < length; i++) {
list.add(map.get(Array.get(keys, i)));
}
val = targetFieldType.isArray() ? list.toArray() : list;
} else if (keys instanceof Collection ck) {
...
} else if (Map.class.isAssignableFrom(keys.getClass())) {
throw new UnSupportConvertOperationException("Un support Map.class key yet.");限制:不支持 Map 类型的聚合 key。
默认 StructConfig.INSTANCE.allowCircularReferences = true → 检测到环就跳过(字段值为 null)。
若设为 false → 抛 RuntimeException("loop dependent with key:...")。
注意:环检测依赖 tempRefFieldValueMap 的写入发生在递归返回之后,因此自引用/互相引用的表必须显式配置,否则可能得到 null 而非报错。
public void add(String fieldName, Object val) {
this.add(fieldName, val, false);
}
...
public void add(String fieldName, Object val, boolean force) {
if (fieldName == null || fieldName.isEmpty()) {
return;
}
// ignore NULL or empty value;
boolean ignore = (val == null)
|| (val instanceof String && ((String) val).isEmpty());
if (ignore) {
return;
}关键点:
- 字段名映射:
HashMap<String, Object>,key 是表头文本 / JSON 字段名,与@StructField(name=...)或字段名匹配。 add会静默丢弃 null 与空串 —— 这让"该列没填"与"该列不存在"在数据层面等价,get()都返回null。- 字段名是否
intern()由StructInternal.INTERN_FIELD_NAME控制。 SingleFieldDescriptor#getFieldValueFrom(instance)对StructImpl走si.get(this);对普通对象走反射。
@Override
public final void accept(T t) {
if (test(t)) {
cellHandler.accept(t);
}
}- 抽象类,
accept是final,子类只需实现Predicate#test。 - 必须提供
public XxxFilter(Consumer<T> cellHandler)构造函数,StructWorker#wrapCellHandler通过getConstructor(Consumer.class)反射实例化。 - 通过
@StructSheet(filter = XxxFilter.class)配置。 - 注意:过滤发生在转换成 Bean 之后、写入结果集合之前。
@SpringBootApplication
└─(starter)StructAutoConfiguration
├─ @Bean StructConfig —— 把 StructProperties 写入 StructConfig.INSTANCE + ArrayConverter
├─ @Bean StructStoreConfig —— 全局默认:workspace / lazyLoad / watchFile / 调度参数 / banner
├─ @Bean StructStoreService —— BeanPostProcessor,收集所有 StructStore、触发初始化
├─ @Bean FileWatcherService —— 监听 workspace 下文件变更 → store::reload
└─ @Import AutoConfiguredMapperScannerRegistrar
└─ ClassPathStructScanner#doScan(AutoConfigurationPackages)
或用户显式使用 @StructScan("com.xxx") → StructScannerRegistrar(ImportBeanDefinitionRegistrar)。
两者互斥:StructAutoConfiguration.StructMapperServiceNotFoundConfiguration 上有 @ConditionalOnMissingBean({StructScannerRegistrar.class})。
继承 Spring 的 ClassPathBeanDefinitionScanner,useDefaultFilters=false。
registerFilters():
- exclude:非具体类;
MapStructStore;ListStructStore - include 1:同时标注
@AutoStruct与@StructSheet - include 2:具体类且(父类是
AbstractStructStore或实现了StructStore)
registerBeanDefinition() 分两类处理:
A. 本身就是 StructStore 实现(用户自定义 Store)→ 直接注册,注入:
StructConstant.CLZ_OF_BEAN←GenericTypeResolver.resolveTypeArguments(clz, StructStore.class)[1]@AutoStruct的mapKey→MapKeyFieldResolver;keyResolverBeanName;keyResolverBeanClass@StructStoreOptions→Options.generate(...)
B. 是 @AutoStruct + @StructSheet 的 Struct Bean → 自动生成一个 Store BeanDefinition:
- beanName = 原 beanName +
"StructStore" - Store 类型选择优先级:
@AutoStruct#clzOfStore()→ 有mapKey/keyResolverBeanName/keyResolverBeanClass则用MapStructStore→ 兜底ListStructStore - 构造参数:
cavs.addIndexedArgumentValue(0, gbd.getBeanClass())(要求 Store 有单参Class<B>构造器) setAutowireMode(AUTOWIRE_BY_NAME)、SCOPE_SINGLETON、ROLE_APPLICATION
⚠️ 注意 B 分支有一个细节:generateStructStoreBeanDefinition里对clzOfStore的两次判断都使用if(不是else if),因此keyResolverBeanClass的判断会覆盖mapKey的结果(两者都最终指向MapStructStore,行为一致)。
AbstractStructStore 用 AtomicIntegerFieldUpdater 维护 status:
NORMAL(0) --casStatusInit()--> INITIALIZING(1) --casStatusDone()--> DONE(2)
afterPropertiesSet():options == null时从容器取StructStoreConfig生成;!lazyLoad则立即initialize()。initialize():casStatusInit()失败说明已有线程在初始化;此时若options.isWaitForInit()则waitForDone()。reload():仅当已DONE时,casStatusReset()后重新initialize()。MapStructStore#loadStructData:WorkerUtil.newWorker(workspace, clzOfBean).toMap(HashMap::new, keyResolver::resolve),结果unmodifiableMap;dispose()时置回EMPTY_MAP。MapStructStore的 key 解析器解析优先级:keyResolver字段 →keyResolverBeanName(容器 byName)→keyResolverBeanClass(容器 byType,其次Reflects#newInstance)→ 都没有则抛NoSuchKeyResolverException。
⚠️ waitForStatus是无 sleep 的忙等自旋(for(;;))。高竞争下会烧 CPU;如需优化,这是明确的改进点。
StructAutoConfiguration#fileWatcherService:
- 确保 workspace 目录存在且是目录;
FileWatcherService.newBuilder()设置调度参数,registerAll(workspace.toPath()),bootstrap();- 对每个
StructStore,用Reflects#resolveStructRelatedFileName(store.clzOfBean())解析出该 Bean 依赖的所有数据文件名(含引用表),逐个registerHook(resolveFilePath(workspace, fileName), store::reload)。
这意味着改一张被引用的子表,所有引用它的父表 Store 也会 reload。
| 前缀 | 类 | 关键属性 |
|---|---|---|
struct.core.* |
StructProperties |
structRequiredDefault ignoreEmptyRow allowCircularReferences arrayConverter.{stringSeparator,stringTrim,ignoreBlank} |
struct.service.* |
StructServiceProperties |
workspace lazyLoad watchFile scheduleInitialDelay scheduleDelay scheduleTimeUnit banner |
| 开关 | StarterConstant |
struct.store.enable(默认 true)、struct.service.enable、struct.watcher.enable |
StructAutoConfiguration 本身由 @ConditionalOnProperty(prefix="struct.store", name="enable", havingValue="true", matchIfMissing=true) 控制。
| 组件 | 线程安全性 | 说明 |
|---|---|---|
StructWorker |
非线程安全 | 持有可变的 tempRefFieldValueMap、structFactory。一次加载一个 Worker |
WorkerUtil.HANDLERS_HOLDER / FACTORY_BEAN_HOLDER |
线程安全 | DCL + volatile 的 Holder,SPI 只加载一次 |
ConverterRegistry |
线程安全 | ConcurrentHashMap;但运行时 register 会立即对所有线程生效 |
EnhancedServiceLoader |
线程安全 | ConcurrentHashMap + DCL;createExtensionInstance 在 synchronized(this) 内实例化 |
StructConfig |
可变全局单例 | enum 单例的字段非 volatile 非 final,并发修改不保证可见性;应在启动期一次性设置 |
AbstractStructStore#cached |
volatile |
读多写少,reload 时整体替换引用 |
MapStructStore#size |
volatile int |
|
StructImpl |
非线程安全 | 单行临时对象 |
性能相关:
- 反射字段访问在
parseStruct()时做一次setAccessible(true),之后复用Field对象;MapKeyFieldResolver用MethodHandle。 StructInternal.INTERN_FIELD_NAME=true+@StructField(cached=true)是官方给出的降内存手段。- 大
.xlsx(≥1.5MB)自动排除 usermode Handler(阈值可经struct.handler.xlsx.UMThreshold调整)。
| 异常 | 抛出场景 |
|---|---|
StructTransformException |
Handler 解析失败、设置字段值失败(包装原始异常) |
NoSuchFieldReferenceException |
refUniqueKey / refGroupBy 在目标 Bean 上找不到字段;required 引用字段解析为 null |
UnSupportConvertOperationException |
转换器无法处理(如不支持的类型、Map 作为聚合 key) |
IllegalAccessPropertyException |
反射 get/set 字段失败;record 上调用 setter |
EndOfExcelSheetException |
SAX 模式提前终止读取(rowNum >= lastRow),被 XlsxSaxStructHandler 捕获并静默结束 |
ServiceNotFoundException |
SPI 找不到实现或实例化失败 |
注意:handleDataFile 会捕获 Handler 抛出的所有 Exception 继续尝试下一个 Handler,因此这些异常不一定会冒泡到调用方;只有全部 Handler 失败才抛 IllegalArgumentException。调试时建议把日志级别开到 INFO(LOGGER.info("{} handle data file failure..."))。
🔴 Bug 完整清单见
KNOWN-BUGS.md。 P0-0 ~ P0-3、P1-1 ~ P1-5 均已修复,清单中每条都标注了实际改动与回归测试。剩余 P2 为待办。其中 P0-0(循环引用检测失效)是在提升单元测试覆盖率时发现的: 那 4 行长期没有测试覆盖,实机验证后发现自引用/互相引用都会
StackOverflowError。 未覆盖的代码往往是 bug 藏身处 —— 提升覆盖率的价值不止于数字。
修复统一了「空值」的处理,三处 Converter 行为均有变更:
| 目标类型 | 修复前 | 修复后 |
|---|---|---|
Enum |
null → 第一个枚举常量;"" → 抛异常 |
null / 空白串 → null |
LocalDate / LocalDateTime |
null → NPE |
null → null(与 DateConverter 一致) |
数组(ArrayConverter) |
非数组 targetType → NPE | 非数组 targetType → null |
数组未被改动的部分:originValue == null 仍返回空数组(框架约定:null 输入 → 目标类型的零值,
同数值的 0/0.0、BigInteger.ZERO)。字符串切分路径的 ignoreBlank(先) → trim(后) 顺序也保持原样。
StructImpl#add 本就丢弃 null 与空串,因此上述语义与框架的既有约定一致。
分隔符常量 ARRAY_CONVERTER_STRING_SEPARATOR(默认 "\\|")是正则,仅适用于 split,不能用于 join。
因此 JSON 数组、protobuf repeated 字段这类「结构化」值改为以 List 形式传入,由 ArrayConverter 逐元素转换。
ConverterRegistry#convertCollection 内部同样走 ArrayConverter,故 List 字段一并受益。
ArrayConverter对非数组目标类型会 NPE:convert()中Array.newInstance(targetType.getComponentType(), 0),当targetType是List.class等getComponentType()==null的类型时抛 NPE。现有两个测试期望它返回null。需二选一修复(改实现 or 改测试)。waitForStatus忙等:AbstractStructStore#waitForStatus无退避,建议改为CountDownLatch/CompletableFuture。StructConfig非线程安全发布:字段缺少volatile。ConverterRegistry#convert找不到转换器时静默返回原值:可考虑加一个严格模式开关,避免"类型没转"被静默吞掉。resolveFilePath对classpath:找不到资源时返回拼接路径(可能含classpath:前缀),最终由new File(...).exists()报错,错误信息不够直观。ExcelUMStructHandler#getExcelCellValue对NUMERIC做了 int/long/double 收窄;日期格式的单元格在 SAX 与 usermode 两条路径上的值形态可能不一致。struct-examples无测试:src/test目录不存在。