1. 程式人生 > 程式設計 >Python 中的 import 機制之實現遠端匯入模組

Python 中的 import 機制之實現遠端匯入模組

所謂的模組匯入( import ),是指在一個模組中使用另一個模組的程式碼的操作,它有利於程式碼的複用。

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

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

與此相反。恰恰我覺得這篇文章的內容可以算是 Python 的進階技能,會深入地探討並以真實案例講解 Python import Hook 的知識點。

當然為了使文章更系統、全面,前面會有小篇幅講解基礎知識點,但請你有耐心的往後讀下去,因為後面才是本篇文章的精華所在,希望你不要錯過。

1. 匯入系統的基礎

1.1 匯入單元構成

匯入單元有多種,可以是模組、包及變數等。

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

模組:類似 *.py,*.pyc, *.pyd ,*.so,*.dll 這樣的檔案,是 Python 程式碼載體的最小單元。

包還可以細分為兩種:

__init__.py

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

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_cache 比 sys.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 關鍵字這種硬編碼的方式,顯然太不優雅了,當你要新增/修改外掛的時候,都需要你修改程式碼。更合適的做法是,將這些外掛以配置的方式,寫在配置檔案中,然後由程式碼去讀取你的配置,動態匯入你要使用的外掛,即靈活又方便,也不容易出錯。

假如我的一個專案中,有 plugin01 、 plugin02 、 plugin03 、 plugin04 四個外掛,這些外掛下都會實現一個核心方法 run() 。但有時候我不想使用全部的外掛,只想使用 plugin02 、 plugin04 ,那我就在配置檔案中寫我要使用的兩個外掛。

# 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 的模組的匯入,其實可以再細分為兩個過程:

  • 由查詢器實現的模組查詢
  • 由載入器實現的模組載入

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_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",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,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 這個抽象類裡,有幾個很重要的方法,在你寫實現載入器的時候需要注意

module.__dict__

在一些老的部落格文章中,你會經常看到 載入器 要實現 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_module 和 create_module() ,並且滿足我們的使用場景。我這邊可以不用重複實現。和舊模式相比,這裡也不需要在設查詢器裡手動執行 execute_module() 。

import urllib.request as urllib2

class UrlMetaLoader(importlib.abc.SourceLoader):
 def __init__(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,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,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'

至此,我實現了一個簡易的可以匯入遠端伺服器上的模組的匯入器。

參考文件

https://docs.python.org/zh-cn...

https://docs.python.org/zh-cn...

https://python3-cookbook.read...