服务定位器(ServiceLocator)是一种反模式吗?

165

最近我读了Mark Seemann写的文章,讲述了Service Locator反模式的两个主要原因:

  1. API使用问题(我对此完全没有问题):
    当类使用Service Locator时,很难看到它的依赖关系,因为在大多数情况下,该类只有一个“无参数构造函数”。相比之下,使用DI方法通过构造函数的参数明确地公开依赖项,因此可以在IntelliSense中轻松地查看依赖项。

  2. 维护问题(这让我感到困惑):
    考虑以下示例:

我们有一个使用Service Locator方法的类“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();
    }
}
这里是我的误解的起点。作者说:
“判断你是否引入了破坏性更改变得更加困难。你需要了解Service Locator所在的整个应用程序,编译器无法帮助你。”
但是等一下,如果我们使用DI方法,我们将在构造函数中引入另一个参数的依赖项(在构造函数注入的情况下)。问题仍然存在。如果我们忘记设置Service Locator,那么我们可能会忘记在IoC容器中添加新映射,而DI方法也会有相同的运行时问题。
此外,作者提到单元测试的困难。但是,我们使用DI方法会遇到问题吗?难道我们不需要更新实例化该类的所有测试吗?我们将更新它们以传递一个新的模拟依赖项,只是为了使我们的测试可编译。我没有看到任何从这种更新和时间开销中获益的好处。
我并不是在试图为Service Locator方法辩护。但是这种误解让我觉得我失去了非常重要的东西。可以有人解除我的疑虑吗?
更新(摘要):
对于我的问题"Service Locator是反模式吗?"的答案真的取决于具体情况。我绝对不建议从你的工具列表中删除它。当你开始处理旧代码时,它可能非常方便。如果你很幸运,正处在项目的开始阶段,那么DI方法可能是更好的选择,因为它比Service Locator具有一些优势。
以下是主要差异,使我决定不在我的新项目中使用Service Locator:
- 最明显和重要的: Service Locator隐藏了类之间的依赖关系。 - 如果您正在使用某个IoC容器,它很可能会在启动时扫描所有构造函数以验证所有依赖项,并提供有关缺少映射(或错误配置)的即时反馈;如果您将自己的IoC容器用作服务定位器,则不可能实现这一点。
欲知详情,请阅读下面给出的优秀答案。

如果你在测试中使用了构建器,那么这并不一定是真的。在这种情况下,你只需要更新构建器即可,而不必更新所有实例化该类的测试。 - Peter Karlsson
你说得对,这要看情况而定。例如,在大型的Android应用程序中,人们迄今为止非常不愿意使用依赖注入(DI),因为会影响低端移动设备的性能。在这种情况下,你需要找到替代方案来编写可测试的代码,我认为服务定位器在这种情况下是一个足够好的替代品。(注意:当新的Dagger 2.0 DI框架成熟时,Android的情况可能会有所改变。) - G. Lombard
2
请注意,自此问题发布以来,Mark Seemann的“Service Locator is an Anti-Pattern post”已经更新,描述了服务定位器如何通过破坏封装违反OOP的最佳论据(以及所有先前论点中使用的所有症状的根本原因)。 更新2015-10-26:服务定位器的根本问题在于它违反了封装 - NightOwl888
1
这是来自《依赖注入:原理与实践》一书的摘录,概述了服务定位器及其主要问题。链接在此:https://freecontent.manning.com/the-service-locator-anti-pattern/,该书链接在此:https://mng.bz/BYNl。 - Steven
10个回答

146

如果您仅因为某些情况下无法使用模式而将其定义为反模式,那么是的,它就是反模式。但按照这种推理方式,所有模式也都是反模式。

相反,我们必须看是否存在模式的有效用途,对于服务定位器(pattern)而言,有几个用例。但让我们先从您提供的示例开始。

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方法会有问题吗?

不会。因为您没有对静态服务定位器的依赖。您是否尝试过使用静态依赖性进行并行测试?这不是什么好玩的事情。


3
谢谢你的问题。Jgauffin在他的回答中指出了一个关于启动时自动检查的重要问题。我之前没有考虑到这一点,现在我认识到了依赖注入另外一个好处。他还举了一个例子:"var myType = new MyType()"。但是在实际应用中,我们永远不会实例化依赖项(因为IoC容器总是为我们完成)。例如,在MVC应用程序中,我们有一个控制器依赖于IMyService,而MyServiceImpl依赖于IMyRepository。我们将永远不会实例化MyRepository和MyService。我们从构造函数参数(比如从ServiceLocator)中获取实例并使用它们,是吗? - davidoff
44
您唯一支持“服务定位器不是反模式”的论点是:“如果没有服务定位,控制反转容器就无法工作”。然而,这个论点是无效的,因为服务定位是关于意图而不是关于机制的,正如Mark Seemann在这里清楚地解释的那样:“封装在组合根中的DI容器不是服务定位器-它是一个基础设施组件。” - Steven
5
Web API并不使用服务定位器(Service Location)来进行控制器(Controller)的依赖注入(DI)。实际上,它完全没有进行DI。它提供了创建自己的ControllerActivator选项,以传递到配置服务(Configuration Services)中。从那里,您可以创建组合根(Composition Root),无论是纯DI还是容器。此外,您将服务定位器的使用与该“服务定位器模式”的定义混淆了起来。根据该定义,组合根DI也可以被认为是“服务定位器模式”。因此,该定义的整个意义都是无意义的。 - Suamere
1
接口隔离原则是关于API设计的,而不是依赖注入。如果您不想暴露您正在使用的接口,那么您违反了ISP而不是遵守它。符合SOLID原则的小类不会因为大量依赖而出现问题。 - jgauffin
2
@jgauffin DI和SL都是IoC的版本。SL只是做法上的错误。一个IoC容器可能是SL,或者它可以使用DI。这取决于它的连线方式。但是SL很糟糕,非常糟糕。这是一种隐藏你紧密耦合所有东西的方法。 - MirroredFate
显示剩余13条评论

43

我还要指出,如果你正在重构遗留代码,服务定位器模式不仅不是反模式,而且实际上是一种实用必需品。 没有人会在数百万行代码上挥舞魔杖,突然所有代码都能够支持依赖注入(DI)。因此,如果您想开始向现有代码库引入DI,请慢慢改变成为DI服务的内容,并且引用这些服务的代码通常不是DI服务。因此,那些服务需要使用服务定位器来获取已转换为使用DI的那些服务的实例。

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


16
当你在处理遗留代码时,为了使你走出困境,一切都可以被证明是正当的,即使这意味着采取中间(不完美)的步骤。服务定位器就是这样的一个中间步骤。它允许你一步一步地走出那个困境,只要你记住存在一种好的替代解决方案,它已经被记录、可重复并被证明是有效的。这个替代解决方案就是依赖注入,这也正是为什么服务定位器仍然是一种反面模式;合理设计的软件不会使用它。 - Steven
2
回复:“当你处理遗留代码时,为了摆脱这个混乱状态,一切都是合理的。”有时我会想,也许只存在一点点遗留代码,但因为我们可以用任何方法来修复它,所以我们从未设法解决它。 - Drew Delano

9

从测试的角度���看,服务定位器(Service Locator)并不好。可以看一下Misko Hevery的Google技术演讲,他用代码示例进行了详细的解释,具体链接是http://youtu.be/RlfLCWKxHJ0,演讲从8分45秒开始。我很喜欢他的比喻:如果你需要25美元,直接要钱就好了,不要把你的钱包给别人,让别人从中取钱。他还将服务定位器比作一个干草堆,你需要的是其中的一根针,而服务定位器知道如何取出它。因此,使用服务定位器的类很难重复利用。


11
这是一个重复的观点,最好留作评论。此外,我认为你(他?)的比喻证明了某些模式更适合于某些问题,而不是其他问题。 - 8bitjunkie
4
我认为该视频足够有用,值得单独回答。评论很容易被忽略。 - d512

8

维护问题(让我感到困惑)

使用服务定位器的不良影响有两个不同的原因。

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

简单明了:带有服务定位器的类比接受其依赖项通过构造函数的类更难重用

考虑这样一种情况:您需要使用LibraryA中的一个服务,其作者决定使用ServiceLocatorA,以及LibraryB中的一个服务,其作者决定使用ServiceLocatorB。我们别无选择,只能在项目中使用两个不同的服务定位器。如果没有好的文档、源代码或作者的联系方式,那么需要配置多少依赖项就成了一个猜谜游戏。如果以上选项都失败了,我们可能需要使用反编译工具来弄清楚依赖关系。我们可能需要配置两个完全不同的服务定位器API,并且根据设计,可能无法简单地包装现有的DI容器。甚至如果服务定位器恰好不在我们需要的服务所在的库中,项目的复杂性可能会进一步增加——我们会隐式地将额外的库引用拖入我们的项目中。
现在考虑使用构造函数注入实现相同的两个服务。添加对LibraryALibraryB的引用。通过分析Intellisense所需的内容,在DI配置中提供依赖项。完成。
Mark Seemann在StackOverflow上的回答清楚地说明了这种图形化效果,不仅适用于从其他库使用服务定位器,还适用于在服务中使用外部默认值。


