反射和依赖注入
问题
虽然我毕业刚开始找的工作是Java,但是工作后一直干的前端:)
后来有机会又干回了Java
在我转后端最开始,刚接手项目时
我问了我同事一个比较弱智的问题
怎么一个类里的私有对象 没有new,也没有输入 怎么就直接能在下面的函数里用上了呢,和js不太一样啊
同事回了一句:依赖注入
我挠头 很显然java原理都忘光了
场景如下:
@Service
public class OrderService {
@Autowired
private UserClient userClient;
// 没有 new,没有 setter,字段还是 private
// 但下面这个方法里它就是有值
public Order createOrder(Long userId) {
UserDTO user = userClient.findById(userId);
return new Order(user);
}
}
前端写久了会本能地找"赋值"这两个字。这里没有赋值,只有一个注解。
结论
注解背后是两件事:反射是 Java 的语言能力,依赖注入是一个设计选择。 前者是手段,后者是目的。
一、@Autowired 替你做了三步
Spring 启动时干的事,拆开看就三步,每一步都要反射:
- 找到有哪些类要管。扫 classpath,读类上的
@Service/@Component——读注解本身就是反射(getAnnotation)。 - 把对象造出来。类只是个名字,Spring 拿着
Class对象找到构造器,反射调用它。 - 把依赖塞进去。找带
@Autowired的字段,反射赋值:
Field f = clazz.getDeclaredField("userClient");
f.setAccessible(true);
f.set(instance, userClientInstance);
Spring 里干第 3 步的是 AutowiredAnnotationBeanPostProcessor,它在 bean 初始化阶段扫描带注解的字段和方法,然后走上面这三行。
所以"没有 setter 也能注入"不是 Spring 有什么特权,是 Java 本来就允许运行时改私有字段。
二、@Autowired / @Resource / @RequiredArgsConstructor
三种写法都能让那个字段"莫名其妙有值",但它们不是一类东西:
// 1. Spring 自己的注解,按类型注入
@Service
public class OrderService {
@Autowired
private UserClient userClient;
}
// 2. JSR-250 的标准注解,不是 Spring 的:先按名字,找不到再按类型
@Service
public class OrderService {
@Resource
private UserClient userClient;
}
// 3. Lombok 的注解,编译期生成构造器,它自己不做任何注入
@Service
@RequiredArgsConstructor
public class OrderService {
private final UserClient userClient;
}
第三个跟前两个不在一层。 @RequiredArgsConstructor 不注入任何东西,它只在编译期帮你把构造器写出来——等价于手写:
public OrderService(UserClient userClient) {
this.userClient = userClient;
}
Spring 4.3 之后,类里只有一个构造器时会自动拿它来注入,连 @Autowired 都不用写。所以它看着像注入,实际是构造器注入的语法糖。
| @Autowired | @Resource | @RequiredArgsConstructor | |
|---|---|---|---|
| 出处 | Spring 自带 | JSR-250(JDK 8 自带,JDK 11 起要额外引依赖) | Lombok,编译期 |
| 匹配方式 | 按类型,多个同类型要配 @Qualifier / @Primary |
先按名字(字段名),找不到再按类型 | 按类型,走构造器 |
| 字段能 final | 不能 | 不能 | 必须 final,否则不进构造器 |
| 循环依赖 | 被三级缓存兜住,能启动 | 同左 | 启动直接报错 |
| 单测 | 要起容器或反射塞值 | 同左 | new 一下就行 |
(后面三行的原因见第五节。)
各自的坑:
- @Autowired 按类型:同一个接口有两个实现,启动就炸,得补
@Qualifier("xxx")或者给其中一个标@Primary。 - @Resource 按名字:字段名就是匹配依据。改个字段名可能换掉注入的对象,或者直接注入失败——这种失败跟代码逻辑无关,排查时很容易往别的方向想。
- @RequiredArgsConstructor 的字段必须是 final(或者标
@NonNull)。忘了加 final,它就不进构造器,运行起来是 null,编译期一点提示都没有。 - Lombok 本身有成本:IDE 要装插件,JDK 升级时 Lombok 版本得跟上,否则编译挂;团队里有人没装插件就会看到一片红线。
- ps:注解没加,就是 null。 我就修过别人一个 bug——
@Autowired忘了写,字段从头到尾没被注入,用到的时候直接 null。麻烦的地方在于编译和启动都不报错,只有跑到那行才知道。
我的选择:默认用 @RequiredArgsConstructor 走构造器;一个类型有多个实现、想按名字挑的时候用 @Resource;@Autowired 字段注入能不写就不写。
三、反射:凭什么改得动 private
反射的字面意思就是程序转过来看自己。类、字段、方法、构造器,在运行时都是 JVM 里的对象:
Field f = OrderService.class.getDeclaredField("userClient"); // private 也拿得到
f.setAccessible(true); // 这次访问别做检查
f.set(service, someClient); // 改掉了
private 不是安全机制,是设计意图——它在说"这个字段的修改应该走我提供的方法"。setAccessible(true) 一开,这个约定就失效了。
三条代价要知道:
- 慢。 要走访问检查、参数装箱成
Object[]、JIT 难内联。装配期(启动、创建对象)用没问题,放在每秒跑几万次的循环里就是开销。 - 绕过封装。 改了不走校验、不触发通知,谁都不知道。
- JDK 9 之后不是万能钥匙。 跨模块反射一个没有
opens的包,setAccessible(true)直接抛InaccessibleObjectException。报错长得像权限问题,实际是模块没开放。
四、依赖注入解决的是"谁去找依赖"
把反射放一边,先说注入本身。
不用注入的写法是自己 new:
public class OrderService {
private final UserClient userClient = new UserClient();
}
问题不在 new,在于 OrderService 从此知道 UserClient 怎么构造。而 UserClient 自己还有依赖(HttpClient、baseUrl……),于是一条链就出来了:每个类都在替它的依赖做决定,改一个底层实现,上面的全得动。
注入的思路是把决定权交出去:
public class OrderService {
private final UserClient userClient;
public OrderService(UserClient userClient) { // 我要什么,说清楚
this.userClient = userClient; // 谁给的,我不管
}
}
类只声明"我需要什么",谁负责送进来是别人的事。这就是控制反转:不是"我去拿",而是"有人给我"。
好处很具体:想换个实现(测试时换假的),OrderService 一行都不用改。前端类比一下就是 props——组件不自己造数据,数据从外面传进来。
五、为什么现在都推荐构造器注入(@RequiredArgsConstructor)
先把一个容易混的点说清楚:构造器注入是"做法",@RequiredArgsConstructor 只是省手写的写法之一。
@Autowired 标在构造器上同样是构造器注入,跟 Lombok 生成出来的那个构造器是一回事。真正对立的是字段注入和构造器注入,不是这两个注解。
@Autowired 做构造器注入,就是把注解标在构造器上:
@Service
public class OrderService {
private final UserClient userClient;
private final SmsClient smsClient;
@Autowired // 标在构造器上,不是字段上
public OrderService(UserClient userClient, SmsClient smsClient) {
this.userClient = userClient;
this.smsClient = smsClient;
}
}
Spring 做的事:发现构造器有参数 → 按参数类型去容器里找 bean → 反射调用这个构造器。全程不碰字段,所以 private 不需要 setAccessible,也不存在"先造个半成品再塞值"那一步。
两个边界:
- 类里只有一个构造器时,这个
@Autowired可以省——Spring 4.3 起自动拿它注入,上面那段去掉注解完全能用。 - 有多个构造器且都不标注解时,Spring 会去找无参构造器——找到了就用它(依赖反而不注入),找不到就启动报错。所以多构造器场景要在你想用的那个上标一个
@Autowired。
顺带一个隐性保险:字段写成 final 之后,无参构造器编译都过不去(final 必须初始化),不会悄悄走到"依赖没注入"那条路上。
所以对照着看就很清楚——同一个注解,标的位置决定它是哪种注入:
@Autowired private UserClient userClient; // 字段注入
@Autowired public OrderService(UserClient c) { ... } // 构造器注入
四条,都能验证:
- 字段能写成 final。 字段注入的字段不能是 final——对象是先造出来、之后才被塞值的。final 意味着构造完成那一刻状态就定了。
- 不会拿到"半个对象"。 字段注入时对象先被 new 出来,那一刻依赖还是 null。这中间有别的代码用到它(
@PostConstruct里、初始化方法里)就是 NPE,而且跟启动顺序有关,时有时无。 - 循环依赖当场暴露。 Spring 对字段注入的循环依赖有兜底(三级缓存,先把半成品引用暴露出去让对方用着),所以能启动成功。构造器注入绕不过去,启动直接报
BeanCurrentlyInCreationException。 这一条常被当成构造器注入的缺点,我反过来想:循环依赖本身是设计问题,能启动不代表没问题,它只是把问题从"启动失败"变成了"某个时刻行为诡异"。 - 单测不用起容器。 构造器注入的测试就是
new OrderService(new FakeUserClient())。字段注入要么起 Spring 容器(慢),要么用反射自己塞值(脆,改个字段名测试就挂)。
四条里我最在意第 2 条:只有它是"能启动但会随机出错",这类问题最难查。
六、如果面试被问到:讲一下 Java 的反射 / 依赖注入
被问"讲一下反射和依赖注入",按这个顺序说,不容易散:
第一步,各一句话定性。 反射是运行时读类信息的能力——类、字段、方法、构造器在 JVM 里都是对象,能枚举、能调用。依赖注入是"谁负责找依赖"的设计选择——类只声明需要什么,由外部送进来。
第二步,说清楚两者什么关系。 注入是目的,反射是实现它的手段之一。Spring 用反射扫注解、调构造器、塞字段。它们是两件事,不是一个东西的两面。
第三步,举一个具体的点证明你真的看过。 比如:private 字段没有 setter 也能注入,不是 Spring 有特权,是 Java 允许运行时改私有字段——Field.setAccessible(true) 之后 set 进去,干这活的是 AutowiredAnnotationBeanPostProcessor。
第四步,给主张。 我倾向构造器注入:字段能 final、没有半初始化状态、循环依赖启动就暴露、单测不用起容器。
追问大概率落在这几个点上:
- 反射有什么代价? 性能(热路径别用)、绕过封装、JDK 9 之后受模块系统限制(没
opens就InaccessibleObjectException)。 - @Autowired 和 @Resource 有什么区别? 前者按类型,后者先按名字(字段名)再按类型。见第二节。
- 循环依赖怎么办? 字段注入被 Spring 三级缓存兜住、能启动;构造器注入启动就报错。我的看法是后者更好——问题暴露在启动期,而不是运行期的某个时刻。
- 为什么字段不能是 final? 因为字段注入是对象造出来之后才塞值的,final 要求构造期就定下来。
讲的时候别一上来背 API。getDeclaredField、setAccessible 这些名字顺口带一句就行,重点是"注解不做事,框架读注解做事"这句——它说明你理解的是机制,不是语法。
七、换成 TypeScript:装饰器不是注解
既然是从前端转回来的,最后补一层对比。两边都叫"注解 / 装饰器",但它俩不是一回事。
Java 的注解是标签,TS 的装饰器是函数。
@Autowired
private UserClient userClient;
这段注解不执行任何代码。它只是往字段上贴了个标签,谁去读、读了要做什么,跟它自己无关。
@Service
class OrderService {}
TS 里这个 @Service 是一个真的会被调用的函数。编译之后:
OrderService = __decorate([Service], OrderService);
等价于 Service(OrderService)。类定义完成的那一刻它就跑了,可以改类、改原型、把自己注册进某个容器。
(这里说的是 experimentalDecorators 那套,Angular、NestJS 目前用的也是它;TS 5.0 之后还有一套新的标准装饰器,签名不一样。)
所以:
| Java 注解 | TS 装饰器 | |
|---|---|---|
| 本质 | 元数据,不执行 | 函数,定义时就被调用 |
| 谁让它生效 | 编译器 / 注解处理器 / 框架反射 | 它自己 |
| 运行时能拿到类型信息吗 | 能,字节码里还在 | 不能,编译后类型被擦除 |
| 想做 DI 还缺什么 | 缺读注解的框架(Spring) | 缺类型元数据(emitDecoratorMetadata) |
但 TS 想做依赖注入,得先补一个洞:类型擦除。
Java 能在运行时读到"这个构造器第二个参数是什么类型",因为类型信息在字节码里还在。TS 编译成 JS 之后类型全没了,运行时拿不到参数类型,自然不知道该注入什么。
Angular / NestJS 的补法是打开一个编译选项:
{ "compilerOptions": { "emitDecoratorMetadata": true } }
打开后 TS 会在产物里额外塞一段类型元数据(配合 reflect-metadata),装饰器这才读得到构造参数的类型。
到这一步两边就长得一样了:都是"运行时拿到类型 → 按类型找依赖 → 塞进去"。区别只在类型信息从哪来——Java 是语言自带的(反射),TS 是编译期额外生成的补丁。
顺带补一个 Java 侧的细节:注解保留到什么时候,由 @Retention 决定。
@RequiredArgsConstructor(Lombok)是SOURCE——编译完就没了,它只在编译期改语法树。@Autowired是RUNTIME——得留到运行时让 Spring 反射读。
同一个"注解",保留策略不同,起作用的时机完全不同。这也解释了第二节那个问题:为什么 Lombok 那个看着像注入、其实不是——它压根没活到运行时。
千里之行,始于足下。