基于反射的 Java 单类程序运行器


前言

我们接下来要实现一个简单的 Java 程序运行器,什么意思呢?比方说,我用记事本写了一个类,此时,如果我不用任何开发工具,那么最原始的办法就是使用 javac 命令编译它,然后使用 java 命令运行它,这样说比较抽象,我们上手演示一下:

  • 先新建一个 java 文件

  • 然后用记事本打开它,输入以下测试内容后保存:

      package org.lizhpn;
    
      public class TestCmd {
    
          public static void main(String[] args) {
              System.out.println("Hello World!");
          }
      }
    
  • 保存成功后,打开 cmd 使用 javac 命令编译它,编译时需要指定编码方式,因为记事本默认保存为 UTF-8,而 Windows 系统默认编码为 GBK,如果不指定将会使用系统默认编码,这样就会导致乱码,甚至不能运行,依次执行下述动作,在开始前需要确保自己的 Java 已配置环境路径:

    1. 进入 java 文件所在目录(命令因不同操作系统会有所区别,以 Windows 为例):

       D:
       cd D:\Test
      
    2. 执行编译命令:

       javac -encoding UTF-8 TestCmd.java
      
  • 如果编译成功是不会输出任何信息,一旦编译成功,我们在 java 文件所在目录下就会看到一个 class 后缀的文件,这就是 Java 的字节码文件,接下来我们就可以使用 java 命令运行这个字节码文件:

         java TestCmd    # 注意不要加 class 后缀,否则会出现 “错误: 找不到或无法加载主类 TestCmd”
    

运行成功的话,我们就可以看到 Hello World!

如果我们不要这么复杂的步骤怎么办?接下来,我们将制作一个运行在 Windows 上的简单 Java 程序运行器让上面的操作隐藏起来。

必备知识点

运行时执行 cmd 命令

// 通过这样调用执行 cmd 命令
Process exec = Runtime.getRuntime().exec(cmdCommand);

// 获取cmd命令的执行结果
InputStream inputStream = exec.getInputStream();

// 获取cmd命令的执行结果,有时候信息会出现在这个错误流中
InputStream errorStream = exec.getErrorStream()

// waitFor 是使当前线程阻塞,直到 cmd 命令执行完毕,返回值 0 表示执行正常结束
int exitCode = exec.waitFor();

cmd 命令简单语法

cmd /c dir 是执行完dir命令后关闭命令窗口。

cmd /k dir 是执行完dir命令后不关闭命令窗口。

cmd /c start dir 会打开一个新窗口后执行dir指令,原窗口会关闭。

cmd /k start dir 会打开一个新窗口后执行dir指令,原窗口不会关闭。

可以用cmd /?查看帮助信息。

如果我们需要在 cmd 中同时执行多个命令怎么办?可以这样办:

a && b  : 执行a,成功后再执行b

a || b  : 先执行a,若执行成功则不再执行b,若失败则再执行b

a & b   : 先执行a再执行b,无论a是否成功

这和布尔运算符还是很相似的,所以记起来也不会很麻烦。

反射相关知识

我们要运用反射的相关知识来运行任意 Java 类中的任意方法,由于之前已经介绍过和反射相关的知识,这里就不再赘述了,有兴趣的小伙伴可以点这里 。

题外知识点

Java 多线程之 Hook(译为钩子),首先,我们必须直到, JVM 是什么时候退出的?

The Java Virtual Machine exits when the only threads running are all daemon threads.

什么意思?Java 虚拟机要退出只有当所有运行着的线程都是守护线程(在后台运行的线程),在 Java 中,要设置一个线程是后台运行的,可以这样设置:

// 获得线程对象实例,再调用 setDaemon 方法
new Thread(System.out::println).setDaemon(true);

换一种说法就是,如果某个线程是守护线程,当其余所有的非守护线程结束后,尽管它还在运行,但是它依然会被中断退出,意味着什么?有些代码还没运行它就没了,所有守护线程原则上一定不能持有那些必须被释放的资源,但是万一呢?这个时候怎么办?

Hook 应运而生,那它有什么作用?它可以在所有非守护线程结束后,也就是 JVM 即将退出时被调用,Hook 实质上持有的是线程对象的集合,每当 JVM 要退出时,就会开始运行它所持有的所有线程。看个例子:

package org.lizhpn;

import java.util.concurrent.TimeUnit;

public class TestCmd {

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> System.out.println("Hook 持有线程 1 执行成功\n"));
        t1.setName("Hook-1");

        Thread t2 = new Thread(() -> System.out.println("Hook 持有线程 2 执行成功\n"));
        t2.setName("Hook-2");

        // 让 Hook 持有 t1, t2
        Runtime.getRuntime().addShutdownHook(t1);
        Runtime.getRuntime().addShutdownHook(t2);

        // 设置线程 t3 为守护线程,并运行它
        Thread t3 = new Thread(() -> {
            while (true) {
                try {
                    TimeUnit.MILLISECONDS.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("我是守护线程");
                printHoledThreads();

                System.out.println("\n");
            }
        });
        t3.setName("守护线程-1");
        t3.setDaemon(true);
        t3.start();

        printHoledThreads();
        System.out.println("\n");
        TimeUnit.MILLISECONDS.sleep(500);
        System.out.println("主函数调用结束了");
        System.out.println("\n");
    }

    private static void printHoledThreads(){
        ThreadGroup threadGroup = Thread.currentThread().getThreadGroup();
        int activeCount = threadGroup.activeCount();

        // 获取该线程组中的所有成员
        Thread[] threads = new Thread[activeCount];
        threadGroup.enumerate(threads);

        for (Thread thread : threads) {
            System.out.println(thread.getName() + ":  " + (thread.isDaemon() ? "守护线程" : "非守护线程"));
        }
    }
}

运行结果:
main:  非守护线程
Monitor Ctrl-Break:  守护线程
守护线程-1:  守护线程

我是守护线程
main:  非守护线程
Monitor Ctrl-Break:  守护线程
守护线程-1:  守护线程


我是守护线程
main:  非守护线程
Monitor Ctrl-Break:  守护线程
守护线程-1:  守护线程


主函数调用结束了


我是守护线程
Monitor Ctrl-Break:  守护线程
守护线程-1:  守护线程
DestroyJavaVM:  非守护线程
Hook-2:  非守护线程
Hook-1:  非守护线程


Hook 持有线程 2 执行成功

Hook 持有线程 1 执行成功

在上面的例子中,我们先定义了两个线程对象,然后让 Hook 持有它们,然后开启了无限循环的守护线程。从运行结果可以看到当名为 main 的主进程结束后,我们添加在 Hook 中的 t1 和 t2 线程都被执行了,而且是以非守护线程的方式执行的,除此之外,还有一个非守护线程叫做 DestroyJavaVM ,这应该是正式销毁 JVM 的进程。

如果是我,这个 Hook 一定是以观察者模式实现的,而且 DestroyJavaVM 这个非守护线程,要么生来就被 Hook 所持有,要么 Hook 生来就被 DestroyJavaVM 持有,后者更有可能,因为如果 DestroyJavaVM 被 Hook 所持有,存在被移除的风险:

Runtime.getRuntime().removeShutdownHook(Thread hook);

那么实际原理是怎样的呢?可以看看这篇文章:

开始制作简单 Java 程序运行器

制作简单的运行界面

package org.lizhpn;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Scanner;

/**
 * 运行文件系统中指定的任意 Java 文件中的任意方法
 *
 * @author lizhpn
 */
public class RunAnySingleJava {

    private static String javaDir;

    public static void main(String[] args) {
        System.out.println("请开始根据指示进行操作!");
        Scanner scanner = new Scanner(System.in);
        String command = "begin";
        while (!command.isEmpty() && !"exit".equals(command)) {

            System.out.println("请输入 Java 文件所在目录:");
            command = scanner.nextLine();
            javaDir = command;

            System.out.println("请输入需要运行一个公开 Java 类的方法(格式: 全限定类名&方法名):");
            command = scanner.nextLine();
            // 获取 Java 类文件位置, 区分出类名和方法名
            String[] commands = command.split("&");
            
            // 如果 Java 类文件可能存在
            if (commands.length > 0) {
                // 开始尝试编译运行
                tryToRun(commands);
            }
        }

        System.out.println("感谢您的使用,祝您生活愉快!");
    }
}

编写运行 cmd 命令的方法

/**
 * 以阻塞方式运行指定的命令
 *
 * @param cmdCommand 待运行命令
 * @return 命令执行结果, 0 表示正常执行
 */
public static int runCmd(String cmdCommand) {
    try {
        Process exec = Runtime.getRuntime().exec(cmdCommand);

        BufferedReader infoReader = new BufferedReader(new InputStreamReader(exec.getInputStream(), "GBK"));
        BufferedReader errorReader = new BufferedReader(new InputStreamReader(exec.getErrorStream(), "GBK"));
        // 开启新线程打印信息流
        new Thread(() -> {
            String line;
            System.out.println("\n");
            try {
                while ((line = infoReader.readLine()) != null) {
                    System.out.println(line);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
            System.out.println("\n");

            try {
                while ((line = errorReader.readLine()) != null) {
                    System.out.println(line);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
            System.out.println("\n");
        }).start();

        // 会阻塞当前线程,等待命令执行完毕
        return exec.waitFor();

    } catch (IOException | InterruptedException e) {
        e.printStackTrace();
    }

    return -1;
}

编译 Java 类

可以在 cmd 上运行就很简单了,直接调用 javac 的命令:

/**
 * java 文件所在路径
 *
 * @param javaFilePath .java 文件所在路径
 * @return 编译结果
 */
public static int tryToCompile(String javaFilePath) {
    return runCmd("cmd /c javac -encoding UTF-8 " + javaFilePath);
}

运行含主类 main 的 Java 类

/**
 * 尝试使用 java 命令运行包含主函数 main 的 Java 程序
 * 
 * @param args = 全限定类名&方法名.split("&");
 * @return 命令执行结果
 */
public static int tryToRunMainClass(String[] args) {

    // 获取 Java 文件中的 public class
    String[] names = args[0].split("\\.");
    String javaFilePath = javaDir + "\\" + names[names.length - 1] + ".java";
    
    if (tryToCompile(javaFilePath) == 0) {
        // 如果编译成功,就开始运行
        System.out.println(args[0] + " 编译成功");

        // 打开 Java 文件所在的目录
        // 打开盘符
        StringBuilder sb = new StringBuilder("cmd /c ");
        sb.append(javaDir.split(":")[0]).append(": && ");

        // 再打开 java 文件所在目录
        sb.append(" cd  ").append(javaDir).append(" && ");

        // 开始运行
        sb.append(" java ").append(names[names.length - 1]);
        return runCmd(sb.toString());
    }
    
    return -1;
}

这个方法只可以运行包含主函数 main 的 Java 类,但这不是我们的目标,我们的目标是什么?我们的目标是星辰大海!

打开方式不对,再来一次,我们本文的目标是什么?我们本文的目标是能够跑任意类的任意方法的 Java 程序运行器!为了这个目标,继续下一步!

自定义类加载器

我们在这里将要自己实现一个类加载器,一个能从文件系统任意位置读取字节码文件的类加载器!怎么办呢?完全没写过啊,于是找到 ClassLoader 的源码,从中发现了一个重要发现:

 *     class NetworkClassLoader extends ClassLoader {
 *         String host;
 *         int port;
 *
 *         public Class findClass(String name) {
 *             byte[] b = loadClassData(name);
 *             return defineClass(name, b, 0, b.length);
 *         }
 *
 *         private byte[] loadClassData(String name) {
 *             // load the class data from the connection
 *              . . .
 *         }
 *     }

在 ClassLoader 类的解释中,有这样一段代码,发现它最终重写了 findClass 方法,但是最终返回的是 defineClass 这个方法,顾名思义定义类,马上来看看这个方法:

/**
 * Converts an array of bytes into an instance of class Class.
 * Before the Class can be used it must be resolved.
 *
 * @param  name
 *         The expected binary name of the class, or
 *         null if not known
 *
 * @param  b
 *         The bytes that make up the class data.  The bytes in positions
 *         off through off+len-1 should have the format
 *         of a valid class file as defined by
 *         The Java™ Virtual Machine Specification.
 *
 * @param  off
 *         The start offset in b of the class data
 *
 * @param  len
 *         The length of the class data
 *
 * @return  The Class object that was created from the specified
 *          class data.
 */
protected final Class<?> defineClass(String name, byte[] b, int off, int len)
    throws ClassFormatError
{
    return defineClass(name, b, off, len, null);
}

刨去不重要的,留下重要的解释,简单来说,这个方法可以从一个字节数组中读取指定全限定类名的字节码信息,最终生成一个 Class 实例对象。这字节数组是什么来头呢?其实就是 .class 文件即字节码文件的数据流。知道这个后,我们就可以定义一个简单的自定义的类加载器:

// 在 RunAnySingleJava 定义为静态内部类
static class FileSysClassLoader extends ClassLoader{

    /**
     * 将文件系统中的指定类的字节码文件(.class文件)加载进来
     * 
     * @param fileName .class 文件的路径
     * @param className 该类的全限定类名
     * @return 该类的类型实例对象
     */
    public Class<?> loadClassFromFileSys(String fileName, String className) {
        File file = new File(fileName);
        
        // 如果该文件存在
        if (file.exists()) {
            // 获取该文件的长度
            int length = (int) file.length();

            // 定义足够长的字节数组保存这个字节码文件的信息
            byte[] fileCode = new byte[length * 2];

            try {
                // 将这个文件一次性读出来
                FileInputStream inputStream = new FileInputStream(file);
                int num = inputStream.read(fileCode, 0, length);        // 返回本次读取的实际长度

                // 调用核心方法 defineClass 将读取出来的字节码文件字节数组转化为该类类型实例对象
                return defineClass(className, fileCode, 0, num);

            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        return null;
    }
}

需要注意的是这个自定义的类加载器只能加载文件系统里实际存在的类,并未使用双亲委派模式。如果需要使用这个类则可以:

public static Class<?> loadClass(String fileName, String className){

    return  new RunAnySingleJava.FileSysClassLoader().loadClassFromFileSys(fileName, className);
}

以反射方式执行指定类的指定方法

/**
 * 运行指定类中的指定方法
 *
 * @param methodName 指定类的方法名
 * @param paramTypes 方法参数类型,暂时只支持无参方法
 * @return 执行结果,null 为执行失败
 */
public static Object runMethodInClass(Class<?> aClass, String methodName, Class<?>... paramTypes){
    try {

        // 标记是否为公开方法
        boolean isPublic = true;

        // 开始获取方法器
        // 先尝试以公开方法获取它
        Method method;
        try {
            method = aClass.getMethod(methodName, paramTypes);
        }catch (NoSuchMethodException e){
            method = null;
        }
        if (method == null) {
            // 没有获取到,说明是非 public 的
            isPublic = false;

            method = aClass.getDeclaredMethod(methodName, paramTypes);
        }

        // 不是公开的要设置为可访问
        if (!isPublic){
            method.setAccessible(true);
        }
        // 要求类必须有无参构造函数
        return method.invoke(aClass.newInstance());

    } catch (NoSuchMethodException | IllegalAccessException |
            InstantiationException | InvocationTargetException e) {
        System.out.println(aClass.getName() + " 没有这个方法: " + methodName);
    }

    return null;
}

运行任意类的任意无参方法

有了自己的文件系统类加载器和反射执行方法后,我们就能实现这个目标了:

/**
 * 尝试利用类加载器和反射运行任意类任意无参方法
 *
 * @param args = 全限定类名&方法名.split("&");
 * @return 方法运行结果,null 为运行失败
 */
public static Object tryToRunClassMethod(String[] args){
    // 获取 Java 文件中的 public class
    String[] names = args[0].split("\\.");
    String className = names[names.length - 1];
    String javaFilePath = javaDir + "\\" + className;

    if (tryToCompile(javaFilePath + ".java") == 0) {
        // 如果编译成功,就开始运行
        System.out.println(args[0] + " 编译成功");
        Class<?> aClass = loadClass(javaFilePath + ".class", args[0]);
        if (aClass != null && args.length > 1) {
            return runMethodInClass(aClass, args[1]);
        }
    }

    return null;
}

完成简易 Java 程序运行器 1.0 版本

public static void main(String[] args) {
    System.out.println("请开始根据指示进行操作!");
    Scanner scanner = new Scanner(System.in);
    String command = "begin";

    // 输入 exit 离开系统
    while (!"exit".equals(command)) {

        while (javaDir.isEmpty()) {

            System.out.println("请输入 Java 文件所在目录:");
            command = scanner.nextLine();

            // 退出
            if ("exit".equals(command)){
                System.out.println("感谢您的使用,祝您生活愉快!");
                System.exit(0);
            }

            javaDir = command;
        }

        System.out.println("请输入需要运行一个公开 Java 类的方法(格式: 全限定类名&方法名):");
        command = scanner.nextLine();
        if (!"exit".equals(command)) {

            // 获取 Java 类文件位置,区分出类名和方法名
            String[] commands = command.split("&");

            // 如果 Java 类文件可能存在
            if (commands.length > 0) {
                // 开始尝试编译运行
                tryToRunClassMethod(commands);
            }
        }
    }

    System.out.println("感谢您的使用,祝您生活愉快!");
}