flask 开发

使用 python3,linux

Flask 简介

准备

创建应用目录

git clone 现成的 git 仓库

1
2
3
git clone https://github.com/miguelgrinberg/flasky.git
cd flasky
git checkout 1a

或者新建目录

1
2
mkdir flasky
cd flasky

准备虚拟环境

使用venv的虚拟环境可以运行独立的解释器,安装独立的包。

1
2
3
apt install python3-venv
# 在flasky目录下
python3 -m venv venv

激活虚拟环境

1
source venv/bin/active

完成虚拟环境中的工作后使用 deactivate 取消激活

安装 flask

在虚拟环境中安装flask

1
(venv) $ pip install flask

应用的基本结构

初始化

1
2
from flask import Flask
app = Flask(__name__)

初始化 flask 应用需要创建一个 Flask类的对象,这个对象的初始化需要一个参数,即模块的__name

web服务器通过WSGI(web server gateway interface) 协议把接收到的请求交给flask应用实例处理。

路由和视图函数

客户端(例如浏览器)把请求发送给web服务器,服务器再把请求发给Flask应用实例,应用实例需要知道每个URL对应哪些代码,所以需要URL与python函数的映射关系,称为路由。。

定义路由的最简单方式是使用app.route装饰器

1
2
3
@app.route('/')
def index():
return '<h1>Hello,world!</h1>'

也可以使用app.add_url_rule()方法

1
2
3
def index():
return '<h1>Hello,world!</h1>'
app.add_url_rule('/','index',index)

index()称为视图函数,客户端访问/时会触发index函数,返回值称为响应,是客户端看到的内容。

Flask 的 URL中可以使用动态内容

1
2
3
@app.route('/user/<name>')
def user(name):
return 'Hello,{}!'.format(name)

URL中的动态部分(由app.route中的尖括号指定)会作为参数传入视图函数,默认是字符串类型,可以使用其他类型,形如@app.route('/user/<int:id>'),可食用的过滤器有 string,,int,float 和 path。

完整应用实例

hello.py

1
2
3
4
5
6
from flask import Flask
app = Flask(__name__)

@app.route('/')
def index():
return '<h1>Hello,world!</h1>'

Web开发服务器

通过 flask run 命令可以启动 flask自带的开发服务器,启动前需要设置FLASK_APP环境变量

1
2
export FLASK_APP=hello.py
flask run

也可以使用app.run()方法启动,在上面flask实例最后加上

1
2
if __name__ == '__main__':
app.run()

开发服务器默认运行在5000端口

调试模式

启动调试模式后,开发服务器会加载重载器和调试器。重载器监测文件目录,发现改动时自动重启服务器;调试器在Web浏览器提供交互式追踪,也可以审查源码。

与上例对应,启动方式分两种

1
2
3
export FLASK_APP=hello.py
export FLASK_DEBUG=1
flask run

或者

1
app.run(debug=True)

命令行选项

1
flask --help

请求-响应循环

应用和请求上下文

收到请求时flask需要访问一些对象才能完成对请求的处理,这时要像视图函数传入参数。为了避免传入不必要的参数,flask使用上下文临时把某些对象设置为全局可访问,例如request对象封装了用户发送的HTTP请求:

1
2
3
4
@app.route('/')
def index():
user_agent = request.headers.get('User-Agent')
return '<p>Your browser is {}</p>'.format(user_agent)

上下文并不是全局变量,不同线程之间的访问是相互独立的。

Flask有请求上下文和应用上下文

变量 类型 说明
current_app 应用上下文 当前的应用实例
g 应用上下文 处理请求时用作临时存储的对象,每次请求都会重置这个对象
request 请求上下文 请求对象,封装了客户端发送的HTTP请求的内容
session 请求上下文 用户会话,一个字典

Flask 在分派请求之前激活上下文,请求处理完成后将其删除。获取上下文使用app.appcontext()方法。

请求分派

收到请求后,Flask 会在app.url_map中寻找处理URL对应使用的函数,

request对象

封装收到的HTTP请求,含有以下常用属性和方法

1
form, args, values, cookies, headers, get_data(), get_form(), blueprint,endpoint, method, scheme, is_secure(), host, path, Query_string, full_path, url, base_url, remote_addr, environ

请求钩子

注册请求狗子在请求之前或者之后使用,通过装饰器实现,有四种。

  • before_request,每次请求之前运行
  • before_first_request,在第一个请求之前运行
  • after_request,无异常抛出时在请求之后运行
  • teardown_request,即使有异常抛出也会在请求之后执行

请求钩子和视图函数之间一般使用 g 对象共享数据。

response对象

响应不但包括返回的字符串,还有请求头和HTTP状态码。Flask中有response对象可以处理返回的内容,包含以下常用属性和方法

1
status_code, headers, set_cookie(), delete_cookie(), content_length, content_type, set_data(), get_data()

redirect()函数专门用来生成重定向响应

1
2
3
@app.route('/index.php')
def indexphp():
redirect('/')

abort()函数用于处理错误

1
2
3
4
@app.route('/user/<name>')
def user():
if name != 'admin':
abort(403)

Flask 扩展

Flask本身没有许多重要功能,比如数据库和身份验证,这些可以通过Flask扩展实现。

模板

Jinja2 模板引擎

渲染模板

默认情况下,Flask在应用目录的 templates 文件夹下寻找模板

templates/index.html:

1
<h1>Hello World!</h1>

templates/user.html:

1
<h1>Hello, {{ name }}!</h1>

hello.py:

1
2
3
4
5
6
7
8
9
from flask import Flask, render_template

@app.route('/')
def index():
return render_template('index.html')

@app.route('/user/<name>')
def user(name):
return render_template('user.html', name=name)

变量

上面模板中使用的{{ name }}结构表示一个变量,这是一种特殊的占位符,告诉模板这个位置的值从渲染模板时提供的数据获取。Jinja2能识别许多类型的变量,包括一些复杂的变量,比如列表、字典和对象。

1
2
3
<p> From a dictionary: {{ adict['key'] }} <\p>
<p> From a list, with a variable index: {{ alist[variableint] }} <\p>
<p> From an object's method: {{ aobj.amethod() }} <\p>

变量的值可以用过滤器修改,形如

1
{{ variable|filter }}

常用过滤器:

过滤器 说明
safe 渲染时不转义(XSS)
captalize 首字母大写,其他小写
lower 全部转成小写
upper 全部转成大写|||
title 每个单词开头的字母大写
trim 去掉首尾的空格
striptags 去掉HTML标签

控制结构

Jinja2提供了控制结构改变渲染流程。

条件判断:

1
2
3
4
5
{% if user %}
Hello, {{ user }}!
{% else %}
Hello, Stranger!
{% endif %}

循环结构:

1
2
3
4
5
<ul>
{% for comment in comments %}
<li>{{ comment }}</li>
{% endfor %}
</ul>

Jinja2还支持宏,类似于函数:

1
2
3
4
5
6
7
8
{% macro render_comment(comment) %}
<li>{{ comment }}</li>
{% endmacro %}
<ul>
{% for comment in comments %}
{{ render_comment(comment) }}
{% endfor %}
</ul>

宏可以单独放在一个文件中,以便重复使用

1
2
3
4
5
6
{% import 'macros.html' as macros %}
<ul>
{% for comment in comments %}
{{ macros.render_comment(comment) }}
{% endfor %}
</ul>

需要多次重复使用的模板代码片段也可以写到单独的文件中,再引入其他模板

1
{% include 'common.html' %}

