Flask-框架秘籍-全-

Flask 框架秘籍(全)

译者:Liusple

来源:https://blog.csdn.net/liusple/category_7379896.html

第一章:Flask 配置

第一章将会帮助你去理解不同的 Flask 配置方法来满足每个项目各式各样的需求。

在这一章,将会涉及到以下方面:

  • 用 virtualenv 搭建环境
  • 处理基本配置
  • 基于类的配置
  • 组织静态文件
  • 用实例文件夹(instance floders)进行部署
  • 视图和模型的融合(composition)
  • 用蓝本(blueprint)创建一个模块化的 web 应用
  • 使用 setuptools 使 Flask 应用可安装

介绍

“Flask is a microframework for Python based on Werkzeug, Jinja2 and good intentions.”

何为微小?是不是意味着 Flask 在功能性上有所欠缺或者必须只能用一个文件来完成 web 应用?并不是这样!它说明的事实是 Flask 目的在于保持核心框架的微小但是高度可扩展。这使得编写应用或者扩展非常的容易和灵活,同时也给了开发者为他们的应用选择他们想要配置的余地,没有在数据库,模板引擎和其他方面做出强制性的限制。通过这一章你将会学到一些建立和配置 Flask 的方法。
开始 Flask 几乎不需要 2 分钟。建立一个简单的 Hello World 应用就和烤派一样简单:

from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello to the World of Flask!'

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

现在需要安装 Flask,这可以通过 pip 实现:

$ pip install Flask 

之前的一小段就是完整的基于 Flask 的 web 应用。导入的 Flask 类创建的实例是一个 web 服务器网关接口(Web Server Gateway Interface WSGI)应用。所以代码里的 app 成为了我们的 WSGI 应用。因为这个一个独立的模块,我们用__name__'__main__' 字符串做比较。如果我们将这些保存为名字是 app.py 的文件,这个应用可以使用下面的命令来运行:

$ python app.py 
 * Running on http://127.0.0.1:5000/ 

现在如果在浏览器中输入 http:/127.0.0.1:5000/,将会看见应用在运行。

提示

千万不要将你的文件保存为 flask.py,如果你这样做了,将会和导入的 Flask 冲突。

用 virtualenv 搭建环境

Flask 能够通过使用 pip 或者 easy_install 进行安装,但我们应该使用 virtualenv 来创建应用环境。通过为应用创建一个单独的环境可以防止全局 Python 被我们自定义的安装所影响。单独的环境是有用的,因为你可以多个应用程序有同一个库的多个版本,或者一些包可能有相同库的不同版本作为它们的依赖。virtualenv 在单独的环境里管理这些,不会让任何错误版本的库影响到任何其他应用。

怎么做

首先用 pip 安装 virtualenv,然后创建一个名字为 my_flask_env 的环境。这同时会创建一个相同名字的文件夹:

$ pip install virtualenv
$ virtualenv my_flask_env 

现在运行下面命令:

$ cd my_flask_env
$ source bin/activate
$ pip install flask 

这将激活环境并且在其中安装 Flask。现在可以在这个环境中对我们的应用做任何事情,而不会影响到任何其他 Python 环境。

原理

直到现在,我们已经使用 pip install flask 多次了。顾名思义,这个命令的意思是安装 Flask,就像安装其他 Python 包一样。如果仔细观察一下通过 pip 安装 Flask 的过程,我们将会看到一些包被安装了。下面是 Flask 包安装过程的一些摘要:

$ pip install -U flask
Downloading/unpacking flask
......
......
Many more lines......
......
Successfully installed flask Werkzeug Jinja2 itsdangerous markupsafe
Cleaning up... 
提示

在前面的命令中,-U 指的是安装与升级。这将会用最新的版本覆盖已经存在的安装。
如果观察的够仔细,总共有五个包被安装了,分别是 flask,Werkzeug,Jinja2,itsdangerous,markupsafe。Flask 依赖这些包,如果这些包缺失了,Flask 将不会工作。

其他

为了更美好的生活,我们可以使用 virtualenvwrapper。顾名思义,这是对 virtualenv 的封装,使得处理多个 virtualenv 更容易。

提示

记住应该通过全局的方式安装 virtualenvwrapper。所以需要停用还处在激活状态的 virtualenv,可以用下面的命令:

$ deactivate

同时,你可能因为权限问题不被允许在全局环境安装 virtualenvwrapper。这种情况下需要切换到超级用户或者使用 sudo。

可以用下面的命令来安装 virtualenvwrapper:

$ pip install virtualenvwrapper
$ export WORKON_HOME=~/workspace
$ source /usr/local/bin/virtualenvwrapper.sh 

在上面的代码里,我们安装了 virtualenvwrapper,创建了一个名字为 WORKON_HOME 的环境变量,同时给它赋值了一个路径,当用 virtualenvwrapper 创建虚拟环境时,虚拟环境将会安装在这个路径下面。安装 Flask 可以使用下面的命令:

$ mkvirtualenv flask
$ pip install flask 

停用虚拟环境,只需运行下面的命令:

$ deactivate 

激活已经存在的 virtualenv,可以运行下面的命令:

$ workon flask 

其他

参考和安装链接如下:

处理基本配置

首先想到的应该是根据每个需求去配置一个 Flask 应用。这一小节,我们将会去理解 Flask 不同的配置方法。

准备

在 Flask 中,配置能够通过 Flask 的一个名为 config 的属性来完成。config 是字典数据类型的一个子集,我们能够像字典一样修改它。

怎么做

举个例子,需要将我们的应用运行在调试模式下,需要写出下面这样的代码:

app = Flask(__name__)
app.config['DEBUG'] = True 
提示

debug 布尔变量可以从 Flask 对象而不是 config 角度来设置:

app.debug = True 

同样也可以使用下面这行代码:

app.run(debug=True) 

使用调试模将会使服务器在有代码改变的时候自动重载,同时它也在出错的时候提供了非常有用的调试信息。

Flask 还提供了许多配置变量,我们将会在相关的章节接触他们。
当应用越来越大的时候,就产生了在一个文件中管理这些配置的需要。在大部分案例中特定于机器基础的配置都不是版本控制系统的一部分。因为这些,Flask 提供了多种方式去获取配置。常用的几种是:

  • 通 pyhton 配置文件(*.cfg),通过使用:app.config.from_pyfile('myconfig.cfg')获取配置

  • 通过一个对象,通过使用:app.config.from_object('myapplication.default_settings')获取配置或者也可以使用:app.config.from_object(__name__) #从当前文件加载配置

  • 通过环境变量,通过使用:app.config.from_envvar('PATH_TO_CONFIG_FILE')获取配置

原理

Flask 足够智能去找到那些用大写字母写的配置变量。同时这也允许我们在配置文件/对象里定义任何局部变量,剩下的就交给 Flask。

提示

最好的配置方式是在 app.py 里定义一些默认配置,或者通过应用本身的任何对象,然后从配置文件里加载同样的配置去覆盖它们。所以代码看起来像这样:

app = Flask(__name__)
DEBUG = True
TESTING = True
app.config.from_object(__name__) #译者注:这句话作用是导入当前文件里定义的配置,比如 DEBUG 和 TESTING
app.config.from_pyfile('/path/to/config/file') 

基于类的配置

配置生产,测试等不同模式的方式是通过使用类继承。当项目越来越大,可以有不同的部署模式,比如开发环境,staging,生产等等,每种模式都有一些不同的配置,也会存在一些相同的配置。

怎么做

我们可以有一个默认配置基类,其他类可以继承基类也可以重载或者增加特定发布环境的配置变量。
下面是一个使用默认配置基类的例子:

class BaseConfig(object):
    'Base config class'
    SECRET_KEY = 'A random secret key'
    DEBUG = True
    TESTING = False
    NEW_CONFIG_VARIABLE = 'my value'

class ProductionConfig(BaseConfig):
    'Production specific config'
    DEBUG = False
    SECRET_KEY = open('/path/to/secret/file').read()

class StagingConfig(BaseConfig):
    'Staging specific config'
    DEBUG = True

class DevelopmentConfig(BaseConfig):
    'Development environment specific config'
    DEBUG = True
    TESTING = True
    SECRET_KEY = 'Another random secret key' 
提示

SECRET KEY 应该被存储在单独的文件里,因为从安全角度考虑,它不应该是版本控制系统的一部分。应该被保存在机器自身的本地文件系统,或者个人电脑或者服务器。

原理

现在,通过 from_object()可以使用任意一个刚才写的类来加载应用配置。前提是我们将刚才基于类的配置保存在了名字为 configuration.py 的文件里:

app.config.from_object('configuration.DevelopmentConfig') 

总体上,这使得管理不同环境下的配置更加灵活和容易。

提示

书源码下载地址:
pan.baidu.com/s/1o7GyZUi 密码:x9rw
download.csdn.net/download/liusple/10186764

组织静态文件

将 JavaScript,stylesheets,图像等静态文件高效的组织起来是所有 web 框架需要考虑的事情。

怎么做

Flask 推荐一个特定的方式组织静态文件:

my_app/
    - app.py
    - config.py
    - __init__.py
    - static/
        - css/
        - js/
        - images/
            - logo.png 

当需要在模板中渲染他们的时候(比如 logo.png),我们可以通过下面方式使用静态文件:

<img src='/statimg/logo.png'> 

原理

如果在应用根目录存在一个和 app.py 同一层目录的名字为 static 的文件夹,Flask 会自动的去读这个文件夹下的内容,而不需要任何其他配置。

其它

与此同时,我们可以在 app.py 定义应用的时候为应用对象提供一个名为 static_folder 的参数:

app=Flask(__name__, static_folder='/path/to/static/folder') 

在怎么做一节里的 img src 中,static 指的是这个应用 static_url_path 的值。可以通过下面方法修改:

app = Flask(
    __name__, static_url_path='/differentstatic',
    static_folder='/path/to/static/folder'
) 

现在,渲染静态文件,可以使用:

<img src='/differentstatic/logo.png'> 
提示

通常一个好的方式是使用 url_for 去为静态文件创建 URLS,而不是明确的定义他们:

<img src='{{ url_for('static', filename="logo.png") }}'> 

我们将会在下面章节看到更多这样的用法。

使用实例文件夹(instance folders)进行特定部署

Flask 也提供了高效管理特定部署的其他方式。实例文件夹允许我们从版本控制系统中费力出特定的部署文件。我们知道不同部署环境比如开发,生产,他们的配置文件是分开的。但还有很多其他文件比如数据库文件,会话文件,缓存文件,其他运行时文件。所以我们可以用实例文件夹像一个 holder bin 一样来存放这些文件。

怎么做

通常,如果在我们的应用里有一个名字问 instance 的文件夹,应用可以自动的识别出实例文件夹:

my_app/
    - app.py
    - instance/
        - config.cfg 

在应用对象里,我们可以用 instance_path 明确的定义实例文件夹的绝对路径:

app = Flask(
    __name__, instance_path='/absolute/path/to/instance/folder'
) 

为了从实例文件夹加载配置文件,可以在应用对象里使用 instance_relative_config 参数:

app = Flask(__name__, instance_relative_config=True) 

这告诉我们的应用从实例文件夹加载配置。下面的实例演示了它如何工作:

app = Flask(
    __name__, instance_path='path/to/instance/folder',
    instance_relative_config=True
)
app.config.from_pyfile('config.cfg', silent=True) 

原理

前面的代码,首先,实例文件夹从给定的路径被加载了,然后,配置从实例文件夹里一个名为 config.cfg 的文件中加载。silent=True 是可选的,用来在实例文件夹里没发现 config.cfg 时不报错误。如果 silent=True 没有给出,并且 config.cfg 没有找到,应用将失败,给出下面的错误:

IOError: [Errno 2] Unable to load configuration file (No such file or directory): '/absolute/path/to/config/file' 
提示

用 instance_relative_config 从实例文件夹加载配置好像是一个对于的工作,可以使用一个配置方法代替。但是这个过程的美妙之处在于,实例文件夹的概念是完全独立于配置的。

译者注:可以参考这篇博客理解实例文件夹。

视图和模型的结合(composition)

随着应用的变大,我们需要用模块化的方式组织我们的应用。下面我们将重构 Hello World 应用。

怎么做

  1. 首先在我们的应用里创建一个文件夹,移动所有的文件到这个新的文件夹里。
  2. 然后在新建的文件夹里创建一个名为__init__.py的文件,这将使得文件夹变成一个模块。
  3. 之后,在顶层目录创建一个新的名为 run.py 的文件。从名字可以看出,这个文件将会用来运行这个应用。
  4. 最后,创建单独的文件夹作为模块。

通过下面的文件结构可以更好的理解:

flask_app/
    - run.py
    - my_app/
        – __init__.py
        - hello/
            - __init__.py
            - models.py
            - views.py 

首先,flask_app/run.py文件里的内容看起来像这样:

from my_app import app
app.run(debug=True) 

然后,flask_app/my_app/__init__.py文件里的内容看起来像这样:

from flask import Flask
app = Flask(__name__)

import my_app.hello.views 

然后,存在一个空文件使得文件夹可以作为一个 Python 包,flask_app/my_app/hello/__init__.py:

# No content.
# We need this file just to make this folder a python module. 

模型文件,flask_app/my_app/hello/models.py,有一个非持久性的键值存储:

MESSAGES = {
    'default': 'Hello to the World of Flask!',
} 

最后是视图文件,flask_app/my_app/hello/views.py。这里,我们获取与请求键相对于的消息,并提供相应的服务创建或更新一条消息:

from my_app import app
from my_app.hello.models import MESSAGES

@app.route('/')
@app.route('/hello')
def hello_world():
    return MESSAGES['default']

@app.route('/show/<key>')
def get_message(key):
    return MESSAGES.get(key) or "%s not found!" % key

@app.route('/add/<key>/<message>')
def add_or_update_message(key, message):
    MESSAGES[key] = message
    return "%s Added/Updated" % key 
提示

记住上面的实例代码不能用在生产环境下。仅仅为了让 Flask 初学者更容易理解进行的示范。

原理

可以看到在my_app/__init__.pymy_app/hello/views.py之间有一个循环导入,前者从后者导入 views,后者从前者导入 app。所以,这实际上将会使得这两个模块相互依赖,但是在这里是没问题的,因为我们不会在my_app/__init__.py里使用 views。我们在文件的底部导入 views,所以它们不会被使用到。

在这个实例中,我们使用了一个非常简单的基于内存的非持久化键值对。当然我们能够在 views.py 文件里重写 MESSAGES,但是最好的方式是保持模型层和视图层的相互独立。

现在,可以用 run.py 就可以运行 app 了:

$ python run.py
* Running on http://127.0.0.1:5000/
* Restarting with reloader 
提示

上面加载信息表示应用正运行在调试模式下,这个应用将会在代码更改的时候重新加载。

现在可以看到我们在 MESSAGES 里面定义的默认消息。可以通过打开http://127.0.0.1:5000/show/default来看到这些消息。通过http://127.0.0.1:5000/add/great/Flask%20is%20great增加一个新的消息。这将会更新 MESSAGES 键值对,看起来像这样:

MESSAGES = {
    'default': 'Hello to the World of Flask!',
    'great': 'Flask is great!!',
} 

现在可以在浏览器打开http://127.0.0.1:5000/show/great,我们将会看到我们的消息,否则会看到一个 not-found 消息。

其他

下一章节,使用蓝图创建一个模块化的 web 应用,提供了一个更好的方式来组织你的 Flask 应用,也是一个对循环导入的现成解决方案。

使用蓝图(blueprint)创建一个模块化的 web 应用

蓝图是 Flask 的一个概念用来帮助大型应用真正的模块化。通过提供一个集中的位置来注册应用中的所有组件,使得应用调度变得简单。蓝本看起来像是一个应用对象,但却不是。它看上去更像是一个可插拔(pluggable)的应用或者是一个更大应用的一小部分。一个蓝本实际上是一组可以注册到应用上的操作集合,并且表示了如何构建一个应用。

准备

我们将会利用上一小节的应用做为例子,通过使用蓝图修改它,使它正常工作。

怎么做

下面是一个使用蓝图的 Hello World 例子。它的效果和前一章节相似,但是更加模块化和可扩展。
首先,从flask_app/my_app/__init__.py文件开始:

from flask import Flask
from my_app.hello.views import hello

app = Flask(__name__)
app.register_blueprint(hello) 

接下来,视图文件,my_app/hello/views.py,将会看起来像下面这些代码:

from flask import Blueprint
from my_app.hello.models import MESSAGES

hello = Blueprint('hello', __name__)

@hello.route('/')
@hello.route('/hello')
def hello_world():
    return MESSAGES['default']

@hello.route('/show/<key>')
def get_message(key):
    return MESSAGES.get(key) or "%s not found!" % key

@hello.route('/add/<key>/<message>')
def add_or_update_message(key, message):
    MESSAGES[key] = message
    return "%s Added/Updated" % key 

我们在flask_app/my_app/hello/views.py文件里定义了一个蓝本。我们不需要在这里使用任何应用对象,完整的路由是通过使用名为 hello 的蓝图定义的。我们用@hello.route 替代了@app.route。这个蓝本在flask_app/my_app/__init__.py被导入了,并且注册在了应用对象上。

我们可以在应用里创建任意数量的蓝图和做大部分的活动(activities),比如提供不同的模板路径和静态文件夹路径。我们甚至为蓝图创建不同的 URL 前缀或者子域。

原理

这个应用的工作方式和上一个应用完全一样。唯一的差别是代码组织方式的不同。

其他

  • 理解上一小节,视图和模型的组合,对理解这一章节有所帮助。

使用 setuptools 使 Flask 应用可安装

现在我们已经有了一个 Flask 应用了,但是怎么去像安装其他 Python 包一样来安装它呢?

怎么做

使用 Python 的 setuptools 库可以很容易使 Flask 应用可安装。我们需要在应用文件夹里创建一个名为 setup.py 的文件,并且配置它去为应用运行一个安装脚本。它将处理任何依赖,描述,加载测试包,等等。
下面是 Hello World 应用安装脚本 setup.py 的一个简单实例:

#!/usr/bin/env python
# -*- coding: UTF-8 -*-
import os
from setuptools import setup

setup(
    name = 'my_app',
    version='1.0',
    license='GNU General Public License v3',
    author='Shalabh Aggarwal',
    author_email='contact@shalabhaggarwal.com',
    description='Hello world application for Flask',
    packages=['my_app'],
    platforms='any',
    install_requires=[
        'flask',
    ],
    classifiers=[
        'Development Status :: 4 - Beta',
        'Environment :: Web Environment',
        'Intended Audience :: Developers',
        'License :: OSI Approved :: GNU General Public License v3',
        'Operating System :: OS Independent',
        'Programming Language :: Python',
        'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
        'Topic :: Software Development :: Libraries :: Python Modules'
    ],
) 

原理

前面的脚本里大部分的配置都是不言而喻的。当我们的应用可从 PyPI 可获取时,分类器(classifiers)是有用的。这将会帮助其他用户通过使用分类器(classiflers)来搜索我们的应用。
现在我们可以用 install 关键字来运行这个文件:

$ python setup.py install 

这将会安装我们的应用,并且也会安装在 install_requires 里提到的依赖,所以 Flask 和所有 Flask 的依赖都会被安装。现在这个应用可以在 Python 环境里像使用其他 Python 包一样来使用了。

第二章:使用 Jinja2 模板

这一章将会从 Flask 的角度来介绍 Jinja2 模板的基础知识;我们同时会学习怎么用模块化和可扩展的模板来创建应用。这一章,将会覆盖以下小节:

  • Bootstrap 布局
  • 块组合(block composition)和布局继承(layout inheritance)
  • 创建自定义的上下文处理器
  • 创建自定义的 Jinja2 过滤器
  • 为表单创建自定义宏(custom macro)
  • 高级日期和时间格式

介绍

在 Flask 中,我们完全可以不用第三方模板引擎写一个完整的 web 应用。举个栗子,看下面的代码;这是一个简单的包含 HTML 样式的 Hello World 应用:

from flask import Flask
app = Flask(__name__)

@app.route('/')
@app.route('/hello')
@app.route('/hello/<user>')
def hello_world(user=None):
    user = user or 'Shalabh'
    return '''
<html>
    <head>
        <title>Flask Framework Cookbook</title>
    </head>
    <body>
        <h1>Hello %s!</h1>
        <p>Welcome to the world of Flask!</p>
    </body>
</html>''' % user

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

在涉及上千行 HTML,JS 和 CSS 代码的大型应用中,使用上面编写方式可行吗?当然不!
这里,模板拯救了我们,因为我们能够保持模板独立来构建我们的视图代码。Flask 提供了对 Jinja2 的默认支持,不过我们可以使用任何其他合适的模板引擎。进一步来说,Jinja2 提供了许多额外的特性来使我们的模板更加强大和模块化。

Bootstrap 布局

大部分的 Flask 应用遵循一个特定的方式去布置模板。在这一小节,我们将会讨论 Flask 应用中推荐的布置模板的方式。

准备

通常,Flask 期待模板被放置在应用根目录下名为 templates 的文件夹中。如果这个文件夹是存在的,Flask 将会自动读取目录,使得在使用 render_template()的时候文件下的目标可获得,这些方式将在本书大量的使用。

怎么做

用一个小的应用来演示。这个应用和第一章的应用非常相似。首先需要做的是在 my_app 文件夹下新增一个名为 templates 的文件夹。这个应用结构看起来像下面这样:

flask_app/
    - run.py
    my_app/
        – __init__.py
        - hello/
            - __init__.py
            - views.py
        - templates
        - 

我们需要去对应用做些修改。视图文件my_app/hello/views.py中的 hello_world 方法将会看起来像这样:

from flask import render_template, request

@hello.route('/')
@hello.route('/hello')
def hello_world():
    user = request.args.get('user', 'Shalabh')
    return render_template('index.html', user=user) 

在前面的方法中,我们去查询 URL 查询 user 参数。如果找到,就使用它,如果没找到,就使用默认的值,Shalabh。然后这个值将会被传递到要呈现的模板上下文(context)中,也就是 index.html,稍后渲染后的模板会被加载。
第一步,my_app/templates/index.html模板将看起来像这样:

<html>
    <head>
        <title>Flask Framework Cookbook</title>
    </head>
    <body>
        <h1>Hello {{ user }}!</h1>
        <p>Welcome to the world of Flask!</p>
    </body>
</html> 

原理

现在在浏览器打开 URL:http://127.0.0.1:5000/hello,将会看到一个响应,像下面这样:

我们也可以传递参数 user 给 URL,比如:http://127.0.0.1:5000/hello?user=John,将会看到下面这个响应:

从 views.py 中可以看出,传递给 URL 的参数可以通过 request 的 request.args.get(‘user’)方法获得,然后传递给了模板上下文中,模板将使用 render_template 进行渲染。使用 Jinja2 占位符{{ user }}解析出这个参数,它的真实值来自于模板上下文中 user 变量值。占位符里放置的所有表达式都依赖于模板上下文。

其他
  • Jinja2 文档可以通过http://jinja.pocoo.org/获得。

块组合和布局继承

通常一个 web 应用将会有许多不同的页面。但,一个网站内大部分页面的头部和底部是差不多的。同样的,菜单也类似。实际上只有中心内容存在差别,剩下都是一样的。因为这些,Jinja2 提供了一个很好的模板间继承方式。

这是一个很好的实践去构建一个基础模板,包含网站的基本布局比如头部和尾部。

准备

这一小节,我们将会尝试去创建一个小的应用,它包含一个主页和商品页(就像我们看到的购物网站那样)。我们会使用 Bootstrap 去给模板做一个最简约的设计。Bootstrap 可以从http://getbootstrap.com下载。

在 models.py 有一些写死的产品数据。他们会在 views.py 被读取,通过 render_template()方法,他们会被当做上下文变量发送给模板。剩下的解析和显示是通过模板语言处理的,在这里就是 Jinja2。

怎么做

看一下项目结构:

flask_app/
    - run.py
    my_app/
        – __init__.py
        - product/
            - __init__.py
            - views.py
            - models.py
    - templates/
        - base.html
        - home.html
        - product.html
    - static/
        - js/
            - bootstrap.min.js
        - css/
            - bootstrap.min.css
            - main.css 

上面的结构中,static/css/bootstrap.min.cssstatic/js/bootstrap.min.js是可以从 Bootstrap 网站下载的标准文件。run.py 和之前一样。介绍一下应用其余的东西。首先,我们定义了模型,my_app/product/models.py。这一章节,我们会使用一个简单的非持久化的键值对存储。我们提前准备了一些写死的商品记录:

PRODUCTS = {
    'iphone': {
        'name': 'iPhone 5S',
        'category': 'Phones',
        'price': 699,
    },
    'galaxy': {
        'name': 'Samsung Galaxy 5',
        'category': 'Phones',
        'price': 649,
    },
    'ipad-air': {
        'name': 'iPad Air',
        'category': 'Tablets',
        'price': 649,
    },
    'ipad-mini': {
        'name': 'iPad Mini',
        'category': 'Tablets',
        'price': 549
    }
} 

接下来是视图文件,my_app/product/views.py。这里我们将会遵循蓝图方式去写应用。

from werkzeug import abort
from flask import render_template
from flask import Blueprint]
from my_app.product.models import PRODUCTS

product_blueprint = Blueprint('product', __name__)

@product_blueprint.route('/')
@product_blueprint.route('/home')
def home():
    return render_template('home.html', products=PRODUCTS)

@product_blueprint.route('/product/<key>')
def product(key):
    product = PRODUCTS.get(key)
    if not product:
        abort(404)
    return render_template('product.html', product=product) 

被传递到 Blueprint 构造函数中的蓝本的名字:product,会被添加到在这个蓝图里定义的端点(endpoints)中。看一下 base.html 代码。

提示

当想终止一个请求并给出特定的错误信息时,使用 abort()会很方便。Flask 提供了一些基本的错误信息页面,也可以根据需要自定义。我们将会在第四章创建自定义的 404 和 500 处理器章节看到相关用法。

应用的配置文件,my_app/__init__.py,将会看起来像这样:

from flask import Flask
from my_app.product.views import product_blueprint

app = Flask(__name__)
app.register_blueprint(product_blueprint) 

除了 Bootstrap 提供的 CSS 代码,我们有自定义的 CSS 代码,my_app/static/css/main.css:

body {
    padding-top: 50px;
}
.top-pad {
    pdding: 40px 15px;
    text-align: center;
} 

来看一下模板,第一个模板是所有模板的基础。所以可以被命名为 base.html,位置为my_app/templates/base.html

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Flask Framework Cookbook</title>
        <link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">
        <link href="{{ url_for('static', filename='css/main.css') }}" rel="stylesheet">
    </head>
    <body>
        <div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
            <div class="container">
                <div class="navbar-header">
                    <a class="navbar-brand" href="{{ url_for('product.home') }}">Flask Cookbook</a>
                </div>
            </div>
        </div>
        <div class="container">
            {% block container %}{% endblock %}
        </div>

        <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
        <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.0.0/jquery.min.js"></script>
        <script src="{{ url_for('static', filename='js/bootstrap.min.js') }}"></script>
    </body>
</html> 

前面大部分代码是 HTML 和 Jinja2 的语法,前一小节已经接触过了。需要指出的是如何使用 url_for()来获取蓝本 URLs。蓝本的名字将会被添加到所有的端点中。这是非常有用的,因为当我们的应用有大量的蓝本时,其中一些是可以有相似的 URLs。

主页,my_app/templates/home.html,我们遍历了所有产品和=并展示他们:

{% extends 'base.html' %}
{% block container %}
    <div class="top-pad">

        {% for id, product in products.items() %}
            <div class="well">
                <h2>
                    <a href="{{ url_for('products.product', key=id) }}">{{ product['name'] }}</a>
                    <small>$ {{ product['price'] }}</small>
                </h2>
            </div>
        {% endfor %}
    </div>
{% endblock %} 
译者注

书里原文写的是 products.iteritems(),运行会错误,Python3 下应为 products.items()

单独的产品页面,my_app/templates/product.html,看起来像这样:

{% extends 'home.html' %}
{% block container %}
    <div class="top-pad">
        <h1>{{ product['name'] }}
            <small>{{ product['category'] }}</small>
        </h1>
        <h3>$ {{ product['price'] }}</h3>
    </div>
{% endblock %} 

原理

在上面的模板结构中,我们可以看到使用了继承模式。base.html 对于所有其他模板而言是一个基础。home.html 从 base.html 继承而来,product.html 继承自 home.html。在 product.html 中,我们重写了在 home.html 中定义的 container 块。运行这个应用,输出看起来像这样:

前面的截图展示了主页的样子。注意浏览器中的 URL。产品页面看起来像这样:

其他

  • 下面两小节将扩展这个应用

创建一个自定义的上下文处理器(context processor)

有时,我们想要在模板里直接计算或者处理一个值。Jinja2 维持了一个宗旨:逻辑处理应该在视图里处理而不能在模板里,目的是保持模板的干净。在这样情况下使用上下文处理器会很方便。我们可以传递值到一个方法里,然后用 Python 进行处理,之后结果会被返回。因此,我们基本上只需在模板上下文里添加一个函数(得益于 Python 允许我们可以传递函数像传递其他对象一样)。

怎么做

让我们以这种格式展示产品名字的描述:Category / Prduct-name:

@product_blueprint.context_processor
def some_processor():
    def full_name(product):
        return '{0} / {1}'.format(product['category'], prodyct['name'])
    return {'full_name': full_name} 

一个上下文就是一个简单的字典,可以修改,增加或删除值。任何用@product_blueprint.context_processor 修饰的方法应该返回一个字典,用来更新实际的上下文。

我们可以像这样使用前面的上下文处理器:

{{ full_name(product) }} 

下面将这个处理器添加到应用中商品列表里(flask_app/my_app/templates/product.html):

{% extends 'home.html' %}

{% block container %}
    <div class="top-pad">
        <h4>{{ full_name(product) }}</h4>
        <h1>{{ product['name'] }}
            <small>{{ product['category'] }}</small>
        </h1>
        <h3>$ {{ product['price'] }}</h3>
    </div>
{% endblock %} 

