插件开发指南

这份指南介绍了插件的API,以及如何编写自己的插件。我建议先阅读 插件 这一部分,再看这份指南。 可用插件列表 这里也有一些实际的例子。

注解

这是一份初稿。如果你发现了任何错误,或某些部分解释的不够清楚,请通过 邮件列表bug report 告知。

插件工作方式:基础知识

插件的API是通过Python的 修饰器 来实现的。简单来说,一个插件就是应用在route回调函数上的修饰器。

这只是一个简化。插件可以做的不仅仅是修饰路由回调,但它是一个很好的起点。让我们看看一些代码:

from bottle import response, install
import time

def stopwatch(callback):
    def wrapper(*args, **kwargs):
        start = time.time()
        body = callback(*args, **kwargs)
        end = time.time()
        response.headers['X-Exec-Time'] = str(end - start)
        return body
    return wrapper

install(stopwatch)

这个插件计算每次请求的响应时间,并在响应头中添加了 X-Exec-Time 字段。如你所见,插件返回了一个wrapper函数,由它来调用原先的回调函数。这就是修饰器的常见工作方式了。

最后一行,将该插件安装到Bottle的默认应用里面。这样,应用中的所有route都会应用这个插件了。就是说,每次请求都会调用 stopwatch() ,更改了route的默认行为。

插件是按需加载的,就是在route第一次被访问的时候加载。为了在多线程环境下工作,插件应该是线程安全的。在大多数情况下,这都不是一个问题,但务必提高警惕。

一旦route中使用了插件后,插件中的回调函数会被缓存起来,接下来都是直接使用缓存中的版本来响应请求。意味着每个route只会请求一次插件。在应用的插件列表变化的时候,这个缓存会被清空。你的插件应当可以多次修饰同一个route。

这种修饰器般的API受到种种限制。你不知道route或相应的应用对象是如何被修饰的,也不知道如何有效地存储那些在route之间共享的数据。但别怕!插件不仅仅是修饰器函数。只要一个插件是callable的或实现了一个扩展的API(后面会讲到),Bottle都可接受。扩展的API给你更多的控制权。

插件API

Plugin 类不是一个真正的类(你不能从bottle中导入它),它只是一个插件需要实现的接口。只要一个对象实现了以下接口,Bottle就认可它作为一个插件。

class Plugin(object)

插件应该是callable的,或实现了 apply() 方法。如果定义了 apply() 方法,那么会优先调用,而不是直接调用插件。其它的方法和属性都是可选的。

name

Bottle.uninstall() 方法和 Bottle.route() 中的 skip 参数都接受一个与名字有关的字符串,对应插件或其类型。只有插件中有一个name属性的时候,这才会起作用。

api

插件的API还在逐步改进。这个整形数告诉Bottle使用哪个版本的插件。如果没有这个属性,Bottle默认使用第一个版本。当前版本是 2 。详见 插件API的改动

setup(self, app)

插件被安装的时候调用(见 Bottle.install() )。唯一的参数是相应的应用对象。

__call__(self, callback)

如果没有定义 apply() 方法,插件本身会被直接当成一个修饰器使用(译者注:Python的Magic Method,调用一个类即是调用类的__call__函数),应用到各个route。唯一的参数就是其所修饰的函数。这个方法返回的东西会直接替换掉原先的回调函数。如果无需如此,则直接返回未修改过的回调函数即可。

apply(self, callback, route)

如果存在,会优先调用,而不调用 __call__() 。额外的 route 参数是 Route 类的一个实例,提供很多该route信息和上下文。详见 Route上下文

close(self)

插件被卸载或应用关闭的时候被调用,详见 Bottle.uninstall()Bottle.close()

Plugin.setup() 方法和 Plugin.close() 方法 会被调用,如果插件是通过 Bottle.route() 方法来应用到route上面的,但会在安装插件的时候被调用。

插件API的改动

插件的API还在不断改进中。在Bottle 0.10版本中的改动,定位了route上下文字典中已确定的问题。为了保持对0.9版本插件的兼容,我们添加了一个可选的 Plugin.api 属性,告诉Bottle使用哪个版本的API。API之间的不同点总结如下。

  • Bottle 0.9 API 1 (无 Plugin.api 属性)
    • 0.9文档中描述的原始插件API。
  • Bottle 0.10 API 2 ( Plugin.api 属性为2)
    • The context parameter of the Plugin.apply() method is now an instance of Route instead of a context dictionary.

Route上下文

Route 的实例被传递给 Plugin.apply() 函数,以提供更多该route的相关信息。最重要的属性总结如下。

