Python 中的 import 机制之实现远程导入模块
所谓的模块导入(import),是指在一个模块中使用另一个模块的代码的操作,它有利于代码的复用。
在Python中使用import关键字来实现这个操作,但不是唯一的方法,还有importlib.import_module()和__import__()等。
也许你看到这个标题,会说我怎么会发这么基础的文章?
与此相反。恰恰我觉得这篇文章的内容可以算是Python的进阶技能,会深入地探讨并以真实案例讲解PythonimportHook的知识点。
当然为了使文章更系统、全面,前面会有小篇幅讲解基础知识点,但请你有耐心的往后读下去,因为后面才是本篇文章的精华所在,希望你不要错过。
1.导入系统的基础
1.1导入单元构成
导入单元有多种,可以是模块、包及变量等。
对于这些基础的概念,对于新手还是有必要介绍一下它们的区别。
模块:类似*.py,*.pyc,*.pyd,*.so,*.dll这样的文件,是Python代码载体的最小单元。
包还可以细分为两种:
__init__.py
关于Namespacepackages,有的人会比较陌生,我这里摘抄官方文档的一段说明来解释一下。
Namespacepackages是由多个部分构成的,每个部分为父包增加一个子包。各个部分可能处于文件系统的不同位置。部分也可能处于zip文件中、网络上,或者Python在导入期间可以搜索的其他地方。命名空间包并不一定会直接对应到文件系统中的对象;它们有可能是无实体表示的虚拟模块。
命名空间包的__path__属性不使用普通的列表。而是使用定制的可迭代类型,如果其父包的路径(或者最高层级包的sys.path)发生改变,这种对象会在该包内的下一次导入尝试时自动执行新的对包部分的搜索。
命名空间包没有parent/__init__.py文件。实际上,在导入搜索期间可能找到多个parent目录,每个都由不同的部分所提供。因此parent/one的物理位置不一定与parent/two相邻。在这种情况下,Python将为顶级的parent包创建一个命名空间包,无论是它本身还是它的某个子包被导入。
1.2相对/绝对导入
当我们import导入模块或包时,Python提供两种导入方式:
- 相对导入(relativeimport):from.importB或from..AimportB,其中.表示当前模块,..表示上层模块
- 绝对导入(absoluteimport):importfoo.bar或者formfooimportbar
你可以根据实际需要进行选择,但有必要说明的是,在早期的版本(Python2.6之前),Python默认使用的相对导入。而后来的版本中(Python2.6之后),都以绝对导入为默认使用的导入方式。
使用绝对路径和相对路径各有利弊:
- 当你在开发维护自己的项目时,应当使用相对路径导入,这样可以避免硬编码带来的麻烦。
- 而使用绝对路径,会让你模块导入结构更加清晰,而且也避免了重名的包冲突而导入错误。
1.3导入的标准写法
在PEP8中对模块的导入提出了要求,遵守PEP8规范能让你的代码更具有可读性,我这边也列一下:
import语句应当分行书写
#bad importos,sys #good importos importsys
import语句应当使用absoluteimport
#bad from..barimportBar #good fromfoo.barimporttest
- import语句应当放在文件头部,置于模块说明及docstring之后,全局变量之前
- import语句应该按照顺序排列,每组之间用一个空格分隔,按照内置模块,第三方模块,自己所写的模块调用顺序,同时每组内部按照字母表顺序排列
#内置模块 importos importsys #第三方模块 importflask #本地模块 fromfooimportbar
1.4几个有用的sys变量
sys.path可以列出Python模块查找的目录列表
>>>importsys >>>frompprintimportpprint >>>pprint(sys.path) ['', '/Library/Frameworks/Python.framework/Versions/3.6/lib/python36.zip', '/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6', '/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/lib-dynload', '/Users/MING/Library/Python/3.6/lib/python/site-packages', '/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/site-packages'] >>>
sys.meta_path存放的是所有的查找器。
>>>importsys >>>frompprintimportpprint >>>pprint(sys.meta_path) [, , ]
sys.path_importer_cache比sys.path会更大点,因为它会为所有被加载代码的目录记录它们的查找器。这包括包的子目录,这些通常在sys.path中是不存在的。
>>>importsys >>>frompprintimportpprint >>>pprint(sys.path_importer_cache) {'/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6':FileFinder('/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6'), '/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/collections':FileFinder('/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/collections'), '/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/encodings':FileFinder('/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/encodings'), '/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/lib-dynload':FileFinder('/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/lib-dynload'), '/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/site-packages':FileFinder('/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/site-packages'), '/Library/Frameworks/Python.framework/Versions/3.6/lib/python36.zip':None, '/Users/MING':FileFinder('/Users/MING'), '/Users/MING/Library/Python/3.6/lib/python/site-packages':FileFinder('/Users/MING/Library/Python/3.6/lib/python/site-packages')}
2._import_的妙用
import关键字的使用,可以说是基础中的基础。
但这不是模块唯一的方法,还有importlib.import_module()和__import__()等。
和import不同的是,__import__是一个函数,也正是因为这个原因,使得__import__的使用会更加灵活,常常用于框架中,对于插件的动态加载。
实际上,当我们调用import导入模块时,其内部也是调用了__import__,请看如下两种导入方法,他们是等价的。
#使用import importos #使用__import__ os=__import__('os')
通过举一反三,下面两种方法同样也是等价的。
#使用import..as.. importpandasaspd #使用__import__ pd=__import__('pandas')
上面我说__import__常常用于插件的动态,事实上也只有它能做到(相对于import来说)。
插件通常会位于某一特定的文件夹下,在使用过程中,可能你并不会用到全部的插件,也可能你会新增插件。
如果使用import关键字这种硬编码的方式,显然太不优雅了,当你要新增/修改插件的时候,都需要你修改代码。更合适的做法是,将这些插件以配置的方式,写在配置文件中,然后由代码去读取你的配置,动态导入你要使用的插件,即灵活又方便,也不容易出错。
假如我的一个项目中,有plugin01、plugin02、plugin03、plugin04四个插件,这些插件下都会实现一个核心方法run()。但有时候我不想使用全部的插件,只想使用plugin02、plugin04,那我就在配置文件中写我要使用的两个插件。
#my.conf custom_plugins=['plugin02','plugin04']
那我如何使用动态加载,并运行他们呢?
#main.py forplugininconf.custom_plugins: __import__(plugin) sys.modules[plugin].run()
3.理解模块的缓存
在一个模块内部重复引用另一个相同模块,实际并不会导入两次,原因是在使用关键字import导入模块时,它会先检索sys.modules里是否已经载入这个模块了,如果已经载入,则不会再次导入,如果不存在,才会去检索导入这个模块。
来实验一下,在my_mod02这个模块里,我import两次my_mod01这个模块,按逻辑每一次import会一次my_mod01里的代码(即打印inmod01),但是验证结果是,只打印了一次。
$catmy_mod01.py print('inmod01') $catmy_mod02.py importmy_mod01 importmy_mod01 $pythonmy_mod02.py inmod01
该现象的解释是:因为有sys.modules的存在。
sys.modules是一个字典(key:模块名,value:模块对象),它存放着在当前namespace所有已经导入的模块对象。
#test_module.py importsys print(sys.modules.get('json','NotFound')) importjson print(sys.modules.get('json','NotFound'))
运行结果如下,可见在导入后json模块后,sys.modules才有了json模块的对象。
$pythontest_module.py NotFound
由于有缓存的存在,使得我们无法重新载入一个模块。
但若你想反其道行之,可以借助importlib这个神奇的库来实现。事实也确实有此场景,比如在代码调试中,在发现代码有异常并修改后,我们通常要重启服务再次载入程序。这时候,若有了模块重载,就无比方便了,修改完代码后也无需服务的重启,就能继续调试。
还是以上面的例子来理解,my_mod02.py改写成如下
#my_mod02.py importimportlib importmy_mod01 importlib.reload(my_mod01)
使用python3来执行这个模块,与上面不同的是,这边执行了两次my_mod01.py
$python3my_mod02.py inmod01 inmod01
4.查找器与加载器
如果指定名称的模块在sys.modules找不到,则将发起调用Python的导入协议以查找和加载该模块。
此协议由两个概念性模块构成,即查找器和加载器。
一个Python的模块的导入,其实可以再细分为两个过程:
- 由查找器实现的模块查找
- 由加载器实现的模块加载
4.1查找器是什么?
查找器(finder),简单点说,查找器定义了一个模块查找机制,让程序知道该如何找到对应的模块。
其实Python内置了多个默认查找器,其存在于sys.meta_path中。
但这些查找器对应使用者来说,并不是那么重要,因此在Python3.3之前,Python解释将其隐藏了,我们称之为隐式查找器。
#Python2.7 >>>importsys >>>sys.meta_path [] >>>
由于这点不利于开发者深入理解import机制,在Python3.3后,所有的模块导入机制都会通过sys.meta_path暴露,不会在有任何隐式导入机制。
#Python3.6 >>>importsys >>>frompprintimportpprint >>>pprint(sys.meta_path) [, , ]
观察一下Python默认的这几种查找器(finder),可以分为三种:
- 一种知道如何导入内置模块
- 一种知道如何导入冻结模块
- 一种知道如何导入来自importpath的模块(即pathbasedfinder)。
那我们能不能自已定义一个查找器呢?当然可以,你只要
- 定义一个实现了find_module方法的类(py2和py3均可),或者实现find_loader类方法(仅py3有效),如果找到模块需要返回一个loader对象或者ModuleSpec对象(后面会讲),没找到需要返回None
- 定义完后,要使用这个查找器,必须注册它,将其插入在sys.meta_path的首位,这样就能优先使用。
importsys classMyFinder(object): @classmethod deffind_module(cls,name,path,target=None): print("Importing",name,path,target) #将在后面定义 returnMyLoader() #由于finder是按顺序读取的,所以必须插入在首位 sys.meta_path.insert(0,MyFinder)
查找器可以分为两种:
object +--Finder(deprecated) +--MetaPathFinder +--PathEntryFinder
这里需要注意的是,在3.4版前,查找器会直接返回加载器(Loader)对象,而在3.4版后,查找器则会返回模块规格说明(ModuleSpec),其中包含加载器。
而关于什么是加载器和模块规格说明,请继续往后看。
4.2加载器是什么?
查找器只负责查找定位找模,而真正负责加载模块的,是加载器(loader)。
一般的loader必须定义名为load_module()的方法。
为什么这里说一般,因为loader还分多种:
object +--Finder(deprecated) |+--MetaPathFinder |+--PathEntryFinder +--Loader +--ResourceLoader--------+ +--InspectLoader| +--ExecutionLoader--+ +--FileLoader +--SourceLoader
通过查看源码可知,不同的加载器的抽象方法各有不同。
加载器通常由一个finder返回。详情参见PEP302,对于abstractbaseclass可参见importlib.abc.Loader。
那如何自定义我们自己的加载器呢?
你只要
- 定义一个实现了load_module方法的类
- 对与导入有关的属性(点击查看详情)进行校验
- 创建模块对象并绑定所有与导入相关的属性变量到该模块上
- 将此模块保存到sys.modules中(顺序很重要,避免递归导入)
- 然后加载模块(这是核心)
- 若加载出错,需要能够处理抛出异常(ImportError)
- 若加载成功,则返回module对象
- 若你想看具体的例子,可以接着往后看。
4.3模块规格说明
导入机制在导入期间会使用有关每个模块的多种信息,特别是加载之前。大多数信息都是所有模块通用的。模块规格说明的目的是基于每个模块来封装这些导入相关信息。
模块的规格说明会作为模块对象的__spec__属性对外公开。有关模块规格的详细内容请参阅ModuleSpec。
在Python3.4后,查找器不再返回加载器,而是返回ModuleSpec对象,它储存着更多的信息
- 模块名
- 加载器
- 模块绝对路径
那如何查看一个模块的ModuleSpec?
这边举个例子
$catmy_mod02.py importmy_mod01 print(my_mod01.__spec__) $python3my_mod02.py inmod01 ModuleSpec(name='my_mod01',loader=<_frozen_importlib_external.SourceFileLoaderobjectat0x000000000392DBE0>,origin='/home/MING/my_mod01.py')
从ModuleSpec中可以看到,加载器是包含在内的,那我们如果要重新加载一个模块,是不是又有了另一种思路了?
来一起验证一下。
现在有两个文件:
一个是my_info.py
#my_info.py name='wangbm'
另一个是:main.py
#main.py importmy_info print(my_info.name) #加一个断点 importpdb;pdb.set_trace() #再加载一次 my_info.__spec__.loader.load_module() print(my_info.name)
在main.py处,我加了一个断点,目的是当运行到断点处时,我修改my_info.py里的name为ming,以便验证重载是否有效?
$python3main.py wangbm >/home/MING/main.py(9)() ->my_info.__spec__.loader.load_module() (Pdb)c ming
从结果来看,重载是有效的。
4.4导入器是什么?
导入器(importer),也许你在其他文章里会见到它,但其实它并不是个新鲜的东西。
它只是同时实现了查找器和加载器两种接口的对象,所以你可以说导入器(importer)是查找器(finder),也可以说它是加载器(loader)。
5.远程导入模块
由于Python默认的查找器和加载器仅支持本地的模块的导入,并不支持实现远程模块的导入。
为了让你更好的理解PythonImportHook机制,我下面会通过实例演示,如何自己实现远程导入模块的导入器。
5.1动手实现导入器
当导入一个包的时候,Python解释器首先会从sys.meta_path中拿到查找器列表。
默认顺序是:内建模块查找器->冻结模块查找器->第三方模块路径(本地的sys.path)查找器
若经过这三个查找器,仍然无法查找到所需的模块,则会抛出ImportError异常。
因此要实现远程导入模块,有两种思路。
- 一种是实现自己的元路径导入器;
- 另一种是编写一个钩子,添加到sys.path_hooks里,识别特定的目录命名模式。
我这里选择第一种方法来做为示例。
实现导入器,我们需要分别查找器和加载器。
首先是查找器
由源码得知,路径查找器分为两种
- MetaPathFinder
- PathEntryFinder
这里使用MetaPathFinder来进行查找器的编写。
在Python3.4版本之前,查找器必须实现find_module()方法,而Python3.4+版,则推荐使用find_spec()方法,但这并不意味着你不能使用find_module(),但是在没有find_spec()方法时,导入协议还是会尝试find_module()方法。
我先举例下使用find_module()该如何写。
fromimportlibimportabc classUrlMetaFinder(abc.MetaPathFinder): def__init__(self,baseurl): self._baseurl=baseurl deffind_module(self,fullname,path=None): ifpathisNone: baseurl=self._baseurl else: #不是原定义的url就直接返回不存在 ifnotpath.startswith(self._baseurl): returnNone baseurl=path try: loader=UrlMetaLoader(baseurl) loader.load_module(fullname) returnloader exceptException: returnNone
若使用find_spec(),要注意此方法的调用需要带有两到三个参数。
第一个是被导入模块的完整限定名称,例如foo.bar.baz。第二个参数是供模块搜索使用的路径条目。对于最高层级模块,第二个参数为None,但对于子模块或子包,第二个参数为父包__path__属性的值。如果相应的__path__属性无法访问,将引发ModuleNotFoundError。第三个参数是一个将被作为稍后加载目标的现有模块对象。导入系统仅会在重加载期间传入一个目标模块。
fromimportlibimportabc fromimportlib.machineryimportModuleSpec classUrlMetaFinder(abc.MetaPathFinder): def__init__(self,baseurl): self._baseurl=baseurl deffind_spec(self,fullname,path=None,target=None): ifpathisNone: baseurl=self._baseurl else: #不是原定义的url就直接返回不存在 ifnotpath.startswith(self._baseurl): returnNone baseurl=path try: loader=UrlMetaLoader(baseurl) returnModuleSpec(fullname,loader,is_package=loader.is_package(fullname)) exceptException: returnNone
接下来是加载器
由源码得知,路径查找器分为三种
- FileLoader
- SourceLoader
按理说,两种加载器都可以实现我们想要的功能,我这里选用SourceLoader来示范。
在SourceLoader这个抽象类里,有几个很重要的方法,在你写实现加载器的时候需要注意
module.__dict__
在一些老的博客文章中,你会经常看到加载器要实现load_module(),而这个方法早已在Python3.4的时候就被废弃了,当然为了兼容考虑,你若使用load_module()也是可以的。
fromimportlibimportabc classUrlMetaLoader(abc.SourceLoader): def__init__(self,baseurl): self.baseurl=baseurl defget_code(self,fullname): f=urllib2.urlopen(self.get_filename(fullname)) returnf.read() defload_module(self,fullname): code=self.get_code(fullname) mod=sys.modules.setdefault(fullname,imp.new_module(fullname)) mod.__file__=self.get_filename(fullname) mod.__loader__=self mod.__package__=fullname exec(code,mod.__dict__) returnNone defget_data(self): pass defexecute_module(self,module): pass defget_filename(self,fullname): returnself.baseurl+fullname+'.py'
当你使用这种旧模式实现自己的加载时,你需要注意两点,很重要:
- execute_module必须重载,而且不应该有任何逻辑,即使它并不是抽象方法。
- load_module,需要你在查找器里手动执行,才能实现模块的加载。。
做为替换,你应该使用execute_module()和create_module()。由于基类里已经实现了execute_module和create_module(),并且满足我们的使用场景。我这边可以不用重复实现。和旧模式相比,这里也不需要在设查找器里手动执行execute_module()。
importurllib.requestasurllib2 classUrlMetaLoader(importlib.abc.SourceLoader): def__init__(self,baseurl): self.baseurl=baseurl defget_code(self,fullname): f=urllib2.urlopen(self.get_filename(fullname)) returnf.read() defget_data(self): pass defget_filename(self,fullname): returnself.baseurl+fullname+'.py'
查找器和加载器都有了,别忘了往sys.meta_path注册我们自定义的查找器(UrlMetaFinder)。
definstall_meta(address): finder=UrlMetaFinder(address) sys.meta_path.append(finder)
所有的代码都解析完毕后,我们将其整理在一个模块(my_importer.py)中
#my_importer.py importsys importimportlib importurllib.requestasurllib2 classUrlMetaFinder(importlib.abc.MetaPathFinder): def__init__(self,baseurl): self._baseurl=baseurl deffind_module(self,fullname,path=None): ifpathisNone: baseurl=self._baseurl else: #不是原定义的url就直接返回不存在 ifnotpath.startswith(self._baseurl): returnNone baseurl=path try: loader=UrlMetaLoader(baseurl) returnloader exceptException: returnNone classUrlMetaLoader(importlib.abc.SourceLoader): def__init__(self,baseurl): self.baseurl=baseurl defget_code(self,fullname): f=urllib2.urlopen(self.get_filename(fullname)) returnf.read() defget_data(self): pass defget_filename(self,fullname): returnself.baseurl+fullname+'.py' definstall_meta(address): finder=UrlMetaFinder(address) sys.meta_path.append(finder)
5.2搭建远程服务端
最开始我说了,要实现一个远程导入模块的方法。
我还缺一个在远端的服务器,来存放我的模块,为了方便,我使用python自带的http.server模块用一条命令即可实现。
$mkdirhttpserver&&cdhttpserver $cat>my_info.py一切准备好,我们就可以验证了。
>>>frommy_importerimportinstall_meta >>>install_meta('http://localhost:12800/')#往sys.meta_path注册finder >>>importmy_info#打印ok,说明导入成功 ok >>>my_info.name#验证可以取得到变量 'wangbm'至此,我实现了一个简易的可以导入远程服务器上的模块的导入器。
参考文档
https://docs.python.org/zh-cn...
https://docs.python.org/zh-cn...
https://python3-cookbook.read...
声明:本文内容来源于网络,版权归原作者所有,内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:czq8825#qq.com(发邮件时,请将#更换为@)进行举报,并提供相关证据,一经查实,本站将立刻删除涉嫌侵权内容。