動態生成類並載入
Java是一門靜態語言,通常,我們需要的class在編譯的時候就已經生成了,為什麼有時候我們還想在執行時動態生成class呢?
因為在有些時候,我們還真得在執行時為一個類動態建立子類。比如,編寫一個ORM框架,如何得知一個簡單的JavaBean是否被使用者修改過呢?
以User
為例:
public class User { private String id; private String name; public String getId() { return id; } public void setId(String id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } }
其實UserProxy
實現起來很簡單,就是建立一個User
的子類,覆寫所有setXxx()
方法,做個標記就可以了:
public class UserProxy extends User { private boolean dirty; public boolean isDirty() { return this.dirty; } public void setDirty(boolean dirty) { this.dirty = dirty; } @Override public void setId(String id) { super.setId(id); setDirty(true); } @Override public void setName(String name) { super.setName(name); setDirty(true); } }
但是這個UserProxy
就必須在執行時動態創建出來了,因為編譯時ORM框架根本不知道User
類。
現在問題來了,動態生成位元組碼,難度有多大?
如果我們要自己直接輸出二進位制格式的位元組碼,在完成這個任務前,必須先認真閱讀JVM規範第4章,詳細瞭解class檔案結構。估計讀完規範後,兩個月過去了。
所以,第一種方法,自己動手,從零開始建立位元組碼,理論上可行,實際上很難。
第二種方法,使用已有的一些能操作位元組碼的庫,幫助我們建立class。
目前,能夠操作位元組碼的開源庫主要有CGLib和Javassist兩種,它們都提供了比較高階的API來操作位元組碼,最後輸出為class檔案。
比如CGLib,典型的用法如下:
Enhancer e = new Enhancer();
e.setSuperclass(...);
e.setStrategy(new DefaultGeneratorStrategy() {
protected ClassGenerator transform(ClassGenerator cg) {
return new TransformingGenerator(cg,
new AddPropertyTransformer(new String[]{ "foo" },
new Class[] { Integer.TYPE }));
}});
Object obj = e.create();
比自己生成class要簡單,但是,要學會它的API還是得花大量的時間,並且,上面的程式碼很難看懂對不對?
有木有更簡單的方法?
有!
換一個思路,如果我們能建立UserProxy.java
這個原始檔,再呼叫Java編譯器,直接把原始碼編譯成class,再載入進虛擬機器,任務完成!
畢竟,建立一個字串格式的原始碼是很簡單的事情,就是拼字串嘛,高階點的做法可以用一個模版引擎。
如何編譯?
Java的編譯器是javac
,但是,在很早很早的時候,Java的編譯器就已經用純Java重寫了,自己能編譯自己,行業黑話叫“自舉”。從Java 1.6開始,編譯器介面正式放到JDK的公開API中,於是,我們不需要建立新的程序來呼叫javac
,而是直接使用編譯器API來編譯原始碼。
使用起來也很簡單:
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
int compilationResult = compiler.run(null, null, null, '/path/to/Test.java');
這麼寫編譯是沒啥問題,問題是我們在記憶體中建立了Java程式碼後,必須先寫到檔案,再編譯,最後還要手動讀取class檔案內容並用一個ClassLoader載入。
有木有更簡單的方法?
有!
其實Java編譯器根本不關心原始碼的內容是從哪來的,你給它一個String
當作原始碼,它就可以輸出byte[]
作為class的內容。
所以,我們需要參考Java Compiler API的文件,讓Compiler直接在記憶體中完成編譯,輸出的class內容就是byte[]
。
程式碼改造如下:
Map<String, byte[]> results;
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager stdManager = compiler.getStandardFileManager(null, null, null);
try (MemoryJavaFileManager manager = new MemoryJavaFileManager(stdManager)) {
JavaFileObject javaFileObject = manager.makeStringSource(fileName, source);
CompilationTask task = compiler.getTask(null, manager, null, null, null, Arrays.asList(javaFileObject));
if (task.call()) {
results = manager.getClassBytes();
}
}
上述程式碼的幾個關鍵在於:
- 用
MemoryJavaFileManager
替換JDK預設的StandardJavaFileManager
,以便在編譯器請求原始碼內容時,不是從檔案讀取,而是直接返回String
; - 用
MemoryOutputJavaFileObject
替換JDK預設的SimpleJavaFileObject
,以便在接收到編譯器生成的byte[]
內容時,不寫入class檔案,而是直接儲存在記憶體中。
最後,編譯的結果放在Map<String, byte[]>
中,Key是類名,對應的byte[]
是class的二進位制內容。
為什麼編譯後不是一個
byte[]
呢?
因為一個.java
的原始檔編譯後可能有多個.class
檔案!只要包含了靜態類、匿名類等,編譯出的class肯定多於一個。
如何載入編譯後的class呢?
載入class相對而言就容易多了,我們只需要建立一個ClassLoader
,覆寫findClass()
方法:
class MemoryClassLoader extends URLClassLoader {
Map<String, byte[]> classBytes = new HashMap<String, byte[]>();
public MemoryClassLoader(Map<String, byte[]> classBytes) {
super(new URL[0], MemoryClassLoader.class.getClassLoader());
this.classBytes.putAll(classBytes);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] buf = classBytes.get(name);
if (buf == null) {
return super.findClass(name);
}
classBytes.remove(name);
return defineClass(name, buf, 0, buf.length);
}
}
除了寫ORM用之外,還能幹什麼?
可以用它來做一個Java指令碼引擎。實際上本文的程式碼主要就是參考了Scripting專案的原始碼。
完整的原始碼呢?
在這裡:https://github.com/michaelliao/compiler,連Maven的包都給你準備好了!
也就200行程式碼吧!動態建立class不是夢