掌握框架——探索依赖注入模式
已发表: 2022-03-11控制反转 (IoC) 的传统观点似乎在两种不同的方法之间划清界限:服务定位器和依赖注入 (DI) 模式。
几乎我知道的每个项目都包含一个 DI 框架。 人们之所以被它们吸引,是因为它们促进了客户端与其依赖项之间的松散耦合(通常通过构造函数注入),并且使用最少或没有样板代码。 虽然这对于快速开发非常有用,但有些人发现它会使代码难以跟踪和调试。 “幕后魔术”通常是通过反思来实现的,这会带来一整套新的问题。
在本文中,我们将探索一种非常适合 Java 8+ 和 Kotlin 代码库的替代模式。 它保留了 DI 框架的大部分优点,同时与服务定位器一样简单,无需外部工具。
动机
- 避免外部依赖
- 避免反射
- 促进构造函数注入
- 最小化运行时行为
一个例子
在下面的示例中,我们将对 TV 实现进行建模,其中可以使用不同的源来获取内容。 我们需要构建一个可以接收来自各种来源(例如,地面、电缆、卫星等)的信号的设备。 我们将构建以下类层次结构:
现在让我们从传统的 DI 实现开始,其中 Spring 等框架为我们连接一切:
public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } } public interface TvSource { void tuneChannel(int channel); } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } }
我们注意到一些事情:
- TV 类表示对 TvSource 的依赖。 外部框架将看到这一点并注入具体实现(地面或有线)的实例。
- 构造函数注入模式允许轻松测试,因为您可以使用替代实现轻松构建 TV 实例。
我们有了一个良好的开端,但我们意识到为此引入一个 DI 框架可能有点矫枉过正。 一些开发人员报告了调试构造问题的问题(长堆栈跟踪、无法跟踪的依赖项)。 我们的客户还表示,制造时间比预期的要长一些,我们的分析器显示反射调用速度放缓。
另一种方法是应用服务定位器模式。 它很简单,不使用反射,对于我们的小代码库来说可能已经足够了。 另一种选择是不理会这些类,并在它们周围编写依赖位置代码。
在评估了许多备选方案后,我们选择将其实现为提供者接口的层次结构。 每个依赖项都有一个关联的提供者,该提供者将单独负责定位类的依赖项并构造注入的实例。 我们还将使提供程序成为易于使用的内部接口。 我们将其称为 Mixin 注入,因为每个提供程序都与其他提供程序混合以定位其依赖项。
详细信息和基本原理中详细说明了我选择这种结构的原因,但这里是简短版本:
- 它隔离了依赖位置行为。
- 扩展接口不属于钻石问题。
- 接口具有默认实现。
- 缺少依赖项会阻止编译(加分!)。
下图显示了依赖项和提供程序如何交互,实现如下图所示。 我们还添加了一个 main 方法来演示我们如何组合我们的依赖项并构造一个 TV 对象。 此示例的更长版本也可以在此 GitHub 上找到。
public interface TvSource { void tuneChannel(int channel); interface Provider { TvSource tvSource(); } } public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } interface Provider extends TvSource.Provider { default TV tv() { return new TV(tvSource()); } } } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Terrestrial(); } } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Cable(); } } } // Here compose the code above to instantiate a TV with a Cable TvSource public class Main { public static void main(String[] args) { new MainContext().tv().turnOn(); } static class MainContext implements TV.Provider, Cable.Provider { } }
关于这个例子的几点说明:
- TV 类依赖于 TvSource,但它不知道任何实现。
- TV.Provider 扩展了 TvSource.Provider,因为它需要 tvSource() 方法来构建 TvSource,即使它没有在那里实现,它也可以使用它。
- 电视可以互换使用地面和有线信号源。
- Terrestrial.Provider 和 Cable.Provider 接口提供了具体的 TvSource 实现。
- main 方法有一个 TV.Provider 的具体实现 MainContext,用于获取 TV 实例。
- 该程序需要在编译时实现 TvSource.Provider 来实例化电视,因此我们以 Cable.Provider 为例。
细节和理由
我们已经看到了这种行为模式及其背后的一些原因。 你现在可能不相信你应该使用它,你是对的; 它不完全是灵丹妙药。 就个人而言,我认为它在大多数方面都优于服务定位器模式。 但是,与 DI 框架相比,必须评估优势是否超过添加样板代码的开销。
提供者扩展其他提供者来定位他们的依赖
当一个提供者扩展另一个提供者时,依赖关系被绑定在一起。 这为防止创建无效上下文的静态验证提供了基本基础。

