《Flask Web开发实战》即将下厂印刷,如果想要购买作者签名版,可以访问http://helloflask.com/book/signed。本书定价129,电商平台预计价格为99,签名版为109。
注意,此商品为预售,无现货,具体发货时间取决于本书正式发售日期。更多信息请访问预售页面查看。
《Flask Web开发实战》即将下厂印刷,如果想要购买作者签名版,可以访问http://helloflask.com/book/signed。本书定价129,电商平台预计价格为99,签名版为109。
注意,此商品为预售,无现货,具体发货时间取决于本书正式发售日期。更多信息请访问预售页面查看。
本项目是《Flask Web开发实战》的衍生品。在本书第16章的前半部分,为了让读者快速对Flask的源码结构建立一个初步的认识(以便阅读后面的内容),推荐读者阅读0.1版本的源码。
本项目对0.1版本Flask源码(项目根目录下的flask.py脚本)中的注释和文档字符串进行了翻译,并在必要的地方添加了一些额外的注解,以便于阅读和理解。
项目地址:https://github.com/greyli/flask-origin
欢迎fork项目进行补充和纠错。
为了更容易理解Flask的实现原理,你需要对WSGI协议以及HTTP协议有一些了解,建议先简单浏览下面的基本知识:
Flask内部实现大量依赖于Werkzeug,包括请求和响应对象,路由匹配,URL生成等等,你可以阅读Werkzeug的文档来深入了解这些内容的具体实现。另外,如果你对模板渲染部分的内容感兴趣,也可以考虑阅读Jinja2文档:
注意:新版本的Werkzeug和Jinja2已经发生很大的变化,0.1版本的Flask对应的Werkzeug源码版本为0.6.1,对应的Jinja2源码版本为2.4。上述文档链接分别为0.14和2.9版本,请谨慎参考。
从Flask 0.11版本开始,官方就建议使用flask run命令来取代app.run()方法运行开发服务器。尽管如此,两年多过去了,仍然有大量新发布的文章和教程在示例中使用app.run()方法启动程序。类似的,虽然内置的命令行支持已经非常完善,但还有很多人在使用Flask-Script。
Added flask and the
flask.climodule to start the local debug server through the click CLI system. This is recommended over the oldflask.run()method as it works faster and more reliable due to a different design and also replacesFlask-Script.
不得不承认,在某些特殊场景下,app.run()更加方便,比如创建Flask命令在附加Werkzeug提供的性能分析中间件后启动程序,这时通过app.run()可以直接在脚本内启动程序。但是在大多数情况下,flask run更能胜任启动开发服务器的工作。而且,在大型项目中,使用app.run()需要你在项目根目录单独创建一个启动脚本,flask run则没有这个要求;在单脚本程序中,使用flask run也可以省掉脚本末尾的两行代码。
注意 这两种方法都只是用来启动内置(Werkzeug提供)的开发服务器,仅适用于开发用途。在生产环境下,应该使用性能更好,更加完善的开发服务器,比如Gunicorn、uWSGI等。
下面我们来了解一下使用flask run启动开发服务器时在几种方式。
如果脚本命名为app.py或wsgi.py,那么在包含程序脚本的目录下直接调用flask run即可:
$ flask run
Flask会自动探测找到脚本中的程序实例并启动。如果脚本命名为其他名称,比如hello.py,那么需要将脚本名写入环境变量FLASK_APP,然后再调用flask run命令:
$ export FLASK_APP=hello $ flask run
提示 在Windows系统下,你需要使用set命令来设置环境变量,比如 > set FLASK_APP=hello,后面的命令亦同。
这种情况下,可以将包含程序实例的对应模块的路径写入FLASK_APP:
$ export FLASK_APP=my_pkg.app $ flask run
通常情况下,我们会在包内的__init__.py文件中创建程序实例,所以这时可以直接将包名称写入FLASK_APP:
$ export FLASK_APP=my_pkg $ flask run
因为Flask会自动探测程序实例,所以使用工厂函数创建程序实例时不需要进行额外设置。具体来说,Flask会在FLASK_APP变量存储的对应模块/包构造文件中寻找名为create_app或make_app的函数,并调用这个函数来创建一个程序实例。
为了让你的程序能够被探测到,工厂函数的名称需要命名为create_app或make_app,而且要确保工厂函数接受默认值参数。这时启动开发服务器的方式仍然不变:
$ export FLASK_APP=my_pkg $ flask run
如果你的工厂函数接受的参数不是默认参数,或者你想详细定义调用工厂函数的方式,那么也可以通过FLASK_APP环境变量来定义:
$ export FLASK_APP="my_pkg:create_app('development')"
$ flask run
提示 Flask的FLASK_APP还接受其他形式的输入值,你可以参考文末给出的文档相关部分链接了解完整内容。
在上面的几种方式中,除了包含程序实例的程序脚本命名为app.py或wsgi.py的情况外,都需要设置FLASK_APP环境变量。有没有办法避免重启电脑或是新打开命令行会话时重复输入FLASK_APP呢?当然。Flask提供了对一个常用的Python虚拟环境管理工具python-dotenv的支持,我们需要先安装它:
$ pip install python-dotenv
当python-dotenv安装后,执行flask run命令会首先将项目根目录下的.env和.flaskenv文件中的环境变量写入。所以,你可以将FLASK_APP写在这两个文件中。按照约定,.env存储包含敏感数据的环境变量,这个文件需要加入到.gitignore中以避免提交到Git仓库中;而.flaskenv时Flask特别支持的文件,这个文件则用来存储和Flask相关的环境变量,比如FLASK_ENV、FLASK_DEBUG等,所以我们可以把FLASK_APP写到这个文件中:
FLASK_APP=my_pkg
现在,我们可以仅通过一个命令来启动开发服务器:
$ flask run
在使用app.run()方法时,我们会通过将debug参数设为True来开启调试模式。而当使用flask run时,则需要通过FLASK_ENV环境变量来设置调试模式。默认情况下,FLASK_ENV的值为production,在开发时我们可以将其设为development来开启调试模式。
同样的,为了避免重复写入这个环境变量,我们也将其写到.flaskenv中:
FLASK_ENV=development
提示 目前已不推荐使用FLASK_DEBUG来开启调试模式,当FLASK_ENV的值为development时调试模式会自动开启。
在通过flask run启动开发服务器时,你可以通过命令行选项来自定义监听的主机和端口,示例如下:
$ flask run --port 5001
下面的示例同时指定了端口和主机:
$ flask run --host 0.0.0.0 --port 5001
另外,Flask还支持通过环境变量来定义命令选项,支持的环境变量名称模式为“FLASK_命令_选项”。比如,如果你想设置端口,那么可以定义FLASK_RUN_PORT环境变量,作用和传入--port选项相同。
你可以通过flask shell命令来启动一个激活了程序上下文的Python Shell,而不是使用python命令:
$ flask shell
从0.11版本到现在的1.0.2版本,app.run()始终处于不建议使用状态,而且Flask的命令行系统、flask run命令的程序探测都在逐渐完善,我觉得未来也许会正式”deprecate“这个app.run()方法。不过,因为某些特殊用途仍然需要使用app.run(),未来的变化还不好说。而且,Miguel Grinberg提交了1个PR让app.run()间接调用flask run,如果这个PR被合并,也许app.run()将会重回正轨。
就目前来说,flask run要远比app.run()更加方便、好用、简洁、直观,准备好了吗?穿上新衣服吧。
今天写单元测试发现了一个常见的问题,即测试时发送POST请求时如何传入布尔值数据(勾选框字段值)?答案是:你没法直接传递布尔值。其实这个答案相当显而易见,客户端当然没法向服务器端发送Python类型的数据,数据的转换是在接受到请求数据后在服务器端进行的。之前在不借助Flask-WTF/WTForms,手动编写表单并处理时就已经注意到了这个问题,不过在测试中不太容易想到。
首先,我们需要了解一下勾选框(<input type="checkbox">)提交的行为:
False。"on";在服务器端,WTForms会将其转换为True。也就是说,勾选框的数据只要不为空,WTForms就会将其转换为True。所以,在测试中,如果你想让勾选框的值最终转换为True,那么就传入任意字符串;反之则传递空字符串或直接不加入该字段。下面是传入空字符串的示例:
def test_privacy_setting(self):
self.login()
response = self.client.post(url_for('user.privacy_setting'), data=dict(
public_collections='', # <--
), follow_redirects=True)
user = User.query.get(1)
self.assertEqual(user.public_collections, False)
顺便说一句,基于勾选框的提交行为,如果没有使用Flask-WTF/WTForms,那么在手动处理提交数据的时候也要进行相应的处理:没有在request.form中获取到勾选框字段(比如,request.form.get('remember')会是None),即表示没有勾选,那么就转换为False;勾选框字段一旦出现,那么就表示勾选,转换为True。
《Flask Web开发实战》删减下近8万字的内容,有时间我会把其中有价值的内容整理成文章发布出来。
大多数Web程序中都会涉及到头像的实现。不同类型的项目,对于头像的需求不同,有些项目可以直接使用Gravatar提供的头像服务,而有的项目则需要提供自定义头像设置。扩展Flask-Avatars几乎满足了所有常见的头像需求:
GitHub主页:https://github.com/greyli/flask-avatars
使用pip安装:
$ pip install flask-avatars
像其他扩展类似,你需要实例化从flask_avatars导入的Avatars类,并传入app实例:
from flask_avatars import Avatars app = Flask(__name__) avatars = Avatars(app)
如果你使用工厂函数创建程序实例,那么这里也可以不传入程序实例app,在工厂函数中对这个avatars对象调用init_app()方法时再传入app实例。
Flask-Avatars内置了几个默认头像,可以用来作为用户注册后的初始头像,或是作为博客评论者的头像。在模板中调用avatars.default()即可获取URL:
<img src="{{ avatars.default() }}">
你可以通过size参数来设置尺寸,默认为m,其他可选值有l和s。实际的调用示例如下所示:
在模板中调用avatars.gravatar()方法并传入Email散列值即可获取Gravatar(gravatar.com)的头像URL:
<img src="{{ avatars.gravatar(email_hash) }}">
Email散列值可以通过下面的方式获取:
import hashlib
avatar_hash = hashlib.md5(my_email.lower().encode('utf-8')).hexdigest()
Robohash(robohash.org)是一个生成随机机器人头像的服务(目前Gravatar的默认头像中已经支持这一类型,可以通过将default参数设为robohash获取)。在模板中调用avatars.robohash()并传入随机文本作为参数即可获取Robohash的头像URL:
<img src="{{ avatars.robohash(some_text) }}">
实际的调用示例如下所示:
Flask-Avatars通过Avatars.io提供了社交头像获取服务,目前支持Facebook、Twitter、Instagram和Gravatar。通过在模板中调用avatars.social_media()方法并传入用户名(username)即可获取对应的头像URL,通过size参数可以设置尺寸,可选值为small、medium和large:
<img src="{{ avatars.social_media(username) }}">
通过platform参数可以设置平台,默认为twitter,可选值为twitter、facebook、instagram和gravatar:
<img src="{{ avatars.social_media(username, platform='facebook') }}">
实际的调用示例如下所示:
Gravatar服务可能会有不稳定的情况,这种情况下,你可以在本地为用户生成头像(identicon),下面是一个简单的示例:
app.config['AVATARS_SAVE_PATH '] = 'the/path/to/save/avatar' avatar = Identicon() filenames = avatar.generate(text=‘一串唯一字符’)
avatar.generate()会根据传入的随机字符创建三个尺寸(可以通过配置变量AVATARS_SIZE_TUPLE自定义)的头像,保存到指定的位置,并返回三个头像文件名。你可以将这个文件名保存到数据库中,并创建一个视图函数来提供头像文件:
from flask import send_form_directory, current_app
@app.route('/avatars/<path:filename>')
def get_avatar(filename):
return send_from_directory(current_app.config['AVATARS_SAVE_PATH'], filename)
Flask-Avatars基于Jcrop提供了头像裁剪功能,具体步骤可以参考文档中的相关部分。下面是示例程序中的裁剪页面:
Flask-Avatars支持的配置列表如下所示:
| 配置 | 默认值 | 说明 |
|---|---|---|
| AVATARS_GRAVATAR_DEFAULT | identicon | Gravatar默认头像类型 |
| AVATARS_SAVE_PATH | None |
头像保存路径 |
| AVATARS_SIZE_TUPLE | (30, 60, 150) |
头像尺寸三元素元组,格式为 (small, medium, large),用于生成identicon头像和裁剪头像 |
| AVATARS_IDENTICON_COLS | 7 | Identicon头像一行的色块数量 |
| AVATARS_IDENTICON_ROWS | 7 | Identicon头像一列的色块数量 |
| AVATARS_IDENTICON_BG | None |
Identicaon头像的背景颜色,传入RGB元组,比如(125, 125, 125)。默认使用随机颜色 |
| AVATARS_CROP_BASE_WIDTH | 500 | 裁剪图片的显示宽度 |
| AVATARS_CROP_INIT_POS | (0, 0) | 裁剪框起始位置,两元素元组(x, y),默认为左上角 |
| AVATARS_CROP_INIT_SIZE | None | 裁剪框的尺寸,默认为尺寸元组中设置的l尺寸大小,即AVATARS_SIZE_TUPLE[0] |
| AVATARS_CROP_MIN_SIZE | None | 裁剪框的限制最小尺寸,默认无限制 |
| AVATARS_CROP_PREVIEW_SIZE | None | 预览窗口的尺寸,默认为尺寸元组中设置的m尺寸大小,即AVATARS_SIZE_TUPLE[1] |
| AVATARS_SERVE_LOCAL | False | 是否从本地加载Jcrop资源,默认从CDN加载 |
Flask-Avatars的Git仓库中包含三个实例程序,也就是文中的截图对应的程序:
你可以通过下面的方式来运行实例程序:
$ git clone https://github.com/greyli/flask-avatars.git $ cd flask-avatars/examples $ pip install flask flask-avatars $ cd basic # 切换到identicon和crop目录可以运行对应的示例程序 $ flask run
这篇文章属于“Flask常用扩展介绍系列”,这个系列的文章目录索引可以在《Flask常用扩展介绍系列文章索引》看到。
当继承db.Model基类的子类被声明创建时,根据db.Model基类继承的元类中设置的行为,类声明后会将表信息注册到db.Model.metadata.tables属性中。
create_all()方法被调用时正是通过这个属性来获取表信息。因此,当我们调用create_all()前,需要确保模型类被声明创建。如果模型类存储在单独的模块中,不导入该模块就不会执行其中的代码,模型类便不会被创建,进而便无法注册表信息到db.Model.metadata.tables中,所以这时需要导入相应的模块。
因为我们的目的是让模型类被创建,所以不论是导入整个模块还是导入其中某个模型类都可以,并不需要导入全部模型类。
一般情况下,我们不用关心这个问题。在单脚本的Flask程序中自不必说,在使用包组织的Flask程序中,创建程序实例时必然需要导入视图函数所在的模块,或是蓝本所在的模块,而这些模块会导入模型类。
下面我们会通过解析源码简单了解模型类对应的表信息是如何注册到db.Model.metadata.tables中的。
不论是单独使用SQLAlchemy时创建的Base基类,还是使用Flask-SQLAlchemy时创建db对象后的db.Model基类,都是通过declarative_base()函数创建,具体源码如下:
def declarative_base(bind=None, metadata=None, mapper=None, cls=object,
name='Base', constructor=_declarative_constructor,
class_registry=None,
metaclass=DeclarativeMeta):
lcl_metadata = metadata or MetaData()
if bind:
lcl_metadata.bind = bind
if class_registry is None:
class_registry = weakref.WeakValueDictionary()
bases = not isinstance(cls, tuple) and (cls,) or cls
class_dict = dict(_decl_class_registry=class_registry,
metadata=lcl_metadata)
if isinstance(cls, type):
class_dict['__doc__'] = cls.__doc__
if constructor:
class_dict['__init__'] = constructor
if mapper:
class_dict['__mapper_cls__'] = mapper
return metaclass(name, bases, class_dict)
源码位置:https://github.com/zzzeek/sqlalchemy/blob/master/lib/sqlalchemy/ext/declarative/api.py#L286
这个函数返回一个元类实例,对应的元类为DeclarativeMeta,这个元类定义了一些特殊行为:
class DeclarativeMeta(type):
def __init__(cls, classname, bases, dict_):
if '_decl_class_registry' not in cls.__dict__:
_as_declarative(cls, classname, cls.__dict__)
type.__init__(cls, classname, bases, dict_)
def __setattr__(cls, key, value):
_add_attribute(cls, key, value)
def __delattr__(cls, key):
_del_attribute(cls, key)
源码位置:https://github.com/zzzeek/sqlalchemy/blob/master/lib/sqlalchemy/ext/declarative/api.py#L62
在_as_declarative()函数以及附加的其他多层调用为这个类进行了更多设置,比如添加__table__属性为存储表信息的Table对象,设置metadata属性等等。
其中魔法方法__setattr__()中调用了_add_attribute()函数,这个函数执行了一系列模型类属性的注册操作,其中的一个操作便是向基类 __table__属性指向的Table对象调用append_column()方法添加表字段信息:
def _add_attribute(cls, key, value):
if '__mapper__' in cls.__dict__:
if isinstance(value, Column):
_undefer_column_name(key, value)
cls.__table__.append_column(value)
cls.__mapper__.add_property(key, value)
...
源码位置:https://github.com/zzzeek/sqlalchemy/blob/master/lib/sqlalchemy/ext/declarative/base.py#L646
经过这一系列注册操作,表信息就被添加到db.Model.metadata.tables属性中,这个属性返回包含所有表信息的字典(表名称与Table实例的映射),下面是一个示例:
immutabledict({'comment': Table('comment', MetaData(bind=None), Column('id'
, Integer(), table=<comment>, primary_key=True, nullable=False), Column('bo
dy', Text(), table=<comment>), Column('timestamp', DateTime(), table=<comme
nt>, default=ColumnDefault(<function utcnow at 0x03D2E3F0>)), Column('flag'
, Integer(), table=<comment>, default=ColumnDefault(0)), Column('author_id
', Integer(), ForeignKey('user.id'), table=<comment>), Column('photo_id', I
nteger(), ForeignKey('photo.id'), table=<comment>), schema=None)})
db.Model.metadata存储MetaData类实例,MetaData类实例存储Table类实例的集合,而Table类实例存储模型类对应的数据库表字段信息,可以通过模型类的__table__属性获取。create_all()方法实际调用的是db.Model.metadata.create_all 方法,这个方法会将Table类实例存储的信息转换为数据库模式(通过sqlclchemy.sql.ddl.SchemaGenerator)。
顺便说一句,因为表信息存储在特定的基类中,所以为了正确创建数据库表,你需要对模型类继承的基类调用create_all()方法,即db.create_all(),或是Base.metadata.create_all(engine)。如果你在测试时新创建一个db对象或是Base基类,那么它是不会包含表信息的。
这篇文章来自我在知乎写的这个回答,作为备份。
Bootstrap-Flask是一个简化在Flask项目中集成前端开源框架Bootstrap过程的Flask扩展。使用Bootstrap可以快速的创建简洁、美观又功能全面的页面,而Bootstrap-Flask让这一过程更加简单和高效。尤其重要的是,Bootstrap-Flask支持最新版本的Bootstrap 4版本。

