python dependency injection
我记得十年前第一次在代码里揉进依赖注入(Dependency Injection,简称DI)的时候,团队里一位老前辈瞥了一眼我的代码,慢悠悠说了句:“你这不是在写代码,你是在搭乐高。”当时我还不太懂,后来才慢慢品出味道。依赖注入这玩意儿,本质上跟乐高积木的思路差不多——零件之间互相不粘死,靠接口和插槽来连接,想换哪个模块就换哪个模块,桌子上的模型不用拆了重来。
这东西说白了就是对象不在自己内部创建依赖,而是从外面把依赖“注入”进来。打个比方,你开一家早点摊,炸油条需要面粉,你是自己去磨面粉,还是打个电话让面粉厂送过来?自己磨就是对象的内部创建,打电话让人送就是依赖注入。面粉厂换了一家,你的油条口味变了,但你的油锅、案板都不用动——这就是DI的好处。咱写代码常犯的毛病,就是在类的__init__里直接把依赖对象new出来,像是锅里直接把面粉搓进去了,换个面粉还得重新洗锅。
DI能解决的核心问题,简单说起来就三件事:解耦、可测试性、灵活配置。解耦最直观,两个模块间的依赖关系变成“我有一个接口,你需要实现这个接口”;可测试性更是DI的成名绝技,单元测试时你可以把数据库操作模块换成内存版的模拟对象,业务逻辑完全不用改;灵活配置最常用在框架层面,不同的环境加载不同的组件,比如测试环境用SQLite、生产环境用PostgreSQL,代码完全不用动。不少Java背景的同学刚接触Python时会觉得DI有点多余,毕竟Python有猴子补丁、有动态类型,但真正大规模项目跑起来,显式的依赖注入比靠全局变量或隐式共享状态稳妥得多。
具体怎么用,完全取决于项目复杂程度。小型脚本、两三天的数据处理任务,硬写DI反而有点过度设计。但一旦项目复杂到十来个类互相牵扯,DI的优势就出来了。最朴素的做法是自己实现一个简易的DI容器。举个例子,把一个发送消息的功能从具体的邮件通知抽象出来:
classMessageSender:defsend(self,message):raiseNotImplementedErrorclassEmailSender(MessageSender):defsend(self,message):# 实际发邮件classSMSSender(MessageSender):defsend(self,message):# 实际发短信classNotificationService:def__init__(self,sender:MessageSender):self._sender=senderdefnotify(self,user,msg):self._sender.send(msg)一旦写好了这个接口,后面的所有业务逻辑都不用关心具体的发送方式。需要测试时,直接传一个模拟的FakeSender进去。要是连容器都要自己手写,可以写个超简单的类:
classSimpleContainer:def__init__(self):self._registry={}defregister(self,name,impl):self._registry[name]=impldefresolve(self,name):returnself._registry[name]()当然,项目再复杂一些,或者团队不止一个人开发,就可以考虑用现成的库了。Python社区里比较成熟的DI框架有dependency-injector、injector、python-dependency-injector等等。dependency-injector算是功能最全的,支持声明式配置、多作用域、异步注入,Django或Flask项目里直接集成也不费什么力气。举个快速上手的例子:
fromdependency_injectorimportcontainers,providersfromdependency_injector.wiringimportinject,ProvideclassContainer(containers.DeclarativeContainer):config=providers.Configuration()session_factory=providers.Factory(Session)user_repo=providers.Factory(UserRepository,session=session_factory)auth_service=providers.Factory(AuthService,user_repo=user_repo)@injectdeflogin_handler(email:str,auth_service:AuthService=Provide[Container.auth_service]):returnauth_service.authenticate(email)这段代码里,auth_service并没有在调用处硬编码的依赖,而是由容器自动解析。一旦换个用户存储后端,只要改容器的user_repo那一行即可,整个业务逻辑完全不动。
谈到最佳实践,踩过坑之后觉得最重要的几条:
第一,别为了DI而DI。如果项目才三四个文件,用DI容器纯粹是增加复杂度。通常项目的依赖关系图超过十来个节点时,开始引入DI带来的收益就明显了。可以在项目初期保持简单,等到出现“改一个通知组件到处改申明”的痛苦时,再引入DI。
第二,保持接口简单。DI的核心是面向接口编程,但Python不像Java有强制接口定义,你可以用抽象基类(ABC)或者直接用鸭子类型。但接口的粒度很重要,一个接口包含的方法超过三五个,很可能就是设计有问题。我习惯一个接口只办一件事,比如EventDispatcher只管发事件,Logger只管写日志,职责越单一,以后的替换越简单。
第三,容器的生命周期要和应用的生命周期对齐。Web应用每次请求都可能需要不同的依赖实例(比如当前用户),如果容器里所有依赖都是单例,就会导致不同请求共用状态。dependency-injector里支持singleton、factory和thread-local等作用域,开发手机应用或Web API时尤其注意这一点。
第四,测试优先。DI的好处在写测试时体现得最淋漓尽致。写每个类之前,可以先想好“如果我要换掉这个依赖,我会怎么做?” 如果答案是“改类里面的代码”,那DI就还没用到位。单元测试里经常用mock库模拟数据库或外部API,但mock本身也是一种隐式注入,只是不如显式的DI容器好维护。我比较习惯在测试代码里直接构造依赖对象,而不是依赖mock.patch修补全局名字空间。修修补补出来的测试,依赖是隐式的,跑着跑着就容易莫名其妙地挂掉。
和同类技术对比,最常拿来跟DI放在一起的有工厂模式和服务定位器。工厂模式把对象的创建逻辑封装在工厂类里,业务代码依赖于工厂,但工厂本身往往还是直接new出对象。把工厂和DI放在一起类比,很像小作坊和大工厂的区别——工厂模式一个厂就负责一种产品的制造,但DI容器像是一个供应链管理系统,它能管理各个工厂、安排它们的依赖关系、控制它们的生命周期。工厂模式的粒度更细、更局部,DI容器的粒度则面向整个应用。
服务定位器(Service Locator)则是另一种思路,它在全局维护一个注册表,对象如果需要什么,直接问注册表要实例。早期的Java项目里很流行,但后来慢慢被DI取代了,原因在于服务定位器让对象的依赖变得更加隐晦,因为只要一个类里出现了locator.get_service("xxx"),这个类的所有依赖都藏起来了,出问题很难trace。DI则把依赖写在构造函数或方法参数里,几乎是显式的,虽然看起来啰嗦,但可读性和可维护性都强不少。我自己的经验是,公司之前的一个老项目,到处是服务定位器调用,后来每次升级某个服务就引起一片连锁反应。迁移到DI后,依赖关系一目了然,重构时心里踏实很多。
最后聊几句关于Python的DI和其他语言(例如Java、C#)的差异。Java里DI几乎是标配,Spring框架无处不在,控制反转容器是语言的基石。Python则因为自身的灵活性和强大的动态特性,DI不是一个必选项。很多Python项目甚至完全不用DI,靠着猴子补丁和全局配置照样能工作。但随着Python被越来越多地用于后端微服务和大型数据平台,DI正在逐渐成为更高层次架构设计的标配。尤其当项目中涉及多个外部依赖、多个环境配置、以及大量mock测试时,DI是不二之选。
这几年的项目经历中,依赖注入给我的最大感受是:它就像厨房里的计量碗——单独看起来没什么了不起,但一旦习惯了用它,那些没有它的地方就总觉得少了点分寸感。
