把 TRANSFORM 做成可扩展能力:JQuick-Excel 的函数与 SPI 调用链
tags: #JQuickExcel #JavaExcel #开源 #POI #Excel工具
简介
报表字段转换是 Excel 集成里最容易膨胀的部分:代码值转文案、日期标准化、姓名拼接、脱敏、默认值处理。把这些逻辑散落在 Controller 或导出循环中,规则难以复用,也让 XML 只剩下表头配置。JQuick-Excel 的 TRANSFORM 允许我把"字段如何变换"写在 DSL 中,并通过 Java SPI 把业务函数接入同一条调用链。
本文只依据 README-CN 已确认的信息介绍六个关键类型:JQuickMethodFunctionProvider、JQuickMethodInvocationManager、JFunctionExecutor,以及 TRANSFORM、JContext、内置求值器的关系。不假设未公开的加载实现细节。
前言
我不会为了一个 if 就创建 SPI,但当同一个转换需要在多个 XML 报表中复用,或转换来自独立业务模块时,SPI 是合理边界。XML 负责声明调用,提供者负责实现业务语义,应用启动或模块装配时让 Java 服务发现机制完成注册。
README-CN 已明确:内置求值器包括 toUpper、dateFormat、trans;外部依赖 jquick-transform-function 可通过 SPI 增加 formatDate、translate 等函数。它也给出了 JQuickMethodFunctionProvider 的核心契约:getMethodName() 返回函数名,invoke(List<Object>) 接收求值后的参数并返回结果;提供者通过 META-INF/services/com.github.paohaijiao.function.core.JQuickMethodFunctionProvider 被发现。JQuickMethodInvocationManager 负责查找和调用已注册函数,JFunctionExecutor 则是 apply(List<Object> args) throws Exception 的执行适配契约。
环境与依赖
当前项目 pom.xml 已包含:
xml
<dependency>
<groupId>io.github.paohaijiao</groupId>
<artifactId>jquick-transform-function</artifactId>
<version>1.4.0</version>
</dependency>
自定义函数所在模块必须能访问 com.github.paohaijiao.function.core.JQuickMethodFunctionProvider,并把服务描述文件打入最终 jar。仅写实现类而缺少 META-INF/services 文件,Java SPI 不会自动发现它。
本文的 XML 同时需要通过 JQuickXmlFactory 调用;因此可沿用项目中 JQuickExcelExportXmlParseFactory、JQuickExcelImportXmlParseFactory 和 JContext 的使用方式。示例中的 fullName 是一个无状态纯函数,适合放在 SPI 提供者中。
代码示例
先实现提供者。参数数量和类型由函数自己定义,但我会在实现中明确校验业务前提,避免把不完整数据悄悄拼成错误报表。
java
package demo;
import com.github.paohaijiao.function.core.JQuickMethodFunctionProvider;
import java.util.List;
public final class FullNameFunction implements JQuickMethodFunctionProvider {
@Override
public String getMethodName() {
return "fullName";
}
@Override
public Object invoke(List<Object> args) {
if (args == null || args.size() != 2) {
throw new IllegalArgumentException("fullName requires firstName and lastName");
}
return String.valueOf(args.get(0)) + " " + String.valueOf(args.get(1));
}
}
然后创建服务描述文件 src/main/resources/META-INF/services/com.github.paohaijiao.function.core.JQuickMethodFunctionProvider,内容只有实现类全限定名:
text
demo.FullNameFunction
在 XML 中调用它。${firstName} 与 ${lastName} 读取当前行字段,fullName(...) 的参数先被求值,再交给提供者。这里同时演示内置的 toUpper,方便比较两类函数的使用方式。
xml
<excels namespace="demo.StudentExcelService">
<excel name="exportExcel" returnClass="void"><![CDATA[
EXPORT WITH SHEET="人员", HEADER=true,
MAPPING={"firstName":"名","lastName":"姓","displayName":"显示名称"},
TRANSFORM={
"displayName":fullName(${firstName},${lastName}),
"lastName":toUpper(${lastName})
}
]]></excel>
</excels>
Java 侧不需要为该函数编写特殊调用代码,只要把提供者随应用放到运行时类路径,按 XML 代理的常规方式导出即可。
java
Map<String, Object> row = new LinkedHashMap<>();
row.put("firstName", "Ada");
row.put("lastName", "Lovelace");
List<Map<String, Object>> source = Collections.singletonList(row);
List<JQuickRow> rows = JQuickRow.toRows(JObjectConverter.convert(source));
try (OutputStream output = new FileOutputStream("people.xlsx")) {
JQuickParseHandler parser = new JQuickExcelExportXmlParseFactory(rows, output);
JQuickFactory factory = new JQuickXmlFactory(parser, "people.xml");
StudentExcelService service = factory.createApi(StudentExcelService.class);
service.exportExcel("field", "value");
}
需要字典时,将其放入 JContext。例如导出"1"为"男"使用 dict.put("1", "男"),XML 写 trans(${dict},${gender})。导入时通常反转为 "男" -> "1",这是数据方向变化,不是 SPI 的差异。
原理说明
一条 TRANSFORM 的逻辑可概括为:当前行字段或 JContext 值,经 ${...} 取值后组成表达式参数;解析得到函数名和参数列表;随后优先查询 JQuickMethodInvocationManager 中的提供者;找到时调用 JQuickMethodFunctionProvider.invoke(List<Object>),未找到时交给内置求值器处理;结果再写回转换字段。仓库的 JExcelCommonHandler 可验证这一职责分工:它解析转换表达式,取得小写函数名与参数列表,再通过 manager.getInvoker(functionName) 查找提供者;否则走内置 JEvaluator.evaluateFunction。
这也解释了为什么提供者不应解析 ${field} 或自行读取工作簿。它收到的已经是参数对象,职责应聚焦为"输入参数到返回值"。JContext 适合传字典、组织信息或规则所需的公共值,但不要把数据库连接、HTTP 客户端等隐式副作用塞进提供者;否则同一份 XML 的结果将难以测试和复现。
README-CN 还确认管理器具备 hasMethod、getSupportedMethods、getInvoker、registerInvoker、registerOrReplaceInvoker、unregisterInvoker 等能力。对常规模块化函数,我优先使用 SPI;对于测试替身或应用启动时的临时装配,可通过注册 API 显式管理。若名称重复,只有明确需要覆盖既有函数时才使用 registerOrReplaceInvoker,避免不同模块静默抢占函数名。
JFunctionExecutor 的位置更偏适配层:它以 apply(args) 表达函数执行契约。我的实践是把它当作"执行入口的统一形状",而不是再发明第二套 XML 函数协议;报表层对外保持 JQuickMethodFunctionProvider,适配和组合在 Java 侧完成。
注意事项
第一,函数名是公共 DSL API。改动 getMethodName() 会直接影响 XML,不应把 Java 类名变化误当作无影响重构。第二,调用链会将函数名转小写后查找,因此命名应采用稳定、可读的小写形式,例如 fullName 在使用前需实际验证当前版本的大小写匹配策略;为避免歧义,团队可统一使用全小写名字如 fullname。
第三,SPI 文件路径和内容必须精确:文件名是接口全限定名,文件内容是提供者全限定名,每行一个提供者。打包后要检查资源确实进入 jar。第四,提供者要避免共享可变状态;导出可能逐行调用转换函数,状态泄漏会造成跨行污染。
第五,TRANSFORM 与 FORMAT 不是同一层。前者改变值,后者控制 Excel 的显示格式。日期应先用转换得到目标值,再用 FORMAT={"date":"yyyy-MM-dd"} 表示单元格展示。第六,当前源码的 applyTransform 中存在一个断言分支与条件方向不一致的可疑点;本文不据此推导运行时行为。接入前应以当前发布版本和真实用例验证 SPI 函数是否被成功调用。
工程化落地与验证
我在接入转换函数时,会先把函数视为模板对外承诺的一部分,而不是临时工具。XML 中出现的函数名、入参顺序和返回值语义,应与提供者实现一同进入版本评审。比如 fullname(firstName,lastName) 的空值策略必须提前确定:是保留空串、返回占位文本,还是由上游校验拒绝该行。这个决定应由业务模板负责,不能让提供者在不同报表间悄悄改变含义。函数应当尽量是无状态的,同一组输入在同一版本中得到同一输出;读取当前时间、随机值或远程服务会降低导出的可重现性。
运行前我会按四项检查 SPI 打包结果。第一,编译产物包含实现类。第二,jar 中包含准确路径的 META-INF/services/com.github.paohaijiao.function.core.JQuickMethodFunctionProvider。第三,文件中只有实现类全限定名,不能混入 Markdown 标记、注释或错误空白。第四,运行时类路径同时具备 JQuick-Excel 及函数模块。开发环境能运行但发布包失效时,通常是资源过滤、阴影打包或模块依赖范围导致服务描述文件没有进入最终制品。
建议用最小导出回归测试验证一条完整路径:准备一行包含正常值的 JQuickRow,让 XML 使用自定义函数;再准备一行空值、参数不足或字典缺失的数据,确认结果与团队约定一致。测试不应只断言文件已生成,还应读取生成工作簿对应单元格,或者至少确认转换后的字段值。对于日期、金额和枚举值,再增加边界样本,例如月末、闰日、负数、未知编码和前后空格。这样可以区分函数本身错误、XML 表达式错误与输入数据错误。
TRANSFORM 的定位是行级值变换,适合名称标准化、字典翻译和可预测的格式化;跨行汇总、排序后的排名、公式联动应留给 FORMULAS 或上游业务计算。一个函数若必须查询数据库,也不应在每一行里发起查询。我的做法是先由应用层批量准备字典或结果集,放进 JContext,再由 trans 或确定性的函数读取,既避免 N+1 访问,也让导出耗时和失败边界更清楚。对于同一批次会重复使用的输入,我还会在进入 DSL 前完成规范化,例如统一日期时区、去除确定无意义的前后空格,并将业务码转换限定在一份受版本控制的字典中。这样 SPI 的职责始终是纯粹转换,出现结果差异时可以复现原始行、上下文和函数版本。
排查问题时,我按"资源发现、函数注册、表达式参数、工作簿结果"的顺序定位。先确认服务文件与实现类都在运行包;再检查函数名是否与 XML 一致;随后使用一行固定数据确认 ${field} 与 ${contextKey} 取到的值;最后检查 Excel 结果。不要用捕获后返回空字符串的方式掩盖参数错误,否则导出的文件看似成功,业务数据却已经被静默污染。对面向用户的导入,我会在应用层将失败行、工作表和列信息整理成可反馈的结果,而不是只把底层异常原样暴露。
总结
TRANSFORM 让 XML 规则具备表达能力,SPI 让这种表达能力可以按模块扩展。JQuickMethodFunctionProvider 定义函数名和调用入口,服务描述文件负责发现,JQuickMethodInvocationManager 负责查找和注册,JFunctionExecutor 提供统一执行适配形状。
我会把无状态、可复用、输入输出明确的报表转换放到 SPI;把字段取值、工作簿读写和显示格式留在框架规则中。这样新增一个业务函数只需要增加 Java 类和服务描述,不必修改每个报表的 Java 导出流程。