我应该避免使用依赖注入和控制反转吗?

14
在我的中型项目中,我使用了静态类来实现存储库、服务等功能,并且它实际上非常有效,即使大多数程序员会预期相反。我的代码库非常紧凑、清晰、易于理解。现在我尝试重写所有内容并使用IoC(控制反转),但我感到非常失望。我不得不手动初始化每个类、控制器等中的许多依赖项,为接口添加更多项目等等。我真的没有看到任何在项目中受益的地方,似乎它引起的问题比解决的问题还要多。我发现了以下IoC/DI的缺点:
  • 代码量更大
  • 面向瑞士卷式设计而非面条式设计
  • 性能较慢,需要在构造函数中初始化所有依赖项,即使我想要调用的方法只有一个依赖项
  • 在没有使用IDE的情况下难以理解
  • 有些错误被推迟到运行时才会出现
  • 添加额外的依赖项(DI框架本身)
  • 新员工必须首先学习DI才能开始工作
  • 有很多样板代码,这对于有创造力的人来说是不好的(例如将构造函数中的实例复制到属性中...)
我们只测试特定方法并使用真实数据库,而不是整个代码库。因此,在不需要为测试进行模拟时,是否应避免使用依赖注入?

请注意,实现DI并不需要DI框架。您可以在没有DI容器的情况下进行DI,这被称为纯DI。我认为在大多数情况下,纯DI更好 - Yacoub Massad
CS101:不要使用全局变量。如果您正在使用(甚至是隐式的 - 例如一些连接打开/关闭)全局变量(请注意与全局常量的区别 - 它们变化,例如是可变的),那么您应该知道您正在做错事,并且应该进行修复。DI是一个常见的解决方法。这里没有太多争议。 - Dax Fohl
6个回答

19

你所关心的大部分问题似乎归结为误用或误解。

  • 更大的代码体积

    这通常是恰当尊重单一职责原则和接口隔离原则的结果。它是否显著增加呢?我怀疑不像你声称的那么大。然而,它最有可能实现的是将类分解为特定的功能,而不是拥有“万能”类来完成任何和所有任务。在大多数情况下,这是健康的关注点分离的迹象,而不是问题。

  • 像意大利千层面般复杂的代码

    再次,这很可能会让您考虑堆栈而不是难以查看的依赖性。 我认为这是一个巨大的好处,因为它导致适当的抽象和封装。

  • 性能变慢 只需使用快速容器即可。 我最喜欢的是SimpleInjector和LightInject。

  • 需要在构造函数中初始化所有依赖项,即使我要调用的方法只有一个依赖项

    同样,这表明您正在违反单一职责原则。这是一件好事,因为它迫使您在逻辑上考虑您的架构,而不是随意添加。

  • 没有使用IDE时更难理解,有些错误会推到运行时

    如果您仍然没有使用IDE,那么真丢人。对于现代计算机来说,没有好的理由。此外,一些容器(SimpleInjector)将在首次运行时进行验证(如果您选择的话)。您可以通过简单的单元测试轻松检测到这一点。

  • 添加额外的依赖项(DI框架本身)

    你必须斟酌利弊。如果学习一个新框架的成本低于维护意大利面条般复杂的代码的成本(我怀疑它会),那么成本是合理的。

  • 新员工必须先学习DI才能使用它

    如果我们回避新的模式,我们永远不会成长。我认为这是丰富和发展您的团队的机会,而不是伤害他们的方式。此外,权衡是学习可能比掌握行业通用模式更困难的混乱代码。

    很多样板代码对于有创意的人来说是不好的(例如从构造函数复制实例到属性...)

    这是完全错误的。强制依赖项应始终通过构造函数传递。只有可选依赖项应该通过属性设置,并且在非常特定的情况下才应该这样做,因为这经常违反单一责任原则。

    我们不测试整个代码库,但只测试某些方法并使用真实数据库。那么,当测试不需要模拟时,是否应避免使用依赖注入?

    我认为这可能是最大的误解。依赖注入不仅仅是为了使测试变得更容易。它是为了让您可以查看类构造函数的签名并立即知道使该类运转所需的内容。这在静态类中是不可能的,因为类可以随心所欲地调用上下堆栈而没有条理或理由。您的目标应该是为您的代码添加一致性、清晰度和区分度。这是使用DI的最大原因,也是我强烈建议您重新审视它的原因。


10
虽然IoC / DI并非适用于所有情况的银弹,但有可能您未正确应用它。依赖注入背后的原则需要时间来掌握,或者至少对我来说是这样的。当正确应用时,它可以带来以下好处(其中之一):
- 改进了可测试性 - 提高了灵活性 - 提高了可维护性 - 提高了并行开发能力
从您的问题中,我已经可以提取出一些可能出现问题的事项:
“我必须在每个类中手动初始化数十个依赖项”
这意味着您创建的每个类都负责创建其所需的依赖项。这是一种称为“控制狂”的反模式。一个类不应该自己创建其依赖项。您甚至可能应用了服务定位器反模式,其中您的类通过调用容器(或表示容器的抽象)请求其依赖项。一个类应该将其所需的依赖项定义为构造函数参数。
“数十个依赖项”
这段话意味着你违反了单一职责原则。这实际上与IoC/DI无关,你的旧代码可能已经违反了单一职责原则,导致其他开发人员难以理解和维护。通常,原始作者很难理解为什么其他人难以维护代码,因为你写的东西通常在你的头脑中非常清晰。通常,违反SRP会导致其他人难以理解和维护代码。而违反SRP的类测试通常更加困难。一个类最多应该有六个依赖项。

增加更多接口等项目

这意味着您正在违反重用抽象原则。通常,应用程序中的大多数组件/类都应该由十几个抽象覆盖。例如,实现某些用例的所有类可能值得一个单一(通用)的抽象。实现查询的类也值得一个抽象。对于我编写的系统,80%至95%的组件(包含应用程序行为的类)都由5到12个(主要是通用的)抽象覆盖。大多数情况下,您不需要仅为接口创建新项目。大多数情况下,我将这些接口放在同一项目的根目录中。

更大的代码量

你所编写的代码量最初可能并没有太大的不同。然而,依赖注入的实践只有在应用SOLID时才能发挥出它的优势,而SOLID则倡导小型专注类。只有一个单一职责的类。这意味着你将拥有许多易于理解和易于组合成灵活系统的小型类。别忘了:我们不应该追求写更少的代码,而是更易于维护的代码。
但是,通过良好的SOLID设计和正确的抽象,我发现实际上需要编写的代码比以前要少得多。例如,可以通过在应用程序的基础设施层中仅编写几行代码来应用某些横切关注点(如日志记录、审计跟踪、授权等),而不必将其分散到整个应用程序中。这甚至使我能够做以前不可行的事情,因为它们迫使我对整个代码库进行全面的变更,这是非常耗时的,管理层不允许我这样做。

相较于意大利瑞士卷饼式代码 当没有使用IDE时更难理解

这有点真实。依赖注入促进类之间解耦,但有时会使浏览代码库变得更加困难,因为一个类通常依赖于抽象而不是具体的类。过去我发现DI给我的灵活性远远超过了查找实现的成本。使用Visual Studio 2015,我可以简单地按CTRL + F12查找接口的实现。如果只有一个实现,Visual Studio将直接跳转到该实现。
较慢的性能
这是不正确的。性能与仅使用静态方法调用的代码库相比没有任何区别。但是您选择使用瞬态生命周期的类,这意味着您在各个地方都要创建新的实例。在我的上一个应用程序中,我每个应用程序只创建一次所有类,这大致与仅具有静态方法调用相同的性能,但具有非常灵活和可维护的优点。但请注意,即使您决定为每个(Web)请求“new”完整的对象图,与在该请求期间执行的任何I/O(数据库、文件系统和Web服务调用)相比,性能成本很可能低几个数量级,即使使用最慢的DI容器。

有些错误是在运行时被推送出来的 添加额外的依赖项(DI框架本身)

这些问题都意味着需要使用DI库。DI库会在运行时进行对象组合。然而,使用依赖注入时,并不一定需要DI库作为必备工具。小型应用程序可以从使用不带工具的依赖注入中受益;这被称为Pure DI。您的应用程序可能不会从使用DI容器中受益,但大多数应用程序实际上会从依赖注入(正确使用时)中受益作为一种实践。再次强调:工具是可选的,编写可维护的代码则是必须的。

但即使您使用DI库,也有一些内置工具的库可让您验证和诊断配置。它们不会给您提供编译时支持,但它们允许您在应用程序启动时或使用单元测试运行此分析。这样可以避免您对整个应用程序进行回归测试,仅仅是为了验证您的容器是否正确连接。我的建议是选择一个DI容器,以帮助您检测这些配置错误。

新员工必须首先学习DI才能开始使用它

这有点道理,但是依赖注入本身并不难学。真正难学的是正确地应用SOLID原则,而如果您想编写需要在考虑到一段时间内由多个开发人员维护的应用程序,那么您无论如何都需要学习这些内容。我更愿意投资于教导团队中的开发人员编写符合SOLID原则的代码,而不仅仅是让他们编写代码;否则,这肯定会在以后造成维护上的麻烦。
很多C# 6中的代码确实存在一些样板代码,但这并不算太糟糕,特别是当您考虑到它带来的优势时。未来的C#版本将消除样板代码,这主要是由于必须定义接受参数的构造函数而导致的空值检查和分配给私有变量。当引入记录类型和非可为空引用类型时,C# 7或8肯定会解决这个问题。
这对于有创造力的人来说不好。
抱歉,但这个论点是纯粹的胡说八道。我见过开发人员一遍又一遍地使用这个论点作为写糟糕代码的借口,他们不想学习设计模式和软件原则和实践。创造力不是编写没有人能理解或无法测试的代码的借口。我们需要应用被接受的模式和实践,在这个边界内有足够的空间进行创造性的编写,同时编写良好的代码。编写代码不是艺术;它是一门手艺。 正如我所说,DI并不适用于所有情况,围绕它的实践需要时间来掌握。我建议您阅读Mark Seemann的《.NET中的依赖注入》这本书;它将提供许多答案,并让您了解何时以及何时不适用它。

2

警告:我讨厌IoC。

这里有很多令人放心的伟大答案。根据Steven(非常强的答案)的说法,主要优点包括:

  • 提高可测试性
  • 提高灵活性
  • 提高可维护性
  • 提高可扩展性

然而我的经验与此截然不同,以下是一些平衡的观点:

(奖励) 愚蠢的Repository模式

经常会将其与IoC一起使用。 Repository模式应仅用于访问外部数据,并且可互换性是核心期望之处。

当您使用Entity Framework时,如果使用了Repository模式,则禁用了Entity Framework的所有功能,Service Layers也是如此。

例如,调用:

var employees = peopleService.GetPeople(false, false, true, true); //Terrible

应该是:

var employees = db.People.ActiveOnly().ToViewModel();

在这种情况下,使用扩展方法。
谁需要灵活性?
如果您没有计划更改服务实现,那么您不需要它。 如果您认为将来会有多个实现,则仅对该部分添加IoC。
但是“可测试性”!
Entity Framework(以及可能也适用于其他ORM),允许您更改连接字符串以指向内存数据库。尽管这只在EF7中才可用。然而,在暂存环境中,它可以是一个新的(正确的)测试数据库。
您是否拥有其他特殊的测试资源和服务点?在今天这个时代,它们可能是不同的WebService URI端点,这些端点也可以在App.Config / Web.Config中配置。
自动化测试使您的代码易于维护
TDD - 如果是Web应用程序,请使用Jasmine或Selenium并进行自动化行为测试。这样所有东西都被测试到了用户。从关键功能和功能开始,这是一项随着时间的推移而进行的投资。
DevOps/SysOps - 维护脚本来为整个环境提供规定(这也是最佳实践),启动暂存环境并运行所有测试。您还可以克隆生产环境并在那里运行测试。 不要将“可维护性”和“可测试性”作为选择IoC的借口。从这些要求开始,并找到最佳的满足这些要求的方法。
可扩展性 - 以何种方式?
(我可能需要阅读这本书)
对于编码人员可扩展性,分布式代码版本控制是常态(尽管我讨厌合并)。
对于人力资源可扩展性,您不应该浪费时间为项目设计额外的抽象层。
对于生产并发用户可扩展性,您应该构建,测试,然后改进。
对于服务器吞吐量可扩展性,您需要考虑比IoC更高级别的问题。您将在客户LAN上运行服务器吗?您能否复制您的数据?您是否在数据库级别或应用程序级别复制?离线访问在移动设备上重要吗?这些是实质性的架构问题,而IoC很少是答案。
试试F12
如果您使用IDE(应该这样做),例如Visual Studio Community Edition,则会知道F12可以快速导航代码。
使用IoC,您将进入接口,然后需要查找所有引用特定接口的引用。只是一个额外的步骤,但对于一个使用频率如此之高的工具,它令人沮丧。
史蒂文就在球场上
“使用Visual Studio 2015,我可以简单地使用CTRL + F12来查找接口的实现。”
是的,但您必须浏览使用情况和声明的列表。(实际上我认为在最新的VS中,声明会单独列出,但仍需要额外点击鼠标,将手离开键盘。我应该说这是Visual Studio的限制,无法直接带您到一个唯一的接口实现处。)

