在很多時候 , 我們會希望所開發的應用軟體允許其他開發者擴展它的功能 。 為了達到這個目的 , 通常的作法是讓軟體支援某種插件框架 (Plugin framework)。 Python 其實擁有內建的機制來實現一個簡單的插件框架 。 本文山姆鍋藉由插件框架來介紹 pkg_resources 這個 Python 用來管理套件資源的套件 (package)。

套件 pkg_resources 簡介

pkg_resources 是一個 Python 套件 , 用來協助管理套件 (packages) 資源 。 套件資源需要以一定的方式封裝 , 但格式不一 ,Egg 跟 Wheel 是目前常用的兩種格式 。 其中 Wheel 是未來主流格式 , 但本文採用的是目前比較通用的 Egg 套件包 。

套件包也存在很多程式語言執行環境 , 例如 :Java 的 Jar 檔 ,Ruby 的 gem 檔 。

蟒蛇蛋 (Python Eggs)

Python 發佈套件 (packages) 的其中格式之一稱為 "egg", 基本上就是把套件相關的檔案以及描述資料按照規定的形式 壓縮的 ZIP 檔 。 Python 執行環境可以理解這種格式並加入執行路徑 ( 也可以使用 PYTHONPATH 環境變數 )。 如此 , 應用便可以根據需要來載入所需的套件或模組 。

除了可以讓應用動態加載所需的套件外 ,Egg 有個跟插件實作所需的特性 : 擴展點 (Entry point)。

擴展點 (Entry Points)

「 擴展點 」 是定義在套件包 (egg 格式 ) 中的描述資料 (metadata), 讓應用可以找到套件包內的特定模組或類別 。 同一個套件包內不能有同名的擴展點 , 但不同套件包可以 。Python 程式庫提供應用所需的機制來找到擴展點並引入 相對應的模組或類別 , 在本文 , 這些類別提供插件的實作 。

在了解如何使用擴展點之前 , 需要先完成套件包的掃描與載入動作 。

掃描與載入套件包

通常在應用啟動時會進行插件的載入與啟動 。 本節說明如何讓套件可以被應用存取 , 也就是說 : 可以引入 (import) 其中的模組 。 針對套件包的掃描與載入 , 山姆鍋定義一個 PackageManager 類別來實現 :

# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals

import logging

import pkg_resources

from ..runtime import environ, settings


logger = logging.getLogger(__name__)


class PackageManager(object):
    """
    Add packages in egg format at runtime.
    """
    def __init__(self, pkgs_dir=environ.pkgs_dir()):
        self.pkgs_dir = pkgs_dir        # 1

    def find_packages(self, add_to_path=True):
        """
        Extends sys.path at runtime to include distributions in user's home directory.
        """

        #logger.debug("sys.path(before): ", sys.path)

        logger.debug("Packages Directory: %s", self.pkgs_dir)
        distributions, errors = pkg_resources.working_set.find_plugins(  # 2
            pkg_resources.Environment([self.pkgs_dir])  # 3
        )

        if len(distributions) > 0:
            logger.debug("Found %d extension package(s).", len(distributions))
            #map(pkg_resources.working_set.add, distributions)  # add plugins+libs to sys.path

            if not add_to_path:
                return

            for it in distributions:
                pkg_resources.working_set.add(it)   # 4
                logger.debug("package added: %s", it.project_name)

            logger.error("Couldn't load: %r", errors)        # display errors
        else:
            logger.debug("No extension package found.")

其中 ,

  1. pkgs_dir 是套件包 ( 副檔名為 .egg) 所在的完整路徑 。
  2. pkg_resources.working_set.find_plugins 這個方法用來找出格式正確的套件包 。
  3. 為了找出套件包 , 需要給定一個環境 (Environment) 物件 , 這裏指定套件包搜尋路徑 , 實際上它還接受其它過濾參數 。
  4. 將找到的套件包加入執行環境的載入路徑 (sys.path), 如此應用可以正常引入 (import) 定義在這些套件包的模組 。

擴充套件管理員實作

一旦套件包正確被加入執行環境的載入路徑後 , 便可以使用擴展點來找出實作應用所需的插件類別 。 山姆鍋也將插件稱為擴充套件 (Extension), 每個插件需要處理下列資料 :

  • name

    插件名稱

  • cls

    實作插件的類別

  • obj

    插件的實例 (instance)

  • entry_point

    插件相關的擴展點

下面定義一個 ExtensionManager 來負責插件的尋找與初始化動作 :

# -*- coding: utf-8 -*-
from __future__ import absolute_import, print_function, unicode_literals

import logging
import pkg_resources

