一两个月前,我们推出了一个新版本的zend-mvc,它提供了许多向前兼容的功能,以帮助用户为即将发布的v3版本准备他们的应用程序。
其中一个显然颇具争议:在v3中,zend-servicemanager不再定义ServiceLocatorAwareInterface
,当您尝试将服务定位器注入应用程序时,这个特定版本的zend-mvc会引发弃用通知服务,或在您的控制器中拉取服务定位器。
争论是这样的:
- “依赖注入太难理解了!”
- “这个特性简化了开发!”
- “如果这很糟糕,为什么它会出现在第一名?”
这些人通常会跟在后面:
- 说他们会更换框架(好吧,我猜?);
- 要求恢复该功能(嗯,否);
- 要求删除弃用通知(为什么?这样您就可以延迟升级直到您要求恢复该功能时的痛苦?);或
- 询问变更的理由。
所以,我决定做最后一个,证明更改的合理性,它解决了我们不做中间两个的原因,并解决了为什么关于ServiceLocatorAware
的有用性的假设和断言是主要是被误导了。
最初发布在其他地方
这最初是作为对问题的评论发布的。我决定将它发布到我的博客上以吸引更多的观众,并提供更多的背景和细节。
最初发布在其他地方
这最初是作为对问题的评论发布的。我决定将它发布到我的博客上以吸引更多的观众,并提供更多的背景和细节。
zend-servicemanager的目的是用作控制反转容器。
它从来没有打算作为通用服务定位器(有趣的是,链接细节主要是模式的缺点!);这个角色是本着“快速应用程序开发”和“简化初始开发”的精神强加给它的,但其意图是,一旦类稳定下来,就应该重构以注入依赖项。(而且我们都知道忙碌的开发人员会发生什么:重构被推迟或永远不会发生。)
为什么不应该注入服务定位器?
谷歌“服务定位器反模式”以了解为什么不应该使用它。要点归结为:
- 依赖隐藏。
- 间接错误。
- 类型安全。
- 脆弱性。
让我们逐一分析。
依赖隐藏
“依赖隐藏”是什么意思?
看看这个类签名:
class Foo implements DispatchableInterface, ServiceLocatorAwareInterface { /* Defined by DispatchableInterface */ public function dispatch(Request $request, Response $response); /* Defined by ServiceLocatorAwareInterface */ public function setServiceLocator(ServiceLocatorInterface $serviceLocator); public function getServiceLocator(); }
基于此,您会期望:
- 您可以在没有依赖项的情况下实例化对象。
- 如果您觉得有必要,您可以将服务定位器传递给实例。
- 您应该能够通过向它传递一个请求和响应实例来执行
dispatch()
,它应该会成功返回。
服务定位器是模糊的;它的用途不明确,而且显然不是必需的依赖项,因为它在setter方法中。
因此,您为dispatch()
方法编写了一个测试,您得到了一个ServiceNotFoundException
。怎么了?
您深入了解dispatch()
方法的代码:
public function dispatch(Request $request, Response $response) { $authentication = $this->serviceLocator->get('authentication'); if (! $authentication->hasIdentity()) { $response->setStatus(401); return $response; } $identity = $authentication->getIdentity(); $response->setBody( $this->serviceLocator->get('renderer')->render( 'foo', ['identity' => $identity] ) ); return $response; }
ServiceNotFoundException
可能被抛出的位置有两个:在方法的第一行,或在setBody()
调用中。在这两种情况下,您都面临着一个难题:
- 您现在知道服务定位器是必需的。从最初看班级来看,这并不明显;它似乎是一个可选依赖项。
- 您不知道每个
authentication
和renderer服务。
后者尤其令人不安。您现在必须了解应用程序中可能定义服务的所有不同位置,并开始搜索这些位置。很有可能,您会发现这些服务名称实际上可能是别名,这意味着您将确定它的别名是什么,但随后必须重新开始搜索以确定实际服务是什么。
简而言之,这就是依赖隐藏。类操作的要求隐藏在代码中,类型不一定能被推断。
(当然,您可以在get()
调用上方添加注释以详细说明类型。但这是一个创可贴;您仍然需要查看代码本身以确定要求是什么。)
依赖隐藏的一个副作用是它使测试变得更加困难。我认为上面的例子说明了这一点;您不能仅查看签名来了解行为和要求,而是需要深入研究代码。此外,测试设置变得更加困难和脆弱,因为您现在需要添加对容器的依赖、填充容器,并希望您没有遗漏任何东西。稍后我会详细讲到这一点。重点是:任何使测试变得更加困难的事情都意味着开发人员将避免测试,从而降低代码质量。
让我们再分解一下:
- 您需要一个特定的对象实例。
- 您现在耦合到服务定位器以检索实例。
- 您通过字符串名称,可以是任何内容,不一定表示目的或其功能。
- 检索可能会引发与正在使用的组件无关的异常,您需要在代码中或稍后调试时考虑到这一点。
您真正想要的只是对象实例。为什么不直接在构造函数中注入该实例?将需求定义为构造函数参数使它们显式化,并确保只看API的人理解操作所需的内容。
tl;dr:您想要的是您正在消耗的依赖性,而不是获得它所需的三个间接步骤。制作所需的所有依赖项,并将它们注入构造函数。
错误间接寻址
再次使用上面的示例,让我们检查一下我们得到了一个ServiceNotFoundException
的事实。这发生在运行时。本质上,引导、路由、实例化控制器和预调度监听器的工作已经运行,只有在我们到达实际请求的逻辑时才会失败,因为缺少依赖项。
在典型的PHP应用程序工作流中,这与直接注入依赖项没有太大区别。但是如果你考虑在像React这样的系统中使用,应用程序的引导可以发生一次,并且分派会一遍又一遍地发生,这是很有问题的;这根本不是运行时异常,由于配置错误。这很难追踪,而且不是您希望在生产中发生的事情。
类型安全
再次回到最初的例子:我们不知道期望的类型是什么,我们也不能保证我们从容器中提取的内容是正确的。
没有经验的开发人员,或者不熟悉容器中给定实例的所有用例的开发人员,可能会将服务映射到意想不到的类。直到运行时,在生产中,您才会知道这已经发生,当您突然出现“方法不存在”的致命PHP错误。这些很难追踪,因为您不知道类型是什么、期望的是什么,也不知道实例最初是在哪里定义的。调试器将采取几个步骤来确定这是由于容器配置错误造成的。
将其与构造函数中声明的依赖项进行比较:
class Foo implements DispatchableInterface { public function __construct( AuthenticationService $authentication, RendererInterface $renderer ); /* Defined by DispatchableInterface */ public function dispatch(Request $request, Response $response); }
您仍然会遇到致命错误,但您会知道该类从一开始就使用无效参数进行实例化,并且知道您需要检查您的映射和/或工厂。此类问题通常可以通过静态分析工具找到,为您提供了另一种轻松提高代码质量的方法。
另一方面,您的IDE现在也可以帮助您了解可用的方法。因为该属性被注入到构造函数中,所以当您在代码中访问它时,大多数IDE内置的静态分析(我再次使用这个词!)将能够推断出类型,并为您提供类型提示。对于服务定位器而言,这并非普遍适用(我知道PHPStorm在这方面取得了一些进展,但我也知道这是一项非常困难的任务,而且容易出错)。
脆性
依赖服务定位器会给您的设计带来脆弱性。
每次添加对get()
的调用时,都会引入新的依赖项。这通常会破坏测试:
- 如果您正在模拟服务定位器,您现在可以在测试过程中对其方法进行额外的调用,从而使模拟失败其断言。
- 如果您是使用具体的定位器实例,并且预期存在一个实例,您现在会在测试执行过程中引发异常。
这种脆弱性导致开发人员不想进行测试,从而使代码更加脆弱,并且将来更有可能以意想不到的方式中断。任何使测试变得更加困难的做法都应该重新考虑。
此外,它会导致未记录的要求,使消费者不太清楚需要提供哪些服务才能使代码正常工作。当您跨团队工作时,这一点至关重要。
使用服务定位器的另一个方面是,您的班级很容易成长为承担太多职责。让我解释一下。
一个经常用于支持使用服务定位器的论据是促进可选依赖关系:仅在特定代码执行路径期间使用的依赖关系。如果依赖性特别重(Web服务、数据库访问等),则认为只有在它们即将被使用时才将它们从容器中拉出才有意义。
有两种方法可以解决这个问题:
- zend-servicemanager(和其他几个IoC实现)已经提供了惰性服务,它通过创建一个代理类来解决这个问题,该代理类包装用于检索服务的工厂。您可以像与原始实例一样与它交互,但“大量实例化”会延迟到第一次使用时。
- 将您的关注点分成多个类!无论如何,这是更好的解决方案;如果您知道某些依赖项仅存在于某些代码路径中,请为该路径创建一个新控制器,并专门路由到它。例如,如果您知道数据库访问只会发生在(a)向服务发出POST请求时,以及(b)发生验证时,则:
- 创建一个专门映射到给定路径的POST请求的控制器,
- 可选地,将数据库连接包装为惰性服务。不过,如果您将请求路由到该特定控制器,那么在性能方面准备好数据库访问可能是可以接受的。
tl;dr:对服务定位器的依赖会导致脆弱的设计和范围蔓延。当您关注依赖关系时,您最终会将关注点拆分为多个类,从而使它们更易于测试和维护。
有个有效的用例
服务定位器有一些有效的用例。当你有很多相关的实例,并且在运行时拉取它们将基于输入时,服务定位器是理想的。此场景包括以下内容:
- 插件和辅助系统
- 策略模式
- 路由系统
然而,在这些情况下,我们不处理一般的应用程序依赖性;我们正在处理特定的上下文,并且实例在该上下文中起作用。
在很多情况下,甚至这些都可以直接注入。如果您知道您的代码路径包含特定的插件或助手,您也可以注入它们。(我们已经使用多个Apigility控制器完成了此操作,因为它简化了测试!)
要点
作为获取依赖项的通用方式,服务定位器充其量是一种反模式,会导致质量下降和脆弱的架构。
我们引入了ServiceLocatorAwareInterface
是由于来自需要“快速应用程序开发”功能并且不清楚依赖项注入的好处的用户的压力。当时,这似乎是个好主意;我们倾听并回应我们的用户。
然而,事后看来,我认为我们这样做是错误的,最终损害了我们的用户;对于那些依赖该模式的人来说,该实现助长了坏习惯并降低了代码质量。希望上面的讨论能更清楚地说明为什么我们最终决定删除它,以及我们认为删除将如何帮助您改进代码。