待办事项应用示例

本教程简要介绍了 Bottle WSGI 框架。本教程的主要目标是,让您在学完之后,能够使用 Bottle 创建一个项目。本文档中没有展示所有功能,但至少介绍了主要的、重要的功能,如请求路由、利用 Bottle 模板引擎格式化输出以及处理 GET 和 POST 请求。最后一部分简要介绍了如何使用 WSGI 应用服务器来服务 Bottle 应用。

要理解本教程的内容,对 WSGI 有基本了解并不是必须的,因为 Bottle 无论如何都会尽可能地将 WSGI 隐藏起来,让用户不必直接接触它。当然,对 Python 编程语言有相当程度的理解是必需的。此外,本教程创建的示例应用会在 SQL 数据库中检索和存储数据,因此对 SQL 有(非常)基本的了解会有帮助,但理解 Bottle 的概念并非必须。这里使用的是 SQLite。由于 Bottle 是一个用于 Web 应用的框架,发送到浏览器的大部分输出都是 HTML。因此,对常用的 HTML 标签有基本概念肯定也会有帮助。如果还需要学习 HTML 基础,Mozilla Developer Network 网站上的 HTML 教程 是一个很好的起点。

为了介绍 Bottle,教程中的 Python 代码被精简,以保持重点。虽然教程中的所有代码都能正常工作,但在“实际环境”中(例如在公共可访问的服务器上)可能不适合直接使用。为此,需要添加输入验证、更好的数据库保护、更好的错误处理等等。

目标

本教程结束时,将编写一个即用型的、简单的、基于 Web 的待办事项应用。该应用接收任务,每个任务包含一个文本(最多 100 个字符)和一个状态(0 表示关闭,1 表示开放)。通过基于 Web 的用户界面,可以查看和编辑开放的任务,以及添加新任务。

开发期间,所有页面都可以在与 Bottle 应用程序代码运行在同一台机器上的 Web 浏览器中,通过地址 127.0.0.1(即:localhost)访问。稍后将展示如何调整应用程序以适应“真实”服务器。

Bottle 将负责路由,并通过模板来格式化输出。任务将存储在一个 SQLite 数据库中。数据库的读写将由 Python 代码完成。

本教程的结果将是一个包含以下页面和功能的应用程序:

  • 起始页 http://127.0.0.1:8080/todo

  • 将新任务添加到列表: http://127.0.0.1:8080/new

  • 编辑任务页: http://127.0.0.1:8080/edit/<number:int>

  • 显示任务详情: http://127.0.0.1:8080/details/<number:int>

  • 将任务格式化为 JSON 显示: http://127.0.0.1:8080/as_json/<number:int>

  • http://127.0.0.1:8080/ 重定向到 http://127.0.0.1:8080/todo

  • 捕获错误

开始之前…

关于 Python 版本的一个说明

Bottle 支持多种 Python 版本。Bottle 0.13 支持 Python 3.8 及更高版本,以及 Python 2 从 2.7.3 开始的版本,不过 Bottle 0.14 将放弃对 Python 2 的支持。由于 Python 核心开发者已于 2020 年 1 月 1 日放弃了对 Python 2 的支持,强烈建议使用最新的 Python 3 版本。

本教程至少需要 Python 3.10,因为在某个时候会用到 match 语句,该语句是在 Python 3.10 中引入的。如果使用 Python 3.8 或 3.9,需要将 match 语句替换为 if-elif-else 结构,其他部分都能正常工作。如果确实必须使用 Python 2.7.x,则还需要将某些地方使用的 f-strings 替换为 Python 2.7.x 中可用的字符串格式化方法。

最后,本教程将使用 python 来运行 Python 3.10 及更高版本。在某些平台上,可能需要输入 python3 来运行已安装的 Python 3 解释器。

安装 Bottle

假设您使用的是相当新的 Python 安装(版本 3.10 或更高),只需要额外安装 Bottle。Bottle 除了 Python 本身外没有其他依赖。按照 Python 模块安装的推荐最佳实践,我们首先创建一个 venv,然后将 Bottle 安装到 venv 中。打开您选择的目录来创建 venv 并执行以下命令:

python -m venv bottle_venv
cd bottle_venv
#for Linux & MacOS
source bin/activate
#for Windows
.\Scripts\activate
pip3 install bottle

SQLite

本教程使用 _SQLite_ 作为数据库。Python 的标准发布版已经包含了 SQLite,并提供了 SQLite 模块 来与数据库交互。因此这里不需要额外安装。

创建一个 SQL 数据库

在开始开发待办事项应用之前,需要创建之后要使用的数据库。为此,将以下脚本保存在项目目录中并使用 python 运行它。或者,也可以在交互式 Python 解释器中执行以下代码:

import sqlite3
connection = sqlite3.connect('todo.db') # Warning: This file is created in the current directory
cursor = connection.cursor()
cursor.execute("CREATE TABLE todo (id INTEGER PRIMARY KEY, task char(100) NOT NULL, status bool NOT NULL)")
cursor.execute("INSERT INTO todo (task,status) VALUES ('Read the Python tutorial to get a good introduction into Python',0)")
cursor.execute("INSERT INTO todo (task,status) VALUES ('Visit the Python website',1)")
cursor.execute("INSERT INTO todo (task,status) VALUES ('Test various editors for and check the syntax highlighting',1)")
cursor.execute("INSERT INTO todo (task,status) VALUES ('Choose your favorite WSGI-Framework',0)")
connection.commit()

这些命令会生成一个名为 todo.db 的数据库文件,其中包含一个名为 todo 的表。该表有三列:idtaskstatusid 是每一行的唯一 ID,稍后用于引用数据行。task 列存储描述任务的文本,最多限制为 100 个字符。最后,status 列用于将任务标记为开放(值为 1)或关闭(值为 0)。

使用 Bottle 编写基于 Web 的待办事项应用

让我们深入了解 Bottle 并创建基于 Web 的待办事项应用。但首先,我们先看看 Bottle 的一个基本概念:路由。

理解路由

基本上,浏览器中可见的每个页面都是在调用页面地址时动态生成的。因此,没有静态内容。这正是 Bottle 中所称的“路由”:服务器上的一个特定地址。例如,从浏览器打开 URL http://127.0.0.1:8080/todo 时,Bottle 会在服务器端“捕获”该调用,并检查是否存在为路由“todo”定义的(Python)函数。如果存在,Bottle 会执行相应的 Python 代码并返回其结果。所以,Bottle(以及其他 Python WSGI 框架)所做的是:它将一个 URL 绑定到一个函数。

通过“Hello World”示例了解 Bottle 基础

在最终开始待办事项应用之前,先创建一个非常基础的“Hello World”示例:

from bottle import Bottle


app = Bottle()

@app.route('/')
def index():
    return 'Hello from Bottle'

if __name__ == '__main__':
    app.run(host='127.0.0.1', port=8080)

将文件保存为任意名称,例如 hello_bottle.py,然后执行该文件 python hello_bottle.py。然后打开浏览器并在地址栏输入 http://127.0.0.1:8080。浏览器窗口现在应该显示文本“Hello from Bottle”。

那么,这里发生了什么?让我们逐行剖析:

  • from bottle import Bottle 从 Bottle 模块导入 Bottle 类。从该类派生的每个实例都代表一个独立、独特的 Web 应用程序。

  • app = Bottle() 创建一个 Bottle 实例。app 是 Web 应用程序对象。

  • @app.route('/') 为该应用创建了一个绑定到 / 的新路由。

  • def index() 定义了一个函数,由于使用了 app.route 装饰器(下文会有更多介绍)修饰,它与路由 /“链接”在一起。

  • return 'Hello from Bottle' “Hello from Bottle” 是调用该路由时发送给浏览器的纯文本。

  • if __name__ == '__main__':: 以下代码仅在持有该代码的文件被 Python 解释器直接执行时才执行。例如,如果 WSGI 服务器正在服务该代码(稍后会有更多介绍),则以下代码不会执行。

  • app.run(host='127.0.0.1', port=8080) 启动内置的开发服务器,监听地址 127.0.0.1 和端口 8080

第一步 - 显示所有开放项

现在了解了路由的概念和 Bottle 的基础知识,让我们为待办事项应用创建第一个实际路由。目标是查看待办事项列表中的所有开放项:

import sqlite3
from bottle import Bottle


app = Bottle()

@app.route('/todo')
def todo_list():
    with sqlite3.connect('todo.db') as connection:
        cursor = connection.cursor()
        cursor.execute("SELECT id, task, status FROM todo WHERE status LIKE '1'")
        result = cursor.fetchall()
        return str(result)

if __name__ == '__main__':
    app.run(host='127.0.0.1', port=8080)

将代码保存为 todo.py,最好与数据库文件 todo.db 放在同一目录中。否则,必须在 sqlite3.connect() 语句中添加 todo.db 的路径。

让我们看看这里发生了什么:为了访问 SQLite 数据库,导入了必需的模块 sqlite3,并从 Bottle 导入了 Bottle 类。定义了一个函数 todo_list(),其中包含几行从数据库读取数据的代码。这里的重点是在 def todo_list() 语句之前的 装饰器函数 @route('/todo')。通过这样做,这个函数被绑定到路由 /todo,所以每次浏览器调用 http://127.0.0.1:8080/todo 时,Bottle 都会返回函数 todo_list() 的结果。这就是 Bottle 中的路由工作方式。