logger = logging.getLogger(__name__)


class Extension(object):
    def __init__(self, name, entry_point):
        self.name = name
        self.entry_point = entry_point
        self.cls = None
        self.obj = None

    def __repr__(self):
        return 'Extension(%s)' % self.name


class ExtensionManager(object):
    def __init__(self, namespace="ava.extension"):
        self.namespace = namespace                      # 1
        self.extensions = []

    def load_extensions(self, invoke_on_load=True):

        # 2
        for it in pkg_resources.working_set.iter_entry_points(self.namespace, name=None):
            logger.debug("Loading extension: %s at module: %s", it.name, it.module_name)
            logger.debug("")


            ext = Extension(it.name, it)
            ext.cls = it.load()             # 3

            if invoke_on_load:
                ext.obj = ext.cls()         # 4
            self.extensions.append(ext)

        # sort extensions by names
        self.extensions.sort(key=lambda e: e.name)  # 5
        logger.debug("Loaded extensions: %r", self.extensions)

    def start_extensions(self, context=None):       # 6
        for ext in self.extensions:
            startfun = getattr(ext.obj, "start", None)
            if startfun is not None and callable(startfun):
                    startfun(context)

    def stop_extensions(self, context=None):        # 7
        for ext in self.extensions:
            stopfun = getattr(ext.obj, "stop", None)
            if stopfun is not None and callable(stopfun):
                try:
                    stopfun(context)
                except Exception:
                    pass
                    #IGNORED.


__all__ = ['Extension', 'ExtensionManager']

其中 ,

  1. namespace 是擴展點的命名空間 , 這裏山姆鍋使用的是 ava.extension , 您可以根據需要更改 。

    pkg_resources 會依據這個命名空間來找出相關聯的擴展點 。

  2. pkg_resources.working_set.iter_entry_points 遍歷命名空間中的所有擴展點 。

  3. 載入插件的類別 。

  4. 建構插件的實例 ( 物件 )

  5. 將所有插件依照給定的名稱排序 。

    有時候插件之間會有相依性 , 因此需要插件按照某種順序初始化 , 使用名稱順序是一種簡單且偷懶的方式 。 插件名稱前面可以加上 '10', '20' 這樣的方式來明確指定順序 。

  6. start_extensions 負責呼叫插件的 start 方法 , 讓插件有機會執行初始化動作 。

  7. stop_extensions 負責呼叫插件的 stop 方法 , 讓插件有機會釋放資源 。

    照道理應該要跟 start_extensions 採取反向的順序來呼叫 , 不過這裏就當做順序無關緊要 。

簡單的擴充套件

為了具體知道如何實作一個插件 , 下面提供一個沒有實質作用的範例 , 單純說明插件應有的結構 :

# -*- coding: utf-8 -*-
from __future__ import absolute_import, unicode_literals

import logging

logger = logging.getLogger(__name__)


class SampleExtension(object):
    def __init__(self):
        self.context = None
        logger.debug("Sample extension created.")

    def start(self, context):
        self.context = context
        logger.debug("Sample extension started.")

    def stop(self, context):
        logger.debug("Sample extension stopped.")

對於插件類別沒有限制一定要繼承自框架類別 , startstop 也是選用 , 但如果有則必須按照上述的定義方式提供 。 插件類別主要的要求 : 必須可以不用參數來建構實例 , 也就是說 : 如果有 __init__ 方法 , 它不能有其它參數或都有預設值 。

擴展套件使用的 setup.py 範例

假設插件透過 setuptools 來建構 , 下面是範例插件使用的 setup.py 檔 :

# -*- coding: utf-8 -*-

"""
This is a setup.py script for packaging plugin/extension.

Usage:
    python setup.py bdist_egg
"""


from setuptools import setup, find_packages

setup(
    name="avax.sample",
    version="0.1.0",
    description="A sample extension",
    zip_safe=True,
    packages=find_packages(),

    entry_points={
        'ava.extension': [              # 1
            'sample = avax.sample.ext:SampleExtension',  # 2
        ]
    }
)

其中 ,

  1. 本文所使用的擴展點 (entry-point) 名稱空間 : ava.extension
  2. sample 是這個插件的名稱 ;avax.sample.ext 是插件類別所在的模組 ;SampleExtension 則是類別名稱 。

製作套件包

使用下列指令建構套件包 :

python setup.py bdist_egg

結語

透過插件框架 , 應用功能的可擴展性可以一定程度地確保 。 本文所提供簡單的實作 , 目的僅在說明基本運作原理 , 如果不符合您的需求 , 網路上可以找到其它更完整的插件框架 。