java 执行shell命令及日志收集避坑指南

等你归去来 2020-11-08 14:33:58
java shell 日志 命令 执行


有时候我们需要调用系统命令执行一些东西,可能是为了方便,也可能是没有办法必须要调用。涉及执行系统命令的东西,则就不能做跨平台了,这和java语言的初衷是相背的。

废话不多说,java如何执行shell命令?自然是调用java语言类库提供的接口API了。

 

1. java执行shell的api

执行shell命令,可以说系统级的调用,编程语言自然必定会提供相应api操作了。在java中,有两个api供调用:Runtime.exec(), Process API. 简单使用如下:

1.1. Runtime.exec() 实现

调用实现如下:

import java.io.InputStream;
public class RuntimeExecTest {
@Test
public static void testRuntimeExec() {
try {
Process process = Runtime.getRuntime()
.exec("cmd.exe /c dir");
process.waitFor();
}
catch (Exception e) {
e.printStackTrace();
}
}
}

简单的说就是只有一行调用即可:Runtime.getRuntime().exec("cmd.exe /c dir") ; 看起来非常简洁。

 

1.2. ProcessBuilder 实现

使用ProcessBuilder需要自己操作更多东西,也因此可以自主设置更多东西。(但实际上底层与Runtime是一样的了),用例如下:

public class ProcessBuilderTest {
@Test
public void testProcessBuilder() {
ProcessBuilder processBuilder = new ProcessBuilder();
processBuilder.command("ipconfig");
//将标准输入流和错误输入流合并,通过标准输入流读取信息
processBuilder.redirectErrorStream(true);
try {
//启动进程
Process start = processBuilder.start();
//获取输入流
InputStream inputStream = start.getInputStream();
//转成字符输入流
InputStreamReader inputStreamReader = new InputStreamReader(inputStream, "gbk");
int len = -1;
char[] c = new char[1024];
StringBuffer outputString = new StringBuffer();
//读取进程输入流中的内容
while ((len = inputStreamReader.read(c)) != -1) {
String s = new String(c, 0, len);
outputString.append(s);
System.out.print(s);
}
inputStream.close();
}
catch (IOException e) {
e.printStackTrace();
}
}
}

看起来是要麻烦些,但实际上是差不多的,只是上一个用例没有处理输出日志而已。但总体来说的 ProcessBuilder 的可控性更强,所以一般使用这个会更自由些。

以下Runtime.exec()的实现:

 // java.lang.Runtime#exec
public Process exec(String[] cmdarray, String[] envp, File dir)
throws IOException {
// 仅为 ProcessBuilder 的一个封装
return new ProcessBuilder(cmdarray)
.environment(envp)
.directory(dir)
.start();
}

 

2. 调用shell思考事项

从上面来看,要调用系统命令,并非难事。那是否就意味着我们可以随便调用现成方案进行处理工作呢?当然不是,我们应当要考虑几个问题?

1. 调用系统命令是进程级别的调用;
进程与线程的差别大家懂的,更加重量级,开销更大。在java中,我们更多的是使用多线程进行并发。但如果用于系统调用,那就是进程级并发了,而且外部进程不再受jvm控制,出了问题也就不好玩了。所以,不要随便调用系统命令是个不错的实践。
2. 调用系统命令是硬件相关的调用;
java语言的思想是一次编写,到处使用。但如果你使用的系统调用,则不好处理了,因为每个系统支持的命令并非完全一样的,你的代码也就会因环境的不一样而表现不一致了。健壮性就下来了,所以,少用为好。
3. 内存是否够用?
一般我们jvm作为一个独立进程运行,会被分配足够多的内存,以保证运行的顺畅与高效。这时,可能留给系统的空间就不会太多了,而此时再调用系统进程运行业务,则得提前预估下咯。
4. 进程何时停止?
当我调起一个系统进程之后,我们后续如何操作?比如是异步调用的话,可能就忽略掉结果了。而如果是同步调用的话,则当前线程必须等待进程退出,这样会让我们的业务大大简单化了。因为异步需要考虑的事情往往很多。
5. 如何获取进程日志信息?
一个shell进程的调用,可能是一个比较耗时的操作,此时应该是只要任何进度,就应该汇报出来,从而避免外部看起来一直没有响应,从而无法判定是死掉了还是在运行中。而外部进程的通信,又不像一个普通io的调用,直接输出结果信息。这往往需要我们通过两个输出流进行捕获。而如何读取这两个输出流数据,就成了我们获取日志信息的关键了。ProcessBuilder 是使用inputStream 和 errStream 来表示两个输出流, 分别对应操作系统的标准输出流和错误输出流。但这两个流都是阻塞io流,如果处理不当,则会引起系统假死的风险。
6. 进程的异常如何捕获?
在jvm线程里产生的异常,可以很方便的直接使用try...catch... 捕获,而shell调用的异常呢?它实际上并不能直接抛出异常,我们可以通过进程的返回码来判定是否发生了异常,这些错误码一般会遵循操作系统的错误定义规范,但时如果是我们自己写的shell或者其他同学写的shell就无法保证了。所以,往往除了我们要捕获错误之外,至少要规定0为正确的返回码。其他错误码也尽量不要乱用。其次,我们还应该在发生错误时,能从错误输出流信息中,获取到些许的蛛丝马迹,以便我们可以快速排错。

 

