1991 年,第一个 Python 解释器诞生,历经快 30 年的发展,Python 已成为当今大学中教学的首选语言,在统计、AI 编程、脚本编写、系统测试等领域均排名第一,并当选 2018 年年度编程语言,足见其流行程度。
Python 不仅拥有非常强大的标准库,更是拥有海量的第三方模块,这跟 Python 高度的可扩展性是密不可分的。如果说把一个 Python 程序比作一栋建筑的话,那么模块就是组成这栋建筑的砖块,而模块的导入就是一块块的砖砌成一栋完整建筑的过程。在 Python 众多特性中,有一个特性隐秘而强大,它就是 Python 的 import hook 机制(PEP302 New Import Hooks)。利用它,我们可以接管 Python 的模块导入流程,实现非常强大的自定义功能,下面是一些知名的 Python 库使用 import hook 的应用:
作为 Python 的一项 “高级” 特性,我们日常的编码一般很少会接触到,大多数时候它都隐藏在 Python 的一些第三方库中。但是当你深入了解它之后,你就会感叹它的强大。笔者也是因为需要实现一个 Python 程序动态插桩的框架而接触到 Python import hook 的,并最终通过该特性实现了自己的需求。下面就让我们一起走进 Python import hook 的世界。
Python 的模块导入机制被设计为可扩展的,其主要实现就是 import hook(导入钩子)。导入钩子分为两种:meta hooks(元钩子) 和 import path hooks(导入路径钩子)。当程序尝试导入一个模块时,导入机制首先在 sys.modules 缓存中查找,如果没有对应的模块缓存,就会依次调用 sys.meta_path 中的 finder 对象,即调用导入协议来查找和加载模块,此时任何其他导入过程都还没有被执行,这允许 meta hooks 重载 sys.path 过程、冻结模块甚至内置模块。 meta hooks 的注册是通过向 sys.meta_path 添加新的查找器对象。import path hooks 是作为 sys.path (或 package.__ path __) 过程的一部分,在遇到它们所关联的路径项的时候被调用。import path hooks 的注册是通过向 sys.path_hooks 添加新的可调用对象。
Python 的导入协议包含了三个概念:
find_spec()
方法 (Python3.4 以后推荐的方法) 或者find_module()
方法。如果 finder 可以查找到模块,则会返回一个 moduleSpecs 对象(对应find_spec()
方法)或 loader 对象 (对应find_module()
方法),没有找到则返回 None。load_module()
的方法,在 Python 3.4 中,修改为实现exec_module()
和create_module()
方法使用 Gradle 构建的,在模块的 build.gradle 里配置:
moduleSpec 对象 (PEP 451),在 Python3.4 中引入,该对象记录了模块的位置、导入器等相关信息,包含了比 loader 对象更详细的信息。
在 Python3.6 中,sys.meta_path 存储着内置的 3 个 finder 对象
>>> import sys
>>> sys.meta_path
[<class '_frozen_importlib.BuiltinImporter'>,
<class '_frozen_importlib.FrozenImporter'>,
<class '_frozen_importlib_external.PathFinder'>]
下面两图分别为 Python3.4 之前的流程和 Python3.4 之后推荐的流程。Python3.4 引入了 moduleSpec 的概念,并且将之前的 load_module 步骤拆分为更详细的多个步骤。目前为止,还保持了向后兼容,所以旧的代码在新版本的 Python 中是可以正常运行的。
Python3.4 之前的导入流程:
Python3.4 及之后推荐的导入流程:
在熟悉了 Python 的导入流程之后,我们就可以向 sys.meta_path 中加入自定义的查找器和导入器,从而实现对模块导入流程的控制,实现自己需要的功能。例如一个简单的例子,在导入模块时打印出模块信息
>>> import sys
>>> class Watcher(object):
@classmethod
def find_spec(cls, name, path, target=None):
print(name, path, target)
return None
sys.meta_path.insert(0, Watcher)
import urllib.parse
>>> sys.meta_path.insert(0, ImportWatcher)
>>> import socket
Importing socket None None
Importing _socket None None
Importing selectors None None
Importing math None None
Importing select None None
又或者可以让 python 在导入第三方模块时自动安装缺少的库。
import sys
import subprocess
import importlib.util
class AutoInstall(object):
_loaded = set()
@classmethod
def find_spec(cls, name, path, target=None):
if path is None and name not in cls._loaded:
cls._loaded.add(name)
print("Installing", name)
try:
out = subprocess.check_output(
[sys.executable, '-m', 'pip', 'install', name])
return importlib.util.find_spec(name)
except Exception as e:
print("Failed")
return None
sys.meta_path.append(AutoInstall)
还有一些更加实用和强大的应用场景,比如上传 Python 程序的审计信息、Python 程序性能监控、模块的懒加载、将配置文件当做一个 python 的模块导入等等。文章开头提到的 pytest 的例子中,pytest 使用 import hook 拦截了 Python 标准的模块导入流程,当 pytest 检测到导入的模块是一个测试用例文件时,pytest 首先会使用 Python 内置的 ast 模块将目标文件的源码解析成 AST(语法树),然后在语法树中查找并重写了 assert 语句,使得在 assert 失败时,能够输出更加详细的调试信息。
在 Python3.6 中,sys.path_hooks 存储着内置了 2 个可调用对象或函数
>>> import sys
>>> sys.path_hooks
[<class 'zipimport.zipimporter'>,
<function FileFinder.path_hook.<locals>.path_hook_for_FileFinder at 0x10ab5a7b8>]
早期的 Python 开发者们希望寻求一种能够直接从 Zip 包中导入 Python 模块的方法,要实现这个功能并不难,只需要对 import.c 进行少量的修改就可以实现了,但是如何将这个功能做到通用并且易于扩展呢?最初的设计方案是允许 sys.path 列表加入非字符串类型的对象,这些对象会实现必要的方法来支持模块的导入,但是这种方案有两个缺点:
后来 Jython 实现了一种妥协的方案,允许 sys.path 中加入字符串类型的子类,在子类中实现导入器协议,但这种方案实在无法被认为是一种 “优雅的实现”。再后来,一种更为合理的设计被采用了,也就是我们这里讲到的 sys.path_hooks,其核心思想来自于 McMillan 的 iu.py。
Python import path hooks 作用于 sys.meta_path 中的最后一个对象 PathFinder。PathFinder 也是一个 finder 对象,这意味着它也实现了find_spec()
方法。当有新的搜索需求到达 PathFinder 时,它会启动两层循环:在外层循环中,PathFinder 会迭代搜索路径sys.path
,如果sys.path_importer_cache
中缓存了当前搜索路径对应的 finder 对象,则直接从缓存从取出返回,如果缓存中不存在,则启动内层循环,迭代sys.path_hooks
并将当前搜索路径作为参数传递给sys.path_hooks
中的可调用对象,如果当前项不能处理该路径,就必须抛出 ImportError,如果可以,则生成一个 finder 对象将其写入sys.path_importer_cache
缓存中并返回此结果。在外层循环中,对返回的 finder 对象,尝试调用find_spec()
方法获取 moduleSpec 对象,如果返回不为 None 的,则将 moduleSpec 返回,否则继续执行外层循环。
>> sys.path_importer_cache
{'/': FileFinder('/'),
'/usr/local/lib/python3.6': FileFinder('/usr/local/lib/python3.6'),
'/usr/local/lib/python3.6/collections': FileFinder('/usr/local/lib/python3.6/collections'),
'/usr/local/lib/python3.6/encodings': FileFinder('/usr/local/lib/python3.6/encodings'),
'/usr/local/lib/python3.6/lib-dynload': FileFinder('/usr/local/lib/python3.6/lib-dynload'),
'/usr/local/lib/python3.6/site-packages': FileFinder('/usr/local/lib/python3.6/site-packages'),
'/usr/local/lib/python36.zip': None}
Python import path hooks 可以用来扩展支持从不同的路径类型中导入模块,例如默认的 zipimporter 可以直接从 zip 包中导入模块,我们也可以通过自定义的 path hooks 实现从远程文件服务器、redis 缓存、mysql 数据库等各种路径类型中导入模块。从远程文件服务器导入模块的实现可以参考python3-cookbook中的例子,这里就不再赘述。
上面已经讲到了 Python import hook 的基本原理和应用,我们可以使用 import hook 实现非常强大的功能,但是笔者在实际的项目需求中还遇到了另外一个问题,我们需要应用源码中显式地将我们自定义的 hook 注册到 sys.meta_path 或者是 sys.path_hooks 中才能生效,而在某些应用场景中,我们不希望侵入应用源码,那么是否有方法可以在 Python 解释器启动后就自动开始执行 import hook 的操作呢?答案是有的,就是这里提到的site 模块。
在 Python 解释器的初始化阶段,会自动导入 site 模块(除非使用 -S 参数显式关闭导入),site 模块的主要作用是补充 sys.path 路径,协助 python 预配置第三方模块目录,并会尝试导入 sitecustomize 和 usercustomize 模块。从模块名我们可以大致猜测出,这两个模块的目的为了自定义站点和用户相关的配置,但其实我们可以在这两个模块中执行任意的 python 代码,当然也可以将我们的自定义 hook 注册到 sys.meta_paths 和 sys.path_hooks 中。
以 Newrelic 为例,Newrelic 是一个性能监控的工具,支持 Python 语言,当安装好它的 newrelic-python-agent 之后,直接使用
$ newrelic-admin run-program python hello.py
就可以启动目标程序并对目标程序进行性能监控了,它的实现方式就是在 newrelic-admin 程序中将 newrelic-python-agent 自定义的 sitecustomize.py 所在的目录加入 PYTHONPATH 中,当被监控程序 python hello.py 启动时,就会加载 newrelic 中注册的 hook 并将性能监控的逻辑注入的程序中。
如果你想要了解更多资讯,欢迎关注我们的微信公众号 我们会定时向大家推送团队同学分享的经验文章哦。