Python循环导入问题

随着 Python 项目的增大,各个模块间的依赖关系将会越来越复杂

如果不合理组织文件模块结构,规划好层级关系,很容易就出现 循环导入(A import B, B import A)

而不幸的是 Python 的包导入机制并没有帮我们检测循环导入(而 Golang 在编译阶段会报错)

一方面我们可以重构(动态一时爽,重构火葬场),借助优秀的设计模式来降低模块间的耦合(最根本)

而另一方面我们可以借助一些小技巧稍微改动代码结构来避免循环导入(治标不治本)

模拟循环导入

文件组织结构

为了探究循环导入的整个过程,这里模拟一个循环导入的场景:

  • fist.py
from second import second_func

def first_func():
    print('first_func called')

first_func()
second_func()
  • second.py
from first import first_func

def second_func():
    print('second_func called')

first_func()
second_func()
  • run.py
import first

fist.pysecond.py 互相依赖形成循环导入,而 run.py 依赖(导入) fist.py ,可用图示如下:

Case1:first.py 作为程序入口

我们先以 first.py 为程序入口,即在命令行中运行 first.py

λ python first.py
Traceback (most recent call last):
  File "first.py", line 1, in <module>
    from second import second_func
  File "C:\Users\ChenHW\Desktop\circular\second.py", line 1, in <module>
    from first import first_func
  File "C:\Users\ChenHW\Desktop\circular\first.py", line 1, in <module>
    from second import second_func
ImportError: cannot import name 'second_func'

Case2:first.py 作为模块

然后以 run.py 为程序入口:

λ python run.py
Traceback (most recent call last):
  File "run.py", line 1, in <module>
    import first
  File "C:\Users\ChenHW\Desktop\circular\first.py", line 1, in <module>
    from second import second_func
  File "C:\Users\ChenHW\Desktop\circular\second.py", line 1, in <module>
    from first import first_func
ImportError: cannot import name 'first_func'

可以发现两种情况下报错位置是相反的:case1first.py 中报错,而 case2second.py 中报错

逐行分析

  1. Python中所有加载到内存的模块都放在 sys.modules
  2. 一个模块不会重复载入
  3. 命令行中启动的脚本名(入口)实际上是 __main__ 而不是模块名

对于 case1,入口为 first.py, 从 Traceback 中可以看出导入路径为:

__main__.py(first.py) –> second.py –> first.py

解释器最先执行 first.py 的第一行代码 from second import second_func

( 注意此时 sys.modules 中记录的是 __main__ 而不是 first)

发现需要先导入 second.py,而 sys.modules 中还没有 second

这意味着 second.py 还未导入,于是转跳到 second.py 执行第一行代码

second.py 第一行代码是 from first import first_func,发现需要先导入 first.py

sys.modules 中还没有 first(只有 __main__second

这意味着 first.py 还未导入,于是转跳回 first.py 执行第一行代码

first.py 第一行代码是 from second import second_func,发现需要导入 second.py

然而 sys.modules 中已经有 second 了,意味着 second.py 已经导入,于是试图从该模块中导入 second_func

而之前在导入 second.py 时还未解析到 "def second_func():" 这一行代码就转跳回 first.py

所以 second 模块的命名空间中并没有 second_func,因此导入失败:ImportError: cannot import name 'second_func'

这样对 case2 的报错也就明白了,从它的 Traceback 中可以看出导入路径为:

__main__.py(run.py) –> first.py –> second.py

case2 的入口为 run.py 而不是 first.py,因此在 run.py 中导入 first.py

first.py 是以模块的身份被导入,从而 sys.modules 中记录的是 first

当执行到 second.py 第一行代码时发现需要导入 first.py

sys.modules 中还已经有 first了,因此试图从 first 模块导入 first_func

然而之前在导入 first.py 时还未解释到 "def first_func():" 这一行代码就转跳到 second.py

所以 first 模块的命名空间中并没有 first_func,最终导入失败:ImportError: cannot import name 'first_func'

Circular imports are fine where both modules use the “import ” form of import. They fail when the 2nd module wants to grab a name out of the first (“from module import name”) and the import is at the top level. That’s because names in the 1st are not yet available, because the first module is busy importing the 2nd.

解决方法

那么这里要如何解决循环导入?仔细分析可以发现:对已经导入的模块,其命名空间中并没有我们想要的名称引用

因此我们可以在转跳到下一个模块之前先定义好下一个模块需要的名称引用,这就需要我们 先定义再导入

对于 case2,我们可以把 first.py 中第一行的导入语句 "from second import second_func" 放到函数体 first_func() 后面:

  • first.py
def first_func(): # 先定义
    print('first_func called')

from second import second_func # 再导入

first_func()
second_func()

其他两个文件不用修改,这样在 second.py 要导入 first 模块时, first_func 已经定义好了,运行结果如下:

λ python run.py
first_func called
second_func called
first_func called
second_func called

对于 case1,我们在之前的修改上直接运行会怎样?

λ python first.py
Traceback (most recent call last):
  File "first.py", line 4, in <module>
    from second import second_func
  File "C:\Users\ChenHW\Desktop\circular\second.py", line 1, in <module>
    from first import first_func
  File "C:\Users\ChenHW\Desktop\circular\first.py", line 4, in <module>
    from second import second_func
ImportError: cannot import name 'second_func'

报错和之前一样,还是因为从 second.py 跳回 first.py 时函数 second_func 还未定义

从而导致 first.pysecond 中导入 second_func 失败

因此我们仿照 case1 的解决方式,先定义 second_func 再导入 first

  • second.py
def second_func(): # 先定义
    print('second_func called')

from first import first_func # 再导入

first_func()
second_func()

命令行中执行:python first.py,结果如下:

first_func called
second_func called
first_func called
second_func called
first_func called
second_func called

没有报错了,但是结果怎么多了两行?

仔细分析可以明白这个输出结果符合导入栈的出栈顺序:

第1、2行是 first 作为模块被 second 导入时执行的结果

第3、4行是 second 作为模块被 __main__first) 导入时执行的结果

第5、6行是 __main__first) 作为程序入口而执行的结果

因此 first.py 其实被执行了两次,一次作为模块一次作为入口,而 second.py 只是作为模块被执行了一次

最后让我们写一个完整的代码验证一下:

  • run.py
import first

print('in model: {}, file : {}'.format(__name__, __file__.rsplit('\\', 1)[-1]))
  • first.py
def first_func():
    print('first_func called')

from second import second_func

print('in model: {}, file : {}'.format(__name__, __file__.rsplit('\\', 1)[-1]))
first_func()
second_func()
print()
  • second.py
def second_func():
    print('second_func called')

from first import first_func

print('in model: {}, file : {}'.format(__name__, __file__.rsplit('\\', 1)[-1]))
first_func()
second_func()
print()
  • Case1
λ python first.py
in model: first, file : first.py
first_func called
second_func called

in model: second, file : second.py
first_func called
second_func called

in model: __main__, file : first.py
first_func called
second_func called
  • Case2
λ python run.py
in model: second, file : second.py
first_func called
second_func called

in model: first, file : first.py
first_func called
second_func called

in model: __main__, file : run.py

Flask 中的循环导入

上面的模拟是为了更好地理解循环导入的流程,下面就实际开发过程中遇到的循环导入问题来分析

Bug 再现

熟悉 Flask 的都知道 Flask 中的 app 实例是一个很重要的单例,初学者容易犯这么一个错误:

app.py 中的视图函数抽取出来放在一个独立的模块 view.py 中,而视图函数在注册路由时需要引用 app 实例

因此在 view.py 中会去导入 app.py 中创建好的 app 实例

而为了能够完成路由的注册,app.py 需要导入 view,这就形成了一个循环引用:

  • app.py
from flask import Flask

app = Flask(__name__) # 先定义

import view # 再导入,这里导入view是为了完成view.py中路由的注册

if __name__ == '__main__':
    app.run()
  • view.py
from app import app # 为了注册路由,需要导入app.py中的app实例

@app.route('/')  # 将路由注册到app.py创建好的app实例中
def index():
    return 'hello world!'

命令行中执行:python app.py

 * Serving Flask app "app" (lazy loading)
 * Environment: production
   WARNING: Do not use the development server in a production environment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

程序没有报错,打开浏览器访问 http://127.0.0.1:5000/,却返回404错误:

也就是说路由并没有真正注册到 app 中,app 实例找不到对应的视图函数

逐行分析

那么路由注册到哪了?输出 app 实例的 id 来看一下就明白了:

  • app.py