实际上,可以将多个路由绑定到一个函数。以下代码:

@route('/todo')
@route('/my_todo_list')
def todo_list():
    ...

也正常工作。但不能将一个路由绑定到多个函数。

浏览器显示的是返回值,即 return 语句提供的值。在此示例中,需要通过 str()result 转换为字符串,因为 Bottle 期望从返回语句中接收字符串或字符串列表。但在这里,数据库查询的结果是一个元组列表,这是 Python DB API 定义的标准。

现在,理解了上面的小脚本后,是时候执行它并观察结果了。只需运行 python todo.py 并在浏览器中打开 URL http://127.0.0.1:8080/todo。如果没有写错代码,输出应该看起来像这样:

[(2, 'Visit the Python website', 1), (3, 'Test various editors for and check the syntax highlighting', 1)]

如果成功了 - 恭喜!Bottle 已成功使用。如果未能正常工作,并且需要进行更改,请记住停止 Bottle 服务页面,否则修订后的版本将不会加载。

输出并不是非常令人兴奋或易于阅读。它是 SQL 查询返回的原始结果。下一步将以更漂亮的方式格式化输出。但在此之前,让我们在开发应用时让生活更轻松一些。

调试和自动重载

可能已经注意到,如果脚本中出现问题,例如数据库连接不工作,Bottle 会向浏览器发送一个简短的错误消息。为了调试目的,获取更多详细信息非常有帮助。可以通过向脚本添加以下内容轻松实现:

from bottle import Bottle
...
if __name__ == '__main__':
    app.run(host='127.0.0.1', port=8080, debug=True)

通过启用“debug”,如果出现错误,您将收到 Python 解释器的完整堆栈跟踪,这通常包含有用的信息,有助于查找错误。此外,模板(见下文)不会被缓存,因此对模板的更改将立即生效,无需停止和重新启动服务器。

警告

debug=True 仅用于开发目的,切勿在生产环境中使用。

开发时的另一个不错的功能是自动重载,通过修改 app.run() 语句启用,如下所示:

app.run(host='127.0.0.1', port=8080, reloader=True)

这将自动检测脚本的更改,并在下次调用时重新加载新版本,无需停止和启动服务器。

同样,该功能主要用于开发时,不应在生产系统上使用。

使用 Bottle 的 SimpleTemplate 格式化输出

现在,让我们看看如何将脚本的输出转换为合适的格式。实际上,Bottle 期望从函数中接收字符串或字符串列表,并将其返回给浏览器。Bottle 不关心字符串本身的内容,因此它可以是例如使用 HTML 标记格式化的文本。

Bottle 有一个易于使用的内置模板引擎,称为“SimpleTemplate”。模板以扩展名为 .tpl 的独立文件存储。默认情况下,它们应位于应用程序 Python 代码所在目录下的 views 目录中。可以在函数内部调用模板。模板可以包含任何类型的文本(很可能是混合了 Python 语句的 HTML 标记)。此外,模板可以接受参数,例如数据库查询的结果集,然后在模板中进行漂亮地格式化。

此处,查询显示开放待办任务的结果将转换为一个简单的 HTML 表格,包含两列:第一列将包含项的 ID,第二列包含文本。结果如上所示,是一个元组列表,每个元组包含一组结果。

要在示例中包含模板,只需添加以下几行代码:

from bottle import Bottle, template
...
    result = cursor.fetchall()
output = template('show_tasks', rows=result)
return output
...

这里做了两件事:首先,从 bottle 额外导入了 template,以便能够使用模板。其次,将模板 show_tasks 的输出赋给变量 output,然后返回该变量。除了调用模板外,还将从数据库查询接收到的 result 赋给了变量 rows,该变量随后传递给模板,以便在模板中使用。如果需要,可以向模板传递多个变量/值。

模板总是返回一个字符串列表,因此无需进行任何转换。可以通过编写 return template('show_tasks', rows=result) 来节省一行代码,这与上面的结果完全相同。

现在是时候编写相应的模板了,它看起来像这样:

%#template to generate a HTML table from a list of tuples (or list of lists, or tuple of tuples or ...)
<p>The open items are as follows:</p>
<table border="1">
%for row in rows:
  <tr>
  %for col in row:
    <td>{{col}}</td>
  %end
  </tr>
%end
</table>

将代码保存为 show_tasks.tpl,保存在 views 目录中。

我们来看一下代码:每一行以 % 开头的都被解释为 Python 代码。因为它实际上是 Python 代码,所以只允许有效的 Python 语句。如果代码有误,模板会像其他 Python 代码一样抛出异常。其他行是纯 HTML 标记。

