动态代理是 Java
语言中非常经典的一种设计模式,也是所有设计模式中最难理解的一种。本文将通过一个简单的例子模拟 JDK
动态代理实现,让你彻底明白动态代理设计模式的本质。
什么是代理
从字面意思来看,代理比较好理解,无非就是代为处理的意思。举个例子,你在上大学的时候,总是喜欢逃课。因此,你拜托你的同学帮你答到,而自己却窝在宿舍玩游戏… 你的这个同学恰好就充当了代理的作用,代替你去上课。
是的,你没有看错,代理就是这么简单!
理解了代理的意思,你脑海中恐怕还有两个巨大的疑问:
- 怎么实现代理模式
- 代理模式有什么实际用途
要理解这两个问题,看一个简单的例子:
1 | public interface Flyable { |
很简单的一个例子,用一个随机睡眠时间模拟小鸟在空中的飞行时间。接下来问题来了,如果我要知道小鸟在天空中飞行了多久,怎么办?
有人说,很简单,在 Bird->fly ()
方法的开头记录起始时间,在方法结束记录完成时间,两个时间相减就得到了飞行时间。
1 |
|
的确,这个方法没有任何问题,接下来加大问题的难度。如果 Bird
这个类来自于某个 SDK
(或者说 Jar
包)提供,你无法改动源码,怎么办?
一定会有人说,我可以在调用的地方这样写:
1 | public static void main(String[] args) { |
这个方案看起来似乎没有问题,但其实你忽略了准备这些方法所需要的时间,执行一个方法,需要开辟栈内存、压栈、出栈等操作,这部分时间也是不可以忽略的。因此,这个解决方案不可行。那么,还有什么方法可以做到呢?
使用继承,继承是最直观的解决方案,相信你已经想到了,至少我最开始想到的解决方案就是继承。 为此,我们重新创建一个类 Bird2
,在 Bird2
中我们只做一件事情,就是调用父类的 fly
方法,在前后记录时间,并打印时间差:
1 | public class Bird2 extends Bird { |
这是一种解决方案,还有一种解决方案叫做:聚合,其实也是比较容易想到的。 我们再次创建新类 Bird3
,在 Bird3
的构造方法中传入 Bird
实例。同时,让 Bird3
也实现 Flyable
接口,并在 fly
方法中调用传入的 Bird
实例的 fly
方法:
1 | public class Bird3 implements Flyable { |
为了记录 Bird->fly ()
方法的执行时间,我们在前后添加了记录时间的代码。同样地,通过这种方法我们也可以获得小鸟的飞行时间。那么,这两种方法孰优孰劣呢?咋一看,不好评判!
继续深入思考,用问题推导来解答这个问题:
问题一:如果我还需要在 fly
方法前后打印日志,记录飞行开始和飞行结束,怎么办? 有人说,很简单!继承 Bird2
并在在前后添加打印语句即可。那么,问题来了,请看问题二。
问题二:如果我需要调换执行顺序,先打印日志,再获取飞行时间,怎么办? 有人说,再新建一个类 Bird4
继承 Bird
,打印日志。再新建一个类 Bird5
继承 Bird4
,获取方法执行时间。
问题显而易见:使用继承将导致类无限制扩展,同时灵活性也无法获得保障。那么,使用聚合是否可以避免这个问题呢? 答案是:可以!但我们的类需要稍微改造一下。修改 Bird3
类,将聚合对象 Bird
类型修改为 Flyable
1 | public class Bird3 implements Flyable { |
为了让你看的更清楚,我将 Bird3
更名为 BirdTimeProxy
,即用于获取方法执行时间的代理的意思。同时我们新建 BirdLogProxy
代理类用于打印日志:
1 | public class BirdLogProxy implements Flyable { |
接下来神奇的事情发生了,如果我们需要先记录日志,再获取飞行时间,可以在调用的地方这么做:
1 | public static void main(String[] args) { |
反过来,可以这么做:
1 | public static void main(String[] args) { |
看到这里,有同学可能会有疑问了。虽然现象看起来,聚合可以灵活调换执行顺序。可是,为什么聚合可以做到,而继承不行呢。我们用一张图来解释一下:
静态代理
接下来,观察上面的类 BirdTimeProxy
,在它的 fly
方法中我们直接调用了 flyable->fly ()
方法。换而言之,BirdTimeProxy
其实代理了传入的 Flyable
对象,这就是典型的静态代理实现。
从表面上看,静态代理已经完美解决了我们的问题。可是,试想一下,如果我们需要计算 SDK
中 100 个方法的运行时间,同样的代码至少需要重复 100 次,并且创建至少 100 个代理类。往小了说,如果 Bird
类有多个方法,我们需要知道其他方法的运行时间,同样的代码也至少需要重复多次。因此,静态代理至少有以下两个局限性问题:
- 如果同时代理多个类,依然会导致类无限制扩展
- 如果类中有多个方法,同样的逻辑需要反复实现
那么,我们是否可以使用同一个代理类来代理任意对象呢?我们以获取方法运行时间为例,是否可以使用同一个类(例如:TimeProxy
)来计算任意对象的任一方法的执行时间呢?甚至再大胆一点,代理的逻辑也可以自己指定。比如,获取方法的执行时间,打印日志,这类逻辑都可以自己指定。这就是本文重点探讨的问题,也是最难理解的部分:动态代理。
动态代理
继续回到上面这个问题:是否可以使用同一个类(例如:TimeProxy
)来计算任意对象的任一方法的执行时间呢。
这个部分需要一定的抽象思维,我想,你脑海中的第一个解决方案应该是使用反射。反射是用于获取已创建实例的方法或者属性,并对其进行调用或者赋值。很明显,在这里,反射解决不了问题。但是,再大胆一点,如果我们可以动态生成 TimeProxy
这个类,并且动态编译。然后,再通过反射创建对象并加载到内存中,不就实现了对任意对象进行代理了吗?为了防止你依然一头雾水,我们用一张图来描述接下来要做什么:
动态生成 Java
源文件并且排版是一个非常繁琐的工作,为了简化操作,我们使用 JavaPoet
这个第三方库帮我们生成 TimeProxy
的源码。希望 JavaPoet
不要成为你的负担,不理解 JavaPoet
没有关系,你只要把它当成一个 Java
源码生成工具使用即可。
PS:你记住,任何工具库的使用都不会太难,它是为了简化某些操作而出现的,目标是简化而不是繁琐。因此,只要你适应它的规则就轻车熟路了。
第一步:生成 TimeProxy 源码
1 | public class Proxy { |
在 main
方法中调用 Proxy.newProxyInstance ()
,你将看到桌面已经生成了 TimeProxy.java
文件,生成的内容如下:
1 | package com.youngfeng.proxy; |
第二步:编译 TimeProxy 源码
编译 TimeProxy
源码我们直接使用 JDK
提供的编译工具即可,为了使你看起来更清晰,我使用一个新的辅助类来完成编译操作:
1 | public class JavaCompiler { |
在 Proxy->newProxyInstance ()
方法中调用该方法,编译顺利完成:
1 | // 为了看的更清楚,我将源码文件生成到桌面 |
第三步:加载到内存中并创建对象
1 | URL[] urls = new URL[] {new URL("file:/" + sourcePath)}; |
通过以上三个步骤,我们至少解决了下面两个问题:
- 不再需要手动创建
TimeProxy
- 可以代理任意实现了
Flyable
接口的类对象,并获取接口方法的执行时间
可是,说好的任意对象呢?
第四步:增加 InvocationHandler 接口
查看 Proxy->newProxyInstance ()
的源码,代理类继承的接口我们是写死的,为了增加灵活性,我们将接口类型作为参数传入:
接口的灵活性问题解决了,TimeProxy
的局限性依然存在,它只能用于获取方法的执行时间,而如果要在方法执行前后打印日志则需要重新创建一个代理类,显然这是不妥的!
为了增加控制的灵活性,我们考虑针将代理的处理逻辑也抽离出来(这里的处理就是打印方法的执行时间)。新增 InvocationHandler
接口,用于处理自定义逻辑:
1 | public interface InvocationHandler { |
想象一下,如果客户程序员需要对代理类进行自定义的处理,只要实现该接口,并在 invoke
方法中进行相应的处理即可。这里我们在接口中设置了三个参数(其实也是为了和 JDK 源码保持一致):
- proxy:这个参数指定动态生成的代理类,这里是
TimeProxy
- method:这个参数表示传入接口中的所有
Method
对象 - args:这个参数对应当前
method
方法中的参数
引入了 InvocationHandler
接口之后,我们的调用顺序应该变成了这样:
1 | MyInvocationHandler handler = new MyInvocationHandler(); |
方法执行流:proxy.fly() => handler.invoke()
为此,我们需要在 Proxy.newProxyInstance ()
方法中做如下改动:
- 在
newProxyInstance
方法中传入InvocationHandler
- 在生成的代理类中增加成员变量
handler
- 在生成的代理类方法中,调用
invoke
方法
1 | public static Object newProxyInstance(Class inf, InvocationHandler handler) throws Exception { |
上面的代码你可能看起来比较吃力,我们直接调用该方法,查看最后生成的源码。在 main
方法中测试 newProxyInstance
查看生成的 TimeProxy
源码:
测试代码
1 | Proxy.newProxyInstance(Flyable.class, new MyInvocationHandler(new Bird())); |
生成的 TimeProxy.java 源码
1 | package com.youngfeng.proxy; |
MyInvocationHandler.java
1 | public class MyInvocationHandler implements InvocationHandler { |
至此,整个方法栈的调用栈变成了这样:
看到这里,估计很多同学已经晕了,在静态代理部分,我们在代理类中传入了被代理对象。可是,使用 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
,感兴趣的同学请自行实践。
JDK 实现揭秘
通过上面的这些步骤,我们完成了一个简易的仿 JDK
实现的动态代理逻辑。接下来,我们一起来看一看 JDK
实现的动态代理和我们到底有什么不同。
Proxy.java
InvocationHandler
可以看到,官方版本 Proxy
类提供的方法多一些,而我们主要使用的接口 newProxyInstance
参数也和我们设计的不太一样。这里给大家简单解释一下,每个参数的意义:
- Classloader:类加载器,你可以使用自定义的类加载器,我们的实现版本为了简化,直接在代码中写死了
Classloader
。 - Class<?>[]:第二个参数也和我们的实现版本不一致,这个其实很容易理解,我们应该允许我们自己实现的代理类同时实现多个接口。前面设计只传入一个接口,只是为了简化实现,让你专注核心逻辑实现而已。
最后一个参数就不用说了,和我们实现的版本完全是一样的。
仔细观察官方版本的 InvocationHandler
,它和我们自己的实现的版本也有一个细微的差别:官方版本 invoke
方法有返回值,而我们的版本中是没有返回值的。那么,返回值到底有什么作用呢?直接来看官方文档:
核心思想:这里的返回值类型必须和传入接口的返回值类型一致,或者与其封装对象的类型一致。
遗憾的是,这里并没有说明返回值的用途,其实这里稍微发挥一下想象力就知道了。在我们的版本实现中,Flyable
接口的所有方法都是没有返回值的,问题是,如果有返回值呢?是的,你没有猜错,这里的 invoke
方法对应的就是传入接口中方法的返回值。
答疑解惑
invoke 方法的第一个参数 proxy 到底有什么作用?
这个问题其实也好理解,如果你的接口中有方法需要返回自身,如果在 invoke
中没有传入这个参数,将导致实例无法正常返回。在这种场景中,proxy
的用途就表现出来了。简单来说,这其实就是最近非常火的链式编程的一种应用实现。
动态代理到底有什么用?
学习任何一门技术,一定要问一问自己,这到底有什么用。其实,在这篇文章的讲解过程中,我们已经说出了它的主要用途。你发现没,使用动态代理我们居然可以在不改变源码的情况下,直接在方法中插入自定义逻辑。这有点不太符合我们的一条线走到底的编程逻辑,这种编程模型有一个专业名称叫 AOP
。所谓的 AOP
,就像刀一样,抓住时机,趁机插入。
基于这样一种动态特性,我们可以用它做很多事情,例如:
- 事务提交或回退(
Web
开发中很常见) - 权限管理
- 自定义缓存逻辑处理
SDK Bug
修复 …
总结
到此为止,关于动态代理的所有讲解已经结束了,原谅我使用了一个诱导性的标题 “骗” 你进来阅读这篇文章。如果你不是一个久经沙场的 “老司机”,10 分钟完全看懂动态代理设计模式还是有一定难度的。但即使没有看懂也没关系,如果你在第一次阅读完这篇文章后依然一头雾水,就不妨再仔细阅读一次。在阅读的过程中,一定要跟着文章思路去敲代码。反反复复,一定会看懂的。我在刚刚学习动态代理设计模式的时候就反复看了不下 5 遍,并且亲自敲代码实践了多次。
为了让你少走弯路,我认为看懂这篇文章,你至少需要学习以下知识点:
- 至少已经理解了面向对象语言的多态特性
- 了解简单的反射用法
- 会简单使用
JavaPoet
生成Java
源码