Java执行windows命令过程的源码分析及细节把控(开发“Java命令执行器”前期准备)

1、执行命令的方法

Runtime.exec("cmd",...)方法:详见Java Runtime类源码分析(开发“Java命令执行器”前期准备)_江南煮酒的博客-CSDN博客

ProcessBuilder.command("cmd",...).start():详见Java ProcessBuilder类源码分析(开发“Java命令执行器”前期准备)_江南煮酒的博客-CSDN博客

2、Java执行cmd命令的源码解析(Linux还没看,如果差别很大,会单独再更一篇说明)

首先要明白Runtime.exec()方法有很多重写方法,但是在jdk源码中,exec()方法最终是:

    public Process exec(String[] cmdarray, String[] envp, File dir)
        throws IOException {
        return new ProcessBuilder(cmdarray)
            .environment(envp)
            .directory(dir)
            .start();
    }

对于

exec(String command),

exec(String command, String[] envp),

exec(String command, String[] envp, File dir),

直接将命令写成一个字符串的exec方法,最终会被StringTokenizer拆分成字符串数组cmdarray再传入ProcessBuilder中(在Java Runtime类源码分析(开发“Java命令执行器”前期准备)_江南煮酒的博客-CSDN博客中有这些exec重写方法的详细调用关系图,就不一一解释了):

    public Process exec(String command, String[] envp, File dir)
        throws IOException {
        if (command.length() == 0)
            throw new IllegalArgumentException("Empty command");
        //进行命令拆分
        StringTokenizer st = new StringTokenizer(command);
        String[] cmdarray = new String[st.countTokens()];
        for (int i = 0; st.hasMoreTokens(); i++)
            cmdarray[i] = st.nextToken();
        return exec(cmdarray, envp, dir);
    }

继续,ProcessBuilder在真正执行这些命令之前又会将存有命令的mdarray字符串数组转成ArrayList集合(尽管它之后还会再把这个集合再转换成字符串数组,其实这里我是不太明白的,但是我并没有分析完全部的ProcessBuilder类的源码,所以可能转成集合这波操作再别的地方有必要的用处):

    public ProcessBuilder command(String... command) {
        this.command = new ArrayList<>(command.length);
        for (String arg : command)
            this.command.add(arg);
        return this;
    }

用户也可以直接自己new 一个ProcessBuilder对象并传入命令,直接到达这一步,ProcessBuilder类中也定义了相当多的构造器和对象方法来完成一个ProcessBuilder对象的构建(详见Java ProcessBuilder类源码分析(开发“Java命令执行器”前期准备)_江南煮酒的博客-CSDN博客),ProcessBuilder对象中最重要的是start()方法,开始执行命令并创建一个进程,start()方法源码比较长,主要分为以下几步:

public Process start() throws IOException {
        // Must convert to array first -- a malicious user-supplied
        // list might try to circumvent the security check.
        //1、将保存命令的字符串集合重新转化成数组
        String[] cmdarray = command.toArray(new String[command.size()]);
        cmdarray = cmdarray.clone();

        for (String arg : cmdarray)
            if (arg == null)
                throw new NullPointerException();
        // Throws IndexOutOfBoundsException if command is empty
        //2、判断数组第一个命令是否为空,因为第一个字符串是指定打开什么的程序名,要确保不为空
        String prog = cmdarray[0];
        SecurityManager security = System.getSecurityManager();
        if (security != null)
            security.checkExec(prog);

        String dir = directory == null ? null : directory.toString();

        for (int i = 1; i < cmdarray.length; i++) {
            if (cmdarray[i].indexOf('\u0000') >= 0) {
                throw new IOException("invalid null character in command");
            }
        }

        try {
            //3、又return一个ProcessImpl.start()方法
            return ProcessImpl.start(cmdarray,
                                     environment,
                                     dir,
                                     redirects,
                                     redirectErrorStream);
        } catch (IOException | IllegalArgumentException e) {
            String exceptionInfo = ": " + e.getMessage();
            Throwable cause = e;
            if ((e instanceof IOException) && security != null) {
                // Can not disclose the fail reason for read-protected files.
                try {
                    security.checkRead(prog);
                } catch (SecurityException se) {
                    exceptionInfo = "";
                    cause = se;
                }
            }
            // It's much easier for us to create a high-quality error
            // message than the low-level C code which found the problem.
            throw new IOException(
                "Cannot run program \"" + prog + "\""
                + (dir == null ? "" : " (in directory \"" + dir + "\")")
                + exceptionInfo,
                cause);
        }

ProcessBuilder对象的start()最终return了一个

ProcessImpl.start(cmdarray,environment,dir,redirects,redirectErrorStream),ProcessImpl是进程Process类的具体实现。ProcessImpl的start()方法也很长,ProcessImpl的start()先是加载了environment环境,确定的标准信息,错误信息的输出流

和读取外界数据的输入流(这里的流是相对于创建的进程来说的,因为还要考虑新建进程的输入和输出流可能会被用户重定向成用户自己定义的流,所以也相对比较复杂,如果有人想细品看源码的话,我只能说自己体会吧):


    // System-dependent portion of ProcessBuilder.start()
    static Process start(String cmdarray[],
                         java.util.Map<String,String> environment,
                         String dir,
                         ProcessBuilder.Redirect[] redirects,
                         boolean redirectErrorStream)
        throws IOException
    {
        String envblock = ProcessEnvironment.toEnvironmentBlock(environment);

        FileInputStream  f0 = null;
        FileOutputStream f1 = null;
        FileOutputStream f2 = null;

        try {
            long[] stdHandles;
            if (redirects == null) {
                stdHandles = new long[] { -1L, -1L, -1L };
            } else {
                stdHandles = new long[3];

                if (redirects[0] == Redirect.PIPE)
                    stdHandles[0] = -1L;
                else if (redirects[0] == Redirect.INHERIT)
                    stdHandles[0] = fdAccess.getHandle(FileDescriptor.in);
                else {
                    f0 = new FileInputStream(redirects[0].file());
                    stdHandles[0] = fdAccess.getHandle(f0.getFD());
                }

                if (redirects[1] == Redirect.PIPE)
                    stdHandles[1] = -1L;
                else if (redirects[1] == Redirect.INHERIT)
                    stdHandles[1] = fdAccess.getHandle(FileDescriptor.out);
                else {
                    f1 = newFileOutputStream(redirects[1].file(),
                                             redirects[1].append());
                    stdHandles[1] = fdAccess.getHandle(f1.getFD());
                }

                if (redirects[2] == Redirect.PIPE)
                    stdHandles[2] = -1L;
                else if (redirects[2] == Redirect.INHERIT)
                    stdHandles[2] = fdAccess.getHandle(FileDescriptor.err);
                else {
                    f2 = newFileOutputStream(redirects[2].file(),
                                             redirects[2].append());
                    stdHandles[2] = fdAccess.getHandle(f2.getFD());
                }
            }

            return new ProcessImpl(cmdarray, envblock, dir,
                                   stdHandles, redirectErrorStream);
        } finally {
            // In theory, close() can throw IOException
            // (although it is rather unlikely to happen here)
            try { if (f0 != null) f0.close(); }
            finally {
                try { if (f1 != null) f1.close(); }
                finally { if (f2 != null) f2.close(); }
            }
        }

    }

最终ProcessImpl的start()方法又return new ProcessImpl(cmdarray, envblock, dir, stdHandles, redirectErrorStream),创建一个Process对象,在ProcessImpl构造器中:

private ProcessImpl(String cmd[],
                        final String envblock,
                        final String path,
                        final long[] stdHandles,
                        final boolean redirectErrorStream)
        throws IOException
    {
        String cmdstr;
        SecurityManager security = System.getSecurityManager();
        boolean allowAmbiguousCommands = false;
        if (security == null) {
            allowAmbiguousCommands = true;
            String value = System.getProperty("jdk.lang.Process.allowAmbiguousCommands");
            if (value != null)
                allowAmbiguousCommands = !"false".equalsIgnoreCase(value);
        }
        if (allowAmbiguousCommands) {
            // Legacy mode.

            // Normalize path if possible.
            String executablePath = new File(cmd[0]).getPath();

            // No worry about internal, unpaired ["], and redirection/piping.
            if (needsEscaping(VERIFICATION_LEGACY, executablePath) )
                executablePath = quoteString(executablePath);

            cmdstr = createCommandLine(
                //legacy mode doesn't worry about extended verification
                VERIFICATION_LEGACY,
                executablePath,
                cmd);
        } else {
            String executablePath;
            try {
                executablePath = getExecutablePath(cmd[0]);
            } catch (IllegalArgumentException e) {
                // Workaround for the calls like
                // Runtime.getRuntime().exec("\"C:\\Program Files\\foo\" bar")

                // No chance to avoid CMD/BAT injection, except to do the work
                // right from the beginning. Otherwise we have too many corner
                // cases from
                //    Runtime.getRuntime().exec(String[] cmd [, ...])
                // calls with internal ["] and escape sequences.

                // Restore original command line.
                StringBuilder join = new StringBuilder();
                // terminal space in command line is ok
                for (String s : cmd)
                    join.append(s).append(' ');

                // Parse the command line again.
                cmd = getTokensFromCommand(join.toString());
                executablePath = getExecutablePath(cmd[0]);

                // Check new executable name once more
                if (security != null)
                    security.checkExec(executablePath);
            }

            // Quotation protects from interpretation of the [path] argument as
            // start of longer path with spaces. Quotation has no influence to
            // [.exe] extension heuristic.
            cmdstr = createCommandLine(
                    // We need the extended verification procedure for CMD files.
                    isShellFile(executablePath)
                        ? VERIFICATION_CMD_BAT
                        : VERIFICATION_WIN32,
                    quoteString(executablePath),
                    cmd);
        }

        handle = create(cmdstr, envblock, path,
                        stdHandles, redirectErrorStream);

        java.security.AccessController.doPrivileged(
        new java.security.PrivilegedAction<Void>() {
        public Void run() {
            if (stdHandles[0] == -1L)
                stdin_stream = ProcessBuilder.NullOutputStream.INSTANCE;
            else {
                FileDescriptor stdin_fd = new FileDescriptor();
                fdAccess.setHandle(stdin_fd, stdHandles[0]);
                stdin_stream = new BufferedOutputStream(
                    new FileOutputStream(stdin_fd));
            }

            if (stdHandles[1] == -1L)
                stdout_stream = ProcessBuilder.NullInputStream.INSTANCE;
            else {
                FileDescriptor stdout_fd = new FileDescriptor();
                fdAccess.setHandle(stdout_fd, stdHandles[1]);
                stdout_stream = new BufferedInputStream(
                    new FileInputStream(stdout_fd));
            }

            if (stdHandles[2] == -1L)
                stderr_stream = ProcessBuilder.NullInputStream.INSTANCE;
            else {
                FileDescriptor stderr_fd = new FileDescriptor();
                fdAccess.setHandle(stderr_fd, stdHandles[2]);
                stderr_stream = new FileInputStream(stderr_fd);
            }

            return null; }});
    }

首先是获取程序的可执行路径,

executablePath = getExecutablePath(cmd[0]);

然后重新拼接命令,再次解析(消除空格带来一些隐患问题,并会重新拼接空格)

// Restore original command line.
StringBuilder join = new StringBuilder();
// terminal space in command line is ok
for (String s : cmd)
    join.append(s).append(' ');

// Parse the command line again.
cmd = getTokensFromCommand(join.toString());

其次

cmdstr = createCommandLine(
        // We need the extended verification procedure for CMD files.
        isShellFile(executablePath)
            ? VERIFICATION_CMD_BAT
            : VERIFICATION_WIN32,
        quoteString(executablePath),
        cmd);

查看可执行的文件是否是.CMD或者.BAT结尾来调整路径的是否需要添加双引号以及斜杠,最终

handle = create(cmdstr, envblock, path,
                stdHandles, redirectErrorStream);

create()方法是一个native本地方法,是使用 win32 函数 CreateProcess 创建一个进程,进程是否能够创建最终取决于这个方法,至此结束!

3、细节把控

  1. 代表命令的字符串数组,是{可执行文件名,参数,参数,...},
    举例:cmd /k dir 转换成 {"cmd", "/k", "dir"},
    也可以直接写成一整个字符串,但如果用字符串数组必须要一个一个拆分。
  2. "cmd /k dir"和"cmd.exe /k dir"是没差的。(Linux系统是不是也要注意这些问题,我会去考证)
  3. cmd /c dir:是执行完dir命令后关闭命令窗口;
    cmd /k dir:是执行完dir命令后不关闭命令窗口。
    (Java在windows系统上执行命令效果等同于“运行”程序执行效果,)

    Java执行windows命令过程的源码分析及细节把控(开发“Java命令执行器”前期准备)

  4. 通过
    Process process = Runtime.getRuntime().exec("cmd /c dir")
    或者
    Process process = new ProcessBuilder().command("cmd", "/k","dir").start()
    创建的process对象,首先不管是/c还是/k都不会弹出窗户(在windows系统中),但是如果执行"cmd /c start dir"命令会弹出窗户,这里的start命令是重新打开一个新的进程执行命令,所以通过process对象获取到的流仍然是原进程的输入输出流,而原进程只执行了一个start命令,真正执行可用命令的进程是start命令产生的进程,这会导致我们将无法获取到真正执行命令的进程的输入输出内容。

  5. 虽然不管是/c还/k都不会弹出窗户,但是/c还是会执行完指定命令直接终止进程,而/k在执行完命令后会继续保持该进程,可以继续通过process.getOutputStream()来传递新的命令继续执行:

    OutputStream outputStream = process.getOutputStream();
    outputStream.write("dir \n".getBytes(new GBK()));
    //刷新流,强制写出任何缓冲的字符,不然命令可能得不到及时的执行
    outputStream.flush();
    也可以通过process.getInputStream()来持续的获取执行完命令的结果信息:
                InputStream inputStream = process.getInputStream();
                byte[] bytes = new byte[1024];
                int len;
                StringBuilder result= new StringBuilder();
                //只是一个死循环,这段代码是不可取的
                while ((len=inputStream.read())>0)
                    result.append(new String(bytes, 0, len, "GBK"));
                System.out.println(result);

    但是理想的美好的,现实是残酷的,首先process.getInputStream().read()不会因为执行完一条命令并读取完缓冲区所有的数据而返回-1,它只会阻塞因为进程还未终止(虽然暂时缓冲区中也没有数据,有点类似socket),其次就算我们不介意这点,但是执行一条命令所产生的数据并不会一次性缓冲完全,所以read()方法也就只能放在while中,不仅导致我们无法知晓执行的结果什么时候才算完整,还一定会阻塞我们当前的java进程。ProcessBuilder对象中有一个redirectOutput()方法可以重定向流(输入和输出流都可以重定向):

                Process process = new ProcessBuilder()
                        .command("cmd","dir")
                        .redirectOutput(new File("log"))
                        .redirectErrorStream(true)
                        .start();

    但是很遗憾该方法最终也只能将流定向到文件中,我们也无法保证该文件中保存的数据什么时候才是完整的命令执行结果(所以无解!)。

  6. 接着5继续,/k命令虽然可以持续输入输出,但是缺点明显(创建的进程输出具有间断性导致无法判断命令是否执行完全,read()方法阻塞很不方便),那么/c呢?/c是执行完一次命令直接终止进程,所以我们可以在进程终止后读取执行的结果从而得到完整的执行结果,但是用/c只能执行一条命令,就算cmd命令可以一次执行多条:

    CMD执行多条命令
    可以用这三种分开 & && ||
    
    用&隔开,用法是前后命令不管是可否运行都会运行下去,1命令&2命令,就是运行1命令,运行2命令。
    
    用&&隔开,用法是前面的命令运行成功才运行后面的命令,1命令&2命令,就是运行1命令没出错、运行成功才运行2命令。
    
    用||隔开,用法是前面的命令运行成功才运行后面的命令,1命令&2命令,就是运行1命令出错、运行不成功才运行2命令。

    但是多条命令的产生的一次性结果会导致我们无法区分,无法用作分析,如果一条一条执行/c命令,每一个/c命令都是创建一个新进程,也是极其消耗资源的。除此之外/c命令执行是否完全要取决于进程是否终止,所以我们还要判断process对象代表的进程是否终止,没有终止要等待其终止:

                try {
                    cmd.waitFor();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

    如果一直不终止,也会导致java进程阻塞,如果在不判断的情况下直接通过流获取数据也可能导致read()方法阻塞,这些都是要考虑的,总之/c和/k都没办法做到鱼和熊掌兼得,根据业务需求自行选择。

  7. 不管是执行/c还是/k,read()方法可能会提前读到-1而终止导致获取的数据并不是完整的输出结果(其实流里还有数据没有读取到),这是因为可能进程就是输出了一个EOF,所以循环调用read()在某种程度上来说是很有必要且不可避免的。

    EOF(End of file)是知C/C++里面的宏定义,具体定义式是#define EOF -1,表示的是文件的结束标志,值等于-1,一般用在文件读取的函数里面,比如fscanf fgetc fgets等,一旦读取到文件最后就返回EOF标志并结束函数调用。

4、java在Linux系统中执行命令的执行流程是否与Windows系统一样以及会遇到那些问题?笔者会单独出一章。 

上一篇:设计模式学习-使用go实现命令模式


下一篇:基于KDEF 数据集的表情分类算法设计