剛來公司就接了一個不發版直接改程式碼的需求

前言

前幾天突然接到一個技術需求,想要做一個功能。前端有一個表單,在頁面上可以直接寫 java 程式碼,寫完後就能保存到資料庫,並且這個程式碼實時生效。這豈非是不用發版就可以隨時改程式碼了嗎?而且有bug也不怕,隨時改。

適用場景:程式碼邏輯需要經常變動的業務。

核心思想

  • 頁面改動 java 程式碼字元串
  • java 程式碼字元串編譯成 class
  • 動態載入到 jvm

實現重點

JDK 提供了一個工具包 javax.tools 讓使用者可以用簡易的 API 進行編譯。

這些工具包的使用步驟:

  1. 獲取一個 javax.tools.JavaCompiler 實例。
  2. 基於 Java 文件對象初始化一個編譯任務 CompilationTask 實例。
  3. 因為JVM 裡面的 Class 是基於 ClassLoader 隔離的,所以編譯成功之後可以通過自定義的類載入器載入對應的類實例
  4. 使用反射 API 進行實例化和後續的調用。

1. 程式碼編譯

這一步需要將 java 文件編譯成 class,其實平常的開發過程中,我們的程式碼編譯都是由 IDEA、Maven 等工具完成。

內置的 SimpleJavaFileObject 是面向源碼文件的,而我們的是源碼字元串,所以需要實現 JavaFileObject 介面自定義一個 JavaFileObject。

public class CharSequenceJavaFileObject extends SimpleJavaFileObject {

    public static final String CLASS_EXTENSION = ".class";

    public static final String JAVA_EXTENSION = ".java";

    private static URI fromClassName(String className) {
        try {
            return new URI(className);
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException(className, e);
        }
    }

    private ByteArrayOutputStream byteCode;
    private final CharSequence sourceCode;

    public CharSequenceJavaFileObject(String className, CharSequence sourceCode) {
        super(fromClassName(className + JAVA_EXTENSION), Kind.SOURCE);
        this.sourceCode = sourceCode;
    }

    public CharSequenceJavaFileObject(String fullClassName, Kind kind) {
        super(fromClassName(fullClassName), kind);
        this.sourceCode = null;
    }

    public CharSequenceJavaFileObject(URI uri, Kind kind) {
        super(uri, kind);
        this.sourceCode = null;
    }

    @Override
    public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException {
        return sourceCode;
    }

    @Override
    public InputStream openInputStream() {
        return new ByteArrayInputStream(getByteCode());
    }

    // 注意這個方法是編譯結果回調的OutputStream,回調成功後就能通過下面的getByteCode()方法獲取目標類編譯後的位元組碼位元組數組
    @Override
    public OutputStream openOutputStream() {
        return byteCode = new ByteArrayOutputStream();
    }

    public byte[] getByteCode() {
        return byteCode.toByteArray();
    }
}

如果編譯成功之後,直接通過 CharSequenceJavaFileObject#getByteCode()方法即可獲取目標類編譯後的位元組碼對應的位元組數組(二進位內容)

  1. 實現 ClassLoader

因為JVM 裡面的 Class 是基於 ClassLoader 隔離的,所以編譯成功之後得通過自定義的類載入器載入對應的類實例,否則是載入不了的,因為同一個類只會載入一次。

主要關注 findClass 方法

public class JdkDynamicCompileClassLoader extends ClassLoader {

    public static final String CLASS_EXTENSION = ".class";

    private final static Map<String, JavaFileObject> javaFileObjectMap = new ConcurrentHashMap<>();

