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

第 3 / 12 章
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 张表的故事。