从表单到 SQLite:为 Flask 项目接入可维护的数据存储

2026-09-27 35 预计阅读时间: 1 分钟
来源: realpython.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

给 Flask 应用增加数据库,真正需要处理的不只是一次 INSERT。配置应该从环境变量读取,数据库连接需要跟随应用上下文关闭,路由要按 Blueprint 组织,表单提交还应经过验证,并在写入后重定向。下面用一个最小博客项目把这些环节串起来。

数据库之外,还有四个边界要处理

一个能长期维护的 Flask 数据写入流程,通常包含以下职责:

  • 环境变量:保存密钥、数据库路径等部署相关配置,避免写死在仓库里。
  • Blueprint:把文章相关路由放在独立模块边界中,避免所有接口堆进应用工厂。
  • Schema:显式定义字段、约束和默认值,让数据库承担一部分数据完整性检查。
  • 请求处理:验证表单、执行参数化 SQL,并采用 Post/Redirect/Get 模式防止刷新时重复提交。

SQLite 很适合本地工具、教程项目和低写入并发的服务。它不需要单独启动数据库进程,但这并不意味着可以忽略连接管理、迁移和备份。

一个可以直接运行的最小项目

创建一个新目录,并准备以下两个文件:

flask-posts/
├── app.py
└── schema.sql

将下面内容保存为 schema.sql:

CREATE TABLE IF NOT EXISTS post (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL CHECK (length(trim(title)) > 0),
    body TEXT NOT NULL DEFAULT '',
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

再将下面代码保存为 app.py。为便于复制运行,Blueprint、数据库函数和应用工厂暂时放在同一个文件中;项目变大后可以分别拆到 blog.py、db.py 和包初始化文件中。

import os
import sqlite3
from pathlib import Path

import click
from flask import (
    Blueprint,
    Flask,
    current_app,
    flash,
    g,
    redirect,
    render_template_string,
    request,
    url_for,
)

blog = Blueprint('blog', __name__)


def get_db():
    if 'db' not in g:
        g.db = sqlite3.connect(current_app.config['DATABASE'])
        g.db.row_factory = sqlite3.Row
    return g.db


def close_db(error=None):
    db = g.pop('db', None)
    if db is not None:
        db.close()


@click.command('init-db')
def init_db_command():
    schema_path = Path(current_app.root_path) / 'schema.sql'
    get_db().executescript(schema_path.read_text(encoding='utf-8'))
    click.echo('Database initialized.')


@blog.route('/')
def index():
    posts = get_db().execute(
        'SELECT id, title, body, created_at '
        'FROM post ORDER BY id DESC'
    ).fetchall()

    return render_template_string(
        '''
        <!doctype html>
        <meta charset='utf-8'>
        <title>Posts</title>
        <h1>Posts</h1>
        <p><a href='{{ url_for("blog.create") }}'>Create a post</a></p>
        {% for message in get_flashed_messages() %}
          <p><strong>{{ message }}</strong></p>
        {% endfor %}
        {% for post in posts %}
          <article>
            <h2>{{ post['title'] }}</h2>
            <p>{{ post['body'] }}</p>
            <small>{{ post['created_at'] }}</small>
          </article>
        {% else %}
          <p>No posts yet.</p>
        {% endfor %}
        ''',
        posts=posts,
    )


@blog.route('/posts/new', methods=['GET', 'POST'])
def create():
    if request.method == 'POST':
        title = request.form.get('title', '').strip()
        body = request.form.get('body', '').strip()

        if not title:
            flash('Title is required.')
        else:
            db = get_db()
            db.execute(
                'INSERT INTO post (title, body) VALUES (?, ?)',
                (title, body),
            )
            db.commit()
            flash('Post created.')
            return redirect(url_for('blog.index'))

    return render_template_string(
        '''
        <!doctype html>
        <meta charset='utf-8'>
        <title>New post</title>
        <h1>New post</h1>
        {% for message in get_flashed_messages() %}
          <p><strong>{{ message }}</strong></p>
        {% endfor %}
        <form method='post'>
          <p><label>Title <input name='title' required></label></p>
          <p><label>Body<br><textarea name='body' rows='8'></textarea></label></p>
          <button type='submit'>Save</button>
        </form>
        <p><a href='{{ url_for("blog.index") }}'>Back</a></p>
        '''
    )


def create_app():
    app = Flask(__name__, instance_relative_config=True)
    app.config.from_mapping(
        SECRET_KEY=os.getenv('FLASK_SECRET_KEY', 'dev-only-change-me'),
        DATABASE=os.getenv(
            'DATABASE_PATH',
            str(Path(app.instance_path) / 'posts.sqlite'),
        ),
    )

    Path(app.config['DATABASE']).parent.mkdir(parents=True, exist_ok=True)
    app.teardown_appcontext(close_db)
    app.cli.add_command(init_db_command)
    app.register_blueprint(blog)
    return app


app = create_app()

安装 Flask、设置环境变量并启动服务:

python -m venv .venv
source .venv/bin/activate
python -m pip install Flask

export FLASK_SECRET_KEY='replace-with-a-random-secret'
export DATABASE_PATH="$PWD/instance/posts.sqlite"

flask --app app init-db
flask --app app run --debug

Windows PowerShell 可以把两条 export 改成:

$env:FLASK_SECRET_KEY = 'replace-with-a-random-secret'
$env:DATABASE_PATH = "$PWD\instance\posts.sqlite"

启动后访问 http://127.0.0.1:5000/posts/new,提交的文章会写入 SQLite,并在首页显示。

为什么这些细节值得保留

get_db() 把连接存进 Flask 的 g 对象,因此同一个请求可以复用连接;teardown_appcontext() 则确保请求结束后关闭它。直接在模块导入时创建全局 SQLite 连接,会给测试、线程和应用工厂带来额外问题。

SQL 使用 ? 占位符,而不是字符串拼接:

db.execute(
    'INSERT INTO post (title, body) VALUES (?, ?)',
    (title, body),
)

这样数据库驱动会把输入当作参数处理,避免标题中的引号破坏 SQL,也降低 SQL 注入风险。不要改成 f-string 或 % 格式化 SQL。

写入成功后返回重定向,而不是直接渲染成功页面。这是 Post/Redirect/Get 模式:浏览器刷新时重新请求的是首页,不会再次提交同一份表单。

Blueprint 的价值则会在功能增长后显现。文章、账户和管理后台可以使用不同的 Blueprint,并分别配置 URL 前缀、模板和权限检查。

上线前需要补齐什么

这个示例刻意保持最小,因此不应原样承担公开生产流量。部署前至少检查:

  • 用随机且稳定的 FLASK_SECRET_KEY,不要使用示例默认值。
  • 为表单增加 CSRF 防护;可以采用 Flask-WTF,或实现等价的令牌校验。
  • 增加身份认证和授权,不能让所有访问者都能创建文章。
  • 限制标题、正文和请求体大小,并处理数据库异常。
  • 不要只靠反复执行 CREATE TABLE IF NOT EXISTS 演进结构;表结构变化后应使用迁移工具。
  • 定期备份数据库文件,并验证恢复流程。
  • 评估并发写入量。SQLite 会序列化部分写操作,写入密集型、多实例服务通常更适合 PostgreSQL 等独立数据库。

判断是否应该继续使用 SQLite,可以看三个问题:是否只有单机或共享磁盘实例、写入是否较少、是否能接受文件级备份方式。如果答案逐渐变成否,保留当前 Blueprint 和配置边界,再替换数据访问层,会比重写整个 Flask 应用容易得多。


相关推荐