2025年7月23日 星期三

Python 學習筆記 : 用 importlib 動態載入模組

最近為了在 Mapleboard 上架設一個仿 Google Cloud Function (GCF) 的輕量級 Serverless 執行平台, 我詢問 AI 解決方案時發現可以用 Python 內建模組 importlib 來實現, 我之前都沒用過, 所以就先來測試一下此模組用法. 

importlib 為 Python 標準函式庫中的內建模組, 用來動態載入 (dynamic import) 其他模組, 或是在程式執行時重新載入已經載入過的模組, 它提供了比一般 import 更靈活的功能, 特別適合在執行時根據字串名稱動態導入模組或呼叫其函式 (需要導入的模組名稱事先未知), 常見於 Plugin 系統, Serverless 執行平台, 與模組熱更新等應用場景. 

首先匯入 importlib 模組 : 

>>> import importlib   

用 dir() 檢視 importlib 模組內容 : 

>>> dir(importlib)   
['_RELOADING', '__all__', '__builtins__', '__cached__', '__doc__', '__file__', '__import__', '__loader__', '__name__', '__package__', '__path__', '__spec__', '_abc', '_bootstrap', '_bootstrap_external', '_imp', '_pack_uint32', '_unpack_uint32', 'find_loader', 'import_module', 'invalidate_caches', 'machinery', 'reload', 'sys', 'util', 'warnings']

可以用自訂模組 members 的 list_members() 顯示類別, 函式, 與子模組等成員, 參考 : 


>>> from members import list_members   
>>> list_members(importlib)      
find_loader <class 'function'>
import_module <class 'function'>   
invalidate_caches <class 'function'>
machinery <class 'module'>
reload <class 'function'>
sys <class 'module'>
util <class 'module'>
warnings <class 'module'>

可見 importlib 有四個函式與子模組 (find_loader 已被棄用), 常用函式如下表 : 


 importlib 常用函式 功能說明
 importlib.import_module(name) 動態匯入模組, 效果與 import 相同, 可指定字串路徑.
 importlib.reload(module) 重新載入已載入的模組, 常用於模組更新後的即時刷新.
 importlib.util.find_spec(name) 傳回指定模組的 ModuleSpec, 可查詢路徑與 loader 等資訊.
 importlib.util.module_from_spec(spec) 根據 spec 產生尚未初始化的模組物件.
 spec.loader.exec_module(module) 將指定模組物件執行, 真正「啟動」模組內容.
 importlib.invalidate_caches() 清除 importlib 的快取, 強迫重新尋找模組.



1. 載入目前目錄下模組或系統模組 : 

importlib.import_module() 函式用來依據傳入之模組名稱 (字串) 動態載入其他模組, 這些模組的位置必須在目前的工作目錄下或系統模組 (可以在 sys.path 找到的模組), 意即它們都在標準查找路徑內. 例如載入標準函式庫中的模組, 只要傳入內建函式名稱即可 :  

>>> math_module=importlib.import_module('math')    
>>> math_module.sqrt(2)   
1.4142135623730951

此與直接匯入 math 做計算效果是一樣的 :

>>> import math   
>>> math.sqrt(2)   
1.4142135623730951

但差別在於傳入 importlib.import_module() 的是字串變數, 因此可以動態地載入任何模組, 而不須於程式開頭靜態載入 (事實上如果模組名稱未知, 而是在執行時才確定, 那也無法使用 import 靜態載入). 

也可以呼叫內建函式 getattr() 從 importlib.import_module() 載入的模組中取出指定之物件或函式, 例如數學函式庫 math_module :

>>> sqrt_func=getattr(math_module, 'sqrt')   
>>> sqrt_func(2)     
1.4142135623730951   

另外也可以載入目前工作目錄下的模組, 只要傳入模組名稱即可, 如果位於子目錄下則加上以點號隔開的路徑即可, 例如 '子目錄.孫目錄.模組名稱'. 

例如在目前工作目錄下建立了一個子目錄 functions :

D:\python\test>mkdir functions  

然後在底下分別編輯三個模組 add.py, sub.py, 與 mul.py :

# add.py
def calc(x, y):
    return x + y

# sub.py
def calc(x, y):
    return x - y

# mul.py
def calc(x, y):
    return x * y

另外為了讓 functions 子目錄符合 Python 套件要求, 在 functions 子目錄下須新增一個空檔案 __init__.py, 檔案目錄結構如下圖 :




然後回上一層 (工作目錄), 新增一個 Python 檔案, 例如 dynamic_loading_1.py :

# dynamic_loading_1.py
import importlib

modules=['add', 'sub', 'mul']  # 模組清單
x, y=10, 3  # 傳入參數
# 執行每個模組的 calc() 函式
for module_name in modules:  
    module_path=f'functions.{module_name}'    # 例如 'functions.add'
    module=importlib.import_module(module_path)
    result=module.calc(x, y)
    print(f'{module_name}.calc({x}, {y})={result}')

此程式中的 modules 串列儲存了 functions 子目錄下的三個模組名稱, 然後用迴圈走訪此串列, 於迴圈中用 importlib.import_module() 依序載入此三模組實例, 呼叫其 calc() 函式進行運算後輸出, 結果如下 :

>>> %Run dynamic_loading_1.py   
add.calc(10, 3)=13
sub.calc(10, 3)=7
mul.calc(10, 3)=30
 
此簡單範例說明了動態載入模組的原理, 在上面的應用程式 dynamic_loading_1.py 中的模組名稱為已知之串列內容, 但是在實際應用上 (例如 serverless 平台) functions 底下的模組名稱可能是動態變化的, 且真正要執行的模組是由客戶端傳來的變數, 無法用 import 靜態載入, 這時就必須用到此例所展示的動態載入技巧了. 


2. 載入其他位置下的模組 : 

如果要載入的模組既非系統模組 (即不在 sys.path 內) 也不在目前工作目錄或其子目錄下, 而是在其他任意位置的話 (僅知其檔案路徑), 就無法使用 importlib.import_module() 載入, 必須使用 importlib.util.spec_from_file_location() 搭配 importlib.util.module_from_spec() 才能載入. 

在上面的範例中, 要載入的模組位於 D:\python\test\functions 資料夾下面 : 

D:\python>tree test\functions /f   
列出磁碟區 新增磁碟區 的資料夾 PATH
磁碟區序號為 1258-16B8
D:\PYTHON\TEST\FUNCTIONS
    add.py
    mul.py
    sub.py
    __init__.py

我在 D:\python\ 下建立一個與 test 平行的 test2 資料夾, 並且新建一個 dynamic_loading_2.py 應用程式來測試從任意位置載入模組的方法 :

D:\python>tree test2 /f  
列出磁碟區 新增磁碟區 的資料夾 PATH
磁碟區序號為 1258-16B8
D:\PYTHON\TEST2
    dynamic_loading_2.py

dynamic_loading_2.py 程式碼如下 :

# dynamic_loading_2.py
import importlib
import os

modules=['add', 'sub', 'mul']  # 模組清單
x, y=10, 3  # 傳入參數
# 定義 functions 資料夾的實際路徑(根據你的說明)
functions_dir=r'D:\python\test\functions'
# 動態載入並執行每個模組的 calc() 函式
for module_name in modules:
    # 串接路徑與檔名
    file_path=os.path.join(functions_dir, f'{module_name}.py')  
    # 從指定檔案路徑建立模組載入規格 (傳回 ModuleSpec 物件 )
    spec=importlib.util.spec_from_file_location(module_name, file_path)
    # 根據模組規格 spec 建立一個新的模組物件 (傳回 ModuleType 物件)
    module=importlib.util.module_from_spec(spec)
    # 執行模組程式碼 (將模組內容載入到記憶體中的 module 物件)
    spec.loader.exec_module(module)
    # 執行 module 物件中的 calc() 函式
    result=module.calc(x, y)  
    print(f'{module_name}.calc({x}, {y})={result}')

此處先定義函式模組的資料夾變數 functions_dir, 然後在遍歷 modules 的迴圈中, 用 importlib.util.spec_from_file_location() 建立載入該模組的規格 (ModuleSpec) 物件, 然後呼叫 importlib.util.module_from_spec() 並傳入規格物件來建立一個模組物件, 最後呼叫 spec.loader.exec_module() 並傳入模組物件將模組載入記憶體, 這樣便能呼叫模組物件中的函式了, 結果如下 : 

>>> %cd 'D:\python'    
>>> %cd 'D:\python\test'  
>>> %cd 'D:\python'  
>>> %cd 'D:\python\test2'  
>>> %Run dynamic_loading_2.py  
add.calc(10, 3)=13
sub.calc(10, 3)=7
mul.calc(10, 3)=30

可見與上面範例一樣都能動態載入模組執行裡面的函式. 但此方法比上面的老, 因為可載入放在任何位置的 .py 模組檔案, 而且該模組不需要在 sys.path 中 (不用汙染 sys.path), 也不需位於合規套件內 (即含有 __init__.py). 

PS :

加入 sys.path 的方法 : 

import sys   
sys.path.append('/home/你的使用者')     

沒有留言 :