简介:这是一份适合初学者的Python Flask入门实例,基于实验楼项目完成,用轻量级Flask框架与Python标准库SQLite3搭建了一个带用户注册/登录/登出、博文发布与展示的小型博客系统。压缩包共8个文件,约5KB,包含flaskr.py主程序、3个HTML页面模板、CSS样式、数据库文件及SQL建表脚本,结构精简,能直观看到路由、模板渲染、session会话和数据库操作的完整流程。标签涉及Python、web开发、flask、sqlite3等。已有3085人学习/下载。通过这个Demo可快速理解用户身份验证、表单处理、Jinja2模板渲染以及SQLite3建表查询等Web开发基础内容,数据库预置了演示数据,适合作为课程设计或新手练手项目。实际敲过的代码和完整目录结构,配合登录、登出、发布博文等页面,让读者能对照运行并逐步拆解学习。
1. 项目定位:Flask + SQLite3 的轻量笔记应用,新手练手首选
几个月前帮一个同学看课程设计,他交上来一个 Flask 写的小 Demo——带登录注册、笔记增删改查,存储用的就是 SQLite3 单文件数据库,代码加起来不到 600 行,没装任何第三方扩展。第一眼觉得简陋,但真正跑通之后反而觉得:这恰恰是理解一个 Web 后端项目从 0 到完整闭环最好的素材。后来我把它改造了一版,用在新人带教里,效果比照着文档抄强太多。
这个项目的价值不在功能多炫,而在于它把 Flask 最基本的东西——路由、模板渲染、Session 会话、SQLite3 原生 SQL——全部串在了一起。适合刚学完 Flask 基础想找完整 CRUD 样本的初学者,也适合需要快速交付课程设计的学生。没有 ORM,没有蓝图分层那种重型设计,反而让数据库操作和 Web 层的关系一目了然。
2. 架构与数据层:目录设计、建表语句与数据库初始化
2.1 目录结构与分层思路
拿到手先把目录展开,这个小项目采用的是 Flask 单应用加简单函数分模块的方式,没有用蓝图。
flask-demo/ ├── app.py # 应用入口,注册路由 ├── db.py # 数据库连接与查询封装 ├── init_db.py # 初始化 SQLite3 建表脚本 ├── schema.sql # 建表 SQL 语句 ├── templates/ │ ├── base.html # 父模板,导航栏与 flash 消息 │ ├── index.html # 笔记列表页 │ ├── login.html # 登录页 │ ├── register.html # 注册页 │ ├── new_note.html # 新建笔记页 │ └── edit_note.html # 编辑笔记页 ├── static/ │ └── style.css # 极简样式 ├── requirements.txt # Flask 唯一依赖 └── notes.db # 运行时自动生成的 SQLite 文件这个结构的核心思路是把「数据库操作」和「路由逻辑」分开。db.py负责所有 SQL 语句的执行,app.py只负责接收请求、调用查询函数、返回渲染结果。新手最容易犯的错就是把sqlite3.connect写在每一个路由里,连接对象满天飞,这个小 Demo 用 Python 的g对象把连接绑定在请求上下文中,代码清爽很多。
2.2 数据模型设计:users 与 notes 两张表
打开schema.sql,核心其实就是两张表,一张存用户,一张存笔记。我直接把原项目的建表语句贴出来:
DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS notes; CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), FOREIGN KEY (user_id) REFERENCES users(id) );这里注意几个细节。created_at用的datetime('now', 'localtime')是 SQLite 内置函数,取的是本地时间,避免 Python 端和 SQLite 端时区不一致的问题。notes表通过user_id外键关联users,查询某用户的笔记列表时只要WHERE user_id = ?就行。密码字段直接存的明文——这个 Demo 为了简化没有做哈希,但如果要部署到公网,必须改成werkzeug.security.generate_password_hash,后面避坑章节会专门讲。
2.3 数据库连接封装与初始化脚本
db.py是整个项目里最有复用价值的部分。它用 Flask 的g对象做连接缓存,每次请求结束自动关闭:
import sqlite3 from flask import g, current_app DATABASE = 'notes.db' def get_db(): if 'db' not in g: g.db = sqlite3.connect( 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() def init_db(): db = get_db() with current_app.open_resource('schema.sql') as f: db.executescript(f.read().decode('utf-8')) def query_db(query, args=(), one=False): cur = get_db().execute(query, args) rv = cur.fetchall() cur.close() return (rv[0] if rv else None) if one else rv参数说明:row_factory = sqlite3.Row让查询结果支持按列名访问,比如row['username']而不是row[0],代码可读性高很多。query_db是封装好的查询函数,所有路由都通过它执行 SQL,后面的笔记模块会反复用到。init_db配合 Flask 的open_resource读取 SQL 文件,比直接 open 文件路径更安全——不会因为工作目录变化而找不到 schema.sql。
3. 核心模块拆解:一个请求从进入到返回的完整链路
3.1 注册与登录:会话认证的最小实现
先看认证模块。登录注册没有用单独的 auth 蓝图,而是直接写在app.py里,核心是一个查询数据库并比对密码的函数。注意这里的实现是教学取向的,生产环境必须换哈希:
from flask import Flask, render_template, request, session, redirect, url_for, flash from werkzeug.security import check_password_hash from db import get_db, query_db, close_db @app.route('/register', methods=['GET', 'POST']) def register(): if request.method == 'POST': username = request.form['username'].strip() password = request.form['password'] error = None if not username or not password: error = '用户名和密码不能为空' elif query_db('SELECT id FROM users WHERE username = ?', (username,), one=True): error = '用户名已被占用' if error is None: db = get_db() # 生产环境应改为 generate_password_hash(password) db.execute('INSERT INTO users (username, password) VALUES (?, ?)', (username, password)) db.commit() return redirect(url_for('login')) flash(error) return render_template('register.html') @app.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': username = request.form['username'].strip() password = request.form['password'] user = query_db( 'SELECT * FROM users WHERE username = ? AND password = ?', (username, password), one=True ) if user is None: flash('用户名或密码错误') else: session.clear() session['user_id'] = user['id'] session['username'] = user['username'] return redirect(url_for('index')) return render_template('login.html')逻辑说明:request.form拿表单字段,query_db的第三个参数one=True表示只取第一行。登录成功把user_id写进session,Flask 的 session 默认是签名的 Cookie,客户端改不了。所有需要登录的页面只要判断session.get('user_id')存在就行。这里登录 SQL 直接拼接了密码比对,属于教学上的简化——我在改造版本里换成了先查用户再单独验证密码哈希,避免把密码明文放 SQL 里。
3.2 笔记模块:认证装饰器与增删改查路由
笔记的路由是一个标准 CRUD 模板。所有笔记操作前都要求登录,这里用一个自定义装饰器搞定,比每个路由写一遍判断干净得多:
from functools import wraps def login_required(view): @wraps(view) def wrapped_view(**kwargs): if session.get('user_id') is None: flash('请先登录') return redirect(url_for('login')) return view(**kwargs) return wrapped_view @app.route('/') @login_required def index(): notes = query_db( 'SELECT * FROM notes WHERE user_id = ? ORDER BY updated_at DESC', (session['user_id'],) ) return render_template('index.html', notes=notes) @app.route('/new', methods=['GET', 'POST']) @login_required def new_note(): if request.method == 'POST': title = request.form['title'].strip() content = request.form['content'].strip() if not title: flash('标题不能为空') else: db = get_db() db.execute( 'INSERT INTO notes (user_id, title, content) VALUES (?, ?, ?)', (session['user_id'], title, content) ) db.commit() return redirect(url_for('index')) return render_template('new_note.html') @app.route('/<int:id>/edit', methods=['GET', 'POST']) @login_required def edit_note(id): note = query_db( 'SELECT * FROM notes WHERE id = ? AND user_id = ?', (id, session['user_id']), one=True ) if note is None: flash('笔记不存在或无权编辑') return redirect(url_for('index')) if request.method == 'POST': title = request.form['title'].strip() content = request.form['content'].strip() if not title: flash('标题不能为空') else: db = get_db() db.execute( 'UPDATE notes SET title = ?, content = ?, updated_at = datetime(\'now\', \'localtime\') WHERE id = ?', (title, content, id) ) db.commit() return redirect(url_for('index')) return render_template('edit_note.html', note=note) @app.route('/<int:id>/delete', methods=['POST']) @login_required def delete_note(id): db = get_db() db.execute('DELETE FROM notes WHERE id = ? AND user_id = ?', (id, session['user_id'])) db.commit() flash('笔记已删除') return redirect(url_for('index'))几个关键点。编辑和删除都带了AND user_id = ?,这是防越权的关键——即使别人猜到笔记 ID,也无法操作不属于自己的数据。<int:id>是 Flask 的 URL 转换器,自动把路径里的数字转成 int,访问/abc/edit直接 404。删除用POST而不是GET,避免搜索引擎爬虫或预加载误删除数据。
3.3 模板层:Jinja2 表单渲染与数据传递
模板部分最有价值的是base.html,它用 Jinja2 的继承机制把导航栏、flash 消息、CSS 引用统一管理。子模板只需要重写block content:
<!doctype html> <html> <head> <title>{% block title %}Flask Notes{% endblock %}</title> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> </head> <body> <nav> {% if session.get('username') %} <span>{{ session['username'] }}</span> <a href="{{ url_for('new_note') }}">新建</a> <a href="{{ url_for('logout') }}">退出</a> {% endif %} </nav> {% with messages = get_flashed_messages() %} {% if messages %} <ul class="flash"> {% for message in messages %} <li>{{ message }}</li> {% endfor %} </ul> {% endif %} {% endwith %} {% block content %}{% endblock %} </body> </html>get_flashed_messages()是 Flask 的 flash 消息机制,配合路由里的flash(error)使用,页面上方就会显示一次性提示。url_for('static', filename='style.css')动态生成静态文件路径,就不用关心应用挂载到哪个子路径了。子模板比如index.html里用{% for note in notes %}遍历传进来的笔记列表,每篇笔记渲染一个卡片,带编辑和删除按钮。
4. 运行与部署:从本地调试到服务器上线的完整命令链
4.1 本地开发环境与 Flask 自带服务器
这个项目跑起来没什么玄学,但新手最容易在新版本 Python 和 Flask 的搭配上翻车。先建虚拟环境再装依赖:
python3 -m venv venv source venv/bin/activate pip install -r requirements.txt python init_db.py flask --app app run --debug每一条命令的作用:venv隔离项目依赖,防止和系统 Python 冲突;init_db.py执行建表语句,生成notes.db文件,这个步骤漏掉的话后面所有查询都会报no such table: notes;flask --app app指定应用入口文件,--debug开启热重载,改代码自动重启服务。
访问http://127.0.0.1:5000会先被跳转到登录页,注册一个账号就能正常使用了。init_db.py里我习惯先强制删除旧表再重建,数据丢失不心疼,但如果你的notes.db里存了不想丢的数据,就把schema.sql里的DROP TABLE注释掉。
4.2 生产部署:使用 Gunicorn 与 Nginx 反代
开发服务器不能直接用在生产环境,一来性能差,二来没有并发能力。我一般会用 Gunicorn 做 WSGI 服务器,Nginx 做反向代理。先把项目上传到服务器,假设放在/opt/flask-demo:
cd /opt/flask-demo python3 -m venv venv && source venv/bin/activate pip install gunicorn gunicorn -w 4 -b 127.0.0.1:8000 app:app命令参数说明:-w 4表示启动 4 个 worker 进程,每个 worker 是独立的 Python 进程,可以同时处理 4 个请求;-b 127.0.0.1:8000表示只监听本机 8000 端口,不直接暴露给公网;app:app是模块名冒号应用变量名的写法,对应app.py里的app = Flask(__name__)。如果这里报ModuleNotFoundError,检查是不是没在项目目录下执行命令。
Nginx 反代配置核心是转发请求头和端口:
server { listen 80; server_name your-domain.com; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }proxy_set_header Host $host这行不能省,Flask 生成绝对 URL 时依赖它。还记得url_for生成的是相对路径吗?如果少了X-Forwarded-For,Flask 的request.remote_addr拿到的全是 Nginx 的 IP,做日志审计时会被误导。
4.3 SQLite 数据备份与恢复
SQLite 是单文件数据库,备份最简单的方式就是直接复制文件,但如果应用正在写入,复制出来的文件可能不一致。我习惯用 SQLite 自带的备份命令:
sqlite3 notes.db ".backup 'backup-$(date +%Y%m%d).db'"或者写成一个定时任务脚本:
#!/bin/bash BACKUP_DIR=/opt/flask-demo/backups mkdir -p $BACKUP_DIR sqlite3 /opt/flask-demo/notes.db ".backup '$BACKUP_DIR/notes-$(date +%Y%m%d-%H%M%S).db'" find $BACKUP_DIR -name "*.db" -mtime +30 -exec rm {} \;脚本先建备份目录,然后执行在线备份命令,最后清理 30 天前的旧备份。.backup是 SQLite 官方推荐的热备份方式,不会因为并发写入产生损坏文件,比cp命令靠谱得多。恢复就直接把备份文件复制回项目目录改名notes.db,重启 Gunicorn 即可。
5. 踩坑记录:SQLite 并发、明文密码与模板渲染的 5 个典型问题
5.1 现象:页面报sqlite3.OperationalError: database is locked
原因:SQLite 默认对写操作是串行的,但 Flask 的 debug 模式开启了多线程,两个线程同时写数据库时,后到的写操作会等待连接锁,超时后直接报错。我最初跑这个 Demo 时开了两个浏览器窗口同时点删除,马上就复现。
解决:给sqlite3.connect增加超时参数,并且减少事务持有时间:
g.db = sqlite3.connect(DATABASE, timeout=10) # 每个写操作后面立刻 commit db.execute(...) db.commit()timeout=10表示等待锁的最长时间是 10 秒,超过就报错而不是无限等待。养成每个写操作后立刻commit的习惯,不要在同一段代码里连续写数据却拖到最后才提交,锁持有时间越长,冲突概率越大。
5.2 现象:登录比对失败,明明密码输入对了
原因:注册时没有处理空格,用户在输入框末尾不小心带了一个空格,注册存进数据库的是"admin ",登录时输入的"admin"对比不上。这个纯属输入校验缺位。
解决:所有用户输入统一strip(),注册和登录都处理。代码里写两处:
username = request.form['username'].strip() password = request.form['password'] # 密码不要 strip,密码本身可能含空格注册和登录对 username 的处理保持一致。密码不 strip 是为了不改变用户原始输入,如果用户密码末尾真的有个空格,strip 掉会导致永远登不上。
5.3 现象:session每次重启就失效,登录状态保持不住
原因:Flask 的 session 是签名 Cookie,需要SECRET_KEY。这个 Demo 如果不设置,Flask 会抛RuntimeError: The session is unavailable because no secret key was set。有人会把密钥写死在代码里,但换一台机器跑或者改了密钥文件,所有已登录用户全部掉线。
解决:不要把密钥写死在源码里。用环境变量传入:
import os app.secret_key = os.environ.get('SECRET_KEY', 'dev-fallback-key')生产环境部署时用export SECRET_KEY='随机长字符串'设置。密钥一旦泄露,攻击者可以伪造 session 数据,所以不要用默认的 fallback。
5.4 现象:模板里{{ note['content'] }}报UndefinedError
原因:Jinja2 的模板语法里,note.content和note['content']都可以取字典值,但如果你传入的对象不是字典而是 SQLite Row,note['content']可能因为 Row 的底层实现不支持某种取值方式而报错。
解决:db.py里设置了row_factory = sqlite3.Row,Row 支持note['content']也支持note.content,不会报错。但如果其他代码把 Row 转成了普通元组再传给模板,就必须用note[2]这种方式,可读性会很差。保持row_factory的设置在全局生效即可,别在某个路由里覆盖它。
5.5 现象:删除笔记后浏览器地址栏刷新,笔记又回来了
原因:这是一个经典的设计错误。删除操作用了 GET 请求,浏览器地址栏刷新或者后退会重新发送 GET 请求,如果删除逻辑在 GET 里,就会重复执行删除。但这里出现的是另一种情况——删除用了 POST,但提交表单后浏览器刷新时会重发 POST,如果页面没有处理重复提交,可能触发第二次删除。
解决:所有修改数据的操作(增删改)必须用 POST 且配合redirect。删除后立刻redirect(url_for('index')),让浏览器跳转到列表页,地址栏变成/,刷新就只会请求列表页,不会重复删除。这个处理叫 Post/Redirect/Get 模式,是 Web 开发的基本功。
6. 二次开发思路:给笔记加一个标签分类功能
6.1 表结构扩展与关联查询
项目原版的笔记只有标题和内容两个字段,我在改造版本里加上了标签系统,作为二次开发的第一站。新增一张标签表和一张中间表:
CREATE TABLE tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE ); CREATE TABLE note_tags ( note_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, FOREIGN KEY (note_id) REFERENCES notes(id), FOREIGN KEY (tag_id) REFERENCES tags(id) );note_tags是多对多关联的中间表,一篇笔记可以打多个标签,一个标签可以挂在多个笔记下。查询某篇笔记的所有标签用一条 JOIN 搞定:
SELECT tags.name FROM tags JOIN note_tags ON note_tags.tag_id = tags.id WHERE note_tags.note_id = ?6.2 路由与模板的改动点
改造覆盖三个地方。第一,new_note路由接收request.form['tags'],按逗号分隔后循环处理:
tags = [t.strip() for t in request.form['tags'].split(',') if t.strip()] for tag_name in tags: tag = query_db('SELECT id FROM tags WHERE name = ?', (tag_name,), one=True) if tag is None: cur = db.execute('INSERT INTO tags (name) VALUES (?)', (tag_name,)) tag_id = cur.lastrowid else: tag_id = tag['id'] db.execute('INSERT INTO note_tags (note_id, tag_id) VALUES (?, ?)', (note_id, tag_id))第二,编辑页面需要把已有的标签显示在表单里,查询方式用上面那条 JOIN。第三,列表页的每篇笔记卡片下方加标签展示:
{% for tag in note.tags %} <span class="tag">{{ tag }}</span> {% endfor %}这里必须先在路由里把每篇笔记的标签查出来组合到 notes 对象里,无法在 Jinja2 里直接执行 SQL。
6.3 验证改造是否正确的测试思路
改完别急着点页面,先跑一轮手动测试:创建一篇带多个标签的笔记,检查数据库note_tags表;编辑笔记去掉一个标签,确认关联记录被删除;用同一个标签创建两篇笔记,在标签页点击能看到两篇。最后写一个最快路径的自动化断言:
import sqlite3 conn = sqlite3.connect('notes.db') tags = conn.execute('SELECT name FROM tags ORDER BY name').fetchall() assert any(t[0] == 'flask' for t in tags), '标签应存在' conn.close()我自己的血泪经验是——凡是加了中间表,第一件事先查一遍SELECT COUNT(*) FROM note_tags,看是不是每次操作都产生了预期的行数。多对多关系最容易出的问题就是重复插入关联记录,好的习惯是插入前先查一次是否已存在。从那次以后我每次改完数据库结构,都强制走一遍「查行数 → 查关联 → 删一条再看行数」的三步验证,再上页面手测。希望帮到你。
本文还有配套的精品资源,点击获取