为何在Python生态很少听说到依赖注入

本文总阅读量

前记

由于使用的主要编程语言是Python,所以对于依赖注入这个概念并不是很清楚或者不知道自己已经在代码中运用了依赖注入的用法,在接触了DDD后才开始真正的了解什么是依赖注入以及依赖注入的重要性,同时也很好奇为何在Python 生态中依赖注入的出现率较低。

1.控制反转与依赖注入

在查找依赖注入的相关资料时发现依赖注入与控制反转这两个词是成对出现的,这是因为控制反转并不是一种技术,而是一种编程思想,这种思想能指导我们如何设计出松耦合优良的程序,而依赖注入是一个具体的设计模式,它是控制反转的一种具体实现。

控制反转这个概念有点模糊,但后端开发者来说却是经常接触到的,比如在对框架与库的使用时,分别接触到他们的反向控制和正向控制,而框架的反向控制正是控制反转的体现。
大多数人很少去区分在项目中使用的包是属于框架还是库,也不会很清晰的去区分它们属于哪一种,我也是这样的,在经过了一段编程生涯后我才可以简单的把主动调用的包归类为库,只能被动调用的包归类为框架。

不过最近在翻阅依赖注入与控制反转的相关文章InversionOfControl时发现,控制反转也可以是框架和库的关键区别点。因为对于一个库来说,程序员使用的方式是主动的调用它,如下代码:

1
2
3
4
import httpx

response = httpx.get("https://so1n.me")
print(response.status_code)

这段代码主动的调用httpx包的get方法发起一个请求以获取网站对应的状态码,由于这种调用方法属于开发者去主动调用库,所以属于正向的控制。

而框架就不一样了,框架一般都会提供一些注册的方法将我们编写的代码注册到框架中,最后由框架来调用程序员编写的代码,如下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import PlainTextResponse


def demo(request: Request) -> PlainTextResponse:
return PlainTextResponse(f"Hello {request.query_params.get('name', '')}!")


def create_app() -> Starlette:
app: Starlette = Starlette()
app.add_route("/", demo, methods=["GET"])
return app


if __name__ == "__main__":
import uvicorn

app: Starlette = create_app()
uvicorn.run(app)

这段代码中先是声明一个路由函数demo,这个路由函数是按照框架要求的方式编写的,这个要求是路由函数必须接收一个Request参数以及返回一个Response类;接着在实例化框架时通过add_route方法以path=/method=GET的形式注册到框架中以及在调用uvicorn.run(app)的时候把控制权转移给了框架,并由框架在后续完成对demo路由函数的调用,这种调用方式属于反向控制。

Note: 对于流式客户端封装的库可能包含着主动调用与被动调用,使其不像框架也不像库。

此外,从例子中可以看到,创建的demo路由函数是交给了框架控制的,不再由开发者控制,而且demo路由函数接收的Request类参数是在运行时由框架管理创建和注销并注入给路由函数供路由函数使用的,这就是控制反转的主体思想,通过这种思想能公减少工程项目不同层次代码打耦合。

依赖注入则是控制反转的一种具体实现方式,这种方式能让一个对象接收它所依赖的其他对象。其中“依赖”是指接收方所需的对象,“注入”是指将“依赖”传递给接收方的过程。在“注入”之后,接收方才会调用该“依赖”。
而依赖注入框架则是一种根据对象的依赖关系的在运行时进行绑定的技术,通常它都会带有一个容器,这个容器托管着许多对象,并在运行时根据对象的依赖关系把对象传递给被控制的其它对象中。比如例子中的starlette框架在运行时就是一个容器,它可以根据不同的请求创建不同的请求对象并在根据请求规则匹配到对应的路由函数后把请求对象注入给路由函数使用。

2.为什么需要控制反转与依赖注入

以一个客户端的设计为例,通常一个客户端会分为调用层,协议层和连接层3层,通常情况下都会这样去实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from typing import Any


class Connection(object):
def __init__(self, host: str, port: int):
self._host: str = host
self._port: int = port

def send(self, data: Any) -> None:
"""发送数据到服务端"""
pass

def read(self) -> Any:
"""从服务端接收数据"""
pass


class Protocol(object):
def __init__(self, host: str, port: int):
self._conn: Connection = Connection(host, port)

def request(self, *args: Any, **kwargs: Any) -> Any:
"""发送请求并等待响应"""
pass


class Client(object):
def __init__(self, host: str, port: int):
self._protocol: Protocol = Protocol(host, port)

这段代码中分别包括了客户端的连接层的Connection,协议层的Protocol以及调用层的Client,对于使用者来说,他们不用去过于了解ConnectionProtocol的实现,只需要知道hostport的参数要填什么以及Client该如何使用即可。

不过这段代码却出现了一条依赖链(Client依赖于ProtocolProtocol依赖于Connection),同时客户端里面的不同层都把控了对下层对象的创建主动权,创建主动时机以及使用权,这样会造成上下层对象有很强的耦合,导致代码比较难维护。
比如要在Connection增加一个SSL功能,这个功能需要一些SSL参数,那么三层都要进行更改,使用者才可以把SSL参数传给Connection,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from typing import Any


class Connection(object):
def __init__(self, host: str, port: int, ssl: Any): # <--
self._host: str = host
self._port: int = port
self._ssl: Any = ssl

def send(self, data: Any) -> None:
"""发送数据到服务端"""
pass

def read(self) -> Any:
"""从服务端接收数据"""
pass


class Protocol(object):
def __init__(self, host: str, port: int, ssl: Any): # <--
self._conn: Connection = Connection(host, port, ssl) # <--

def request(self, *args: Any, **kwargs: Any) -> Any:
"""发送请求并等待响应"""
pass


class Client(object):
def __init__(self, host: str, port: int, ssl: Any): # <--
self._protocol: Protocol = Protocol(host, port, Any) # <--

通过这段代码可以看到,为了让连接层支持SSL功能,需要从Client开始一层一层的把SSL功能的参数传递下去,这还好只是分了三层,比较容易操作,如果分的层次比较多,将会非常的难受,而且在工程项目中,一个类可能会被不同的类所依赖的,这意味着为一个基础类增减某些功能会导致其它依赖它的类也要进行修改,这会浪费大量的开发时间和测试时间,同时使依赖它的类都需要发生变动,这会增加系统出现BUG的风险。

为了解决这个问题,可以根据控制反转的思想,把上层对象创建下层对象的权利和创建时机转移给第三方来控制,仅保留上层对象对下层对象的使用权,修改完的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from typing import Any


class Connection(object):
def __init__(self, host: str, port: int, ssl: Any): # <--
self._host: str = host
self._port: int = port
self._ssl: Any = ssl

def send(self, data: Any) -> None:
"""发送数据到服务端"""
pass

def read(self) -> Any:
"""从服务端接收数据"""
pass


class Protocol(object):
def __init__(self, connection: Connection): # <--
self._conn: Connection = connection # <--

def request(self, *args: Any, **kwargs: Any) -> Any:
"""发送请求并等待响应"""
pass


class Client(object):
def __init__(self, protocol: Protocol): # <--
self._protocol: Protocol = protocol # <--

可以发现,这段代码经过变更后,每一层只接收自己需要依赖的对象,在这种设计下可以在ClientProtocol代码没变动的情况下同时接收实现SSL功能和没实现SSL功能等实现了sendread方法的Connection对象。

接下来只要通过第三方来创建对象即可,代码如下:

1
2
3
4
5
6
7
8
9
10
11
client: Client = Client(
protocol=Protocol(
connection=Connection("127.0.0.1", 8000, object())
)
)

no_ssl_client: Client = Client(
protocol=Protocol(
connection=NoSslConnection("127.0.0.1", 8000)
)
)

这段代码中管理依赖对象的生命周期以及对象的关系全靠手动编写代码,使其在运行时完成对象绑定的,如果项目中分了很多层或者依赖关系比较复杂的话,手动处理会比较麻烦,也不方便后续的迭代。
这时就需要通过依赖注入框架来帮忙自动整理依赖关系以及注入到需要的对象中,比如在使用dependency-injector这个依赖注入框架后,代码就可以变为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from dependency_injector import containers, providers
from dependency_injector.wiring import Provide, inject

class Container(containers.DeclarativeContainer):
# 依赖注入框架提供的容器,该容器会管理对象的创建

# 用于加载配置
config = providers.Configuration()

# 通过`providers.Singleton`创建的对象在整个生命周期中会保持唯一(单例)
conn = providers.Singleton(
Connection ,
host=config.host,
port=config.port,
)
# 创建protocol层的工厂函数
protocol = providers.Factory(
Protocol,
connection=Connection,
)

