看完这篇文章,你不可能不懂「动态代理」

2022-08-22 13:33:58 浏览数 (1)

虽然学会了静态代理,但是招财这几天仍然是有些闷闷不乐,因为始终没有想出上次陀螺留给自己的问题的解决思路。

如何为任意对象任意方法前后添加同一个处理逻辑?

手动为每一个对象的每一个方法中添加同一段代码逻辑是不可能的,这辈子都不可能的。「懒」是科技进步的重要动力!

思考未果,招财终于要求助陀螺了。

捉襟见肘的静态代理

“师傅,你上次留给我的问题我没想通。这种需求的现实意义在哪儿呢?”招财开门见山。

陀螺说:“如果真的能在任意方法前后添加自己的逻辑,那作用可就太大了!你可以在逻辑运行之前先校验操作权限;你也可以在逻辑运行之前先开始一个事务,在逻辑完成之后提交或回滚事务。这种功能怎么用完全取决于你的想象力。”

“真没想到居然有这么大的作用!那么该怎么实现呢?”

“你觉得静态代理能不能解决这个问题?”陀螺反问道。

招财回答说:“可以倒是可以,我们可以为每个类针对每一种逻辑编写一个静态代理,但是问题就在这,如果被代理的类很多,代理逻辑也很多,就会造成类爆炸的局面啊。”

“我觉得静态代理更适合为某些特定的接口实现代理,而且代理对象必须显式地创建。”招财继续补充道。

陀螺:“你说的没错,问题就在于静态代理需要显式地创建代理对象,那如果我们能够动态生成代理对象,而这个生成过程用户完全无感知,这个问题是不是就可以解决了呢?”

“真的有这种方法吗?”招财的眼睛里都发着光。

“这就是动态代理了。这件事情确实很难,我们需要一点点地来完成这件事情,跟上我的思路,保证能让你彻底理解动态代理!”陀螺自信地对招财说。

动态代理的诞生

“首先回忆一下静态代理中你编写的日志代理。”说着,陀螺给出了代码。

“这个代码你应该已经非常熟悉了吧。”陀螺问招财。

“是啊,payable是被代理对象,SiShiDaDaoLogProxy是生成的代理类,代理对象实现了Payable接口,在重写pay()方法的时候进行了逻辑增强,但是本质上仍然调用的是被代理对象的方法。”招财回答得很流利。

陀螺点了点头,“很好,假设现在我们通过某种方式获得了上面的源码,现在我们的目标是要动态生成这个代理对象。”

“动态生成?我只知道一开始学习Java的时候,通常会先写一个HelloWorld.java源文件,然后利用javac工具编译成HelloWorld.class文件,你说的动态生成和这个有关系吗?”招财问道。

“原理是类似的,我们需要把上面的SiShiDaDaoLogProxy写入到磁盘中生成.java文件,然后利用JDK提供的编译工具转为.class文件,再通过类加载器将.class文件加载到JVM中,最后我们通过反射就能获得SiShiDaDaoLogProxy实例对象了。”

“这......这涉及到的知识点也太多了!JDK提供的编译工具我甚至都没有听过,类加载的知识也几乎已经忘光了,也就反射总在框架中遇到,多少还有点印象。师傅,我是不是得先补一补这些知识点啊?”招财有点绝望地问。

"你这种想法是很多初学者的通病,学一个知识点的时候总是不自觉地把其他相关知识点也学了一遍,最后忘了自己一开始的学习目的是什么,本末倒置。记住,要先掌握脉络,再学细节!"陀螺正色道。

陀螺看着招财还是有点不自信,继续说道:“别担心,要不是碰到这个动态代理,JDK自带的编译器恐怕你这辈子也用不上了,所以你只要知道它的作用是什么即可,代码都不需要看懂。至于类加载机制,你要理解我们需要一个类加载器来加载上一步得到的.class文件到JVM虚拟机中,这样才能生成实例对象,了解这些就够了。至于反射,你确实应该掌握,好在它本身非常简单,跟着我的思路就能理解了。”

