依赖注入是旨在减少组件连接性的面向对象编程中的一种常用技术。 如果使用正确,除了可以实现此目标外,它还可以为您的应用程序带来真正的神奇品质。 像任何魔术一样,该技术被认为是一组咒语,而不是严格的科学论文。 这导致对现象的错误解释,并因此导致对伪影的滥用。 在我的创作材料中,我建议读者简要而本质上逐步地走逻辑路线,从面向对象设计的适当基础到自动依赖注入的魔力。
该材料基于
Hypo IoC容器的开发,我在
上一篇文章中提到
过 。 在微型代码示例中,我将使用Ruby作为编写简短示例的最简洁的面向对象语言之一。 这不会给其他语言的开发人员造成问题。
级别1:依赖倒置原则
面向对象范例中的开发人员每天都要面对对象的创建,而对象的创建又可能依赖于其他对象。 这导致依赖图。 假设我们正在处理以下形式的对象模型:
-一些计费服务(InvoiceProcessor)和通知服务(NotificationService)。 当满足某些条件时,发票处理服务会发送通知,我们将使此逻辑超出范围。 原则上,该模型已经很好,因为各个组件负责不同的职责。 问题在于我们如何实现这些依赖关系。 一个常见的错误是在使用此依赖项时初始化了一个依赖项:
class InvoiceProcessor def process(invoice)
鉴于我们获得了逻辑上独立的对象的高连接性(High Coupling),这是一个错误。 这导致违反“单一责任原则”-从属对象除了其直接职责外,还必须初始化其从属关系; 并且“知道”依赖构造函数的接口,这将导致更改的其他原因(
“更改原因”,R。Martin )。 传递在依赖对象外部初始化的这种依赖关系是更正确的:
class InvoiceProcessor def initialize(notificationService) @notificationService = notificationService end def process(invoice) @notificationService.notify(invoice.owner) end end notificationService = NotificationService.new invoiceProcessor = InvoiceProcessor.new(notificationService)
此方法与依赖倒置原则一致。 现在,我们正在通过消息发送接口传输对象-计费服务不再需要“知道”如何构造通知服务对象。 在编写发票处理服务的单元测试时,开发人员不必为如何用存根替换通知服务接口的实现而困惑。 在具有动态类型的语言(例如Ruby)中,您可以替换满足notify方法的任何对象; 通过使用静态类型(例如C#/ Java),可以使用INotificationService接口,为此可以轻松创建Mock。 亚历山大·比恩秋(Alexander Byndyu)
在最近庆祝成立十周年
的一篇文章中详细披露了依赖倒置的问题!
级别2:相关对象的注册表
使用依赖反转的原理似乎并不复杂。 但是随着时间的流逝,由于对象和关系数量的增加,出现了新的挑战。 NotificationService可以由InvoiceProcessor以外的服务使用。 此外,他本人可能还依赖于其他服务,而其他服务又依赖于第三服务等。 同样,某些组件可能并不总是在单个副本中使用。 主要任务是找到问题的答案-“何时创建依赖关系?”。
要解决此问题,您可以尝试基于相关性的关联数组来构建解决方案。 他的工作的示例界面可能如下所示:
registry.add(InvoiceProcessor) .depends_on(NotificationService) registry.add(NotificationService) .depends_on(ServiceX) invoiceProcessor = registry.resolve(InvoiceProcessor) invoiceProcessor.process(invoice)
在实践中不难实现:
每次调用container.resolve()时,我们将转到工厂,该工厂将创建依赖项实例,以递归方式绕过注册表中描述的依赖关系图。 对于container.resolve(InvoiceProcessor),将执行以下操作:
- factory.resolve(InvoiceProcessor)-工厂在寄存器中请求InvoiceProcessor依赖项,接收NotificationService,这也需要进行组装。
- factory.resolve(NotificationService)-工厂在寄存器中请求NotificationService依赖项,接收ServiceX,这也需要进行组装。
- factory.resolve(ServiceX)-没有依赖关系,可以创建,然后在调用堆栈上返回到步骤1,获取InvoiceProcessor类型的组装对象。
每个组件可能都依赖于其他几个组件,因此显而易见的问题是“如何将设计器参数与生成的依赖项实例正确匹配?”。 一个例子:
class InvoiceProcessor def initialize(notificationService, paymentService)
在具有静态类型的语言中,参数类型可以用作选择器:
class InvoiceProcessor { constructor(notificationService: NotificationService, paymentService: PaymentService) {
在Ruby中,您可以使用约定-只需使用snake_case格式的类型名称,这将是预期的参数名称。
级别3:依赖项生存期管理
我们已经有了一个好的依赖管理解决方案。 它的唯一限制是需要在每次调用时创建依赖关系的新实例。 但是,如果我们不能创建一个组件的多个实例怎么办? 例如,与数据库的连接池。 深入探讨,是否需要提供受控的依赖项生存期? 例如,在HTTP请求完成后,关闭与数据库的连接。
很明显,在原始解决方案中要替换的候选对象是InstanceFactory。 更新的图表:
逻辑解决方案是使用一组策略(
Strategy,GoF )获取组件实例。 现在,我们在调用Container :: resolve时并不总是创建新实例,因此将Factory重命名为Resolver是适当的。 请注意,Container :: register方法具有一个新参数-life_time(lifetime)。 该参数是可选的-默认情况下,其值为“ transient”(瞬变),与先前实现的行为相对应。 单例策略也很明显-通过使用它,仅创建了该组件的一个实例,每次都会返回该实例。
范围是一个稍微复杂的策略。 通常需要使用介于两者之间的某种东西来代替“瞬态路径”和“独来独往的东西”,而这种组件在另一个组件的整个生命周期中都存在。 一个类似的示例可以是Web应用程序请求对象,它是诸如HTTP参数,数据库连接,模型聚合之类的对象存在的上下文。 在请求的整个生命周期中,我们收集并使用这些依赖项,并且在销毁这些依赖关系之后,我们希望所有这些依赖关系也将被销毁。 为了实现这种功能,有必要开发一个相当复杂的封闭对象结构:
该图显示了一个片段,该片段反映了在范围有效期的实现范围内Component和LifetimeStrategy类中的更改。 结果是一种“双桥”(类似于
Bridge,GoF模板)。 使用继承和聚合技术的复杂性,Component成为容器的核心。 顺便说一下,该图具有多个继承。 在编程语言和良心允许的地方,您可以那样做。 在Ruby中,我使用杂质,在其他语言中,您可以用另一个桥代替继承:
序列图显示了会话组件的生命周期,该生命周期与请求组件的生命周期相关:

从图中可以看到,在某个时间点上,当请求组件完成其任务时,将调用release方法,这将开始破坏作用域的过程。
第4级:依赖注入
到目前为止,我还讨论了如何确定依赖项注册表,然后如何根据形成的关系图创建和销毁组件。 那是为了什么? 假设我们将其用作Ruby on Rails的一部分:
class InvoiceController < ApplicationController def pay(params) invoice_repository = registry.resolve(InvoiceRepository) invoice_processor = registry.resolve(InvoiceProcessor) invoice = invoice_repository.find(params[:id]) invoice_processor.pay(invoice) end end
用这种方式编写的代码将不会更具可读性,可测试性或灵活性。 我们不能“强制” Rails通过其构造函数注入控制器依赖项;框架未提供。 但是,例如,在ASP.NET MVC中,这是在基本级别上实现的。 为了充分利用自动依赖项解析机制,您需要实现控制反转(IoC,控制反转)技术。 在这种方法中,解决依赖关系的责任超出了应用程序代码的范围,并由框架承担。 考虑一个例子。
想象一下,我们从头开始设计Rails之类的东西。 我们实施以下方案:
应用程序收到请求,路由器检索参数并指示适当的控制器处理此请求。 这样的方案有条件地复制了典型Web框架的行为,只有很小的差别-IoC容器涉及创建和实现依赖项。 但是这里出现了一个问题,容器本身是在哪里创建的? 为了覆盖将来应用程序的尽可能多的对象,我们的框架必须在其操作的早期阶段创建一个容器。 显然,没有比应用程序构建器App更合适的地方了。 这也是配置所有依赖项的最合适的位置:
class App
任何应用程序都有一个入口点,例如main方法。 在此示例中,入口点是调用方法。 此方法的目的是调用路由器以处理传入的请求。 入口点应该是直接调用容器的唯一位置-从那一刻起,容器应该越过路,所有随后的魔术都应在“引擎盖下”发生。 在这种架构中,控制器的实现确实看起来很不寻常。 尽管我们没有显式实例化它,但它具有带有参数的构造函数:
class Controller
该环境“了解”如何创建控制器实例。 由于嵌入在Web应用程序核心中的IoC容器提供的依赖项注入机制,这才有可能实现。 现在,在控制器的构造函数中,您可以列出其操作所需的所有内容。 最主要的是,相应的组件已在容器中注册。 现在转到路由器实现:
class Router
请注意,路由器取决于控制器。 如果我们回顾依赖项设置,那么Controller是一个短暂的组件,而Router是一个常数。 怎么会这样 答案是,从外部看,组件不是相应类的实例。 实际上,这些是具有工厂方法(
Factory Method,GoF )实例的代理对象(
Proxy,GoF ); 它们根据分配的策略返回组件的实例。 由于控制器已注册为“瞬态”,因此路由器在访问新实例时将始终对其进行处理。 顺序图显示了一种大概的工作机制:
即 除了依赖关系管理之外,基于IoC容器的良好框架还负责正确管理组件的寿命。
结论
依赖注入技术可以具有相当复杂的内部实现。 这是将实现灵活应用程序的复杂性转移到框架核心的代价。 这样的框架的用户不必担心纯粹的技术方面,而可以花更多的时间来舒适地开发应用程序的业务逻辑。 应用程序程序员使用高质量的DI实现,首先编写可测试的,受支持的代码。 我以前的文章
Orthodox Backend中描述的
Dandy框架是依赖注入实现的一个很好的例子。