Jinja2支持模板继承,类似于类继承。首先,创建一个base.html的基模板

1
2
3
4
5
6
7
8
9
10
11
<html>
<head>
{% block head %}
<title>{% block title %}{% endblock %} - My Application</title>
{% endblock %}
</head>
<body>
{% block body %}
{% end block %}
</body>
</html>

基模板中定义的 block 可以在衍生模板中覆盖,本段定义了 head, title 和 body 区块,title包含在head中,衍生模板:

1
2
3
4
5
6
7
8
{% extends 'base.html' %}
{% block title %}Index{% endblock %}
{% block head %}
{{ super() }}
<style></style>
{% endblock %}
{% block body %}
{% endblock %}

extends 表明要从 base.html继承,基模板中的block被重新定义,被子模板中同名区块的内容替代,子模板中可以使用 super()引用基模板的内容。

使用Flask-Bootstrap集成Bootstrap

1
pip install flask-bootstrap
1
2
3
from flask_bootstrap import Bootstrap
# ..
bootstrap = Bootstrap(app)

初始化Bootstrap后,就可以使用包含Bootstrap文件的基模板,利用模板继承就可以使用。例如改写user.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{% extends "bootstrap/base.html" %}

{% block title %}Flasky{% endblock %}

{% block navbar %}
<div class="navbar navbar-inverse" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/">Flasky</a>
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li><a href="/">Home</a></li>
</ul>
</div>
</div>
</div>
{% endblock %}

{% block content %}
<div class="container">
<div class="page-header">
<h1>Hello, {{ name }}!</h1>
</div>
</div>
{% endblock %}

bootstrap/base.html中定义了很多区块

区块 说明
doc 整个HTML文档
html_attribs <html>标签的属性
html <html>标签中的内容
head <head>标签的内容
title <title>标签的内容
meta 一组<meta>标签
styles CSS声明
body_attribs <body>标签的属性
body <body>标签的内容
navbar 用户定义的导航栏
content 用户定义的页面内容
scripts 文档底部的JavaScript声明

很多区块里都有Bootstrap的自用内容,如果直接覆盖会产生问题,需要使用 super函数

1
2
3
4
{% block scripts %}
{{ super() }}
<script src='my.js'></script>
{% endblock %}

自定义错误界面

Flask有errorhandler装饰器用于处理错误,错误页面可以由继承模板得到

1
2
3
4
5
6
7
@app.errorhandler(404)
def page_not_found(e):
return render_template('404.html'),404

@app.errorhandler(500)
def internal_server_error(e):
return render_template('500.html'),500

模板可以多重继承,将上面的user.html的content和title区块的内容删除,命名为base.html,404.html只需继承base.html并覆盖这两个区块的内容即可生成自定义的错误页面。

链接

使用url_for()函数获得页面的链接,参数为视图函数名,动态URL需要传入关键字参数。例如:url_for(user, name='john', page=2, version=1)

静态文件

服务器默认从static文件夹下寻找所需要的静态文件,使用url_for('static', filename='css/style.css')获得链接。

1
2
3
4
5
{% block head %}
{{ super() }}
<link rel="shortcunt icon" href="{{ url+for('static', filename='favicon.ico') }}" type="image/x-icon">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
{% endblock %}

使用Flask-Moment本地化日期和时间

服务器需要统一时间单位,一般使用UTC。不过用户需要的是当地的时间。Flask-moment是一个flask扩展,能简化把Moment.js集成到Jinja2模板的过程。pip install flask-moment。初始化Moment:

1
2
from flask_moment import Moment
moment = Moment(app)

Flask-Moment依赖jQuery.js和Moment.js,引入Moment.js:

1
2
3
4
{% block scripts %}
{{ super() }}
{{ moment.include_moment() }}
{% endblock %}

添加一个datetime变量:

1
2
3
4
5
from datetime import datetime

@app.route('/')
def index():
return render_tempalate('index.html',currenttime=datetima.utcnow())

渲染currenttime,templates/index.html

1
2
<p>The local date and time is {{ cureent_time.format('LLL') }}.</p>
<p>That was {{ moment(current_time.fromNow(refresh=True)) }}</p>

这里的format('LLL')表示渲染的复杂度,从L到LLLL,还可以接受其他的格式说明符。fromNow渲染相对时间戳,会不断刷新。这个例子显示的是July 18, 2019 12:12 AMa few seconds ago,随着时间变化还会变成a minute agotwo mimutes ago等等。可以使用locale本地化,例如

1
{{ moments.locale('es') }}

表单

Flask本身可以处理表单,但可以使用flask-wtf进行简化。pip install flask-wtf

配置

flask-wtf无需再应用层初始化,但需要配置密钥用于防止会话被篡改。

1
2
app = Flask(__name__)
app.config['SECRET_KEY'] = 'hard to guess'

为了增强安全性,密钥通常不写在源代码中,而是保存在环境变量中。

表单类

使用Flask-wtf时,每个web表单都由一个继承自FlaskForm的类表示。这个类定义表单中的一组字段,每个字段都用对象来表示。字段对象可以附属一个或多个验证函数用于验证用户提交的数据是否有效。

一个例子,包含一个文本字段和提交按钮:

1
2
3
4
5
6
7
from flask_wtf import FlaskForm
from wtfroms import StringField, SubmitField
from wtforms,validators import DataRequired

class NameForm(FlaskForm):
name = StringField('What is your name?', validators=[DataRequired()])
submit = SubmitField('Submit')

这个表单中的字段定义为类变量,各个类变量的值时相对应字段类型的对象。这个例子中NameForm表单中有一个名为name的文本字段和一个名为submit的提交按钮。StringField类表示属性为type=”text”的HTML input元素。SubmitField类表示属性为type=”submit”的元素。字段构造函数的第一个参数是渲染HTML时使用的标注label。Validators指定一个由验证函数组成的列表,在用户提交数据之前进行验证。

渲染表单

在视图函数中通过form参数把一个NameForm实例传入模板,就可以生成一个HTML表单,可以传入关键字参数添加指定的属性,hidden_tag为表单添加CSRF token。

1
2
3
4
5
<form method="POST">
{{ form.hidden_tag() }}
{{ form.name.label }}{{ form.name(id='namefiedl') }}
{{ form.submit() }}
</form>

使用这样的方式渲染和美化表单工作量依然很大,所以应该尽量使用bootstrap的表单样式,上述表单可以用以下方式渲染,其中quick_form的参数为FlaskWTF表单对象:

1
2
{% import "bootstrap/wtf.html" %}
{{ wtf.quick_form(form) }}

一个完整示例(templates/index.html):

1
2
3
4
5
6
7
8
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Hello, {% if name %}{{ name }}{% else %}Stranger{% endif %}</h1>
{{ wtf.quick_form(form) }}
{% endblock %}

在视图函数中处理表单

视图函数需要渲染表单并且收集表单中的用户提交的数据。

1
2
3
4
5
6
7
8
@app.route('/', methods=['GET', 'POST'])
def index():
name = None
form = NameForm()
if form.validate_on_submit():
name = form.name.data
form.name.data = ''
return render_template('index.html', form=form, name=name)

重定向和用户会话

上个函数存在一个问题,输入名字并且提交表单后,刷新会收到浏览器的警告,是否重新提交数据。因为最后一个请求是POST,刷新后浏览器会重新发送请求。使用重定向作为POST请求的响应,浏览器会重新发送一个GET请求。

这个方法也有一个问题,用户已经提交了名字,然而一旦用户刷新,POST请求结束,用户提交的数据就不见了。因此应用需要保存输入的名字,这样重定向后的请求才能获得并使用这个名字。

