分类目录归档:计算机与编程

为已存在的数据库生成 SQLAlchemy / Flask-SQLAlchemy 模型类

SQLAlchemy 基于模型类对数据库表进行操作,所以,如果你想对已存在的数据库表进行操作,就要先为它编写对应的模型类。

对于简单的数据库,比如只有几张表,没有复杂的关系,表字段也很少,你可以直接对照表模式手写模型类。

其他情况下,使用自动化工具 SQLAcodegen / Flask-SQLAcodegen 自动生成模型类定义会更加方便,根据单独使用 SQALchemy 还是使用扩展 Flask-SQLAlchemy,你可以选择阅读对应的章节。

单独使用 SQLAlchemy

首先使用 pip 安装:

$ pip install sqlacodegen

执行下面的命令将模型类输出到 models.py 文件里(将覆盖目标文件原内容):

$ sqlacodegen --outfile models.py sqlite:///database.db

这个命令的格式如下:

$ sqlacodegen --outfile <输出的文件名> <数据库连接 URI>

–outfile / -o 选项设置输出的目标文件,不给出这个选项将直接在命令行输出生成的模型类定义,比如:

$ sqlacodegen sqlite:///data.db
# coding: utf-8
from sqlalchemy import Column, DateTime, Integer, String
from sqlalchemy.ext.declarative import declarative_base


Base = declarative_base()
metadata = Base.metadata


class Message(Base):
    __tablename__ = 'message'

    id = Column(Integer, primary_key=True)
    name = Column(String(20))
    body = Column(String(200))
    timestamp = Column(DateTime, index=True)

提示 如上所示,生成的模型类定义会包含 Base 和 metadata 对象定义和相关导入语句,你或许需要进行细微的调整。

你可以使用下面的命令查看更多可用的设置选项:

$ sqlacodegen --help

提示 除了使用 SQLAcodegen,你也可以直接使用内置的 Automap 扩展生成模型类。

使用 Flask-SQLAlchemy

首先使用 pip 安装 Flask-SQLAcodegen:

$ pip install flask-sqlacodegen

执行下面的命令将模型类输出到 models.py 文件里(将覆盖目标文件原内容):

$ flask-sqlacodegen --flask --outfile models.py sqlite:///database.db

这个命令的格式如下:

$ flask-sqlacodegen --flask --outfile <输出的文件名> <数据库连接 URI>

–flask 选项设置输出 Flask-SQLAlchemy 模型类,不给出这个选项将直接输出 SQLAlchemy 原生模型类定义;–outfile 选项设置输出的目标文件,不给出这个选项将直接在命令行输出生成的模型类定义,比如:

$ flask-sqlacodegen --flask sqlite:///data.db
# coding: utf-8
from sqlalchemy import Column, DateTime, Integer, String
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

class Message(db.Model):
    __tablename__ = 'message'

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(20))
    body = db.Column(db.String(200))
    timestamp = db.Column(db.DateTime, index=True)

提示 如上所示,生成的模型类定义会包含一个 db 对象定义和相关导入语句,你或许需要进行细微的调整。

你可以使用下面的命令查看更多可用的设置选项:

$ flask-sqlacodegen --help

程序员的基本常识——学习篇

这个系列是《软技能》一书的摘抄整理,详细介绍见该系列首篇文章《程序员的基本常识——职业篇》。我会提取书中的有用信息,重新组织,并补充一些想法。欢迎评论补充相关信息,或是纠正错误观点。

对于心智成长,大部分情况下学校和家庭都在帮倒忙,你通常只能靠阅读来完成自我教育。在编程世界里情况类似。你也许会在学校里学习计算机基础知识,但对于大量的编程语言、框架和工具,你通常都需要自学。自学、解决问题和正确提问是程序员必须掌握的几个「元能力」。

