博客教程
将会创建一个名为 Flaskr 的具备基本功能的博客应用。应用用户可以 注册、登录、发贴和编辑或者删除自己的帖子。可以打包这个应用并且安装到其他电脑上
项目布局
创建并进入项目文件夹,并设置个 python 虚拟环境
mkdir flask-tutorial
cd flask-tutorialPython 项目使用 包 来管理代码,把代码分为不同的模块,然后在需要的地方导入模块。教程项目包含如下内容:
flaskr/,一个包含应用代码和文件的 Python 包tests/,一个包含测试模块的文件夹.venv/,一个 Python 虚拟环境,用于安装 Flask 和其他依赖的包- 告诉 Python 如何安装项目的安装文件
- 版本控制配置,如 git 。不管项目大小,应当养成使用版本控制的习惯
- 项目需要的其他文件
最后,项目布局如下:
/home/user/Projects/flask-tutorial
├── flaskr/
│ ├── __init__.py
│ ├── db.py
│ ├── schema.sql
│ ├── auth.py
│ ├── blog.py
│ ├── templates/
│ │ ├── base.html
│ │ ├── auth/
│ │ │ ├── login.html
│ │ │ └── register.html
│ │ └── blog/
│ │ ├── create.html
│ │ ├── index.html
│ │ └── update.html
│ └── static/
│ └── style.css
├── tests/
│ ├── conftest.py
│ ├── data.sql
│ ├── test_factory.py
│ ├── test_db.py
│ ├── test_auth.py
├── .venv/
├── pyproject.toml
└── MANIFEST.in如果使用版本控制,那么应当忽略运行项目时产生的临时文件以及编辑代码时,编辑器产生的临时文件。忽略文件的基本原则是:不是自己写的文件就可以忽略。.gitignore 文件应当与下面类似:
.venv/
*.pyc
__pycache__/
instance/
.pytest_cache/
.coverage
htmlcov/
dist/
build/
*.egg-info/应用设置
一个 Flask 应用是一个 Flask 类的实例。应用的所有东西(例如配 置和 URL )都会和这个实例一起注册。
创建一个 Flask 应用最粗暴直接的方法是在代码的最开始创建一个全局 Flask 实例。但是当项目越来越大的时候就会有些力不从心了。
可以在一个函数内部创建 Flask 实例来代替创建全局实例。这个函 数被称为 应用工厂 。所有应用相关的配置、注册和其他设置都会在函数内部完成,然后返回这个应用。
应用工场
创建 flaskr 文件夹并且文件夹内添加 __init__.py 文件。 __init__.py 有两个作用:一是包含应用工厂; 二是告诉 Python flaskr 文件夹应当视作为一个包
mkdir flaskrflaskr/__init__.py
import os
from flask import Flask
def create_app(test_config=None):
# create and configure the app
app = Flask(__name__, instance_relative_config=True)
app.config.from_mapping(
SECRET_KEY='dev',
DATABASE=os.path.join(app.instance_path, 'flaskr.sqlite'),
)
if test_config is None:
# load the instance config, if it exists, when not testing
app.config.from_pyfile('config.py', silent=True)
else:
# load the test config if passed in
app.config.from_mapping(test_config)
# ensure the instance folder exists
try:
os.makedirs(app.instance_path)
except OSError:
pass
# a simple page that says hello
@app.route('/hello')
def hello():
return 'Hello, World!'
return appcreate_app 是一个应用工厂函数,看似简单的函数其实已经做了许多事情
app = Flask(__name__, instance_relative_config=True)创建Flask实例__name__是当前 Python 模块的名称。应用需要知道在哪里设置 路径,使用__name__是一个方便的方法。instance_relative_config=True告诉应用配置文件是相对于 instance folder 的相对路径。实例文件夹在flaskr包的外面,用于存放本地数据(例如配置密钥和 数据库),不应当提交到版本控制系统
app.config.from_mapping()设置一个应用的缺省配置:SECRET_KEY是被 Flask 和扩展用于保证数据安全的。在开发过程中,为了方便可以设置为'dev',但是在发布的时候应当使用一个随机值来重载它DATABASESQLite 数据库文件存放在路径。它位于 Flask 用于存放实例的app.instance_path之内
app.config.from_pyfile()使用config.py中的值来重载缺省配置,如果config.py存在的话。 例如:当正式部署的时候,用于设置一个正式的SECRET_KEYtest_config也会被传递给工厂,并且会替代实例配置。这样可以实现测试和开发的配置分离,相互独立。
os.makedirs()可以确保app.instance_path存在。 Flask 不会自动创建实例文件夹,但是必须确保创建这个文件夹,因为 SQLite 数据 库文件会被保存在里面
运行应用
通过使用 flask 命令来运行应用。终端目录应当在最顶层的 flask-tutorial 目录下,不是在 flaskr 包里面。
在调试模式下,当页面出错的时候会显示一个交互调试器,并且当修改代码 保存后会重启服务器。
flask --app flaskr run --debug可以看到类似如下输出内容:
* Serving Flask app "flaskr"
* Debug mode: on
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: nnn-nnn-nnn在浏览器中访问 http://127.0.0.1:5000/hello ,就可以看到 “Hello, World!” 信息
定义和操作数据库
应用使用 SQLite 数据库来储存用户和博客内容。 Python 内置了 SQLite 数据库支持,相应的模块为 sqlite3 。
使用 SQLite 的便利性在于不需要单独配置一个数据库服务器,并且 Python 提供了内置支持。但是当并发请求同时要写入时,会比较慢一点,因为每个写操作是按顺序进行的。小应用没有问题,但是大应用可能就需要考虑换成别的数据库
连接数据库
当使用 SQLite 数据库(包括其他多数数据库的 Python 库)时,首先创建一个数据库的连接。所有查询和操作都要通过该连接来执行,完事后该连接关闭。
在网络应用中连接往往与请求绑定。在处理请求的某个时刻,连接被创建。在发送响应之前连接被关闭。
flaskr/db.py
import sqlite3
import click
from flask import current_app, g
def get_db():
if 'db' not in g:
g.db = sqlite3.connect(
current_app.config['DATABASE'],
detect_types=sqlite3.PARSE_DECLTYPES
)
g.db.row_factory = sqlite3.Row
return g.db
def close_db(e=None):
db = g.pop('db', None)
if db is not None:
db.close()g 是一个特殊对象,独立于每一个请求。在处理请求过程中,可以用于储存可能多个函数都会用到的数据。把连接储存于其中,可以多次使用, 而不用在同一个请求中每次调用 get_db 时都创建一个新的连接。
current_app 是另一个特殊对象,该对象指向处理请求的 Flask 应用。 这里使用了应用工厂,那么在其余的代码中就不会出现应用对象。当应用创建后,在处理一个请求时, get_db 会被调用。这样就需要使用 current_app
sqlite3.connect() 建立一个数据库连接,该连接指向配置中的 DATABASE 指定的文件。这个文件现在还没有建立,后面会在初始化数据库的时候建立该文件
sqlite3.Row 告诉连接返回类似于字典的行,这样可以通过列名称来操作数据。
close_db 通过检查 g.db 来确定连接是否已经建立。如果连接已建立, 那么就关闭连接。以后会在应用工厂中告诉应用 close_db 函数,这样每次请求后就会调用它
创建表
在 SQLite 中,数据储存在 表 和 列 中。在储存和调取数据之前需要先创建它们。Flaskr 会把用户数据储存在 user 表中,把博客内容储存在 post 表中。下面创建一个文件储存用于创建空表的 SQL 命令:
flaskr/schema.sql
DROP TABLE IF EXISTS user;
DROP TABLE IF EXISTS post;
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
);
CREATE TABLE post (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author_id INTEGER NOT NULL,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
title TEXT NOT NULL,
body TEXT NOT NULL,
FOREIGN KEY (author_id) REFERENCES user (id)
);在 flaskr/db.py 文件中添加 Python 函数,用于运行这个 SQL 命令:
def init_db():
db = get_db()
with current_app.open_resource('schema.sql') as f:
db.executescript(f.read().decode('utf8')
@click.command('init-db')
def init_db_command():
"""Clear the existing data and create new tables."""
init_db()
click.echo('Initialized the database.')open_resource() 打开一个文件,该文件名是相对于 flaskr 包的。这样就不需要考虑以后应用具体部署在哪个位置。 get_db 返回一个数据库连接,用于执行文件中的命令。
click.command() 定义一个名为 init-db 命令行,它调用 init_db 函数,并为用户显示一个成功的消息
在应用中注册
close_db 和 init_db_command 函数需要在应用实例中注册,否则无法使用。然而,既然使用了工厂函数,那么在写函数的时候应用实例还无 法使用。代替地,我们写一个函数,把应用作为参数,在函数中进行注册
flaskr/db.py
def init_app(app):
app.teardown_appcontext(close_db)
app.cli.add_command(init_db_command)app.teardown_appcontext() 告诉 Flask 在返回响应后进行清理的时候调用此函数。
app.cli.add_command() 添加一个新的 可以与 flask 一起工作的命令。
在工厂中导入并调用这个函数。在工厂函数中把新的代码放到函数的尾部,返 回应用代码的前面。flaskr/__init__.py
def create_app():
app = create_app()
# existing code omitted
from . import db
db.init_app(app)
return app初始化数据库文件
现在 init-db 已经在应用中注册好了,可以与 flask 命令一起使用
flask --app flaskr init-db现在会有一个 flaskr.sqlite 文件出现在项目所在文件夹的 instance 文件夹中
蓝图和视图
视图是一个应用对请求进行响应的函数。 Flask 通过模型把进来的请求 URL 匹配到对应的处理视图。视图返回数据, Flask 把数据变成出去的响应。 Flask 也可以反过来,根据视图的名称和参数生成 URL
创建蓝图
Blueprint 是一种组织一组相关视图及其他代码的方式。这与把视图及其他代码直接注册到应用的方式不同,蓝图方式是把它们注册到蓝图,然后在工厂函数中 把蓝图注册到应用。
Flaskr 有两个蓝图,一个用于认证功能,另一个用于博客帖子管理。每个蓝图的代码都在一个单独的模块中。使用博客首先需要认证,因此先写认证蓝图
flaskr/auth.py
import functools
from flask import (
Blueprint, flash, g, redirect, render_template, request, session, url_for
)
from werkzeug.security import check_password_hash, generate_password_hash
from flaskr.db import get_db
bp = Blueprint('auth', __name__, url_prefix='/auth')这里创建名为 'auth' 的 Blueprint 。和应用对象一样, 蓝图需要知道是在哪里定义的,因此把 __name__ 作为函数的第二个参数。 url_prefix 会添加到所有与该蓝图关联的 URL 前面
在 flaskr/__init__.py 中使用 app.register_blueprint() 导入并注册 蓝图。新的代码放在工厂函数的尾部返回应用之前
def create_app():
app = create_app()
# existing code omitted
from . import auth
app.register_blueprint(auth.bp)
return app认证蓝图将包括注册新用户、登录和注销视图
第一个视图:注册
当用访问 /auth/register URL 时, register 视图会返回用于填写注册 内容的表单的 HTML 。当用户提交表单时,视图会验证表单内容,然后要么再次显示表单并显示一个出错信息,要么创建新用户并显示登录页面。
现在只是编写视图代码,后面会编写生成 HTML 表单的模板
flaskr/auth.py,get 方法直接返回模版页面,post 方法校验表单参数
@bp.route('/register', methods=('GET', 'POST')
def register():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
db = get_db()
error = None
if not username:
error = 'Username is required.'
elif not password:
error = 'Password is required.'
if error is None:
try:
db.execute(
"INSERT INTO user (username, password) VALUES (?, ?)",
(username, generate_password_hash(password)),
)
db.commit()
except db.IntegrityError:
error = f"User {username} is already registered."
else:
return redirect(url_for("auth.login")
flash(error)
return render_template('auth/register.html')这个 register 视图做了以下工作:
@bp.route关联了 URL/register和register视图函数。当 Flask 收到一个指向/auth/register的请求时就会调用register视图并把其返回值作为响应- 如果用户提交了表单,那么
request.method将会是'POST'。这咱情况下 会开始验证用户的输入内容 request.form是一个特殊类型的dict,其映射了提交表单的键和值。表单中,用户将会输入其username和password- 验证
username和password不为空 - 如果验证成功,就把新用户的数据插入数据库。
db.execute使用了带有?占位符的 SQL 查询语句。占位符可以代替后面的元组参数中相应的值。使用占位符的好处是会自动帮你转义输入值,以抵御 SQL 注入攻击 。- 因为安全原因,不能把密码明文储存在数据库中。而是应当使用
generate_password_hash()生成安全的哈希值, 再把哈希值储存到数据库中。因为查询修改了数据,所以要使用meth:db.commit() <sqlite3.Connection.commit>保存修改 - 如果用户名已存在,会产生一个
sqlite3.IntegrityError错误, 应当将该错误作为一个验证错误显示给用户
- 用户数据保存后将转到登录页面。
url_for()根据登录视图的名称生成相应的 URL 。与写固定的 URL 相比, 这样做的好处是如果以后需要修改该视图相应的 URL ,那么不用修改所有涉及到 URL 的代码。redirect()为生成的 URL 生成一个重定向响应 - 如果验证失败,那么会向用户显示一个出错信息。
flash()用于储存在渲染模块时可以调用的信息 - 当用户最初访问
auth/register时,或者注册出错时,应用显示一个注册表单。render_template()会渲染一个包含 HTML 的模板
登录
这个视图和上述 register 视图原理相同。flaskr/auth.py
@bp.route('/login', methods=('GET', 'POST')
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
db = get_db()
error = None
user = db.execute(
'SELECT * FROM user WHERE username = ?', (username,)
).fetchone()
if user is None:
error = 'Incorrect username.'
elif not check_password_hash(user['password'], password):
error = 'Incorrect password.'
if error is None:
session.clear()
session['user_id'] = user['id']
return redirect(url_for('index')
flash(error)
return render_template('auth/login.html')与 register 有以下不同之处:
-
首先需要查询用户并存放在变量中,以备后用
fetchone()根据查询返回一个记录行。 如果查询没有结果,则返回None。后面还用到fetchall(),它返回包括所有结果的列表。 -
check_password_hash()以相同的方式哈希提交的 密码并安全的比较哈希值。如果匹配成功,那么密码就是正确的。 -
session是一个dict,它用于储存横跨请求的值。当验证 成功后,用户的id被储存于一个新的会话中。会话数据被储存到一个 向浏览器发送的 cookie 中,在后继请求中,浏览器会返回它。 Flask 会安全对数据进行 签名 以防数据被篡改。
现在用户的 id 已被储存在 session 中,可以被后续的请求使用。 请每个请求的开头,如果用户已登录,那么其用户信息应当被载入,以使其可用于其他视图
flaskr/auth.py
@bp.before_app_request
def load_logged_in_user():
user_id = session.get('user_id')
if user_id is None:
g.user = None
else:
g.user = get_db().execute('SELECT * FROM user WHERE id = ?', (user_id,)).fetchone()bp.before_app_request() 注册一个在视图函数之前运行的函数,不论其 URL 是什么。 load_logged_in_user 检查用户 id 是否已经储存在 session 中,并从数据库中获取用户数据,然后储存在 g.user 中。 g.user 的持续时间比请求要长。 如果没有用户 id ,或者 id 不存在,那么 g.user 将会是 None
注销
注销的时候需要把用户 id 从 session 中移除。 然后 load_logged_in_user 就不会在后继请求中载入用户了。flaskr/auth.py
@bp.route('/logout')
def logout():
session.clear()
return redirect(url_for('index')在其他视图中验证
用户登录以后才能创建、编辑和删除博客帖子。在每个视图中可以使用 装饰器 来完成这个工作。 flaskr/auth.py
def login_required(view):
@functools.wraps(view)
def wrapped_view(**kwargs):
if g.user is None:
return redirect(url_for('auth.login')
return view(**kwargs)
return wrapped_view装饰器返回一个新的视图,该视图包含了传递给装饰器的原视图。新的函数检查用户 是否已载入。如果已载入,那么就继续正常执行原视图,否则就重定向到登录页面
端点和 URL
url_for() 函数根据视图名称和发生成 URL 。视图相关联的名称亦称为 端点 ,缺省情况下,端点名称与视图函数名称相同。
例如,前文被加入应用工厂的 hello() 视图端点为 'hello' ,可以使用 url_for('hello') 来连接。如果视图有参数,可使用 url_for('hello', who='World') 连接。
当使用蓝图的时候,蓝图的名称会添加到函数名称的前面。上面的 login 函数 的端点为 'auth.login' ,因为它已被加入 'auth' 蓝图中
模板
模板文件会储存在 flaskr 包内的 templates 文件夹内。模板是包含静态数据和动态数据占位符的文件。模板使用指定的数据生成最终的文档。 Flask 使用 Jinja 模板库来渲染模板
在 Flask 中, Jinja 被配置为 自动转义 HTML 模板中的任何数据。即渲染用户的输入是安全的。 任何用户输入的可能出现歧意的字符,如 < 和 > ,会被 转义 ,替换为 安全 的值。这些值在浏览器中看起来一样,但是没有副作用
基础布局
应用中的每一个页面主体不同,但是基本布局是相同的。每个模板会 扩展 同一个 基础模板并重载相应的小节,而不是重写整个 HTML 结构。flaskr/templates/base.html
<!DOCTYPE html>
<title>{% block title %}{% endblock %} - Flaskr</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
<nav>
<h1>Flaskr</h1>
<ul>
{% if g.user %}
<li><span>{{ g.user['username'] }}</span></li>
<li><a href="{{ url_for('auth.logout') }}">Log Out</a> {% else %}</li>
<li><a href="{{ url_for('auth.register') }}">Register</a></li>
<li>
<a href="{{ url_for('auth.login') }}">Log In</a>
{% endif %}
</li>
</ul>
</nav>
<section class="content">
<header>{% block header %}{% endblock %}</header>
{% for message in get_flashed_messages() %}
<div class="flash">{{ message }}</div>
{% endfor %} {% block content %}{% endblock %}
</section>g 在模板中自动可用。 根据 g.user 是否被设置(在 load_logged_in_user 中进行),要么显示 用户名和注销连接,要么显示注册和登录连接。 url_for() 也是自动可用的,可用于生成视图的 URL ,而不用手动来指定。
在标题下面,正文内容前面,模板会循环显示 get_flashed_messages() 返回的每个消息。在视图中使用 flash() 来处理出错信息,在模板中就可以这样 显示出出来。
模板中定义三个块,这些块会被其他模板重载:
{% block title %}会改变显示在浏览器标签和窗口中的标题{% block header %}类似于title,但是会改变页面的标题{% block content %}是每个页面的具体内容,如登录表单或者博客帖子
其他模板直接放在 templates 文件夹内。为了更好地管理文件,属于某个蓝图 的模板会被放在与蓝图同名的文件夹内
注册
flaskr/templates/auth/register.html
{% extends 'base.html' %} {% block header %}
<h1>{% block title %}Register{% endblock %}</h1>
{% endblock %} {% block content %}
<form method="post">
<label for="username">Username</label>
<input name="username" id="username" required />
<label for="password">Password</label>
<input type="password" name="password" id="password" required />
<input type="submit" value="Register" />
</form>
{% endblock %}{% extends 'base.html' %} 告诉 Jinja 这个模板基于基础模板,并且需要替换 相应的块。所有替换的内容必须位于 {% block %} 标签之内。
一个实用的模式是把 {% block title %} 放在 {% block header %} 内部。 这里不但可以设置 title 块,还可以把其值作为 header 块的内容, 一举两得。
input 标记使用了 required 属性。这是告诉浏览器这些字段是必填的。 如果用户使用不支持这个属性的旧版浏览器或者不是浏览器的东西创建的请求, 那么你还是要在视图中验证输入数据。总是在服务端中完全验证数据,即使客户端 已经做了一些验证,这一点非常重要
登录
本模板除了标题和提交按钮外与注册模板相同。flaskr/templates/auth/login.html
{% extends 'base.html' %} {% block header %}
<h1>{% block title %}Log In{% endblock %}</h1>
{% endblock %} {% block content %}
<form method="post">
<label for="username">Username</label>
<input name="username" id="username" required />
<label for="password">Password</label>
<input type="password" name="password" id="password" required />
<input type="submit" value="Log In" />
</form>
{% endblock %}注册一个用户
现在验证模板已写好,可以注册一个用户,访问 http://127.0.0.1:5000/auth/register 。
在不填写表单的情况,尝试点击 “Register” 按钮,浏览器会显示出错信息。尝试在 register.html 中删除 required 属性后再次点击 “Register” 按钮。 页面会重载并显示来自于视图中的 flash() 的出错信息,而不是浏览器显示出错信息
填写用户名和密码后会重定向到登录页面。尝试输入错误的用户名,或者输入正常的用户名和错误的密码。如果登录成功,那么会看到一个出错信息,因为还没有写登录 后要转向的 index 视图
静态文件
验证视图和模板已经可用了,但是看上去很朴素。可以使用一些 CSS 给 HTML 添加点样式。样式不会改变,所以应当使用 静态文件 ,而不是模板
Flask 自动添加一个 static 视图,视图使用相对于 flaskr/static 的相对路径。 base.html 模板已经使用了一个 style.css 文件连接:
{{ url_for('static', filename='style.css') }}除了 CSS ,其他类型的静态文件可以是 JavaScript 函数文件或者 logo 图片。都放置于 flaskr/static 文件夹中,并使用 url_for('static', filename='...') 引用
博客蓝图
博客蓝图与验证蓝图所使用的技术一样。博客页面应当列出所有的帖子,允许已登录 用户创建帖子,并允许帖子作者修改和删除帖子。flaskr/blog.py
from flask import (
Blueprint, flash, g, redirect, render_template, request, url_for
)
from werkzeug.exceptions import abort
from flaskr.auth import login_required
from flaskr.db import get_db
bp = Blueprint('blog', __name__)在 flaskr/__init__.py 使用 app.register_blueprint() 在工厂中 导入和注册蓝图
def create_app():
# existing code omitted
from . import blog
app.register_blueprint(blog.bp)
app.add_url_rule('/', endpoint='index')
return app与验证蓝图不同,博客蓝图没有 url_prefix 。因此 index 视图会用于 / , create 会用于 /create ,以此类推。博客是 Flaskr 的主要 功能,因此把博客索引作为主索引是合理的。
但是下文的 index 视图的端点会被定义为 blog.index 。一些验证视图会指定向普通的 index 端点。 使用 app.add_url_rule() 关联端点名称 'index' 和 / URL ,这样 url_for('index') 或 url_for('blog.index') 都会有效,会生成同样的 / URL
索引
索引会显示所有帖子,最新的会排在最前面。为了在结果中包含 user 表中的作者信息,使用了一个 JOIN 。flaskr/blog.py
@bp.route('/')
def index():
db = get_db()
posts = db.execute(
'SELECT p.id, title, body, created, author_id, username'
' FROM post p JOIN user u ON p.author_id = u.id'
' ORDER BY created DESC'
).fetchall()
return render_template('blog/index.html', posts=posts)flaskr/templates/blog/index.html
{% extends 'base.html' %} {% block header %}
<h1>{% block title %}Posts{% endblock %}</h1>
{% if g.user %}
<a class="action" href="{{ url_for('blog.create') }}">New</a>
{% endif %} {% endblock %} {% block content %} {% for post in posts %}
<article class="post">
<header>
<div>
<h1>{{ post['title'] }}</h1>
<div class="about">by {{ post['username'] }} on {{ post['created'].strftime('%Y-%m-%d') }}</div>
</div>
{% if g.user['id'] == post['author_id'] %}
<a class="action" href="{{ url_for('blog.update', id=post['id']) }}">Edit</a>
{% endif %}
</header>
<p class="body">{{ post['body'] }}</p>
</article>
{% if not loop.last %}
<hr />
{% endif %} {% endfor %} {% endblock %}当用户登录后, header 块添加了一个指向 create 视图的连接。当用户是博客作者时,可以看到一个“ Edit ”连接,指向 update 视图。 loop.last 是一个 Jinja for 循环 内部可用的特殊变量,它用于在每个 博客帖子后面显示一条线来分隔帖子,最后一个帖子除外
创建
create 视图与 register 视图原理相同。要么显示表单,要么发送内容 已通过验证且内容已加入数据库,或者显示一个出错信息。先前写的 login_required 装饰器用在了博客视图中,这样用户必须登录以后 才能访问这些视图,否则会被重定向到登录页面
flaskr/blog.py
@bp.route('/create', methods=('GET', 'POST')
@login_required
def create():
if request.method == 'POST':
title = request.form['title']
body = request.form['body']
error = None
if not title:
error = 'Title is required.'
if error is not None:
flash(error)
else:
db = get_db()
db.execute(
'INSERT INTO post (title, body, author_id)'
' VALUES (?, ?, ?)',
(title, body, g.user['id'])
)
db.commit()
return redirect(url_for('blog.index')
return render_template('blog/create.html')flaskr/templates/blog/create.html
{% extends 'base.html' %}
{% block header %}
<h1>{% block title %}New Post{% endblock %}</h1>
{% endblock %}
{% block content %}
<form method="post">
<label for="title">Title</label>
<input name="title" id="title" value="{{ request.form['title'] }}" required>
<label for="body">Body</label>
<textarea name="body" id="body">{{ request.form['body'] }}</textarea>
<input type="submit" value="Save">
</form>
{% endblock %}更新
update 和 delete 视图都需要通过 id 来获取一个 post ,并且 检查作者与登录用户是否一致。为避免重复代码,可以写一个函数来获取 post , 并在每个视图中调用它
flaskr/blog.py
def get_post(id, check_author=True):
post = get_db().execute(
'SELECT p.id, title, body, created, author_id, username'
' FROM post p JOIN user u ON p.author_id = u.id'
' WHERE p.id = ?',
(id,)
).fetchone()
if post is None:
abort(404, f"Post id {id} doesn't exist.")
if check_author and post['author_id'] != g.user['id']:
abort(403)
return postabort() 会引发一个特殊的异常,返回一个 HTTP 状态码。它有一个可选参数, 用于显示出错信息,若不使用该参数则返回缺省出错信息。 404 表示“未找到”, 403 代表“禁止访问”。( 401 表示“未授权”,但是我们重定向到登录 页面来代替返回这个状态码)
check_author 参数的作用是函数可以用于在不检查作者的情况下获取一个 post 。这主要用于显示一个独立的帖子页面的情况,因为这时用户是谁没有关系, 用户不会修改帖子。
flaskr/blog.py
@bp.route('/<int:id>/update', methods=('GET', 'POST')
@login_required
def update(id):
post = get_post(id)
if request.method == 'POST':
title = request.form['title']
body = request.form['body']
error = None
if not title:
error = 'Title is required.'
if error is not None:
flash(error)
else:
db = get_db()
db.execute(
'UPDATE post SET title = ?, body = ?'
' WHERE id = ?',
(title, body, id)
)
db.commit()
return redirect(url_for('blog.index')
return render_template('blog/update.html', post=post)和所有以前的视图不同, update 函数有一个 id 参数。该参数对应路由中 的 <int:id> 。一个真正的 URL 类似 /1/update 。 Flask 会捕捉到 URL 中的 1 ,确保其为一个 int ,并将其作为 id 参数传递给视图。 如果没有指定 int: 而是仅仅写了 <id> ,那么将会传递一个字符串。 要生成一个指向更新页面的 URL ,需要传递 id 参数给 url_for() : url_for('blog.update', id=post['id']) 。前文的 index.html 文件中 同样如此。
create 和 update 视图看上去是相似的。 主要的不同之处在于 update 视图使用了一个 post 对象和一个 UPDATE 查询代替了一个 INSERT 查询。作为一个明智的重构者,可以使用 一个视图和一个模板来同时完成这两项工作
flaskr/templates/blog/update.html
{% extends 'base.html' %} {% block header %}
<h1>{% block title %}Edit "{{ post['title'] }}"{% endblock %}</h1>
{% endblock %} {% block content %}
<form method="post">
<label for="title">Title</label>
<input name="title" id="title" value="{{ request.form['title'] or post['title'] }}" required />
<label for="body">Body</label>
<textarea name="body" id="body">{{ request.form['body'] or post['body'] }}</textarea>
<input type="submit" value="Save" />
</form>
<hr />
<form action="{{ url_for('blog.delete', id=post['id']) }}" method="post">
<input class="danger" type="submit" value="Delete" onclick="return confirm('Are you sure?');" />
</form>
{% endblock %}这个模板有两个表单。第一个提交已编辑过的数据给当前页面( /<id>/update )。 另一个表单只包含一个按钮。它指定一个 action 属性,指向删除视图。这个按钮 使用了一些 JavaScript 用以在提交前显示一个确认对话框。
参数 \{\{ request.form['title'] or post['title'] \}\} 用于选择在表单显示什么 数据。当表单还未提交时,显示原 post 数据。但是,如果提交了非法数据,然后 需要显示这些非法数据以便于用户修改时,就显示 request.form 中的数据。 request 是又一个自动在模板中可用的变量
删除
删除视图没有自己的模板。删除按钮已包含于 update.html 之中,该按钮指向 /<id>/delete URL 。既然没有模板,该视图只处理 POST 方法并重定向到 index 视图。flaskr/blog.py
@bp.route('/<int:id>/delete', methods=('POST',)
@login_required
def delete(id):
get_post(id)
db = get_db()
db.execute('DELETE FROM post WHERE id = ?', (id,)
db.commit()
return redirect(url_for('blog.index')项目可安装化
项目可安装化是指创建一个 wheel 文件,以使项目可以安装到其他环境中,就像在你的项目环境中安装 Flask 一样。这样使项目如同其他库一样进行 部署,使用标准的 Python 工具来管理项目。
可安装化还可以带来如下好处,这些好处在教程中可以不太明显或者初学者可能没注意到:
- 现在 Python 和 Flask 能够理解如何
flaskr包,是因为在项 目文件夹中运行的。可安装化后,可以从任何地方导入项目并运行 - 可以和其他包一样管理项目的依赖,即使用
pip install yourproject.whl来安装项目并安装相关依赖 - 测试工具可以分离测试环境和开发环境
描述项目
pyproject.toml 文件描述项目及其构建方法。
pyproject.toml
[project]
name = "flaskr"
version = "1.0.0"
dependencies = [
"flask",
]
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"setuptools 构建后端需要 MANIFEST.in 文件来说明需要包含的非 Python 文件
MANIFEST.in
include flaskr/schema.sql
graft flaskr/static
graft flaskr/templates
global-exclude *.pyc这告诉构建器复制所有 static 和 templates 文件夹中的文件, schema.sql 文件,但是排除所有字节文件。
安装项目
使用 pip 在虚拟环境中安装项目。
pip install -e .这个命令告诉 pip 在当前文件夹中寻找 pyproject.toml 并在 编辑 或 开发 模式下安装。编辑模式是指当改变本地代码后,只需要重新项目。比如 改变了项目依赖之类的元数据的情况下。
可以通过 pip list 来查看项目的安装情况
$ pip list
Package Version Location
-------------- --------- ----------------------------------
click 6.7
Flask 1.0
flaskr 1.0.0 /home/user/Projects/flask-tutorial
itsdangerous 0.24
Jinja2 2.10
MarkupSafe 1.0
pip 9.0.3
setuptools 39.0.1
Werkzeug 0.14.1
wheel 0.30.0至此没有改变项目运行的方式, --app 还是被设置为 flaskr , 还是使用 flask run 运行应用。不同的是可以在任何地方运行应用,而不仅仅是在 flask-tutorial 目录下
覆盖测试
Flask 提供了测试客户端, 可以模拟向应用发送请求并返回响应数据。应当尽可能多地进行测试。函数中的代码只有在函数被调用的情况下才会运行。 分支中的代码,如 if 块中的代码,只有在符合条件的情况下才会运行。测试 应当覆盖每个函数和每个分支。
越接近 100% 的测试覆盖,越能够保证修改代码后不会出现意外。但是 100% 测试 覆盖不能保证应用没有错误。通常,测试不会覆盖用户如何在浏览器中与应用进行 交互。尽管如此,在开发过程中,测试覆盖仍然是非常重要的
使用 pytest 和 coverage 来进行测试和衡量代码
$ pip install pytest coverage配置和固件
测试代码位于 tests 文件夹中。 tests/conftest.py 文件包含名为 fixtures (固件)的配置函数。每个测试都会用到这个函数。测试位于 Python 模块中,以 test_ 开头,并且模块中的每个测试函数也以 test_ 开头。
每个测试会创建一个新的临时数据库文件,并产生一些用于测试的数据。写一个 SQL 文件来插入数据。tests/data.sql
INSERT INTO user (username, password)
VALUES
('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'),
('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79');
INSERT INTO post (title, body, author_id, created)
VALUES
('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00');app 固件会调用工厂并为测试传递 test_config 来配置应用和数据库, 而不使用本地的开发配置
tests/conftest.py
import os
import tempfile
import pytest
from flaskr import create_app
from flaskr.db import get_db, init_db
with open(os.path.join(os.path.dirname(__file__), 'data.sql'), 'rb') as f:
_data_sql = f.read().decode('utf8')
@pytest.fixture
def app():
db_fd, db_path = tempfile.mkstemp()
app = create_app({
'TESTING': True,
'DATABASE': db_path,
})
with app.app_context():
init_db()
get_db().executescript(_data_sql)
yield app
os.close(db_fd)
os.unlink(db_path)
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def runner(app):
return app.test_cli_runner()tempfile.mkstemp() 创建并打开一个临时文件,返回该文件描述符和路径。 DATABASE 路径被重载,这样它会指向临时路径,而不是实例文件夹。设置好路径之后,数据库表被创建,然后插入数据。测试结束后,临时文件会被关闭并删除。
TESTING 告诉 Flask 应用处在测试模式下。 Flask 会改变一些内部行为以方便测试。其他的扩展也可以使用这个标志方便测试。
client 固件调用 app.test_client() 由 app 固件创建的应用 对象。测试会使用客户端来向应用发送请求,而不用启动服务器。
runner 固件类似于 client 。 app.test_cli_runner() 创建一个运行器, 可以调用应用注册的 Click 命令。
Pytest 通过匹配固件函数名称和测试函数的参数名称来使用固件。例如 下面要写 test_hello 函数有一个 client 参数。 Pytest 会匹配 client 固件函数,调用该函数,把返回值传递给测试函数
工厂
工厂本身没有什么好测试的,其大部分代码会被每个测试用到。因此如果工厂代码 有问题,那么在进行其他测试时会被发现。唯一可以改变的行为是传递测试配置。如果没传递配置,那么会有一些缺省配置可 用,否则配置会被重载
tests/test_factory.py
from flaskr import create_app
def test_config():
assert not create_app().testing
assert create_app({'TESTING': True}).testing
def test_hello(client):
response = client.get('/hello')
assert response.data == b'Hello, World!'数据库
在一个应用环境中,每次调用 get_db 都应当返回相同的连接。退出环境后, 连接应当已关闭
tests/test_db.py
import sqlite3
import pytest
from flaskr.db import get_db
def test_get_close_db(app):
with app.app_context():
db = get_db()
assert db is get_db()
with pytest.raises(sqlite3.ProgrammingError) as e:
db.execute('SELECT 1')
assert 'closed' in str(e.value)init-db 命令应当调用 init_db 函数并输出一个信息。tests/test_db.py
def test_init_db_command(runner, monkeypatch):
class Recorder(object):
called = False
def fake_init_db():
Recorder.called = True
monkeypatch.setattr('flaskr.db.init_db', fake_init_db)
result = runner.invoke(args=['init-db'])
assert 'Initialized' in result.output
assert Recorder.called这个测试使用 Pytest’s monkeypatch 固件来替换 init_db 函数。 前文写的 runner 固件用于通过名称调用 init-db 命令
验证
对于大多数视图,用户需要登录。在测试中最方便的方法是使用客户端制作一个 POST 请求发送给 login 视图。与其每次都写一遍,不如写一个类,用类的方法来做这件事,并使用一个固件把它传递给每个测试的客户端。
tests/conftest.py
class AuthActions(object):
def __init__(self, client):
self._client = client
def login(self, username='test', password='test'):
return self._client.post(
'/auth/login',
data={'username': username, 'password': password}
)
def logout(self):
return self._client.get('/auth/logout')
@pytest.fixture
def auth(client):
return AuthActions(client)通过 auth 固件,可以在调试中调用 auth.login() 登录为 test 用户。这个用户的数据已经在 app 固件中写入了数据。
register 视图应当在 GET 请求时渲染成功。 在 POST 请求中,表单数据合法时,该视图应当重定向到登录 URL ,并且用 户的数据已在数据库中保存好。数据非法时,应当显示出错信息。
tests/test_auth.py
import pytest
from flask import g, session
from flaskr.db import get_db
def test_register(client, app):
assert client.get('/auth/register').status_code == 200
response = client.post(
'/auth/register', data={'username': 'a', 'password': 'a'}
)
assert response.headers["Location"] == "/auth/login"
with app.app_context():
assert get_db().execute(
"SELECT * FROM user WHERE username = 'a'",
).fetchone() is not None
@pytest.mark.parametrize(('username', 'password', 'message'), (
('', '', b'Username is required.'),
('a', '', b'Password is required.'),
('test', 'test', b'already registered'),
)
def test_register_validate_input(client, username, password, message):
response = client.post(
'/auth/register',
data={'username': username, 'password': password}
)
assert message in response.dataclient.get() 制作一个 GET 请求并由 Flask 返回 Response 对象。类似的 client.post() 制作一个 POST 请求, 转换 data 字典为表单数据。
为了测试页面是否渲染成功,制作一个简单的请求,并检查是否返回 一个 200 OK status_code 。如果渲染失败, Flask 会返回一个 500 Internal Server Error 代码。
当注册视图重定向到登录视图时, headers 会有一个包含登 录 URL 的 Location 头部。
data 以字节方式包含响应体。如果想要检测渲染页面中 的某个值,请在 data 中检测。字节值只能与字节值作比较,如果想比较文 本,请使用 get_data(as_text=True) 。
pytest.mark.parametrize 告诉 Pytest 以不同的参数运行同一个测试。 这里用于测试不同的非法输入和出错信息,避免重复写三次相同的代码。
login 视图的测试与 register 的非常相似。后者是测试数据库中的数据, 前者是测试登录之后 session 应当包含 user_id 。
tests/test_auth.py
def test_login(client, auth):
assert client.get('/auth/login').status_code == 200
response = auth.login()
assert response.headers["Location"] == "/"
with client:
client.get('/')
assert session['user_id'] == 1
assert g.user['username'] == 'test'
@pytest.mark.parametrize(('username', 'password', 'message'), (
('a', 'test', b'Incorrect username.'),
('test', 'a', b'Incorrect password.'),
)
def test_login_validate_input(auth, username, password, message):
response = auth.login(username, password)
assert message in response.data在 with 块中使用 client ,可以在响应返回之后操作环境变量,比如 session 。 通常在请求之外操作 session 会引发一个异常。
logout 测试与 login 相反。注销之后, session 应当不包含 user_id 。tests/test_auth.py
def test_logout(client, auth):
auth.login()
with client:
auth.logout()
assert 'user_id' not in session博客
所有博客视图使用之前所写的 auth 固件。调用 auth.login() ,并且客户端的后继请求会登录为 test 用户。
index 索引视图应当显示已添加的测试帖子数据。作为作者登录之后,应当有 编辑博客的连接。
当测试 index 视图时,还可以测试更多验证行为。当没有登录时,每个页面 显示登录或注册连接。当登录之后,应当有一个注销连接。tests/test_blog.py
import pytest
from flaskr.db import get_db
def test_index(client, auth):
response = client.get('/')
assert b"Log In" in response.data
assert b"Register" in response.data
auth.login()
response = client.get('/')
assert b'Log Out' in response.data
assert b'test title' in response.data
assert b'by test on 2018-01-01' in response.data
assert b'test\nbody' in response.data
assert b'href="/1/update"' in response.data用户必须登录后才能访问 create 、 update 和 delete 视图。帖子作者才能访问 update 和 delete 。否则返回一个 403 Forbidden 状态码。如果要访问 post 的 id 不存在,那么 update 和 delete 应当返回 404 Not Found
tests/test_blog.py
@pytest.mark.parametrize('path', (
'/create',
'/1/update',
'/1/delete',
)
def test_login_required(client, path):
response = client.post(path)
assert response.headers["Location"] == "/auth/login"
def test_author_required(app, client, auth):
# change the post author to another user
with app.app_context():
db = get_db()
db.execute('UPDATE post SET author_id = 2 WHERE id = 1')
db.commit()
auth.login()
# current user can't modify other user's post
assert client.post('/1/update').status_code == 403
assert client.post('/1/delete').status_code == 403
# current user doesn't see edit link
assert b'href="/1/update"' not in client.get('/').data
@pytest.mark.parametrize('path', (
'/2/update',
'/2/delete',
)
def test_exists_required(client, auth, path):
auth.login()
assert client.post(path).status_code == 404对于 GET 请求, create 和 update 视图应当渲染和返回一个 200 OK 状态码。当 POST 请求发送了合法数据后, create 应当在 数据库中插入新的帖子数据, update 应当修改数据库中现存的数据。当数据 非法时,两者都应当显示一个出错信息
tests/test_blog.py
def test_create(client, auth, app):
auth.login()
assert client.get('/create').status_code == 200
client.post('/create', data={'title': 'created', 'body': ''})
with app.app_context():
db = get_db()
count = db.execute('SELECT COUNT(id) FROM post').fetchone()[0]
assert count == 2
def test_update(client, auth, app):
auth.login()
assert client.get('/1/update').status_code == 200
client.post('/1/update', data={'title': 'updated', 'body': ''})
with app.app_context():
db = get_db()
post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
assert post['title'] == 'updated'
@pytest.mark.parametrize('path', (
'/create',
'/1/update',
)
def test_create_update_validate(client, auth, path):
auth.login()
response = client.post(path, data={'title': '', 'body': ''})
assert b'Title is required.' in response.datadelete 视图应当重定向到索引 URL ,并且帖子应当从数据库中删除。tests/test_blog.py
def test_delete(client, auth, app):
auth.login()
response = client.post('/1/delete')
assert response.headers["Location"] == "/"
with app.app_context():
db = get_db()
post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
assert post is None运行测试
额外的配置可以添加到项目的 pyproject.toml 文件。这些配置不是必需的, 但是可以使用测试更简洁明了
pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.coverage.run]
branch = true
source = ["flaskr"]使用 pytest 来运行测试。该命令会找到并且运行所有测试。
$ pytest
========================= test session starts ==========================
platform linux -- Python 3.6.4, pytest-3.5.0, py-1.5.3, pluggy-0.6.0
rootdir: /home/user/Projects/flask-tutorial
collected 23 items
tests/test_auth.py ........ [ 34%]
tests/test_blog.py ............ [ 86%]
tests/test_db.py .. [ 95%]
tests/test_factory.py .. [100%]
====================== 24 passed in 0.64 seconds =======================如果有测试失败, pytest 会显示引发的错误。可以使用 pytest -v 得到每个测试的列表,而不是一串点。
可以使用 coverage 命令代替直接使用 pytest 来运行测试,这样可以衡量 测试覆盖率
coverage run -m pytest在终端中,可以看到一个简单的覆盖率报告:
$ coverage report
Name Stmts Miss Branch BrPart Cover
------------------------------------------------------
flaskr/__init__.py 21 0 2 0 100%
flaskr/auth.py 54 0 22 0 100%
flaskr/blog.py 54 0 16 0 100%
flaskr/db.py 24 0 4 0 100%
------------------------------------------------------
TOTAL 153 0 44 0 100%还可以生成 HTML 报告,可以看到每个文件中测试覆盖了哪些行:
$ coverage html这个命令在 htmlcov 文件夹中生成测试报告,然后在浏览器中打开 htmlcov/index.html 查看
部署产品
把应用部署到一个服务器上。本文只是给出如何创建发行文件并 进行安装的概览,但是不会具体讨论使用哪种服务器或者软件。可以在用于 开发的电脑中设置一个新的虚拟环境,以便于尝试下面的内容。但是建议不要 用于部署一个真正的公开应用。以多种不同方式部署应用的列表参见 生产部署 。
构建和安装
当需要把应用部署到其他地方时,需要构建一个 wheel ( .whl )文件。 构建工作需要安装和使用 build 工具
$ pip install build
$ python -m build --wheel构建的文件为 dist/flaskr-1.0.0-py3-none-any.whl 。文件名由项目名称、版本号和一些关于项目安装要求的标记组成,形如: {project name}-{version}-{python tag}-{abi tag}-{platform tag} 。
复制这个文件到另一台机器, 创建一个新的虚拟环境 ,然后用 pip 安 装这个文件
pip install flaskr-1.0.0-py3-none-any.whlPip 会安装项目和相关依赖。
既然这是一个不同的机器,那么需要再次运行 init-db 命令,在实例文件 夹中创建数据库。
code$ flask --app flaskr init-db
当 Flask 探测到它已被安装,它会与前文不同,使用 .venv/var/flaskr-instance 作为实例文件夹
配置密钥
在教程开始的时候给了 SECRET_KEY 一个缺省值。在产品中应当 设置一些随机内容。否则网络攻击者就可以使用公开的 'dev' 键来修改会话 cookie ,或者其他任何使用密钥的东西。
可以使用下面的命令输出一个随机密钥:
$ python -c 'import secrets; print(secrets.token_hex()
'192b9bdd22ab9ed4d12e236c78afcb9a393ec15f71bbf5dc987d54727823bcbf在实例文件夹创建一个 config.py 文件。工厂会读取这个文件,如果该文件存在的话。提制生成的值到该文件中。.venv/var/flaskr-instance/config.py
SECRET_KEY = '192b9bdd22ab9ed4d12e236c78afcb9a393ec15f71bbf5dc987d54727823bcbf'其他必须的配置也可以写入该文件中。 Flaskr 只需要 SECRET_KEY 即可
运行产品服务器
当运行公开服务器而不是进行开发的时候,不应当使用内建的开发服务器 ( flask run )。开发服务器由 Werkzeug 提供,目的是为了方便开发, 但是不够高效、稳定和安全。
替代地,应当选用一个产品级的 WSGI 服务器。例如使用 Waitress 。首先在虚拟环境中安装它:
$ pip install waitress需要把应用告知 Waitree ,但是方式与 flask run 那样使用 --app 不同。需要告知 Waitree 导入并调用应用工厂来得到一个应用对象。
$ waitress-serve --call 'flaskr:create_app'
Serving on http://0.0.0.0:8080以多种不同方式部署应用的列表参见 生产部署 。使用 Waitress 只是一个示例,选择它是因为它同时支持 Windows 和 Linux 。还有 其他许多 WSGI 服务器和部署选项可供选择
继续开发
通过教程已经学到了许多 Flask 和 Python 的概念。回顾一下,并比较每 一步的代码有何变化。比较你的项目与 示例项目 , 可能会发现有较大的区别,蹒跚学步,很自然。
Flask 远不止教程所涉及的这些内容,但是您已经可以开始网络应用开发了。 Flask 在幕后使用了 Jinja 、 Click 、 Werkzeug 和 ItsDangerous ,它们也有各自的文档。 Flask 还有许多功能强大的 扩展 ,比如数据库扩展或者表单验证扩展等等
如果要继续开发 Flaskr 项目,建议尝试以下内容:
- 点击帖子标题,显示帖子详细页面
- 喜欢或者不喜欢一个帖子
- 评论
- 标记。点击标记显示所有带有该标记的帖子
- 一个可以过滤标题的搜索框
- 分页显示,每页只显示五个帖子
- 帖子可以上传图片
- 帖子支持用 Markdown 撰写
- 一个新帖子的 RSS 源
版本差异(Flask → 3.1.x)
| 特性 | 本文编写时 | 当前(Flask 3.1.x) |
|---|---|---|
| 版本基线 | Flask 2.x | 3.1 为最新稳定版;要求 Python 3.9+ |
| 异步视图 | 部分支持 | 3.0 起原生支持 async 视图(无需额外扩展) |
| CLI | flask run | 3.x 默认启用 debugger/pin;FLASK_DEBUG 等环境变量不变 |
| 依赖 | — | 3.x 依赖 Werkzeug 3.x;app.json/config 方式不变 |
| 安全性 | — | 3.x 强化 cookie 与会话默认值 |
本文讲解的路由、模板、表单、数据库集成等核心模式在 Flask 3.x 中完全适用;升级主要关注 Python 3.9+ 要求与异步视图支持。