1. 从一个 Flask 实例说起:为什么我不建议你直接抄官方文档
很多人第一次接触 Python Web 开发,都是从 Flask 开始的。原因很简单:它足够轻,几行代码就能跑起来一个能返回内容的服务。但问题也恰恰出在这里——官方文档里那个“Hello World”太简单了,简单到让人误以为 Flask 就这么点东西。等你真正想做一个稍微像样的项目,比如带表单提交、带数据库读写、带用户登录状态的小应用,就会发现到处是坑:路由为什么 404 了?模板里的变量怎么传?表单数据怎么拿?数据库连接什么时候关?
我写这篇文章的起因,是最近帮一个刚入行的朋友梳理他做的一个练习项目。他的需求很朴素:做一个简单的待办事项管理页面,能添加、能标记完成、能删除。他用 Flask 写,前后折腾了快一周,代码越写越乱,最后连自己都看不懂了。我看了他的代码,发现核心问题不是 Flask 难,而是他没有理解 Flask 的“请求-响应”模型,也没有把项目结构当回事,所有逻辑都堆在一个文件里,路由、数据库操作、模板渲染混在一起。
所以这篇博文,我打算用一个完整的、可复现的 Flask 实例,把从零搭建到跑通的全过程拆开来讲。这个实例不追求功能多复杂,但会覆盖一个真实项目最核心的几个环节:项目结构设计、路由与视图函数、模板渲染、表单处理、数据持久化、以及调试与排错。适合刚学完 Python 基础、想动手做一个 Web 小项目的朋友,也适合写过一点 Flask 但总觉得“不踏实”的开发者。我会把每一步为什么这么做、不这么做会出什么问题,都讲清楚。你跟着走一遍,至少能少踩我当年踩过的那些坑。
2. 项目整体设计与技术选型:为什么是 Flask,为什么这么组织
2.1 为什么选 Flask 而不是 Django 或 FastAPI
这个问题几乎每个新手都会问。我的回答一直很直接:看你要做什么,以及你想学什么。Django 是大而全,自带 ORM、Admin、认证、表单,适合快速做一个内容管理类系统,但它的学习曲线陡,很多“魔法”你一开始根本看不懂。FastAPI 是异步优先,性能好,适合做 API 服务,但它的生态和模板渲染能力相对弱一些,做传统的服务端渲染页面不是它的强项。
Flask 的定位是“微框架”,它只给你最核心的东西:路由、请求上下文、响应对象、模板引擎(Jinja2)。数据库、表单验证、用户认证这些,都需要你自己选型、自己组装。这看起来麻烦,但恰恰是学习 Web 开发最好的方式——你会清楚地知道每一个环节是怎么工作的,数据从哪里来、到哪里去。等你把 Flask 这套流程走通了,再去用 Django 或 FastAPI,你会发现它们只是帮你把某些环节自动化了,底层逻辑是一样的。
所以这个实例选 Flask,不是为了做多大的项目,而是为了把 Web 开发的核心链路走一遍。
2.2 项目结构:为什么不能把所有代码写在一个文件里
我见过太多新手把 Flask 项目写成一个app.py,里面塞了几百行代码。一开始还能跑,但一旦要加功能,就变成灾难。路由函数里混着数据库查询,模板里嵌着业务逻辑,改一个地方崩三个地方。这不是 Flask 的问题,是项目结构的问题。
一个最小但合理的 Flask 项目结构,我建议至少分成这几块:
app.py或run.py:应用入口,负责创建应用实例、注册蓝图、启动服务。models.py:数据模型定义,负责和数据库打交道。views.py或routes.py:视图函数,处理请求、调用模型、返回响应。templates/:Jinja2 模板文件,负责页面渲染。static/:静态文件,CSS、JS、图片等。config.py:配置文件,数据库连接串、密钥等。
这个结构不是 Flask 强制的,但它符合“关注点分离”的原则。视图函数只关心“收到什么请求、返回什么响应”,数据操作交给模型,页面展示交给模板。这样你改数据库不影响页面,改页面不影响业务逻辑。对于这个待办事项实例,我会用这个结构来组织代码。
2.3 数据库选型:为什么用 SQLite 而不是 MySQL
对于练习项目,我强烈建议用 SQLite。原因有三:第一,它不需要单独安装数据库服务,Python 标准库自带sqlite3,Flask 生态里也有 SQLAlchemy 可以直接用;第二,它就是一个文件,复制、备份、删除都很方便,不会因为数据库服务没启动而报错;第三,它的 SQL 语法和 MySQL、PostgreSQL 基本一致,你学会了 SQLite,换到其他数据库只需要改连接串。
当然,SQLite 不适合高并发写入的场景,但对于一个待办事项应用,完全够用。等你需要部署到生产环境,再换成 PostgreSQL 或 MySQL,代码改动量很小。
2.4 模板引擎:为什么用 Jinja2 而不是前端框架
这个实例的目标是服务端渲染,也就是页面在服务器端生成好 HTML 再返回给浏览器。Jinja2 是 Flask 默认的模板引擎,语法简单,支持变量替换、条件判断、循环、模板继承。你不需要写 JavaScript 就能做出动态页面。对于新手来说,这比前后端分离更容易理解整个请求链路。
当然,如果你要做复杂的交互,比如实时更新、拖拽排序,那还是需要前端框架。但那是下一步的事。先把服务端渲染搞明白,再学前端框架,你会更清楚数据是怎么流动的。
3. 核心细节解析与实操要点:从路由到模板的完整链路
3.1 路由与视图函数:URL 是怎么找到对应代码的
Flask 的核心是路由。你用@app.route('/')装饰器把一个 URL 和一个函数绑定起来。当用户访问这个 URL 时,Flask 就会调用对应的函数,并把函数的返回值作为响应内容返回给浏览器。
这里有几个关键点新手容易搞混:
第一,路由的路径是相对于应用根路径的。比如@app.route('/todo')对应的是http://localhost:5000/todo,不是http://localhost:5000/app/todo。如果你用了蓝图(Blueprint),可以给蓝图加前缀,但那是进阶用法。
第二,视图函数的返回值可以是字符串、元组、Response 对象,或者用render_template渲染的模板。返回字符串时,Flask 默认把 Content-Type 设为text/html,所以你可以直接返回 HTML 片段。但更好的做法是用模板,把 HTML 和 Python 代码分开。
第三,HTTP 方法默认只允许 GET。如果你的视图要处理表单提交,必须显式声明methods=['GET', 'POST']。否则浏览器发 POST 请求时,Flask 会返回 405 Method Not Allowed。
第四,URL 变量。比如@app.route('/todo/<int:todo_id>'),这里的<int:todo_id>会把 URL 里的数字捕获成整数,传给视图函数的todo_id参数。如果你写<todo_id>,默认是字符串。这个机制在删除、编辑单条记录时非常有用。
我见过一个常见的错误:视图函数名重复。Flask 要求每个视图函数名唯一,因为它是用函数名来生成 URL 的(url_for)。如果你有两个函数都叫index,Flask 会报错。所以命名要清晰,比如list_todos、add_todo、delete_todo。
3.2 请求上下文:request 对象到底怎么用
在视图函数里,你可以直接访问request对象,它包含了当前请求的所有信息:表单数据、查询参数、请求头、Cookie、文件上传等。这个request不是全局变量,而是“请求上下文”的一部分。Flask 用上下文变量(ContextVar)来实现,保证每个请求的request是独立的,不会串。
常用的几个属性:
request.method:请求方法,GET 或 POST。request.form:POST 表单数据,是一个类似字典的对象。request.args:URL 查询参数,比如?page=2。request.files:上传的文件。request.json:如果请求体是 JSON,可以直接解析成字典。
新手最容易犯的错是:在视图函数外面访问request。比如你写一个工具函数,想在里面读request.form,但如果没有把请求上下文传进去,就会报RuntimeError: Working outside of request context。解决办法是把request作为参数传进去,或者用flask.current_app和flask.request的代理对象,但后者也需要在应用上下文中。
还有一个坑:request.form和request.args都是ImmutableMultiDict,同名 key 可以有多个值。如果你用request.form['key'],当 key 不存在时会抛KeyError。更安全的做法是用request.form.get('key', default_value)。
3.3 模板渲染:Jinja2 的变量、循环和继承
Jinja2 的语法用两个大括号{{ }}表示变量,用{% %}表示控制结构。比如:
{% for todo in todos %} <li>{{ todo.title }}</li> {% endfor %}这里todos是从视图函数传给模板的变量,通常是一个列表。todo.title是对象的属性,Jinja2 会自动调用 Python 的属性访问或字典键访问。
模板继承是 Jinja2 最强大的功能之一。你可以定义一个base.html,里面放页头、导航、页脚,然后其他模板用{% extends "base.html" %}继承它,用{% block content %}{% endblock %}填充内容。这样你改一次页头,所有页面都生效。
新手常问:模板里能不能写 Python 代码?答案是:能写一点,但不建议。Jinja2 支持简单的表达式和过滤器,比如{{ name|upper }}把名字转大写,{{ items|length }}取长度。但复杂的逻辑应该放在视图函数里,模板只负责展示。否则你会在模板里写一堆业务逻辑,最后没法维护。
还有一个细节:Jinja2 默认会自动转义 HTML 特殊字符,防止 XSS 攻击。如果你确实要渲染 HTML,比如富文本内容,需要用|safe过滤器。但一定要确保内容是你信任的,否则会有安全风险。
3.4 数据库操作:SQLAlchemy 的基本用法和常见陷阱
Flask 本身不包含 ORM,但 Flask-SQLAlchemy 是最常用的扩展。它把 SQLAlchemy 集成到 Flask 中,让你用 Python 类来定义表,用对象操作来增删改查。
定义一个模型:
from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() class Todo(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(100), nullable=False) done = db.Column(db.Boolean, default=False)这里db.Model是基类,db.Column定义字段。primary_key=True表示主键,nullable=False表示不能为空,default=False表示默认值。
查询数据:
todos = Todo.query.all() # 查所有 todo = Todo.query.get(todo_id) # 按主键查新增数据:
new_todo = Todo(title='学习 Flask') db.session.add(new_todo) db.session.commit()删除数据:
db.session.delete(todo) db.session.commit()几个常见的坑:
第一,忘记db.session.commit()。SQLAlchemy 的 session 是工作单元模式,你 add 或 delete 之后,数据还在内存里,必须 commit 才会写入数据库。如果你只 add 不 commit,刷新页面发现数据没变,就是这个原因。
第二,在视图函数外面操作数据库。和request一样,数据库操作也需要应用上下文。如果你在脚本里直接调用Todo.query.all(),会报RuntimeError: Working outside of application context。解决办法是用with app.app_context():包裹。
第三,数据库迁移。如果你改了模型字段,比如加了一列,直接运行会报错,因为表结构没变。开发阶段可以删掉数据库文件重新创建,但生产环境需要用 Flask-Migrate 做迁移。这个实例为了简单,每次启动时用db.create_all()创建表,如果表已存在就跳过。
4. 实操过程与核心环节实现:一步步跑通待办事项应用
4.1 环境准备与依赖安装
先确认你的 Python 版本。Flask 2.x 需要 Python 3.7 以上,我建议用 3.9 或更高。你可以用python --version查看。
然后创建一个虚拟环境,这是好习惯,避免污染全局包:
python -m venv venv source venv/bin/activate # Linux/macOS venv\Scripts\activate # Windows安装依赖:
pip install flask flask-sqlalchemy如果你需要表单验证,可以加flask-wtf,但这个实例为了简单,直接用request.form手动处理。
4.2 项目骨架搭建
按照前面的结构,创建目录和文件:
mkdir todo-flask cd todo-flask mkdir templates static touch app.py models.py views.py config.pyconfig.py内容:
import os class Config: SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-key-change-in-production' SQLALCHEMY_DATABASE_URI = 'sqlite:///todo.db' SQLALCHEMY_TRACK_MODIFICATIONS = False这里SECRET_KEY用于会话加密和 CSRF 保护,开发阶段可以用默认值,但上线前一定要改成随机字符串。SQLALCHEMY_DATABASE_URI指定数据库文件路径,sqlite:///todo.db表示当前目录下的todo.db文件。SQLALCHEMY_TRACK_MODIFICATIONS设为 False 可以关闭不必要的信号追踪,减少内存开销。
4.3 模型定义与数据库初始化
models.py:
from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() class Todo(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(100), nullable=False) done = db.Column(db.Boolean, default=False) def __repr__(self): return f'<Todo {self.id}: {self.title}>'app.py:
from flask import Flask from config import Config from models import db def create_app(): app = Flask(__name__) app.config.from_object(Config) db.init_app(app) with app.app_context(): db.create_all() return app if __name__ == '__main__': app = create_app() app.run(debug=True)这里用工厂函数create_app创建应用实例,方便测试和扩展。db.init_app(app)把数据库和 app 绑定。with app.app_context(): db.create_all()在应用上下文中创建表。如果todo.db不存在,会自动创建。
4.4 视图函数与路由实现
views.py:
from flask import Blueprint, render_template, request, redirect, url_for from models import db, Todo bp = Blueprint('todo', __name__) @bp.route('/') def index(): todos = Todo.query.order_by(Todo.id.desc()).all() return render_template('index.html', todos=todos) @bp.route('/add', methods=['POST']) def add(): title = request.form.get('title', '').strip() if title: new_todo = Todo(title=title) db.session.add(new_todo) db.session.commit() return redirect(url_for('todo.index')) @bp.route('/toggle/<int:todo_id>') def toggle(todo_id): todo = Todo.query.get_or_404(todo_id) todo.done = not todo.done db.session.commit() return redirect(url_for('todo.index')) @bp.route('/delete/<int:todo_id>') def delete(todo_id): todo = Todo.query.get_or_404(todo_id) db.session.delete(todo) db.session.commit() return redirect(url_for('todo.index'))这里用蓝图Blueprint组织路由,url_for('todo.index')生成 URL。get_or_404在找不到记录时自动返回 404 页面,比手动判断更简洁。
然后在app.py里注册蓝图:
from views import bp app.register_blueprint(bp)4.5 模板编写与页面渲染
templates/base.html:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>待办事项</title> <style> body { font-family: sans-serif; max-width: 600px; margin: 40px auto; } .todo { display: flex; align-items: center; margin: 8px 0; } .done { text-decoration: line-through; color: #999; } .actions { margin-left: auto; } </style> </head> <body> {% block content %}{% endblock %} </body> </html>templates/index.html:
{% extends "base.html" %} {% block content %} <h1>待办事项</h1> <form action="{{ url_for('todo.add') }}" method="post"> <input type="text" name="title" placeholder="输入新任务" required> <button type="submit">添加</button> </form> <ul> {% for todo in todos %} <li class="todo"> <span class="{% if todo.done %}done{% endif %}">{{ todo.title }}</span> <span class="actions"> <a href="{{ url_for('todo.toggle', todo_id=todo.id) }}"> {% if todo.done %}撤销{% else %}完成{% endif %} </a> <a href="{{ url_for('todo.delete', todo_id=todo.id) }}">删除</a> </span> </li> {% else %} <li>暂无任务</li> {% endfor %} </ul> {% endblock %}这里{% for ... %}{% else %}{% endfor %}是 Jinja2 的特性,当列表为空时执行 else 分支。url_for根据视图函数名和参数生成 URL,比硬编码更安全。
4.6 运行与验证
启动应用:
python app.py访问http://127.0.0.1:5000,你应该能看到待办事项页面。试着添加几条任务,点击完成、撤销、删除,确认功能正常。如果页面报错,看终端输出的堆栈信息,Flask 的 debug 模式会显示详细的错误页面。
5. 常见问题与排查技巧实录:那些年我踩过的坑
5.1 启动报错:ModuleNotFoundError
最常见的原因是依赖没装,或者虚拟环境没激活。先确认pip list里有 Flask 和 Flask-SQLAlchemy。如果是在 IDE 里运行,检查解释器是不是选到了虚拟环境。另一个可能是文件名冲突,比如你把文件命名为flask.py,Python 会优先导入你的文件而不是 Flask 库,导致ImportError。改个名字就好。
5.2 数据库报错:no such table
这个错误通常是因为db.create_all()没有执行,或者执行时数据库文件路径不对。检查SQLALCHEMY_DATABASE_URI的路径,sqlite:///todo.db是相对路径,相对于应用实例的根目录。如果你在子目录里运行,可能会创建到别的地方。可以在create_app里打印app.instance_path确认。另外,如果你改了模型字段,旧的表结构不会自动更新,需要删掉todo.db重新创建。
5.3 表单提交后页面没变化
先检查表单的method是不是post,视图函数的methods参数有没有包含POST。然后看request.form.get('title')是不是拿到了值。如果title为空,可能是 input 的name属性写错了,或者表单没有正确提交。还有一个隐蔽的坑:如果你用了 CSRF 保护(Flask-WTF),但没在表单里加csrf_token,提交会被拒绝。这个实例没用 CSRF,所以不会遇到,但生产环境一定要加。
5.4 模板报错:UndefinedError
Jinja2 在变量未定义时会报UndefinedError。比如你在模板里用了{{ todo.title }},但todo是None。解决办法是在视图函数里确保传给模板的变量都有值,或者在模板里用{% if todo %}判断。另一个常见原因是变量名拼写错误,比如视图里传的是todos,模板里写成了todo_list。仔细核对变量名。
5.5 静态文件 404
Flask 默认从static/目录提供静态文件,URL 前缀是/static。如果你把 CSS 放在static/css/style.css,模板里应该用{{ url_for('static', filename='css/style.css') }}。如果你改了静态文件目录,需要在创建 app 时指定static_folder参数。另外,浏览器缓存可能导致你改了 CSS 但页面没变,强制刷新(Ctrl+F5)试试。
5.6 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 启动报 ModuleNotFoundError | 依赖未安装或虚拟环境未激活 | 激活虚拟环境,pip install flask flask-sqlalchemy |
| 数据库 no such table | 未执行 create_all 或路径错误 | 检查 URI,确保在 app context 中调用 create_all |
| 表单提交无反应 | method 或 methods 配置错误 | 表单 method="post",视图 methods=['GET','POST'] |
| 模板 UndefinedError | 变量未传或拼写错误 | 检查视图 render_template 参数和模板变量名 |
| 静态文件 404 | 路径错误或缓存 | 用 url_for 生成路径,强制刷新浏览器 |
| 405 Method Not Allowed | 视图未声明 POST | 添加 methods=['POST'] |
| 500 Internal Server Error | 代码异常 | 开启 debug 模式查看堆栈 |
5.7 独家避坑技巧
第一,开发阶段一定要开debug=True。它会自动重载代码,修改后不用手动重启,而且出错时会显示详细的调试页面,包括变量值和堆栈信息。但上线前一定要关掉,否则会泄露敏感信息。
第二,数据库操作尽量放在视图函数里,不要放在模板里。模板只负责展示,不负责查询。如果你在模板里调用Todo.query.all(),不仅逻辑混乱,还可能因为上下文问题报错。
第三,用url_for而不是硬编码 URL。这样你改路由路径时,所有链接自动更新,不会出现死链。
第四,提交表单后用redirect而不是直接render_template。这是“Post/Redirect/Get”模式,防止用户刷新页面时重复提交表单。你添加完任务后刷新,不会又加一条。
第五,给数据库字段加索引。如果待办事项很多,按done字段查询会慢。可以在模型里加index=True,比如done = db.Column(db.Boolean, default=False, index=True)。不过对于小项目,这不是必须的。
6. 从实例到项目:后续可以怎么扩展
这个实例虽然简单,但它是一个完整的闭环:请求进来,视图处理,模型读写,模板渲染,响应返回。你把这个链路搞清楚了,后面加功能就是在这个骨架上挂东西。
比如你想加用户系统,可以引入 Flask-Login,给 Todo 模型加一个user_id外键,视图函数里用current_user过滤数据。你想加分类标签,可以再加一个 Tag 模型,和 Todo 做多对多关联。你想做 API,可以用 Flask-RESTful 或直接返回jsonify的结果。你想部署上线,可以用 Gunicorn 或 uWSGI 作为 WSGI 服务器,Nginx 做反向代理,数据库换成 PostgreSQL。
但不管怎么扩展,核心逻辑不变:路由找到视图,视图操作模型,模型读写数据库,模板渲染页面。你把这个实例吃透了,后面学什么框架、做什么项目,都会觉得有迹可循。
我个人在实际操作中的体会是,Flask 的“简单”是一种假象,它的简单在于核心概念少,但要把这些概念组合成一个可维护的项目,需要你对项目结构、请求生命周期、数据库会话有清晰的认识。我见过太多人卡在“能跑但不敢改”的阶段,根源就是没有理解这些底层机制。希望这个实例能帮你跨过这个阶段。