应用把数据存储在用户会话中,用户会话是一种私有存储,每个连接到服务器的客户端都可以访问,属于请求上下文中的变量,名为session,像标准的Python字典一样操作。默认情况下,用户会话保存在客户端的cookie中,使用之前设置的密钥加密签名。

实现重定向和用户会话:

1
2
3
4
5
6
7
8
9
from flask import Flask, render_template, session, redirect, url_for

@app.route('/', methods=['GET', 'POST'])
def index():
form = NameForm()
if form.validate_on_submit():
session['name'] = form.name.data
return redirect(url_for('index'))
return render_template('index.html', form=form, name=session.get('name'))

闪现消息

请求完成后,有时需要让用户知道状态发生了改变,比如用户提交了错误的用户名或密码,服务器应该重新渲染表单,并且提示用户名或密码武侠,flash()函数提供这样的功能。

1
2
3
4
5
6
7
8
9
10
11
from flask import Flask, render_template, session, redirect, rul_for, flash
@app.route('/', methods=['GET', 'POST'])
def index():
form = NameForm()
if form.validate_on_sumit():
old_name = session.get('name')
if old_name is not None and old_name != form.name.data:
flash('Looks like you have changed your name')
session['name'] = form.name.data
return redirct(url_for('index'))
return render_template('index.html', form = form, name = session.get('name'))

只在视图函数中调用flash()并不能显示信息,模板必须对它进行渲染,可以在基膜版中渲染使得所有页面都能显示闪现信息。Flask为模板提供get_flashed_messages()用于获取和渲染信息。

1
2
3
4
5
6
7
8
9
{% block content %}
<div class="container">
{% for message in get_flashed_messages() %}
<div class="alert alert-warning"">
<button type="button" class="close" data-dismiss="alert">&times;</buttion>
{{ message }}
</div>
{% endfor %}
{% block page_content %}{% endblock %}

数据库

SQL数据库

关系型数据库把数据存储在表中,表为实体建模。表中的列数是固定的,行数是可变的。

NoSQL数据库

不符合以上关系模型的数据库统称为NoSQL数据库,一般使用集合代替表,使用文档代替记录。

Python数据库框架

大多数数据库引擎提供对应的python包,如果无法满足需求,还有一些数据库抽象层代码包可选,例如 SQL Alchemy 和MongoEngine。

使用 Flask-SQLAlchemy管理数据

Flask-SQLAlchemy是一个Flask扩展,简化了在Flask应用中使用SQLAlchemy的操作。pip install flask-sqlalchemy,数据库使用URL指定,几个例子:

数据库引擎 URL
Mysql mysql://username:password@hostname/database
Postgres postgresql://username:password@hostname/database
SQLite(Linux, MacOS) sqlite:////absolute/path/to/database
SQLite(Windows) sqlite:///c:/absolute/path/to/database

应用使用的数据库URL必须保存到Flask配置对象的SQLALCHEMY_DATABASE_URI键中。同时建议把SQLIALCHEMY_TRACK_MODIFICATIONS键设置为False,在不需要跟踪对象变化是降低内存消耗。初始化一个简单的SQLite数据库:

1
2
3
4
5
6
7
8
import os
from flask_sqlalchemy import SQLAlchemy

basedir = os.path.absopath(os.path.dirname(__file__))
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'data.sqlite')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)

db是SQLAlchemy类的实例,表示应用使用的数据库。

定义模型

模型表示应用使用的持久化实体。ORM中,模型一般是一个python类,其中的属性对应于数据库表中的列。定义Role和User模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Role(db.Model):
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True)
def __repr__(self):
return '<Role %>' % self.name

class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, index=True)
def __repr__(self):
return '<User %>' % self.username

Flask-SQLAlchemy要求每个模型都定义主键。

关系

定义关系:

1
2
3
4
5
6
7
class Role(db.Model):
# ...
users = db.relationship('User', backref='role')

class User(db.Model):
# ...
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))

数据库操作

创建/删除表

1
2
3
4
flask shell
from hello import db
db.drop_all()
db.create_all()

插入行

1
2
3
4
5
6
7
from hello import Role, User
admin_role = Role(name='Admin')
mod_role = Role(name='Moderator')
user_role = Role(name='User')
user_john = User(username='john', role=admin_role)
user_susan = User(username='susan', role=user_role)
user_david = User(username='david', role=user_role)

模型的构造函数接受的参数是使用关键字参数指定的模型属性初始值。role属性也可以使用,这是一对多关系的高级表示。新建对象是没有明确设定id属性,大多数数据库的主键由数据库自身管理。目前这些对象只存在于python中,没有写入数据库,所以id为None。

对数据库的改动通过数据库会话管理,在Flask-SQLAlchemy中,会话由db.session表示,准备把对象写入数据库之前,要首先将其添加到会话中。

1
2
3
4
5
6
db.session.add(admin_role)
db.session.add(mod_role)
db.session.add(user_role)
eb.session.add(user_john)
db.session.add(user_susan)
db.session.add(user_david)

或者可以简写为

1
db.session.add_all([admin_role, mod_role, user_role, user_john, user_susan, user_david])

为了把对象写入数据库,需要调用commit方法提交会话:

1
db.session.commit()

数据库会话能保证数据的以执行,提交操作如果在写入会话的过程中发生了错误,整个会话都会失败,数据库会话也可以回滚,调用db.session.rollback()后,添加到数据库会话中的所有对象都将还原到它在数据库中的状态。

修改行

在数据会话上调用add方法也能更新模型,把Admin角色重命名为Administrator:

1
2
3
admin_role.name = 'Administrator'
db.session.add(admin_role)
db.session.commit

删除行

1
2
db.session.delete(mod_role)
db.session.commit()

查询行

Flask-Alchemy为每个模型类提供了query对象,最基本的模型查询使用all方法取回所有记录:

1
2
3
4
Role.query.all()
# [<Role 'Administrator'>, <Role 'User'>]
User.query.all()
# [<User 'john'>, <User 'susan'>, <User 'david'>]

使用过滤器可以配置query对象以进行更精准的查询,查找角色为User的用户:

1
2
User.query.filter_by(role=user_role).all()
# [<User 'susan'>, <User 'david'>]

如果要查看SQLALlchemy为查询生成的SQL查询语句,只需要把query对象转换为字符串:

1
2
str(User.query.filter_be(role=user_role))
'SELECT users.id AS users_id, users.username AS users_username, users.role_id AS users_role_id \nFROM Users \nWHERE :param_1 = users.role_id'

r如果推出了shell会话,前面这些例子中创建的对象不会以python对象的形式存在,但在数据库中仍有相应的行。可以从数据库读取行,重新创建Python对象,如加载名为User的user_role:

1
user_role = Role.query.filter_by(name='User').first()

first方法返回第一个结果,如果没有则返回None。

filter_by等过滤器在query对象上调用,返回一个过滤后的query对象。

关系与查询的处理方式类似,下面这个例子分别从关系的两端查询用户和角色的一对多关系

1
2
3
4
5
users = user_role.users
users
# [<User 'susan'>, <User 'david'>
users[0].role
# <Role 'User'>

有个小问题,执行user_role.user时会隐式调用all()方法,返回一个列表,此时query对象时隐藏的,无法指定过滤器。可以加入lazy='dynamic'参数,禁止自动查询。

1
2
3
4
class Role(db.Model):
#...
users = db.realationship('User', backref='role', lazy='dynamic')
# ...

这样user.role.users会返回一个query对象,可以添加过滤器。

在视图函数中操作数据库

前一节的数据库操作可以直接在视图函数中进行,把用户输入的名字记录到数据库中(hello.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@app.route('/', methods=['GET', 'POST'])
def index():
form = NameForm()
if form.validate_on_submit()
user = User.query.filter_by(username=form.name.data).first()
if user is None:
user = User(username=form.name.data)
db.session.add(user)
db.session.commit()
session['known'] = False
else:
session['known'] = True
session['name'] = form.name.data
form.name.data = ''
return redirect(url_for('index'))
return render_template('index.html', form=form, name=session.get('name'), known=session.get('known', False))

为了正常运行,需要先在shell中创建数据库表。通过knowns可以对已知用户和新用户显示不同的内容(templates/index.html):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Hello, {% if name %}{{ name }}{% else %}Stranger{% endif %}</h1>
{% if not known %}
<p>Pleased to meet you!</p>
{% else %}
<p>Happy to see you again!</p>
{% endif %}
</div>
{{ wtf.quick_form(form) }}
{% endblock %}

集成python shell

每次启动会话都要导入数据库实例和模型,可以通过配置让flask shell命令自动导入这些对象。若想把对象添加到导入列表,必须使用app.shell_context_processor装饰器创建并注册一个shell上下文管理器:

1
2
3
@app.shell_context_processor
def make_shell_context():
return dict(db=db, User=User, Role=Role)

这个shell上下文处理器函数返回一个字典,包含数据库实例和模型,除了默认导入的app以外,flask shell命令将自动吧这些对象导入shell。

使用Flask-Migrate实现数据库迁移

开发过程中,有时需要修改数据库模型,而且修改之后要更新数据库。当数据库表不存在时,Flask-SQLAlchemy才会根据模型创建。因此,更新表需要删除旧表,但是会丢失所有数据。

更好的方法是使用数据库迁移框架,它可以追踪数据库模式的变化,然后以增量方式把变化应用到数据库中。SQLAlchemy开发人员编写了一个迁移框架名为Alembic,Flask由Flask-Migrate扩展。

创建迁移仓库

先安装Flask-Migrate,pip install flask-migrate

初始化:

1
2
3
4
from flask_migrate import Migrate

# ...
migrate = Migrate(app.db)

Flask-Migrate添加了 flask db命令和几个子命令,新项目中可以使用init子命令添加迁移支持

1
flask db init

这会创建 migrations 目录,所有迁移脚本存放在这里。

电子邮件

Flask-Mail插件提供电子邮件支持,需要时查阅。

大型应用的结构

Flask 并不强制开发者使用特定方式组织大型应用,以下介绍一种用包和模块组织大型应用的方式。

项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|-flasky/
|-app/
|-templates/
|-static/
|-main/
|-__init__.py
|-errors.py
|-forms.py
|-views.py
|-__init__.py
|-email.py
|-models.py
|-migrations/
|-tests/
|-__init__.py
|-test*.py
|-venv/
|-requirements.txt
|-config.py
|-flasky.py

说明:

  • app/ 保存Flask 应用

  • migrations/ 保存数据库迁移脚本

  • test/ 单元测试

  • venv/ Python虚拟环境

  • requirements.txt 列出所有依赖包,便于在其他计算机中重新生成i昂同的虚拟环境

  • config.py 存储配置

  • flasky.py 定义Flask应用实例,辅助管理应用

配置选项

除去之前 hello.py 中类似字典的 app.config对象之外,可以使用具有层次结构的配置类。config.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import os
basedir = os.path.abspath(os.path.dirname(__file__))


class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard to guess string'
MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.googlemail.com')
MAIL_PORT = int(os.environ.get('MAIL_PORT', '587'))
MAIL_USE_TLE = os.environ.get('MAIL_USE_TLS', 'tls').lower() in ['true', 'on', '1']
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
FLASKY_MAIL_SUBJECT_PREFIX = '[flasky]'
FLASKY_MAIL_SENDER = 'Flasky Admin <flasky@example.com>'
FLASKY_ADMIN = os.environ.get('FLASKY_ADMIN')
SQLALCHEMY_TRACK_MODIFICATIONS = False

@staticmethod
def init_app(app):
pass

class DevelpmentConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or 'sqlite:///' + os.path.join(basedir, 'data-dev.sqlite')


class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DAATABASE_URL') or 'sqlite://'


class ProductionConfig(Config):
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URI') or 'sqlite://' + os.path.join(basedir, 'data.sqlite')


config = {
'development': DevelopmentConfig,
'tessting': TestingConfig,
'production': ProductionConfig,

'default': DeevelopmentConfig
}

基类Config 包含通用配置,各个子类分别定义专用的配置。为了灵活和安全,多数配置都可以从环境变量中导入。千万不能把敏感信息写在纳入版本控制系统的配置文件中。Config及其子类可以定义 init_app()方法,其参数为应用实例。

应用包

应用包用于夫南方应用的所有代码,模板和静态文件,我们可以直接把这个包成为 app,也可以使用一个专属名称。templates 和 static 目录现在是应用包的一部分,数据库模型和电子邮件支持函数也要放到这个包中。

使用应用工厂函数

在单个文件开发应用有个很大的缺点:应用在全局作用域中创建,无法动态修改配置。解决办法是延迟创建实例,把创建过程移到可显式调用的 工厂函数中。不仅可以给脚本留出配置应用的时间,还能够创建多个应用实例,方便测试。工厂函数在 app 包的构造文件中定义。app/__init__.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from flask import Flask, render_template
from flask_botstrap import Bootstrap
from flask_mail import Mail
from flask_moment import Moment
from flassk_sqlalchemy import SQLAlchemy
from config import config

bootstrap = Bootstrap()
mail = Mail()
moment = Moment()
db = SQLAlchemy()

def create_app(config_name):
app = Flask(__name__)
app.config.from_object(config[onfig_name])
config[config_name].init_app(app)

bootstrap.init_app(app)
mail.init__app(app)
moment.init_app(app)
db.init_app(app)

# 添加路由和自定义错误页面

return app

构造文件导入了大多数正在使用的Flask扩展,由于尚未初始化所需的应用实例,所以创建扩展类时没有向构造函数传入参数,因此扩展并未初始化。create_app()时应用的工厂函数,接受一个参数,应用使用的配置名。配置类在 config.py 中定义。其中定义的配置可以使用app.config提供的from_object 方法直接导入应用。应用创建并且配置完成后就可以初始化扩展,在之前创建的对象上调用init_app()完成初始化,可以实现更复杂的初始化。

这个工厂函数创建的应用还不完整,因为没有路由和自定义的错误页面处理程序。

在蓝本中实现应用功能

转换成工厂函数让定义路由变得复杂了。在单脚本应用中,应用实例存在于全局变量作用域,路由可以直接使用app.route装饰器定义。但现在应用在运行时创建,只有调用 create_app 之后才能使用 app.route 装饰器,这是定义路由就太晚了。自定义的错误页面同理。

Flask使用蓝本 blueprint 提供了解决办法。蓝本与应用类似,可以定义路由和错误处理程序,不同的是,蓝本中定义的路由和错误处理程序处于休眠状态,知道蓝本注册到应用上之后,他们才真正成为应用的一部分。使用位于全局作用域中的蓝本时,定义路由和错误处理程序的方法几乎与但脚本应用一样。

蓝本可以在单个文件中定义,也可以使用更结构化的方式在包中的多个模块中创建。为了获得灵活性,我们在应用包中创建一个子包,用于保存应用的第一个蓝本。app/main/__init__.py

1
2
3
4
5
from flask import Bluprint

main = Blueprint('main', __name__)

from . import views, errors

蓝本通过实例化一个Blueprint类对象,这个构造函数有两个必须指定的参数,蓝本名称和蓝本所在的包或模块。与应用一样,第二个参数一般使用__name__

应用的路由保存在在 app/main/views.py 中,错误处理保存在 app/main/errors.py 中,导入这两个模块就能把路由和错误处理程序与蓝本关联起来。导入在末尾进行时为了防止循环导入依赖,因为在 view 和 errors 中还要导入 main 蓝本。

蓝本在工厂函数create_app()中注册到应用上,app/__init__.py

1
2
3
4
5
def create_app(config_name):
from .main import main as main_blueprint
app.register_blueprint(main_blueprint)

return app

错误处理程序,app/main/errors.py

1
2
3
4
5
6
7
8
9
10
from flask import render_template
from . import main

@main.app_errorhandler(404):
def page_not_found(e):
return render_template('404.html'),400

@main.app_errorhandler(500):
def internal_server_error(e):
return render_template('500.html'),500

在蓝本中编写错误处理程序稍有不同,如果使用errorhandler装饰器,则只有蓝本中的错误才能触发,要想注册应用全局的错误处理程序,必须使用 app_errorhandler装饰器。

在蓝本中定义路由,app/main/views.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from datetime import datetime
from flask import render_template, session, redirect, url_for
from . impot main
from .forms import NameFrom
from .. import db
from ..models import User

@main.route('/', methods=['GET', 'POST'])
def index():
form = NameForm()
if form.validate_on_submit():
# ...
return redirect(url_for('main.index'))
return render_template('index.html', form=form, message=session.get('name'), known=session.get('known', False), current_time=datetime.utcnow())

在脚本中编写视图函数主要有两点不同:第一,与前面的错误处理程序一样,路由装饰器由蓝本提供,因此使用的是main.route,而非app.route;第二,url_for 函数的用法不同,在蓝本中,Flask会为所有蓝本中的端点加上一个命名空间,这样就可以在不同的蓝本中使用相同的端点名定义视图函数而不产生冲突。命名空间是蓝本的名称,即 Blueprint 构造函数的第一个参数,而且它与端点名之间以一个 . 分割。因此视图函数 index 注册的端点名为 main.index, url使用 url_for('main.index')获取。蓝本中,url_for可以使用省略形式,省略蓝本名,url_for('.index')

需要引入表单对象,保存在app/main/forms.py

应用脚本

应用实例在顶级目录的 flasky.py 中定义。flasky.py

1
2
3
4
5
6
7
8
9
10
11
import os
from app import create_app, db
from app.modules import User, Role
from flask_migrate import Migrate

app = create_app(os.getenv('FLASK_CONFIG') or 'defalt')
migrate = Migrate(app, db)

@app.shell_context_processor
def make_shell_context():
return dict(db=db, User=User, Role=Role)

这个脚本创建应用实例,读取环境变量,初始化 Flask-Migrate 为Python shell 定义上下文。

设置环境变量:

1
2
export FLASK_APP=flasky.py
export FLASK_DEBUG=1

需求文件

requirements.txt文件用于记录所有依赖包及其版本号,可以在另一台计算机上重新恒诚虚拟环境。

生成:pip freeze > requirements.txt

还原:pip install -r requiirements.txt

单元测试

简单的两个测试,tests/test_basics.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import unittest
from flask import current_app
from app import create_app, db

class BasicsTestCase(unittest.Testcase):
def setUb(self):
self.app = create_app('testing')
self.app_context = self.app.app_context
self.app_context.push()
db.create_all()

def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()

def test_app_exitsts(self):
self.assertFalse(current_app is None)

def test_app_is_testing(self):
self.assertTure(current_app.config['TESTING'])

使用 unittest 包编写,测试用例类的 setUp 和 tearDown 方法分别在各测试之前和之后运行,名称以test_开头的方法都作为测试运行。

setUp方法尝试创建一个测试环境,尽量与正常运行应用所需的环境已知。首先使用测试配置创建应用,然后激活上下文,保证在测试中能够使用 current_app,然后使用create_all创建一个全新的数据库库,数据库和应用上下文在 tearDown中删除。第一个测试确保应用实例存在,第二个确保应用在测试环境中运行。

为了执行单元测试,可以在flasky.py中添加一个自定义命令。flasky.py

1
2
3
4
5
6
@app.cli.command()
def test():
"""Run unit tests."""
import unittest
tests = unittest.TestLoader().discover('tests')
unittest.TextTestRunner(verbosity=2).run(tests)

app.cli.command装饰器把被装饰的函数名当作命令,调用 unittest包中提供的测试运行程序。使用flask test运行。

创建数据库

运行应用

实例:社交博客应用

用户身份认证

Flask 有很多身份验证包,这里的方案使用了多个包,通过编写胶水代码让不同的包写作。使用的包:Flask-Login 管理以登录用户的用户会话;Werkzeug 计算密码散列值并进行核对;itsdangerous 生成并核对加密安全令牌。

除了身份验证外,还将用到的扩展:Flask-Mai;Flask-Bootstrap;Flask-WTF。

密码安全性

使用Werkzeug计算密码散列值

Werkzeug 中的security模块实现了密码散列值的计算,这一功能的实现只需要两个函数,分别在注册和核对两个阶段。generate_password_hash(password, method='pbkdf2:sha256', salt_length=8),这个函数的输入为原始密码,返回散列的字符串形式供存入数据库。method和salt_length多数情况下可以使用默认值。check_password_hash(hash, password)这个函数的参数是从数据库取回的密码散列值和用户输入的密码,返回True时表示密码正确。

在之前创建的User模型基础上加入密码散列的改动,app/models.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from werkzeug.security import generate_password_hash, check_password_hash

class User(db.Model):
# ...
password_hash = db.Column(db.String(128))

@property
def password(self):
raise AttributeError('password is not a readable attribute')

@password.setter
def password(self, password):
self.password_hash = generate_password_hash(password)

def verify_password(self, password):
return check_password_hash(self.password_hash, password)

添加单元测试,测试最近的改动。test/test_user_model.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import unittest
from app.models import User

class UserModelTestCase(unittest.TestCase):
def test_password_setter(self):
u = User(password = 'cat')
self.assertTrue(u.password_hash is not None)
def test_no_password_getter(self):
u = User(password = 'cat')
with self.assertRaises(AtrributeError):
u.password
def test_password_verification(self):
u = User(password = 'cat')
self.assertTrue(u.verify_password('cat'))
self.assertFalse(u.verify_password('dog'))
def test_password_aslts_are_random(self):
u = User(password='cat')
u2 = User(password='cat')
self.assertTrue(u.password_hash !- u2.password_hash)

创建身份验证蓝本

使用一个名为 auth 的蓝本验证身份,保存在同名python包中。这个蓝本的包构造函数创建蓝本对象,再从views.py模块中导入路由。app/auth/__imit__.py

1
2
3
4
from flask import Blueprint

auto = Blueprint('auth', __name__)
from . import views

views.py 导入蓝本,然后使用蓝本的route 装饰器定义身份与验证相关的路由。这里添加了/login路由,app/auth/views.py

1
2
3
4
5
6
from flask import render_template
from . import auth

@auth.route('/login'):
def login():
return render_template('auth/login.html')

auth 蓝本要在 create_app 工厂函数中附加到应用上,app/__init__.py

1
2
3
4
5
def create_app(config_name):
# ...
from .auth import auth as auth_blueprint
app.register_blueprint(auth_blueprint, url_prefix='/auth')
return app

注册蓝本使用的url_prefix时可选参数,注册后蓝本定义的所有路由会加上指定的前缀。例如/lgoin会注册成/auth/login

使用 Flask-Login 验证身份

登录后,用户的验证状态要记录在用户会话中。Flask-Login 用于管理用户身份验证系统中的验证状态,不依赖特定的身份验证机制。pip install flask-login

准备用于登录的用户模型

Flask-Login的运行需要应用中有 User对象,User模型必须实现几个属性和方法:

属性/方法 说明
is_suthenticated 如果用户提供的登录凭据有效则返回True
is_active 如果允许用户登录,返回True;如果想禁用用户,可以返回False
is_anonymous 对普通用户返回False,对匿名用户返回True
get_id() 返回用户的唯一标识符,使用Unicode编码字符串

这些属性和方法可以在模型类中实现,但是Flask-Login提供了UserMixin类,包含了默认实现,能满足多数需求,修改用户模型,app/models.py:

1
2
3
4
5
6
7
8
9
from flask_login import UserMixin

class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(64), unique=True, index=True)
username = db.Column(db.String(64), unique = True)
password_hash = db.Column(db.String(128))
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))

这个示例中,用户使用电子邮件登录。

Flask-Login在应用的工厂函数中初始化,app/__init__.py

1
2
3
4
5
6
7
8
9
from flask_login import LoginManager

login_manager = LoginManager()
login_manager.login_view = 'auth.login'

def create_app(config_name):
#...
login_manager.init_app(app)
#...

LoginManager 对象的 login_view 属性用于设置登陆页面的端点。匿名用户尝试访问受保护的页面时,Flask-Login将重定向到登陆页面,因为登陆路由在蓝本中定义,所以需要加上蓝本的名称。

Flask-Login 要求应用指定一个函数,在扩展需要从数据库中获取指定标识符对应的用户时调用,app/models.py

1
2
3
4
5
from . import login_manager

@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))

login_manager.user_loader 装饰器把这个函数注册给 Flask-Login,在这个扩展需要获取一登陆用户的信息时调用,传入的用户标识符是一个字符串,因此这个函数先把标识符转换成整数,然后传给 Flask-SQLAlchemy 查询,加载用户。正常情况下这个返回值是用户对象,如果失败返回None。

保护路由

为了保护路由,只让通过身份验证的用户访问,Flask-Login提供了一个login_required 装饰器,可以使用多个装饰器,各装饰器只对随后的装饰器和目标函数起作用。以下例子中,两个装饰器不能对调位置:

1
2
3
4
5
6
from flask_login import login_required

@app.route('/secret')
@login_required
def secret():
pass

添加登录表单

呈现给用户的登录表单中包含一个用于输入电子邮件地址的文本字段,一个密码字段,一个 记住我 复选框和一个提交按钮,使用Flask-WTF类,/app/auth/forms.py

1
2
3
4
5
6
7
8
9
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BoolenField, SubmitField
from wtforms.validators import DataRequired, Length, Email

class LoginForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Length(1,64), Email()])
password = PasswordField('Password', validators=[DataRequired()])
remember_me = BooleanField('Keep me logged in')
submit = SubmitField('Log In')

PasswordField 类表示属性为 type=”password” 的 input 元素,BooleanField类表示复选框。电子邮件字段用到了Length,Email,和DataRequired三个验证函数。登录页面使用的模板保存在 auth/login.html中,只需使用Flask-Bootstrap提供的 wtf.quick_form 宏渲染表单即可。base.html中的导航栏可以使用Jinja2条件语句判断当前用户的登陆状态,分别显示 Log In 或 Log out 链接,app/templates/base.html

1
2
3
4
5
6
7
<ul class="nav navbar-nav navbar-right">
{% if current_user.is_authenticated %}
<li><a href="{{ url_for('auth.logout') }}">Log Out</a></li>
{% else %}
<li><a href="{{ url_for('auth.login') }}">Log In</a></li>
{% endif %}
</ul>

判断条件中的变量 current_user 由Flask-Login定义,在视图函数和模板中自动可用,这个变量的值是当前登录的用户,如果未登录则是一个匿名用户代理对象,它的 isauthenticated 属性是False,,所以可以判断用户是否登录。

登入用户

实现 login,app/auth/views.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from flask import render_template, redirect, request, url_for, flask
from flask_login import login_user
from . import auth
from ..models import User
from .forms import LoginForm

@auth.route('/login', methods=['GET', 'POST'])
def login():
form = LoginFOrm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user is not None and user.verify_password(form.password.data)
login_user(user, form.remember_me.data)
next = request.args.get('next')
if next is None or not next.startswith('/'):
next = url_for('main.index')
return redirect(next)
flash('Invalid username or password.')
return render_template('auth/login.html', form=form)

修改后的模板,app/templates/auth/login.html

1
2
3
4
5
6
7
8
9
10
11
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky - Login{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Login</h1>
</div>
<div class="col-md-4">
{{ wtf.quick_form(form) }}
</div>
{% endblock %}

登出用户

退出路由的实现,app/auth/views.py

1
2
3
4
5
6
7
8
from flask_login import logout_user, login_required

@auth.route('/logout')
@login_required
def loggout():
logout_user()
flash('You have benn logged gout.')
return redirect(url_for('main.index'))

理解 Flask-Login 的运作方式

用户登录过程涉及的操作:

  1. 点击 Log In 链接,访问 /auth/login,处理这个URL的函数返回登陆表单模板。
  2. 用户输入用户名和密码,点击提交,调用相同的处理函数,但这一次是POST请求。处理函数会验证表单提交的凭据,调用Flask-Login的 login_user函数,登入用户。login_user函数把用户ID以字符串形式写入用户会话。视图函数重定向到首页。
  3. 浏览器收到重定向,请求首页。调用首页的视图函数,渲染模板,出现对 current_user的引用。这个请求没有给上下文变量 current_user 赋值,因此调用Flask-Login内部的 _get_user函数,找出用户是谁。_get_user检查用户会话中有没有用户ID,如果没有,返回一个Flask-Login的 AnonymousUser实例,如果由,调用应用中使用 user_load 装饰器注册的函数,传入用户ID。应用中的 user_ loader 从数据库中读取用户并返回。Flask-login把它赋值给当前请求的 current_user上下文变量。模板接收它。

使用 login_required 装饰器装饰的试图函数将使用 current_user 上下文变量判断 current_user.is_authenticated 表达式是否为 True 。logout_user 函数比较简单,直接从用户会话中删除用户ID。

登录测试

为验证登录功能可用,可以更新首页,使用已登录用户的名字显示一个欢迎信息。app/templates/index.html

1
2
3
4
5
6
Hello,
{% if current_user.is_autenticated %}
{{ current_user.username }}
{% else %}
Stranger
{% endif %}!

因为未实现注册功能,目前只能在shell中注册新用户。

1
2
3
4
flask shell
u = User(email='john@example.com', username='john', password='cat')
db.session.add(u)
db.session.commit()

访问首页可以看到结果

注册新用户

如果新用户想成为应用的成员,必须在应用中注册。这样应用才能识别并登入用户。登录页面要显示一个注册页面的链接,让用户输入电子邮件地址,用户名和密码。

添加用户注册表单

app/auth/forms.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms import ValidationError
from ..models import User

class RegistrationForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Length(1,64), Email()])
username = StringField('Username', validators=[DataRequired(), Length(1,64),
Regexp('^[A-Za-z][A-Za-z0-9]*$', 0, 'Username must have only letters, numbers, dots or '
'underscores')])
password = PasswordField('Password', validators=[DataRequired(), EqualTo('password2', message='Password must math.')])
password2 = PasswordField('Confirm password', validators=[DataRequired()])
submit = SubmitField('Register')
def validate_email(self, field):
if User.query.filter_by(email=field.data).first()
raise ValidationError('Email already registered.')
def validate_username(self, field):
if User.query.filter_by(username=field.data).first():
raise ValidatioinError('Username already in use.')

