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

资讯详情

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

Spring Boot依赖注入选型:构造器注入与Setter注入的对比实践

Spring Boot依赖注入选型:构造器注入与Setter注入的对比实践 先抛个结论免得你翻到一半没耐心在一个正常的 Spring Boot 项目里我把构造器注入当作默认选择Setter 注入只留给确实可选、确实需要运行时替换的依赖。这是我在多个项目里反复折腾后沉淀下来的原则。但如果你以为这篇文章就是教你“无脑用构造器”那也偏了我会把两种方式的适用边界、背后原理、踩坑场景都拆开讲清楚。构造器注入和 Setter 注入的争论在 Java 社区里吵了很多年。Stack Overflow 上相关问题的浏览数加起来早就超百万了。你随便打开一个开源项目有人用 Lombok 的RequiredArgsConstructor写一堆 final 字段有人还在 XML 里配置property namexxx refxxx/更老的代码里还可能见到Autowired直接打在字段上。三种风格并存的情况在一家公司里都很常见。这篇文章就用实际项目视角来分析依赖注入到底在解决什么问题、构造器注入和 Setter 注入各自藏着哪些优劣、选型的时候要考虑什么以及真遇到循环依赖这种疑难杂症怎么处理。1. 先搞清楚依赖注入在解决什么两种方式差在哪1.1 依赖注入的本质以及它为什么不是可有可无的“配置技巧”依赖注入一点都不神秘。本质上就是“一个类需要的东西不在自己内部 new 出来而是让别人从外面递进来”。比如一个订单服务需要调用库存服务扣减库存最原始的做法是在订单服务里直接new InventoryService()这样订单服务就和库存服务的具体实现绑死了将来想换一个带缓存的库存服务、想在测试里塞一个 Mock 对象都得改源码。依赖注入改变的是控制方向订单服务不负责创建库存服务只声明“我需要一个库存服务”。至于这个服务是真实实现还是测试替身、是单例还是每次新建、是加事务代理还是不加全部交给外部容器或者组装代码来决策。用做饭来打个比方依赖注入不是你每次炒菜都去楼下菜地种菜而是提前把食材采购交给菜市场你只管接菜下锅。在 Spring 里实现依赖注入主要有三种写法构造器注入、Setter 注入、字段注入。字段注入就是Autowired直接打在字段上我后面会提到它但它不在本次的对比主线上。真正需要认真对比的是构造器注入和 Setter 注入这两种“正规军”。1.2 一个代码例子看清两种写法的根本差异假设有个用户服务需要依赖一个邮件发送器还要一个用户仓库。用构造器注入会这么写Service public class UserService { private final MailSender mailSender; private final UserRepository userRepository; public UserService(MailSender mailSender, UserRepository userRepository) { this.mailSender mailSender; this.userRepository userRepository; } public void register(User user) { userRepository.save(user); mailSender.send(user.getEmail(), 欢迎注册); } }换成 Setter 注入就是Service public class UserService { private MailSender mailSender; private UserRepository userRepository; Autowired public void setMailSender(MailSender mailSender) { this.mailSender mailSender; } Autowired public void setUserRepository(UserRepository userRepository) { this.userRepository userRepository; } public void register(User user) { userRepository.save(user); mailSender.send(user.getEmail(), 欢迎注册); } }注意看两者的本质区别。构造器注入是在对象创建的那一刻把依赖定死final关键字保证了依赖不能被替换Setter 注入则是先创建出一个“缺胳膊少腿”的对象之后再由外部把依赖塞进去。这个差异初看只是代码风格问题实际上它会影响对象的生命周期、可测试性、甚至系统排查故障的方式。2. 构造器注入为什么 Spring 官方和大量框架默认推荐它2.1 构造器注入的优势拆解不可变、全量、类型安全Spring 官方文档在讨论依赖注入方式时明确表达过对构造器注入的偏向。这里我不去翻官网原文了直接说我理解的几个核心原因。第一不可变性。构造器注入配合final字段依赖在对象创建后就不能被修改。这一点在并发场景下尤其重要。设想一个订单服务被多个线程同时调用如果某个依赖字段还能被 Setter 在运行期偷偷换掉可能会出现一个线程看到的服务和另一个线程看到的服务不是同一个实现的诡异问题。用final从语言层面就堵死了这个口子。第二依赖完整性。构造器注入强制要求所有必需依赖在对象创建时全部到位。你少传一个参数编译直接报错。这意味着一个已经被构造出来的服务对象一定是“完整可用的”。反观 Setter 注入如果某个setXxx被漏掉了对象照样能创建成功但等到某个方法里真正用到这个依赖时才会抛出NullPointerException。这类错误不是发生在启动阶段而是发生在业务运行过程中排查成本高得多。第三不容易被误用。构造器注入天然不支持运行期替换依赖这会逼着开发者把可变需求放到方法参数里而不是塞进对象状态里。我见过不少混乱的老项目大量业务状态放在 Service 的字段里然后用 Setter 注入各种辅助服务结果对象的状态散落各地非常难维护。构造器注入虽然没有彻底解决这个问题但至少让“依赖”这部分是干净清爽的。2.2 构造器注入的坑循环依赖、参数膨胀与可选依赖任何方案都有代价构造器注入的代价也很明确。最出名的问题就是循环依赖。假设 A 服务需要 B 服务B 服务又需要 A 服务这时候两边都用构造器注入Spring 在创建 A 的实例时发现参数列表里有 B于是去创建 B创建 B 时又发现需要 A于是再回头创建 A。可这个时候 A 还没创建完成Spring 手里只有 A 的一个半成品构造器注入根本拿不到完整 A。应用启动会直接报错提示存在循环依赖。这个问题的本质是构造器注入要求“先有完整对象才能注入”而循环依赖要求“在对象未完成前就注入”。两者天然矛盾。Spring 对 Setter 注入能通过提前暴露早期单例引用绕开这个问题但对构造器注入真的无能为力。第二个问题是参数膨胀。如果一个 Service 的构造器有六七个参数看着就让人头疼。但这要说句公道话构造器参数膨胀通常是设计坏味道的信号说明这个类干的活太多、依赖了太多外部能力。这时候正确做法不是改成 Setter 注入来把问题藏起来而是应该拆分类。比如一个报表服务既依赖订单仓库、用户仓库、商品仓库、库存仓库还依赖导出工具不如拆成报表数据聚合服务和报表导出服务两个类各自构造器只有三个左右参数清楚也容易测试。第三个问题是可选依赖。有些依赖确实可有可无比如一个日志收集器有就发送没有就忽略。构造器注入没法表达“可选”要么你传一个空实现要么传null这会让调用方很别扭。Setter 注入天然适合这种场景不 set 就为空代码层面对可选依赖友好得多。2.3 现代 Spring 项目里构造器注入的写法final 字段 Lombok现在的 Spring Boot 项目大多不用手写构造器了。我推荐一种我非常习惯的写法字段全部声明为private final然后用 Lombok 的RequiredArgsConstructor自动生成构造器。Service RequiredArgsConstructor public class OrderService { private final InventoryService inventoryService; private final PaymentService paymentService; private final OrderRepository orderRepository; public Order createOrder(OrderRequest request) { // 省略业务逻辑 } }这段代码里没有写任何构造器Lombok 在编译期会生成一个有这三个参数的构造器而且参数顺序和字段声明顺序一致。Spring 看到唯一的构造器会自动调用它完成依赖注入不需要Autowired。相比手写构造器这种写法减少了一大堆样板代码也避免了“明明有构造器却忘了加Autowired导致注入失败”这种低级错误。唯一要注意的是团队里必须统一使用 Lombok如果项目对注解处理器有限制那还是手动写构造器更稳妥。别小看多写的那几行代码在几十个 Service 的规模下手写构造器容易漏字段加了新依赖之后经常忘记同步更新构造器。注意RequiredArgsConstructor并不是 Spring 的注解它是 Lombok 的。它生成的是普通的带参构造器Spring 之所以能自动识别是因为 Spring 的自动装配规则是“如果类只有一个构造器就直接使用它”。这个机制和注解无关所以哪怕你换用 Kotlin、换用其他 JVM 语言的项目只要遵循“单构造器”原则效果都是一样的。3. Setter 注入灵活背后的代价什么时候它更合适3.1 Setter 注入的优势可选依赖、后置替换、循环依赖虽然我平时默认构造器但 Setter 注入并不是一无是处它在几个场景里确实有不可替代的价值。先说可选依赖。如果一个依赖真的可不传Setter 注入的语义最自然。比如一个消息消费服务里需要打点统计数据这个打点器在本地开发时可以没有生产环境必须配置。你用构造器注入就会被迫传一个空实现用 Setter 注入就舒服多了——有容器配置就注入没有就不注入代码里判断一下是否为空就完事。再说运行期替换。Spring 管理的 Bean 大部分是启动时就固化的但有些场景需要在运行时动态调整比如根据配置中心的新配置切换不同的数据源、把测试环境专用的 Mock 发送器替换成真实发送器。Setter 提供了这种灵活性构造器注入做不到因为final字段一旦赋值就不能变。第三是解决循环依赖。老版本的 Spring 支持对一个设置了 Setter 注入的 Bean 提前暴露引用从而破解循环依赖。虽然 Spring Boot 2.6 之后默认禁止循环依赖了但很多老项目依然在用旧版本如果遇到循环依赖且你暂时没法重设计模块Setter 注入可以临时续命。此外Setter 注入在 XML 配置时代非常流行。如果你接手过用 XML 配置 Bean 的老系统会发现到处是property标签今天写 Spring Boot 的新项目一般不会再这么用了但在兼容老框架或者需要外部化配置的场景里Setter 还是更顺畅。3.2 Setter 注入的风险可变状态、漏设置、隐式依赖Setter 注入最大的问题我用一句话概括它允许你创建一个“不完整的对象”并把问题推迟到运行时才暴露。漏设置依赖是最典型的坑。一个类里有六个私有字段外部的装配代码只调用了五个 Setter编译不报错测试也不一定报错等到了某个用户量大的接口上突然就NullPointerException了。这种 bug 最恶心的地方在于它不发生在启动阶段而是在业务高峰才冒出来定位时需要排查的对象状态又多又乱。可变性也是风险来源。Setter 注入的字段默认不是final任何人都可以在任意时刻调用setXxx换掉依赖。如果一个 Bean 是单例的这种替换还会影响其他线程造成间歇性的诡异行为。我排查过一个问题某个老项目里一边是容器注入的RedisTemplate一边是某个定时任务在运行期通过 Setter 换成了带不同序列化器的实例结果有些请求读到的是乱码。最后定位到是某个后置处理器偷偷调了 Setter这种代码用构造器注入从根上就写不出来。还有个问题比较隐蔽隐式依赖。Java 开发者看到字段就能知道这个类依赖哪些东西但看到一堆 Setter 方法未必能立刻意识到这些是容器注入的依赖还是普通业务方法。这会让代码的可读性下降新人接手时搞不清哪些字段可以被安全替换。相比之下构造器参数列表就是一份明晃晃的“依赖清单”一打开类文件就知道这玩意儿到底靠什么活。3.3 实战中的 Setter 注入写法与注意事项如果真的要用 Setter 注入我建议至少把范围控制住。不要把所有依赖都写成 Setter只让真正可选的依赖走 Setter必选依赖还是要塞进构造器。这样可以兼顾两边的优点。在 Spring 里的写法很简单Service public class MetricService { private final MetricReporter reporter; // 必选依赖构造器注入 private Tracer tracer; // 可选依赖Setter 注入 public MetricService(MetricReporter reporter) { this.reporter reporter; } Autowired(required false) public void setTracer(Tracer tracer) { this.tracer tracer; } }这里有个细节值得注意Autowired(required false)表示如果容器里没有Tracer类型的 Bean也不会报错这个 Setter 就不会被调用。这比在字段上加Autowired(required false)来得更可控因为 Setter 方法内部可以做非空判断、可以打日志、可以设置默认值。如果使用 XML 配置老项目Setter 注入通常不需要额外的注解配置中心直接按属性名调用它的限制没那么多。不过我们团队的默认约定是新代码禁止新增 Setter 注入老代码要改动时看情况迁移。4. 选择策略回到项目里怎么拍板而不是背教条4.1 决策矩阵按依赖性质、场景和团队习惯选网上很多文章把这个问题讲成了“二选一”我觉得这是误导。真正负责的技术决策应该基于依赖的性质和项目上下文。我放一个自己常用的小决策矩阵你可以直接拿来做判断依据判断维度倾向构造器注入倾向 Setter 注入依赖是否必选必选依赖可选依赖依赖是否需要不可变需要不可变运行期允许替换是否可能循环依赖不适合出现循环依赖可以临时解决循环依赖测试复杂度构造时一次性传入测试简单需要额外 set 一步类是否会膨胀触发拆分类的信号掩盖类职责过重配置方式注解/Java Config历史 XML 项目或外部化配置团队代码规范追求统一和强制允许运行时灵活装配我特别想强调“团队代码规范”这一行。技术选型要考虑团队的执行成本。如果团队里三分之一的人写构造器三分之一写 Setter剩下三分之一用Autowired字段注入那不管哪种方式的技术优劣多明显代码都会变成一锅粥。这种情况下统一标准比选择哪个标准更重要。我建议新团队或者半新不旧的项目就用构造器注入作为统一规范因为它对新人最友好——编译失败比运行时 NPE 好处理得多。4.2 混用与折中构造器为主Setter 只留个角落我个人的最终方案不算激进先选构造器注入再明确保留一小块 Setter 注入的地盘。具体来说一个 Service 的依赖分成两种。必选依赖也就是缺少它业务根本无法工作的依赖走构造器注入可选依赖也就是有则用、无则不用的增强能力走 Setter 注入。这种“构造器为主 Setter 少量可选”的模式兼顾了不可变性和灵活性代码看起来也直白。举一个我在实战中重构过的例子。原来有个ReportService所有依赖都是字段级AutowiredService public class ReportService { Autowired private OrderRepository orderRepository; Autowired private UserRepository userRepository; Autowired private ExcelExporter excelExporter; Autowired(required false) private MetricsCollector metricsCollector; }改成构造器为主后Service RequiredArgsConstructor public class ReportService { private final OrderRepository orderRepository; private final UserRepository userRepository; private final ExcelExporter excelExporter; Autowired(required false) private MetricsCollector metricsCollector; }注意metricsCollector我还是用了字段注入因为它确实可选而且只在某些环境里存在。但另外三个必选依赖全部变成了final字段。这样启动时缺了这些核心仓库Spring 会直接报错而不是等用户访问报表时才抛 NPE。如果团队规范允许Autowired(required false)的字段注入也可以替换成 Setter 注入语义更清楚但我个人认为这是锦上添花不是必须。4.3 一个完整的重构示例从 Setter 全家桶到构造器优先再走一个更彻底的重构流程看看老的四五个 Setter 注入怎么一步步变成干净的构造器注入。原始代码长这样Service public class RefundService { private PaymentClient paymentClient; private RefundRepository refundRepository; private AuditLogger auditLogger; public RefundService() { // 留一个无参构造器过去 XML 和测试代码都能用 } public void setPaymentClient(PaymentClient paymentClient) { this.paymentClient paymentClient; } public void setRefundRepository(RefundRepository refundRepository) { this.refundRepository refundRepository; } public void setAuditLogger(AuditLogger auditLogger) { this.auditLogger auditLogger; } public RefundResult refund(RefundRequest request) { // 业务逻辑 } }重构第一步添加带参构造器把已有字段全部参数化Service public class RefundService { private final PaymentClient paymentClient; private final RefundRepository refundRepository; private final AuditLogger auditLogger; public RefundService(PaymentClient paymentClient, RefundRepository refundRepository, AuditLogger auditLogger) { this.paymentClient paymentClient; this.refundRepository refundRepository; this.auditLogger auditLogger; } public RefundResult refund(RefundRequest request) { // 业务逻辑 } }第二步删掉无参构造器和所有 Setter 方法。如果 Spring 容器里还有其他代码直接调用setPaymentClienthttps://通过反射等方式改字段或者干脆是 XML 配置需要同步清理。这一步可能打破一些旧测试因为它们之前用new RefundService(); setXxx(...)来组装对象现在要改成new RefundService(paymentClient, refundRepository, auditLogger)。第三步用 Lombok 再压缩一遍重复的构造器样板Service RequiredArgsConstructor public class RefundService { private final PaymentClient paymentClient; private final RefundRepository refundRepository; private final AuditLogger auditLogger; public RefundResult refund(RefundRequest request) { // 业务逻辑 } }重构完的变化其实不只是代码变短了。注入依赖这件事变成一个显式的、强制的要求任何组件少了paymentClient或者refundRepository都无法创建。而这种“创建失败比运行时失败好一百倍”的思路正是我一直强调的核心价值。5. 常见问题与排查技巧实录5.1 循环依赖真的无解吗多种解法对比先看怎么判断项目里到底有没有循环依赖。Spring Boot 2.6 之后如果启动了循环依赖日志里会直接提示类似“The dependencies of some of the beans in the application context form a cycle”的错误并且直接启动失败。旧版本不会报错但 Lazy 初始化时可能运行时才炸这种情况要特别小心。处理循环依赖我用过四种方式各有适用场景。方式一是重新设计依赖关系。比如 A 依赖 B 的方法查询订单B 需要 A 的方法查询用户很可能是把数据查出来再传给对方就更合理。把 A 真正需要的数据定义成普通参数B 的方法接收参数而不是反查 A依赖方向就从 A→B、B→A 变成了 A→B 单向。方式二是在某一方使用Lazy。这个注解加在构造器参数上可以让 Spring 为 A 生成一个代理对象而不是立即初始化它从而打破循环Service public class AService { private final BService bService; public AService(Lazy BService bService) { this.bService bService; } }这个方案改动小但引入了代理调试时会有额外的间接层只能作为临时的过渡举措。方式三是把依赖移动到事件机制。A 不再直接调用 B而是发布一个事件B 监听事件并自己处理。这样 A 和 B 在 Spring 容器层面甚至不需要互相依赖自然就不存在循环。适合那些本意就是解耦的场景。方式四就是旧版 Spring 里的 Setter 注入。它能绕过构造器循环的硬限制但要付出可变性的代价。我在老项目里见过很多用 Setter 注入“解”循环的代码表面上是解决了可代码越往后越脏。我的经验是只要不是急着救火优先走前三种Setter 是最后的退路。5.2 测试环境怎么处理构造器注入的“重”构造函数有些人觉得构造器注入里的依赖太多了测试起来麻烦。这个想法我理解但我的实践经验恰恰相反构造器注入是最容易测试的。假设要测UserService你直接在测试里new UserService(mockMailSender, mockUserRepository)就完事了。没有复杂反射、没有测试框架的注入机制所有依赖都是显式的参数。这个特性在单元测试里简直无价。而那些参数太多的构造器恰恰会成为你重构的信号——如果一个类有七个依赖不好测那就拆分它。Setter 注入的测试虽然也不复杂但有个潜在问题你测试组装对象的时候容易漏掉某些 Setter特别是当这个类字段很多的时候。这时候你测试通过不代表装配代码可以通过容器接入有些字段可能在容器里才有值。而构造器注入就完全没有这种割裂测试里的组装方式和容器实际运行时的组装方式完全一致。我每次写测试时都特别看重“测试环境与生产环境行为是否一致”这点因为不一致意味着即使测试全绿上线也未必稳。5.3 团队代码 Review 时可以用的检查清单我在团队里推行过一段时间构造器注入优先的规范培养出了一种条件反射式检查代码的方式。这里分享一个轻量清单Review 代码时照着扫一遍就能发现很多隐患。检查点通过需要改进新增的必选依赖是用哪种方式注入的构造器注入Setter 或字段注入新增的可选依赖是否明确标注了 requiredfalse有明确标注没有区分可选性类的字段是否大部分是 final是大量非 final 注入字段构造器参数是否会超过 5 个不会考虑拆分服务类是否出现循环依赖启动正常无告警使用了 Lazy 或准备重设计测试中创建被测对象是否方便直接构造器传参依赖 Spring 测试容器注入字段“依赖 Spring 测试容器注入字段”这条我要单独说一下。有些团队测试代码里大量依赖SpringBootTest和MockBean测试时启动整个容器。这种集成测试写起来爽跑起来慢而且一改装配方式就容易红。构造器注入让更多单测可以直接用new创建对象大幅减少对容器的依赖整体反馈速度提升不少。6. 我踩过的坑和现在的选型习惯6.1 一个典型的失败案例几年前接手过一个比较乱的后端服务里面大量使用字段级Autowired加上少部分 Setter 注入。这个服务平时启动没问题但每次上线前都要人工检查一遍配置。有一次一个下游存储服务从单机切到集群运维改了配置中心里的地址结果不生效查了半天才发现有个核心 Service 的存储依赖是 Setter 注入的被某个后置处理器在运行期换成了另一个实现配置根本没传进去。那次故障让我彻底下定决心清理代码。清理的过程并不顺利。几十个 Service 里有一半用了字段注入还有一些用了 Setter。我当时定了几条规则所有必选依赖必须改成构造器注入用RequiredArgsConstructor真正的可选依赖只允许 Setter 注入且必须显式写Autowired(required false)运行期替换依赖只能在专门的装配类里做不许在 Service 内随意改字段。执行了大半年整个服务从“启动靠运气”变成了“启动失败早暴露”故障率明显下降。6.2 我的最终建议构造器优先但别教条回到标题那个问题哪个更适合你的项目我的答案很直白如果项目在 JDK 17 Spring Boot 3 这种现代技术栈上默认选构造器注入如果项目里有大量老 XML 配置或者不适合使用 Lombok 的环境Setter 注入也还能用但要控制范围、规范统一如果遇到循环依赖先尝试重新设计依赖别急着用 Setter 解掉就完事。我要补一句写作法规范的时候最大的成本不是选择哪一种方案而是让团队所有人都遵守同一种方案。好的代码库不一定用最先进的技巧但一定有最高的一致性。构造器注入在“维护一致性”上非常厉害因为它在编译期就强制你遵守规范而 Setter 注入把规则留给开发者的自觉。这是我最终选择构造器优先的重要原因。另外建议你在做技术分享或者团队规范制定的时候别把这件事讲成“A 比 B 好”。把决策矩阵讲清楚把场景分类讲清楚让大家在具体场景里能自己判断效果会好得多。毕竟代码是写给人看的也是写在特定上下文里的规定定得太死反而会让团队在遇到合理例外的时候产生抵触情绪。
返回列表