使用Python開發插件化應用程序

插件化應用是個老話題啦,在我們的日常生活中更是屢見不鮮。無論是多年來臃腫不堪的Eclipse,亦或者是擴展豐富著稱的Chrome,乃至近年來最優秀的編輯器VSCode,插件都是這其中重要的組成部分。插件的意義在於擴展應用程序的功能,這其實有點像iPhone手機和AppStore的關係,沒有應用程序的手機無非就是一部手機,而擁有了應用程序的手機則可以是Everything。顯然,安裝或卸載應用程序並不會影響手機的基本功能,而應用程序離開了手機同樣無法單獨運行。所以,所謂“插件”,實際上是一種按照一定規範開發的應用程序,它只能運行在特定的軟件平臺/應用程序且無法運行。這裏,最重要的一點是應用程序可以不依賴插件單獨運行,這是這類“插件式”應用的基本要求。

好了,在瞭解了插件的概念以後,我們來切入今天的正文。博主曾經在《基於Python實現Windows下壁紙切換功能》這篇文章中編寫了一個小程序,它可以配合Windows註冊表實現從 Unsplash 上抓取壁紙的功能。最近,博主想爲這個小程序增加 必應壁紙WallHaven 兩個壁紙來源,考慮到大多數的壁紙抓取流程是一樣的,博主決定以“插件”的方式完成這次迭代,換句話說,主程序不需要再做任何調整,當我們希望增加新的數據源的時候,只需要寫一個.py腳本即可,這就是今天這篇文章的寫作緣由。同樣的功能,如果使用Java/C#這類編譯型語言來做,我們可能會想到爲插件定義一個IPlugin接口,這樣每一個插件實際上都是IPlugin接口的實現類,自然而然地,我們會想到通過反射來調用接口裏的方法,這是編譯型語言的做法。而面對Python這樣的解釋型語言,我們同樣有解釋型語言的做法。

首先,我們從一個最簡單的例子入手。我們知道,Python中的import語法可以用來引入一個模塊,這個模塊可以是Python標準庫、第三方庫和自定義模塊。現在,假設我們有兩個模塊:foo.pybar.py

#foo.py
import sys

class Chat:

    def send(self,uid,msg):
        print('給{uid}發送消息:{msg}'.format(uid=uid,msg=msg))

    def sendAll(self,msg):
        print('羣發消息:{msg}'.format(msg=msg))

#bar.py
import sys

class Echo:

    def say(self):
        print("人生苦短,我用Python")

def cry():
    print("男人哭吧哭吧不是罪")

通常, 爲了在當前模塊(main.py)中使用這兩個模塊,我們可以使用以下語句:

import foo
from bar import *

這是一種簡單粗暴的做法,因爲它會導入模塊中的全部內容。一種更好的做法是按需加載,例如下面的語句:

from foo import Chat