    public JdkDynamicCompileClassLoader(ClassLoader parentClassLoader) {
        super(parentClassLoader);
    }


    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        JavaFileObject javaFileObject = javaFileObjectMap.get(name);
        if (null != javaFileObject) {
            CharSequenceJavaFileObject charSequenceJavaFileObject = (CharSequenceJavaFileObject) javaFileObject;
            byte[] byteCode = charSequenceJavaFileObject.getByteCode();
            return defineClass(name, byteCode, 0, byteCode.length);
        }
        return super.findClass(name);
    }

    @Override
    public InputStream getResourceAsStream(String name) {
        if (name.endsWith(CLASS_EXTENSION)) {
            String qualifiedClassName = name.substring(0, name.length() - CLASS_EXTENSION.length()).replace('/', '.');
            CharSequenceJavaFileObject javaFileObject = (CharSequenceJavaFileObject) javaFileObjectMap.get(qualifiedClassName);
            if (null != javaFileObject && null != javaFileObject.getByteCode()) {
                return new ByteArrayInputStream(javaFileObject.getByteCode());
            }
        }
        return super.getResourceAsStream(name);
    }

    /**
     * 暫時存放編譯的源文件對象,key為全類名的別名(非URI模式),如club.throwable.compile.HelloService
     */
    void addJavaFileObject(String qualifiedClassName, JavaFileObject javaFileObject) {
        javaFileObjectMap.put(qualifiedClassName, javaFileObject);
    }

    Collection<JavaFileObject> listJavaFileObject() {
        return Collections.unmodifiableCollection(javaFileObjectMap.values());
    }
}
  1. 封裝了上面的 ClassLoader 和 JavaFileObject
public class JdkDynamicCompileJavaFileManager extends ForwardingJavaFileManager<JavaFileManager> {

    private final JdkDynamicCompileClassLoader classLoader;
    private final Map<URI, JavaFileObject> javaFileObjectMap = new ConcurrentHashMap<>();

    public JdkDynamicCompileJavaFileManager(JavaFileManager fileManager, JdkDynamicCompileClassLoader classLoader) {
        super(fileManager);
        this.classLoader = classLoader;
    }

    private static URI fromLocation(Location location, String packageName, String relativeName) {
        try {
            return new URI(location.getName() + '/' + packageName + '/' + relativeName);
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException(e);
        }
    }

    @Override
    public FileObject getFileForInput(Location location, String packageName, String relativeName) throws IOException {
        JavaFileObject javaFileObject = javaFileObjectMap.get(fromLocation(location, packageName, relativeName));
        if (null != javaFileObject) {
            return javaFileObject;
        }
        return super.getFileForInput(location, packageName, relativeName);
    }

    /**
     * 這裡是編譯器返回的同(源)Java文件對象,替換為CharSequenceJavaFileObject實現
     */
    @Override
    public JavaFileObject getJavaFileForOutput(Location location, String className, JavaFileObject.Kind kind, FileObject sibling) throws IOException {
        JavaFileObject javaFileObject = new CharSequenceJavaFileObject(className, kind);
        classLoader.addJavaFileObject(className, javaFileObject);
        return javaFileObject;
    }

    /**
     * 這裡覆蓋原來的類載入器
     */
    @Override
    public ClassLoader getClassLoader(Location location) {
        return classLoader;
    }

    @Override
    public String inferBinaryName(Location location, JavaFileObject file) {
        if (file instanceof CharSequenceJavaFileObject) {
            return file.getName();
        }
        return super.inferBinaryName(location, file);
    }

    @Override
    public Iterable<JavaFileObject> list(Location location, String packageName, Set<JavaFileObject.Kind> kinds, boolean recurse) throws IOException {
        Iterable<JavaFileObject> superResult = super.list(location, packageName, kinds, recurse);
        List<JavaFileObject> result = new ArrayList<>();
        // 這裡要區分編譯的Location以及編譯的Kind
        if (location == StandardLocation.CLASS_PATH && kinds.contains(JavaFileObject.Kind.CLASS)) {
            // .class文件以及classPath下
            for (JavaFileObject file : javaFileObjectMap.values()) {
                if (file.getKind() == JavaFileObject.Kind.CLASS && file.getName().startsWith(packageName)) {
                    result.add(file);
                }
            }
            // 這裡需要額外添加類載入器載入的所有Java文件對象
            result.addAll(classLoader.listJavaFileObject());
        } else if (location == StandardLocation.SOURCE_PATH && kinds.contains(JavaFileObject.Kind.SOURCE)) {
            // .java文件以及編譯路徑下
            for (JavaFileObject file : javaFileObjectMap.values()) {
                if (file.getKind() == JavaFileObject.Kind.SOURCE && file.getName().startsWith(packageName)) {
                    result.add(file);
                }
            }
        }
        for (JavaFileObject javaFileObject : superResult) {
            result.add(javaFileObject);
        }
        return result;
    }

