掌握框架–探索依赖注入模式

本文概述

  • 动机
  • 一个例子
  • 细节和理由
  • 服务寿命
  • 总结
关于控制反转(IoC)的传统观点似乎在两种不同的方法之间划了一条界限:服务定位器和依赖注入(DI)模式。
【掌握框架–探索依赖注入模式】我所知道的几乎每个项目都包含一个DI框架。人们之所以喜欢它们, 是因为它们以最少或没有样板代码的方式促进了客户端及其依赖关系之间的松散耦合(通常通过构造函数注入)。尽管这对于快速开发非常有用, 但有些人发现它会使代码难以跟踪和调试。 “ 幕后魔术” 通常是通过反思来实现的, 这会带来一系列新问题。
在本文中, 我们将探讨一种非常适合Java 8+和Kotlin代码库的替代模式。它保留了DI框架的大多数优点, 同时又像服务定位器一样简单, 而无需外部工具。
动机
  • 避免外部依赖
  • 避免反射
  • 促进构造函数注入
  • 最小化运行时行为
一个例子 在下面的示例中, 我们将为电视实现建模, 其中可以使用不同的源来获取内容。我们需要构建一种可以从各种来源(例如, 地面, 电缆, 卫星??等)接收信号的设备。我们将构建以下类层次结构:
掌握框架–探索依赖注入模式

文章图片
现在让我们从一个传统的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); } }

我们注意到一些事情:
  • 电视类表示对TvSource的依赖。外部框架将看到此情况, 并注入一个具体实现的实例(地面或电缆)。
  • 构造函数注入模式允许轻松测试, 因为你可以使用替代实现轻松构建电视实例。
我们有一个良好的开端, 但是我们意识到为此引入一个DI框架可能有点过头了。一些开发人员报告了调试构造问题(长堆栈跟踪, 不可跟踪的依赖项)的问题。我们的客户还表示, 制造时间比预期的要长一些, 而我们的探查器显示反射呼叫的速度有所降低。
一种替代方法是应用服务定位器模式。它简单明了, 不使用反射, 对于我们小的代码库可能就足够了。另一种选择是不让类留下, 并在它们周围编写依赖项位置代码。
在评估了许多替代方案之后, 我们选择将其实现为提供程序接口的层次结构。每个依赖项都有一个关联的提供程序, 该提供程序将全权负责查找类的依赖项并构造注入的实例。我们还将使提供程序成为易于使用的内部接口。我们将其称为Mixin Injection, 因为每个提供程序都与其他提供程序混合在一起以查找其依赖项。
我为何决定采用此结构的详细信息在” 详细信息和基本原理” 中进行了详细说明, 但这是简短的版本:
  • 它隔离了依赖项定位行为。
  • 扩展接口不会陷入钻石问题。
  • 接口具有默认实现。
  • 缺少依赖项会阻止编译(要点!)。
下图显示了依赖项和提供程序之间的交互方式, 下面说明了实现。我们还添加了一个主要方法来演示如何构成依赖关系并构造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 { } }

有关此示例的一些注意事项:
  • 电视类取决于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 Injection简化了用于测试的复杂依赖图的构造, 因为你只需要实现扩展主题提供者的上下文类即可。上面的MainContext是一个完美的示例, 其中所有接口都有默认实现, 因此可以有一个空实现。替换依赖项仅需要覆盖其提供者方法。
让我们看一下电视课的以下测试。它需要实例化TV, 但不是使用类构造函数, 而是使用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); } }

现在让我们向电视课添加另一个依赖项。 CathodeRayTube依赖项可以使魔术在屏幕上显示图像。它与电视实现方式脱钩, 因为将来我们可能要切换到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(); } } }

如果这样做, 你将注意到我们刚刚编写的测试仍可以按预期编译并通过。我们向电视添加了新的依赖关系, 但还提供了默认实现。这意味着如果我们只想使用真实的实现, 就不必模拟它, 并且我们的测试可以创建具有所需任意模拟粒度级别的复杂对象。
当你要模拟复杂的类层次结构中特定的内容(例如, 仅数据库访问层)时, 这非常方便。这种模式可以轻松设置有时比单独测试更受欢迎的社交测试。
无论你的偏好如何, 你都可以确信可以使用任何一种形式的测试, 这些测试在每种情况下都更适合你的需求。
避免外部依赖
如你所见, 没有对外部组件的引用或提及。对于许多具有规模甚至安全性约束的项目而言, 这是关键。它还可以帮助实现互操作性, 因为框架不必提交特定的DI框架。在Java中, 已经进行了一些努力, 例如缓解Java兼容性问题的JSR-330 Dependency Injection for Java Standard。
避免反思
服务定位器的实现通常不依赖于反射, 而DI的实现则依赖于反射(Dagger 2除外)。这具有使应用程序启动变慢的主要缺点, 因为框架需要扫描你的模块, 解析依赖关系图, 以反射方式构造你的对象等。
Mixin Injection需要你编写代码以实例化你的服务, 类似于服务定位器模式中的注册步骤。这项额外的工作可以完全消除反射调用, 从而使你的代码更快, 更直接。
最近吸引了我注意并从避免反射中受益的两个项目是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.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模式, 看看是否可以使你的代码更安全, 更容易推理。
相关:JS最佳实践:使用TypeScript和依赖注入构建Discord Bot

    推荐阅读