依赖注入:摧毁面向对象设计并最终获胜的反模式
Source: Dev.to
企业应用并没有因为 DI 而变得更好——它们变得更慢、更难修改、更难测试、更难升级,维护成本也更高。DI 并没有解决复杂性;它本身就成了复杂性。
想象一下,你因为拇指疼去看医生。
医生拿出一把大锤子把你的脚砸碎。
你一边跛着一边尖叫离开。
他笑着说:“很好,你已经不再想你的拇指了,对吧?”
那就是依赖注入。
EJB2 是那根疼痛的拇指,DI 是大锤子。
EJB3 在 2006 年治好了拇指。
我们用了二十年时间去砸掉身体的其余部位,并把它称作“企业最佳实践”。
1. 历史意外(2003–2006)
- EJB2 强制使用 JNDI 查找和 Home 接口,为容器管理的 Bean 引入了不必要的间接层和样板代码。
- Rod Johnson 在 Spring 中构建了一个更轻量的容器来隐藏这些痛点,使得绕过生命周期不匹配变得更容易。
- 随后 EJB3/JPA 出现,简化了一切——不再需要 Home 接口,不再有丑陋的查找,原来的痛点也随之永远消失。
然而,我们并没有庆祝直接实例化对象的自由,而是把大锤子留下来,强制系统中的每个类都必须使用它。
2. 每个人都能认出的具体例子
在当今的 DI 框架中,像永久存储这样简单的东西也被抽象层层堆砌:
interface Storage { /* … */ }
@Component
@Qualifier("perm")
class S3Storage implements Storage { /* … */ }
@Service
class OrderService(
@Qualifier("perm") Storage storage
) { /* … */ }
“永久”的含义现在成了散落在 qualifier、注解和 YAML 文件中的不可见配置细节。要换后端?你得在整个代码库中搜寻所有的 wiring 不匹配。
对比一下以职责为中心的做法:
final class PermanentStorage {
private static final S3Client s3 = resilientInstrumentedS3Client();
public static Identifier save(Object o) { /* … */ }
public static Object load(Identifier id) { /* … */ }
}
只有两个公共方法。所有繁琐的细节——重试、指标、追踪、分段上传、凭证轮换——都封装在这一个类里,永远不对外暴露。调用方从未直接接触 AmazonS3。要换成其他供应商?改动一个文件,系统的其余部分甚至感觉不到。
这不仅更简洁;它才是封装应有的模样。
3. DI 与面向对象设计不兼容
在真正的 OO 中,构造函数创建一个完整有效的对象,拥有自己的不变量,并通过干净的 API 隐藏实现细节。
而在 DI 容器里,构造函数沦为一长串依赖清单,只是声明容器应该注入什么,而不是保证对象的有效性。不变量被推到配置文件或注解中,使得在创建时根本无法推断对象状态。大多数类甚至无法手动实例化而不启动整个上下文——new 成了代码味道,局部推理被全局魔法取代。
今天大多数支持 DI 的论点并非基于功能必需或架构清晰,而是文化惯性。开发者捍卫它是因为他们被教过,而不是因为它真的解决了现代代码库中的实际问题。
4. SOLID 的所谓依据是个神话(DIP 与 ISP 与 DI 毫无关系)
业界喜欢用 SOLID 来为 DI 正名——尤其是 DIP(依赖倒置原则)和 ISP(接口分离原则)。这是一种类别错误。
4.1 DIP 实际说了什么
真正的 DIP 是关于 源代码依赖方向 的静态设计规则,而不是运行时的注入。
DIP 的含义
- 高层模块不应依赖低层模块。
- 两者都应依赖 抽象。
- 这些抽象应在领域中代表 有意义的角色。
仅此而已。
DIP 并不要求:
- DI 容器
- 基于注解的 wiring
- 每个类对应一个接口
- 配置驱动的对象图
DIP 只是鼓励 稳定、聚焦的抽象——小接口、单一且连贯的职责(不一定只有一个方法,但必须只有一个目的)。
而 DI 却恰恰相反:
- 单实现接口
- 构造函数依赖清单
- 将实现细节泄漏到 API 中
- 全局容器耦合取代局部推理
DIP 试图降低耦合,DI 却引入了一种新的全局耦合——对容器本身的耦合。
4.2 ISP 实际说了什么
ISP 更简单:客户端不应被迫依赖它们不使用的方法。
实践中:
- 接口应 小
- 接口应 内聚
- 接口应对应 角色,而非便利的产物
ISP 关注 缩小接口,而不是 制造更多接口。
DI 文化把 ISP 扭曲为:
- “每个类必须有一个接口”。
- “每个依赖都应通过抽象注入”。
这完全倒置。为单一实现创建毫无意义的包装接口是 违反 ISP,而不是遵循它。
4.3 核心问题
- DIP 与 ISP 是 设计原则。
- DI 是 运行时框架机制。
它们解决的是不同的问题。把它们混为一谈导致业界产生以下误解:
- “使用 DI 就等于在应用 SOLID!”
- “把所有东西都标注上,就等于在做 OO!”
实际上:
- DIP 需要 干净、最小且有意义的抽象。
- ISP 需要 聚焦角色的接口。
- DI 产生 大量无意义的接口和容器驱动的 wiring——这正是两者的反面。
结果是:一个庞大的生态系统把 DI 当作道德正确的象征,却在悄悄侵蚀封装性、可读性和演进式设计。
5. DI 主动阻碍演进式架构
企业软件并不会在上线后立即死亡——它们往往要活 10–25 年,期间业务规则和技术栈都会变化。你需要的是演进式的变更:小幅更新、持续清理、最小惊喜。
富领域对象通过在普通 Java 中封装逻辑、最小化框架干预来实现这一点。框架升级只会触及少数边界代码,核心保持不变。
DI 正好相反,它孕育的是革命式软件,每 5–7 年就需要一次彻底重写。行为被散落在贫血的服务类中,不变量丢失,逻辑被配置、qualifier、代理和 AOP 去上下文化。每个类都被织入全局图中,微小的改动会波及整个系统。
框架成为了骨架,主导了领域本身,升级比如 Spring Boot 2 → 3 就会变成多人工年噩梦——伴随七位数的咨询费用——因为真正的程序代码生活在代理和 YAML 中,而不是可读的代码里。
没有容器时,差距天壤之别:
- 构建更快,因为没有类加载器噪声或容器启动。
- 代码更易阅读,因为逻辑是显式的而非魔法。
- 应用更透明——就像把高保真音箱上的毯子掀开一样。
逻辑集中在普通 Java 对象中,而不是分散在服务和配置文件里,使得演进变成增量而非灾难性。
6. 我的个人受控实验
我用了多年 Spring,也用了 EJB2。这里有个我发现的肮脏秘密:
EJB2 已经比现代的 Spring Boot 更简单。
EJB2 中每次调用都伴随 RMI 的开销和冗余,在 Spring 中被扩展为容器维护的负担。事实上,EJB2 中已经存在的生命周期管理遗留被 DI 放大——容器管理一切,从而使得应用不必要地 …