Pythonic依赖注入:实用指南

上世纪90年代,Bob Martin提出了一个特别简单但有用的原理来解耦软件组件:

  • 高级模块不应依赖于低级模块。 两者都应依赖抽象。
  • 抽象不应依赖细节。 细节应取决于抽象。

在用于软件开发的SOLID框架中,这被称为依赖性反转原理。 依赖注入是一种设计模式,支持设计遵循此原理的软件组件。

在本文中,我们将研究一些在Python中进行依赖项注入的选项,其中一个有用的支持PEP484的Python 3框架。 作为运行示例,我们将编写一个程序,该程序通过Web API远程控制围栏防护机器人。

为此,我们将编写一个使用requests的小型API客户端:

 汇入要求 
  RobotApi类: 
url = 'http://robot-api.com'

def send_command(自己,命令,数据):
formatted_url = f'{self.url} / {command}'
requests.post(formatted_url,data = data)

为了控制机器人,我们编写了用于高级运动操作的通用类:

  RobotControls类: 
def __init __(self,api):
self.api = api
  def move_west(): 
self.api.send_command( 'move_west' ,data = { 'meters' :1})

def move_east():
self.api.send_command( 'move_east' ,data = { 'meters' :1})

def move_north(self):
self.api.send_command( 'move_north' ,data = { 'meters' :1})

def move_south():
self.api.send_command( 'move_south' ,data = { 'meters' :1})

在此示例中, RobotControls类隐式依赖于定义send_command方法的抽象接口。 由于RobotControls不使用任何导入,而是通过其构造函数接收此依赖关系,因此我们非常夸张地说,该依赖关系已注入。

以这种方式注入依赖项具有许多优点:

  • RobotControls类变得可配置,因此可重用。 通过更改通过__init__注入的api实例,我们可以更改RobotControls的整体行为
  • RobotControls类变得易于单元测试,尤其是在api或任何api依赖项使用IO操作的情况下,因为我们可以轻松地用模拟或存根IO的版本替换该依赖项。
  • 使用依赖注入可以鼓励我们更加注意RobotControls中的RobotControls以及依赖项中的职责,从而增加程序中各个单元的整体凝聚力和封装性。

通过__init__注入依赖项的主要缺点是,它要求我们构造一个具有send_command方法的对象,然后才能实例化RobotControls 。 在我们的示例中,这似乎不是一个大问题,但请考虑以下情况:我们要将RobotControls注入到新类中,并且希望将该类作为依赖项注入到另一个类中,依此类推。 以我们的FenceGuardingRobot类为例:

  FenceGuardingRobot类: 
fence_length = 20
  def __init __(自己,robot_controls): 
self.robot_controls =机器人控制
 防御者(自己): 
对于范围内的_(self.fence_length):
self.robot_controls.move_north()
对于范围内的_(self.fence_length):
self.robot_controls.move_south()

为了实例化此类,我们现在必须编写:

  fence_guarding_robot = FenceGuardingRobot(RobotControls(RobotApi())) 

不得不手动构造这种依赖关系图的烦恼是为什么随着代码库的增大,在许多项目中普遍使用依赖关系注入的良好意图经常消失的原因之一。 您当然可以使用默认参数值来减少此问题,但是您冒着针对具体而非抽象进行编程的风险。

避免这种痛苦的一种方法是使用围绕Python中的super关键字构建的模式。 super关键字在Python中的含义与许多其他面向对象的语言中的含义略有不同。 当调用super ,Python将计算调用类的基类的所谓线性化 ,这简单地意味着Python会弄清楚应该在基类中查找名称的顺序。 在Python中,这称为方法解析顺序。 我们可以将示例更改为通过super使用依赖项注入,如下所示:

 汇入要求 


RobotApi类:
url = 'http://robot-api.com'

def send_command(自己,命令,数据):
formatted_url = f'{self.url} / {command}'
requests.post(formatted_url,data = data)


