這里介紹Java動態編譯技術原理!
編譯,一般來說就是將源代碼轉換成機器碼的過程,比如在C語言中中,將C語言源代碼編譯成a.out,,但是在Java中的理解可能有點不同,編譯指的是將java 源代碼轉換成class字節碼的過程,而不是真正的機器碼,這是因為中間隔着一個JVM。雖然對於編譯的理解不同,但是編譯的過程基本上都是相同的。但是我們熟悉的編譯大都是點擊一下Eclipse或者Intellij Idea的Run或者Build按鈕,但是在點擊后究竟發生什么?其實我沒仔細了解過,只是知道這個程序運行起來了,但是如果你使用過javac命令去編譯代碼時,可能了解的就更深一些,據說印度的Java程序員最開始編程的時候使用的都是文本編輯器而不是IDE,這樣更能接觸底層的過程。
除了使用javac命令編譯Java程序,從Java 1.6開始,我們也可以在程序運行時根據程序實際運行來構建一些類並進行編譯,這需要JDK提供給我們一些可供調用的接口來完成編譯工作。
一、編譯源碼需要啥?
那么問題來了,如果要了解運行時編譯的過程和對應的接口,首先要明白的就是編譯這個過程都會涉及哪些工具和要解決的問題?從我們熟悉的構建過程開始:
- 編譯工具(編譯器):顯然沒有這個東西我們啥也干不了;
- 要編譯的源代碼文件:沒有這個東西,到底編啥呢?
- 源代碼、字節碼文件的管理:其實這里靠的是文件系統的支持,包括文件的創建和管理;
- 編譯過程中的選項:要編譯的代碼版本、目標,源代碼位置,classpath和編碼等等,見相關文章;
- 編譯中編譯器輸出的診斷信息:告訴你編譯成功還是失敗,會有什么隱患提出警告信息;
按照這些信息,JDK也提供了可編程的接口對象上述信息,這些API全部放在javax.tools包下,對應上面的信息如下:
-
編譯器:涉及到的接口和類如下:
- JavaCompiler
- JavaCompiler.CompilationTask
- ToolProvider
-
在上面的接口和類中,ToolProvider類似是一個工具箱,它可以提供JavaCompiler類的實例並返回,然后該實例可以獲取JavaCompiler.CompilationTask實例,然后由JavaCompiler.CompilationTask實例來執行對應的編譯任務,其實這個執行過程是一個並發的過程。
-
源代碼文件:涉及到接口和類如下:
- FileObject
- ForwardingFileObject
- JavaFileObject
- JavaFileObject.Kind
- ForwardingJavaFileObject
- SimpleJavaFileObject
-
上述后面的4個接口和類都是FileObject子接口或者實現類,FIleObject接口代表了對文件的一種抽象,可以包括普通的文件,也可以包括數據庫中的數據庫等,其中規定了一些操作,包括讀寫操作,讀取信息,刪除文件等操作。我們要用的其實是JavaFileObject接口,其中還增加了一些操作Java源文件和字節碼文件特有的API,而SimpleJavaFileObject是JavaFileObject接口的實現類,但是其中你可以發現很多的接口其實就是直接返回一個值,或者拋出一個異常,並且該類的構造器由protected修飾的,所以要實現復雜的功能,需要我們必須擴展這個類。ForwardingFileObject、ForwardingJavaFileObject類似,其中都是包含了對應的FileObject和JavaFileObject,並將方法的執行委托給這些對象,它的目的其實就是為了提高擴展性。
-
文件的創建和管理:涉及接口和類如下:
- JavaFileManager
- JavaFileManager.Location
- StandardJavaFileManager
- ForwardingJavaFileManager
- StandardLocation
-
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); DiagnosticCollector<JavaFileObject> collector = new DiagnosticCollector<>(); // 該JavaFileManager實例是com.sun.tools.javac.file.JavacFileManager JavaFileManager manager= compiler.getStandardFileManager(collector, null, null);
JavaFileManager用來創建JavaFileObject,包括從特定位置輸出和輸入一個JavaFileObject,ForwardingJavaFileManager也是出於委托的目的。而StandardJavaFileManager是JavaFileManager直接實現類,JavaFileManager.Location和StandardLocation描述的是JavaFileObject對象的位置,由JavaFileManager使用來決定在哪創建或者搜索文件。由於在javax.tools包下沒有JavaFileManager對象的實現類,如果我們想要使用,可以自己實現該接口,也可以通過JavaCompiler類中的getStandardFileManager完成,如下:
-
編譯選項的管理:
- OptionChecker
-
這個接口基本上沒有用過。
-
診斷信息的收集:涉及接口和類如下:
- Diagnostic
- DiagnosticListener
- Diagnostic.Kind
- DiagnosticCollector
-
Diagnostic會輸出編譯過程中產生的問題,包括問題的信息和出現問題的定位信息,問題的類別則在Diagnostic.Kind中定義。DiagnosticListener則是從編譯器中獲取診斷信息,當出現診斷信息時則會調用其中的report方法,DiagnosticCollector則是進一步實現了DiagnosticListener,並將診斷信息收集到一個list中以便處理。
在Java源碼運行時編譯的時候還會遇到一個與普通編譯不同的問題,就是類加載器的問題,由於這個問題過大,而且比較核心,將會專門寫一篇文章介紹。
二、如何在運行時編譯源代碼?
好了說了這么多了,其實都是為了下面的實例作為鋪墊,我們還是從上述的幾個組件來說明。
1、准備編譯器對象
這里只有一種方法,如下:
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); // ...... // 在其他實例都已經准備完畢后, 構建編譯任務, 其他實例的構建見如下 Boolean result = compiler.getTask(null, manager, collector, options,null,Arrays.asList(javaFileObject));
2、診斷信息的收集
// 初始化診斷收集器 DiagnosticCollector<JavaFileObject> collector = new DiagnosticCollector<>(); // ...... // 編譯完成之后,獲取編譯過程中的診斷信息 collector.getDiagnostics().forEach(item -> System.out.println(item.toString()))
在這個過程中可以通過Diagnostic實例獲取編譯過程中出錯的行號、位置以及錯誤原因等信息。
3、源代碼文件對象的構建
由於JDK提供的FileObject、ForwardingFileObject、JavaFileObject、ForwardingJavaFileObject、SimpleJavaFileObject都無法直接使用,所以我們需要根據需求自定義,此時我們要明白SimpleJavaFileObject類中的哪些方法是必須要覆蓋的,可以看如下過程:
下面是調用compiler中的getTask方法時的調用棧,可以看出從main()方法中開始調用getTask方法開始,直到編譯工作開始進行,首先讀取源代碼,調用com.sun.tools.javac.main包中的readSource()方法,源代碼如下:
public CharSequence readSource(JavaFileObject filename) { try { inputFiles.add(filename); return filename.getCharContent(false); } catch (IOException e) { log.error("error.reading.file", filename, JavacFileManager.getMessage(e)); return null; } }
其中調用ClientCodeWrapper$WrappedFileObject對象中的filename.getCharContent(false)方法來讀取要編譯的源碼,源代碼如下:
public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException { try { return clientFileObject.getCharContent(ignoreEncodingErrors); } catch (ClientCodeException e) { throw e; } catch (RuntimeException e) { throw new ClientCodeException(e); } catch (Error e) { throw new ClientCodeException(e); } }
而其中的clientFileObject.getCharContent(ignoreEncodingErrors),其實就是調用我們實現的自定義的JavaFIleObject對象,因此源代碼文本是必須的,因此getCharContent方法是必須實現的,另外在編譯器編譯完成之后要將編譯完成的字節碼輸出,如下圖:
這時調用writeClass()輸出字節碼,通過打開一個輸出流OutputStream
來完成該過程,因此openOutputStream()這個方法也是必須實現的。因此該類的實現如下:
public static class MyJavaFileObject extends SimpleJavaFileObject { private String source; private ByteArrayOutputStream outPutStream; // 該構造器用來輸入源代碼 public MyJavaFileObject(String name, String source) { // 1、先初始化父類,由於該URI是通過類名來完成的,必須以.java結尾。 // 2、如果是一個真實的路徑,比如是file:///test/demo/Hello.java則不需要特別加.java // 3、這里加的String:///並不是一個真正的URL的schema, 只是為了區分來源 super(URI.create("String:///" + name + Kind.SOURCE.extension), Kind.SOURCE); this.source = source; } // 該構造器用來輸出字節碼 public MyJavaFileObject(String name, Kind kind){ super(URI.create("String:///" + name + kind.extension), kind); source = null; } @Override public CharSequence getCharContent(boolean ignoreEncodingErrors){ if(source == null){ throw new IllegalArgumentException("source == null"); } return source; } @Override public OutputStream openOutputStream() throws IOException { outPutStream = new ByteArrayOutputStream(); return outPutStream; } // 獲取編譯成功的字節碼byte[] public byte[] getCompiledBytes(){ return outPutStream.toByteArray(); } }
4、文件管理器對象的構建
文件管理對象顯然也是不能直接使用JDK提供的接口,因為只有ForwardingJavaFileManager是一個類,其他的都是接口,而且在ForwardingJavaFileManager中構造器又是protected,所以如果想定制化使用的話,需要實現接口或者繼承類,如果只是簡單使用,可以如下:
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); DiagnosticCollector<JavaFileObject> collector = new DiagnosticCollector<>(); // 該JavaFileManager實例是com.sun.tools.javac.file.JavacFileManager JavaFileManager manager= compiler.getStandardFileManager(collector, null, null);
但是compiler.getStandardFileManager()返回的是com.sun.tools.javac.file.JavacFileManager
實例,這個不是公開的類,所以我們無法直接使用,只能通過這種調用返回實例。
但是我們課也可以構造自己的FileManager,為了更好的構建,需要理解JavaFileManager
在內存中編譯時的使用過程,如下:
-
在編譯過程中,首先是編譯器會遍歷JavaFileManager對象,獲取指定位置的所有符合要求的JavaFileObject對象,甚至可以遞歸遍歷,這時調用的是list()方法,該方法會掃面所有涉及的到的包,包括一個類和它實現的接口和繼承的類:
-
之后根據獲取到的JavaFileObject對象,獲取它的二進制表示的名稱,通過調用inferBinaryName()方法;
-
之后是輸出編譯類,而類的表示為JavaFileObject對象,注意此時的
JavaFileObject.Kind
為CLASS
,調用的方法是getJavaFileForOutput()
,注意該方法的調用是在JavaFileObject
中openOutputStream()
方法之前,如下圖:
既然了解了上述的流程,我們自定義的文件管理器如下:
private static Map<String, JavaFileObject> fileObjects = new ConcurrentHashMap<>(); // 這里繼承類,不實現接口是為了避免實現過多的方法 public static class MyJavaFileManager extends ForwardingJavaFileManager<JavaFileManager> { protected MyJavaFileManager(JavaFileManager fileManager) { super(fileManager); } @Override public JavaFileObject getJavaFileForInput(Location location, String className, JavaFileObject.Kind kind) throws IOException { JavaFileObject javaFileObject = fileObjects.get(className); if(javaFileObject == null){ super.getJavaFileForInput(location, className, kind); } return javaFileObject; } @Override public JavaFileObject getJavaFileForOutput(Location location, String qualifiedClassName, JavaFileObject.Kind kind, FileObject sibling) throws IOException { JavaFileObject javaFileObject = new MyJavaFileObject(qualifiedClassName, kind); fileObjects.put(qualifiedClassName, javaFileObject); return javaFileObject; } }
5、編譯選項的選擇
在使用javac
命令的時候,可以添加很多的選項,在實現API完成編譯的時候也可以提供參數,比如編譯目標,輸出路徑以及類路徑等等,如下:
List<String> options = new ArrayList<>(); options.add("-target"); options.add("1.8"); options.add("-d"); options.add("/"); // 省略...... compiler.getTask(null, javaFileManager, collector, options, null, Arrays.asList(javaFileObject));
6、其他問題
- 想將編譯完成的字節碼輸出為文件,也不需要上面自定義JavaFileManager,直接使用JavaCompiler提供的即可,而且在自定義的JavaFileObject中也不需要實現OpenOutStream這種方法,代替要提供options.add(“-d”),options.add(“/”)等編譯選項;如果不輸出為文件按照上述的例子即可;
- StandardLocation中的元素可以代替真實的路徑位置,但是不會輸出為文件,可以為一個內存中的文件;
- 在編譯完成之后要將字節碼文件加載進來,因此就要涉及到類加載機制,由於這也是一個很大的話題,所以后面會專門總結一篇,但是在這里還是要說明一下,由於上面編譯時沒有額外的依賴包,所以不用考慮加載依賴文件的問題,但是當如果有這樣的需求時,我們可以利用類加載的委托機制,將依賴文件的加載全部交給父加載器去做即可。
完整的代碼如下:
package com.wdx.compiler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.tools.Diagnostic; import javax.tools.DiagnosticCollector; import javax.tools.FileObject; import javax.tools.ForwardingJavaFileManager; import javax.tools.JavaCompiler; import javax.tools.JavaFileManager; import javax.tools.JavaFileObject; import javax.tools.SimpleJavaFileObject; import javax.tools.ToolProvider; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.net.URI; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class CubeJavaCompiler{ private static final Logger logger = LoggerFactory.getLogger(CubeJavaCompiler.class); private static final JavaCompiler _compiler = ToolProvider.getSystemJavaCompiler(); private static final DiagnosticCollector<JavaFileObject> collector = new DiagnosticCollector<>(); private static final CubeJavaFileManager manager = new CubeJavaFileManager(_compiler.getStandardFileManager(collector, null, null)); private static final Map<String, JavaFileObject> fileObjectMap = new ConcurrentHashMap<>(); private static List<String> options = new ArrayList<>(); static { options.add("-Xlint:unchecked"); options.add("-target"); options.add("1.8"); } public static Class<?> compile(String code, String className) throws ClassNotFoundException{ String qualified = className.substring(className.lastIndexOf('.') + 1, className.length()); CubeJavaObject cubeJavaObject = new CubeJavaObject(qualified, code); JavaCompiler.CompilationTask task = _compiler.getTask(null, manager, collector, options, null, Arrays.asList(cubeJavaObject)); task.call(); //輸出診斷信息 for (Diagnostic<? extends JavaFileObject> diagnostic : collector.getDiagnostics()) { try { logger.error("編譯錯誤:{}", diagnostic.toString()); } catch (Exception e) { logger.error("輸出內容錯誤", e); } } return cubeJavaClassLoader.loadClass(className); } private static ClassLoader cubeJavaClassLoader = new ClassLoader() { @Override protected Class<?> findClass(String name) throws ClassNotFoundException { JavaFileObject fileObject = fileObjectMap.get(name); if(fileObject != null){ byte[] bytes = ((CubeJavaObject)fileObject).getCompiledBytes(); return defineClass(name, bytes, 0, bytes.length); } try{ return ClassLoader.getSystemClassLoader().loadClass(name); } catch (Exception e){ logger.error("加載類失敗,{}", name, e); return super.findClass(name); } } }; private static class CubeJavaObject extends SimpleJavaFileObject{ private String code; private ByteArrayOutputStream outPutStream; public CubeJavaObject(String qualified, String code) { super(URI.create("String:///" + qualified + Kind.SOURCE.extension), Kind.SOURCE); this.code = code; } public CubeJavaObject(String qualified, Kind kind) { super(URI.create("String:///" + qualified + kind.extension), kind); } @Override public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException { if(code == null){ throw new IllegalArgumentException("code required"); } return code; } @Override public OutputStream openOutputStream() throws IOException { outPutStream = new ByteArrayOutputStream(); return outPutStream; } public byte[] getCompiledBytes(){ return outPutStream.toByteArray(); } } private static class CubeJavaFileManager extends ForwardingJavaFileManager<JavaFileManager> { public CubeJavaFileManager(JavaFileManager fileManager) { super(fileManager); } @Override public JavaFileObject getJavaFileForInput(Location location, String className, JavaFileObject.Kind kind) throws IOException { JavaFileObject javaFileObject = fileObjectMap.get(className); if(javaFileObject == null){ super.getJavaFileForInput(location, className, kind); } return javaFileObject; } @Override public JavaFileObject getJavaFileForOutput(Location location, String className, JavaFileObject.Kind kind, FileObject sibling) throws IOException { JavaFileObject javaFileObject = new CubeJavaObject(className, kind); fileObjectMap.put(className, javaFileObject); return javaFileObject; } } }