ApacheCommons——commons-exec(外部进程与系统命令安全执行)

commons-exec(外部进程与系统命令安全执行)

1、概述

commons-exec(Apache Commons Exec)是 Java 用于在 JVM 内部高效、可靠地执行外部操作系统命令或可执行程序的标准工具包。它彻底解决了原生 Runtime.getRuntime().exec() 和 ProcessBuilder 容易引发的线程死锁、流阻塞、超时无法杀死子进程、跨平台参数转义等严重漏洞。

分类 模块常用类/接口 (Class / Interface) 核心功能与解决问题 核心 API / 典型使用场景
命令构建 (Command) CommandLine 安全构建命令行及参数 自动处理跨平台空格转义与参数引用(Quoting),防止命令注入风险,避免手动拼接字符串。 CommandLine cmd = CommandLine.parse("ffmpeg");cmd.addArgument("-i").addArgument(inputPath);
执行器 (Executor) Executor DefaultExecutor 触发命令执行与进程生命周期管理 负责调度底层的子进程,支持同步阻塞等待(execute(cmd))或结合回调进行异步后台运行。 Executor executor = new DefaultExecutor();executor.setWorkingDirectory(workDir);int exitCode = executor.execute(cmd);
流处理 (Stream) ExecuteStreamHandler PumpStreamHandler 子进程 I/O 流重定向与防死锁管理 通过异步线程并发抽干子进程的 stdoutstderr 并写回 stdin,从根本上解决系统 OS 缓冲区满导致进程挂起死锁的问题。 new PumpStreamHandler(outputStream, errorStream) • 用于捕获外部命令的控制台输出或重定向至日志文件
看门狗/超时 (Watchdog) ExecuteWatchdog 进程执行超时监控与强制销毁 实时监控子进程运行状态,超过设定毫秒数后通过 OS 信号强制杀死(destroy)卡死或挂起的子进程。 ExecuteWatchdog watchdog = new ExecuteWatchdog(10000);executor.setWatchdog(watchdog); • 预防后台长命令或第三方 CLI 无响应
异步句柄 (Result) ExecuteResultHandler DefaultExecuteResultHandler 异步模式下的结果回调与状态等待 配合 executor.execute(cmd, handler) 实现无阻塞执行,支持异步等待(waitFor())及成功/失败回调。 DefaultExecuteResultHandler resultHandler = new DefaultExecuteResultHandler();resultHandler.waitFor();
退出码校验 (Exit Value) Executor.setExitValue(int) setExitValues(int[]) 校验子进程退出状态码 设定合规的期望退出码(缺省默认为 0)。若实际退出码不匹配,执行器将直接抛出 ExecuteException executor.setExitValue(0);executor.setExitValues(new int[]{0, 1});(容忍特定成功标示)
xml 复制代码
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-exec</artifactId>
    <version>1.4.0</version>
</dependency>

2、命令构建

commons-exec(Apache Commons Exec)是 Apache 提供的一个用于在 Java 中执行外部系统命令和进程的强大工具包。相比于 JDK 原生的 Runtime.getRuntime().exec() 或 ProcessBuilder,它解决了阻塞死锁、流阻塞(StdOut/StdErr 缓冲区溢出)、超时控制以及平台兼容性等常见痛点。

在 commons-exec 中,命令构建的核心是围绕 CommandLine 类展开的。

2.1、核心API

CommandLine 用于安全地解析、拼接和校验要执行的命令行及其参数。它可以防止参数中的空格导致命令截断,并支持通过占位符动态填充参数。

API 方法 作用说明 典型应用场景与最佳实践
CommandLine.parse(String command) 静态工厂方法 通过传入完整的命令行字符串快捷创建 CommandLine 对象。 快捷解析简单命令。注意:若参数中包含复杂的动态变量或特殊字符,更推荐使用构造函数形式以防解析歧义。
new CommandLine(String executable) 构造函数(最推荐) 指定要执行的可执行程序路径或可执行文件名(如 "ffmpeg""C:/Tools/app.exe")。 最安全规范的构建方式。将可执行程序与参数分离,防止因字符串整体拼接导致的命令注入风险。
addArgument(String arg) 追加单个参数 默认会自动检测并对包含空格的参数应用双引号转义(Quoting)。 追加文件路径、配置参数或动态字符串(如 cmd.addArgument("/path/with space/file.txt"))。
addArgument(String arg, boolean handleQuoting) 带转义控制的参数追加 追加参数,可显式指定是否开启自动转义/加引号。 显式禁用转义(handleQuoting = false),适用于某些本身就需要包含原生通配符或格式化引号的特殊 CLI 参数。
addArguments(String[] args) / addArguments(String argsStr) 批量追加参数 批量追加参数数组,或按空格拆分并追加参数字符串。 传递已排好序的参数列表或将上一环节传入的参数数组直接注入到当前命令中。
setSubstitutionMap(Map<String, ?> substitutionMap) 模板化参数绑定 设置占位符变量映射,动态替换参数中的 ${variable} 变量。 动态构建命令模板,如 cmd.addArgument("-i").addArgument("${input}") 后批量替换变量。
toStrings() / toString() 命令表示与输出 将构建好的可执行程序及其所有参数转化为字符串数组或单行字符串。 调试与日志记录(例如在 logger.info() 中打印即将执行的完整 Shell 命令)。

2.2、使用示例

1. 基础构建(推荐方式:new CommandLine + addArgument)

这种方式最安全,能有效避免空格导致的参数解析错乱问题。

java 复制代码
import org.apache.commons.exec.CommandLine;

public class CmdBuildBasicDemo {
    public static void main(String[] args) {
        // 1. 指定主命令/可执行文件
        CommandLine cmd = new CommandLine("ping");

        // 2. 逐步追加参数
        cmd.addArgument("-c"); // 发送包的次数(Linux/macOS)
        cmd.addArgument("4");
        cmd.addArgument("127.0.0.1");

        System.out.println("构建的命令: " + cmd.toString());
        // 输出: ping -c 4 127.0.0.1
    }
}
java 复制代码
构建的命令: [ping, -c, 4, 127.0.0.1]

