ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Bean注入方式全解析:构造器、Setter与字段注入的实战对比

Spring Bean注入方式全解析:构造器、Setter与字段注入的实战对比 Spring中bean的注入方式听起来像是个基础题但每次代码评审里我都能看到有人在这个问题上踩坑——有人一个类里十个字段全是Autowired有人为了解决循环依赖把构造器注入改成setter还有人分不清Resource和Autowired到底该用哪个。说实话这个问题往浅了说是一行注解的事往深了说它牵扯到Spring容器如何管理对象生命周期、如何解析依赖、以及代码写完之后到底好不好测试和重构。这篇文章就把我这些年实际用下来的心得和踩坑整理一遍希望对正在学Spring或者写了一段时间SpringBoot但没细想过注入机制的人有帮助。1. 三种基础注入方式从“怎么写”到“怎么想”很多初学者学Spring接触到的第一种注入方式就是Autowired往字段上一放完事。但写过几年项目之后你会发现字段注入是最省事的写法却不是最稳妥的写法。日常开发里基本就是构造器注入、setter注入、字段注入这三种下面逐个说清楚。1.1 构造器注入官方推荐的默认选择不只是因为“不可变”先在代码层面看一个典型的构造器注入长什么样Service public class OrderService { private final PaymentGateway paymentGateway; private final OrderRepository orderRepository; public OrderService(PaymentGateway paymentGateway, OrderRepository orderRepository) { this.paymentGateway paymentGateway; this.orderRepository orderRepository; } }用Lombok的项目里通常会简写成这样Service RequiredArgsConstructor public class OrderService { private final PaymentGateway paymentGateway; private final OrderRepository orderRepository; }.final关键字是构造器注入最关键的信号依赖一旦注入进来整个生命周期内就不能被替换。这个特性带来几个实打实的好处。第一对象永远不会处于“半初始化”状态。你new一个OrderService出来如果没有传PaymentGateway编译器就直接报错但在字段注入或setter注入的场景里依赖是后面才被Spring反射填充的如果你自己在代码里new了这个类再调用它的方法就极有可能碰到NullPointerException。构造器注入把“依赖必须存在”这件事变成了强约束。第二测试友好。写单元测试的时候直接new OrderService(mockPaymentGateway, mockOrderRepository)不需要Spring容器不需要Mockito的注解支持干净利落。如果哪天你发现一个类“没法new”那通常不是构造器注入的问题而是这个类本身设计糟糕。第三有助于发现循环依赖。如果两个类互相通过构造器注入Spring容器在启动阶段就会直接报错而不是等到运行期某个方法里炸出空指针。这一点后面在讲循环依赖的章节里还会重点展开。Spring官方文档其实一直强调构造器注入作为首选方式Autowired注解的javadoc里也明确写过“首选构造器注入”这类表述。但很多人在实际项目里没有遵循原因无非是类太多、构造器参数太长看着不舒服。我个人的做法是一个类如果构造参数超过4、5个首先怀疑的不是注入方式而是这个类的职责是不是太重了——该拆分了。1.2 Setter注入适合可选依赖和运行时更新Setter注入的样板代码是这样的Service public class NotificationService { private MailSender mailSender; Autowired(required false) public void setMailSender(MailSender mailSender) { this.mailSender mailSender; } }Spring从早期的XML配置时代就支持setter注入一直保留到现在它和构造器注入相比最大的特点就是“可空、可变”。依赖可以不是必需的如果一个MailSender在容器里不存在注入就能被跳过代码不会崩。依赖也可以在运行过程中被替换比如测试环境里手动换掉一个实现。这个特性在少数特定场景下确实有优势比如你想给一个基础类提供默认实现允许子类或者外部配置覆盖setter注入会灵活很多。但我必须提醒一句setter注入制造了一个“看起来是final实际不是final”的对象。你很难保证拿到这个对象时所有字段都已经被填充过了尤其是在不那么严谨的初始化流程里。如果这个类同时被Spring容器管理又被你在业务代码里手动new出来用那setter注入几乎一定会带来偶发的空指针问题。对我来说setter注入更像是一个“兼容性选项”老代码迁移、可选依赖、或者某些框架扩展需要提供默认setter的时候才用。新写的业务代码我基本不会主动选择它。1.3 字段注入最省事但为什么我不推荐在正式项目里用字段注入大概是新手最爱的写法没有之一Service public class ReportService { Autowired private ReportRepository reportRepository; Autowired private PdfExporter pdfExporter; }看起来又短又清晰对吧但这份“清晰”是假象。字段注入最大的问题在于它把依赖关系完全隐藏了。你一眼扫过去只知道这个服务“用了”两个组件但这个服务到底能不能脱离Spring容器独立运行它的完整依赖是什么在字段注入的写法里你没有答案。只有翻到构造器注入编译器和IDE才能帮你把依赖关系摊开。而且字段注入不能配合final使用所以Spring只能靠反射绕过访问控制直接设置字段。这在绝大多数情况下没问题但反射是有成本的也确实会让某些静态代码分析工具抱怨。更重要的是当你需要写单元测试时字段注入这个类会非常别扭——你得先new出来再强制用反射把依赖塞进去或者在测试框架里启用InjectMocks。这等于用测试框架的“魔法”去弥补生产代码设计上的短板。我也看到有人用构造器注入之后故意在某些字段上又补一个Autowired用“两种方式混用”来追求心理上的安全感这个真没必要。混用会让类的依赖关系更加混乱。我现在的团队规范就是final字段一律构造器注入可选或可能替换的依赖用setter字段注入只允许出现在快速验证型Demo、或者某个不承载核心业务的配置类里。为了让你在选型时有更直观的判断我把三种方式的关键点列在下面对比项构造器注入Setter注入字段注入依赖是否final是否否依赖是否必填强约束可设置requiredfalse难以表达可选对象初始化完整性保证不保证不保证单元测试友好度非常好一般差需要反射循环依赖检测提前报错可能被容器掩饰可能被容器掩饰代码可读性依赖一目了然中等依赖被隐藏2. Spring按什么规则找到这个bean类型、名字与限定符写代码的时候你只需要加一个注解看起来很轻松但真正理解注入机制之后遇到“启动报错找不到bean”或者“同一个类型有两个实现”的时候就不会两眼一抹黑。Spring的依赖解析其实是在容器启动阶段完成的核心入口是AutowiredAnnotationBeanPostProcessor它会扫描需要注入的点然后交给DefaultListableBeanFactory去匹配候选bean。2.1 byType和byName从RootBeanDefinition到依赖描述Spring默认的注入规则官方术语叫byType也就是按照类型去找bean。如果容器里只有一个PaymentGateway的实现类OK直接匹配。但如果有两个实现类呢这时候Spring会尝试用byName作为补充策略——看看当前字段叫什么名字容器里有没有同名的bean。举个非常容易遇到的例子public interface PaymentGateway { void pay(); } Component public class AlipayGateway implements PaymentGateway { ... } Component public class WechatGateway implements PaymentGateway { ... }这时候如果你在业务类里写Resource private PaymentGateway alipayGateway;注意Resource会优先按字段名alipayGateway去找容器里名为alipayGateway的bean所以能命中AlipayGateway这个Component默认生成的bean名类名首字母小写。但如果字段名写成paymentGateway容器里没有这个名字的bean它才会退回去按类型找——类型匹配又有两个实现于是报错。再看AutowiredAutowired private PaymentGateway paymentGateway;Autowired默认是严格byType找到多个候选时不看字段名直接抛NoUniqueBeanDefinitionException。这一点和Resource存在明显差异很多人踩坑就是因为把两者当成同一个东西。顺带说一句Autowired之所以有时候能“按名字”找到是因为Spring在多个候选情况下会额外检查Qualifier以及字段名兜底匹配但这不是它的首选策略。2.2 Autowired、Resource、Inject三兄弟的差异点这三者在日常代码里经常被混用但它们出身、标准、行为都不完全一样下面那个表格我每次培训都会拿出来讲对比项AutowiredResourceInject来源Spring框架自带的注解JDK标准JSR-250JSR-330标准默认装配方式先byType多个候选时报错先byName再byType和Autowired基本一致是否支持Qualifier支持不支持直接组合用name属性支持是否支持requiredfalse支持不支持不支持典型使用场景Spring/SpringBoot项目尽量不写或用于兼容旧代码标准环境下切换容器时Resource在SpringBoot项目里不是不能用只是我个人不太推荐在团队新代码里使用它。原因是它的装配规则更“暧昧”先按名字名字找不到再类型这种隐式的策略差异在代码量大的项目里很容易制造跨模块的匹配问题。相反Autowired规则简单直接类型优先有歧义就报错逼着你显式给出限定条件。Inject在Spring环境里和Autowired行为几乎一样但写习惯了反而少见我一般建议团队统一用Autowired减少不必要的认知负担。还有一个细节Autowired(required false)。这个在字段注入里能表达“这个依赖可能不存在”的语义比如某些组件只在特定环境注册但这种写法容易掩盖配置错误。核心业务依赖我不建议用requiredfalse如果要支持可选依赖更好的做法是使用ObjectProviderT后面会专门说。2.3 同一类型多个Bean时的解决思路Qualifier、Primary、集合注入如果容器里同一个接口有两个实现而你又必须用Autowired那么最直接的方案就是加QualifierService public class PaymentService { private final PaymentGateway paymentGateway; public PaymentService(Qualifier(alipayGateway) PaymentGateway paymentGateway) { this.paymentGateway paymentGateway; } }注意括弧里传的是bean的name不是类的简单名。配合Lombok的时候Qualifier也不能乱放正确写法是把Qualifier标在构造器参数上。另外一种思路是配置Primary。在某个实现类上加了这个注解Spring在遇到多个候选时就会默认选它Component Primary public class AlipayGateway implements PaymentGateway { } Component public class WechatGateway implements PaymentGateway { }这样在大多数场景里你都可以无脑Autowired PaymentGateway同时如果需要特殊实现再用Qualifier(wechatGateway)覆盖。Primary适合“有默认实现”的情况Qualifier适合“调用方明确指定”的情况两者并不互斥。还有一种很实用的写法是集合注入。当你有多个策略类实现同一个接口希望一次性注入到列表中轮询使用Service public class PriceCalculator { private final ListDiscountStrategy strategies; public PriceCalculator(ListDiscountStrategy strategies) { this.strategies strategies; } }Spring会把容器里所有DiscountStrategy类型的bean按顺序塞进这个List而且如果你在某个bean上加了Order注解它可以控制顺序。这个模式在策略模式里非常好用我经常在优惠、通知渠道、消息处理器这类场景里用。3. XML、注解还是Java Config不同时代的注入写法怎么看虽然现在打开一个SpringBoot项目大概率看不到XML配置文件了但Spring的历史包袱还是会时不时冒出来。理解三种配置形态里注入方式的差异能帮你读老代码、查历史问题时少很多疑惑。3.1 XML时代的constructor-arg与property是怎样映射注入方式的在Spring还没有大规模使用注解的时代bean之间的依赖关系长这样bean idorderService classcom.example.OrderService constructor-arg refpaymentGateway/ constructor-arg reforderRepository/ /bean bean idpaymentGateway classcom.example.AlipayGateway/ bean idorderRepository classcom.example.OrderRepository/constructor-arg对应的就是构造器注入property对应的是setter注入bean idnotificationService classcom.example.NotificationService property namemailSender refmailSender/ /beanXML配置的优点是依赖关系全部显式暴露在外部文件里不用翻阅Java代码就能看到谁依赖谁缺点是文件容易膨胀而且重构时如果类或方法名字变了XML里的配置很容易漏改直到启动报错才发现。现在SpringBoot项目已经很少直接写XML了但一些老系统、或者某些需要额外集成配置的场景还是会碰到。当你要在一个全面的基于注解的bean里注入一个XML里定义的bean只要它的bean name正确照样可以通过Qualifier定位到这种“混编”Spring是可以支持的只是维护上要格外小心。3.2 Java Config中Bean方法注入的细节到了SpringBoot的Configuration时代我们用Java代码的方式声明bean注入表达更加直观Configuration public class DataSourceConfig { Bean Primary public DataSource primaryDataSource() { HikariDataSource ds new HikariDataSource(); ds.setJdbcUrl(jdbc:mysql://localhost:3306/main); return ds; } Bean public DataSource batchDataSource() { HikariDataSource ds new HikariDataSource(); ds.setJdbcUrl(jdbc:mysql://localhost:3306/batch); return ds; } Bean public JdbcTemplate primaryJdbcTemplate(Qualifier(primaryDataSource) DataSource dataSource) { return new JdbcTemplate(dataSource); } }这里有个细节值得注意同一个Configuration类里如果另一个Bean方法需要“注入”同一个类里定义的Bean对象通常的策略是把依赖作为参数传进来。Spring会从容器里解析这个参数。很多人在这里会犯一个错误——以为直接调用另一个Bean方法就行比如primaryJdbcTemplate(primaryDataSource())。在Configuration类里这样写确实能拿到同一个代理增强的bean实例但如果你把这个类当普通类用或者在非Configuration的普通类里直接调用Bean方法那每次调用都会创建一个新对象完全脱离了容器管理。所以我的建议是任何时候都优先使用参数注入而不是方法内直接调用。再延伸到Value。它也是注入的一种只不过注入的是外部配置值Service public class SmsSender { private final String accessKey; private final int maxRetries; public SmsSender(Value(${sms.access-key}) String accessKey, Value(${sms.max-retries:3}) int maxRetries) { this.accessKey accessKey; this.maxRetries maxRetries; } }这种注入方式一般不会被归类在“bean注入”里但它在构造器注入场景下特别常用很多配置参数都可以直接通过构造器参数进入bean内部保证不变性。如果你在配置类里用ConfigurationProperties那本质上也是把配置信息绑定为一个bean再注入到需要的地方。3.3 我有过的一次混用配置的教训——最终怎么理清有一年我在做一个老项目的迁移项目结构特别乱一部分bean用XML定义一部分bean用Component扫描还有一部分在Configuration里手写。结果经常出现的情况是某个Autowired字段在本地启动时没问题部署到测试环境突然找不到bean了。排查后发现根本原因是XML里定义的bean名和注解扫描出来的bean名不一致。当时我以为两者都会生成“类名首字母小写”的bean名但实际上XML里如果指定了id就是用那个id作为bean名根本不看类名。比如这个配置bean idpayGateway classcom.example.AlipayGateway/注解扫描的bean名是alipayGatewayXML里定义的bean名是payGateway对外表现是两个不同名字的bean但类型相同。结果Autowired一上来发现两个候选直接报错。那次之后我给自己定了一条规矩一个项目里配置方式尽量统一不要一处注解一处XML交叉定义同一个类型的bean。如果确实要迁移那就一次性把所有XML bean迁移成Configuration或Component保持同一种语义体系。依赖注入追求的是“可预期”而混用多个配置体系恰恰是最不可预期的来源之一。4. 依赖注入最容易翻车的几个场景和我的排查链路这一部分全是实战中真金白银踩出来的坑。很多问题表面上看起来是“注入方式选错了”但根子上是对Spring容器生命周期和依赖解析规则的误解。4.1 循环依赖三级缓存真正解决了什么先看一个最经典的循环依赖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会直接报错The dependencies of some of the beans in the application context form a cycle。为什么构造器注入解决不了循环依赖因为Spring实例化AService时必须先拿到BService但要实例化BService又必须先拿到AService。两者都还没创建完谁也没法给谁用。但如果你把两边都改成setter注入Service public class AService { private BService bService; Autowired public void setBService(BService bService) { this.bService bService; } } Service public class BService { private AService aService; Autowired public void setAService(AService aService) { this.aService aService; } }Spring就能启动了。这是靠所谓的“三级缓存”机制完成的。简单来说第一级缓存singletonObjects存放已经完整创建好的单例bean。第二级缓存earlySingletonObjects存放已经实例化但还没完成属性填充的“早期对象”。第三级缓存singletonFactories存放可以获取早期对象引用的工厂方法。Spring在创建AService的时候发现它依赖BService于是先把AService的早期对象工厂放进三级缓存然后去创建BService创建BService时发现它依赖AService此时三级缓存里已经能提供AService的早期引用于是BService先把A的引用拿到等B创建完成后再回填到A。整个过程像两个人互相给对方留了个门禁卡。但这里有个必须强调的点三级缓存解决的是“实例化已完成、但属性还没填充”的引用问题它解决不了构造器注入阶段的循环依赖。因为构造器是在实例化阶段就跑了此时早期对象还没有产生。所以一旦循环依赖发生在构造器注入路径上Spring只能无能狂怒。SpringBoot 2.6之后官方还把spring.main.allow-circular-references默认改成了false也就是即使你的setter循环依赖能跑也会直接启动失败。官方的态度很明确循环依赖是设计上的坏味道不该默认被允许。我个人的处理思路是先看能不能打破循环比如把A对B的依赖改成单向或者把公共逻辑抽到一个新的服务类里。如果实在要保留至少用ObjectProvider延迟获取Service public class AService { private final ObjectProviderBService bServiceProvider; public AService(ObjectProviderBService bServiceProvider) { this.bServiceProvider bServiceProvider; } public void doSomething() { BService b bServiceProvider.getIfAvailable(); ... } }这样做的好处是A创建时不需要立即拿到B只在真正用到时才去容器里查找循环依赖的耦合就被打开了。4.2 注入进来的bean为什么是null实例化时机排查“明明是Autowired为什么运行的时候是null”这是我在技术群里被问得最多的问题之一。通常原因逃不出下面这几种。第一种这个bean根本不是Spring管理的。你在一个类上手动new了一个对象里面写Autowired字段想着Spring会自动注入这当然不可能。Spring的依赖注入只对被它容器管理的bean生效。排查方法很简单看这个类的实例化过程是不是走了new、Class.forName或者其他脱离容器的路径。第二种在构造函数里调用依赖的方法。这个坑更隐蔽Component public class UserService { Autowired private UserRepository userRepository; public UserService() { userRepository.init(); // 此时userRepository还是null } }Spring在调用构造器时依赖字段还没有来得及赋值此时访问userRepository必然空指针。正确的做法是使用构造器注入或者把初始化逻辑放到PostConstruct方法里因为PostConstruct会在依赖注入完成后才执行。第三种配置类或过滤器、拦截器里的bean获取方式有误。比如有些情况下你在Filter里直接使用了Autowired字段但Filter的实例化时点比Spring容器里普通bean的注入时点要早或者它是由Web容器托管的不在Spring容器生命周期里这时候字段注入就会失效。解决方式一般是改用SpringBeanAutowiringSupport或者从WebApplicationContext里手动获取但更推荐的是把这类“基础设施”也注册为Spring管理的bean而不要依赖它自己new出来的对象。遇到注入为null我的排查链路一般是这样先确认类上有Component/Service/Repository等注解且扫描包路径覆盖到了。再确认这个类是不是被new出来的。再确认是不是在构造器阶段访问了依赖。最后再确认是不是多个bean类型匹配导致注入到了另一个代理对象或空对象。4.3 单例注入原型依赖换个思路的解耦做法默认情况下Spring里的bean都是单例。如果你把一个原型作用域的依赖注入进单例bean比如下面这样Component Scope(prototype) public class PrototypeTask { ... } Service public class TaskRunner { private final PrototypeTask prototypeTask; public TaskRunner(PrototypeTask prototypeTask) { this.prototypeTask prototypeTask; } public void run() { prototypeTask.execute(); // 每次都是同一个对象 } }你会发现注入进来的PrototypeTask实际上一直是同一个实例因为单例bean在创建时就已经把依赖固定下来了。这是作用域失效的经典问题。想每次调用都拿到新的原型对象一般有几种做法。最简单的是直接注入ObjectProviderTService public class TaskRunner { private final ObjectProviderPrototypeTask taskProvider; public TaskRunner(ObjectProviderPrototypeTask taskProvider) { this.taskProvider taskProvider; } public void run() { PrototypeTask task taskProvider.getObject(); task.execute(); } }另一种是Lookup方法注入Service public abstract class TaskRunner { Lookup abstract PrototypeTask getPrototypeTask(); public void run() { PrototypeTask task getPrototypeTask(); task.execute(); } }从设计上看Lookup更精妙它会在运行时生成子类代理每调用一次返回一个新鲜实例。但抽象类的写法在某些场景下不够直观团队接受度低。我平时更推ObjectProvider因为它在构造器注入风格里很自然语义也好理解现在不拿等用的时候再拿。类似的如果有一些可选依赖不想在启动时强校验是否存在ObjectProvider也比Autowired(required false)清晰得多。4.4 从一个“自调用失效”的案例看代理与注入的关系还有一个和注入高度相关的问题Spring在注入时往往注入的是一个代理对象不是原始对象。Transactional、Async、Cacheable这些注解之所以能生效靠的就是容器在创建bean时套了一层代理。如果你在类内部直接调用被这些注解标注的方法——典型的就是同类里this.doSomething()——这个调用不会经过代理因此注解功能失效。举个实际例子Service public class OrderService { Transactional public void createOrder() { ... } public void createAndNotify() { createOrder(); // 直接this调用Transactional不生效 } }这个问题虽然不属于传统意义上的“注入方式”但它和“容器到底注入了一个什么样的bean”直接相关。我用一个比较直观的方式去理解你在代码里Autowired进来的那个对象可能并不是你写的那个类的原始实例而是Spring通过CGLIB或JDK动态代理造出来的“增强版”。所以当你在排查某个注解功能失效的时候先想想自己是不是绕过了代理。实际解决办法有很多比如把被代理的方法所在逻辑拆到另一个代理类通过注入该代理类来调用或者使用AopContext.currentProxy()但我不太推荐在业务代码里四处用这个而ObjectProvider再配合独立的方法持有者类是最干净的做法。5. 我的注入方式选型建议与团队约定写到这里基本把Spring中bean注入方式的技术面都覆盖了。如果再往上一层从团队代码规范和可维护性角度看我见过的项目里有个很普遍的现象注入方式不统一。一个类里三个字段用字段注入另一个类里两个依赖用构造器注入还有一个类的依赖写在setter上。单个看都能跑但放在一起就特别难维护。新同事接手时根本没法快速判断哪些依赖是必须的哪些是可选的哪些是允许被替换的。我目前自己在团队里推了一套很简单的约定分享出来供参考普通业务Service、Component一律构造器注入字段加final。如果依赖是可选或可能在运行时被替换的优先使用ObjectProviderT而不是Autowired(required false)或setter注入。不在新代码里使用字段注入除非是在Demo、测试辅助类或Spring内部机制演示场景。避免在同一个项目里混用XML和注解来声明同一类型的bean。出现循环依赖时先重构再考虑换个注入方式不要为了启动成功而无脑改成setter或字段注入。同一接口多实现时通过Primary定默认Qualifier做差异化覆盖避免在多个地方散落魔法字符串。这些约定直接关联到依赖注入的本质让对象的依赖关系尽量显式、稳定、可测试。构造器注入天然满足这三点setter注入的灵活性在极少数场景有价值字段注入把依赖藏起来虽然写的时候省事但维护成本和测试成本都会慢慢积累出来。你要是现在正在学Spring建议把这篇文章里的每一种注入方式都亲手搭个测试工程跑一遍。用Autowired、Resource、Qualifier、Primary分别试再写一个循环依赖案例看看报错信息长什么样模拟一个Autowired为null的场景实际排查一遍。这些经验靠背是背不牢的跑过一遍之后你会对这个所谓“基础问题”有真正立体的理解。
返回列表