我是山姆鍋

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

python-plugin.pyview 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
# -*- 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`
    會依據這個命名空間來找出相關聯的擴展點。
  1. pkg_resources.working_set.iter_entry_points
    遍歷命名空間中的所有擴展點。
  2. 載入插件的類別。
  3. 建構插件的實例 (物件)
將所有插件依照給定的名稱排序。

:   有時候插件之間會有相依性,因此需要插件按照某種順序初始化,使用名稱順序是一種簡單且偷懶的方式。
    插件名稱前面可以加上 \'10\', \'20\' 這樣的方式來明確指定順序。
  1. 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
# -*- 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 檔:

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
# -*- 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` 則是類別名稱。

製作套件包

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

1
python setup.py bdist_egg

結語

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

參考資料

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