ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

代理模式从入门到实战:Java动态代理与Spring AOP源码解析

代理模式从入门到实战:Java动态代理与Spring AOP源码解析 做后端第一天开始我就发现“代理Proxy”这个词在开发里无处不在Java反射里有动态代理Spring源码里藏着ProxyFactoryABAP那边有SPROXY来生成代理类甚至手机App抓包要先配WiFi代理开发工具连不上目标接口时报的也是“proxy failed”。同一个词在不同技术栈里干着完全不同的活但底层逻辑高度一致不直接碰真实对象而是在中间拦一道替调用方干活或者替目标方挡事。这篇文章我按自己实际踩坑的顺序来写先讲透动态代理的底层机制再带大家看一遍Spring AOP的ProxyFactory源码链路然后是本地代理故障排查那些401、404、503的报错到底在说什么接着把HiSuite Proxy配置和ABAP Proxy生成这两个相对冷门的场景也拉出来聊。这并不是什么高深理论但都是能直接用在排障和编码里的硬货。1. 动态代理到底在解决什么问题1.1 代理的本质是拦截和转发不是包装很多人会把代理和装饰器混在一起这两者看着像动机完全不同。装饰器是在原功能上叠加新能力强调的是“增强”代理强调的是“控制访问”目标通常是我不让调用方直接碰真实对象所有请求都得先过我这一关。生活里最好懂的例子是房产中介。你买房不会直接找房东而是找中介。中介帮你挡掉了大量无效看房也替房东筛选了真正有购买力的人。作为调用方你以为自己在跟房东沟通其实所有对话都被中介过滤了一遍。这就是代理模式调用方持有的是代理对象代理对象背后才是真正的目标。放到代码里也是一样的。比如一个Service类要加日志、加权限校验最朴素的做法是改成方法里写一堆if和log但这样业务代码就被污染了。动态代理能做到的是业务类保持干净所有附加逻辑都写在InvocationHandler或者MethodInterceptor里由代理对象在方法调用前后插入这些逻辑。1.2 动态代理相对静态代理的价值静态代理是最原始的代理方式手写一个代理类实现和目标类相同的接口在代理类里持有真实目标对象然后逐个方法转调。这个方案的问题很明显每增加一个接口就要同步维护一个代理类项目一多代码量爆炸。如果真实目标新增方法代理类必须同步改漏一个就编译错。所有代理类的模板代码都差不多纯粹是体力活。动态代理就是把“生成代理类”这件事交给了JVM或者字节码框架。只要给出目标和拦截逻辑运行时就自动生成一个代理对象接口再多也不用手写。Spring AOP、MyBatis的Mapper代理、Retrofit的接口实现、Mockito的Mock对象底层全是这套机制。1.3 动态代理的主要应用场景AOP横切逻辑日志、事务、权限、性能监控这是Spring里最常见的使用场景。远程调用伪装成本地调用像Retrofit把HTTP接口变成Java接口底层就是一个动态代理在拦截方法调用并组装请求。延迟加载Hibernate的实体类属性就是这样你访问某个字段时才真正发起SQL查询用的是代理对象拦截getter。测试MockMockito.mock()返回的对象本质是一个代理方法调用不走真实逻辑而是返回预设值。ABAP和中间件集成SAP PI/PO通过代理对象把外部服务包装成ABAP类方法对业务代码来说是透明的。2. Java动态代理底层机制拆解2.1 JDK动态代理基于接口的反射实现JDK自带的动态代理只认接口不认类。核心就两个东西InvocationHandler接口和Proxy类。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([proxy] before method: method.getName()); Object result method.invoke(target, args); System.out.println([proxy] after method: method.getName()); return result; } } // 使用 UserService userService (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class?[]{UserService.class}, new LogHandler(new UserServiceImpl()) );这里有几个细节值得注意。第一invoke方法里传入的proxy参数是那个运行时生成的代理对象本身千万不要在这个方法里把proxy强制转换成业务接口再调用方法那样会无限递归直接栈溢出。第二Proxy.newProxyInstance的三个参数分别是类加载器、接口数组、拦截处理器。代理对象的类在运行时被动态生成它的类型是com.sun.proxy.$ProxyN所以你把代理对象强转成了接口类型但无法强转成实现类类型因为代理类和实现类没有任何继承关系。调试时你会在Idea的变量面板里看到$Proxy25这种类名很多同事第一次看到会慌以为对象被搞坏了。其实这就是JDK动态代理对象的正常形态它和你写的UserServiceImpl是两个完全不同的类唯一的关系是实现了同一个接口。2.2 CGLIB动态代理基于字节码的子类继承CGLIB走的是另一条路它通过ASM字节码技术直接生成目标类的一个子类子类里重写了目标类的所有非final方法然后在重写的方法里回调MethodInterceptor。因为没有依赖接口它可以代理普通类。Enhancer enhancer new Enhancer(); enhancer.setSuperclass(UserServiceImpl.class); enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) - { System.out.println([cglib] before method: method.getName()); Object result proxy.invokeSuper(obj, args); System.out.println([cglib] after method: method.getName()); return result; }); UserServiceImpl proxyService (UserServiceImpl) enhancer.create();注意这里用的是invokeSuper不是反射的method.invoke。因为代理类是目标类的子类直接invoke的话调用的还是重写后的方法又递归了。invokeSuper走的是父类方法调用这才是安全的。CGLIB的限制同样明显final类无法被继承final方法无法被重写JDK 17环境下还要注意模块对非导出类的反射访问限制。另外CGLIB生成子类时会复制父类的所有非private方法如果目标类内部方法特别多生成的字节码也会偏大。2.3 两者的核心差异与选型建议对比项JDK动态代理CGLIB动态代理实现方式反射 运行时生成接口实现类ASM字节码 生成目标类子类依赖条件目标必须有接口目标类可继承方法不可final性能特点创建代理对象快反射调用稍慢创建稍慢方法调用走FastClass机制整体更高适用场景Spring默认早期选择Spring Boot 2.x默认选择Spring在早期版本里的策略是目标类实现了接口就用JDK没有接口才退回CGLIB。但从Spring Boot 2.x开始官方把默认策略改成了强制CGLIB也就是spring.aop.proxy-target-classtrue成了默认值连接口都继承自BeanDefinition的上下文都默认走子类代理。为什么做出这个改动一方面CGLIB调用的整体性能在现代JVM上已经不输JDK代理另一方面强制CGLIB可以避免那种“类没实现接口导致AOP静默失效”的经典坑。我见过不止一个项目因为把proxyTargetClass配成false某个只有实现类没有接口的Bean悄悄绕过了事务切面线上出现诡异的数据不一致排查了大半天才发现AOP压根没生效。3. Spring底层代理源码解析从ProxyFactory到AOP生效3.1 ProxyFactory在Spring里的核心角色Spring的AOP底层所有代理对象的创建都集中在ProxyFactory这个类上。它不像表面的AOP注解那么花哨本质就干三件事收集通知器Advisor、决定用哪种代理方式、生成代理对象。核心源码在DefaultAopProxyFactory#createAopProxypublic AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException { if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) { Class? targetClass config.getTargetClass(); if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) { return new JdkDynamicAopProxy(config); } return new ObjenesisCglibAopProxy(config); } else { return new JdkDynamicAopProxy(config); } }这段逻辑是理解Spring代理的关键。如果你的配置开启了proxyTargetClass或者目标类没有任何用户自带的接口Spring就走CGLIB只有当目标类本身就是接口类型或者已经是JDK代理类才会稳定走JDK代理。Configuration配置类也是特殊的代理目标Spring需要拦截Bean方法调用保证容器内单例语义所以配置类强制走CGLIB增强。哪怕你的配置类没有AOP需求只要被Spring容器接管它被创建出来时十有八九也是CGLIB代理对象。3.2 从注解到代理对象的完整链路当你在方法上写Transactional或者自定义一个Aspect注解Spring处理的过程大致是第一步AnnotationAwareAspectJAutoProxyCreator作为BeanPostProcessor注册进容器它在postProcessAfterInitialization阶段介入。第二步Bean实例创建完成后Spring调用wrapIfNecessary用findEligibleAdvisors找到当前Bean匹配的所有切面通知。第三步如果找到了匹配的Advisor就把Bean包进ProxyFactory生成代理对象返回。第四步**容器里最终保存的Bean引用就是代理对象你通过Autowired拿到的是代理不是原始Bean。这里有个非常重要的点postProcessAfterInitialization发生在Bean初始化之后所以Bean的构造方法不会经过代理拦截。如果你试图在构造方法里调用某个被AOP增强的方法会发现切面完全不生效因为那时候代理都还没创建。3.3 循环依赖场景下的提前代理暴露Spring的三级缓存解决了循环依赖但很多人没注意到它跟代理的关系。A和B互相依赖A创建时发现自己需要B而B还没创建完于是Spring把A提前暴露到三级缓存。关键是三级缓存里存的不是A的原始对象而是一个ObjectFactory。这个工厂在getEarlyBeanReference里会执行一个动作如果A需要被代理就在这里提前生成代理对象。这样B拿到手的引用就是A的代理避免了“原始对象和代理对象不一致”的问题。AbstractAutoProxyCreator里有一段代码就是干这个的protected Object getEarlyBeanReference(Object bean, String beanName) { if (this.earlyProxyReferences.remove(beanName) ! null) { return wrapIfNecessary(bean, beanName); } return bean; }这段逻辑解决的核心矛盾是如果不在早期就创建代理那么同一个Bean在容器里就会存在两种引用——早期暴露的原始对象和最终替换的代理对象B依赖的那份就是原始对象后续A里的切面逻辑全失效。3.4 实操查看Bean到底是哪种代理我平时排查问题第一步就是确认目标Bean的代理类型。最简单的方法是断点看类名类名类似com.sun.proxy.$Proxy12就是JDK动态代理。类名类似com.example.service.UserServiceImpl$$EnhancerBySpringCGLIB$$xxxx就是CGLIB代理。不想断点的话可以在代码里判断if (AopUtils.isJdkDynamicProxy(bean)) { // JDK动态代理 } else if (AopUtils.isCglibProxy(bean)) { // CGLIB代理 }还有一个容易被忽视的坑如果你在Service内部用this.xxx()调用同类方法切面不会生效。因为this指向的是原始对象而不是代理对象整个拦截链被跳过了。解决办法是把Bean注入自己加上Lazy避免循环依赖或者拆到另一个Bean里调用。4. 开发工具本地代理故障排查实录4.1 报错现场cc switch local proxy failed while handling codex endpoint我在实际调试开发辅助工具时遇到过一个很典型的报错cc switch local proxy failed while handling codex endpoint /responses. provi... unexpected status 404 not found: cc switch local proxy failed while handling这个报错文本虽然看着复杂但拆开看并不难。local proxy是本地代理服务它负责把本机的API请求转发到实际的目标服务。unexpected status指的就是HTTP状态码不在预期范围内404代表目标接口不存在503代表上游服务暂时不可用401代表身份认证失败。我当时遇到的情况是本地代理服务正常启动端口也在监听但每次转发到/responses这个端点时上游返回404。第一反应不是怀疑代理代码而是先用curl直接测目标端点curl -i http://127.0.0.1:8080/responses -H Authorization: Bearer token结果发现直接访问同样返回404。这说明问题不在代理层而是上游服务的路由压根没有注册这个路径。查了配置发现请求需要打到的实际地址是/v1/responses本地代理配置文件里的basePath漏了/v1前缀。路径不一致是最容易引发404的原因排查时要先确认接口文档和完整地址。4.2 HTTP状态码背后的真实含义我看过很多人在代理报错面前乱猜一通其实状态码已经把方向指出来了。拿这类本地代理工具来举例状态码典型原因排查方向401 Unauthorized请求头里没有携带有效的认证token或token过期检查Authorization头确认token未过期且bearer格式正确404 Not Found目标URL路径不对路由未注册复核完整URL确认basePath和端点路径拼写503 Service Unavailable目标服务正在重启、过载或被网关限流看上游服务日志检查健康检查端点我那次还遇到过503。当时本地代理启动了目标服务也在跑但代理一转发就报503。用curl直接访问目标服务很正常问题就出在了本地代理与目标之间的负载均衡策略上目标服务靠着心跳检查代理发现实例临时不可达于是直接返回503。重启了目标服务等心跳恢复再请求就正常了。4.3 这类代理故障的通用排查套路在自己踩过几次坑之后我总结了一套固定的排查顺序遇到“proxy failed”先按这个来第一步开DEBUG日志。本地代理工具一般有debug模式日志里会打印详细的请求地址、请求头和上游响应这是最直接的信息源。第二步绕过代理直连。用curl、Postman直接请求目标地址确认是代理的问题还是目标服务的问题。第三步检查代理配置。注意basePath、target、headers这些字段重点确认路径拼接、认证头、超时时间。第四步检查目标服务的健康状态。看进程、看日志、看端口别在代理层浪费时间。第五步注意代理支持的协议类型。有时候配置文件里填了一个框架不认识的协议类型启动时会直接抛unsupported proxy type一类的错误。这种报错通常是字符串匹配不上配置里写了大写框架枚举里定义的是小写或者协议类型本身就写错了。核对一下框架文档里支持的协议枚举基本都能解决。注意排查任何代理类问题时别一上来就怀疑网络环境先看日志再复现90%的问题出在配置或上游服务而不是代理框架本身。5. HiSuite Proxy配置与抓包实录5.1 HiSuite Proxy是用来做什么的华为手机通过USB连电脑时HiSuite是官方管理工具。很多人不知道HiSuite里还内嵌了代理功能也就是HiSuite Proxy。它的核心用途是让真机App的网络请求走电脑上的抓包工具方便开发者和测试人员定位接口问题。简单理解这个代理就是一道中间站手机App发出请求先到电脑上监听的端口再转发到真实服务器。这样你就有了一个观察点所有请求和响应都可以在电脑上截获看到App到底调了哪个接口、参数是什么、返回了什么。5.2 安装配置的完整步骤我自己配置过一次步骤不复杂但有一点容易走弯路这里按实测过的顺序写第一步在电脑上启动抓包工具比如Charles或Fiddler记下监听端口通常是8888或8866。先确认电脑防火墙没有挡这个端口的入站连接。第二步在HiSuite的偏好设置里找到代理相关选项填入电脑IP和刚才的端口。手机和电脑必须处于同一局域网电脑IP用ipconfig或ifconfig查。第三步手机端也要在WiFi设置里配置代理填写电脑IP和对应端口。这一步经常被漏掉只看电脑端配置结果手机流量根本没走代理。第四步抓HTTPS包还需要安装并信任证书。电脑端把Charles或Fiddler的根证书导出传到手机上安装然后在手机设置里手动信任该证书。第五步配置完成后在手机上操作任意App观察电脑端抓包工具是否出现流量。5.3 常见问题与避坑经验Android 7的证书信任问题安卓高版本默认只信任系统证书用户安装的证书对大多数App无效。解决方案是做系统证书导入需要Root或者使用调试机型或者让App开启networkSecurityConfig允许用户证书。代理端口被占用启动抓包工具时报端口被占用可能是之前运行的服务没有释放端口。换个端口或者用lsof查一下占用进程。手机连不上代理先确认电脑防火墙再确认手机和电脑在同一网段。很多公司办公WiFi默认开了AP隔离手机和电脑即使连同一个WiFi也互相不通。连接一会就断检查抓包工具的代理设置是否只开了“本机代理”而没开“远程代理”。有些工具在默认配置下拒绝来自其他设备的连接需要勾选“Allow remote devices to connect”。提示用这套方案抓自己的App包没问题但别把别人的流量也导过来一方面道德上不合适另一方面某些合规要求也很严格自己调试自己的应用就够了。6. ABAP Proxy企业集成里的代理生成6.1 SPROXY和ABAP代理生成ABAP的世界里也有代理不过它跟Java反射、字节码完全不是一回事。SAP PI/PO是SAP的集成中间件外部系统和SAP之间通过接口通信SAP里用SPROXY这个事务代码来生成代理对象。生成的ABAP类就是外部服务的代理业务代码调用这个类的方法就像调用本地方法一样底层实际在做SOAP/HTTP的远程调用。这种代理分成出站Outbound和入站Inbound。出站代理负责从SAP主动调用外部系统入站代理负责接收外部系统的请求生成对应的处理类让ABAP开发填充业务逻辑。6.2 通过接口编号查询接口函数很多人第一次接触SPROXY都卡在“怎么通过接口编号查到对应的接口函数”这一步。这里的接口编号不是随便的字符串而是ESREnterprise Services Repository里对象的命名空间和接口名组合。实际操作分几步第一步进入事务代码SPROXY这可以直接打开对象浏览器。第二步在左侧树里按命名空间找到目标接口或者用“查找”功能输入接口编号/名称系统会自动定位到对应的Proxy类。第三步双击接口后主界面会列出生成出来的方法名把这些方法名和ESR里定义的接口操作对应起来。第四步定位到具体方法后选中方法点“显示”或直接双击跳进生成代码。生成的类通常命名规则是CO_XI_前缀后面跟着接口名或命名空间缩写。如果是在SE80里让同事交接最常见的问题是他只告诉你接口编号没告诉你命名空间。没有命名空间SPROXY里搜半天也搜不到。正确做法是让接口设计方完整提供命名空间 接口名 接口版本这三个合起来才能唯一定位。6.3 生成代理之后的注意事项不要手工修改生成代码SPROXY生成的类是工具产物任何手动修改都会在下次重新生成时被覆盖。真要调整逻辑写在增强点Enhancement里。注意结构映射代理类里的结构和ESR里的消息结构一一对应如果外部系统升级了字段必须重新导入ESR并重新生成代理光改ABAP代码没用。激活顺序生成完代理后关联的接口和数据元素必须全部激活在激活列表里用F9批量激活。只激活单独某个类很容易出现依赖未激活的错误。用接口编号定位方法时版本对不上会指向错误的对象比如ESR里有V1.0和V1.1两套接口编号相同方法签名可能已经变了。查完编号之后一定要确认目标对象所属的软件组件版本和代码里IF_PROXY引用是否一致。ABAP代理这个场景看起来偏冷门但在企业和外部系统做集成时非常关键。我接过几次ESB改造的活发现很多接口联调失败都不是因为业务逻辑而是在SPROXY生成阶段就埋了雷要么命名空间配错要么版本对不上要么生成了代码但没激活。把这些基础规则理清楚联调能省一半时间。最后分享一个我自己调试代理问题时的习惯现在不管是Java动态代理、Spring AOP代理还是本地代理工具和ABAP Proxy我遇到问题第一件事永远是先确认“当前拿到的对象到底是不是代理对象、是哪种代理”。不看对象实际类型就调方法、不看状态码就去查网络、不确认生成的代码是否激活就开始改配置这些弯路我都走过。拿到代理对象后用getClass()看一眼类名用AopUtils判断一下代理方式用curl确认一下上游用SPROXY核对一下接口标识大部分问题在十分钟内都能定位。归根结底代理只是中间层真正要盯住的永远是两端调用方传进来什么目标方返回什么。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进