可以看到,Python 的 for 语句使用了两次,用于遍历 rows。如上所示,rows 是一个保存数据库查询结果的变量,因此它是一个元组列表。第一个 for 语句访问列表中的元组,第二个访问元组中的项,并将它们分别放入表格的单元格中。所有 forifwhile 等语句都必须以 %end 结束,否则输出将不符合预期。

如果在非 Python 代码行中需要访问模板内的变量,请将其放入双花括号中,例如上面示例中的 {{ col  }}。这会告诉模板将变量的实际值插入到这个位置。

再次运行脚本并查看输出。仍然不是非常美观,也不是完整的 HTML,但至少比元组列表更易读。

添加一个基础模板

Bottle 的 SimpleTemplate 与其他模板引擎一样,支持模板嵌套。这非常方便,因为它允许定义一个基础模板,其中包含例如 HTML 文档类型定义、头部和主体部分,然后将此基础模板用于生成实际输出的其他模板。基础模板如下所示:

<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<title>ToDo App powered by Bottle</title>
</head>
<body>
{{!base}}
</body>
</html>

将此模板保存为 base.tpl,保存在 views 文件夹中。

可以看到,模板包含一个典型网站的基本 HTML 骨架。{{!base}} 插入使用基础模板的其他模板的内容。

要在其他模板(例如 shows_task.tpl)中使用基础模板,只需在该模板的开头添加以下行:

% rebase('base.tpl')
...

这会告诉模板将其内容重新基于模板 base.tpl

重新加载 http://127.0.0.1:8000/todo,现在的输出是有效的 HTML。当然,可以根据需要扩展基础模板,例如通过加载 CSS 样式表或在头部中的 <style>...</style> 部分定义自己的样式。

使用 GET 参数

应用有了第一个显示任务的路由,但到目前为止它只显示开放的任务。让我们修改这个功能,并为路由添加一个(可选的)GET 参数,允许用户选择是只显示开放任务(这是默认设置),只显示关闭任务,还是显示数据库中存储的所有任务。这应该通过检查一个名为 show 的键来实现,该键可以有以下三个值之一:openclosedall。因此,例如打开 URL http://127.0.0.1:8080?show=all 应该使应用程序显示数据库中的所有任务。

更新后的路由和相应函数如下所示:

...
from bottle import request
...
@app.get('/todo')
def todo_list():
    show  = request.query.show or 'open'
    match show:
        case 'open':
            db_query = "SELECT id, task FROM todo WHERE status LIKE '1'"
        case 'closed':
            db_query = "SELECT id, task FROM todo WHERE status LIKE '0'"
        case 'all':
            db_query = "SELECT id, task FROM todo"
        case _:
            return template('message.tpl',
                message = 'Wrong query parameter: show must be either open, closed or all.')
    with sqlite3.connect('todo.db') as connection:
        cursor = connection.cursor()
        cursor.execute(db_query)
        result = cursor.fetchall()
    output = template('show_tasks.tpl', rows=result)
    return output
...

首先,从 Bottle 导入中添加了 request。Bottle 的 request 对象保存了发送到应用程序的所有请求数据。此外,路由更改为 @app.get(...),明确说明此路由只接受 GET 请求。

注意

此更改并非严格必要,因为 app.route() 隐式地也只接受 GET 请求。然而,遵循 Python 之禅:“显式优于隐式。”

show_all  = request.query.show or 'open' 的作用如下:queryrequest 对象的属性,它保存了 GET 请求的数据。因此 request.query.show 返回请求中键 show 的值。如果 show 不存在,则将值 open 赋给 show 变量。这也意味着 GET 请求中的任何其他键都将被忽略。

接下来的 match 语句根据 show 的值将 SQL 查询赋给变量 db_query,如果 show 既不是 open 也不是 closed 也不是 all,则显示错误消息。todo_list() 函数的其余代码保持不变。

在处理此路由时,让我们对 show_tasks 模板做一点补充。添加以下行:

<p><a href="/new">Add a new task</a></p>

在模板末尾添加一个链接,用于向数据库添加新任务。相应的路由和函数将在下一节创建。

最后,上面代码中使用的新的模板 message.tpl 看起来像这样:

% rebase('base.tpl')
<p>{{ message }}</p>
<p><a href="/todo">Back to main page</p>

使用表单和 POST 数据

现在所有任务都可以正常查看了,让我们进入下一步,添加功能来向待办事项列表添加新任务。新任务应该从一个普通的 HTML 表单接收,并通过 POST 请求发送数据。

为此,首先向代码中添加一个新的路由。该路由应该接受 GET 和 POST 请求:

