ServiceLocator 是反模式吗?

最近我读了 Mark Seemann 的文章关于服务定位器反模式的文章。

作者指出了 ServiceLocator 是反模式的两个主要原因:

  1. API 使用问题 (我对此完全没问题)
    当类使用服务定位器时,很难看到它的依赖关系,因为在大多数情况下,类只有一个 PARAMETERLESS 构造函数。 与 ServiceLocator 不同,DI 方法通过构造函数的参数显式地公开依赖关系,因此在 IntelliSense 中很容易看到依赖关系。

  2. 维修问题 (这让我很困惑)
    考虑下面的示例

我们有一个使用服务定位器方法的类 “ MyType”:

public class MyType
{
public void MyMethod()
{
var dep1 = Locator.Resolve<IDep1>();
dep1.DoSomething();
}
}

现在我们要向类“ MyType”添加另一个依赖项

public class MyType
{
public void MyMethod()
{
var dep1 = Locator.Resolve<IDep1>();
dep1.DoSomething();
            

// new dependency
var dep2 = Locator.Resolve<IDep2>();
dep2.DoSomething();
}
}

这就是我的误解开始的地方,作者说:

很难判断你是否正在引入一个突破性的改变。您需要了解使用 ServiceLocator 的整个应用程序,而编译器不会帮助您。

但是等一下,如果我们使用 DI 方法,我们会在构造函数中引入一个带有另一个参数的依赖项(在构造函数注入的情况下)。问题依然存在。如果我们可能忘记设置 ServiceLocator,那么我们可能会忘记在 IoC 容器中添加一个新的映射,而 DI 方法也会遇到同样的运行时问题。

此外,作者还提到了单元测试的困难。但是,我们不会对 DI 的方法有异议吗?我们不需要更新所有实例化该类的测试吗?我们将更新它们以传递一个新的模拟依赖项,以使我们的测试可编译。我看不出这种更新和花费时间有什么好处。

我不是在为服务定位器辩护。但这个误会让我觉得我失去了很重要的东西。有人能消除我的疑虑吗?

更新(摘要) :

我的问题“服务定位器是反模式吗”的答案实际上取决于具体情况。我也绝对不会建议把它从你的工具清单上划掉。当您开始处理遗留代码时,它可能会变得非常方便。如果您足够幸运,能够处于项目的最初阶段,那么 DI 方法可能是一个更好的选择,因为它比服务定位器有一些优势。

这里有一些主要的不同之处,它们让我决定不在我的新项目中使用服务定位器:

  • 最明显和最重要的是: ServiceLocator 隐藏了类依赖关系
  • 如果你正在使用一些 IoC 容器,它很可能会在启动时扫描所有的构造函数,以验证所有的依赖关系,并给你关于缺少映射(或错误配置)的即时反馈; 如果你使用 IoC 容器作为服务定位器,这是不可能的

详情请阅读下面给出的精彩答案。

40124 次浏览

如果仅仅因为在某些情况下模式不适合而将模式定义为反模式,那么是的,它是一个反模式。但是根据这个推理,所有的模式也都是反模式的。

相反,我们必须查看模式是否有有效的用法,对于 ServiceLocator,有几个用例。但是让我们从你们给出的例子开始。

public class MyType
{
public void MyMethod()
{
var dep1 = Locator.Resolve<IDep1>();
dep1.DoSomething();


// new dependency
var dep2 = Locator.Resolve<IDep2>();
dep2.DoSomething();
}
}

该类的维护噩梦是依赖关系被隐藏。如果你创建并使用该类:

var myType = new MyType();
myType.MyMethod();

如果使用服务位置隐藏这些依赖项,那么您不会理解它们具有依赖项。现在,如果我们改用依赖注入:

public class MyType
{
public MyType(IDep1 dep1, IDep2 dep2)
{
}


public void MyMethod()
{
dep1.DoSomething();


// new dependency
dep2.DoSomething();
}
}

您可以直接发现依赖项,并且在满足这些依赖项之前不能使用这些类。

在典型的业务应用程序中,您应该避免使用服务位置。它应该是在没有其他选项时使用的模式。

模式是反模式吗?

没有。

