8.11 【进阶】实现远程导入模块

由于 Python 默认的 查找器和加载器 仅支持本地的模块的导入,并不支持实现远程模块的导入。

为了让你更好的理解 Python Import Hook 机制,我下面会通过实例演示,如何自己实现远程导入模块的导入器。

1. 动手实现导入器

当导入一个包的时候,Python 解释器首先会从 sys.meta_path 中拿到查找器列表。

默认顺序是:内建模块查找器 -> 冻结模块查找器 -> 第三方模块路径(本地的 sys.path)查找器

若经过这三个查找器,仍然无法查找到所需的模块,则会抛出ImportError异常。

因此要实现远程导入模块,有两种思路。

  • 一种是实现自己的元路径导入器;

  • 另一种是编写一个钩子,添加到sys.path_hooks里,识别特定的目录命名模式。

我这里选择第一种方法来做为示例。

实现导入器,我们需要分别查找器和加载器。

首先是查找器

由源码得知,路径查找器分为两种

  • MetaPathFinder

  • PathEntryFinder

这里使用 MetaPathFinder 来进行查找器的编写。

在 Python 3.4 版本之前,查找器必须实现 find_module() 方法,而 Python 3.4+ 版,则推荐使用 find_spec() 方法,但这并不意味着你不能使用 find_module(),但是在没有 find_spec() 方法时,导入协议还是会尝试 find_module() 方法。

我先举例下使用 find_module() 该如何写。

from importlib import abc

class UrlMetaFinder(abc.MetaPathFinder):
    def __init__(self, baseurl):
        self._baseurl = baseurl

    def find_module(self, fullname, path=None):
        if path is None:
            baseurl = self._baseurl
        else:
            # 不是原定义的url就直接返回不存在
            if not path.startswith(self._baseurl):
                return None
            baseurl = path

        try:
            loader = UrlMetaLoader(baseurl)
            loader.load_module(fullname)
            return loader
        except Exception:
            return None

若使用 find_spec() ,要注意此方法的调用需要带有两到三个参数。

第一个是被导入模块的完整限定名称,例如 foo.bar.baz。 第二个参数是供模块搜索使用的路径条目。 对于最高层级模块,第二个参数为 None,但对于子模块或子包,第二个参数为父包 __path__ 属性的值。 如果相应的 __path__ 属性无法访问,将引发 `ModuleNotFoundError <https://docs.python.org/zh-cn/3/library/exceptions.html#ModuleNotFoundError>`__。 第三个参数是一个将被作为稍后加载目标的现有模块对象。 导入系统仅会在重加载期间传入一个目标模块。

from importlib import abc
from importlib.machinery import ModuleSpec

class UrlMetaFinder(abc.MetaPathFinder):
    def __init__(self, baseurl):
        self._baseurl = baseurl
    def find_spec(self, fullname, path=None, target=None):
        if path is None:
            baseurl = self._baseurl
        else:
            # 不是原定义的url就直接返回不存在
            if not path.startswith(self._baseurl):
                return None
            baseurl = path

        try:
            loader = UrlMetaLoader(baseurl)
            return ModuleSpec(fullname, loader, is_package=loader.is_package(fullname))
        except Exception:
            return None

接下来是加载器

由源码得知,路径查找器分为三种

  • FileLoader

  • SourceLoader

按理说,两种加载器都可以实现我们想要的功能,我这里选用 SourceLoader 来示范。

在 SourceLoader 这个抽象类里,有几个很重要的方法,在你写实现加载器的时候需要注意

  • get_code:获取源代码,可以根据自己场景实现实现。

  • exec_module:执行源代码,并将变量赋值给 module.__dict__

  • get_data:抽象方法,必须实现,返回指定路径的字节码。

  • get_filename:抽象方法,必须实现,返回文件名

在一些老的博客文章中,你会经常看到 加载器 要实现 load_module() ,而这个方法早已在 Python 3.4 的时候就被废弃了,当然为了兼容考虑,你若使用 load_module() 也是可以的。

from importlib import abc

class UrlMetaLoader(abc.SourceLoader):
    def __init__(self, baseurl):
        self.baseurl = baseurl

    def get_code(self, fullname):
        f = urllib2.urlopen(self.get_filename(fullname))
        return f.read()

    def load_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__)
        return None

    def get_data(self):
        pass

    def execute_module(self, module):
        pass

    def get_filename(self, fullname):
        return self.baseurl + fullname + '.py'

当你使用这种旧模式实现自己的加载时,你需要注意两点,很重要:

  • execute_module 必须重载,而且不应该有任何逻辑,即使它并不是抽象方法。

  • load_module,需要你在查找器里手动执行,才能实现模块的加载。。

做为替换,你应该使用 execute_module()create_module() 。由于基类里已经实现了 execute_modulecreate_module(),并且满足我们的使用场景。我这边可以不用重复实现。和旧模式相比,这里也不需要在设查找器里手动执行 execute_module()

import urllib.request as urllib2

class UrlMetaLoader(importlib.abc.SourceLoader):
    def __init__(self, baseurl):
        self.baseurl = baseurl

    def get_code(self, fullname):
        f = urllib2.urlopen(self.get_filename(fullname))
        return f.read()

    def get_data(self):
        pass

    def get_filename(self, fullname):
        return self.baseurl + fullname + '.py'

查找器和加载器都有了,别忘了往sys.meta_path 注册我们自定义的查找器(UrlMetaFinder)。

def install_meta(address):
    finder = UrlMetaFinder(address)
    sys.meta_path.append(finder)

所有的代码都解析完毕后,我们将其整理在一个模块(my_importer.py)中

# my_importer.py
import sys
import importlib
import urllib.request as urllib2

class UrlMetaFinder(importlib.abc.MetaPathFinder):
    def __init__(self, baseurl):
        self._baseurl = baseurl


    def find_module(self, fullname, path=None):
        if path is None:
            baseurl = self._baseurl
        else:
            # 不是原定义的url就直接返回不存在
            if not path.startswith(self._baseurl):
                return None
            baseurl = path

        try:
            loader = UrlMetaLoader(baseurl)
            return loader
        except Exception:
            return None

class UrlMetaLoader(importlib.abc.SourceLoader):
    def __init__(self, baseurl):
        self.baseurl = baseurl

    def get_code(self, fullname):
        f = urllib2.urlopen(self.get_filename(fullname))
        return f.read()

    def get_data(self):
        pass

    def get_filename(self, fullname):
        return self.baseurl + fullname + '.py'

def install_meta(address):
    finder = UrlMetaFinder(address)
    sys.meta_path.append(finder)

2. 搭建远程服务端

最开始我说了,要实现一个远程导入模块的方法。

我还缺一个在远端的服务器,来存放我的模块,为了方便,我使用python自带的 http.server 模块用一条命令即可实现。

$ mkdir httpserver && cd httpserver
$ cat>my_info.py<EOF
name='wangbm'
print('ok')
EOF
5sM!ebM5sM!ebMt0fNkt0fNk
$ cat my_info.py
name='wangbm'
print('ok')
$
$ python3 -m http.server 12800
Serving HTTP on 0.0.0.0 port 12800 (http://0.0.0.0:12800/) ...
...

一切准备好,我们就可以验证了。

>>> from my_importer import install_meta
>>> install_meta('http://localhost:12800/') # 往 sys.meta_path 注册 finder
>>> import my_info  # 打印ok,说明导入成功
ok
>>> my_info.name  # 验证可以取得到变量
'wangbm'

至此,我实现了一个简易的可以导入远程服务器上的模块的导入器。