@app.route('/new', method=['GET', 'POST'])
def new_task():
    if request.POST:
        new_task = request.forms.task.strip()
        with sqlite3.connect('todo.db') as connection:
            cursor = connection.cursor()
            cursor.execute("INSERT INTO todo (task,status) VALUES (?,?)", (new_task, 1))
            new_id = cursor.lastrowid
        return template('message.tpl',
            message=f'The new task was inserted into the database, the ID is {new_id}')
    else:
        return template('new_task.tpl')

创建了一个新的路由,分配给 /new,它接受 GET 和 POST 请求。在该路由分配的函数 new_task 内部,检查前一节介绍的 request 对象,以确定接收到的是 GET 请求还是 POST 请求:

...
if request.POST:
    #The code here is only executed if POST data, e.g. from a
    #HTML form, is inside the request.
else:
    #the code here is only executed if no POST data was received.
...

request.forms 是保存由 HTML 表单提交的数据的属性。request.forms.task 保存表单字段 task 的数据。由于 task 是一个字符串,因此还额外应用了 strip 方法来移除字符串之前或之后的任何空白字符。

然后将新任务写入数据库,并返回新任务的 ID。如果没有接收到 POST 数据,则发送模板 new_task。此模板包含用于输入新任务的 HTML 表单。模板如下所示:

%#template of the form for a new task
% rebase('base.tpl')
<p>Add a new task to the ToDo list:</p>
<form action="/new" method="post">
  <p><input type="text" size="100" maxlength="100" name="task"></p>
  <p><input type="submit" name="save" value="save"></p>
</form>

编辑现有项

完成简单待办事项应用的最后一步是编辑数据库中现有任务的功能。无论是更改其状态还是更新任务的文本。

仅使用到目前为止介绍的路由是可能的,但会相当棘手。为了简化操作,我们使用 Bottle 的一个特性,称为 动态路由,这使得这个编码任务变得非常容易。

动态路由的基本语句如下:

.. code-block:: python

@app.route(‘some_route/<something>’)

<something> 被称为“通配符”。此外,通配符 something 的值会传递给分配给此路由的函数,以便在函数内部处理数据。可选地,可以对通配符应用过滤器。过滤器只做一件事:它检查通配符是否匹配特定类型的数据,例如整数值或正则表达式。如果不匹配,则会引发错误。

此路由使用了 int 过滤器,它首先检查通配符是否匹配整数值。如果匹配,则将通配符字符串转换为 Python 整数对象。

用于编辑任务的完整路由如下所示:

@app.route('/edit/<number:int>', method=['GET', 'POST'])
def edit_task(number):
    if request.POST:
        new_data = request.forms.task.strip()
        status = request.forms.status.strip()
        if status == 'open':
            status = 1
        else:
            status = 0
        with sqlite3.connect('todo.db') as connection:
            cursor = connection.cursor()
            cursor.execute("UPDATE todo SET task = ?, status = ? WHERE id LIKE ?", (new_data, status, number))
        return template('message.tpl',
            message=f'The task number {number} was successfully updated')
    else:
        with sqlite3.connect('todo.db') as connection:
            cursor = connection.cursor()
            cursor.execute("SELECT task FROM todo WHERE id LIKE ?", (number,))
            current_data = cursor.fetchone()
        return template('edit_task', current_data=current_data, number=number)

代码的大部分逻辑与 /new 路由和相应的 new_task 函数非常相似:路由接受 GET 和 POST 请求,并根据请求,要么发送模板 edit_task,要么根据接收到的表单数据更新数据库中的任务。

这里新增的是动态路由 @app.route('/edit/<number:int>' ...),它接受一个通配符,该通配符应该是整数值。通配符被分配给变量 number,函数 edit_task 也期望接收这个变量。因此,例如打开 URL http:/127.0.0.1:8080/edit/2 将打开 ID 为 2 的任务进行编辑。如果没有传递数字,无论是省略参数还是传递了一个非整数的字符串,都会引发错误。

函数中调用的模板 edit_task.tpl 如下所示:

%#template for editing a task
%#the template expects to receive a value for "number" as well a "old", the text of the selected ToDo item
% rebase('base.tpl')
<p>Edit the task with ID = {{number}}</p>
<form action="/edit/{{number}}" method="post">
  <p>
  <input type="text" name="task" value="{{current_data[0]}}" size="100" maxlength="100">
  <select name="status">
    <option>open</option>
    <option>closed</option>
  </select>
  </p>
  <p><input type="submit" name="save" value="save"></p>
</form>

下一节“返回 JSON 数据”将展示另一个使用过滤器的动态路由示例。

返回 JSON 数据

Bottle 的一个很棒的功能是,如果将 Python 字典传递给路由的 return 语句,它会自动生成一个内容类型为 JSON 的响应。这使得使用 Bottle 构建基于 Web 的 API 变得非常容易。让我们为待办事项应用构建一个路由,它将数据库中的任务作为 JSON 返回。这非常直接;代码如下所示:

@app.route('/as_json/<number:re:[0-9]+>')
def task_as_json(number):
    with sqlite3.connect('todo.db') as connection:
        cursor = connection.cursor()
        cursor.execute("SELECT id, task, status FROM todo WHERE id LIKE ?", (number,))
        result = cursor.fetchone()
    if not result:
        return {'task': 'This task ID number does not exist!'}
    else:
        return {'id': result[0], 'task': result[1], 'status': result[2]}

可以看出,唯一的区别是返回的字典。结果要么是一个包含“id”、“task”和“status”这三个键的 JSON 对象,要么只包含一个名为“task”的键,其值为错误消息。

此外,此路由的通配符 number 使用了应用 RegEx 的 re 过滤器。当然,这里也可以使用用于 /edit` 路由的 int 过滤器(并且可能更合适),但这里使用 RegEx 过滤器只是为了展示它。该过滤器基本上可以处理 Python 的 RegEx 模块 可以处理的任何正则表达式。

返回静态文件

有时可能需要将一个路由关联到静态文件而不是 Python 函数。静态文件可以是 JPG 或 PNG 图形、PDF 文件或静态 HTML 文件,而不是模板。无论如何,首先需要添加另一个导入:

from bottle import static_file

到代码中,以导入 Bottle 处理发送静态文件的函数 static_file。假设所有静态文件都位于应用程序相对路径下的 static 子目录中。从该目录提供静态文件的代码如下所示:

...
from pathlib import Path

ABSOLUTE_APPLICATION_PATH = Path(__file__).parent[0]
...

@app.route('/static/<filepath:path>')
def send_static_file(filepath):
    ROOT_PATH = ABSOLUTE_APPLICATION_PATH / 'static'
    return static_file(filepath,
                       root=ROOT_PATH)

导入了 Python 的 pathlib 模块的 Path 类,然后用它来确定应用程序所在的绝对路径。这是必需的,因为 static_file 方法需要静态内容的绝对路径。当然,路径也可以硬编码到代码中,但使用 pathlib 更优雅。

路由 /static/<filepath:path> 利用了 Bottle 内置的 path 过滤器,保存要提供的文件名的通配符被赋给了 filepath。从代码可以看出,static_file 函数需要要提供的文件名以及文件所在的目录的根路径。

Bottle 会自动猜测文件的 MIME 类型。但也可以通过向 static_file 添加第三个参数来明确指定,例如为提供静态 HTML 文件指定 mimetype='text/html'。有关 static_file 的更多信息可以在 static_file 文档 中找到。

捕获错误

当尝试打开一个不存在的网页时,浏览器会显示“404 Not Found”错误消息。Bottle 提供了一个选项来捕获这些错误并返回一个自定义的错误消息。其工作方式如下:

@app.error(404)
def error_404(error):
    return 'Sorry, this page does not exist!'

如果发生 404 Not Found 错误,则会运行使用 app.error(404) 修饰的函数,并返回您选择的自定义错误消息。传递给函数的 error 参数是一个包含两个元素的元组:第一个元素是实际的错误代码,第二个元素是实际的错误消息。可以在函数中使用此元组,也可以不使用。当然,与所有路由一样,也可以将多个错误/路由分配给一个函数,例如:

@app.error(404)
@error(403)
def something_went_wrong(error):
    return f'{error}: There is something wrong!'

创建重定向(附加部分)

虽然待办事项应用工作正常,但它仍然有一个小缺陷:当尝试在浏览器中打开根路由 127.0.01:8080 时,会发生 404 错误,因为没有为 `/ 建立路由。这并不是什么大问题,但至少有点出乎意料。当然,可以通过将路由 app.route('/todo') 修改为 app.route('/') 来改变这一点。或者,如果想保留 /todo 路由,可以向代码添加一个重定向。同样,这非常直接:

...
from bottle import redirect
...

@app.route('/')
def index():
    redirect('/todo')

首先,添加了(到目前为止)缺失的路由 app.route('/'),它修饰了 index() 函数。它只有一行代码,将浏览器重定向到 todo 路由。当打开 URL 127.0.0.1:8080 时,浏览器将自动重定向到 http://127.0.0.1:8080/todo

总结

学习完以上所有章节后,希望您已经对 Bottle 的工作原理有了初步了解,从而可以编写新的基于 Bottle 的 Web 应用程序。

以下章节将展示如何使用在更高的负载/更多的网络流量下表现更好的 Web 服务器来服务 Bottle。

部署

到目前为止,我们使用的是 Bottle 内置的开发服务器,它基于 Python 中包含的 WSGI 参考服务器。虽然这个服务器非常适合且非常方便进行开发,但它并不真正适合服务“真实世界”的应用程序。但在查看替代方案之前,让我们先看看如何调整内置服务器的设置。