from flask import Flask

app = Flask(__name__)
print('id为{}的app实例化'.format(id(app)))

import view

if __name__ == '__main__':
    print('id为{}的app启动'.format(id(app)))
    app.run()
  • view.py
from app import app

print('id为{}的app注册路由'.format(id(app)))

@app.route('/')
def index():
    return 'hello world!'

命令行中执行:python app.py

id为56217936的app实例化
id为66627696的app实例化
id为66627696的app注册路由
id为56217936的app启动
 * Serving Flask app "app" (lazy loading)
 * Environment: production
   WARNING: Do not use the development server in a production environment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

可以看到 app 被实例化了两次,而且注册路由的 app 和最终启动的 app 不是同一个 app 实例

也就是说最终启动的 app 实例并没有注册我们在 view 中写好的路由和视图函数,因此最终只能返回404

为什么app会被实例化两次,而且注册路由的那个 app 不是启动的 app?

相信经过前面的讨论已经不难分析了:

首先执行 app.py 创建 id 为 56217936 的 app 实例,然后导入 view,进入 view.py 执行代码

view.py 在导入 app 时 sys.modules 中还没有 app

因为 app.py 是程序的入口文件,在 sys.modules 中为 __main__

所以 app.py会再次被执行, id 为 66627696 的 app 被实例化

由于这次是以模块被导入的,所以在 "if __name__ == '__main__':" 处为 False (__name__ 为 “app”)

导入结束返回到 view.py,此时获取到的 app 实例是第二次实例化的 app 了

因此接下来注册路由自然就会注册在第二个 app 实例上(id 为 66627696 的 app 注册路由)

view.py 导入结束返回到最开始的 app.py

"if __name__ == '__main__':" 为 True,启动第一个 app 实例(id为56217936的app启动)

深入理解

这个 Bug 告诉我们,import 机制并不总是保证单例,它只是保证了模块的单例而已

换句话说,Python 全局的 sys.modules 字典保证了模块的单例,但不能防止模块文件被执行两次

app 是一个有状态的实例,而且要求是一个单例,实例化两次是不被允许的

即使我们先定义后导入,但这仅仅是保证程序能够正常运行,而不能保证 app 单例

解决方法

使用 Flask 蓝图(blueprint),分层解耦,还是那句话:

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决

将路由注册到蓝图,然后把蓝图注册到 app 实例:

  • app.py
from flask import Flask

from view import home_bp # 从view中导入蓝图实例

app = Flask(__name__)
app.register_blueprint(home_bp) # 将蓝图注册到app实例中,注意到register_blueprint是app实例的方法

if __name__ == '__main__':
    app.run()
  • view.py
from flask import Blueprint # 不再返回去导入app.py了,而是导入flask中的Blueprint,循环导入被解开

home_bp = Blueprint('home', __name__) # 创建蓝图实例,被app.py导入

@home_bp.route('/') # 将路由注册到home_bp实例而不是app实例中,彻底解耦了
def index():
    return 'hello world!'

命令行中执行:python app.py

* Serving Flask app "app" (lazy loading)
 * Environment: production
   WARNING: Do not use the development server in a production environment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
127.0.0.1 - - [09/Nov/2018 00:58:56] "GET / HTTP/1.1" 200 -

注意最后一行输出,我们成功地请求到了注册路由,执行相对应的视图函数并返回

小结

导入时如果报错找不到某个实例或变量,那么需要考虑是否可能出现了循环导入,因为循环导入和程序入口有很大的关系

这取决于该文件是以模块被加载还是作为程序入口(作为程序入口时在循环导入下会被执行两次)

有时候循环导入默不作声,带来难以发现的 Bug

最典型的就是 Flask 中的 app 实例,一不小心就会被实例化两次,然而程序能正常运行

参考资料

文章目录
  1. 1. 模拟循环导入
    1. 1.1. 文件组织结构
    2. 1.2. Case1:first.py 作为程序入口
    3. 1.3. Case2:first.py 作为模块
    4. 1.4. 逐行分析
    5. 1.5. 解决方法
  2. 2. Flask 中的循环导入
    1. 2.1. Bug 再现
    2. 2.2. 逐行分析
    3. 2.3. 深入理解
    4. 2.4. 解决方法
  3. 3. 小结
  4. 4. 参考资料
|