2 行代码实现修改源码后自动重载

有时候,我感到疲倦,因为,我每修改一处代码,想要看到改动是否生效的时候,我要先 Ctrl CKill 进程,然后重新运行,才能看到结果,改的次数多了,不仅浪费时间,降低效率,还浪费体力。有没有办法做到修改了项目使用的源码文件后,让程序自动重新运行?

肯定有办法,三方库 watchdog 可以监控文件的新增,删除,和修改,可以在这些事件发生后执行相应的动作,但它不够完美:

  1. 可以对某一路径进行监听,但不能解析项目 import 了哪些文件,import 的文件不在同一路径下,需要手工配置多个路径就很麻烦,不具有通用性。
  2. 不能判断文件是否真正的修改,有时候只是保存下,文件内容并没有变化,此时不应该触发重启。
  3. 如果在统一路径,修改了项目未引用的文件,也会触发重启。

直到我用了 Django,Django 的 autoreload 机制,完美的解决了上面 3 个问题,改动代码保存后可以立即看到程序的及时反馈,大大提升了 Debug 的效率,堪称神器。

这么好的神器,能否移植到其他项目上?

​能否移植,取决于 autoreload 是否与 Django 松耦合,我们先来看一下它的工作原理。

1、Django 是怎么自动重载的?

用过 Django 的朋友都知道,当你执行 python manage.py runserver 后,只要修改了项目用到的文件,Django 会自动重新启动服务,这种及时反馈机制,大大的方便了开发者,可以快速确认自己的修改是否正确,为测试省了不少时间。

从 Django(Django==3.0.4) 的源码 django/core/management/commands/runserver.py 走起,执行 runserver 命令后就执行了这个函数。

def run(self, **options):
    """Run the server, using the autoreloader if needed."""
    use_reloader = options['use_reloader']

    if use_reloader:
        autoreload.run_with_reloader(self.inner_run, **options)
    else:
        self.inner_run(None, **options)

self.inner_run 是真正干活的,先不管它。执行命令时如果不加 --noreload,就会运行 autoreload.run_with_reloader,我们继续追踪到 django/utils/autoreload.py

def run_with_reloader(main_func, *args, **kwargs):
    signal.signal(signal.SIGTERM, lambda *args: sys.exit(0))
    try:
        if os.environ.get(DJANGO_AUTORELOAD_ENV) == 'true':
            reloader = get_reloader()
            logger.info('Watching for file changes with %s', reloader.__class__.__name__)
            start_django(reloader, main_func, *args, **kwargs)
        else:
            exit_code = restart_with_reloader()
            sys.exit(exit_code)
    except KeyboardInterrupt:
        pass

函数第一行 signal.SIGTERM 来捕捉用户输入 kill 指令,让程序退出并返回 0。 接下来就是判断环境变量是 DJANGO_AUTORELOAD_ENV 是否为 true,如果是,执行 start_django,否则执行 restart_with_reloader。默认设置情况下,第一次运行时,环境变量是没有设置的,因此会运行 restart_with_reloader

def restart_with_reloader():
    new_environ = {**os.environ, DJANGO_AUTORELOAD_ENV: 'true'}
    args = get_child_arguments()
    while True:
        p = subprocess.run(args, env=new_environ, close_fds=False)
        if p.returncode != 3:
            return p.returncode

这里先通过 get_child_arguments 获取命令及参数,再进入循环,通过 subprocess.run 来运行 Django 服务,Django 运行的过程中,函数是阻塞在此处的,Django 进程运行结束返回的结果不是 3,程序直接就退出了。

p = subprocess.run(args, env=new_environ, close_fds=False)

大家猜测下 Django 进程什么时候返回 3 呢? 相信你已经猜到了,就是文件有修改时,Django 进程返回了 3,通过循环,实现重新启动的效果。

def trigger_reload(filename):
    logger.info('%s changed, reloading.', filename)
    sys.exit(3)

调用这个函数的类为 StatReloader 和 WatchmanReloader,具体的细节见 py37env/lib/python3.7/site-packages/django/utils/autoreload.py

理解了工作原理后,就可以为我所用了。

2、autoreload 为我所用

好在 django.utils.autoreload 和 django 其他模块是松耦合的,不需要修改代码即可可以直接移植到其他项目使用。做法很简单,只需要将 Django 库中 utils 目录下的 autoreload.py 文件复制到自己项目的路径下,再导入使用即可。

两行代码就可以实现,我这里做了个 demo:

demo 目录树如下:

(py37env) ➜  test tree
.
├── autoreload.py
├── test.py
└── test2.py

0 directories, 3 files

test.py 文件内容如下:

# filename: test.py
import autoreload
import test2

def main():
    print("---------------------")
    print("test.main1")
    print("test.main2")
    print("test.main3")
    test2.main()

if __name__ == '__main__':
    autoreload.run_with_reloader(main)

test2.py 文件内容如下:

def main():
    print("test2.main11")
    print("test2.main22")
    print("test2.main33")

运行 python test.py 后,程序打印了预期的结果,但没有退出,说明 autoreload 内部是以守护进程方式运行主函数 main。修改 test.py test2.py 的任何地方,程序都会重新运行,非常便于调试。如果只保存,未修改任何内容,则程序不会重新运行,非常智能。

运行结果如下:

---------------------
test.main1
test.main2
test.main3
test2.main11
test2.main22
test2.main33
---------------------
test.main1
test.main2
test.main3
test.main4
test2.main11
test2.main22
test2.main33
test2.main44

视频展示:https://b23.tv/MAqqLK

源代码我放在了公众号后台,如果不想动手找 Django 源码 autoreload ,可以关注「Python七号」,回复关键词 「autoreload」 下载。