Spring Framework的依赖注入是Java后端开发里最常用也最容易被讲玄的东西。IoC、DI、BeanFactory、ApplicationContext这几个词一出来,新手基本就懵了。其实剥开外壳,依赖注入想解决的只有一个问题:让对象不再自己new依赖,而是由容器在运行时把依赖塞给你。这篇文章我结合这些年写代码和维护老项目的经历,把Spring Framework依赖注入的底层思路、三种注入方式、常用配置、Bean生命周期,以及最常见的注入失败场景都梳理出来。适合刚开始学Spring的读者,也适合已经用Spring但经常被“为什么这个字段是null”折磨的人。我会用一个下单场景作为线索,从手写一个极简注入器开始,再回到Spring Framework的实践,这样你不仅能会用,还能知道它背后的逻辑。
1. 依赖注入的本质与Spring Framework的设计思想
1.1 控制反转:到底反转了什么
先看一段最传统的代码。你需要在OrderService里调用一个库存服务扣减库存,最常见的写法是:
public class OrderService { private InventoryService inventoryService; public OrderService() { this.inventoryService = new InventoryServiceImpl(); } }这段代码看起来没什么问题,可一旦你要把InventoryServiceImpl换成加缓存的新实现,或者需要传入一个Mock来写单元测试,就得打开OrderService的构造函数去改。依赖关系越多,这种“对象内部自己找依赖”的方式越会让系统变成一团乱麻。
控制反转(IoC)的核心思想,就是把“创建并组装依赖”这件事从对象内部拿出来,交给一个外部容器统一管理。你负责声明“我需要什么”,至于这些东西从哪里来、什么时候创建、生命周期多长,都由容器决定。依赖注入(DI)就是实现控制反转最常见的一种手段。用个生活类比:你下馆子吃饭不需要自己种菜买菜,只需要点菜,后厨自然会把你需要的东西端上来。容器就是那个后厨,OrderService只需要在“菜单”上写清楚自己需要哪些依赖。
Spring Framework从最早的版本开始就围绕这个思想构建。BeanFactory负责定义和管理Bean,ApplicationContext在它的基础上加了资源加载、事件广播、国际化等功能。你平时写的那些@Service、@Repository标记的类,都会被容器扫描、实例化并做依赖注入。理解这一点,后面所有问题就都建立在地基上了。
1.2 三种常见的依赖注入姿势
Spring Framework支持三种注入方式:构造器注入、Setter注入、字段注入。如果你翻过老代码,三种方式大概率都见过。它们的区别和适用场景我直接列在表里:
| 注入方式 | 声明形式 | 优点 | 缺点 | 使用建议 |
|---|---|---|---|---|
| 构造器注入 | 在构造方法上声明依赖,通常配合final字段 | 依赖不可变,对象创建时就必须传入完整依赖;单元测试友好,能通过构造函数直接传Mock | 依赖过多时构造器参数会很长 | 主流方案,Spring官方也推荐 |
| Setter注入 | 通过set方法传入依赖 | 可选的依赖可以在后续重新配置;对象可以先创建再补齐依赖 | 对象状态可能不完整,调用时容易漏配置;依赖关系不明显 | 适合可选依赖或需要动态替换的场景 |
| 字段注入 | 直接在字段上写@Autowired或@Resource | 代码最简洁,少写getter/setter | 依赖被隐藏在字段里,外部一眼看不到;被private修饰,测试时只能靠反射,容易注入成null | 不推荐,但老项目中很常见 |
我自己的习惯是优先构造器注入。原因很简单:依赖一旦声明成final字段,就必须在构造器里传进来,如果你漏了配置,编译期间就能发现,而不是等到运行时才冒出空指针。字段注入虽然写起来爽,但依赖是隐性的,排查问题的成本反而高。
2. 手写一个极简IoC容器理解注入过程
2.1 先设计一个下单场景
为了把依赖注入讲透,我建议你跟我一起拆一个模拟项目X。这个项目是一个简化得不能再简化的商城下单链路:OrderService负责创建订单,它依赖InventoryService去扣库存,依赖PaymentService去发起支付。传统写法里,OrderService内部直接new出两个实现类的实例。一旦支付渠道从支付宝换成微信,又要改代码。
更麻烦的是,如果InventoryServiceImpl的构造函数里需要配置数据库连接,那么OrderService自己也必须知道这些底层配置,哪怕它根本不关心。这就是典型的耦合过度。
我现在要做的,是写一个几十行的简陋容器,让它负责创建OrderService需要的所有依赖。这个容器是我的理解起点,也是理解Spring框架依赖注入的一把钥匙。
2.2 用反射实现一个极简注入器
先定义一个注解,用来标记哪些字段需要注入:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface MyInject { }再写一个SimpleContainer。为了直观,这里手动注册几个Bean,然后通过反射遍历每个Bean的字段,找到带@MyInject注解的字段,把对应的依赖塞进去:
public class SimpleContainer { private Map<String, Object> beans = new HashMap<>(); public void start() throws Exception { // 模拟Spring的Bean注册 beans.put("inventoryService", new InventoryServiceImpl()); beans.put("paymentService", new PaymentServiceImpl()); // 容器接管依赖注入:遍历所有Bean,挨个填充依赖字段 for (Object bean : beans.values()) { injectDependencies(bean); } } private void injectDependencies(Object bean) throws Exception { Class<?> clazz = bean.getClass(); for (Field field : clazz.getDeclaredFields()) { if (field.isAnnotationPresent(MyInject.class)) { // 私有字段也要能注入,所以先setAccessible field.setAccessible(true); Object dependency = findBeanByType(field.getType()); field.set(bean, dependency); } } } private Object findBeanByType(Class<?> type) { for (Object bean : beans.values()) { if (type.isAssignableFrom(bean.getClass())) { return bean; } } throw new RuntimeException("No bean of type " + type.getName()); } }这段代码虽然简陋,但它已经抓住了依赖注入的核心:容器中维护了一份Bean列表,当某个Bean被创建后,容器去检查它的字段,把依赖按类型塞进去。OrderService完全不用关心InventoryServiceImpl是怎么创建出来的。
2.3 容器接管后,业务代码发生了什么样的变化
在容器方案里,OrderService不再是那个“主动找供应商”的角色,它只负责声明需求:
public class OrderService { @MyInject private InventoryService inventoryService; @MyInject private PaymentService paymentService; public void createOrder() { // 直接使用依赖 inventoryService.deductStock(); paymentService.pay(); } }看到没,OrderService里没有一行new。它只通过注解告诉容器:我需要两个Service。等到代码运行起来,SimpleContainer会通过反射把两个依赖填进字段。
Spring Framework做的事情本质上和我这个几十行的容器一模一样,只是它把细节做得极其完善。它不只是保存一个Map,还维护了Bean定义、作用域、生命周期回调、循环依赖处理、AOP代理等一堆东西。但如果你理解了上面这段代码,再回头去读Spring文档,很多概念就不难了。
3. Spring Framework中配置依赖注入的常用姿势
3.1 老项目里常见的XML配置方式
在用Spring Framework做项目时,你至少得看得懂XML配置。虽然现在新项目基本都是注解和Java Config,但很多遗留系统里依然躺着几百行的applicationContext.xml,这些系统还得有人维护。
一个典型的XML配置长这样:
<bean id="inventoryService" class="com.example.service.impl.InventoryServiceImpl"/> <bean id="paymentService" class="com.example.service.impl.PaymentServiceImpl"/> <bean id="orderService" class="com.example.service.OrderService"> <constructor-arg ref="inventoryService"/> <property name="paymentService" ref="paymentService"/> </bean>这里有两个知识点:constructor-arg表示通过构造器把inventoryService传给orderService,property则表示通过setter方法把paymentService设置进来。ref指向的是另一个Bean的id,id在一个容器内必须唯一。
XML方式的好处是全局配置一目了然,所有Bean之间的依赖关系都集中在一个文件里。坏处是配置量太庞大,每加一个类就要来改一遍。所以后来的项目基本都转向了注解和Java Config。
3.2 Java Config与现代注解方式
Spring Framework从3.0开始推荐Java Config,也就是用@Configuration和@Bean来声明Bean。下面这段配置和上文的XML是等价效果:
@Configuration public class AppConfig { @Bean public InventoryService inventoryService() { return new InventoryServiceImpl(); } @Bean public PaymentService paymentService() { return new PaymentServiceImpl(); } @Bean public OrderService orderService(InventoryService inventoryService, PaymentService paymentService) { OrderService service = new OrderService(inventoryService); service.setPaymentService(paymentService); return service; } }@Bean方法的方法名默认就是Bean的名字,方法参数会由Spring自动从容器中找Bean传入。这种配置方式的好处是类型安全,写代码时有IDE提示,不用像XML那样靠字符串拼。
更现代的项目则直接用@ComponentScan自动扫描。在类上标记@Controller、@Service、@Repository或@Component,Spring会在应用启动时扫描包路径,自动把带有这些注解的类注册成Bean。配合@Autowired在字段或构造器上声明依赖,代码可以简洁到一行专门配置都没有。
3.3 @Autowired背后的匹配规则,不只是“按类型”
很多人以为@Autowired就是“按类型找Bean”,这话只对了一半。Spring Framework的依赖注入匹配规则是:先按类型找,如果找到多个候选,再按字段名或参数名匹配;如果还不行,就看有没有@Qualifier指定名称;最终仍然有多个或找不到,就会启动失败。
举个实际例子。PaymentService有两个实现类,一个是WechatPayServiceImpl,另一个是AlipayServiceImpl。你写:
@Service public class CheckoutService { @Autowired private PaymentService paymentService; }启动时Spring会找PaymentService类型的Bean,发现有两个,接着它尝试用字段名paymentService去匹配Bean的名字,结果没有一个Bean叫paymentService,所以直接抛出NoUniqueBeanDefinitionException。
解决办法有三个:
- 在某个实现类上标@Primary,表示它是默认首选;
- 在字段上再写@Qualifier("alipayServiceImpl"),显式指定Bean名;
- 把注解换成@Resource(name = "alipayServiceImpl"),它默认按名称找,但要小心没有指定name时的行为。
这里有个容易被忽略的细节:@Qualifier的值默认就是Bean名称,如果你把类名改了,这里也要跟着改。我见过不少人改了实现类名忘了改@Qualifier,启动时报NoSuchBeanDefinitionException。
4. 依赖注入背后的Bean生命周期与作用域
4.1 从创建到销毁,依赖注入发生在哪个环节
依赖注入不是随手做的,它在Bean生命周期里有一个明确的位置。一个Bean在Spring Framework中的完整流程大致是:
- 实例化:调用构造器创建对象,哪怕字段还没有赋值;
- 填充属性/依赖注入:Spring扫描Bean中需要注入的字段、setter或构造器参数,把依赖填进去;
- 初始化前操作:执行BeanPostProcessor的postProcessBeforeInitialization方法;
- 初始化:执行@PostConstruct、InitializingBean、init-method等;
- 初始化后操作:执行BeanPostProcessor的postProcessAfterInitialization,这一步经常用来生成AOP代理对象;
- 使用;
- 销毁:执行@PreDestroy、DisposableBean、destroy-method等。
理解这个顺序非常关键。举个例子,如果你在构造器里直接访问一个@Autowired字段,那个字段一定是null,因为属性填充发生在构造器之后。这也是为什么构造器注入的依赖必须通过构造器参数传进来,而不能先挖个字段再指望后面填充。
我还遇到过有人在@PostConstruct方法里去调用某个注入的字段,正常情况下没问题,但如果那个依赖本身初始化失败了,整个应用启动就会直接报错,而不是等到业务调用时才暴露。搞清楚生命周期顺序,排查这类问题时定位会快很多。
4.2 作用域对注入的影响,比你想的更隐蔽
默认情况下Spring Framework中所有Bean都是单例(singleton)。也就是说整个容器里只有一个实例,每次注入的都是同一个对象。你把一个prototype类型的Bean注入到一个singleton的Bean里,然后希望每次获取时都拿一个新的实例,这个期望是错的。
@Component @Scope("prototype") public class PrototypeBean { } @Component public class SingletonService { @Autowired private PrototypeBean prototypeBean; }在这个例子里,SingletonService创建的时候,prototypeBean会被创建一次并注入。之后无论你从哪里拿SingletonService,它里面的prototypeBean都是同一个旧对象,不会每次重新创建。
如果你确实需要每次拿到新的prototype实例,推荐用ObjectProvider:
@Component public class SingletonService { @Autowired private ObjectProvider<PrototypeBean> prototypeBeanProvider; public PrototypeBean getPrototypeBean() { return prototypeBeanProvider.getIfAvailable(); } }调用getIfAvailable()时才会创建新的prototype实例。对Web环境里的request、session作用域也一样,直接注入一个request作用域的Bean到单例中时经常拿到的是不对的实例,需要开启代理模式,或者用ObjectProvider解决。这个坑在拆微服务接口时特别常见,值得留个心眼。
5. 常见问题与排查技巧实录
5.1 循环依赖:三级缓存能解决一部分,不是全部
循环依赖指的是A依赖B,B又依赖A。Spring Framework确实有一张“三级缓存”可以处理大多数Setter和字段注入的循环依赖,核心思路是在A创建到一半时先把一个早期引用暴露出来,让B先注入它,等A完成后续初始化后再继续。但有一个例外:构造器注入的循环依赖无法通过三级缓存解决。
@Service public class AService { private final BService bService; public AService(BService bService) { this.bService = bService; } } @Service public class BService { private final AService aService; public BService(AService aService) { this.aService = aService; } }启动时Spring找不到任何一方的完整实例,构造器又必须先返回完整对象,于是抛出BeanCurrentlyInCreationException。这种问题的根源是设计不合理,而不是配置问题。拆分逻辑、调整依赖方向永远是首选,其次才考虑在其中一方加上@Lazy,让Spring注入一个延迟代理对象,等到实际使用时才去真正解析。
我自己的经验是,一旦出现循环依赖,先停下来看看是不是把不该放在一起的职责硬塞到了一起。用三级缓存兜底看起来省事,但会让对象之间的依赖关系隐藏得更深,后面改起来更难受。
5.2 多个实现类时注入失败
前面提到多个实现会导致NoUniqueBeanDefinitionException,这里再说说实际排查时怎么选方案。假设支付宝和微信支付都有实现类,支付渠道需要根据业务动态选择,那么更好的做法往往是注入一个支付策略的容器,而不是在字段上写@Resource(name = "alipayServiceImpl")这种硬编码。但如果只是需要一个默认实现,@Primary最省事。
| 方案 | 写法 | 适用场景 |
|---|---|---|
| @Primary | 在一个实现类上标记,默认优先 | 有明确的“主实现”时 |
| @Qualifier | @Autowired里加上指定名字 | 在注入点明确要哪个Bean |
| @Resource | 直接按Bean名称注入 | 不想引入Spring特定注解,比较依赖JSR-250语义 |
需要注意,@Primary和@Qualifier可以同时存在,@Qualifier的优先级更高。我见过有人只在配置文件里加@Primary,但注入点还带着历史遗留的@Qualifier,结果改动没生效,排查了半天才发现是两个注解打架。
5.3 注入进来是null的排查清单
这是我个人最常碰到的求助类型。一个@Autowired字段在调试时显示null,大多数情况下和容器配置有关。你可以按这个清单一项一项查:
- 类是否真的被扫描到了:是否在@ComponentScan的包路径下,类上有没有@Component/@Service等注解;
- 是不是手抖new出来的:直接用new关键字创建对象,Spring根本不会管它内部的字段注入;
- 有没有静态上下文:static字段上写@Autowired虽然不报错,但不会在Bean初始化时被正常注入;
- 构造器里访问了字段注入的属性:构造器执行阶段属性填充还没开始,访问必然为null;
- 多个Bean实现没区分:注入接口时类型没有唯一候选,如果字段名也匹配不上,可能直接启动失败;
- 依赖本身初始化抛错:Spring应用启动过程不是原子上线,也可能因为其他原因导致依赖注入中断。
把这些排查项做成一个固定流程,是效率最高的方式。不然每次看到null就到处打断点,非常容易浪费时间。
5.4 构造器注入与Lombok的配合
Lombok的@RequiredArgsConstructor会为所有final字段生成一个构造器,配合Spring Framework的构造器注入非常简洁:
@Service @RequiredArgsConstructor public class OrderService { private final InventoryService inventoryService; private final PaymentService paymentService; }从Spring Framework 4.3开始,如果一个类只有一个构造器,Spring会自动使用它来注入依赖,所以不用再额外写@Autowired。但要注意一个前提:类里只能有一个有参构造器。如果你同时写了Lombok的@RequiredArgsConstructor,又手动加了一个无参构造器,Lombok会在编译时就报警告,因为构造器冲突。
另外,如果OrderService后面因为某些需要加了一个带其他参数的构造器,Spring就不再自动判断,而是可能报“No default constructor found”之类的错误。这种情况下你需要在真正要用的构造器上显式加@Autowired,或者调整设计。Lombok加构造器注入虽然方便,但也要理解底层的自动装配规则,不然排查起来更混乱。
6. 依赖注入的几个设计建议
6.1 优先构造器注入,把依赖暴露在明处
我在实际项目中几乎一律使用构造器注入,配合final字段。好处是在类的构造器签名上就能看到它依赖哪些东西,阅读代码的人不用翻遍字段注解。测试也方便,直接new出来往里塞Mock就行,不用引入spring-test的ReflectionTestUtils。
有人说构造器参数太长怎么办。参数太长说明这个类承担的职责太多,该拆分服务,而不是换成字段注入来掩盖问题。一个类如果依赖五六个以上的Bean,先反思是不是违反了单一职责原则。
6.2 面向接口注入,但别为抽象而抽象
依赖注入天然适合面向接口编程。需要替换支付厂商时,只要换一个实现类,调用方完全不用改。但现实中也有人一上来就每个Service都抽接口,哪怕只有一个实现,徒增层级。
我的做法是:当出现第二个不同实现,或者你需要用Mock来测试时,再抽接口完全来得及。过度设计比不设计更让人头疼。
6.3 结合Spring Framework版本演进看细节
最后提一下版本差异。Spring Framework 4.3前,构造器注入一般还要在构造器上写@Autowired;4.3之后单构造器会自动注入。Spring Boot项目依赖的Spring Framework版本各不相同,你在网上搜索解决方法时看到的老帖子未必适用于当前版本,尤其要注意Bean名称匹配、@Configuration注解、以及jakarta包名下@Resource等细节的变化。
我本人因为长时间维护跨版本项目,踩过很多“网上API和当前API对不上”的坑。最好的习惯是在项目里固化Spring Framework版本,遇到问题先对照当前版本对应文档,而不是盲目抄代码。
最后说一点个人体会。很多刚接触Spring的人觉得依赖注入不过是把new换个写法,实际意义不大。但当你维护过一个没有依赖注入的老系统,或者手写过一个极简容器后,才能真正理解这种“反向控制”的价值。依赖注入不是为了炫技,而是为了让代码的依赖关系变得清晰、可替换、可测试。我建议你也可以找个小模块,照着上面的思路写一个几十行的注入器,跑通之后再回来看Spring,一定会觉得它亲切很多。