在不同的端口和 IP 上运行 Bottle

作为标准设置,Bottle 监听 IP 地址 127.0.0.1(也称为 localhost)和端口 8080。修改设置非常简单,因为可以通过向 Bottle 的 run() 函数传递额外的参数来更改端口和地址。

在最初的“Hello World”示例中,服务器是通过 app.run(host='127.0.0.1', port=8080) 启动的。要更改端口,只需将不同的端口号传递给 port 参数。要更改 Bottle 监听的 IP 地址,只需将不同的 IP 地址传递给 host 参数。

警告

强烈建议*不要*以 Root / 管理员权限运行基于 Bottle 或任何 Web 应用程序!整个代码都以提升的权限执行,这意味着如果编程出错,对系统造成损害的风险(大大)更高。此外,如果外部人员能够侵入应用程序(例如通过利用代码中的错误),该人员可能能够在服务器上以提升的权限工作。强烈建议以用户权限运行 Bottle,对于实际应用,可能由专门为此目的设置的专用用户运行。如果应用程序需要在 80 和/或 443 等特权端口上监听,一种常见且成熟的做法是使用 WSGI 应用服务器在本地的非特权端口上以用户权限服务 Bottle 或任何基于 WSGI 的应用程序,并在 WSGI 应用服务器前面使用反向代理 Web 服务器。下文将对此进行更多介绍。

使用不同的服务器运行 Bottle

如上所述,内置服务器非常适合本地开发、个人使用或内部网络中的非常小群体。除此之外,开发服务器可能会成为瓶颈,因为它采用单线程,一次只能处理一个请求。此外,它通常可能不够健壮。

Bottle 提供了多种 服务器适配器。要使用内置开发服务器以外的其他服务器运行 Bottle 应用程序,只需将 server 参数传递给 run 函数即可。以下示例使用 Pylons 项目的 Waitress WSGI 应用服务器。Waitress 在 Linux、MacOS 和 Windows 上都能同样出色地工作。

注意

尽管 Bottle 提供了多种服务器适配器,但除了内置服务器之外的每个服务器都必须单独安装。这些服务器*不是* Bottle 的依赖项!

要安装 Waitress,进入安装 Bottle 的 venv 并运行:

pip3 install waitress

要通过 Waitress 服务应用程序,只需使用 Bottle 对 Waitress 的服务器适配器,将 app.run 更改为:

app.run(host='127.0.0.1', port=8080, server='waitress')

使用 python todo.py 启动应用程序后,输出中应该会打印类似 Bottle v0.13.2 server starting up (using WaitressServer())... 的行。这确认正在使用的是 Waitress 服务器而不是 WSGIRefServer。

这与其他 Bottle 支持的服务器的工作方式完全相同。然而,这样做有一个潜在的缺点:无法向服务器传递任何额外参数。这在许多“真实世界”场景中可能是必需的。下一节将展示解决方案。

使用 WSGI 应用服务器服务 Bottle 应用

与任何其他 Python WSGI 框架一样,用 Bottle 编写的应用程序有一个所谓的入口点,可以传递给 WSGI 应用服务器,然后由它来服务 Web 应用程序。对于 Bottle,入口点是使用代码行 app = Bottle() 创建的 app 实例。

继续使用 Waitress(如前一节已使用),服务应用程序的工作方式如下:

waitress-serve todo:app

其中 todo 是包含 Bottle 应用程序的文件名,app 是入口点,即 Bottle 的实例。直接调用 WSGI 应用服务器允许向服务器传递所需数量的参数,例如:

waitress-serve --listen:127.0.0.1:8080 --threads=2 todo:app

结语

本教程到此结束。展示了 Bottle 的基本概念,并编写了第一个利用 Bottle WSGI 框架的应用程序。此外,还展示了如何使用 WSGI 应用服务器为实际应用程序服务 Bottle 应用。

如引言中所述,本教程并未展示 Bottle 提供的所有可能性。这里跳过的包括例如接收文件对象和流以及如何处理认证数据。有关 Bottle 所有功能的完整概述,请参阅完整的 Bottle 文档

完整示例列表

待办事项列表示例是逐步开发的,这里是完整的列表和模板:

应用主代码 todo.py

import sqlite3
from pathlib import Path
from bottle import Bottle, template, request, redirect


ABSOLUTE_APPLICATION_PATH = Path(__file__).parents[0]
app = Bottle()

@app.route('/')
def index():
    redirect('/todo')


