在很多時候,我們會希望所開發的應用軟體允許其他開發者擴展它的功能。為了達到這個目的, 通常的作法是讓軟體支援某種插件框架 (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
類別來實現:
package.py view raw 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 from __future__ import absolute_import, division, print_function, unicode_literalsimport loggingimport pkg_resourcesfrom ..runtime import environ, settingslogger = 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 def find_packages (self, add_to_path=True) : """ Extends sys.path at runtime to include distributions in user's home directory. """ logger.debug("Packages Directory: %s" , self.pkgs_dir) distributions, errors = pkg_resources.working_set.find_plugins( pkg_resources.Environment([self.pkgs_dir]) ) if len(distributions) > 0 : logger.debug("Found %d extension package(s)." , len(distributions)) if not add_to_path: return for it in distributions: pkg_resources.working_set.add(it) logger.debug("package added: %s" , it.project_name) logger.error("Couldn't load: %r" , 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
來負責插件的尋找與初始化動作:
python-plugin.py view raw 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 from __future__ import absolute_import, print_function, unicode_literalsimport loggingimport pkg_resourceslogger = 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 self.extensions = [] def load_extensions (self, invoke_on_load=True) : 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() if invoke_on_load: ext.obj = ext.cls() self.extensions.append(ext) self.extensions.sort(key=lambda e: e.name) logger.debug("Loaded extensions: %r" , self.extensions) def start_extensions (self, context=None) : 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) : 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 __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
採取反向的順序來呼叫,不過這裏就當做順序無關緊要。
簡單的擴充套件
為了具體知道如何實作一個插件,下面提供一個沒有實質作用的範例,單純說明插件應有的結構:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 from __future__ import absolute_import, unicode_literalsimport logginglogger = 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__ 方法,它不能有其它參數或都有預設值。
假設插件透過 setuptools 來建構,下面是範例插件使用的 setup.py 檔:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 """ This is a setup.py script for packaging plugin/extension. Usage: python setup.py bdist_egg """ from setuptools import setup, find_packagessetup( name="avax.sample" , version="0.1.0" , description="A sample extension" , zip_safe=True , packages=find_packages(), entry_points={ 'ava.extension' : [ 'sample = avax.sample.ext:SampleExtension' , ] } )
其中,
本文所使用的擴展點 (entry-point) 名稱空間: ava.extension
。
sample
是這個插件的名稱;`avax.sample.ext`
是插件類別所在的模組;`SampleExtension` 則是類別名稱。
製作套件包
使用下列指令建構套件包:
1 python setup.py bdist_egg
結語
透過插件框架,應用功能的可擴展性可以一定程度地確保。本文所提供簡單的實作,目的僅在說明基本運作原理,
如果不符合您的需求,網路上可以找到其它更完整的插件框架。
參考資料
pkg_resoruces: https://pythonhosted.org/setuptools/pkg_resources.html