Skip to content

簡單的插件框架 - 使用 pkg_resources

Published: 7 分鐘

在很多時候,我們會希望所開發的應用軟體允許其他開發者擴展它的功能。為了達到這個目的, 通常的作法是讓軟體支援某種插件框架(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

結語

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

參考資料

pkg_resoruces: https://pythonhosted.org/setuptools/pkg_resources.html

郭信義 (Sam Kuo)

奔騰網路科技技術長,專長分散式系統、Web 應用與雲端服務架構、設計、開發、部署與維運。工作之餘,喜歡關注自由軟體的發展與應用,偶爾寫一下部落格文章。

你可能會有興趣的文章