1. 学习怎样学习

  • 我们几乎都是下意识地倾向于学习自己感兴趣的东西。主动学习是效率更高的方式。
  • 学习知识最好的方式就是立即将其用于实践。如果关于某个主题你能获得足够的知识,你就可以发挥自己心灵深处强大的创造力和好奇心。当我们能够在一件事情上尽情发挥的时候,我们的内心就倾向于吸收更多的信息,思考更有意义的问题。这种自然的探索和好奇心能够帮助我们轻而易举的记住更多的信息。
  • 一旦你已经实践过,并积累了各式各样的问题,立刻回到书本当中,你会有强烈的冲动去消化吸收其中的内容。
  • 然后,你可以把自己学到的新知识重新应用于实践。已解决实践过程中发现的问题为目标,在向着只是前进的道路上重复这个循环。
  • 最后,你可以通过写作文章或是教给其他人来加深理解。
  • 这个过程可以归纳为「学习——实践——掌握——教授」。

以学习某个 Web 框架来说,从头到尾读完一本相关的技术书,并不会让你掌握这个框架。只有在了解基础知识后,自己动手借助书中的知识编写一个 Web 程序,并尝试尽可能多的解决实际问题,才会让你学会这个框架。

2. 十步学习法

十步学习法的基本思想是先对自己要学的东西有个基本的了解。至少了解以下三个要点:

  • 如何开始:明确你要掌握哪些前导知识,从哪些资料开始最为合适。
  • 学科范围:确定你的学习方向和大致的学习范围。在开始阶段,你不需要了解每一个细节,只需大致了解学科的轮廓。
  • 基础知识:了解最基本的知识点是哪些,知道自己学的哪 20% 就能满足 80% 的日常应用。

1~6 步:前期调研,收集资料,制定学习计划(这些步骤只做一次)

  • 第 1 步:了解全局

了解自己将要学习的主题的全局,建立一个基本的概念。

  • 第 2 步:确定范围

明确自己到底要学什么,有一个可控的学习范围,而不是试图解决太大的问题。让自己的关注点落脚到更小也更可控的范围,根据你的时间预算,尝试把一个大的主题分解为多个可控的子主题。比如,「学习 HTML」可以进一步细化为「学习 HTML 的基本语法,了解页面基本结构和常用的元素」。

  • 第 3 步:定义目标

明确对你来说,学到何种地步,达成何种目标后算是完成学习。好的成功标准应该是具体的、无二义性的。以学习 HTML 为例,「学会用 HTML 编写网页」就是坏的成功标准,而「可以使用 HTML 创建一个内容丰富、语法标准、样式美观并且支持响应式的个人主页」就是一个好的成功标准。

  • 第 4 步:寻找资源

对于学习编程来说,你可以在网上找到大量的资源(这里的资源包括书、教程、视频、代码等)。试着在 Google、YouTube、亚马逊、豆瓣读书或其他网站搜索相关关键字,然后根据描述或评论选择资源。尽量选择系统性的教程或书。对于编程语言、框架和工具来说,官方入门教程一般都不会太差。

  • 第 5 步:创建学习计划

学习计划就是你的学习路径,通常你可以根据相关主题的技术书的目录来制定学习路径。好的技术书都遵循这样的规律:打好基础,做好铺垫,然后逐个展开每一章的论述。通过对比几本同类型的书籍目录,制定专属于你的学习路径。

  • 第 6 步:筛选资源

根据你的学习计划,去掉重复、无用、过时、难度不适宜的资源。只留下 1~2 个品质最高的资源即可。

7~10 步:学习——实践——掌握——教授(循环往复)

  • 第 7 步:开始学习,浅尝辄止

获取刚好足够开始动手实践的基础知识。大多数人在学习过程中通常会犯两类错误:第一类错误是在知之不多的情况下就盲目开始,即行动太早;第二类错误是在行动之前准备过多,即行动太晚。

  • 第 8 步:动手操作,边玩边学

通过动手实践验证你在前一步骤学习的知识,并记录下新的疑问。比如,做一个简单的小项目。

  • 第 9 步:全面掌握,学以致用

