如何让你的 Python 项目架构更整洁?

共 13581字,需浏览 28分钟

 ·

2023-11-10 16:57

入门教程、案例源码、学习资料、读者群

请访问: python666.cn

大家好,欢迎来到 Crossin的编程教室 !

对于活跃的大型 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

作者:piglei

来源:piglei



Crossin的第2本书码上行动:利用Python与ChatGPT高效搞定Excel数据分析已经上市了

点此查看上一本《码上行动:零基础学会Python编程》介绍

本书从 Python 和 Excel 结合使用的角度讲解处理分析数据的思路、方法与实战应用。不论是希望从事数据分析岗位的学习者,还是其他职业的办公人员,都可以通过本书的学习掌握 Python 分析数据的技能。书中创新性地将 ChatGPT 引入到教学当中,用 ChatGPT 答疑并提供实训代码,并介绍了使用 ChatGPT 辅助学习的一些实用技巧,给学习者带来全新的学习方式

京东双十一5折优惠进行中

读者朋友们购买后可在后台联系我,加入读者交流群,Crossin会为你开启陪读模式,解答你在阅读本书时的一切疑问。


感谢转发点赞的各位~

_往期文章推荐_

如何将Python「羊了个羊」打包成exe
一段奇葩的1024代码



【教程】: python

【答疑】: 666
更多资源点击阅读原文
浏览 1159
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报