☰
Java 设计模式之依赖注入(Dependency Injection):在 java-design-patterns 中实现解耦与可测试性
2026/10/1 10:39:35 网站建设 项目流程
  • 示例工程
  • 教程

【免费下载链接】java-design-patterns

Design patterns implemented in Java

项目地址:https://gitcode.com/GitHub_Trending/ja/java-design-patterns
点击查看免费下载

依赖注入(Dependency Injection,DI)是 Java 世界中用于解耦对象依赖创建与使用的最重要设计模式之一,本指南以当前仓库 java-design-patterns 中 dependency-injection 模块的完整源码与测试为实证基础,系统讲解该模式的核心思想、构造器注入/Setter 注入/框架注入三种落地方式,以及它在 Spring、Jakarta EE、Google Guice 等框架中的真实应用。读完本文,你将能够识别违反依赖倒置的坏代码,并亲手把手动装配、Guice 模块绑定等手段应用到自己的项目中。

模式概览:别名与意图

Dependency Injection 模式在业界还有两个常见别名:

  • Inversion of Control(IoC,控制反转)
  • Dependency Inversion(依赖倒置)

模式的核心意图是:将“对象所需依赖的创建过程”与“依赖的使用过程”解耦,从而使代码更加灵活、更加易于测试。仓库中 App.java 的类注释进一步点明了 IoC 的两条具体规则:

  • 高层模块不应依赖低层模块,两者都应依赖抽象;
  • 抽象不应依赖细节,细节应依赖抽象。

也就是说,依赖注入的本质是让对象不再自己“造”依赖,而是从外部“接收”依赖。

现实世界的类比:餐厅与供应商

设想一家高档餐厅,主厨做菜需要各种各样的食材。他不必为每种食材亲自跑去找各自的制造商,而是依赖一位可靠的供应商,由供应商每天从各个制造商那里采购新鲜食材送上门。这样主厨就能专注于烹饪,无需操心采购。

在 Dependency Injection 模式中,供应商扮演"注入器(Injector)"的角色,把所需依赖(食材)提供给"对象"——主厨。主厨可以使用这些食材,却不必知道它们的来源,依赖的获取与使用被清晰分离。这种思路同时提升了厨房(以及软件系统)的效率、灵活性与可维护性。

用一句最简单的话概括:

依赖注入把“依赖的获取”与“使用者的自身行为”分离开来。

维基百科对它的定义是:在软件工程中,依赖注入是一种对象接收其所需的其他对象(称为依赖)的技术。

模式时序图

下图展示了依赖注入的核心交互流程——客户端(Wizard)通过注入器获得抽象依赖(Tobacco)并调用其方法:

程序示例:三种注入方式的渐进实现

仓库中的示例围绕一位老巫师展开:他时不时想填满烟斗抽上一斗烟,但他不希望被锁定在单一烟草品牌上,而是想随时互换不同品牌。下面我们沿着源码逐步实现。

1. 抽象与具体实现:Tobacco 体系

首先定义Tobacco抽象类与具体品牌类。注意当前仓库版本中的Tobacco使用了 Lombok 的@Slf4j注解(Tobacco.java):

@Slf4j public abstract class Tobacco { public void smoke(Wizard wizard) { LOGGER.info("{} smoking {}", wizard.getClass().getSimpleName(), this.getClass().getSimpleName()); } } public class SecondBreakfastTobacco extends Tobacco { } public class RivendellTobacco extends Tobacco { } public class OldTobyTobacco extends Tobacco { }

三个具体类 SecondBreakfastTobacco、RivendellTobacco、OldTobyTobacco 均继承自Tobacco,它们共享smoke(Wizard)的日志行为,同时又是可互换的具体实现。

2. Wizard 接口与违反 IoC 的反例:SimpleWizard

所有巫师都实现统一的 Wizard 接口:

public interface Wizard { void smoke(); }

接着是SimpleWizard——它是一段故意违反控制反转原则的反面教材(SimpleWizard.java):

public class SimpleWizard implements Wizard { private final OldTobyTobacco tobacco = new OldTobyTobacco(); public void smoke() { tobacco.smoke(this); } }

可以看到,SimpleWizard在字段初始化时直接new了一个具体实现OldTobyTobacco:它依赖的是“细节”而非“抽象”,既无法在测试中替换为 Mock/Stub,也无法在运行时切换品牌。这正是 DI 模式要解决的问题。

3. 构造器注入:AdvancedWizard

AdvancedWizard通过构造器接收依赖,字段为final,一旦注入即不可变(AdvancedWizard.java):

public class AdvancedWizard implements Wizard { private final Tobacco tobacco; public AdvancedWizard(Tobacco tobacco) { this.tobacco = tobacco; } @Override public void smoke() { tobacco.smoke(this); } }

它只依赖抽象Tobacco,具体品牌由调用方在构造时决定——创建与使用被彻底分离,这也让单元测试可以自由注入任意品牌。

4. Setter 注入:AdvancedSorceress

AdvancedSorceress展示的是Setter 注入变体,借助 Lombok 的@Setter注解生成setTobacco(...)方法(AdvancedSorceress.java):

@Setter public class AdvancedSorceress implements Wizard { private Tobacco tobacco; @Override public void smoke() { tobacco.smoke(this); } }

与构造器注入相比,Setter 注入允许在对象创建之后再更换依赖,适合依赖需要在运行时动态切换的场景;代价是字段无法声明为final,失去了不可变性保证。

5. 框架注入:Guice 与 TobaccoModule

第四种方式把模式推进到框架层面:使用Google Guice完成自动装配。首先定义一个 Guice 模块,将抽象绑定到具体实现(TobaccoModule.java):

public class TobaccoModule extends AbstractModule { @Override protected void configure() { bind(Tobacco.class).to(RivendellTobacco.class); } }

bind(Tobacco.class).to(RivendellTobacco.class)意味着:只要容器需要Tobacco,就自动提供RivendellTobacco实例。接下来GuiceWizard通过构造器上的@Inject注解声明注入点(GuiceWizard.java):

public class GuiceWizard implements Wizard { private final Tobacco tobacco; @Inject public GuiceWizard(Tobacco tobacco) { this.tobacco = tobacco; } @Override public void smoke() { tobacco.smoke(this); } }

6. 运行入口 App 与程序输出

最终,App.java 的main方法一次性演示了上述四种风格:

public static void main(String[] args) { var simpleWizard = new SimpleWizard(); simpleWizard.smoke(); var advancedWizard = new AdvancedWizard(new SecondBreakfastTobacco()); advancedWizard.smoke(); var advancedSorceress = new AdvancedSorceress(); advancedSorceress.setTobacco(new SecondBreakfastTobacco()); advancedSorceress.smoke(); var injector = Guice.createInjector(new TobaccoModule()); var guiceWizard = injector.getInstance(GuiceWizard.class); guiceWizard.smoke(); }

程序输出(时间戳因运行时刻而异):

11:54:05.205 [main] INFO com.iluwatar.dependency.injection.Tobacco -- SimpleWizard smoking OldTobyTobacco 11:54:05.207 [main] INFO com.iluwatar.dependency.injection.Tobacco -- AdvancedWizard smoking SecondBreakfastTobacco 11:54:05.207 [main] INFO com.iluwatar.dependency.injection.Tobacco -- AdvancedSorceress smoking SecondBreakfastTobacco 11:54:05.308 [main] INFO com.iluwatar.dependency.injection.Tobacco -- GuiceWizard smoking RivendellTobacco

注意最后一行:SimpleWizard因硬编码只抽OldTobyTobacco,而GuiceWizard抽的是RivendellTobacco——这正体现了“由容器决定依赖,而不是由使用者决定”的控制反转。

如何在仓库中运行与验证

dependency-injection模块是一个独立 Maven 模块,其 pom.xml 声明了以下依赖与配置:

  • 日志:slf4j-api+logback-classic;
  • 测试:junit-jupiter-engine(scope 为 test);
  • 依赖注入框架:com.google.inject:guice;
  • 打包插件maven-assembly-plugin将主类配置为com.iluwatar.dependency.injection.App。

在仓库根目录(java-design-patterns)下,可使用随仓库提供的 Maven Wrapper 运行:

# 运行该模块的全部单元测试 ./mvnw -pl dependency-injection -am test # 打包可执行 jar(assembly 插件会生成含主类的可运行包) ./mvnw -pl dependency-injection -am package

若直接运行主类,可执行java -cp ... com.iluwatar.dependency.injection.App(classpath 需包含模块编译产物与 guice、slf4j 等依赖,实践中最简单的方式是使用上面的 assembly 打包产物)。

测试如何验证注入行为

仓库为每种注入方式都配备了单元测试,它们共同印证了“依赖可注入、可替换”这一核心主张。

AdvancedWizardTest.java 验证构造器注入:将三种烟草依次通过构造器传入AdvancedWizard,断言每次日志都精确匹配AdvancedWizard smoking <品牌名>,并且没有额外输出:

List<Tobacco> tobaccos = List.of(new OldTobyTobacco(), new RivendellTobacco(), new SecondBreakfastTobacco()); tobaccos.forEach(tobacco -> { final AdvancedWizard advancedWizard = new AdvancedWizard(tobacco); advancedWizard.smoke(); String lastMessage = appender.getLastMessage(); assertEquals("AdvancedWizard smoking " + tobacco.getClass().getSimpleName(), lastMessage); }); assertEquals(tobaccos.size(), appender.getLogSize());

GuiceWizardTest.java 则更进一步,包含两个用例:

  • testSmokeEveryThingThroughConstructor:直接向构造器传参验证@Inject构造器本身的行为;
  • testSmokeEveryThingThroughInjectionFramework:在测试中动态构建匿名AbstractModule,把Tobacco.class分别绑定到三种品牌,再由Guice.createInjector(...).getInstance(GuiceWizard.class)获取实例并验证日志。该用例证明:只需改动绑定配置即可更换依赖实现,业务代码GuiceWizard完全无需改动。

这些测试依赖utils.InMemoryAppender(InMemoryAppender.java)在内存中捕获日志,从而断言行为而无需依赖真实控制台输出——这本身就是“依赖抽象、可被替换”思想在测试基建上的体现。

何时使用依赖注入模式

根据仓库文档,以下场景适合采用 DI:

  • 需要降低类与类之间的耦合、提升应用模块化程度时;
  • 对象创建过程复杂,或应当与类的使用过程分离时;
  • 应用需要更轻松的单元测试,允许依赖以 Mock 或 Stub 形式注入时;
  • 在使用管理对象生命周期与依赖的框架(如 Spring、Jakarta EE,即原 Java EE)时。

Java 中的真实应用

  • Spring、Jakarta EE 与 Google Guice等框架大量使用依赖注入来管理组件生命周期与依赖关系;
  • 需要灵活架构、组件易于互换的桌面应用与 Web 应用,广泛受益于 DI 带来的可配置性。

优点与权衡

优点:

  • 提升模块化程度与关注点分离;
  • 简化单元测试——依赖可轻松被 Mock 替换;
  • 通过促进松耦合提升灵活性与可维护性。

权衡与代价:

  • 可能使配置复杂化,尤其是在大型项目中;
  • 对不熟悉 DI 概念或框架的开发者存在学习曲线;
  • 需要谨慎管理对象的生命周期与作用域(scope),否则容易引发作用域泄漏或内存问题。

相关设计模式

在 java-design-patterns 仓库中,与 DI 密切相关的模式包括:

  • 工厂方法(Factory Method) 与 抽象工厂(Abstract Factory):用于创建将被 DI 机制注入的实例,两者常配合使用;
  • 服务定位器(Service Locator):DI 的一种替代方案,用于定位服务或组件,但对“查找过程”的解耦效果不如 DI 彻底;
  • 单例(Singleton):常与 DI 结合使用,为整个应用提供服务的单一实例。

参考资料

本文内容以仓库文档与源码为准,以下为文档中列出的延伸阅读书目(供深入学习参考):

  • Clean Code: A Handbook of Agile Software Craftsmanship(Robert C. Martin)
  • Dependency Injection: Design Patterns Using Spring and Guice(Dhanji R. Prasanna)
  • Dependency Injection Principles, Practices, and Patterns(Steven van Deuren / Mark Seemann)
  • Google Guice: Agile Lightweight Dependency Injection Framework
  • Java 9 Dependency Injection: Write Loosely Coupled Code with Spring 5 and Guice
  • Java Design Pattern Essentials
  • Pro Java EE Spring Patterns: Best Practices and Design Strategies
  • Spring in Action(Craig Walls)
  • 示例工程
  • 教程

【免费下载链接】java-design-patterns

Design patterns implemented in Java

项目地址:https://gitcode.com/GitHub_Trending/ja/java-design-patterns
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询