RobotControls(RobotApi)类:
def move_west():
super()。send_command( 'move_west' ,data = { 'meters' :1})

def move_east():
super()。send_command( 'move_east' ,data = { 'meters' :1})

def move_north(self):
super()。send_command( 'move_north' ,data = { 'meters' :1})

def move_south():
super()。send_command( 'move_south' ,data = { 'meters' :1})


FenceGuardingRobot(RobotControls)类:
fence_length = 20

防御者(自己):
对于范围内的_(self.fence_length):
super()。move_north()
对于范围内的_(self.fence_length):
super()。move_south()

您可以使用内置的help方法查看类型的方法解析顺序:

 帮助(FenceGuardingRobot) 

输出:

  FenceGuardingRobot(RobotControls)类 
| 方法解析顺序:
| 护栏机器人
| 机械手控制
| RobotApi
| Builtins.object
|
| 此处定义的方法:
|
| 守卫(个体经营)
|
| -------------------------------------------------- --------------------
| 此处定义的数据和其他属性:
|
| fence_length = 20
|
| -------------------------------------------------- --------------------
| 从RobotControls继承的方法:
|
| move_east(个体)
|
| move_north(个体)
|
| move_south(个体)
|
| move_west(个体)
|
| -------------------------------------------------- --------------------
| 从RobotApi继承的方法:
|
| send_command(自己,命令,数据)
|
| -------------------------------------------------- --------------------
| 从RobotApi继承的数据描述符:
|
| __dict__
| 实例变量字典(如果已定义)
|
| __弱引用__
| 对对象的弱引用列表(如果已定义)
|
| -------------------------------------------------- --------------------
| 从RobotApi继承的数据和其他属性:
|
| url ='http://robot-api.com'

在“ Method resolution order部分中,我们可以看到,当使用super查找名称时,Python将首先在FenceGuardingRobot查找名称,然后在FenceGuardingRobot RobotControls ,然后在RobotApi ,然后在object RobotApi

现在,要实例化FenceGuardRobot ,我们要做的就是编写FenceGuardRobot() 。 如果要替换FenceGuardRobot的依赖关系(例如,模拟出RobotApi类),我们要做的就是定义一个新类型,该类型send_command化的早期添加一个新的send_command方法:

 从unittest.mock导入Mock 


类MockRobotApi(RobotApi):
send_command = Mock()


类MockedFenceGuardingRobot(FenceGuardingRobot,MockRobotApi):
通过

如果我们检查MockedFenceGuardingRobot的方法解析顺序,我们会看到MockRobotApiRobotApi之前:

 帮助(MockedFenceGuardingRobot) 

输出:

  MockedFenceGuardingRobot类(FenceGuardingRobot,MockRobotApi) 
| 方法解析顺序:
| 模拟围栏机器人
| 护栏机器人
| 机械手控制
| 模拟机器人
| RobotApi
| Builtins.object
  ... 

请注意, MockRobotApi必须继承自RobotApi ,以使方法解析按此顺序进行。 这是python用于构造线性化算法的结果。

与通过__init__注入依赖项的简单方法相比,使用super进行依赖项注入的模式的主要优势在于,我们不必手动构造依赖关系图。 所有的连线都由super的语义处理。

这种方法有一些缺点。 首先,最不清楚的是哪些名称由哪些依赖项提供。 在我们的示例中,很明显,因为所有类都只具有一个依赖项,但是当每个类具有多个依赖项时,事情就会变得很倾斜。 例如,如果FenceGuardingRobot具有多个依赖关系,则仅通过查看定义就无法分辨出MockedFenceGuardingRobot正在嘲笑类的哪一部分。

其次,必须将所有参与该模式的类设计为多重继承。 具体来说,如果一个类通过__init__接受参数,则必须小心也接受*args**kwargs并调用super().__init__(*args, **kwargs)即使该类自注入后不继承任何东西类可能不是线性化中的最后一个类:

  A类: 