例如,没有服务位置的控制反转容器就无法工作,这是它们在内部解决服务的方式。

但是一个更好的例子是 ASP.NET MVC 和 WebApi。你认为是什么使得控制器中的依赖注入成为可能?没错,服务地点。

你的问题

但是等一下,如果我们使用 DI 方法,我们将引入一个 与构造函数中另一个参数的依赖关系(如果 这个问题仍然存在。

还有两个更严重的问题:

  1. 对于服务位置,您还要添加另一个依赖项: 服务定位器。
  2. 如何判断依赖项应该具有哪个生存期,以及应该如何/何时清理它们?

通过使用容器的构造函数注入,您可以免费得到它。

如果可以的话 忘记设置 ServiceLocator,那么我们可能会忘记添加一个新的 我们的 IoC 容器和 DI 方法中的映射将具有相同的 运行时问题。

那倒是真的。但是使用构造函数注入时,您不必扫描整个类来确定缺少哪些依赖项。

一些更好的容器还在启动时验证所有依赖项(通过扫描所有构造函数)。因此,使用这些容器,您可以直接得到运行时错误,而不是在以后的某个时间点。

另外,作者提到了单元测试的困难,但是,我们不会在 DI 方法上遇到问题吗?

没有。因为您没有对静态服务定位器的依赖项。您是否尝试过使用静态依赖项进行并行测试?一点都不好玩。

作者认为“编译器不会帮助你”——这是事实。 当您设计一个类时,您需要仔细地选择它的接口——除了其他目标之外,还要使它独立于... ... ,因为它是有意义的。

通过让客户机通过显式接口接受对服务(对依赖项)的引用,您可以

  • 隐式地得到检查,所以编译器“帮助”。
  • 您还消除了客户机了解“ Locator”或类似机制的需要,因此客户机实际上更加独立。

你是对的,DI 有它的问题/缺点,但是上面提到的优点远远超过了它们... ..。 你是对的,对于 DI,在接口(构造函数)中引入了一个依赖项——但是希望这正是你所需要的依赖项,并且你希望它是可见的和可检查的。

我还想指出,如果您正在重构遗留代码,那么服务定位器模式不仅不是反模式,而且是实际需要。没有人会在数百万行代码上挥舞魔棒然后突然间所有的代码都变成了 DI。因此,如果你想开始将 DI 引入到现有的代码库中,通常情况下,你会慢慢地将事物变成 DI 服务,而引用这些服务的代码通常不会是 DI 服务。因此,THOSE 服务将需要使用服务定位器,以便获得那些已经转换为使用 DI 的服务的实例。

因此,在重构大型遗留应用程序以开始使用 DI 概念时,我想说的是,服务定位器不仅不是反模式,而且是将 DI 概念逐步应用到代码库的唯一方法。

从测试的角度来看,ServiceLocator 是不好的。请参阅 Misko Hevery 的 Google Tech Talk,在8:45分钟开始的 http://youtu.be/RlfLCWKxHJ0代码示例中给出了很好的解释。我喜欢他的类比: 如果你需要25美元,直接要钱,而不是把你的钱包从哪里拿钱。他还比较了服务定位器与大海捞针,有你需要的针,并知道如何检索它。因此,使用服务定位器的类很难重用。

维修问题 (这让我很困惑)

在这方面,使用服务定位器有两个不同的原因。

  1. 在您的示例中,您将服务定位器的静态引用硬编码到您的类中。这个 紧密的情侣你的类直接到服务定位器,这依次意味着 如果没有服务定位器,它将无法工作。此外,您的单元测试(以及使用该类的任何其他人)也隐式地依赖于服务定位器。这里似乎没有注意到的一件事是 当使用构造函数注入时,单元测试时不需要 DI 容器,它极大地简化了单元测试(以及开发人员理解它们的能力)。这就是使用构造函数注入实现的单元测试好处。
  2. 至于为什么构造函数 Intellisense 很重要,这里的人们似乎完全忽略了这一点。一个类只写一次,但是 它可以在多个应用程序(即多个 DI 配置)中使用。随着时间的推移,如果您可以通过查看构造函数定义来理解类的依赖关系,而不是查看(希望是最新的)文档,或者,如果没有查看文档,则返回到原始源代码(这可能不太方便)来确定类的依赖关系是什么,那么这将带来回报。带有服务定位器的类通常是 更容易写,但是在项目的持续维护中,您要为这种便利性支付更多的费用。

简单明了: 带有服务定位器的类是 更难重复使用,而不是通过其构造函数接受其依赖项的类。

考虑这样的情况,您需要使用来自 LibraryA的服务,其作者决定使用 ServiceLocatorA,以及来自 LibraryB的服务,其作者决定使用 ServiceLocatorB。我们别无选择,只能在项目中使用2个不同的服务定位器。如果我们没有好的文档、源代码或者快速拨号的作者,那么需要配置多少依赖项就是一个猜谜游戏。如果这些选项失败,我们可能需要使用反编译器 只是来确定依赖项是什么。我们可能需要配置2个完全不同的服务定位器 API,根据设计的不同,可能不能简单地包装现有的 DI 容器。在两个库之间共享一个依赖项实例可能根本不可能。如果服务定位器实际上并不与我们需要的服务位于同一个库中,那么项目的复杂性甚至可能进一步加剧——我们正在隐式地将额外的库引用拖入我们的项目中。

现在考虑使用构造函数注入生成的两个相同的服务。添加对 LibraryA的引用。添加对 LibraryB的引用。在 DI 配置中提供依赖项(通过 Intellisense 分析需要什么)。成交。

Mark Seemann 有一个 StackOverflow 的答案清楚地以图形形式说明了这一优点,它不仅适用于使用来自另一个库的服务定位器,而且适用于在服务中使用外部默认值。

我的知识不足以判断这一点,但总的来说,我认为如果某个东西在特定情况下有用,并不一定意味着它不能是反模式。特别是,当您处理第三方库时,您不能完全控制所有方面,并且最终可能会使用不是很好的解决方案。

以下是 C # 自适应代码中的一段:

”不幸的是,服务定位器有时是不可避免的反模式。在某些应用程序类型(特别是 Windows 工作流基础)中,基础结构本身不适合进行构造函数注入。在这些情况下,唯一的选择是使用服务定位器。这总比完全不注入依赖项要好。尽管我对(反)模式刻薄,但它比手动构造依赖关系要好得多。毕竟,它仍然支持那些由允许装饰器、适配器和类似好处的接口提供的非常重要的扩展点。”

—— Hall,Gary McLean。通过 C # 的自适应代码: 使用设计模式和 SOLID 原则的敏捷编码(开发者参考)(p. 309)。培生教育。

是的,服务定位器是一个反模式它 违反了封装规定可靠

服务定位器(SL)

解决 [ ABC1 + DI]问题。它允许通过接口名称来满足需求。服务定位器可以是单例的,也可以传递给构造函数。

class A {
IB ib


init() {
ib = ServiceLocator.resolve<IB>();
}
}

这里的问题是,客户机(A)使用的正是 不清楚类(IB 的实现)。

显式通过参数

class A {
IB ib


init(ib: IB) {
self.ib = ib
}
}

SL 与 DI IoC 容器(框架)

当 DI IoC Container (框架)更多的是关于创建实例时,SL 是关于保存实例的。

SL 在检索构造函数内部的依赖项时作为 命令工作 当 DI IoC Container (框架)将依赖项放入构造函数时,它作为 用力命令工作

我可以建议考虑使用通用方法来避免 ServiceLocator 模式的缺点。 它允许显式声明类依赖项和替换模拟,并且不依赖于特定的 DI 容器。 这种方法可能的缺点是:

  1. 它使您的控件类成为泛型的。
  2. 覆盖某些特定接口并不容易。

1 First Declare 接口

public interface IResolver<T>
{
T Resolve();
}
  1. 创建“扁平化”类,实现从 DI Container 解析最常用的接口并注册它。 这个简短的例子使用服务定位器,但是在组合根之前。另一种方法是注入每个接口与构造函数。
  public class FlattenedServices :
IResolver<I1>,
IResolver<I2>,
IResolver<I3>
{
private readonly DIContainer diContainer;


public FlattenedServices(DIContainer diContainer)
{
this.diContainer = diContainer;
}


I1 IResolver<I1>.Resolve()
=> diContainer.Resolve<I1>();


I2 IResolver<I2>.Resolve()
=> diContainer.Resolve<I2>();


I3 IResolver<I3>.Resolve()
=> diContainer.Resolve<I3>();
}
  1. 在某些 MyType 类上的构造函数注入应该类似
  public class MyType<T> : IResolver<T>
where T : class, IResolver<I1>, IResolver<I3>
{
T servicesContext;


public MyType(T servicesContext)
{
this.servicesContext = servicesContext
?? throw new ArgumentNullException(nameof(serviceContext));
_ = (servicesContext as IResolver<I1>).Resolve() ?? throw new ArgumentNullException(nameof(I1));
_ = (servicesContext as IResolver<I3>).Resolve() ?? throw new ArgumentNullException(nameof(I3));
}


public void MyMethod()
{
var dep1 = ((IResolver<I1>)servicesContext).Resolve();
dep1.DoSomething();
            

var dep3 = ((IResolver<I3>)servicesContext).Resolve();
dep3.DoSomething();
}


T IResolver<T>.Resolve() => serviceContext;
}

附言。如果不需要在 MyType中进一步传递 servicesContext,那么可以声明 object servicesContext;并使泛型只 ctor 不是 class。

附注。FlattenedServices类可以被看作是主要的 DI 容器,品牌容器可以被看作是补充容器。

我认为这篇文章的作者在证明 5年后写的更新是一个反模式的过程中给自己留下了不好的印象。在那里,据说这是正确的方式:

public OrderProcessor(IOrderValidator validator, IOrderShipper shipper)
{
if (validator == null)
throw new ArgumentNullException("validator");
if (shipper == null)
throw new ArgumentNullException("shipper");
        

this.validator = validator;
this.shipper = shipper;
}

接下来是:

现在很清楚,在调用 Process 方法之前,需要所有这三个对象; 这个版本的 OrderProcessor 类通过类型系统公布它的前提条件。除非将参数传递给构造函数和方法,否则甚至无法编译客户端代码(可以传递 null,但这是另一个讨论)。

让我再次强调最后一部分:

你可以传递 null,但那是另一个讨论

为什么又要讨论这个?这可是件大事.将其依赖项作为参数接收的对象完全依赖于应用程序(或测试)以前的执行,以便将这些对象作为有效的引用/指针提供。它没有被作者表达的术语“封装”,因为它依赖于大量的外部机器以令人满意的方式运行,这样对象才能构造出来,然后当它需要使用其他类时才能正常工作。

作者声称没有封装的是服务定位器,因为它依赖于一个附加的对象,而这个对象在测试中是无法分离的。但是其他对象很可能是一个平凡的映射或向量,所以它是没有行为的纯数据。例如,在 C + + 中,容器不是语言的一部分,所以对于所有非平凡的类,都依赖于容器(向量、散列映射、字符串等)。它们不是因为依赖容器而被隔离吗?我不这么认为。

我认为,无论是使用手动依赖注入还是使用服务定位器,这些对象都不是真正与其他对象隔离开来的,它们需要它们的依赖关系,是或者是,但是它们是以不同的方式提供的。我个人认为定位器甚至有助于 DRY 原则,因为在应用程序中反复传递指针容易出错,而且是重复性的。服务定位器也可以更加灵活,因为对象可以在需要时(如果需要)检索其依赖项,而不仅仅是通过构造函数。

通过对象的构造函数(在使用服务定位器的对象上) ,依赖关系不够明确的问题已经由我之前强调过的事情解决了: 传递空指针。它甚至可以用来混合和匹配两个系统: 如果指针为空,使用服务定位器,否则,使用指针。现在它是通过类型系统实现的,对于类的用户来说是显而易见的。但我们可以做得更好。

另外一个用 C + + 肯定可以实现的解决方案(我不知道 Java/C # ,但我想它也可以实现)是编写一个像 LocatorChecker<IOrderValidator, IOrderShipper>那样被实例化的 helper 类。该对象可以检查它的构造函数/析构函数,即服务定位器拥有所需类的有效实例,因此,与 Mark Seeman 提供的示例相比,它的重复性也更少。