属性 描述
应用程序 安装该route的应用对象
规则 规则字符串(例如 /wiki/<page>
方法 HTTP方法的字符串(例如: GET)
回调 未应用任何插件的原始回调函数,用于内省。
名称 route的名字,如未指定则为 None
插件 route安装的插件列表,除了整个应用范围内的插件,额外添加的(见 Bottle.route() )
滑雪运动员 应用安装了,但该route没安装的插件列表(见 meth:Bottle.route )
配置 传递给 Bottle.route() 修饰器的额外参数,存在一个字典中,用于特定的设置和元数据

对你的应用而言, Route.config 也许是最重要的属性了。记住,这个字典会在所有插件中共享,建议添加一个独一无二的前缀。如果你的插件需要很多设置,将其保存在 config 字典的一个独立的命名空间吧。防止插件之间的命名冲突。

改变 Route 对象

Route 的一些属性是不可变的,改动也许会影响到其它插件。坏主意就是,monkey-patch一个损坏的route,而不是提供有效的帮助信息来让用户修复问题。

在极少情况下,破坏规则也许是恰当的。在你更改了 Route 实例后,抛一个 RouteReset 异常。这会从缓存中删除当前的route,并重新应用所有插件。无论如何,router没有被更新。改变 rulemethod 的值并不会影响到router,只会影响到插件。这个情况在将来也许会改变。

运行时优化

插件应用到route以后,被插件封装起来的回调函数会被缓存,以加速后续的访问。如果你的插件的行为依赖一些设置,你需要在运行时更改这些设置,你需要在每次请求的时候读取设置信息。够简单了吧。

但是,出于性能方面的考虑,可能值得根据当前的需要选择不同的包装器,使用闭包,或者在运行时启用或禁用插件。让我们以内置的HooksPlugin为例:如果没有安装钩子,插件会将自己从所有受影响的路由中删除,实际上没有开销。一旦你安装了第一个钩子,插件就会自动激活并再次生效。

为了达到这个目的,你需要控制回调函数的缓存: Route.reset() 函数清空单一route的缓存, Bottle.reset() 函数清空所有route的缓存。在下一次请求的时候,所有插件被重新应用到route上面,就像第一次请求时那样。

如果在route的回调函数里面调用,两种方法都不会影响当前的请求。当然,可以抛出一个 RouteReset 异常,来改变当前的请求。

插件例子: SQLitePlugin

这个插件提供对sqlite3数据库的访问,如果route的回调函数提供了关键字参数(默认是"db"),则"db"可做为数据库连接,如果route的回调函数没有提供该参数,则忽略该route。wrapper不会影响返回值,但是会处理插件相关的异常。 Plugin.setup() 方法用于检查应用,查找冲突的插件。

import sqlite3
import inspect

class SQLitePlugin(object):
    ''' This plugin passes an sqlite3 database handle to route callbacks
    that accept a `db` keyword argument. If a callback does not expect
    such a parameter, no connection is made. You can override the database
    settings on a per-route basis. '''

    name = 'sqlite'
    api = 2

    def __init__(self, dbfile=':memory:', autocommit=True, dictrows=True,
                 keyword='db'):
         self.dbfile = dbfile
         self.autocommit = autocommit
         self.dictrows = dictrows
         self.keyword = keyword

    def setup(self, app):
        ''' Make sure that other installed plugins don't affect the same
            keyword argument.'''
        for other in app.plugins:
            if not isinstance(other, SQLitePlugin): continue
            if other.keyword == self.keyword:
                raise PluginError("Found another sqlite plugin with "\
                "conflicting settings (non-unique keyword).")

    def apply(self, callback, context):
        # Override global configuration with route-specific values.
        conf = context.config.get('sqlite') or {}
        dbfile = conf.get('dbfile', self.dbfile)
        autocommit = conf.get('autocommit', self.autocommit)
        dictrows = conf.get('dictrows', self.dictrows)
        keyword = conf.get('keyword', self.keyword)

        # Test if the original callback accepts a 'db' keyword.
        # Ignore it if it does not need a database handle.
        args = inspect.getargspec(context.callback)[0]
        if keyword not in args:
            return callback

        def wrapper(*args, **kwargs):
            # Connect to the database
            db = sqlite3.connect(dbfile)
            # This enables column access by name: row['column_name']
            if dictrows: db.row_factory = sqlite3.Row
            # Add the connection handle as a keyword argument.
            kwargs[keyword] = db

            try:
                rv = callback(*args, **kwargs)
                if autocommit: db.commit()
            except sqlite3.IntegrityError, e:
                db.rollback()
                raise HTTPError(500, "Database Error", e)
            finally:
                db.close()
            return rv

        # Replace the route callback with the wrapped one.
        return wrapper

这个插件十分有用,已经和Bottle提供的那个版本很类似了(译者注:=。= 一模一样)。只要60行代码,还不赖嘛!下面是一个使用例子。

sqlite = SQLitePlugin(dbfile='/tmp/test.db')
bottle.install(sqlite)

@route('/show/<page>')
def show(page, db):
    row = db.execute('SELECT * from pages where name=?', page).fetchone()
    if row:
        return template('showpage', page=row)
    return HTTPError(404, "Page not found")

@route('/static/<fname:path>')
def static(fname):
    return static_file(fname, root='/some/path')

@route('/admin/set/<db:re:[a-zA-Z]+>', skip=[sqlite])
def change_dbfile(db):
    sqlite.dbfile = '/tmp/%s.db' % db
    return "Switched DB to %s.db" % db

第一个route提供了一个"db"参数,告诉插件它需要一个数据库连接。第二个route不需要一个数据库连接,所以会被插件忽略。第三个route确实有一个"db"参数,但显式的禁用了sqlite插件,这样,"db"参数不会被插件修改,还是包含URL传过来的那个值。