这个 HTML 页面将看起来像这样:

其他

  • 通过阅读块组合和布局继承来理解这一小节中的上下文(context)。

创建一个自定义的 Jinja2 过滤器

看了前面小节,有经验的开发者可能认为使用上下文处理器来描述商品名字是愚蠢的。我们可以简单的写一个过滤器去得到相同的结果;同时会变得更简洁。使用过滤器去描述商品名字的代码看起来像这样:

@product_blueprint.template_filter('full_name')
def full_name_filter(product):
    return '{0} / {1}'.format(product['category'], product['name']) 

可以像下面这样使用它:

{{ product | full_name }} 

前面的代码和上一小节的效果是一样的。

译者注

template_filter()方法好像新版本的 Flask 已经取消了,应该使用 add_app_template_filter()替代。
所以注册过滤器代码得改为:

def full_name_filter(product):
    return '{0} / {1}'.format(product['category'], product['name'])

product_blueprint.add_app_template_filter(full_name_filter, 'full_name') 

怎么做

让事情变得高端一点,创建一个过滤器来基于本地语言格式化货币:

import ccy
from flask impor request

@app.template_filter('format_currenty')
def format_currency_filter(amount):
    currency_code = ccy.countryccy(request.accept_languages.best[-2:])
    return '{0} {1}'.format(currency_code, amount) 
译者注

同上,需改写为:

def format_currency_filter(amount):
    currency_code = ccy.countryccy(request.accept_languages.best[-2:])
    return '{0} {1}'.format(currency_code, amount)

product_blueprint.add_app_template_filter(format_currency_filter, 'format_currency') 
提示

request.accept_language 列表在请求里没有 ACCEPT-LANGUAGE 头的时候可能会无效。

前面一小段代码需要安装包:ccy:

$ pip install ccy 

这个过滤器将会获取最匹配当前浏览器配置的语言(我的是 en-US),然后从配置字符串里获取最后两个字符,然后根据最后两个字符表示的 ISO 国家代码去获取货币。

原理

这个过滤器可以在模板里这样使用:

<h3>{{ product['price'] | format_currenty }}</h3> 

结果看起来像这样:

为表单创建一个自定义的宏(macro)

宏允许我们去编写可以重复使用的 HTML 代码。它们类似于常规编程语言中的函数。我们可以传递参数给宏就像我们在 Python 中对函数做的那样,然后我们可以使用宏去处理 HTML 块。宏可以被调用任意次数,输出将会根据其中的逻辑而变化。

准备

在 Jinja2 中使用宏非常普遍的并且有很多使用案例。这里我们将看到如何创建一个宏和如何使用它。

怎么做

输入表单是 HTML 许多冗余代码中的一个。大部分字段(fields)都有相似的代码,仅仅是样式做了些修改。下面是一个宏,它在调用的时候创建输入字段。为了更好的服用,创建宏的方式最好方式是在一个单独的文件里进行,比如_helpers.html:

{% macro render_field(name, class='', value='', type='text') -%}
    <input type="{{ type }}" name="{{ name }}" class="{{ class }}" value="{{ value }}">
{%- endmacro %} 
提示

在/之前%之后的减号(-)将会消除代码块之前之后的空格,使 HTML 代码能容易阅读。

现在,这个宏使用前需导入:

{% from '_helpers.html' import render_field %} 

然后,使用方法如下:

<fieldset>
    {{ render_field('username', 'icon-user') }}
    {{ render_field('password', 'icon-key', type='password') }}
</fieldset> 

这是一个很好的实践是在不同文件里定义不同的宏来保持代码的简洁和增加代码的可读性。如果一个宏不能在当前文件之外访问,需要在名称前面加上一个下划线来命名宏。

高级的时间和日期格式

在 web 应用里格式化日期和时间是一个很痛苦的事情。使用 datetime 库在 Python 层面处理增加了开销,正确的处理时区也是非常复杂的。当存储到数据库时,我们都需要标准化时间戳到 UTC,但是需要向全时间用户展示的时候,时间戳每次都需要被处理。
更机智的方式是在客户端处理他们,也就是在浏览器。浏览器总是知道用户的准确时区,并能够正确的处理时间和日期。同时,减少应用不必要的消耗。为此,我们将使用 Moment.js。

准备

和其他 JS 库一样,我们的应用可以用下面的方式包含 Moment.js。我们仅仅需要将 moment.min.js 文件放置在 static/js 文件夹中。通过添加下面的代码和其他 JS 库,Moment.js 将在 HTML 中变得可用:

<script src="/static/js/moment.min.js"></script> 

基本的使用 Moment.js 的方法见下面代码。可以在浏览器的控制台使用它们:

>>> moment().calendar();
"Today at 4:49 PM"
>>> moment().endOf('day').fromNow();
"in 7 hours"
>>> moment().format('LLLL');
"Tuesday, April 15 2014 4:55 PM" 
译者注

导入 moment.min.js 最好在页头导入,如果在页尾导入,会出现 moment is not defined 的错误。

怎么做

在我们的应用里使用 Moment.js 最好的方式是用 Python 写一个修饰器(wrapper),然后通过 Jinja2 环境变量使用它。更多信息参见http://runnable.com/UqGXnKwTGpQgAAO7/dates-and-times-in-flask-for-python寻求更多信息:

from jinja2 import Markup

class momentjs(object):
    def __init__(self, timestamp):
        self.timestamp = timestamp

    # Wrapper to call moment.js method
    def render(self, format):
        return Markup("<script>\ndocument.write(moment(\"%s\").%s);\n</script>" %(self.timestamp.strftime("%Y-%m-%dT%H:%M:%S"), format))

    # Format time
    def format(self, fmt):
        return self.render("format(\"%s\")" % fmt)

    def calendar(self):
        return self.render("calendar()")

    def fromNow(self):
        return self.render("fromNow()") 

当我们需要的时候可以添加许多 Moment.js 方法到之前的类中。现在,在 app.py 文件中,我们设置这个类到 Jinja 环境变量中。

# Set jinja template global
app.jinja_env.globals['momentjs'] = momentjs 

可以在模板中像下面这样使用它:

<p>Current time: {{ momentjs(timestamp).calendar() }}</p>
<br/>
<p>Time: {{momentjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}}</p>
<br/>
<p>From now: {{momentjs(timestamp).fromNow()}}</p> 

其他

  • Moment.js 库的更多信息参见:http://momentjs.com/

第三章:Flask 中的数据模型

这一章将会覆盖任何应用中最重要的部分:和数据库的交互。本章中将介绍如何用 Flask 连接数据库系统,定义模型,查询数据。

本章将包含下面小节:

  • 创建一个 SQLAlchemy DB 实例
  • 创建一个基本的商品模型
  • 创建一个关系类别模型
  • 使用 Alembic 和 Flask-Migrate 实现数据库迁移(migration)
  • 用 Redis 建立模型数据索引
  • 使用非关系型数据库 MongoDB

介绍

Flask 被设计的足够灵活来支持任何数据库。最简单的方式是直接使用 sqlite3,sqlite3 它提供了 DB-API2.0 接口,不提供 ORM。sqlite3 使用 SQL 语句和数据库对话。这种方法不推荐用来构建大型应用,因为最终维护应用会变成一个噩梦。同样,用这种方法实际上是不存在模型的,所有的事情在视图函数中进行,比如在视图函数中编写查询语句去和数据库交互。

本章我们将使用广泛使用的 SQLAlchemy 为 Flask 应用创建一个 ORM 层。同时学习如何编写一个使用 NoSQL 数据库的 Flask 应用。

提示

ORM 的指的是对象关系映射(Object Relation Mapping/Modeling),抽象的表明了我们的应用数据如何存储,如何处理。强大的 ORM 使得设计和查询业务逻辑非常简单和简洁。

创建一个 SQLAlchemy DB 实例

SQLAlchemy 是一个 Python SQL 工具集,它提供了一个 ORM,可以灵活高效的处理 SQL,并且通过它能够感受到 Python 的面向对象特性。

准备

Flask-SQLAlchemy 是一个扩展,为 Flask 提供了 SQLAlchemy 接口。
安装 Flask-SQLAlchemy:

$ pip install flask-sqlalchemy 

使用 Flask-SQLAlchemy 首先要做的是设置应用配置参数,告诉 SQLAlchemy 数据库的位置:

app.config['SQLALCHENY_DATABASE_URI'] = os.environ('DATABASE_URI') 

SQLALCHEMY_DATABASE_URI 是数据库协议的组合,需要认证,需要数据库的名字。用 SQLite 举例,它看起来像这样:

sqlite:tmp/test.db 

用 PostgreSQL 举例,看起来像这样:

postgresql://yourusername:yourpassword@localhost/yournewdb. 

这个扩展提供了一个叫做 Model 的类,它用来为我们的应用定义模型。了解更多数据库 URLS 参见
http://docs.sqlalchemy.org/en/rel_0_9/core/engines.html#database-urls

提示

除了 SQLite,都需要安装独立的数据库。比如,如果需要使用 PostgreSQL,需要安装 psycopg2.

怎么做

用一个小应用进行演示。下面的小节也一直会使用这个应用。这里,我们将会看到如何创建一个 db 实例,和一些基本的 DB 命令。文件结构看起来像这样:

flask_catalog/
    - run.py
    my_app/
        - __init__.py 

首先从 flask_app/run.py 开始,这已经在书里见到很多次了:

from my_app import app
app.run(debug=True) 

然后是应用配置文件,flask_app/my_app/__init__.py:

from flask import Flask
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:tmp/test.sqlite'
db = SQLAlchemy(app) 
译者注

原书为 from flask.ext.sqlalchemy import SQLAlchemy,现已不建议这么使用。
原书为 sqlite:tmp/test.db,改为 sqlite:tmp/test.sqlite。

我们配置 SQLALCHEMY_DATABASE_URI 为一个特定的路径。然后我们创建了一个 SQLAlchemy 对象叫做 db。从名字可以看出,这个对象将会处理所有和 ORM 相关的活动。之前提到过,db 对象有一个名为 Model 的类,它提供了在 Flask 创建模型的基础。任何类都可以继承 Model 去创建模型,模型将作为数据库表。

现在如果在浏览器打开http://127.0.0.1:5000,我们看不到任何东西。因为应用里就没有东西。

更多

有时你可能需要一个单独的 SQLAlchemy db 实例可以被多个应用使用或者动态的创建应用。在这些情况下,我们不会讲一个 db 实例绑定在一个单独的应用上。这里我们必须和应用上下文一起工作以达到预期的结果。
这种情况下,使用 SQLAlchemy 注册方式将有所不同:

from flask import Flask
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

def create_app():
    app = Flask(__name__)
    db.init_app(app)
    return app 
提示

上面的方法也可以用来初始化其他 Flask 扩展,而且这在实际应用中是很通常的做法。

现在,所有使用全局 db 实例的操作都需要一个 Flask 上下文了:

Flask application context
>>> from my_app import create_app
>>> app = create_app()
>>> app.test_request_context().push()
>>> # Do whatever needs to be done
>>> app.test_request_context().pop()
Or we can use context manager
with app():
    # We have flask application context now till we are inside the with block 

其他

  • 下面章节将扩展当前的应用为一个完整的应用,以帮助我们更好的理解 ORM

创建一个基本的商品模型

这一小节,我们将创建一个应用帮助我们在网站目录中显示商品。它也可以用来向目录中添加商品也可以根据需要删除他们。从前面章节可以看到,也可以使用非持久化的存储,但是现在我们将数据存储在数据库里做持久化存储。

怎么做

文件夹目录看起来像这样:

flask_catalog/
    - run.py
    my_app/
        – __init__.py
        catalog/
            - __init__.py
            - views.py
            - models.py 

首先,修改应用配置文件,flask_catalog/my_app/__init__.py

from flask import Flask
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:tmp/test.sqlite'
db = SQLAlchemy(app)

from my_app.catalog.views import catalog
app.register_blueprint(catalog)

db.create_all() 

最后一句 db.create_all(),告诉应用在特定的数据库里创建所有的表。所以,当应用运行的时候,如果表不存在的话,所有的表将会创建。现在是时候去在flask_catalog/my_app/catalog/models.py中创建模型了:

from my_app import db

class Product(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(255))
    price = db.Column(db.Float)

    def __init__(self, name, price):
        self.name = name
        self.price = price

    def __repr__(self):
        return '<Product %d>' % self.id 

这个文件中,我们创建了一个叫做 Product 的模型,它有三个字段,id,name,price。id 是一个自增长的字段,它会存储记录的 ID 并且做为主键。name 是一个字符串类型的字段,price 是浮点类型的。

现在为视图添加一个新的文件,flask_catalog/my_app/catalog/views.py。在这个文件里我们有许多视图方法来处理商品模型和应用:

from flask import request, jsonify, Blueprint
from my_app import app, db
from my_app.catalog.models import Product

catalog = Blueprint('catalog', __name__)

@catalog.route('/')
@catalog.route('/home')
def home():
    return "Welocme to the Catolog Home." 

这个方法处理了主页看起来像什么样子。大多数情况下会在应用里使用模板进行渲染。我们稍后会继续讨论这个问题,现在看下下面的代码:

@catalog.route('/product/<id>')
def product(id):
    product = Product.query.get_or_404(id)
    return 'Product - %s, $%s' % (product.name, product.price) 

这个方法控制了当用户用商品特定 ID 进行搜索时的输出。我们用 ID 过滤商品,当商品被找到的时候返回它的信息;如果没有找到,产生一个 404 错误。看下面的代码:

@catalog.route('/products')
def products():
    products = Product.query.all()
    res = {}
    for product in products:
        res[product.id] = {
            'name': product.name,
            'price': str(product.price)
    }
    return jsonify(res) 

这个方法以 JSON 格式返回了所有商品信息。看下面代码:

@catalog.route('/product-create', methods=['POST',])
def create_product():
    name = request.form.get('name')
    price = request.form.get('price')
    product = Product(name, price)
    db.session.add(product)
    db.session.commit()
    return "Product created" 

这个方法控制了数据库中商品的创建。我们首先从 request 请求中获取信息,然后用这些信息创建一个 Product 实例。然后将这个 Product 实例添加到数据库会话中(session),然后提交保存这条记录到数据库。

原理

首先,数据库是空的没有任何商品。这可以通过在浏览器打开http://127.0.0.1:5000/products进行确认。页面上仅仅会显示一个{}。
现在,要去创建一个商品。为此我们需要发送一个 POST 请求,POST 请求可以很容易的通过使用 Python request 库实现:

>>> import requests
>>> requests.post('http://127.0.0.1:5000/product-create', data={'name': 'iPhone 5S', 'price': '549.0'}) 

想要确认商品是否在数据库里了,可以在浏览器里再一次输入http://127.0.0.1:5000/products。这次,它会以 JSON 的形式输出商品信息。

其他

  • 在下一小节,创建一个关系类别模型,中将会演示表之间的关系

创建一个关系类别模型

前一小节,我们创建了一个简单的商品模型。但是,在实际情况下,应用会更加复杂,各个表之间有各种各样的关系(relationships)。这些关系可以是一对一的,一对多的,多对一的,或者是多对多的。这一小节,我们将用例子去理解他们中的一些。

怎么做

假设我们每个商品类别可以有多个商品,但是每个商品至少有一个类别。让我们修改之前的一些代码,同时对模型和视图做出修改。在模型中我们增加了一个 Category 模型,在视图中,我们增加了新的方法去处理类别相关的调用。

首先修改 models.py,增加 Category 模型,并且修改 Product 模型:

from my_app import db

class Product(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(255))
    price = db.Column(db.Float)
    category_id = db.Column(db.Integer, db.ForeignKey('category.id'))
    category = db.relationship('Category', backref=db.backref('products', lazy='dynamic'))

    def __init__(self, name, price, category):
        self.name = name
        self.price = price
        self.category = category

    def __repr__(self):
        return '<Product %d>' % self.id 

在前面的 Product 模型中,注意新增加的两个字段 category_id 和 category。category_id 是 Category 模型的外键,category 代表关系表。从他们的定义可以看出一个是关系,另一个使用这个关系在数据库中存储外键值。这是一个从商品到类别的简单多对一的关系。同时,注意 category 字段中的 backref 参数;这个参数允许我们可以在视图里编写 category.prodycts 从 Category 模型获取商品。从另一端看这一个一对多的关系。考虑下面代码:

calss Category(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100))

    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return '<Category %d>' % self.id 

前面的代码是 Category 模型,它仅仅只有一个 name 字段。
现在修改 views.py 来适应模型的改变:

from my_app.catalog.models import Product, Category

@catalog.route('/products')
def products():
    products = Product.query.all()
    res = {}
    for product in products:
        res[product.id] = {
            'name': product.name,
            'price': product.price,
            'category': product.category.name
        }
    return jsonify(res) 

这里,我们只做了一个修改,在返回商品 JSON 信息的时候添加了 category 信息。看下面的代码:

@catalog.route('/product-create', methods=['POST',])
def create_product():
    name = request.form.get('name')
    price = request.form.get('price')
    categ_name = request.form.get('category')
    category = Category.query.filter_by(name=categ_name).first()
    if not category:
        category = Category(categ_name)
    product = Product(name, price, category)
    db.session.add(product)
    db.session.commit()
    return "Product created." 

看一下是如何在创建商品之前查找类别的。首先,使用请求中的类别名在已经存在的类别里进行搜索。如果找到了,就使用它进行商品的创建。否则,就创建一个新的类别。看下面的代码:

@catalog.route('/category-create', methods=['POST',])
def create_category():
    name = request.form.get('name')
    category = Category(name)
    db.session.add(category)
    db.session.commit()
    return 'Category created.' 

前面的是一个非常简单的使用请求里的 name 来创建类别的方法。看下面的代码:

@catalog.route('/categories')
def categories():
    categories = Category.query.all()
    res = {}
    for category in categoires:
        res[category.id] = {
            'name': category.name
        }
        for product in category.products:

            res[category.id]['products'] = {
                'id': product.id,
                'name': product.name,
                'price': product.price
            }
    return jsonify(res) 

上面代码有点复杂。首先从数据库里获取到所有的类别信息,然后遍历每个类别,获取所有的商品信息,然后用 JSON 的形式返回。

译者注

上面代码是存在问题的,它的原意是想列出每个类别下所有的商品,但是结果只能列出一个。可以改为:

@catalog.route('/categories')
def categories():
    categories = Category.query.all()
    res = {}
    for category in categories:
        res[category.id] = {
            'name': category.name
        }
        res[category.id]['products'] = []
        for product in category.products:
            p = {
                'id': product.id,
                'name': product.name,
                'price': product.price
            }
            res[category.id]['products'].append(p)
    return jsonify(res) 

使用 Alembic 和 Flask-Migrate 进行数据库迁移(migration)

现在我们想要在 Product 模型中添加一个新的叫做 company 的字段。一种方式是去通过使用 db.drop_all()和 db.create_all()删除数据库然后新建一个。但是,这种方法不能用于生产中。我们希望迁移我们的数据库到最新的模型,并保持所有数据完整。
为此,我们使用 Alembic,这是一个基于 Python 的工具来管理数据库迁移和使用 SQLAlchemy 作为底层引擎。Alembic 在很大程度上提供了自动迁移,但有一些限制(当然,我们不能期望任何工具是完美的)。我们使用一个叫做 Flask-Migrate 的扩展来简化迁移的过程。

准备

先安装 Flask-Migrate

$ pip install Flask-Migrate 

这个将会安装 Flask-Script 和 Alembic 还有其他一些依赖。Flask-Script 使得 Flask-Migrate 提供一些简单使用的命令行参数,这些参数对用户而言是一个高级别的抽象,并且隐藏了所有复杂的特性(如果需要的话事实上也不是很难的去自定义)。

怎么做

为了能够迁移,需要稍微去修改一下 app 定义,my_app/__init__.py看起来像这样:

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_script import Manager
from flask_migrate import Migrate, MigrateCommand

app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = 'sqlite:tmp/test.sqlite'
db = SQLAlchemy(app)
migrate = Migrate(app, db)

manager = Manager(app)
manager.add_command('db', MigrateCommand)

# import my_app.catalog.views
from my_app.catalog.views import catalog
app.register_blueprint(catalog)

db.create_all() 

同时,需要对 run.py 做些小改动:

from my_app import manager
manager.run() 

run.py 的改动是因为我们需要使用 Flask script manager 的方式去启动应用。script manager 同样提供了额外的命令行参数。在这个例子中我们将使用 db 做为命令行参数。
如果我们把 run.py 当做脚本运行时,给它传递–help 参数,终端这时候将会展示所有的选项,看起来像下面这样:

现在,运行这个应用,可以使用:

$ python run.py runserver 

初始化迁移,需要运行 init 命令:

$ python run.py db init 

之后当我们对模型做了更改,需要运行 migrate 命令:

$ python run.py db migrate 

为了将更改反映到数据库上,需要运行 upgrade 命令:

$ python run.py db upgrade 

原理

现在,修改商品模型,添加一个新的字段 company:

class Product(db.Model):
    # ...
    # Same product model as last recipe
    # ...
    company = db.Column(db.String(100)) 

migrate 的结果看起来像这样:

$ python run.py db migrate
INFO [alembic.migration] Context impl SQLiteImpl.
INFO [alembic.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added column 
    'product.company' Generating <path/to/application>/
    flask_catalog/migrations/versions/2c08f71f9253_.py ... done 

前面的代码,我们看到 Alembic 将新的模型和数据库进行比较,然后检测到 product 新增了 company 一列(由 Product 模型创建)。

相似的,upgrade 的的输出将看起来像这样:

$ python run.py db upgrade
INFO [alembic.migration] Context impl SQLiteImpl.
INFO [alembic.migration] Will assume non-transactional DDL.
INFO [alembic.migration] Running upgrade None -> 2c08f71f9253, empty message 

这里,Alembic 用之前检测到的迁移来升级数据库。可以看到输出中有一个 16 进制数。这代表了执行迁移的版本。
Alembic 内部使用它来追踪数据库表的更改。

用 Redis 建立模型数据索引

也许有些特性要去实现,但是不想对他们做持久化存储。所以,我们可以将他们存储在内存里保持一段时间,然后隐藏他们,举个例子,在网站上向访问者展示访问过的商品列表。

准备

我们将使用 Redis 来做到这些,使用下面命令安装 Redis:

$ pip install redis 

确保你的 Redis 服务器在运行,以便链接。安装和运行 Redis 服务器,参见:http://redis.io/topics/quickstart

然后我们需要和 Redis 连接。在my_app/__init__.py中添加下面代码可以做到这些:

from redis import Redis
redis = Redis() 

我们可以在应用中需要用到 Redis 的地方构造 redis,比如定义 app 的地方,或者在视图文件里。最好是在应用
文件中做,因为这样连接将在整个应用中保持打开,仅仅通过导入 redis 就能够在任何需要的地方使用,。

怎么做

我们将在 Redis 中设置一个集合来存储最近浏览过的商品。当我们浏览商品的时候会填充它。该记录将在 10 分钟后过期。对 views.py 做如下修改:

from my_app import redis

@catalog.route('/product/<id>')
def product(id):
    product = Product.query.get_or_404(id)
    product_key = 'product-%s' % product.id
    redis.set(product_key, product.name)
    redis.expire(product_key, 600)
    return 'Product - %s, $%s' % (product.name, product.price) 
提示

好的习惯是从配置文件获取 expire 时间,600。可以在my_app/__init__.py中设置该值,然后从这里获取。

在前面的方法中,注意 redis 对象的 set()和 expire()方法。首先,我们在 Redis 中使用 product_key 设置商品的 ID。然后,我们设置过期时间为 600 秒。

现在我们将查找缓存中还存活的键。然后获取和这些键相匹配的商品,之后返回他们:

@catalog.route('/recent-products')
def recent_products():
    keys_alive = redis.keys('product-*')
    products = [redis.get(k).decode("utf-8") for k in keys_alive]
    return jsonify({'products': products}) 
译者注

我运行代码的时候,因为 redis.get(k)获取到的字符串是 unicode 类型的,如果没有 decode(“utf-8”),jsonify 解析会出错,所以这里加上解码。

原理

当用户访问一个商品的时候就会有一条记录被存储,这条记录将保持 600 秒。下面的 10 分钟这个商品将被列在最近商品中,除非再一次被访问,然后再一次设置为 10 分钟。

使用非关系型数据库 MongoDB

有时,我们正在构建的应用程序中使用的数据可能根本不是结构化的,也可以是半结构化的,也可以是其模式随时间变化的数据。在这种情况下,我们将避免使用 RDBMS,因为它增加了痛苦,并且难以理解和维护。这时应该使用 NoSQL 数据库。
同时,在当前流行的开发环境下,由于开发速度快,不一定能够第一次设计出完美的结构(scheam)。NoSQL 提供了修改结构的灵活性,而不需要太多麻烦。
在生产环境中,数据库通常在短时间内增长到一个巨大的规模。这极大地影响了整个系统的性能。垂直和水平缩放技术(Vertical-and horizontal-scaling)也是可用的,但是非常昂贵。这些情况下,可以考虑使用 NoSQL 数据库,因为它就是为了这个目的而被设计的。NoSQL 数据库能够在大型集群上运行,并处理大量生成的高速数据,这使得它们在使用传统 RDBMS 处理伸缩性(scaling)问题时是一个不错的选择。
这里将使用 MongoDB 来演示 Flask 如何集成 NoSQL。

准备

Flask 有许多 MongoDB 的扩展。我们将使用 Flask-MongoEngine,因为它提供了一个非常好的抽象,让我们很容易理解。通过下面命令安装它:

$ pip install flask-mongoengine 

记住去开启 MongoDB 服务器,以便连接。更多安装和运行 MongoDB 的信息,可以参见http://docs.mongodb.org/manual/installation/

怎么做

下面使用 MongoDB 重写我们的应用。首先修改my_app/__init__.py:

from flask import Flask
from flask_mongoengine import MongoEngine
from redis import Redis

app = Flask(__name__)
app.config['MONGODB_SETTINGS']  = {'DB': 'my_catalog'}
app.debug = True
db = MongoEngine(app)

redis = Redis()

from my_app.catalog.views import catalog
app.register_blueprint(catalog) 
提示

现在我们使用 MONGODB_SETTINGS 的配置而不是通常 SQLAlchemy-centric 的配置。这里,我们只需指定数据库的名字就可以使用。首先,我们需要在 MongoDB 中创建数据库,使用下面命令:

>>> mongo
MongoDB shell version: 2.6.4
> use my_catalog
switched to db my_catalog 

接下来,我们将使用 MongoDB 字段创建一个 Product 模型。修改flask_catalog/my_app/catalog/models.py:

import datetime
from my_app import db

class Product(db.Document):
    created_at = db.DateTimeField(default=datetime.datetime.now, required=True)
    key = db.StringField(max_length=255, required=True)
    name = db.StringField(max_length=255, required=True)
    price = db.DecimalField()

    def __repr__(self):
        return '<Product %r>' % self.id 

其他

注意创建模型的 MongoDB 字段和前面小节使用的 SQLAlchemy 是相似的。这里取消了 ID 字段,我们设置了 created_at,这个字段将会存储记录创建的时间戳。

接下来是视图文件,flask_catalog/my_app/catalog/views.py:

from decimal import Decimal
from flask import request, Blueprint, jsonify
from my_app.catalog.models import Product

catalog = Blueprint('catalog', __name__)

@catalog.route('/')
@catalog.route('/home')
def home():
    return "Welcome to the Catalog Home."

@catalog.route('/product/<key>')
def product(key):
    product = Product.objects.get_or_404(key=key)
    return 'Product - %s, $%s' % (product.name, product.price)

@catalog.route('/products')
def products():
    products = Product.objects.all()
    res = {}
    for product in products:
        res[product.key] = {
            'name': product.name,
            'price': str(product.price)
        }
    return jsonify(res)

@catalog.route('/product-create', methods=['POST',])
def create_product():
    name = request.form.get('name')
    key = request.form.get('key')
    price = request.form.get('price')
    product = Product(
        name=name,
        key=key,
        price=Decimal(price)
    )
    product.save()
    return 'Product created.' 

你会发现这非常类似于基于 SQLAlchemy 模型创建的视图。仅仅存在一些差异,而且是很容易理解的。

第四章:视图的使用

对于任何 Web 应用程序,控制与 Web 请求的交互以及适当的响应来满足这些请求是非常重要的。这一章将讲解正确处理请求的各种方式,然后用最好的方式设计他们。

这一章将包含下面的小节:

  • 基于函数的视图和 URL 路由
  • 基于类的视图
  • URL 路由和商品分页
  • 渲染模板
  • 处理 XHR 请求
  • 优雅的装饰请求
  • 创建自定义的 404 和 500 处理
  • Flash 消息用于更好的用户反馈
  • 基于 SQL 的搜索

介绍

Flask 为我们的应用程序提供了几种设计和布局 URL 路由的方法。同时,它提供了基于类的方式(类可以被继承和根据需要进行修改)来处理视图,这种方式和函数一样简单。前面版本中,Flask 支持基于函数的视图。但是,受 Django 影响,在 0.7 版本的时候,Flask 介绍了一个热插拔(pluggable)视图的概念,这允许我们去创建类,然后编写类的方法。这使得构建 RESTful API 的过程非常简单。同时,我可以进一步探索 Werkzeug,使用更灵活但是稍复杂的 URL maps。事实上,大型应用程序和框架更喜欢使用 URL maps。

基于函数的视图和 URL 路由

这是 Flask 里最简单的编写视图的方法。我们仅仅需要编写一个方法然后用端点(endpoint)装饰它。

准备

为理解这一小节,可以从任何一个 Flask 应用开始。这个应用可以是一个新的,空的或者任何复杂的应用。我们需要做的是理解这一小节列出的方法。

怎么做

下面是三种最常用的方法来处理各种各样的请求,用简单的例子说明一下。

# A simple GET request
@app.route('/a-get-request')    
def get_request():
    bar = request.args.get('foo', 'bar')
    return 'A simple Flask request where foo is %s' % bar 

一个处理 GET 请求的例子看起来就像上面这样。我们检查 URL 查询参数是否含有一个叫 foo 的参数。如果有,就在响应中展示他们;否则使用默认值 bar。

# A simple POST request
@app.route('/a-post-request', method=['POST'])
def post_request():
    bar = request.form.get('foo', 'bar')
    return 'A simple Flask request where foo is %s' % bar 

和 GET 请求很相似,只有一点差别,路由(route)现在有了一个额外的参数:methods。同时,用 request.form 替换了 request.args,因为 POST 请求假定数据是以表单方式提交的。

提示

是否真有必要将 GET 和 POST 写在单独的方法里?当然不!

# A simple GET/POST request
@app.route('/a-request', methods=['GET', 'POST'])
def some_request():
    if request.method == 'GET':
        bar = request.args.get('foo', 'bar')
    else:
        bar = request.form.get('foo', 'bar')
    return 'A simple Flask request where foo is %s' % bar 

在这里,我们可以看到我们已经将前两种方法合并为一个,现在 GET 和 POST 都由一个视图函数处理。

原理

让我们试着理解前面的方法。
默认的,任何 Flask 视图函数仅仅支持 GET 请求。为了让处理函数支持其他请求,我们需要告诉 route()装饰器我们想要支持的方法。这就是我们在 POST 和 GET/POST 这两个方法中做的。

对于 GET 请求,request 对象将会查找 args,即 request.args.get(),对于 POST 方法,将查找 form,即 request.form.get()。
此外,如果我们尝试向只支持 POST 的方法发出 GET 请求,则请求将失败,导致 405 错误。所有方法都是如此。看下面的截图:

更多

有时,我们可能希望有一个 URL 映射模式,可以将带端点的 URL 规则定义在一个单独的地方,而不是分散在应用的各处。为此,我们不能用 route()装饰方法,应该像下面这样在应用对象定义路由:

def get_request():
    bar = request.args.get('foo', 'bar')
    return 'A simple Flask request where foo is %s' % bar

app = Flask(__name__)
app.add_url_rule('/a-get-request', view_func=get_request) 

确保为 view_func 分配的方法提供了正确的相对路径。

基于类的视图

Flask 在 0.7 版本介绍了热插拔(pluggable)视图的概念;这为现有的实现增加了很大的灵活性。我们可以用类的方式编写视图,这些视图可以用通用的方式编写,并允许继承。

准备

理解这一小节之前需理解上一小节。

怎么做

Flask 提供一个叫做 View 的类,我们可以继承它做自定义的处理。
下面是一个简单的 GET 请求例子:

from flask.views import View

class GetRequest(View):
    def dispatch_request(self):
        bar = request.args.get('foo', 'bar')
        return 'A simple Flask request where foo is %s' % bar

app.add_url_rule(
    '/a-get-request', view_func=GetRequest.as_view('get_request')
) 

为了同时满足 GET 和 POST 请求,我们可以编写以下代码:

from flask.views import View

class GetPostRequest(View):
    methods = ['GET', 'POST']

    def dispatch_request(self):
        if request.method == 'GET':
            bar = request.args.get('foo', 'bar')
        if request.method == 'POST':
            bar = request.form.get('foo', 'bar')
        return 'A simple Flask request where foo is %s' % bar

app.add_url_rule(
    '/a-request', view_func=GetPostRequest.as_view('a_request')
) 

原理

默认情况下,Flask 视图函数仅仅支持 GET 请求。基于类的视图也是如此。为了支持或者处理各种类型的请求,我们需要通过类 methods 属性来告诉我们的类,我们需要支持的 HTTP 方法。这正是我们在之前的 GET/POST 请求示例中所做的。
对于 GET 请求,request 将会查找 args,即 request.args.get(),对于 POST 将会查找 form,即 request.form.get()。
另外,如果我们试图对只支持 POST 的方法进行 GET 请求,则请求将失败,报 405 错误。其他方法也是如此。

更多

现在很多人认为不可能在 View 类里仅仅只声明 GET 和 POST 方法,然后 Flask 会处理剩余的东西。对于这个问题的回答是使用 MethodView。让我们用 MethodView 来写之前的片段:

from flask.views import MethodView

class GetPostRequest(MethodView):

    def get(self):
        bar = request.args.get('foo', 'bar')
        return 'A simple Flask request where foo is %s' % bar

    def post(self):
        bar = request.form.get('foo', 'bar')
        return 'A simple Flask request where foo is %s' % bar

app.add_url_rule(
    '/a-reqquest', view_func=GetPostRequest.as_view('a_request')
) 

其他

  • 参见前一小节,明白基于函数的视图和基于类的视图差别

URL 路由和商品分页

有时,我们可能需要解析不同部分的 URL 的各个部分。举个例子,我们的 URL 可以有一个整数部分,字符串部分,特定长度的字符串部分,斜杠等等。我们可以使用 URL 转换器(converters)解析 URL 中的所有这些组合。这一小节,我们将会看到如何做到这些。同时,我们将会学习如何使用 Flask-SQLAlchemy 扩展完成分页。

准备

我们已经看到了几个基本 URL 转换器的实例。这一小节,我们将会看到一些高级的 URL 转换器并学习如何使用它们。

怎么做

假设我们有一个 URL 路由像下面这样:

@app.route('/test/<name>')
def get_name(name):
    return name 

这里,http://127.0.0.1:5000/test/Shalabh里的 Shalabh 会被解析出来,然后传入到 get_name 方法的 name 参数中。这是一个 unicode 或者字符串转换器,是默认的,不需要显式地指定。

我们同样可以有一些特定长度的字符串。假设我们想要去解析一个 URL 包括一个国家码和货币码。国家码通常是两个字符长度,货币码是三个字符长度。可以像下面这样做:

@app.route('/test/<string(minlength=2,maxlength=3):code>')
def get_name(code):
    return code 

URL 中含有的 US 和 USD 都将被匹配,http://127.0.0.1:5000/test/USDhttp://127.0.0.1:5000/test/US处理方法类似。我们还可以通过 length 参数匹配准确的长度,而不是 minlength 和 maxlength。
我们可以用类似的方式解析整数:

@app.route('/test/<int:age>')
def get_age(age):
    return str(age) 

我们可以指定期望的最大值和最小值是多少,比如,@app.route('/test/<int(min=18,max=99):age>')。在前面的例子里,我们也可以解析 float 数,只需将 int 改为 float。

有时,我们希望忽略 URL 中的斜杠或者解析文件系统路径中 URL 或者其它 URL 路径。可以这样做:

@app.route('/test/<path:file>/end')
def get_file(file):
    return file 

如果接收到类似于http://127.0.0.1:5000/test/usr/local/app/settings.py/end的请求,usr/local/app/settings.py将会作为 file 参数传递到这个方法中。

给应用添加分页

在第三章,我们创建了一个处理程序来展示数据库里的商品列表。如果我们有成百上千个商品,将会在一个列表里展示所有的商品,这将花费一些时间。同时,如果我们想要在模板里渲染这些商品,我们不应该在一个页面里展示超过 10~20 个的商品。分页将有助于构建优秀的应用。

@catalog.route('/products')
@catalog.route('/products/<int:page>')
def products(page=1):
    products = Product.query.paginate(page, 10).items
    res = {}
    for product in products:
        res[product.id] = {
            'name': product.name,
            'price': product.price,
            'category': product.category.name
        }
    return jsonify(res) 

在前面的处理程序中,我们添加了一个新的 URL 路径,给它添加了一个 page 参数。现在,http://127.0.0.1:5000/productshttp://127.0.0.1:5000/products/1是一样的,他们都会返回数据库里的前 10 个商品。http://127.0.0.1:5000/products/2将会返回下 10 个商品,以此类推。

提示

paginate()方法接收三个参数,返回一个 Pagination 类的对象。三个参数是:

  • page:需要被列出的当前页码。
  • per_page:每页需要列出的条目数量。
  • error_out:如果该页没找到条目,将会报 404 错误。为了防止这个的发生,可以设置error_out为 False,这样将返回空的列表。

渲染模板

在编写了视图之后,我们肯定希望将内容呈现在模板上并得到底层数据库的数据。

准备

为了渲染模板,我们将使用 Jinja2 作为模板语言。参见第二章去深入理解模板。

怎么做

我们将继续使用前面的小节的商品目录应用程序。现在,我们修改视图来渲染模板,然后将数据库的信息展示在这些模板上面。
下面是修改过的 views.py 代码和模板。
首先开始修改视图,flask_catalog_template/my_app/catalog/views.py,在特定处理方法里渲染模板:

from flask import render_template

@catalog.route('/')
@catalog.route('/home')
def home():
    return render_template('home.html') 

注意 render_template()方法。当 home 方法被调用时,将会渲染 home.html。看下面的代码:

@catalog.route('/product/<id>') 
def product(id):
    product = Product.query.get_or_404(id)
    return render_template('product.html', product=product) 

这里,product.html 模板将会在模板中渲染 product 对象。看下面代码:

@catalog.route('/products')
@catalog.route('/products/<int:page>')
def products(page=1):
    products = Product.query.paginate(page, 10)
    return render_template('products.html', products=products) 

这里,products.html 模板将会用分页好的 product 列表对象进行渲染。看下面代码:

@catalog.route('/product-create', methods=['POST','GET'])
def create_product():
    # … Same code as before …
    return render_template('product-create.html') 

从前面的代码里可以看到,在这个例子里,新建的商品将被模板渲染。同样也可以使用 redirect(),但是我们将在下面的小节讲到它。现在看下面的代码:

@catalog.route('/category-create', methods=['POST',])
def create_category():
    # ... Same code as before ...
    return render_template('category.html', category=category)

@catalog.route('/category/<id>')
def category(id):
    category = Category.query.get_or_404(id)
    return render_template('category.html', category=category)

@catalog.route('/categories')
def categories():
    categories = Category.query.all()
    return render_template('categories.html', categories=categories) 

上面三个处理方法和之前讨论过的渲染商品模板过程类似。

下面是创建的所有模板。理解这些模板是如何编写出来的,是如何工作的,需要参见第二章。

flask_catalog_template/my_app/templates/home.html 看起来像这样:

{% extends 'base.html' %}
{% block container %}
    <h1>Welcome to the Catalog Home</h1>
    <a href="{{ url_for('catalog.products') }}">Click here to see the catalog</a>
{% endblock %} 

flask_catalog_template/my_app/templates/product.html 看起来像这样:

{% extends 'home.html' %}
{% block container %}
    <div class="top-pad">
        <h1>{{ product.name }}<small> {{ product.category.name}}</small></h1>
        <h4>{{ product.company }}</h4>
        <h3>{{ product.price }}</h3>
    </div>
{% endblock %} 

flask_catalog_template/my_app/templates/products.html 看起来像这样:

{% extends 'home.html' %}
{% block container %}
    <div class="top-pad">
        {% for product in products.items %}
            <div class="well">
                <h2>
                    <a href="{{ url_for('catalog.product', id=product.id)}}">{{ product.name }}</a>
                    <small>$ {{ product.price }}</small>
                </h2>
            </div>
        {% endfor %}
        {% if products.has_prev %}
            <a href="{{ url_for('catalog.products', page=products.prev_num) }}">
                {{"<< Previous Page"}}
            </a>
        {% else %}
            {{"<< Previous Page"}}
        {% endif %} |
        {% if products.has_next %}
            <a href="{{ url_for('catalog.products', page=products.next_num) }}"> 
                {{"Next page >>"}}
             </a>
        {% else %}
            {{"Next page >>"}}
        {% endif %}
    </div>
{% endblock %} 

flask_catalog_template/my_app/templates/category.html 看起来像这样:

{% extends 'home.html' %}
{% block container %}
    <div class="top-pad">
        <h2>{{ category.name }}</h2>
        <div class="well">
            {% for product in category.products %}
                <h3>
                    <a href="{{ url_for('catalog.product', id=product.id) }}">{{ product.name }}</a>
                    <small>$ {{ product.price }}</small>
                </h3>
            {% endfor %}
        </div>
    </div>
{% endblock %} 

flask_catalog_template/my_app/templates/categories.html 看起来像这样:

{% extends 'home.html' %}
{% block container %}
    <div class="top-pad">
        {% for category in categories %}
            <a href="{{ url_for('catalog.category', id=category.id) }}">
                <h2>{{ category.name }}</h2>
            </a>
        {% endfor %}
    </div>
{% endblock %} 

原理

我们的视图方法在最后都有一个render_template方法。这意味着在成功完成请求之后,将使用一些参数去渲染模板。

提示

注意在 products.html 是如何完成分页的。还可以进一步改进,显示两个导航链接之间的页面编号。建议你们自己探索。

处理 XHR 请求

Asynchronous JavaScript XMLHttpRequest (XHR),即熟知的 Ajax,Ajax 在过去的几年里已经成为了 web 应用重要的一部分。随着单页(one-page)应用和 JavaScript 应用框架,比如 AngularJS,BackboneJS 等的出现,web 发展技术呈现指数级增长。

准备

Flask 提供了一个简单的方法在视图函数里处理 XHR 请求。我们甚至可以对正常的 web 请求和 XHR 做通用化的处理。我们仅仅需要在 request 对象里寻找一个标志,来决定所需调用的方法。

我们将升级前面小节的商品目录应用来演示 XHR 请求。

怎么做

Flask request 对象含有一个标记叫做is_xhr,通过它可以判断请求是 XHR 请求还是一个简单的 web 请求。通常,当有一个 XHR 请求时,调用方希望结果返回的是 JSON 格式,这样可以用来在网页上正确的位置渲染内容,而不是重新加载整个页面。
所以,假设我们在主页上有一个 Ajax 请求来获取数据库中商品的数量。一种方式是将商品数量放进render_template()上下文。另一种方法是将此信息作为 Ajax 调用的一个响应。我们将使用第二种方式,以便理解怎么用 Flask 处理 XHR:

from flask import request, render_template, jsonify

@catalog.route('/')
@catalog.route('/home')
def home():
    if request.is_xhr:
        products = Product.query.all()
        return jsonify({'count': len(products)})
    return render_template('home.html') 
提示

将 XHR 处理和常规请求写在一个方法里将变得有些臃肿,因为随着应用的增长,XHR 和常规请求业务逻辑将有所不同。
在这些情况下,XHR 请求和常规请求需要分开。这甚至可以扩展到使用蓝图来保持 URL 的简洁。

前面的方法中,我们首先检查这是否是一个 XHR 请求。如果是,则返回 JSON 数据,否则像之前做的一样渲染 home.html。修改flask_catalog_template/my_app/templates/base.html,增加一个 scripts 块。这里展示的空块可以放置在包含 BootstrapJS 脚本的后面:

{% block scripts %}

{% endblock %} 

接下来,看一下flask_catalog_template/my_app/templates/home.html,这里发送了一个 Ajax 请求给了 home()处理程序,该处理程序检查该请求是否是 XHR。如果是,它从数据库取得商品的数量,然后以 JSON 的形式返回出去。看一下 scripts 块里的代码:

{% extends 'base.html' %}
{% block container %}
    <h1>Welcome to the Catalog Home</h1>
    <a href="{{ url_for('catalog.products') }}" id="catalog_link">
        Click here to see the catalog
    </a>
{% endblock %}

{% block scripts %}
<script>
$(document).ready(function(){
    $.getJSON("/home", function(data) {
        $('#catalog_link').append('<span class="badge">' + data.count + span>');
    });
});
</script>
{% endblock %} 

原理

现在主页包含了一个标记(badge),会展示数据库里商品的数量。此标记只在整个页面加载后才加载。当数据库商品数量非常巨大时,加载标记和加载页面其他内容的差别才会体现出来。
下面的截图显示了主页的样子:

译者注

如果没有出现商品数量,原因之一可能是 base.html 中引用的 jquery 是访问不到的,可以替换 jquery 地址为:
<script src="https://code.jquery.com/jquery-3.1.1.min.js"></script>

优雅的装饰请求

有一些人可能认为每次检查请求是否是 XHR,会让代码可读性变得很差。为了解决这个问题,我们有一个简单的解决方案。可以写一个装饰器为我们处理冗余代码。

准备

这一小节,我们将写一个装饰器。装饰器对于 Python 的初学者来说可能很陌生。如果是这样,参见http://legacy.python.org/dev/peps/pep-0318/来理解装饰器。

怎么做

下面是为了这一章所写的装饰器:

from functools import wraps

def template_or_json(template=None):
    """Return a dict from your view and this will either pass it to a template or render json. Use like:
    @template_or_json('template.html')
    """

    def decorated(f):
        @wraps(f)
        def decorated_fn(*args, **kwargs):
            ctx = f(*args, **kwargs)
            if request.is_xhr or not template:
                return jsonify(ctx)
            else:
                return render_template(template, **ctx)
        return decorated_fn
    return decorated 

这个装饰器做的就是之前小节中我们对 XHR 的处理,即检查请求是否是 XHR,根据结果是否决定是渲染模板还是返回 JSON 数据。

现在,让我们将装饰器用在 home()上:

@app.route('/')
@app.route('/home')
@template_or_json('home.html')
def home():
    products = Product.query.all()
    return {'count': len(products)} 

创建自定义的 404 和 500 处理

每个应用都会在某些情况下向用户抛出错误。这些错误可能是由于输入了一个错误的 URL(404),服务器内部错误(500),或者一个用户被禁止访问的(403)导致的。一个好的应用程序可以以交互的方式处理这些错误而不是显示一个丑陋的白色页面,这对大多数用户来说毫无意义。Flask 对所有的错误提供了一个容易使用的装饰器。

准备

Flask 对象 app 有一个叫做 errorhandler()的方法,这使得处理应用程序错误的方式更加美观和高效。

怎么做

看下面的代码:

@app.errorhandler(404)
def page_not_found(e):
    return render_template('404.html'), 404 

这里,我们创建了一个用 errorhandler()装饰的方法,当 404 Not Found 错误发生的时候它渲染了 404.html 模板。

下面是flask_catalog_template/my_app/templates/404.html的代码,在 404 错误发生的时候进行渲染。

{% extends 'home.html' %}
{% block container %}
    <div class="top-pad">
        <h3>Hola Friend! Looks like in your quest you have reached a location which does not exist yet.</h3>
        <h4>To continue, either check your map location (URL) or go back <a href="{{ url_for('catalog.home') }}">home</a></h4>
    </div>
{% endblock %} 

原理

如果输入了一个错误的 URL,比如http://127.0.0.1:5000/i-am-lost,我们将看到下面的样子:

类似地,我们可以为其他错误代码添加错误处理程序。

更多

还可以根据应用程序需求创建自定义错误并将其和错误代码和自定义错误输出绑定。可以通过下面代码做到这些:

class MyCustom404(Exception):
    pass

@app.errorhandler(MyCustom404)
def special_page_not_found(error):
    return rendera_template("errors/custom_404.html"), 404 

Flash 消息为了更好的用户反馈

所有 web 应用的重要一部分是良好的用户反馈。举个例子,当用户创建一个商品的时候,好的用户体验是提示用户商品已经创建好了。

准备

我们将向已经存在的商品目录应用程序添加 flash 消息功能。我们得确保为应用添加了一个密匙(secret key),因为会话(session)依赖于这个密匙,当缺失密匙的时候,flash 消息会出错。

怎么做

为了演示 flash 消息,我们将在创建商品时提示 flash 消息。首先,在flask_catalog_template/my_app/__init__.py添加一个密匙。

app.secret_key = 'some_random_key' 

现在修改flask_catalog_template/my_app/catalog/views.pycreate_product(),当创建商品的时候向用户提示一个消息。同时,这个处理程序做了一个小的修改,现在可以通过 web 接口使用 form 来创建产品:

from flask import flash

@catalog.route('/product-create', methods=['GET', 'POST'])
def create_product():
    if request.method == "POST":
        name = request.form.get('name')
        price = request.form.get('price')
        categ_name = request.form.get('category')
        category = Category.query.filter_by(name=categ_name).first()
        if not category:
            category = Category(categ_name)
        product = Product(name, price, category)
        db.session.add(product)
        db.session.commit()
        flash('The product %s has been created' % name, 'success')
        return redirect(url_for('catalog.product', id=product.id))
    return render_template('product-create.html') 

在前面的方法中,我们首先检查请求类型是否是 POST,如果是,我们继续进行商品创建,或者呈现表单来创建商品。同时,注意 flash 消息,它提醒用户一个商品创建成功了。flash()的第一个参数是要被显示的消息,第二个参数是消息的类型。我们可以使用消息类型中的任何合适的标识符。这稍后可以确定要显示的警告消息类型。
新增了一个模板;它包含了商品表单的代码。模板的路径是:flask_catalog_template/my_app/templates/product-create.html:

{% extends 'home.html' %}
{% block container %}
    <div class="top-pad">
        <form class="form-horizontal" method="POST" action="{{ url_for('catalog.create_product') }}" role="form">
            <div class="form-group">
                <label for="name" class="col-sm-2 control-label">Name</label>
                <div class="col-sm-10">
                    <input type="text" class="form-control" id="name" name="name">
                </div>
            </div>
            <div class="form-group">
                <label for="price" class="col-sm-2 control-label">Price</label>
                <div class="col-sm-10">
                    <input type="number" class="form-control" id="price" name="price">
                </div>
                </div>
            <div class="form-group">
                <label for="category" class="col-sm-2 control-label">Category</label>
                <div class="col-sm-10">
                    <input type="text" class="form-control" id="category" name="category">
                </div>
            </div>
            <button type="submit" class="btn btn-default">Submit</button>
        </form>
    </div>
{% endblock %} 

我们将修改我们的基础模板,flask_catalog_template/my_app/templates/base.html来支持 flash 消息。仅仅需要在 container 块前添加<div>里的代码:

<br/>
<div>
    {% for category, message in get_flashed_messages (with_categories=true) %}
        <div class="alert alert-{{category}} alert-dismissable">
            <button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button>
            {{ message }}
        </div>
    {% endfor %}
</div> 
提示

<div>容器,我们添加了一个机制显示 flash 消息,在模板中获取 flash 消息需使用get_flashed_messages()

原理

当访问http://127.0.0.1:5000/product-create的时候将看到下面截图中这样的表单:

填写表单点击 Submit。商品页顶部将显示一个提醒消息:

基于 SQL 的搜索

在任何应用中,能够基于某些标准在数据中搜索记录是很重要的。这一小节,我们将用 SQLAlchemy 完成基本的基于 SQL 的搜索。同样的方法也可以用来搜索任何其他数据库系统。

准备

开始的时候我们已经在商品目录应用程序中完成了一定程度的搜索。当展示商品页的时候,我们用 ID 搜索特定的商品。现在我们将进一步深入,在名称和类别的基础上进行搜索。

怎么做

下面是一个方法,将在目录应用程序里通过 name,price,company,category 搜索。我们可以搜索一个或多个标准(criterion)(除了 category,它仅能被单独搜索)。注意,对于不同的值有不同的表示形式。比如价格中的浮点数可以用相等进行搜索,但是在字符串的情况下可以使用相似进行搜索。同时,留意 join 是如何完成 category 的搜索的。这些方法在视图文件里完成,即,flask_catalog_template/my_app/catalog/views.py:

from sqlalchemy.orm.util import join
@catalog.route('/product-search')
@catalog.route('/product-search/<int:page>')
def product_search(page=1):
    name = request.args.get('name')
    price = request.args.get('price')
    company = request.args.get('company')
    category = request.args.get('category')
    products = Product.query
    if name:
        products = products.filter(Product.name.like('%' + name + '%'))
    if price:
        products = products.filter(Product.price == price)
    if company:
        products = products.filter(Product.company.like('%' + company + '%'))
    if category:
        products = products.select_from(join(Product, Category)).filter(Category.name.like('%' + category + '%'))
    return render_template(
        'products.html', products=products.paginate(page, 10)
    ) 

原理

输入一个 URL 进行商品的搜索,比如http://127.0.0.1:5000/product-search?name=iPhone。这将搜索名称为 iPhone 的商品,然后在 pruducts.html 模板上列出搜索结果。相似的,当需要的时候我们可以搜索 price 或者 company,或者 category。为了更好的理解,你可以尝试各种各样的组合。

提示

我们使用相同的产品列表页面来呈现搜索结果。使用 Ajax 实现搜索将非常有趣。这留给你自己完成。

第五章:使用 WTForms 处理表单

表单处理是任何应用程序中不可或缺的一部分。无数的案例说明任何 web 应用中表单的存在都是非常重要的。用户登录或者提交一些数据,或者需要从用户得到一些输入,这些都需要表单。和表单同样重要的是表单验证。以交互的方式向用户展示验证信息会提高用户体验。

这一章,将涉及以下小节:

  • SQLAlchemy 模型数据做为表单展现
  • 在服务器端验证字段
  • 创建一个通用的表单集
  • 创建自定义字段和验证
  • 创建自定义部件(widget)
  • 通过表单上传文件
  • CSRF 保护

介绍

web 应用中有许多设计和实现表单的方法。随着 Web2.0 的出现,表单验证和向用户展示验证信息变得非常重要。客户端验证可以在前端使用 JavaScript 和 HTML5 完成。服务端验证在增加应用安全方面扮演一个重要的角色,防止添加任何不正确的数据进入数据库。

WTForms 默认情况下给服务端提供了许多的字段,这加快了开发的速度减少了工作量。它同样提供了根据需要编写自定义验证器和自定义字段的灵活性。
我们这一章将使用一个 Flask 扩展,叫做 Flask-WTF(https://flask-wtf.readthedocs.org/en/latest/)。它集成了了 WTForms 和 Flask,为我们处理了大量我们需要做的事情,使得我们开发应用高效更安全。安装它:

$ pip install Flask-WTF 

SQLAlchemy 模型数据作为表单展现

首先,用 SQLAlchemy 模型创建一个表单。我们将用商品目录应用中的商品模型,然后给它添加在前端使用表单创建商品的功能。

准备

我们将用第四章的商品目录应用,为 Product 模型创建一个表。

怎么做

Product 模型看起来像 models.py 里这些代码:

class Product(db.Model):

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(255))
    price = db.Column(db.Float)
    category_id = db.Column(db.Integer, db.ForeignKey('category.id'))
    category = db.relationship('Category', backref=db.backref('products', lazy='dynamic'))
    company = db.Column(db.String(100)) 

现在,我们将创建一个 ProductForm 类来表示表单需要的字段,ProductForm 将继承由 flask_wtf 提供的 Form 类。

from flask_wtf import Form
from wtforms import TextField, DecimalField, SelectField

class ProductForm(Form):
    name = TextField('Name')
    price = DecimalField('Price')
    category = SelectField('Category', coerce=int) 

我们从 flask-wtf 扩展导入 Form。其他东西比如 fields 和 validators 都是直接从 wtforms 导入的。字段 Name 是 TextField 类型,它需要 text 数据,Price 是 DecimalField 类型,数据将会被解析为 Python 的十进制类型。设置 Category 类型为 SelectField,这意味着,当创建商品时,只能从之前创建好的类别里选择一个。

注意

注意在 category 字段里有一个叫做 coerce 的参数,它的意思是会在任何验证或者处理之前强制转化表单的输入为一个整数。在这里,强制仅仅意味着转换,由一个特定数据类型到另一个不同的数据类型。

views.py 中 create_product()处理程序需要修改:

from my_app.catalog.models import ProductForm

@catalog.route('/product-create', methods=['GET', 'POST'])
def create_product():
    form = ProductForm(request.form, csrf_enabled=False)
    categories = [(c.id, c.name) for c in Category.query.all()]
    form.category.choices = categories
    if request.method == 'POST':
        name = request.form.get('name')
        price = request.form.get('price')
        category = Category.query.get_or_404(
            request.form.get('category')
        )
        product = Product(name, price, category)
        db.session.add(product)
        db.session.commit()
        flash('The product %s has been created' % name, 'success')
        return redirect(url_for('catalog.product', id=product.id))
    return render_template('product-create.html', form=form) 

create_product()方法从 POST 请求中的 form 获取参数。这个方法会在 GET 请求时渲染一个空的表单,其中包含预先填充的选项。在 POST 请求中,表单数据将用来创建一个新的商品,并且当商品创建完成的时候,将会展示创建好的商品页。

注意

你将注意到使用form=ProductForm(request.form, csrf_enabled=False)时,我们设置 csrf_enabled 为 False。CSRF 是任何应用中重要的一部分。我们将在这章 CSRF 保护一节做详细讨论。

模板templates/product-create.html同样需要修改。WTForms 创建的 objects 对象提供了一个简单的方式去创建 HTML 表单,代码如下:

{% extends 'home.html' %}

{% block container %}
    <div class="top-pad">
        <form method="POST" action="{{ url_for('catalog.create_product') }}" role="form">
            <div class="form-group">{{ form.name.label }}: {{ form.name() }}</div>
            <div class="form-group">{{ form.price.label }}: {{ form.price() }}</div>
            <div class="form-group">{{ form.category.label }}: {{ form.category() }}</div>
            <button type="submit" class="btn btndefault">Submit</button>
        </form>
    </div>
{% endblock %} 

原理

在一个 GET 请求中,打开http://127.0.0.1:5000/product-create,我们将看到和下面截图类似的表单:

你可以填写这个表单去创建一个新的商品。

其他

  • 下一小节将帮助理解怎么验证我们刚刚创建的字段

在服务器端验证字段

现在有了表单和字段,我们必须验证他们以确保只有正确的数据存入数据库,并且提前处理这些错误,可以避免破坏数据库。这些验证通常可以用来防止 XSS 和 CSRF 攻击。WTForms 提供了许多字段类型,他们自身有默认验证。除了这些,还有一些验证器可以根据选择和需要使用。我们将使用他们其中的一些来进一步理解这个概念。

怎么做

在 WTForm 字段中很容易添加验证器。我们仅仅需要传递一个 validators 参数,它接收要实现的验证器列表。每个验证器有它自己的参数,这使得可以在很大程度上控制验证。
让我们使用 validations 来修改 ProductForm 类:

from decimal import Decimal
from wtforms.validators import InputRequired, NumberRange

class ProductForm(Form):
    name = TextField('Name', validators=[InputRequired()])
    price = DecimalField('Price', validators=[
        InputRequired(), NumberRange(min=Decimal('0.0'))
    ])
    category = SelectField(
        'Category', validators=[InputRequired()], coerce=int
    ) 

这里,在许多字段中添加了 InputRequired 验证器,它意味着这些字段是必须填写的,这些字段如果不填写,表单就不会被提交。

Price 字段有一个额外的验证器 NumberRange,并将 min 参数设置为了 0。这意味着,我们不能用小于 0 的值做为商品的价格。为了完成配合这些调整,我们得修改 create_product():

@catalog.route('/product-create', methods=['GET', 'POST'])
def create_product():
    form = ProductForm(request.form, csrf_enabled=False)
    categories = [(c.id, c.name) for c in Category.query.all()]
    form.category.choices = categories

    if request.method == 'POST' and form.validate():
        name = form.name.data
        price = form.price.data
        category = Category.query.get_or_404(form.category.data)
        product = Product(name, price, category)
        db.session.add(product)
        db.session.commit()
        flash('The product %s has been created' % name, 'success')
        return redirect(url_for('product', id=product.id))
    if form.errors:
        flash(form.errors, 'danger')
    return render_template('product-create.html', form=form) 
提示

form.errors 消息将会以 JSON 形式展示表单错误。可以用更好的形式向用户展示他们,这留给你们自己实现。

这里,我们修改了 create_product()方法去验证输入表单的值,并且检查了请求方法类型。在 POST 请求里,表单数据将先进行验证。如果因为一些原因验证失败了,这个页面将重新渲染一遍,并显示一些错误信息在上面。如果验证成功了,并且商品成功创建了,新建的商品将被展示出来。

原理

现在,试着不填写任何字段进行提交。一个错误警告消息会像下面进行展示:

尝试一些非法验证的不同组合,可以看到不同的错误消息提示。

更多

我们可以使用 validate_on_submit 替代既要检查请求类型是 POST 还是 PUT,还要进行表单验证的过程。之前代码是:

if request.method == 'POST' and form.validate(): 

可以用下面方法来替代:

if form.validate_on_submit(): 

创建一个通用的表单集

一个应用取决于设计和目的会存在各种各样的表单。其中大部分都有相同的字段并且有相同的验证器。我们有可能会想,我们能不能将这些共同的表单分离出来并且当需要的时候重用他们,这对于 WTForms 提供的表单定义的类结构来说,是可能的。

怎么做

在商品目录应用中,我们有两个表单,一个用于 Product,一个用于 Category。这些表单都有一个共同的字段:Name。我们可以为这个字段创建一个通用的表单,然后 Product 和 Category 可以使用这个通用表单而不是都去创建一个 Name 字段。通过下面代码,可以实现这个功能:

class NameForm(Form):
    name = TextField('Name', validators=[InputRequired()])

class ProductForm(NameForm):
    price = DecimalField('Price', validators=[
        InputRequired(), NumberRange(min=Decimal('0.0'))
    ])
    category = SelectField(
        'Category', validators=[InputRequired()], coerce=int
    )
    company = TextField('Company', validators=[Optional()])

class CategoryForm(NameForm):
    pass 

我们创建了一个通用的表单 NameForm。表单 ProductForm 和 CategoryForm,他们继承了 NameForm,默认有一个名为 Name 的字段。然后根据需要添加其他字段。

我们可以修改 category_create()方法去使用 CategoryForm 创建种类:

@catalog.route('/category-create', methods=['GET', 'POST'])
def create_category():
    form = CategoryForm(request.form, csrf_enabled=False)

    if form.validate_on_submit():
        name = form.name.data
        category = Category(name)
        db.session.add(category)
        db.session.commit()
        flash('The category %s has been created' % name, 'success')
        return redirect(url_for('catalog.category', id=category.id))
    if form.errors:
        flash(form.errors)
    return render_template('category-create.html', form=form) 

为了商品类别的创建,需要新增templates/category-create.html模板:

{% extends 'home.html' %}

{% block container %}
    <div class="top-pad">
        <form method="POST" action="{{ url_for('catalog.create_category') }}" role="form">
            <div class="form-group">{{ form.name.label }}: {{ form.name() }}</div>
            <button type="submit" class="btn btndefault">Submit</button>
        </form>
    </div>
{% endblock %} 
译者注

新版本 Flask 建议用 StringField 代替使用 TextField

原理

新增商品类别表单看起来像这样:

提示

这是演示如何使用通用表单的一个小例子。这种方法的实际好处可以在电子商务应用程序中看到,我们可以使用公共地址表单,然后可以将它们扩展到单独的计费账单和发货地址表单上。

创建自定义字段和验证

除了提供一些字段和验证器,Flask 也提供了创建自定义字段和验证器的灵活性。有时,我们需要解析一些表单参数,但是他们不能利用现有的字段来实现。这种情况下,我们需要自定义字段了。

怎么做

在我们的商品目录中,category 使用 SelectField,我们在 create_product()方法的 GET 请求中,填充了该字段。如果该字段可以自行填充将会变得很方便。我们在 models.py 里实现一个自定义的字段 :

class CategoryField(SelectField):

    def iter_choices(self):
        categories = [(c.id, c.name) for c in Category.query.all()]
        for value, label in categories:
            yield (value, label, self.coerce(value) == self.data)

    def pre_validate(self, form):
        for v, _ in [(c.id, c.name) for c in Category.query.all()]:
            if self.data == v:
                break
            else:
                raise ValueError(self.gettext('Not a valid choice'))

class ProductForm(NameForm):

    price = DecimalField('Price', validators=[
        InputRequired(), NumberRange(min=Decimal('0.0'))
    ])
    category = CategoryField(
        'Category', validators=[InputRequired()], coerce=int
    ) 

SelectField 实现了一个叫做iter_choices()的方法,这个方法使用choices参数提供的值列表填充表单值。我们重写了iter_choices()方法,从数据库里直接获取类别的值,这避免了在每次使用表单的时候每次都需要填写字段的麻烦。

提示

这里通过使用 CategoryField 的行为,同样可以使用 QuerySelectField 实现。参见http://wtforms.readthedocs.org/en/latest/ext.html#wtforms.ext.sqlalchemy.fields.QuerySelectField寻求更多信息。

views.py 里的 create_product()方法也需要修改。需移除下面两句:

categories = [(c.id, c.name) for c in Category.query.all()]
form.category.choices = categories 

原理

上面程序不会有任何视觉效果。唯一的更改是在表单中填充类别值,如上一节所解释的那样。

更多

我们刚刚看了如何自定义字段。相似的,我们可以自定义验证器。假设我们不允许有重复的类别。我们可以在模型里很轻松的实现该功能,现在让我们在表单里使用一个自定义验证器:

from wtforms.validators import ValidationError

def check_duplicate_category(case_sensitive=True):
    def _check_duplicate(form, field):
        if case_sensitive:
            res = Category.query.filter(Category.name.like('%' + field.data + '%')).first()
        else:
            res = Category.query.filter(Category.name.ilike('%' + field.data + '%')).first()
        if res:
            raise ValidationError(
                'Category named %s already exists' % field.data
            )
    return _check_duplicate

class CategoryForm(NameForm):
    name = TextField('Name', validators=[
        InputRequired(), check_duplicate_category()
    ]) 

我们用工厂方式(factory style)创建了一个装饰器,我们可以根据是否需要区分大小写来获得不同的验证结果。
我们甚至可以使用基于类的设计,这可以使验证器更加通用和灵活,这留给读者自行探索。

创建自定义控件(widget)

就像我们创建自定义字段和验证器一样,我们同样可以创建自定义控件。这些控件允许我们控制前端字段看起来像什么样子。每个字段类型都有一个与之关联的控件。WTForms 本身提供了许多基础的 HTML5 的控件。为了理解如何创建一个自定义控件,我们将转换填写商品类别的 select 控件为一个 radio 控件。我想很多人会说,可以直接使用 WTForms 提供的 radio 字段啊!这里我们仅仅尝试去理解并自己实现它。

怎么做

前面小节,我们创建了 CategoryField。这个字段使用了超类(superclass)Select 提供的 Select 控件。让我们用 radio 输入替换 select 控件:

from wtforms.widgets import html_params, Select, HTMLString

class CustomCategoryInput(Select):

    def __call__(self, field, **kwargs):
        kwargs.setdefault('id', field.id)
        html = []
        for val, label, selected in field.iter_choices():
            html.append(
                '<input type="radio" %s> %s' % (html_params(name=field.name, value=val, checked=selected, **kwargs), label)
            )
        return HTMLString(' '.join(html))

class CategoryField(SelectField):
    widget = CustomCategoryInput()

    # Rest of the code remains same as in last recipe Creating custom field and validation 

我们在CategoryField类中新增了叫做widget的类属性。这个widget指向了CustomCategoryInput,它处理该字段要呈现出来样子的 HTML 代码生成。CustomCategoryInput类有一个__call__方法,重写了iter_choices()提供的值,现在返回radio

原理

当打开http://127.0.0.1:5000/product-create,将会看到:

通过表单上传文件

通过表单上传文件是许多 Web 框架关注的问题。Flask 和 WTForms 使用了一个简洁的方式为我们处理了。

怎么做

首先需要一点配置。需要向应用配置提供一个参数:UPLOAD_FOLDER。这个参数告诉 Flask 上传文件被存储的位置。我们将实现一个存储商品图片的功能。

提示

一种存储商品图片的方式是以二进制的方式存储在数据库里。但是这种方式很低效的,在任何应用中都不推荐使用。我们应该总是将图片和其他文件存储在文件系统中,然后将他们的路径以字符串的形式存储在数据库中。

my_app/__init__.py新增下面配置:

import os

ALLOWED_EXTENSIONS = set(['txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'])
app.config['UPLOAD_FOLDER'] = os.path.realpath('.') + '/my_app/static/uploads' 
# 译者注

如果是在 windows 运行程序,需要处理反斜杠。简单方式是将/my_app/static/uploads 更改为\my_app\static\uploads,并且需要新建 uploads 文件夹,当然最好的处理方法是兼容 linux 和 windows 两种不同的文件路径处理方式。

提示

看一下app.config['UPLOAD_FOLDER']语句,我们存储图片到 static 里的一个子文件中。这将使得渲染图片变得非常容易。ALLOWED_EXTENSIONS语句被用来确保只有特定格式的文件才能被上传。这个列表仅仅用作演示,对于图片,我们可以过滤更多类型。

修改模型文件my_app/catalog/models.py

from wtforms import FileField

class Product(db.Model):
    image_path = db.Column(db.String(255))

    def __init__(self, name, price, category, image_path):
        self.image_path = image_path

class ProductForm(NameForm):
    image = FileField('Product Image') 

ProductFormimage字段FileField,和Productimage_path字段。这就是之前我们讨论的,在文件系统中存储图片,并在数据库中存储他们的路径。

现在修改文件my_app/catalog/views.py里的 create_product()方法来保存文件:

import os
from werkzeug import secure_filename
from my_app import ALLOWED_EXTENSIONS

def allowed_file(filename):
    return '.' in filename and filename.lower().rsplit('.', 1)[1] in ALLOWED_EXTENSIONS

@catalog.route('/product-create', methods=['GET', 'POST'])
def create_product():
    form = ProductForm(request.form, csrf_enabled=False)

    if form.validate_on_submit():
        name = form.name.data
        price = form.price.data
        category = Category.query.get_or_404(form.category.data)
        image = request.files['image']
        filename = ''
        if image and allowed_file(image.filename):
            filename = secure_filename(image.filename)
            image.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
        product = Product(name, price, category, filename)
        db.session.add(product)
        db.session.commit()
        flash('The product %s has been created' % name, 'success')
        return redirect(url_for('catalog.product', id=product.id))

    if form.errors:
        flash(form.errors, 'danger')
    return render_template('product-create.html', form=form) 

我们需要向模板templates/product-create.html新增 product-create 表单。修改表单标签定义来包含
enctype 参数,在 Submit 按钮前新增图片字段(或者表单里其他你感觉必要的地方):

<form method="POST" action="{{ url_for('create_product') }}" role="form" enctype="multipart/form-data">
    <!-- The other field definitions as always -->
    <div class="formgroup">
        {{ form.image.label }}: {{ form.image(style='display:inline;') }}
    </div> 

这个表单应该包含参数enctype="multipart/form-data",以便告诉应用该表单参数含有多个数据。

渲染存储在 static 文件夹中的图片非常容易。templates/product.html中需要显示图片的地方仅仅需增加 img 标记。

<img src="{{ url_for('static', filename='uploads/' + product.image_path) }}"/> 

原理

上传图片的页面将看起来像这样:

创建了商品之后,图片被显示出来像这样:

CSRF(Cross-site Request Forgery protection)保护

本章第一小节,我们已经知道了 CSRF 是 web 表单安全中重要的一部分。这里我们将讨论细节。CSRF 指的是跨站请求伪造,即一些人黑进了携带 cookie 的请求,然后使用它触发一些破坏性的活动。我们不会讨论 CSRF 的细节,因为网上有很多关于此的资源。我们将讨论 WTForms 怎么帮助我们防止 CSRF。Flask 默认不提供任何 CSRF 保护,因为这得从表单验证层面进行处理,而不是由 Flask 提供。我们可以使用 Flask-WTF 扩展处理这些。

提示

参加http://en.wikipedia.org/wiki/Cross-site_request_forgery了解更多 CSRF。

怎么做

Flask-WTF 默认情况下提供的表单是 CSRF 保护的。如果我们看一下之前的小节,可以看到我们明确的告诉表单不要开启 CSRF 保护。我们仅仅需要删除相应的语句就可以使能 CSRF。
所以,form = ProductForm(request.form, csrf_enabled=False)将变为form = ProductForm(request.form)
我们应用同样需要做些配置上的改动。

app.config['WTF_CSRF_SECRET_KEY'] = 'random key for form' 

默认情况下,CSRF key 和应用 secret key 是一样的。

当 CSRF 启动的时候,我们得在表单里提供一个额外的字段,这是一个隐藏的字段,包含了 CSRF token。WTForms 为我们处理隐藏的字段,我们仅需在表单里添加{{ form.csrf_token }}

<form method="POST" action="/some-action-like-create-product">
    {{ form.csrf_token }}
</form> 

很容易嘛!但是表单提交方式不仅这一种。我们同样会通过 AJAX 提交表单;实际上这比使用普通表单很普遍,这种形式也正取代传统 web 应用。
因为这个原因,我们得在应用配置里增加额外的一步:

from flask_wtf.csrf import CsrfProtect

# Add configurations
CsrfProtect(app) 

前面的配置将允许我们可以在模板的任何位置通过使用{{ csrf_token() }}获取 CSRF token。现在,有两种方式向 AJAX POST 请求添加 CSRF token。
一种方式是在 script 标签里获取 CSRF token,然后在 POST 请求中使用:

<script type="text/javascript">
    var csrfToken = "{{ csrf_token() }}";
</script> 

另外一种方式是在 meta 标签中渲染 token,然后在需要的地方使用它:

<meta name="csrf-token" content="{{ csrf_token() }}"/> 

两者之间的区别是,第一种方法可能会在多个地方存在重复,这要取决于应用里 script 标签的数量。

现在,向 AJAX POST 里添加 CSRF token,得先添加 X-CSRFToken 属性。这属性值可以通过之前两种方法里任一一种都可以取得。我们将用第二种方法做为例子:

var csrfToken = $('meta[name="csrf-token"]').attr('content');
$.ajaxSetup({
    beforeSend: function(xhr, settings) {
        if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type)) {
            xhr.setRequestHeader("X-CSRFToken", csrftoken)
        }
    }
}) 