def __init __(self,a,* args,** kwargs):
self.a = a
super().__ init __(* args,** kwargs)
  B级: 
def __init __(self,b):
self.b = b
super().__ init __(* args,** kwargs)
  C(A,B)级: 
通过

如果您需要以这种方式注入不是为多重继承设计的类,则需要为其编写一个适配器类。 有关示例,请参见原始文章。

最后,您不再针对抽象而是针对具体概念进行编码。 您可以使用abc模块在Python中编写抽象类,但是根据用于检查未实现成员的工具的不同,您可能必须将依赖项的抽象成员重新声明为依赖类中的抽象。

作为第三个选择,您可以使用一个库。 存在多种可能性,但我将介绍我最熟悉的一种: serum (完整披露:我是作者)。

serum尝试从前两种依赖注入方法中吸取两全其美的优势:它可以处理所有连接,因此您不必自己手动构建依赖图,而是使用合成而不是继承来构建。

我们可以重写示例以使用serum ,如下所示:

 来自血清进口注射液,成分,环境 

类RobotApi(Component):
url = 'http://robot-api.com'

def send_command(自己,命令,数据):
formatted_url = f'{self.url} / {command}'
requests.post(formatted_url,data = data)

RobotControls(Component)类:
api =注入(RobotApi)
  def move_west(): 
self.api.send_command( 'move_west' ,data = { 'meters' :1})

def move_east():
self.api.send_command( 'move_east' ,data = { 'meters' :1})

def move_north(self):
self.api.send_command( 'move_north' ,data = { 'meters' :1})

def move_south():
self.api.send_command( 'move_south' ,data = { 'meters' :1})

FenceGuardingRobot类:
robot_controls =注入(RobotControls)
fence_length = 20
 防御者(自己): 
对于范围内的_(self.fence_length):
self.robot_controls.move_north()
对于范围内的_(self.fence_length):
self.robot_controls.move_south()

要模拟出RobotApi我们可以执行以下操作:

 类MockRobotApi(RobotApi): 
send_command = Mock()
 与环境(MockRobotApi): 
断言isinstance(
FenceGuardingRobot()。robot_controls.robot_api,
模拟机器人

甚至

 从血清导入模拟 
从unittest.mock导入MagicMock

使用Environment():
模拟(RobotApi)
断言isinstance(
FenceGuardingRobot()。robot_controls.robot_api,
魔术模拟

使用serum而不是像super这样的语言功能进行依赖注入的主要缺点是可注入类必须从serum.Component继承。 serum.Component主要增加了约束,即类的__init__方法只能采用self参数。 这对于使serum所使用的惰性依赖注入机制成为可能是必要的。

您可以通过按字符串名称而不是按类型注入依赖项来解决此问题,但这会禁用一些与PEP 484相关的serum特征:

 从血清进口环境中注入 
  NotAComponent类: 
通过
 实例= NotAComponent() 
与环境(依赖关系=实例):
断言inject('dependency')是实例

到此,我们结束了Python依赖注入机制的介绍。 总而言之,您可以使用__init__但这通常会导致讨厌的代码连接您的应用程序。 您可以使用super但是从某种意义上说很难分辨出哪个依赖项提供了哪个名称,这通常会导致倾斜的依赖项。 最后,您可以使用结合了两全其美的类似serum的库。 有关更多信息,发出跟踪和请求serum请求,请参阅GitHub页面。

更新04/18/18

我刚刚发布了4.0.0版本的serum 。 此版本旨在使该框架在客户端代码中的侵入性大大降低。 在此版本中,您可以使用纯python的注释语法定义依赖类和依赖项:

  RobotApi类: 
...
  FenceGuardingRobot类: 
api:RobotAPi
...

然后使用装饰器开始使用serum

 从血清进口依赖性,注射 
  @dependency 
RobotApi类:
...
  @注入 
FenceGuardingRobot类:
api:RobotApi

断言isinstance(FenceGuardingRobot()。api,RobotApi)

就是这样!