Python 通过钩子远程加载模块 问题
你想自定义Python的import语句,使得它能从远程机器上面透明的加载模块。
Python 通过钩子远程加载模块 解决方案
首先要提出来的是安全问题。本节讨论的思想如果没有一些额外的安全和认知机制的话会很糟糕。 也就是说,我们的主要目的是深入分析Python的import语句机制。 如果你理解了本节内部原理,你就能够为其他任何目的而自定义import。 有了这些,让我们继续向前走。
本节核心是设计导入语句的扩展功能。有很多种方法可以做这个, 不过为了演示的方便,我们开始先构造下面这个Python代码结构:
这些文件的内容并不重要,不过我们在每个文件中放入了少量的简单语句和函数, 这样你可以测试它们并查看当它们被导入时的输出。例如:
这里的目的是允许这些文件作为模块被远程访问。 也许最简单的方式就是将它们发布到一个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
实体加载模块。
远程模块加载跟其他的加载使用方法几乎是一样的。例如:
Python 通过钩子远程加载模块 讨论
在详细讨论之前,有点要强调的是,Python的模块、包和导入机制是整个语言中最复杂的部分, 即使经验丰富的Python程序员也很少能精通它们。 我在这里推荐一些值的去读的文档和书籍,包括 importlib module 和 PEP 302. 文档内容在这里不会被重复提到,不过我在这里会讨论一些最重要的部分。
首先,如果你想创建一个新的模块对象,使用 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.etree
和 xml.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_cache
比 sys.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中的目录。 如果找到了命名空间包,所有的结果路径被加到一起来构建最终的命名空间包。
所有的包都包含了一个内部路径设置,可以在__path__属性中看到,例如:
>>> import xml.etree.ElementTree
>>> xml.__path__
['/usr/local/lib/python3.3/xml']
>>> xml.etree.__path__
['/usr/local/lib/python3.3/xml/etree']
>>>
之前提到,__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的处理。 通过这种扩展加载的模块跟普通方式加载的特性是一样的。
如果到现在为止你还是不是很明白,那么可以通过增加一些日志打印来测试下本节。像下面这样: