有時候我們需要呼叫系統命令執行一些東西,可能是為了方便,也可能是沒有辦法必須要呼叫。涉及執行系統命令的東西,則就不能做跨平臺了,這和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執行功能就搞定了。