2. 参数处理:空格自动转义 (handleQuoting)

当参数中包含空格(如文件路径 "C:\Program Files\test.txt")时,addArgument 默认会自动添加双引号包围,防止操作系统将其拆分为多个参数。

java 复制代码
import org.apache.commons.exec.CommandLine;

public class CmdBuildQuotingDemo {
    public static void main(String[] args) {
        CommandLine cmd = new CommandLine("git");
        
        cmd.addArgument("commit");
        cmd.addArgument("-m");
        // 含有空格的参数,默认 handleQuoting = true,自动加上双引号
        cmd.addArgument("fix bug: resolve OOM issue");

        System.out.println("命令表示: " + cmd.toString());
        // 输出: git commit -m "fix bug: resolve OOM issue"
        
        // 如果不希望 Commons-Exec 自动加引号,可以手动禁用:
        // cmd.addArgument("some arg", false);
    }
}
java 复制代码
命令表示: [git, commit, -m, "fix bug: resolve OOM issue"]

3. 占位符与模板化参数绑定 (SubstitutionMap)

在批处理或通用工具开发中,经常需要构建带动态变量的命令模板,CommandLine 原生支持类似 ${variable} 的占位符替换。

java 复制代码
import org.apache.commons.exec.CommandLine;

import java.io.File;
import java.util.HashMap;
import java.util.Map;

public class CmdBuildTemplateDemo {
    public static void main(String[] args) {
        // 1. 创建带占位符的命令
        CommandLine cmd = new CommandLine("ffmpeg");
        cmd.addArgument("-i");
        cmd.addArgument("${inputFile}");  // 占位符 1
        cmd.addArgument("-b:v");
        cmd.addArgument("${bitrate}");    // 占位符 2
        cmd.addArgument("${outputFile}"); // 占位符 3

        // 2. 构建替换变量 Map
        Map<String, Object> map = new HashMap<>();
        map.put("inputFile", new File("/tmp/input.mp4"));
        map.put("bitrate", "1500k");
        map.put("outputFile", new File("/tmp/output.mp4"));

        // 3. 绑定变量映射映射集
        cmd.setSubstitutionMap(map);

        System.out.println("模板替换后的命令: " + cmd.toString());
        // 输出: ffmpeg -i /tmp/input.mp4 -b:v 1500k /tmp/output.mp4
    }
}
java 复制代码
模板替换后的命令: [ffmpeg, -i, /tmp/input.mp4, -b:v, 1500k, /tmp/output.mp4]

4. 静态解析:CommandLine.parse()

如果已经有一串完整的命令行文本,可以直接使用 .parse() 解析,但需注意路径中包含空格时的处理。

java 复制代码
import org.apache.commons.exec.CommandLine;

public class CmdBuildParseDemo {
    public static void main(String[] args) {
        String commandLineStr = "tar -czvf archive.tar.gz /path/to/dir";

        // 直接从完整文本解析出 CommandLine 对象
        CommandLine cmd = CommandLine.parse(commandLineStr);

        System.out.println("主命令: " + cmd.getExecutable()); // tar
        System.out.println("参数列表: " + String.join(" ", cmd.getArguments())); // -czvf archive.tar.gz /path/to/dir
    }
}
java 复制代码
主命令: tar
参数列表: -czvf archive.tar.gz /path/to/dir

2.3、跨平台(Windows vs Linux)命令构建技巧

不同操作系统在执行内置命令(如 dir、echo、copy 或 shell 脚本)时存在差异。使用 CommandLine 构建时需特别注意:

java 复制代码
import org.apache.commons.exec.CommandLine;

public class CrossPlatformCmdDemo {
    public static CommandLine createSystemCmd(String shellScript) {
        boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");
        CommandLine cmd;

        if (isWindows) {
            // Windows 下的内置命令 (如 dir, copy) 需要借由 cmd.exe 执行
            cmd = new CommandLine("cmd.exe");
            cmd.addArgument("/c");
            cmd.addArgument(shellScript, false); // 保持脚本原样,防止 cmd.exe 解析错乱
        } else {
            // Linux / macOS 下借由 /bin/sh 执行
            cmd = new CommandLine("/bin/sh");
            cmd.addArgument("-c");
            cmd.addArgument(shellScript, false);
        }

        return cmd;
    }
}

3、执行器

在 commons-exec 中,执行器(Executor)是负责真正启动、调度、控制进程执行并捕获 exit code(退出码)的核心组件。它的顶层接口是 Executor,最常用的标准实现类是 DefaultExecutor。

它配合 ExecuteWatchdog(超时/看门狗)、ExecuteStreamHandler(流处理器/输入输出捕获)以及 ExecutionResultHandler(异步结果处理器),彻底解决了 Java 原生进程调用容易引发的阻塞死锁和流溢出问题。

类 / 接口 角色与职责 核心 API / 典型使用场景 最佳实践 (Best Practices)
Executor (接口) 进程执行器的顶层接口 定义了命令同步执行(execute(cmd))与异步执行(execute(cmd, handler))的标准契约。 execute(CommandLine command)execute(CommandLine command, ExecuteResultHandler handler) 面向接口编程;构建统一的本地命令/脚本执行抽象层。
DefaultExecutor 核心执行器实现类 封装了 OS 原生子进程启动、流重定向绑定(StreamHandler)、退出码校验(setExitValue)与超时监控机制。 setWorkingDirectory(File dir)setStreamHandler(ExecuteStreamHandler streamHandler)setWatchdog(ExecuteWatchdog watchdog) 线程不安全 ,建议每次命令执行均局部实例化;务必为其绑定 PumpStreamHandler 防止进程挂起。
ExecuteWatchdog 进程看门狗 / 超时控制器 后台实时监控子进程生命周期,超过预设阈值(毫秒)后向操作系统发送摧毁信号强制杀死进程。 new ExecuteWatchdog(long timeout)watchdog.isWatching()watchdog.killedProcess() 高可用必备 。执行任何外部 CLI/Shell 脚本时均应配置 Watchdog,防止僵尸进程耗尽系统资源;执行后可调用 killedProcess() 校验是否因超时被杀。
ExecuteResultHandler (接口) 异步执行回调处理器 定义了异步执行模式下命令执行成功(onProcessComplete)与执行失败(onProcessFailed)的响应契约。 onProcessComplete(int exitValue)onProcessFailed(ExecuteException e) 用于响应式/非阻塞命令调度(如后台异步转码、邮件通知等),避免主线程被长时间阻塞。
DefaultExecuteResultHandler 默认异步结果处理器 实现了 ExecuteResultHandler,内部维护执行状态并提供阻塞等待与结果查询接口。 resultHandler.waitFor()resultHandler.waitFor(long timeout, TimeUnit unit)resultHandler.getExitValue() 需要异步启动但又需在特定时刻同步等待结果时使用;建议优先使用带超时的 waitFor 避免无限阻塞。

3.1、核心API

DefaultExecutor 核心 API 详解:

  • setExitValue(int expectedExitValue) / setExitValues(int[] expectedExitValues):设置合法的退出码(默认为 0)。如果进程返回的 exit code 不在预期列表中,执行器会抛出 ExecuteException。
  • setStreamHandler(ExecuteStreamHandler streamHandler):绑定标准输入、输出和错误流处理器(如 PumpStreamHandler)。
  • setWatchdog(ExecuteWatchdog watchdog):绑定超时看门狗,用于进程防卡死。
  • setWorkingDirectory(File dir):设置外部命令执行时的工作目录。
  • execute(CommandLine command):同步阻塞执行命令,返回进程退出码。
  • execute(CommandLine command, ExecuteResultHandler handler):异步非阻塞执行命令,执行结果回调给 handler。

3.2、使用示例

1. 同步执行(带超时控制与流捕获)

这是最常见的场景:同步等待命令执行完毕,设置 5 秒超时,并将标准输出与错误输出捕获到 Java 内存中。

java 复制代码
import org.apache.commons.exec.*;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.charset.StandardCharsets;

