依赖注入的困境:为什么我终于在字段上抛弃 @Autowired
Source: Dev.to
请提供您希望翻译的正文内容,我将为您翻译成简体中文并保持原有的格式。
美学陷阱:我们为何爱上字段注入
在我们拆解之前,必须先承认我们最初使用它的原因。看看下面的代码片段:
@Service
public class OrderService {
@Autowired
private UserRepository userRepository;
@Autowired
private PaymentService paymentService;
@Autowired
private InventoryClient inventoryClient;
// Business Logic...
}
它无疑非常简洁。没有占据半屏的笨重构造函数。它看起来像是“Spring 的方式”。多年来,这在教程和 Stack Overflow 回答中都是标准做法。只需一行代码就能添加依赖。
然而,这种“整洁”只是视觉幻觉。它就像把乱糟糟的房间里的东西全塞进壁橱。房间看起来干净,但实际上你让系统更难管理。
不可变性的理由
作为工程师,我们应该追求 不可变性。一个在创建后无法更改的对象在多线程环境中本质上更安全、更可预测,也更容易推理。
当你使用字段注入时,无法将依赖声明为 final。Spring 需要在构造函数执行后通过反射进入你的对象来注入这些字段。这意味着你的依赖在技术上是可变的。
通过切换到构造函数注入,你可以重新使用 final 关键字:
@Service
public class OrderService {
private final UserRepository userRepository;
private final PaymentService paymentService;
private final InventoryClient inventoryClient;
public OrderService(
UserRepository userRepository,
PaymentService paymentService,
InventoryClient inventoryClient) {
this.userRepository = userRepository;
this.paymentService = paymentService;
this.inventoryClient = inventoryClient;
}
}
现在,你的类是 “生而已准备好”。一旦 OrderService 实例化,你就可以 100 % 保证 userRepository 已经存在,并且不会被某些不良进程更改或设为 null。这就是线程安全和防御式编程的基础。
单元测试的理由
如果你想了解你的架构有多好,看看你的单元测试。如果你的测试设置看起来像一次仪式性的献祭,那么你的架构就出了问题。
字段注入使单元测试不必要地困难。 因为这些字段是私有的,且 Spring 在幕后完成大量工作,你无法在测试中直接实例化该类。你只有两个糟糕的选项:
-
在测试中使用 Spring – 例如
@SpringBootTest或@MockBean。
你的“单元”测试现在启动了一个微型的 Spring 上下文。它慢、重,且不再是单元测试(而是集成测试)。 -
使用反射 – 例如
ReflectionTestUtils手动将 mock “塞入” 私有字段。
这很脆弱。如果你重命名字段,测试会崩溃,但编译器不会告诉你原因。
使用构造函数注入,测试轻而易举。由于构造函数是创建对象的唯一方式,你只需直接传入 mock:
@Test
void shouldProcessOrder() {
UserRepository mockUserRepo = mock(UserRepository.class);
PaymentService mockPaymentService = mock(PaymentService.class);
InventoryClient mockInventoryClient = mock(InventoryClient.class);
// Standard Java. No magic. No Spring. Fast.
OrderService service = new OrderService(mockUserRepo, mockPaymentService, mockInventoryClient);
service.process(new Order());
}
快速失败:凌晨 2:00 的生产 Bug
我们都遇到过这种情况。你部署了一个改动,应用启动正常,一切看起来都很绿。然后在凌晨 2:00 时,某个特定用户访问了一个边缘案例的 API 接口,日志里炸出了 NullPointerException。
为什么会这样? 因为使用字段注入时,Spring 允许应用在依赖缺失或循环依赖的情况下启动。字段会保持为 null。只有当代码真正尝试使用该字段时,你才会发现问题。
构造函数注入是你的预警系统。因为 Spring 必须调用构造函数来创建 bean,它必须立即满足 所有 依赖。如果缺少某个 bean,ApplicationContext 将无法加载,应用甚至在你的机器上都启动不起来,更别说在生产环境了。
我宁愿在本地花五分钟修复一个启动错误,也不愿花五个小时向利益相关者解释为什么支付服务在深夜崩溃。
单一职责原则
**单一职责原则(SRP)**指出,一个类应该只有一个且唯一的改变理由。
字段注入使得违反这一原则变得过于容易。因为每个依赖只需要一行 @Autowired,开发者往往会在类中随意添加额外的协作对象,而不考虑内聚性。构造函数注入则迫使你明确类真正需要的 什么,从而更难累积不相关的职责。
TL;DR
| 方面 | 字段注入 | 构造函数注入 |
|---|---|---|
| 可见性 | 私有字段,隐藏的依赖 | 显式的构造函数参数 |
| 不可变性 | 依赖不能被 final | 依赖可以被 final |
| 可测试性 | 需要 Spring 上下文或反射技巧 | 使用 mock 的普通 Java 实例化 |
| 快速失败 | 运行时出现 null | 缺少 bean 导致启动失败 |
| SRP 执行 | 容易添加隐藏的协作者 | 明确的契约,促进内聚 |
今天就切换到构造函数注入。你的代码将更简洁、更安全、更易于测试,当下一个生产环境的 bug 在凌晨 2 点潜入时,团队会感谢你的。
Constructor Injection vs. Field Injection
“毁灭构造函数”
当你依赖字段注入(@Autowired)时,很容易忽视一个类开始承担太多职责。我见过那些在屏幕上看起来“整洁”的服务,里面有 15 个 @Autowired 字段。
当你切换到构造函数注入时,拥有 15 个依赖的类看起来就像个怪物。构造函数变得庞大、难以阅读且丑陋。
这正是重点所在。
那个“毁灭构造函数”是一个信号——代码在告诉你:
“嘿,我做得太多了。请把我重构成更小、更专注的服务。”
字段注入就像是一层化妆品,掩盖了皮肤感染;构造函数注入则迫使你看到问题并加以处理。
循环依赖:无限循环
循环依赖(服务 A 需要 B,B 又需要 A)通常表明设计不佳。字段注入几乎可以不被注意地让它们发生。Spring 会尝试使用代理来解决,往往导致令人困惑的行为。
构造函数注入默认不允许循环依赖。如果你尝试这样做,Spring 会抛出 BeanCurrentlyInCreationException。
虽然这看起来像是个麻烦,但实际上它是一道防护栏。它迫使你重新思考服务边界。通常,循环依赖意味着你需要一个第三方服务(服务 C)来持有共享逻辑,或者应该转向事件驱动的方式。
Lombok 速成技巧
我最常听到的反对声音是:
“但是我不想为 200 个服务编写和维护构造函数!”
我理解。作为程序员,只要能自动化的任务,我都会去做。这时 Project Lombok 就成了你的最佳伙伴。
使用 @RequiredArgsConstructor 注解,你可以兼得两者的好处:将字段声明为 private final,Lombok 会在编译时生成构造函数。
@Service
@RequiredArgsConstructor
public class OrderService {
private final UserRepository userRepository;
private final PaymentService paymentService;
private final InventoryClient inventoryClient;
// No manual constructor needed!
}
专业体现在细节
归根结底,使用构造函数注入是关于 有意图 的。它意味着你有意识地选择编写:
- 与框架解耦的代码
- 易于测试的代码
- 架构上健全的代码
它让你远离 “Spring 魔法”,走向 “Java 卓越”。
如果你正在处理一个充斥 @Autowired 字段的遗留代码库,别慌。你不必今晚就重构所有东西。但对每一个新服务,尝试使用构造函数方式。你会发现测试变得更简单,类也更小。
你的代码体现了你的工匠精神。别让像字段注入这样的捷径破坏它。
你的看法是什么?
你是死忠 @Autowired 的粉丝,还是已经拥抱了构造函数注入?在评论里聊聊吧。如果你觉得这篇文章有帮助,考虑分享给仍被 “字段注入陷阱” 困住的 junior 开发者。