動態代理是Java語言中非常經典的一種設計模式,也是所有設計模式中最難理解的一種。本文將通過一個簡單的例子模擬JDK動態代理實現,讓你徹底明白動態代理設計模式的本質,文章中可能會涉及到一些你沒有學習過的知識點或概念。如果恰好遇到了這些知識盲點,請先去學習這部分知識,再來閱讀這篇文章。
什麼是代理
從字面意思來看,代理比較好理解,無非就是代為處理的意思。舉個例子,你在上大學的時候,總是喜歡逃課。因此,你拜託你的同學幫你答到,而自己卻窩在宿舍玩遊戲... 你的這個同學恰好就充當了代理的作用,代替你去上課。
是的,你沒有看錯,代理就是這麼簡單!
理解了代理的意思,你腦海中恐怕還有兩個巨大的疑問:
- 怎麼實現代理模式
- 代理模式有什麼實際用途
要理解這兩個問題,看一個簡單的例子:
public interface Flyable {
void fly();
}
public class Bird implements Flyable {
@Override
public void fly() {
System.out.println("Bird is flying...");
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
複製程式碼
很簡單的一個例子,用一個隨機睡眠時間模擬小鳥在空中的飛行時間。接下來問題來了,如果我要知道小鳥在天空中飛行了多久,怎麼辦?
有人說,很簡單,在Bird->fly()方法的開頭記錄起始時間,在方法結束記錄完成時間,兩個時間相減就得到了飛行時間。
@Override
public void fly() {
long start = System.currentTimeMillis();
System.out.println("Bird is flying...");
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("Fly time = " + (end - start));
}
複製程式碼
的確,這個方法沒有任何問題,接下來加大問題的難度。如果Bird這個類來自於某個SDK(或者說Jar包)提供,你無法改動原始碼,怎麼辦?
一定會有人說,我可以在呼叫的地方這樣寫:
public static void main(String[] args) {
Bird bird = new Bird();
long start = System.currentTimeMillis();
bird.fly();
long end = System.currentTimeMillis();
System.out.println("Fly time = " + (end - start));
}
複製程式碼
這個方案看起來似乎沒有問題,但其實你忽略了準備這些方法所需要的時間,執行一個方法,需要開闢棧記憶體、壓棧、出棧等操作,這部分時間也是不可以忽略的。因此,這個解決方案不可行。那麼,還有什麼方法可以做到呢?
a)使用繼承
繼承是最直觀的解決方案,相信你已經想到了,至少我最開始想到的解決方案就是繼承。 為此,我們重新建立一個類Bird2,在Bird2中我們只做一件事情,就是呼叫父類的fly方法,在前後記錄時間,並列印時間差:
public class Bird2 extends Bird {
@Override
public void fly() {
long start = System.currentTimeMillis();
super.fly();
long end = System.currentTimeMillis();
System.out.println("Fly time = " + (end - start));
}
}
複製程式碼
這是一種解決方案,還有一種解決方案叫做:聚合,其實也是比較容易想到的。 我們再次建立新類Bird3,在Bird3的構造方法中傳入Bird例項。同時,讓Bird3也實現Flyable介面,並在fly方法中呼叫傳入的Bird例項的fly方法:
public class Bird3 implements Flyable {
private Bird bird;
public Bird3(Bird bird) {
this.bird = bird;
}
@Override
public void fly() {
long start = System.currentTimeMillis();
bird.fly();
long end = System.currentTimeMillis();
System.out.println("Fly time = " + (end - start));
}
}
複製程式碼
為了記錄Bird->fly()方法的執行時間,我們在前後新增了記錄時間的程式碼。同樣地,通過這種方法我們也可以獲得小鳥的飛行時間。那麼,這兩種方法孰優孰劣呢?咋一看,不好評判!
繼續深入思考,用問題推導來解答這個問題:
問題一:如果我還需要在fly方法前後列印日誌,記錄飛行開始和飛行結束,怎麼辦? 有人說,很簡單!繼承Bird2並在在前後新增列印語句即可。那麼,問題來了,請看問題二。
問題二:如果我需要調換執行順序,先列印日誌,再獲取飛行時間,怎麼辦? 有人說,再新建一個類Bird4繼承Bird,列印日誌。再新建一個類Bird5繼承Bird4,獲取方法執行時間。
問題顯而易見:使用繼承將導致類無限制擴充套件,同時靈活性也無法獲得保障。那麼,使用 聚合 是否可以避免這個問題呢? 答案是:可以!但我們的類需要稍微改造一下。修改Bird3類,將聚合物件Bird型別修改為Flyable
public class Bird3 implements Flyable {
private Flyable flyable;
public Bird3(Flyable flyable) {
this.flyable = flyable;
}
@Override
public void fly() {
long start = System.currentTimeMillis();
flyable.fly();
long end = System.currentTimeMillis();
System.out.println("Fly time = " + (end - start));
}
}
複製程式碼
為了讓你看的更清楚,我將Bird3更名為BirdTimeProxy,即用於獲取方法執行時間的代理的意思。同時我們新建BirdLogProxy代理類用於列印日誌:
public class BirdLogProxy implements Flyable {
private Flyable flyable;
public BirdLogProxy(Flyable flyable) {
this.flyable = flyable;
}
@Override
public void fly() {
System.out.println("Bird fly start...");
flyable.fly();
System.out.println("Bird fly end...");
}
}
複製程式碼
接下來神奇的事情發生了,如果我們需要先記錄日誌,再獲取飛行時間,可以在呼叫的地方這麼做:
public static void main(String[] args) {
Bird bird = new Bird();
BirdLogProxy p1 = new BirdLogProxy(bird);
BirdTimeProxy p2 = new BirdTimeProxy(p1);
p2.fly();
}
複製程式碼
反過來,可以這麼做:
public static void main(String[] args) {
Bird bird = new Bird();
BirdTimeProxy p2 = new BirdTimeProxy(bird);
BirdLogProxy p1 = new BirdLogProxy(p2);
p1.fly();
}
複製程式碼
看到這裡,有同學可能會有疑問了。雖然現象看起來,聚合可以靈活調換執行順序。可是,為什麼 聚合 可以做到,而繼承不行呢。我們用一張圖來解釋一下:
靜態代理
接下來,觀察上面的類BirdTimeProxy,在它的fly方法中我們直接呼叫了flyable->fly()方法。換而言之,BirdTimeProxy其實代理了傳入的Flyable物件,這就是典型的靜態代理實現。
從表面上看,靜態代理已經完美解決了我們的問題。可是,試想一下,如果我們需要計算SDK中100個方法的執行時間,同樣的程式碼至少需要重複100次,並且建立至少100個代理類。往小了說,如果Bird類有多個方法,我們需要知道其他方法的執行時間,同樣的程式碼也至少需要重複多次。因此,靜態代理至少有以下兩個侷限性問題:
- 如果同時代理多個類,依然會導致類無限制擴充套件
- 如果類中有多個方法,同樣的邏輯需要反覆實現
那麼,我們是否可以使用同一個代理類來代理任意物件呢?我們以獲取方法執行時間為例,是否可以使用同一個類(例如:TimeProxy)來計算任意物件的任一方法的執行時間呢?甚至再大膽一點,代理的邏輯也可以自己指定。比如,獲取方法的執行時間,列印日誌,這類邏輯都可以自己指定。這就是本文重點探討的問題,也是最難理解的部分:動態代理。
動態代理
繼續回到上面這個問題:是否可以使用同一個類(例如:TimeProxy)來計算任意物件的任一方法的執行時間呢。
這個部分需要一定的抽象思維,我想,你腦海中的第一個解決方案應該是使用反射。反射是用於獲取已建立例項的方法或者屬性,並對其進行呼叫或者賦值。很明顯,在這裡,反射解決不了問題。但是,再大膽一點,如果我們可以動態生成TimeProxy這個類,並且動態編譯。然後,再通過反射建立物件並載入到記憶體中,不就實現了對任意物件進行代理了嗎?為了防止你依然一頭霧水,我們用一張圖來描述接下來要做什麼:
動態生成Java原始檔並且排版是一個非常繁瑣的工作,為了簡化操作,我們使用 JavaPoet 這個第三方庫幫我們生成TimeProxy的原始碼。希望 JavaPoet 不要成為你的負擔,不理解 JavaPoet 沒有關係,你只要把它當成一個Java原始碼生成工具使用即可。
PS:你記住,任何工具庫的使用都不會太難,它是為了簡化某些操作而出現的,目標是簡化而不是繁瑣。因此,只要你適應它的規則就輕車熟路了。
第一步:生成TimeProxy原始碼
public class Proxy {
public static Object newProxyInstance() throws IOException {
TypeSpec.Builder typeSpecBuilder = TypeSpec.classBuilder("TimeProxy")
.addSuperinterface(Flyable.class);
FieldSpec fieldSpec = FieldSpec.builder(Flyable.class, "flyable", Modifier.PRIVATE).build();
typeSpecBuilder.addField(fieldSpec);
MethodSpec constructorMethodSpec = MethodSpec.constructorBuilder()
.addModifiers(Modifier.PUBLIC)
.addParameter(Flyable.class, "flyable")
.addStatement("this.flyable = flyable")
.build();
typeSpecBuilder.addMethod(constructorMethodSpec);
Method[] methods = Flyable.class.getDeclaredMethods();
for (Method method : methods) {
MethodSpec methodSpec = MethodSpec.methodBuilder(method.getName())
.addModifiers(Modifier.PUBLIC)
.addAnnotation(Override.class)
.returns(method.getReturnType())
.addStatement("long start = $T.currentTimeMillis()", System.class)
.addCode("\n")
.addStatement("this.flyable." + method.getName() + "()")
.addCode("\n")
.addStatement("long end = $T.currentTimeMillis()", System.class)
.addStatement("$T.out.println(\"Fly Time =\" + (end - start))", System.class)
.build();
typeSpecBuilder.addMethod(methodSpec);
}
JavaFile javaFile = JavaFile.builder("com.youngfeng.proxy", typeSpecBuilder.build()).build();
// 為了看的更清楚,我將原始碼檔案生成到桌面
javaFile.writeTo(new File("/Users/ouyangfeng/Desktop/"));
return null;
}
}
複製程式碼
在main方法中呼叫Proxy.newProxyInstance(),你將看到桌面已經生成了TimeProxy.java檔案,生成的內容如下:
package com.youngfeng.proxy;
import java.lang.Override;
import java.lang.System;
class TimeProxy implements Flyable {
private Flyable flyable;
public TimeProxy(Flyable flyable) {
this.flyable = flyable;
}
@Override
public void fly() {
long start = System.currentTimeMillis();
this.flyable.fly();
long end = System.currentTimeMillis();
System.out.println("Fly Time =" + (end - start));
}
}
複製程式碼
第二步:編譯TimeProxy原始碼
編譯TimeProxy原始碼我們直接使用JDK提供的編譯工具即可,為了使你看起來更清晰,我使用一個新的輔助類來完成編譯操作:
public class JavaCompiler {
public static void compile(File javaFile) throws IOException {
javax.tools.JavaCompiler javaCompiler = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager fileManager = javaCompiler.getStandardFileManager(null, null, null);
Iterable iterable = fileManager.getJavaFileObjects(javaFile);
javax.tools.JavaCompiler.CompilationTask task = javaCompiler.getTask(null, fileManager, null, null, null, iterable);
task.call();
fileManager.close();
}
}
複製程式碼
在Proxy->newProxyInstance()方法中呼叫該方法,編譯順利完成:
// 為了看的更清楚,我將原始碼檔案生成到桌面
String sourcePath = "/Users/ouyangfeng/Desktop/";
javaFile.writeTo(new File(sourcePath));
// 編譯
JavaCompiler.compile(new File(sourcePath + "/com/youngfeng/proxy/TimeProxy.java"));
複製程式碼
第三步:載入到記憶體中並建立物件
URL[] urls = new URL[] {new URL("file:/" + sourcePath)};
URLClassLoader classLoader = new URLClassLoader(urls);
Class clazz = classLoader.loadClass("com.youngfeng.proxy.TimeProxy");
Constructor constructor = clazz.getConstructor(Flyable.class);
Flyable flyable = (Flyable) constructor.newInstance(new Bird());
flyable.fly();
複製程式碼
通過以上三個步驟,我們至少解決了下面兩個問題:
- 不再需要手動建立TimeProxy
- 可以代理任意實現了Flyable介面的類物件,並獲取介面方法的執行時間
可是,說好的任意物件呢?
第四步:增加InvocationHandler介面
檢視Proxy->newProxyInstance()的原始碼,代理類繼承的介面我們是寫死的,為了增加靈活性,我們將介面型別作為引數傳入:
介面的靈活性問題解決了,TimeProxy的侷限性依然存在,它只能用於獲取方法的執行時間,而如果要在方法執行前後列印日誌則需要重新建立一個代理類,顯然這是不妥的!
為了增加控制的靈活性,我們考慮針將代理的處理邏輯也抽離出來(這裡的處理就是列印方法的執行時間)。新增InvocationHandler
介面,用於處理自定義邏輯:
public interface InvocationHandler {
void invoke(Object proxy, Method method, Object[] args);
}
複製程式碼
想象一下,如果客戶程式設計師需要對代理類進行自定義的處理,只要實現該介面,並在invoke方法中進行相應的處理即可。這裡我們在介面中設定了三個引數(其實也是為了和JDK原始碼保持一致):
- proxy => 這個引數指定動態生成的代理類,這裡是
TimeProxy
- method => 這個參數列示傳入介面中的所有Method物件
- args => 這個引數對應當前method方法中的引數
引入了InvocationHandler介面之後,我們的呼叫順序應該變成了這樣:
MyInvocationHandler handler = new MyInvocationHandler();
Flyable proxy = Proxy.newProxyInstance(Flyable.class, handler);
proxy.fly();
方法執行流:proxy.fly() => handler.invoke()
複製程式碼
為此,我們需要在Proxy.newProxyInstance()方法中做如下改動:
- 在newProxyInstance方法中傳入InvocationHandler
- 在生成的代理類中增加成員變數handler
- 在生成的代理類方法中,呼叫invoke方法
public static Object newProxyInstance(Class inf, InvocationHandler handler) throws Exception {
TypeSpec.Builder typeSpecBuilder = TypeSpec.classBuilder("TimeProxy")
.addModifiers(Modifier.PUBLIC)
.addSuperinterface(inf);
FieldSpec fieldSpec = FieldSpec.builder(InvocationHandler.class, "handler", Modifier.PRIVATE).build();
typeSpecBuilder.addField(fieldSpec);
MethodSpec constructorMethodSpec = MethodSpec.constructorBuilder()
.addModifiers(Modifier.PUBLIC)
.addParameter(InvocationHandler.class, "handler")
.addStatement("this.handler = handler")
.build();
typeSpecBuilder.addMethod(constructorMethodSpec);
Method[] methods = inf.getDeclaredMethods();
for (Method method : methods) {
MethodSpec methodSpec = MethodSpec.methodBuilder(method.getName())
.addModifiers(Modifier.PUBLIC)
.addAnnotation(Override.class)
.returns(method.getReturnType())
.addCode("try {\n")
.addStatement("\t$T method = " + inf.getName() + ".class.getMethod(\"" + method.getName() + "\")", Method.class)
// 為了簡單起見,這裡引數直接寫死為空
.addStatement("\tthis.handler.invoke(this, method, null)")
.addCode("} catch(Exception e) {\n")
.addCode("\te.printStackTrace();\n")
.addCode("}\n")
.build();
typeSpecBuilder.addMethod(methodSpec);
}
JavaFile javaFile = JavaFile.builder("com.youngfeng.proxy", typeSpecBuilder.build()).build();
// 為了看的更清楚,我將原始碼檔案生成到桌面
String sourcePath = "/Users/ouyangfeng/Desktop/";
javaFile.writeTo(new File(sourcePath));
// 編譯
JavaCompiler.compile(new File(sourcePath + "/com/youngfeng/proxy/TimeProxy.java"));
// 使用反射load到記憶體
URL[] urls = new URL[] {new URL("file:" + sourcePath)};
URLClassLoader classLoader = new URLClassLoader(urls);
Class clazz = classLoader.loadClass("com.youngfeng.proxy.TimeProxy");
Constructor constructor = clazz.getConstructor(InvocationHandler.class);
Object obj = constructor.newInstance(handler);
return obj;
}
複製程式碼
上面的程式碼你可能看起來比較吃力,我們直接呼叫該方法,檢視最後生成的原始碼。在main方法中測試newProxyInstance檢視生成的TimeProxy原始碼:
測試程式碼
Proxy.newProxyInstance(Flyable.class, new MyInvocationHandler(new Bird()));
複製程式碼
生成的TimeProxy.java原始碼
package com.youngfeng.proxy;
import java.lang.Override;
import java.lang.reflect.Method;
public class TimeProxy implements Flyable {
private InvocationHandler handler;
public TimeProxy(InvocationHandler handler) {
this.handler = handler;
}
@Override
public void fly() {
try {
Method method = com.youngfeng.proxy.Flyable.class.getMethod("fly");
this.handler.invoke(this, method, null);
} catch(Exception e) {
e.printStackTrace();
}
}
}
複製程式碼
MyInvocationHandler.java
public class MyInvocationHandler implements InvocationHandler {
private Bird bird;
public MyInvocationHandler(Bird bird) {
this.bird = bird;
}
@Override
public void invoke(Object proxy, Method method, Object[] args) {
long start = System.currentTimeMillis();
try {
method.invoke(bird, new Object[] {});
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("Fly time = " + (end - start));
}
}
複製程式碼
至此,整個方法棧的呼叫棧變成了這樣:
看到這裡,估計很多同學已經暈了,在靜態代理部分,我們在代理類中傳入了被代理物件。可是,使用newProxyInstance生成動態代理物件的時候,我們居然不再需要傳入被代理物件了。我們傳入了的實際物件是InvocationHandler實現類的例項,這看起來有點像生成了InvocationHandler的代理物件,在動態生成的代理類的任意方法中都會間接呼叫InvocationHandler->invoke(proxy, method, args)方法。
其實的確是這樣。TimeProxy真正代理的物件就是InvocationHandler,不過這裡設計的巧妙之處在於,InvocationHandler是一個介面,真正的實現由使用者指定。另外,在每一個方法執行的時候,invoke方法都會被呼叫 ,這個時候如果你需要對某個方法進行自定義邏輯處理,可以根據method的特徵資訊進行判斷分別處理。
如何使用
上面這段解釋是告訴你在執行Proxy->newProxyInstance方法的時候真正發生的事情,而在實際使用過程中你完全可以忘掉上面的解釋。按照設計者的初衷,我們做如下簡單歸納:
- Proxy->newProxyInstance(infs, handler) 用於生成代理物件
- InvocationHandler:這個介面主要用於自定義代理邏輯處理
- 為了完成對被代理物件的方法攔截,我們需要在InvocationHandler物件中傳入被代理物件例項。
檢視上面的程式碼,你可以看到我將Bird例項已經傳入到了MyInvocationHandler中,原因就是第三點。
這樣設計有什麼好處呢?有人說,我們大費周章,饒了一大圈,最終變成了這個樣子,到底圖什麼呢?
想象一下,到此為止,如果我們還需要對其它任意物件進行代理,是否還需要改動newProxyInstance方法的原始碼,答案是:完全不需要!
只要你在newProxyInstance方法中指定代理需要實現的介面,指定用於自定義處理的InvocationHandler物件,整個代理的邏輯處理都在你自定義的InvocationHandler實現類中進行處理。至此,而我們終於可以從不斷地寫代理類用於實現自定義邏輯的重複工作中解放出來了,從此需要做什麼,交給InvocationHandler。
事實上,我們之前給自己定下的目標“使用同一個類來計算任意物件的任一方法的執行時間”已經實現了。嚴格來說,是我們超額完成了任務,TimeProxy不僅可以計算方法執行的時間,也可以列印方法執行日誌,這完全取決於你的InvocationHandler介面實現。因此,這裡取名為TimeProxy其實已經不合適了。我們可以修改為和JDK命名一致,即$Proxy0,感興趣的同學請自行實踐,本篇文章的程式碼將放到我的Github倉庫,文章結尾會給出程式碼地址。
JDK實現揭祕
通過上面的這些步驟,我們完成了一個簡易的仿JDK實現的動態代理邏輯。接下來,我們一起來看一看JDK實現的動態代理和我們到底有什麼不同。
Proxy.java
InvocationHandler
可以看到,官方版本Proxy類提供的方法多一些,而我們主要使用的介面newProxyInstance引數也和我們設計的不太一樣。這裡給大家簡單解釋一下,每個引數的意義:
- Classloader:類載入器,你可以使用自定義的類載入器,我們的實現版本為了簡化,直接在程式碼中寫死了Classloader。
- Class<?>[]:第二個引數也和我們的實現版本不一致,這個其實很容易理解,我們應該允許我們自己實現的代理類同時實現多個介面。前面設計只傳入一個介面,只是為了簡化實現,讓你專注核心邏輯實現而已。
最後一個引數就不用說了,和我們實現的版本完全是一樣的。
仔細觀察官方版本的InvocationHandler,它和我們自己的實現的版本也有一個細微的差別:官方版本invoke方法有返回值,而我們的版本中是沒有返回值的。那麼,返回值到底有什麼作用呢?直接來看官方文件:
核心思想:這裡的返回值型別必須和傳入介面的返回值型別一致,或者與其封裝物件的型別一致。
遺憾的是,這裡並沒有說明返回值的用途,其實這裡稍微發揮一下想象力就知道了。在我們的版本實現中,Flyable介面的所有方法都是沒有返回值的,問題是,如果有返回值呢?是的,你沒有猜錯,這裡的invoke方法對應的就是傳入介面中方法的返回值。
答疑解惑
invoke方法的第一個引數proxy到底有什麼作用?
這個問題其實也好理解,如果你的介面中有方法需要返回自身,如果在invoke中沒有傳入這個引數,將導致例項無法正常返回。在這種場景中,proxy的用途就表現出來了。簡單來說,這其實就是最近非常火的鏈式程式設計的一種應用實現。
動態代理到底有什麼用?
學習任何一門技術,一定要問一問自己,這到底有什麼用。其實,在這篇文章的講解過程中,我們已經說出了它的主要用途。你發現沒,使用動態代理我們居然可以在不改變原始碼的情況下,直接在方法中插入自定義邏輯。這有點不太符合我們的一條線走到底的程式設計邏輯,這種程式設計模型有一個專業名稱叫 AOP。所謂的AOP,就像刀一樣,抓住時機,趁機插入。
基於這樣一種動態特性,我們可以用它做很多事情,例如:
- 事務提交或回退(Web開發中很常見)
- 許可權管理
- 自定義快取邏輯處理
- SDK Bug修復 ...
如果你閱讀過 Android_Slide_To_Close 的原始碼會發現,它也在某個地方使用了動態代理設計模式。
總結
到此為止,關於動態代理的所有講解已經結束了,原諒我使用了一個誘導性的標題“騙”你進來閱讀這篇文章。如果你不是一個久經沙場的“老司機”,10分鐘完全看懂動態代理設計模式還是有一定難度的。但即使沒有看懂也沒關係,如果你在第一次閱讀完這篇文章後依然一頭霧水,就不妨再仔細閱讀一次。在閱讀的過程中,一定要跟著文章思路去敲程式碼。反反覆覆,一定會看懂的。我在剛剛學習動態代理設計模式的時候就反覆看了不下5遍,並且親自敲程式碼實踐了多次。
為了讓你少走彎路,我認為看懂這篇文章,你至少需要學習以下知識點:
- 至少已經理解了面嚮物件語言的多型特性
- 瞭解簡單的反射用法
- 會簡單使用 JavaPoet 生成Java原始碼
如果你在閱讀文章的過程中,有任何不理解的問題或者建議,歡迎在文章下方留言告訴我!
本篇文章例子程式碼:github.com/yuanhoujun/…
我是歐陽鋒,設計模式是一種非常好的程式設計指導模型,它在所有程式語言中是通用的,並且是亙古不變的。我建議你在這個方面多下苦功,不要糾結在一些重複的勞動中,活用設計模式會讓你的程式碼更顯靈動。想要了解我嗎?看這裡:歐陽鋒檔案館。