2
我的知识水平不足以判断这个问题,但总的来说,如果某个东西在特定情况下有用,这并不一定意味着它不能成为反模式。尤其是当你处理第三方库时,你无法完全控制所有方面,可能会使用不是最好的解决方案。
以下是来自《适应性代码 via C#》的一段:
“不幸的是,服务定位器有时是不可避免的反模式。在某些应用程序类型 - 特别是 Windows Workflow Foundation - 基础结构不利于构造函数注入。在这些情况下,唯一的选择是使用服务定位器。这比根本不注入依赖项要好。尽管我对(反)模式有很多抨击,但它仍然比手动构造依赖关系好得多。毕竟,它仍然提供了由接口提供的所有重要扩展点,这些接口允许修饰符、适配器和类似的优点。”
-- Hall, Gary McLean. 适应性代码 via C#: 遵循设计模式与SOLID原则的敏捷编码(开发人员参考)(p. 309). Pearson Education.

1

我建议考虑通用方法,以避免Service Locator模式的缺点。它允许显式声明类依赖关系和替换模拟对象,不依赖于特定的DI容器。这种方法可能存在的缺点是:

  1. 它使您的控制类变得通用。
  2. 不容易覆盖某个特定接口。

1 首先声明接口

public interface IResolver<T>
{
    T Resolve();
}
  1. 创建一个“平铺”的类,实现从 DI 容器中解析最常用的接口并进行注册。 这个简短的示例使用了 Service Locator(服务定位器)但是在组合根之前。另一种方法是将每个接口通过构造函数注入。
  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>();
  }

某些 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;并使通用构造函数不是类。

另外,FlattenedServices类可以被视为主要的DI容器,而品牌容器可以被视为补充容器。


0

2
我理解你的意思,但是增加更多细节会更有帮助,例如为什么它违反了SOLID原则。 - reggaeguitar

0
作者认为“编译器不会帮你”,这是真的。当你设计一个类时,你需要仔细选择它的接口,以使其尽可能独立。
通过在客户端显式接受对服务(依赖项)的引用,您可以:
  • 隐式地获得检查,因此编译器可以“帮助”。
  • 您还消除了客户端需要了解“定位器”或类似机制的需求,因此客户端实际上更加独立。
您说得对,依赖注入有其问题/缺点,但是我认为提到的优点远远超过它们的缺点。您是正确的,使用 DI 在接口(构造函数)中引入了一种依赖性,但是这是您需要并且想要使其可见和可检查的依赖性。

Zrin,谢谢您的想法。据我理解,采用“适当”的 DI 方法后,除了单元测试之外,我不应该在任何地方实例化我的依赖项。因此编译器只会帮助我处理测试。但是正如我在原始问题中所描述的那样,这种在损坏的测试上提供的“帮助”对我没有任何益处。难道是吗? - davidoff
“静态信息”或“编译时检查”的论点是一个伪命题,就像@davidoff指出的那样,依赖注入同样容易受到运行时错误的影响。我还想补充一点,现代IDE提供成员评论/摘要信息的工具提示视图,即使在没有这些工具提示的IDE中,某人仍然会查看文档,直到他们熟悉API。文档就是文档,无论是必需的构造函数参数还是关于如何配置依赖项的说明。 - tuespetre
在考虑实现/编码和质量保证时,代码的可读性至关重要,特别是对于接口定义。如果您可以不使用自动编译时检查,并且如果您充分注释/记录您的接口,那么我想您至少可以部分地弥补隐藏依赖于一种全局变量的缺点,其内容不易看到/预测。我认为您需要有足够的理由来使用这种模式,以抵消这些缺点。 - Zrin

0

我认为这篇文章的作者在证明这是一种反模式时,自己给自己开了枪。五年后更新的文章中提到了正确的方法:

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++编写帮助程序类,例如LocatorChecker<IOrderValidator, IOrderShipper>。该对象可以在其构造函数/析构函数中检查服务定位器是否持有所需类的有效实例,因此比Mark Seeman提供的示例更少重复。


0

服务定位器(SL)

服务定位器 解决了[DIP + DI]问题。它允许通过接口名称满足需求。服务定位器可以作为单例或可以传递到构造函数中。

class A {
  IB ib

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

这里的问题是客户端(A)使用了哪些类(IB的实现)并不清楚。
建议 - 明确传递参数。
class A {
  IB ib

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

SL vs DI IoC 容器(框架)

SL 侧重于保存实例,而 DI IoC 容器(框架)则更多地关注创建实例。

在构造函数中检索依赖项时,SL 的工作方式类似于PULL命令;而 DI IoC 容器(框架)的工作方式则类似于PUSH命令,将依赖项放入构造函数中。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接