首页
/ Flask Flaskr 教程实战:Blog 蓝图与博客文章 CRUD 视图的完整实现

Flask Flaskr 教程实战:Blog 蓝图与博客文章 CRUD 视图的完整实现

2026-09-04 17:54:40作者:吴年前Myrtle

本文基于 Flask 官方教程中的 Blog Blueprint 章节(docs/tutorial/blog.rst),完整讲解 Flaskr 博客应用中 blog 蓝图的设计与实现:从蓝图定义与注册、帖子列表页的 JOIN 查询,到创建、编辑、删除帖子的完整 CRUD 视图,并结合仓库中 examples/tutorial/flaskr/blog.py 的源码与 examples/tutorial/tests/test_blog.py 的测试用例,说明登录鉴权、作者权限校验(403/404)、路由参数转换等关键机制的落地细节。读完后你能够掌握用 Flask 蓝图组织业务模块、在视图中完成数据库校验与模板渲染的标准写法,并理解 url_forabortg 等核心组件在真实业务流中的调用链。

Flaskr 博客应用的帖子列表页(Index 视图),已登录用户可见 New 和 Edit 操作链接

功能目标与整体设计

blog 蓝图是 Flaskr 应用的核心业务模块,它的功能目标非常明确:列出所有帖子(最新在前)、允许已登录用户创建帖子、允许帖子作者编辑或删除自己的帖子

与负责登录注册的 auth 蓝图(定义在 examples/tutorial/flaskr/auth.py,带 url_prefix="/auth")不同,blog 蓝图不设 url_prefix——因为博客是 Flaskr 的主功能,帖子列表页就是应用的主页 /,创建页在 /create,编辑页在 /<id>/update,删除页在 /<id>/delete

整个模块依赖两个前置基础设施:

  • get_db():定义在 examples/tutorial/flaskr/db.py。它把 sqlite3 连接挂到应用上下文的 g 对象上,同一个请求内多次调用会复用同一连接;连接通过 app.teardown_appcontext(close_db) 在每个请求结束时关闭。开启 detect_types=sqlite3.PARSE_DECLTYPES 并注册 timestamp 类型转换器(db.py 第 48 行),使 created 字段自动解析为 datetime 对象,模板里才能直接调用 strftime
  • login_required 装饰器:定义在 examples/tutorial/flaskr/auth.py。它检查 g.user 是否为 None,未登录时重定向到 url_for("auth.login")。而 g.user 本身由同文件中的 load_logged_in_userauth.py 第 32-43 行)通过 @bp.before_app_request 在每个请求开始前从 session 的 user_id 加载到 g 上。

定义蓝图并在应用工厂中注册

新建 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__)

然后在应用工厂 create_app 中导入并注册,把新代码放在工厂函数返回 app 之前(见 examples/tutorial/flaskr/init.py):

def create_app():
    app = ...
    # existing code omitted

    from . import blog
    app.register_blueprint(blog.bp)
    app.add_url_rule('/', endpoint='index')

    return app

这里有一个关键细节:index 视图注册在 blog 蓝图下,其 endpoint 全名是 blog.index,而认证视图(如登录成功后的跳转)引用的却是裸的 index endpoint。app.add_url_rule('/', endpoint='index') 把 endpoint 名 'index' 关联到 / 这个 URL,使得 url_for('index')url_for('blog.index') 都能工作,生成同一个 / URL。

仓库中的 flaskr/init.py 第 42-46 行 还保留了这段解释注释:在另一个应用中,你也可以给 blog 蓝图设一个 url_prefix,并在工厂里单独定义一个主 index 视图(类似 hello 视图),那时 indexblog.index 的 URL 就不同了——教程为了简单才让两者指向同一个 /

Index 视图:JOIN 查询展示全部帖子

index 视图展示所有帖子,最新在前。查询用 JOINuser 表中的作者信息带出来,避免模板里为每个帖子再查一次用户名(blog.py 第 16-25 行):

@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)

对应的模板 examples/tutorial/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 %}

模板中有三个值得注意的点:

  1. {% if g.user %}:用户登录状态通过 g 在模板中直接可见,已登录才显示 "New" 链接;
  2. g.user['id'] == post['author_id']:只有帖子作者才看到 "Edit" 链接——这是前端层面的权限控制(真正的安全边界在后端的 get_post 校验,见下文);
  3. loop.last:Jinja for 循环内的特殊变量,用于在除最后一篇之外的每篇帖子后渲染一条分隔线,视觉上分隔各帖。

post['created'].strftime('%Y-%m-%d') 能这样用,正是因为 db.py 注册了 timestamp 类型转换器,created 列已被解析成 datetime 而非字符串。

Create 视图:表单校验与插入

create 视图的工作方式与 auth 蓝图里的 register 视图一致:要么显示表单,要么校验提交数据、写入数据库,或者显示错误。login_required 装饰器用于所有需要登录的 blog 视图——未登录用户访问会被重定向到登录页(blog.py 第 60-83 行):

@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')

模板 examples/tutorial/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 %}

这里的实践要点:

  • methods=('GET', 'POST') 同时处理"显示表单"(GET)和"提交表单"(POST);
  • 作者身份来自 g.user['id'] 而非表单提交值,杜绝了伪造作者的可能;
  • db.commit() 显式提交,成功后 redirect(url_for('blog.index')) 回到列表页——即典型的"表单成功后重定向"(Post/Redirect/Get)模式;
  • 校验失败时 flash(error) 记录消息,重新渲染同一模板,用户已输入的内容通过 request.form['title'] 保留在输入框中。

get_post 辅助函数:404/403 权限模型