1
在使用IoC方面,有许多“教科书式”的论点,但根据我的个人经验,它的好处包括:
- 可以测试项目的部分内容,并模拟其他部分。例如,如果您有一个从数据库返回配置信息的组件,很容易对其进行模拟,以便您的测试可以在没有真实数据库的情况下工作。使用静态类无法做到这一点。 - 更好地可见和控制依赖项。使用静态类很容易添加一些依赖项而不会注意到,这可能会在后期引起问题。使用IoC可以更明确和可见地处理这些依赖关系。 - 更明确的初始化顺序。使用静态类时,这通常是一个黑匣子,因为可能存在循环使用而导致潜在问题。
唯一的不便之处是,将所有内容放在接口之前,不能直接从使用处导航到实现处(F12)。
然而,最好由项目的开发人员在特定情况下评估其利弊。

2
使用Visual Studio 2015,按下CTRL + F12将直接跳转到实现。问题解决了 ;) - Steven

1

你没有选择使用IOC库(StructureMap、Ninject、Autofac等)的原因是什么?使用它们中的任何一个都会使你的生活变得更加轻松。

虽然David L已经对你的观点做出了优秀的评论,但我也会补充自己的观点。

代码规模更大

我不确定你是如何得到更大的代码库的;IOC库的典型设置非常小,而且由于你在类构造函数中定义了你的不变量(依赖关系),所以你也删除了一些不再需要的代码(即“new xyz()”之类的东西)。

比意大利面条代码更慢

我碰巧很喜欢意大利肉酱面:)

性能较慢,需要在构造函数中初始化所有依赖项,即使我想调用的方法只有一个依赖项

如果您这样做,那么您实际上根本没有使用依赖注入。您应该通过类本身构造函数参数中声明的依赖项来接收准备好的、完全加载的对象图——而不是在构造函数中创建它们! 大多数现代IOC库速度非常快,永远不会成为性能问题。 这里有一个证明这一点的好视频。
没有使用IDE时更难理解
这是真的,但这也意味着您可以利用机会以抽象的方式思考。例如,您可以查看一段代码。
public class Something
{
    readonly IFrobber _frobber;
    public Something(IFrobber frobber)
    {
        _frobber=frobber;
    }
    
    public void LetsFrobSomething(Thing theThing)
    {
        _frobber.Frob(theThing)
    }
}

当你查看这段代码并试图确定它是否有效,或者它是否是问题的根本原因时,你可以忽略实际的IFrobber实现;它只代表抽象能力来Frob某些东西,你不需要在脑海中记住任何特定的Frobber可能如何工作。你可以集中精力确保这个类做了它应该做的事情 - 即委托一些工作给某种类型的Frobber。
还要注意,你甚至不需要在这里使用接口;你也可以注入具体实现。然而,这往往违反了依赖反转原则(与我们在这里谈论的DI只有间接关系),因为它强制该类依赖于具体实现而不是抽象。
一些错误被推到运行时
这与在构造函数中手动构建图形没有更多或更少的关系;
添加额外的依赖项(DI框架本身)
这也是真的,但大多数IOC库都非常小和不显眼,而且在某个时候你必须决定稍微增加一点生产成果的权衡是否值得(它确实值得)。

新员工需要先学习DI才能使用它

这与任何新技术的情况没有什么不同 :) 学习使用IOC库往往会开启头脑,让人想到其他可能性,如TDD、SOLID原则等等,这从来不是一件坏事!

有很多样板代码,这对于有创意的人来说很糟糕(例如将实例从构造函数复制到属性中...)

我不理解这个问题,你可能会产生大量样板代码;我不认为将给定的依赖项存储在私有只读成员中算是值得谈论的样板代码 - 请注意,如果每个类有超过3或4个依赖项,则可能违反SRP并应重新考虑设计。

最后,如果您还不相信这里提出的任何论点,我仍然建议您阅读Mark Seeman的“ .Net中的依赖注入”。(或者他在博客上发表的任何其他关于DI的观点)我保证你会学到一些有用的东西,而且我可以告诉你,它改变了我编写软件的方式。


0

如果你必须在代码中手动初始化依赖项,那么你做错了什么。IoC的一般模式是构造函数注入或者可能是属性注入。类或控制器根本不应该知道DI容器。

通常,你所需要做的就是:

  1. 配置容器,例如Interface = Class in Singleton scope
  2. 使用它,例如Controller(Interface interface) {}
  3. 从一个地方控制所有依赖关系的好处

我没有看到任何样板代码、性能下降或其他你所描述的问题。我真的无法想象如何编写更复杂或更简单的应用程序而没有它。

但总的来说,你需要决定什么更重要。是取悦“有创意的人”还是建立可维护和强大的应用程序。

顺便说一句,要从构造函数创建属性或字段,你可以在R#中使用Alt+Enter,它会为你完成所有工作。


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