被 AI 惯坏后踩的坑:Spring 代理对象 + 反射 = NPE

被 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 里面的 userClientnull,直接 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 的方法,老老实实通过正常方式调用。反射是在和代理对着干。

面试题不是白背的,只是容易忘 :)

相关推荐
huanmieyaoseng10032 小时前
SpringBoot使用Redis缓存
java·spring boot·后端
无心水2 小时前
2、5分钟上手|PyPDF2 快速提取PDF文本
java·linux·分布式·后端·python·架构·pdf
他日若遂凌云志3 小时前
一文搞懂多线程:解锁并发编程
后端
码上实战3 小时前
到底Java 适不适合做 AI 呢?
java·人工智能·后端·python·ai
sTone873753 小时前
数据库选型深入 — 从原理到决策
后端
码农BookSea3 小时前
RAG详解:让大模型看见你的私有知识
人工智能·后端
rannn_1113 小时前
【Redis|实战篇7】黑马点评|附近商铺、用户签到、UV签到
java·数据库·redis·后端·uv
来一斤小鲜肉3 小时前
一文搞懂:如何用 Spring AI 搭建 MCP Server 和 Client
后端·langchain
极客沐森3 小时前
面试提问:在电商秒杀活动中,如何防止“超卖”现象的发生
后端