Bootstrap-Flask logo
GitHub项目主页:https://github.com/greyli/bootstrap-flask
简单来说,Bootstrap-Flask的出现是为了替代不够灵活且缺乏维护的Flask-Bootstrap。它的主要设计参考了Flask-Bootstrap,其中渲染表单和分页部件的宏基于Flask-Bootstrap中的相关代码修改实现。和Flask-Bootstrap相比,前者有下面这些优点:
你如果使用过Flask-Bootstrap,那么除了安装时的名称外,这个过程基本没有不同。
安装:
$ pip install bootstrap-flask
初始化:
from flask_bootstrap import Bootstrap from flask import Flask app = Flask(__name__) bootstrap = Bootstrap(app)
如果你使用工厂函数创建程序实例,那么可以使用下面的方式初始化扩展:
from flask_bootstrap import Bootstrap
from flask import Flask
bootstrap = Bootstrap()
def create_app():
app = Flask(__name__)
bootstrap.init_app(app)
return app
在简单的示例程序中,或是在开发时,你可以使用它提供的两个快捷方法来生成Bootstrap资源引用代码,如下所示:
<head>
{{ bootstrap.load_css() }}
</head>
<body>
...
{{ bootstrap.load_js() }}
</body>
目前,Bootstrap-Flask一共提供了7个宏,分别用来快捷渲染各类Bootstrap页面组件,并提供了对扩展Flask-WTF、Flask-SQLAlchemy的支持。
| 宏 | 模板路径 | 说明 |
|---|---|---|
| render_field() | bootstrap/form.html | 渲染一个WTForms表单字段 |
| render_form() | bootstrap/form.html | 渲染一个WTForms表单类 |
| render_pager() | bootstrap/pagination.html | 渲染一个简单分页导航,包含上一页和下一页按钮 |
| render_pagination() | bootstrap/pagination.html | 渲染一个标准分页导航部件 |
| render_nav_item() | bootstrap/nav.html | 渲染一个导航条目 |
| render_breadcrumb_item() | bootstrap/nav.html | 渲染一个面包屑条目 |
| render_static() | bootstrap/utils.html |
渲染一个资源引用语句,即 <link>或<script>标签语句
|
使用方法相当简单,以渲染Flask-WTF(WTForms)的表单类的render_form()宏为例,你只需要从对应的模板路径导入宏,然后调用即可并传入必要的参数:
{% from 'bootstrap/form.html' import render_form %}
{{ render_form(form) }}
你可以在项目仓库的examples目录下找到一个完整的示例程序,示例程序的运行方式如下:
$ git clone https://github.com/greyli/bootstrap-flask.git $ pip install flask flask-wtf flask-sqlalchemy bootstrap-flask $ cd bootstrap-flask/examples $ flask run
现在访问http://localhost:5000即可看到示例程序主页。示例程序包含所有宏的实际调用示例,其中分页宏示例页面如下图所示:

