社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  Python

使用 import-linter 让你的 Python 项目架构更整洁

未闻Code • 1 月前 • 26 次点击  

对于活跃的大型 Python 项目而言,维持架构的整洁性是一件颇具挑战的事情,这主要体现在包与包、模块与模块之间,难以保持简单而清晰的依赖关系。

一个大型项目,通常包含数以百记的子模块,各自实现特定的功能,互相依赖。如果在架构层面上缺少设计,开发实践上没有约束,这些模块间的依赖关系,常常会发展成为一个胡乱缠绕的线团,让人难以理清头绪。

这会带来以下问题:

架构理解成本高:当新人加入项目时,会有许多关于架构的疑问,比方说:既然 common.validators 是一个低层的通用工具模块,为何要引用高层模块 workloads 中的 ConfigVar 模型?影响开发效率:想要开发新功能时,开发者难以判断代码应放在哪个包的哪个模块中,而且不同的人可能会有不同的看法,很难达成共识模块职责混乱:依赖关系很乱,基本等同于模块的职责也很乱,这意味着部分模块可能承担太多,关注了不应该关注的抽象

如果把依赖关系画成一张图,一个架构健康的项目的图,看上去应该层次分明,图中所有依赖都是单向流动,不存在环形依赖。健康的依赖关系,有助于各个模块达成“关注点分离”的状态,维持职责单一。

本文介绍一个治理模块间依赖关系的工具:import-linter[1] 。

import-linter 简介

import-linter[2] 是由 seddonym[3] 开发的一个开源代码 Linter 工具。

要使用 import-linter 检查依赖关系,首先需要在配置文件中定义一些“契约(contract)”。举个例子,下面是一份 import-linter 的配置文件:

# file: .importlinter[importlinter]root_packages =     foo_projinclude_external_packages = True
[importlinter:contract:layers-main]name=the main layerstype=layerslayers = foo_proj.client foo_proj.lib

其中的 [importlinter:contract:layers-main] 部分,定义了一个名为 the main layers 的“分层(layers)”类型的契约,分层契约意味着高层模块可以随意导入低层模块中的内容,反之就不行。

the main layers 设定了一个分层关系: foo_proj.client 模块属于高层,foo_proj.lib 属于低层。

运行命令 lint-imports,工具会扫描当前项目中的所有 import 语句,构建出模块依赖关系图,并判断依赖关系是否符合配置中的所有契约。

假如在项目中的 foo_proj/lib.py 文件里,存在以下内容:

from foo_proj.client import Client

则会导致 lint-imports 命令报错:

$ lint-imports# ...Broken contracts


    
----------------
the main layers---------------
foo_proj.lib is not allowed to import foo_proj.client:
- foo_proj.lib -> foo_proj.client (l.1)

只有当我们删除这条 import 语句后,代码才能通过检查。

除了“分层”类型的契约以外,import-linter 还内置了两种契约:

禁止(forbidden)[4]:禁止一些模块导入你所指定的其他模块独立(independence)[5]:标记一个列表中的模块互相独立,不会导入其他模块中的任何内容

如果这些内置契约不能满足你的需求,你还可以编写自定义契约,详情可查阅 官方文档[6]

在项目中引入 import-linter

要在项目中引入 import-linter 工具,首先需要编写好你所期望的所有契约。你可以试着从以下几个关键问题开始:

从顶层观察项目,它由哪几个关键分层构成,之间的关系如何?许多项目中都存在类似 application -> services -> common -> utils 这种分层结构,将它们记录为对应契约对于某些复杂的子模块,其内部是否存在清晰的分层?如果能找到 views -> models -> utils 这种分层,将其记录为对应契约有哪些子模块满足“禁止(forbidden)”或“独立(independence)”契约?如果存在,将其记录下来

将这些契约写入到配置文件中以后,执行 lint-imports,你会看到海量的报错信息(如果没有任何报错,那么恭喜你,项目很整洁,关掉文章去干点其他事情吧!)。它们展示了哪些导入关系违反了你所配置的契约。

逐个分析这些报错信息,将其中不合理的导入关系添加到各契约的 ignore_imports 配置中:

[importlinter:contract:layers-main]name=the main layerstype=layerslayers =     foo_proj.client    foo_proj.libignore_imports =    # 暂时忽略从 lib 模块中导入 client,使其不报错    foo_proj.lib -> foo_proj.client

处理完全部的报错信息以后,配置文件中的 ignore_imports 可能会包含上百条必须被忽略的导入信息,此时,再次执行 lint-imports 应该不再输出任何报错(一种虚假的整洁)。

接下来便是重头戏,我们需要真正修复这些导入关系。

饭要一口一口吃,修复依赖关系也需要一条一条来。首先,试着从 ignore_imports 中删除一条记录,然后执行 lint-imports,观察并分析该报错信息,尝试找到最合理的方式修复它。

不断重复这个过程,最后就能完成整个项目的依赖关系治理。

Tip:在删减 ignore_imports 配置的过程中,你会发现有些导入关系会比其他的更难修复,这很正常。修复依赖关系常常是一个漫长的过程,需要整个团队的持续投入。

修复依赖关系的常见方式

下面介绍几种常见的修复依赖关系的方式。

为了方便描述,我们假设在以下所有场景中,项目定义了一个“分层”类型的契约,而低层模块违反契约,反过来依赖了高层模块。

1. 合并与拆分模块

调整依赖关系最直接的办法是合并模块。

假设有一个低层模块 clusters,违规导入了高层模块 resources 的子模块 cluster_utils 里的部分代码。考虑到这些代码本身就和 clusters 有一定关联性,因此,你其实可以把它们直接挪到 clusters.utils 子模块里,从而消除这个依赖关系。

如下所示:

# 分层:resources -> clusters# 调整前resources -> clustersclusters -> resources.cluster_utils    # 违反契约!
# 调整后resources -> clustersclusters -> clusters.utils

如果被依赖的代码与所有模块间的关联都不太密切,你也可以选择将它拆分成一个新模块。

比方说,一个低层模块 users 依赖了高层模块 marketing 中发送短信相关的代码,违反了契约。你可以选择把代码从 marketing 中拆分出来,置入一个处于更低层级的新模块 utils.messaging 中。

# 分层:marketing -> users# 调整前marketing -> usersusers -> marketing    # 违反契约!
# 分层:marketing -> users -> utils# 调整后marketing -> usersusers -> utils.messaging

这样做以后,不健康的依赖关系便能得到解决。

2. 依赖注入

依赖注入(Dependency injection)是一种常见的解耦依赖关系的技巧。

举个例子,项目中设置了一个分层契约:marketing -> users, 但 users 模块却直接导入了 marketing 模块中的短信发送器 SmsSender 类,违反了契约。

# file: users.py
from marketing import SmsSender # 违反契约!
class User: """简单的用户对象"""
def __init__(self): self.sms_sender = SmsSender()
def add_notification(self, message: str, send_sms: bool): """向用户发送新通知""" # ... if send_sms: self.sms_sender.send(message)

要通过“依赖注入”修复该问题,我们可以直接删除代码中对 SmsSender 的依赖,改为要求调用方必须在实例化 User 时,主动传入一个“代码通知器(sms_sender)”对象。

# file: users.py


    

class User: """简单的用户对象
:param sms_sender: 用于发送短信通知的通知器对象 """
def __init__(self, sms_sender): self.sms_sender = sms_sender

这样做以后,User 对“短信通知器”的依赖就变弱了,不再违反分层契约。

添加类型注解

但是,前面的依赖注入方案并不完美。当你想给 sms_sender 参数添加类型注解时,很快会发现自己开始重蹈覆辙:不能写 def __init__(self, sms_sender: SmsSender),那样得把删掉的 import 语句找回来。

# file: users.pyfrom typing import TYPE_CHECKING
if TYPE_CHECKING: # 因为类型注解找回高层模块的 SmsSender,违反契约! from marketing import SmsSender

即使像上面这样,把 import 语句放在 TYPE_CHECKING 分支中,import-linter 仍会将其当做普通导入对待(注:该行为可能会在未来发生改动,详见 Add support for detecting whether an import is only made during type checking · Issue #64[7],将其视为对契约的一种违反。

为了让类型注解正常工作,我们需要在 users 模块中引入一个新的抽象:SmsSenderProtocol 协议(Protocol),替代实体 SmsSender 类型。

from typing import Protocol
class SmsSenderProtocol(Protocol):
def send(message: str): ...
class User:
def __init__(self, sms_sender: SmsSenderProtocol): self.sms_sender = sms_sender

这样便解决了类型注解的问题。

Tip:通过引入 Protocol 来解耦依赖关系,其实上是对依赖倒置原则(Dependency Inversion Principle)的一种应用。依赖倒置原则认为:高层模块不应该依赖低层模块,二者都应该依赖抽象。

关于它的更多介绍,推荐阅读我的另一篇文章:《Python 工匠:写好面向对象代码的原则(下) 》[8]

3. 简化依赖数据类型

在以下代码中,低层模块 monitoring 依赖了高层模块 processes 中的 ProcService 类型:

# file:monitoring.pyfrom processes import ProcService    # 违反契约!
def build_monitor_config(service: ProcService): """构造应用监控相关配置
:param service: 进程服务对象 """ # ... # 基于 service.port 和 service.host 完成构造 # ...

经过分析后,可以发现 build_monitor_config 函数实际上只使用了 service 对象的两个字段:host 和 port,不依赖它的任何其他属性和方法。所以,我们完全可以调整函数签名,将其改为仅接受两个必要的简单参数:

# file:monitoring.py
def build_monitor_config(host: str, port: int): """构造监控相关配置
:param host: 主机域名 :param port: 端口号 """ # ...

调用方的代码也需要进行相应修改:

# 调整前build_monitor_config(svc)
# 调整后build_monitor_config(svc.host, svc.port)

通过简化函数所接收的参数类型,我们消除了模块间的不合理依赖。

4. 延迟提供函数实现

Python 是一门非常动态的编程语言,我们可以利用这种动态,延迟提供某些函数的具体实现,从而扭转模块间的依赖关系。

假设低层模块 users 目前违反了契约,直接依赖了高层模块 marketing 中的 send_sms 函数。要扭转该依赖关系,第一步是在低层模块 users 中定义一个用来保存函数的全局变量,并提供一个配置入口。

代码如下所示:

# file: users.py
SendMsgFunc = Callable[[str], None]# 全局变量,用来保存当前的“短信发送器”函数实现_send_sms_func: Optional[SendMsgFunc] = None
def set_send_sms_func(func: SendMsgFunc): global _send_sms_func _send_sms_func = func

调用 send_sms 函数时,判断当前是否已提供具体实现:

# file: users.py
def send_sms(message: str): """发送短信通知""" if not _send_sms_func: raise RuntimeError("Must set the send_sms function")
_send_sms_func(message)

完成以上修改后,users 不再需要从 marketing 中导入“短信发送器”的具体实现。而是可以由高层模块 marketing 来一波“反向操作”,主动调用 set_send_sms_func,将实现注入到低层模块 users 中:

# file: marketing.py
from user import set_send_sms_func
def _send_msg(message: str): """发送短信的具体实现函数""" ...
set_send_sms_func(_send_msg)

这样便完成了依赖关系的扭转。

变种:简单的插件机制

除了用一个全局变量来保存函数的具体实现以外,你还可以将 API 设计得更复杂一些,实现一种类似于“插件”的注册与调用机制,满足更丰富的需求场景。

举个简单的例子,在低层模块中,实现“插件”的抽象定义以及用来保存具体插件的注册器:

# file: users.py
from typing import Protocol
class SmsSenderPlugin(Protocol): """由其他模块实现并注册的插件类型"""
def __call__(self, message: str): ...
class SmsSenderPluginCenter: """管理所有“短信发送器”插件"""
@classmethod    def register(cls, name: str, plugin: SmsSenderPlugin): """注册一个插件""" # ...
@classmethod    def call(cls, name: str): """调用某个插件""" # ...

在其他模块中,调用 SmsSenderPluginCenter.register 来注册具体的插件实现:

# file: marketing.py
from user import SmsSenderPluginCenter
SmsSenderPluginCenter.register('default', DefaultSender())SmsSenderPluginCenter.register('free', FreeSmsSender())

和使用全局变量一样,插件机制同样是对依赖倒置原则的一种具体应用。上面的代码仅包含最简单的原理示意,真实的代码实现会更复杂一些,不在此文中赘述。

5. 由配置文件驱动

假设低层模块 users 违规依赖了高层模块 marketing 中的一个工具函数 send_sms。除了使用上面介绍的方式以外,我们也可以选择将工具函数的导入路径定义成一个配置项,置入配置文件中。

# file:settings.py
# 用于发送短信通知的函数导入路径SEND_SMS_FUNC = 'marketing.send_sms'

在 users 模块中,不再直接引用 marketing 模块,而是通过动态导入配置中的工具函数的方式,来使用 send_sms 函数。

# file: users.py
from settings import SEND_SMS_FUNC
def send_sms(message: str): func = import_string(SEND_SMS_FUNC) return func(message)

这样也可以完成依赖关系的解耦。

Tip:关于 import_string 函数的具体实现,可以参考 Django 框架[9]

6. 用事件驱动代替函数调用

对于那些耦合关系本身较弱的模块,你也可以选择用事件驱动的方式来代替函数调用。

举个例子,低层模块 networking 每次变独立域名数据时,均需要调用高层模块 applications 中的 deploy_networking 函数,更新对应的资源,这违反了分层契约。

# file: networking/main.py
from applications.utils import deploy_networking # 导入高层模块,违反契约!
deploy_networking(app)

该问题很适合用事件驱动来解决(以下代码基于 Django 框架的信号机制[10] 编写)。

引入事件驱动的第一步是发送事件。我们需要修改 networking 模块,删除其中的函数调用代码,改为发送一个类型为 custom_domain_updated 的信号:

# file: networking/main.py
from networking.signals import custom_domain_updated
custom_domain_updated.send(sender=app)

第二步,是在 applications 模块中新增事件监听代码,完成资源更新操作:

# file: applications/main.py
from applications.utils import deploy_networkingfrom networking.signals import custom_domain_updated
@receiver(custom_domain_updated)def on_custom_domain_updated(sender, **kwargs): """触发资源更新操作""" deploy_networking(sender)

这样便完成了解耦工作。

总结

在依赖关系治理方面,import-linter[11] 是一个非常有用的工具。它通过提供各种类型的“契约”,让我们得以将项目内隐式的复杂依赖关系,通过配置文件显式的表达出来。再配合 CI 等工程实践,能有效地帮助我们维持项目架构的整洁。

如果你想在项目中引入 import-linter,最重要的工作是修复已有的不合理的依赖关系。常见的修复方式包括合并与拆分、依赖注入、事件驱动,等等。虽然手法多种多样,但最重要的事用一句话便可概括:把每行代码放在最恰当的模块中,必要时在当前模块引入新的抽象,借助它的力量来反转模块间的依赖关系。

愿你的项目架构永远保持整洁!

References

[1] import-linter: https://github.com/seddonym/import-linter
[2] import-linter: https://github.com/seddonym/import-linter
[3] seddonym: https://github.com/seddonym
[4] 禁止(forbidden): https://import-linter.readthedocs.io/en/stable/contract_types.html#forbidden-modules
[5] 独立(independence): https://import-linter.readthedocs.io/en/stable/contract_types.html#independence
[6] 官方文档: https://import-linter.readthedocs.io/en/stable/custom_contract_types.html
[7] Add support for detecting whether an import is only made during type checking · Issue #64: https://github.com/seddonym/grimp/issues/64
[8] 《Python 工匠:写好面向对象代码的原则(下) 》: https://www.piglei.com/articles/write-solid-python-codes-part-3/
[9] Django 框架: https://github.com/django/django/blob/main/django/utils/module_loading.py#L19
[10] Django 框架的信号机制: https://docs.djangoproject.com/en/4.2/topics/signals/
[11] import-linter:  https://github.com/seddonym/import-linter

更多每日开发小技巧

尽在未闻 Code Telegram Channel !


END

未闻 Code·知识星球开放啦!

一对一答疑爬虫相关问题

职业生涯咨询

面试经验分享

每周直播分享

......

未闻 Code·知识星球期待与你相见~

一二线大厂在职员工

十多年码龄的编程老鸟

国内外高校在读学生

中小学刚刚入门的新人

“未闻 Code技术交流群”等你来!

入群方式:添加微信“mekingname”,备注“粉丝群”(谢绝广告党,非诚勿扰!)

Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/170239
 
26 次点击