这个表单使用WTForms提供的Regexp验证函数,后面两个参数分别是正则表达式标志和验证失败显示的错误信息。EqualTo验证函数检查两个字段是否一致,输入为另一个字段。还含有两个自定义验证函数,以方法方式实现。如果表单定义了validate_开头并且后面跟着字段名的方法,这个方法就和常规的验证函数一起使用。这里为email 和 username 定义了验证函数,确保数据库中没有出现过。自定义验证函数表示验证失败可以跑出 ValidationError 异常,参数是错误信息。

显示这个表单的模板是 app/templates/auth/register.html,使用wtf.quick_form渲染表单,登录页面要显示一个指向注册页面的链接,app/templates/auth/login.html

1
2
3
4
5
6
<p>
New user?
<a href="{{ url_for('auth.register') }}">
Click here to register
</a>
</p>

注册新用户

app/auth/views.py

1
2
3
4
5
6
7
8
9
10
@auth.route('/register', methods=['GET', 'POST'])
def register():
form = RegistrationForm()
if form.validate_on_submit():
user = User(email=form.email.data, username=form.username.data, password=form.password.data)
db.session.add(User)
db.session.commit()
flash('You can now login.')
return redirect(url_for('auth_login'))
return render_template('auth/register.html', form=form)

确认账户

有时需要确认用户提供的信息是否正确,常见于检查电子邮件地址。通过向提供的邮箱发送确认邮件,要求用户点击一个包含确认令牌的特殊URL可以确认。

使用 itsdangerous 生成确认令牌

1
2
3
4
5
6
7
8
9
flask shell
from itsdangerous import TimedJSONWebSignatureSerialize as Serializer
s = Serializer(app.config['SECRET_KEY'], expires_in=3600)
token = s.dumps({'confirm': 23})
token
# evJh........
data = s.loads(token)
data
# {'confirm': 23}

itsdangerous 提供多种生成令牌的方法,其中 TimedJSONWebSignatureSerializer 正常具有过期时间的 JSON Web签名(JWS),构造函数接受的参数是一个密钥,可以使用 SECRET_KEY 设置。

dumps 方法为指定的数据生成一个加密签名,然后对数据和签名进行序列化,生成令牌字符串。expires_in参数设置过期时间,单位是秒。

loads 方法解码令牌,参数是令牌字符串,检验签名和过期时间。如果都有效,返回原始数据,否则抛出异常。这个功能可以添加到User模型中。app/models.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
from flask import current_app
from . import db

class User(UserMixin, db.Model):
# ...
confirmed = db.Column(db.Boolean, default=False)
def generate_confirmation_token(self, expiration=3600):
s = Serializer(current_app.config['SECRET_KEY'], expiration)
return s.dumps({'confirm': self.id}).decode('utf-8')
def confirm(self, token):
s = Serializer(current_app.config['SECRET_KEY'])
try:
data = s.loads(token.encode('utf-8'))
except:
return False
if data.get('confirm') != self.id
return False
self.confirmed = True
db.session.add(self)
return True

发送确认邮件

当前的/register路由把新用户添加到数据库中之后会重定向到/index,在这之前需要发送确认邮件,app/auth/views.py

1
2
3
4
5
6
7
8
9
10
11
12
from ..email import send_email

@app.route('/register', methods=['GET', 'POST'])
def register():
form = RegistrationForm()
if form.validate_on_submit():
# ...
db.session.add(User)
db.session.conmmit()
token = user.generate_cofirmation_token()
send_email(user.email, 'Confirm Your Account', 'auth/email/confirm', user=user, token=token)
flash('A confirmation email has been sent to your')

身份验证蓝本使用的电子邮件模板保存在 templates/auth/email 目录中,以便与HTML模板区分。一个电子邮件需要两个模板,分别用于渲染纯文本正文和HTML正文。例如以下纯文本模板,app/templates/auth/email/confirm.txt

1
2
3
4
Dear {{ user.username }}
Welcome to Flasky!
To confirm your account please click on the following link:
{{ url_for('auth.confirm', token=token, _external=True) }}

确认账户的视图函数,app/auth/views.py

1
2
3
4
5
6
7
8
9
10
11
12
13
from flask_login import current_user

@auth.route('/confirm/<token>'):
@login_required
def confirm(token):
if current_user.confirmed:
return redirect(url_for('main.index'))
if current_user.confirm(token):
db.session.commit()
flash('You have confirmed your account.')
else:
flash('The confirmation link is invalid or has expired.')
return redirect(url_for('main.index'))

由于令牌确认完全在User模型中完成,因此视图函数只需要调用 confirm 方法即可,再根据确认结果显示不同的信息。确认成功后,User模型中 confirmed属性的值会修改并添加到会话中,然后提交数据库会话。

各个应用可以自行决定用户确认账户之前可以做哪些操作,比如允许未经确认的用户登录,但只显示一个界面,要求访问之前确认账户。这一步可以通过 Flask 提供的 before_request 钩子完成,对于蓝本来说,before_request 钩子只能应用到属于蓝本的请求上。如果想在蓝本中使用针对应用全局请求的钩子,必须使用 before_app_request装饰器。app/auth/views.py

1
2
3
4
5
6
7
8
9
10
11
12
13
@auth.before_app_request
def before_request():
if current_user.is_authenticated \
and not current_user.confirmed \
and request.blueprint != 'auth' \
and request.endpoint != 'static':
return redirect(url_for('auth.unconfirmed'))

@auth.route('/unconfirmed')
def unconfirmed():
if current_user.is_anonymous or current_user.confirmed:
return redirect(url_for('main.index'))
return render_template('auth/confirmed.html')

用户已登录,未确认并且请求的URL不在身份验证蓝本中,不是对静态文件的请求,要赋予用户访问路由的权限时,before_app_request 会拦截请求,重定向到auth/unconfirmed

重新发送账户确认邮件,app/auth/views.py

1
2
3
4
5
6
7
8
@auth.route('/confirm')
@login_required
def resend_confirmation():
token = current_user.generate_confirmation_token()
send_email(current_user.email, 'Confirm Your Account',
'auth/email/confirm', user=current_user, token=token)
flash('A new confirmation email has been sent to you.')
return redirect(url_for('main.index'))

管理账户

拥有应用账户的用户有时可能需要修改账户信息,也可添加功能。

用户角色

实现方式结合了分立的角色和权限,赋予用户分立的角色,但是哥哥角色都通过权限列表定义允许用户执行的操作。

角色在数据库中的表示

改进后的Role模型,app/models.py

1
2
3
4
5
6
7
8
9
10
11
12
class Role(db.Model):
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True)
default = db.Column(db.Boolean, default=False, index=True)
permissions = db.Column(db.Integer)
users = db.reationship('User', backref='role', lazy='dynamic')

def __init__(self, **kwargs):
super(Role, self).__init__(**kwargs)
if self.permissions is None:
self.permissions = 0

