深刻探討 Python 的 import 機制:實現遠程導入模塊

所謂的模塊導入( import ),是指在一個模塊中使用另外一個模塊的代碼的操做,它有利於代碼的複用。html

在 Python 中使用 import 關鍵字來實現這個操做,但不是惟一的方法,還有 importlib.import_module()__import__() 等。python

也許你看到這個標題,會說我怎麼會發這麼基礎的文章?shell

與此相反。偏偏我以爲這篇文章的內容能夠算是 Python 的進階技能,會深刻地探討並以真實案例講解 Python import Hook 的知識點。json

固然爲了使文章更系統、全面,前面會有小篇幅講解基礎知識點,但請你有耐心的日後讀下去,由於後面纔是本篇文章的精華所在,但願你不要錯過。flask

1. 導入系統的基礎

1.1 導入單元構成

導入單元有多種,能夠是模塊、包及變量等。緩存

對於這些基礎的概念,對於新手仍是有必要介紹一下它們的區別。bash

模塊:相似 *.py,*.pyc, *.pyd ,*.so,*.dll 這樣的文件,是 Python 代碼載體的最小單元。服務器

還能夠細分爲兩種:網絡

  • Regular packages:是一個帶有 __init__.py 文件的文件夾,此文件夾下可包含其餘子包,或者模塊
  • Namespace packages

關於 Namespace packages,有的人會比較陌生,我這裏摘抄官方文檔的一段說明來解釋一下。app

Namespace packages 是由多個 部分 構成的,每一個部分爲父包增長一個子包。 各個部分可能處於文件系統的不一樣位置。 部分也可能處於 zip 文件中、網絡上,或者 Python 在導入期間能夠搜索的其餘地方。 命名空間包並不必定會直接對應到文件系統中的對象;它們有多是無實體表示的虛擬模塊。

命名空間包的 __path__ 屬性不使用普通的列表。 而是使用定製的可迭代類型,若是其父包的路徑 (或者最高層級包的 sys.path) 發生改變,這種對象會在該包內的下一次導入嘗試時自動執行新的對包部分的搜索。

命名空間包沒有 parent/__init__.py 文件。 實際上,在導入搜索期間可能找到多個 parent 目錄,每一個都由不一樣的部分所提供。 所以 parent/one 的物理位置不必定與 parent/two 相鄰。 在這種狀況下,Python 將爲頂級的 parent 包建立一個命名空間包,不管是它自己仍是它的某個子包被導入。

1.2 相對/絕對導入

當咱們 import 導入模塊或包時,Python 提供兩種導入方式:

  • 相對導入(relative import ):from . import B 或 from ..A import B,其中.表示當前模塊,..表示上層模塊
  • 絕對導入(absolute import):import foo.bar 或者 form foo import bar

你能夠根據實際須要進行選擇,但有必要說明的是,在早期的版本( Python2.6 以前),Python 默認使用的相對導入。然後來的版本中( Python2.6 以後),都以絕對導入爲默認使用的導入方式。

使用絕對路徑和相對路徑各有利弊:

  • 當你在開發維護本身的項目時,應當使用相對路徑導入,這樣能夠避免硬編碼帶來的麻煩。
  • 而使用絕對路徑,會讓你模塊導入結構更加清晰,並且也避免了重名的包衝突而導入錯誤。

1.3 導入的標準寫法

在 PEP8 中對模塊的導入提出了要求,遵照 PEP8規範能讓你的代碼更具備可讀性,我這邊也列一下:

  • import 語句應當分行書寫
# bad
import os,sys

# good
import os
import sys
複製代碼
  • import語句應當使用absolute import
# bad
from ..bar import  Bar

# good
from foo.bar import test
複製代碼
  • import語句應當放在文件頭部,置於模塊說明及docstring以後,全局變量以前

  • import語句應該按照順序排列,每組之間用一個空格分隔,按照內置模塊,第三方模塊,本身所寫的模塊調用順序,同時每組內部按照字母表順序排列

# 內置模塊
import os
import sys

# 第三方模塊
import flask

# 本地模塊
from foo import bar
複製代碼

1.4 幾個有用的 sys 變量

sys.path 能夠列出 Python 模塊查找的目錄列表

>>> import sys
>>> from pprint import pprint
>>> 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 存放的是全部的查找器。

>>> import sys
>>> from pprint import pprint
>>> pprint(sys.meta_path)
[<class '_frozen_importlib.BuiltinImporter'>,
 <class '_frozen_importlib.FrozenImporter'>,
 <class '_frozen_importlib_external.PathFinder'>]
複製代碼

sys.path_importer_cachesys.path 會更大點, 由於它會爲全部被加載代碼的目錄記錄它們的查找器。 這包括包的子目錄,這些一般在 sys.path 中是不存在的。

>>> import sys
>>> from pprint import pprint
>>> 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
import os

