Java执行shell命令工具类封装 | 字数总计: 2.3k | 阅读时长: 9分钟 | 阅读量: |
背景 最近工作中遇到了需要用Java
来执行shell
命令的场景,刚开始参考网上的一些实现写了一个简单的工具类,项目使用的是Spring Boot
框架,部署运行在k8s
容器中,在测试时偶现容器内程序卡死的情况,本地测试时又一切正常。参考了几篇博文后了解到问题所在,于是进行改进优化,封装出了一个较为通用的工具类,遂在此记录之。
Java
执行shell
命令的API
Runtime.getRuntime().exec(command)
实现Runtime.getRuntime()
可以获得当前虚拟机的运行时环境,其实现是典型的饿汉式单例模式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public class Runtime { private static Runtime currentRuntime = new Runtime (); public static Runtime getRuntime () { return currentRuntime; } private Runtime () {} ...... }
而exec
方法是执行指定的字符串命令,包含多个重载方法,可以指定环境变量和工作目录。
这里我以一个curl
命令为例,简单写法如下:
1 2 3 4 5 6 public static void main (String[] args) throws IOException, InterruptedException { Process process = Runtime.getRuntime().exec("curl https://lilu.org.cn/" ); int waitFor = process.waitFor(); process.destroy(); System.out.println("process退出值:" + waitFor); }
查看exec
及其重载的方法可知,其内部是利用ProcessBuilder
类运作的:
1 2 3 4 5 6 7 public Process exec (String[] cmdarray, String[] envp, File dir) throws IOException { return new ProcessBuilder (cmdarray) .environment(envp) .directory(dir) .start(); }
所以接下来我们重点看下ProcessBuilder
的实现。
ProcessBuilder
实现从类名就可以看出,该类使用了建造者模式,可不依赖Runtime
类单独使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public static void main (String[] args) throws IOException, InterruptedException { ProcessBuilder builder = new ProcessBuilder ("/bin/sh" , "-c" , "curl https://lilu.org.cn/" ); Map<String, String> env = new HashMap <>(); env.put("env" , "dev" ); builder.environment().putAll(env); builder.directory(new File ("/Users/sunchaser/workspace/" )); Process process = builder.start(); int waitFor = process.waitFor(); process.destroy(); System.out.println("process退出值:" + waitFor); }
以上就是Java
执行shell
命令的API
的使用方式,存在的一个问题是无法获取命令执行后的输出。
获取shell
命令的输出 我们知道,在cmd
或终端中执行shell
命令后,或多或少会有一些字符输出在终端窗口上,例如curl https://lilu.org.cn/
命令会输出获取到的html
字符,那在java
中如何获取这些输出内容呢?
我们分析下ProcessBuilder#start()
方法,发现其调用了ProcessImpl.start
方法返回一个Process
类对象,而ProcessImpl.start
方法中用new
关键字创建了一个UNIXProcess
对象。UNIXProcess
类是Process
的一个子类,在其构造器中调用了native
方法forkAndExec
,其底层是fork
了一个子线程来执行命令,而shell
命令的输出被分为两个标准输入流存储在缓冲区中,想获得输出只需读取缓冲区中对应的流即可。
代码实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 package com.sunchaser.sparrow.javase.shell;import lombok.extern.slf4j.Slf4j;import java.io.*;import java.util.ArrayList;import java.util.HashMap;import java.util.List;import java.util.Map;@Slf4j public class ProcessBuilderTest { public static void main (String[] args) throws IOException, InterruptedException { ProcessBuilder builder = new ProcessBuilder ("/bin/sh" , "-c" , "curl https://lilu.org.cn/" ); Map<String, String> env = new HashMap <>(); env.put("env" , "dev" ); builder.environment().putAll(env); builder.directory(new File ("/Users/sunchaser/workspace/" )); Process process = builder.start(); List<String> execInfoList = new ArrayList <>(); List<String> execErrList = new ArrayList <>(); collectStreamInfo(process.getInputStream(), execInfoList); collectStreamInfo(process.getErrorStream(), execErrList); int waitFor = process.waitFor(); process.destroy(); System.out.println("process退出值:" + waitFor); } private static void collectStreamInfo (InputStream is, List<String> collector) throws IOException { BufferedReader br = new BufferedReader (new InputStreamReader (is)); String line; while ((line = br.readLine()) != null ) { log.info("CommandExecutor collectStreamInfo: " + line); collector.add(line); } } }
以上代码就是我最初在业务代码中的核心实现,部署在容器环境中时会偶现应用卡死的情况。
卡死原因简单分析:首先k8s
分配的资源是1c1g
,fork
出的子线程执行命令后不断地向缓冲区写入字符,而主线程中同步读取缓冲区中的字符,由于是单核CPU
,底层操作系统是流水线作业,如果恰好主线程将缓冲区中的字符读取完(子线程执行命令并未完成),按代码逻辑顺序,主线程会调用waitFor()
方法阻塞挂起,而此时CPU
切换回子线程继续执行命令向缓冲区写字符,当缓冲区写满的时候,子线程无法继续写,等待主线程读取缓冲区,而主线程此时在挂起等待子线程执行结束。于是,子线程等待主线程清理缓冲区,主线程等待子线程执行完毕,两个进程相互等待,导致死锁。
可以在start
之前调用ProcessBuilder#redirectErrorStream(true)
方法让shell
命令的两个输出合并成一个标准输出流,即合并成一个缓冲区,以便进行读取。
优雅封装Shell
命令执行器ShellCommandExecutor
由于读取缓冲区字符不是执行shell
命令的必要步骤,只是为了查看执行的过程及结果,所以我们可以使其异步化。然而即使异步化缓冲区的读取,单核CPU
极端情况下还是会出现上述死锁问题,为了以防万一,还要设置命令执行的超时时间。
封装待执行的shell
命令 shell
命令可指定环境变量和工作目录,经过上面分析我们还要加上一个超时时间,所以Command
类封装如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @Data public static class Command { private String[] command; private Map<String, String> environment; private File dir; private Long timeOutInterval; }
封装shell
命令的执行结果 一方面要收集缓冲区中的字符,另一方面要记录waitFor
方法返回的状态码,同时还要提供执行是否成功的判断方法及打印缓冲区内容的方法。所以Result
类封装如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 @EqualsAndHashCode(callSuper = true) @Data public static class Result extends Command { private final StringBuilder execInfoBuilder = new StringBuilder (); private Integer exitCode; public String status () { return exitCode == 0 ? SUCCESS.toString() : WARN.toString(); } public void print () { log.info("command: {}, exitCode: {}, timeout={}ms, execute result:\n{}" , Arrays.toString(this .getCommand()), exitCode, this .getTimeOutInterval(), this .getExecInfoBuilder().toString()); } } public enum Status { SUCCESS, WARN, ; }
封装Shell
命令执行器核心执行方法 主要是利用Future#get
方法让线程池中的任务具有超时时间,核心代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 @Slf4j public class ShellCommandExecutor { private static final ExecutorService SHELL_COMMAND_EXECUTOR; static { int cpu = Runtime.getRuntime().availableProcessors(); log.info("Runtime.getRuntime().availableProcessors() = {}" , cpu); SHELL_COMMAND_EXECUTOR = Executors.newFixedThreadPool( cpu - 1 , new ThreadFactory () { private final ThreadFactory defaultFactory = Executors.defaultThreadFactory(); private final AtomicInteger threadNumber = new AtomicInteger (1 ); @Override public Thread newThread (@Nonnull Runnable r) { Thread thread = this .defaultFactory.newThread(r); if (!thread.isDaemon()) { thread.setDaemon(true ); } thread.setName("shell-command-" + this .threadNumber.getAndIncrement()); return thread; } } ); } private static final String LINE_SEPARATOR = "line.separator" ; private final Command command; private final Result result; public Result execute () { Future<Result> future = SHELL_COMMAND_EXECUTOR.submit(() -> { InputStream is = null ; Process process = null ; try { process = prepareProcessBuilder().start(); is = process.getInputStream(); StringBuilder execInfoBuilder = result.getExecInfoBuilder(); InputStreamReader isr = new InputStreamReader (is); BufferedReader br = new BufferedReader (isr); String line; while ((line = br.readLine()) != null ) { execInfoBuilder.append(line); execInfoBuilder.append(System.getProperty(LINE_SEPARATOR)); } int exitCode = process.waitFor(); result.exitCode = exitCode; if (exitCode == 0 ) { if (log.isDebugEnabled()) { log.debug("ShellCommandExecutor执行shell命令的子线程正常执行完成结束. exitCode == 0" ); } } else { if (log.isDebugEnabled()) { log.debug("ShellCommandExecutor执行shell命令的子线程执行失败异常结束. exitCode == {}" , exitCode); } } result.print(); } catch (Exception e) { log.error("[ShellCommandExecutor] execute error" , e); throw new ShellCommandExecutorException (e); } finally { IoUtil.close(is); if (Objects.nonNull(process)) { process.destroy(); } } return result; }); Long timeOutInterval = command.getTimeOutInterval(); if (timeOutInterval > 0 ) { try { future.get(timeOutInterval, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { log.error("[ShellCommandExecutor] future get timeout" , e); future.cancel(true ); } catch (Exception e) { log.error("[ShellCommandExecutor] future get error" , e); } } return result; } private ProcessBuilder prepareProcessBuilder () { ProcessBuilder builder = new ProcessBuilder (command.getCommand()).redirectErrorStream(true ); Map<String, String> environment = command.getEnvironment(); File dir = command.getDir(); if (Objects.nonNull(environment)) { builder.environment().putAll(environment); } if (Objects.nonNull(dir)) { builder.directory(dir); } return builder; } }
提供静态方法对外使用 核心封装基本介绍完毕,接下来只需提供静态方法对外使用。
主要代码如下:
1 2 3 public static Result execute (String[] commands, File dir, Map<String, String> env, Long timeout) { return new ShellCommandExecutor (commands, dir, env, timeout).execute(); }
完整代码可查看 Github地址
使用示例 下面以执行npm install
命令为例,使用我们封装好的工具类,代码如下:
1 2 3 4 5 6 7 8 9 public static void main (String[] args) { List<String> commands = Lists.newArrayList( "acorn@^8.6.0" , "antd@^4.16.13" ); for (String command : commands) { ShellCommandExecutors.execute("npm install " + command); } }
参考