1. 概述

在这篇文章中,我们将学习如何从Java应用程序执行shell命令

首先,我们将使用Runtime类提供的*exec()*方法。然后,我们将学习ProcessBuilder,它提供了更多的自定义选项。

2. 操作系统依赖性

shell命令是依赖于操作系统的,因为它们在不同系统上的行为会有所不同。因此,在创建任何运行shell命令的Process之前,我们需要知道JVM运行的操作系统。

此外,在Windows上,shell通常被称为cmd.exe。而在Linux和macOS上,shell命令是通过/bin/sh来运行的。为了在这些不同的机器上保持兼容,我们可以程序性地在Windows机器上添加cmd.exe,否则添加/bin/*sh。例如,我们可以通过读取System类的"os.name"属性来判断代码运行的机器是否为Windows:

boolean isWindows = System.getProperty("os.name")
  .toLowerCase().startsWith("windows");

3. 输入和输出

通常,我们需要连接进程的输入和输出流。具体来说,InputStream作为标准输入,OutputStream作为标准输出。我们必须始终消费输出流,否则进程将不会返回并永远挂起。

让我们实现一个常用的类StreamGobbler,它消费InputStream

private static class StreamGobbler implements Runnable {
    private InputStream inputStream;
    private Consumer<String> consumer;

    public StreamGobbler(InputStream inputStream, Consumer<String> consumer) {
        this.inputStream = inputStream;
        this.consumer = consumer;
    }

    @Override
    public void run() {
        new BufferedReader(new InputStreamReader(inputStream)).lines()
          .forEach(consumer);
    }
}

这个类实现了Runnable接口,这意味着任何Executor都可以执行它。

4. Runtime.exec()

接下来,我们将使用.exec()方法启动一个新的进程,并使用之前创建的StreamGobbler

例如,我们可以列出用户主目录中的所有子目录,然后将其打印到控制台:

Process process;
if (isWindows) {
    process = Runtime.getRuntime()
      .exec(String.format("cmd.exe /c dir %s", homeDirectory));
} else {
    process = Runtime.getRuntime()
      .exec(String.format("/bin/sh -c ls %s", homeDirectory));
}
StreamGobbler streamGobbler = 
  new StreamGobbler(process.getInputStream(), System.out::println);
Future<?> future = executorService.submit(streamGobbler);

int exitCode = process.waitFor();

assertDoesNotThrow(() -> future.get(10, TimeUnit.SECONDS));
assertEquals(0, exitCode);

这里,我们使用.newSingleThreadExecutor()创建了一个新的子进程,然后使用.submit()运行包含shell命令的Process。此外,.submit()返回一个*Future*对象,我们可以利用它来检查进程的结果。同时,确保调用返回对象的.get()方法来等待计算完成。如果你是从main方法中运行上述代码,请确保对executorService对象调用shutdown,否则代码将永远不会停止。这同样适用于下面的所有示例。在我们的代码中,我们使用JUnit生命周期方法来进行必要的清理,如那样做。

注意:JDK 18已弃用Runtime类的*.exec(String command)方法。

4.1. 处理管道

目前,无法使用.exec()处理管道。幸运的是,管道是shell特性的一部分。因此,我们可以在需要使用管道的地方创建整个命令,然后传递给.exec()

if (IS_WINDOWS) {
    process = Runtime.getRuntime()
        .exec(String.format("cmd.exe /c dir %s | findstr \"Desktop\"", homeDirectory));
} else {
    process = Runtime.getRuntime()
        .exec(String.format("/bin/sh -c ls %s | grep \"Desktop\"", homeDirectory));
}

在这里,我们列出了用户主目录中的所有目录,并搜索“Desktop”文件夹。

5. ProcessBuilder

另一种选择是使用*ProcessBuilder*,它比Runtime方法更受青睐,因为我们可以定制它,而不仅仅是运行一个字符串命令

简而言之,通过这种方法,我们可以:

  • 通过.directory()更改shell命令运行的工作目录
  • 通过提供键值对映射给.environment()来更改环境变量
  • 以自定义方式重定向输入和输出流
  • 将它们都继承给当前JVM进程的流,使用.inheritIO()

同样,我们可以运行与前一个示例相同的shell命令:

ProcessBuilder builder = new ProcessBuilder();
if (isWindows) {
    builder.command("cmd.exe", "/c", "dir");
} else {
    builder.command("sh", "-c", "ls");
}
builder.directory(new File(System.getProperty("user.home")));
Process process = builder.start();
StreamGobbler streamGobbler = 
  new StreamGobbler(process.getInputStream(), System.out::println);
Future<?> future = executorService.submit(streamGobbler);

int exitCode = process.waitFor();

assertDoesNotThrow(() -> future.get(10, TimeUnit.SECONDS));
assertEquals(0, exitCode); 

5.1. 处理管道

Java 9引入了ProcessBuilder API中的管道概念:

public static List<Process> startPipeline(List<ProcessBuilder> builders) throws IOException

使用*startPipeline*方法,我们可以传递一个ProcessBuilder对象列表。此静态方法将为每个ProcessBuilder启动一个Process。这样就创建了一个由标准输入和输出流链接的进程链。

例如,我们可以为每个独立的命令创建一个进程构建器,并将它们组合成管道:

@Test
public void givenProcessBuilder_whenStartingPipeline_thenSuccess()
  throws IOException, InterruptedException {
    List<ProcessBuilder> builders = Arrays.asList(
      new ProcessBuilder("find", "src", "-name", "*.java", "-type", "f"), 
      new ProcessBuilder("wc", "-l"));

    List<Process> processes = ProcessBuilder.startPipeline(builders);
    Process last = processes.get(processes.size() - 1);

    List<String> output = readOutput(last.getInputStream());
    assertThat("Results should not be empty", output, is(not(empty())));
}

在上述示例中,我们在src目录中搜索所有.java文件,并将结果管道到另一个进程进行计数。

要了解Java 9对Process API的其他改进,请查看我们的文章《Java 9 Process API改进》:链接

6. 总结

正如我们在快速教程中看到的,我们可以通过两种不同的方式在Java中执行shell命令。

一般来说,如果我们计划定制新启动进程的执行,例如改变其工作目录,应该考虑使用ProcessBuilder

如往常一样,源代码可以在GitHub上找到:链接