# 创建client层的工厂函数
client = providers.Factory(
Client,
protocol=protocol,
)

# 注入装饰器,可以自动的把对应的值注入到被装饰的函数中
@inject
def main(client: Client = Provide[Container.client]) -> None:
...

if __name__ == "__main__":
# 初始化容器
container = Container()
# 通过env加载配置
container.config.api_key.from_env("host", required=True)
container.config.timeout.from_env("port", as_=int, default=4)
# 通过wire把容器与模块连接起来,这样该模块的`inject`装饰器可以读到对应的容器
container.wire(modules=[__name__])

main() # 通过带有SSL功能的Connection运行代码

with container.conn.override(NoSslConnection("127.0.0.1", 8000)):
main() # 通过没有SSL功能的Connection运行代码

通过这段代码可以发现,每个对象都存在于容器Container中,并通过inject装饰器就可以自动的把依赖对象进行绑定,不再需要手动处理,在依赖层级比较深的时候能缓解开发者的心智负担,同时通过override语法可以很方便的在新的作用域替换其中的一个依赖对象。

3.为何在Python生态中很少听到依赖注入

了解了依赖注入与控制反转后可以发现,在Python生态也会通过控制反转的方式去进行解耦,但是很少有人会直接说自己用了依赖注入来解决上述的问题。

会这样的第一个原因是大部分被依赖注入容器托管的对象都被要求是单例的,而Python的每个模块中的对象也都是单例的,这样一来实现工程项目就会比较方便,比如下面一个Web项目的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# orm.py
class Orm(object):
pass


orm: Orm = Orm()

# route.py
from starlette.requests import Request
from starlette.responses import PlainTextResponse
from orm import orm


def demo(request: Request) -> PlainTextResponse:
name: str = request.query_params.get("name", "")
_id: int = orm.user.get(name)
return PlainTextResponse(f"Hello {_id}!")

这个例子是Python开发者常用的开发模式,在这个例子中有ormroute两个模块,这时如果把Python的运行时环境认为是一个大的依赖注入容器,把orm模块和route模块认为是容器的托管的对象,把route模块引用到了orm模块则认为是依赖注入容器把orm注入到了route模块中时可以发现,Python开发者在不经意间就实现类似于静态语言的依赖注入了。

除此之外,还有重要的一点是在静态语言中,编译期,装载期和运行时期都是严格分离的,无法在运行期执行装载期的工作,这样就需要依赖注入容器通过反射来进行处理,比如JavaSpring框架,而Python是一门动态语言,它的运行时环境可以认为是一个大的依赖注入容器,所以Python可以在运行时替换某个对象,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  
import requests
import httpx


class Demo(object):
def __init__(self):
self._get = requests.get

def __call__(self, *args, **kwargs) -> Any:
return self._get(*args, **kwargs)

print(type(Demo()("https://so1n.me")))
# >>> <class 'requests.models.Response'>
requests.get = httpx.get
print(type(Demo()("https://so1n.me")))
# >>> <class 'httpx.Response'>

在这段代码中,由于httpx库采用了类似requests库的实现,所以在使用上差别不大,在Python中可以简单的对依赖对象进行替换,通过输出结果可以看出,没替换前返回的响应结果类型是requests.model.Respnose而替换后响应结果类型是httpx.Response,证明已经替换成功了。

所以Python开发者可以通过Python的语法特性快速的实现静态语言中依赖注入容器的相关功能了,可以认为在Python中依赖注入是很常见的,但是因为太常见了,而且只需要用到Python的语言特性就可以实现依赖注入容器的功能,导致没有那么多人知道自己已经使用了依赖注入,也没必要用到依赖注入框架,所以讨论的热度会比较低,这可能就是在Python生态中很少听到依赖注入的原因吧。

总结

可以看出通过控制反转可以很方便的把对象之中的依赖进行解耦,方便项目的迭代开发,而依赖注入是控制反转的具体实现,在静态语言中通过依赖注入可以将对象的索取从编译期和装载期移到了运行期。而Python由于本身是动态语言以及自己的语言特性,开发者会通过常见的开发模式来达到静态语言依赖注入的类似需求,所以在Python生态中很少听到依赖注入,不过对于DDD领域设计开发则是一个例外。

查看评论