Apache Geaflow推理框架Geaflow-infer 解析系列(四)依赖管理

第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 (冲突)

参考资源

相关推荐
云渠道商yunshuguoji20 分钟前
亚马逊云渠道商:如何用 EC2 Auto Scaling 轻松应对流量洪峰?
架构
泉城老铁1 小时前
Vue2实现语音报警
前端·vue.js·架构
云渠道商yunshuguoji1 小时前
阿里云渠道商:如何选择高性价比阿里云GPU配置?
架构
Mr_万能胶2 小时前
到底原研药,来瞧瞧 Google 官方《Android API 设计指南》
android·架构·android studio
腾讯云开发者3 小时前
架构火花|AI时代,架构师的护城河在哪里?
架构
无心水3 小时前
【分布式利器:限流】3、微服务分布式限流:Sentinel集群限流+Resilience4j使用教程
分布式·微服务·架构·sentinel·分布式限流·resilience4j·分布式利器
梁bk3 小时前
Redis 多级缓存架构学习笔记
redis·缓存·架构
一起学开源4 小时前
分布式基石:CAP定理与ACID的取舍艺术
分布式·微服务·架构·流程图·软件工程
语落心生4 小时前
Apache Geaflow推理框架Geaflow-infer 解析系列(一)Geaflow-Infer 模块简介
架构