根据你掌握的资源,解决你在实践中记录下的大量问题。同时深入了解各个主题。比如,阅读你要学习的语言、框架的源码。

  • 第 10 步:乐为人师,融会贯通

要想确定你确实掌握了某些知识,最好的方式就是把自己学到的知识教给别人。你可以在 Stack Overflow、论坛、IM 群组里回答问题,也可以写文章或录视频。把自己所学教给别人是查漏补缺的好办法,这同时也会增强你的表达能力和理解能力。

3. 寻找导师

  • 在你的软件开发生涯中,拥有一位导师可以说是一笔巨大的财富,因为一位优秀的导师能够让你无需亲身经历现实的重重考验就拥有丰富的经验,从而更高效的掌握某种技术。
  • 我们通常会犯这样的错误——根据他人的生活来判读其是否具有帮助我们的能力。然而,最好的老师往往深藏不露。你不应该因为某人在自己的生活中成就平平,或者看起来不过如此就对他的印象大打折扣。
  • 你可以找一位已经成功实现你想要做的事情的人,也可以找一位曾经帮别人实现了你现在想要做到的事情的人。
  • 导师未必一定得是非常厉害的专家,只比你领先一步的人也值得学习。
  • 你可以在本地的开发小组或是网上的论坛等地方寻找你的导师,也可以在你的公司或交际圈里寻找,或者通过付费向行家请教。
  • 优秀的书籍也可以做你的导师。
  • 在寻找导师之前,你必须要明确两件事,第一是你需要导师帮你解决什么问题;第二是为了能够换取导师的帮助,你能给他提供什么?

4. 乐为人师

  • 成为导师可以让你受益无穷。
  • 每个人都有教的能力:只要在某些地方快人一步,就能帮助别人。
  • 有时候你给比人做导师真正要做的就是给予关注。我们在生活中都需要别人的帮助以看到自己看不到的东西,因为当遇到涉及自身的问题和麻烦的时候,我们都会有些目光短浅。
  • 教授是学习的最佳途径之一。当你担任导师的时候,你会以全新的视角观察和思考,并面对更多有挑战的问题。在探究答案的过程中,你会愈加深入思考,发现自己的漏洞,甚至完全改变最初的想法。
  • 一旦我们试着向别人解释某件事情的运作原理或背后的原因的时候,我们在认知上的漏洞就会暴露出来。
  • 最好的教学方式就是以谦虚的视角来观察问题,以权威的口吻去诠释问题。你要明白,你教的目的是为了帮助别人,而不是为了证明自己的优越性或者寻求认可。
  • 除了写博客,录视频和演讲也是很好的教学方式。演讲时要心态谦卑,信心满满(而不是傲慢自大)。

5. 学历

  • 拥有(好的)学历并不是成功所必须的,但它限制了可以提供给你的职位数量,并且某种程度上也限制了你的晋升。
  • 学历教育可以提供一个相对完善的计算机基础教育。当然,这些知识你也可以通过自学获得,不过这需要花费大量的时间。
  • 如果你没有(好的)学历,那么就通过作品来证明你的能力。比如,你可以写博客,独立开发一个程序,在 GitHub 上创建或参与开源项目,或是在 Stack Overflow 回答问题。
  • 弥补措施是,你可以通过自考获得一个学位,大概需要花费两年的业余时间外加两千块的投入。

6. 知识短板

  • 专注于自身强项,这没什么不妥,但有时候,如果弱点得不到解决,通常会成为你的职业或生活的桎梏。
  • 准确识别短板的最佳方式之一就是看看自己在哪些工作上花费了大量的时间,或者一直进行重复性劳动。比如,学习操作系统或各类软件的快捷键就可以节省大量时间;编写宏或自动化脚本来简化各种软件或系统中的重复操作。
  • 维护一份清单,列出自己需要去研究或者不清楚的事物。

本文首发于公众号「李辉的代码厨房」。

为 Discourse 安装插件

不得不说,Discourse 的插件安装方式真是麻烦(免费的代价……)。官方的插件目录在 https://www.discourse.org/plugins 可以看到,插件介绍页面并没有介绍如何安装插件,只介绍了某个付费版本的套餐内置了这些插件。

这次一共安装了三个插件:

  • Sloved:因为 HelloFlask 论坛越来越像一个工单处理系统,所以能够标记问题已解决的 Solved 插件会很有用。
  • Sitemap:生成站点地图。
  • Canned Replies:能够预先保存某些经常发布的回复(staff only)。因为太多人发帖时论坛说明也不看,markdown 也不会用,写得乱七八糟,每次都要一一提醒,所以这个插件可以节省不必要的体力开支。

具体的安装方法可以在官方论坛的这个帖子看到。步骤并不复杂,下面是一个简化版本的安装介绍:

首先获取到你要安装的插件的 GitHub 或 BitBucket 仓库地址,官方插件可以在 Discourse 的 GitHub 组织中找到。这里以投票插件为例,它的仓库地址为 https://github.com/discourse/discourse-voting。

1. 打开配置文件

登录到你的服务器,执行下面的命令,编辑配置文件 app.yml:

$ cd /var/discourse
$ nano containers/app.yml

2. 插入插件的仓库地址

在这个文件中,找到 hooks 一节,把插件地址写到 `- git clone https://github.com/discourse/docker_manager.git` 一行的下面,注意在地址前添加 `- git clone`:

hooks: 
 after_code: 
    - exec: 
       cd: $home/plugins 
       cmd: 
         - git clone https://github.com/discourse/docker_manager.git 
         - git clone https://github.com/discourse/discourse-voting.git

3. 重新构建容器

使用下面的命令重新构建容器(确保没有改变当前目录,即 `/var/discourse`):

./launcher rebuild app

经过漫长的等待,安装就完成了。你可以在论坛的设置-插件页面对插件进行配置。

从国内的 PyPI 镜像(源)安装 Python 包

不论是使用 pip,还是 Pipenv、Poetry等工具,安装 Python 包的时候会默认从官方的 PyPI 源下载文件,速度比较慢。国内的一些公司和机构提供了 PyPI 镜像源(mirror source),你可以设置让这些工具从国内的镜像源安装 Python 包,以便提高下载速度。

官方 PyPI 源的 URL 为 https://pypi.org/simple (旧的 URL 为 https://pypi.python.org/simple ),下面我们将以豆瓣提供的镜像源为例(URL 为 https://pypi.doubanio.com/simple/),介绍不同工具更改 PyPI 镜像源的方法:

pip

临时设置可以通过 -i 选项:

$ pip install -i https://pypi.doubanio.com/simple/ flask

全局设置有不同的层级和文件位置,以用户全局(per-user)为例,在 Linux & macOS 中,配置需要写到 ~/.pip/pip.conf 文件中;Windows 中,配置文件位置为 %HOMEPATH%\pip\pip.ini,%HOMEPATH% 即你的用户文件夹,一般为“\Users\<你的用户名>”,具体值可以使用 echo %HOMEPATH% 命令查看。

通常你需要手动创建对应的目录和文件,然后写入下面的内容:

[global]
index-url = https://pypi.doubanio.com/simple
[install]
trusted-host = pypi.doubanio.com

附注:按照 pip 文档,上面的配置文件位置是旧(legacy)的配置方式,但是因为比较方便设置,这里沿用了。新的建议是 Linux & macOS 放到 $HOME/.config/pip/pip.conf,Windows 则放到 %APPDATA%\pip\pip.ini。具体可以访问 pip 文档配置部分查看。

Pipenv

类似 pip 的 -i (–index-url)选项,你可以使用 –pypi-mirror 临时设置镜像源地址:

$ pipenv install --pypi-mirror https://pypi.doubanio.com/simple flask

如果想对项目全局(per-project)设置,可以修改 Pipfile 中 [[source]] 小节:

[[source]]

url = "https://pypi.doubanio.com/simple"
verify_ssl = true
name = "douban"

另外一种方式是使用环境变量 PIPENV_PYPI_MIRROR 设置(Windows 系统使用 set 命令):

$ export PIPENV_PYPI_MIRROR=https://pypi.doubanio.com/simple

你可以通过把这个环境变量的设置语句写入到终端的配置文件里实现“永久”设置,Linux & macOS 可参考这里,Windows 可参考这里

Poetry / Flit

因为 Poetry,Flit 这些工具遵循 PEP 518 创建了一个 pyproject.toml 文件来替代 setup.py、Pipfile 等配置文件,所以我们可以在这个文件里更改 PyPI 源。

使用 Poetry 时,在 pyproject.toml 末尾添加下面的内容来设置自定义镜像源:

[[tool.poetry.source]]
name = "douban"
url = "https://pypi.doubanio.com/simple/"

目前暂时没有其他方式,未来或许会为 poetry add 命令添加一个相关的设置选项。

同样的,Flit 大概要添加下面这些内容(未测试):

[[tool.flit.source]]
name = "douban"
url = "https://pypi.doubanio.com/simple/"

常用的国内 PyPI 镜像列表

  • 豆瓣 https://pypi.doubanio.com/simple/
  • 网易 https://mirrors.163.com/pypi/simple/
  • 阿里云 https://mirrors.aliyun.com/pypi/simple/
  • 清华大学 https://pypi.tuna.tsinghua.edu.cn/simple/

顺便提一下,使用镜像源需要注意一个问题:包的版本可能不会及时更新,遇到这种情况可以通过临时换回官方源解决。

(使用 HTML、JavaScript、Flask 或 Nginx)为丢失的图片显示默认图片

当在 HTML 页面上显示图片时,如果图片不存在,我们通常需要显示一个默认图片。

假设我们的图片路径在 /imgs 下,默认图片为 /imgs/default.jpg,下面是一些常见的处理方法示例。

Solution 1: HTML / JavaScript

最简单的,你可以使用 <img> 元素的 onerror 属性来设置默认图片:

<img src="/imgs/cat.jpg" onerror="this.src='/imgs/default.jpg'">

类似的,你也可以使用 JavaScript(jQuery)监听 img 元素的 error 事件(当图片地址无效时浏览器会触发这个事件):

$('img').on("error", function() {
  $(this).attr('src', '/imgs/default.jpg');  // 替换为默认图片
});

Solution 2: Flask

除此之外,你也可以在服务器端处理,以 Flask 为例,你可以写一个自定义视图函数来加载图片:

import os
from flask import send_from_directory

# ...

@app.route('/img/<path:filename>')
def get_image(filename):
    img_path = os.path.join(images_path, filename)  # 获取图片路径

    if not os.path.exists(img_path):  # 判断图片文件是否存在
        return send_from_directory(os.path.join(images_path, '/imgs/default.jpg'))
    return send_from_directory(img_path)

在模板里,使用这个视图函数获取图片 URL:

<img src="{{ url_for('get_image', filename='imgs/' + image.filename) }}" >

Solution 3: Nginx

在生产环境下,出于性能的考虑,我们通常会使用 Web 服务器(比如 Nginx / Apache) 来服务(serve)静态文件。以 Nginx 为例,你可以使用 try_files 指令来设置默认图片:

location /imgs/ {
    try_files $uri /imgs/default.jpg;
}

附注:本文改写自我的SO 回答

在 Flask-Admin 中集成富文本编辑器 CKEditor

Flask-Admin 里,默认使用普通的文本区域(<textarea>)来编辑长文本。借助 Flask-CKEditor,你可以很容易的为 Flask-Admin 集成富文本编辑器 CKEditor

首先安装 Flask-CKEditor:

$ pip install flask-ckeditor

下面是一个简单的例子,其中的关键步骤已用注释标出:

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView
from flask_ckeditor import CKEditor, CKEditorField  # 导入扩展类 CKEditor 和 字段类 CKEditorField

app = Flask(__name__)
app.config['SECRET_KEY'] = 'dev'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///'

db = SQLAlchemy(app)
ckeditor = CKEditor(app)  # 初始化扩展

class Post(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(120))
    text = db.Column(db.Text)


# 自定义 Post 模型
class PostAdmin(ModelView):
    form_overrides = dict(text=CKEditorField)  # 重写表单字段,将 text 字段设为 CKEditorField
    create_template = 'edit.html'  # 指定创建记录的模板
    edit_template = 'edit.html'  # 指定编辑记录的模板

admin = Admin(app, name='Flask-CKEditor demo')
admin.add_view(PostAdmin(Post, db.session))

if __name__ == '__main__':
    app.run(debug=True)

在模板文件夹里,我们创建一个 edit.html 文件(templates/edit.html),在这个文件里重载 Flask-Admin 的编辑模板,加载 CKEditor 资源:

{% extends 'admin/model/edit.html' %} <!-- 声明继承 Flask-Admin 的模型编辑模板 -->

{% block tail %} <!-- 向父模板的 tail 块内追加内容 -->
    {{ super() }}
    {{ ckeditor.load() }} <!-- 加载 CKEditor 的 JavaScript 文件,默认从 CDN 获取 -->
{% endblock %}

实际的效果如下图所示:

完整的可运行的示例程序代码可以在这里获取到。

你可以阅读 Flask-Admin 文档Flask-CKEditor 文档了解更多进阶用法。

附注:本文改写自我在 Stack Overflow 上的回答(https://stackoverflow.com/a/46481343/5511849)。

《Flask 入门教程》后记

恭喜,你已经完成了整个 Flask 入门教程。不出意外的话,你也编写了你的第一个 Web 程序,并把它部署到了互联网上。这是一件值得纪念的事,它可以作为你的编程学习之路上的一个小小的里程碑。继续加油!

留言 & 打卡

如果你完成了这个教程,可以在 HelloFlask 论坛上的这个帖子留言打卡,欢迎分享你的心得体会和经验总结。如果你对这本书有什么建议,也可以在这里进行留言反馈。

讨论与求助

如果你想和其他同学交流 Flask、Web 开发等相关话题,或是在学习中遇到了问题,想要寻求帮助,下面是一些好去处:

付费支持

本书采取自愿付费原则,价格为 10 元。如果你愿意,可以通过付费来支持我,让我有更多的时间和动力写作 Flask 教程和文章。你可以通过支付宝账号 withlihui@gmail.com 转账,或是扫描下面的二维码付款。

支付宝二维码 / 微信二维码

qr_alipayqr_wechat

进阶阅读

说来惭愧,在这本教程几乎每一章的结尾,我都会提到《Flask Web 开发实战》,每次写到这里,我都觉得自己好像在写“问候家明”。所以,最合适的进阶读物我已经推荐过很多次了。除了这本书,其他的进阶读物如下:

未完待续

你喜欢这本书以及这本书的写作模式吗?

如果有足够的人喜欢的话,或许我会考虑写一本包含 Flask 进阶知识的《Flask 进阶教程》。按照设想,在这个进阶教程里,这个 Watchlist 程序变成一个支持多人注册和使用的简化版豆瓣电影 / IMDb。同时介绍各类常用扩展的使用和 Flask 上下文、蓝本等进阶知识。

再或者,这个教程或许会升级为一本完整的书,使用类似的编写模式,引入一个更加丰富有趣的程序,包含优化后的入门知识和 Flask 进阶内容。

如果你期待这样一本进阶教程 / 书的出现,欢迎让我知道。你可以在打卡 & 留言贴发布留言,或是直接发邮件(withlihui@gmail.com)告诉我。

《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

进阶提示