    /**
     * 自定義方法,用於添加和快取待編譯的源文件對象
     */
    public void addJavaFileObject(Location location, String packageName, String relativeName, JavaFileObject javaFileObject) {
        javaFileObjectMap.put(fromLocation(location, packageName, relativeName), javaFileObject);
    }
}
  1. 使用 JavaCompiler 編譯並反射生成實例對象
public final class JdkCompiler {

    static DiagnosticCollector<JavaFileObject> DIAGNOSTIC_COLLECTOR = new DiagnosticCollector<>();

    @SuppressWarnings("unchecked")
    public static <T> T compile(String packageName,
                                String className,
                                String sourceCode) throws Exception {
        // 獲取系統編譯器實例
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        // 設置編譯參數
        List<String> options = new ArrayList<>();
        options.add("-source");
        options.add("1.8");
        options.add("-target");
        options.add("1.8");
        // 獲取標準的Java文件管理器實例
        StandardJavaFileManager manager = compiler.getStandardFileManager(DIAGNOSTIC_COLLECTOR, null, null);
        // 初始化自定義類載入器
        JdkDynamicCompileClassLoader classLoader = new JdkDynamicCompileClassLoader(Thread.currentThread().getContextClassLoader());

        // 初始化自定義Java文件管理器實例
        JdkDynamicCompileJavaFileManager fileManager = new JdkDynamicCompileJavaFileManager(manager, classLoader);
        String qualifiedName = packageName + "." + className;
        // 構建Java源文件實例
        CharSequenceJavaFileObject javaFileObject = new CharSequenceJavaFileObject(className, sourceCode);
        // 添加Java源文件實例到自定義Java文件管理器實例中
        fileManager.addJavaFileObject(
                StandardLocation.SOURCE_PATH,
                packageName,
                className + CharSequenceJavaFileObject.JAVA_EXTENSION,
                javaFileObject
        );
        // 初始化一個編譯任務實例
        JavaCompiler.CompilationTask compilationTask = compiler.getTask(
                null,
                fileManager,
                DIAGNOSTIC_COLLECTOR,
                options,
                null,
                Collections.singletonList(javaFileObject)
        );
        Boolean result = compilationTask.call();
        System.out.println(String.format("編譯[%s]結果:%s", qualifiedName, result));
        Class<?> klass = classLoader.loadClass(qualifiedName);
        return (T) klass.getDeclaredConstructor().newInstance();
    }
}

完成上面工具的搭建之後。我們可以接入資料庫的操作了。資料庫層面省略,只展示 service 層

service 層:

public class JavaService {

    public Object saveAndGetObject(String packageName,String className,String javaContent) throws Exception {
        Object object = JdkCompiler.compile(packageName, className, javaContent);
        return object;
    }

}

測試:

public class TestService {

    public static void main(String[] args) throws Exception {
        test();
    }

    static String content="package cn.mmc;\n" +
            "\n" +
            "public class SayHello {\n" +
            "    \n" +
            "    public void say(){\n" +
            "        System.out.println(\"11111111111\");\n" +
            "    }\n" +
            "}";

    static String content2="package cn.mmc;\n" +
            "\n" +
            "public class SayHello {\n" +
            "    \n" +
            "    public void say(){\n" +
            "        System.out.println(\"22222222222222\");\n" +
            "    }\n" +
            "}";

    public static void test() throws Exception {
        JavaService javaService = new JavaService();
        Object sayHello = javaService.saveAndGetObject("cn.mmc", "SayHello", content);
        sayHello.getClass().getMethod("say").invoke(sayHello);

        Object sayHello2 = javaService.saveAndGetObject("cn.mmc", "SayHello", content2);
        sayHello2.getClass().getMethod("say").invoke(sayHello2);
    }
}

我們在啟動應用時,更換了程式碼文件記憶體,然後直接反射調用對象的方法。執行結果:

可以看到,新的程式碼已經生效!!!