这里新增了 default 字段,这是注册新用户时赋予的角色,只能有一个角色的这个字段为 True,因为应用在 roles 表中搜索默认角色,因此设置了索引。另一处改动是增加了 permissions 字段,SQLAlchemy 默认把这个字段的值设为None,因此添加了一个类构造函数,默认把 permissions设置为0。各操作所需的权限在不同的应用中是不一样的,定义权限:

操作 权限名 权限值
关注用户 FOLLOW 1
在他人的文章中发表评论 COMMENT 2
写文章 WRITE 4
管理他人发表的评论 MODERATE 8
管理员权限 ADMIN 16

使用2的幂表示权限可以方便的组合权限。app/models.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Permission:
FOLLOW = 1
COMMENT = 2
WRITE = 4
MODERATE = 8
ADMIN = 16

class Role(db.Model):
# ...
def add_permission(self, perm):
if not self.hash_permission(perm):
self.permissions += perm
def remove_permission(self, perm):
if self.has_permission(perm):
self.permissions -= perm
def reset_permissions(self):
self.permissions = 0
def has_permission(self, perm):
return self.permissions & perm == perm

用户角色和权限组合的定义:

用户角色 权限 说明
匿名 未登录用户
用户 FOLLOW, COMMENT, WRITE 新用户默认角色
协管员 FOLLOW, COMMENT, WRITE, MODERATE 增加管理其他用户评论的权限
管理员 FOLLOW, COMMENT, WRITE, MODERATE, ADMIN 具有所有权限,包括修改其他用户所属角色

可以在Role类中添加一个类方法,将角色添加到数据库。既方便测试,也方便部署到生产环境。app/models.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Role(db.Model):
# .
@staticmethod
def insert_roles():
roles = {
'User': [Permission.FOLLOW, Permission.COMMENT, Permission.WRITE],
'Moderator': [Permission.FOLLOW, Permission.COMMENT, Permission.WRITE, Permission.MODERATE],
'Administrator': [Permission.FOLLOW, Permission.COMMENT, Permission.WRITE, Permission.MODERATE, Permission.ADMIN]
}
default_role = 'User'
for r in roles:
role = Role.query.filter_by(name=r).first()
if role is None:
role = Role(name=r)
role.reset_permission()
for perm in roles[r]:
role.add_permission(perm)
role.default = (role.name == default_role)
db.session.add(role)
db.session.commit()

insert_roles 函数不直接创建角色对象,通过角色名查找现有的角色,然后进行更新。没有角色时创建新角色对象。它是一个静态方法,不需要创建对象,直接在类上调用,例如Role.insert_roles(),静态方法参数中没有 self。

赋予角色

用户注册账户时应被赋予用户角色,管理员在最开始就应该赋予 管理员 角色,由保存在设置变量FLASKY_ADMIN 中的电子邮件地址识别,app/models.py

1
2
3
4
5
6
7
8
9
10
class User(UserMixin, db.Model):
# ...
def __init__(self, **args):
super(User, self).__init(**kwargs)
if self.role is None:
if self.email == current_app.config['FLASKY_ADMIN']:
self.role = Role.query.filter_by(name='Administrator').first()
if self.role is None:
self.role = Role.query.filter_by(default=True).first
# ...

User 类的构造函数先调用积累的构造函数,如果没有定义角色,用电子邮件确定角色。

检验角色

为了简化角色和权限的实现过程,可在User模型中添加辅助方法,检查权限,可以委托前面添加的权限管理方法,app/models.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from flask_login import UserMixin, AnonymousUserMixin

class User(UserMixin, db.Model):
# ...
def can(self, perm):
return self.role is not None and self.role.hase_permission(perm)
def is_administrator(self):
return self.can(Permission.ADMIN)
class AnonymousUser(AnonymousUserMixin):
def can(self, permissions):
return False
def is_administrator(self):
return False
login_manager.anonymous_user = AnonymousUser

如果想让视图函数只对由特定权限的用户开放,可以使用自定义装饰器。app/decorators.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from functools import wraps
from flask_import abort
from flask_login import current_user

def permission_required(permission):
def decorator(f):
@wraps(f)
def decorated_funtion(*args, **kwargs):
if not_current_user.can(permission):
abortt(403)
return f(*args, **kwargs)
retrun decorated_funtion
return decorator

def admin_required(f):
return permission_required(Permission.ADMIN)(f)

装饰器的使用:

1
2
3
4
5
6
7
8
9
10
11
12
from .decorators import admin_required, permission_required

@main.route('/admin')
@login_required
@admin_required
def for_admins_only():
return "For administrators"
@main.route('/moderate')
@login_required
@permission_required(Permission.MODERATE)
def for_moderators_only():
return "For comment moderators"

在模板中可能也需要检查权限,所以Permission类的所有常量要能在模板中访问,为了避免每次都调用 render_template 时多添加一个参数,可以使用上下文处理器。在渲染时,上下文处理器能让变量在所有模板中可访问,app/main/__init__.py

1
2
3
@main.app_context_processor
def inject_permissions():
return dict(Permission=Permission)

编写角色和权限的测试,tests/test_user_model.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class UserModelTestCase(unittest.TestCase):
# ...
def test_user_role(self):
u = User(email='john@example.com', password='cat')
self.assertTrue(u.can(Permission.FOLLOW))
self.assertTrue(u.can(Permission.COMMENT))
self.assertTrue(u.can(Permission.WRITE))
self.assertFalse(u.can(Permission.MODERATE))
self.assertFalse(u.can(Permission.ADMIN))
def test_anonymous_user(self):
u = AnonymousUser()
self.assertFalse(u.can(Permission.FOLLOW))
self.assertFalse(u.can(Permission.COMMENT))
self.assertFalse(u.can(Permission.WRITE))
self.assertFalse(u.can(Permission.MODERATE))
self.assertFalse(u.can(Permission.ADMIN))

先在 shell 会话中添加这些新角色到开发数据库

1
2
3
flask shell
Role.insert_roles()
Role.query.all()

最好更新用户列表,为在此之前创建的用户账户分配用户角色

1
2
3
4
5
6
7
8
9
flask shell
admin_role = Role.query.filter_by(name='Administrator').first()
default_role = Role.query.filter_by(default=True).first()
for u in User.query.all():
if u.email == app.config['FLASKY_ADMIN']:
u.role = admin_role
else:
u.role = default_role
db.session.commit()

用户资料

资料信息

在数据库中存储一些额外信息用于站视,扩充 User模型,app/models.py

1
2
3
4
5
6
7
class User(UserMixin, db.Model):
# ...
name = db.Column(db.String(64))
location = db.Column(db.String(64))
about_me = db.Column(db.Text())
member_since = db.Column(db.DateTime(), default=datatime.utcnow)
last_seen = db.Column(db.dateTime(), default=datetime.utcnow)

db.Column的default参数可以接受函数作为参数,每次需要生成默认值,SQLAlchemy都会调用指定的函数。member_since只需要使用一次默认值,而last_seen每次用户访问后都需要刷新,可以在User类中添加一个方法执行这个操作,app/models.py

1
2
3
4
5
6
class User(UserMixin, db.Model):
# ...
def ping(self):
self.last_seen = datetime.utcnow()
db.session.add(self)
db.session.commit()

每次收到用户请求都要调用这个 ping 方法,可以使用 auth 蓝本中的 before_app_request 处理程序,app/auth/views.py

1

作者

lll

发布于

2019-11-23

更新于

2022-09-19

许可协议