服务定位器模式的主要痛点之一是您需要调用通用的GetService<T>()
方法,该方法将以某种方式解决您的依赖关系。 在编译时,您无法保证依赖项会在定位器中注册,并且您的程序可能会在运行时失败。
DI 模式也没有解决这个问题。 依赖关系解析通常是通过外部工具的反射完成的,该工具大部分对用户隐藏,如果不满足依赖关系,该工具在运行时也会失败。 诸如 IntelliJ 的 CDI 之类的工具(仅在付费版本中可用)提供了某种程度的静态验证,但似乎只有带有注释预处理器的 Dagger 通过设计解决了这个问题。
类维护 DI 模式的典型构造函数注入
这不是必需的,但开发人员社区绝对需要。 一方面,你可以只看构造函数,立即看到类的依赖关系。 另一方面,它实现了许多人坚持的那种单元测试,即通过模拟其依赖项来构建被测主题。
这并不是说不支持其他模式。 事实上,人们甚至可能会发现 Mixin 注入简化了构建复杂依赖图以进行测试,因为您只需要实现一个扩展主题提供者的上下文类。 上面的MainContext
是一个完美的例子,所有接口都有默认实现,所以它可以有一个空的实现。 替换依赖项只需要覆盖其提供者方法。
让我们看看下面的电视类测试。 它需要实例化一个电视,但不是调用类构造函数,而是使用 TV.Provider 接口。 TvSource.Provider 没有默认实现,所以我们需要自己编写。
public class TVTest { @Test public void testWithProvider() { TvSource source = Mockito.mock(TvSource.class); TV.Provider provider = () -> source; // lambdas FTW provider.tv().turnOn(); Mockito.verify(source, times(1)).tuneChannel(42); } }
现在让我们向 TV 类添加另一个依赖项。 CathodeRayTube 依赖项具有使图像出现在电视屏幕上的魔力。 它与 TV 实现分离,因为我们将来可能希望切换到 LCD 或 LED。
public class TV { public TV(TvSource source, CathodeRayTube cathodeRayTube) { ... } public interface Provider extends TvSource.Provider, CathodeRayTube.Provider { default TV tv() { return new TV(tvSource(), cathodeRayTube()); } } } public class CathodeRayTube { public void beam() { System.out.println("Beaming electrons to produce the TV image"); } public interface Provider { default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }
如果您这样做,您会注意到我们刚刚编写的测试仍然可以按预期编译和通过。 我们为 TV 添加了一个新的依赖项,但我们也提供了一个默认实现。 这意味着如果我们只想使用真正的实现,我们不必模拟它,我们的测试可以创建具有我们想要的任何级别的模拟粒度的复杂对象。
当您想要模拟复杂类层次结构中的特定内容(例如,只有数据库访问层)时,这会派上用场。 该模式可以轻松设置有时比单独测试更喜欢的那种社交测试。
无论您的偏好如何,您都可以确信您可以转向任何形式的测试,以更好地满足您在每种情况下的需求。
避免外部依赖
如您所见,没有对外部组件的引用或提及。 这对于许多具有规模甚至安全限制的项目来说是关键。 它还有助于互操作性,因为框架不必致力于特定的 DI 框架。 在 Java 中,已经做出了一些努力,例如 JSR-330 Dependency Injection for Java Standard,可以缓解兼容性问题。
避免反射
服务定位器实现通常不依赖反射,但 DI 实现会依赖反射(Dagger 2 除外)。 这具有减慢应用程序启动速度的主要缺点,因为框架需要扫描您的模块,解析依赖关系图,反射性地构造您的对象等。
Mixin 注入要求您编写代码来实例化您的服务,类似于服务定位器模式中的注册步骤。 这个小小的额外工作完全消除了反射调用,使您的代码更快更直接。
最近引起我注意并从避免反射中受益的两个项目是 Graal 的 Substrate VM 和 Kotlin/Native。 两者都编译为本机字节码,这需要编译器提前知道您将进行的任何反射调用。 对于 Graal,它在 JSON 文件中指定,难以编写,无法静态检查,无法使用您喜欢的工具轻松重构。 首先使用 Mixin 注入来避免反射是获得原生编译好处的好方法。
最小化运行时行为
通过实现和扩展所需的接口,您可以一次构建一个依赖关系图。 每个提供者都位于具体实现旁边,这为您的程序带来了顺序和逻辑。 如果您以前使用过 Mixin 模式或 Cake 模式,那么这种分层方式会很熟悉。
在这一点上,可能值得讨论 MainContext 类。 它是依赖图的根,知道大局。 此类包括所有提供程序接口,并且是启用静态检查的关键。 如果我们回到示例并从其实现列表中删除 Cable.Provider,我们将清楚地看到这一点:
static class MainContext implements TV.Provider { } // ^^^ // MainContext is not abstract and does not override abstract method tvSource() in TvSource.Provider
这里发生的是应用程序没有指定要使用的具体 TvSource,编译器捕获了错误。 使用服务定位器和基于反射的 DI,这个错误可能会被忽视,直到程序在运行时崩溃——即使所有的单元测试都通过了! 我相信我们展示的这些和其他好处超过了编写使模式工作所需的样板文件的缺点。
捕获循环依赖
让我们回到 CathodeRayTube 示例并添加一个循环依赖项。 假设我们希望它被注入一个 TV 实例,所以我们扩展 TV.Provider:
public class CathodeRayTube { public interface Provider extends TV.Provider { // ^^^ // cyclic inheritance involving CathodeRayTube.Provider default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }
编译器不允许循环继承,我们无法定义这种关系。 当这种情况发生时,大多数框架在运行时都会失败,开发人员倾向于解决它只是为了让程序运行。 尽管这种反模式可以在现实世界中找到,但它通常是糟糕设计的标志。 当代码无法编译时,我们应该鼓励我们在更改为时已晚之前寻找更好的解决方案。
保持对象构造的简单性
支持 SL 而不是 DI 的论据之一是它简单且易于调试。 从示例中可以清楚地看出,实例化依赖项将只是提供者方法调用的链。 追溯依赖的来源就像步入方法调用并查看最终结果一样简单。 调试比这两种方法都简单,因为您可以直接从提供者那里准确导航依赖项的实例化位置。
使用寿命
细心的读者可能已经注意到,这个实现并没有解决服务生命周期问题。 所有对提供者方法的调用都会实例化新对象,这类似于 Spring 的 Prototype 范围。
这个和其他的考虑稍微超出了本文的范围,因为我只是想展示模式的本质而不分散细节。 但是,在产品中充分使用和实施需要考虑到具有终身支持的完整解决方案。
结论
无论您习惯于依赖注入框架还是编写自己的服务定位器,您都可能想探索这种替代方案。 考虑使用我们刚刚看到的 mixin 模式,看看您是否可以使您的代码更安全、更易于推理。