到這裏,我們先來思考第一個問題,Python是怎麼樣去查找一個模塊的呢?這和Python中的導入路徑有關,通過sys.path我們可以非常容易地找到這些路徑,常見的導入路徑有當前目錄site-package目錄PYTHONPATH。熟悉Python的朋友應該都知道,site-packagePYTHONPATH各自的含義,前者是通過pip安裝的模塊的導入目錄,後者是Python標準庫的導入目錄。當前目錄這個從何說起呢?事實上,從我們寫下from…import…語句的時候,這個機制就已經在工作了,否則Python應該是找不到foo和bar這兩個模塊的了。這裏還有相對導入和絕對導入的問題,一個點(.)和兩個點(..)的問題,這些我們在這裏暫且按下不表,因爲我們會直接修改sys.path(逃

在Python中有一種動態導入模塊的方式,我們只需要告訴它模塊名稱、導入路徑就可以了,這就是下面要說的importlib標準庫。繼續用foo和bar這兩個神奇的單詞來舉例,假設我們現在不想通過import這種偏“靜態”的方式導入一個模塊,我們應該怎麼做呢?一起來看下面代碼:

import foo
from foo import Chat
from bar import *
import importlib

#調用foo模塊Chat類方法
foo.Chat().send('Dear','I Miss You')
moduleFoo = importlib.import_module('.','foo')
classChat = getattr(moduleFoo,'Chat')
classChat().send('Dear','I Miss You')

#調用bar模塊Echo類方法
Echo().say()
moduleBar = importlib.import_module('.','bar')
classEcho = getattr(moduleBar,'Echo')
classEcho().say()

#調用bar模塊中的cry()方法
cry()
methodCry = getattr(moduleBar,'cry')
methodCry()

可以注意到,動態導入可以讓我們在運行時期間引入一個模塊(.py),這恰恰是我們需要的功能。爲了讓大家對比這兩種方式上的差異,我給出了靜態引入和動態引入的等價代碼。其中,getattr()其實可以理解爲Python中的反射,我們總是可以按照模塊->->方法的順序來逐層查找,即:通過dir()方法,然後該怎麼調用就怎麼調用。所以,到這裏整個“插件化”的思路就非常清晰了,即:首先,通過配置來爲Python增加一個導入路徑,這個導入路徑本質上就是插件目錄。其次,插件目錄內的每一個腳本文件(.py)就是一個模塊,每個模塊都有一個相同的方法簽名。最終,通過配置來決定要導入哪一個模塊,然後調用模塊中類的實例方法即可。順着這個思路,博主爲 WallPaper 項目引入了插件機制,核心代碼如下:

if(pluginFile == '' or pluginName == ''):
        spider = UnsplashSpider()
        imageFile = spider.getImage(downloadFolder)
        setWallPaper(imageFile)
    else:
        if(not check(pluginFile,addonPath)):
            print('插件%s不存在或配置不正確' % pluginName)
            return
        module = importlib.import_module('.',pluginFile.replace('.py',''))
        instance = getattr(module,pluginName)
        imageFile = instance().getImage(downloadFolder)
        setWallPaper(imageFile)

接下來,我們可以很容易地擴展出 必應壁紙WallHaven 兩個“插件”。按照約定,這兩個插件都必須實現getImage()方法,它接受一個下載目錄作爲參數,所以,顯而易見,我們在這個插件裏實現壁紙的下載,然後返回壁紙的路徑即可,因爲主程序會完成剩餘設置壁紙的功能。

# 必應每日壁紙插件
class BingSpider:

    def getImage(self, downloadFolder):
        searchURL = 'https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=zh-CN'
        response = requests.get(searchURL)
        data = json.loads(response.text)

        resultId = data['images'][0]['hsh']
        resultURL = 'https://cn.bing.com' + data['images'][0]['url']
        print(u'正在爲您下載圖片:%s...' % resultId)
        if(not path.exists(downloadFolder)):
            os.makedirs(downloadFolder)
        
        jpgFile = resultId + '.jpg'
        jpgFile = os.path.join(downloadFolder, jpgFile)
        response = requests.get(resultURL)
        with open(jpgFile,'wb') as file:
            file.write(response.content)
        return jpgFile      

# WallHaven壁紙插件
class WallHavenSpider:

    def getImage(self,downloadFolder): 
        url = 'https://alpha.wallhaven.cc/wallpaper/' 
        response = requests.get(url) 
        print(response.text)
        soup = BeautifulSoup(response.text,'html.parser')
        imgs = soup.find_all('img')
        length = len(imgs)
        if length > 0:
            match = random.choice(imgs)
            rawUrl = match.get('src')
            rawId = rawUrl.split('/')[-1]
            rawUrl = 'https://w.wallhaven.cc/full/' + rawId[0:2] + '/wallhaven-' + rawId
            raw = requests.get(rawUrl) 
            imgFile = os.path.join(downloadFolder, rawId)
            with open(imgFile,'wb') as f:
                f.write(raw.content)
        return imgFile  

好了,現在功能是實現了,我們來繼續深入“插件化”這個話題。考慮到Python是一門解釋型的語言,我們在編寫插件的時候,更希望做到“熱插拔”,比如修改了某個插件後,希望它可以立刻生效,這個時候我們就需要重新加載模塊,此時importlib的reload就能滿足我們的要求,這正是博主一開始就要使用importlib,而不是import語法對應內建方法__import__()的原因。以C#的開發經歷而言,雖然可以直接更換DLL實現更新,可更新的過程中IIS會被停掉,所以,這種並不能被稱之爲“熱更新”。基於以上兩點考慮,博主最終決定使用watchdog配合importlib來實現“熱插拔”,下面是關鍵代碼:

class LoggingEventHandler(FileSystemEventHandler):

    # 當配置文件修改時重新加載模塊
    # 爲節省篇幅已對代碼進行精簡
    def on_modified(self, event):
        super(LoggingEventHandler, self).on_modified(event)
        what = 'directory' if event.is_directory else 'file'
        confPath = os.path.join(sys.path[0],'config.ini')
        if(what =='file' and event.src_path == confPath):
            importlib.reload(module)
        logging.info("Modified %s: %s", what, event.src_path)

好了,現在我們就完成了這次“插件化”的迭代,截止到目前爲止,博主共完成了 UnsplashBing壁紙WallHaven國家地理 四個“源”的接入,這些插件在實現上基本大同小異,本質上來講它們是一個又一個的爬蟲,只要實現了getImage()這個方法都可以接入進來,這就是我們通常說的“約定大於配置”,關於更多的代碼細節,大家可以通過Github來了解。

簡單回顧下這篇博客,核心其實是importlib模塊的使用,它可以讓我們在運行時期間動態導入一個模塊,這是實現插件化的重要前提。以此爲基礎,我們設計了基於Python腳本的單文件插件,即從指定的目錄加載腳本文件,每個腳本就是一個插件。而作爲插件化的一個延伸,我們介紹了watchdog模塊的簡單應用,配合importlib模塊的reload()方法,就可以實現所謂的“熱更新”。好了,以上就是這篇博客的所有內容了,我們下一篇見!

發佈了247 篇原創文章 · 獲贊 913 · 訪問量 206萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章