第4章:依赖管理详解
章节导读
虚拟环境的创建只是第一步,更重要的是将所有必需的文件和依赖正确地准备好 。本章将深入讲解 InferDependencyManager 如何:
- 从 JAR 包中提取运行时文件
- 管理 Python 依赖包
- 配置 Conda 环境
通过本章,你将理解 geaflow-infer 的"工程化"设计------如何将一个复杂的 Python 推理系统打包为一个 Java JAR,并在运行时动态还原。
核心设计
makefile
代码层面:
编写 Java 类
└─→ 编译成 .class
└─→ 打包成 JAR
运行时:
JAR 启动
└─→ InferDependencyManager.extractRuntimeFiles()
└─→ 提取 infer_server.py, requirements.txt, 脚本
└─→ 执行初始化脚本
└─→ Conda 虚拟环境就绪
└─→ Python 推理进程启动
好处:
✓ 用户无需手动配置 Python 环境
✓ 所有依赖都在 JAR 中,便于分发
✓ 自动化程度高,易于部署
4.1 InferDependencyManager 职责
设计目标
makefile
问题: 用户需要运行推理,但 Python 环境和依赖文件分散各处
InferDependencyManager 的使命:
├─ 将所有依赖文件从 JAR 包中提取出来
├─ 将文件放到正确的目录
├─ 生成必要的配置文件(requirements.txt)
├─ 执行 Shell 脚本安装 Python 依赖
└─ 验证环境是否正确初始化
核心职责清单
markdown
1. 依赖发现 (Dependency Discovery)
- 扫描 JAR 中的 infer/inferRuntime 目录
- 列出所有运行时文件
2. 文件提取 (File Extraction)
- 从 JAR 读取二进制数据
- 写入到文件系统
- 保留目录结构
3. 依赖配置 (Dependency Configuration)
- 生成 requirements.txt
- 配置 Python 环境变量
- 设置 PYTHONPATH
4. 脚本执行 (Script Execution)
- 调用 install-infer-env.sh
- 创建 Conda 虚拟环境
- 安装 pip 依赖包
5. 错误处理 (Error Handling)
- 文件写入失败处理
- 脚本执行失败处理
- 权限问题处理
4.2 运行时文件准备
JAR 内部结构
geaflow-infer.jar 的标准结构:
scss
geaflow-infer.jar
│
├─ META-INF/
│ ├─ MANIFEST.MF (JAR 元数据)
│ └─ maven/ (Maven 信息)
│
├─ org/apache/geaflow/ (Java 编译类)
│ └─ infer/
│ ├─ *.class (编译后的 Java 类)
│ └─ ...
│
└─ infer/ (资源文件,关键!)
├─ inferRuntime/ (运行时库)
│ ├─ infer_server.py (Python 推理服务)
│ ├─ requirements.txt (依赖列表)
│ ├─ utils/
│ │ ├─ __init__.py
│ │ ├─ pickle_utils.py
│ │ └─ ...
│ └─ lib/
│ ├─ libcuda.so
│ └─ ...
│
└─ env/
└─ install-infer-env.sh (环境初始化脚本)
文件提取流程
java
public class InferDependencyManager {
private static final String INFER_RUNTIME_PATH = "infer/inferRuntime";
private static final String FILE_IN_JAR_PREFIX = "/";
/**
* 第一步: 从 JAR 中列举所有运行时文件
*/
private List<String> buildInferRuntimeFiles() {
List<String> runtimeFiles;
try {
// 1. 使用 ClassLoader 获取资源 URL
ClassLoader classLoader =
InferDependencyManager.class.getClassLoader();
URL resourceUrl = classLoader
.getResource(INFER_RUNTIME_PATH);
// 2. 列出路径下的所有文件
List<Path> filePaths =
InferFileUtils.getPathsFromResourceJAR(
INFER_RUNTIME_PATH
);
// 3. 转换为文件名列表
runtimeFiles = filePaths.stream().map(path -> {
String filePath = path.toString();
// 删除前导 "/" (如果有)
if (filePath.startsWith(FILE_IN_JAR_PREFIX)) {
filePath = filePath.substring(1);
}
LOGGER.info("发现运行时文件: {}", filePath);
return filePath;
}).collect(Collectors.toList());
} catch (Exception e) {
LOGGER.error("读取运行时文件列表失败", e);
throw new GeaflowRuntimeException(
"获取运行时文件失败", e);
}
return runtimeFiles;
}
/**
* 第二步: 提取文件到本地目录
*/
private void extractRuntimeFiles() {
List<String> runtimeFiles = buildInferRuntimeFiles();
for (String filePath : runtimeFiles) {
try {
// 构造目标路径
String targetPath =
environmentContext.getInferFilesDirectory()
+ "/" + filePath;
// 创建父目录
File targetFile = new File(targetPath);
targetFile.getParentFile().mkdirs();
// 从 JAR 读取内容
InputStream inputStream =
InferDependencyManager.class
.getResourceAsStream("/" + filePath);
if (inputStream == null) {
LOGGER.warn("文件不存在: {}", filePath);
continue;
}
// 写入到目标文件
try (FileOutputStream fos =
new FileOutputStream(targetFile)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = inputStream.read(buffer))
!= -1) {
fos.write(buffer, 0, bytesRead);
}
}
LOGGER.info("✓ 提取文件: {}", targetPath);
// 对于 .sh 文件,设置执行权限
if (filePath.endsWith(".sh")) {
targetFile.setExecutable(true);
}
} catch (IOException e) {
LOGGER.error("提取文件失败: {}", filePath, e);
throw new GeaflowRuntimeException(
"提取文件失败: " + filePath, e);
}
}
}
}
核心操作示例
从 ClassLoader 读取文件
java
// ❌ 错误做法:直接用相对路径
File file = new File("infer/infer_server.py"); // ✗ 找不到!
// ✓ 正确做法:使用 ClassLoader
InputStream stream =
getClass().getResourceAsStream("/infer/infer_server.py");
// 或者
URL url = getClass().getResource("/infer/infer_server.py");
// 为什么?
// ClassLoader 知道 JAR 的位置
// 它可以从 JAR 中读取资源,就像读文件系统一样
递归列举 JAR 中的目录
java
public static List<Path> getPathsFromResourceJAR(String folder)
throws Exception {
List<Path> result = new ArrayList<>();
// 获取资源 URL
URL url = InferDependencyManager.class
.getClassLoader().getResource(folder);
if (url == null) {
return result;
}
// 如果是 JAR 内的资源
if (url.getProtocol().equals("jar")) {
// jar:file:///path/to/jar.jar!/infer/inferRuntime
// 提取 JAR 文件路径
String jarPath = url.getPath()
.substring(5, url.getPath().indexOf("!"));
// 使用 ZipFile 读取 JAR 内容
try (ZipFile zipFile = new ZipFile(jarPath)) {
Enumeration<? extends ZipEntry> entries =
zipFile.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
// 找到目标文件夹下的所有文件
if (entry.getName().startsWith(folder
+ "/") && !entry.isDirectory()) {
result.add(Paths.get(entry.getName()));
}
}
}
}
// 如果是文件系统中的资源
else if (url.getProtocol().equals("file")) {
Path path = Paths.get(url.toURI());
Files.walk(path)
.filter(Files::isRegularFile)
.forEach(result::add);
}
return result;
}
4.3 Shell 脚本执行机制
脚本设计
install-infer-env.sh 详解
bash
#!/bin/bash
# 这是从 JAR 中提取出来的脚本
# 参数:
# $1: 虚拟环境目录 (如 /tmp/geaflow_infer_env)
# $2: requirements.txt 文件路径
set -e # 任何错误都退出
ENV_DIR=${1}
REQUIREMENTS_FILE=${2}
echo "=========================================="
echo "开始初始化推理虚拟环境"
echo "环境目录: ${ENV_DIR}"
echo "依赖文件: ${REQUIREMENTS_FILE}"
echo "=========================================="
# Step 1: 检查 conda 是否可用
if ! command -v conda &> /dev/null; then
echo "✗ 错误: conda 未安装"
exit 1
fi
# Step 2: 创建虚拟环境
echo "正在创建虚拟环境..."
conda create -p ${ENV_DIR}/conda \
python=3.8 \
numpy \
scipy \
-y \
-q
echo "✓ 虚拟环境创建成功"
# Step 3: 激活虚拟环境
source ${ENV_DIR}/conda/bin/activate
# Step 4: 升级 pip
echo "正在升级 pip..."
pip install --upgrade pip -q
# Step 5: 安装依赖
echo "正在安装 Python 依赖..."
if [ -f "${REQUIREMENTS_FILE}" ]; then
pip install -r "${REQUIREMENTS_FILE}" -q
else
echo "⚠️ 警告: ${REQUIREMENTS_FILE} 不存在"
exit 1
fi
echo "✓ 依赖安装成功"
# Step 6: 验证环境
echo "正在验证环境..."
python -c "import sys; print(f'Python: {sys.version}')" || exit 1
# Step 7: 打印信息
echo "=========================================="
echo "✓ 虚拟环境初始化完成"
echo "Python 路径: ${ENV_DIR}/conda/bin/python"
echo "=========================================="
Java 中的脚本执行
java
public class InferDependencyManager {
/**
* 执行环境初始化脚本
*/
public void setupEnvironment() {
String shellPath = buildInferEnvShellPath;
String requirementsPath = inferEnvRequirementsPath;
String envDir = environmentContext.getVirtualEnvDirectory();
LOGGER.info("执行初始化脚本: {}", shellPath);
try {
// 构建进程命令
ProcessBuilder pb = new ProcessBuilder(
"bash", // 使用 bash 解释器
shellPath, // 脚本路径
envDir, // 参数 1: 虚拟环境目录
requirementsPath // 参数 2: requirements 路径
);
// 设置进程属性
pb.inheritIO(); // 继承标准输入输出(显示脚本输出)
// 启动进程
Process process = pb.start();
// 等待进程完成(设置超时)
boolean completed = process.waitFor(5, TimeUnit.MINUTES);
if (!completed) {
process.destroyForcibly();
throw new TimeoutException(
"环境初始化超时(超过5分钟)");
}
int exitCode = process.exitValue();
if (exitCode != 0) {
throw new RuntimeException(
"环境初始化脚本失败,退出码: " + exitCode);
}
LOGGER.info("✓ 环境初始化脚本执行成功");
} catch (Exception e) {
LOGGER.error("执行环境初始化脚本失败", e);
throw new GeaflowRuntimeException(
"环境初始化失败: " + e.getMessage(), e);
}
}
}
进程执行的细节
java
// ProcessBuilder 的常用配置
// 1. 继承父进程的标准输入输出
pb.inheritIO();
// 等价于:
pb.redirectInput(ProcessBuilder.Redirect.INHERIT);
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
// 2. 重定向输出到文件
pb.redirectOutput(new File("output.log"));
// 3. 合并标准输出和错误输出
pb.redirectErrorStream(true);
// 4. 设置工作目录
pb.directory(new File("/home/user"));
// 5. 修改环境变量
Map<String, String> env = pb.environment();
env.put("CUSTOM_VAR", "value");
// 6. 启动进程
Process process = pb.start();
// 7. 读取输出流
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
// 8. 等待进程完成
int exitCode = process.waitFor();
// 9. 强制杀死进程
process.destroyForcibly();
4.4 Conda 环境配置
Conda 的作用
makefile
问题: Python 有多个版本,依赖包有版本冲突
解决方案: Conda 虚拟环境
├─ 隔离 Python 版本
├─ 隔离依赖包版本
└─ 支持创建、克隆、删除多个环境
Conda 命令详解
bash
# 1. 创建虚拟环境(推荐方式)
conda create -p /path/to/env python=3.8
# -p: 指定路径(而不是在 conda 的默认 envs 目录)
# python=3.8: 指定 Python 版本
# 2. 激活虚拟环境
source /path/to/env/bin/activate # Linux/Mac
# 或
conda activate /path/to/env
# 3. 安装依赖包
pip install numpy==1.21.0
# 或使用 conda 安装
conda install numpy==1.21.0
# 4. 列出已安装的包
pip list
# 5. 检查虚拟环境
which python # 应该显示虚拟环境中的 python
# 6. 停用虚拟环境
conda deactivate
# 7. 删除虚拟环境
conda env remove -p /path/to/env
GeaFlow 的 Conda 配置
python
# infer_server.py 中的虚拟环境使用
import sys
import os
# 1. 虚拟环境路径来自环境变量(由 Java 端设置)
python_exec = os.environ.get('PYTHON_EXEC')
# 如: /tmp/geaflow_infer_env/conda/bin/python3
# 2. 库路径
lib_path = os.environ.get('LIB_PATH')
# 如: /tmp/geaflow_infer_env/conda/lib
# 3. PYTHONPATH(自定义模块查找路径)
pythonpath = os.environ.get('PYTHONPATH')
# 如: /tmp/geaflow_infer_files:/tmp/geaflow_infer_env/conda/lib
# 4. 动态库路径
ld_library_path = os.environ.get('LD_LIBRARY_PATH')
# 如: /tmp/geaflow_infer_env/conda/lib
# 5. 导入依赖
import numpy
import torch
# 这些包都来自虚拟环境,不会污染系统 Python
依赖版本管理
requirements.txt 示例
ini
# requirements.txt
# 这个文件在 JAR 中,由 InferDependencyManager 提取
# 基础数值计算
numpy==1.21.0
scipy==1.7.0
pandas==1.3.0
# 深度学习框架
tensorflow==2.6.0
torch==1.9.0
# 机器学习库
scikit-learn==0.24.2
xgboost==1.4.2
# 数据处理
Pillow==8.3.1
opencv-python==4.5.3.56
# 工具库
pydantic==1.8.2
requests==2.26.0
版本冲突处理
makefile
问题: pip install 时报错 "dependency conflict"
例如:
numpy>=1.20.0 但 tensorflow 需要 numpy<1.19.0
解决方案:
1. 检查 requirements.txt 中的版本约束
2. 使用相容的版本组合
3. 使用 pip 的 --no-deps 略过依赖检查(不推荐)
例子:
# 修改 requirements.txt
tensorflow==2.6.0 # 自动会安装兼容的 numpy
# 而不是 tensorflow==2.6.0 + numpy==1.21.0 (冲突)