依赖注入:摧毁面向对象设计并最终获胜的反模式

发布: (2025年12月8日 GMT+8 16:47)
10 min read
原文: Dev.to

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 核心问题

  • DIPISP设计原则
  • 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 放大——容器管理一切,从而使得应用不必要地 …

Back to Blog

相关文章

阅读更多 »

适配器模式:真实案例解析

介绍 您正在将第三方支付网关集成到您的应用程序中。一切看起来都很直接,直到您意识到他们的 SDK 使用了一个 comple…