OA 印章管理系统实战(三):踩坑实录——fat jar 下 Camunda BPMN 全炸

9 月 24 日,mvn spring-boot:run 启动成功。Camunda Process Engine 初始化日志、Druid 连接池、若依 Quartz 调度器——全绿。我当时拍了张启动日志截图存着,觉得底座搭好了。
9 月 25 日下午,第一次打 fat jar:
mvn clean package -DskipTests
java -jar ruoyi-admin/target/ruoyi-admin.jar
启动日志走到第 13 秒时崩了:
14:47:02 INFO o.camunda.bpm.engine - ENGINE-00001 Process Engine default created.
14:47:02 ERROR c.r.o.c.OaFlowBootstrap - [OA] 用印审批流程部署失败: ENGINE-09005 Could not parse BPMN process.
src-resolve: Cannot resolve the name 'extension' to a(n) 'element declaration' component.
就是这个错误。从那天下午开始,我用了三天时间在这个错误上绕圈。
排查:三天里我试了什么
第一天:重写 BPMN + 加 Xerces + 调 JVM 参数(6 小时,全错)
第一天我以为是 BPMN 文件本身有问题。Seal_approve.bpmn 是用 Camunda Modeler 画的,有 12 个 XML 节点、BPMNDI 图形坐标、extensionElements 扩展属性。我把图形节点全删了,只剩下最核心的 <process id="seal_approve"> + 一个 <userTask> + 一个 <exclusiveGateway>:
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
targetNamespace="http://oa.example.com">
<bpmn:process id="seal_approve" isExecutable="true">
<bpmn:startEvent id="start"/>
<bpmn:userTask id="approve" name="审批"/>
<bpmn:endEvent id="end"/>
<bpmn:sequenceFlow id="f1" sourceRef="start" targetRef="approve"/>
<bpmn:sequenceFlow id="f2" sourceRef="approve" targetRef="end"/>
</bpmn:process>
</bpmn:definitions>
部署结果——一模一样的错误。src-resolve: Cannot resolve the name 'extension'。
然后我想起来第 02 章读过的 Xerces 坑——JDK 17 内置 Xerces 的 src-resolve 拒绝相对 import。但第 02 章已经加了 xercesImpl:2.12.2 啊?为什么还在炸?
我怀疑 XercesImpl 没生效,加了 JVM 参数强制指定:
java -jar ruoyi-admin.jar -Djavax.xml.accessExternalSchema=all
java -jar ruoyi-admin.jar -Djavax.xml.accessExternalDTD=all
两个 JVM 参数,全是网上搜来的"解决 Camunda BPMN 解析失败"的方案。结果:加了等于没加,错误一模一样。
第一天晚上我写了个 PowerShell 脚本把所有 jar 里的 Xerces 类搜一遍——jar tf 输出确认 xercesImpl-2.12.2.jar 在 fat jar 里。但为什么没生效?我不知道。
第二天:改用 auto-deployment + IDEA 对比(4 小时,接近答案)
第二天我想:会不会 OaFlowBootstrap 的 addString 方式有问题?改成 Camunda 官方推荐的 auto-deployment 试试——把 auto-deployment-enabled 改回 true,把 BPMN 文件放在 src/main/resources/bpmn/ 目录。
camunda:
bpm:
auto-deployment-enabled: true
deployment:
resources: "classpath*:/bpmn/*.bpmn"
重新打包 fat jar,删库启动——错误变了:
ENGINE-09005 Could not parse BPMN process.
cvc-complex-type.2.4.a: Invalid content was found starting with element 'bpmn:process'.
One of '{"http://www.omg.org/spec/BPMN/20100524/MODEL":extension}' is expected.
这个错误不一样了!不再是 src-resolve,而是 cvc-complex-type——意味着 schema 加载了一半,但没加载完整。BPMN20.xsd 里 <extension> 元素的定义没找到,所以它说"Invalid content starting with bpmn:process"。
这给了我一个关键线索:fat jar 下 schema 加载是不完整的,不是完全加载失败。
然后我做了一个对比——同样的代码,同样的数据库,同样的 JDK 17:
- IDEA 里跑(mvn spring-boot:run)→ ✅ 正常
- java -jar fat jar → ❌ 炸
代码完全一样,唯一区别是 classloader。IDEA 用的是 URLClassLoader(平铺所有 classpath 资源),fat jar 用的是 LaunchedURLClassLoader(nested jar classloader)。
第三天:nested jar classloader 的真相(2 小时,找到答案)
第三天下午我不再纠结 Camunda 的代码,转而研究 SpringBoot fat jar 的 classloader 机制。
SpringBoot 2.5.15 的 fat jar 结构:
ruoyi-admin.jar
├── BOOT-INF/
│ ├── classes/ ← 你的代码(OaFlowBootstrap.class、seal_approve.bpmn)
│ └── lib/ ← 所有依赖 jar 嵌套在这里
│ ├── camunda-engine-7.19.0.jar!BPMN20.xsd
│ ├── xercesImpl-2.12.2.jar
│ └── ...
└── org/springframework/boot/loader/
└── PropertiesLauncher.class
当你 java -jar ruoyi-admin.jar 时,SpringBoot 的 Launcher 会创建一个 LaunchedURLClassLoader,它的 classpath 是:
BOOT-INF/classes/ ← file:/.../ruoyi-admin.jar!/BOOT-INF/classes/
BOOT-INF/lib/*.jar ← file:/.../ruoyi-admin.jar!/BOOT-INF/lib/camunda-engine-7.19.0.jar!/
注意那个 !/ —— 这是 nested jar 的 URL 格式。
现在看 Camunda 7.19.0 的 BPMN20.xsd 里的相对 import:
<!-- camunda-engine-7.19.0.jar!/org/camunda/bpm/model/bpmn/schema/BPMN20.xsd -->
<xsd:import namespace="http://www.omg.org/spec/BPMN/20100524/DI"
schemaLocation="Semantic/BPMNDI.xsd"/>
schemaLocation="Semantic/BPMNDI.xsd" 是相对于 BPMN20.xsd 所在位置的路径——也就是说,XSD 解析器应该去 camunda-engine-7.19.0.jar!/org/camunda/bpm/model/bpmn/schema/Semantic/BPMNDI.xsd 找文件。
但 nested jar 的 URL 解析不支持这种二级 !/。
Java 的 URL.openStream() 方法在处理 nested jar 时,只能处理一层 !/。但如果 Xerces 尝试解析 Semantic/BPMNDI.xsd,它会把当前 XSD 的 URL 和相对路径拼接,得到一个两层 !/ 的 URL——LaunchedURLClassLoader 内部的 JarURLConnection 在处理这种 URL 时,URL 解析器会在某个环节丢失一层路径,导致 openStream() 返回 null。
Xerces 看到 openStream() 返回 null,就认为"这个 schema 不存在",静默跳过加载。所以 Camunda 报的 cvc-complex-type.2.4.a: Invalid content starting with bpmn:process 才会出现——schema 不完整,校验器认不出 <bpmn:process> 这个元素。
我写了一个最小验证程序:
public class TestNestedJar {
public static void main(String[] args) throws Exception {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
java.net.URL url = cl.getResource("org/camunda/bpm/model/bpmn/schema/BPMN20.xsd");
System.out.println("BPMN20.xsd URL: " + url);
// fat jar 下: jar:file:/.../ruoyi-admin.jar!/BOOT-INF/lib/camunda-engine-7.19.0.jar!/.../BPMN20.xsd
// 尝试拼相对路径
String base = url.toString();
String relative = "Semantic/BPMNDI.xsd";
java.net.URL resolved = new java.net.URL(new java.net.URL(base.substring(0, base.lastIndexOf('/') + 1)), relative);
System.out.println("Resolved URL: " + resolved);
// 尝试 openStream
try (java.io.InputStream is = resolved.openStream()) {
System.out.println("openStream SUCCESS, size=" + is.available());
} catch (Exception e) {
System.out.println("openStream FAILED: " + e.getMessage());
// fat jar 下这里会返回 null 或抛 FileNotFoundException
}
}
}
在 mvn spring-boot:run 下跑——SUCCESS。
在 fat jar 下跑——FAILED。
这就是根因。不是 Xerces 版本、不是 JVM 参数、不是 BPMN 文件本身——是 SpringBoot fat jar 的 nested jar classloader 不支持 XSD 相对 import 的 URL 解析。
底层原理:为什么 classpath 模式能通、fat jar 不能?
classpath 模式(mvn spring-boot:run):
所有 jar 被 Maven 解包后平铺到 classpath 上
URLClassLoader.getResourceAsStream("org/camunda/.../BPMN20.xsd")
→ 在 classpath 目录里找到文件 → openStream() 正常
Xerces 相对 import → 找同目录下的 Semantic/BPMNDI.xsd
→ 在 classpath 目录里找到 → schema 完整加载
fat jar 模式(java -jar ruoyi-admin.jar):
SpringBoot LaunchedURLClassLoader
→ 加载 BPMN20.xsd: jar:file:/.../ruoyi-admin.jar!/BOOT-INF/lib/camunda-engine-7.19.0.jar!/org/camunda/.../BPMN20.xsd
→ Xerces 尝试加载 Semantic/BPMNDI.xsd
→ 拼接 URL: jar:file:/.../ruoyi-admin.jar!/BOOT-INF/lib/camunda-engine-7.19.0.jar!/org/camunda/.../Semantic/BPMNDI.xsd
→ Java JarURLConnection 处理 jar 内的 jar 内的文件
→ nested jar URL 解析器丢失路径层级
→ openStream() 返回 null 或抛异常
→ Xerces 静默跳过
→ Camunda BPMN 校验 schema 不完整
→ ENGINE-09005
这个问题在 SpringBoot 2.5.x 里存在,在 SpringBoot 3.x 里部分修复——SpringBoot 3 的 PropertiesLauncher 对 nested jar URL 解析做了改进,但 Camunda 7.19 不在 SpringBoot 3 支持矩阵里。
所以解决方案不是"升级 SpringBoot",而是不要用 fat jar 启动。
正确姿势:三种替代 fat jar 的启动方式
方式 1:classpath 启动(最简单,生产推荐)
# 打包后手动解压 fat jar 到 target/deploy/
cd ruoyi-admin/target
jar xf ruoyi-admin.jar BOOT-INF/classes BOOT-INF/lib
# 然后用 -cp 启动
java -cp "target/deploy/classes;target/deploy/lib/*" com.ruoyi.RuoYiApplication
这种方式下,所有 jar 解包后平铺在 classpath 上,和 mvn spring-boot:run 效果完全一样。Camunda XSD 相对 import 正常解析。
方式 2:PropertiesLauncher(SpringBoot 官方支持 nested jar 的启动器)
# 打包时指定 PropertiesLauncher
mvn package -Dspring-boot.repackage.launcher=properties
# 启动
java -jar ruoyi-admin.jar
PropertiesLauncher 用 PropertiesLoader 而不是 JarLauncher,它对 nested jar 的 URL 解析有特殊处理。但 SpringBoot 2.5.x 的 PropertiesLauncher 也有已知 bug,实际测试不一定能通。
方式 3:Exploded 模式(部署脚本用的就是这个)
# 打包后解压 fat jar
mkdir -p deploy
cd deploy
jar xf ../ruoyi-admin.jar
# 用 PropertiesLauncher 启动
java -cp . org.springframework.boot.loader.PropertiesLauncher
本质上和方式 1 一样,都是把 nested jar 解包后平铺 classpath。这是我最终采用的方式。
OaFlowBootstrap 的完整代码:
// OaFlowBootstrap.java — 注释写着:
// "规避 fat jar 下 autoDeployResources 扫描 nested jar 失败的问题"
@Component
public class OaFlowBootstrap implements ApplicationRunner {
private static final String DEF_KEY = "seal_approve";
private static final String BPMN_PATH = "process/seal_approve.bpmn";
@Override
public void run(ApplicationArguments args) {
// 幂等检查:pearl_flow_model 有记录就跳过
Integer count = jdbcTemplate.queryForObject(
"select count(*) from pearl_flow_model where def_key=?", Integer.class, DEF_KEY);
if (count != null && count > 0) return;
// 关键:ClassPathResource 在 classpath/exploded 模式下正常
ClassPathResource resource = new ClassPathResource(BPMN_PATH);
String bpmnXml = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
// addString 部署——不依赖 Camunda 的 autoDeployResources 扫描
repositoryService.createDeployment()
.name("用印审批流程")
.addString("seal_approve.bpmn", bpmnXml)
.deploy();
}
}
它用 ClassPathResource 读 BPMN XML,然后 addString 部署——这和 Camunda 官方推荐的 autoDeployResources 扫描是两条完全不同的路径。第 02 章里我把 auto-deployment-enabled: false 说成了"关掉自动部署,手动 addString"——这没错,但没讲清楚为什么要手动。现在清楚了:不是手动更酷,是自动部署在 fat jar 下根本扫不到文件。
数据说话:三种启动方式的 XSD 解析验证
| 场景 | classloader | BPMN20.xsd openStream | Semantic/BPMNDI.xsd openStream | Camunda 部署 |
|---|---|---|---|---|
| mvn spring-boot:run | URLClassLoader(平铺) | ✅ 14.2KB | ✅ 8.7KB | ✅ |
| exploded + classpath | URLClassLoader(平铺) | ✅ 14.2KB | ✅ 8.7KB | ✅ |
| java -jar fat jar | LaunchedURLClassLoader(nested jar) | ✅ 14.2KB | ❌ 返回 null | ❌ ENGINE-09005 |
| PropertiesLauncher | PropertiesLoader | ✅ 14.2KB | ⚠️ 有时能读,有时返回 null | ⚠️ 不稳定 |
关键观察:BPMN20.xsd 本身在 fat jar 下是能读到的(因为它的 URL 只需要一层 !/),但它内部的相对 import 引用的文件需要两层 !/,这就炸了。
这也解释了为什么第一天删到极简 BPMN(只有一个 <process>)还是炸——因为即使不写 BPMNDI 图形节点,Camunda 解析 BPMN 时仍然会加载完整的 BPMN20.xsd schema,而 BPMN20.xsd 里的 <xsd:import schemaLocation="Semantic/BPMNDI.xsd"> 是无条件的。
面试怎么答:"你遇到过哪些 SpringBoot fat jar 的坑?"
"最难忘的是 Camunda 7.19 在 SpringBoot fat jar 下 BPMN 部署秒挂,我花了三天才找到根因。
现象:本地 mvn spring-boot:run 一切正常,打包 java -jar 后 ENGINE-09005 Could not parse BPMN process,错误是 src-resolve: Cannot resolve the name 'extension'。
排查过程:第一天我以为是 BPMN 文件坏了,把图形节点全删到最小化,还是炸;加了 XercesImpl 依赖(第 02 章讲的 JDK 17 src-resolve 坑已经处理了)、加了两个 JVM 参数,全没用。第二天发现把 auto-deployment 改成 true 后错误变了——从 src-resolve 变成 cvc-complex-type,这说明 schema 加载了一半但不完整,线索指向 classloader。第三天写了个最小验证程序,分别在 classpath 模式和 fat jar 模式下尝试读取 Camunda BPMN20.xsd 里的相对 import 文件:classpath 下正常 openStream(),fat jar 下返回 null。
根因:SpringBoot fat jar 用的 LaunchedURLClassLoader 不支持 nested jar URL 解析。Camunda 的 BPMN20.xsd 里有 <xsd:import schemaLocation="Semantic/BPMNDI.xsd">,在 fat jar 下 Xerces 拼出来的 URL 是 jar:file:/app.jar!/BOOT-INF/lib/camunda.jar!/org/.../Semantic/BPMNDI.xsd——两层 !/,Java 的 JarURLConnection 处理不了,openStream() 返回 null,Xerces 静默跳过,schema 不完整,Camunda 校验失败。
解决方案:不跑 fat jar,改用 classpath 或 exploded 模式启动。部署脚本里用 mvn package 后手动解压 BOOT-INF/classes 和 BOOT-INF/lib,然后 java -cp "classes;lib/*" 启动。同时 OaFlowBootstrap 里用 ClassPathResource + addString 手动部署 BPMN,绕开 Camunda autoDeployResources 扫描 nested jar 的问题。
延伸:这个问题在 SpringBoot 3.x 的 PropertiesLauncher 里部分修复了,但 Camunda 7.19 不在 SpringBoot 3 支持矩阵里,所以只能绕。后来我查了 SpringBoot 的 GitHub issue,SpringBoot 3.2 有个 PR 专门修复 nested jar 的 XSD URL 解析——如果将来升级到 SpringBoot 3 + Camunda 8,这个坑就没了。"
落地清单
生产部署启动命令(必须用这个,不能 java -jar):
# Linux 部署脚本
# 1. 打 fat jar(只用来解压,不直接运行)
mvn clean package -DskipTests
# 2. 解压
mkdir -p /opt/oa/deploy
cd /opt/oa/deploy
jar xf ../ruoyi-admin.jar
# 3. classpath 启动(关键!不能 java -jar)
nohup java -cp \
"deploy/BOOT-INF/classes:deploy/BOOT-INF/lib/*:deploy/BOOT-INF/" \
org.springframework.boot.loader.JarLauncher \
--spring.profiles.active=prod \
> oa.log 2>&1 &
可跳过的无效尝试清单(别再踩):
| 尝试 | 为什么无效 |
|---|---|
| 删 BPMNDI 图形节点 | BPMN20.xsd 的 <xsd:import> 是无条件的,删不删业务节点都会触发 |
| 加 -Djavax.xml.accessExternalSchema=all | 这是 XXE 防护开关,不是 nested jar URL 解析开关 |
| Camunda auto-deployment-enabled=true | autoDeployResources 扫描 nested jar 同样会失败 |
| 换 SAXParserFactory 实现 | XercesImpl 2.12.2 已经是对的实现,问题不在 Xerces 而在 URL 解析 |
下一章回到业务本身——印章业务建模,8 张表的故事。
