插件化应用是个老话题啦,在我们的日常生活中更是屡见不鲜。无论是多年来臃肿不堪的 Eclipse,亦或者是扩展丰富著称的 Chrome,乃至近年来最优秀的编辑器 VSCode,插件都是这其中重要的组成部分。插件的意义在于扩展应用程序的功能,这其实有点像 iPhone 手机和 AppStore 的关系,没有应用程序的手机无非就是一部手机,而拥有了应用程序的手机则可以是 Everything。显然,安装或卸载应用程序并不会影响手机的基本功能,而应用程序离开了手机同样无法单独运行。所以,所谓“插件”,实际上是一种按照一定规范开发的应用程序,它只能运行在特定的软件平台/应用程序且无法运行。这里,最重要的一点是应用程序可以不依赖插件单独运行,这是这类“插件式”应用的基本要求。
好了,在了解了插件的概念以后,我们来切入今天的正文。博主曾经在《基于 Python 实现 Windows 下壁纸切换功能》这篇文章中编写了一个小程序,它可以配合 Windows 注册表实现从 Unsplash 上抓取壁纸的功能。最近,博主想为这个小程序增加 必应壁纸 和 WallHaven 两个壁纸来源,考虑到大多数的壁纸抓取流程是一样的,博主决定以“插件”的方式完成这次迭代,换句话说,主程序不需要再做任何调整,当我们希望增加新的数据源的时候,只需要写一个.py 脚本即可,这就是今天这篇文章的写作缘由。同样的功能,如果使用 Java 或者 C#这类编译型语言来做,我们可能会想到为插件定义一个 IPlugin 接口,这样每一个插件实际上都是 IPlugin 接口的实现类,自然而然地,我们会想到通过反射来调用接口里的方法,这是编译型语言的做法。而面对 Python 这样的解释型语言,我们同样有解释型语言的做法。
首先,我们从一个最简单的例子入手。我们知道,Python 中的 import 语法可以用来引入一个模块,这个模块可以是 Python 标准库、第三方库和自定义模块。现在,假设我们有两个模块:foo.py
和 bar.py
。
1#foo.py
2import sys
3
4class Chat:
5
6 def send(self,uid,msg):
7 print('给{uid}发送消息:{msg}'.format(uid=uid,msg=msg))
8
9 def sendAll(self,msg):
10 print('群发消息:{msg}'.format(msg=msg))
1#bar.py
2import sys
3
4class Echo:
5
6 def say(self):
7 print("人生苦短,我用Python")
8
9def cry():
10 print("男人哭吧哭吧不是罪")
通常, 为了在当前模块(main.py)中使用这两个模块,我们可以使用以下语句:
1import foo
2from bar import *
这是一种简单粗暴的做法,因为它会导入模块中的全部内容。一种更好的做法是按需加载,例如下面的语句:
1from foo import Chat
到这里,我们先来思考第一个问题,Python 是怎么样去查找一个模块的呢?这和 Python 中的导入路径有关,通过sys.path
我们可以非常容易地找到这些路径,常见的导入路径有当前目录
、site-package目录
和PYTHONPATH
。熟悉 Python 的朋友应该都知道,site-package
和PYTHONPATH
各自的含义,前者是通过 pip 安装的模块的导入目录,后者是 Python 标准库的导入目录。当前目录这个从何说起呢?事实上,从我们写下from…import…
语句的时候,这个机制就已经在工作了,否则 Python 应该是找不到 foo 和 bar 这两个模块的了。这里还有相对导入和绝对导入的问题,一个点(.
)和两个点(..
)的问题,这些我们在这里暂且按下不表,因为我们会直接修改sys.path
(逃
在 Python 中有一种动态导入模块的方式,我们只需要告诉它模块名称、导入路径就可以了,这就是下面要说的importlib
标准库。继续用 foo 和 bar 这两个神奇的单词来举例,假设我们现在不想通过 import 这种偏“静态”的方式导入一个模块,我们应该怎么做呢?一起来看下面代码:
1import foo
2from foo import Chat
3from bar import *
4import importlib
5
6#调用foo模块Chat类方法
7foo.Chat().send('Dear','I Miss You')
8moduleFoo = importlib.import_module('.','foo')
9classChat = getattr(moduleFoo,'Chat')
10classChat().send('Dear','I Miss You')
11
12#调用bar模块Echo类方法
13Echo().say()
14moduleBar = importlib.import_module('.','bar')
15classEcho = getattr(moduleBar,'Echo')
16classEcho().say()
17
18#调用bar模块中的cry()方法
19cry()
20methodCry = getattr(moduleBar,'cry')
21methodCry()
可以注意到,动态导入可以让我们在运行时期间引入一个模块(.py),这恰恰是我们需要的功能。为了让大家对比这两种方式上的差异,我给出了静态引入和动态引入的等价代码。其中,getattr()
其实可以理解为 Python 中的反射,我们总是可以按照模块
->类
->方法
的顺序来逐层查找,即:通过 dir()方法,然后该怎么调用就怎么调用。所以,到这里整个“插件化”的思路就非常清晰了,即:首先,通过配置来为 Python 增加一个导入路径,这个导入路径本质上就是插件目录。其次,插件目录内的每一个脚本文件(.py)就是一个模块,每个模块都有一个相同的方法签名。最终,通过配置来决定要导入哪一个模块,然后调用模块中类的实例方法即可。顺着这个思路,博主为 WallPaper 项目引入了插件机制,核心代码如下:
1if(pluginFile == '' or pluginName == ''):
2 spider = UnsplashSpider()
3 imageFile = spider.getImage(downloadFolder)
4 setWallPaper(imageFile)
5 else:
6 if(not check(pluginFile,addonPath)):
7 print('插件%s不存在或配置不正确' % pluginName)
8 return
9 module = importlib.import_module('.',pluginFile.replace('.py',''))
10 instance = getattr(module,pluginName)
11 imageFile = instance().getImage(downloadFolder)
12 setWallPaper(imageFile)
接下来,我们可以很容易地扩展出 必应壁纸 和 WallHaven 两个“插件”。按照约定,这两个插件都必须实现 getImage()方法,它接受一个下载目录作为参数,所以,显而易见,我们在这个插件里实现壁纸的下载,然后返回壁纸的路径即可,因为主程序会完成剩余设置壁纸的功能。
1# 必应每日壁纸插件
2class BingSpider:
3
4 def getImage(self, downloadFolder):
5 searchURL = 'https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=zh-CN'
6 response = requests.get(searchURL)
7 data = json.loads(response.text)
8
9 resultId = data['images'][0]['hsh']
10 resultURL = 'https://cn.bing.com' + data['images'][0]['url']
11 print(u'正在为您下载图片:%s...' % resultId)
12 if(not path.exists(downloadFolder)):
13 os.makedirs(downloadFolder)
14
15 jpgFile = resultId + '.jpg'
16 jpgFile = os.path.join(downloadFolder, jpgFile)
17 response = requests.get(resultURL)
18 with open(jpgFile,'wb') as file:
19 file.write(response.content)
20 return jpgFile
1# WallHaven壁纸插件
2class WallHavenSpider:
3
4 def getImage(self,downloadFolder):
5 url = 'https://alpha.wallhaven.cc/wallpaper/'
6 response = requests.get(url)
7 print(response.text)
8 soup = BeautifulSoup(response.text,'html.parser')
9 imgs = soup.find_all('img')
10 length = len(imgs)
11 if length > 0:
12 match = random.choice(imgs)
13 rawUrl = match.get('src')
14 rawId = rawUrl.split('/')[-1]
15 rawUrl = 'https://w.wallhaven.cc/full/' + rawId[0:2] + '/wallhaven-' + rawId
16 raw = requests.get(rawUrl)
17 imgFile = os.path.join(downloadFolder, rawId)
18 with open(imgFile,'wb') as f:
19 f.write(raw.content)
20 return imgFile
好了,现在功能是实现了,我们来继续深入“插件化”这个话题。考虑到 Python 是一门解释型的语言,我们在编写插件的时候,更希望做到“热插拔”,比如修改了某个插件后,希望它可以立刻生效,这个时候我们就需要重新加载模块,此时 importlib 的 reload 就能满足我们的要求,这正是博主一开始就要使用 importlib,而不是 import 语法对应内建方法__import__()的原因。以 C#的开发经历而言,虽然可以直接更换 DLL 实现更新,可更新的过程中 IIS 会被停掉,所以,这种并不能被称之为“热更新”。基于以上两点考虑,博主最终决定使用 watchdog 配合 importlib 来实现“热插拔”,下面是关键代码:
1class LoggingEventHandler(FileSystemEventHandler):
2
3 # 当配置文件修改时重新加载模块
4 # 为节省篇幅已对代码进行精简
5 def on_modified(self, event):
6 super(LoggingEventHandler, self).on_modified(event)
7 what = 'directory' if event.is_directory else 'file'
8 confPath = os.path.join(sys.path[0],'config.ini')
9 if(what =='file' and event.src_path == confPath):
10 importlib.reload(module)
11 logging.info("Modified %s: %s", what, event.src_path)
好了,现在我们就完成了这次“插件化”的迭代,截止到目前为止,博主共完成了 Unsplash 、 Bing 壁纸 、 WallHaven 和 国家地理 四个“源”的接入,这些插件在实现上基本大同小异,本质上来讲它们是一个又一个的爬虫,只要实现了 getImage()这个方法都可以接入进来,这就是我们通常说的“约定大于配置”,关于更多的代码细节,大家可以通过Github来了解。
简单回顾下这篇博客,核心其实是 importlib 模块的使用,它可以让我们在运行时期间动态导入一个模块,这是实现插件化的重要前提。以此为基础,我们设计了基于 Python 脚本的单文件插件,即从指定的目录加载脚本文件,每个脚本就是一个插件。而作为插件化的一个延伸,我们介绍了 watchdog 模块的简单应用,配合 importlib 模块的 reload()方法,就可以实现所谓的“热更新”。好了,以上就是这篇博客的所有内容了,我们下一篇见!