编写插件

本指南解释了插件 API 以及如何编写自定义插件。如果您尚未阅读,建议先阅读 插件基础。您可能还想查看 使用插件 以获取一些实际示例。

插件 API

任何接受一个函数并返回一个函数的 callable 都是一个有效的插件。然而,这种简单的方法有其局限性。需要更多上下文和控制的插件可以实现扩展的 Plugin 接口并使用高级功能。请注意,这不是一个你可以从 bottle 导入的真实类,只是一个插件必须实现的契约,以便被识别为扩展插件。

class Plugin

插件必须是 callable 或实现 apply()。如果定义了 apply(),则总是优先于直接调用插件。所有其他方法和属性都是可选的。

name

Bottle.uninstall()Bottle.route()skip 参数都接受一个 name 字符串来引用插件或插件类型。这仅适用于具有 name 属性的插件。

api

插件 API 仍在不断发展。这个整数属性告诉 bottle 要使用哪个版本。如果缺失,bottle 默认使用第一个版本。当前版本是 2。详情请参见 插件 API 版本

setup(self, app: Bottle)

当插件通过 Bottle.install() 安装到应用程序时立即调用。唯一的参数是插件被安装到的应用程序对象。此方法 **不** 会为通过 apply 应用到路由的插件调用,仅会为安装到应用程序的插件调用。

__call__(self, callback)

只要未定义 apply(),插件本身就被用作 decorator,直接应用于每个路由 callback。唯一的参数是要装饰的 callback。此方法返回的任何内容都会替换原始 callback。如果不需要包装或替换给定的 callback,只需返回未修改的 callback 参数即可。

apply(self, callback, route: Route)

如果定义了此方法,则会优先使用它而不是 __call__() 来装饰路由 callback。额外的 route 参数是 Route 的一个实例,提供了大量关于要装饰的路由的上下文和元信息。详情请参见 路由上下文

close(self)

当插件被卸载或应用程序关闭时立即调用(参见 Bottle.uninstall()Bottle.close())。此方法 **不** 会为通过 apply 应用到路由的插件调用,仅会为安装到应用程序的插件调用。

插件 API 版本

插件 API 仍在不断发展,并随着 Bottle 0.10 的发布而改变,以解决路由上下文字典的某些问题。为了确保与 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)

    • Plugin.apply() 方法的 context 参数现在是 Route 的一个实例,而不是上下文字典。

路由上下文

传递给 Plugin.apply()Route 实例提供了关于要装饰的路由、原始路由 callback 和路由特定配置的详细信息。

请记住,Route.config 对路由是局部的,但在所有插件之间共享。一个好的做法是添加一个唯一的 prefix,或者如果你的插件需要大量配置,将其存储在 config 字典内的一个单独的 namespace 中。这有助于避免插件之间的命名冲突。

虽然一些 Route 属性是可变的,但更改可能会对其他插件产生不希望的影响,并且只影响尚未应用的插件。如果你需要对路由进行更改,并希望所有插件都能识别这些更改,请在之后调用 Route.reset()。这将清除路由缓存,并在下次调用该路由时再次应用所有插件,使所有插件都有机会适应新的 config。然而,router 不会被更新。对 rulemethod 值的更改对 router 没有影响,只对插件有影响。这将来可能会改变。

运行时优化

一旦所有插件都应用到路由,被包装的路由 callback 就会被缓存,以加快后续请求。如果你的插件的行为依赖于 configuration,并且你希望能够在运行时更改该 configuration,你需要在每次请求时读取 configuration。这很简单。

然而,出于性能原因,根据当前需求返回不同的 wrapper、使用 closures 或在运行时启用或禁用插件可能是值得的。以内置的 HooksPlugin 为例:如果没有安装任何 hooks,插件会从所有路由中移除自己,并且几乎没有开销。一旦你安装了第一个 hook,插件就会重新激活并再次生效。

要实现这一点,你需要控制 callback 缓存:Route.reset() 清除单个路由的缓存,而 Bottle.reset() 一次性清除应用程序所有路由的所有缓存。在下一次请求时,所有插件会重新应用于该路由,就像第一次请求一样。

常见模式

依赖或资源注入

插件可以检查 callback 是否接受特定的关键字参数,并且只有当该参数存在时才应用自己。例如,需要 db 关键字参数的路由 callback 需要数据库连接。不需要此类参数的路由可以被跳过,不进行装饰。参数名称应该是可配置的,以避免与其他插件或路由参数冲突。

请求上下文属性

插件可以将新的 request-local 属性添加到当前的 request 中,例如用于持久 session 的 request.session 或用于已登录用户的 request.user。参见 Request.__setattr__

响应类型映射

插件可以检查被包装的 callback 的返回值,并将输出转换或序列化为新类型。内置的 JsonPlugin 正是这样做的。

零开销插件

在特定路由上不需要的插件应返回未更改的 callback。如果它们想在运行时从路由中移除自己,可以调用 Route.reset() 并在下次触发时跳过该路由。

每个请求之前/之后

插件可以是 before_requestafter_request hooks(参见 Bottle.add_hook())的便捷替代方案,特别是当两者都需要时。

插件示例:SQLitePlugin

这个插件为被包装的 callback 提供一个 sqlite3 数据库连接 handle 作为附加的关键字参数,但前提是 callback 期望它。如果不是,该路由将被忽略,并且不会增加开销。wrapper 不会影响返回值,但会正确处理与插件相关的异常。Plugin.setup() 用于检查应用程序并搜索冲突的插件。

import sqlite3
import inspect

class SQLitePlugin:

    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, route):
        # Override global configuration with route-specific values.
        conf = route.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(route.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

这个插件只是一个示例,但实际上是可用的。

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

第一个路由需要数据库连接,并通过接受 db 关键字参数来告诉插件创建一个 handle。第二个路由不需要数据库,因此被插件忽略。第三个路由确实期望一个 'db' 关键字参数,但明确跳过了 sqlite 插件。这样,该参数就不会被插件覆盖,并且仍然包含同名 url 参数的值。