这将确保在所有 AJAX POST 请求发出去之前都添加了 CSRF token。

原理

下面的截图显示了我们表单添加了 CSRF token 的样子:

token 是完全随机的,所有请求都是不同的。实现 CSRF-token 生成的方式有很多种,但这些已经超出了本书的范围,但还是鼓励读者去看一下是如何实现的,并且理解他们。

第六章:Flask 认证

认证是任何应用重要的部分,无论是 web,还是桌面,还是手机应用。每个应用都有处理它用户认证最好的方法。基于 web 的应用,尤其是 SaaS 应用,这一过程极其重要,因为这是应用安全与不安全的之间的界限。
这一章,将包含下面小节:

  • 基于 session 的简单认证
  • 使用 Flask-Login 扩展认证
  • 使用 OpenID 认证
  • 使用 Facebook 认证
  • 使用 Google 认证
  • 使用 Twitter 认证

介绍

Flask 为了保持简单和灵活,默认不提供认证机制。但是开发者可以根据每个应用的需求自己实现。
应用的用户认证可以通过多种方式完成。它可以通过使用简单的 session 完成,也可以通过更安全的 Flask-Login 扩展完成。同样也可以集成受欢迎的第三方服务比如 OpenID,或者 Facebook,Google 等等。这一章将看到这些方法的使用。

基于 session 的简单认证

在基于 session 的认证中,当用户第一次登陆后,用户信息被存储在服务器的 session 和浏览器的 cookie 中。之后,当用户打开应用时,存储在 cookie 中的用户信息将和服务器中的 seesion 做比较。如果 session 是存活的,用户将自动登陆。

注意

应用配置应该总是指定 SECRET_KEY,否则存储在 cookie 中的数据和服务器的 session 都将是明文,这样很不安全。

我们将自己完成一个简单的认证机制。

注意

这一小节完成的东西只是用来演示基本的认证的原理。这种方法不能用来任何生产环境中。

准备

我们从第五章的 Flask 应用开始。它使用了 SQLAlchemy 和 WTForms 扩展(详情见前一章)。

怎么做

在开始认证之前,我们需要一个模型来存储用户详细信息。首先在flask_authentication/my_app/auth/models.py里创建一个模型和表单:

from werkzeug.security import generate_password_hash,check_password_hash
from flask_wtf import Form
from wtforms import TextField, PasswordField
from wtforms.validators import InputRequired, EqulTo
from my_app import db

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(100))
    pwdhash = db.Column(db.String())

    def __init__(self, username, password):
        self.username = username
        self.pwdhash = generate_password_hash(password)

    def check_password(self, password):
        return check_password_hash(self.pwdhash, password) 

前面的代码是 User 模型,拥有两个字段:username 和 pwdhash。username 字段意思从名字可以看出。pwdhash 字段存储加了盐的密码,因为建议不要在数据库直接存储密码。

然后,创建两个表单:一个用于用户注册,一个用于登录。在 RegistrationForm 中,我们将创建两个 PasswordField,就像其他网站注册一样;目的是确保用户在两个字段里输入的密码一致:

class RegistrationForm(Form):
    username = TextField('Username', [InputRequired()])
    password = PasswordField(
        'Password', [
            InputRequired(), EqualTo('confirm', message='Passwords must match')
        ]
    )
    confirm = PasswordField('Confirm Password', [InputRequired()])

class LoginForm(Form):
    username = TextField('Username', [InputRequired()])
    password = PasswordField('Password', [InputRequired()]) 

然后,在flask_authentication/my_app/auth/views.py创建视图处理用户的注册和登录请求:

from flask import request, render_template, flash, redirect, url_for, session, Blueprint
from my_app import app, db
from my_app.auth.models import User, RegisterationForm, LoginForm

auth = Blueprint('auth', __name__)

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

@auth.route('/register', methods=['GET', 'POST'])
def register():
    if session.get('username'):
        flash('You are already logged in.', 'info')
        return rendirect(url_for('auth.home'))

    form = RegistrationForm(request.form)

    if request.method == 'POST' and form.validate():
        username = request.form.get('username')
        password = request.form.get('password')
        existing_username = User.query.filter_by(username=username).first()
        if existing_username:
            flash('This username has been already taken. Try another one.', 'warning')
            return render_template('register.html', form=form)
        user = User(username, password)
        db.session.add(user)
        db.session.commit()
        flash('You are now registered. Please login.', 'success')
        return redirect(url_for('auth.login'))

    if form.errors:
        flash(form.errors, 'danger')
    return render_template('register.html', form=form) 

前面的方法处理用户注册。在 GET 请求中,注册表单展示给了用户;表单需要填写用户名和密码。然后检查用户名是否已经被注册。如何用户名已经被注册,用户需要填写一个新的用户名。之后一个新的用户在数据库里被创建,然后重定向到登录页面。登录通过下面代码处理:

@auth.route('/login', methods=['GET', 'POST'])
def login():
    form = LoginForm(request.form)
    if request.method == 'POST' and form.validate():
        username = request.form.get('username')
        password = request.form.get('password')
        existing_user = User.query.filter_by(username=username).first()
        if not (existing_user and existing_user.check_password(password)):
            flash('Invalid username or password. Please try again.', 'danger')
            return render_template('login.html', form=form)
        session['username'] = username
        flash('You have successfully logged in.', 'success')
        return redirect(url_for('auth.home'))

    if form.errors:
        flash(form.errors, 'danger')
    return render_template('login.html', form=form) 

前面的方法处理了用户登录。在表单验证之后,我们首先检查用户名是否存在。如果不存在,用户需重新输入用户名。同样的,我们检查密码是否正确。如果不正确,用户需重新填写密码。如果所有的检查通过了,session 使用 username 作为键存储用户的用户名。如果 session 存在则表示用户已登录。现在看下面用户注销代码:

@auth.route('/logout')
def logout():
    if 'username' in session:
        session.pop('username')
        flash('You have successfully logged out.', 'success')

    return redirect(url_for('auth.home')) 

在理解了 login()方法后,前面的代码是很容易理解的。这里,我们从 session 中删除了 username,用户就自动注销了。

之后,我们将创建 register()和 login()用到的模板。
flask_authentication/my_app/templates/base.html模板几乎和第五章一样。唯一的区别是使用 catalog 的地方被 auth 替换了。
首先,我们将有一个简单的主页flask_authentication/my_app/templates/home.html,其中会根据用户是否注册和登录显示出不同的链接:

{% extends 'base.html' %}
{% block container %}
    <h1>Welcome to the Authentication Demo</h1>
    {% if session.username %}
        <h3>Hey {{ session.username }}!!</h3>
        <a href="{{ url_for('auth.logout') }}">Click here to logout</a>
    {% else %}
    Click here to <a href="{{ url_for('auth.login') }}">login</a> or
        <a href="{{ url_for('auth.register') }}">register</a>
    {% endif %}
{% endblock %} 

之后,创建一个注册页,flask_authentication/my_app/templates/register.html

{% extends 'home.html' %}
{% block container %}
    <div class="top-pad">
        <form method="POST" action="{{ url_for('auth.register') }}" role="form">
            {{ form.csrf_token }}
            <div class="form-group">{{ form.username.label }}: {{ form.username() }}</div>
            <div class="form-group">{{ form.password.label }}: {{ form.password() }}</div>
            <div class="form-group">{{ form.confirm.label }}: {{ form.confirm() }}</div>
            <button type="submit" class="btn btn-default"> Submit</button>
        </form>
    </div>
{% endblock %} 

最后,我们创建一个简单的登录页,flask_authentication/my_app/templates/login.html

{% extends 'home.html' %}
{% block container %}
    <div class="top-pad">
        <form method="POST" action="{{ url_for('auth.login') }}" role="form">
            {{ form.csrf_token }}
            <div class="form-group">{{ form.username.label }}: {{ form.username() }}</div>
            <div class="form-group">{{ form.password.label }}: {{ form.password() }}</div>
            <button type="submit" class="btn btn-default"> Submit</button>
        </form>
    </div>
{% endblock %} 

原理

看下面的截图,可以知道应用是如何工作的。
第一个截图是当打开http://127.0.0.1:5000/home时的主页:

这是用户未登录时的主页样子。

打开http://127.0.0.1:5000/register是注册页:

注册之后,打开ttp://127.0.0.1:5000/register可以看到登录页:

最后,用户登录后的主页http://127.0.0.1:5000/home看起来是:

使用 Flask-Login 扩展进行认证

前面一节,我们已经学习了如何完成基于 session 的认证。Flask-Login 是一个受欢迎的扩展,可以为我们以很好的方式处理很多东西,防止我们重新造轮子。它也不限制我们使用任何特定的数据库或者限制我们使用特定的字段/方法进行身份验证。它同样可以处理 Remember me 特性和账户找回等功能。

准备

我们可以修改上一小节创建的应用,来用 Flask-Login 扩展完成同样的功能。
开始之前,需安装扩展:

$ pip install Flask-Login 

怎么做

为了使用 Flask-Login,首先需修改应用配置,flask_authentication/my_app/__init__.py

from flask_login import LoginManager

# Do other application config

login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'login' 

从扩展导入LoginManager之后,我们创建了这个类的一个对象。然后,使用LoginManagerinit_app()方法配置 app 对象。之后,根据需要,login_manager还有很多配置可以设置。这里,我们演示一个基本的和必须的配置,即login_view,它表示登录请求的视图处理函数。我们甚至可以配置需要展示给用户的信息,我们 session 将会持续多久,应用处理登录使用的请求头等等。更多Flask-Login信息,参见https://flask-login.readthedocs.org/en/latest/#customizing-the-login-process

Flask-Login 需要我们在 User 模型里增加一些额外的方法:

def is_authenticated(self):
    return True

def is_active(self):
    return True

def is_anonymous(self):
    return False

def get_id(self):
    return self.id 
译者注

使用 flask_login 替换 flask_ext_login
原书为 return unicode(self.id),应为 return self.id

在前面的代码里,我们增加了四个方法,它们的解释在下面:

  • is_authenticated(): 这个方法通常返回 True。仅在我们不希望用户不被认证的时候返回 False。

  • is_active(): 这个方法通常返回 True。仅在我们封锁了或者禁止了一个用户的时候返回 False。

  • is_anonymous(): 这个方法用来表示一个用户不应该登录系统,应该作为一个匿名用户登录系统。对于正常登录的用户来说这个方法通常返回 False。

  • get_id(): 这个方法代表了认证用户的唯一 ID。这应该是一个 unicode 值。

接下来,我们得去修改my_app/views.py

from flask import g
from flask_login import current_user, login_user, logout_user, login_required
from my_app import login_manager

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

@auth.before_request
def get_current_user():
    g.user = current_user 

前面的方法中,@auth.before_request 装饰方法表示当收到每个请求时,在视图函数前调用该方法。这里我们记住了已经登录的用户:

@auth.route('/login', methods=['GET', 'POST'])
def login():
    if current_user.is_authenticated:
        flash('You are already logged in.')
        return redirect(url_for('auth.home'))

        # 这边好像有问题
        # Same block of code as from last recipe Simple session based authentication
        # Next replace the statement session['username'] = username by the one below
        login_user(existing_user)
        flash('You have successfully logged in.', 'success')
        return redirect(url_for('auth.home'))

    if form.errors:
        flash(form.errors, 'danger')
    return render_template('login.html', form=form)

@auth.route('/logout')
@login_required
def logout():
    logout_user()
    return redirect(url_for('auth.home')) 

login()方法中,在任何其他操作前,我们先检查current_user是否已认证。这里,current_user是一个代理,用来表示当前已登录的用户。在所有验证通过之后,使用login_user()方法进行用户登录。这个方法接收一个user对象并处理所有为登录用户而进行的会话活动。
现在,看logout方法,首先看到这个方法用login_required()装饰。这个装饰器确保这个方法执行前用户是登录的。它可以用在应用里的任何视图方法中。注销一个用户,我们需要去调用logout_user(),这将清除当前已登录用户的session,然后将用户从应用中注销。
我们不需要自己处理session。模板也存在一个小的改动。每当需要根据用户是登录状态时来显示一些内容,应该这样处理:

{% if current_user.is_authenticated %}
    ...do something...
{% endif %} 
译者注

原书为 current_user.is_authenticated(),这是错误的,应该去掉括号。
原书为 redirect(url_for(‘home’)),这是错误的,应为 redirect(url_for(‘auth.home’))。

原理

这一小节的演示效果和上一小节是一样的。仅仅是完成方式的不同。

更多

Flask-Login 使得实现 Remember me 特性相当简单。仅仅需要性 login_user()方法传递 remember=True。这将在用户电脑上保存一个 cookie,当 session 是存活的时候,Flask-Login 会自动登录。读者可以自行实现。

其他

  • Flask 提供了一个特殊的对象:g。可以阅读http://flask.pocoo.org/docs/0.10/api/#flask.g了解更多。

下面暂不进行翻译

使用 OpenID 认证

使用 Facebook 认证

使用 Google 认证

使用 Twitter 认证

第七章:构建 RESTful API

API,即应用编程接口,可以概括为应用对开发者的接口。就像用户有一个可以和应用沟通的可视化界面一样,开发者同样需要一个接口和应用交互。REST,即表现层状态转移,它不是一个协议或者标准。它只是一种软件架构风格,或者是为编写应用程序定义的一组约束,旨在简化应用程序内外接口。当 web 服务 API 遵循了 REST 风格进行编写时,它们就可以称为 RESTful API。RESTful 使得 API 和应用内部细节分离。这使得扩展很容易,并且使得事情变得简单。统一接口确保每个请求都得文档化。

提示

关于 REST 和 SOAP 哪个好存在一个争论。它实际上是一个主观问题,因为它取决于需要做什么。每个都有它自己的好处,应该根据应用程序的需要来进行选择。

这一章,我们将包含下面小节:

  • 创建一个基于类的 REST 接口
  • 创建一个基于扩展的 REST 接口
  • 创建一个 SQLAlchemy-independent REST API
  • 一个完整的 REST API 例子

介绍

从名字可以看出,表现层状态转移(REST)意味着可以分离 API 到逻辑资源,这些资源可以通过使用 HTTP 请求获得和操作,一个 HTTP 请求由 GET,POST,PUT,PATCH,DELETE 中的一个(还有其他 HTTP 方法,但这些是最常使用的)。这些方法中的每一个都有一个特定的意义。REST 的关键隐含原则之一是资源的逻辑分组应该是简单容易理解的,提供简单性和可移植性。
这本书到这里,我们一直在使用一个资源叫做 Product。让我们来看看怎么讲 API 调用映射到资源分离上:

  • GET /products/1:获取 ID 为 1 的商品
  • GET /products:获取商品列表
  • POST /products:创建一个新商品
  • PUT /products/1:更新 ID 为 1 的商品
  • PATCH /products/1:部分更新 ID 为 1 的商品
  • DELETE /products/1:删除 ID 为 1 的商品