@app.get('/todo')
def todo_list():
    show  = request.query.show or 'open'
    match show:
        case 'open':
            db_query = "SELECT id, task, status FROM todo WHERE status LIKE '1'"
        case 'closed':
            db_query = "SELECT id, task, status FROM todo WHERE status LIKE '0'"
        case 'all':
            db_query = "SELECT id, task, status FROM todo"
        case _:
            return template('message.tpl',
                message = 'Wrong query parameter: show must be either open, closed or all.')
    with sqlite3.connect('todo.db') as connection:
        cursor = connection.cursor()
        cursor.execute(db_query)
        result = cursor.fetchall()
    output = template('show_tasks.tpl', rows=result)
    return output


@app.route('/new', method=['GET', 'POST'])
def new_task():
    if request.POST:
        new_task = request.forms.task.strip()
        with sqlite3.connect('todo.db') as connection:
            cursor = connection.cursor()
            cursor.execute("INSERT INTO todo (task,status) VALUES (?,?)", (new_task, 1))
            new_id = cursor.lastrowid
        return template('message.tpl',
            message=f'The new task was inserted into the database, the ID is {new_id}')
    else:
        return template('new_task.tpl')


@app.route('/edit/<number:int>', method=['GET', 'POST'])
def edit_task(number):
    if request.POST:
        new_data = request.forms.task.strip()
        status = request.forms.status.strip()
        if status == 'open':
            status = 1
        else:
            status = 0
        with sqlite3.connect('todo.db') as connection:
            cursor = connection.cursor()
            cursor.execute("UPDATE todo SET task = ?, status = ? WHERE id LIKE ?", (new_data, status, number))
        return template('message.tpl',
            message=f'The task number {number} was successfully updated')
    else:
        with sqlite3.connect('todo.db') as connection:
            cursor = connection.cursor()
            cursor.execute("SELECT task FROM todo WHERE id LIKE ?", (number,))
            current_data = cursor.fetchone()
        return template('edit_task', current_data=current_data, number=number)


@app.route('/details/<task:re:[0-9]+>')
def show_item(task):
        with sqlite3.connect('todo.db') as connection:
            cursor = connection.cursor()
            cursor.execute("SELECT task, status FROM todo WHERE id LIKE ?", (task,))
            result = cursor.fetchone()
        if not result:
            return template('message.tpl',
            message = f'The task number {item} does not exist!')
        else:
            return template('message.tpl',
            message = f'Task: {result[0]}, status: {result[1]}')


@app.route('/as_json/<number:re:[0-9]+>')
def task_as_json(number):
    with sqlite3.connect('todo.db') as connection:
        cursor = connection.cursor()
        cursor.execute("SELECT id, task, status FROM todo WHERE id LIKE ?", (number,))
        result = cursor.fetchone()
    if not result:
        return {'task': 'This task IF number does not exist!'}
    else:
        return {'id': result[0], 'task': result[1], 'status': result[2]}


@app.route('/static/<filepath:path>')
def send_static_file(filepath):
    ROOT_PATH = ABSOLUTE_APPLICATION_PATH / 'static'
    return static_file(filepath, root= ROOT_PATH)


@app.error(404)
def mistake404(error):
    return 'Sorry, this page does not exist!'


if __name__ == '__main__':
    app.run(host='127.0.0.1', port=8080, debug=True, reloader=True)
    # remember to remove reloader=True and debug=True when moving
    # the application from development to a productive environment

模板 base.tpl

<!doctype html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>ToDo App powered by Bottle</title>
  </head>
  <body>
    {{!base}}
  </body>
</html>

模板 show_tasks.tpl

%#template to generate a HTML table from a list of tuples (or list of lists, or tuple of tuples or ...)
% rebase('base.tpl')
<p>The open ToDo tasks are as follows:</p>
<table border="1">
%for row in rows:
  <tr>
  %for col in row:
    <td>{{col}}</td>
  %end
  </tr>
%end
</table>
<p><a href="/new">Add a new task</a></p>

模板 message.tpl

% rebase('base.tpl')
<p>{{ message }}</p>
<p><a href="/todo">Back to main page</p>

模板 new_task.tpl

%#template of the form for a new task
% rebase('base.tpl')
<p>Add a new task to the ToDo list:</p>
<form action="/new" method="post">
  <p><input type="text" size="100" maxlength="100" name="task"></p>
  <p><input type="submit" name="save" value="save"></p>
</form>

模板 edit_task.tpl

%#template for editing a task
%#the template expects to receive a value for "no" as well a "old", the text of the selected ToDo item
<p>Edit the task with ID = {{no}}</p>
<form action="/edit/{{no}}" method="get">
  <input type="text" name="task" value="{{old[0]}}" size="100" maxlength="100">
  <select name="status">
    <option>open</option>
    <option>closed</option>
  </select>
  <br>
  <input type="submit" name="save" value="save">
</form>