简介:在Python Web开发中,使用Flask这类轻量级框架搭配SQLite3嵌入式数据库,是快速搭建小型业务系统的常见组合。围绕图书管理这一典型CRUD场景,开发者需要理解路由与视图函数的映射、Jinja2模板渲染机制,以及表单提交后重定向的工作模式。与此同时,SQLite3在并发写入时产生的database is locked问题、参数化查询防止SQL注入的价值、UTF-8编码设置等细节,都是新手容易踩坑的高频点。本文以一个完整的Flask+SQLite3图书管理小项目为线索,从目录结构、数据库初始化、数据访问层封装,到一次请求从URL进入页面渲染的完整链路,再到五大高频异常现象的排查过程,逐一拆解。内容贴近工程实践,适合希望掌握原生sqlite3操作、暂不引入ORM的Web后端初学者,也可作为后续扩展登录、搜索与分页功能的基础起点。
1. 试验楼小Demo初印象:一个Flask+SQLite3的图书管理练习
标题里的“试验楼”是我给这个练习项目的仓库起的内部代号,它既不指向任何线上平台,也不是什么课程配套资源,而是一个我自己手敲的 Flask + SQLite3 图书管理小 Demo。项目很小,但闭环完整:添加、列表、编辑、删除,外加模板继承和基于 Cookie 的访问计数。反直觉的结论是:越小的 Demo 越考验细节,SQLite3 的数据库锁、POST 重复提交、中文编码,任何一个坑都能把新手卡一晚上。它适合想完整走一遍 Python web 开发流程的读者——你会看到路由怎么通到模板、SQLite3 原生操作怎么写、一个小项目该怎么分层。不用 Docker、不用 ORM,只要本机有 Python3 就能跑起来。
2. 项目骨架与数据层:从Flask工厂到SQLite3表结构
2.1 目录结构:小Demo也该有清晰边界
解压这个项目后,你会看到下面这个目录。它不是官方脚手架生成的,而是我手动拆出来的,每个文件只负责一件事:
flask-demo/ ├── app.py # 应用入口:路由、视图函数、请求处理 ├── db.py # 数据库连接、初始化、CRUD 数据访问函数 ├── requirements.txt # 依赖清单,目前只有 flask ├── book.db # SQLite3 数据库文件,首次初始化后生成 ├── templates/ │ ├── base.html # 父模板:导航栏 + 内容块 │ ├── index.html # 图书列表页 │ ├── add.html # 添加图书表单页 │ └── edit.html # 编辑图书表单页 └── static/ └── style.css # 极简样式,管列表和表单的排版这个结构是 Flask 单模块项目最常见的布局,也是“试验楼小 Demo”最有参考价值的地方。我第一版把所有逻辑塞进一个 app.py,四百多行也能跑,但想加搜索时,光找“列表查询应该在哪个函数里”就花了半小时。后来把 db.py 单独拎出来,职责边界立刻清晰:db.py 只回答“数据库怎么连、表怎么建、增删改查怎么写”,app.py 只回答“URL 来了我要干什么”。模板目录和 static 目录遵循 Flask 默认约定,如果你改了template_folder='/templates'这类参数,目录结构就失去了教科书般的意义。
2.2 数据库初始化:用sqlite3原生模块建表与连接
因为我一开始就决定不引入 SQLAlchemy,所以数据库部分全部走 Python 标准库 sqlite3。db.py 里的连接管理用到了 Flask 的 g 对象:
import sqlite3 from flask import g DATABASE = 'book.db' def get_db(): # g 是 Flask 的请求全局对象,同一个请求里复用同一个连接 if 'db' not in g: g.db = sqlite3.connect(DATABASE) 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() def init_db(): # 建表语句:图书表保存书名、作者、入库时间 db = get_db() db.execute(''' CREATE TABLE IF NOT EXISTS books ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, author TEXT NOT NULL, published_year INTEGER, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) ''') db.commit()这段代码有三个细节值得说。第一,sqlite3.connect(DATABASE)默认行工厂返回的是元组,row_factory = sqlite3.Row之后才能用book['title']这样的列名访问,模板里不要写book[0]。第二,CURRENT_TIMESTAMP生成的是 UTC 时间,如果你想要本地时间,查询时用datetime(created_at, 'localtime')。第三,init_db()每次启动都执行,CREATE TABLE IF NOT EXISTS保证不会重复建表。
在 app.py 里记得注册关闭钩子:
app.teardown_appcontext(close_db)这样每次请求结束,无论成功还是报错,数据库连接都会归还。我见过不少人把连接开到全局,结果浏览器并发刷新两次,直接就报database is locked。后面的避坑章节会详细讲这个。
2.3 数据访问层:把SQL封装成函数,避免到处拼SQL
视图函数里直接写db.execute不是不行,但 CRUD 重复代码会失控。我的做法是在 db.py 里做一层数据访问函数,把 SQL 都收拢在一起:
def all_books(): db = get_db() cur = db.execute('SELECT * FROM books ORDER BY id DESC') rows = cur.fetchall() cur.close() return rows def find_book(book_id): db = get_db() cur = db.execute('SELECT * FROM books WHERE id = ?', (book_id,)) row = cur.fetchone() cur.close() return row def insert_book(title, author, published_year): db = get_db() db.execute( 'INSERT INTO books (title, author, published_year) VALUES (?, ?, ?)', (title, author, published_year) ) db.commit() return db.execute('SELECT last_insert_rowid()').fetchone()[0] def update_book(book_id, title, author, published_year): db = get_db() db.execute( 'UPDATE books SET title = ?, author = ?, published_year = ? WHERE id = ?', (title, author, published_year, book_id) ) db.commit() def delete_book(book_id): db = get_db() db.execute('DELETE FROM books WHERE id = ?', (book_id,)) db.commit()参数全部用?占位符,这是我这篇里最想强调的习惯。新手用 f-string 拼 SQL 很容易遇到两个问题:书名叫It's a book时 SQL 语法直接炸,更糟的是用户输入变成注入入口。用参数绑定,Python 标准库会把值安全地转义好。另一点是每执行完一条写操作就 commit,否则连接关闭时未提交的事务会回滚——我一度以为 update 没生效,其实是忘写了 commit。
fetchall 之后我手动 cur.close(),这是因为 sqlite3 的 cursor 在查询未结束时可能占用连接内部状态。如果你用with db.execute(...) as cur上下文,也可以自动释放。数据访问函数返回的是 Row 对象,在视图层可以直接传给模板,Jinja2 能像访问字典一样取字段。
提示:SQLite3 的 Row 工厂只对当前连接生效。如果你在 db.py 里另开连接,记得重新设置 row_factory。
3. 核心模块拆解:一个请求从URL进入到页面渲染的完整链路
3.1 路由与视图函数:Flask如何把请求映射到Python函数
用一个请求来走读整个项目:用户在首页点击“添加”,提交表单后浏览器 POST 到/add。Flask 拿到 URL,通过路由装饰器找到add函数,然后执行。app.py 里核心路由是这样写的:
from flask import Flask, render_template, request, redirect, url_for, flash from db import get_db, all_books, find_book, insert_book, update_book, delete_book app = Flask(__name__) app.secret_key = 'dev-secret-key-change-me' @app.route('/') def index(): books = all_books() return render_template('index.html', books=books) @app.route('/add', methods=['GET', 'POST']) def add(): if request.method == 'POST': title = request.form.get('title', '').strip() author = request.form.get('author', '').strip() published_year = request.form.get('published_year', '') if not title or not author: flash('书名和作者不能为空', 'error') return redirect(url_for('add')) insert_book(title, author, int(published_year) if published_year else None) flash('添加成功', 'success') return redirect(url_for('index')) return render_template('add.html')@app.route是 Flask 的核心机制:路径字符串与视图函数绑定。methods=['GET', 'POST']让同一个函数同时处理页面展示和表单提交,这是我在这里唯一推荐的“一函数两用”。注意request.form.get的第二个参数是缺省值,.strip()会把用户误敲的空格去掉。校验失败后flash再重定向回add,不会停留在当前页面。
url_for('index')是 Flask 反向构造 URL 的写法,它根据函数名生成路径。如果你以后把index改成book_list,模板和重定向里的url_for('book_list')会同步生效,不用手动搜/字符串逐个替换。
3.2 模板渲染与静态资源:页面层怎么和视图衔接
视图函数把数据交给模板后,真正展示在用户面前的 HTML 由 Jinja2 渲染。base.html 是所有页面的骨架:
<!DOCTYPE html> <html lang="zh"> <head> <meta charset="utf-8"> <title>{% block title %}图书库{% endblock %}</title> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> </head> <body> <nav><a href="{{ url_for('index') }}">首页</a> <a href="{{ url_for('add') }}">添加</a></nav> <main> {% block content %}{% endblock %} </main> </body> </html>index.html 继承 base.html,只写内容块:
{% extends 'base.html' %} {% block content %} <h1>图书列表</h1> <ul> {% for book in books %} <li> {{ book.title }} - {{ book.author }} ({{ book.published_year or '未知年份' }}) <a href="{{ url_for('edit', book_id=book.id) }}">编辑</a> <a href="{{ url_for('delete', book_id=book.id) }}" onclick="return confirm('确定删除?')">删除</a> </li> {% else %} <li>还没有书</li> {% endfor %} </ul> {% endblock %}Jinja2 的for ... else是我特别喜欢的一个特性:列表为空时自动走 else 分支,视图函数不用额外写if books:。注意published_year or '未知年份'处理数据库里可能存在的 NULL 值。模板里的url_for('edit', book_id=book.id)会生成类似/edit/1的路径,Flask 会把book_id作为整型参数传给视图。
静态文件的引用同样用url_for('static', filename='style.css'),这样即使部署时把静态目录挂到 CDN,也只需要改 Flask 配置,模板不用动。
3.3 表单处理与校验:POST请求里最容易翻车的地方
PRG(Post/Redirect/Get)是这个小 Demo 里我认为最有价值的设计。第一次写这个项目时,我在 POST 成功后直接return render_template('add.html'),结果用户按 F5 刷新,表单原样重提交,数据库里多出一条重复记录。后来改成redirect(url_for('index')),刷新时变成 GET 请求,问题消失。
再看编辑路由的回显逻辑:
@app.route('/edit/<int:book_id>', methods=['GET', 'POST']) def edit(book_id): book = find_book(book_id) if book is None: flash('这本书不存在', 'error') return redirect(url_for('index')) if request.method == 'POST': title = request.form.get('title', '').strip() author = request.form.get('author', '').strip() published_year = request.form.get('published_year', '') update_book(book_id, title, author, int(published_year) if published_year else None) flash('修改成功', 'success') return redirect(url_for('index')) return render_template('edit.html', book=book)这里先调find_book确认资源存在,是处理 “不存在的 ID” 的标准姿势。如果直接执行 UPDATE,数据库不会报错,但用户被莫名其妙带到编辑页,体验很差。注意int(published_year)这里没有捕获ValueError,如果用户输入 “abc”,会直接 500。二次开发时应该改成 try/except,让异常落回页面提示而不是白屏。
删除操作我用的是 POST 而不是 GET:
@app.route('/delete/<int:book_id>', methods=['POST']) def delete(book_id): delete_book(book_id) return redirect(url_for('index'))为什么不用<a href="/delete/1">?因为 GET 请求会被浏览器预取、被搜索引擎爬虫抓取,如果删除操作挂在 GET 上,外部触发就可能误删数据。删除按钮在模板里是一个小表单,提交 POST 请求。这个习惯在真实项目中特别重要,Demo 阶段就应该养成。
4. 运行与避坑:从flask run到SQLite3锁问题的排查全过程
4.1 启动前准备:虚拟环境、依赖安装与首次初始化
假定你已经把压缩包解压到flask-demo目录。打开终端:
cd flask-demo python -m venv .venv source .venv/bin/activate # Windows 使用 .venv\Scripts\activate pip install -r requirements.txt python -c "from db import init_db; init_db()"requirements.txt 内容只有一行:flask>=2.2。init_db()会创建 book.db 和 books 表。如果命令执行后没看到 book.db,优先检查当前目录是不是 flask-demo——sqlite3 的book.db是相对路径,它的最终位置取决于 Python 进程的工作目录,而不是 app.py 所在目录。我一般会先ls看一眼,再决定要不要调整路径。
4.2 运行与调试验证:从flask run到逐条测接口
初始化完成后,启动开发服务器:
flask --app app.py run --debug --port 8000--debug开启代码重载和调试器,改任意 Python 文件后 Flask 会重启。--port 8000是为了避开我机器上 5000 端口被占的情况,你完全可以用默认端口。启动后打开http://127.0.0.1:8000,按下面清单自测:
- 添加一本《示例图书》,看是否跳回列表并出现“添加成功”提示
- 编辑该书,把出版年份改为 2024,确认回显正确
- 删除该书,确认弹确认框后列表少一行
- 直接访问
http://127.0.0.1:8000/edit/999,应跳回列表并提示“这本书不存在”
如果某一步不如预期,看终端里的 Flask 日志。debug 模式会把完整堆栈打出来,sqlite3.OperationalError之类的错误一眼就能定位。开发时不要跳过这些边界测试,它们比“跑起来看到首页”更能证明项目完整。
4.3 避坑记录:五个高频问题从现象到解决
这一节是我重写这个小 Demo 时的踩坑台账,每一条都经历过“现象 → 原因 → 解决”的循环。
现象一:sqlite3.OperationalError: database is locked
我第一版把sqlite3.connect写在模块顶层,整个进程共用一个连接。浏览器同时打开两个页面,其中一个发起写入,另一个会拿不到写锁,直接报错。原因就是 SQLite3 对于写操作只允许一个连接持锁,全局连接不释放,并发请求互相阻塞。解决方式是在get_db()里每请求新建连接,请求结束close_db,并在sqlite3.connect里加上timeout=5给短暂锁竞争留出等待时间。
现象二:表单提交后返回 405 Method Not Allowed
模板里<form method="post" action="{{ url_for('index') }}">,但 index 路由没有methods=['POST'],Flask 只能返回 405。这个错误的根源往往是手滑写错了 action,不一定是路由缺失。解决:打开浏览器检查表单的 action 是否符合预期;如果 action 指向add,确认@app.route('/add')里带上了methods=['GET', 'POST']。
现象三:中文变成乱码
项目里全是中文文本,某天打开页面发现书名变成“æ±è”这样的乱码。原因不在数据库,而是 base.html 漏写了<meta charset="utf-8">,浏览器按默认编码猜,中文必乱。解决:模板加上该 meta 标签。如果加完还乱,检查数据库文件是不是用非 UTF-8 环境写入的——Python3 默认 UTF-8,一般不会出问题,但如果你用旧版导出数据再导入,要留意源头编码。
现象四:刷新页面重复添加记录
这个在 3.3 里说过,再补一个排查视角:反复刷新列表页没事,刷新添加页就有问题。原因就是 POST 成功之后没有重定向,浏览器认为当前页面仍是可重复提交的表单。解决:redirect(url_for('index'))不是可选项,是必选项。我在写第二个版本时把这个写进项目注释,避免自己再犯。
现象五:TypeError: 'NoneType' object is not subscriptable
访问edit/999时,find_book(999)返回 None,后续代码还继续取book['title'],于是 TypeError。原因是边界数据没有兜底。解决:视图函数里先判断if book is None再决定走渲染还是重定向。这个习惯在加了用户登录之后更重要——用户传进来的 id 你永远要假设可能不存在。
5. 二次开发:把Demo改造成带登录与搜索的完整工具
5.1 先加登录:用装饰器挡掉裸奔的增删改
小 Demo 的增删改没有任何权限控制,二次开发第一步应该是加登录。在 app.py 里定义装饰器:
from functools import wraps from flask import session, redirect, url_for def login_required(f): @wraps(f) def wrapper(*args, **kwargs): if not session.get('user'): return redirect(url_for('login')) return f(*args, **kwargs) return wrapper然后在add、edit、delete路由上方加@login_required,放在@app.route下面。只判断session['user']存在性还不够严谨,但作为 Demo 第一步已经够了。我一般会把用户表也建进 book.db,密码用哈希后存放,而不是明文——这个习惯能省掉以后上线前的安全工作。
5.2 加搜索与分页:把SQLite3的LIKE和LIMIT用起来
图书列表一旦数据过百,就该有搜索和分页。在 db.py 补一个函数:
def search_books(keyword, limit=20, offset=0): db = get_db() cur = db.execute( 'SELECT * FROM books WHERE title LIKE ? OR author LIKE ? ' 'ORDER BY id DESC LIMIT ? OFFSET ?', (f'%{keyword}%', f'%{keyword}%', limit, offset) ) rows = cur.fetchall() cur.close() return rows视图里接收request.args.get('q'),把关键词传给 search_books,模板里放一个文本框用来输入搜索词。这里 LIMIT 和 OFFSET 也用参数绑定,不要手拼数字。搜索分页合起来能让这个 Demo 立刻变成一个可用的图书管理工具,而不再是单纯的教学样板。
5.3 扩展建议:什么时候拆蓝图、换ORM
如果路由超过十五个,我会拆成 books 和 auth 两个蓝图;如果出现多表关联、字段迁移、外键约束这些需求,再去考虑 SQLAlchemy。这个 Demo 保留原生 sqlite3 反而更合适,因为它能让 SQL 的执行过程完全透明,调试报错时你能清楚知道是哪一条语句出了问题。从那以后,我每次拿到新环境都要先跑一遍自测清单,确认数据库锁和编码设置没让我翻车,再继续加功能。这个项目虽然小,却是你后续改造第一篇真实 Web 应用的最短路径,希望帮到你。
本文还有配套的精品资源,点击获取