作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
马丁·科尔的头像

马丁科尔

Martin是一位全能的全栈开发人员,在包括Java在内的各种技术方面拥有多年的经验, C#, Python和其他.

专业知识

以前在

微软
分享

传统的观点 控制反转(国际奥委会)似乎在两种不同的方法之间划清了界限:服务定位器和依赖注入(DI)模式.

实际上,我知道的每个项目都包含一个DI框架. 人们被它们所吸引,因为它们促进了客户机与其依赖项之间的松耦合(通常通过构造函数注入),并且很少或没有样板代码. 虽然这对快速发展很有好处, 有些人发现它会使代码难以跟踪和调试. “幕后的魔力”通常是通过反思来实现的, 这会带来一系列的新问题.

在本文中, 我们将探索另一种非常适合Java 8+和Kotlin代码库的模式. 它保留了DI框架的大部分优点,同时又像服务定位器一样简单, 不需要外部工具.

动机

  • 避免外部依赖
  • 避免反射
  • 促进构造函数注入
  • 最小化运行时行为

一个例子

在下面的例子中, 我们将为电视实现建模, 哪里可以使用不同的来源来获取内容. 我们需要制造一种能接收各种信号源信号的装置.g.地面、有线、卫星等.). 我们将构建如下的类层次结构:

实现任意信号源的电视设备的类层次结构

现在让我们从传统的DI实现开始, 一个是像Spring这样的框架为我们连接一切;

公共类TV {
    私有最终TvSource源;

    公共电视(TvSource来源){
        这.Source =源;
    }

    public void turnOn() {
        系统.出.println(“打开电视”);
        这.source.tuneChannel (42);
    }
}

公共接口TvSource {
    void tuneChannel(int channel);
}

公共类Terrestrial实现TvSource {
    @Override
    public void tuneChannel(int channel) {
        系统.出.printf(“调整天线频率到通道%d\n”,通道);
    }
}

公共类电缆实现TvSource {
    @Override
    public void tuneChannel(int channel) {
        系统.出.printf("将数字信号转换到通道%d\n",通道);
    }
}

我们注意到一些事情:

  • TV类表示对TvSource的依赖. 外部框架将看到这一点,并注入具体实现的实例(地面或电缆)。.
  • 构造函数注入模式允许简单的测试,因为您可以轻松地使用替代实现构建TV实例.

我们开了个好头, 但我们意识到,为此引入一个DI框架可能有点过头了. 一些开发人员报告了调试构造问题(长堆栈跟踪)的问题, 难以捉摸的依赖关系). 我们的客户也表示,制造时间比预期的要长一些, 我们的侧写器显示反射调用变慢了.

另一种选择是应用服务定位器模式. 它很简单,不使用反射,对于我们的小代码库来说可能已经足够了. 另一种选择是不去管这些类,而是围绕它们编写依赖位置代码.

在评估了许多备选方案之后,我们选择将其实现为提供者接口的层次结构. 每个依赖项都有一个相关的提供商,它将单独负责定位类的依赖项并构造注入实例. 为了便于使用,我们还将使提供者成为一个内部接口. 我们将其称为Mixin注入,因为每个提供程序都与其他提供程序混合在一起,以定位其依赖项.

我为什么选择这种结构的细节在细节和基本原理中详细阐述, 但这里是简短的版本:

  • 它隔离了依赖位置行为.
  • 扩展接口不属于菱形问题.
  • 接口有默认实现.
  • 缺少依赖项会妨碍编译(加分项)!).

下图显示了依赖项和提供程序是如何交互的, 其实现如下所示. 我们还添加了一个main方法来演示如何组合依赖项和构造TV对象. 这个例子的更长的版本也可以在这里找到 GitHub.

提供者和依赖项之间的交互

公共接口TvSource {
    void tuneChannel(int channel);

    接口提供者{
        TvSource TvSource ();
    }
}

公共类TV {
    私有最终TvSource源;

    公共电视(TvSource来源){
        这.Source =源;
    }

    public void turnOn() {
        系统.出.println(“打开电视”);
        这.source.tuneChannel (42);
    }

    接口提供程序扩展TvSource.提供者{
        default TV TV () {
            返回新电视(tvSource());
        }
    }
}

公共类Terrestrial实现TvSource {
    @Override
    public void tuneChannel(int channel) {
        系统.出.printf(“调整天线频率到通道%d\n”,通道);
    }

    接口提供程序扩展TvSource.提供者{
        @Override
        TvSource () {
            return new Terrestrial();
        }
    }
}

公共类电缆实现TvSource {
    @Override
    public void tuneChannel(int channel) {
        系统.出.printf("将数字信号转换到通道%d\n",通道);
    }

    接口提供程序扩展TvSource.提供者{
        @Override
        TvSource () {
            return new Cable();
        }
    }
}

//在这里编写上面的代码,用Cable TvSource实例化一个电视
公共类Main {
    public static void main(String[] args) {
        新的MainContext ().tv().接通开启();
    }

    静态类MainContext实现TV.提供商、有线.提供者{}
}

关于这个例子的一些注意事项:

  • TV类依赖于TvSource,但它不知道任何实现.
  • 电视.提供者扩展了TvSource.提供程序,因为它需要tvSource()方法来构建tvSource, 它可以使用它,即使它没有在那里实现.
  • 地面源和有线源可以被电视交替使用.
  • 地面.供应商和电缆.提供者接口提供具体的TvSource实现.
  • 主要方法有MainContext of TV的具体实现.用于获取电视实例的提供程序.
  • 该程序需要一个TvSource.提供程序实现在编译时实例化电视,因此我们包括Cable.提供程序为例.

详情及原理

我们已经看到了这种模式的运作,以及它背后的一些原因. 到现在为止,您可能还不确定是否应该使用它, 和 you would be right; it’s not exactly a silver bullet. 就我个人而言,我认为它在大多数方面都优于服务定位器模式. 然而, 与DI框架相比, 我们必须评估这些优点是否超过了添加样板代码的开销.

提供商扩展其他提供商来定位它们的依赖关系

当一个提供程序扩展另一个提供程序时,依赖项被绑定在一起. 这为防止创建无效上下文的静态验证提供了基本基础.

服务定位器模式的主要痛点之一是需要调用泛型 GetService() 方法,该方法将以某种方式解决依赖项. 在编译时, 您不能保证将在定位器中注册依赖项, 你的程序可能会在运行时失败.

DI模式也没有解决这个问题. 依赖解析通常是通过一个外部工具的反射来完成的,这个工具对用户来说是隐藏的, 如果不满足依赖关系,在运行时也会失败. 工具包括 IntelliJ的CDI (仅在付费版本中可用)提供一定程度的静态验证,但只有 匕首 它的注释预处理器似乎在设计上解决了这个问题.

类维护DI模式的典型构造函数注入

这不是必需的,但肯定是开发人员社区所希望的. 一方面,您可以查看构造函数,并立即看到类的依赖项. 另一方面,它使 单元测试的类型 很多人都坚持, 哪一种方法是用它的依赖性的模拟来构造被测对象.

这并不是说不支持其他模式. 事实上, 你甚至会发现Mixin Injection简化了构建复杂的依赖图来进行测试,因为你只需要实现一个扩展主题提供者的上下文类. 的 MainContext 上面是一个完美的例子,其中所有接口都有默认实现, 所以它可以有一个空的实现. 替换依赖项只需要重写其提供程序方法.

让我们看看下面电视课的测试. 它需要实例化TV,但不是调用类构造函数,而是使用TV.提供程序接口. 的TvSource.提供者没有默认实现,所以我们需要自己编写它.

公共类TVTest {
    @Test
    公共无效testWith提供者() {
        TvSource source = 5.模拟(TvSource.类);
        TV.提供者 提供者 = () -> source; // lambdas FTW
        提供者.tv().接通开启();
        5.验证(源,乘以(1)).tuneChannel (42);
    }
}

现在让我们向TV类添加另一个依赖项. cathderaytube依赖项的神奇作用是使图像出现在电视屏幕上. 它与电视的实现是分离的,因为我们将来可能想要切换到LCD或LED.

公共类TV {
    公共电视(TvSource源,阴极deraytube) { ... }

    公共接口提供程序扩展TvSource.提供者,CathodeRayTube.提供者{
        default TV TV () {
            返回新电视(tvSource(), cathderaytube ());
        }
    }
}

公共类阴极电子管{
    Public void beam() {
        系统.出.println(“发射电子以产生电视图像”);
    }

    公共接口提供程序{
        默认为:
            返回new cathderaytube ();
        }
    }
}

如果您这样做,您将注意到我们刚刚编写的测试仍然按照预期编译并通过. 我们为电视添加了一个新的依赖项,但我们也提供了一个默认实现. 这意味着如果我们只想使用真实的实现,就不必模拟它, 我们的测试可以创建具有我们想要的任何级别模拟粒度的复杂对象.

当您想要模拟复杂类层次结构中的特定内容时,这将派上用场.g.,只有数据库访问层). 该模式可以轻松地设置类型 交际测试 这有时比单独测试更可取.

不管你的偏好如何, 您可以确信,您可以转向任何形式的测试,以更好地满足您在每种情况下的需求.

避免外部依赖

正如您所看到的,没有引用或提到外部组件. 这对于许多有规模甚至安全限制的项目来说是关键. 它还有助于互操作性,因为框架不必向特定的DI框架提交. 在Java中,已经进行了诸如 JSR-330 Java标准的依赖注入 可以缓解兼容性问题.

避免反射

服务定位器实现通常不依赖于反射, 但是DI实现可以(除了匕首 2的显著例外). 这样做的主要缺点是减慢了应用程序的启动速度,因为框架需要扫描您的模块, 解析依赖关系图, 反射性地构造你的对象, 等.

Mixin注入需要你编写代码来实例化你的服务, 类似于服务定位器模式中的注册步骤. 这一点额外的工作完全消除了反射调用, 使代码更快、更直接.

最近有两个项目引起了我的注意,并从避免反思中受益 gral 's Substrate VM芬兰湾的科特林/本机. 两者都编译为本地字节码, 这要求编译器提前知道您将进行的任何反射调用. 对于Graal,它在a中指定 难以编写的JSON文件,不能静态检查,不能用你最喜欢的工具轻松重构. 首先使用Mixin注入来避免反射是获得本机编译好处的好方法.

最小化运行时行为

通过实现和扩展所需的接口, 您一次构建一个依赖关系图. 每个提供程序都位于具体实现的旁边,这为程序带来了秩序和逻辑. 如果您以前使用过Mixin模式或Cake模式,就会对这种分层很熟悉.

此时,可能有必要讨论一下MainContext类. 它是依赖关系图的根,了解全局. 该类包括所有提供程序接口,是启用静态检查的关键. 如果我们回到示例并删除Cable.从它的implements列表中我们可以清楚地看到:

   静态类MainContext实现TV.提供者{}
//  ^^^
// MainContext不是抽象的,也不会覆盖tvSource中的抽象方法tvSource().提供者

这里发生的事情是应用程序没有指定要使用的具体TvSource, 编译器捕获了这个错误. 使用服务定位器和基于反射的DI, 在程序运行时崩溃之前,这个错误可能不会被注意到——即使所有单元测试都通过了! 我相信这些以及我们所展示的其他好处超过了编写使模式工作所需的样板文件的缺点.

捕获循环依赖

让我们回到阴极电子管的例子,并添加一个循环依赖. 假设我们希望它被注入TV实例,那么我们扩展TV.供应商:

公共类阴极电子管{
    公共接口提供程序扩展TV.提供者{
//  ^^^
//循环继承涉及到阴极射线管.提供者
        默认为:
            返回new cathderaytube ();
        }
    }
}

编译器不允许循环继承,我们不能定义这种关系. 当这种情况发生时,大多数框架都会在运行时失败, 开发人员倾向于绕过它,只是为了让程序运行. 尽管这种反模式可以在现实世界中找到,但它通常是糟糕设计的标志. 当代码编译失败时, 应该鼓励我们在为时已晚之前寻找更好的解决方案.

保持对象构造的简单性

支持SL而不是DI的一个理由是,它更直接,更容易调试. 从示例中可以清楚地看出,实例化依赖项将只是一个提供者方法调用链. 跟踪依赖项的来源非常简单,只需进入方法调用并查看最终的位置即可. 调试比这两种方法都简单,因为您可以精确地导航到实例化依赖项的位置, 直接从提供者处获取.

服务生命周期

细心的读者可能已经注意到,这个实现没有解决服务生命周期问题. 对提供程序方法的所有调用都将实例化新对象,使其类似于 Spring的原型范围.

这一点和其他注意事项都超出了本文的讨论范围, 因为我只是想表现出图案的本质,而不是分散注意力的细节. 在产品中充分使用和实现, 然而, 需要考虑具有终身支持的完整解决方案.

结论

无论您是习惯于依赖注入框架还是编写自己的服务定位器, 你可能想要探索一下这个选择. 考虑使用我们刚刚看到的mixin模式,看看是否能让你的代码更安全,更容易推理.

了解基本知识

  • 控制反转是什么意思?

    当一段代码能够将行为委托给编写该代码时不知道的另一个组件(通常通过众所周知的接口和扩展点)时,我们谈论控制反转。.

  • 什么是依赖注入?

    依赖注入是一种模式,通过它我们可以在系统的组件级别实现控制反转. 类只声明完成其目标所需的组件, 而不是如何找到或创建这些组件.

聘请Toptal这方面的专家.
现在雇佣
马丁·科尔的头像
马丁科尔

位于 布宜诺斯艾利斯,阿根廷

成员自 2014年7月10日

作者简介

Martin是一位全能的全栈开发人员,在包括Java在内的各种技术方面拥有多年的经验, C#, Python和其他.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

专业知识

以前在

微软

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

Toptal开发者

加入总冠军® 社区.