技术栈:Spring Boot + Maven + JNI
适用场景:CI/CD 构建依赖缺失 + JNI 本地库跨平台部署
1. 问题背景
在使用云效、Jenkins 等 CI/CD 平台构建 Java 项目时,经常会遇到一类让人头疼的问题:项目依赖了某些不在 Maven 中央仓库中的 JAR 包(比如硬件厂商提供的 SDK、老旧的本地库封装),导致云端编译失败。同时,像 rxtx 这类依赖 JNI 本地库的组件,还要求在不同操作系统上手动放置对应的 DLL 或 SO 文件,给部署带来额外负担。
本文以一个真实的 IoT 项目为例,介绍两种实用的解决方案,让项目在云端 CI 和本地离线环境下都能顺利编译和运行。
我们的项目是一个基于 Spring Boot 2.2 的 Java 多模块物联网平台,其中有两个"不听话"的依赖:
mysdk-jna(com.sun.examples:mysdk-jna:1.0.0)是海康威视 SDK 封装所需的 JNA 扩展包,不在 Maven 中央仓库中。开发者的本地.m2缓存里通常已经有了,所以本地编译正常,但云端构建环境是全新的,拉不到这个包就直接报[ERROR] build error。
rxtx(org.rxtx:rxtx:2.1.7)是 Java 串口通信库,JAR 包本身能从 Maven 仓库下载,但它依赖的 JNI 本地库(rxtxSerial.dll/librxtxSerial.so)需要手动放置到java.library.path目录下,每次换一台机器部署都要重复操作。
2. 方案一:项目内本地 Maven 仓库
核心思路是在项目目录下建一个local-repo文件夹,按照标准 Maven 仓库的目录结构存放非公共依赖的 JAR 和 POM,然后在pom.xml中声明这个本地仓库。这样无论是云端还是本地,Maven 都能从项目目录里解析到这些依赖。
2.1 创建目录结构
在项目根目录下创建local-repo,按照groupId的路径层级组织文件:
local-repo/ ├── com/sun/examples/mysdk-jna/1.0.0/ │ ├── mysdk-jna-1.0.0.jar │ ├── mysdk-jna-1.0.0.pom │ └── _remote.repositories └── org/rxtx/rxtx/2.1.7/ ├── rxtx-2.1.7.jar ├── rxtx-2.1.7.pom └── _remote.repositoriesJAR 和 POM 文件可以从开发者本地的~/.m2/repository中直接复制。每个目录下还需要一个_remote.repositories文件,内容如下:
#NOTE: This is a Maven Resolver internal implementation file mysdk-jna-1.0.0.jar>= mysdk-jna-1.0.0.pom>=>=表示该构件是本地安装的,不关联任何远程仓库,防止 Maven 尝试去远程校验。
2.2 修改 pom.xml
在根pom.xml的</dependencies>之后添加仓库配置:
<repositories><repository><id>project-local-repo</id><name>Project Local Repository</name><url>file://${maven.multiModuleProjectDirectory}/local-repo</url></repository></repositories>这里用的是${maven.multiModuleProjectDirectory}而非${project.basedir}。后者在子模块中会解析为子模块自身的路径,导致子模块找不到仓库。maven.multiModuleProjectDirectory是 Maven 3.3.1+ 提供的属性,始终指向根项目目录,完美解决了多模块项目中的路径问题。
2.3 提交并验证
将local-repo目录和修改后的pom.xml一起提交到代码仓库。执行mvn compile验证所有模块都能正常解析依赖。
这个方案的好处是简单直接、零外部依赖。代价是仓库体积会增加(通常只增加几 MB),但对于内部项目来说完全可以接受。
3. 方案二:跨平台本地库自动加载
rxtx 的 JAR 包只包含 Java 类,不包含 JNI 本地库。传统做法是把rxtxSerial.dll(Windows)或librxtxSerial.so(Linux)手动复制到C:\Windows\System32或$JAVA_HOME/jre/lib/amd64/下。这种方式在 CI 环境或多台服务器部署时非常不便。
我们的做法是把本地库文件打包进 JAR 的资源目录中,运行时自动提取并加载。
3.1 资源目录规划
在src/main/resources/native/下按操作系统和架构组织文件:
src/main/resources/native/ ├── windows/x86_64/ │ ├── rxtxSerial.dll │ └── rxtxParallel.dll └── linux/x86_64/ ├── librxtxSerial.so └── librxtxParallel.soWindows 的 DLL 从开发机的System32复制;Linux 的 SO 文件可以从 rxtx 官方发布的rxtx-2.1-7-bins-r2.zip中提取(路径为Linux/x86_64-unknown-linux-gnu/librxtxSerial.so)。
3.2 NativeLibLoader 工具类
核心逻辑分三步:检测平台、提取本地库、动态追加搜索路径。
publicclassNativeLibLoader{privatestaticvolatilebooleanloaded=false;publicstaticsynchronizedvoidloadRxtxLibs(){if(loaded)return;Stringos=detectOs();// "windows" / "linux"Stringarch=detectArch();// "x86_64"StringnativeDir;String[]nativeLibs;if("windows".equals(os)&&"x86_64".equals(arch)){nativeDir="windows/x86_64";nativeLibs=newString[]{"rxtxSerial.dll","rxtxParallel.dll"};}elseif("linux".equals(os)&&"x86_64".equals(arch)){nativeDir="linux/x86_64";nativeLibs=newString[]{"librxtxSerial.so","librxtxParallel.so"};}else{log.warn("当前平台({}/{})未内置本地库,请手动放置",os,arch);loaded=true;return;}try{PathtempDir=Files.createTempDirectory("rxtx-native-");tempDir.toFile().deleteOnExit();intextractedCount=0;for(StringlibName:nativeLibs){StringresourcePath="/native/"+nativeDir+"/"+libName;try(InputStreamis=NativeLibLoader.class.getResourceAsStream(resourcePath)){if(is==null)continue;FiletempFile=newFile(tempDir.toFile(),libName);Files.copy(is,tempFile.toPath(),StandardCopyOption.REPLACE_EXISTING);if("linux".equals(os)){tempFile.setExecutable(true,false);}tempFile.deleteOnExit();extractedCount++;}}if(extractedCount>0){addLibraryPath(tempDir.toFile());}loaded=true;}catch(Exceptione){log.error("rxtx 本地库自动加载失败: {}",e.getMessage(),e);loaded=true;}}}平台检测通过System.getProperty("os.name")和System.getProperty("os.arch")实现,做了标准化处理,比如把amd64和x86_64统一归为x86_64。提取本地库到临时目录时,Linux 下需要额外调用setExecutable(true, false)赋予可执行权限,否则 JVM 会报UnsatisfiedLinkError。
3.3 动态追加 java.library.path
Java 默认只在启动时读取java.library.path,运行时修改系统属性不会生效。需要通过反射直接修改 ClassLoader 内部缓存的路径数组:
privatestaticvoidaddLibraryPath(Filedir)throwsException{// 修改系统属性,影响后续新创建的 ClassLoaderStringexistingPath=System.getProperty("java.library.path","");System.setProperty("java.library.path",existingPath+File.pathSeparator+dir.getAbsolutePath());// 反射修改当前 ClassLoader 已缓存的路径数组FieldusrPathsField=ClassLoader.class.getDeclaredField("usr_paths");usrPathsField.setAccessible(true);String[]usrPaths=(String[])usrPathsField.get(null);StringdirPath=dir.getAbsolutePath();for(Stringpath:usrPaths){if(path.equals(dirPath))return;}String[]newUsrPaths=newString[usrPaths.length+1];System.arraycopy(usrPaths,0,newUsrPaths,0,usrPaths.length);newUsrPaths[usrPaths.length]=dirPath;usrPathsField.set(null,newUsrPaths);}这段代码同时做了两件事:修改系统属性(为后续可能创建的 ClassLoader 生效)和反射修改usr_paths数组(为当前 ClassLoader 立即生效)。
3.4 集成到 Spring 服务
在串口服务的@PostConstruct方法中调用加载器,确保在任何串口操作之前完成本地库加载:
@ServicepublicclassSerialPortServiceImplimplementsISerialPortService{@PostConstructpublicvoidinit(){NativeLibLoader.loadRxtxLibs();}}loadRxtxLibs()内部通过volatile boolean loaded标志位保证线程安全且只执行一次,多次调用没有副作用。
4. 注意事项
(1)本地仓库与 Git。local-repo目录应该纳入 Git 版本管理。如果 JAR 文件较大(超过 100MB),建议考虑 Git LFS 或者使用专门的二进制制品管理方案。
(2)JDK 版本兼容性。反射修改usr_paths的方式依赖 JDK 内部实现细节。在 JDK 8 和 JDK 11 上测试正常,但 JDK 17+ 的强封装机制(--illegal-access=deny)可能会导致反射失败,届时需要添加 JVM 启动参数--add-opens java.base/java.lang=ALL-UNNAMED。如果项目计划升级 JDK 版本,建议提前关注。
(3)Linux 并口库的问题。rxtx 2.1.7 官方发布的 Linux x86_64 版本中没有提供librxtxParallel.so。由于串口通信场景下只会加载librxtxSerial.so,并口库极少被使用,可以不提供。如果需要完整覆盖,可以在 32 位 Linux 环境中获取。注意不能混用不同架构的本地库,否则 JVM 加载时会报UnsatisfiedLinkError: wrong ELF class。
5. 总结
| 方案 | 解决的问题 | 适用阶段 |
|---|---|---|
| 项目内本地 Maven 仓库 | 编译期依赖找不到 | CI/CD 构建 + 离线开发 |
| 跨平台本地库自动加载 | 运行期 JNI 库缺失 | 多平台部署 |
两个方案结合使用,可以让依赖了非公共组件的 Java 项目在开发和部署流程上更加顺畅。
非公共依赖和 JNI 本地库是 Java 项目中常见但又容易被忽视的问题。本文介绍的两种方案都不复杂,但对构建稳定性和部署效率的提升是实实在在的。