通过这种方式,我编写此代码是可测试的,但是我缺少它吗?


13

我有一个名为的界面IContext。出于此目的,除了以下内容外,它的作用并不重要:

T GetService<T>();

该方法的作用是查看应用程序的当前DI容器,并尝试解决依赖关系。我认为还算标准。

在我的ASP.NET MVC应用程序中,我的构造函数如下所示。

protected MyControllerBase(IContext ctx)
{
    TheContext = ctx;
    SomeService = ctx.GetService<ISomeService>();
    AnotherService = ctx.GetService<IAnotherService>();
}

因此,我不是在为每个服务的构造函数中添加多个参数(因为这对于扩展应用程序的开发人员来说确实很烦人和耗时),而是使用此方法来获取服务。

现在,感觉不对。但是,我目前在脑海中证明它的方式是这样- 我可以嘲笑它

我可以。模拟IContext测试控制器并不难。无论如何,我必须:

public class MyMockContext : IContext
{
    public T GetService<T>()
    {
        if (typeof(T) == typeof(ISomeService))
        {
            // return another mock, or concrete etc etc
        }

        // etc etc
    }
}

但是正如我所说,这感觉很不对劲。任何想法/虐待。


8
这称为服务定位器,我不喜欢它。关于此主题的著作很多- 入门请参阅martinfowler.com/articles/injection.htmlblog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern
本杰明·霍奇森

摘自Martin Fowler的文章:“我经常听到抱怨,这类服务定位器是一件坏事,因为它们无法测试,因为您不能用它们替代实现。当然,您可以对它们进行糟糕的设计才能融入其中有点麻烦,但您不必这样做。在这种情况下,服务定位器实例只是一个简单的数据持有人。我可以使用服务的测试实现轻松地创建定位器。” 你能解释为什么你不喜欢它吗?也许可以回答?
LiverpoolsNumber9'9

8
他是对的,这是错误的设计。很简单:public SomeClass(Context c)。这段代码很清楚,不是吗?它指出,that SomeClass取决于Context。错误,但请等待,事实并非如此!它仅依赖于X从Context获得的依赖关系。这意味着,即使您仅更改s ,每次对其进行更改Context可能会中断。但是,是的,你知道,你只改不,所以是好的。但是编写好的代码不是您所知道的,而是新员工在第一次查看您的代码时所知道的。SomeObjectContextYYXSomeClass
valenterry 2015年

@DocBrown对我来说,这就是我所说的-我看不到这里的区别。您能进一步解释一下吗?
valenterry

1
@DocBrown我现在明白你的意思了。是的,如果他的上下文只是所有依赖项的捆绑,那么这不是一个坏设计。但是,这可能是不好的命名,但这只是一个假设。OP应该阐明上下文中是否还有其他方法(内部对象)。同样,讨论代码也可以,但是这是programmers.stackexchange,所以对我来说,我们还应该尝试看看“背后”的事情,以提高OP。
valenterry 2015年

Answers:


5

在构造函数中使用一个参数代替许多参数不是该设计的问题部分。只要您的IContext类不过是服务门面(专门用于提供在中使用的依赖项)MyControllerBase,而不是整个代码中使用的常规服务定位符,那部分代码就可以了。

您的第一个示例可能会更改为

protected MyControllerBase(IContext ctx)
{
    TheContext = ctx;
    SomeService = ctx.GetSomeService();
    AnotherService = ctx.GetAnotherService();
}

那不会是重大的设计变更MyControllerBase。这种设计的好坏仅取决于您是否愿意

  • 确保TheContextSomeService并且AnotherService始终带有模拟对象的初始化,否则它们都与真实的物体
  • 或者,允许使用3个对象的不同组合对其进行初始化(这意味着,在这种情况下,您需要分别传递参数)

因此,在构造函数中仅使用一个参数而不是3是完全合理的。

这是问题的关键是IContext露出GetService方法在公众面前。恕我直言,您应该避免这种情况,而应保持“工厂方法”的明确。那么可以使用服务定位器从我的示例中实现GetSomeServiceGetAnotherService方法吗?恕我直言,这取决于。只要IContext类出于提供明确的服务对象列表的特定目的而保持简单的抽象工厂状态,恕我直言是可以接受的。抽象工厂通常只是“胶水”代码,本身无需进行单元测试。无论如何,您应该问自己是否在诸如之类的方法的上下文中GetSomeService,是否真的需要服务定位符,或者是否显式构造函数调用不会更简单。

因此要当心,当您坚持IContext只将实现作为公共通用GetService方法的包装的设计时,允许使用任意类来解决任意依赖关系,那么一切都会应用@BenjaminHodgson在其答案中所写的内容。


我同意这一点。该示例的问题是通用GetService方法。重构为显式命名和键入的方法更好。最好还是IContext在构造函数中明确说明实现的依赖项。
本杰明·霍奇森

1
@BenjaminHodgson:有时可能会更好,但并不 总是更好。当ctor参数列表越来越长时,您会注意到代码的味道。看到我以前的答案:programmers.stackexchange.com/questions/190120/...
布朗博士

@DocBrown构造函数过度注入的“代码味道”表明违反了SRP,这是真正的问题。简单地结束了几个服务成门面类,只能暴露他们的性能,确实没有解决这一问题的根源。因此,Facade不应只是围绕其他组件的简单包装,而应理想地提供一个简化的API来使用它们(或以其他方式简化它们)...
AlexFoxGill 2015年

...请记住,构造函数过度注入之类的“代码气味” 本身并不是问题。这只是暗示代码中存在更深层的问题,通常可以通过在发现问题后进行明智的重构来解决
AlexFoxGill 2015年

微软将其纳入其中时,您在哪里IValidatableObject
RubberDuck

15

这种设计被称为服务定位器 *,我不喜欢它。有很多反对的说法:

服务定位器将您耦合到您的容器。使用常规的依赖项注入(构造函数在其中明确说明了依赖项),您可以直接将容器替换为其他容器,或者返回new-expressions。对于您而言IContext,这实际上是不可能的。

服务定位器隐藏依赖项。作为客户端,很难告诉您需要构造一个类的实例。您需要某种IContext,但需要设置上下文以返回正确的对象以完成MyControllerBase工作。从构造函数的签名来看,这一点都不明显。使用常规DI,编译器会准确告诉您所需的内容。如果您的课程有很多依赖关系,您会感到痛苦,因为这会刺激您进行重构。服务定位器隐藏了设计不良的问题。

服务定位器会导致运行时错误。如果GetService使用错误的类型参数进行调用,则会出现异常。换句话说,您的GetService功能不是全部功能。(总功能是FP界的一个主意,但是从根本上讲,功能应该始终返回一个值。)最好让编译器提供帮助,并在出现依赖项错误时告诉您。

服务定位器违反了Liskov替换原则。由于其行为根据类型参数的不同而不同,因此可以将Service Locator视为在接口上实际上具有无限数量的方法!这个论点在这里详细说明。

服务定位器很难测试。您已经给出了一个IContext用于测试的伪造示例,这很好,但是当然最好不要一开始就编写该代码。只需直接注入伪造的依赖项,而无需通过服务定位器。

简而言之,就是不要这样做。对于具有很多依赖的类问题,这似乎是一种诱人的解决方案,但是从长远来看,这只会使您的生活痛苦不堪。

*我正在使用通用Resolve<T>方法将Service Locator定义为一个对象,该方法能够解决任意依赖关系,并且在整个代码库中使用(不仅在Composition Root中)。这与Service Facade(捆绑一些已知的少量依赖项的对象)或Abstract Factory(创建单一类型实例的对象- 抽象工厂的类型可能是通用的,但方法不同)不同。 。


1
您正在抱怨服务定位器模式(我同意)。但是实际上,在OP的示例中,MyControllerBase既没有与特定的DI容器耦合,也没有“真正”成为Service Locator反模式的示例。
布朗

@DocBrown我同意。不是因为它使我的生活更轻松,而是因为上面给出的大多数示例与我的代码无关。
LiverpoolsNumber9

2
对我来说,服务定位器反模式的标志是通用GetService<T>方法。解决任意依赖性是真正的气味,在OP的示例中该气味是正确的。
本杰明·霍奇森

1
使用服务定位器的另一个问题是,它降低了灵活性:每个服务接口只能有一个实现。如果您构建两个依赖IFrobnicator的类,但后来决定一个应该使用原始的DefaultFrobnicator实现,但是另一个确实应该在其周围使用CacheingFrobnicator装饰器,则必须更改现有代码,而如果直接注入依赖项,您要做的就是更改安装代码(如果使用的是DI框架,则更改配置文件)。因此,这是违反OCP的行为。
Jules 2015年

1
@DocBrown该GetService<T>()方法允许请求任意类:“此方法的作用是查看应用程序的当前DI容器,并尝试解决依赖关系。我认为这是非常标准的。” 。我在这个答案的顶部回复了您的评论。这是100%服务定位器
AlexFoxGill 2015年

5

马克·西曼Mark Seemann)明确指出了反对Service Locator反模式的最佳论据,因此我不会过多地解释为什么这是一个坏主意-这是一个学习过程,您必须花时间自己理解(我还推荐Mark的书)。

好了,请回答这个问题-让我们重新陈述您的实际问题

因此,我不是在为每个服务的构造函数中添加多个参数(因为这对于扩展应用程序的开发人员而言确实很烦人和耗时),而是使用此方法来获取服务。

StackOverflow上有一个解决此问题的问题。如其中一项评论所述:

最好的一句话是:“构造函数注入的奇妙好处之一是,它使违反单一责任原则的行为显而易见。”

您在错误的位置寻找解决方案。重要的是要知道什么时候上课做得太多。在您的情况下,我强烈怀疑不需要“基本控制器”。实际上,在OOP中,几乎几乎根本不需要继承。行为和共享功能的变化可以完全通过适当使用接口来实现,这通常会导致代码分解和封装更好-无需将依赖项传递给超类构造函数。

在我研究过的所有存在基础控制器的项目中,它的完成纯粹是为了共享方便的属性和方法,例如IsUserLoggedIn()GetCurrentUserId()停止。这是对继承的可怕滥用。相反,创建一个公开这些方法的组件,并在需要时对其进行依赖。这样,您的组件将保持可测试性,并且它们之间的依赖关系将显而易见。

