標籤:
由於Python支援運行時動態載入,設計一個外掛程式式結構是比較簡單的。如果使用PyQt的話,可以輕鬆地建立出一個外掛程式式的UI結構。不過,在很多時候,主程式使用C++/STL編寫,通過Python來實現外掛程式擴充。這裡主要探討“純Python”實現的外掛程式結構。C++Python的模式後面再說(可參考,C++嵌入Python: http://www.vckbase.com/index.php/wv/1258,C++嵌入Python要點:http://blog.chinaunix.net/uid-13148801-id-2906720.html)。
如果要通過C++編寫一個Python模組進行載入,可以參考:http://my.oschina.net/u/2306127/blog/369997。
為了擴充軟體的功能,通常我們會把軟體設計成外掛程式式結構。Python這樣的動態語言天生就支援外掛程式式編程。與C++相比,Python已經定義好模組的介面,想要載入一個外掛程式,一個__import__()就能很輕鬆地搞定。不需要特定的底層知識。而且與C++等靜態語言相比,Python的外掛程式式結構更顯靈活。因為外掛程式載入後,可以利用Python語言的動態性,充分地修改核心的邏輯。
簡單地說一個__import__()可能不大清楚。現在就來看一個最簡單的外掛程式式結構程式。它會掃描plugins檔案夾下的所有.py檔案。然後把它們載入。
#-*- encoding: utf-8 -*-#main1.pyimport osclass Platform: def __init__(self): self.loadPlugins() def sayHello(self, from_): print "hello from %s." % from_ def loadPlugins(self): for filename in os.listdir("plugins"): if not filename.endswith(".py") or filename.startswith("_"): continue self.runPlugin(filename) def runPlugin(self, filename): pluginName=os.path.splitext(filename)[0] plugin=__import__("plugins."+pluginName, fromlist=[pluginName]) #Errors may be occured. Handle it yourself. plugin.run(self)if __name__=="__main__": platform=Platform()
然後在plugins子目錄裡面放入兩個檔案:
#plugins1.pydef run(platform): platform.sayHello("plugin1")#plugins2.pydef run(platform): platform.sayHello("plugin2")
再建立一個空的__init__.py在plugins檔案夾裡面。從package裡面匯入模組的時候,Python要求一個__init__.py。
運行main1.py,看一下啟動並執行結果。首先是列印一下檔案夾結構方便大家理解:
h:\projects\workon\testplugins>tree /f /a卷 Data 的檔案夾 PATH 列表卷序號為 ****-****H:.| main1.py|\---plugins plugin1.py plugin2.py __init__.pyh:\projects\workon\testplugins>main1.pyhello from plugin1.hello from plugin2.
一般地,載入外掛程式前要首先掃描外掛程式,然後依次載入並運行外掛程式。我們上面的樣本程式main1.py也是如此,分為兩個函數。第一個loadPlugins()掃描外掛程式。它把plugins目錄下面所有.py的檔案除了__init__.py都當成外掛程式。runPlugin()載入並運行外掛程式。其中兩個關鍵:使用__import__()函數把外掛程式當成模組匯入,它要求所有的外掛程式都定義一個run()函數。各種語言實現的外掛程式式結構其實也基本上分為這兩個步驟。所不同的是,Python語言實現起來更加的簡潔。
或許聽起來還有點玄奧。詳細地說一下__import__()。它和常見的import語句很相似,只不過換成函數形式並且返回模組以供調用。import module相當於__import__("module"),from module import func相當於__import__("module", fromlist=["func"]),不過與想象有點不同,import package.module相當於__import__("package.module", fromlist=["module"])。
如何調用外掛程式一般有個約定。像我們這裡就約定每個外掛程式都實現一個run()。有時候還可以約定實現一個類,並且要求這個類實現某個管理介面,以方便核心隨時啟動、停止外掛程式。要求所有的外掛程式都有這幾個介面方法:
#interfaces.pyclass Plugin: def setPlatform(self, platform): self.platform=platform def start(self): pass def stop(self): pass
想要運行這個外掛程式,我們的runPlugin()要改一改,另外增加一個shutdown()來停止外掛程式:
class Platform: def __init__(self): self.plugins=[] self.loadPlugins() def sayHello(self, from_): print "hello from %s." % from_ def loadPlugins(self): for filename in os.listdir("plugins"): if not filename.endswith(".py") or filename.startswith("_"): continue self.runPlugin(filename) def runPlugin(self, filename): pluginName=os.path.splitext(filename)[0] plugin=__import__("plugins."+pluginName, fromlist=[pluginName]) clazz=plugin.getPluginClass() o=clazz() o.setPlatform(self) o.start() self.plugins.append(o) def shutdown(self): for o in self.plugins: o.stop() o.setPlatform(None) self.plugins=[]if __name__=="__main__": platform=Platform() platform.shutdown()
外掛程式改成這樣:
#plugins1.pyclass Plugin1: def setPlatform(self, platform): self.platform=platform def start(self): self.platform.sayHello("plugin1") def stop(self): self.platform.sayGoodbye("plugin1")def getPluginClass(): return Plugin1#plugins2.pydef sayGoodbye(self, from_): print "goodbye from %s." % from_class Plugin2: def setPlatform(self, platform): self.platform=platform if platform is not None: platform.__class__.sayGoodbye=sayGoodbye def start(self): self.platform.sayHello("plugin2") def stop(self): self.platform.sayGoodbye("plugin2")def getPluginClass(): return Plugin2
運行結果:
h:\projects\workon\testplugins>main.pyhello from plugin1.hello from plugin2.goodbye from plugin1.goodbye from plugin2.
詳細觀察的朋友們可能會發現,上面的main.py,plugin1.py, plugin2.py幹了好幾件令人驚奇的事。
首先,plugin1.py和plugin2.py裡面的外掛程式類並沒有繼承自interfaces.Plugin,而platform仍然可以直接調用它們的start()和stop()方法。這件事在Java、C++裡面可能是件麻煩的事情,但是在Python裡面卻是件稀疏平常的事,彷彿吃飯喝水一般正常。事實上,這正是Python鼓勵的約定編程。Python的檔案介面協議就只規定了read(), write(), close()少數幾個方法。多數以檔案作為參數的函數都可以傳入自訂的檔案對象,只要實現其中一兩個方法就行了,而不必實現一個什麼FileInterface。如果那樣的話,需要實現的函數就多了,可能要有十幾個。
再仔細看下來,getPluginClass()可以把類型當成值返回。其實不止是類型,Python的函數、模組都可以被當成普通的對象使用。從類型產生一個執行個體也很簡單,直接調用clazz()就建立一個對象。不僅如此,Python還能夠修改類型。上面的例子我們就示範了如何給Platform增加一個方法。在兩個外掛程式的stop()裡面我們都調用了sayGoodbye(),但是仔細觀察Platform的定義,裡面並沒有定義。原理就在這裡:
#plugins2.pydef sayGoodbye(self, from_): print "goodbye from %s." % from_class Plugin2: def setPlatform(self, platform): self.platform=platform if platform is not None: platform.__class__.sayGoodbye=sayGoodbye
這裡首先通過platform.__class__得到Platform類型,然後Platform.sayGoodbye=sayGoodbye新 增了一個方法。使用這種方法,我們可以讓外掛程式任意修改核心的邏輯。這正在文首所說的Python實現外掛程式式結構的靈活性,是靜態語言如C++、Java等 無法比擬的。當然,這隻是示範,我不大建議使用這種方式,它改變了核心的API,可能會給其它程式員造成困惑。但是可以採用這種方式替換原來的方法,還可 以利用“面向切面編程”,增強系統的功能。
接下來我們還要再改進一下載入外掛程式的方法,或者說外掛程式的布署方法。前面我們實現的外掛程式體系主要的缺點是每個外掛程式只能有一個原始碼。如果想附帶一些圖 片、聲音資料,又怕它們會和其它的外掛程式衝突。即使不衝突,下載時分成單獨的檔案也不方便。最好是把一個外掛程式壓縮成一個檔案供下載安裝。
Firefox是一個支援外掛程式的著名軟體。它的外掛程式以.xpi作為副檔名,實際上是一個.zip檔案,裡麵包含了javascript代碼、資料檔案等很多內容。它會把外掛程式包下載複製並解壓到%APPDATA%\Mozilla\Firefox\Profiles\XXXX.default\extensions裡面,然後調用其中的install.js安裝。與此類似,實用的Python程式也不大可能只有一個原始碼,也要像Firefox那樣支援.zip包格式。
實現一個類似於Firefox那樣的外掛程式布署體系並不會很難,因為Python支援讀寫.zip檔案,只要寫幾行代碼來做壓縮與解壓縮就行了。首先要看一下zipfile這個模組。用它解壓縮的代碼如下:
import zipfile, osdef installPlugin(filename): with zipfile.ZipFile(filename) as pluginzip: subdir=os.path.splitext(filename)[0] topath=os.path.join("plugins", subdir) pluginzip.extractall(topath)
ZipFile.extractall()是Python 2.6後新增的函數。它直接解壓所有壓縮包內的檔案。不過這個函數只能用於受信任的壓縮包。如果壓縮包內包含了以/或者盤符開始的絕對路徑,很有可能會損壞系統。推薦看一下zipfile模組的說明文檔,事先過濾非法的路徑名。
這裡只有解壓縮的一小段代碼,安裝過程的介面互動相關的代碼很多,不可能在這裡舉例說明。我覺得UI是非常考驗軟體設計師的部分。常見的軟體會要求 使用者到網站上尋找並下載外掛程式。而Firefox和KDE提供了一個“組件(組件)管理介面”,使用者可以直接在介面內尋找外掛程式,查看它的描述,然後直接點擊 安裝。安裝後,我們的程式遍曆外掛程式目錄,載入所有的外掛程式。一般地,軟體還需要向使用者提供外掛程式的啟用、禁用、依賴等功能,甚至可以讓使用者直接在軟體介面上給 外掛程式評分,這裡就不再詳述了。
有個小技巧,安裝到plugins/subdir下的外掛程式可以通過__file__得到它自己的絕對路徑。如果這個外掛程式帶有圖片、聲音等資料的時候,可以利用這個功能載入它們。比如上面的plugin1.py這個外掛程式,如果它想在啟動的時候播放同目錄的message.wav,可以這樣子:
#plugins1.pyimport osdef alert(): soundFile=os.path.join(os.path.dirname(__file__), "message.wav") try: import winsound winsound.PlaySound(soundFile, winsound.SND_FILENAME) except (ImportError, RuntimeError): passclass Plugin1: def setPlatform(self, platform): self.platform=platform def start(self): self.platform.sayHello("plugin1") alert() def stop(self): self.platform.sayGoodbye("plugin1")def getPluginClass(): return Plugin1
接下來我們再介紹一種Python/Java語言常用的外掛程式管理方式。它不需要事先有一個外掛程式解壓過程,因為Python支援從.zp檔案匯入模組,很類似於Java直接從.jar檔案載入代碼。所謂安裝,只要簡單地把外掛程式複製到特定的目錄即可,Python代碼自動掃描並從.zip檔案內載入代碼。下面是一個最簡單的例子,它和上面的幾個例子一樣,包含一個main.py,這是主程式,一個plugins子目錄,用於存放外掛程式。我們這裡只有一個外掛程式,名為plugin1.zip。plugin1.zip有以下兩個檔案,其中description.txt儲存了外掛程式內的入口函數和外掛程式的名字等資訊,而plugin1.py是外掛程式的主要代碼:
description.txtplugin1.py
其中description.txt的內容是:
[general]name=plugin1description=Just a test code=plugin1.Plugin1
plugin1.py與前面的例子類似,為了省事,我們去掉了stop()方法,它的內容是:
class Plugin1: def setPlatform(self, platform): self.platform=platform def start(self): self.platform.sayHello("plugin1")
重寫的main.py的內容是:
# -*- coding: utf-8 -*-import os, zipfile, sys, ConfigParserclass Platform: def __init__(self): self.loadPlugins() def sayHello(self, from_): print "hello from %s." % from_ def loadPlugins(self): for filename in os.listdir("plugins"): if not filename.endswith(".zip"): continue self.runPlugin(filename) def runPlugin(self, filename): pluginPath=os.path.join("plugins", filename) pluginInfo, plugin = self.getPlugin(pluginPath) print "loading plugin: %s, description: %s" % \ (pluginInfo["name"], pluginInfo["description"]) plugin.setPlatform(self) plugin.start() def getPlugin(self, pluginPath): pluginzip=zipfile.ZipFile(pluginPath, "r") description_txt=pluginzip.open("description.txt") parser=ConfigParser.ConfigParser() parser.readfp(description_txt) pluginInfo={} pluginInfo["name"]=parser.get("general", "name") pluginInfo["description"]=parser.get("general", "description") pluginInfo["code"]=parser.get("general", "code") sys.path.append(pluginPath) moduleName, pluginClassName=pluginInfo["code"].rsplit(".", 1) module=__import__(moduleName, fromlist=[pluginClassName, ]) pluginClass=getattr(module, pluginClassName) plugin=pluginClass() return pluginInfo, pluginif __name__=="__main__": platform=Platform()
與前一個例子的主要不同之處是getPlugin()。它首先從.zip檔案內讀取描述資訊,然後把這個.zip檔案添加到sys.path裡面。最後與前面類似地匯入模組並執行。
解壓還是不解壓,兩種方案各有優劣。一般地,把.zip檔案解壓到獨立的檔案夾內需要一個解壓縮過程,或者是人工解壓,或者是由軟體解壓。解壓後的運行效率會高一些。而直接使用.zip包的話,只需要讓使用者把外掛程式複製到特定的位置即可,但是每次啟動並執行時候都需要在記憶體裡面解壓縮,效率降低。另外,從.zip檔案讀取資料總是比較麻煩。推薦不包含沒有資料檔案的時候使用。
基於Python的外掛程式式系統結構實驗