one-python-craftsman/zh_CN/9-a-story-on-cyclic-imports.md

196 lines
9.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Python 工匠:一个关于模块的小故事
## 前言
> 这是 “Python 工匠”系列的第 9 篇文章。[[查看系列所有文章]](https://github.com/piglei/one-python-craftsman)
<div style="text-align: center; color: #999; margin: 14px 0 14px;font-size: 12px;">
<img src="https://www.piglei.com/static/uploaded/2019/05/ricardo-gomez-angel-669574-unsplash_w1280.jpg" width="100%" />
</div>
模块Module是我们用来组织 Python 代码的基本单位。很多功能强大的复杂站点,都由成百上千个独立模块共同组成。
虽然模块有着不可替代的用处,但它有时也会给我们带来麻烦。比如,当你接手一个新项目后,刚展开项目目录。第一眼就看到了攀枝错节、难以理解的模块结构,那你肯定会想: *“这项目也太难搞了。”* 😂
在这篇文章里,我准备了一个和模块有关的小故事与你分享。
## 一个关于模块的小故事
小 R 是一个刚从学校毕业的计算机专业学生。半个月前,他面试进了一家互联网公司做 Python 开发,负责一个与用户活动积分有关的小项目。项目的主要功能是查询站点活跃用户,并为他们发送有关活动积分的通知: *“亲爱的用户,您好,您当前的活动积分为 x”*
项目主要由 `notify_users.py` 脚本和 `fancy_site` 包组成,结构与各文件内容如下:
```text
├── fancy_site
│   ├── __init__.py
│   ├── marketing.py # 与市场活动有关的内容
│   └── users.py # 与用户有关的内容
└── notify_users.py # 脚本:发送积分通知
```
文件 `notify_users.py`
```python
from fancy_site.users import list_active_users
from fancy_site.marketing import query_user_points
def main():
"""获取所有的活跃用户,将积分情况发送给他们"""
users = list_active_users()
points = query_user_points(users)
for user in users:
user.add_notification(... ...)
# <... 已省略 ...>
```
文件 `fancy_site/users.py`
```python
from typing import List
class User:
# <... 已省略 ...>
def add_notification(self, message: str):
"""为用户发送新通知"""
pass
def list_active_users() -> List[User]:
"""查询所有活跃用户"""
pass
```
文件:`fancy_site/marketing.py`
```python
from typing import List
from .users import User
def query_user_points(users: List[User]) -> List[int]:
"""批量查询用户活动积分"""
def send_sms(phone_number: str, message: str):
"""为某手机号发送短信"""
```
只要在项目目录下执行 `python notify_user.py`,就能实现给所有活跃用户发送通知。
### 需求变更
但有一天,产品经理找过来说,光给用户发站内信通知还不够,容易被用户忽略。除了站内信以外,我们还需要同时给用户推送一条短信通知。
琢磨了五秒钟后,小 R 跟产品经理说:*“这个需求可以做!”*。毕竟给手机号发送短信的 `send_sms()` 函数早就已经有人写好了。他只要先给 `add_notification` 方法添加一个可选参数 `enable_sms=False`,当传值为 `True` 时调用 `fancy_site.marketing` 模块里的 `send_sms` 函数就行。
一切听上去根本没有什么难度可言,十分钟后,小 R 就把 `user.py` 改成了下面这样:
```python
# 导入 send_sms 模块的发送短信函数
from .marketing import send_sms
class User:
# <...> 相关初始化代码已省略
def add_notification(self, message: str, enable_sms=False):
"""为用户添加新通知"""
if enable_sms:
send_sms(user.mobile_number, ... ...)
```
但是,当他修改完代码,再次执行 `notify_users.py` 脚本时,程序却报错了:
```raw
Traceback (most recent call last):
File "notify_users.py", line 2, in <module>
from fancy_site.users import list_active_users
File .../fancy_site/users.py", line 3, in <module>
from .marketing import send_sms
File ".../fancy_site/marketing.py", line 3, in <module>
from .users import User
ImportError: cannot import name 'User' from 'fancy_site.users' (.../fancy_site/users.py)
```
错误信息说,无法从 `fancy_site.users` 模块导入 `User` 对象。
### 解决环形依赖问题
小 R 仔细分析了一下错误,发现错误是因为 `users``marketing` 模块之间产生的环形依赖关系导致的。
当程序在 `notify_users.py` 文件导入 `fancy_site.users` 模块时,`users` 模块发现自己需要从 `marketing` 模块那里导入 `send_sms` 函数。而解释器在加载 `marketing` 模块的过程中,又反过来发现自己需要依赖 `users` 模块里面的 `User` 对象。
如此一来,整个模块依赖关系成为了环状,程序自然也就没法执行下去了。
![modules_before](https://www.piglei.com/static/uploaded/2019/05/modules_before.png)
不过,没有什么问题能够难倒一个可以正常访问 Google 的程序员。小 R 随便上网一搜,发现这样的问题很好解决。因为 Python 的 import 语句非常灵活,他只需要 **把在 users 模块内导入 send_sms 函数的语句挪到 `add_notification` 方法内,延缓 import 语句的执行就行啦。**
```python
class User:
# <...> 相关初始化代码已省略
def add_notification(self, message: str, send_sms=False):
"""为用户添加新通知"""
# 延缓 import 语句执行
from .marketing import send_sms
```
改动一行代码后,大功告成。小 R 简单测试后,发现一切正常,然后把代码推送了上去。不过小 R 还没来得及为自己点个赞,意料之外的事情发生了。
这段明明几乎完美的代码改动在 **Code Review** 的时候被审计人小 C 拒绝了。
### 小 C 的疑问
小 R 的同事小 C 是一名有着多年经验的 Python 程序员,他对小 R 说:“使用延迟 import虽然可以马上解决包导入问题。但这个小问题背后隐藏了更多的信息。比如**你有没有想过 send_sms 函数,是不是已经不适合放在 marketing 模块里了?”**
被小 C 这么一问,聪明的小 R 马上意识到了问题所在。要在 `users` 模块内发送短信,重点不在于用延迟导入解决环形依赖。而是要以此为契机,**发现当前模块间依赖关系的不合理,拆分/合并模块,创建新的分层与抽象,最终消除环形依赖。**
认识清楚问题后,他很快提交了新的代码修改。在新代码中,他创建了一个专门负责通知与消息类的工具模块 `msg_utils`,然后把 `send_sms` 函数挪到了里面。之后 `users` 模块内就可以毫无困难的从 `msg_utils` 模块中导入 `send_sms` 函数了。
```python
from .msg_utils import send_sms
```
新的模块依赖关系如下图所示:
![modules_afte](https://www.piglei.com/static/uploaded/2019/05/modules_after.png)
在新的模块结构中,整个项目被整齐的分为三层,模块间的依赖关系也变得只有**单向流动**。之前在函数内部 `import` 的“延迟导入”技巧,自然也就没有用武之地了。
小 R 修改后的代码获得了大家的认可,很快就被合并到了主分支。故事暂告一段落,那么这个故事告诉了我们什么道理呢?
## 总结
模块间的循环依赖是一个在大型 Python 项目中很常见的问题,越复杂的项目越容易碰到这个问题。当我们在参与这些项目时,**如果对模块结构、分层、抽象缺少应有的重视。那么项目很容易就会慢慢变得复杂无比、难以维护。**
所以,合理的模块结构与分层非常重要。它可以大大降低开发人员的心智负担和项目维护成本。这也是我为什么要和你分享这个简单故事的原因。“在函数内延迟 import” 的做法当然没有错,但我们更应该关注的是:**整个项目内的模块依赖关系与分层是否合理。**
最后,让我们再尝试从 小 R 的故事里强行总结出几个道理吧:
- 合理的模块结构与分层可以降低项目的开发维护成本
- 合理的模块结构不是一成不变的,应该随着项目发展调整
- 遇到问题时,不要选**“简单但有缺陷”**的那个方案,要选**“麻烦但正确”**的那个
- 整个项目内的模块间依赖关系流向,应该是单向的,不能有环形依赖存在
看完文章的你,有没有什么想吐槽的?请留言或者在 [项目 Github Issues](https://github.com/piglei/one-python-craftsman) 告诉我吧。
[>>>下一篇【10.做一个精通规则的玩家】](10-a-good-player-know-the-rules.md)
[<<<上一篇【8.使用装饰器的技巧】](8-tips-on-decorators.md)
## 附录
- 题图来源: Photo by Ricardo Gomez Angel on Unsplash
- 更多系列文章地址:<https://github.com/piglei/one-python-craftsman>
系列其他文章:
- [所有文章索引 [Github]](https://github.com/piglei/one-python-craftsman)
- [Python 工匠:编写条件分支代码的技巧](https://www.piglei.com/articles/python-else-block-secrets/)
- [Python 工匠:异常处理的三个好习惯](https://www.piglei.com/articles/three-rituals-of-exceptions-handling/)
- [Python 工匠:编写地道循环的两个建议](https://www.piglei.com/articles/two-tips-on-loop-writing/)