Flask 完整入门教程
本教程基于 Flask 3.x + Flask-SQLAlchemy 3.x + Python 3.10+ 编写。如使用更早版本,部分 API(如 db.session.get、Query.get)行为可能不同。
每一节都有完整可运行的代码,照着敲一遍就能掌握。
目录
- 1. Flask 是什么
- 2. 环境搭建
- 3. 第一个 Flask 程序
- 4. 路由详解
- 5. HTTP 请求方法
- 6. request 对象:获取请求数据
- 7. 返回响应
- 8. 模板渲染 Jinja2
- 9. 模板继承
- 10. 静态文件
- 11. url_for 与重定向
- 12. Cookie 与 Session
- 13. 表单处理 Flask-WTF
- 14. 数据库 Flask-SQLAlchemy
- 15. 蓝图 Blueprint
- 16. 错误处理
- 17. 请求钩子
- 18. 实战项目:待办事项应用
- 19. 项目部署
- 20. 学习路线与常见问题
1. Flask 是什么
Flask 是 Python 的一个轻量级 Web 框架。
什么是 Web 框架? 简单说,你想做一个网站,需要处理"用户访问网址 → 返回网页内容"这件事。框架帮你把底层网络通信、路由匹配这些繁琐的事都做好了,你只需要写业务逻辑。
Flask 的特点:
| 轻量 | 核心代码很少,只做最基本的事 |
| 灵活 | 想用什么组件自己选(数据库、表单库等) |
| 易学 | 几行代码就能跑起一个网站 |
| 生态好 | 有大量官方扩展(Flask-SQLAlchemy、Flask-Login 等) |
Flask vs Django:
- Django:大而全,自带后台管理、ORM、模板等,适合大型项目。
- Flask:小而美,自由组合,适合中小项目和快速入门。
⬆ 返回目录
2. 环境搭建
2.1 确认 Python 已安装
打开命令行(Windows 是 CMD/PowerShell,Mac/Linux 是终端),输入:
python –version
如果显示 Python 3.10.x 以上就 OK。如果没有,去 python.org 下载安装,安装时勾选 Add Python to PATH。
2.2 创建虚拟环境(强烈推荐)
虚拟环境可以让你每个项目的依赖互不干扰。
# 1. 创建一个项目文件夹
mkdir myflask
cd myflask
# 2. 创建虚拟环境
python -m venv venv
# 3. 激活虚拟环境
# Windows:
venv\\Scripts\\activate
# Mac / Linux:
source venv/bin/activate
激活成功后,命令行前面会出现 (venv) 字样。
⚠️ 每次重新打开命令行,都要重新激活虚拟环境。
2.3 安装 Flask
pip install flask
验证:
python -c "import flask; print(flask.__version__)"
能看到版本号(应为 3.x)就成功了。
⬆ 返回目录
3. 第一个 Flask 程序
新建文件 app.py:
from flask import Flask
# 创建 Flask 应用实例
# __name__ 告诉 Flask 当前模块的名字,Flask 靠它找到模板和静态文件的位置
app = Flask(__name__)
# 装饰器:把 URL "/" 和下面的函数绑定起来
@app.route('/')
def index():
return '你好,Flask!这是我的第一个网站'
# 只有当直接运行这个文件时才启动服务器
if __name__ == '__main__':
app.run(debug=True)
运行:
python app.py
你会看到输出:
* Running on http://127.0.0.1:5000
打开浏览器访问 http://127.0.0.1:5000,就能看到「你好,Flask!」。
关键概念解释
① app = Flask(__name__) 创建一个 Flask 应用对象,相当于"建了一个网站"。
② @app.route('/') 这叫装饰器,作用是:当用户访问 / 这个网址时,就执行下面的函数。这个函数叫做视图函数(view function)。
③ return '…' 视图函数必须返回内容,Flask 会把它作为 HTTP 响应发给浏览器。
④ debug=True 调试模式,好处:
- 改代码后自动重启服务器,不用手动重启
- 出错时浏览器显示详细错误信息
⚠️ 上线时一定要关掉 debug,否则有安全风险。
⬆ 返回目录
4. 路由详解
路由就是"网址"和"处理函数"之间的对应关系。
4.1 基本路由
@app.route('/')
def index():
return '首页'
@app.route('/about')
def about():
return '关于我们'
@app.route('/contact')
def contact():
return '联系方式'
访问 /about 就执行 about() 函数。
💡 提示:Python 里函数名不能重复。但同一个函数可以绑定多个路由(见下方示例);反过来,多个不同的函数也可以绑定不同的 URL。
@app.route('/')
@app.route('/home')
@app.route('/index')
def home():
return '多个网址都能访问到我'
4.2 动态路由(URL 变量)
有时候网址里带有参数,比如用户 ID、文章标题。
@app.route('/user/<username>')
def show_user(username):
return f'你好,{username}!'
- 访问 /user/zhangsan → 显示「你好,zhangsan!」
- 访问 /user/lisi → 显示「你好,lisi!」
4.3 路由转换器(限定参数类型)
默认参数是字符串。可以指定类型:
| string | 字符串(默认),不能含 / | <name> |
| int | 整数 | <int:id> |
| float | 浮点数 | <float:price> |
| path | 字符串,可以含 / | <path:filepath> |
| uuid | UUID 字符串 | <uuid:uid> |
@app.route('/post/<int:post_id>')
def show_post(post_id):
# post_id 已经是 int 类型了,可以直接运算
return f'这是第 {post_id} 篇文章,下一篇是第 {post_id + 1} 篇'
访问 /post/100 ✅ 正常 访问 /post/abc ❌ 404 错误(因为 abc 不是整数)
4.4 路由的斜杠规则
@app.route('/test/') # 结尾有斜杠
def test1():
return 'test1'
- 访问 /test/ ✅
- 访问 /test ✅(Flask 会自动重定向到 /test/)
@app.route('/test2') # 结尾没斜杠
def test2():
return 'test2'
- 访问 /test2 ✅
- 访问 /test2/ ❌ 404
💡 建议:统一风格,要么都带斜杠,要么都不带。
⬆ 返回目录
5. HTTP 请求方法
浏览器访问网页时,会使用不同的"方法":
| GET | 获取数据 | 最常见,参数在 URL 里 |
| POST | 提交数据 | 表单提交、上传文件 |
| PUT | 更新数据 | 一般用于 API |
| DELETE | 删除数据 | 一般用于 API |
默认情况下,@app.route() 只响应 GET 请求。
from flask import request
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
return '你提交了表单(POST)'
else:
return '你正在查看登录页(GET)'
⚠️ 如果只写 methods=['POST'],那么用浏览器直接访问(GET)就会返回 405 错误。 ⚠️ 修改数据的操作(新增、更新、删除)都应该用 POST/PUT/DELETE,不要用 GET,否则会被搜索引擎爬虫、浏览器预取误触发。
⬆ 返回目录
6. request 对象:获取请求数据
request 是 Flask 提供的一个全局对象,包含了本次请求的所有信息。使用前要导入:
from flask import request
6.1 获取 URL 查询参数(GET 参数)
网址:/search?keyword=python&page=2
@app.route('/search')
def search():
keyword = request.args.get('keyword') # 'python'
page = request.args.get('page', '1') # '2',默认值 '1'
page_int = request.args.get('page', 1, type=int) # 转成 int:2
return f'搜索关键词:{keyword},第 {page} 页'
要点:
- request.args.get('key') 获取参数
- 第二个参数是默认值,找不到时返回它
- type=int 可以自动转换类型
6.2 获取表单数据(POST 参数)
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
return f'注册成功!用户名:{username}'
# GET 请求时返回一个简单的 HTML 表单
return '''
<form method="post">
<input type="text" name="username" placeholder="用户名">
<input type="password" name="password" placeholder="密码">
<button type="submit">注册</button>
</form>
'''
request.args 和 request.form 的区别:
| 数据来源 | URL 问号后面的参数 | POST 请求体 |
| 方法 | GET | POST |
| 例子 | /search?q=abc | 表单提交 |
6.3 获取 JSON 数据(用于 API)
@app.route('/api/user', methods=['POST'])
def create_user():
data = request.get_json() # 解析 JSON 请求体
name = data.get('name')
age = data.get('age')
return f'收到:{name}, {age} 岁'
用 curl 测试:
curl -X POST http://127.0.0.1:5000/api/user \\
-H "Content-Type: application/json" \\
-d '{"name":"小明","age":18}'
6.4 上传文件
import os
from werkzeug.utils import secure_filename
@app.route('/upload', methods=['GET', 'POST'])
def upload():
if request.method == 'POST':
file = request.files.get('myfile')
if file and file.filename:
# secure_filename 防止恶意文件名(如 ../../etc/passwd)
filename = secure_filename(file.filename)
os.makedirs('uploads', exist_ok=True) # 确保目录存在
file.save(os.path.join('uploads', filename))
return f'上传成功:{filename}'
return '''
<form method="post" enctype="multipart/form-data">
<input type="file" name="myfile">
<button type="submit">上传</button>
</form>
'''
⚠️ 表单必须加 enctype="multipart/form-data",否则文件传不上去。
6.5 其他常用属性
@app.route('/info')
def info():
return {
'method': request.method, # 请求方法
'url': request.url, # 完整 URL
'path': request.path, # 路径部分
'host': request.host, # 主机名
'headers': dict(request.headers), # 请求头
'ip': request.remote_addr, # 客户端 IP
}
💡 返回字典时,Flask 会自动把它转成 JSON。
⬆ 返回目录
7. 返回响应
7.1 返回字符串 / HTML
@app.route('/text')
def text():
return '纯文本'
@app.route('/html')
def html():
return '<h1>标题</h1><p>段落</p>'
7.2 返回 JSON
from flask import jsonify
@app.route('/api/data')
def api_data():
return jsonify({
'code': 200,
'message': 'success',
'data': [1, 2, 3]
})
💡 直接 return {'a': 1} 也可以,Flask 会自动调用 jsonify。
7.3 自定义状态码和响应头
from flask import make_response
@app.route('/custom')
def custom():
# 方式一:返回元组 (内容, 状态码, 响应头)
return '创建成功', 201, {'X-My-Header': 'hello'}
# 方式二:使用 make_response
# resp = make_response('创建成功', 201)
# resp.headers['X-My-Header'] = 'hello'
# return resp
常用状态码:
| 200 | 成功 |
| 201 | 创建成功 |
| 301/302 | 重定向 |
| 400 | 请求参数错误 |
| 401 | 未登录 |
| 403 | 无权限 |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
⬆ 返回目录
8. 模板渲染 Jinja2
前面我们把 HTML 直接写在 Python 字符串里,很不优雅。Flask 使用 Jinja2 模板引擎 来解决这个问题。
8.1 目录结构
Flask 默认约定:
项目文件夹/
├── app.py
└── templates/ ← 模板文件夹(必须叫这个名字)
└── index.html
8.2 第一个模板
templates/index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{{ title }}</title>
</head>
<body>
<h1>你好,{{ name }}!</h1>
<p>欢迎来到我的网站</p>
</body>
</html>
app.py:
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def index():
# 把变量传给模板
return render_template('index.html', title='首页', name='小明')
if __name__ == '__main__':
app.run(debug=True)
要点:
- render_template('文件名', 变量=值, …) 渲染模板
- 模板里用 {{ 变量名 }} 显示变量
8.3 Jinja2 常用语法
① 变量
<p>{{ name }}</p>
<p>{{ user.age }}</p> <!– 访问属性 –>
<p>{{ user['name'] }}</p> <!– 访问字典 –>
<p>{{ items[0] }}</p> <!– 访问列表 –>
② 判断
{% if score >= 90 %}
<p>优秀</p>
{% elif score >= 60 %}
<p>及格</p>
{% else %}
<p>不及格</p>
{% endif %}
③ 循环
<ul>
{% for item in items %}
<li>{{ loop.index }}. {{ item }}</li> <!– loop.index 从 1 开始 –>
{% endfor %}
</ul>
循环中可用的变量:
- loop.index:当前序号(从 1 开始)
- loop.index0:当前序号(从 0 开始)
- loop.first:是否第一个
- loop.last:是否最后一个
- loop.length:总长度
④ 过滤器
用 | 调用过滤器,用来格式化变量:
<p>{{ name|upper }}</p> <!– 转大写 –>
<p>{{ name|lower }}</p> <!– 转小写 –>
<p>{{ name|length }}</p> <!– 长度 –>
<p>{{ price|round(2) }}</p> <!– 保留 2 位小数 –>
<p>{{ text|default('暂无') }}</p> <!– 默认值 –>
<p>{{ html_content|safe }}</p> <!– 不转义 HTML(谨慎使用) –>
<p>{{ content|truncate(50) }}</p> <!– 截断 –>
<p>{{ nums|sum }}</p> <!– 求和 –>
<p>{{ nums|max }}</p> <!– 最大值 –>
⚠️ 默认情况下 Jinja2 会转义 HTML,防止 XSS 攻击。如果确实需要渲染 HTML,用 |safe,但要确保内容可信。
⑤ 注释
{# 这是注释,不会出现在最终 HTML 里 #}
8.4 完整示例
app.py:
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def index():
user = {'name': '小明', 'age': 18}
scores = [95, 88, 72, 60, 45]
return render_template('index.html', user=user, scores=scores)
if __name__ == '__main__':
app.run(debug=True)
templates/index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>成绩单</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 40px auto; }
.pass { color: green; }
.fail { color: red; }
</style>
</head>
<body>
<h1>{{ user.name }} 的成绩单</h1>
<p>年龄:{{ user.age }}</p>
<ul>
{% for score in scores %}
<li class="{{ 'pass' if score >= 60 else 'fail' }}">
{{ loop.index }}. {{ score }} 分
{% if score >= 90 %}(优秀){% endif %}
</li>
{% endfor %}
</ul>
<p>总分:{{ scores|sum }}</p>
<p>最高分:{{ scores|max }}</p>
<p>平均分:{{ ((scores|sum) / (scores|length))|round(1) }}</p>
</body>
</html>
💡 注意最后一行:Jinja2 中过滤器的优先级高于算术运算,所以 scores|sum / scores|length 的括号一定要写清楚,避免歧义。
⬆ 返回目录
9. 模板继承
多个页面往往有相同的头部、导航、底部。Jinja2 的模板继承可以避免重复代码。
9.1 创建基础模板
templates/base.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{% block title %}我的网站{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<nav>
<a href="{{ url_for('index') }}">首页</a>
<a href="{{ url_for('about') }}">关于</a>
</nav>
<main>
{% block content %}
<!– 子模板会覆盖这里的内容 –>
{% endblock %}
</main>
<footer>
<p>© 2025 我的网站</p>
</footer>
{% block script %}{% endblock %}
</body>
</html>
9.2 子模板继承
templates/index.html:
{% extends 'base.html' %}
{% block title %}首页 – {{ super() }}{% endblock %}
{% block content %}
<h1>欢迎来到首页</h1>
<p>这里是首页内容</p>
{% endblock %}
templates/about.html:
{% extends 'base.html' %}
{% block title %}关于我们{% endblock %}
{% block content %}
<h1>关于我们</h1>
<p>我们是一个专注 Flask 教程的团队</p>
{% endblock %}
要点:
- {% extends 'base.html' %} 必须放在第一行
- {% block 名字 %} 定义可替换的区域
- {{ super() }} 表示保留父模板中该块的内容
⬆ 返回目录
10. 静态文件
CSS、JavaScript、图片等不需要动态渲染的文件叫静态文件。
Flask 默认约定放在 static 文件夹:
项目文件夹/
├── app.py
├── static/
│ ├── style.css
│ ├── main.js
│ └── images/
│ └── logo.png
└── templates/
└── index.html
10.1 在模板中引用
<!– CSS –>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<!– JS –>
<script src="{{ url_for('static', filename='main.js') }}"></script>
<!– 图片 –>
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="logo">
💡 用 url_for('static', filename='…') 而不是直接写 /static/…,这样路径更灵活可靠。
static/style.css 示例:
body {
font-family: -apple-system, "Microsoft YaHei", sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
nav {
background: #333;
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
}
nav a {
color: #fff;
text-decoration: none;
margin-right: 16px;
}
nav a:hover {
text-decoration: underline;
}
⬆ 返回目录
11. url_for 与重定向
11.1 url_for:根据函数名生成 URL
from flask import url_for
@app.route('/')
def index():
return '首页'
@app.route('/user/<int:user_id>')
def user_profile(user_id):
return f'用户 {user_id}'
在视图函数里使用(最常见,直接用即可):
@app.route('/demo')
def demo():
home_url = url_for('index') # /
user_url = url_for('user_profile', user_id=5) # /user/5
return f'首页:{home_url},用户页:{user_url}'
💡 只有脱离请求上下文的场景(比如单元测试)才需要用 app.test_request_context() 包裹。日常写视图函数时,url_for 可以直接用。
在模板里使用:
<a href="{{ url_for('index') }}">首页</a>
<a href="{{ url_for('user_profile', user_id=5) }}">用户5</a>
为什么要用 url_for?
- 如果以后改了路由地址,用 url_for 的地方会自动跟着变
- 自动处理特殊字符转义
- 处理静态文件路径
11.2 重定向 redirect
from flask import redirect, url_for
@app.route('/old')
def old_page():
return redirect(url_for('index')) # 重定向到首页
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
# 处理登录逻辑…
return redirect(url_for('dashboard'))
return render_template('login.html')
@app.route('/dashboard')
def dashboard():
return '这是仪表盘页面'
💡 Post/Redirect/Get 模式:表单提交成功后应该重定向,而不是直接返回页面。这样用户刷新页面时不会重复提交表单。
11.3 abort:主动中断请求
from flask import abort
@app.route('/admin')
def admin():
if not is_admin():
abort(403) # 返回 403 无权限
return '管理员页面'
@app.route('/post/<int:post_id>')
def post(post_id):
post = find_post(post_id)
if post is None:
abort(404) # 返回 404 不存在
return render_template('post.html', post=post)
⬆ 返回目录
12. Cookie 与 Session
HTTP 是无状态协议,服务器默认记不住你是谁。Cookie 和 Session 用来解决这个问题。
| 存储位置 | 浏览器 | 服务器(Flask 默认是加密后存在 Cookie 里) |
| 安全性 | 低(用户可见可改) | 较高 |
| 容量 | 小(约 4KB) | 大 |
| 用途 | 记住偏好设置 | 登录状态 |
12.1 使用 Session
必须先设置密钥:
from flask import Flask, session
app = Flask(__name__)
app.secret_key = 'a-very-secret-key-please-change-it'
⚠️ secret_key 用来加密 session 数据,一定要随机且保密。生产环境应该从环境变量读取。
常用操作:
from flask import session, redirect, url_for, request, render_template
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username')
# 实际项目中应该验证密码
if username == 'admin':
session['username'] = username # 写入 session
session['role'] = 'admin'
return redirect(url_for('profile'))
return '用户名错误'
return render_template('login.html')
@app.route('/profile')
def profile():
# 读取 session
if 'username' not in session:
return redirect(url_for('login'))
return f'欢迎回来,{session["username"]}!'
@app.route('/logout')
def logout():
session.clear() # 清空所有 session
# 或删除单个:session.pop('username', None)
return redirect(url_for('login'))
Session 常用方法:
session['key'] = 'value' # 设置
value = session.get('key') # 获取(推荐,不会报 KeyError)
session['key'] # 获取(不存在会报错)
session.pop('key', None) # 删除
session.clear() # 清空
'key' in session # 判断是否存在
session.permanent = True # 设为持久化
12.2 设置 Session 有效期
要让下面的配置生效,必须同时把 session.permanent 设为 True:
from datetime import timedelta
app.permanent_session_lifetime = timedelta(days=7) # 7 天
@app.route('/login')
def login():
session.permanent = True # ⚠️ 必须设置,否则上面的有效期配置不生效
session['username'] = 'admin'
...
💡 如果 session.permanent 为 False(默认),session 会在浏览器关闭时过期,app.permanent_session_lifetime 不起作用。
12.3 使用 Cookie(简单场景)
from flask import make_response, request
@app.route('/set_cookie')
def set_cookie():
resp = make_response('Cookie 已设置')
resp.set_cookie('theme', 'dark', max_age=60*60*24*30) # 30 天
return resp
@app.route('/get_cookie')
def get_cookie():
theme = request.cookies.get('theme', 'light')
return f'当前主题:{theme}'
💡 建议:登录状态用 Session,用户偏好(主题、语言)用 Cookie。
⬆ 返回目录
13. 表单处理 Flask-WTF
原生表单处理比较麻烦(要手动验证、防 CSRF)。Flask-WTF 让这一切变得简单。
13.1 安装
pip install flask-wtf
13.2 定义表单类
from flask_wtf import FlaskForm
from wtforms import (
StringField, PasswordField, TextAreaField, SelectField,
SubmitField, IntegerField,
)
from wtforms.validators import DataRequired, Length, Email, EqualTo, NumberRange
class RegisterForm(FlaskForm):
username = StringField('用户名', validators=[
DataRequired(message='用户名不能为空'),
Length(min=3, max=20, message='用户名长度需在 3-20 之间')
])
email = StringField('邮箱', validators=[
DataRequired(message='邮箱不能为空'),
Email(message='邮箱格式不正确')
])
age = IntegerField('年龄', validators=[
NumberRange(min=1, max=120, message='年龄需在 1-120 之间')
])
gender = SelectField('性别', choices=[
('male', '男'),
('female', '女'),
('other', '其他')
])
bio = TextAreaField('个人简介', validators=[
Length(max=500, message='简介不能超过 500 字')
])
password = PasswordField('密码', validators=[
DataRequired(message='密码不能为空'),
Length(min=6, message='密码至少 6 位')
])
password2 = PasswordField('确认密码', validators=[
DataRequired(),
EqualTo('password', message='两次密码不一致')
])
submit = SubmitField('注册')
常用验证器:
| DataRequired() | 不能为空 |
| Length(min, max) | 长度范围 |
| Email() | 邮箱格式 |
| EqualTo('field') | 与另一个字段相同 |
| NumberRange(min, max) | 数值范围 |
| Regexp(pattern) | 正则匹配 |
| URL() | URL 格式 |
13.3 在视图里使用
from flask import Flask, render_template, redirect, url_for, flash
app = Flask(__name__)
app.secret_key = 'your-secret-key'
@app.route('/register', methods=['GET', 'POST'])
def register():
form = RegisterForm()
# validate_on_submit() = 是 POST 请求 且 验证通过
if form.validate_on_submit():
username = form.username.data
email = form.email.data
age = form.age.data
# 这里应该保存到数据库
flash(f'注册成功!欢迎 {username}', 'success')
return redirect(url_for('register'))
# 验证失败时,form.errors 里会有错误信息
return render_template('register.html', form=form)
if __name__ == '__main__':
app.run(debug=True)
13.4 模板
templates/register.html:
{% extends 'base.html' %}
{% block content %}
<h1>用户注册</h1>
<!– flash 消息显示。category 就是 flash() 的第二个参数,直接拼到 class 上 –>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="post">
<!– CSRF 令牌,必须加,否则提交会失败 –>
{{ form.hidden_tag() }}
<p>
{{ form.username.label }}<br>
{{ form.username(size=30) }}
{% for error in form.username.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</p>
<p>
{{ form.email.label }}<br>
{{ form.email(size=30) }}
{% for error in form.email.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</p>
<p>
{{ form.age.label }}<br>
{{ form.age() }}
{% for error in form.age.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</p>
<p>
{{ form.gender.label }}<br>
{{ form.gender() }}
</p>
<p>
{{ form.bio.label }}<br>
{{ form.bio(rows=4, cols=40) }}
{% for error in form.bio.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</p>
<p>
{{ form.password.label }}<br>
{{ form.password() }}
{% for error in form.password.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</p>
<p>
{{ form.password2.label }}<br>
{{ form.password2() }}
{% for error in form.password2.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</p>
<p>{{ form.submit() }}</p>
</form>
{% endblock %}
13.5 flash 消息
flash() 用来显示一次性提示消息(如"登录成功"):
flash('操作成功', 'success') # 第二个参数是分类
flash('操作失败', 'error')
模板里读取:
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
💡 flash 依赖 session,所以必须设置 secret_key。
⬆ 返回目录
14. 数据库 Flask-SQLAlchemy
14.1 安装
pip install flask-sqlalchemy
14.2 基本配置
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
import os
app = Flask(__name__)
# SQLite 数据库文件会保存在当前目录下
basedir = os.path.abspath(os.path.dirname(__file__))
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'app.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
常见数据库连接字符串:
# SQLite(最简单,适合开发)
'sqlite:///app.db'
# MySQL(需要 pip install pymysql)
'mysql+pymysql://用户名:密码@localhost:3306/数据库名'
# PostgreSQL(需要 pip install psycopg2-binary)
'postgresql://用户名:密码@localhost:5432/数据库名'
14.3 定义模型
模型就是 Python 类,对应数据库中的表。
from datetime import datetime, timezone
class User(db.Model):
__tablename__ = 'users' # 表名,不写默认是类名小写
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
username = db.Column(db.String(50), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password = db.Column(db.String(200), nullable=False)
age = db.Column(db.Integer, default=0)
is_active = db.Column(db.Boolean, default=True)
# ⚠️ Python 3.12+ 弃用了 datetime.utcnow(),这里用带时区的写法
created_at = db.Column(
db.DateTime,
default=lambda: datetime.now(timezone.utc)
)
# 一对多关系:一个用户有多篇文章
# lazy 参数应为字符串('select'/'joined'/'dynamic'),默认就是 'select'
posts = db.relationship('Post', backref='author', lazy='select')
def __repr__(self):
return f'<User {self.username}>'
class Post(db.Model):
__tablename__ = 'posts'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text)
created_at = db.Column(
db.DateTime,
default=lambda: datetime.now(timezone.utc)
)
# 外键,指向 users 表的 id
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
def __repr__(self):
return f'<Post {self.title}>'
常用字段类型:
| Integer | 整数 |
| String(n) | 字符串,最大长度 n |
| Text | 长文本 |
| Boolean | 布尔值 |
| Float | 浮点数 |
| DateTime | 日期时间 |
| Date | 日期 |
常用选项:
| primary_key=True | 主键 |
| autoincrement=True | 自增 |
| unique=True | 值唯一 |
| nullable=False | 不能为空 |
| default=值 | 默认值 |
| index=True | 创建索引 |
14.4 创建数据库表
if __name__ == '__main__':
with app.app_context():
db.create_all() # 创建所有表(已存在则跳过)
app.run(debug=True)
⚠️ Flask 2.3+ 中 db.create_all() 必须在应用上下文里执行。 create_all() 只会新建表,不会修改已有表结构。改字段要删掉 app.db 重建,或用 Flask-Migrate 做迁移。 💡 一旦项目启用 Flask-Migrate,就不要再使用 db.create_all(),否则迁移系统会因为"表已存在"而混乱。
14.5 增删改查(CRUD)
💡 统一使用 db.session.get(Model, pk)。Query.get() 在 Flask-SQLAlchemy 3.x 中已弃用,会触发 LegacyAPIWarning。
新增
@app.route('/add_user', methods=['POST']) # 修改数据应该用 POST
def add_user():
user = User(username='小明', email='xm@example.com', password='123456', age=18)
db.session.add(user) # 添加到会话
db.session.commit() # 提交到数据库
return f'用户已添加,ID = {user.id}'
批量添加:
users = [
User(username='张三', email='zs@example.com', password='123'),
User(username='李四', email='ls@example.com', password='123'),
]
db.session.add_all(users)
db.session.commit()
查询
# 1. 查询所有
users = User.query.all()
# 2. 按主键查询(推荐新写法)
user = db.session.get(User, 1)
# 3. 查询第一条
user = User.query.first()
# 4. 条件查询
user = User.query.filter_by(username='小明').first()
users = User.query.filter(User.age > 18).all()
users = User.query.filter(User.age >= 18, User.is_active == True).all()
# 5. 模糊查询
users = User.query.filter(User.username.like('%小%')).all()
users = User.query.filter(User.username.contains('小')).all()
# 6. 排序
users = User.query.order_by(User.age.desc()).all() # 降序
users = User.query.order_by(User.age.asc()).all() # 升序
# 7. 分页
page = User.query.paginate(page=1, per_page=10, error_out=False)
# page.items 当前页数据
# page.total 总数
# page.pages 总页数
# page.has_next() / page.has_prev()
# 8. 计数
count = User.query.count()
# 9. 复杂条件(or_ / and_ / in_)
from sqlalchemy import or_, and_, not_
users = User.query.filter(or_(User.age < 18, User.age > 60)).all()
users = User.query.filter(User.id.in_([1, 2, 3])).all()
更新
@app.route('/update_user/<int:user_id>', methods=['POST'])
def update_user(user_id):
user = db.session.get(User, user_id)
if not user:
return '用户不存在', 404
user.age = 20
user.email = 'new@example.com'
db.session.commit() # 提交修改
return '更新成功'
删除
@app.route('/delete_user/<int:user_id>', methods=['POST'])
def delete_user(user_id):
user = db.session.get(User, user_id)
if not user:
return '用户不存在', 404
db.session.delete(user)
db.session.commit()
return '删除成功'
14.6 关系操作
# 一对多:给用户添加文章
user = db.session.get(User, 1)
post = Post(title='我的第一篇文章', content='内容…', author=user)
db.session.add(post)
db.session.commit()
# 或者
post = Post(title='第二篇', content='…', user_id=user.id)
db.session.add(post)
db.session.commit()
# 查询用户的文章
user = db.session.get(User, 1)
for post in user.posts:
print(post.title)
# 查询文章的作者
post = db.session.get(Post, 1)
print(post.author.username)
14.7 事务与错误处理
@app.route('/transfer', methods=['POST'])
def transfer():
try:
# 一系列数据库操作
user1 = db.session.get(User, 1)
user1.age += 1
db.session.commit()
except Exception as e:
db.session.rollback() # 出错回滚
return f'操作失败:{e}', 500
return '操作成功'
14.8 Flask-Migrate(数据库迁移)
开发中表结构会变化,create_all() 无法修改已有表,这时用 Flask-Migrate:
pip install flask-migrate
from flask_migrate import Migrate
db = SQLAlchemy(app)
migrate = Migrate(app, db)
初始化(只需一次):
flask db init
每次修改模型后:
flask db migrate -m "添加了 xxx 字段"
flask db upgrade
⬆ 返回目录
15. 蓝图 Blueprint
当项目变大时,把所有路由写在一个文件里会很难维护。蓝图可以把路由分组到不同文件。
15.1 项目结构
myproject/
├── app.py
├── models.py
├── config.py
├── extensions.py
├── templates/
│ ├── base.html
│ ├── index.html
│ └── blog/
│ └── list.html
├── static/
└── blueprints/
├── __init__.py
├── main.py
├── auth.py
└── blog.py
15.2 创建蓝图
blueprints/main.py:
from flask import Blueprint, render_template
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
def index():
return render_template('index.html')
@main_bp.route('/about')
def about():
return '关于我们'
blueprints/auth.py:
from flask import Blueprint, render_template, request, redirect, url_for, session
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
session['username'] = request.form.get('username')
return redirect(url_for('main.index'))
return render_template('auth/login.html')
@auth_bp.route('/logout')
def logout():
session.clear()
return redirect(url_for('main.index'))
参数说明:
- 第一个参数 'auth':蓝图的名字,用于 url_for('auth.login')
- url_prefix='/auth':该蓝图下所有路由都加前缀
- template_folder:可指定模板目录(可选)
15.3 注册蓝图
app.py:
from flask import Flask
from blueprints.main import main_bp
from blueprints.auth import auth_bp
from blueprints.blog import blog_bp
app = Flask(__name__)
app.secret_key = 'your-secret-key'
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(blog_bp)
if __name__ == '__main__':
app.run(debug=True)
15.4 url_for 的变化
使用蓝图后,url_for 要加蓝图名前缀:
url_for('main.index') # 主蓝图的 index
url_for('auth.login') # auth 蓝图的 login
url_for('blog.post', post_id=1)
模板里也一样:
<a href="{{ url_for('main.index') }}">首页</a>
<a href="{{ url_for('auth.login') }}">登录</a>
15.5 使用工厂模式(推荐)
大型项目常用应用工厂模式,便于测试和配置切换。
extensions.py:
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
db = SQLAlchemy()
migrate = Migrate()
app.py:
from flask import Flask
from extensions import db, migrate
def create_app(config_name='development'):
app = Flask(__name__)
# 配置
app.config['SECRET_KEY'] = 'your-secret-key'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# 初始化扩展
db.init_app(app)
migrate.init_app(app, db)
# 注册蓝图
from blueprints.main import main_bp
from blueprints.auth import auth_bp
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
return app
if __name__ == '__main__':
app = create_app()
app.run(debug=True)
⬆ 返回目录
16. 错误处理
16.1 自定义错误页面
from werkzeug.exceptions import HTTPException
@app.errorhandler(404)
def page_not_found(error):
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_error(error):
db.session.rollback() # 出错时回滚数据库
return render_template('errors/500.html'), 500
@app.errorhandler(403)
def forbidden(error):
return render_template('errors/403.html'), 403
templates/errors/404.html:
{% extends 'base.html' %}
{% block content %}
<div style="text-align: center; padding: 60px 0;">
<h1 style="font-size: 80px; margin: 0;">404</h1>
<p>页面走丢了~</p>
<a href="{{ url_for('main.index') }}">返回首页</a>
</div>
{% endblock %}
16.2 捕获所有异常
from werkzeug.exceptions import HTTPException
@app.errorhandler(Exception)
def handle_exception(e):
# 如果是 HTTP 异常,交给对应的处理器
if isinstance(e, HTTPException):
return e
# 记录日志
app.logger.error(f'未处理的异常:{e}')
return render_template('errors/500.html'), 500
⚠️ HTTPException 必须从 werkzeug.exceptions 导入,否则会报 NameError。
16.3 使用日志
import logging
from logging.handlers import RotatingFileHandler
if not app.debug:
handler = RotatingFileHandler('app.log', maxBytes=1024*1024, backupCount=10)
handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s %(levelname)s: %(message)s')
handler.setFormatter(formatter)
app.logger.addHandler(handler)
# 使用
app.logger.info('用户登录成功')
app.logger.error('数据库连接失败')
⬆ 返回目录
17. 请求钩子
钩子函数可以在请求的不同阶段自动执行。
@app.before_request
def before_request():
"""每个请求处理之前执行"""
# 常用于:记录日志、检查登录状态、打开数据库连接
print(f'收到请求:{request.method} {request.path}')
@app.after_request
def after_request(response):
"""每个请求处理之后执行(必须返回 response)"""
# 常用于:添加响应头、压缩响应
response.headers['X-Powered-By'] = 'Flask Tutorial'
return response
@app.teardown_request
def teardown_request(exception):
"""请求结束后执行,无论是否出错"""
# 常用于:关闭连接、清理资源
pass
⚠️ Flask 2.3+ 移除了 before_first_request。替代方案:在 create_app() 里直接执行初始化代码。
实用例子:全局登录检查
@app.before_request
def require_login():
# 不存在的路由交给 Flask 的 404 处理,不要在这里拦
if request.endpoint is None:
return
# 白名单:不需要登录的 endpoint
allowed = {'auth.login', 'auth.register', 'main.index', 'static'}
if request.endpoint in allowed:
return
if 'username' not in session:
# 带上 next 参数,登录后可以跳回原页面
return redirect(url_for('auth.login', next=request.path))
登录视图里配合使用:
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
# … 验证逻辑 …
session['username'] = 'admin'
next_page = request.args.get('next') or url_for('main.index')
return redirect(next_page)
return render_template('login.html')
⬆ 返回目录
18. 实战项目:待办事项应用
我们把学到的知识综合起来,做一个完整的待办事项(Todo)应用。
18.1 功能
- 查看所有待办事项
- 添加新的待办
- 标记完成/未完成
- 删除待办
- 数据保存在 SQLite 数据库
18.2 项目结构
todo_app/
├── app.py
├── templates/
│ ├── base.html
│ ├── index.html
│ └── errors/
│ └── 404.html
└── static/
└── style.css
18.3 完整代码
app.py:
import os
from datetime import datetime, timezone
from flask import Flask, render_template, request, redirect, url_for, flash
from flask_sqlalchemy import SQLAlchemy
# ———- 初始化 ———-
basedir = os.path.abspath(os.path.dirname(__file__))
app = Flask(__name__)
app.config['SECRET_KEY'] = 'dev-secret-key-change-in-production'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'todo.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
# ———- 模型 ———-
class Todo(db.Model):
__tablename__ = 'todos'
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.String(200), nullable=False)
done = db.Column(db.Boolean, default=False)
created_at = db.Column(
db.DateTime,
default=lambda: datetime.now(timezone.utc)
)
def __repr__(self):
return f'<Todo {self.id} {self.content}>'
# ———- 路由 ———-
@app.route('/')
def index():
"""首页:显示所有待办"""
# 未完成的排在前面,然后按创建时间倒序
todos = Todo.query.order_by(Todo.done.asc(), Todo.created_at.desc()).all()
total = len(todos)
done_count = sum(1 for t in todos if t.done)
return render_template('index.html',
todos=todos,
total=total,
done_count=done_count)
@app.route('/add', methods=['POST'])
def add():
"""添加待办"""
content = request.form.get('content', '').strip()
if not content:
flash('内容不能为空', 'error')
elif len(content) > 200:
flash('内容不能超过 200 字', 'error')
else:
todo = Todo(content=content)
db.session.add(todo)
db.session.commit()
flash('添加成功', 'success')
return redirect(url_for('index'))
@app.route('/toggle/<int:todo_id>', methods=['POST'])
def toggle(todo_id):
"""切换完成状态"""
todo = db.session.get(Todo, todo_id)
if not todo:
flash('待办不存在', 'error')
else:
todo.done = not todo.done
db.session.commit()
return redirect(url_for('index'))
@app.route('/delete/<int:todo_id>', methods=['POST'])
def delete(todo_id):
"""删除待办"""
todo = db.session.get(Todo, todo_id)
if not todo:
flash('待办不存在', 'error')
else:
db.session.delete(todo)
db.session.commit()
flash('删除成功', 'success')
return redirect(url_for('index'))
@app.route('/clear_done', methods=['POST'])
def clear_done():
"""清空已完成"""
Todo.query.filter_by(done=True).delete()
db.session.commit()
flash('已清空所有完成的待办', 'success')
return redirect(url_for('index'))
# ———- 错误处理 ———-
@app.errorhandler(404)
def not_found(e):
return render_template('errors/404.html'), 404
# ———- 启动 ———-
if __name__ == '__main__':
with app.app_context():
db.create_all()
app.run(debug=True)
templates/base.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}我的待办{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="container">
<header>
<h1>📝 我的待办清单</h1>
</header>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<main>
{% block content %}{% endblock %}
</main>
</div>
</body>
</html>
templates/index.html:
{% extends 'base.html' %}
{% block content %}
<!– 添加表单 –>
<form action="{{ url_for('add') }}" method="post" class="add-form">
<input type="text" name="content" placeholder="今天要做什么?" maxlength="200" autofocus>
<button type="submit">添加</button>
</form>
<!– 统计信息 –>
<div class="stats">
<span>共 {{ total }} 项</span>
<span>已完成 {{ done_count }} 项</span>
{% if total > 0 %}
<span>完成率 {{ (done_count / total * 100)|round(0)|int }}%</span>
{% endif %}
</div>
<!– 待办列表 –>
{% if todos %}
<ul class="todo-list">
{% for todo in todos %}
<li class="todo-item {% if todo.done %}done{% endif %}">
<!– 切换状态改成 POST 表单提交 –>
<form action="{{ url_for('toggle', todo_id=todo.id) }}" method="post" class="inline">
<button type="submit" class="check" title="切换完成状态">
{% if todo.done %}✅{% else %}⬜{% endif %}
</button>
</form>
<span class="content">{{ todo.content }}</span>
<span class="time">{{ todo.created_at.strftime('%m-%d %H:%M') }}</span>
<!– 删除也改成 POST 表单 –>
<form action="{{ url_for('delete', todo_id=todo.id) }}" method="post" class="inline"
onsubmit="return confirm('确定删除吗?');">
<button type="submit" class="delete" title="删除">🗑️</button>
</form>
</li>
{% endfor %}
</ul>
{% if done_count > 0 %}
<div class="actions">
<form action="{{ url_for('clear_done') }}" method="post"
onsubmit="return confirm('确定清空所有已完成的待办吗?');">
<button type="submit" class="link-btn">清空已完成</button>
</form>
</div>
{% endif %}
{% else %}
<div class="empty">
<p>还没有待办事项</p>
<p>在上面的输入框里添加第一条吧!</p>
</div>
{% endif %}
{% endblock %}
templates/errors/404.html:
{% extends 'base.html' %}
{% block content %}
<div class="empty">
<h1 style="font-size: 64px; color: #ddd; margin: 0;">404</h1>
<p>页面走丢了~</p>
<a href="{{ url_for('index') }}">返回首页</a>
</div>
{% endblock %}
static/style.css:
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "Microsoft YaHei", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 640px;
margin: 0 auto;
background: #fff;
border-radius: 16px;
padding: 32px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}
header h1 {
font-size: 24px;
color: #333;
margin-bottom: 24px;
text-align: center;
}
/* 添加表单 */
.add-form {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.add-form input {
flex: 1;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 15px;
outline: none;
transition: border-color 0.2s;
}
.add-form input:focus { border-color: #667eea; }
.add-form button {
padding: 12px 24px;
background: #667eea;
color: #fff;
border: none;
border-radius: 10px;
font-size: 15px;
cursor: pointer;
transition: background 0.2s;
}
.add-form button:hover { background: #5568d3; }
/* 统计 */
.stats {
display: flex;
gap: 16px;
font-size: 13px;
color: #888;
padding: 0 4px 16px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 16px;
}
/* 列表 */
.todo-list { list-style: none; }
.todo-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 8px;
border-bottom: 1px solid #f5f5f5;
transition: background 0.15s;
}
.todo-item:hover { background: #fafafa; }
.todo-item .check {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
padding: 0;
}
.todo-item .content { flex: 1; font-size: 15px; color: #333; }
.todo-item.done .content {
text-decoration: line-through;
color: #bbb;
}
.todo-item .time { font-size: 12px; color: #ccc; }
.todo-item .delete {
background: none;
border: none;
cursor: pointer;
opacity: 0.3;
transition: opacity 0.2s;
font-size: 16px;
}
.todo-item .delete:hover { opacity: 1; }
/* 内联表单,让按钮和文字同行 */
form.inline {
display: inline;
margin: 0;
padding: 0;
}
/* 操作 */
.actions {
text-align: center;
margin-top: 20px;
}
.actions .link-btn {
background: none;
border: none;
color: #999;
font-size: 13px;
cursor: pointer;
}
.actions .link-btn:hover { color: #f56c6c; text-decoration: underline; }
/* 空状态 */
.empty {
text-align: center;
padding: 60px 20px;
color: #bbb;
}
.empty p:first-child { font-size: 18px; margin-bottom: 8px; }
/* 提示消息 */
.alert {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 14px;
}
.alert-success { background: #f0f9eb; color: #67c23a; }
.alert-error { background: #fef0f0; color: #f56c6c; }
18.4 运行
python app.py
访问 http://127.0.0.1:5000,一个完整的待办应用就跑起来了!
⬆ 返回目录
19. 项目部署
开发完成后,需要部署到服务器上让别人访问。
19.1 生产环境注意事项
| debug | True | False |
| SECRET_KEY | 硬编码 | 从环境变量读取 |
| 服务器 | Flask 内置 | Gunicorn / uWSGI |
| 数据库 | SQLite | MySQL / PostgreSQL |
| HTTPS | 无 | 必须 |
19.2 用环境变量管理配置
import os
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-fallback-key')
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///app.db')
app.config['DEBUG'] = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'
Linux 下设置环境变量:
export SECRET_KEY="your-random-secret-key"
export DATABASE_URL="mysql+pymysql://user:pass@localhost/dbname"
生成随机密钥:
python -c "import secrets; print(secrets.token_hex(32))"
19.3 用 Gunicorn 部署(Linux)
pip install gunicorn
情况 A:非工厂模式(第 3-18 节的写法)
app.py 里已经有一个现成的 app 对象,直接:
# wsgi.py
from app import app
情况 B:工厂模式(第 15.5 节的写法)
app.py 里只有 create_app,没有 app 变量,需要:
# wsgi.py
from app import create_app
app = create_app()
⚠️ 千万别混用,否则会报 ImportError: cannot import name 'app' from 'app'。
启动:
# 4 个 worker 进程,监听 8000 端口
gunicorn -w 4 -b 0.0.0.0:8000 wsgi:app
19.4 用 Nginx 做反向代理
server {
listen 80;
server_name yourdomain.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;
}
location /static {
alias /path/to/your/app/static;
expires 30d;
}
}
19.5 用 systemd 守护进程
创建 /etc/systemd/system/myflask.service:
[Unit]
Description=Flask App
After=network.target
[Service]
User=www-data
WorkingDirectory=/path/to/your/app
Environment="SECRET_KEY=your-key"
ExecStart=/path/to/venv/bin/gunicorn -w 4 -b 127.0.0.1:8000 wsgi:app
Restart=always
[Install]
WantedBy=multi-user.target
启动:
sudo systemctl daemon-reload
sudo systemctl start myflask
sudo systemctl enable myflask
19.6 免费平台部署
不想折腾服务器?可以用这些平台:
- PythonAnywhere:免费额度,适合学习
- Render:免费层,支持自动部署
- Vercel / Railway / Fly.io:现代 PaaS,体验好
⬆ 返回目录
20. 学习路线与常见问题
20.1 推荐学习路线
第 1 周:基础
├── 路由、视图函数
├── request / response
└── Jinja2 模板
第 2 周:进阶
├── 表单处理 Flask-WTF
├── 数据库 Flask-SQLAlchemy
└── 蓝图 Blueprint
第 3 周:完善
├── 用户认证 Flask-Login
├── 数据库迁移 Flask-Migrate
└── 错误处理与日志
第 4 周:实战
├── 做一个完整项目(博客 / 商城)
└── 部署上线
20.2 常用扩展清单
| Flask-SQLAlchemy | 数据库 ORM |
| Flask-Migrate | 数据库迁移 |
| Flask-WTF | 表单和验证 |
| Flask-Login | 用户登录管理 |
| Flask-Mail | 发送邮件 |
| Flask-Caching | 缓存 |
| Flask-CORS | 跨域支持 |
| Flask-RESTful / Flask-Smorest | 构建 REST API |
| Flask-Limiter | 限流 |
| Flask-Admin | 后台管理 |
20.3 常见问题
Q1:改了代码不生效? 确保 debug=True,或者手动重启服务器。
Q2:TemplateNotFound 错误? 检查模板文件是否在 templates/ 文件夹(名字必须是这个),路径大小写是否一致。
Q3:RuntimeError: Working outside of application context? 在应用上下文之外调用了 db 等对象。解决:
with app.app_context():
db.create_all()
Q4:KeyError: 'SECRET_KEY' 或 session 不工作? 设置 app.secret_key = '随便一串字符'。
Q5:端口 5000 被占用(Mac 常见)?
python app.py –port 5001
# 或在代码里
app.run(debug=True, port=5001)
Mac 上 5000 端口被 AirPlay 占用,可以在「系统设置 → 通用 → 隔空投送与接力」里关闭「隔空播放接收器」。
Q6:表单提交报 CSRF token missing? Flask-WTF 表单里要加 {{ form.hidden_tag() }}。
Q7:修改模型后数据库没变化? db.create_all() 不会修改已有表。开发阶段直接删除 .db 文件重建,生产环境用 Flask-Migrate。
Q8:Query.get() 报警告? Flask-SQLAlchemy 3.x 已弃用 Query.get(),改用 db.session.get(Model, pk)。
Q9:datetime.utcnow() 报警告? Python 3.12+ 弃用了 datetime.utcnow(),用 lambda: datetime.now(timezone.utc) 替代。
Q10:如何返回中文不乱码? 确保 HTML 里有 <meta charset="UTF-8">,Python 文件保存为 UTF-8 编码。
20.4 调试技巧
① 使用 Flask Shell
flask shell
>>> from app import db, User
>>> User.query.all()
② 打印调试
app.logger.info(f'用户 ID: {user_id}')
print(request.args) # 调试时临时用
③ 用 Postman / curl 测试 API
curl http://127.0.0.1:5000/api/users
curl -X POST http://127.0.0.1:5000/api/users \\
-H "Content-Type: application/json" \\
-d '{"name":"test"}'
④ 查看路由列表
with app.app_context():
for rule in app.url_map.iter_rules():
print(f'{rule.endpoint:20s} {rule.methods} {rule}')
⬆ 返回目录
总结
恭喜你完成了这份 Flask 教程!回顾一下你学到的:
✅ Flask 基础:路由、视图函数、请求与响应 ✅ 模板:Jinja2 语法、模板继承、静态文件 ✅ 会话管理:Cookie、Session、Flash 消息 ✅ 表单:Flask-WTF 定义、验证、渲染 ✅ 数据库:SQLAlchemy 模型、增删改查、关系 ✅ 工程化:蓝图、工厂模式、错误处理、钩子 ✅ 实战:完整的待办应用 ✅ 部署:Gunicorn + Nginx + systemd
下一步建议:
最重要的:多写代码。 看十遍不如自己敲一遍。遇到报错不要怕,报错信息就是最好的老师。
祝你学习顺利!🚀




