10.11 通过钩子远程加载模块

问题

你想自定义 Pythonimport 语句,使得它能从远程机器上面透明的加载模块。

解决方案

首先要提出来的是安全问题。

本节核心是设计导入语句的扩展功能。有很多种方法可以做这个, 不过为了演示的方便,我们开始先构造下面这个 Python 代码结构:

testcode/
    spam.py
    fib.py
    grok/
        __init__.py
        blah.py

这些文件的内容并不重要,不过我们在每个文件中放入了少量的简单语句和函数, 这样你可以测试它们并查看当它们被导入时的输出。例如:

# spam.py
print("I'm spam")

def hello(name):
    print('Hello %s' % name)

# fib.py
print("I'm fib")

def fib(n):
    if n < 2:
        return 1
    else:
        return fib(n-1) + fib(n-2)

# grok/__init__.py
print("I'm grok.__init__")

# grok/blah.py
print("I'm grok.blah")

这里的目的是允许这些文件作为模块被远程访问。 也许最简单的方式就是将它们发布到一个 web 服务器上面。在testcode 目录中像下面这样运行 Python:

服务器运行起来后再启动一个单独的 Python 解释器。 确保你可以使用 urllib 访问到远程文件。例如:

为了替代手动的通过 urlopen() 来收集源文件, 我们通过自定义 import 语句来在后台自动帮我们做到。

加载远程模块的第一种方法是创建一个显示的加载函数来完成它。例如:

这个函数会下载源代码,并使用 compile() 将其编译到一个代码对象中, 然后在一个新创建的模块对象的字典中来执行它。下面是使用这个函数的方式:

对于简单的模块这个是行得通的。 不过它并没有嵌入到通常的 import 语句中,如果要支持更高级的结构比如包就需要更多的工作了。

一个更酷的做法是创建一个自定义导入器。第一种方法是创建一个元路径导入器。如下:

下面是一个交互会话,演示了如何使用前面的代码:

这个特殊的方案会安装一个特别的查找器 UrlMetaFinder 实例, 作为 sys.meta_path 中最后的实体。 当模块被导入时,会依据 sys.meta_path 中的查找器定位模块。 在这个例子中,UrlMetaFinder 实例是最后一个查找器方案, 当模块在任何一个普通地方都找不到的时候就触发它。

作为常见的实现方案,UrlMetaFinder 类包装在一个用户指定的 URL 上。 在内部,查找器通过抓取指定URL的内容构建合法的链接集合。 导入的时候,模块名会跟已有的链接作对比。如果找到了一个匹配的, 一个单独的 UrlModuleLoader 类被用来从远程机器上加载源代码并创建最终的模块对象。 这里缓存链接的一个原因是避免不必要的HTTP请求重复导入。

自定义导入的第二种方法是编写一个钩子直接嵌入到 sys.path 变量中去, 识别某些目录命名模式。 在 urlimport.py 中添加如下的类和支持函数:

要使用这个路径查找器,你只需要在 sys.path 中加入 URL 链接。例如:

关键点就是 handle_url() 函数,它被添加到了 sys.path_hooks 变量中。 当 sys.path的实体被处理时,会调用 sys.path_hooks 中的函数。 如果任何一个函数返回了一个查找器对象,那么这个对象就被用来为 sys.path 实体加载模块。

远程模块加载跟其他的加载使用方法几乎是一样的。例如:

讨论

首先,如果你想创建一个新的模块对象,使用 imp.new_module() 函数:

模块对象通常有一些期望属性,包括 __file__ (运行模块加载语句的文件名) 和 __package__ (包名)。

其次,模块会被解释器缓存起来。模块缓存可以在字典 sys.modules 中被找到。 因为有了这个缓存机制,通常可以将缓存和模块的创建通过一个步骤完成:

如果给定模块已经存在那么就会直接获得已经被创建过的模块,例如:

由于创建模块很简单,很容易编写简单函数比如第一部分的 load_module() 函数。 这个方案的一个缺点是很难处理复杂情况比如包的导入。 为了处理一个包,你要重新实现普通 import 语句的底层逻辑(比如检查目录,查找 __init__.py 文件, 执行那些文件,设置路径等)。这个复杂性就是为什么最好直接扩展 import 语句而不是自定义函数的一个原因。

扩展 import 语句很简单,但是会有很多移动操作。 最高层上,导入操作被一个位于 sys.meta_path 列表中的“元路径”查找器处理。 如果你输出它的值,会看到下面这样:

当执行一个语句比如 import fib 时,解释器会遍历 sys.mata_path 中的查找器对象, 调用它们的 find_module() 方法定位正确的模块加载器。 可以通过实验来看看:

注意看 find_module() 方法是怎样在每一个导入就被触发的。 这个方法中的 path 参数的作用是处理包。 多个包被导入,就是一个可在包的 __path__ 属性中找到的路径列表。 要找到包的子组件就要检查这些路径。 比如注意对于 xml.etreexml.etree.ElementTree 的路径配置:

sys.meta_path 上查找器的位置很重要,将它从队头移到队尾,然后再试试导入看:

现在你看不到任何输出了,因为导入被 sys.meta_path 中的其他实体处理。 这时候,你只有在导入不存在模块的时候才能看到它被触发:

