静态代理和动态代理这个话题,网上文章一抓一大把,但大部分都停在“动态代理看起来好牛逼”的层面。真到面试官问你“JDK动态代理生成的代理类长什么样?为什么它只能代理接口?CGLIB和JDK代理到底差在哪?”的时候,很多人还是会卡住。这篇(二)里我干脆把两个东西掰开揉碎,从字节码层面看到调用链,再结合Spring AOP里最容易踩的坑一起讲清楚。内容不搞虚的,有示例、有源码、有反编译验证,也有我实际调式时撞过的墙。适合对代理模式有基础了解、想彻底弄懂底层机制并用到项目里的人。
1. 静态代理真的不行了吗?——先看清它的问题到底在哪
1.1 一个最基础的静态代理示例
静态代理的思路其实非常简单:定义一个和真实目标对象相同的接口,代理类实现这个接口,内部持有真实对象,然后在每个方法前后插入增强逻辑。我先写一个很常见的用户服务例子,看起来长这样:
public interface UserService { void saveUser(String name); }public class UserServiceImpl implements UserService { @Override public void saveUser(String name) { System.out.println("保存用户:" + name); } }现在要给它加日志和事务控制,最直接的方式就是写一个代理类:
public class UserServiceProxy implements UserService { private final UserService target; public UserServiceProxy(UserService target) { this.target = target; } @Override public void saveUser(String name) { System.out.println("事务开始..."); try { target.saveUser(name); System.out.println("事务提交..."); } catch (Exception e) { System.out.println("事务回滚..."); throw e; } } }调用的时候把真实对象包进代理类:
UserService userService = new UserServiceProxy(new UserServiceImpl()); userService.saveUser("张三");这段代码本身没什么问题,组合模式加接口实现,结构清楚,跑起来也稳定。静态代理最大的特点就是“简单、直观”:代理类、目标类、接口都在编译期写死了,IDE里点一下就能跳转,出了问题不用猜。很多新手项目一开始就是从这种写法起步的,它完全能跑,也不会出什么幺蛾子。
1.2 静态代理的代价:类爆炸、逻辑重复、维护成本高
静态代理的问题不是出在“能不能用”,而是出在“扩展一个横切逻辑时要改多少地方”。假设你现在有用户服务、订单服务、库存服务三个接口,都想加上日志和事务。如果继续用静态代理,就得写三个代理类,每个类里的日志代码和事务代码几乎一模一样,只是目标对象不同。
我见过一个真实项目,早期为了给几十个Service加上统一的权限校验,写了三十多个静态代理类。后来产品要改校验逻辑,开发人员加班改了一整晚,原因就是这些代理类里的重复代码散落得到处都是。这就是典型的“类爆炸”和“逻辑重复”:每增加一个被代理对象,就要新建一个代理类;每修改一次增强逻辑,就要改动所有代理类。
更深一层看,静态代理是把“增强逻辑”硬编码到了类的结构里。代理类一旦编译完,它跟目标对象的绑定关系就固定了。你用组合方式虽然可以把目标对象换成任意实现类,但代理类本身仍然只能服务“同一个接口”。除非你为每一种接口都单独写一个代理类,否则这个增强逻辑没法复用到另一个完全不同业务接口上。
所以静态代理并不是“不行了”,而是它更适合逻辑稳定、接口变化少的场景。如果你只是想给某个接口加一层访问控制,静态代理反而比动态代理更直观。但一旦增强逻辑开始横跨多个业务对象,静态代理的维护成本就会指数级上升,这时候就该考虑动态代理了。
2. 动态代理的本质:把增强逻辑从代理类中抽出来
2.1 一个Handler搞定所有类的日志
动态代理和静态代理相比,最大的区别在于:代理类不是写死的,而是在运行时由JVM动态生成的,你只需要提供一个“增强逻辑”处理器。还是刚才那个日志场景,用JDK动态代理可以这样写:
import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; public class LogHandler implements InvocationHandler { private final Object target; public LogHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("before " + method.getName()); Object result = method.invoke(target, args); System.out.println("after " + method.getName()); return result; } }然后创建代理对象:
import java.lang.reflect.Proxy; UserService userService = (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class[]{UserService.class}, new LogHandler(new UserServiceImpl()) ); userService.saveUser("张三");注意,这个LogHandler完全没有依赖UserService接口,它接受任何对象作为目标。今天你想代理用户服务,明天想代理订单服务,只需要换一下目标对象和接口数组就行,增强逻辑写一份就到处能用。
这就是动态代理的核心价值:把“代理类”从静态写死变成运行时生成,把“增强逻辑”从代理类中抽出来放到一个独立处理器里。你的业务代码不需要每个接口都写一个代理类,只需要写一个通用的处理器,然后告诉框架“我想给谁加什么逻辑”就够了。
2.2 JDK代理和CGLIB代理怎么选:一张表说清
凡是用过动态代理的人,都会遇到一对经典组合:JDK动态代理和CGLIB动态代理。很多人只记住了“JDK代理要接口,CGLIB不用接口”,但真正选型的时候还得看更多差异。我把两者的核心对比整理成一张表:
| 对比维度 | JDK动态代理 | CGLIB动态代理 |
|---|---|---|
| 实现原理 | 运行时生成一个实现目标接口的代理类 | 运行时生成目标类的子类 |
| 目标要求 | 必须存在接口 | 不需要接口,普通类即可 |
| 生成类名称 | 通常类似$Proxy0 | 目标类$$EnhancerByCGLIB$$ |
| 字节码生成 | 基于标准Java反射和ProxyGenerator | 基于ASM字节码操作库 |
| 可代理方法 | 接口中定义的方法 | 非final且可被继承重写的方法 |
| 构造方法 | 只能通过接口访问 | 会调用父类的构造方法 |
| Spring中的默认选择 | Spring Boot 1.x之前的常见默认 | Spring Boot 2.x默认开启proxyTargetClass |
| 性能特点 | 创建代理对象快,反射调用有一定开销 | 代理对象创建稍慢,但后续调用使用FastClass索引,通常更快 |
这张表里最重要的信息不是“快和慢”,而是“代理类到底是怎么诞生的”。JDK代理是在运行时生成一个实现了指定接口的类,所以它天然依赖接口。CGLIB是生成目标类的子类,通过重写父类方法实现增强,所以不依赖接口,但恰恰因为依赖继承,final方法和final类就成了它的死穴。
2.3 为什么JDK动态代理只能代理接口而不是类
很多同学第一次用JDK动态代理时,会试着直接传一个类进去:
UserServiceImpl userService = (UserServiceImpl) Proxy.newProxyInstance(...);结果一跑就抛ClassCastException,甚至IllegalArgumentException。原因很简单:JDK生成的代理类默认继承了一个Proxy基类,Java是单继承,所以这个新生成的类不可能再去继承你的UserServiceImpl,它唯一能做的就是实现接口来扩展能力。
接口在这里起的作用是“给代理类一个合法的类型身份”。你调用Proxy.newProxyInstance时传入的接口数组,决定了生成的代理类长什么样、能强转成什么类型。如果目标类没有实现任何接口,代理类就完全没有办法和目标类建立方法签名层面的对应关系,自然也就无法代理了。
这就是JDK动态代理的边界。理解了这个边界,你再回头看Spring里的proxyTargetClass配置就会很清楚:Spring默认用JDK代理,但如果你的Bean没有接口,或者你强制要求用类代理,Spring就会切换到CGLIB,因为CGLIB绕开了“必须继承Proxy”的这个限制。
3. JDK动态代理源码级剖析
3.1 一段标准的JDK Proxy代码
我们先写一个标准JDK动态代理的完整代码,方便后面讲源码时对照。假设要给一个计算器接口加耗时统计:
public interface Calculator { int add(int a, int b); }public class CalculatorImpl implements Calculator { @Override public int add(int a, int b) { return a + b; } }处理器:
public class TimingHandler implements InvocationHandler { private final Object target; public TimingHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { long start = System.currentTimeMillis(); Object result = method.invoke(target, args); long cost = System.currentTimeMillis() - start; System.out.println(method.getName() + " cost " + cost + "ms"); return result; } }创建代理:
Calculator calculator = (Calculator) Proxy.newProxyInstance( Calculator.class.getClassLoader(), new Class[]{Calculator.class}, new TimingHandler(new CalculatorImpl()) ); int result = calculator.add(1, 2); System.out.println(result);跑完之后,你会发现在TimingHandler里能拿到被调用方法的Method对象、入参数组,并且在方法执行前后做增强。这个模式很眼熟,对吧?Spring里的@Around通知本质上也是类似的套路,只是它帮你把切面表达得更优雅了。
3.2 newProxyInstance内部做了什么:从ProxyFactory到字节码
Proxy.newProxyInstance看起来只是一个工厂方法,但它背后做了很多事情。我简化了一下关键流程:
第一步,校验传入的接口是否合法。比如接口不能重复、接口必须是接口类型、接口不能是java.lang.Object的方法等。目标类有没有实现接口其实无所谓,它只关心你传进接口数组里的是不是真正的接口。
第二步,调用getProxyClass0,这里有一个很重要的缓存机制。如果同一个类加载器和同一个接口组合已经被创建过,那么直接返回缓存的代理类,避免重复生成字节码。
第三步,通过ProxyClassFactory生成代理类的字节码。JDK底层使用ProxyGenerator.generateProxyClass,它会根据接口里的方法、Object的equals、hashCode、toString等方法生成一个完整的class文件。这个class文件包含一个构造方法,构造方法接收InvocationHandler参数,并把它赋值给继承自Proxy的h字段。
第四步,通过反射拿到这个构造方法,并实例化代理对象,传入你自定义的InvocationHandler实例。
整个流程可以用一句话概括:JDK动态代理不是直接用反射拦截你的方法,而是先“写”一个类,把方法调用统一转发给h.invoke(),然后你在invoke()里做增强。
3.3 invoke方法里三个参数的实战用法
invoke(Object proxy, Method method, Object[] args)这三个参数,很多人只用了method和args,却忽略了proxy,还经常在proxy上踩坑。
proxy就是当前生成的代理对象本身。它最大的用处是作为“代理标识”,比如你想区分当前调用是来自代理还是来自具体目标,或者需要在回调里把代理对象传给某个框架时使用。但它也是最容易引发递归的地方。你要是手一抖,在invoke里写了:
proxy.toString();那就完了。因为toString()又会触发代理类的调用,再次进入invoke,无限递归,直接栈溢出。这一点我在给同事review代码时见过好几次,排查起来特别痛苦,因为报错堆栈只是巨大的StackOverflowError,根本看不出第一行是谁触发的。
method参数是反射得到的Method对象,你可以拿到方法名、参数列表、注解信息。想在增强逻辑里根据方法名做差异化处理,通常就是靠它。比如只给名字以save开头的方法加事务,而不是所有方法都加。
args就是方法入参数组。注意如果方法是无参的,这里得到的是null而不是空数组。所以你遍历args之前一定要判断一下args != null,否则分分钟NullPointerException。
3.4 把生成的$Proxy0反编译出来看看
光看源码描述还是有点抽象,最好的方式是把JDK生成的代理类保存到本地,然后反编译。以前我调试代理逻辑时,会在代码里加这么一段:
import sun.misc.ProxyGenerator; import java.io.FileOutputStream; byte[] bytes = ProxyGenerator.generateProxyClass("$Proxy0", new Class[]{Calculator.class}); try (FileOutputStream out = new FileOutputStream("Proxy0.class")) { out.write(bytes); }然后打开终端,用javap -c Proxy0查看字节码。你会看到类似这样的关键片段:代理类的add方法里面,并不是直接调用CalculatorImpl.add,而是调用了super.h.invoke(this, method, args)。也就是说,每一次方法调用都变成了对InvocationHandler.invoke的调用,而真正的方法是你在invoke内部通过反射再去触发的。
这里顺便说一句,在JDK 9之后,sun.misc.ProxyGenerator已经不允许直接访问了,模块化之后包名也有变化。不过你依然可以通过-Djava.base之类的方式做实验,或者干脆用上面这段代码在JDK 8环境验证一次,再回到高版本看行为。理解了这个过程后,JDK动态代理的底层对你来说就不再是黑盒了。
4. CGLIB动态代理的完整面貌
4.1 示例:Enhancer + MethodInterceptor
CGLIB的使用方式和JDK代理不太一样。它不需要接口,直接对一个普通类做增强。我们用刚才的CalculatorImpl这个类,但这次不让它实现任何接口,只是一个普通类:
public class CalculatorImpl { public int add(int a, int b) { return a + b; } }CGLIB创建一个代理:
import net.sf.cglib.proxy.Enhancer; import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(CalculatorImpl.class); enhancer.setCallback(new MethodInterceptor() { @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { long start = System.currentTimeMillis(); Object result = proxy.invokeSuper(obj, args); long cost = System.currentTimeMillis() - start; System.out.println(method.getName() + " cost " + cost + "ms"); return result; } }); CalculatorImpl proxy = (CalculatorImpl) enhancer.create(); int result = proxy.add(1, 2); System.out.println(result);注意,这里我使用的是proxy.invokeSuper(obj, args),不是method.invoke(obj, args)。很多人会写错成后者,也能跑,但性能就差很多了。invokeSuper走的是CGLIB的FastClass机制,它通过索引直接定位到父类方法,省去了反射查找的时间,而method.invoke走的是标准的反射调用。
4.2 底层原理:子类继承与FastClass索引
CGLIB生成出来的代理类,本质上是目标类的一个子类。JVM在运行时通过ASM库动态生成一个子类字节码,这个子类把所有可重写的方法都重写了一遍,并在每个方法体里调用你设置的MethodInterceptor.intercept()方法。
但CGLIB不是直接使用反射去调用目标方法,而是额外生成了两个FastClass类:一个用于调用目标对象的方法,一个用于调用代理对象的方法。FastClass里边维护了一个方法签名到整数索引的映射,调用时直接根据方法签名找到索引,再通过索引跳转到对应的方法体,少掉了反射方法查找的开销。
这也是为什么很多性能对比文章会说CGLIB调用比JDK代理快。但在JDK 8以后,随着JIT对反射的优化越来越激进,两者的实际差距已经小很多了。真正决定你选JDK代理还是CGLIB的,往往不是性能,而是“目标对象有没有接口”和“你有没有强制使用类代理”。
4.3 CGLIB的限制:final方法、static方法、final类都不能被代理
CGLIB能代理普通类,但它依赖的是“生成子类并重写父类方法”。一旦方法被final修饰,子类就无法重写;类被final修饰,子类根本无法生成。同样的,static方法属于类本身,不是实例方法,也不会被子类重写,所以CGLIB对static方法同样无能为力。
我在项目里遇到过这样一个案例:有个老代码里的工具类方法被定义成public static,业务方想通过CGLIB给它加一层缓存,结果怎么增强都不生效。后来追代码发现,静态方法根本没有进入intercept,因为这个方法压根不会被CGLIB生成到代理子类的方法表里。
另外,CGLIB还有一个容易被忽略的细节:Enhancer在创建代理对象时会调用父类的构造方法。如果父类没有无参构造方法,或者构造方法里做了重量级初始化,你使用CGLIB时就得小心了,可能还没开始增强就已经把副作用跑了一遍。
4.4 Spring里的选择逻辑:为什么SpringBoot默认CGLIB?
Spring AOP从很早起就同时支持JDK代理和CGLIB。早期Spring选择JDK代理作为默认方案:只要目标Bean实现了接口,就默认用JDK代理,只有目标Bean没有接口时才用CGLIB。这样设计的考虑是JDK代理是标准API,不引入额外依赖,安全且稳定。
但Spring Boot 2.x之后,默认把proxyTargetClass改成了true。也就是说,即使Bean实现了接口,Spring也优先用CGLIB创建子类代理。为什么这么改?一方面是很多开发者在用JDK代理时遇到“类型强转只能转接口不能转实现类”的麻烦,另一方面是CGLIB现在的成熟度和稳定性已经足够好,类代理在一些场景下更符合直觉。
实际使用中,你不需要自己改Spring的默认配置,但你完全可以关注一个问题:如果服务莫名其妙的代理失效,先看一眼当前Bean到底用的是JDK代理还是CGLIB。用类名排查,JDK代理的类名里通常有$Proxy,CGLIB代理的类名里通常有$$EnhancerByCGLIB$$,一眼就能分辨出来。
5. 框架和业务里的动态代理实践
5.1 Spring AOP中代理对象的识别与调试
Spring AOP的代理对象对业务代码几乎是透明的,但排查问题时你不一定能看出来自己拿到的到底是不是代理。最直接的方法是打印类的全限定名:
System.out.println(bean.getClass().getName());如果打印结果里含$Proxy或$$EnhancerByCGLIB$$,说明这个Bean已经被代理了。另一个办法是使用Spring提供的工具类:
import org.springframework.aop.support.AopUtils; boolean isProxy = AopUtils.isAopProxy(bean);如果isProxy返回true,说明它已经是代理对象。还有一个细节:AopUtils.getTargetClass(bean)能拿到代理背后的真实目标类。调试的时候,这个方法特别有用,你想知道切面到底作用在类上的哪个方法,直接查目标类即可。
我自己的习惯是,当发现某个带@Transactional的方法突然不生效时,第一步永远都是先打印Bean的真实类型。因为一旦确定Bean本身没有被代理,后面的排查方向就完全改变了。很多时候问题不是出在切面逻辑,而是Bean根本没有被Spring的AOP机制接管。
5.2 事务和异步注解失效的经典案例
动态代理在Spring中最常见的应用是@Transactional和@Async。这两个注解看着简单,背后却都是AOP代理在起作用。我见过太多的“注解不生效”案例,最后的根因都指向同一个问题:同类内部的this调用,绕过了代理对象。
比如这样一个Service:
@Service public class OrderService { @Transactional public void createOrder(OrderDTO dto) { // 下单逻辑 this.sendNotify(dto); } @Async public void sendNotify(OrderDTO dto) { // 异步通知逻辑 } }createOrder方法内部调用的是this.sendNotify,这个this是当前业务对象本身,不是代理对象。所以@Async根本不会生效,因为异步代理的拦截逻辑没有机会执行。
解决思路有三种。第一种是把sendNotify拆到另一个独立的Spring Bean中,通过注入调用。第二种是在当前类里注入自己:
@Autowired private OrderService self;然后用self.sendNotify(dto)调用。第三种是启用AopContext:
@EnableAspectJAutoProxy(exposeProxy = true)然后通过((OrderService) AopContext.currentProxy()).sendNotify(dto)获取代理。但这会让代码可读性变差,我建议项目早期就把“内部方法调用也要走代理”的规则定清楚,否则后面全是坑。
5.3 代理对象序列化、equals/hashCode的坑
动态代理对象在序列化时很容易出问题。JDK代理对象内部持有InvocationHandler,而handler里又持有目标对象,目标对象可能再引用一堆资源。你如果直接把代理对象丢给Redis或者写入消息队列,很可能把一整条依赖链都序列化进去,轻则数据膨胀,重则反序列化失败。
CGLIB代理对象也有类似的问题。代理类是动态生成的子类,很多序列化框架对它并不友好,甚至干脆序列化不了。我的建议是:往缓存、MQ、数据库里存的永远是纯数据对象,不要存代理对象。如果在接口返回层遇到Jackson序列化代理对象,最好用@JsonIgnoreType标记掉某些代理类,或者在配置里关闭对代理的序列化。
再说equals和hashCode。JDK代理对这两个方法的调用也会进入InvocationHandler.invoke。如果你的handler里没有专门处理它们,那么把两个代理对象放进Set或者作为Map的Key时,行为可能不符合预期。CGLIB代理默认会对equals和hashCode做处理,但如果你希望基于目标对象的值去比较,还是需要在callback里显式控制。总之,尽量不要依赖代理对象做身份判断,业务上要用的标识字段单独提取出来,比什么都稳。
6. 动态代理常见问题排查实录(速查表)
6.1 五个方法判断当前对象是不是代理对象
排查动态代理问题,第一步永远是判断“我现在拿到的到底是不是代理对象”。我整理了五个快速判断的方法,按使用频率排序如下:
- 打印
bean.getClass().getName(),类名含$Proxy多半是JDK代理,含$$EnhancerByCGLIB$$多半是CGLIB代理; - 使用
org.springframework.aop.support.AopUtils.isAopProxy(bean),Spring项目里通用; - 判断
bean instanceof java.lang.reflect.Proxy,只能识别JDK代理; - 判断
bean instanceof net.sf.cglib.proxy.Factory,CGLIB生成的代理类实现了这个接口; - 查看Spring配置里的
proxyTargetClass和spring.aop.proxy-target-class,预判当前项目用哪种代理。
这五个方法单独用不一定准,但组合起来基本能锁定结论。我实际排查时最常用第一个,因为通过类名一眼能看出代理类型,后面的反推思路瞬间就清晰了。
6.2 常见异常/问题与解决对照表
| 现象 | 常见原因 | 解决思路 |
|---|---|---|
ClassCastException: $Proxy0 cannot be cast to XxxImpl | 使用JDK代理,接口本身存在,但代码里试图强转成实现类 | 改为强转为接口类型,或切换CGLIB代理 |
StackOverflowError,日志反复出现某个get方法 | 在invoke或intercept里调用了代理对象的方法 | 避免在处理器中调用proxy的方法,改为操作目标对象 |
@Transactional不生效,日志没有事务边界 | 同类内部this调用,绕过了代理 | 拆分Bean、注入自身或使用AopContext |
@Async不生效 | 同上,另外可能是异步回调通过this调用了同类方法 | 让异步逻辑走代理对象 |
CGLIB代理中final方法始终没被增强 | CGLIB通过子类重写实现,final方法无法重写 | 去掉final修饰,或改用接口+JDK代理 |
Exception: Failed to instantiate ... no constructor | CGLIB创建代理对象时调用了目标类构造方法,但没有合适的构造器 | 为目标类提供可访问的构造方法,避免无参构造缺失 |
| 序列化后丢失业务逻辑 | 直接把代理对象存入缓存/消息体 | 转换为普通DTO后保存,不存代理对象 |
| 方法调用性能明显偏低 | intercept里用了method.invoke而非methodProxy.invokeSuper | 改为CGLIB的FastClass调用方式 |
表格之外还要提醒一句:动态代理的很多“诡异问题”,根源其实在“代理链叠加”。比如你用一个代理对象去创建另一个代理对象,handler层层嵌套,调用链又长又难查。遇到这类问题,先把代理层数降到最小,逐层验证,会比看一堆堆栈高效得多。
6.3 一套排查动态代理问题的“信号-路径”思路
多年的调式经验告诉我,排查动态代理问题不要上来就翻源码,先想清楚四个关键信号:当前Bean是不是代理、是哪种代理、方法是不是可被代理、代理逻辑有没有被触发。
第一,确认Bean是否被代理,用上面那五个方法判断。第二,确认代理类型,JDK代理还是CGLIB代理,这决定了你能代理的范围。第三,检查目标方法的修饰符,接口方法?final?static?这些都能直接决定代理能否生效。第四,在handler里加一行输出,确认拦截方法是否真的被执行。
把这个“信号-路径”思想理清楚之后,你会发现自己排查代理问题的速度会快很多。因为你不再漫无目的地在堆栈里翻,而是按顺序逐个排除,最后总能定位到具体环节。
7. 实战中的几个经验与心得
7.1 能用静态代理就用静态代理,别为了炫技上动态代理
这句话听起来有点反直觉,但我想说的是,技术选型永远跟着业务复杂度走。如果一个模块里只有两个接口需要加日志,静态代理写起来十行代码,逻辑清晰,出了问题断点一打就到。强行换成动态代理,反而引入了一堆“运行时生成类”的抽象,排查问题的人如果没有相关经验,看代码会懵半天。
我个人的标准很简单:增强逻辑只服务于一个或两个固定接口,且接口本身变化不大,就用静态代理;如果增强逻辑要横跨多个不相干的业务对象,或者将来可能要动态配置,就上动态代理。静态和动态不是进化关系,而是两种不同侧重的工具。
7.2 增强逻辑如果可以配置,handler和callback要设计好
动态代理最强大的地方是“一个处理器可以服务所有目标对象”,但它也很容易被写成一个上帝类,职责全部堆到一个invoke或intercept方法里。我在实际项目里见过一个handler,里面塞了日志、鉴权、租户隔离、敏感词过滤、数据权限十几种逻辑,代码超过两千行,改一个地方都要全局回归。
更好的做法是把handler设计成小的、可组合的增强单元,每个增强单元只做一件事,再通过一个调用链把它们串起来。如果你需要运行时可配置,还可以把这些增强单元的关键参数外置到配置中心。这样动态代理的灵活性才真正落地,而不是变成新的技术债。
7.3 一个调试技巧:把生成的代理类保存下来,肉眼反编译
最后分享一个我用了很多年的调试技巧。动态代理的Bug往往难在“看不到代理类本身”,你怀疑它有某段逻辑,但代码里根本找不到这个类。把生成的代理类落盘,再用javap反编译,就能直接看清每个方法到底转交给了谁。
JDK 8里可以用ProxyGenerator.generateProxyClass把代理class保存出来。CGLIB则可以通过Enhancer配合设置new WeakReference或者反射获取byte[]再写文件。反编译后你可能发现,某些你以为会被拦截的方法根本没有进入handler,或者invoke里调用proxy.xxx导致死循环。看懂这些字节码之后,一切黑盒都会变成白盒。
我个人在实际排查中,最深的体会是:静态代理和动态代理之间的本质区别,从来不是“运行速度谁快谁慢”,而是“增强逻辑到底写在编译期还是运行期”。静态代理把增强逻辑写死在类里,看得见摸得着;动态代理把增强逻辑抽出来,在运行时把类拼出来。理解了这个底层思维,再去看Spring AOP、Feign、MyBatis这些框架里的代理机制,都会觉得特别顺。真要遇到难搞的代理问题,别忘了先看一眼类名,再决定从哪里下手。