updatedelete 视图都需要按 id 取出帖子并校验作者是否为当前登录用户。为避免重复代码,抽出一个共用函数(blog.py 第 28-57 行):

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 post

abort()(来自 werkzeug.exceptions)会抛出一个特殊异常,返回对应 HTTP 状态码:

  • 404(Not Found):帖子 id 不存在,并附带一条展示给用户的消息 f"Post id {id} doesn't exist."abort 的第二个参数是可选的错误消息,缺省时使用默认消息);
  • 403(Forbidden):帖子存在但当前用户不是作者。
  • 注意与 401(Unauthorized)的区别:登录态问题不走 401,而是由 login_required 重定向到登录页。

check_author 参数让该函数也能在不校验作者的场合复用——比如将来写一个"查看单篇帖子"的详情页视图,任何用户都可见,只需调用 get_post(id, check_author=False)

Update 视图:路由参数、转换类型与双表单模板

update 视图带有路由参数(blog.py 第 86-110 行):

@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)

与之前写的视图相比,这里有几个新知识点:

  1. 路由变量与类型转换update 函数的参数 id 对应路由中的 <int:id>。真实 URL 形如 /1/update,Flask 会捕获 1,确保它是 int 类型后再作为 id 参数传入视图函数。如果写成 <id> 而不用 int: 前缀,传进来的就是字符串。
  2. url_for 生成带参数的 URL:要生成编辑页 URL,需要把 id 传给 url_for,它才知道要填充什么值:url_for('blog.update', id=post['id'])index.html 模板里也正是这样用的)。
  3. createupdate 的差异:两个视图结构非常相似,主要差别是 update 使用 post 对象和 UPDATE 查询而非 INSERT。教程特意不把它们合并成一个视图和模板,以保持教学清晰——实际项目中通过重构确实可以合并。

Flaskr 的帖子编辑页(Update 视图),上方是编辑表单,下方是带确认对话框的 Delete 按钮

模板 examples/tutorial/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 %}
  • 第一个表单把编辑后的数据 POST 到当前页(/<id>/update);第二个表单只有一个按钮,通过 action 属性改为 POST 到 delete 视图。删除按钮用一小段 JavaScript(return confirm('Are you sure?'))在提交前弹出确认对话框。
  • {{ request.form['title'] or post['title'] }} 模式:表单未提交时显示 post 的原始数据;如果提交了无效数据(比如标题为空),则显示 request.form 中的值,让用户在修正错误时保留已输入的内容。requestg 一样,是模板中自动可用的变量。

Delete 视图:只接受 POST 并跳回列表

删除操作没有独立模板——删除按钮就内嵌在 update.html 中,POST 到 /<id>/delete。因此该视图只处理 POST 方法,成功后重定向回 indexblog.py 第 113-125 行):

@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'))

注意 get_post(id) 的返回值在这里被有意忽略——调用它只是为了执行存在性检查(404)和作者检查(403),校验通过后才执行删除。把破坏性操作限定在 POST 方法上,也避免了通过直接访问 GET URL 误删数据。

测试用例印证权限模型

教程仓库中的 examples/tutorial/tests/test_blog.py 用测试客户机完整验证了上述行为:

  • test_login_required:对 /create/1/update/1/delete 三个路径发起 POST,断言响应头 Location/auth/login——印证 login_required 的重定向行为;
  • test_author_required:把 1 号帖子的 author_id 改为另一用户后,当前用户执行 /1/update/1/delete 均返回 403,且首页上不再出现 href="/1/update" 的编辑链接——印证 get_post 的作者校验与 index.html 中的前端条件一致;
  • test_exists_required:对不存在的 /2/update/2/delete 发起 POST,返回 404——印证 abort(404, ...) 分支;
  • test_create / test_update / test_delete:分别验证插入、更新、删除后数据库状态(如 SELECT COUNT(id) FROM post 从 1 变 2、title 变为 "updated"、帖子被删);
  • test_create_update_validate:空标题提交后响应体包含 "Title is required."——印证 flash 错误消息会出现在重新渲染的页面中。

这些测试运行在 examples/tutorial/tests/conftest.py 提供的测试配置之上,数据库由 examples/tutorial/flaskr/schema.sql 定义的 userpost 两张表初始化,post.created 使用 TIMESTAMP ... DEFAULT CURRENT_TIMESTAMP,与 index 视图按 created DESC 排序、模板中 strftime 渲染日期的行为相互对应。

小结

blog 蓝图以不到 130 行代码实现了 Flaskr 的核心业务闭环,其中体现了几个在真实 Flask 应用中反复出现的模式:

  1. 蓝图按业务模块组织视图url_prefix 与 endpoint 命名策略(blog.index 与裸 index 通过 add_url_rule 指向同一 URL)需要在注册时一并规划;
  2. "读取-校验-写入-重定向"的视图结构:GET 渲染表单,POST 校验后写库,成功 redirect,失败 flash 并重新渲染;
  3. 共用 get_post 函数集中处理 404/403,把存在性与作者权限检查从视图逻辑中剥离,check_author 参数为将来扩展(如公开的单篇详情页)留了口子;
  4. 前端展示条件(g.user['id'] == post['author_id'])与后端强制校验(abort(403))分层协作,前者改善体验,后者保障安全;
  5. g 承载请求级状态g.userg.db),连接在 teardown_appcontext 中自动释放,视图和模板共享同一份上下文。

以上源码与测试均可在仓库中直接查阅:examples/tutorial/flaskr/blog.pyexamples/tutorial/flaskr/init.pyexamples/tutorial/flaskr/db.pyexamples/tutorial/tests/test_blog.py,以及教程文档 docs/tutorial/blog.rst

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.79 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
988
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384