# 使用 __import__
os = __import__('os')
複製代碼

經過觸類旁通,下面兩種方法一樣也是等價的。

# 使用 import .. as ..
import pandas as pd

# 使用 __import__
pd = __import__('pandas')
複製代碼

上面我說 __import__ 經常用於插件的動態,事實上也只有它能作到(相對於 import 來講)。

插件一般會位於某一特定的文件夾下,在使用過程當中,可能你並不會用到所有的插件,也可能你會新增插件。

若是使用 import 關鍵字這種硬編碼的方式,顯然太不優雅了,當你要新增/修改插件的時候,都須要你修改代碼。更合適的作法是,將這些插件以配置的方式,寫在配置文件中,而後由代碼去讀取你的配置,動態導入你要使用的插件,即靈活又方便,也不容易出錯。

假如個人一個項目中,有 plugin01plugin02plugin03plugin04 四個插件,這些插件下都會實現一個核心方法 run() 。但有時候我不想使用所有的插件,只想使用 plugin02plugin04 ,那我就在配置文件中寫我要使用的兩個插件。

# my.conf
custom_plugins=['plugin02', 'plugin04']
複製代碼

那我如何使用動態加載,並運行他們呢?

# main.py

for plugin in conf.custom_plugins:
    __import__(plugin)
    sys.modules[plugin].run()
複製代碼

3. 理解模塊的緩存

在一個模塊內部重複引用另外一個相同模塊,實際並不會導入兩次,緣由是在使用關鍵字 import 導入模塊時,它會先檢索 sys.modules 裏是否已經載入這個模塊了,若是已經載入,則不會再次導入,若是不存在,纔會去檢索導入這個模塊。

來實驗一下,在 my_mod02 這個模塊裏,我 import 兩次 my_mod01 這個模塊,按邏輯每一次 import 會一次 my_mod01 裏的代碼(即打印 in mod01),可是驗證結果是,只打印了一次。

$ cat my_mod01.py 
print('in mod01')                    
 $ cat my_mod02.py 
import my_mod01                                        
import my_mod01     
 $ python my_mod02.py 
in mod01                          
複製代碼

該現象的解釋是:由於有 sys.modules 的存在。

sys.modules 是一個字典(key:模塊名,value:模塊對象),它存放着在當前 namespace 全部已經導入的模塊對象。

# test_module.py

import sys
print(sys.modules.get('json', 'NotFound'))

import json
print(sys.modules.get('json', 'NotFound'))
複製代碼

運行結果以下,可見在 導入後 json 模塊後,sys.modules 纔有了 json 模塊的對象。

$ python test_module.py
NotFound
<module 'json' from 'C:\Python27\lib\json\__init__.pyc'>
複製代碼

因爲有緩存的存在,使得咱們沒法從新載入一個模塊。

但若你想反其道行之,能夠藉助 importlib 這個神奇的庫來實現。事實也確實有此場景,好比在代碼調試中,在發現代碼有異常並修改後,咱們一般要重啓服務再次載入程序。這時候,如有了模塊重載,就無比方便了,修改完代碼後也無需服務的重啓,就能繼續調試。

仍是以上面的例子來理解,my_mod02.py 改寫成以下

# my_mod02.py

import importlib
import my_mod01
importlib.reload(my_mod01)
複製代碼

使用 python3 來執行這個模塊,與上面不一樣的是,這邊執行了兩次 my_mod01.py

$ python3 my_mod02.py
in mod01
in mod01
複製代碼

4. 查找器與加載器

若是指定名稱的模塊在 sys.modules 找不到,則將發起調用 Python 的導入協議以查找和加載該模塊。

此協議由兩個概念性模塊構成,即 查找器加載器

一個 Python 的模塊的導入,其實能夠再細分爲兩個過程:

  1. 由查找器實現的模塊查找
  2. 由加載器實現的模塊加載

4.1 查找器是什麼?

查找器(finder),簡單點說,查找器定義了一個模塊查找機制,讓程序知道該如何找到對應的模塊。

其實 Python 內置了多個默認查找器,其存在於 sys.meta_path 中。

但這些查找器對應使用者來講,並非那麼重要,所以在 Python 3.3 以前, Python 解釋將其隱藏了,咱們稱之爲隱式查找器。

# Python 2.7
>>> import sys
>>> sys.meta_path
[]
>>> 
複製代碼

因爲這點不利於開發者深刻理解 import 機制,在 Python 3.3 後,全部的模塊導入機制都會經過 sys.meta_path 暴露,不會在有任何隱式導入機制。

# Python 3.6
>>> import sys
>>> from pprint import pprint
>>> pprint(sys.meta_path)
[<class '_frozen_importlib.BuiltinImporter'>,
 <class '_frozen_importlib.FrozenImporter'>,
 <class '_frozen_importlib_external.PathFinder'>]