你之前安装过一个捕获未知模块的查找器,这个是 UrlMetaFinder 类的关键。 一个 UrlMetaFinder 实例被添加到 sys.meta_path 的末尾,作为最后一个查找器方案。 如果被请求的模块名不能定位,就会被这个查找器处理掉。 处理包的时候需要注意,在 path 参数中指定的值需要被检查,看它是否以查找器中注册的 URL 开头。 如果不是,该子模块必须归属于其他查找器并被忽略掉。

对于包的其他处理可在 UrlPackageLoader 类中被找到。 这个类不会导入包名,而是去加载对应的 __init__.py 文件。 它也会设置模块的 __path__ 属性,这一步很重要, 因为在加载包的子模块时这个值会被传给后面的 find_module() 调用。 基于路径的导入钩子是这些思想的一个扩展,但是采用了另外的方法。 我们都知道,sys.path 是一个 Python 查找模块的目录列表,例如:

sys.path 中的每一个实体都会被额外的绑定到一个查找器对象上。 你可以通过查看 sys.path_importer_cache 去看下这些查找器:

sys.path_importer_cachesys.path 会更大点, 因为它会为所有被加载代码的目录记录它们的查找器。 这包括包的子目录,这些通常在 sys.path 中是不存在的。

要执行 import fib ,会顺序检查 sys.path 中的目录。 对于每个目录,名称“fib”会被传给相应的 sys.path_importer_cache 中的查找器。 这个可以让你创建自己的查找器并在缓存中放入一个实体。试试这个:

在这里,你可以为名字“debug”创建一个新的缓存实体并将它设置成 sys.path 上的第一个。 在所有接下来的导入中,你会看到你的查找器被触发了。 不过,由于它返回 (None, []),那么处理进程会继续处理下一个实体。

sys.path_importer_cache 的使用被一个存储在 sys.path_hooks 中的函数列表控制。 试试下面的例子,它会清除缓存并给 sys.path_hooks 添加一个新的路径检查函数:

正如你所见,check_path() 函数被每个 sys.path 中的实体调用。 不顾,由于抛出了 ImportError 异常, 啥都不会发生了(仅仅将检查转移到 sys.path_hooks 的下一个函数)。

知道了怎样 sys.path 是怎样被处理的,你就能构建一个自定义路径检查函数来查找文件名,比如 URL。例如:

事实上,一个用来在 sys.path 中查找 URL 的自定义路径检查函数已经构建完毕。 当它们被碰到的时候,一个新的 UrlPathFinder 实例被创建并被放入 sys.path_importer_cache. 之后,所有需要检查 sys.path 的导入语句都会使用你的自定义查找器。

基于路径导入的包处理稍微有点复杂,并且跟 find_loader() 方法返回值有关。 对于简单模块,find_loader() 返回一个元组 (loader, None), 其中的 loader 是一个用于导入模块的加载器实例。

对于一个普通的包,find_loader() 返回一个元组 (loader, path), 其中的 loader 是一个用于导入包(并执行 __init__.py)的加载器实例, path 是一个会初始化包的 __path__ 属性的目录列表。 例如,如果基础 URL是 http://localhost:15000) 并且一个用户执行 import grok , 那么 find_loader() 返回的 path 就会是 [ ‘http://localhost:15000/grok‘ ]

find_loader() 还要能处理一个命名空间包。 一个命名空间包中有一个合法的包目录名,但是不存在 __init__.py 文件。 这样的话,find_loader() 必须返回一个元组 (None, path), path 是一个目录列表,由它来构建包的定义有 __init__.py 文件的 __path__ 属性。 对于这种情况,导入机制会继续前行去检查sys.path 中的目录。 如果找到了命名空间包,所有的结果路径被加到一起来构建最终的命名空间包。 关于命名空间包的更多信息请参考 10.5 小节。

所有的包都包含了一个内部路径设置,可以在 __path__ 属性中看到,例如:

之前提到,__path__ 的设置是通过 find_loader() 方法返回值控制的。 不过,__path__ 接下来也被sys.path_hooks 中的函数处理。 因此,但包的子组件被加载后,位于 __path__ 中的实体会被 handle_url() 函数检查。 这会导致新的 UrlPathFinder 实例被创建并且被加入到 sys.path_importer_cache 中。

还有个难点就是 handle_url() 函数以及它跟内部使用的 _get_links() 函数之间的交互。 如果你的查找器实现需要使用到其他模块(比如 urllib.request ), 有可能这些模块会在查找器操作期间进行更多的导入。 它可以导致 handle_url() 和其他查找器部分陷入一种递归循环状态。 为了解释这种可能性,实现中有一个被创建的查找器缓存(每一个 URL 一个)。 它可以避免创建重复查找器的问题。 另外,下面的代码片段可以确保查找器不会在初始化链接集合的时候响应任何导入请求:

最后,查找器的 invalidate_caches() 方法是一个工具方法,用来清理内部缓存。 这个方法再用户调用 importlib.invalidate_caches() 的时候被触发。 如果你想让 URL 导入者重新读取链接列表的话可以使用它。

对比下两种方案(修改 sys.meta_path 或使用一个路径钩子)。 使用 sys.meta_path 的导入者可以按照自己的需要自由处理模块。 例如,它们可以从数据库中导入或以不同于一般模块/包处理方式导入。 这种自由同样意味着导入者需要自己进行内部的一些管理。 另外,基于路径的钩子只是适用于对 sys.path 的处理。 通过这种扩展加载的模块跟普通方式加载的特性是一样的。

如果到现在为止你还是不是很明白,那么可以通过增加一些日志打印来测试下本节。像下面这样:

Last updated

Was this helpful?