public class SyncExecutorDemo {
    public static void main(String[] args) {
        try {
            // 1. 构建命令
            CommandLine cmd = new CommandLine("ping");
            cmd.addArgument("127.0.0.1");

            // 2. 创建执行器并配置工作目录与合法退出码
            DefaultExecutor executor = new DefaultExecutor();
            executor.setWorkingDirectory(new File("."));
            executor.setExitValue(0); // 期望的成功退出码为 0

            // 3. 设置 5 秒超时看门狗 (防卡死)
            ExecuteWatchdog watchdog = new ExecuteWatchdog(10000);
            executor.setWatchdog(watchdog);

            // 4. 捕获标准输出与标准错误输出
            ByteArrayOutputStream outStream = new ByteArrayOutputStream();
            ByteArrayOutputStream errStream = new ByteArrayOutputStream();
            PumpStreamHandler streamHandler = new PumpStreamHandler(outStream, errStream);
            executor.setStreamHandler(streamHandler);

            // 5. 同步执行命令
            System.out.println("开始同步执行命令...");
            int exitCode = executor.execute(cmd);

            // 6. 输出结果
            System.out.println("退出码: " + exitCode);
            System.out.println("标准输出:\n" + outStream.toString());

        } catch (ExecuteException e) {
            System.err.println("命令执行失败/超时,Exit Code: " + e.getExitValue());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

2. 异步执行(非阻塞 + 回调结果)

在 Web 服务或后台任务中,如果执行的系统命令耗时较长,推荐使用异步方式,避免阻塞主线程。

java 复制代码
import org.apache.commons.exec.*;

public class AsyncExecutorDemo {
    public static void main(String[] args) throws Exception {
        // 1. 构建耗时较长的命令 (如 sleep 或 ping 多次)
        CommandLine cmd = new CommandLine("ping");
        cmd.addArgument("-c");
        cmd.addArgument("5");
        cmd.addArgument("127.0.0.1");

        DefaultExecutor executor = new DefaultExecutor();

        // 2. 使用默认异步结果处理器
        DefaultExecuteResultHandler resultHandler = new DefaultExecuteResultHandler();

        // 3. 异步启动执行 (不阻塞当前线程)
        System.out.println("异步启动任务...");
        executor.execute(cmd, resultHandler);

        // 主线程继续做其他事情
        System.out.println("主线程未被阻塞,继续处理其他业务 logic...");

        // 4. 显式等待异步任务结束 (或设置最大等待时间)
        resultHandler.waitFor(10000); // 最多等待 10 秒

        // 5. 校验执行状态
        if (resultHandler.hasResult()) {
            if (resultHandler.getException() != null) {
                System.err.println("异步任务执行异常: " + resultHandler.getException().getMessage());
            } else {
                System.out.println("异步任务成功结束,Exit Code: " + resultHandler.getExitValue());
            }
        } else {
            System.err.println("任务未在规定时间内完成!");
        }
    }
}

3. 结合 ExecuteWatchdog 实现被动杀进程与手动销毁

看门狗除了能超时自动 Kill 进程外,还可以由主程序主动调用 destroyProcess() 来强行终止外部进程。

java 复制代码
import org.apache.commons.exec.*;

public class ManualKillDemo {
    public static void main(String[] args) throws Exception {
        CommandLine cmd = new CommandLine("ping");
        cmd.addArgument("127.0.0.1");
        cmd.addArgument("-n");
        cmd.addArgument("100"); // 模拟超长命令

        DefaultExecutor executor = new DefaultExecutor();
        
        // 创建看门狗(设为无限超时,手动控制终止)
        ExecuteWatchdog watchdog = new ExecuteWatchdog(ExecuteWatchdog.INFINITE_TIMEOUT);
        executor.setWatchdog(watchdog);

        DefaultExecuteResultHandler resultHandler = new DefaultExecuteResultHandler();
        
        System.out.println("启动长任务...");
        executor.execute(cmd, resultHandler);

        // 模拟运行 2 秒后手动杀掉进程
        Thread.sleep(2000);
        if (watchdog.isWatching()) {
            System.out.println("检测到进程正在运行,强制杀死进程...");
            watchdog.destroyProcess(); // 强制摧毁子进程
        }

        resultHandler.waitFor(); // 等待清理完成
        System.out.println("进程已被成功销毁。");
    }
}

3.3、执行器最佳实践

  • 务必配置 ExecuteWatchdog:生产环境执行外部系统命令时,一定要配置合理超时时间的 ExecuteWatchdog,防止因外部程序卡死导致 JVM 线程资源耗尽。
  • 务必消耗/处理 IO 流:若不通过 setStreamHandler 绑定 PumpStreamHandler 处理输出流,一旦外部程序产生大量日志输出填满操作系统缓冲区,将导致子进程永久卡死(挂起)。
  • 正确设置 Exit Value:某些 CLI 工具成功执行后的返回值不一定是 0(例如 robocopy 或某些 Shell 脚本),此时应显式调用 executor.setExitValues(new int\[\]{0, 1}),防止误抛 ExecuteException。

4、流处理

在 commons-exec 中,外部进程的标准输入(System.in)、标准输出(System.out)和标准错误输出(System.err)如果处理不当,极易导致操作系统 IO 缓冲区溢出,进而引起 JVM 子进程永久卡死(挂起)。

commons-exec 专门设计了一套流处理体系,核心围绕 ExecuteStreamHandler 接口及其实现类 PumpStreamHandler 展开,用来异步抽干或重定向外部进程的输入输出流。

类 / 接口 角色与职责 典型场景
ExecuteStreamHandler (接口 ,流处理器的顶层接口 定义进程启动/停止时重定向 IO 流的规范
PumpStreamHandler 核心通用流处理器 将子进程的 StdOut/StdErr 泵送(Pump)到 Java 的 OutputStream,将 InputStream 喂给子进程
LogOutputStream 抽象日志输出流 将外部进程的控制台输出按行拦截,方便对接 Logback/Log4j 打印日志
ByteArrayOutputStream JDK 内存流 用于将外部命令的输出直接捕获为内存字符串

4.1、核心API

1. PumpStreamHandler

PumpStreamHandler 是最基础且常用的流处理器,负责开启独立的线程池,实时拉取子进程的输出流。

  • PumpStreamHandler():默认将进程输出重定向到 Java 的 System.out 和 System.err。
  • PumpStreamHandler(OutputStream outAndErr):将标准输出和标准错误合并写入同一个输出流。
  • PumpStreamHandler(OutputStream out, OutputStream err):分别指定标准输出和标准错误的接收流。
  • PumpStreamHandler(OutputStream out, OutputStream err, InputStream input):不仅接收输出,还支持向子进程的标准输入(StdIn)写入数据。

2. LogOutputStream

LogOutputStream 继承自 OutputStream,它重写了字节写入逻辑,内部实现了自动按行缓冲。当接收到换行符时,会触发 processLine(String line, int logLevel) 回调。

  • processLine(String line, int logLevel) (抽象方法):每当子进程输出完整的一行日志时调用,开发者需重写此方法。

4.2、使用示例

1. 捕获输出到内存字符串(ByteArrayOutputStream)

最基础的场景:将命令的输出结果保存到变量中供后续逻辑解析(如获取命令行输出的 IP 地址、软件版本号等)。

java 复制代码
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecutor;
import org.apache.commons.exec.PumpStreamHandler;

import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;

public class StreamCaptureDemo {
    public static void main(String[] args) throws Exception {
        CommandLine cmd = new CommandLine("ping");
        cmd.addArgument("127.0.0.1");
        cmd.addArgument("-c");
        cmd.addArgument("2");

        // 1. 创建内存输出流
        ByteArrayOutputStream stdout = new ByteArrayOutputStream();
        ByteArrayOutputStream stderr = new ByteArrayOutputStream();

        // 2. 绑定到 PumpStreamHandler
        PumpStreamHandler streamHandler = new PumpStreamHandler(stdout, stderr);

        DefaultExecutor executor = new DefaultExecutor();
        executor.setStreamHandler(streamHandler);

        // 3. 执行命令
        executor.execute(cmd);

        // 4. 获取并解析输出字符串
        String outStr = stdout.toString();
        String errStr = stderr.toString();

        System.out.println("标准输出内容:\n" + outStr);
        if (!errStr.isEmpty()) {
            System.err.println("标准错误内容:\n" + errStr);
        }
    }
}
java 复制代码
标准输出内容:
PING 127.0.0.1 (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.046 ms
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.093 ms

--- 127.0.0.1 ping statistics ---
2 packets transmitted, 2 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 0.046/0.070/0.093/0.023 ms

2. 逐行拦截并适配日志框架(LogOutputStream)

在企业级开发中,外部命令(如 Python 脚本、Shell 脚本或 C++ 可执行程序)打印的日志需要整合到 Java 项目的 Logback/SLF4J 日志文件中。

java 复制代码
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecutor;
import org.apache.commons.exec.LogOutputStream;
import org.apache.commons.exec.PumpStreamHandler;

public class StreamLogDemo {

    // 自定义按行处理日志流
    static class ExecLogger extends LogOutputStream {
        private final String prefix;

        public ExecLogger(String prefix) {
            this.prefix = prefix;
        }

        @Override
        protected void processLine(String line, int logLevel) {
            // 在实际项目中,这里替换为: logger.info("[{}] {}", prefix, line);
            System.out.println("[" + prefix + "] " + line);
        }
    }

    public static void main(String[] args) throws Exception {
        CommandLine cmd = new CommandLine("ping");
        cmd.addArgument("127.0.0.1");

        // 分别为 StdOut 和 StdErr 创建 LogOutputStream
        ExecLogger infoLog = new ExecLogger("INFO");
        ExecLogger errorLog = new ExecLogger("ERROR");

        PumpStreamHandler streamHandler = new PumpStreamHandler(infoLog, errorLog);

        DefaultExecutor executor = new DefaultExecutor();
        executor.setStreamHandler(streamHandler);

        executor.execute(cmd);
    }
}
java 复制代码
[INFO] PING 127.0.0.1 (127.0.0.1): 56 data bytes
[INFO] 64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.090 ms
[INFO] 64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.056 ms
[INFO] 64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.060 ms
[INFO] 64 bytes from 127.0.0.1: icmp_seq=3 ttl=64 time=0.099 ms
[INFO] 64 bytes from 127.0.0.1: icmp_seq=4 ttl=64 time=0.221 ms
[INFO] 
[INFO] --- 127.0.0.1 ping statistics ---
[INFO] 5 packets transmitted, 5 packets received, 0.0% packet loss
[INFO] round-trip min/avg/max/stddev = 0.056/0.105/0.221/0.060 ms

3. 实时写入数据到外部进程(System.in 输入流控制)

某些外部程序(如 cat、交互式 Bash 或密码输入提示)启动后会等待控制台输入。可以通过 PumpStreamHandler 的第三个构造参数向进程喂数据。

java 复制代码
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecutor;
import org.apache.commons.exec.PumpStreamHandler;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;

public class StreamInputDemo {
    public static void main(String[] args) throws Exception {
        // 执行 cat 命令,它会读取标准输入并原样打印
        CommandLine cmd = new CommandLine("cat");

        // 模拟要给子进程输入的文本数据
        String inputData = "Hello Commons-Exec!\nLine 2 input\n";
        ByteArrayInputStream stdin = new ByteArrayInputStream(inputData.getBytes(StandardCharsets.UTF_8));

        ByteArrayOutputStream stdout = new ByteArrayOutputStream();

        // 传入 stdin,实现向子进程自动写入数据
        PumpStreamHandler streamHandler = new PumpStreamHandler(stdout, System.err, stdin);

        DefaultExecutor executor = new DefaultExecutor();
        executor.setStreamHandler(streamHandler);

        executor.execute(cmd);

        System.out.println("cat 命令返回结果:\n" + stdout.toString());
    }
}
java 复制代码
cat 命令返回结果:
Hello Commons-Exec!
Line 2 input

4. 忽略/丢弃进程输出(避免占用内存)

如果外部命令会产生极其庞大的日志(如产生 GB 级别的临时打印),而程序并不关心这些输出,可以使用 org.apache.commons.io.output.NullOutputStream 或不重定向,避免内存爆满。

java 复制代码
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecutor;
import org.apache.commons.exec.PumpStreamHandler;
import org.apache.commons.io.output.NullOutputStream;

public class StreamIgnoreDemo {
    public static void main(String[] args) throws Exception {
        CommandLine cmd = new CommandLine("some-verbose-tool");

        // 使用 NullOutputStream 忽略并丢弃所有输出
        PumpStreamHandler streamHandler = new PumpStreamHandler(
                NullOutputStream.INSTANCE, 
                NullOutputStream.INSTANCE
        );

        DefaultExecutor executor = new DefaultExecutor();
        executor.setStreamHandler(streamHandler);

        executor.execute(cmd);
    }
}

4.3、最佳实践

  • 永远不要传 null 给 StreamHandler:如果不显式配置,DefaultExecutor 默认会绑定重定向到 System.out 的 PumpStreamHandler。一旦手动传 null 或未清空流,会导致管道阻塞挂起。
  • 注意字符编码:在通过 ByteArrayOutputStream 提取字符串时,务必明确传入 StandardCharsets.UTF_8 或特定平台的字符集(Windows 下命令行可能是 GBK),防止中文字符乱码。
  • 日志流要防止阻塞:重写 LogOutputStream.processLine 时,不要在回调方法内执行耗时的同步网络请求或死锁操作,因为该方法是在 commons-exec 专门的流抽干线程中同步调用的。

5、看门狗/超时

在 commons-exec 中,看门狗(Watchdog)是防止外部子进程出现死锁、死循环或无响应的关键机制。当外部进程执行时间超过预期设定值时,看门狗会自动向操作系统发送终止信号以销毁(Kill)子进程,从而避免 JVM 线程被永久卡死。

类 / 接口 角色与职责 典型场景
ExecuteWatchdog 核心超时看门狗类 监控进程执行时长,超时自动强行终止子进程
TimeoutObserver (接口) 超时观察者接口 配合 Watchdog 使用,在触发超时事件时执行自定义回调通知
Watchdog 基础定时触发器 ExecuteWatchdog 内部依赖的底层定时器管理类

5.1、核心API

ExecuteWatchdog 是最常用的类,主要控制超时阈值以及查询进程运行或杀死状态。

  • ExecuteWatchdog(long timeout):构造函数。传入超时时间(毫秒),若传入 ExecuteWatchdog.INFINITE_TIMEOUT(即 -1),则代表无超时限制。
  • isWatching():检查看门狗当前是否正在监视运行中的进程。
  • killedProcess():检查外部进程是否因为超时而被看门狗强制摧毁。
  • destroyProcess():主程序主动调用以立即强制销毁被监视的外部进程。
  • checkException(Exception e):在捕获异常时传入,如果该异常是由于超时杀进程引起的,将抛出 ExecuteException。

5.2、使用示例

1. 基础超时控制(超时自动 Kill 进程)

最常见的场景:限制外部命令(如 Python 脚本、Ping 命令、Shell 转换工具)最多执行 3 秒,若超时则主动结束并抛出异常。

java 复制代码
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecutor;
import org.apache.commons.exec.ExecuteException;
import org.apache.commons.exec.ExecuteWatchdog;

public class WatchdogBasicDemo {
    public static void main(String[] args) {
        // 构建一个需要执行很长时间的命令 (比如 ping 10 次)
        CommandLine cmd = new CommandLine("ping");
        cmd.addArgument("127.0.0.1");
        cmd.addArgument("-n");
        cmd.addArgument("10"); // Windows 环境;Linux/macOS 下为 -c

        DefaultExecutor executor = new DefaultExecutor();

        // 1. 创建看门狗:设置 3000 毫秒(3 秒)超时限制
        ExecuteWatchdog watchdog = new ExecuteWatchdog(3000);
        executor.setWatchdog(watchdog);

        try {
            System.out.println("开始执行任务,最长允许运行 3 秒...");
            executor.execute(cmd);
            System.out.println("任务在规定时间内顺利完成!");
        } catch (ExecuteException e) {
            // 2. 检查进程是否是被看门狗强制杀掉的
            if (watchdog.killedProcess()) {
                System.err.println("错误:外部进程执行超时(超过 3 秒),已被看门狗强制终止!");
            } else {
                System.err.println("错误:外部进程执行失败,Exit Code: " + e.getExitValue());
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

2. 手动干预:结合 destroyProcess 被动销毁进程

不依赖固定超时,而是将超时设为无限,并由 Java 主线程根据特定业务逻辑(如收到用户取消指令、HTTP 链接中断等)随时手动杀死外部进程。

java 复制代码
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecuteResultHandler;
import org.apache.commons.exec.DefaultExecutor;
import org.apache.commons.exec.ExecuteWatchdog;

public class WatchdogManualKillDemo {
    public static void main(String[] args) throws Exception {
        CommandLine cmd = new CommandLine("ping");
        cmd.addArgument("127.0.0.1");

        DefaultExecutor executor = new DefaultExecutor();

        // 1. 设置无限超时的看门狗
        ExecuteWatchdog watchdog = new ExecuteWatchdog(ExecuteWatchdog.INFINITE_TIMEOUT);
        executor.setWatchdog(watchdog);

        // 2. 异步启动命令
        DefaultExecuteResultHandler resultHandler = new DefaultExecuteResultHandler();
        System.out.println("异步启动长周期后台任务...");
        executor.execute(cmd, resultHandler);

        // 模拟主程序处理业务逻辑...
        Thread.sleep(2000);

        // 3. 业务逻辑判断需要强制中断外部子进程
        if (watchdog.isWatching()) {
            System.out.println("触发外部取消动作,正在主动销毁子进程...");
            watchdog.destroyProcess(); // 手动销毁进程
        }

        // 等待进程清理完毕
        resultHandler.waitFor();
        System.out.println("子进程已被销毁,异步任务清理完成。");
    }
}

3. 使用 TimeoutObserver 监听超时回调

如果想在看门狗触发超时瞬间执行一些自定义告警或日志记录逻辑,可以使用 Watchdog 自带的 TimeoutObserver 观察者接口。

java 复制代码
import org.apache.commons.exec.TimeoutObserver;
import org.apache.commons.exec.Watchdog;

public class WatchdogObserverDemo {

    // 定义超时监听器
    static class CustomTimeoutListener implements TimeoutObserver {
        @Override
        public void timeoutOccured(Watchdog w) {
            // 在这里对接告警系统,比如发送钉钉通知、微信告警或记录特定日志
            System.err.println("【System Alert】检测到任务触发 Watchdog 超时阈值,准备执行终止动作!");
        }
    }

    public static void main(String[] args) throws Exception {
        // 创建底层 Watchdog 并绑定超时观察者
        Watchdog watchdog = new Watchdog(2000); // 2秒超时
        watchdog.addTimeoutObserver(new CustomTimeoutListener());

        System.out.println("启动独立监控任务...");
        watchdog.start();

        // 模拟耗时任务
        Thread.sleep(3000);

        watchdog.stop();
    }
}

5.3、最佳实践

  • 防范死锁必配选项:DefaultExecutor 在默认情况下没有超时限制,必须主动配置 ExecuteWatchdog,否则一旦外部程序卡死在等待系统资源状态,对应的 JVM 线程将被永久挂起。
  • 正确区分退出原因:在捕获 ExecuteException 异常时,务必调用 watchdog.killedProcess() 进行二次判定。如果是 true 说明是因为超时被强杀;如果是 false 则说明是外部程序以非 0 的错误码异常退出。

6、异步句柄

在 commons-exec 中,异步句柄(ExecuteResultHandler)是实现非阻塞式系统命令调用的核心机制。当执行耗时较长的外部程序(如大文件压缩、视频转码、后台批处理脚本)时,通过异步句柄可以避免 JVM 主线程被阻塞挂起。

commons-exec 提供了异步结果处理器的顶层接口 ExecuteResultHandler 以及标准实现类 DefaultExecuteResultHandler。

类 / 接口 角色与职责 典型场景
ExecuteResultHandler (接口) 异步执行结果回调接口 自定义处理进程执行成功与失败的回调函数
DefaultExecuteResultHandler 默认的异步句柄实现类 提供非阻塞执行、状态查询和 waitFor() 阻塞等待等方法

6.1、核心API

DefaultExecuteResultHandler 内部维护了并发锁与执行状态,是官方推荐使用的异步结果句柄。

  • hasResult():判断异步任务是否已执行完毕(无论是成功还是抛出异常)。
  • getExitValue():获取进程的退出码(Exit Code)。注意:若进程尚未结束,调用此方法会抛出 IllegalStateException。
  • getException():获取执行过程中的异常信息。如果进程正常退出,则返回 null;若因为超时被强杀或抛出 ExecuteException,可由此获取异常。
  • waitFor():阻塞等待异步任务执行结束(无超时限制)。
  • waitFor(long timeoutMillis):限时阻塞等待异步任务。如果在指定毫秒数内未完成,该方法将直接返回,不会抛出超时异常(可配合 hasResult() 检查是否结束)。

6.2、使用示例

1. 基础异步调用(非阻塞 + 状态轮询)

启动命令后主线程可继续处理其他逻辑,随后通过 hasResult() 检查任务状态。

java 复制代码
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecuteResultHandler;
import org.apache.commons.exec.DefaultExecutor;

public class AsyncBasicDemo {
    public static void main(String[] args) throws Exception {
        CommandLine cmd = new CommandLine("ping");
        cmd.addArgument("127.0.0.1");
        cmd.addArgument("-n");
        cmd.addArgument("4"); // 发送 4 个包

        DefaultExecutor executor = new DefaultExecutor();

        // 1. 创建默认异步结果句柄
        DefaultExecuteResultHandler resultHandler = new DefaultExecuteResultHandler();

        // 2. 传入 resultHandler 开启异步执行
        System.out.println("[Main] 启动异步命令执行...");
        executor.execute(cmd, resultHandler);

        // 3. 主线程不被阻塞,可以继续执行其他业务
        System.out.println("[Main] 主线程继续做其他业务处理 logic...");

        // 4. 轮询等待任务完成
        while (!resultHandler.hasResult()) {
            System.out.println("[Main] 异步任务还在运行中,等待 500ms...");
            Thread.sleep(500);
        }

        // 5. 获取最终执行结果
        if (resultHandler.getException() != null) {
            System.err.println("任务执行出错: " + resultHandler.getException().getMessage());
        } else {
            System.out.println("任务执行成功,Exit Code: " + resultHandler.getExitValue());
        }
    }
}

2. 限时等待句柄(waitFor 控制超时)

利用 resultHandler.waitFor(ms) 可以实现类似 Java Future.get(timeout) 的限时等待功能。

java 复制代码
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecuteResultHandler;
import org.apache.commons.exec.DefaultExecutor;
import org.apache.commons.exec.ExecuteWatchdog;

public class AsyncWaitForDemo {
    public static void main(String[] args) throws Exception {
        CommandLine cmd = new CommandLine("ping");
        cmd.addArgument("127.0.0.1");
        cmd.addArgument("-n");
        cmd.addArgument("10"); // 执行时间较长的任务

        DefaultExecutor executor = new DefaultExecutor();
        
        // 配置 Watchdog 防卡死
        ExecuteWatchdog watchdog = new ExecuteWatchdog(10000);
        executor.setWatchdog(watchdog);

        DefaultExecuteResultHandler resultHandler = new DefaultExecuteResultHandler();

        System.out.println("提交异步任务...");
        executor.execute(cmd, resultHandler);

        // 主线程等待最多 3 秒
        System.out.println("主线程同步等待任务,最多等待 3000ms...");
        resultHandler.waitFor(3000);

        // 检查 3 秒后任务是否结束
        if (resultHandler.hasResult()) {
            System.out.println("任务已在 3 秒内完成,退出码: " + resultHandler.getExitValue());
        } else {
            System.out.println("任务在 3 秒内未完成,主线程不再等待,放至后台继续运行...");
        }
    }
}

3. 实现 ExecuteResultHandler 自定义回调函数

如果希望在命令完成(成功或失败)时由系统主动触发事件(类似于事件驱动/响应式编程),可以自行实现 ExecuteResultHandler 接口。

java 复制代码
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecutor;
import org.apache.commons.exec.ExecuteException;
import org.apache.commons.exec.ExecuteResultHandler;

public class CustomAsyncHandlerDemo {

    // 自定义异步结果回调处理器
    static class MyExecutionCallback implements ExecuteResultHandler {
        private final String taskName;

        public MyExecutionCallback(String taskName) {
            this.taskName = taskName;
        }

        @Override
        public void onProcessComplete(int exitValue) {
            // 进程成功完成时的回调
            System.out.println("【异步回调】任务 [" + taskName + "] 执行成功,退出码: " + exitValue);
            // 这里可以触发后续业务逻辑,如更新数据库状态、发送完成通知等
        }

        @Override
        public void onProcessFailed(ExecuteException e) {
            // 进程执行失败或抛出异常时的回调
            System.err.println("【异步回调】任务 [" + taskName + "] 执行失败,异常原因: " + e.getMessage());
            // 这里可以触发失败重试或告警通知
        }
    }

    public static void main(String[] args) throws Exception {
        CommandLine cmd = new CommandLine("ping");
        cmd.addArgument("127.0.0.1");
        cmd.addArgument("-n");
        cmd.addArgument("3");

        DefaultExecutor executor = new DefaultExecutor();

        System.out.println("提交带自定义回调的异步任务...");
        executor.execute(cmd, new MyExecutionCallback("PingTask-01"));

        // 保持主线程存活以观察回调函数的触发
        Thread.sleep(4000);
        System.out.println("主程序运行结束。");
    }
}

6.3、最佳实践

  • 避免早拆包(未完成即获取结果):在没有调用 waitFor() 或 hasResult() 确认任务结束前,切勿调用 resultHandler.getExitValue(),否则会抛出 IllegalStateException。
  • 配合看门狗使用:异步执行同样可能发生卡死。务必为 DefaultExecutor 配置 ExecuteWatchdog;当任务超时被杀掉时,异步句柄会捕获到异常并触发 onProcessFailed。
  • 回调函数中的异常处理:在自定义实现 ExecuteResultHandler.onProcessComplete/onProcessFailed 时,回调内部的代码应当做好 try-catch 保护,防止回调逻辑崩溃影响 commons-exec 内部线程。

7、退出码校验

在 commons-exec 中,退出码(Exit Value / Exit Code)是外部进程执行完毕后返回给操作系统的整数状态码。默认情况下,绝大多数操作系统和 CLI 工具以 0 表示成功,非 0 表示失败。

commons-exec 会在进程结束后自动校验退出码。如果返回的退出码不在预设的合法退出码列表中,执行器就会抛出 ExecuteException。

类 / 接口 角色与职责 典型场景
DefaultExecutor 核心执行器 提供设置和校验合法退出码的核心 API
ExecuteException 退出码异常 当进程返回非预期退出码时抛出,可从中提取实际退出码

7.1、核心API

DefaultExecutor 提供了两个核心方法用于配置允许的退出码:

  • setExitValue(int value):设置单一合法退出码。默认值为 0。
  • setExitValues(int[] values):设置多个合法退出码数组。例如某些工具将 0(完全成功)和 1(部分成功/警告)都视为正常执行,此时需配置多个合法值。
  • isFailure(int exitValue):执行器内部用于校验传入的退出码是否属于失败状态(不在预设的合法列表中)。

7.2、使用示例

1. 默认退出码校验(默认仅 0 为成功)

如果不进行任何配置,DefaultExecutor 默认只接受 0。一旦程序返回非 0 值,直接抛出 ExecuteException。

java 复制代码
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecutor;
import org.apache.commons.exec.ExecuteException;

public class ExitCodeDefaultDemo {
    public static void main(String[] args) {
        // 构建一个故意执行失败的命令(比如 ping 一个无效参数)
        CommandLine cmd = new CommandLine("ping");
        cmd.addArgument("invalid_option_xyz");

        DefaultExecutor executor = new DefaultExecutor(); // 默认 setExitValue(0)

        try {
            executor.execute(cmd);
            System.out.println("命令执行成功!");
        } catch (ExecuteException e) {
            // 捕获退出码异常
            int exitCode = e.getExitValue();
            System.err.println("命令执行失败,实际返回退出码: " + exitCode);
            System.err.println("异常信息: " + e.getMessage());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

2. 多合法退出码配置 (setExitValues)

在实际业务中,许多系统工具的非 0 返回值并不代表程序崩溃。例如:

  • Robocopy (Windows 复制工具):0 表示没有文件被复制,1 表示成功复制了文件,两者都是正常状态。
  • Grep 命令:0 表示找到匹配项,1 表示未找到匹配项(并非程序出错)。

针对这种情况,需要使用 setExitValues 配置多个合法退出码:

java 复制代码
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecutor;

public class ExitCodeMultipleDemo {
    public static void main(String[] args) {
        CommandLine cmd = new CommandLine("findstr");
        cmd.addArgument("target_text");
        cmd.addArgument("test.txt");

        DefaultExecutor executor = new DefaultExecutor();

        // 配置 0 (找到匹配) 和 1 (未找到匹配) 均为合法退出码,不会抛出 ExecuteException
        executor.setExitValues(new int[]{0, 1});

        try {
            int exitCode = executor.execute(cmd);
            
            if (exitCode == 0) {
                System.out.println("成功找到了匹配的文本!");
            } else if (exitCode == 1) {
                System.out.println("程序正常运行完成,但未找到匹配文本。");
            }
        } catch (Exception e) {
            System.err.println("真正发生了系统或执行异常: " + e.getMessage());
        }
    }
}

3. 忽略退出码校验(完全自定义业务逻辑)

如果不想让 commons-exec 抛出 ExecuteException,而是希望任何退出码都正常返回,由 Java 代码自己通过 if-else 来处理后续逻辑,可以通过配置 null 或把 setExitValues 覆盖为空来实现。

java 复制代码
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecutor;

public class ExitCodeIgnoreDemo {
    public static void main(String[] args) {
        CommandLine cmd = new CommandLine("ping");
        cmd.addArgument("127.0.0.1");

        DefaultExecutor executor = new DefaultExecutor();

        // 传 null 表示不进行任何退出码校验,避免抛出 ExecuteException
        executor.setExitValues(null);

        try {
            int exitCode = executor.execute(cmd);
            System.out.println("命令执行完毕,手动捕获退出码: " + exitCode);

            // 自定义业务逻辑判断
            switch (exitCode) {
                case 0:
                    System.out.println("状态: 成功");
                    break;
                case 1:
                    System.out.println("状态: 警告/部分成功");
                    break;
                default:
                    System.out.println("状态: 未知错误 code=" + exitCode);
                    break;
            }
        } catch (Exception e) {
            System.err.println("进程启动失败或发生了 IO 异常: " + e.getMessage());
        }
    }
}

7.3、最佳实现

  • 先查目标 CLI 工具文档:在使用 commons-exec 调用第三方二进制文件或 Shell 脚本前,务必查阅该命令的 Return Codes 说明,防止因非 0 的常规返回码导致 Java 业务抛出异常终止。
  • 区分 ExecuteException 与其他 IO 异常:
    • ExecuteException:说明进程成功启动并运行完毕,但其返回的退出码不在预期的合法列表中。
    • IOException:说明进程压根未能启动(如可执行程序路径不存在、权限不足等)。
相关推荐
asaotomo1 小时前
从抓包插件到浏览器安全 Agent:Hx0 鹰眼 v1.0.6,正式接入 MCP
安全·渗透测试·agent·浏览器插件·ai工具·mcp
辰烨chenye1 小时前
LeetCode Hot 100 题解 · 堆篇
java·算法·leetcode
shehuiyuelaiyuehao2 小时前
算法29,前缀和,除自身以外的数组的乘积
java·数据结构·算法
小刘在重生~2 小时前
Java常用类|String类详解 + BigDecimal精准计算(含面试题)
java·开发语言·python
随遇而安zx2 小时前
【多线程】---JMM 与 volatile 深度解析
java·多线程
邪修king3 小时前
Re:Linux系统篇(十九):进程篇(八): 进程等待详解:wait/waitpid,僵尸进程到底该如何回收
java·开发语言
啵啵啵鱼3 小时前
DS-顺序表
java·数据结构·笔记·后端·链表·obsidian
TinyMemory3 小时前
Java 面向对象核心入门(七):方法重载 Overload,同一个方法名能干不一样的活
java·面向对象·overload·方法重载
吴声子夜歌4 小时前
Java扩展——OkHttp
java·开发语言·okhttp