複製代碼

觀察一下 Python 默認的這幾種查找器 (finder),能夠分爲三種:

  • 一種知道如何導入內置模塊
  • 一種知道如何導入凍結模塊
  • 一種知道如何導入來自 import path 的模塊 (即 path based finder)。

那咱們能不能自已定義一個查找器呢?固然能夠,你只要

  • 定義一個實現了 find_module 方法的類(py2和py3都可),或者實現 find_loader 類方法(僅 py3 有效),若是找到模塊須要返回一個 loader 對象或者 ModuleSpec 對象(後面會講),沒找到須要返回 None
  • 定義完後,要使用這個查找器,必須註冊它,將其插入在 sys.meta_path 的首位,這樣就能優先使用。
import sys

class MyFinder(object):
 @classmethod
    def find_module(cls, name, path, target=None):
        print("Importing", name, path, target)
        # 將在後面定義
        return MyLoader()

# 因爲 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 返回。詳情參見 PEP 302,對於 abstract base class 可參見 importlib.abc.Loader。

那如何自定義咱們本身的加載器呢?

你只要

  • 定義一個實現了 load_module 方法的類
  • 對與導入有關的屬性(點擊查看詳情)進行校驗
  • 建立模塊對象並綁定全部與導入相關的屬性變量到該模塊上
  • 將此模塊保存到 sys.modules 中(順序很重要,避免遞歸導入)
  • 而後加載模塊(這是核心)
  • 若加載出錯,須要可以處理拋出異常( ImportError)
  • 若加載成功,則返回 module 對象

若你想看具體的例子,能夠接着日後看。

4.3 模塊規格說明

導入機制在導入期間會使用有關每一個模塊的多種信息,特別是加載以前。 大多數信息都是全部模塊通用的。 模塊規格說明的目的是基於每一個模塊來封裝這些導入相關信息。

模塊的規格說明會做爲模塊對象的 __spec__ 屬性對外公開。 有關模塊規格的詳細內容請參閱 ModuleSpec

在 Python 3.4 後,查找器再也不返回加載器,而是返回 ModuleSpec 對象,它儲存着更多的信息

  • 模塊名
  • 加載器
  • 模塊絕對路徑

那如何查看一個模塊的 ModuleSpec ?

這邊舉個例子

$ cat my_mod02.py
import my_mod01
print(my_mod01.__spec__)
 $ python3 my_mod02.py
in mod01
ModuleSpec(name='my_mod01', loader=<_frozen_importlib_external.SourceFileLoader object at 0x000000000392DBE0>, origin='/home/MING/my_mod01.py')
複製代碼

從 ModuleSpec 中能夠看到,加載器是包含在內的,那咱們若是要從新加載一個模塊,是否是又有了另外一種思路了?

來一塊兒驗證一下。

如今有兩個文件:

一個是 my_info.py

# my_info.py
name='wangbm'
複製代碼

另外一個是:main.py

# main.py
import my_info

print(my_info.name)

# 加一個斷點
import pdb;pdb.set_trace()

# 再加載一次
my_info.__spec__.loader.load_module()

print(my_info.name)
複製代碼

main.py 處,我加了一個斷點,目的是當運行到斷點處時,我修改 my_info.py 裏的 name 爲 ming ,以便驗證重載是否有效?

$ python3 main.py
wangbm
> /home/MING/main.py(9)<module>()
-> my_info.__spec__.loader.load_module()
(Pdb) c
ming
複製代碼

從結果來看,重載是有效的。

4.4 導入器是什麼?

導入器(importer),也許你在其餘文章裏會見到它,但其實它並非個新鮮的東西。

它只是同時實現了查找器和加載器兩種接口的對象,因此你能夠說導入器(importer)是查找器(finder),也能夠說它是加載器(loader)。

5. 遠程導入模塊

因爲 Python 默認的 查找器和加載器 僅支持本地的模塊的導入,並不支持實現遠程模塊的導入。

爲了讓你更好的理解 Python Import Hook 機制,我下面會經過實例演示,如何本身實現遠程導入模塊的導入器。

5.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。 第三個參數是一個將被做爲稍後加載目標的現有模塊對象。 導入系統僅會在重加載期間傳入一個目標模塊。

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)
複製代碼

5.2 搭建遠程服務端

最開始我說了,要實現一個遠程導入模塊的方法。

我還缺一個在遠端的服務器,來存放個人模塊,爲了方便,我使用python自帶的 http.server 模塊用一條命令便可實現。

$ mkdir httpserver && cd httpserver
$ cat>my_info.py<EOF
name='wangbm'
print('ok')
EOF
 $ 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'
複製代碼

至此,我實現了一個簡易的能夠導入遠程服務器上的模塊的導入器。

參考文檔

相關文章
相關標籤/搜索