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

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

相关推荐
GetcharZp22 分钟前
GitHub 2.4 万 Star!D2 正在重新定义程序员画图方式
后端
zhangxingchao2 小时前
多 Agent 架构到底怎么选?从 Claude Agent Teams、Cognition/Devin 到工程落地原则
前端·人工智能·后端
IT_陈寒2 小时前
SpringBoot那个自动配置的坑,害我排查到凌晨三点
前端·人工智能·后端
ServBay2 小时前
OpenCode 和它的7款必备插件
后端·github·ai编程
ping某2 小时前
逐字节拆解 tcpdump
后端
阿凡9807302 小时前
花 100 dollar,用 Claude 打通 EasyEDA&Fusion 双向同步
后端·程序员
irving同学462382 小时前
从零搭建生产级 RAG:Embedding、Chunking、Hybrid Search 与 Reranker
前端·后端
她的男孩2 小时前
从零搭一个企业后台,为什么我把能力拆成 Starter 和 Plugin
java·后端·架构
胡志辉2 小时前
本地 AI 编码助手从 0 配起来:先选模型,再接 Ollama、VS Code、Claude Code 和 Codex
前端·后端
RainCity2 小时前
Java Swing 自定义组件库分享(七)
java·笔记·后端