分页宏示例
这个项目还刚刚起步,各方面都有需要完善的地方,近期我会为它编写一份完善的文档,欢迎有兴趣的朋友贡献代码!
这篇文章基于我在知乎上的这个回答,进行了相应的简化处理,放到这里做个备份。
答案在源码里。
WTForms会在你对表单实例调用Form.validate()方法时收集所有的行内验证方法,然后在对每个字段调用Field.validate()方法验证时将这些自定义行内验证方法一并和通过validators参数传入的验证器列表一起调用,进行验证。因为WTForms在调用时把对应的field作为参数传入了行内验证方法,所以你可以在自定义验证方法中通过field.data获取对应字段的数据。
WTForms会在你对表单实例调用Form.validate()方法时收集所有的行内验证方法。在Form类中的validate()方法定义中,你可以看到WTForms是如何收集这些行内验证方法的:
class Form(with_metaclass(FormMeta, BaseForm)):
....
def validate(self):
extra = {}
for name in self._fields:
inline = getattr(self.__class__, 'validate_%s' % name, None)
if inline is not None:
extra[name] = [inline]
return super(Form, self).validate(extra)
源码位置:https://github.com/wtforms/wtforms/blob/2.2.1/wtforms/form.py#L305-L308
这里迭代所有的字段属性,然后表单类中是否包含validate_字段名形式的方法。如果有,那么就添加到extra字段里,这个字段被传递到BaseForm类的validate()方法中。在BaseForm类的validate()方法中,WTForms迭代所有字段,并对每个字段调用Field.validate()方法验证字段,继续传入自定义的行内验证方法:
class BaseForm(object):
...
def validate(self, extra_validators=None):
self._errors = None
success = True
for name, field in iteritems(self._fields): # 迭代字段名和字段对象
if extra_validators is not None and name in extra_validators: # 判断当前迭代字段是否存在自定义验证器
extra = extra_validators[name]
else:
extra = tuple()
if not field.validate(self, extra): # 调用字段类的验证方法进行验证,传入自定义验证器
success = False
return success
源码位置:https://github.com/wtforms/wtforms/blob/2.2.1/wtforms/form.py#L134-L154
而在字段基类Field的validate()方法中,WTForms使用itertool模块提供的chain()函数把你实例化字段类时传入的验证器(self.validators)和自定义行内验证器(extra_validators)连接到一起:
class Field(object):
...
def validate(self, form, extra_validators=tuple()):
...
# Run validators
if not stop_validation:
chain = itertools.chain(self.validators, extra_validators) # 合并两类验证器
stop_validation = self._run_validation_chain(form, chain) # 运行验证器
...
源码位置:https://github.com/wtforms/wtforms/blob/2.2.1/wtforms/fields/core.py#L204-L206
连接起来的所有验证器赋值为chain变量,并传入到self._run_validation_chain(form, chain)进行进一步调用:
class Field(object):
...
def _run_validation_chain(self, form, validators):
for validator in validators:
try:
validator(form, self) # 传入字段类本身作为第二个参数
源码位置:https://github.com/wtforms/wtforms/blob/2.2.1/wtforms/fields/core.py#L226
这个方法迭代所有的验证器对字段数据进行验证。关键在于validator(form, self),可以看到这里传入了第二个参数self,即Field类本身,这也是为什么你可以在自定义验证方法中通过field.data获取当前字段数据。
很多朋友对《Flask Web开发实战》中的项目实例很感兴趣,这篇文章就来简单的对这些项目进行介绍,并给出一些截图。这几个项目的源码和在线Demo链接均可以在helloflask.com看到。
Hello, Flask!
第1~6章以及第13章的示例程序统一包含在helloflask仓库中的demos目录下。另外,这个仓库也作为《Flask Web开发实战》的仓库,书的勘误文件等内容也会一并在这里更新。
第7章:留言板 – SayHello
Say hello to the world.
这个项目比较简单,主要用来介绍项目组织和Web程序开发流程,没有复杂功能,介绍了虚拟数据的生成和时间日期的本地化。
A blue blog.
一个基础的博客程序,使用工厂函数和蓝本组织程序,主要包含下面这些功能点:
Capture and share every wonderful moment.
一个进阶的程序实例,主要包含下面的功能点:
We are todoist, we use todoism.
一个简单的待办事项程序,使用jQuery实现简单的单页效果,主要包含下面的功能点:
Chatroom for coders, not cats.
一个使用Flask-SocketIO实现的聊天室,主要包含下面这些功能点:
Create social share component in Jinja2 template based on share.js.
Flask-Share是一个基于share.js实现,可以在模板中方便的创建社交分享组件的扩展。
这篇文章大部分内容为《Flask Web开发实战》第10章的删减章节,另外摘取了部分书中现有的内容。我为这篇文章单独编写了示例程序,GitHub地址为https://github.com/helloflask/github-login。运行示例程序的步骤如下:
$ git clone https://github.com/helloflask/github-flask.git $ cd github-login $ pipenv install --skip-lock # 如果没有安装pipenv,那么执行pip install pipenv $ flask run # 在此之前需要在GitHub注册OAuth程序并将客户端ID与密钥写入程序,具体见下文
附注 第三方登录的原理是与第三方服务进行OAuth认证交互的,这里不会详细介绍OAuth,具体可以阅读OAuth官网列出的资源,另外即将上市的Flask新书里也提供了相关内容。
简单来说,为一个网站添加第三方登录指的是提供通过其他第三方平台账号登入当前网站的功能。比如,使用QQ、微信、新浪微博账号登录。对于某些网站,甚至可以仅提供社交账号登录的选项,这样网站本身就不需要管理用户账户等相关信息。对用户来说,使用第三方登录可以省去注册的步骤,更加方便和快捷。
如果项目和GitHub、开源项目、编程语言等方面相关,或是面向的主要用户群是程序员时,可以仅支持GitHub的第三方登录,比如Gitter、GitBook、Coveralls和Travis CI等。在Flask程序中,除了手动实现,我们可以借助其他扩展或库,我们在这篇文章里要使用的GitHub-Flask扩展专门用于实现GitHub第三方登录,以及与GitHub进行Web API资源交互。
起这个标题是为了更好理解,具体来说,整个流程实际上是指OAuth2中Authorization Code模式的授权流程。为了便于理解,这里按照实际操作顺序列出了整个授权流程的实现步骤:
和其他主流第三方服务相同,GitHub使用OAuth2中的Authorization Code模式认证。因为认证后,根据授权的权限,客户端可以获取到用户的资源,为了便于对客户端进行识别和限制,我们需要在GitHub上进行注册,获取到客户端ID和密钥才能进行OAuth授权。
在服务提供方的网站上进行OAuth程序注册时,通常需要提供程序的基本信息,比如程序的名称、描述、主页等,这些信息会显示在要求用户授权的页面上,供用户识别。在GitHub中进行OAuth程序注册非常简单,访问https://github.com/settings/applications/new填写注册表单(如果你没有GitHub账户,那么需要先注册一个才能访问这个页面。),注册表单各个字段的作用和示例如图所示。
表单中的信息都可以后续进行修改。在开发时,程序的名称、主页和描述可以使用临时的占位内容。但Callback URL(回调URL)需要正确填写,这个回调URL用来在用户确认授权后重定向到程序中。因为我们需要在本地开发时进行测试,所以需要填写本地程序的URL,比如http://127.0.0.1:5000/callback/github,我们需要创建处理这个请求的视图函数,在这个视图函数中获取回调URL附加的信息,后面会详细介绍。
注意 这里因为是在开发时进行本地测试,所以填写了程序运行的地址,在生产环境要避免指定端口。另外,在这里localhost和127.0.0.1将会被视为两个地址。在程序部署上线时,你需要将这些地址更换为真实的网站域名地址。
注册成功后,我们会在重定向后的页面看到我们的Client ID(客户端ID)和Client Secret(客户端密钥),我们需要将这两个值分别赋值给配置变量GITHUB_CLIENT_ID和GITHUB_CLIENT_SECRET:
GITHUB_CLIENT_ID = 'GitHub客户端ID' GITHUB_CLIENT_SECRET = 'GitHub客户端密钥'
注意 示例程序中为了便于测试,直接在脚本中写出了,在生产环境下,你应该将它们写入到环境变量,然后在脚本中从环境变量读取。
首先使用pip或Pipenv等工具安装GitHub-Flask:
$ pip install github-flask
和其他扩展类似,你可以使用下面的方式初始化扩展(注意扩展类大小写):
from flask import Flask from flask_github import GitHub app = Flask(__name__) github = GitHub(app)
如果你使用工厂函数创建程序,那么可以使用下面的方式初始化扩展:
from flask import Flask
from flask_github import GitHub
github = GitHub()
...
def create_app():
app = Flask(__name__)
app.config.from_pyfile('settings.py')
github.init_app(app)
...
return app
注意 虽然扩展名称是GitHub-Flask,但实际的包名称仍然是flask_github(Flask扩展名称可以倒置(即“Foo-Flask”),但包名称的形式必须为“flask_foo“。)。另外要注意扩展类的拼写,其中H为大写。
在示例程序中,我们首先进行了下面的基础工作:
app = Flask(__name__)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'secret string')
# Flask-SQLAlchemy
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///' + os.path.join(app.root_path, 'data.db'))
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
# 命令函数
@app.cli.command()
@click.option('--drop', is_flag=True, help='Create after drop.')
def initdb(drop):
"""Initialize the database."""
if drop:
db.drop_all()
db.create_all()
click.echo('Initialized database.')
# 存储用户信息的数据库模型类
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100)) # 用户名
access_token = db.Column(db.String(200)) # 授权完成后获取的访问令牌
# 管理每个请求的登录状态,如果已登录(session里有用户id值),将模型类对象保存到g对象中
@app.before_request
def before_request():
g.user = None
if 'user_id' in session:
g.user = User.query.get(session['user_id'])
# 登入
@app.route('/login')
def login():
if session.get('user_id', None) is None:
... # 进行OAuth授权流程,具体见后面
flash('Already logged in.')
return redirect(url_for('index'))
# 登出
@app.route('/logout')
def logout():
session.pop('user_id', None)
flash('Goodbye.')
return redirect(url_for('index'))
现在我们可以执行上面创建的initdb命令来创建数据库和表(确保当前目录在demos/github-login下):
$ flask initdb
我们在本节一开始详细描述了以GitHub为例的完整的OAuth授权的过程,现在让我们来创建登录按钮。示例程序非常简单,只包含一个主页(index.html),这个页面由index视图处理:
@app.route('/')
def index():
is_login = True if g.user else False # 判断用户登录状态
return render_template('index.html', is_login=is_login)
这个视图在渲染模板时传入了用于判断用户登录状态的is_login变量,我们在模板中根据这个变量渲染不同的元素,如果已经登入,显示退出按钮,否则显示登入按钮:
{% if is_login %}
<a class="btn" href="{{ url_for('logout') }}">Logout</a>
{% else %}
<a class="btn" href="{{ url_for('login') }}">Login with GitHub</a>
{% endif %}
在实际的项目中,你可以使用GitHub的logo来让登录按钮更好看一些。
提示 使用Flask-Login时,你可以直接在模板中通过current_user.is_authenticated属性来判断用户登入状态。
这个登录按钮的URL指向的是login视图,这个视图用来发送授权请求,如下所示:
@app.route('/login')
def login():
if session.get('user_id', None) is None: # 判断用户登录状态
return github.authorize(scope='repo')
flash('Already logged in.')
return redirect(url_for('index'))
在这个视图中,如果用户没有登录,我们就调用github.authorize()方法。这个方法会生成授权URL,并向这个URL发送请求。
附注 GitHub-Flask扩内置了除了客户端ID和密钥外所有必要的URL,比如API的URL,获取访问令牌的URL等(我们也可以通过相应的配置键进行修改,具体参考GitHub-Flask的文档)。
发起认证请求的URL中必须加入的参数是客户端ID,GitHub-Flask会自动使用我们之前通过配置变量传入的值。在授权URL中附加的可选参数如下所示:
|
名称 |
类型 |
说明 |
|
scope |
字符串 |
请求的权限列表,使用空格分隔 |
|
state |
字符串 |
用于CSRF保护的随机字符,也就是CSRF令牌 |
|
redirect_uri |
字符串 |
用户授权结束后的重定向URL(必须是外部URL) |
这三个参数都可以在调用github.authorize()方法时使用对应的名称作为关键字参数传入。
如果不设置scope,GitHub-Flask扩展默认设置为None,那么会拥有的权限是获取用户的公开信息。但是因为我们需要测试为项目加星(star)的操作,所以需要请求名为repo的权限值。
附注 选择scope时尽量只选择需要的内容,申请太多的权限可能会被用户拒绝。GitHub提供的所有的可用scope列表及其说明可以在GitHub开发文档看到。
如果不设置redirect_uri,那么GitHub会使用我们填写的callback URL。但是需要注意的是,如果我们填写了,那就必须和注册程序时填写的URL完全相同。我们在这里没有指定,因此将会使用注册OAuth程序时设置的http://localhost:5000/callback/github。
现在程序会重定向到GitHub的授权页面(会先要求登录GitHub),如下所示:

授权页面
当用户同意授权或拒绝授权后,GitHub会将用户重定向到我们设置的callback URL,我们需要创建一个视图函数来处理回调请求。如果用户同意授权,GitHub会在重定向的请求中加入code参数,一个临时生成的值,用于程序再次发起请求交换access token。程序这时需要向请求访问令牌URL(即https://github.com/login/oauth/access_token)发起一个POST请求,附带客户端ID、客户端密钥、code以及可选的redirect_uri和state。请求成功后的的响应会包含访问令牌(Access Token)。
很幸运,上面的一系列工作GitHub-Flask会在背后替我们完成。我们只需要创建一个视图函数,定义正确的URL规则(这里的URL规则需要和GitHub上填写的Callback URL匹配),并为其附加一个github.authorized_handler装饰器。另外,这个函数要接受一个access_token参数,GitHub-Flask会在授权请求结束后通过这个参数传入访问令牌,如下所示:
@app.route('/callback/github')
@github.authorized_handler
def authorized(access_token):
if access_token is None:
flash('Login failed.')
return redirect(url_for('index'))
# 下面会进行创建新用户,保存访问令牌,登入用户等操作,具体见后面
...
return redirect(url_for('chat.app'))
接受到GitHub返回的响应后,GitHub-Flask会调用这个authorized()函数,并传入access_token的值。如果授权失败,access_token的值会是None,这时我们重定向到主页页面,并显示一个错误消息。如果access_token不为None,我们会进行创建新用户,保存访问令牌,登入用户等操作,具体见下一节。
在获取到访问令牌后,我们需要做下面的工作:
在这个示例程序中,我们使用用户名(username)作为用户的唯一标识,为了从数据库中查找对应的用户,我们需要获取用户在GitHub上的用户名。
如果授权成功,那么我们就使用这个访问令牌向GitHub提供的Web API的/user端点发起一次GET请求。这可以通过GitHub-Flask提供的get()方法实现,传入访问令牌作为access_token参数的值。我们把表示用户的资源端点“user”传入get()方法,因为GitHub-Flask会自动补全完整的请求URL,即https://api.github.com/user。
response = github.get('user', access_token=access_token)
提示 GitHub-Flask提供了一系列方法来调用GitHub通过Web API开放的资源。和在jQuery为AJAX提供的方法类似,它提供了底层的request()方法和方便的get()、post()、put()、delete()等方法(这些方法内部会调用request方法),可以用来发送不同HTTP方法的请求。
/user端点对应用户资料,返回的JSON数据如下所示:
{
"avatar_url": "https://avatars3.githubusercontent.com/u/12967000?v=4",
"bio": null,
"blog": "greyli.com",
"company": "None",
"created_at": "2015-06-19T13:00:23Z",
"email": "withlihui@gmail.com",
"events_url": "https://api.github.com/users/greyli/events{/privacy}",
"followers": 132,
"followers_url": "https://api.github.com/users/greyli/followers",
"following": 8,
"following_url": "https://api.github.com/users/greyli/following{/other_user}",
"gists_url": "https://api.github.com/users/greyli/gists{/gist_id}",
"gravatar_id": "",
"hireable": true,
"html_url": "https://github.com/greyli",
"id": 12967000,
"location": "China",
"login": "greyli",
"name": "Grey Li",
"node_id": "MDQ6VXNlcjEyOTY3MDAw",
"organizations_url": "https://api.github.com/users/greyli/orgs",
"public_gists": 7,
"public_repos": 61,
"received_events_url": "https://api.github.com/users/greyli/received_events",
"repos_url": "https://api.github.com/users/greyli/repos",
"site_admin": false,
"starred_url": "https://api.github.com/users/greyli/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/greyli/subscriptions",
"type": "User",
"updated_at": "2018-06-24T02:05:38Z",
"url": "https://api.github.com/users/greyli"
}
附注 用户端点返回的响应示例以及其他所有开放的资源端点可以在GitHub的API文档(https://developer.github.com/v3/)中看到。
GitHub-Flask会把GitHub的JSON响应主体解析为一个字典并返回,我们使用对应的键获取这些数据。其中登录用户名使用login作为键获取:
username = response['login']
获取到用户名后,我们判断是否已存在该用户,如果存在更新access_token字段值;如果不存在则创建一个新的User实例,把用户名和访问令牌存储到用户模型的对应字段里:
user = User.query.filter_by(username=username).first()
if user is None:
user = User(username=username, access_token=access_token)
db.session.add(user)
user.access_token = access_token # update access token
db.session.commit()
最后,我们登入对应的用户对象或是新创建的用户对象(将用户id写入session):
flash('Login success.')
# log the user in
# if you use flask-login, just call login_user() here.
session['user_id'] = user.id
因为我们需要在其他视图里调用GitHub资源,为了避免每次都获取和传入访问令牌,我们可以使用github.access_token_getter装饰器创建一个统一的令牌获取函数:
@github.access_token_getter
def token_getter():
user = g.user
if user is not None:
return user.access_token
当你在某处直接使用github.get()等方法而不传入访问令牌时,GitHub-Flask会通过你提供的这个回调函数来获取访问令牌。
注意 虽然在很多开源库的示例程序中,都会把access令牌存储到session中,但session不能用来存储敏感信息(具体可以访问专栏的这篇文章了解)。所以除了作测试用途,在生产环境下正确的做法是把访问令牌存储到数据库中。
现在,我们的主页视图需要更新,对于登录的用户,我们将会显示用户在GitHub上的资料:
@app.route('/')
def index():
if g.user:
is_login = True
response = github.get('user')
avatar = response['avatar_url']
username = response['name']
url = response['html_url']
return render_template('index.html', is_login=is_login, avatar=avatar, username=username, url=url)
is_login = False
return render_template('index.html', is_login=is_login)
类似的,我们使用github.get()方法获取/user端点的用户资料,因为设置了令牌获取函数,所以不用显式的传入访问令牌值。这些数据(头像、显示用户名和GitHub用户主页URL)将会显示在主页,如下图所示:

登录成功后的主页
因为我们在进行授权时请求了repo权限,我们还可以对用户的仓库进行各类操作,示例程序中添加了一个加星的示例,如果你登录后点击主页的“Star HelloFlask on GitHub”按钮,就会加星对应的仓库。这个按钮指向的star视图如下所示:
@app.route('/star/helloflask')
def star():
github.put('user/starred/greyli/helloflask', headers={'Content-Length': '0'})
flash('Star success.')
return redirect(url_for('index'))
完整的用于处理回调请求的authorized()视图函数如下所示:
@app.route('/callback/github')
@github.authorized_handler
def authorized(access_token):
if access_token is None:
flash('Login failed.')
return redirect(url_for('index'))
response = github.get('user', access_token=access_token)
username = response['login'] # get username
user = User.query.filter_by(username=username).first()
if user is None:
user = User(username=username, access_token=access_token)
db.session.add(user)
user.access_token = access_token # update access token
db.session.commit()
flash('Login success.')
# log the user in
# if you use flask-login, just call login_user() here.
session['user_id'] = user.id
return redirect(url_for('index'))
一次完整的OAuth认证就这样完成了。在实际的项目中,支持第三方登录后,我们需要对原有的登录系统进行调整。通过第三方认证创建的用户没有密码,所以如果这部分用户使用传统方式登录的话会出现错误。我们添加一个if判断,如果用户对象的password_hash字段为空时,我们会返回一个错误提示,提醒用户使用上次使用的第三方服务进行登录,如下所示:
@app.route('/login', methods=['GET', 'POST'])
def login():
...
if request.method == 'POST':
...
user = User.query.filter_by(email=email).first()
if user is not None:
if user.password_hash is None:
flash('Please use the third patry service to log in.')
return redirect(url_for('.login'))
...
如果你想让用户也可以直接使用账户密码登录,那么可以在授权成功后重定向到新的页面请求用户设置密码。
《Flask Web开发实战:入门、进阶与原理解析》是我刚刚完成写作的一本技术书,涵盖了Flask Web开发学习的完整路径,而且包含大量的程序实例。你可以通过下面的文章了解这本书的更多信息:
本书动态: