《Flask 入门教程》小挑战

经过本书的学习,你应该有能力独立开发一个简单的 Web 程序了。所以这里有一个小挑战:为你的 Watchlist 添加一个留言板功能,效果类似 SayHello

下面是一些编写提示:

  • 编写表示留言的模型类,更新数据库表
  • 创建留言页面的模板
  • 在模板中添加留言表单
  • 添加显示留言页面的视图函数
  • 在显示留言页面的视图函数编写处理表单的代码
  • 生成一些虚拟数据进行测试
  • 编写单元测试
  • 更新到部署后的程序
  • 可以参考 SayHello 源码

如果在完成这个挑战的过程中遇到了困难,可以在 HelloFlask 论坛发起讨论(设置帖子分类为“Flask 入门教程”)。除此之外,你可以在后记查看更多讨论的去处。

《Flask 入门教程》第 11 章:部署上线

在这个教程的最后一章,我们将会把程序部署到互联网上,让网络中的其他所有人都可以访问到。

Web 程序通常有两种部署方式:传统部署和云部署。传统部署指的是在使用物理主机或虚拟主机上部署程序,你通常需要在一个 Linux 系统上完成所有的部署操作;云部署则是使用其他公司提供的云平台,这些平台为你设置好了底层服务,包括 Web 服务器、数据库等等,你只需要上传代码并进行一些简单设置即可完成部署。这一章我们会介绍使用云平台 PythonAnywhere 来部署程序。

部署前的准备

对于某些配置,生产环境下需要使用不同的值。为了让配置更加灵活,我们把需要在生成环境下使用的配置改为优先从环境变量中读取,如果没有读取到,则使用默认值:

app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev')
app.config['SQLALCHEMY_DATABASE_URI'] = prefix + os.path.join(os.path.dirname(app.root_path), os.getenv('DATABASE_FILE', 'data.db'))

以第一个配置变量为例,os.getenv('SECRET_KEY', 'dev') 表示读取系统环境变量 SECRET_KEY 的值,如果没有获取到,则使用 dev

注意 像密钥这种敏感信息,保存到环境变量中要比直接写在代码中更加安全。

对于第二个配置变量,我们仅改动了最后的数据库文件名。在示例程序里,因为我们部署后将继续使用 SQLite,所以只需要为生产环境设置不同的数据库文件名,否则的话,你可以像密钥一样设置优先从环境变量读取整个数据库 URL。

在部署程序时,我们不会使用 Flask 内置的开发服务器运行程序,因此,对于写到 .env 文件的环境变量,我们需要手动使用 python-dotenv 导入。下面在项目根目录创建一个 wsgi.py 脚本,在这个脚本中加载环境变量,并导入程序实例以供部署时使用:

wsgi.py:手动设置环境变量并导入程序实例

import os

from dotenv import load_dotenv

dotenv_path = os.path.join(os.path.dirname(__file__), '.env')
if os.path.exists(dotenv_path):
    load_dotenv(dotenv_path)

from watchlist import app

这两个环境变量的具体定义,我们将在远程服务器环境创建新的 .env 文件写入。

最后让我们把改动提交到 Git 仓库,并推送到 GitHub 上的远程仓库:

$ git add .
$ git commit -m "Ready to deploy"
$ git push

提示 你可以在 GitHub 上查看本书示例程序的对应 commit:92eabc8

使用 PythonAnywhere 部署程序

首先访问注册页面注册一个免费账户。注册时填入的用户名将作为你的程序域名的子域部分,以及分配给你的 Linux 用户名。比如,如果你的用户名为 greyli,最终为你分配的程序域名就是 http://greyli.pythonanywhere.com/ 。

注册完成后会有一个简单的教程,你可以跳过,也可以跟着了解一下基本用法。管理面板主页如下所示:

管理面板主页

导航栏包含几个常用的链接,可以打开其他面板:

  • Consoles(控制台):可以打开 Bash、Python Shell、MySQL 等常用的控制台
  • Files(文件):创建、删除、编辑、上传文件,你可以在这里直接修改代码
  • Web:管理 Web 程序
  • Tasks(任务):创建计划任务
  • Databases(数据库):设置数据库,免费账户可以使用 MySQL

这些链接对应页面的某些功能也可以直接在管理面板主页打开。

我们需要先来创建一个 Web 程序,你可以点击导航栏的 Web 链接,或是主页上的“Open Web tab”按钮打开 Web 面板:

Web 面板

点击“Add a new web app”按钮创建 Web 程序,第一步提示升级账户后可以自定义域名,我们直接点击“Next”按钮跳到下一步:

自定义域名

这一步选择 Web 框架,为了获得更灵活的控制,选择手动设置(Manual configuration):

选择 Web 框架

接着选择你想使用的 Python 版本:

选择 Python 版本

最后点击“Next”按钮即可完成创建 Web 程序流程:

结束创建 Web 程序流程

接下来我们需要进行一系列程序初始化操作,最后再回到 Web 面板进行具体的设置。

初始化程序运行环境

我们首先要考虑把代码上传到 PythonAnywhere 的服务器上。上传代码一般有两种方式:

  • 从 GitHub 拉取我们的程序
  • 在本地将代码存储为压缩文件,然后在 Files 标签页上传压缩包

因为我们的代码已经推送到 GitHub 上,这里将采用第一种方式。首先通过管理面板主页的“Bash”按钮或是 Consoles 面板下的“Bash”链接创建一个命令行会话:

打开新的命令行会话

在命令行下输入下面的命令:

$ pip3 install --user pipenv  # 安装 Pipenv
$ git clone https://github.com/greyli/watchlist  # 注意替换 Git 仓库地址
$ cd watchlist  # 切换进程序仓库

这会把程序代码克隆到 PythonAnywhere 为你分配的用户目录中,路径即 /home/你的 PythonAnywhere 用户名/你的参仓库名,比如 /home/greyli/watchlist

注意替换 git clone 命令后的 Git 地址,将 greyli 替换为你的 GitHub 用户名,将 watchlist 替换为你的仓库名称。

提示 如果你使用 Python 2.7,那么需要使用 pip 来执行安装 Pipenv 的命令;打开 Python Shell 时使用 python 命令,而不是 python3。

提示 如果你在 GitHub 上的仓库类型为私有仓库,那么需要将 PythonAnywhere 服务器的 SSH 密钥添加到 GitHub 账户中,具体参考第 1 章“设置 SSH 密钥”小节。

下面我们在项目根目录创建 .env 文件,并写入生产环境下需要设置的两个环境变量。其中,密钥(SECRET_KEY)的值是随机字符串,我们可以使用 uuid 模块来生成:

$ python3
>>> import uuid
>>> uuid.uuid4().hex
'3d6f45a5fc12445dbac2f59c3b6c7cb1'

复制生成的随机字符备用,接着创建 .env 文件:

$ nano .env

写入设置密钥和数据库名称的环境变量:

SECRET_KEY=3d6f45a5fc12445dbac2f59c3b6c7cb1
DATABASE_FILE=data-prod.db

最后安装依赖并执行初始化操作:

$ pipenv install  # 创建虚拟环境并安装依赖
$ pipenv shell  # 激活虚拟环境
$ flask initdb  # 初始化数据库
$ flask admin  # 创建管理员账户

先不要关闭这个标签页,后面我们还要在这里执行一些命令。点击右上角的菜单按钮,并在浏览器的新标签页打开 Web 面板。

设置并启动程序

代码部分我们已经设置完毕,接下来进行一些简单设置就可以启动程序了。

代码

回到 Web 标签页,先来设置 Code 部分的配置:

代码配置

点击源码(Source code)和工作目录(Working directory)后的路径并填入项目根目录,目录规则为“/home/用户名/项目文件夹名”。

点击 WSGI 配置文件(WSGI configuration file)后的链接打开编辑页面,删掉这个文件内的所有内容,填入下面的代码:

import sys

path = '/home/watchlist/watchlist'
if path not in sys.path:
    sys.path.append(path)

from wsgi import app as application

完成后点击绿色的 Save 按钮或按下 Ctrl+S 保存修改,点击右上角的菜单按钮返回 Web 面板。

PythonAnywhere 会自动从这个文件里导入名称为 application 的程序实例,所以我们从项目目录的 wsgi 模块中导入程序实例 app,并将名称映射为 application

虚拟环境

为了让程序正确运行,我们需要在 Virtualenv 部分填入虚拟环境文件夹的路径:

虚拟环境配置

使用 Pipenv 时,你可以在项目根目录下使用下面的命令获取当前项目对应的虚拟环境文件夹路径(返回前面打开的命令行会话输入下面的命令):

$ pipenv --venv

复制输出的路径,点击 Virtualenv 部分的红色字体链接,填入并保存。

静态文件

静态文件可以交给 PythonAnywhere 设置的服务器来处理,这样会更高效。要让 PythonAnywhere 处理静态文件,我们只需要在 Static files 部分指定静态文件 URL 和对应的静态文件文件夹目录,如下所示:

静态文件配置

注意更新目录中的用户名和项目文件夹名称。

启动程序

一切就绪,点击绿色的重载按钮即可让配置生效:

重载程序

现在访问你的程序网址“https://用户名.pythonanywhere.com”(Web 面板顶部的链接),比如https://greyli.pythonanywhere.com 即可访问程序。

最后还要注意的是,免费账户需要每三个月点击一次黄色的激活按钮(在过期前你会收到提醒邮件):

激活程序

更新部署后的程序

当你需要更新程序时,流程和部署类似。在本地完成更新,确保程序通过测试后,将代码推送到 GitHub 上的远程仓库。登录到 PythonAnywhere,打开一个命令行会话(Bash),切换到项目目录,使用 git pull 命令从远程仓库拉取更新:

$ cd watchlist
$ git pull

然后你可以执行一些必要的操作,比如安装新的依赖等等。最后在 Web 面板点击绿色的重载(Reload)按钮即可完成更新。

本章小结

程序部署上线以后,你可以考虑继续为它开发新功能,也可以从零编写一个新的程序。虽然本书即将接近尾声,但你的学习之路才刚刚开始,因为本书只是介绍了 Flask 入门所需的基础知识,你还需要进一步学习。在后记中,你可以看到进一步学习的推荐读物。接下来,有一个挑战在等着你。

进阶提示

  • 因为 PythonAnywhere 支持在线管理文件、编辑代码、执行命令,你可以在学习编程的过程中使用它来在线开发 Web 程序。
  • PythonAnywhere 的 Web 面板还有一些功能设置:Log files 部分可以查看你的程序日志,Traffic 部分显示了你的程序访问流量情况,Security 部分可以为你的程序程序开启强制启用 HTTPS 和密码保护。
  • 《Flask Web 开发实战》 第 14 章详细介绍了部署 Flask 程序的两种方式,传统部署和云部署。
  • 本书主页 & 相关资源索引:http://helloflask.com/tutorial

《Flask 入门教程》第 10 章:组织你的代码

虽然我们的程序开发已经完成,但随着功能的增多,把所有代码放在 app.py 里会让后续的开发和维护变得麻烦。这一章,我们要对项目代码进行一次重构,让项目组织变得更加合理。

Flask 对项目结构没有固定要求,你可以使用单脚本,也可以使用包。这一章我们会学习使用包来组织程序。

先来看看我们目前的项目文件结构:

├── .flaskenv
├── Pipfile
├── Pipfile.lock
├── app.py
├── static
│   ├── favicon.ico
│   ├── images
│   │   ├── avatar.png
│   │   └── totoro.gif
│   └── style.css
├── templates
│   ├── 400.html
│   ├── 404.html
│   ├── 500.html
│   ├── base.html
│   ├── edit.html
│   ├── index.html
│   ├── login.html
│   └── settings.html
└── test_watchlist.py

使用包组织代码

我们会创建一个包,然后把 app.py 中的代码按照类别分别放到多个模块里。下面是我们需要执行的一系列操作(这些操作你也可以使用文件管理器或编辑器完成):

$ mkdir watchlist  # 创建作为包的文件夹
$ mv static templates watchlist  # 把 static 和 templates 文件夹移动到 watchlist 文件夹内
$ cd watchlist  # 切换进包目录
$ touch __init__.py views.py errors.py models.py commands.py  # 创建多个模块

我们把这个包称为程序包,包里目前包含的模块和作用如下表所示:

模块 作用
__init__.py 包构造文件,创建程序实例
views.py 视图函数
errors.py 错误处理函数
models.py 模型类
commands.py 命令函数

提示 除了包构造文件外,其他的模块文件名你可以自由修改,比如 views.py 也可以叫 routes.py。

创建程序实例,初始化扩展的代码放到包构造文件里(__init__.py),如下所示:

import os
import sys

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager

# ...

