为什么不用静态分析来实现 HMR

因为运行时依赖图构建能做到更细粒度

几个月前,我看到 How to build Hot Module Replacement in Python 这篇文章,讲的是用他们的 Tach 这个 Python 静态分析工具,可以获得一个依赖图,类似这样的键值格式:

{
  "a.py": ["b.py", "c.py"],
  "b.py": ["d.py"],
  "d.py": ["e.py"],
  "c.py": ["f.py"]
}

说起来,这个项目上个月开始 unmaintained 了 😅 一问才发现原来是开发者跑去 AI 创业了。我觉得网友说的挺好:
I guess that's the issue with open source tools being backed by VCs. These tools will never be maintained if you can't make tons of money off of them.

然后在每次检测到某个文件更改时,依照依赖图更新(调用 import)它和它所有直接、间接影响的模块,这就时它的大致原理。

问题

Python 开发者通常不喜欢把文件分得太细,所以事实上如果按照它这套技术方案的话,往往每次重载要涉及的文件会很多(甚至任何一个文件都会让整个项目的每个模块都重载)。使用这套解决方案相比通常的冷重载,其实只省下了 import 第三方库的时间和启动 Python 本身的 overhead。这也让这个所有中间状态都丢失了。

另一方面,懒加载和动态的加载也没法通过静态分析获得。

我们的运行时方案

hmr采用截然不同的办法,在运行时记录依赖关系。与tach的静态分析的方式相反,hmr不通过 AST 去分析依赖,而是记录每次 import 了哪些 module,以及用了那个模块中的哪些变量,来决定依赖图。

举个例子,一个文件data.py如下:

a = 1
b = 2
c = a + b  # 3

然后有个文件a.py使用了data.py的值:

from data import a
print(a)

如果是静态分析,会认为a.py依赖于data.py,如果data.py重载了,则a.py也应该重载。

而我们的hmr则支持“更细粒度的反应式”,什么意思呢,它认为a.py只依赖于data.py中的a的值。所以如果这时候更改a.pyb = 2b = 3,这时候data.a完全没有改变,所以a.py并不会重载。

只有当你修改了a = 1为别的值时,才会触发a.py重新打印 a 的值。同理,如果你的入口文件是c.py其内容如下:

import data
print(data.c)

那么当你改变a或者b的时候(只要a + b的结果变了)那 c 就会变,导致重新打印出data.c出来。

这就是静态分析永远难以企及的细粒度热重载。

原理

其实很简单,本质上,我只要让每次某个 module 被__getattr__了都记录下来就行了。比如,如果a模块触发了b.__getattr__("c"),意味着a.py中肯定存在from b import c或者b.c这样的地方,那么a模块就依赖于b.c

至于如何自定义 module 的__getattr__,就是普通的元编程了(我在 这篇 文章中详述过)。简单来说就是实现一个自定义的 ModuleType 子类,然后在sys.meta_path中注册一个 ModuleFinder 就行了。

结语

经过大半年的努力,我真的处理了超级多 edge cases,解决了很多 performance issues,目的就是让所有的魔法都 work transparently,让所有的 dragon 都不用被用户影响。

现在 hmr 作为一个 CLI 已经完全模仿 python 的行为了。比如你平常以python main.py -a --b c这样启动的应用,直接改成hmr main.py -a --b c这样,所有事情应该 just work ~

如果你用 uvicorn 启动 Web 服务,你也可以试试看 uvicorn-hmr,它也是一个 drop-in replacement of uvicorn --reload,直接替换命令即可。我在向后兼容上做的非常小心

包名同 CLI 名,比如 hmr 就是pip install hmr,uvicorn-hmr 就是pip install uvicorn-hmr


最近还做了个有趣的更 hack 的工具,hmr-daemon,它的行为是,pip install hmr-daemon后,什么代码都不用改,但是当你修改某个文件而且此时 python 进程还没退出的话,这个模块就会重载,当然它影响到的模块也会按正确的顺序重载。对于在 REPL 比如ipython中使用你项目中的模块时很有用。