博客 / 詳情

返回

使用 pkgutil 實現動態插件系統

pkgutil 簡介

pkgutil 是 Python 標準庫中的一個模塊,提供了用於處理 Python 包的工具函數。它的核心功能之一是 iter_modules() 函數,能夠動態遍歷和發現指定包路徑下的所有子模塊和子包。這一特性使其成為實現動態插件系統的選擇之一。(之前也介紹過藉助__init_subclass__()在子類繼承時動態註冊插件)

與手動遍歷文件系統或使用第三方庫相比,pkgutil 具有以下優勢:

  • 標準庫原生支持:無需引入額外依賴
  • 跨平台兼容:統一處理不同操作系統的路徑差異
  • 支持命名空間包:能夠正確處理 PEP 420 定義的命名空間包
  • 與導入系統緊密集成:返回的模塊名可直接用於 importlib.import_module()

核心函數:iter_modules

iter_modules() 函數簽名如下:

pkgutil.iter_modules(path=None, prefix='')
  • path:要搜索的路徑列表,通常使用包的 __path__ 屬性
  • prefix:返回的模塊名前綴,常用於構建完整的模塊導入路徑

該函數返回一個迭代器,每個元素是一個三元組 (module_info_finder, name, ispkg)

  • module_info_finder:查找器對象(Python 3.6+ 為 ModuleInfo 實例)
  • name:模塊或包的名稱(不含前綴)
  • ispkg:布爾值,表示是否為包(含有 __init__.py 的目錄)

實現動態插件系統

設計思路

一個典型的插件系統包含以下組件:

  1. 協議定義:使用 typing.Protocol 定義插件必須實現的接口
  2. 插件發現:使用 pkgutil.iter_modules() 自動發現所有插件包
  3. 插件加載:使用 importlib.import_module() 動態導入插件模塊
  4. 插件驗證:運行時檢查插件是否滿足協議要求
  5. 插件執行:調用插件方法執行具體任務

代碼實現

首先定義插件協議:

# jobs/base.py
from typing import Protocol, runtime_checkable

@runtime_checkable
class JobProtocol(Protocol):
    """插件協議定義"""
    
    def enabled(self) -> bool:
        """判斷插件是否啓用"""
        ...
    
    def run(self) -> bool:
        """執行插件任務"""
        ...

@runtime_checkable 裝飾器使得協議可以在運行時通過 isinstance() 進行檢查。

寫稿的時候想起來可以加個_runable()方法, 執行run()方法之前先檢查是否滿足可執行條件。

插件加載器實現:

# main.py
import logging
import pkgutil
import importlib
from types import ModuleType
from jobs.base import JobProtocol

logger = logging.getLogger(__name__)

def load_jobs(package: str = "jobs") -> list[JobProtocol]:
    """動態加載指定包下的所有任務插件"""
    loaded_jobs: list[JobProtocol] = []

    # 導入目標包
    pkg = importlib.import_module(package)
    
    # 遍歷子包
    for finder, name, ispkg in pkgutil.iter_modules(pkg.__path__, pkg.__name__ + "."):
        # 只處理子包
        if not ispkg:
            continue
        
        # 動態導入模塊
        module = importlib.import_module(name)
        
        # 獲取工廠函數並創建實例
        if hasattr(module, "job_factory"):
            job = module.job_factory()
            
            # 協議驗證
            if isinstance(job, JobProtocol) and job.enabled():
                loaded_jobs.append(job)
    
    return loaded_jobs

插件實現示例:

# jobs/jobs1/__init__.py
from jobs.base import JobProtocol
from .job import MyJob1

def job_factory() -> JobProtocol:
    return MyJob1()

# jobs/jobs1/job.py
class MyJob1:
    def enabled(self) -> bool:
        return True

    def run(self) -> bool:
        print(f"{self.__class__.__name__} is running")
        return True

之所以每個插件放單獨的package中,是想着如果插件功能複雜,單個文件的篇幅可能會極長,可以拆分到不同的文件中。每個插件也可以維護單獨的配置加載方式。而且可以利用上 pkgutil 返回的 ispkg 。如果插件的功能簡單,也可以寫成單獨的文件。

項目結構

project/
├── main.py                 # 主程序入口
├── jobs/
│   ├── __init__.py        # 包初始化文件
│   ├── base.py            # 協議定義
│   ├── jobs1/             # 插件1
│   │   ├── __init__.py    # 導出 job_factory
│   │   └── job.py         # 具體實現
│   ├── jobs2/             # 插件2
│   │   ├── __init__.py
│   │   └── job.py
│   └── jobs3/             # 插件3(可禁用)
│       ├── __init__.py
│       └── job.py

運行結果示例

2026-03-01 21:13:26 - INFO - 開始加載任務...
2026-03-01 21:13:26 - INFO - 成功加載任務: jobs.jobs1
2026-03-01 21:13:26 - INFO - 成功加載任務: jobs.jobs2
2026-03-01 21:13:26 - INFO - 任務 jobs.jobs3 已禁用,跳過
2026-03-01 21:13:26 - WARNING - 任務 jobs.jobs4 未實現 JobProtocol 協議(缺少 enabled 或 run 方法)
2026-03-01 21:13:26 - INFO - 共加載 2 個任務
2026-03-01 21:13:26 - INFO - 開始執行任務...
MyJob1 is running
2026-03-01 21:13:26 - INFO - 任務 MyJob1 執行完成,結果: True
MyJob2 is running
2026-03-01 21:13:26 - INFO - 任務 MyJob2 執行完成,結果: True
2026-03-01 21:13:26 - INFO - 所有任務執行完畢

實際應用注意事項

包結構規範

確保插件目錄是規範的 Python 包:

  • 每個插件包必須包含 __init__.py 文件
  • 父包(jobs/)也應包含 __init__.py,確保 __path__ 屬性正確設置
  • 雖然 Python 3.3+ 支持命名空間包(無 __init__.py),但顯式定義包結構更加健壯

錯誤處理策略

動態加載過程中存在多種潛在的失敗點,需要逐一處理:

try:
    pkg = importlib.import_module(package)
except ImportError as e:
    logger.error(f"導入包失敗: {e}")
    return []

for finder, name, ispkg in pkgutil.iter_modules(pkg.__path__, prefix):
    try:
        module = importlib.import_module(name)
    except Exception as e:
        logger.error(f"加載模塊 {name} 失敗: {e}")
        continue
    
    if not hasattr(module, "job_factory"):
        continue
    
    try:
        job = module.job_factory()
    except Exception as e:
        logger.error(f"實例化插件 {name} 失敗: {e}")
        continue

使用 logging 替代 print

生產環境中應使用 logging 模塊:

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)

這提供了日誌級別控制、時間戳、輸出重定向等關鍵能力。

Protocol 運行時檢查

typing.Protocol 配合 @runtime_checkable 裝飾器支持運行時類型檢查:

from typing import Protocol, runtime_checkable

@runtime_checkable
class JobProtocol(Protocol):
    def enabled(self) -> bool: ...
    def run(self) -> bool: ...

# 檢查實例是否滿足協議
if isinstance(job, JobProtocol):
    job.run()

注意:運行時檢查僅驗證方法是否存在,不驗證方法簽名。如果參數類型不匹配,運行時仍會報錯。

插件隔離與依賴管理

  • 避免循環導入:插件模塊不應導入主程序模塊
  • 延遲導入:插件內部的重量級依賴應在 run() 方法中導入,而非模塊頂層
  • 異常隔離:每個插件的執行應該相互獨立,一個插件失敗不應影響其他插件
def run_jobs(jobs: list[JobProtocol]) -> None:
    for job in jobs:
        try:
            job.run()
        except Exception as e:
            logger.error(f"任務執行失敗: {e}")
            # 繼續執行其他任務

插件順序控制

如果插件執行順序很重要,可以考慮以下策略:

  • 使用插件名稱前綴排序(如 jobs/01_init/jobs/02_process/
  • 在協議中添加 priority() 方法
  • 在插件元數據中定義依賴關係

性能考量

  • iter_modules() 遍歷文件系統,頻繁調用可能影響性能
  • 考慮在程序啓動時一次性加載所有插件,後續使用緩存的插件列表
  • 對於大量插件,可以考慮延遲加載(lazy loading)模式

安全性考慮

動態加載代碼存在潛在安全風險:

  • 僅從可信路徑加載插件
  • 在沙箱環境中運行不受信任的插件
  • 限制插件的文件系統和網絡訪問權限

補充

代碼示例

  • main.py
"""動態任務加載器

使用 pkgutil 模塊動態發現和加載 jobs 包下的所有任務插件。
"""

import logging
import pkgutil
import importlib
from types import ModuleType
from jobs.base import JobProtocol

# 配置日誌
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(levelname)s - %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)


def load_jobs(package: str = "jobs") -> list[JobProtocol]:
    """動態加載指定包下的所有任務插件。

    遍歷 package 下的所有子包,嘗試導入每個子包並調用其 job_factory 函數
    創建任務實例。只有實現了 JobProtocol 協議且 enabled() 返回 True 的
    任務才會被執行。

    Args:
        package: 要掃描的包名,默認為 "jobs"。

    Returns:
        成功加載的任務實例列表。
    """
    loaded_jobs: list[JobProtocol] = []

    try:
        pkg: ModuleType = importlib.import_module(package)
    except ImportError as e:
        logger.error(f"導入包 {package} 失敗: {e}")
        return loaded_jobs

    # pkg.__path__ 可能是 None(當 package 是命名空間包但沒有子包時)
    if not hasattr(pkg, "__path__") or pkg.__path__ is None:
        logger.warning(f"包 {package} 沒有 __path__ 屬性,無法遍歷子模塊")
        return loaded_jobs

    for finder, name, ispkg in pkgutil.iter_modules(pkg.__path__, pkg.__name__ + "."):
        # 只處理子包,跳過模塊文件
        if not ispkg:
            logger.debug(f"跳過模塊 {name}(只加載子包)")
            continue

        try:
            module: ModuleType = importlib.import_module(name)
        except Exception as e:
            logger.error(f"加載任務模塊 {name} 失敗: {e}")
            continue

        if not hasattr(module, "job_factory"):
            logger.warning(f"模塊 {name} 沒有 job_factory 函數,跳過")
            continue

        try:
            job: JobProtocol = module.job_factory()

            # 使用 Protocol 的運行時檢查功能驗證協議實現
            if not isinstance(job, JobProtocol):
                logger.warning(
                    f"任務 {name} 未實現 JobProtocol 協議(缺少 enabled 或 run 方法)"
                )
                continue

            if not job.enabled():
                logger.info(f"任務 {name} 已禁用,跳過")
                continue

            loaded_jobs.append(job)
            logger.info(f"成功加載任務: {name}")

        except Exception as e:
            logger.error(f"創建任務實例 {name} 失敗: {e}")
            continue

    return loaded_jobs


def run_jobs(jobs: list[JobProtocol]) -> None:
    """執行所有任務。

    Args:
        jobs: 要執行的任務實例列表。
    """
    for job in jobs:
        try:
            result = job.run()
            logger.info(f"任務 {job.__class__.__name__} 執行完成,結果: {result}")
        except Exception as e:
            logger.error(f"任務 {job.__class__.__name__} 執行失敗: {e}")


def main() -> None:
    """程序入口函數。"""
    logger.info("開始加載任務...")
    jobs = load_jobs()
    logger.info(f"共加載 {len(jobs)} 個任務")

    logger.info("開始執行任務...")
    run_jobs(jobs)
    logger.info("所有任務執行完畢")


if __name__ == "__main__":
    main()
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.