陀螺的话让招财安心了许多,重新打起了精神。

v1.0——先动态编译一段源码吧

“我们先创建一个类Proxy,在里面定义一个newProxyInstance的静态方法,该方法返回一个Object对象,这个对象就是我们最终生成的代理对象。”说罢,陀螺给出了代码。

“为了方便你理解,我把每个步骤的代码分别作了封装,步骤2和步骤3你只需要理解他们的含义就行了,具体的代码不是研究的重点。这两个步骤的代码在接下来的讲述中几乎不会发生变化,因此接来的讲述我会用createJavaFilecompile来分别代替两个步骤,不会再给出具体代码。”陀螺对招财解释道。

“如此一来,客户只需要调用Proxy.newProxyInstance(ClassLoader classLoader)就能得到SiShiDaDaoLogProxy对象实例了是吧。”招财问。

“没错。”

“可是,我看到newProxyInstance方法有个参数,需要传一个ClassLoader,这个参数是什么意思?”招财有点不解地问。

“还记得我们需要一个类加载器来加载步骤3生成的.class文件到JVM中吗?这个参数就是类加载器的一个实例,提供这个参数是让客户可以灵活地选择不同的类加载器来完成这个操作。”

招财撅了噘嘴,“我不理解这个参数提供的必要性,你直接默认一个类加载器不是更好吗?我觉得大部分的用户都不知道这个参数该传什么值吧。”

“别急,之后你就会知道我设计这个参数的意图了。为了让你知道怎么传这个参数,我自定义了一个类加载器,这个操作其实并不难。”

“还有第5步,我也不是很懂。”招财继续追问。

“别急,先看一下我们目前为止的所有代码,然后解释给你听。”

“我再啰嗦一遍目前为止我们做的事情。”陀螺耐心地解释。

  1. 我们通过generateSrc方法得到了SiShiDaDaoLogProxy类的源码,这个源码就是一开始给你看的静态代理的代码(重点)
  2. 将源码文件写入磁盘,生成SiShiDaDaoLogProxy.java文件(不是重点)
  3. 利用JDK提供的编译工具,将SiShiDaDaoLogProxy.java编译成SiShiDaDaoLogProxy.class文件(不是重点)
  4. 使用自定义的类加载器(MyClassLoader)将SiShiDaDaoLogProxy.class加载到内存(不是重点)
  5. 使用反射,得到SiShiDaDaoLogProxy的实例对象(重点)

“你会发现,generateSrc生成的源码只有一个有参的构造函数,因此第5步需要通过反射获取这个有参的构造函数对象,并传入new SiShiDaDao()进行实例化,效果和下面的代码是一样的。”

代码语言:javascript复制
new SiShiDaDaoLogProxy(new SiShiDaDao());

“这个我懂了,”招财点点头,“但是你说在第2步和第3步分别生成了两个文件,这两个文件保存在哪里了呢?”

“这就是动态代理的奇妙之处了!它自动给你生成了源码文件和字节码文件,对此你却毫无感知。你甚至都不需要知道自动生成的类的名字是什么。这里我也不会告诉你文件保存在哪里了,因为这个问题并不重要,之后你自己运行代码看看就知道了。”陀螺解释说,“我们现在运行一下客户端程序,看看有什么结果吧。”

运行结果如下

看见陀螺兴奋的样子,招财有点为难,因为她不明白折腾了这么久,最终得到的竟是和之前静态代理一样的运行效果。

吾爱吾师,吾更爱真理!

招财鼓起勇气,问道:“这个结果和静态代理的运行结果没有差别,不是吗?”

陀螺从招财委婉的话里听出了她的困惑,“结果虽然一样,但是实现机制却发生了翻天覆地的变化。你有没有发现,我们没有手写任何的代理类。之前静态代理还需要手写SiShiDaDaoLogProxy,我们完全是自动生成的。”

“你说的这一点我理解了。但是目前自动生成的都是写死的代码,也就是说目前只能为SiShiDaDao这个类中的pay()方法做代理,效果还差得远呢。”

“你说得没错,接下来我们就稍微改进一下,这个阶段我们的目标是,要得到一个对象,这个对象可以代理实现了任意接口的类,从而被代理类中的每一个方法前后都会添加我们的日志逻辑。”

v2.0——为实现了任意接口的类做日志代理

陀螺问招财,“如果你是设计者,站在使用者的角度让你来设计这个接口,你会怎么设计?”

招财思考了一番,“newProxyInstance方法里应该添加另一个参数,用来指代被代理对象实现的接口,意思就是我要得到实现了这个接口的类的代理对象。”

“很好。这样一来,我们就不能在generateSrc方法中将生成的类的实现关系写死,需要一点变化。看下图,所有用红色线框圈出来的部分都是需要动态修改的,而且更麻烦的一点是,我们还需要动态生成这个接口中声明的所有的方法,包括方法的参数和返回值信息。”

“我想,这一定又离不开反射吧。”招财无奈地说道。

“是的,重点体会思想。别担心,这些代码很容易理解,但是需要你多看几遍。接下来我们来实现新的generateSrc方法。”陀螺继续说道,“但是下面的代码可能会让你有点不适,因为通过拼接字符串的方式获取源码,可读性很差。但是先体会思想,之后我会让你看到最终动态生成的源码内容,你也就明白了下面的代码究竟做了什么。”

此时客户端调用

运行结果如下

动态生成的代码如下

注:代码为动态生成的原始内容,未经IDE格式化

陀螺解释说:“虽然generateSrc方法看起来很麻烦,但是生成的最终结果却很容易理解,就是生成一个实现了某个接口的类,并在重写接口所有方法的过程前后添加了日志逻辑。”

“逻辑我理解了,只不过对generateSrc的代码还有点晕。我就暂时先不理会generateSrc的细节了,先把握整体思路。我有两个问题,首先,我看到自动生成的类名由SiShiDaDaoLogProxy变成了$Proxy0,这是为什么?”招财抛出了第一个问题。

“好眼力。在代理对象生成的过程中你会发现,我们从始至终都没有用到过这个类的名字,所以名字叫什么其实无所谓。此外,动态代理根据我们传入参数的不同会返回不同的代理对象,所以我干脆就起了一个中性一点的名字Proxy0。至于为什么用开头,因为JDK有个规范,在ClassPath下只要是开头的.class文件,一般都是自动生成的,我只是遵照了一下这个规范罢了。”

“第二个问题,目前这个版本的功能是要得到实现了任意接口的类的代理,并且当客户端传入的接口对象是Payable.class时,也得到了我们期望的运行结果。但是我认为这只是恰好传入的参数是Payable.class罢了,如果传入的其他接口类,比如Comparable.class,我不认为客户端能调用成功,因为newProxyInstance方法进行对象实例化时传递的参数是new SiShiDaDao()。”招财指了指代码。

代码语言:javascript复制
// 参数被写死了
Payable p = (Payable) proxyConstructor.newInstance(new SiShiDaDao());

“而当参数是Comparable.class的时候,我们需要传入的应该是实现了Comparable接口的对象实例。我说得对不,师傅。”招财幸灾乐祸地问。

招财的成长让陀螺大感吃惊,笑了笑说:“你说的没错,如果传入的参数不是Payable.class,虽然能够生成我们期望的代码,但是没办法运行,原因正如你刚才所说。不仅如此,目前自动生成的代理类只能添加固定的日志逻辑,我们希望这个逻辑能让用户自己定义。”

“所以,第3个版本要来了吧。”招财摩拳擦掌,已经迫不及待地听陀螺继续讲下去了。

“没错!”

v3.0——为实现了任意接口的类做任意代理

“想让用户可以自定义逻辑,那么在调用newProxyInstance方法的时候自然应该多一个参数。很显然,每个用户传入的逻辑都不一样,但是参数却只有一个,你想到了什么?”陀螺问招财。

“多态。这个参数应该是个接口或者高度抽象的类,用户去实现接口或重写方法来编写自己的逻辑。”

“说得没错,这里我们就用接口来实现。我把这个接口命名为InvocationHandler,并在里边定义一个方法invoke,用户必须重写这个方法来编写自己的逻辑。”

代码语言:javascript复制
public interface InvocationHandler {

    Object invoke(...) throws Throwable;

}

“我们的newProxyInstance方法的声明也就变为了这样。”

“接下来我们需要确定invoke方法中的参数,”陀螺继续说道,“因为我们要在方法前后添加逻辑,所以用户实现InvocationHandler接口并重写invoke方法时,其中的代码结构应该是这个样子。”说罢,陀螺给出了代码。

陀螺接着说:“我们需要在beforeafter方法中间调用某个方法,可以传入Method对象,这样就可以利用反射来调用这个方法了,因此invoke方法中至少应该包含Method对象和方法的参数,像这样invoke(Method m, Object[] args)。”

招财提出了一个问题:“但是反射调用方法的时候还需要知道调用的是哪个对象的方法,这个参数该怎么得到呢?”

陀螺回答道:“这个好办,我们可以在实现InvocationHandler的时候,创建一个构造器,通过构造函数的方式传入被代理对象,如此一来代码就变成了这样。”

看到这里,招财已经两眼放光了,大叫:“我知道了!现在我们重写的invoke方法中其实已经包含了最完整的逻辑,而且这个对象也会作为参数被传入到newProxyInstance方法中,也就是说,在之后自动生成的代理对象中只要调用LogInvocationHandler实例对象的invoke方法,然后把Method参数和Object[]参数传入就可以了!”

看着招财兴奋的样子,陀螺也忍不住乐起来,“哈哈哈,没错!你已经说出了动态代理的核心思想了。现在抛开newProxyInstance函数内部的实现细节,客户端该怎么调用我们已经完成的封装?”

“首先我们需要创建一个被代理对象,这里就以SiShiDaDao的实例对象为例吧;其次,实现InvocationHandler接口重写invoke方法,创建自己的逻辑;再次,调用Proxy.newProxyInstance方法,得到代理对象;最后调用代理对象的目标方法就可以了。”招财回答得很流利。

“我写的代码没错吧师傅。”招财一脸得意,“接下来是不是可以看看newProxyInstance方法的实现细节了?”

陀螺摆摆手,“别急!在了解newProxyInstance的细节之前,你需要先明白newProxyInstance自动生成的源码应该是什么样子,你试着写一下,就用你刚刚写的客户端调用的参数。”

招财想了一下,给出了自己的代码。

"嗯嗯,"陀螺点点头,“大致的思路是对的,但是有几点小问题。”

“您说说看。”

“第一,Payable应该写成全限定类名designPattern.proxy.dynamicProxy.Payable,这样无论传入什么接口类型,编译的时候都不会有问题。”

“第二,在获取Method的时候,你是传入方法名来进行获取的,这不够。因为可能存在方法重载的情况,就是方法名相同但是方法参数不同。因此更好的做法是同时根据方法名和方法参数来获取Method对象。”

“第三,pay()方法没有捕获异常,因为$Proxy0中的所有方法都用到了反射,需要进行异常捕获。”

“那注意了这三点,是不是我就可以实现newProxyInstance细节了?”招财迫不及待地问。

“没错,你现在已经完全有能力实现了,只不过需要加亿点点细节!”

“亿点点????”

陀螺说:“因为Payable接口中声明的方法pay()很简单,既没有返回值,也没有方法参数,所以需要在实现细节中考虑到有返回值和方法参数的情况。但是细节对你来说已经不重要了,因为你听懂了原理就已经掌握了动态代理的精髓,我直接给你看代码吧!”

代码可能引起不适,可以直接跳过,或者访问github(见文章末尾)获取完整代码,自己跑一下效果更佳

注:虽然代码处理了方法返回值和参数的问题,但是还有很多细节未完善,比如会重写接口中的所有的方法,包括staticprivate方法,这显然是不对的

大家只需理解精神即可,这里的细枝末节对我们并不重要

“现在,我们终于可以在实现了任意接口任意对象任意方法的前后添加自己的逻辑了!”招财兴奋的喊道。

陀螺笑了笑:“恭喜你,到此为止,你已经完全掌握了最难的设计模式——动态代理。现在你会发现,我们费尽心思设计的Proxy类和InvocationHandler接口再也不需要变动了。”

“是啊,那我们可以把这个功能封装起来,然后在我们的项目里用动态代理了。”招财有点激动。

“虽然花了我们不少精力,但是得承认,我们目前完成的功能是不完善的。好在JDK为我们封装了动态代理,其实我们一步步做的所有工作都是在模拟JDK提供的动态代理,包括接口和方法的名称,都和JDK的动态代理一模一样。但是在某一些参数上,我们和JDK的动态代理有一点差别。”

“哪些参数有区别?”招财问道。

“我们设计的newProxyInstance方法和JDK的稍微有点区别,JDK的第二个参数是个数组,不过这无关紧要,你只要知道这一点就行。”

陀螺继续说道:“还有一个参数比较重要,但是我们在当前版本中并没有给出。甚至很多程序喵对JDK中的这个参数的存在意义都搞不清楚。”

这可彻底激发了招财的好奇心,“这个参数是什么啊?”

陀螺明没有直接回答招财,反而问道:“招财啊,我们目前实现的动态代理有什么优点?有什么缺点呢?”

招财不明所以,但是师傅既然问了,总得回答,“优点是,使用者可以不需要在意newProxyInstance的实现细节,只需要实现InvocationHandler接口,在invoke方法里添加自己的逻辑,然后按照步骤就可以创造出自己的代理对象;硬要说缺点的话,那就是只能在最后才能获得代理对象,自己在invoke方法中定义逻辑的时候对代理对象毫无操作权限。”

陀螺赞许的点点头,“说到点子上了!虽然大部分使用者都不会直接在invoke中使用代理对象,但是为了功能的完善性,JDK提供了这个参数。接下来,我们稍微修改一下我们的代码,非常简单。”

v4.0——终于完成对JDK动态代理的模拟

陀螺解释说:“问题在于我们需要把生成的代理对象传到invoke方法中,很显然应该在newProxyInstance方法中做点文章。在自动生成代码的时候做一点改变,将this对象传入invoke方法。”

“这样的话invoke方法的声明也需要改变一下,改成invoke(Object proxy, Method m, Object[] args),对吧?”招财补充道。

“没错,这样在重写invoke方法的时候,用户就可以获取到代理对象proxy,针对代理对象进行一系列操作就可以了。到此为止,我们完成了对JDK动态代理的模拟。”

后记

招财好奇地问:“师傅,JDK也和我们似的,通过拼接字符串来得到代理对象的源码,然后再编译吗?”

陀螺哈哈大笑,“要真是这样,JDK未免也太low了吧。JDK官方提供了Class字节码的规范,只要你知道这个规范,你可以直接按照这个规范编写字节码文件,从而跳过先生成.java,然后动态编译成.class的过程。JDK动态代理就是在运行期生成字节码,直接写Class字节码文件的,这样效率比较高。”

“师傅,你一开始就规定了必须使用接口来使用动态代理,是不是也和JDK的实现有关系啊。难道还有不是利用接口来实现动态代理的方式不成?”招财又又又一次抛出了问题。

陀螺对自己的弟子是又爱又恨,“你这家伙还真是敏锐,除了JDK动态之外还有CGLib动态代理,前者通过接口实现,后者通过继承实现,但是别想让我继续给你讲CGLib了,讲完JDK动态代理我半条命都快没了。下次吧!”

PS:作者也实在不想让陀螺回答下去了,毕竟他写这篇文章快两个周了......

强烈建议下载源码自己动手跑一下,关于动态代理的所有版本源码见github 地址:https://github.com/chanmufeng/JavaMeta

0 人点赞