app = Flask(__name__)
app.config['SECRET_KEY'] = 'dev'
# 注意更新这里的路径,把 app.root_path 添加到 os.path.dirname() 中
# 以便把文件定位到项目根目录
app.config['SQLALCHEMY_DATABASE_URI'] = prefix + os.path.join(os.path.dirname(app.root_path), 'data.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

db = SQLAlchemy(app)
login_manager = LoginManager(app)

@login_manager.user_loader
def load_user(user_id):
	from watchlist.models import User
	user = User.query.get(int(user_id))
	return user

login_manager.login_view = 'login'

@app.context_processor
def inject_user():
	from watchlist.models import User
	user = User.query.first()
	return dict(user=user)

from watchlist import views, errors, commands

在构造文件中,为了让视图函数、错误处理函数和命令函数注册到程序实例上,我们需要在这里导入这几个模块。但是因为这几个模块同时也要导入构造文件中的程序实例,为了避免循环依赖(A 导入 B,B 导入 A),我们把这一行导入语句放到构造文件的结尾。同样的,load_user() 函数和 inject_user() 函数中使用的模型类也在函数内进行导入。

其他代码则按照分类分别放到各自的模块中,这里不再给出具体代码,你可以参考源码仓库。在移动代码之后,注意添加并更新导入语句,比如使用下面的导入语句来导入程序实例和扩展对象:

from watchlist import app, db

使用下面的导入语句来导入模型类:

from watchlist.models import User, Movie

以此类推。

组织模板

模块文件夹 templates 下包含了多个模板文件,我们可以创建子文件夹来更好的组织它们。下面的操作创建了一个 errors 子文件夹,并把错误页面模板都移动到这个 errors 文件夹内(这些操作你也可以使用文件管理器或编辑器完成):

$ cd templates  # 切换到 templates 目录
$ mkdir errors  # 创建 errors 文件夹
$ mv 400.html 404.html 500.html errors  # 移动错误页面模板到 errors 文件夹

因为错误页面放到了新的路径,所以我们需要修改代码中的 3 处模板文件路径,以 404 错误处理函数为例:

@app.errorhandler(400)
def bad_request(e):
    return render_template('errors/400.html'), 400

单元测试

你也可以将测试文件拆分成多个模块,创建一个 tests 包来存储这些模块。但是因为目前的测试代码还比较少,暂时不做改动,只需要更新导入语句即可:

from watchlist import app, db
from watchlist.models import Movie, User
from watchlist.commands import forge, initdb

因为要测试的目标改变,测试时的 --source 选项的值也要更新为包的名称 watchlist

$ coverage run --source=watchlist test_watchlist.py

提示 你可以创建配置文件来预先定义 --source 选项,避免每次执行命令都给出这个选项,具体可以参考文档配置文件章节

现在的测试覆盖率报告会显示包内的多个文件的覆盖率情况:

$ coverage report
Name                    Stmts   Miss  Cover
-------------------------------------------
watchlist\__init__.py      25      1    96%
watchlist\commands.py      35      1    97%
watchlist\errors.py         8      2    75%
watchlist\models.py        16      0   100%
watchlist\views.py         77      2    97%
-------------------------------------------
TOTAL                     161      6    96%

启动程序

因为我们使用包来组织程序,不再是 Flask 默认识别的 app.py,所以在启动开发服务器前需要使用环境变量 FLASK_APP 来给出程序实例所在的模块路径。因为我们的程序实例在包构造文件内,所以直接写出包名称即可。在 .flaskenv 文件中添加下面这行代码:

FLASK_APP=watchlist

最终的项目文件结构如下所示:

├── .flaskenv
├── Pipfile
├── Pipfile.lock
├── test_watchlist.py
└── watchlist  # 程序包
    ├── __init__.py
    ├── commands.py
    ├── errors.py
    ├── models.py
    ├── static
    │   ├── favicon.ico
    │   ├── images
    │   │   ├── avatar.png
    │   │   └── totoro.gif
    │   └── style.css
    ├── templates
    │   ├── base.html
    │   ├── edit.html
    │   ├── errors
    │   │   ├── 400.html
    │   │   ├── 404.html
    │   │   └── 500.html
    │   ├── index.html
    │   ├── login.html
    │   └── settings.html
    └── views.py

本章小结

对我们的程序来说,这样的项目结构已经足够了。但对于大型项目,你可以使用蓝本和工厂函数来进一步组织程序。结束前,让我们提交代码:

$ git add .
$ git commit -m "Orignize application with package"
$ git push

提示 你可以在 GitHub 上查看本书示例程序的对应 commit:f705408

进阶提示

  • 蓝本类似于子程序的概念,借助蓝本你可以把程序不同部分的代码分离开(比如按照功能划分为用户认证、管理后台等多个部分),即对程序进行模块化处理。每个蓝本可以拥有独立的子域名、URL 前缀、错误处理函数、模板和静态文件。
  • 工厂函数就是创建程序的函数。在工厂函数内,我们先创建程序实例,并在函数内完成初始化扩展、注册视图函数等一系列操作,最后返回可以直接运行的程序实例。工厂函数可以接受配置名称作为参数,在内部加载对应的配置文件,这样就可以实现按需创建加载不同配置的程序实例,比如在测试时调用工厂函数创建一个测试用的程序实例。
  • 《Flask Web 开发实战》 第 7 章介绍了使用包组织程序,第 8 章介绍了大型项目结构以及如何使用蓝本和工厂函数组织程序。
  • 本书主页 & 相关资源索引:http://helloflask.com/tutorial

《Flask 入门教程》第 9 章:测试

在此之前,每次为程序添加了新功能,我们都要手动在浏览器里访问程序进行测试。除了测试新添加的功能,你还要确保旧的功能依然正常工作。在功能复杂的大型程序里,如果每次修改代码或添加新功能后手动测试所有功能,那会产生很大的工作量。另一方面,手动测试并不可靠,重复进行测试操作也很枯燥。

基于这些原因,为程序编写自动化测试就变得非常重要。

注意 为了便于介绍,本书统一在这里介绍关于测试的内容。在实际的项目开发中,你应该在开发每一个功能后立刻编写相应的测试,确保测试通过后再开发下一个功能。

单元测试

单元测试指对程序中的函数等独立单元编写的测试,它是自动化测试最主要的形式。这一章我们将会使用 Python 标准库中的测试框架 unittest 来编写单元测试,首先通过一个简单的例子来了解一些基本概念。假设我们编写了下面这个函数:

def sayhello(to=None):
    if to:
        return 'Hello, %s!' % to
    return 'Hello!'

下面是我们为这个函数编写的单元测试:

import unittest

from module_foo import sayhello


class SayHelloTestCase(unittest.TestCase):  # 测试用例

    def setUp(self):  # 测试固件
        pass

    def tearDown(self):  # 测试固件
        pass

    def test_sayhello(self):  # 第 1 个测试
        rv = sayhello()
        self.assertEqual(rv, 'Hello!')
       
    def test_sayhello_to_somebody(self)  # 第 2 个测试
        rv = sayhello(to='Grey')
		self.assertEqual(rv, 'Hello, Grey!')


if __name__ == '__main__':
    unittest.main()

测试用例继承 unittest.TestCase 类,在这个类中创建的以 test_ 开头的方法将会被视为测试方法。

内容为空的两个方法很特殊,它们是测试固件,用来执行一些特殊操作。比如 setUp() 方法会在每个测试方法执行前被调用,而 tearDown() 方法则会在每一个测试方法执行后被调用(注意这两个方法名称的大小写)。

如果把执行测试方法比作战斗,那么准备弹药、规划战术的工作就要在 setUp() 方法里完成,而打扫战场则要在 tearDown()方法里完成。

每一个测试方法(名称以 test_ 开头的方法)对应一个要测试的函数 / 功能 / 使用场景。在上面我们创建了两个测试方法,test_sayhello() 方法测试 sayhello() 函数,test_sayhello_to_somebody() 方法测试传入参数时的 sayhello() 函数。

在测试方法里,我们使用断言方法来判断程序功能是否正常。以第一个测试方法为例,我们先把 sayhello() 函数调用的返回值保存为 rv 变量(return value),然后使用 self.assertEqual(rv, 'Hello!') 来判断返回值内容是否符合预期。如果断言方法出错,就表示该测试方法未通过。

下面是一些常用的断言方法:

  • assertEqual(a, b)
  • assertNotEqual(a, b)
  • assertTrue(x)
  • assertFalse(x)
  • assertIs(a, b)
  • assertIsNot(a, b)
  • assertIsNone(x)
  • assertIsNotNone(x)
  • assertIn(a, b)
  • assertNotIn(a, b)

这些方法的作用从方法名称上基本可以得知。

假设我们把上面的测试代码保存到 test_sayhello.py 文件中,通过执行 python test_sayhello.py 命令即可执行所有测试,并输出测试的结果、通过情况、总耗时等信息。

测试 Flask 程序

回到我们的程序,我们在项目根目录创建一个 test_watchlist.py 脚本来存储测试代码,我们先编写测试固件和两个简单的基础测试:

test_watchlist.py:测试固件

import unittest

from app import app, db, Movie, User


class WatchlistTestCase(unittest.TestCase):

    def setUp(self):
        # 更新配置
        app.config.update(
            TESTING=True,
            SQLALCHEMY_DATABASE_URI='sqlite:///:memory:'
        )
        # 创建数据库和表
        db.create_all()
        # 创建测试数据,一个用户,一个电影条目
        user = User(name='Test', username='test')
        user.set_password('123')
        movie = Movie(title='Test Movie Title', year='2019')
        # 使用 add_all() 方法一次添加多个模型类实例,传入列表
        db.session.add_all([user, movie])
        db.session.commit()

        self.client = app.test_client()  # 创建测试客户端
        self.runner = app.test_cli_runner()  # 创建测试命令运行器

    def tearDown(self):
        db.session.remove()  # 清除数据库会话
        db.drop_all()  # 删除数据库表
    
    # 测试程序实例是否存在
    def test_app_exist(self):
        self.assertIsNotNone(app)

    # 测试程序是否处于测试模式
    def test_app_is_testing(self):
        self.assertTrue(app.config['TESTING'])

某些配置,在开发和测试时通常需要使用不同的值。在 setUp() 方法中,我们更新了两个配置变量的值,首先将 TESTING 设为 True 来开启测试模式,这样在出错时不会输出多余信息;然后将 SQLALCHEMY_DATABASE_URI 设为 'sqlite:///:memory:',这会使用 SQLite 内存型数据库,不会干扰开发时使用的数据库文件。你也可以使用不同文件名的 SQLite 数据库文件,但内存型数据库速度更快。

接着,我们调用 db.create_all() 创建数据库和表,然后添加测试数据到数据库中。在 setUp() 方法最后创建的两个类属性分别为测试客户端和测试命令运行器,前者用来模拟客户端请求,后者用来触发自定义命令,下一节会详细介绍。

在 tearDown() 方法中,我们调用 db.session.remove() 清除数据库会话并调用 db.drop_all() 删除数据库表。测试时的程序状态和真实的程序运行状态不同,所以需要调用 db.session.remove() 来确保数据库会话被清除。

测试客户端

app.test_client() 返回一个测试客户端对象,可以用来模拟客户端(浏览器),我们创建类属性 self.client 来保存它。对它调用 get() 方法就相当于浏览器向服务器发送 GET 请求,调用 post() 则相当于浏览器向服务器发送 POST 请求,以此类推。下面是两个发送 GET 请求的测试方法,分别测试 404 页面和主页:

test_watchlist.py:测试固件

class WatchlistTestCase(unittest.TestCase):
    # ...
    # 测试 404 页面
    def test_404_page(self):
        response = self.client.get('/nothing')  # 传入目标 URL
        data = response.get_data(as_text=True)
        self.assertIn('Page Not Found - 404', data)
        self.assertIn('Go Back', data)
        self.assertEqual(response.status_code, 404)  # 判断响应状态码
    
    # 测试主页
    def test_index_page(self):
        response = self.client.get('/')
        data = response.get_data(as_text=True)
        self.assertIn('Test\'s Watchlist', data)
        self.assertIn('Test Movie Title', data)
        self.assertEqual(response.status_code, 200)

调用这类方法返回包含响应数据的响应对象,对这个响应对象调用 get_data() 方法并把 as_text 参数设为 True 可以获取 Unicode 格式的响应主体。我们通过判断响应主体中是否包含预期的内容来测试程序是否正常工作,比如 404 页面响应是否包含 Go Back,主页响应是否包含标题 Test's Watchlist。

接下来,我们要测试数据库操作相关的功能,比如创建、更新和删除电影条目。这些操作对应的请求都需要登录账户后才能发送,我们先编写一个用于登录账户的辅助方法:

test_watchlist.py:测试辅助方法

class WatchlistTestCase(unittest.TestCase):
    # ...
    # 辅助方法,用于登入用户
    def login(self):
        self.client.post('/login', data=dict(
            username='test',
            password='123'
        ), follow_redirects=True)

在 login() 方法中,我们使用 post() 方法发送提交登录表单的 POST 请求。和 get() 方法类似,我们需要先传入目标 URL,然后使用 data 关键字以字典的形式传入请求数据(字典中的键为表单 <input> 元素的 name 属性值),作为登录表单的输入数据;而将 follow_redirects 参数设为 True 可以跟随重定向,最终返回的会是重定向后的响应。

下面是测试创建、更新和删除条目的测试方法:

test_watchlist.py:测试创建、更新和删除条目

class WatchlistTestCase(unittest.TestCase):
    # ...
    # 测试创建条目
    def test_create_item(self):
        self.login()
        
        # 测试创建条目操作
        response = self.client.post('/', data=dict(
            title='New Movie',
            year='2019'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertIn('Item created.', data)
        self.assertIn('New Movie', data)
        
        # 测试创建条目操作,但电影标题为空
        response = self.client.post('/', data=dict(
            title='',
            year='2019'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Item created.', data)
        self.assertIn('Invalid input.', data)
        
        # 测试创建条目操作,但电影年份为空
        response = self.client.post('/', data=dict(
            title='New Movie',
            year=''
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Item created.', data)
        self.assertIn('Invalid input.', data)

    # 测试更新条目
    def test_update_item(self):
        self.login()
        
        # 测试更新页面
        response = self.client.get('/movie/edit/1')
        data = response.get_data(as_text=True)
        self.assertIn('Edit item', data)
        self.assertIn('Test Movie Title', data)
        self.assertIn('2019', data)
        
        # 测试更新条目操作
        response = self.client.post('/movie/edit/1', data=dict(
            title='New Movie Edited',
            year='2019'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertIn('Item updated.', data)
        self.assertIn('New Movie Edited', data)
        
        # 测试更新条目操作,但电影标题为空
        response = self.client.post('/movie/edit/1', data=dict(
            title='',
            year='2019'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Item updated.', data)
        self.assertIn('Invalid input.', data)
        
        # 测试更新条目操作,但电影年份为空
        response = self.client.post('/movie/edit/1', data=dict(
            title='New Movie Edited Again',
            year=''
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Item updated.', data)
        self.assertNotIn('New Movie Edited Again', data)
        self.assertIn('Invalid input.', data)

    # 测试删除条目
    def test_delete_item(self):
        self.login()
        
        response = self.client.post('/movie/delete/1', follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertIn('Item deleted.', data)
        self.assertNotIn('Test Movie Title', data)

在这几个测试方法中,大部分的断言都是在判断响应主体是否包含正确的提示消息和电影条目信息。

登录、登出和认证保护等功能的测试如下所示:

test_watchlist.py:测试认证相关功能

class WatchlistTestCase(unittest.TestCase):
    # ...
    # 测试登录保护
    def test_login_protect(self):
        response = self.client.get('/')
        data = response.get_data(as_text=True)
        self.assertNotIn('Logout', data)
        self.assertNotIn('Settings', data)
        self.assertNotIn('<form method="post">', data)
        self.assertNotIn('Delete', data)
        self.assertNotIn('Edit', data)

    # 测试登录
    def test_login(self):
        response = self.client.post('/login', data=dict(
            username='test',
            password='123'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertIn('Login success.', data)
        self.assertIn('Logout', data)
        self.assertIn('Settings', data)
        self.assertIn('Delete', data)
        self.assertIn('Edit', data)
        self.assertIn('<form method="post">', data)
        
        # 测试使用错误的密码登录
        response = self.client.post('/login', data=dict(
            username='test',
            password='456'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Login success.', data)
        self.assertIn('Invalid username or password.', data)
        
        # 测试使用错误的用户名登录
        response = self.client.post('/login', data=dict(
            username='wrong',
            password='123'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Login success.', data)
        self.assertIn('Invalid username or password.', data)
        
        # 测试使用空用户名登录
        response = self.client.post('/login', data=dict(
            username='',
            password='123'
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Login success.', data)
        self.assertIn('Invalid input.', data)
        
        # 测试使用空密码登录
        response = self.client.post('/login', data=dict(
            username='test',
            password=''
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Login success.', data)
        self.assertIn('Invalid input.', data)

    # 测试登出
    def test_logout(self):
        self.login()

        response = self.client.get('/logout', follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertIn('Goodbye.', data)
        self.assertNotIn('Logout', data)
        self.assertNotIn('Settings', data)
        self.assertNotIn('Delete', data)
        self.assertNotIn('Edit', data)
        self.assertNotIn('<form method="post">', data)
    
    # 测试设置
    def test_settings(self):
        self.login()
        
        # 测试设置页面
        response = self.client.get('/settings')
        data = response.get_data(as_text=True)
        self.assertIn('Settings', data)
        self.assertIn('Your Name', data)

        # 测试更新设置
        response = self.client.post('/settings', data=dict(
            name='Grey Li',
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertIn('Settings updated.', data)
        self.assertIn('Grey Li', data)

        # 测试更新设置,名称为空
        response = self.client.post('/settings', data=dict(
            name='',
        ), follow_redirects=True)
        data = response.get_data(as_text=True)
        self.assertNotIn('Settings updated.', data)
        self.assertIn('Invalid input.', data)

测试命令

除了测试程序的各个视图函数,我们还需要测试自定义命令。app.test_cli_runner() 方法返回一个命令运行器对象,我们创建类属性 self.runner 来保存它。通过对它调用 invoke() 方法可以执行命令,传入命令函数对象,或是使用 args 关键字直接给出命令参数列表。invoke() 方法返回的命令执行结果对象,它的 output 属性返回命令的输出信息。下面是我们为各个自定义命令编写的测试方法:

test_watchlist.py:测试自定义命令行命令

# 导入命令函数
from app import app, db, Movie, User, forge, initdb


class WatchlistTestCase(unittest.TestCase):
    # ...
    # 测试虚拟数据
    def test_forge_command(self):
        result = self.runner.invoke(forge)
        self.assertIn('Done.', result.output)
        self.assertNotEqual(Movie.query.count(), 0)

    # 测试初始化数据库
    def test_initdb_command(self):
        result = self.runner.invoke(initdb)
        self.assertIn('Initialized database.', result.output)

    # 测试生成管理员账户
    def test_admin_command(self):
        db.drop_all()
        db.create_all()
        result = self.runner.invoke(args=['admin', '--username', 'grey', '--password', '123'])
        self.assertIn('Creating user...', result.output)
        self.assertIn('Done.', result.output)
        self.assertEqual(User.query.count(), 1)
        self.assertEqual(User.query.first().username, 'grey')
        self.assertTrue(User.query.first().validate_password('123'))

    # 测试更新管理员账户
    def test_admin_command_update(self):
        # 使用 args 参数给出完整的命令参数列表
        result = self.runner.invoke(args=['admin', '--username', 'peter', '--password', '456'])
        self.assertIn('Updating user...', result.output)
        self.assertIn('Done.', result.output)
        self.assertEqual(User.query.count(), 1)
        self.assertEqual(User.query.first().username, 'peter')
        self.assertTrue(User.query.first().validate_password('456'))

在这几个测试中,大部分的断言是在检查执行命令后的数据库数据是否发生了正确的变化,或是判断命令行输出(result.output)是否包含预期的字符。

运行测试

最后,我们在程序结尾添加下面的代码:

if __name__ == '__main__':
    unittest.main()

使用下面的命令执行测试:

$ python test_watchlist.py
...............
----------------------------------------------------------------------
Ran 15 tests in 2.942s

OK

如果测试出错,你会看到详细的错误信息,进而可以有针对性的修复对应的程序代码,或是调整测试方法。

测试覆盖率

为了让让程序更加强壮,你可以添加更多、更完善的测试。那么,如何才能知道程序里有哪些代码还没有被测试?整体的测试覆盖率情况如何?我们可以使用 Coverage.py 来检查测试覆盖率,首先安装它(添加 --dev 参数将它作为开发依赖安装):

$ pipenv install coverage --dev

使用下面的命令执行测试并检查测试覆盖率:

$ coverage run --source=app test_watchlist.py

因为我们只需要检查程序脚本 app.py 的测试覆盖率,所以使用 --source 选项来指定要检查的模块或包。

最后使用下面的命令查看覆盖率报告:

$ coverage report
Name     Stmts   Miss  Cover
----------------------------
app.py     146      5    97%

从上面的表格可以看出,一共有 146 行代码,没测试到的代码有 5 行,测试覆盖率为 97%。

你还可以使用 coverage html 命令获取详细的 HTML 格式的覆盖率报告,它会在当前目录生成一个 htmlcov 文件夹,打开其中的 index.html 即可查看覆盖率报告。点击文件名可以看到具体的代码覆盖情况,如下图所示:

覆盖率报告

同时在 .gitignore 文件后追加下面两行,忽略掉生成的覆盖率报告文件:

htmlcov/
.coverage

本章小结

通过测试后,我们就可以准备上线程序了。结束前,让我们提交代码:

$ git add .
$ git commit -m "Add unit test with unittest"
$ git push

提示 你可以在 GitHub 上查看本书示例程序的对应 commit:66dc487

进阶提示

我可能不会回复

从 2017 年以来,我处理了大量的技术提问。尤其是 Flask 书出版后,更多的提问从各个渠道向我涌来。有时,我甚至觉得自己就像是一个在各个平台开了很多工单的客服。对于提问者来说,你只是在向某人提个问题;而对我来说,则是今天又多了一个要处理的提问。这段时间经常是一天中的一半时间都在处理提问。这不是个好现象,也该是改变的时候了。我并没有成为全职答题家的想法,我也需要时间学习新技术、休息、工作挣钱(虽然还没有工作……)。

为了回归正常的生活,我做了一个决定:从现在开始,所有通过电子邮件、微信、QQ(及群内@我)、Telegram(及群组内@我)、Twitter、知乎私信、文章评论(和文章本身无关的提问)、博客留言板、豆邮等渠道发来的技术提问我可能不会回复。(从明天起,做一个冷漠的人……)

除了书籍勘误以外,所有的技术问题,如果自己搜索相关信息并尝试后仍然无法解决,请在 HelloFlask 论坛发帖子提出(发帖前请阅读论坛帮助了解提问规则),我有时间会看,但不一定回复。(如果因为网络问题无法访问 HelloFlask 论坛,那就在 GitHub 上的 镜像仓库创建 issue)

有两个原因促使我做出这个决定:

1. 处理糟糕的提问浪费了大量的时间和精力。在这些提问里,除了少部分给我带来一些启发外,大部分都是一些简单的马虎问题,比如依赖没有安装、虚拟环境没有激活、环境变量没有设置、函数名写错、语法错误等等。大部分提问都是寥寥几句,既没有相关代码,也没有完整的错误回溯信息或命令行输出。所有相关内容都需要你一句一句的追问,看到新提供的一小块截图,你还要再次追问没截到的那部分内容。而且,因为大多数提问者热衷于用截图提供信息,如果你想在网上搜索相关内容,或是执行他的代码进行测试,还得手动把截图里的内容打出来。

还有些干脆是 XY 问题

  1. 有人遇到了问题 X。
  2. 他觉得问题 Y 可能是解决问题 X 的方法。
  3. 于是他去问别人问题 Y 怎么解决。
  4. 别人费劲力气浪费了大量时间教他处理问题 Y 后,才发现他只是想处理问题 X,而问题 X 和问题 Y 基本没什么关系。

举个简单的例子:

  1. 某个人买了一只鸡(这只鸡是公鸡,但是他不知道),发现他的鸡不下蛋。因为他不知道他的鸡是公鸡,所有真正要解决的问题 X 是“我的鸡是公鸡还是母鸡?”。
  2. 他错误的把问题 X 想成了问题 Y——”我的鸡得了不下蛋的病,应该怎么治?“
  3. 于是他上网发了一个帖子,求助问题 Y。
  4. 别人详细询问了鸡的健康状态,最近的饮食情况,居住环境等信息,并给出了各种治疗建议。在浪费了其他人大量时间后,他上传了鸡的照片……

除此之外,最让人崩溃的情况是这样的,在你像个侦探一样,进行了一系列追问,思考研究了半天之后,对方突然发过来“哦,我知道了,XXX 没有安装”。还有一些,在你认真思考,搜索了相关内容,最后给出回复之后却没有回音了。

2. 这些回答除了解决提问者的问题外,没有再产生更大的价值。因为大部分交流都被封闭在某个特定的程序内(一对一的交流无法被其他人看到,而 IM 群组里的消息检索起来非常麻烦),不能在互联网上搜索到,所以也无法帮到后续遇到同样问题的人。

更重要的是,IM 和 IM 群组(QQ 群、Telegram 群组、微信群)是非常糟糕的技术问题讨论工具,我甚至觉得 IM 只适合用来闲聊灌水。IM 本身的特性塑造了很多人的交流和思考方式,让人变得不会认真组织语言:喜欢发零散的多条信息,而不是一条长消息;面对多个人发来的多条回复时,没法专注思考其中的某一条。这些都让交流的效率变得非常低。在 IM 群组里投入任何有价值的信息都只会在短时间内发挥很少的价值,不久就会被扔到聊天记录里。而且在这些不支持代码高亮和预格式化的工具里交流技术问题是非常痛苦的体验。

对于这两个问题,最终的解决方案就是 HelloFlask 论坛。很早就想创建一个论坛,这些提问让我终于下定决心做这件事。一方面,论坛是最佳的技术问题讨论工具,论坛编辑器对代码的支持很好,每个问题作为单独的主题更方便讨论,而且可以被其他人方便的检索到。在此之前,我曾尝试将 GitHub Issue 作为问题讨论工具,但参与的人很少。有了论坛后,我就可以拒绝来自其他工具和网站的提问,让提问者到论坛发帖。另一方面,论坛可以吸引更多兴趣相投的朋友加入,大家可以一起讨论和解决问题,这样就不会让我一个人超负荷。

至于让提问者学会提问,还有很长的路要走。

我的书终于重印了

盼了几个月,《Flask Web 开发实战》终于重印了。为什么这么期待重印?当然是因为重印可以修正书中的错误!假如你经常写博客的话,你可以想象一下这样的场景,你在昨天发布的一篇文章里发现大量的错误,但是却没法更新文章,而你的读者还在不停的找出更多的错误……而面对一本已经付印的书,除了感到愧疚,努力整理勘误,告诉读者阅读勘误外,你什么也做不了。这种感觉真是太糟糕了!

这次重印的 1-2 版本主要有下面这些变化:

  • 修正了 1-1 版本 11 月前已知的所有勘误。
  • 更新前言开始介绍 Flask 时的 GitHub 仓库 stars 和 watchers 数量。
  • 前言最后添加了初稿被删掉的后记(稍作修改)。
  • 在前言、附录、封底的作者简介下面添加了勘误页面网址。
  • 完善了多处内容,包括为新版本 Pipenv 和 PyCharm 的变化添加说明,去掉附录中过时的项目等,具体见可改进实现文件
  • 修正了 42 处断行连字符错误。
  • 比 1-1 的封面更好看,封面文字的颜色变淡了,纸张变白了。

尽管修正了这么多的错误,1-2 版本的勘误表仍然还有不少内容。不过,我相信,如果还有下一次重印,那么大部分错误都将得到修正。希望这一天早点来到!

顺便说一句,据出版社编辑的消息,电子书文件已经相应更新,如果没有接收到推送,可以联系各平台客服手动更新文件。

出版社寄了两本 1-2 版本的样书,稍后会在知乎专栏 Hello, Flask! 送出,也算是稍稍填补一下我的愧疚。

HelloFlask 论坛上线

很早的时候就想弄一个 Flask 论坛,但一直没有时间,最近终于下定决定完成这件事。

简单对比了目前比较流行的论坛程序,发现 Flarum 的界面最符合我的审美,功能也不差。不过安装的时候花了很大功夫,各种出错,最后实在不想继续下去。何况 Flarum 目前还没有发布稳定版,可以再等一等,最终决定先用 Discourse。安装 Discourse 的过程倒是很顺利,大部分时间都花在了安装完成后的设置和主题调整上。

这个 HelloFlask 论坛目前已经开始试运行,网址是 discuss.helloflask.com,欢迎对 Flask、Python、Web 开发等话题感兴趣的朋友加入。

它将会用来替代下面这些程序和群组的大部分功能:

  • HelloFlask Google+ 群组
  • HelloFlask QQ 群
  • HelloFlask Telegram 群组
  • HelloFlask GitHub 仓库
  • Flask-China GitHub 仓库

如果你遇到了自己无法解决的技术问题,请在这个论坛发帖子,而不是直接通过邮件、私信等途径联系我。

Tips:

  • 注册时请尽量不要使用 QQ 邮箱(验证邮件会被拦截,需要手动到“收信记录”里取回),建议使用 GitHub 登录。
  • 因为部署在 DigitalOcean 上,如果无法访问可以尝试使用 VPN。

博客改版

可以自由控制博客的样式,大概是独立博客最显著的优点之一。个人博客像是自己的房子,你拆墙打洞,想怎么弄都行,而其他社交网站和博客平台则像是精装修的出租屋,一切都是房东说了算。昨天花了很长时间给博客动了一次大手术。一直觉得传统博客的两栏布局很多余,边栏在所有页面都会显示,干扰正常的阅读。而主边栏包含文章摘要,每页只能显示很少的文章,阅读起来也不够方便。单从阅读的角度看,单栏布局+全部文章列表才是最合理的设计。

这次的调整对博客各个方面都进行了简化,尽可能多的去掉页面上的噪音(干扰信息):

  • 去掉所有页面的边栏。
  • 随着边栏一起去掉的还有边栏上的“Unfortunately not Flask powered”徽章、站点统计信息、日历挂件、文章分类、月度归档、标签和近期文章。
  • 因为没有了边栏,除了文章正文页面,外部不再有分类、标签和月度归档的入口,搜索框放到了博客页面底部。
  • 去掉了背景图片。
  • 博客主页使用年度归档文章列表,跟摘要说再见了。
  • 所有插件的语言改为中文。
  • 去掉导航栏的想法分类,以后短想法也采用标准文章格式。
  • 页面宽度缩减到 860px。 优化了评论框的宽度,修复了响应式问题。
  • 简化了相关文章扩展的样式,文章标题去掉粗体显示;提示文字使用 `<p>`,而不是 `<h3>`,上方添加一个分割线。

这次调整后,未来大概很长一段时间里都不会再有大变化了,除非是更换博客引擎(以前考虑过换用静态博客引擎,但是因为旧文章不方便迁移,所以迟迟没有动手)。未来考虑会在细节上再进行一些优化:

  • 去掉“相关文章”插件。
  • 去掉“图片弹窗”插件。
  • 去掉 Google 统计的代码,似乎对我没什么用。
  • 个人主页使用和博客页面统一的样式。

2018 年总结

和去年一样,又为写书花掉了一整年,这一年最大的感受就是累。上半年因为拖稿很焦虑,写完后又因为内容写太多了(700 页),审稿审到想吐,这也导致成书有不少笔误……出版后轻松不少,但生活开始充满了各种各样的杂事:推广新书,整理源码,打击盗版,回复读者提问,整理勘误,更新网站。几个月里,我的角色不停的在销售专员、客服、售后、技术顾问之间换来换去,以至于都快忘了我只能拿到 9% 的版税。而到手的一万多稿费,买了新的电脑和手机后就花的差不多了。反正我是再也不会写那么厚的书了,我发誓。

话虽这么说,我倒挺喜欢这种生活方式,有种做手工艺人的感觉,而且书写完了会有很大的满足感和成就感。

成果

这一年大概有下面这些值得记录的成果:

时间花销

这一年仍然没有进行时间统计,大概的时间花销如下:

  • 1~5 月:写作,最终在五月底定稿
  • 6 月:完善书相关的项目源码、追加多次书稿修改
  • 7 月:部署 5 个项目的在线 Demo、上线网站 helloflask.com
  • 8月:为书发售做准备、写多篇文章
  • 9 月:书发售、写文章、推广书、送书
  • 10 月:写文章、处理盗版、整理勘误、回复读者提问
  • 11 月:准备并参加 PyCon、处理盗版、整理勘误、回复读者提问
  • 12 月:做外包项目、写《Flask 入门教程》、处理盗版、整理勘误、回复读者提问

其他

和去年一样,除了翻过几本技术书外,今年几乎没有看什么书。电影的话,留下印象的只有最近去看的龙猫和无双,而音乐一直在重复旧的播放列表。这一年运动也严重不足,长时间使用电脑导致手腕和手指不太舒服(翻了下统计数据,上半年每天鼠标点击的次数在 2000~4000 左右,击键次数在 15000~30000 左右),期待意念键盘和鼠标早日发明出来。因为写作占用了大量时间,技术进步并不大,学习的内容也都局限在 Flask 和 Web 两个领域,明年需要扩展学习的范围。

2019 年做什么?大概是恢复正常的生活节奏,清理掉过度收集的信息,再学一点新东西(或许再写本书 :p)。

慎用 OneTab 扩展

新年第一天就发生了一件让人沮丧的事情,在电脑和浏览器都没有出现异常的情况下,OneTab(一个用来管理浏览器标签的扩展)保存的 3000 多个标签页突然全都不见了。这个问题也许和最近的一次 Chrome 自动更新有关(没错,就是那个界面非常难看而且不允许恢复旧样式的版本)。

在网上搜索后才发现有那么多人有同样的经历,在 Reddit 和 Stack Overflow 上有大量的帖子在讨论如何恢复丢失的标签数据。而在 Chrome 商店也可以看到很多一星评价,它们大都是这样开头的:“I love this extension until I lost all my saved tabs suddenly…”。

真是荒唐,一个用来保存标签页的工具,却没有提供一个可靠的存储机制。从大量评论来看,维护者是不负责任的,从来没有回复过反馈信息,官方文档也没有任何相关说明。OneTab 将数据存储在浏览器的本地存储中,文件位置大概在 AppData\Local\Google\Chrome\User Data\Default\Local Storage\ 目录下,而浏览器有可能会重写这里的文件。旧版本使用 SQLite 存储数据,新版本换成了 LevelDB,数据保存在 leveldb 目录下。或许可以尝试从这个目录下的文件导出数据,但暂时不清楚是否还存储了其他扩展的数据,尝试了几个工具,没能打开文件。因为不想为它浪费更多的时间,就此作罢。

这个扩展不值得信任,对于正在使用这个扩展的朋友,建议每次存档标签页以后都手动导出到本地文件进行备份或改用其他同类工具(比如 Tab OutlinerQlearly)。扩展本身提供了导出的功能,但导出的数据为纯文本,会丢失时间戳、分组名称、锁定和加星状态,也有人建议将 OneTab 页面作为网页保存来备份,或是使用网盘同步 leveldb 目录。

虽然是件坏事,但也有积极的一面。保存了几千个待读的标签页本身就说明了处理信息方式的不合理。这些“觉得有用但是目前没有时间处理”的标签页,在过一段时间后重新来看,大部分都变得没用了,不会再去浏览。尽管我一再提醒自己尽快处理掉这些积攒的标签页,但是却迟迟没有动身,直到 OneTab “自我了结”。这次数据丢失可以看做一个改变信息处理方式的契机,我决定停用这个扩展,尝试养成每晚清空标签页的习惯,并试着建立一个更好的信息处理机制。

忽然想起来,在使用 OneTab 之前积攒的大量书签还没有处理,而 Pocket 里面还躺着几百个待读的网页……


Update 2019/10/23

换了新电脑后又开始继续用 OneTab 了(同时搭配 Tab Outliner),不过这次采取了备份措施,每次点完存档后都会手动导出标签页信息到网盘同步的文本文档里。打开大量标签页直到电脑死机、存档大量标签页但不再回顾处理的坏习惯还没有改掉。目前标签页数量 963。

Update 2020/11/24

弃用 OneTab 大概有半年了,目前在用 Tab Outliner,免费版也支持备份数据到 Google 硬盘(不过需要手动备份)。同时也开始定期清理用不到的标签页,不过此前用 OneTab 备份在 TXT 文件里等待整理的几千个标签页也许再也不会打开了。

《Flask 入门教程》第 7 章:表单

在 HTML 页面里,我们需要编写表单来获取用户输入。一个典型的表单如下所示:

<form method="post">  <!-- 指定提交方法为 POST -->
    <label for="name">名字</label>
    <input type="text" name="name" id="name"><br>  <!-- 文本输入框 -->
    <label for="occupation">职业</label>
    <input type="text" name="occupation" id="occupation"><br>  <!-- 文本输入框 -->
    <input type="submit" name="submit" value="登录">  <!-- 提交按钮 -->
</form>

编写表单的 HTML 代码有下面几点需要注意:

  • 在 <form> 标签里使用 method 属性将提交表单数据的 HTTP 请求方法指定为 POST。如果不指定,则会默认使用 GET 方法,这会将表单数据通过 URL 提交,容易导致数据泄露,而且不适用于包含大量数据的情况。
  • <input> 元素必须要指定 name 属性,否则无法提交数据,在服务器端,我们也需要通过这个 name 属性值来获取对应字段的数据。

提示 填写输入框标签文字的 <label> 元素不是必须的,只是为了辅助鼠标用户。当使用鼠标点击标签文字时,会自动激活对应的输入框,这对复选框来说比较有用。for 属性填入要绑定的 <input> 元素的 id 属性值。

创建新条目

创建新条目可以放到一个新的页面来实现,也可以直接在主页实现。这里我们采用后者,首先在主页模板里添加一个表单:

templates/index.html:添加创建新条目表单

<p>{{ movies|length }} Titles</p>
<form method="post">
    Name <input type="text" name="title" autocomplete="off" required>
    Year <input type="text" name="year" autocomplete="off" required>
    <input class="btn" type="submit" name="submit" value="Add">
</form>

在这两个输入字段中,autocomplete 属性设为 off 来关闭自动完成(按下输入框不显示历史输入记录);另外还添加了 required 标志属性,如果用户没有输入内容就按下了提交按钮,浏览器会显示错误提示。

两个输入框和提交按钮相关的 CSS 定义如下:

/* 覆盖某些浏览器对 input 元素定义的字体 */
input[type=submit] {
    font-family: inherit;
}

input[type=text] {
    border: 1px solid #ddd;
}

input[name=year] {
    width: 50px;
}

.btn {
    font-size: 12px;
    padding: 3px 5px;
    text-decoration: none;
    cursor: pointer;
    background-color: white;
    color: black;
    border: 1px solid #555555;
    border-radius: 5px;
}

.btn:hover {
    text-decoration: none;
    background-color: black;
    color: white;
    border: 1px solid black;
}

接下来,我们需要考虑如何获取提交的表单数据。

处理表单数据

默认情况下,当表单中的提交按钮被按下,浏览器会创建一个新的请求,默认发往当前 URL(在 <form> 元素使用 action 属性可以自定义目标 URL)。

因为我们在模板里为表单定义了 POST 方法,当你输入数据,按下提交按钮,一个携带输入信息的 POST 请求会发往根地址。接着,你会看到一个 405 Method Not Allowed 错误提示。这是因为处理根地址请求的 index 视图默认只接受 GET 请求。

提示 在 HTTP 中,GET 和 POST 是两种最常见的请求方法,其中 GET 请求用来获取资源,而 POST 则用来创建 / 更新资源。我们访问一个链接时会发送 GET 请求,而提交表单通常会发送 POST 请求。

为了能够处理 POST 请求,我们需要修改一下视图函数:

@app.route('/', methods=['GET', 'POST'])

在 app.route() 装饰器里,我们可以用 methods 关键字传递一个包含 HTTP 方法字符串的列表,表示这个视图函数处理哪种方法类型的请求。默认只接受 GET 请求,上面的写法表示同时接受 GET 和 POST 请求。

两种方法的请求有不同的处理逻辑:对于 GET 请求,返回渲染后的页面;对于 POST 请求,则获取提交的表单数据并保存。为了在函数内加以区分,我们添加一个 if 判断:

app.py:创建电影条目

from flask import request, url_for, redirect, flash

# ...

@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'POST':  # 判断是否是 POST 请求
        # 获取表单数据
        title = request.form.get('title')  # 传入表单对应输入字段的 name 值
        year = request.form.get('year')
        # 验证数据
        if not title or not year or len(year) > 4 or len(title) > 60:
            flash('Invalid input.')  # 显示错误提示
            return redirect(url_for('index'))  # 重定向回主页
        # 保存表单数据到数据库
        movie = Movie(title=title, year=year)  # 创建记录
        db.session.add(movie)  # 添加到数据库会话
        db.session.commit()  # 提交数据库会话
        flash('Item Created.')  # 显示成功创建的提示
        return redirect(url_for('index'))  # 重定向回主页

    user = User.query.first()
    movies = Movie.query.all()
    return render_template('index.html', user=user, movies=movies)

在 if 语句内,我们编写了处理表单数据的代码,其中涉及 3 个新的知识点,下面来一一了解。

请求对象

Flask 会在请求触发后把请求信息放到 request 对象里,你可以从 flask 包导入它:

from flask import request

因为它在请求触发时才会包含数据,所以你只能在视图函数内部调用它。它包含请求相关的所有信息,比如请求的路径(request.path)、请求的方法(request.method)、表单数据(request.form)、查询字符串(request.args)等等。

在上面的 if 语句中,我们首先通过 request.method 的值来判断请求方法。在 if 语句内,我们通过 request.form 来获取表单数据。request.form 是一个特殊的字典,用表单字段的 name 属性值可以获取用户填入的对应数据:

if request.method == 'POST':
    title = request.form.get('title')
    year = request.form.get('year')

flash 消息

在用户执行某些动作后,我们通常在页面上显示一个提示消息。最简单的实现就是在视图函数里定义一个包含消息内容的变量,传入模板,然后在模板里渲染显示它。因为这个需求很常用,Flask 内置了相关的函数。其中 flash() 函数用来在视图函数里向模板传递提示消息,get_flashed_messages() 函数则用来在模板中获取提示消息。

flash() 的用法很简单,首先从 flask 包导入 flash 函数:

from flask import flash

然后在视图函数里调用,传入要显示的消息内容:

flash('Item Created.')

flash() 函数在内部会把消息存储到 Flask 提供的 session 对象里。session 用来在请求间存储数据,它会把数据签名后存储到浏览器的 Cookie 中,所以我们需要设置签名所需的密钥:

app.config['SECRET_KEY'] = 'dev'  # 等同于 app.secret_key = 'dev'

提示 这个密钥的值在开发时可以随便设置。基于安全的考虑,在部署时应该设置为随机字符,且不应该明文写在代码里, 在部署章节会详细介绍。

下面在基模板(base.html)里使用 get_flashed_messages() 函数获取提示消息并显示:

<!-- 插入到页面标题上方 -->
{% for message in get_flashed_messages() %}
	<div class="alert">{{ message }}</div>
{% endfor %}
<h2>...</h2>

alert 类为提示消息增加样式:

.alert {
    position: relative;
    padding: 7px;
    margin: 7px 0;
    border: 1px solid transparent;
    color: #004085;
    background-color: #cce5ff;
    border-color: #b8daff;
    border-radius: 5px;
}

通过在 <input> 元素内添加 required 属性实现的验证(客户端验证)并不完全可靠,我们还要在服务器端追加验证:

if not title or not year or len(year) > 4 or len(title) > 60:
    flash('Invalid input.')  # 显示错误提示
    return redirect(url_for('index'))
# ...
flash('Item Created.')  # 显示成功创建的提示

提示 在真实世界里,你会进行更严苛的验证,比如对数据去除首尾的空格。一般情况下,我们会使用第三方库(比如 WTForms)来实现表单数据的验证工作。

如果输入的某个数据为空,或是长度不符合要求,就显示错误提示“Invalid input.”,否则显示成功创建的提示“Item Created.”。

重定向响应

重定向响应是一类特殊的响应,它会返回一个新的 URL,浏览器在接受到这样的响应后会向这个新 URL 再次发起一个新的请求。Flask 提供了 redirect() 函数来快捷生成这种响应,传入重定向的目标 URL 作为参数,比如 redirect('http://helloflask.com')

根据验证情况,我们发送不同的提示消息,最后都把页面重定向到主页,这里的主页 URL 均使用 url_for() 函数生成:

if not title or not year or len(year) > 4 or len(title) > 60:
    flash('Invalid title or year!')  
    return redirect(url_for('index'))  # 重定向回主页
flash('Movie Created!')
return redirect(url_for('index'))  # 重定向回主页

编辑条目

编辑的实现和创建类似,我们先创建一个用于显示编辑页面和处理编辑表单提交请求的视图函数:

app.py:编辑电影条目

@app.route('/movie/edit/<int:movie_id>', methods=['GET', 'POST'])
def edit(movie_id):
    movie = Movie.query.get_or_404(movie_id)

    if request.method == 'POST':  # 处理编辑表单的提交请求
        title = request.form['title']
        year = request.form['year']
        
        if not title or not year or len(year) > 4 or len(title) > 60:
            flash('Invalid input.')
            return redirect(url_for('edit', movie_id=movie_id))  # 重定向回对应的编辑页面
        
        movie.title = title  # 更新标题
        movie.year = year  # 更新年份
        db.session.commit()  # 提交数据库会话
        flash('Item Updated.')
        return redirect(url_for('index'))  # 重定向回主页
    
    return render_template('edit.html', movie=movie)  # 传入被编辑的电影记录

这个视图函数的 URL 规则有一些特殊,如果你还有印象的话,我们在第 2 章的《实验时间》部分曾介绍过这种 URL 规则,其中的 <int:movie_id> 部分表示 URL 变量,而 int 则是将变量转换成整型的 URL 变量转换器。在生成这个视图的 URL 时,我们也需要传入对应的变量,比如 url_for('edit', movie_id=2) 会生成 /movie/edit/2。

movie_id 变量是电影条目记录在数据库中的主键值,这个值用来在视图函数里查询到对应的电影记录。查询的时候,我们使用了 get_or_404() 方法,它会返回对应主键的记录,如果没有找到,则返回 404 错误响应。

为什么要在最后把电影记录传入模板?既然我们要编辑某个条目,那么必然要在输入框里提前把对应的数据放进去,以便于进行更新。在模板里,通过表单 <input> 元素的 value 属性即可将它们提前写到输入框里。完整的编辑页面模板如下所示:

templates/edit.html:编辑页面模板

{% extends 'base.html' %}

{% block content %}
<h3>Edit item</h3>
<form method="post">
    Name <input type="text" name="title" autocomplete="off" required value="{{ movie.title }}">
    Year <input type="text" name="year" autocomplete="off" required value="{{ movie.year }}">
    <input class="btn" type="submit" name="submit" value="Update">
</form>
{% endblock %}

最后在主页每一个电影条目右侧都添加一个指向该条目编辑页面的链接:

index.html:编辑电影条目的链接

<span class="float-right">
    <a class="btn" href="{{ url_for('edit', movie_id=movie.id) }}">Edit</a>
    ...
</span>

点击某一个电影条目的编辑按钮打开的编辑页面如下图所示:

编辑电影条目

删除条目

因为不涉及数据的传递,删除条目的实现更加简单。首先创建一个视图函数执行删除操作,如下所示:

app.py:删除电影条目

@app.route('/movie/delete/<int:movie_id>', methods=['POST'])  # 限定只接受 POST 请求
def delete(movie_id):
    movie = Movie.query.get_or_404(movie_id)  # 获取电影记录
    db.session.delete(movie)  # 删除对应的记录
    db.session.commit()  # 提交数据库会话
    flash('Item Deleted.')
    return redirect(url_for('index'))  # 重定向回主页

为了安全的考虑,我们一般会使用 POST 请求来提交删除请求,也就是使用表单来实现(而不是创建删除链接):

index.html:删除电影条目表单

<span class="float-right">
    ...
    <form class="inline-form" method="post" action="{{ url_for('delete', movie_id=movie.id) }}">
        <input class="btn" type="submit" name="delete" value="Delete" onclick="return confirm('Are you sure?')">
    </form>
    ...
</span>

为了让表单中的删除按钮和旁边的编辑链接排成一行,我们为表单元素添加了下面的 CSS 定义:

.inline-form {
    display: inline;
}

最终的程序主页如下图所示:

添加表单和操作按钮后的主页

本章小结

本章我们完成了程序的主要功能:添加、编辑和删除电影条目。结束前,让我们提交代码:

$ git add .
$ git commit -m "Create, edit and delete item by form"
$ git push

提示 你可以在 GitHub 上查看本书示例程序的对应 commit:84e766f。在后续的 commit 里,我们为另外两个常见的 HTTP 错误:400(Bad Request) 和 500(Internal Server Error) 错误编写了错误处理函数和对应的模板,前者会在请求格式不符要求时返回,后者则会在程序内部出现任意错误时返回(关闭调试模式的情况下)。

进阶提示

  • 从上面的代码可以看出,手动验证表单数据既麻烦又不可靠。对于复杂的程序,我们一般会使用集成了 WTForms 的扩展 Flask-WTF 来简化表单处理。通过编写表单类,定义表单字段和验证器,它可以自动生成表单对应的 HTML 代码,并在表单提交时验证表单数据,返回对应的错误消息。更重要的,它还内置了 CSRF(跨站请求伪造) 保护功能。你可以阅读 Flask-WTF 文档和 Hello, Flask! 专栏上的表单系列文章了解具体用法。
  • CSRF 是一种常见的攻击手段。以我们的删除表单为例,某恶意网站的页面中内嵌了一段代码,访问时会自动发送一个删除某个电影条目的 POST 请求到我们的程序。如果我们访问了这个恶意网站,就会导致电影条目被删除,因为我们的程序没法分辨请求发自哪里。解决方法通常是在表单里添加一个包含随机字符串的隐藏字段,在提交时通过对比这个字段的值来判断是否是用户自己发送的请求。在我们的程序中没有实现 CSRF 保护。
  • 使用 Flask-WTF 时,表单类在模板中的渲染代码基本相同,你可以编写宏来渲染表单字段。如果你使用 Bootstap,那么扩展 Bootstrap-Flask 内置了多个表单相关的宏,可以简化渲染工作。
  • 你可以把删除按钮的行内 JavaScript 代码改为事件监听函数,写到单独的 JavaScript 文件里。
  • 《Flask Web 开发实战》第 4 章介绍了表单处理的各个方面,包括表单类的编写和渲染、错误消息显示、自定义错误消息语言、文件和多文件上传、富文本编辑器等等。
  • 本书主页 & 相关资源索引:http://helloflask.com/tutorial