本文将先介绍jdk动态代理的基本用法,并对其原理和注意事项予以说明。之后将以两个最常见的应用场景为例,进行代码实操。这两个应用场景分别是拦截器和声明性接口,它们在许多开发框架中广泛使用。比如在spring和mybatis中均使用了拦截器模式,在mybatis中还利用动态代理来实现声明性接口的功能。因此,掌握动态代理的原理和代码书写方式,对阅读理解这些开源框架非常有益。
文中的示例代码基于jdk8编写,且都经过验证,但在将代码迁移到博客的过程中,难免存在遗漏。如果您将代码复制到自己的IDE后无法运行,或存在语法错误,请在评论中留言指正 😉
小示例
先来看一个jdk代理的最小demo
点击查看代码
上述代码执行后的输出结果如下:
运行的代理类为: class com.sun.proxy.$Proxy0 | |
调用的代理方法为: greeting | |
调用方法的参数为: Hello, Jdk Proxy | |
请在这里插入代理的逻辑代码... |
其中倒数第二行的businessProxy变量,就是一个代理对象,它是BusinessInterface接口的一个实例,但我们并没有编写这个接口的实现类,而是通过Proxy.newProxyInstance方法生成出了该接口的实例。那么这个动态代理实例对应的Class长什么样子呢?上面的结果输出中已经打印出来了,这个代理类名称为com.sun.proxy.$Proxy0。实际上,如果我们再为另外一个接口生成代理对象的话,它的Class名称为com.sun.proxy.$Proxy1,依次类推。
还有一个值得关注的问题:最重要的逻辑代码应该写在哪里?答案是写在InvocationHandler这个接口的invoke()方法中,也就是上面示例代码的第⑵处。由此可以看出:代理对象实际要执行的代码,就是invoke()方法中的代码,换言之,代理对象所代理的所有接口方法,最终要执行的代码都在invoke方法里,因此,这里是一切魔法的入口。
编写一个jdk代理实例的基本步骤如下:
-
编写业务接口
因为jdk代理是基于接口的,因此,只能将业务方法定义成接口,但它可以一次生成多个接口的代理对象 -
编写调用处理器
即编写一个java.lang.reflect.InvocationHandler接口的实现类,代理对象的业务逻辑就写成该接口的invoke方法中 -
生成代理对象
有了业务接口和调用处理器后,将二者作为参数,通过Proxy.newProxyInstance方法便可以生成这个(或这些)接口的代理对象。比如上述示例代码中的businessProxy对象,它拥有greeting()这个方法,调用该方法时,实际执行的就是invoke方法。
代理对象生成原理
代理的目的,是为接口动态生成一个实例对象,该对象有接口定义的所有方法。调用对象的这些方法时,都将执行生成该对象时,指定的“调用处理器”中的方法(即invoke方法)。
生成代理对象的方法签名如下:
Proxy.newProxyInstance (ClassLoader loader, Class<?>[] interfaces, InvocationHandler handler) |
classloader一般选择当前类的类加载器,interfaces是一个接口数组,newProxyInstance方法将为这组接口生成实例对象,handler中的代码则是生成的实例对象实际要执行的内容,这些代码就位于invoke方法中。在生成代理对象前,会先生成一个Class,这个Class实现了interfaces中的所有接口,且这些方法的内容为直接调用handler#invoke,如下图所示:
下面将以小示例中的BusinessInterface接口和ProxyLogicHandler为基础,用普通Java代码的方式,模拟一下Proxy.newProxyInstance的代码逻辑,如下:
点击查看代码
上面的代码是示意性的,并不正确,比如它没有使用到loader和interfaces参数,调用hanlder.invoke方法时,对于method参数只是简单的用'greeting'字符串替代,类型都不正确。但这段示意代码很简单明了地呈现了真实的Proxy.newProxyInstance方法内部的宏观流程。
下面再提供一个与真实的newProxyInstance方法稍微接近一点的模拟实现(需要您对jdk里JavaCompiler类的使用有一定了解)
点击查看代码
代码运行结果为:
执行invocationHandler#invoke()方法 | |
调用的代理方法名为:sayHi | |
调用时传递的参数为:East Knight |
应用场景
上面提到:代理是在运行期,为接口动态生成了一个实现类,和这个实现类的实例。那这个功能有什么用呢?我们直接写一个实现类不也是一样的么?代理类与我们手动写代码的主要差异在于它的动态性,它允许我们在程序的运行期间动态创建Class,这对于框架类程序,为其预设的业务组件增加公共特性提供了技术支持。因为这种额外特性的加持,对业务代码没有直接的侵入性,因此效果非常好。动态代理的两个最常用见应用场景为拦截器和声明性接口,下面分别介绍。
拦截器功能
搭载器就是将目标组件劫持,在执行目标组件代码的前后,塞入一些其它代码。比如在正式执行业务方法前,先进行权限校验,如果校验不通过,则拒绝继续执行。对于此类操作,业界已经抽象出一组通用的编程模型:面向切面编程AOP。
接下来,将以演员和导演为业务背景,实现一个简易的拦截器,各个组件介绍如下:
-
Performer <Interface>
演员接口,有play和introduction方法 -
DefaultActor <Class>
代码男性演员,它实现了Performer接口,也是拦截器将要拦截的对象 -
Director <Interface>
导演接口,只有一个getCreations方法, 该方法返回一个字符串列表,它代表导演的作品集 -
DefaultDirector <Class>
Director接口的实现类,同时也是拦截器将要拦截的对象 -
ProxyForInterceptor <Class>
拦截器核心类,实现了InvocationHandler接口,拦截器代码位于接口的invoke方法中。拦截器将持有Performer和Direcotor的真实实现实例,并在调用Performer的play和introduction方法前,先执行一段代码。这里实现为打印一段文本,接着再调用play或introduction,执行完后,再执行一段代码,也是打印一段文本。Director实例方法的拦截处理逻辑与此相同。这便是最简单的拦截器效果了。
-
IntercepterTestMain <Class>
拦截器测试类,在main方法中,验证上述组件的拦截器功能效果。这个例子中,特意写了两个接口和两个实现类,就是为了演示,JDK的动态代理是支持多接口的。
下面是各个组件的源代码
Performer
DefaultActor
Director
DefaultDirector
ProxyForInterceptor
IntercepterTestMain
以上代码的执行结果如下:
[DirectorActorProxyHandler]: 调用的代理方法为:play | |
[DirectorActorProxyHandler]: >>> 调用 play 之前的逻辑 | |
[DefaultActor]: 默认男演员正在即兴表演《长板坡》 | |
[DirectorActorProxyHandler]: <<< 调用 play 之后的逻辑 | |
[DirectorActorProxyHandler]: 调用的代理方法为:introduction | |
[DirectorActorProxyHandler]: >>> 调用 introduction 之前的逻辑 | |
[DirectorActorProxyHandler]: <<< 调用 introduction 之后的逻辑 | |
[IntercepterTestMain ]: 代理对象返回的个人简介内容为: 李白·上李邕: 大鹏一日同风起,扶摇直上九万里。假令风歇时下来,犹能颠却沧溟水。世人见我恒殊调,闻余大言皆冷笑。宣父尚能畏后生,丈夫未可轻年少。 | |
[DirectorActorProxyHandler]: 调用的代理方法为:getCreations | |
[DirectorActorProxyHandler]: >>> 调用 getCreations 之前的逻辑 | |
[DirectorActorProxyHandler]: <<< 调用 getCreations 之后的逻辑 | |
[IntercepterTestMain ]: 代理对象返回的导演作品列表: | |
· 活着 | |
· 盲井 | |
· 走出夹边沟 | |
· 少年派的奇幻漂流 |
可以看到,在main方法中,调用代理类的play方法后(位于代码的①处),在执行真实的DefaultActor#play方法前后,均有额外的文本输出,这些都不是DefaultActor#play方法的逻辑。这便实现了拦截器效果,且对于使用者而言(即编写DefaultActor类的开发者),是无侵入无感知的。
声明性接口
声明性接口的特点是:开发者只需要提供接口,并在接口方法中声明该方法要完成的功能(通常是以多个注解的方式声明),但不用编写具体的功能实现代码,而是通过框架的工厂方法来获取该接口的实例。当然,该实例会完成接口方法中所声明的那些功能。比较典型的产品是MyBatis的Mapper接口。实现手段也是采用jdk动态代理,在InvocationHandler的invoke方法中,完成该接口方法所声明的那些特性功能。
接下来,本文将模拟MyBatis的Mapper功能,组件说明如下:
-
SqlMapper <Annotaton>
与MyBatis的Mapper注解等效,用于标识一个接口为Sql映射接口,但在本示例中,这个接口并未使用到。因为这个标识接口的真实用途,是在SpringBoot环境中,用于自动扫描和加载Mapper接口的。本示例仅模拟Mapper本身的声明性功能,因此用不上它。保留这个接口,只是为了显得更完整。 -
Select <Annotation>
与MyBatis的Select注解等效,它有一个sql属性,用于指定要执行的SQL语句,且支持#{}形式的插值 -
ParamName <Annotation>
与MyBatis的Param注解等效,用于标识Mapper接口的方法参数名称,以便用于Select注解中sql语句的插值替换 -
PerformerMapper <Interface>
演员实体的数据库访问接口,与开发者使用MyBatis时,日常编写的各类Mapper接口一样。在里边定义各种数据库查询接口方法,并利用Select和ParamName注解,声明数据操作的具体功能。 -
ProxyForDeclaration <Classs>
整个Mapper功能的核心类,实现了InvocationHandler接口,在invoke方法中,完成Mapper的所有功能 -
DeclarationTestMain <Classs>
声明性接口的功能测试类,在main方法中,通过jdk代理获得一个PerformerMapper实例,并调用其中的getQuantityByNameAndAage、getRandomPoetryOf和listAllOfAge方法,分别传入不的SQL和参数,用以验证3种不同的情况。
下面是各个组件的源代码:
SqlMapper
Select
ParamName
PerformerMapper
ProxyForDeclaration
DeclarationTestMain
以上代码的执行结果为:
[ProxyForDeclaration]: 调用的方法名为:getQuantityByNameAndAage | |
[ProxyForDeclaration]: 原始sql为:select count(*) from performer where name=#{name} and age = #{ age } | |
[ProxyForDeclaration]: 插值替换后的sql为:select count(*) from performer where name='Jane Lotus' and age = 47 | |
[DeclarationTestMain]: 代理实例方法方法的返回值为:40 | |
[ProxyForDeclaration]: 调用的方法名为:getRandomPoetryOf | |
[ProxyForDeclaration]: 原始sql为:select poetry_item from poetry where performer_name = #{ name } | |
[ProxyForDeclaration]: 插值替换后的sql为:select poetry_item from poetry where performer_name = '杜甫' | |
[DeclarationTestMain]: 代理实例方法的返回值为:黄四娘家花满蹊,千朵万朵压枝低。 | |
[ProxyForDeclaration]: 调用的方法名为:listAllOfAge | |
[ProxyForDeclaration]: 原始sql为:select * from performer where age >= #{age} limit #{ pageSize } | |
Exception in thread "main" guzb.diy.proxy.ProxyForDeclaration$SqlMapperExecuteException: 未知参数:pageSize | |
at guzb.diy.proxy.ProxyForDeclaration.interpolateSql(ProxyForDeclaration.java:55) | |
at guzb.diy.proxy.ProxyForDeclaration.invoke(ProxyForDeclaration.java:29) | |
at com.sun.proxy.$Proxy1.listAllOfAge(Unknown Source) | |
at guzb.diy.proxy.DeclarationTestMain.main(JdkProxyStudyMain.java:24) |
以上代码共模拟了3个调用Mapper的场景:
-
调用getQuantityByNameAndAage()方法根据姓名的年龄查询演员数量。但并未真正执行JDBC查询,只是将SQL进行了插值替换和输出,然后随机返回了一个数字。这足以演示声明性接口这一特性了,真实地执行jdbc查询,那将一个代码量巨大的工作,它的缺失并不影响本示例的主旨。
-
调用getRandomPoetryOf()方法查询指定诗人的一段诗句。同样没有真正执行jdbc查询,而是随机返回了一句诗文。
-
调用listAllOfAge()方法查询指定年龄的所有演员。该方法有意设计为引发一个异常,因为接口方法上声明的SQL中,pageSize这个插值变量并未在方面签名中声明。
分类: 专栏, 专栏 / Java