Published on

浅谈 Python 库的插件系统设计

可选配的插件

Reading Time

8 min read

插件(Plug-in),扩展(Extension)或增件(Addon),都差不多指的是一个东西:为一个已有软件增添额外功能的组件。给软件设计一个易用和强大的插件系统,能让你的软件寿命更长,让整个社区来共同建设,符合开源的精神。

上周末我给PDM实现了一个插件系统,于是就顺便利用这篇文章总结一下 Python 库里面用到的插件系统的设计方法。大体说来,插件分两种类型:

  1. 安装了以后需要写配置、写代码让插件生效——我称之为可选配的插件
  2. 安装了以后插件功能即生效,或者程序运行时自动生效——我称之为安装即生效的插件

下面我会分别对这两种类型,结合一些项目的例子来说明。

可选配的插件

可选配的插件一般用在 Python 库中1,特点是可配置,可调整插件参数,但需要写额外的代码或配置来装载它。

Requests

作为 Python 中最著名的库没有之一,Requests 的层级划分和模块解耦做得非常好。这样开发者想在上面做二次开发非常容易,有种随心所欲的感觉。主要的扩展点有:

  • 如果想自定义网关、请求处理的方式,自定义一个类继承requests.adapters.BaseAdapter。这个类的实例可以通过session.mount(prefix, adapter)加载到session中。比如requests-wsgi-adapter就把请求发给了 WSGI 应用,而不是 Internet 地址。
  • 如果想自定义请求认证的方式,自定义一个类继承requests.auth.AuthBase。这个类的实例可以直接传给sessionAPI 的auth=参数。
  • 如果只是想修改返回的响应,可以增加response钩子函数,赋给session.hooks属性。
  • 如果想封装一系列的操作,包括 Cookie、认证、响应处理等,可以自定义一个Session类继承requests.Session,比如Requests-OAuthlib

Flask

Flask 说:「本框架什么功能也没有,你上 GitHub 上找啊,那里的扩展又多,说话又好听,只有靠扩展才能勉强生活这样子。」

所以 Flask 的插件系统设计也是相当优秀的,所有的扩展点都收拢到了flask.Flaskapp 对象上,扩展中只用接受到这个对象,然后对它进行一顿改造就完了。一些扩展点有:

  • 绑定一个视图蓝图:app.register_blueprint()
  • 请求前、请求后钩子:@app.before_request, @app.after_request
  • 信号钩子:flask.signals模块
  • 模板过滤器、模板全局函数、变量:@app.context_processor, @app.template_filter
  • 错误处理器:@app.errorhandler

只要里面提供的扩展点,都可以打包在一个扩展中统一对外提供,唯一缺失的就是 DB model,导致 Flask 扩展不能包含 db model,这是一个很大的限制。

Django

Django 在扩展方便性上比 Flask 差一些,但它的插件模块自治性非常好。因为 Django 是以 app 为单位进行组织的,模板、静态文件、数据库模型、admin 视图,测试,都可以包含在一个 app 中,不依赖外部的组件。这样一个 app 就可以单独分拆出来到处使用。但是如果插件中有包含 middleware, logging 处理这些东西,用户还是要单独在settings.py中配置,不是很方便,而且插件也必须深度绑定 Django。

Marko

Marko是我自己写的一个 CommonMark 的 parser 和 renderer。众所周知 CommonMark 是个 spec 极度变态的 Markdown 标准,它的 parser 没办法用 BNF+AST 的方法来实现。几乎所有的 CommonMark 库(甚至 Markdown 库)都是穷举所有元素类型,为他们分别编写 parse 函数和 render 函数来实现。我在做 Marko 之初,就希望它是一个比较容易扩展的 Markdown 库,用户能扩展:

  • 修改已有元素的解析方法
  • 修改已有元素的渲染方法
  • 增加新的自定义元素类型

并能把这一坨聚合在一个包里发出。

在介绍 Marko 的插件系统前,我们先看看Python-Markdown的扩展方法

Python-Markdown 的扩展方法

我猜没有人给这货写过扩展吧,它的官方文档,几乎什么也没写,要研究怎么写扩展,得去看源码(从例子中学习)。经过一番抓头,得出大致有这么几个扩展点

  • Preprocessor 先扫描一遍文档,元素的解析要在这里做
  • InlineParser, BlockParser ,修改解析得到的 inline 元素和 block 元素
  • Treeprocessor,渲染 AST

最抓头的是所有解析都得手写正则,还有各种回溯机制,相当反人类。

Marko 的扩展方法

这里先说下 Markdown 的模块划分,所有元素的匹配和解析方法,包括块级元素和行内元素,都被封装在各自的元素类中,然后所有元素类都会被加载到 Parser 类中进行解析。得到一个 AST 以后再喂给 Renderer 类,Renderer 类中对于每种元素都有一个对应的 render 方法,把所有 render 的结果字符串拼接起来就得到了最终渲染的结果。

所以这里主要的扩展操作就是类的继承、替换,加上考虑到多个扩展想继承同一个类,为避免相互覆盖,我采用了基于 Mixin 的方式:

  • 对于元素,自定义元素类
  • 对于 parser,定义一个ParserMixin类实现自定义解析
  • 对于 renderer, 定义一个RendererMixin类实现自定义 render 方法

最后把这三者都组装在一个对象中:


class MyExtension:
    elements = [...]
    parser_mixins = [ParserMixin]
    renderer_mixins = [RendererMixin]

在入口出通过和Python-Markdown相似的extensions=[MyExtension]读入扩展对象,将这三个属性取出,合成最终的 parser 和 renderer:

self.parser = type("Parser", bases=("BaseParser",) + tuple(ext.parser_mixins))
self.parser.add_elements(ext.elements)
self.renderer = type("Renderer", bases=("HTMLRenderer",) + tuple(ext.renderer_mixins))

Footnotes

  1. Python 库(Library)是针对 Python 应用(Application)而言的,前者主要用来 import,发布到 PyPI 上,后者主要是用来 run,一般不发布到 PyPI 上。

Share: