有很多初学者因为受各种文章的吹捧而痴迷「前后端分离」这个词,刚开始学后端 Web 框架就想知道怎么实现前后端分离。就我目前有限的经验来看,前后端分离并没有那么重要,只是另一种程序架构(开发模式)而已。和传统的后端渲染的 Web 程序相比,并没有明显的优劣之分,两种方式都各有优缺点,也各有适合的场景。
没必要过分追捧任何一种语言、框架、工具、操作系统……都了解都能用最好,工具永远没有要做的事情重要。没有能胜任所有工作的完美工具,更多时候是按需选用最合适的工具。
有很多初学者因为受各种文章的吹捧而痴迷「前后端分离」这个词,刚开始学后端 Web 框架就想知道怎么实现前后端分离。就我目前有限的经验来看,前后端分离并没有那么重要,只是另一种程序架构(开发模式)而已。和传统的后端渲染的 Web 程序相比,并没有明显的优劣之分,两种方式都各有优缺点,也各有适合的场景。
没必要过分追捧任何一种语言、框架、工具、操作系统……都了解都能用最好,工具永远没有要做的事情重要。没有能胜任所有工作的完美工具,更多时候是按需选用最合适的工具。
Jinja2 有三种定界符语法:
{{ ... }} 用来标记变量;{% ... %} 用来标记语句;{# ... #} 用来标记注释;如果你同时使用了 JavaScript 模板引擎,而该 JavaScript 模板引擎也使用相同的语法标记符,那就会产生冲突。一般来说,有下面三种兼容性处理方式:
第一种方式最直观,使用 Jinja2 的 raw 标签声明原生代码块,也就是不需要进行后端渲染的代码块。使用 raw 和 endraw 标签把 JavaScript 模板部分标记出来即可,比如:
{% raw %}
<div id="app">
{{ js_var }}
</div>
{% endraw %}
这种方式的副作用最少,尽管需要多几行代码,但不会影响你写 Jinja2 或其他 JavaScript 库的语法习惯。
第二种方式是修改 Jinja2 的语法定界符号,一般只修改变量定界符即可,其他的按需修改。具体通过修改程序实例的下面几个属性来实现:
from flask import Flask
app = Flask(__name__)
app.jinja_env.block_start_string = '(%' # 修改块开始符号
app.jinja_env.block_end_string = '%)' # 修改块结束符号
app.jinja_env.variable_start_string = '((' # 修改变量开始符号
app.jinja_env.variable_end_string = '))' # 修改变量结束符号
app.jinja_env.comment_start_string = '(#' # 修改注释开始符号
app.jinja_env.comment_end_string = '#)' # 修改注释结束符号
第三种方式是修改 JavaScript 模板的语法定界符号,具体方法因 JavaScript 模板/框架而异,可以参见相关文档了解。以 Vuejs 为例,下面将模板定界符改为中括号:
var app = new Vue({
el: "#app",
delimiters: ["[[", "]]"],
data: {
message: "Hello Vue!"
}
})
如果你觉得使用 raw 标签太麻烦,而修改语法定界符又不习惯,这里还有一个折中方法:两边都使用双花括号作为定界符,但根据花括号内部是否添加空格来进行区分。
具体来说,对 Jinja2 变量使用 Jinja2 标准语法,也就是使用 {{ 作为变量开始符号,注意花括号右侧有一个空格,结束符号类似,需要在花括号左侧加入一个空格,即 }}。实际示例如下:
{{ jinja_var }}
而 JavaScript 模板使用没有空格的双花括号,即:
{{js_var}}
这是一种更适合心细的懒人的方法,如果是团队项目,可能会对不习惯这种用法的人造成困扰,记得在文档里注明。这种方式只需要修改 Jinja2 定界符:
app.jinja_env.variable_start_string = '{{ '
app.jinja_env.variable_end_string = ' }}'
另外需要注意的是,如果你使用了其他 Flask 扩展的内置 Jinja2 模板或宏,需要确保它们都使用了包含空格的标准 Jinja2 语法。举例来说,用来方便集成 Bootstrap 的 Flask-Bootstrap 就没法使用,需要使用替代的 Bootstrap-Flask。其他扩展,比如 Flask-Admin,Flask-Security 暂未测试,欢迎了解的同学反馈兼容情况。
本文隶属于《Flask Web 开发实战》番外系列。这篇文章会介绍如何在 Flask 项目中集成 Celery。
第一步是创建一个 Celery 程序实例。因为 Flask 程序实例通常会命名为 app,为了避免冲突,我们一般会把 Celery 实例命名为 celery 或 celery_app:
from celery import Celery
celery = Celery(__name__, broker='pyamqp://guest@localhost//')
@celery.task
def add(x, y):
return x + y
大多数教程,包括目前的 Flask 文档里都会介绍用下面的方式加载配置:
celery.conf.update(app.config) # 这里的 app 是 Flask 程序实例
也就是把 Celery 配置和 Flask 配置写在一起,然后从 Flask 程序实例的配置字典里更新配置。
但问题是,Celery 从 4.0 开始启用新的小写配置名,某些配置被新的名称替换。虽然旧的大写配置仍然支持,但如果你打算使用小写配置名,或是打算在未来进行迁移,这里的配置加载方式就会失效,因为 Flask 在从文件或对象导入配置时只会导入大写形式的配置变量。
因此,我建议将 Celery 配置写在单独的文件里,不要和 Flask 配置混在一起。按照 Celery 文档的示例,你可以在当前目录创建一个 celeryconfig.py 文件(或是其他名字)保存配置:
broker_url = 'pyamqp://'
result_backend = 'rpc://'
task_serializer = 'json'
result_serializer = 'json'
accept_content = ['json']
timezone = 'Europe/Oslo'
enable_utc = True
然后使用下面的方法加载配置(使用其他模块名,或是在其他路径时,记得修改传入的字符串):
celery.config_from_object('celeryconfig')
如果需要在创建 Celery 实例时传入 broker 和 backend 配置,可以直接写出或是从配置模块中导入:
from celeryconfig import broker_url
celery = Celery(__name__, broker=broker_url)
你可以单独创建 Celery 程序,但我们通常会需要为它添加 Flask 程序上下文支持,因为有时候你的 Celery 任务函数会用到依赖于 Flask 程序上下文的某些变量。
下面我们为 Celery 创建了一个工厂函数,在工厂函数中创建 Celery 实例,加载配置,并实现 Flask 程序上下文支持:
from flask import Flask
from celery import Celery
from celeryconfig import broker_url
def make_celery(app):
celery = Celery(__name__, broker=broker_url)
celery.config_from_object('celeryconfig')
class ContextTask(celery.Task):
def __call__(self, *args, **kwargs):
with app.app_context():
return self.run(*args, **kwargs)
celery.Task = ContextTask
return celery
app = Flask(__name__)
celery = make_celery(app)
在定义 Celery 任务的模块里,比如 tasks.py,你可以导入这个 Celery 程序实例:
from app import celery
@celery.task
def add(x, y):
return x + y
当 Flask 程序也使用工厂函数创建时,我们可以全局创建 Celery 程序实例,然后在创建 Flask 程序实例的工厂函数里更新 Celery 程序配置并进行上下文设置:
from flask import Flask
from celery import Celery
from celeryconfig import broker_url
celery = Celery(__name__, broker=broker_url)
def create_app():
app = Flask(__name__)
register_celery(app)
return app
def register_celery(app):
celery.config_from_object('celeryconfig')
class ContextTask(celery.Task):
def __call__(self, *args, **kwargs):
with app.app_context():
return self.run(*args, **kwargs)
celery.Task = ContextTask
同样直接导入 Celery 实例并创建任务:
from app import celery
@celery.task
def add(x, y):
return x + y
这本来是一个完整的 Celery 入门教程,但因为去年的一次硬盘损坏,对应的示例程序弄丢了,暂时没有毅力重写一遍,所以这篇文章只抽取了其中和 Flask 相关的内容。
因为距离初稿写作的时间已经过去半年多,Celery 的最新版本也已经是 4.3.0,如果文中有什么疏漏,或是有更好的实现方式,欢迎评论指出。
SQLAlchemy 基于模型类对数据库表进行操作,所以,如果你想对已存在的数据库表进行操作,就要先为它编写对应的模型类。
对于简单的数据库,比如只有几张表,没有复杂的关系,表字段也很少,你可以直接对照表模式手写模型类。
其他情况下,使用自动化工具 SQLAcodegen / Flask-SQLAcodegen 自动生成模型类定义会更加方便,根据单独使用 SQALchemy 还是使用扩展 Flask-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 扩展生成模型类。
首先使用 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
不得不说,Discourse 的插件安装方式真是麻烦(免费的代价……)。官方的插件目录在 https://www.discourse.org/plugins 可以看到,插件介绍页面并没有介绍如何安装插件,只介绍了某个付费版本的套餐内置了这些插件。
这次一共安装了三个插件:
具体的安装方法可以在官方论坛的这个帖子看到。步骤并不复杂,下面是一个简化版本的安装介绍:
首先获取到你要安装的插件的 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
经过漫长的等待,安装就完成了。你可以在论坛的设置-插件页面对插件进行配置。
不论是使用 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 镜像源的方法:
临时设置可以通过 -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 文档配置部分查看。
类似 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 这些工具遵循 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/"
顺便提一下,使用镜像源需要注意一个问题:包的版本可能不会及时更新,遇到这种情况可以通过临时换回官方源解决。
当在 HTML 页面上显示图片时,如果图片不存在,我们通常需要显示一个默认图片。
假设我们的图片路径在 /imgs 下,默认图片为 /imgs/default.jpg,下面是一些常见的处理方法示例。
最简单的,你可以使用 <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'); // 替换为默认图片
});
除此之外,你也可以在服务器端处理,以 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) }}" >
在生产环境下,出于性能的考虑,我们通常会使用 Web 服务器(比如 Nginx / Apache) 来服务(serve)静态文件。以 Nginx 为例,你可以使用 try_files 指令来设置默认图片:
location /imgs/ {
try_files $uri /imgs/default.jpg;
}
附注:本文改写自我的SO 回答。
在 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)。
经过本书的学习,你应该有能力独立开发一个简单的 Web 程序了。所以这里有一个小挑战:为你的 Watchlist 添加一个留言板功能,效果类似 SayHello。
下面是一些编写提示:
如果在完成这个挑战的过程中遇到了困难,可以在 HelloFlask 论坛发起讨论(设置帖子分类为“Flask 入门教程”)。除此之外,你可以在后记查看更多讨论的去处。
在这个教程的最后一章,我们将会把程序部署到互联网上,让网络中的其他所有人都可以访问到。
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。
首先访问注册页面注册一个免费账户。注册时填入的用户名将作为你的程序域名的子域部分,以及分配给你的 Linux 用户名。比如,如果你的用户名为 greyli,最终为你分配的程序域名就是 http://greyli.pythonanywhere.com/ 。
注册完成后会有一个简单的教程,你可以跳过,也可以跟着了解一下基本用法。管理面板主页如下所示:
导航栏包含几个常用的链接,可以打开其他面板:
这些链接对应页面的某些功能也可以直接在管理面板主页打开。
我们需要先来创建一个 Web 程序,你可以点击导航栏的 Web 链接,或是主页上的“Open Web tab”按钮打开 Web 面板:
点击“Add a new web app”按钮创建 Web 程序,第一步提示升级账户后可以自定义域名,我们直接点击“Next”按钮跳到下一步:
这一步选择 Web 框架,为了获得更灵活的控制,选择手动设置(Manual configuration):
接着选择你想使用的 Python 版本:
最后点击“Next”按钮即可完成创建 Web 程序流程:
接下来我们需要进行一系列程序初始化操作,最后再回到 Web 面板进行具体的设置。
我们首先要考虑把代码上传到 PythonAnywhere 的服务器上。上传代码一般有两种方式:
因为我们的代码已经推送到 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 入门所需的基础知识,你还需要进一步学习。在后记中,你可以看到进一步学习的推荐读物。接下来,有一个挑战在等着你。
虽然我们的程序开发已经完成,但随着功能的增多,把所有代码放在 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。
在此之前,每次为程序添加了新功能,我们都要手动在浏览器里访问程序进行测试。除了测试新添加的功能,你还要确保旧的功能依然正常工作。在功能复杂的大型程序里,如果每次修改代码或添加新功能后手动测试所有功能,那会产生很大的工作量。另一方面,手动测试并不可靠,重复进行测试操作也很枯燥。
基于这些原因,为程序编写自动化测试就变得非常重要。
注意 为了便于介绍,本书统一在这里介绍关于测试的内容。在实际的项目开发中,你应该在开发每一个功能后立刻编写相应的测试,确保测试通过后再开发下一个功能。
单元测试指对程序中的函数等独立单元编写的测试,它是自动化测试最主要的形式。这一章我们将会使用 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!') 来判断返回值内容是否符合预期。如果断言方法出错,就表示该测试方法未通过。
下面是一些常用的断言方法:
这些方法的作用从方法名称上基本可以得知。
假设我们把上面的测试代码保存到 test_sayhello.py 文件中,通过执行 python test_sayhello.py 命令即可执行所有测试,并输出测试的结果、通过情况、总耗时等信息。
回到我们的程序,我们在项目根目录创建一个 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。