被 AI 惯坏后踩的坑:Spring 代理对象 + 反射 = NPE
背景
这是一个曾经背烂了的 Java 面试题------"Spring AOP 的实现原理是什么?CGLIB 代理和 JDK 动态代理有什么区别?"
当年面试能答得头头是道,什么"CGLIB 通过生成子类实现代理"、"代理对象不是原始对象"......结果现在天天让 AI 写代码,真碰上了反而没反应过来,debug 了好一会儿才想明白。
记录一下,给同样被 AI 惯坏的朋友们提个醒。
省流版结论
不要对 Spring Bean 用反射调用私有方法。
你
@Autowired,@Resource拿到的可能是 CGLIB 代理对象,代理对象的字段全是null。 反射会绕过代理拦截,直接在代理对象上执行,访问到的字段自然是null。
场景还原
有一个定时任务 SomeJob,里面注入了 Feign Client 来调用其他微服务:
java
@Component
public class SomeJob {
@Autowired
private UserClient userClient; // Feign Client,调远程服务
@Autowired
private KafkaTemplate kafkaTemplate;
// 公开的入口方法,内部有一堆前置校验
public void execute() {
// ... 校验 Redis 缓存、检查状态、匹配条件 ...
sendNotify(record);
}
// 私有方法,真正干活的
private void sendNotify(Record record) {
// 调用 Feign Client 查询用户信息
Result<UserInfo> result = userClient.getUserInfo(record.getUid());
// ... 组装消息,发送 Kafka ...
}
}
写集成测试时,我只想测 sendNotify 这一个方法,不想跑 execute 里那一大堆前置校验。但 sendNotify 是 private 的,没法直接调,于是很自然地用了 Spring 提供的 ReflectionTestUtils 通过反射调用:
java
@Autowired
private SomeJob someJob;
@Test
public void test() {
Record record = buildTestRecord();
// 通过反射调用私有方法,跳过 execute() 里的前置校验
ReflectionTestUtils.invokeMethod(someJob, "sendNotify", record);
}
结果一跑,debug 进去发现 sendNotify 里面的 userClient 是 null,直接 NPE。
第一反应:是不是 Eureka 没连上?Feign 客户端没创建出来?
排查了半天网络和配置,发现不是。把 sendNotify 改成 public,换成直接调用 someJob.sendNotify(record),userClient 有值,一切正常。
同样的对象,同样的方法,只是调用方式不一样,字段就一个有值一个没值?
原因
SomeJob 这个 Bean 被 Spring AOP 增强了(项目里有链路追踪、熔断器等切面),所以容器里放的不是原始对象,而是一个 CGLIB 代理对象。
代理对象长这样:
ini
┌── CGLIB 代理对象(你拿到的) ──────┐
│ │
│ feignClient = null ❌ │
│ redisTemplate = null ❌ │
│ kafkaTemplate = null ❌ │
│ │
│ ┌── 原始 target 对象 ──────────┐ │
│ │ feignClient = 已注入 ✅ │ │
│ │ redisTemplate = 已注入 ✅ │ │
│ │ kafkaTemplate = 已注入 ✅ │ │
│ └──────────────────────────────┘ │
└────────────────────────────────────┘
Spring 只对 target 做了依赖注入,代理对象只是个"壳",自身字段全部是默认值 null。
两种调用方式对比
正常调用(走代理)
kotlin
someJob.doSomething(param)
│
▼
CGLIB 拦截器
│ 执行 AOP 逻辑(链路追踪、熔断等)
▼
target.doSomething(param) ← 在原始对象上执行
│
▼
this.feignClient.call(...) ← this = target,字段有值 ✅
反射调用(绕过代理)
kotlin
ReflectionTestUtils.invokeMethod(someJob, "doSomething", param)
│
▼
Method.invoke(proxy, param) ← 直接在代理对象上执行,不经过拦截器!
│
▼
this.feignClient.call(...) ← this = proxy,字段是 null ❌ → NPE
反射调用时 this 指向代理对象本身,而代理对象的字段没有被注入,所以 null。
怎么避免
方案一:别用反射,改成 public 直接调用(推荐)
最简单。测试本来就不该依赖反射去调私有方法,如果一个方法值得单独测试,说明它值得被暴露出来(至少 package-private)。
java
// 把方法改成 public 或 package-private
someJob.doSomething(param); // 直接调用,走代理,一切正常
方案二:从代理中取出 target 再反射
如果确实不想改原始代码的可见性:
java
Object target = AopTestUtils.getUltimateTargetObject(someJob);
ReflectionTestUtils.invokeMethod(target, "doSomething", param);
AopTestUtils.getUltimateTargetObject() 会层层剥开代理,拿到最里面的原始对象。
方案三:用 Mockito 做单元测试
不启动 Spring 容器,手动 mock 依赖,压根不会有代理问题:
java
@InjectMocks
private SomeJob someJob;
@Mock
private FeignClient feignClient;
总结
| 正常调用 | 反射调用 | |
|---|---|---|
| 经过 CGLIB 拦截 | 是 | 否 |
this 指向 |
target 原始对象 | proxy 代理对象 |
| 字段值 | 已注入 | null |
一句话:Spring Bean 的方法,老老实实通过正常方式调用。反射是在和代理对着干。
面试题不是白背的,只是容易忘 :)