有時候我們需要調用系統指令執行一些東西,可能是為了友善,也可能是沒有辦法必須要調用。涉及執行系統指令的東西,則就不能做跨平台了,這和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運作功能就搞定了。
不要害怕今日的苦,你要相信明天,更苦!