在很多時候,我們會希望所開發的應用軟體允許其他開發者擴展它的功能。為了達到這個目的, 通常的作法是讓軟體支援某種插件框架(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.")
其中,
- pkgs_dir 是套件包(副檔名為 .egg)所在的完整路徑。
- pkg_resources.working_set.find_plugins 這個方法用來找出格式正確的套件包。
- 為了找出套件包,需要給定一個環境(Environment)物件,這裏指定套件包搜尋路徑,實際上它還接受其它過濾參數。
- 將找到的套件包加入執行環境的載入路徑(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']
其中,
-
namespace 是擴展點的命名空間,這裏山姆鍋使用的是
ava.extension
,您可以根據需要更改。:
pkg_resources
會依據這個命名空間來找出相關聯的擴展點。 -
pkg_resources.working_set.iter_entry_points
遍歷命名空間中的所有擴展點。 -
載入插件的類別。
-
建構插件的實例(物件)
-
將所有插件依照給定的名稱排序。
: 有時候插件之間會有相依性,因此需要插件按照某種順序初始化,使用名稱順序是一種簡單且偷懶的方式。 插件名稱前面可以加上 ‘10’, ‘20’ 這樣的方式來明確指定順序。
-
start_extensions 負責呼叫插件的
start
方法,讓插件有機會執行初始化動作。 -
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.")
對於插件類別沒有限制一定要繼承自框架類別, start
跟
stop
也是選用,但如果有則必須按照上述的定義方式提供。
插件類別主要的要求:必須可以不用參數來建構實例,也就是說:如果有
__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
]
}
)
其中,
- 本文所使用的擴展點(entry-point)名稱空間:
ava.extension
。 sample
是這個插件的名稱;`avax.sample.ext` 是插件類別所在的模組;`SampleExtension` 則是類別名稱。
製作套件包
使用下列指令建構套件包:
python setup.py bdist_egg
結語
透過插件框架,應用功能的可擴展性可以一定程度地確保。本文所提供簡單的實作,目的僅在說明基本運作原理, 如果不符合您的需求,網路上可以找到其它更完整的插件框架。
參考資料
pkg_resoruces: https://pythonhosted.org/setuptools/pkg_resources.html ⎘