创建一个基于类的 REST 接口

在第四章里我们看到了在 Flask 里如何使用基于类的视图。我们将使用相同的概念去创建视图,为我们应用提供 REST 接口。

准备

让我们写一个简单的视图来处理 Product 模型的 REST 接口。

怎么做

需要简单的修改商品视图,来继承 MethodView 类:

from flask.views import MethodView

class ProductView(MethodView):

    def get(self, id=None, page=1):
        if not id:
            products = Product.query.paginate(page, 10).items
            res = {}
            for product in products:
                res[product.id] = {
                    'name': product.name,
                    'price': product.price,
                    'category': product.category.name
                }
            # 译者注 加上这一句,否则会报错
            res = json.dumps(res)
        else:
            product = Product.query.filter_by(id=id).first()
            if not product:
                abort(404)
            res = json.dumps({
                'name': product.name,
                'price': product.price,
                'category': product.category.name
            })
        return res 

get()方法搜索 product,然后返回 JSON 结果。
可以用同样的方式完成 post(),put(),delete()方法:

def post(self):
    # Create a new product.
    # Return the ID/object of newly created product.
    return

def put(self, id):
    # Update the product corresponding provided id.
    # Return the JSON corresponding updated product.
    return

def delete(self, id):
    # Delete the product corresponding provided id.
    # Return success or error message.
    return 

很多人会想为什么我们没在这里写路由。为了包含路由,我们得像下面这样做:

product_view = ProductView.as_view('product_view')
app.add_url_rule('/products/', view_func=product_view, methods=['GET', 'POST'])
app.add_url_rule('/products/<int:id>', view_func=product_view, methods=['GET', 'PUT', 'DELETE']) 

第一句首先转换类为实际的视图函数,这样才可以用在路由系统中。后面两句是 URL 规则和其对应的请求方法。

译者注

测试时如果遇到/products/路由已经注册,原因可能是第四章已经定义了一个/products/视图函数,注释掉即可,或者修改这里的路由名称。

原理

MethodView 类定义了请求中的 HTTP 方法,并将名字转为小写。请求到来时,HTTP 方法匹配上类中定义的方法,就会调用相应的方法。所以,如果对 ProductView 进行一个 GET 调用,它将自动的匹配上 get()方法。

更多

我们还可以使用一个叫做 Flask-Classy 的扩展(https://pythonhosted.or/Flask-Classy)。这将在很大程度上自动处理类和路由,并使生活更加美好。我们不会在这里讨论这些,但它是一个值得研究的扩展。

创建基于扩展的 REST 接口

前面一节中,我们看到如何使用热插拔的视图创建一个 REST 接口。这里我们将使用一个 Flask 扩展叫做 Flask-Restless。Flask-Restless 是完全为了构建 REST 接口而开发的。它提供了一个简单的为使用 SQLAlchemy 创建的数据模型构建 RESTful APIs 的方法。这些生成的 api 以 JSON 格式发送和接收消息。

准备

首先,需安装 Flask-Restless 扩展:

$ pip install Flask-Restless 

我们借用第四章的程序构建我们的应用,以此来包含 RESTful API 接口。

提示

如果 views 和 handlers 的概念不是很清楚,建议在继续阅读之前,先去阅读第四章。

怎么做

通过使用 Flask-Restless 是非常容易向一个 SQLAlchemy 模型新增 RESTful API 接口的。首先,需向应用新增扩展提供的 REST API 管理器,然后通过使用 app 对象创建一个实例:

from flask_restless import APIManager
manager = APIManager(app, flask_sqlalchemy_db=db) 

之后,我们需要通过使用 manager 实例使能模型里的 API 创建。为此,需向 views.py 新增下面代码:

from my_app import manager

manager.create_api(Product, methods=['GET', 'POST', 'DELETE'])
manager.create_api(Category, methods=['GET', 'POST', 'DELETE']) 

这将在 Product 和 Category 模型里创建 GET,POST,DELETE 这些 RESTful API。通常,如果 methods 参数缺失的话,只支持 GET 方法。

原理

为了测试和理解这些是如何工作的,我们通过使用 Python requests 库发送一些请求:

>>> import requests
>>> import json
>>> res = requests.get("http://127.0.0.1:5000/api/category")
>>> res.json()
{u'total_pages': 0, u'objects': [], u'num_results': 0, u'page': 1} 
译者注

res.json()可能会从出错,可使用 res.text

我们发送了一个 GET 请求去获取类别列表,但是现在没有记录。来看一下商品:

>>> res = requests.get('http://127.0.0.1:5000/api/product')
>>> res.json()
{u'total_pages': 0, u'objects': [], u'num_results': 0, u'page': 1} 

我们发送了一个 GET 请求去获取商品列表,但是没有记录。现在让我们创建一个商品:

>>> d = {'name': u'iPhone', 'price': 549.00, 'category':{'name':'Phones'}}
>>> res = requests.post('http://127.0.0.1:5000/api/product', data=json.dumps(d), headers={'Content-Type': 'application/json'})
>>> res.json()
{u'category': {u'id': 1, u'name': u'Phones'}, u'name': u'iPhone', 
u'company': u'', u'price': 549.0, u'category_id': 1, u'id': 2, u'image_path': u''} 

我们发送了一个 POST 请求去创建一个商品。注意看请求里的 headers 参数。每个发给 Flask-Restless 的 POST 请求都应该包含这个头。现在,我们再一次搜索商品列表:

>>> res = requests.get('http://127.0.0.1:5000/api/product')
>>> res.json()
{u'total_pages': 1, u'objects': [{u'category': {u'id': 1, u'name': u'Phones'}, u'name': u'iPhone', u'company': u'', u'price': 549.0, u'category_id': 1, u'id': 1, u'image_path': u''}], u'num_results': 1, u'page': 1} 

我们可以看到新创建的商品已经在数据库中了。
同样需要注意的是,查询结果默认已经分好页了,这是优秀的 API 的标识之一。

更多

自动创建 RESTful API 接口非常的酷,但是每个应用都需要一些自定义,验证,处理业务的逻辑。
这使得使用 preprocessors 和 postprocessors 成为可能。从名字可以看出,preprocessors 会在请求被处理前运行,postprocessors 会在请求处理完,发送给应用前运行。它们被定义在 create_api()中,做为请求类型(GET,POST 等)映射,并且作为前处理程序或后处理程序的方法列表,用于处理指定的请求:

manager.create_api(
    Product,
    methods=['GET', 'POST', 'DELETE'],
    preprocessors={
        'GET_SINGLE': ['a_preprocessor_for_single_get'],
        'GET_MANY': ['another_preprocessor_for_many_get'],
        'POST': ['a_preprocessor_for_post']
    },
    postprocessors={
        'DELETE': ['a_postprocessor_for_delete']
    }
) 

单个或多个记录都可以调用 GET,PUT,PATCH 方法;但是它们各有两个变体(variants)。举个例子,前面的代码里,对于 GET 请求有 GET_SINGLE 和 GET_MANY。preprocessors 和 postprocessors 对于各自请求接收不同的参数,然后执行它们,并且没有返回值。参见https://flask-restless.readthedocs.org/en/latest/了解更多细节。

译者注

对 preprocessor 和 postprocessors 的理解,参见http://flask-restless.readthedocs.io/en/stable/customizing.html#request-preprocessors-and-postprocessors

创建一个 SQLAlchemy-independent REST API

在前一小节中,我们看到了如何使用依赖于 SQLAlchemy 的扩展创建一个 REST API 接口。现在我们将使用一个名为 Flask-Restful 的扩展,它是在 Flask 可插拔视图上编写的,并且独立于 ORM。

准备

首先,安装扩展:

$ pip install Flask-Restful 

我们将修改前面的商品目录应用,通过使用这个扩展增加一个 REST 接口。

怎么做

通常,首先要修改应用的配置,看起来像这样:

from flask_restful import Api   

api = Api(app) 

这里,app 是我们应用的对象/实例。
接下来,在 views.py 里创建 API。在这里,我们将尝试理解 API 的框架,更详细的实现在下一小节里:

from flask_restful import Resource
from my_app import api

class ProductApi(Resource):

    def get(self, id=None):
        # Return product data
        return 'This is a GET response'

    def post(self):
        # Create a new product
        return 'This is a POST response'

    def put(self, id):
        # Update the product with given id
        return 'This is a PUT response'

    def delete(self, id):
        # Delete the product with given id
        return 'This is a DELETE response' 

前面的 API 结构是很容易理解的。看下面代码:

api.add_resource(ProductApi, '/api/product', '/api/product/<int:id>') 

这里,我们为 ProductApi 创建路由,我们可以根据需要指定多条路由。

原理

我们将使用 Python requests 库在看这些是如何工作的,就像前一小节那样:

>>> import requests
>>> res = requests.get('http://127.0.0.1:5000/api/product')
>>> res.json()
u'This is a GET response'
>>> res = requests.post('http://127.0.0.1:5000/api/product')
>u'This is a POST response'
>>> res = requests.put('http://127.0.0.1:5000/api/product/1')
u'This is a PUT response'
>>> res = requests.delete('http://127.0.0.1:5000/api/product/1')
u'This is a DELETE response' 

在前面一小段代码中,我们看到了我们的请求被相应的方法处理了;从回复中可以确认这一点。

其他

  • 确保在继续向下阅读之前先阅读完这一小节

一个完整的 REST API 例子

这一小节,我们将上一小节的 API 框架改写为一个完整的 RESTful API 接口。

准备

我们将使用上一小节的 API 框架作为基础,来创建一个完整的 SQLAlchemy-independent RESTful API。尽管我们使用 SQLAlchemy 作为 ORM 来进行演示,这一小节可以使用任何 ORM 或者底层数据库进行编写。

怎么做

下面的代码是 Product 模型完整的 RESTful API 接口。views.py 看起来像这样:

from flask_restful import reqparse

parser = reqparse.RequestParser()
parser.add_argument('name', type=str)
parser.add_argument('price', type=float)
parser.add_argument('category', type=dict) 

前面的一小段代码,我们为希望在 POST,PUT 请求中解析出来的参数创建了 parser。请求期待每个参数不是空值。如果任何参数的值是缺失的,则将使用 None 做为值。看下面代码:

class ProductApi(Resource):

    def get(self, id=None, page=1):
        if not id:
            products = Product.query.paginate(page, 10).items
        else:
            products = [Product.query.get(id)]
        if not products:
            abort(404)
        res = {}
        for product in products:
            res[product.id] = {
                'name': product.name,
                'price': product.price,
                'category': product.category.name
            }
        return json.dumps(res) 

前面的 get 方法对应于 GET 请求,如果没有传递 id,将返回商品分好页的商品列表;否则,返回匹配的商品。看下面 POST 请求代码:

def post(self):
    args = parser.parse_args()
    name = args['name']
    price = args['price']
    categ_name = args['category']['name']
    category = Category.query.filter_by(name=categ_name).first()
    if not category:
        category = Category(categ_name)
    product = Product(name, price, category)
    db.session.add(product)
    db.session.commit()
    res = {}
    res[product.id] = {
        'name': product.name,
        'price': product.price,
        'category': product.category.name,
    }
    return json.dumps(res) 

前面 post()方法将在 POST 请求时创建一个新的商品。看下面代码:

def put(self, id):
    args = parser.parse_args()
    name = args['name']
    price = args['price']
    categ_name = args['category']['name']
    category = Category.query.filter_by(name=categ_name).first()
    Product.query.filter_by(id=id).update({
        'name': name,
        'price': price,
        'category_id': category.id,
    })
    db.session.commit()
    product = Product.query.get_or_404(id)
    res = {}
    res[product.id] = {
        'name': product.name,
        'price': product.price,
        'category': product.category.name,
    }
    return json.dumps(res) 

前面代码,通过 PUT 请求更新了一个已经存在的商品。这里,我们应该提供所有的参数,即使我们仅仅想更新一部分。这是因为 PUT 被定义的工作方式就是这样。如果我们想要一个请求只传递那些我们想要更新的参数,这应该使用 PATCH 请求。看下面代码:

def delete(self, id):
    product = Product.query.filter_by(id=id)
    product.delete()
    db.session.commit()
    return json.dumps({'response': 'Success'}) 

最后同样重要的是,DELETE 请求将删除匹配上 id 的商品。看下面代码:

api.add_resource(
    ProductApi,
    '/api/product',
    '/api/product/<int:id>',
    '/api/product/<int:id>/<int:page>'
) 

上一句代码是我们的 API 可以容纳的所有 URL 的定义。

提示

REST API 的一个重要方面是基于令牌的身份验证,它只允许有限和经过身份验证的用户能够使用和调用 API。这将留给你自己探索。我们在第六章 Flask 认证中介绍的用户身份验证的基础知识,将作为此概念的基础。

第八章:为 Flask 应用提供管理员接口

每个应用需要一些接口给用户提供一些特权,以此来维护和升级应用资源。举个例子,我们可以在电商应用里有这样一个接口:这个接口允许一些特殊用户来创建商品类别和商品等。一些用户可能有权限来处理在网站购物的用户,处理他们的账单信息等等。相似的,还有很多案例需要从应用里隔离出一个接口,和普通用户分开。

这一章将包含下面小节:

  • 创建一个简单的 CRUD 接口
  • 使用 Flask-Admin 扩展
  • 使用 Flask-Admin 注册模型
  • 创建自定义表单和行为
  • WYSIWYG 文本集成
  • 创建用户角色

介绍

和其他 Python web 框架比如 Django 不同的是,Flask 默认情况下不提供管理员接口。尽管如此,这被很多人视为缺点,但这其实是给了开发者去根据需要创建管理员接口的灵活性。
我们可以选择从头开始为我们的应用程序编写管理界面,也可以使用 Flask 扩展。扩展为我们做了大部分的事情,但也允许我们去根据需要自定义逻辑处理。Flask 中一个受欢迎的创建管理员接口的扩展是 Flask-Admin(https://pypi.python.org/pypi/Flask-Admin)。这一章我们将从自己创建管理员接口开始,然后使用 Flask-Admin 扩展。

创建一个简单的 CRUD 接口

CRUD 指的是 Create,Read,Update,Delete。一个管理员接口必要的能力是可以根据需要创建,修改或者删除应用里的记录/资源。我们将创建一个简单的管理员接口,这将允许管理员用户进行这些操作,而其他普通用户则不能。

准备

我们将从第六章的应用开始,给它添加管理员认证和管理员接口。接口只允许管理员创建,修改,删除用户记录。这一小节,会提到一些特定的内容以帮助理解一些概念。

怎么做

首先修改 models.py,向 User 模型新增一个字段:admin。这个字段将帮助我们区别这个用户是否是管理员。

from wtforms import BooleanField

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(60))
    pwdhash = db.Column(db.String())
    admin = db.Column(db.Boolean())

    def __init__(self, username, password, admin=False):
        self.username = username
        self.pwdhash = generate_password_hash(password)
        self.admin = admin

    def is_admin(self):
        return self.admin 

前面 is_admin()方法仅仅返回了 admin 字段的值。这个可以根据需要自定义的实现。看下面代码:

class AdminUserCreateForm(Form):
    username = TextField('Username', [InputRequired()])
    password = PasswordField('Password', [InputRequired()])
    admin = BooleanField('Is Admin ?')

class AdminUserUpdateForm(Form):
    username = TextField('Username', [InputRequired()])
    admin = BooleanField('Is Admin ?') 

同时,我们创建了两个用在管理员视图里的表单。
现在修改 views.py 里的视图,来完成管理员接口:

from functools import wraps
from my_app.auth.models import AdminUserCreateForm, AdminUserUpdateForm

def admin_login_required(func):
    @wraps(func)
    def decorated_view(*args, **kwargs):
        if not current_user.is_admin:
            return abort(403)
        return func(*args, **kwargs)
    return decorated_view 

前面代码是admin_login_required装饰器,效果和login_required装饰器类似。区别在于它需要使用login_required,并且检查当前登录用户是否是管理员。

接下来用来创建管理员接口的处理程序。注意@admin_login_required装饰器的使用方法。其他内容和我们之前学到的事一样的,现在只关注视图和认证处理:

@auth.route('/admin')
@login_required
@admin_login_required
def home_admin():
    return render_template('admin-home.html')

@auth.route('/admin/users-list')
@login_required
@admin_login_required
def users_list_admin():
    users = User.query.all()
    return render_template('users-list-admin.html', users=users)

@auth.route('/admin/create-user', methods=['GET', 'POST'])
@login_required
@admin_login_required
def user_create_admin():
    form = AdminUserCreateForm(request.form)
    if form.validate_on_submit():
        username = form.username.data
        password = form.password.data
        admin = form.admin.data
        existing_username = User.query.filter_by(username=username).first()
        if existing_username:
            flash('This username has been already taken. Try another one.', 'warning')
            return render_template('register.html', form=form)
        user = User(username, password, admin)
        db.session.add(user)
        db.session.commit()
        flash('New User Created.', 'info')
        return redirect(url_for('auth.users_list_admin'))
    if form.errors:
        flash(form.errors, 'danger')
    return render_template('user-create-admin.html', form=form) 

前面的方法允许管理员用户在系统里创建新用户。这个行为和 register()方法是类似的,但是允许设置用户的 admin 标志。看下面代码:

@auth.route('/admin/update-user/<id>', methods=['GET', 'POST'])
@login_required
@admin_login_required
def user_update_admin(id):
    user = User.query.get(id)
    form = AdminUserUpdateForm(
        rquest.form,
        username=user.username, 
        admin=user.admin
    )

    if form.validate_on_submit():
        username = form.username.data
        admin = form.admin.data

        User.query.filter_by(id=id).update({
            'username': usernmae,
            'admin': admin,
        })
        db.session.commit()
        flash('User Updated', 'info')
        return redirect(url_for('auth.users_list_admin'))

    if form.errors:
        flash(form.errors, 'danger')

    return render_template('user-update-admin.html', form=form, user=user) 

前面的方法允许管理员更新其他用户的记录。但是,最好别允许管理员修改任何用户的密码。大多数情况下,只能允许用户自己修改密码。尽管如此,一些情况下,管理员还是有修改密码的权限,但是不应该看到用户设置的密码。看下面代码:

@auth.route('/admin/delete-user/<id>')
@login_required
@admin_login_required
def user_delete_admin(id):
    user = User.query.get(id)
    db.session.delete(user)
    db.session.commit()
    flash('User Deleted.', 'info')
    return redirect(url_for('auth.users_list_admin')) 

user_delete_admin()方法实际上应该在 POST 请求里完成。这留给读者自己完成。
下面需要创建模板。从前面视图代码里可以看出,我们需要新增四个模板,分别是admin-home.html,user-create-admin.html,user-update-admin.html,users-list-admin.html。下一小节看一下他们如何工作的。读者现在应该可以自己实现这些模板了,作为参考,具体代码可下载本书示例代码。

译者注

原文为 user.delete(),现修改为 db.session.delete(user)。
原味为 if form.validate(),现修改为 if form.validate_on_submit():

原理

我们为应用新增一个菜单条目,这在管理员主页上添加了一个链接,页面看起来像这样:

一个用户必须作为管理员登录才能访问这些页面和其他管理员页面。如果一个用户不是作为管理员登录的,应该展示一个错误,看起来像下面这样:

管理员登录后主页看起来像这样:

管理员可以看到系统里的用户列表也可以创建一个新用户。用户列表页本身也提供了编辑和删除用户的选项。

提示

创建第一个管理员,需要通过使用控制台命令创建一个用户,设置 admin 标记为 True。

使用 Flask-Admin 扩展

Flask-Admin 是一个扩展,用来帮助更简单更快速的为应用创建管理员接口。这一小节将专注于使用这个扩展。

准备

首先,需要安装 Flask-Admin 扩展:

$ pip install Flask-Admin 

我们扩展上一小节的应用来使用 Flask-Admin 完成它。

怎么做

使用 Flask-Admin 扩展为任何 Flask 应用新增一个简单的管理员接口只需要几句。
我们仅仅需要向应用配置里增加下面几句:

from flask_admin import Admin
app = Flask(__name__)
# Add any other application configuration
admin = Admin(app) 

仅仅用 Flask-Admin 扩展提供的 Admin 类初始化应用,只会提供一个基本的管理员界面,看起来像这样:

注意截图里的 URL 是http://127.0.0.1:5000/admin/。我们同样可以添加自己的视图,仅仅需要继承 BaseView 类就可以添加一个类作为视图了:

from flask_admin import BaseView, expose

class HelloView(BaseView):
    @expose('/')
    def index(self):
        return self.render('some-template.html') 

之后,我们需要在 Flask 配置里添加这个视图到 admin 对象上:

import my_app.auth.views as views
admin.add_view(views.HelloView(name='Hello')) 

现在管理员主页看起来像这样:

需要注意的一件事是,默认情况下这个页面没有进行任何的认证,这需要自行实现。因为 Flask-Admin 没有对认证系统做任何的假设。我们的应用使用的是 Flask-Login 进行登录,所以我们可以新增一个方法叫 is_accessible()到 HelloView 类中:

def is_accessible(self):
    return current_user.is_authenticated and current_user.is_admin 
译者注

原书为current_user.is_authenticated() and current_user.is_admin(),这会报错,不是函数,不能调用,所以需去掉()。

更多

在完成前面的代码之后,还有一个管理员视图不需要认证,任何人就可以访问。这就是管理员主页。为了仅仅向管理员开放这个页面,我们需要继承 AdminIndexView 并完成 is_accessible()方法:

from flask_admin import AdminIndexView

class MyAdminIndexView(AdminIndexView):
    def is_accessible(self):
        return current_user.is_authenticated and current_user.is_admin 

之后,需要在应用配置里把这个视图做为 index_view 传递到 admin 对象,实现如下:

admin = Admin(app, index_view=views.MyadminIndexView()) 

这个方法使得所有的管理员视图仅向管理员开放。我们还可以在需要时在 is_accessible()中实现任何权限或条件访问规则。

使用 Flask-Admin 注册模型

上一小节,我们看到了如何使用 Flask-Admin 扩展在应用里创建管理员接口。这一小节,我们将会看到如何为已存在的模型创建管理员接口/视图,使得可以进行 CRUD 操作。

准备

我们将扩展上一小节应用来为 User 模型创建管理员接口。

怎么做

使用 Flask-Admin 注册一个模型到管理员接口里是非常简单的。需要像下面这样添加几行代码:

from flask_admin.contrib.sqla import ModelView

# Other admin configuration as shown in last recipe
admin.add_view(ModelView(views.User, db.session)) 

这里,第一行,我们从 flask_admin.contrib.sqla 导入了 ModelView。flask_admin.contrib.sqla 是由 Flask-Admin 提供的一个继承 SQLAlehcmy 模型的视图。这将为 User 模型创建一个新的管理员视图。视图看起来像这样:

看前面的截图,很多人都会认为向用户显示密码的哈希值是没有意义的。同时,Flask-Admin 默认的模型创建机制在创建 User 时会失败,因为我们 User 模型里有一个__init__()方法。这个方法期望三个字段,然而 Flask-Admin 里面的模型创建逻辑是非常通用的,在模型创建的时候不会提供任何值。
现在,我们将自定义 Flask-Admin 的一些默认行为,来修改 User 创建机制,以及隐藏视图里的密码哈希值:

class UserAdminView(ModelView):
    column_searchable_list = ('username',)
    column_sortable_list = ('username', 'admin')
    column_exclude_list = ('pwdhash',)
    form_excluded_columns = ('pwdhash',)
    form_edit_rules = ('username', 'admin')

    def is_accessible(self):
        return current_user.is_authenticated and current_user.is_admin 

前面的代码展示了 User 的管理员视图遵循的一些规则和配置。其中有一些是很容易理解的。可能会对column_exclude_listform_excluded_columns有点困惑。前者将排除管理员视图自身提供的列,不能在搜索,创建和其他 CRUD 操作里使用这些列。后者将防止在 CRUD 表单上显示这些字段。看下面代码:

def scaffold_form(self):
    form_class = super(UserAdminView, self).scaffold_form()
    form_class.password = PasswordField('Password')
    return form_class 

前面方法将重写模型的表单创建,添加了一个密码字段,这将替换密码哈希值。看下面代码:

def create_model(self, form):
    model = self.model(
            form.username.data, form.password.data, form.admin.data
        )
    form.populate_obj(model)
    self.session.add(model)
    self._on_model_change(form, model, True)
    self.session.commit() 

前面方法重写了模型创建逻辑,以适应我们的应用。
为了在应用配置里向 admin 对象添加一个模型,得像下面这样编码:

admin.add_view(views.UserAdminView(views.User, db.session)) 

提示

self._on_model_change(form, model, True)一句。最后一个参数 True 表示调用是为了创建一个新的记录。

User 模型的管理员界面将看起来像下面这样:

这里有一个搜索框,没有显示密码哈希值。用户创建和编辑视图也有更改,建议读者亲自运行这个程序看看效果。

创建自定义表单和动作

这一小节,我们将使用 Flask-Admin 提供的表单来创建自定义的表单。同时将使用自定义表单创建一个自定义动作。

准备

上一小节,我们看到 User 更新表单没有更新密码的选项。表单看起来像这样:

这一小节,我们将自定义这个表单允许管理员为任何用户更改密码。

怎么做

完成这个特性仅仅需要修改 views.py。首先,我们从 Flask-Admin 表单里导入 rules 开始:

from flask_admin.form import rules 

上一小节,form_edit_rules设置了两个字段:username 和 admin。这表示 User 模型更新视图中可供管理用户编辑的字段。

更新密码不是一个简单的事情,不是向列表 form_edit_rules 仅仅添加一个或者多个字段就可以完成的。因为我们不能存储密码的明文。我们得存储密码的哈希值,这不能被任何用户直接修改。我们需要用户输入密码,然后在存储的时候转为一个哈希值进行存储。我们将看到如何在下面的代码里实现这个:

form_edit_rules = (
    'username', 'admin',
    rules.Header('Reset Password'),
    'new_password', 'confirm'
)
form_create_rules = (
    'username', 'admin', 'notes', 'password'
) 

前面代码表示现在表单有了一个 header,它将密码重置部分和其他部分分离开了。之后,我们将新增两个字段 new_password 和 confirm,这将帮助我们安全的修改密码:

def scaffold_form(self):
    form_class = super(UserAdminView, self).scaffold_form()
    form_class.password = PasswordField('Password')
    form_class.new_password = PasswordField('New Password')
    form_class.confirm = PasswordField('Confirm New Password')
    return form_class 

scaffold_form()方法需要修改,以便使得这两个新的字段在表单渲染的时候变得有效。
最后,我们将实现 update_model()方法,这在更新记录的时候会被调用:

def update_model(self, form, model):
    form.populate_obj(model)
    if form.new_password.data:
        if form.new_password.data != form.confirm.data:
            flash('Passwords must match')
            return
        model.pwdhash = generate_password_hash(form.new_password.data)
    self.session.add(model)
    self._on_model_change(form, model, False)
    self.session.commit() 

前面代码中,我们首先确保两个字段中输入的密码是一样的。如果是,我们将继续重置密码以及任何其他更改。

提示

self._on_model_change(form, model, False)。这里最后一个参数 False 表示这个调用不能用于创建一个新记录。这同样用在了上一小节创建用户那里。那个例子中,最后一个参数设置为了 True。

原理

用户更新表单看起来像下面这样:

如果我们在两个密码字段里输入的密码是相同的,才会更新用户密码。

WYSIWYG 文本集成

做为一个网站的用户,我们都知道使用传统的 textarea 字段编写出漂亮的格式化的文本是一个噩梦。有许多插件使得生活变得美好,可以转换简单的文本字段到 What you see is what you get(WYSIWYG)编辑器。CKEditor 就是这样一个编辑器。这是一个开源项目,提供了非常好的扩展,并且有大型社区的支持。同时允许用户根据需要构建附加物(add-ons)。

准备

我们从向 User 模型新增一个新的 notes 字段开始,然后使用 CKEditor 集成这个字段来编写格式化的文本。这会添加额外的 Javascript 库和 CSS 类到普通 textarea 字段中,以将其转换为与 CKEditor 兼容的 textarea 字段。

怎么做

首先,我们将向 User 模型添加 notes 字段,看起来像这样:

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(60))
    pwdhash = db.Column(db.String())
    admin = db.Column(db.Boolean())
    notes = db.Column(db.UnicodeText)

    def __init__(self, username, password, admin=False, notes=''):
        self.username = username
        self.pwdhash = generate_password_hash(password)
        self.admin = admin
        self.notes = notes 

之后,我们将创建一个自定义的 wtform 控件和一个字段:

from wtforms import widgets, TextAreaField

class CKTextAreaWidget(widgets.TextArea):
    def __call__(self, field, **kwargs):
        kwargs.setdefault('class_', 'ckeditor')
        return super(CKTextAreaWidget, self).__call__(field, **kwargs) 

在前面自定义控件中,我们向 TextArea 控件添加了一个 ckeditor 类。如果需要了解更多的 WTForm 控件,参见第五章创建一个自定义控件这一节。看下面代码:

class CKTextAreaField(TextAreaField):
    widget = CKTextAreaWidget() 

前面代码里,我们设置控件为 CKTextAreaWidget,当这个文本字段进行渲染的时候,CSS 类 ckeditor 会被添加进去。

接下来,我们需要修改 UserAdminView 类中表单规则,我们可以指定创建和编辑表单时使用的模板。我们同样需要用 CKTextAreaField 重写 TextAreaField:

form_overrides = dict(notes=CKTextAreaField)
create_template = 'edit.html'
edit_template = 'edit.html' 

前面的代码中,form_overrides 允许用 CKTextAreaFiled 字段替代普通的 textarea 字段。

剩下部分是之前提到的templates/edit.html模板:

{% extends 'admin/model/edit.html' %}

{% block tail %}
    {{ super() }}
    <script src="http://cdnjs.cloudflare.com/ajax/libs/ckeditor/4.0.1/ckeditor.js"></script>
{% endblock %} 

这里,我们扩展 Flask-Admin 提供的默认 edit.html,向里面添加了 CKEditors JS 文件,这样 ckeditors 类的 CKTextAreaField 才可以使用。

原理

在做了这些修改之后,用户创建表单将看起来像这样,需注意 Notes 字段:

这里,任何在 Note 字段里输入的东西将会在保存的时候被自动转成 HTML,这使得可以用在任何地方以进行显示。

创建用户权限

现在为止,我们看到了使用 is_accessible()方法可以轻松地创建对特定管理用户可访问的视图。可以将其扩展到不同类型的场景,特定用户只能查看特定视图。在模型中,还有另一种在更细粒度级别上实现用户角色的方法,其中角色决定用户是否能够执行所有或部分 CRUD 操作。

准备

这一小节,我们将看到一种创建用户角色的基本方法,其中管理员用户只能执行他们有权执行的操作。

提示

记住这只是完成用户角色的一种方法。还有很多更好的方法,但是现在讲解的方式是演示创建用户角色的最好例子。
一个合适的方法是去创建用户组,给用户组分配角色而不是个人用户。另一种方法可以是基于复杂策略的用户角色,包括根据复杂的业务逻辑定义角色。这种方法通常被企业系统所采用比如 ERP,CRM 等等。

怎么做

首先,我们向 User 模型添加一个字段:roles:

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Colum(db.String(60))
    pwdhash = db.Column(db.String())
    admin = db.Column(db.Boolean())
    notes = db.Column(db.UnicodeText)
    roles = db.Column(db.String(4))

    def __init__(self, username, password, admin=False, notes='', roles='R'):
        self.username = username
        self.pwdhash = generate_password_hash(password)
        self.admin = admin
        self.notes = notes
        self.roles = self.admin and self.roles or '' 

这里,我们添加了一个新的字段:roles,这个字段是长度为 4 的字符串字段。我们假定任何用户这个字段值是 C,R,U,D 的组合。一个用户如果 roles 字段值是 CRUD,即有执行所有操作的权限。缺少哪个权限就不允许执行相应的动作。读权限是对任何管理员开放的。

接下来,我们需要对 UserAdminView 类做一些修改:

from flask.ext.admin.actions import ActionsMixin

class UserAdminView(ModelView, ActionsMixin):

    form_edit_rules = (
        'username','admin','roles','notes',
        rules.Header('Reset Password'),
        'new_password', 'confirm'
    )

    form_create_rules = (
        'username','admin','roles','notes','password'
    ) 

前面的代码中,我们仅仅向创建和编辑表单里添加了 roles 字段。我们同样继承了一个叫做 ActionsMixin 的类。这在大规模更新时(如大规模删除)是必须的。看下面代码:

def create_model(self, form):
    if 'C' not in current_user.roles:
        flash('You are not allowed to create users.', 'warning')
        return
    model = self.model(
        form.username.data, form.password.data, form.admin.data,
        form.notes.data
    )
    form.populate_obj(model)
    self.session.add(model)
    self._on_model_change(form, model, True)
    self.session.commit() 

这个方法里,首先检查当前用户 roles 字段是否含有创建的权限(是否有 C)。如果没有,就显示一个错误,然后返回。看下面代码:

 def update_model(self, form, model):
    if 'U' not in current_user.roles:
        flash('You are not allowed to edit users.', 'warning')
        return
    form.populate_obj(model)
    if form.new_password.data:
        if form.new_password.data != form.confirm.data:
            flash('Passwords must match')
            return
        model.pwdhash = generate_password_hash(form.new_password.data)
    self.session.add(model)
    self._on_model_change(form, model, False)
    self.session.commit() 

这个方法中,我们首先检查当前用户 roles 字段是否含有修改记录的权限(是否有 U)。如果没有,就显示一个错误,然后返回。看下面代码:

def delete_model(self, model):
    if 'D' not in current_user.roles:
        flash('You are not allowed to delete users.', 'warning')
        return
    super(UserAdminView, self).delete_model(model) 

相似的,这里我们检查当前用户是否被允许去删除记录。看下面代码:

def is_action_allowed(self, name):
    if name == 'delete' and 'D' not in current_user.roles:
        flash('You are not allowed to delete users.', 'warning')
        return False
    return True 

前面方法中,我们检查当前操作是否是 delete 并且检查当前用户是否被允许去删除。如果不,就显示一个错误,返回一个 False。

原理

这一小节代码的效果和之前应用运行起来的效果类似,但是,现在用户只有有了相应的权限才能执行相应的操作。否则将显示错误信息。

用户列表看起来像下面这样:

测试其余的功能,比如创建用户(普通用户或者管理员用户),删除用户,更新用户等等,这些读者最好自己尝试做一遍。

第九章:国际化和本地化

web 应用通常不限制于一个地区或者为一种特定语言的人群服务。比如,一个 web 应用意图服务于欧洲的用户,除了英语同样需要支持其它欧洲语言国家比如德国,法国,意大利,西班牙等等。这一章节将讲述如何在一个 Flask 应用中支持多种语言。

这一章将包括下面小节:

  • 新增一种语言
  • 延迟计算和 gettext/ngettext 函数
  • 全球语言转换动作

介绍

在任何 web 应用中支持第二种语言都是一件麻烦的事情。每次应用发生修改的时候都增加了额外的开销,并且这种开销随着语言数量的增加而增加。除了为每种语言修改文本之外,还有很多事情需要去处理。其中一些是处理货币,数字,时间日期格式等等。

Flask-Babel 是一个扩展,用来向 Flask 应用添加 i18n 和 l1on 支持,它提供了一些工具和技术来使得这个过程更简单和更容易实现。

提示

i18n 表示国际化,l10n 表示本地化。
这一章节,我们将使用这个扩展来理解这些概念。

新增一种语言

默认情况下 Flask 应用的语言是英语(大多数 web 框架都是如此)。我们将为我们的应用新增第二种语言并且为应用字符串新增一些转换。向用户展示的语言将依据用户浏览器中设置的语言而定。

准备

我们从安装 Flask-Babel 扩展开始:

$ pip install Flask-Babel 

这个扩展使用 Babel,pytz 和 speaklater 来向应用添加 i18b 和 l1on。
我们将使用第五章的应用来做演示。

怎么做

首先,我们从配置部分开始,使用 app 对象创建一个 Babel 类的实例,并且指定这里可以使用的语言。French 被添加作为第二种语言:

from flask_babel import Babel

ALLOWED_LANGUAGES = {
    'en': 'English',
    'fr': 'French',
}
babel = Babel(app) 
提示

我们使用 en 和 fr 作为语言代码。他们分别表示英语(标准)和法语(标准)。如果我们想新增其他同一标准但是地区不同的语言比如英语(US)和英语(GB),这样的话需要使用这些代码比如 en-us 和 en-gb。

接下来,我们将在应用文件夹创建一个文件叫做 babel.cfg。这个文件的路径将是flask_catalog/my_app/babel.cfg,它将包含下面内容:

[python: catalog/**.py]
[jinja2: templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_ 

这里,前两行告诉 Babel 哪些文件需要进行文本转换。第三行加载了一些扩展使得这些文件里的文本搜索变得可能。

应用的语言环境依赖于使用@babel.localeselector 装饰器修饰的这个方法的输出结果。向视图文件 views.py 新增下面方法:

from my_app import ALLOWED_EXTENSIONS, babel

@babel.localeselector
def get_locale():
    return request.accept_languages.best_match(ALLOWED_LANGUAGES.keys())
    # return g.get('current_lang', 'en') 

前面方法从请求获取 Accept-Languages 头,然后寻找我们允许的最佳匹配语言。

提示

修改浏览器的语言首选项是非常简单的。但是任何情况下,如果你不打算弄乱浏览器的语言首选项,仅仅需要从 get_locale()方法返回期待的语言代码。

接下来,我们需要标记一些文本是打算用来根据语言进行转换的。首先从 home.html 开始:

{% block container %}
<h1>{{ _('Welcome to the Catalog Home') }}</h1>
  <a href="{{ url_for('catalog.products') }}" id="catalog_link">
      {{ _('Click here to see the catalog ') }}
  </a>
{% endblock %} 

这里,_ 是 Babel 提供的 gettext 函数的简写,它用来转换字符串。
之后,我们需要运行下面命令来使得被标记的文本在浏览器渲染我们模板时变得可用:

$ pybabel extract -F my_app/babel.cfg -o my_app/messages.pot my_app 

前面命令遍历 babel.cfg 中所配置的文件内容,挑选出那些被标记为可转换的文本。所有这些文本被放置在 my_app/messages.pot 文件中。看下面命令:

$ pybabel init -i my_app/messages.pot -d my_app/translations -l fr 

前面初始化命令创建了一个.po 文件,它包含那些需要被翻译文本的翻译。这个文件被创建在特定的文件夹里,即my_app/translations/fr/LC_MESSAGES/messages.po。当我们添加越多的语言时,越多的文件夹就会被添加。

现在,我们需要向 messages.po 文件新增一些翻译。这可以手动处理,或者我们也可以使用 GUI 工具比如 Poedit(http://poedit.net/)。使用这个工具,转换将看起来像下面截图这样:

手动编辑 messages.po 将看起来像下面代码。为了演示只有一条信息被翻译:

#:my_app/templates/home.html:6
msgid "Click here to see the catalog"
msgstr "Cliquez ici pour voir le catalogue" 

在翻译添加完之后保存 messages.po 文件,然后运行下面命令:

$ pybabel compile -d my_app/translations 

这将在 message.po 文件旁边创建一个 messages.mo 文件,它将被应用用来去渲染翻译文本。

提示

有时在运行上面代码之后消息不会被编译。这是因为这些信息可能被标记为模糊的(以#开头)。这需要进行人工排查,如果信息需要被编译器更新则需要移除#标记。为了通过检查,向前面编译命令添加一个-f 标记,这将强制编译所有东西。

原理

如果我们设置浏览器语言为 French,然后运行应用,主页将看起来像下面截图这样:

如果浏览器首选项语言不是法语,文本将以英语展示,英语是默认语言。

更多

接下来,如果我们需要去更新 messages.po 文件的翻译,我们不需要再一次运行 init 命令。取而代之的是运行更新命令即:

$ pybabel update -i my_app/messages.pot -d my_app/translations 

之后,像之前一样运行编译命令。

提示

通常会依据于用户的 IP 和位置(有 IP 推断而来)来改变网站的语言。和使用 Accept-Language 头相比,这是一个更好的处理本地化的方法。

其他

  • 全球语言转换动作一节将允许用户直接去修改应用语言而不是在浏览器层面处理。
  • 多语言的一个重要方面是需要处理日期,时间,货币。Babel 处理这些同样非常的简洁。我建议你自己尝试实现。参见 Babel 文档寻求更多信息http://babel.pocoo.org/docs/

延迟计算和 gettext/negettext 函数

延迟计算(lazy evaluation)是一种计算策略,用来延迟表达的计算,直到需要值的时候才进行计算,因此这也叫做 call-by-need 机制。在我们的应用中,存在一些文本实例需要在渲染模板的时候才进行计算。通常情况下,当我们的文本在请求上下文之外被标记为可翻译时,我们就会推迟这些文本的执行,直到它们真正需要时。

准备

让我们从前一小节应用开始。现在,我们希望商品和类别创建表单中的标签可以显示翻译的值。

怎么做

为了标记商品和类别表单中的所有字段都是可以翻译的,我们需要对 models.py 做下面的修改:

class NameForm(Form):
    name = StringField(_('Name'), validators=[InputRequired()])

class ProductForm(NameForm):
    price = DecimalField(_('Price'), validators=[
        InputRequired(), NumberRange(min=Decimal('0.0'))
    ])
    category = CategoryField(
        _('Category'), validators=[InputRequired()], coerce=int
    )
    image = FileField(_('Product Image'))

class CategoryForm(NameForm):
    name = StringField(_('Name'), validators=[
        InputRequired(), check_duplicate_category()
    ]) 

注意到所有这些字段标签都使用了 _()进行了标记。
现在,运行 pybabel extract 和 update 命令来更新 messages.po 文件,然后填充相关翻译,并且运行编译命令。具体细节参见上一小节。
使用http://127.0.0.1:5000/product-create打开商品创建页面。但是,它像我们期待的那样工作了吗?没有!因为,我们中的大多数应该猜到出现这样的情况原因可能是因为文本被标记为在请求上下文之外可翻译。

为了使之生效,我们仅仅需要修改下面的 import 语句:

from flask_babel import lazy_ggetext as _ 

现在,我们有了更多的文本要来翻译。比如我们需要翻译商品创建的 flash 消息文本,像下面这样:

flash("The product %s has been created" % name) 

为了标记它为可翻译的,我们不能仅仅简单的将所有东西包在 _()或 gettext()里面。gettext()函数支持占位符,可以使用%(name)s 替代。使用这种方法,前面代码将看起来像下面这样:

flash(_('The product %(name)s has been created', name=name)) 

这句话的翻译结果看起来像这样 le produit %(name)s a été créé。

有些情况下,我们需要根据条目的数量来管理翻译,也就是单数或复数的名称。通过使用 ngettext()方法处理它。我们以在 products.html 模板中显示页码为例进行说明。
为此我们需要添加下面这行:

{{ngettext('%(num)d page', '%(num)d pages', products.pages)}} 

这里,模板将渲染 page 如果只有一个页面,如果不止一个页面,将渲染 pages。

这是非常有趣的去注意 messages.po 文件里的翻译看起来是什么样子:

#:my_app/templates/products.html:20
#,python-format
msgid "%(num)d page"
msgid_plural "%(num)d pages"
msgstr[0] "%(num)d page"
msgstr[1] "%(num)d pages" 

全球语言转换动作

前面一节,我们看到了依赖于当前浏览器语言首选项改变语言的处理。但是现在,我们需要一个机制来脱离浏览器的语言首选项转换语言。为此,我们需要在应用层面进行处理。

准备

我们将修改上一小节的应用来完成语言转换。我们将新增一个额外的 URL 部分到所有的路由中来增加当前语言。我们可以仅仅在 URL 里修改语言就可以实现语言的切换。

怎么做

首先需要修改所有的 URL 规则来增加一个额外的 URL 部分。
@app.route('/')将变为@app.route('/<lang>/'),同时@app.route('/home')将变为@app.route('/<lang>/home')。相似的,@app.route('/product-search/<int:page>')将变为@app.route('/<lang>/product-search/<int:page>')。所有的 URL 规则都需要这样处理。

现在,需要新增一个函数来添加 URL 中传递过来的语言到全局代理对象 g 中:

@app.before_request
def before():
    if request.view_args and 'lang' in request.view_args:
        g.current_lang = request.view_args['lang']
        request.view_args.pop('lang') 

这个方法将在每个请求之前运行,向 g 中添加当前语言。
但是这意味着当前应用的所有的 url_for()调用需要修改来传递一个额外的参数 lang。幸运的是,有一个简单的方法处理它,像下面这样:

from flask import url_for as flask_url_for

@app.context_processor
def inject_url_for():
    return {
        'url_for': lambda endpoint, **kwargs: flask_url_for(
            endpoint, lang=g.current_lang, **kwargs
        )   
    }

url_for = inject_url_for()['url_for'] 

前面代码中,我们首先导入url_forflask_url_for。然后我们更新应用上下文处理器来添加url_for()函数,它是 Flask 提供的url_for()的修改版本,其中添加了额外的参数。

原理

现在,运行这个应用,你会注意到所有的 URLs 有了一个语言部分。下面截图显示了渲染的模板看起来像什么样子。
打开http://127.0.0.1:5000/en/home我们将看到下面这样子:

主页使用英语作为语言。
现在,仅仅修改 URL 为http://127.0.0.1:5000/fr/home然后主页将看起来像这样:

主页使用法语作为语言。

其他

  • 第一小节,新增一个语言,是依赖于浏览器设置的语言来处理本地化。
译者注

Flask-Babel 使用方法参见其中文文档:
translations.readthedocs.io/en/latest/flask-babel.html

第十章:调试,错误处理和测试

直到现在,我们一直专注于应用开发,并且一次只增加一个特性。了解我们的应用程序的健壮程度并跟踪应用程序的工作和执行情况是非常重要的。这反过来又导致了在应用程序出现问题时被通知的必要性。开发应用程序时漏掉某些边缘情况是正常的,通常情况下,即使是测试用例也会遗漏它们。了解这些边缘情况是有必要的,当他们真正发生时,可以相应的进行处理。
测试本身是一个非常大的话题,有很多书在讲述它。这里我们尝试理解 Flask 测试的基本知识。

这一章,我们将包含下面小节:

  • 设置基本 logging 文件
  • 错误发生时发送邮件
  • 使用 Sentry 监测异常
  • 使用 pdb 调试
  • 创建第一个简单测试
  • 为视图和逻辑编写更多的测试
  • Nose 库集成
  • 使用 mocking 避免真实 API 访问
  • 确定测试覆盖率
  • 使用 profiling 寻找瓶颈

介绍

高效的日志功能和快速调试能力是选择应用开发框架时需要考虑的因素。框架拥有越好的日志和调试能力,应用开发就会变得更快,维护也会更容易。它有助于帮助开发者快速找出应用里的问题,有时日志可以在终端用户发现问题前提前发现问题。高效的错误处理在增加用户满意度方面和减轻开发者调试痛苦方面都扮演着重要的角色。即使代码是完美的,应用有时也会报错。为什么?答案是显而易见的,虽然代码是完美的,但是这个世界并不是。有数不清的情况会发生,作为开发者,我们总是想知道背后的原因。编写应用测试是编写优秀软件的重要支柱之一。
Python 自带的日志调试系统在 Flask 下也可以很好的工作。我们这一章将使用这个日志调试系统,之后去使用一个炫酷的服务叫做 Sentry,它极大程度上减少了调试日志的痛苦。
我们已经阐述了应用开发中测试的重要性,我们将看到如何为 Flask 应用编写单元测试。我们同样将看到如何测量代码覆盖率和寻找应用瓶颈。

设置基本 logging 文件

通常,Flask 不会为我们生成日志,除了带有堆栈跟踪信息的错误,这些错误会被发送给 logger(我们将在本章的其余部分看到更多关于这一点的说明)。当在开发模式下,使用 run.py 运行应用时会产生很多的堆栈信息,但是在生产环境下很难奢望还拥有这些信息。幸运的是,logging 库提供了很多 log 处理方法可以根据需要进行使用。

准备

我们将开始我们的商品目录应用,使用 FileHandler 添加一些基本 logging。他们将信息记录在文件系统的特定文件里。

怎么做

首先,需要改动__init__.py

app.config['LOG_FILE'] = 'application.log'

if not app.debug:
    import logging
    from logging import FileHandler
    file_handler = FileHandler(app.config['LOG_FILE'])
    file_handler.setLevel(logging.INFO)
    app.logger.addHandler(file_handler) 

这里我们增加了一个配置用于指定日志文件的位置。这指的是从应用目录的相对路径,除非特别指定绝对路径。接下来,我们要检查是否启用 debug 模式,如果否,在文件里添加一个日志输出,并设置日志等级为 INFO.DEBUG,这个是最低级别,将会记录所有级别的信息。更多细节,参见日志库文档。

之后,在应用需要日志的地方仅需添加 logger,应用就会将日志信息记录到指定文件。让我们在 views.py 添加一些 loggers 进行演示:

@catalog.route('/')
@catalog.route('/<lang>/')
@catalog.route('/<lang>/home')
@template_or_json('home.html')
def home():
    products = Product.query.all()
    app.logger.info(
        'Home page with total of %d products' % len(products)
    )
    return {'count': len(products)}

@catalog.route('/<lang>/product/<id>')
def product(id):
    product = Product.query.filter_by(id=id).first()
    if not product:
        app.logger.warning('Requested product not found.')
        abort(404)
    return render_template('product.html', product=product) 

前面代码中,我们为视图添加了一些 logger。home()里的第一个 logger 等级是 info,product()等级是 warning。如果我们在__init__.py设置日志等级为 INFO,两者都将被记录。但是如果设置等级为 WARNING,只有 warning 日志会被记录。

原理

前面代码会在应用根目录创建一个叫做 application.log 的文件。日志会被记录到这个文件,内容看起来像下面这样,内容根据被调用的 handler 不同而有所区别。第一个是来自 home 的请求,第二个是请求商品不存在时的情况:

Home page with total of 1 products
Requested product not found. 

更多

  • 阅读 Python 日志库文档了解更多 handlers,参见https://docs.python.org/dev/library/logging.handlers.html

发生错误时发送邮件

这是一个好的主意,在未知事情发生的时候去接收这些错误。实现这非常的容易,并且为错误处理的带来了便利。

准备

我们将采用上一小节的应用,给它添加 mail_handler,来使得应用可以在发生错误的时候发送邮件。同时,我们将演示怎么使用 Gmail 和 SMTP 服务器创建这些 e-mail。

怎么做

首先向配置文件__init__.py添加处理程序。这和我们在上一小节添加 file_handler 是类似的:

RECEPIENTS = ['some_receiver@gmail.com']

if not app.debug:
    import logging
    from logging import FileHandler, Formatter
    from logging.handlers import SMTPHandler
    file_handler = FileHandler(app.config['LOG_FILE'])
    file_handler.setLevel(logging.INFO)
    app.logger.addHandler(file_handler)
    mail_handler = SMTPHandler(
        ("smtp.gmail.com", 587), 'sender@gmail.com', RECEPIENTS,
        'Error occurred in your application',
        ('sender@gmail.com', 'some_gmail_password'), secure=())
    mail_handler.setLevel(logging.ERROR)
    app.logger.addHandler(mail_handler)
    for handler in [file_handler, mail_handler]:
        handler.setFormatter(Formatter(
            '%(asctime)s %(levelname)s: %(message)s '           
            '[in %(pathname)s:%(lineno)d]'
        )) 

这里我们设置了一些 e-mail 地址,错误发生的时候会给这些地址发送邮件。同时注意 mail_handler 中设置了日志等级为 EROOR。这是因为只有重要和关键的事情才需要发送邮件。
更多配置 SMTPHanderder 的细节,参见它的文档。

提示

确保关闭 run.py 中的 debug 标记,这样才能使能应用日志,并且为内部应用程序错误发送电子邮件(错误 500)。

原理

为了引起一个内部应用错误,只需在处理程序任何地方拼错关键字即可。你将在你的邮箱中收到一封邮件,具有配置中设置的格式和完整的堆栈信息以供参考。

更多

当找不到页面时(404),我们还可能希望记录所有这些错误。为此,我们只需稍微修改一下 errorhandler 方法:

@app.errorhandler(404)
def page_not_found(e):
    app.logger.error(e)
    return render_template('404.html'), 404 

使用 Sentry 监控异常

Sentry 是一个工具,它简化了监控异常的过程,同时也给用户带来了深入了解这些错误的可能。日志中的错误很大程度上会被我们眼睛忽略掉。Sentry 分类了不同类型的错误,对错误的重复次数进行计数。这有助于理解错误的严重性,并帮助我们相应地处理它们。

准备

我们将从 Sentry 安装和配置开始。有很多安装和配置 Sentry 的方法。Sentry 还提供了一个基于 SaaS 的托管解决方案,您可以跳过前面讨论的安装部分,直接进行集成。可以从https://www.getsentry.com获取 Sentry。

这里,我们将讨论一个非常基础的 Sentry 安装和配置方法,剩下的留给你们自己实现。我们将使用 PostgreSQL 做为 Sentry 的数据库,因为这是 Sentry 团队强烈推荐使用的。运行下面命令:

$ pip install sentry[postgres] 

Sentry 是一个服务程序,我们需要一个客户端去访问它。推荐使用 Raven,通过下面命令可以安装:

$ pip install raven[flask] 

这里还需要一个库:blinker。这用来处理 Flask 应用的信号(这已经超出本书的范围了,但是你可以阅读https://pypi.python.org/pypi/blinker了解更多)。可以使用下面命令安装:

$ pip install blinker 

怎么做

安装好了之后,我们需要去给 Sentry 服务器添加配置。首先,在你选择的路径初始化配置文件。推荐在当前虚拟环境里一个名字为 etc 的文件夹下做初始化。可以通过下面命令运行:

$ sentry init etc/sentry.conf.py 

之后,基础配置看起来像这样:

from sentry.conf.server import *

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql_psycopg2',
        'NAME': 'sentry', # Name of the postgres database
        'USER': 'postgres', # Name of postgres user
        'PASSWORD': '',
        'HOST': '',
        'PORT': '',
        'OPTIONS': {
            'autocommit': True,
        }
    }
}
SENTRY_URL_PREFIX = 'http://localhost:9000'
SENTRY_WEB_HOST = '0.0.0.0'
SENTRY_WEB_PORT = 9000
SENTRY_WEB_OPTIONS = {
    'workers': 3, # the number of gunicorn workers
    'limit_request_line': 0, # required for raven-js
    'secure_scheme_headers': {'X-FORWARDED-PROTO': 'https'},
} 

我们同样可以配置邮件服务器的细节,使得 Sentry 在错误发生的时候发送邮件,高效的从日志里获取信息,就像上一小节做的那样。详情可以参见 http://sentry.readthedocs.org/en/latest/quickstart/index.html#configure-outbound-mail

现在,在 postgres 中,我们需要去创建 Sentry 中使用的数据库,并升级初始集合:

$ createdb -E utf-8 sentry
$ sentry --config=etc/sentry.conf.py upgrade 

升级进程将创建一个默认的超级用户。如果没有,请运行下面命令:

$ sentry --config=etc/sentry.conf.py createsuperuser
Username: sentry
Email address: someuser@example.com
Password:
Password (again):
Superuser created successfully.
$ sentry --config=etc/sentry.conf.py repair –owner=sentry 

上一个命令中,sentry 是在创建超级用户时选择的用户名。
现在,开启 Sentry 服务仅仅需要运行下面的命令:

$ sentry --config=etc/sentry.conf.py start 

通常,Sentry 运行在 9000 端口,可以通过http://localhost:9000/访问到。

接下来,我们需要使用 GUI 在 Sentry 中创建一个团队(team),然后创建一个项目去记录我们应用的错误日志。使用超级用户登录 Sentry 后,会看到一个按钮,如下面的截图所示:

根据表单要求创建一个团队和项目。项目表单看起来像这样:

之后,下个屏幕截图看起来像这样。这里的细节将用于我们的 Flask 应用程序的配置。

现在,拷贝前面截图中高亮的部分,然后粘贴到 Flask 配置文件中。这将使得任何未被捕捉到的错误会被记录到 Sentry。

原理

Sentry 记录一个错误,看起来像这样:

还可以在 Sentry 中记录消息和用户定义的异常。将这个留给你们自己去实现。

使用 pdb 调试

大多数 Python 开发者读这本书的时候可能已经对 Python 调试器 pdb 的用法有一点了解。对于那些不知道它的人来说,pdb 是一个用于调试 Python 程序的交互式调试器。我们可以在需要的地方设置断点,使用单步调试,看堆栈信息。
许多新的开发者可能持有这样的观点,调试可以使用日志就可以了。但是调试器可以让我们看到运行流程,每一步的运行状态,会节省很多的开发时间。

准备

这一小节将使用 Python 内带的 pdb 模块,使用上一小节的应用做为演示。

怎么做

使用 pdb 大多是情况下非常的简单。我们仅仅需要在需要打断点的地方插入下面一句就可以了;

import pdb; pdb.set_trace() 

这将触发应用在这个断点停止执行,之后可以使用调试器命令单步执行。
现在,在我们的方法中插入这一句,在商品处理函数中:

def products(page=1):
    products = Product.query.paginate(page, 10)
    import pdb; pdb.set_trace()
    return render_template('products.html', products=products) 

当来到这一行的时候,调试器提示符就会启动;看起来像这样:

-> return render_template('products.html', products=product)
(Pdb) u
> /Users/shalabhaggarwal/workspace/flask_heroku/lib/python2.7/sitepackages/Flask-0.10.1-py2.7.egg/flask/app.py(1461)dispatch_request()
-> return self.view_functionsrule.endpoint
(Pdb) u
> /Users/shalabhaggarwal/workspace/flask_heroku/lib/python2.7/sitepackages/Flask-0.10.1-py2.7.egg/flask/app.py(1475)full_dispatch_request()
-> rv = self.dispatch_request()
(Pdb) u
> /Users/shalabhaggarwal/workspace/flask_heroku/lib/python2.7/sitepackages/Flask-0.10.1-py2.7.egg/flask/app.py(1817)wsgi_app()
-> response = self.full_dispatch_request() 

看 Pdb 中使用的 u,这意味单步执行。该语句中的所有变量,参数属性在当前上下文中都可以使用,以帮助解决问题或理解代码的运行流程。

其他

  • 更多调试器命令参见 https://docs.python.org/2/library/pdb.html#debugger-commands

创建第一个简单测试

测试是任何开发过程中的一个核心,在维护和扩展中也是如此。尤其是 web 应用程序面临高流量,高用户的情况下,测试变的尤其重要,因为用户反馈决定了程序的命运。这一小节,我们将看到如何开始编写测试,后面小节也会讲到更复杂的测试。

准备

在应用根目录下新建一个文件:app_tests.py,即my_app文件夹里面。
Python 库 unittest2 需要使用下面命令安装:

$ pip install unittest2 

怎么做

开始,app_tests.py测试文件看起来像这样:

import os
from my_app import app, db
import unittest2 as unittest
import tempfile 

前面的代码导入了需要的包。我们将使用 unittest2(前面已经使用 pip 安装了)。需要一个 tempfile 来动态创建 SQLite 数据库。
所有的测试用例需要继承 unitest.TestCase:

class CatalogTestCase(unittest.TestCase):

    def setUp(self):
        self.test_db_file = tempfile.mkstemp()[1]
        app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + self.test_db_file
        app.config['TESTING'] = True
        self.app = app.test_client()
        db.create_all() 

前面方法在任何测试方法运行前运行,里面新建了一个测试客户端。这个类里面需要测试的方法是以test_前缀开头的。这里,在应用配置里设置了数据库的名字,是一个时间戳,这将不会重复。同样设置了 TESTING 标记为 True,关闭了错误捕捉,为了更好的进行测试。最后运行 db 的 create_all()方法创建所需的数据库表。看下面代码:

def tearDown(self):
    os.remove(self.test_db_file) 

前面方法会在测试运行完后运行。我们移除了当前的数据库文件。看下面代码:

def test_home(self):
    rv = self.app.get('/')
    self.assertEqual(rv.status_code, 200) 

前面代码是我们的第一个测试,我们发送了一个 HTTP GET 请求到我们的应用,然后测试返回码是否是 200,200 代表这是一个成功的 GET 请求。

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

原理

为了运行测试文件,仅仅需要在终端运行下面命令:

$ python app_tests.py 

下面截图显示了测试的输出结果:

为视图和逻辑编写更多的测试

上一小节,我们开始为 Flask 应用编写测试了,这一小节,我们将为应用编写更多的测试,这些测试将覆盖视图,以便测试行为和逻辑。

准备

我们将继续使用上一小节的app_tests.py文件。

怎么做

在编写任何测试前,需要在 setUp()方法中添加一些配置,关闭 CSRF token,因为测试环境不会生成它们:

app.config['WTF_CSRF_ENABLED'] = False 

下面是这一小节需要创建的测试用例,我们会逐步介绍每一个测试用例:

def test_products(self):
    "Test Products list page"
    rv = self.app.get('/en/products')
    self.assertEqual(rv.status_code, 200)
    self.assertTrue('No Previous Page' in rv.data)
    self.assertTrue('No Next Page' in rv.data) 

前面测试发送一个 GET 请求到/products,然后 assert 返回状态码是否是 200。同样 assert 有没有前一页和后一页(做为模板渲染逻辑的一部分)。看下面代码:

def test_create_category(self):
    "Test creation of new category"
    rv = self.app.get('/en/category-create')
    self.assertEqual(rv.status_code, 200)

    rv = self.app.post('/en/category-create')
    self.assertEqual(rv.status_code, 200)
    self.assertTrue('This field is required.' in rv.data)

    rv = self.app.get('/en/categories')
    self.assertEqual(rv.status_code, 404)
    self.assertFalse('Phones' in rv.data)
    rv = self.app.post('/en/category-create', data={
        'name': 'Phones',
    })
    self.assertEqual(rv.status_code, 302)

    rv = self.app.get('/en/categories')
    self.assertEqual(rv.status_code, 200)
    self.assertTrue('Phones' in rv.data)

    rv = self.app.get('/en/category/1')
    self.assertEqual(rv.status_code, 200)
    self.assertTrue('Phones' in rv.data) 

前面测试创建一个 category,并且 assert 相应的状态信息。当一个 category 成功创建的时候,我们将重定向到新建好的 category 页面,这时候状态码是 302。看下面代码:

def test_create_product(self):
"Test creation of new product"
rv = self.app.get('/en/product-create')
self.assertEqual(rv.status_code, 200)

rv = self.app.post('/en/product-create')
self.assertEqual(rv.status_code, 200)
self.assertTrue('This field is required.' in rv.data)

# Create a category to be used in product creation
rv = self.app.post('/en/category-create', data={
    'name': 'Phones',
})
self.assertEqual(rv.status_code, 302)

rv = self.app.post('/en/product-create', data={
    'name': 'iPhone 5',
    'price': 549.49,
    'company': 'Apple',
    'category': 1
})
self.assertEqual(rv.status_code, 302)

rv = self.app.get('/en/products')
self.assertEqual(rv.status_code, 200)
self.assertTrue('iPhone 5' in rv.data) 

前面测试创建了一个商品,assert 了每个调用相应的状态信息。

提示 【待修改】

做为这个测试的一部分,我们对 create_product()方法做了一些修改。之前见到的image = request.files['image']被替换为了image = request.filesrequest.files['image']。这是因为在使用 HTML 表单的时候,我们有一个空的参数 request.files[‘image’],但是现在我们没有了。

看下面代码:

def test_search_product(self):
    "Test searching product"
    # Create a category to be used in product creation
    rv = self.app.post('/en/category-create', data={
        'name': 'Phones',
    })
    self.assertEqual(rv.status_code, 302)

    # Create a product
    rv = self.app.post('/en/product-create', data={
        'name': 'iPhone 5',
        'price': 549.49,
        'company': 'Apple',
        'category': 1
    })
    self.assertEqual(rv.status_code, 302)

    # Create another product
    rv = self.app.post('/en/product-create', data={
        'name': 'Galaxy S5',
        'price': 549.49,
        'company': 'Samsung',
        'category': 1
    })
    self.assertEqual(rv.status_code, 302)

    self.app.get('/')

    rv = self.app.get('/en/product-search?name=iPhone')
    self.assertEqual(rv.status_code, 200)
    self.assertTrue('iPhone 5' in rv.data)
    self.assertFalse('Galaxy S5' in rv.data)

    rv = self.app.get('/en/product-search?name=iPhone 6')
    self.assertEqual(rv.status_code, 200)
    self.assertFalse('iPhone 6' in rv.data) 

前面测试文件新建了一个 category 和两个 product。之后,搜索一个产品,并确保结果中只返回搜索的产品。

怎么做

运行测试文件,需要在终端运行下面命令:

$ python app_tests.py -v
test_create_category (__main__.CatalogTestCase)
Test creation of new category ... ok
test_create_product (__main__.CatalogTestCase)
Test creation of new product ... ok
test_home (__main__.CatalogTestCase)
Test home page ... ok
test_products (__main__.CatalogTestCase)
Test Products list page ... ok
test_search_product (__main__.CatalogTestCase)
Test searching product ... ok
---------------------------------------------------------------
Ran 5 tests in 0.189s

OK 

上面输出表明了测试的结果。

Nose 库集成

Nose 是一个库,可以用来使得测试更容易更有趣。它提供了许多工具来加强测试。尽管 Nose 可以用于多种用途,最重要的用法仍然是测试收集器和运行器。Nose 从当前工作目录下的 Python 源文件、目录和软件包中自动收集测试用例。我们将重点关注如何使用 Nose 运行单个测试,而不是每次运行全部测试。

准备

首先,安装 Nose 库:

$ pip install nose 

怎么做

我们可以使用 Nose 运行应用中所有的测试,通过下面命令:

$ nosetests -v
Test creation of new category ... ok
Test creation of new product ... ok
Test home page ... ok
Test Products list page ... ok
Test searching product ... ok
---------------------------------------------------------------
Ran 5 tests in 0.399s

OK 

这将选择应用程序中的所有测试,并运行它们,即使我们有多个测试文件。
为了运行单个测试文件,需使用下面命令:

$ nosetests app_tests.py 

现在,如果需要运行单个测试,可以使用下面命令:

$ nosetests app_tests:CatalogTestCase.test_home 

当我们有一个内存密集型的应用程序和大量的测试用例时,这一点变得非常重要。测试本身可能会花费大量的时间来运行,而且每次这样做对开发人员来说都是非常令人沮丧的。相反,我们更愿意只运行那些与所做的更改有关的测试,或者在某个更改上失败的测试。

其他

  • 还有许多其他配置 Nose 的方法。参见http://nose.readthedocs.org/en/latest/usage.html

使用 mocking 避免真实 API 访问

确定测试覆盖率

前一小节,包含了测试编写,但测试还有一个重要的方法是测试覆盖。覆盖率表示测试覆盖了我们多少的代码。覆盖率越高,测试越高(尽管这不是优秀测试的唯一标准)。这一小节,我们将检查我们应用的覆盖率。

提示

记住百分百的测试率并不意味着代码是完美的。然后在多数情况下,这比没有测试或者低覆盖率要好很多。没有测试的东西都可能是存在问题的。

准备

我们将使用一个库叫做 coverage。安装它:

$ pip install coverage 

怎么做

最简单的获取覆盖率细节的方法是使用命令行。仅需运行下面命令:

$ coverage run –source=../<Folder name of application> --omit=app_tests.py,run.py app_tests.py 

这里–source 表示需要覆盖率中需要考虑的目录,–omit 表示需要忽略的文件。

现在,在终端打印报告,需运行:

$ coverage report 

下面截图显示了输出:

为了得到覆盖率 HTML 形式的输出,运行下面命令:

$ coverage html 

这会在当前工作目录中创建一个新的文件夹叫做 htmlcov。仅需在用浏览器打开其中的 index.html,就可以看到所有的细节。
或者,我们可以在测试文件中包含一段代码,这样每次运行测试时都会获得覆盖率报告。在 app_tests.py 中最前面添加以下代码片段:

import coverage
cov = coverage.coverage(
    omit = [
    '/Users/shalabhaggarwal/workspace/mydev/lib/python2.7/sitepackages/*',
    'app_tests.py'
    ]
)
cov.start() 

这里,导入了 coverage 库,然后创建了一个对象;告诉 coverage 忽略所有的 site-packages(通常 coverage 会包含所有的依赖),以及测试文件本身。然后,我们开始计算覆盖率的过程。
最后,修改最后一个代码块:

if __name__ == '__main__':
    try:
        unittest.main()
    finally:
        cov.stop()
        cov.save()
        cov.report()
        cov.html_report(directory = 'coverage')
        cov.erase() 

前面的代码,首先将 unittest.main()放在 try..finally 块中。这是因为在执行完所有测试之后,unittest.main()会退出。现在,在这个方法完成之后,覆盖率的代码会运行。我们首先停止覆盖率报告,保存它,在控制台上打印报告,然后在删除临时文件.coverage 之前产生 HTML 版本的报告(这些是自动完成的)。

原理

现在运行命令:

$ python app test.py 

输出看起来像这样:

其他

  • 使用 Nose 库也是可以测量覆盖率的。这留给你们自己探索。参见https://nose.readthedocs.org/en/latest/plugins/cover.html?highlight=coverage

使用 profiling 寻找瓶颈

当我们决定去扩展应用的时候,Profiling 是一个重要的工具。在扩展前,我们希望知道哪一个进程是一个瓶颈,影响了整体上的运行。Python 有一个自带的分析器叫做 cProfile 可以帮助我们做这件事,但是为了生活更加的美好,Werkzeug 自带一个基于 cProfile 的 ProfilerMiddleware。我们将使用它来寻找应用瓶颈。

准备

我们将使用上一小节的应用,新建一个文件叫做 generate_profile.py,在里面增加 ProfileMiddleware。

怎么做

在 run.py 旁边,新建一个文件 generate_profile.py,这个文件就像 run.py 一样,但是它使用 ProfilerMiddleware:

from werkzeug.contrib.profiler import ProfilerMiddleware
from my_app import app
app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions = [10])
app.run(debug=True) 

这里,我们从 werkzeug 导入了 ProfileMiddleware,然后修改 wsgi_app 去使用它,限制输出只打印 10 个调用的结果。

原理

现在,使用 generate_profile.py 运行应用:

$ python generate_profile.py 

之后新建一个新的商品。然后特定调用的输出类似于下面截图:

从前面截图中看出,这个过程中最密集的调用是对数据库的调用。所以,如果我们决定在未来某个时候提高性能,那么这个是首先需要考虑的问题。

第十一章:部署

现在,我们已经知道了如何使用不同的方法去编写 Flask 应用。部署一个应用和管理部署和开发应用一样重要。有许多部署应用的方式,需要去选择一个最合适的方式。从安全和性能角度来说,合理正确的部署是非常重要的。有许多方式来监控部署之后的应用,其中一些是收费的,也有一些是免费的。根据提供的需求和特性来决定是否使用。

这一章将包含下面小节:

  • 使用 Apache 部署
  • 使用 uWSGI 和 Nginx 部署
  • 使用 Gunicorn 和 Supervisor 部署
  • 使用 Tornado 部署
  • 使用 Fabric 进行部署
  • S3 文件上传
  • 使用 Heroku 部署
  • 使用 AWS Elastic Beanstalk 部署
  • 使用 Pingdom 监控应用
  • 使用 New Relic 进行应用性能管理和监控

介绍

这一章,我们将讨论多种应用部署技术和监控技术。
每种工具和技术有它自己的特性。举个例子,给应用添加太多的监控事实证明是对应用的额外负担,对开发者同样如此。相似的,如果不使用监控,会错过未被检测出来的用户错误和引起用户不满。
所以,应该选择恰当的工具,这样才能让生活和谐。
部署监控工具中,我们将讨论 Pingdom 和 New Relic。Sentry 是另一个从开发人员的角度来看,被证明是最有益的工具。我们已经在第十章讨论过它了。

使用 Apache 部署

首先,我们将学习怎么使用 Apache 部署 Flask 应用。对于 Python web 应用来说,我将使用 mod_wsgi,它实现了一个简单的 Apache 模块,可以托管(host)任何 Python 应用,也支持 WSGI 接口。

提示

mod_wsgi 不同于 Apache,需要被单独安装。

准备

我们将从商品应用程序开始,对其进行适当的调整,使用 Apache HTTP 服务器部署它。
首先,使得我们的应用可安装,那样我们的应用和所有的依赖都会在 Python 加载路径上。可以使用第一章的 setup.py 脚本完成功能。对于这个应用稍微修改了 script 脚本:

packages=[
    'my_app',
    'my_app.catalog',
],
include_package_data=True,
zip_safe = False, 

首先,我们列出了所有需要作为应用程序一部分的安装包。它们每个都需要一个__init__.py文件。zip_safe 标记告诉安装者不要将这个应用作为 ZIP 文件进行安装。include_package_data 从相同目录下的 MANIFEST.in 文件里读取内容,包含提到的所有包。MANIFEST.in 看起来像这样:

recursive-include my_app/templates *
recursive-include my_app/static *
recursive-include my_app/translations * 

现在,安装应用只需使用下面的命令:

$ python setup.py install 
提示

mod_wsgi 的安装跟操作系统有关系。在 Debian 系列系统里安装很简单,使用安装工具即可,比如 apt 或者 aptitude。更多细节,参见https://code.google.com/p/modwsgi/wiki/InstallationInstructions and https://github.com/GrahamDumpleton/mod_wsgi

怎么做

我们需要新建一些文件,第一个是 app.wsgi。这将使我们的应用作为 WSGI 应用进行加载:

activate_this = '<Path to virtualenv>/bin/activate_this.py'
execfile(activate_this, dict(__file__=activate_this))

from my_app import app as application
import sys, logging
logging.basicConfig(stream = sys.stderr) 

如果是在 virtualenv 下执行的安装,需要在应用加载前激活虚拟环境。如果是在系统下进行的安装,前两句不需要。然后把 app 对象作为 application 导入。最下面两句是可选的,它们用标准 lgger 进行输出,默认情况下 mod_wsgi 是没开启的。

提示

app 对象需要作为 application 进行导入,因为 mod_wsgi 期望 application 这样的关键字。

接下来是一个配置文件被 Apache HTTP 服务器用来服务应用。这个文件命名为 apache_wsgi.conf:

<VirtualHost *>
        WSGIScriptAlias / <Path to application>/flask_catalog_deployment/app.wsgi
        <Directory <Path to application>/flask_catalog_deployment>
            Order allow,deny
            Allow from all
        </Directory>
</VirtualHost> 

前面的代码是 Apache 配置,这个告诉 HTTP 服务器需要加载应用的目录。

最后一步是在 apache2/httpd.conf 文件添加 apache_wsgi.conf 文件的路径,使用服务器运行的时候加载我们的应用:

Include <Path to application>/flask_catalog_deployment/apache_wsgi.conf 

原理

使用下面命令重启 Apache 服务器服务:

$ sudo apachectl restart 

在浏览器打开http://127.0.0.1/会看到应用的主页。任何错误发生的时候可以通过/var/log/apache2/error_log(不同操作系统该文件路径不一样)文件查看。

更多

我们可能会发现商品图片可能会失效。为此,需要对应用配置做一个小的修改:

app.config['UPLOAD_FOLDER'] = '<Some static absolutepath>/flask_test_uploads' 

我们选择了一个 static 路径,因为我们不希望每次应用修改或安装的时候都进行修改。
现在,包括这个路径到 apache_wsgi.conf 中:

Alias /static/uploads/ "<Some static absolutepath>/flask_test_uploads/"
<Directory "<Some static absolute path>/flask_test_uploads">
    Order allow,deny
    Options Indexes
    Allow from all
    IndexOptions FancyIndexing
</Directory> 

之后,安装应用和重启 apachectl。

其他

使用 uWSGI 和 Nginx 部署

对于那些已经知道 uWSGI 和 Nginx 的人来说,没有更多需要解释的了。uWSGI 是和服务器的一个协议,提供了一个完整的 stack 托管服务。Nginx 是一个反向代理和 HTTP 服务器,它非常的轻便,几乎可以处理无限量的请求。Nginx 能够无缝的使用 uWSGI,并为了更好的性能提供了许多底层的优化。

准备

我们将使用上一小节的应用,还有 app.wsgi,setup.py,MANIFEST.ini 文件。同样,上一小节对应用配置文件的修改同样适用于这一小节。

提示

关闭可能在运行的 HTTP 服务器,比如 Apache 等等。

怎么做

首先,需要安装 uWSGI 和 Nginx。在 Debian 发行版比如 Ubuntu 上,安装很容易,可以使用:

# sudo apt-get install nginx
# sudo apt-get install uWSGI 
提示

你可以在 virtualenv 里使用 pip install uWSGI 安装 uWSGI。

不同的操作系统,各个软件安装方法不同,需参见各自文档。

确保有一个用于 uWSGI 的文件夹 apps-enabled,这里将放置应用特定的 uWSGI 配置文件。也要确保有一个供 Nginx 使用的 sites-enabled 文件夹,这里放置网站特定的配置文件。通常安装好软件后他们都在/etc/文件下已经存在了。如何没有根据不同操作系统进行相应的创建。

接下来,我们将在应用里创建一个叫做 uwsgi.ini 的文件:

[uwsgi]
http-socket = :9090
plugin = python
wsgi-file = <Path to application>/flask_catalog_deployment/app.wsgi
processes = 3 

为了测试 uWSGI 是否正常工作,需运行下面命令:

$ uwsgi --ini uwsgi.ini 

前面命令相对于运行下面命令:

$ uwsgi --http-socket :9090 --plugin python --wsgi-file app.wsgi 

现在,在浏览器输入http://127.0.0.1:9090/。这将打开应用主页。

创建一个软链接到 apps-enabled 文件夹:

$ ln -s <path/to/uwsgi.ini> <path/to/apps-enabled> 

在向下继续之前,编辑前面的文件,使用 socket 替换 http-socket。这将协议从 HTTP 改为 uWSGI(更多参见http://uwsgi-docs.readthedocs.org/en/latest/Protocol.html)。现在,创建一个新的文件叫做 nginx-wsgi.conf。这包含用于服务应用的 Nginx 配置和静态文件:

location / {
    include uwsgi_params;
    uwsgi_pass 127.0.0.1:9090;
}
location /static/uploads/{
    alias <Some static absolute path>/flask_test_uploads/;
} 

前面代码块,uwsgi_pass 指定 uWSGI 服务器需要被映射到的指定位置。
创建一个软连接到 sites-enabled 文件夹:

$ ln -s <path/to/nginx-wsgi.conf> <path/to/sites-enabled> 

编辑 nginx.conf 文件(通常位置是/etc/nginx/nginx.conf),增加下面行:

include <path/to/sites-enabled>/*; 

做好这些之后,重启 Nginx:

$ sudo nginx -s reload 

浏览器输入http://127.0.0.1/来看通过 Nginx 和 uWSGI 服务的程序。

提示

这一小节的一些指令会根据操作系统的不同而不同。不同包的安装方法也不一样。

其他

  • 了解更多 uWSGI,参见http://uwsgi-docs.readthedocs.org/en/latest/
  • 了解更多 Nginx,参见http://nginx.com/
  • DigitalOcean 写了一篇很好的文章关于这个话题。参见https://www.digitalocean.com/community/tutorials/how-to-deploy-python-wsgi-applications-using-uwsgi-web-server-with-nginx
  • 了解 Apache 和 Nginx 的不同,参见 Anturis 的文章,https://anturis.com/blog/nginx-vs-apache/

使用 Gunicorn 和 Supervisor 部署

Gunicorn 是一个为了 Unix 的 WSGI HTTP 的服务器。它非常的轻便,快速。它的简单性在于它与各种 web 框架的广泛兼容性。
Supervisor 是一个监控工具能够控制各种进程,处理这些进程的启动,并且在这些进程异常退出的时候重启它们。它能够被扩展到使用 XML-RPC API 控制远程位置上的程序,而不用登录远程服务器(我们不会在这里讨论这,因为已经超出了本书的范围)。
需要记住的一件事是这些工具能够和之前章节提到的工具比如 Nginx 一起配合。

准备

我们将从 gunicorn 和 supervisor 安装开始:

$ pip install gunicorn
$ pip install supervisor 

怎么做

检查 gunicorn 是否正常工作,需在应用文件夹里运行下面命令:

$ gunicorn -w 4 -b 127.0.0.1:8000 my_app:app 

之后,在浏览器输入http://127.0.0.1:8000/可以看到应用的主页。
现在需要使用 Supervisor 去做相同的事情,让应用作为后台进程运行,这将让 Supervisor 自身控制进程而不是人为控制。首先,需要一个 Supervisor 配置文件。在 virtualenv 里运行下面命令可以获得配置文件。Supervisor 通常会寻找 etc 文件夹,里面存在一个 supervisord.conf 文件。在系统层面的安装下,这个文件夹是/etc,但在 virtualenv 里,会在 virtualenv 里寻找 etc,然后返回到/etc/:

$ echo_supervisord_conf > etc/supervisord.conf 
提示

echo_supervisord_conf 是由 Supervisor 提供的,它向特定位置输出一个配置文件。

下面命令将会在 etc 文件夹里创建一个叫做 supervisord.conf 的文件。在这个文件里添加下面代码块:

[program:flask_catalog]
command=<path/to/virtualenv>/bin/gunicorn -w 4 -b 127.0.0.1:8000 my_
app:app
directory=<path/to/virtualenv>/flask_catalog_deployment
user=someuser # Relevant user
autostart=true
autorestart=true
stdout_logfile=/tmp/app.log
stderr_logfile=/tmp/error.log 
提示

注意不应该使用 root 权限去运行这个应用。当应用程序崩溃时是一个巨大的安全漏洞,可能会伤害操作系统本身。

原理

现在运行下面命令:

$ supervisord
$ supervisorctl status
flask_catalog RUNNING pid 40466, uptime 0:00:03 

第一个命令启动 supervisord 服务器,接下来查看所有进程的状态。

提示

这一小节提到的工具可以和 Nginx 配合,其中 Ninx 作为代理服务器。建议你自己尝试一下。
每次当修改应用的时候,都需要重启 Gunicorn,以便让这些修改生效,运行下面命令:

$ supervisorctl restart all 

你可以重启特定程序而不是所有:

$ supervisorctl restart flask_catalog 

其他

使用 Tornado 部署

Tornado 是一个完整的 web 框架,它本身也是一个 web 服务器。这里,我们将使用 Flask 去创建应用,包含一个基本的 URL 路由和模板,服务器部分由 Tornado 完成。Tornado 是为了支持上千的并发请求而创建的。

提示

Tornado 在配合使用 WSGI 应用的时候会存在一些限制。所以慎重选择。阅读更多,参见http://www.tornadoweb.org/en/stable/wsgi.html#running-wsgi-apps-on-tornado-servers

准备

安装 Tornado 使用:

$ pip install tornado 

怎么做

接下来,创建一个叫做 tornado_server.py 的文件,填写下面的内容:

from tornado.wsgi import WSGIContainer
from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop
from my_app import app

http_server = HTTPServer(WSGIContainer(app))
http_server.listen(5000)
IOLoop.instance().start() 

这里,我们为应用创建了一个 WSGT 容器;这个容器被用来创建一个 HTTP 服务器,端口是 5000。

原理

使用下面命令运行前一小节创建的 Python 文件:

$ python tornado_server.py 

浏览器输入http://127.0.0.1:5000/可以看见主页。

提示

Tornado 可以和 Nginx(作为代理服务器),Supervisor(进程管理)一起使用为了最好的效果。这留给你们自己完成。

使用 Fabric 进行部署

Fabric 是 Python 的一个命令行工具;它简化了使用 SSH 部署应用或系统管理任务的过程。同时它允许执行远程服务器的 shell 命令,使得部署简化了,因为所有操作现在可以被压缩到一个 Python 文件里,在需要的时候运行即可。因此,它减轻了每次都得登录进服务器然后手动输入命令行升级程序的痛苦。

准备

安装 Fabric:

$ pip install fabric 

我们将使用上一小节的应用。创建一个 Fabric 文件对远程服务器进行部署。

相似的,假设远程服务器已经创建好了,所有 virtualenv 环境里的依赖包都安装好了。

怎么做

首先在应用里创建一个叫做 fabfile.py 的文件, 最好在应用根目录下,即和 setup.py,run.py 同一层目录。Fabric 通常期望文件的名字是 fabfile.py。如果使用了一个不同的名字,在执行的时候需要明确的指定。
一个基本的 Fabric 文件看起来像这样:

from fabric.api import sudo, cd, prefix, run

def deploy_app():
    "Deploy to the server specified"
    root_path = '/usr/local/my_env'
    with cd(root_path):
        with prefix("source %s/bin/activate" % root_path):
            with cd('flask_catalog_deployment'):
                run('git pull')
                run('python setup.py install')
            sudo('bin/supervisorctl restart all') 

这里,首先进入 virtualenv,然后使能它,然后进入应用程序。然后从 Git 导入代码,然后使用 setup.py install 更新应用。之后,重启 supervisor 进行,这样修改可以生效。

提示

这里使用的大多数命令是很简单的。除了 prefix,它将后续所有的命令封装在它的块中。这意味着,先激活 virtualenv,然后在 with 块中所有的命令将会在 virtualenv 激活状态下运行的。在离开 with 块的时候,会离开 virtualenv 环境。

原理

运行这个文件,需要提供脚本所要执行的远程服务器。所以命令看起来是:

$ fab -H my.remote.server deploy_app 

更多

我们可以在 fab 脚本里指定远程地址,这可能是个好主意。因为部署服务器在大多数情况下是相同的。
为了做到这些,fab 脚本看起来像这样:

from fabric.api import settings

def deploy_app_to_server():
    "Deploy to the server hardcoded"
    with settings(host_string='my.remote.server'):
        deploy_app() 

这里,我们硬编码了 host 然后调用了之前创建好的方法进行部署。

S3 文件上传

Amazon 将 S3 解释为存储,是为了让开发者使用大规模的计算更容易。S3 通过 web 接口提供了一个非常简单的接口,这使得存储和在大量数据里的检索变得简单。直到现在,在我们的商品目录应用中,我们看到在创建商品过程中图片管理存在问题。如果这些图像存储在某个地方,并且可以从任何地方访问,这些问题将消失。我们将使用 S3 解决这个问题。

准备

Amazon 提供 boto,一个完整的 Python 库,提供了和 Amazon Web Service 之间的接口。大部分的 AWS 特性可以使用 boto 完成。安装 boto:

$ pip install boto 

怎么做

现在对已经存在的商品目录程序做一些修改,来使用 S3 上传文件。
首先,做配置,来允许 boto 访问 S3。在应用配置文件里增加下面语句,即,my_app/__init__.py

app.config['AWS_ACCESS_KEY'] = 'Amazon Access Key'
app.config['AWS_SECRET_KEY'] = 'Amazon Secret Key'
app.config['AWS_BUCKET'] = 'flask-cookbook' 

接下来,对 views.py 文件做些修改:

from boto.s3.connection import S3Connection 

上面从 boto 导入了需要的东西。接下来,替换 create_product()里面的两行:

filename = secure_filename(image.filename)
image.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) 

使用下面这些替换这两行:

filename = image.filename
conn = S3Connection(
    app.config['AWS_ACCESS_KEY'], app.config['AWS_SECRET_KEY']
)
bucket = conn.create_bucket(app.config['AWS_BUCKET'])
key = bucket.new_key(filename)
key.set_contents_from_file(image)
key.make_public()
key.set_metadata(
    'Content-Type', 'image/' + filename.split('.')[-1].lower()
) 

最后需要修改 product.html,这里修改图片 src 路径。使用下面语句提供之前的 img src:

<img src="{{ 'https://s3.amazonaws.com/' + config['AWS_BUCKET'] + '/' + product.image_path }}"/> 

原理

现在,像平常一样运行这个应用,然后创建一个商品。当创建好的商品进行渲染时,商品图像会花一点时间才出来,因为现在图片是由 S3 提供的(而不是本地机器)。如果出现这个现象,说明 S3 集成成功了。

使用 Heroku 部署

Heroku 是一个云应用平台,提供了一个简单的快速的方式去构建和部署 web 应用。Heroku 管理服务器,部署,和开发者开发应用时的操作。使用 Heroku toolbelt 来部署 Heroku 是相当简单。

准备

我们将使用上一小节的应用。
第一步需下载 Heroku toolbelt,可以通过https://toolbelt.heroku.com/下载。
一旦 toolbelt 被安装了,就可以在终端中使用一系列命令了。我们在这一小节后面会见到。

提示

建议使用一个新的 virtualenv 执行 Heroku 部署,以便为应用只安装需要的包。这将使得部署应用更快更容易。

现在,运行下面命令登录 Heroku 账户,并且和服务器同步 SSH key:

$ heroku login
Enter your Heroku credentials.
Email: shalabh7777@gmail.com
Password (typing will be hidden):
Authentication successful. 

如果不存在,将提示您创建新的 SSH 密钥。根据具体情况进行操作。

怎么做

现在,我们已经有了一个需要被部署到 Heroku 的应用了。首先,Heroku 需要知道部署时候需要运行的命令 。在 Procfile 里面添加下面内容:

web: gunicorn -w 4 my_app:app 

这里,我们将告诉 Heroku 去运行这个命令来启动应用。

提示

Profile 里面还可以做很多许多其他的配置和命令。更多细节,参见 Heroku 文档。

Heroku 需要知道需要被安装的依赖包。通过 requirements.txt 文件完成:

Flask==0.10.1
Flask-Restless==0.14.0
Flask-SQLAlchemy==1.0
Flask-WTF==0.10.0
Jinja2==2.7.3
MarkupSafe==0.23
SQLAlchemy==0.9.7
WTForms==2.0.1
Werkzeug==0.9.6
boto==2.32.1
gunicorn==19.1.1
itsdangerous==0.24
mimerender==0.5.4
python-dateutil==2.2
python-geoip==1.2
python-geoip-geolite2==2014.0207
python-mimeparse==0.1.4
six==1.7.3
wsgiref==0.1.2 

这个文件包含应用所有的依赖,还有依赖的依赖。产生这个文件的一个简单方式是使用下面命令:

$ pip freeze > requirements.txt 

这将用 virtualenv 里被安装的所有包来创建/更新 requirements.txt 文件。
现在,需要创建应用的 Git 仓库。为此,需运行下面命令:

$ git init
$ git add .
$ git commit -m "First Commit" 

现在,有了一个 Git 仓库,并且添加了所有的文件。

提示

确保在仓库里有一个.gitignore 文件来保证不添加临时文件比如.pyc 到仓库里。

现在,创建一个 Heroku 应用,然后添加应用到 Heroku:

$ heroku create
Creating damp-tor-6795... done, stack is cedar
http://damp-tor-6795.herokuapp.com/ | git@heroku.com:damp-tor-6795.git
Git remote heroku added
$ git push heroku master 

最后一个命令之后,许多东西会打印在终端上。这些表面了所有正在安装的包和最终启动的应用程序。

原理

在前面命令成功完成之后,仅仅需要在浏览器打开部署最后 Heroku 提供的 URL 或者运行下面命令:

$ heroku open 

这将打开应用主页。尝试创建一个新的商品并上传图片,之后会看到由 Amazon S3 提供的图片了。
为了看到应用的日志,运行下面命令:

$ heroku logs 

更多

我们刚刚做的部署有一个小故障。每次通过 git push 命令更新部署时,SQLite 都会被重写。解决办法是使用 Heroku 提供的 Postgres。建议你们自己去做一下。

使用 AWS Elastic Beanstalk 部署

上一小节,我们看到如何将应用部署到服务器,使用 Heroku 是很容易做到这个的。相似的,Amazon 有一个服务叫做 Elastic Beanstalk,允许开发者很容易的部署他们的应用到 Amazon EC2。仅仅需要一些配置,一个 Flask 应用使用 Elastic Beanstalk 就可以在几分钟类就部署到 AWS。

准备

我们将使用上一小节的应用。唯一保持一样的文件是 requirement.txt。上一小节其余添加的文件就可以被忽略,这一小节不会用到。

现在,首先要做的是从 Amazon 网站下载 AWS Elastic Beanstalk 命令行工具库。地址是http://aws.amazon.com/code/6752709412171743。这将下载一个 ZIP 包,需要进行解压然后放置在适当的位置。
这个工具的路径应该被添加进环境变量 PATH 中,这样这个命令才可以全局使用。这个可以通过 export 命令实现:

$ export PATH=$PATH:<path to unzipped EB CLI package>/eb/linux/python2.7/ 

同样需要添加路径到~/.profile 或者~./bash_profile 文件:

$ export PATH=$PATH:<path to unzipped EB CLI package>/eb/linux/python2.7/ 

怎么做

使用 Beanstalk 进行部署时,有一些惯例需要去遵守。Beanstalk 假设存在一个文件叫做 application.py,它包含了应用对象(我们的例子中就是 app 对象)。Beanstalk 把这个文件视为 WSGI 文件,它将用在部署中。

提示

在使用 Apache 部署一节中,有一个文件叫做 app.wsgi,在这个文件中我们导入 app 对象为 application,因为 apache/mod_wsgi 需要这样做。Amazon 也需要这样做,因为通常情况下,Amazon 背后使用的是 Apache 进行的部署。

application.py 文件内容看起来像这样:

from my_app import app as application
import sys, logging
logging.basicConfig(stream = sys.stderr) 

现在,在应用里创建一个 Git 仓库,提交这些文件:

$ git init
$ git add .
$ git commit -m "First Commit" 
提示

确保在仓库里有一个.gitignore 文件,防止添加临时文件进仓库,比如.pyc。
现在需要到 Elastic Beanstalk 运行下面命令:

$ eb init 

前面命令表示初始化 Elastic Beanstalk 实例。为了创建 EC2 实例,它将要求 AWS 凭据以及其他许多配置选项,这些可以根据需要进行选择。更多细节参见http://docs.aws.amazon.com/elasticbeanstalk/latest/dg/create_deploy_Python_flask.html

做完这些之后,运行下面命令触发服务器的创建,然后部署应用:

$ eb start 
提示

上面命令背后的事情是,先创建了 EC2 实例(一卷),分配一个弹性 IP,然后运行下面命令将我们的应用程序 push 到新创建的服务器,以便进行部署: $ git aws.push

这将花一点时间完成。当完成的时候,你可以使用下面命令检查应用的状态:

$ eb status –verbose 

当需要升级应用的时候,仅仅需要使用 git 提交修改,然后使用 push:

$ git aws.push 

原理

当部署进程完成的时候,它会给你一个应用 URL。在浏览器输入 URL,就可以看见应用了。
然后你会发现一些问题。静态文件比如 CSS,JS 工作不正常。这是因为 Beanstalk 里的 static 路径没有配置正确。这可以通过 AWS 管理控制台里的应用监控/配置页面修改应用配置进行修复。看下面截图进行更好的理解:

点击左边选项中的 Configuration 选项。

注意到前面截屏中高亮的部分。这是每个应用中需要改变的地方。打开 Software Settings。

修改 virtual path 的/static/,如上图截屏所示。

在这些修改完成之后,Elastic Beanstalk 创建的环境会自动更新,将花一点时间。当处理结束的时候,再一次检查应用看 static 文件是否正确工作了。

使用 Pingdom 监控应用

Pingdom 是一个网站监控工具,当你网站宕机的时候能够快速通知你。这个工具背后的思想是每一个间隔 ping 一下网站,比如 30s。如果 ping 失败了,它将通过 e-mail,SMS 等通知你。它将保持一个更快的频率来 ping 网站,直到网站恢复。还有其他一些监控特性,但这里我们会不涉及。

准备

因为 Pingdom 是一个 Saa 服务,第一步得注册一个账号。Pingdom 提供了一个月的免费试用,如果你想尝试的话。网站是:https://www.pingdom.com
我们使用上一小节的应用做演示。

怎么做

成功注册之后,创建一个时间检查。看一下下面的截图:

如你所见,我已经为 AWS 实例添加了一个检查。为了创建一个新的检查,点击 ADD NEW 按钮。填写弹出来的表单。

原理

在成功创建检查之后,有意的在代码里引发一个错误来破坏应用,然后部署到 AWS。当有错误的应用部署的时候,你将收到一封邮件。邮件看起来像这样:

一旦,你的应用修复了,然后重新部署了,下一封邮件将看起来像这样:

使用 New Relic 进行应用性能管理和监控

New Relic 是一个分析软件,提供了接近实时的操作和应用分析。它提供了应用各个方面的分析。它可以完成分析器的工作。事实上工作起来的情形是,我们的应用发送数据给 New Relic,而不是 New Relic 向我们应用请求分析。

准备

我们将使用上一小节的应用,使用 AWS 部署的。
第一步注册 New Relic 账号。在简单注册流程和邮件验证完成后,将登陆主页。这里,可以显示 license key,这会用来连接应用到这个账户。主页看起来像这样:

这里点击 Reveal your license key。

怎么做

一旦你获得了 license key,我们需要去安装 newrelic 库:

$ pip install newrelic 

现在,需要产生一个叫做 newrelic.ini 的文件,它将包含关于许可密钥,我们的应用程序的名称,等等这些细节。使用下面命令完成:

$ newrelic-admin generate-config LICENSE-KEY newrelic.ini 

前面命令中,用你账户真实的 license key 替换 LICENSE-KEY。现在有了一个新的文件叫做 newrelic.ini。打开并进行编辑应用的名字或其他东西。

为了验证 newrelic.ini 是否可用正常使用,运行下面命令:

$ newrelic-admin validate-config newrelic.ini 

这将告诉我们验证是否成功。如果不,检查 license key 是否正确。

现在,在应用配置文件my_app/__init__.py最上面添加下面几行。确保下面几行添加在其他行之前:

import newrelic.agent
newrelic.agent.initialize('newrelic.ini') 

现在,需要更新 requirements.txt 文件。运行下面命令:

$ pip freeze > requirements.txt 

之后,提交修改,然后部署到应用到 AWS 使用下面命令:

$ git aws.push 

原理

一旦你的应用成果部署到 AWS,它将发送分析数据到 New Relic,并且主页有了一个新的应用可以添加。

打开应用分析页面,大量的统计数据将会出现。它还将显示哪些调用花费了最长的时间,以及应用是如何处理的。你同样可以看到多个选项卡对应于不同类型的监控。

第十二章:其他贴士和技巧

这本书已经覆盖了使用 Flask 创建 web 应用需要知道的所有东西。但还是有很多需要你自己去探索。最后一章,我们将讲述额外一些小节,如果有必要的话,他们可以被添加进应用。

这一章,将包含下面内容:

  • 使用 Whoosh 进行全文搜索
  • 使用 Elasticsearch 进行全文搜索
  • 使用 signals
  • 使用缓存
  • 为 Flask 应用支持 E-mail
  • 理解异步操作
  • 使用 Celery

介绍

这一章,我们首先将学习如何使用 Whoosh 和 Elasticsearch 进行全文搜索。全文搜索对于提供大量内容和选项的 Web 应用程序(如电子商务网站)非常重要。接下来我们将捕捉信号,这些信号是在应用里某些操作执行时被发送的。然后为我们的 Flask 应用实现缓存。
我们同样将会看到应用如何支持发送 e-mail。然后将看到如何实现应用异步。通常,WSGI 应用是同步和阻塞的,不能同时处理多个同步请求。我们将看到如何通过一个简单的例子解决这个问题。我们还会集成 Celery 到我们的应用,看一个任务队列对应用带来的好处。

使用 Whoosh 进行全文搜索

Whoosh 是使用 Python 完成的一个快速全文索引和搜索库。它是一个完全 Pythonic 的 API,使得开发者为他们的应用增加搜索功能非常容易和高效。这一节,我们将使用一个叫做 Flask-WhooshAlchemy 的包,它集成了 Whoosh 文本搜索功能和 SQLAlchemy,以便用于 Flask 应用中。

准备

使用下面命令安装 Flask-WhooshAlchemy:

$ pip install flask_whooshalchemy 

它将安装需要的包和依赖。

译者注

flask_whooshalchemy 不支持 Python3,另外对中文支持也不好,不推荐使用,可以使用 jieba。

怎么做

使用 SQLAlchemy 集成 Whoosh 和 Flask 是非常简单的。首先,我们需要提供一个 Whoosh 目录的路径,这个目录下将创建模型索引。这应该在应用配置里完成,即my_app/__init__.py:

app.config['WHOOSH_BASE'] = '/tmp/whoosh' 

你可以选择任意你喜欢的路径,可以是绝对路径也可以是相对路径。

接下来,我们需要改变 models.py 文件,使得一些 string/text 字段可搜索:

import flask.ext.whooshalchemy as whooshalchemy
from my_app import app

class Product(db.Model):
    __searchable__ = ['name', 'company']
    # … Rest of code as before … #

whooshalchemy.whoosh_index(app, Product)

class Category(db.Model):
    __searchable__ = ['name']
    # … Rest of code as before … #

whooshalchemy.whoosh_index(app, Category) 

注意每个模型添加的__searchable__语句。它告诉 Whoosh 去创建这些字段的索引。记住这些字段应该是 text 或者是 string 类型的。whoosh_index 语句告诉应用为这些模型创建索引,如果他们还不存在的话。

做好这些之后,添加一个 handler 使用 Whoosh 进行搜索。可以在 views.py 处理这些:

@catalog.route('/product-search-whoosh')
@catalog.route('/product-search-whoosh/<int:page>')
def product_search_whoosh(page=1):
    q = request.args.get('q')
    products = Product.query.whoosh_search(q)
    return render_template(
        'products.html', products=products.paginate(page, 10)
    ) 

这里,通过 q 获取 URL 参数,然后传递它的值到 whoosh_search()方法里。这个方法将会对 Product 模型的 name 和 company 字段进行全文搜索。我们前面已经进行了设置,使得模型里的 name 和 company 变得可搜索了。

原理

在第四章基于 SQL 搜索一节中我们实现了一个基于基本字段搜索的方法。但是在使用 Whoosh 情况下,搜索时我们不需要指定任何字段。我们可以输入任何字段,如何匹配上可搜索字段的话,将会返回结果,并按照相关性进行排序。

首先,在应用创建一些商品。现在,打开http://127.0.0.1:5000/product-search-whoosh?q=iPhone,结果页将显示商品名包含 iPhone 的列表。

提示

Whoosh 提供了一些高级选项,我们可以控制哪些字段可以搜索或者结果是如何排序的。你可以根据应用的需要自行探索。

其他

  • 参考 https://pythonhosted.org/Whoosh/
  • 参考 https://pypi.python.org/pypi/Flask-WhooshAlchemy

使用 Elasticsearch 进行全文搜索

Elasticsearch 是一个基于 Lucene 的搜索服务,是一个开源信息检索库。ElasticSearch 提供了一个分布式全文搜索引擎,它具有 RESTful Web 接口和 schema-free JSON 文档。这一小节,我们将使用 Elasticsearch 为我们的 Flask 应用完成全文搜索。

准备

我们将使用一个叫做 pyelasticsearch 的 Python 库,它使得处理 Elasticsearch 很容易:

$ pip install pyelasticsearch 

我们同样需要安装 Elasticsearch 服务本身。可以从http://www.elasticsearch.org/download/下载。解压文件,然后运行下面命令:

$ bin/elasticsearch 

默认情况下,将在http://localhost:9200/上运行 Elasticsearch 服务。

怎么做

为了演示集成,我们将从向应用配置添加 Elasticsearch 开始,即my_app/__init__.py:

from pyelasticsearch import ElasticSearch
from pyelasticsearch.exceptions import IndexAlreadyExistsError

es = ElasticSearch('http://localhost:9200/')
try:
    es.create_index('catalog')
except IndexAlreadyExistsError, e:
    pass 

这里,我们从 ElasticSearch 类创建了一个 es 对象,它接收了服务器 URL。然后创建了一个叫做 catalog 的索引。他们是在 try-except 块中处理的,因为如果索引已经存在 ,将会抛出 IndexAlradyExistsError,通过捕捉异常,可以忽略这个错误。

接下来,我们需要往 Elasticsearch 索引里添加文档(document)。可以在视图和模型里完成这些,但是最好是将它添加在模型层。所以,我们将在 models.py 里完成这些:

from my_app import es

class Product(db.Model):

    def add_index_to_es(self):
        es.index('catalog', 'product', {
            'name': self.name,
            'category': self.category.name
        })
        es.refresh('catalog')

class Category(db.Model):

    def add_index_to_es(self):
        es.index('catalog', 'category', {
            'name': self.name,
        })
        es.refresh('catalog') 

这里,在每个模型里,我们添加了一个叫做 add_index_to_es()的方法,这个方法将添加与当前 Product 或者 Category 对象对应的文档到 catalog 索引里,并伴随相关的文件类型,即 product 或 category。最后,我们刷新索引以保证新建的索引可以被搜索到。

add_index_to_es()方法可以在我们创建,更新,删除商品时被调用。为了演示,我们仅仅在 views.py 创建商品时添加了这个方法:

from my_app import es

def create_product():
    #... normal product creation as always ...#
    db.session.commit()
    product.add_index_to_es()
    #... normal process as always ...#

@catalog.route('/product-search-es')
@catalog.route('/product-search-es/<int:page>')
def product_search_es(page=1):
    q = request.args.get('q')
    products = es.search(q)
    return products 

同时,添加一个product_search_es()方法,允许在刚刚创建的 Elasticsearch 上进行搜索。对 create_category()方法做同样的处理。

怎么做

假设已经在每个类别里面创建一些商品。现在,如果打开http://127.0.0.1:5000/product-search-es?q=galaxy,我们将看到类似于下面截图的回复:

使用 signals

Signals 可以理解为应用里发生的事件。这些事件可以被一些特定接收方订阅,当事件发生的时候会触发一个函数。事件的发生由发送方广播,发送方可以指定接收方触发函数里可以使用的参数。

提示

您应该避免修改信号中的任何应用程序数据,因为信号不是按指定的顺序执行的,而且很容易导致数据损坏。

准备

我们将使用一个叫做 binker 的 Python 库,它提供了一些信号特性。Flask 内建了对 blinker 的支持,可以很大程度上使用信号。其中一些核心信号是由 Flask 提供的。

这一小节,我们将使用上一小节的应用,通过信号添加额外的 product 和 category 文档(documents)到索引里。

怎么做

首先,我们创建新建商品和类别时的信号。可以在 models.py 中处理这个。也可以在任何我们希望的文件里处理,因为信号是在全局范围创建的:

from blinker import Namespace

catalog_signals = Namespace()
product_created = catalog_signals.signal('product-created')
category_created = catalog_signals.signal('category-created') 

我们使用 Namespace 去创建信号,因为这将在自定义命名空间创建他们,而不是在全局空间,这将有助于管理信号。我们创造了两个信号,可以从他们的名字明白他们的意思。

之后,我们需要去为这些信号创建订阅者,给他们绑定函数。为了这个目的,add_index_to_es()需要被移除,在全局范围需要创建新的函数:

def add_product_index_to_es(sender, product):
    es.index('catalog', 'product', {
        'name': product.name,
        'category': product.category.name   
    })
    es.refresh('catalog')

product_created.connect(add_product_index_to_es, app)

def add_category_index_to_es(sender, category):
    es.index('catalog', 'category', {
        'name': category.name,
    })
    es.refresh('catalog')

category_created.connect(add_category_index_to_es, app) 

前面的代码中,我们使用.connect()为信号创建了订阅者。这个方法接收一个函数,这个函数会在事件发生时调用。它同样接收一个发送方做为可选参数。app 对象作为发送者被提供,因为,我们不希望我们的函数在任何应用任何地方触发的时候都会被调用。这在扩展的情况下尤其适用,可以被多个应用程序使用。接收方调用的函数接收发送者作为第一个参数,如果发送方没提供的话,通常情况下是 None。我们提供product/category作为第二个参数,为了将这条记录添加进 Elasticsearch 索引。

现在,我们仅仅需要触发可以被接收方捕捉的信号。可以在 views.py 处理这些。为了达到这个目的,需要移除add_index_to_es()方法,使用.send()方法替换他们:

from my_app.catalog.models import product_created, category_created

def create_product():
    #... normal product creation as always ...#
    db.session.commit()
    product_created.send(app, product=product)
    # product.add_index_to_es()
    #... normal process as always ...# 

对 create_category()做同样的处理。

原理

当一个商品被创建的时候,product_created信号被触发了,app 做为发送方,商品做为关键参数。这个会在 models.py 中被捕捉,当add_product_index_to_es()函数被调用的时候,会向目录索引添加文档。

其他

  • 参考资料 https://pypi.python.org/pypi/blinker
  • 参考资料 http://flask.pocoo.org/docs/0.10/signals/#core-signals
  • Flask-SQLAlchemy 提供的信号可以在https://pythonhosted.org/Flask-SQLAlchemy/signals.html找到

使用缓存

当应用程序的响应时间增加成为一个问题时,缓存成为了任何 Web 应用程序的重要组成部分。Flask 本身默认不提供任何缓存支持,但是 Werkzeug 支持。Werkzeug 对缓存提供了基本的支持,并可以使用多种后端(backend),比如 Memcached 和 Redis。

准备

我们将安装一个叫做 Flask-Cache 的扩展,这会在很大程度上简化缓存的使用:

$ pip install Flask-Cache 

怎么做

首先,需初始化 Cache。将在应用配置里进行处理,my_app/__init__.py

from flask.ext.cache import Cache

cache = Cache(app, config={'CACHE_TYPE': 'simple'}) 

这里,使用 simple 做为 Cache 的类型,缓存存储在内存里。不建议在生产环境这样做。对于生产环境,需使用 Redis,Memcached,文件系统等等这些。Flask-Cache 对这些全部支持。

接下来,需在方法里增加缓存;这是非常容易实现的。我们仅仅需要对视图方法增加@cache.cached(timeout=

from my_app import cache
@catalog.route('/categories')
@cache.cached(timeout=120)
def categories():
    # Fetch and display the list of categories 

这种缓存方式以键值对的形式进行存储,键为请求路径,值为这个方法的输出值。

原理

在添加了前面的代码后,检查缓存是否生效。首先访问http://127.0.0.1:5000/categories获取类别列表。在将会在缓存里为这个 URL 存储一个键值对。现在,快速的创建一个新的类别,然后再次访问商品类别列表页面。你将看到新建的类别没有被列出来。等几分钟,然后刷新页面。新的类别才会被展示出来。这是因为第一次访问的时候类别列表被缓存了,有效时间是 2 分钟,即 120 秒。

这可能看起来像是应用的一个错误,但是在大型应用程序中,减少对数据库的访问次数是一种恩惠,并且整个应用程序体验也会得到改善。缓存通常用于那些结果不经常更新的处理程序。

更多

我们中的许多人可能认为,在单个类别或产品页面中,这种缓存将失败,因为每个记录都有一个单独的页面。解决这个问题的方法是 memoization。当一个方法用同样参数进行多次调用时,结果应该是从缓存中加载而不是访问数据库。实现 memoization 非常简单:

@catalog.route('/product/<id>')
@cache.memoize(120)
def product(id):
    # Fetch and display the product 

现在,如果我们在浏览器输入http://127.0.0.1:5000/product/1,第一次请求将从数据库读取数据。但是,第二次,如果做相同的访问,将从缓存加载数据。如果我们打开另一个商品页面http://127.0.0.1:5000/product/2,将从数据库获取商品细节。

其他

  • 了解更多 Flask-Cache,参见https://pythonhosted.org/Flask-Cache/
  • 了解更多 memoization,参见http://en.wikipedia.org/wiki/Memoization

为 Flask 应用支持 E-mail

发送邮件功能是任何 web 应用最基础功能中的一个。基于 Python 的应用,可以使用 smptblib 非常容易的完成这一功能。在 Flask 中,使用 Flask-Mail 扩展更加简化了这一过程。

准备

Flask-Mail 通过 pip 安装:

$ pip install Flask-Mail 

让我们以一个简单的例子为例,每当添加新类别时,将向应用管理者发送一封 e-mail。

怎么做

首先,需要在应用配置里实例化 Mail 对象,即my_app/__init__.py:

from flask_mail import Mail

app.config['MAIL_SERVER'] = 'smtp.gmail.com'
app.config['MAIL_PORT'] = 587
app.config['MAIL_USE_TLS'] = True
app.config['MAIL_USERNAME'] = 'gmail_username'
app.config['MAIL_PASSWORD'] = 'gmail_password'
app.config['MAIL_DEFAULT_SENDER'] = ('Sender name', 'sender email')
mail = Mail(app) 

同时,我们需一些配置去创建 e-mail 服务和发送者账号。前面的代码是一个配置 Gmail 账户的简单示例。任何 SMTP 服务可以像这样建立。还有一些其他选项可以选择,可以参见 Flask-Mail 文档https://pythonhosted.org/Flask-Mail

原理

在类别创建的时候发送 e-mail,我们需要对 view.py 做下面修改:

from my_app import mail
from flask_mail import Message

@catalog.route('/category-create', methods=['GET', 'POST'])
def create_category():
    # … Create category … #
    db.session.commit()
    message = Message(
        "New category added",
        recipients=['some-receiver@domain.com']
    )
    message.body = 'New category "%s" has been created' % category.name
    mail.send(message)
    # … Rest of the process … # 

这里,一个新的 e-mail 将从我们创建的发送方发送给接收列表方。

更多

现在,假设需要发送一封非常大的邮件,包含非常多的 HTML 文本。将这些全都写在 Python 文件里会使得代码丑陋和难以管理。一个简单的方法是创建模板,然后在发送邮件的时候渲染。我创建了两个模板,一个是 HTML 文本,一个纯文本。

category-create-email-text.html 模板看起来像这样:

A new category has been added to the catalog.
The name of the category is {{ category.name }}.
Click on the URL below to access the same:
{{ url_for('catalog.category', id=category.id, _external = True) }}
This is an automated email. Do not reply to it. 

category-create-email-html.html 模板看起来像这样:

<p>A new category has been added to the catalog.</p>
<p>The name of the category is <a href="{{ url_for('catalog.category', id=category.id, _external = True) }}">
        <h2>{{ category.name }}</h2>
    </a>.
</p>
<p>This is an automated email. Do not reply to it.</p> 

之后,我们需修改之前在 views.py 里创建 e-mail 的代码;

message.body = render_template(
    "category-create-email-text.html", category=category
)
message.html = render_template(
    "category-create-email-html.html", category=category
) 

其他

  • 阅读下一小节,明白怎么将耗时的 email 发送过程用一个异步线程处理

理解异步操作

应用中有一些操作可能是耗时的,会使用应用变慢,即使这并不是真正意义上的慢。但这降低了用户体验。为了解决这个问题,最简单的方式是使用线程进行异步操作。这一小节,我们将使用 Python 的 thread 和 threading 库来完成这一功能。threading 库是 thread 的一个简单接口;它提供了更多功能和隐藏了用户不常使用的一些东西。

准备

我们将使用上一小节的应用代码。我们中的许多人可能会注意到当邮件在发送的时候,应用在等待这一过程完成,事实上这是不必要的。E-mail 发送可以在后台处理,这样我们的应用对用户来说响应就变得及时。

怎么做

使用 thread 库处理异步是非常简单。仅仅需要在 views.py 里增加这些代码:

import thread

def send_mail(message):
    with app.app_context():
        mail.send(message)
# Replace the line below in create_category()
#mail.send(message)
# by
thread.start_new_thread(send_mail, (message,)) 

你可以看到,在一个新的线程里发生邮件,这个线程接收一个叫 message 的参数。我们需要去创建一个新的send_mail()方法,因为我们的 e-mail 模板包含url_for,所以send_mail方法仅能运行在应用上下文中,默认情况下在一个新建的线程里不可用。

同时,发送 e-mail 同样可以使用 threading 库:

from threading import Thread

# Replace the previously added line in create_category() by
new_thread = Thread(target=send_mail, args=[message])
new_thread.start() 

实际上,效果和前面一样,但是 threading 库提供了在需要的时候启动线程的灵活性,而不是同时创建和启动线程。

原理

很容易观察上面代码的效果。比较这个应用的执行效果和前一小节的应用效果。你会发现这个应用响应性更强。其他一种方式是监测日志输出,在 e-mail 发送之前,新建的类别页面将会加载。

使用 Celery

Celery 是为 Python 准备的任务队列。早期的时候有一个扩展是集成了 Flask 和 Celery,但是在 Celery3.0 的时候,这个扩展被废弃了。现在,Celery 可以直接在 Flask 里使用,而仅仅需做一些配置。前面小节,我们实现了异步发送邮件,这一小节将使用 Celery 完成同样的功能。

准备

安装 Celery:

$ pip install celery 

在 Flask 下使用 Celery,我们仅仅需要修改一点 Flask app 配置。这里,使用 Redis 做为代理(broker)。
我们将使用前一小节的应用,然后使用 Celery 完成它。

怎么做

首先要做的是,在应用配置文件里做一些配置,即my_app/__init__.py:

from celery import Celery

app.config.update(
    CELERY_BROKER_URL='redis://localhost:6379',
    CELERY_RESULT_BACKEND='redis://localhost:6379'
)
def make_celery(app):
    celery = Celery(
        app.import_name, broker=app.config['CELERY_BROKER_URL']
    )
    celery.conf.update(app.config)
    TaskBase = celery.Task
    class ContextTask(TaskBase):
        abstract = True
        def __call__(self, *args, **kwargs):
            with app.app_context():
                return TaskBase.__call__(self, *args, **kwargs)
    celery.Task = ContextTask
    return celery 

前面的代码直接来自于 Flask 网站,大部分情况下可以在应用里这样使用:

celery = make_celery(app) 

运行 Celery 进程,需执行下面命令:

$ celery worker -b redis://localhost:6379 --app=my_app.celery -l INFO 
提示

确保 Redis 运行在 broker URL 上,如配置中所指定的那样。

这里,-b 指定了 broker,-app 指定了在配置文件里创建的 celery 对象。
现在,仅仅需要在 views.py 里使用 celery 对象去异步发送邮件:

from my_app import celery

@celery.task()
def send_mail(message):
    with app.app_context():
        mail.send(message)

# Add this line wherever the email needs to be sent
send_mail.apply_async((message,)) 

如果我们希望一个方法作为 Celery 任务运行只需增加@celery.task 装饰器即可。Celery 进程会自动检测到这些方法。

原理

现在,我们创建了一个商品,并且一封邮件发送了,我们可以在 Celery 进程日志里看到一个任务正在运行,看起来像这样:

[2014-08-28 01:16:47,365: INFO/MainProcess] Received task: my_app.catalog.views.send_mail[d2ca07ae-6b47-4b76-9935-17b826cdc340]
[2014-08-28 01:16:55,695: INFO/MainProcess] Task my_app.catalog.views.send_mail[d2ca07ae-6b47-4b76-9935-17b826cdc340] succeeded in 8.329121886s: None 

其他

  • 更多 Celery 信息,参见 http://docs.celeryproject.org/en/latest/index.html
posted @ 2024-05-20 16:50  绝不原创的飞龙  阅读(24)  评论(0编辑  收藏  举报