以上问题,如果都能处理得当,那么我认为,这个调用就是安全的。反之则是有风险的。

不过,问题看着虽然多,但都是些细化的东西,也无需太在意。基本上,我们通过线程池来控制进程的膨胀问题;通过读取io流来解决异常信息问题;通过调用类型规划内存及用量问题;

 

3. 完整的shell调用参考

说了这么多理论,还不如来点实际。don't bb, show me the code! 

import com.my.mvc.app.common.exception.ShellProcessExecException;
import com.my.mvc.app.common.helper.NamedThreadFactory;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.io.FileUtils;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 功能描述: Shell命令运行工具类封装
*
*/
@Log4j2
public class ShellCommandExecUtil {
/**
* @see #runShellCommandSync(String, String[], Charset, String)
*/
public static int runShellCommandSync(String baseShellDir, String[] cmd,
Charset outputCharset) throws IOException {
return runShellCommandSync(baseShellDir, cmd, outputCharset, null);
}
/**
* 真正运行shell命令
*
* @param baseShellDir 运行命令所在目录(先切换到该目录后再运行命令)
* @param cmd 命令数组
* @param outputCharset 日志输出字符集,一般windows为GBK, linux为utf8
* @param logFilePath 日志输出文件路径, 为空则直接输出到当前应用日志中,否则写入该文件
* @return 进程退出码, 0: 成功, 其他:失败
* @throws IOException 执行异常时抛出
*/
public static int runShellCommandSync(String baseShellDir, String[] cmd,
Charset outputCharset, String logFilePath)
throws IOException {
long startTime = System.currentTimeMillis();
boolean needReadProcessOutLogStreamByHand = true;
log.info("【cli】receive new Command. baseDir: {}, cmd: {}, logFile:{}",
baseShellDir, String.join(" ", cmd), logFilePath);
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(new File(baseShellDir));
initErrorLogHolder(logFilePath, outputCharset);
int exitCode = 0;
try {
if(logFilePath != null) {
ensureFilePathExists(logFilePath);
// String redirectLogInfoAndErrCmd = " > " + logFilePath + " 2>&1 ";
// cmd = mergeTwoArr(cmd, redirectLogInfoAndErrCmd.split("\\s+"));
pb.redirectErrorStream(true);
pb.redirectOutput(new File(logFilePath));
needReadProcessOutLogStreamByHand = false;
}
Process p = pb.start();
if(needReadProcessOutLogStreamByHand) {
readProcessOutLogStream(p, outputCharset);
}
try {
p.waitFor();
}
catch (InterruptedException e) {
log.error("进程被中断", e);
setProcessLastError("中断异常:" + e.getMessage());
}
finally {
exitCode = p.exitValue();
log.info("【cli】process costTime:{}ms, exitCode:{}",
System.currentTimeMillis() - startTime, exitCode);
}
if(exitCode != 0) {
throw new ShellProcessExecException(exitCode,
"进程返回异常信息, returnCode:" + exitCode
+ ", lastError:" + getProcessLastError());
}
return exitCode;
}
finally {
removeErrorLogHolder();
}
}
/**
* 使用 Runtime.exec() 运行shell
*/
public static int runShellWithRuntime(String baseShellDir,
String[] cmd,
Charset outputCharset) throws IOException {
long startTime = System.currentTimeMillis();
initErrorLogHolder(null, outputCharset);
Process p = Runtime.getRuntime().exec(cmd, null, new File(baseShellDir));
readProcessOutLogStream(p, outputCharset);
int exitCode;
try {
p.waitFor();
}
catch (InterruptedException e) {
log.error("进程被中断", e);
setProcessLastError("中断异常:" + e.getMessage());
}
catch (Throwable e) {
log.error("其他异常", e);
setProcessLastError(e.getMessage());
}
finally {
exitCode = p.exitValue();
log.info("【cli】process costTime:{}ms, exitCode:{}",
System.currentTimeMillis() - startTime, exitCode);
}
if(exitCode != 0) {
throw new ShellProcessExecException(exitCode,
"进程返回异常信息, returnCode:" + exitCode
+ ", lastError:" + getProcessLastError());
}
return exitCode;
}
/**
* 确保文件夹存在
*
* @param filePath 文件路径
* @throws IOException 创建文件夹异常抛出
*/
public static void ensureFilePathExists(String filePath) throws IOException {
File path = new File(filePath);
if(path.exists()) {
return;
}
File p = path.getParentFile();
if(p.mkdirs()) {
log.info("为文件创建目录: {} 成功", p.getPath());
return;
}
log.warn("创建目录:{} 失败", p.getPath());
}
/**
* 合并两个数组数据
*
* @param arrFirst 左边数组
* @param arrAppend 要添加的数组
* @return 合并后的数组
*/
public static String[] mergeTwoArr(String[] arrFirst, String[] arrAppend) {
String[] merged = new String[arrFirst.length + arrAppend.length];
System.arraycopy(arrFirst, 0,
merged, 0, arrFirst.length);
System.arraycopy(arrAppend, 0,
merged, arrFirst.length, arrAppend.length);
return merged;
}
/**
* 删除以某字符结尾的字符
*
* @param originalStr 原始字符
* @param toTrimChar 要检测的字
* @return 裁剪后的字符串
*/
public static String trimEndsWith(String originalStr, char toTrimChar) {
char[] value = originalStr.toCharArray();
int i = value.length - 1;
while (i > 0 && value[i] == toTrimChar) {
i--;
}
return new String(value, 0, i + 1);
}
/**
* 错误日志读取线程池(不设上限)
*/
private static final ExecutorService errReadThreadPool = Executors.newCachedThreadPool(
new NamedThreadFactory("ReadProcessErrOut"));
/**
* 最后一次异常信息
*/
private static final Map<Thread, ProcessErrorLogDescriptor>
lastErrorHolder = new ConcurrentHashMap<>();
/**
* 主动读取进程的标准输出信息日志
*
* @param process 进程实体
* @param outputCharset 日志字符集
* @throws IOException 读取异常时抛出
*/
private static void readProcessOutLogStream(Process process,
Charset outputCharset) throws IOException {
try (BufferedReader stdInput = new BufferedReader(new InputStreamReader(
process.getInputStream(), outputCharset))) {
Thread parentThread = Thread.currentThread();
// 另起一个线程读取错误消息,必须先启该线程
errReadThreadPool.submit(() -> {
try {
try (BufferedReader stdError = new BufferedReader(
new InputStreamReader(process.getErrorStream(), outputCharset))) {
String err;
while ((err = stdError.readLine()) != null) {
log.error("【cli】{}", err);
setProcessLastError(parentThread, err);
}
}
}
catch (IOException e) {
log.error("读取进程错误日志输出时发生了异常", e);
setProcessLastError(parentThread, e.getMessage());
}
});
// 外部线程读取标准输出消息
 String stdOut;
while ((stdOut = stdInput.readLine()) != null) {
log.info("【cli】{}", stdOut);
}
}
}
/**
* 新建一个进程错误信息容器
*
* @param logFilePath 日志文件路径,如无则为 null
*/
private static void initErrorLogHolder(String logFilePath, Charset outputCharset) {
lastErrorHolder.put(Thread.currentThread(),
new ProcessErrorLogDescriptor(logFilePath, outputCharset));
}
/**
* 移除错误日志监听
*/
private static void removeErrorLogHolder() {
lastErrorHolder.remove(Thread.currentThread());
}
/**
* 获取进程的最后错误信息
*
* 注意: 该方法只会在父线程中调用
*/
private static String getProcessLastError() {
Thread thread = Thread.currentThread();
return lastErrorHolder.get(thread).getLastError();
}
/**
* 设置最后一个错误信息描述
*
* 使用当前线程或自定义
*/
private static void setProcessLastError(String lastError) {
lastErrorHolder.get(Thread.currentThread()).setLastError(lastError);
}
private static void setProcessLastError(Thread thread, String lastError) {
lastErrorHolder.get(thread).setLastError(lastError);
}
/**
* 判断当前系统是否是 windows
*/
public static boolean isWindowsSystemOs() {
return System.getProperty("os.name").toLowerCase()
.startsWith("win");
}
/**
* 进程错误信息描述封装类
*/
private static class ProcessErrorLogDescriptor {
/**
* 错误信息记录文件
*/
private String logFile;
/**
* 最后一行错误信息
*/
private String lastError;
private Charset charset;
ProcessErrorLogDescriptor(String logFile, Charset outputCharset) {
this.logFile = logFile;
charset = outputCharset;
}
String getLastError() {
if(lastError != null) {
return lastError;
}
try{
if(logFile == null) {
return null;
}
List<String> lines = FileUtils.readLines(
new File(logFile), charset);
StringBuilder sb = new StringBuilder();
for (int i = lines.size() - 1; i >= 0; i--) {
sb.insert(0, lines.get(i) + "\n");
if(sb.length() > 200) {
break;
}
}
return sb.toString();
}
catch (Exception e) {
log.error("【cli】读取最后一次错误信息失败", e);
}
return null;
}
void setLastError(String err) {
if(lastError == null) {
lastError = err;
return;
}
lastError = lastError + "\n" + err;
if(lastError.length() > 200) {
lastError = lastError.substring(lastError.length() - 200);
}
}
}
}

以上实现,完成了我们在第2点中讨论的几个问题:

1. 主要使用 ProcessBuilder 完成了shell的调用;
2. 支持读取进程的所有输出信息,且在必要的时候,支持使用单独的文件进行接收输出日志;
3. 在进程执行异常时,支持抛出对应异常,且给出一定的errMessage描述;
4. 如果想控制调用进程的数量,则在外部调用时控制即可;
5. 使用两个线程接收两个输出流,避免出现应用假死,使用newCachedThreadPool线程池避免过快创建线程;

接下来,我们进行下单元测试:

public class ShellCommandExecUtilTest {
@Test
public void testRuntimeShell() throws IOException {
int errCode;
errCode = ShellCommandExecUtil.runShellWithRuntime("E:\\tmp",
new String[] {"cmd", "/c", "dir"}, Charset.forName("gbk"));
Assert.assertEquals("进程返回码不正确", 0, errCode);
}
@Test(expected = ShellProcessExecException.class)
public void testRuntimeShellWithErr() throws IOException {
int errCode;
errCode = ShellCommandExecUtil.runShellWithRuntime("E:\\tmp",
new String[] {"cmd", "/c", "dir2"}, Charset.forName("gbk"));
Assert.fail("dir2 应该要执行失败,但却通过了,请查找原因");
}
@Test
public void testProcessShell1() throws IOException {
int errCode;
errCode = ShellCommandExecUtil.runShellCommandSync("/tmp",
new String[]{"cmd", "/c", "dir"}, Charset.forName("gbk"));
Assert.assertEquals("进程返回码不正确", 0, errCode);
String logPath = "/tmp/cmd.log";
errCode = ShellCommandExecUtil.runShellCommandSync("/tmp",
new String[]{"cmd", "/c", "dir"}, Charset.forName("gbk"), logPath);
Assert.assertTrue("结果日志文件不存在", new File(logPath).exists());
}
@Test(expected = ShellProcessExecException.class)
public void testProcessShell1WithErr() throws IOException {
int errCode;
errCode = ShellCommandExecUtil.runShellCommandSync("/tmp",
new String[]{"cmd", "/c", "dir2"}, Charset.forName("gbk"));
Assert.fail("dir2 应该要执行失败,但却通过了,请查找原因");
}
@Test(expected = ShellProcessExecException.class)
public void testProcessShell1WithErr2() throws IOException {
int errCode;
String logPath = "/tmp/cmd2.log";
try {
errCode = ShellCommandExecUtil.runShellCommandSync("/tmp",
new String[]{"cmd", "/c", "dir2"}, Charset.forName("gbk"), logPath);
}
catch (ShellProcessExecException e) {
e.printStackTrace();
throw e;
}
Assert.assertTrue("结果日志文件不存在", new File(logPath).exists());
}
}

至此,我们的一个安全可靠的shell运行功能就搞定了。

 

版权声明
本文为[等你归去来]所创,转载请带上原文链接,感谢
https://www.cnblogs.com/yougewe/p/13935877.html

  1. 【计算机网络 12(1),尚学堂马士兵Java视频教程
  2. 【程序猿历程,史上最全的Java面试题集锦在这里
  3. 【程序猿历程(1),Javaweb视频教程百度云
  4. Notes on MySQL 45 lectures (1-7)
  5. [computer network 12 (1), Shang Xuetang Ma soldier java video tutorial
  6. The most complete collection of Java interview questions in history is here
  7. [process of program ape (1), JavaWeb video tutorial, baidu cloud
  8. Notes on MySQL 45 lectures (1-7)
  9. 精进 Spring Boot 03:Spring Boot 的配置文件和配置管理,以及用三种方式读取配置文件
  10. Refined spring boot 03: spring boot configuration files and configuration management, and reading configuration files in three ways
  11. 精进 Spring Boot 03:Spring Boot 的配置文件和配置管理,以及用三种方式读取配置文件
  12. Refined spring boot 03: spring boot configuration files and configuration management, and reading configuration files in three ways
  13. 【递归,Java传智播客笔记
  14. [recursion, Java intelligence podcast notes
  15. [adhere to painting for 386 days] the beginning of spring of 24 solar terms
  16. K8S系列第八篇(Service、EndPoints以及高可用kubeadm部署)
  17. K8s Series Part 8 (service, endpoints and high availability kubeadm deployment)
  18. 【重识 HTML (3),350道Java面试真题分享
  19. 【重识 HTML (2),Java并发编程必会的多线程你竟然还不会
  20. 【重识 HTML (1),二本Java小菜鸟4面字节跳动被秒成渣渣
  21. [re recognize HTML (3) and share 350 real Java interview questions
  22. [re recognize HTML (2). Multithreading is a must for Java Concurrent Programming. How dare you not
  23. [re recognize HTML (1), two Java rookies' 4-sided bytes beat and become slag in seconds
  24. 造轮子系列之RPC 1:如何从零开始开发RPC框架
  25. RPC 1: how to develop RPC framework from scratch
  26. 造轮子系列之RPC 1:如何从零开始开发RPC框架
  27. RPC 1: how to develop RPC framework from scratch
  28. 一次性捋清楚吧,对乱糟糟的,Spring事务扩展机制
  29. 一文彻底弄懂如何选择抽象类还是接口,连续四年百度Java岗必问面试题
  30. Redis常用命令
  31. 一双拖鞋引发的血案,狂神说Java系列笔记
  32. 一、mysql基础安装
  33. 一位程序员的独白:尽管我一生坎坷,Java框架面试基础
  34. Clear it all at once. For the messy, spring transaction extension mechanism
  35. A thorough understanding of how to choose abstract classes or interfaces, baidu Java post must ask interview questions for four consecutive years
  36. Redis common commands
  37. A pair of slippers triggered the murder, crazy God said java series notes
  38. 1、 MySQL basic installation
  39. Monologue of a programmer: despite my ups and downs in my life, Java framework is the foundation of interview
  40. 【大厂面试】三面三问Spring循环依赖,请一定要把这篇看完(建议收藏)
  41. 一线互联网企业中,springboot入门项目
  42. 一篇文带你入门SSM框架Spring开发,帮你快速拿Offer
  43. 【面试资料】Java全集、微服务、大数据、数据结构与算法、机器学习知识最全总结,283页pdf
  44. 【leetcode刷题】24.数组中重复的数字——Java版
  45. 【leetcode刷题】23.对称二叉树——Java版
  46. 【leetcode刷题】22.二叉树的中序遍历——Java版
  47. 【leetcode刷题】21.三数之和——Java版
  48. 【leetcode刷题】20.最长回文子串——Java版
  49. 【leetcode刷题】19.回文链表——Java版
  50. 【leetcode刷题】18.反转链表——Java版
  51. 【leetcode刷题】17.相交链表——Java&python版
  52. 【leetcode刷题】16.环形链表——Java版
  53. 【leetcode刷题】15.汉明距离——Java版
  54. 【leetcode刷题】14.找到所有数组中消失的数字——Java版
  55. 【leetcode刷题】13.比特位计数——Java版
  56. oracle控制用户权限命令
  57. 三年Java开发,继阿里,鲁班二期Java架构师
  58. Oracle必须要启动的服务
  59. 万字长文!深入剖析HashMap,Java基础笔试题大全带答案
  60. 一问Kafka就心慌?我却凭着这份,图灵学院vip课程百度云