除了其他方面,当使用MVC模式时,我总是会建议使用瘦控制器。您可以在此处阅读有关此内容的更多信息但该模式的本质很简单,即MVC中的控制器应该只做一件事:处理MVC框架传递的参数,将其他问题委托给其他组件。这也是工作中的单一责任原则。

知道您的用例做出更准确的判断确实会有所帮助,但是老实说,我无法想到任何情况下基类胜于精心构造的依赖关系。



1
“构造函数注入的奇妙好处之一是,它使违反单一责任原则的行为显而易见”。真的很好的答案。不完全同意。有趣的是,我的用例是,不必在具有至少100个控制器的系统中重复代码。但是,对于SRP来说-每个注入的服务都有一个责任,使用它们的控制器也一样-一个折射镜怎么办?
LiverpoolsNumber9'9

1
@ LiverpoolsNumber9关键是将功能从BaseController移到依赖项,直到在BaseController中除了protected将新组件单线委托之外,什么都没有。然后,您可能会丢失BaseController并将这些protected方法替换为对依赖关系的直接调用-这将简化您的模型并使所有依赖关系明确(这在具有100个控制器的项目中非常好!)
AlexFoxGill 2015年

@ LiverpoolsNumber9-如果您可以将BaseController的一部分复制到pastebin,我可以提出一些具体建议
AlexFoxGill 2015年

-2

我要根据其他人的贡献为此添加一个答案。非常感谢大家。首先,这是我的回答:“不,这没什么问题”。

Doc Brown的“ Service Facade”答案 我接受了此答案,因为我正在寻找的(如果答案为“否”)是一些示例或对我正在做的事情的扩展。他提供此信息的目的是建议A)有一个名字,B)可能有更好的方法来做到这一点。

本杰明·霍奇森(Benjamin Hodgson)的“服务定位器”答案 尽管我很欣赏在这里获得的知识,但我所拥有的并不是“服务定位器”。这是一个“服务外观”。此答案中的所有内容都是正确的,但不适用于我的情况。

USR的答案

我将更详细地解决这个问题:

您正在以这种方式放弃很多静态信息。您像许多动态语言一样将决策推迟到运行时。这样,您将失去静态验证(安全性),文档和工具支持(自动完成,重构,查找用法,数据流)。

我不会丢失任何工具,也不会丢失任何“静态”类型。服务外观将返回我在DI容器或中配置的内容default(T)。它返回的是“类型化”。反射被封装。

我不明白为什么添加额外的服务作为构造函数参数会带来很大的负担。

当然不是“稀有”。当我使用基本控制器时,每次需要更改其构造函数时,我可能都必须更改10、100、1000个其他控制器。

如果您使用依赖项注入框架,则甚至不必手动传递参数值。然后,您又失去了一些静态优势,但没有那么多。

我正在使用依赖注入。这才是重点。

最后,朱尔斯对本杰明回答的评论 我没有失去任何灵活性。这是我的服务门面。我可以添加多个参数以GetService<T>区分不同的实现,就像配置DI容器时所做的一样。因此,例如,我可以更改GetService<T>()GetService<T>(string extraInfo = null)来解决此“潜在问题”。


无论如何,再次感谢大家。这真的很有用。干杯。


4
我不同意您在这里设有服务门面。该GetService<T>()方法能够(尝试)解决任意依赖项。正如我在回答的脚注中所解释的,这使它成为服务定位器,而不是服务外观。如果您要像@DocBrown所建议的那样用一小部分GetServiceX()/ GetServiceY()方法替换它,那它将是一个Facade。
本杰明·霍奇森

2
您应该阅读并密切注意本文的最后一部分,部分是有关Abstract Service Locator的,实际上,这是您在做什么。我已经看到这种反模式破坏了整个项目-特别要注意“这将使您作为维护开发人员的生活变得更糟,因为您将需要使用大量的脑力来掌握所做的每项更改的含义,这会引起您的关注。 “
AlexFoxGill 2015年

3
在静态类型上-您错过了重点。如果我尝试编写不使用DI提供所需类的构造函数参数的代码,则它将无法编译。这是解决问题的静态,编译时安全性。如果您编写代码,为构造函数提供一个IContext,但该IContext尚未正确配置为提供其实际需要的参数,则它将在运行时编译并失败
Ben Aaronson 2015年

3
@ LiverpoolsNumber9那么,为什么根本没有强类型的语言呢?编译器错误是防范错误的第一道防线。单元测试是第二个。单元测试也不会被您接受,因为它是类及其依赖项之间的交互,因此您将进入第三道防线:集成测试。我不知道您运行这些代码的频率,但是您现在谈论的是几分钟或更长时间量级的反馈,而不是IDE强调编译器错误所需的毫秒数。
Ben Aaronson

2
@ LiverpoolsNumber9错误:现在您的测试必须填充a IContext并注入它,并且至关重要:如果添加新的依赖项,即使测试在运行时失败,代码仍将编译
AlexFoxGill 2015年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.