尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring依赖注入三种方式详解:构造器、Setter与字段注入选型

Spring依赖注入三种方式详解:构造器、Setter与字段注入选型 Spring依赖注入这事儿凡是写过两年代码的Java后端应该都跟它打过交道。但说到“三种核心注入方式”很多人只是天天用Autowired往字段上一甩从来没想过还有别的写法更没想过为什么要这样设计。这篇文章就把构造器注入、Setter注入、字段注入掰开揉碎讲清楚顺便把循环依赖、三级缓存、Autowired和Resource的区别这些高频坑点一起聊明白。无论你是刚入门Spring的小白还是被循环依赖折磨过的老手都可以参考一下我实践后的选型思路。1. 内容整体设计与思路拆解1.1 DI到底在解决什么问题依赖注入的本质是把“创建对象”和“使用对象”这两件事彻底分开。举一个生活化的例子你想喝咖啡不需要自己种咖啡豆、买磨豆机、研究烘焙工艺直接走进咖啡店说要一杯美式就行。咖啡店负责把豆子、水、牛奶按流程组装好你只负责喝。程序里的DI就是这家咖啡店而容器Spring IoC容器就是那个熟悉所有配方的咖啡师。在Spring出现之前Java程序员写业务代码最痛苦的事情之一就是到处new对象。A依赖BB依赖CC又依赖D一旦某个类的构造函数变了所有调用它的地方全得跟着改。而使用了DI之后对象的创建和组装全部交给容器业务代码只关心“我需要什么”不关心“这东西哪来的”。这不仅解耦了代码还让单元测试变得极其方便——你想测试某个服务直接塞一个Mock进去就行不需要真去启动一个完整的环境。1.2 三种注入方式的核心差异Spring支持三种主流注入方式构造器注入、Setter注入、字段注入。它们都能达到“让容器帮我们装配依赖”的目的但设计哲学、适用场景、踩坑概率完全不同。从代码写法上讲构造器注入是在类的构造函数上声明依赖Setter注入是通过setXxx()方法接收依赖字段注入则是直接在属性上加Autowired或Resource。从容器实现的角度看这三种方式对应了不同的Bean实例化阶段构造器注入在Bean创建早期就完成Setter注入在属性填充阶段完成字段注入本质上是反射直接写字段属于属性填充的一种特殊实现。很多新手刚接触Spring时被Idea的自动提示带偏习惯性用Autowired加字段注入。这种写法确实够简洁但它在长期维护里埋了不少雷。构造器注入才是官方推荐的实践也不是说其他方式一无是处而是得看场景分清主次。1.3 为什么选型比写代码更重要我在代码评审里看过太多“混搭式”注入——一个类里构造函数注入几个依赖字段又注入几个Setter又注入几个。这种写法本身不报错但它会让依赖关系变得支离破碎你没法一眼看出这个类到底必须依赖什么、可依赖什么、可以后改什么。选型的关键是先搞清楚“你希望这个依赖以什么形式存在”。必须依赖、缺失就无法工作的用构造器注入让编译期和容器启动期就能发现问题可选依赖、有默认实现的用Setter注入方便运行时替换测试或快捷脚本里偶尔用的用字段注入也不是罪过只是别让它成为你的默认选项。理解了这个设计思路下面的三种方式就好懂了。实操中每一种都有大量细节包括注解选择、循环依赖、作用域问题哪一样都值得单独写一篇。接下来我把每种方式拆开讲配上真实的代码片段和你容易忽略的坑。2. 三种核心注入方式的深度解析2.1 构造器注入官方推荐的“火车驶过”模式构造器注入的写法非常直观依赖全部通过构造函数传入Service public class OrderService { private final UserService userService; private final ProductService productService; public OrderService(UserService userService, ProductService productService) { this.userService userService; this.productService productService; } }从Spring 4.3之后如果类只有一个构造函数可以省略Autowired容器会自动匹配参数并注入。如果是多个构造函数则在需要注入的那一个上显式标注。为什么官方推荐它第一依赖不可变性。字段标成final对象创建之后依赖关系不能再被改变这在并发环境下特别有价值——你不需要担心某个线程把依赖换掉了。第二依赖完整性。容器启动时如果某个构造器参数找不到对应的Bean启动会直接失败这其实是好事程序在运行前就把问题暴露了。第三便于测试。单元测试里直接new OrderService(mockUser, mockProduct)就行不需要Spring容器不需要反射代码清清楚楚。构造器注入的问题和坑如果类有几十个依赖构造器的参数列表会变得很长这在老项目里很常见。遇到这种类本质上是设计出了问题——你的类做的事情太多了不符合单一职责而不是构造器注入的错。解决办法是拆分类或者用Configuration加方法组合依赖。另外一个潜在坑是循环依赖。构造器注入在Bean创建阶段就要拿到依赖一旦A和B互相通过构造器依赖容器启动时就会直接抛BeanCurrentlyInCreationException。这个异常比字段注入的循环依赖更难绕过因为Spring无法用提前暴露单例工厂的方式解决构造器级联的环。这也是一些老项目拆不掉字段注入的现实原因之一——历史代码已经形成了复杂的依赖网改成构造器注入就得重构循环依赖。2.2 Setter注入可选依赖的“灵活卡口”Setter注入的典型写法Service public class EmailService { private MailSender mailSender; Autowired public void setMailSender(MailSender mailSender) { this.mailSender mailSender; } }也可以省略AutowiredSpring Boot的某些场景下能省但显式标注更稳妥容器会在属性填充阶段调用setXxx()方法。Setter注入的核心优势是“灵活”。你可以不注入某个依赖也能创建对象之后需要时再通过Setter替换。这就好比电脑的外接硬盘不是必须插着才能开机但你想扩展存储的时候随时可以插上。这种特性让它天然适合那些有默认实现、可以在运行时被替换的依赖或者配置项不是强制的组件。另一个优势是处理循环依赖比构造器友好。因为Setter注入发生在Bean实例化之后Spring能通过提前暴露“提前引用”的方式绕开循环依赖后面会细讲三级缓存。不过我还是得提醒一句能用构造器解决的别为了规避循环依赖而刻意用Setter循环依赖本身通常就是设计味道不对的信号。实际开发中Setter注入适合什么场景依赖确实可选有默认实现只在需要时才覆盖。同一个类需要在不同环境下换不同实现比如开发环境用Mock、生产环境用真实客户端。与旧代码兼容老类已有无参构造函数不想因为增加依赖而改动所有调用处。缺点也很明显依赖不是final的意味着Bean在运行期可能被意外替换或者某个依赖忘了被注入直到调用时才抛NullPointerException。所以用Setter注入时一定要在方法里做好非空判断或者提供合理的默认值。另外如果你过于依赖Setter的可变性代码里到处是setXxx()其实等于把“对象”当成了“数据仓库”一定程度上削弱了封装性。2.3 字段注入最便捷但也最容易埋雷的写法字段注入也就是最常见的Autowired加在属性上Service public class OrderService { Autowired private UserService userService; }代码量最少刚接触Spring的人几乎立即就能上手。它本质上是通过反射把容器里的Bean直接写入私有字段所以类里无需构造函数也无需Setter。为什么我用它用得最少先说说问题。第一个问题是不可测试性。我想做单元测试但订单服务依赖用户服务字段是private的没法直接设置。要么靠SpringBootTest拉完整容器慢、重、耦合要么用ReflectionTestUtils.setField去反射改丑。构造器注入则完全没有这个问题。第二个问题是隐藏的依赖。从字段注入的类上你只看到一堆Autowired但类到底哪些依赖是必须的、哪些是可选替代的全都不直观。代码review时别人根本没法一眼判断这个类的依赖边界。第三个问题是循环依赖。字段注入发生在Bean已经实例化之后所以Spring能通过提前暴露临时引用解决Setter和字段级别的循环依赖。不过这会造成一个假象——代码看起来“正常运行”实际上只是依赖被塞进了半初始化的对象里。等真正调用某个方法时可能因为对象还没完全构建好而出现诡异问题。字段注入就一定不能用吗也不能一棍子打死。在Spring Boot测试类、快速原型、某些配置类里为了少写模板代码用字段注入确实方便。比如SpringBootTest里临时注入测试客户端或者一个CommandLineRunner里只需要一次性使用某个服务这时字段注入是合理的取舍。但我对团队成员的要求是业务Service类一律构造器注入基础设施和配置类用Setter或Configuration方法注入测试类可以放松这个限制。这样既保证了代码质量也照顾了效率。2.4 Autowired与Resource的细微差别很多人把Autowired和Resource当同一个东西用实际它们来源不同、装配策略不同混用会导致一些很隐蔽的注入问题。Autowired是Spring框架的注解默认按**类型byType注入。如果同类型有多个Bean再按名称byName**匹配名称也匹配不上就抛NoUniqueBeanDefinitionException。Resource是JSR-250规范的标准注解Spring对其做了支持。默认按**名称byName**注入如果找不到再退化为按类型。Resource还支持通过name属性显式指定要注入的Bean名称。实际开发中的一个经典场景系统里有两个DataSource一个主库一个从库类型一样。用Autowired时必须搭配Qualifier(primaryDataSource)才能准确选到目标用Resource(name primaryDataSource)则更简单直观。但有两点要注意Resource是JDK扩展包里的注解在一些非常严格的模块化环境里可能引发依赖问题另外Spring官方文档推荐优先使用Autowired原因是它更紧密地结合了Spring容器的高级特性比如Primary、Qualifier、ObjectProvider这些都能配合使用。Resource则相对简单不能很好地支持这些Spring特有扩展。我个人的建议是一个项目里统一用一种注解别混。用Autowired配合Qualifier能覆盖几乎所有场景如果团队里有很深的Java EE背景习惯Resource那也请全项目统一。3. 实操过程与核心环节实现3.1 选型决策流程从依赖性质到注入方式如果你正在设计一个新类不知道怎么选注入方式可以按照下面这个决策流程走一遍这是我经历过多个项目后总结出来的。先判断这个依赖是不是可选的缺失就无法工作比如Service里的核心Repository、认证依赖、数据源选构造器注入。有默认实现或可以通过配置切换比如缓存客户端、消息发送器、日志上报器选Setter注入。这个依赖只是临时用一下比如测试类、启动类里的懒加载工具选字段注入但要自觉控制数量。再判断依赖的数量少于5个依赖构造器注入非常简洁。超过5个先怀疑是否违反单一职责再考虑拆分类而不是换注入方式。有些类是基础设施比如统一封装某个第三方SDK的Client依赖较多且都是基础配置可以用Configuration定义一个Bean方法统一装配。还有一个容易被忽略的维度依赖之间的关系。如果A依赖BB又依赖A无论用什么注入方式都得换个思路重新设计。最好的做法是打破循环把公共逻辑抽到第三个类或者引入事件机制解耦。实在没法改才考虑用Setter或字段注入配合三级缓存机制绕开启动报错但这属于历史债务不是推荐方案。3.2 Spring Boot中的配置建议与代码示例Spring Boot环境下推荐在类上不加任何注入注解直接用构造器加final字段。Spring会通过RequiredArgsConstructor需要Lombok或者显式构造函数完成装配。Service RequiredArgsConstructor public class OrderQueryService { private final OrderRepository orderRepository; private final UserClient userClient; private final PriceCalculator priceCalculator; public OrderDetail query(Long orderId) { Order order orderRepository.findById(orderId) .orElseThrow(() - new BusinessException(订单不存在)); UserBrief user userClient.getById(order.getUserId()); BigDecimal finalPrice priceCalculator.calculate(order); return new OrderDetail(order, user, finalPrice); } }Lombok的RequiredArgsConstructor会为所有final字段生成构造函数代码清爽不说还天然保证了依赖的不可变性。如果你的团队禁用Lombok就手写构造函数效果完全一样。对于有多实现注入需求的场景比如不同的支付渠道可以结合Qualifier或者Map注入Service RequiredArgsConstructor public class PaymentDispatcher { private final MapString, PaymentProcessor processorMap; public void pay(String channel, PaymentRequest request) { PaymentProcessor processor processorMap.get(channel); if (processor null) { throw new UnsupportedOperationException(unsupported channel: channel); } processor.process(request); } }Spring容器会自动把PaymentProcessor的所有实现按Bean名称组装成Map这个特性在配置类、策略模式场景里非常实用比手动维护一个List再遍历匹配优雅得多。如果你是做Spring Boot集成监控、外部对接这类场景建议多利用ConfigurationProperties配合Bean统一管理外部依赖的创建和注入。举个例子对接一个第三方短信服务不要在业务类里直接new SmsClient()而是定义配置类Configuration ConfigurationProperties(prefix sms) Data public class SmsProperties { private String endpoint; private String accessKey; } Configuration public class SmsConfig { Bean public SmsClient smsClient(SmsProperties properties) { return new SmsClient(properties.getEndpoint(), properties.getAccessKey()); } }这样一来所有组件都通过构造器注入SmsClient底层SDK怎么初始化上层完全不关心。替换SDK版本或改配置只动配置类就够了。3.3 从“报错”入手定位依赖装配失败的现场实际操作中最常见的报错就是启动时抛NoSuchBeanDefinitionException或NoUniqueBeanDefinitionException。很多人一看“Bean不存在”就怀疑是不是注解没加其实原因可能有好几类排查时按这个顺序走效率最高类上有没有Component、Service、Repository这类注解或者有没有在Configuration里注册BeanSpring Boot的启动类所在的包能不能扫描到目标类包路径不对时注解全白加。是不是同类型有多个实现导致按类型注入不知道选哪个如果是用Primary标记主实现或Qualifier(beanName)指定具体名字。是不是依赖循环链条过长容器在启动阶段无法完成装配这种通常会伴随BeanCurrentlyInCreationException。环境变量或配置项没对齐比如引用了ConfigurationProperties的类但对应的配置没有填也会导致Bean创建失败。我遇到过一个扎心的场景一个模块在本地环境跑得好好的一到测试环境就NoSuchBeanDefinitionException。排查了很久发现是两个微服务共用一套基础代码其中一个服务用了条件装配ConditionalOnProperty而测试环境的配置里恰好没打开那个开关。条件装配这东西看起来简单出了问题却是最隐蔽的。所以凡是用了ConditionalOnXxx的类务必在类注释里写清楚“在什么条件下这个Bean才会被创建”不然同事接锅的时候会崩溃。3.4 循环依赖与三级缓存的本质聊Spring DI三级缓存是绕不开的话题。很多人听说“Spring能解决循环依赖”就放心大胆地用字段注入互相引用但没有真正理解它解决的边界。三级缓存是Spring容器内部维护的三层Map第一级singletonObjects存放已完全初始化好的单例Bean。第二级earlySingletonObjects存放已经实例化但还没完成属性填充的早期Bean。第三级singletonFactories存放“获取早期Bean引用的工厂”这个工厂本质上是ObjectFactory可以在Bean实例化后立即暴露一个引用占位。当容器发现Bean A依赖Bean B而B又依赖A时A实例化后会先把自己的“早期引用”放进三级缓存。等B创建时它可以从三级缓存里拿到A的早期引用完成自己的属性填充然后B初始化完A再从缓存里补上B的依赖继续完成A的初始化。这个机制只能解决Setter/字段注入的循环依赖不能解决构造器循环依赖。因为构造器注入发生在实例化阶段A还没实例化完三级缓存里根本没有A的早期引用B自然拿不到东西。所以只要见到构造器循环依赖的报错就必须通过重构代码来打破环而不是指望容器去“智能处理”。三级缓存的设计还有一个副作用Spring默认的Bean作用域是单例只有单例Bean能用三级缓存提前暴露原型PrototypeBean本身就不缓存实例所以原型Bean之间的循环依赖永远无法解决。我用过不少框架像Spring AI、Spring Security这些都会用依赖注入来管理复杂的Bean图。Spring AI里的Agent、模型客户端、向量存储全都是通过DI装配在一起的。如果对DI的理解只停留在“加注解”层面根本应付不了这类复杂框架的调试。反过来吃透了构造器注入、循环依赖边界你甚至能读懂Spring AI内部那些前置过滤器的装配顺序排查问题会轻松很多。4. 常见问题与排查技巧实录4.1 多实现类注入到底选Primary还是Qualifier同一个接口有多个实现类时直接在构造器里写接口类型Spring会告诉你“找到了两个候选Bean”。解决方案有三种选择逻辑也不同Primary适合“大多数时候用这个默认实现”比如主数据源、主缓存。标记后仍可借助Qualifier指定其他实现。Qualifier(beanName)适合在具体注入点明确指定要哪个比如“某个服务必须走Redis另一个走本地缓存”。MapString, Interface或ListInterface注入适合“按名称动态选择”的策略模式比如支付渠道、消息中间件类型。Spring会自动按Bean名组装Map非常强大。我在一个多租户项目里通过MapString, TenantDataSourceProvider注入按租户ID取对应的DataSource比写if-else优雅得多。但要注意Map的key是Bean名称如果你通过Bean方法创建Bean方法名就是默认Bean名想自定义就修改方法名别在Bean(xxx)里乱写保持统一。4.2 为什么构造器参数比字段注入更利于测试这点踩过坑的人都懂。字段注入的类在单元测试里想伪造依赖必须引入Spring容器或者反射工具。构造器注入则完全不受限直接手动组装Test void testQuery() { UserClient mockClient mock(UserClient.class); OrderRepository mockRepo mock(OrderRepository.class); OrderQueryService service new OrderQueryService(mockRepo, mockClient, priceCalculator); // 接下来就是纯业务测试 }这种测试跑得快、不用等上下文启动、出错了定位也准。我在Spring Boot项目里推“构造器注入JUnit5Mockito”的组合之后单元测试覆盖率从30%涨到了70%一个核心原因就是类不能偷偷背上隐式依赖测试必须显式提供。补充一个小技巧如果一个类已经有多个依赖测试时传参太累可以用InjectMocks来自动注入Mock但前提是类得用构造器或Setter注入字段注入反而会让InjectMocks无能为力。4.3 作用域与注入Singleton和Prototype相爱相杀Spring的Bean默认是单例意味着整个容器里只有一个实例。如果这个单例Bean注入了一个原型Bean那注入进去的其实还是那一个原型实例不会每次使用都新建。这正是“单例注入原型失效”问题的根源。解决方案有几种注入ObjectProviderT每次调用getObject()时重新从容器获取原型Bean会创建新实例。使用Lookup注解标注一个抽象方法Spring会动态生成子类每次调用都返回新的原型Bean。干脆把原型Bean的作用域改成SCOPE_PROTOTYPE且不要注入实例改为每次都从容器中拉取。实际业务里真正需要原型Bean的场景其实很少常见的是异步任务上下文、某次请求的临时对象。用ObjectProvider最省事既能保持构造器注入的风格又能绕开单例的生命周期限制。4.4 条件装配和第三方框架集成时的注入陷阱Spring Boot的自动配置原理本质上是条件装配比如ConditionalOnClass、ConditionalOnMissingBean、ConditionalOnProperty。这带来的好处是加入一个starter依赖很多Bean自动配置好了但坏处是当你想覆盖某个默认Bean时名字没对上条件没触发结果容器里出现了两个Bean或者一个都没有。举一个实际踩过的坑项目中引入了一个Redis消息消费组件本地环境只用单机缓存测试环境要连集群结果本地一直报找不到某个RedisMessageListenerContainer的Bean。排查发现自动配置类的条件里有ConditionalOnMissingBean但我在代码里自定义的Bean名称和自动配置期望的不一样导致自动配置认为已经有人定义过就跳过了而我的Bean因为某个配置项没生效没创建成功两头落空。排查这类问题的手段一个是看启动日志里的“ConditionEvaluationReport”另一个是直接打开spring.factories或AutoConfiguration.imports文件逐个检查条件。条件装配的排查经验对做Spring Boot监控、对接第三方接口或者引入Spring AI这类大集成框架的人来说都是必备技能。建议在项目里统一维护“自定义Bean命名规范”尽量让自定义Bean覆盖自动配置时使用相同的方法名减少条件判断的意外。4.5 一个团队规范示例如何在代码评审中快速判断注入好坏下面这个检查清单可以帮助你在评审别人的PR时快速判断也可以对照自己的代码做自查。[ ] 业务Service类是否都用构造器注入字段是否final[ ] 是否有类同时混用三种注入方式有的话要求写清楚原因。[ ] 是否有原型Bean被注入单例Bean且期望每次新建有的话看是否用了ObjectProvider。[ ] 是否有Autowired和Resource混用有的话统一。[ ] 是否出现依赖数量超过5个的巨型构造器有的话建议拆分。[ ] 是否有字段注入的测试类且没有单元测试有的话要么补测试要么改成构造器注入。[ ] 循环依赖是否通过Setter/字段绕过去如果是建议重构。这份清单执行起来不算繁琐但对代码质量提升非常明显。我见过太多项目从字段注入滑向不可测试泥潭的案例等反应过来时重构成本已经非常高。5. 注入方式选型的经验总结5.1 我最终确定的团队推荐方案经过两三个项目的反复磨合我的推荐方案收敛成了下面这样业务Service类一律构造器注入配合final字段和Lombok的RequiredArgsConstructor。基础设施类Cache、MQ Client、HttpClient等构造器注入创建逻辑收拢到Configuration里的Bean方法。可选依赖或有默认实现的组件Setter注入在方法内提供默认值和非空判断。单元测试类允许字段注入但尽量用Mockito的InjectMocks。配置类不要滥用Autowired能用构造器参数注入配置属性的就别用字段。这个方案不是纯理论推演而是在真实项目里验证过。最近一个上线半年多的Spring Boot服务十几个Service类全是构造器注入业务测试跑得又快又稳。比起以前字段注入满天飞的时代浪费在排查“到底是哪个Bean没装配”上的时间明显少了很多。5.2 最后再分享一个小技巧如果你要接手一个老项目里面全是字段注入和循环依赖暂时没法大改。有一个低成本的过渡技巧把字段改成构造器参数但类上暂时保留Autowired注解不要删——实际上Spring对构造器注入和字段注入的兼容性很好你可以分步骤迁移先只改其中一个类跑一遍完整测试确认无误后再动下一个。这比一次性大重构安全得多。另外排查依赖问题时多利用IDEA的Spring面板和启动日志里的Bean定义输出启动时加上--debug可以打出很详细的自动配置报告。别靠猜环境打印出来的装配记录会告诉你每一步发生了什么。把这些工具用顺手比背再多注解都管用。依赖注入看起来是一个“老掉牙”的知识点但它贯穿了Spring框架的几乎所有角落。无论是手写Spring源码、调试Spring Security的过滤器链还是给Spring AI搭建多Agent协同追到底层都在和Bean的生命周期、装配顺序打交道。把三种注入方式理解透了你看框架源码时会觉得很多地方“理所当然”而不是“黑魔法”。这篇内容算是我这几年实际写下来的一点沉淀希望对你有用。
返回列表