一个人做个人网站(二):后端骨架——把 Flask 写成一个"小框架"
上一篇交代了整体架构。从这一篇开始,我们钻进代码。
先说一个很多人对 Flask 的误解:它"什么都没有",所以项目容易写成一坨。我的体会恰好相反——正因为什么都没有,你才被迫提前想清楚那几件每个项目都绕不开的事:应用怎么组装、接口返回长什么样、路由怎么分层、配置从哪来、表结构怎么演进。想清楚这五件事,一个几十上百个接口的小站,代码也能保持清爽。
这一篇就讲我给「小欢喜」后端立的五条规矩。代码不多,但每一条都在后面所有模块里反复生效。
一、应用工厂:组装过程只有四步,一眼能看完
入口文件 run.py 只有三行有效代码:
from app import create_app
app = create_app()
if __name__ == '__main__':
app.run(host='127.0.0.1', port=5001, debug=True)
真正的组装逻辑在 app/__init__.py 的工厂函数里。它只做四件事:
def create_app():
app = Flask(__name__)
app.config.from_object(get_config()) # 1. 装配置
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
db.init_app(app) # 2. 初始化扩展
cors.init_app(app, resources={r'/api/*': {'origins': app.config['CORS_ORIGINS']}},
supports_credentials=True)
from . import models # 3. 先导模型,再注册蓝图
from .api import register_blueprints
register_blueprints(app)
with app.app_context():
db.create_all() # 4. 启动即建表
@app.errorhandler(404) # 外加:全局错误收口
def not_found(_err):
return jsonify({'code': 404, 'message': '资源不存在', 'data': None}), 404
# ... 413、500 同理
return app
为什么用工厂而不是在包顶层直接 app = Flask(__name__)?两个实际好处:
- 测试和脚本可以各自创建独立 app。比如
seed.py灌示例数据时自己create_app(),不必借用一个全局实例; - 创建时机可控。配置、扩展、蓝图的装配顺序显式可见,不会出现"导入某个模块时 app 还没建好"的玄学问题。
注意第 3 步的顺序:先 import models,再注册蓝图,最后 create_all()。SQLAlchemy 必须先看到所有模型类的定义,才知道要建哪些表——漏了导入,那张表就会"安静地不存在",直到第一个请求打过来才报错。所以我在 models/__init__.py 里把全部模型集中导出,这一行导入就是建表的完整清单。
二、扩展实例单独放一个文件,治循环导入
Flask-SQLAlchemy 的典型坑是这样的:A 文件创建 db = SQLAlchemy(app),B 文件的模型要 from A import db,而 A 又要导入 B 注册东西——循环依赖。
标准解法是把"扩展实例"和"应用实例"彻底分离,单独一个 extensions.py:
# app/extensions.py:只创建实例,不绑定 app
from flask_sqlalchemy import SQLAlchemy
from flask_cors import CORS
db = SQLAlchemy()
cors = CORS()
模型文件只管 from ..extensions import db,完全不关心 app 在哪。绑定动作推迟到工厂里的 db.init_app(app)。同样的原则用在装饰器里:utils/auth.py 需要查 User,但不在文件顶部导入,而是放进函数体内:
def login_required(view_func):
@functools.wraps(view_func)
def wrapper(*args, **kwargs):
...
# 延迟导入,避免循环依赖
from ..extensions import db
user = db.session.get(User, payload.get('sub'))
...
这条规矩只有一句话:实例定义不依赖 app,依赖 app 的动作都放进工厂或请求生命周期里。
三、统一响应信封:所有接口都说同一种语言
整个后端最划算的一个决定,是第一天就定死 API 的返回结构:
{ "code": 0, "message": "success", "data": { } }
成功 code=0,失败时 code 等于 HTTP 状态码(400/401/404…),data 为 null。配套三个工具函数放在 utils/responses.py:
def ok(data=None, message='success'):
return jsonify({'code': 0, 'message': message, 'data': data})
def fail(message='请求失败', code=400):
return jsonify({'code': code, 'message': message, 'data': None}), code
def paginate(query, page, per_page, serializer):
page = _safe_int(page, 1)
per_page = min(_safe_int(per_page, 10), 50) # 单页最多 50,防止有人传 99999
total = query.count()
items = query.offset((page - 1) * per_page).limit(per_page).all()
return ok({
'list': [serializer(item) for item in items],
'total': total, 'page': page, 'per_page': per_page,
'pages': (total + per_page - 1) // per_page,
})
它带来的统一性贯穿全栈。后端任何一个视图函数,不管多复杂,出口只有两种:return ok(...) 或 return fail(...);列表接口全部走 paginate,返回的字段名(list/total/page/pages)全站一致。分页参数的解析也集中在这里——page=-3、per_page=abc 这种非法输入静默回退默认值,而不是抛 500。
前端因此可以写一个极简的 axios 封装,响应拦截器直接把信封拆掉:
// frontend/src/api/http.js
http.interceptors.response.use(
(response) => response.data, // 业务代码拿到的直接就是 {code,message,data}
(error) => {
const status = error.response?.status
const message = error.response?.data?.message || error.message || '网络异常'
const wrapped = new Error(message)
wrapped.status = status
if (status === 401) { // 401 统一清登录态、跳登录页
localStorage.removeItem('site_token')
...
}
return Promise.reject(wrapped)
},
)
于是页面里的调用长这样:const { data } = await http.get('/posts', { params }),失败一律走 catch 且错误消息可以直接弹给用户看。业务代码里再也找不到一处手写的 res.data.data.code 判断。
另外注意 401 拦截里有个小分支:宠物聊天接口用的是独立的解锁 token,它的 401 不能把后台登录态清掉——所以拦截器按 URL 区分了处理方式。统一收口不等于一刀切,收口的位置要留得出例外的口子。
四、蓝图双轨制:每个业务域天然分成"前台"和"后台"
这个站的接口有鲜明的两副面孔:博客、相册、视频这些模块,访客只读,管理员增删改。如果把它们混在一个蓝图里用 if 判断权限,很快就会乱。
我的约定是:每个业务域出两个蓝图——
# api/posts.py
public_bp = Blueprint('posts_public', __name__, url_prefix='/api')
admin_bp = Blueprint('posts_admin', __name__, url_prefix='/api/admin')
public_bp:GET /api/posts、GET /api/posts/<slug>,谁都能访问;admin_bp:POST/PUT/DELETE /api/admin/posts...,每个视图挂@login_required。
权限策略因此变成了 URL 前缀级别的约定:/api/admin/* 即"需要登录",一眼可审。所有蓝图在一个地方集中注册:
# api/__init__.py
def register_blueprints(app):
for bp in (
auth_bp,
posts_public_bp, posts_admin_bp,
gallery_public_bp, gallery_admin_bp,
videos_public_bp, videos_admin_bp,
# ... 其余业务域
upload_bp, oss_direct_bp, pet_bp, seo_bp,
):
app.register_blueprint(bp)
鉴权本身是一个装饰器,逻辑也很直白:从 Authorization: Bearer <token> 取 JWT,解码、验过期、验用户,通过后把用户挂到 g.current_user:
@admin_bp.put('/posts/<int:post_id>')
@login_required
def update_post(post_id):
post = db.session.get(Post, post_id)
if not post:
return fail('文章不存在', 404)
...
这里还埋了一个"主动踢下线"机制:JWT 原本签发后 7 天内无法作废,但改了密码怎么办?我在站点配置表里存一个 admin_tokens_before 时间戳,签发时间早于它的 token 一律视为失效,改密码即刻全端登出。登录、验证码、防爆破的完整设计是第 3 篇的内容。
五、模型层:序列化是模型自己的事
模型只描述数据和"它如何变成字典",不掺和 HTTP。以文章为例:
class Post(db.Model):
__tablename__ = 'posts'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
slug = db.Column(db.String(200), unique=True, nullable=False, index=True)
status = db.Column(db.String(20), default='published', index=True) # published/draft
views = db.Column(db.Integer, default=0)
# ...
created_at = db.Column(db.DateTime, default=datetime.now, index=True)
published_at = db.Column(db.DateTime, index=True) # 草稿阶段为空,首次发布才写入
def to_dict(self, with_content=False):
data = {
'id': self.id, 'title': self.title, 'slug': self.slug,
'status': self.status, 'views': self.views,
'category': self.category.to_dict() if self.category else None,
'tags': self.tag_names(),
'published_at': self.published_at.strftime('%Y-%m-%d') if self.published_at else '',
}
if with_content: # 列表不带正文,详情才带
data['content_md'] = self.content_md
data['content_html'] = self.content_html
return data
几个刻意的约定:
- 序列化方法带"粒度开关"。
to_dict()给列表用(轻量),to_dict(with_content=True)给详情用。列表接口绝不小心把几千字正文全吐出去; - 时间在出模型时就格式化成字符串,前端拿到的永远是
2026-09-20,不需要自己处理时区; - 关系为空时给
None而不是炸掉(self.category.to_dict() if self.category else None); - 关联导航函数和模型放一起。上一篇/下一篇、相关文章这种"带业务规则的查询"(要和列表排序严格一致)放在
models/content.py里,API 层和爬虫 SSR 层共用同一份,不会两边写出不同的排序逻辑。
六、create_all 免迁移:单人项目的表结构演进策略
我没有用 Flask-Migrate/Alembic。启动时一句 db.create_all(),新表自动创建。这是个有意为之的取舍,必须讲清楚它的边界,否则就是埋雷。
它能优雅处理的:
- 加一张新表:写好模型,重启,表就有了;
- 加一个带默认值的新列:MySQL 给存量行直接填默认值(所以我的列几乎都显式写了
default=''/default=0); - 加索引:
index=True对新表生效。
它处理不了、需要手动 SQL 的:
- 给已存在的表加新列(create_all 不会改既有表结构);
- 改列类型、改约束、数据迁移。
所以配套规矩是:线上需要改老表时,我在 DBeaver 里执行一条 ALTER TABLE,并把语句同步写进部署文档留存。对一个单人、表结构一年改不了几次的小站,这套方式比维护一长串迁移脚本轻得多。
什么时候应该上 Migrate? 我的判断标准:开始有第二套环境需要同步结构、或需要对存量数据做不可逆转换时。工具应该在痛感出现时才引入。
七、配置分层:代码里不允许出现任何一个真实密码
config.py 用类继承区分环境,所有值从 .env 读:
class BaseConfig:
SECRET_KEY = _env('SECRET_KEY', 'dev-secret-do-not-use-in-prod')
SQLALCHEMY_DATABASE_URI = (
'mysql+pymysql://{user}:{pwd}@{host}:{port}/{name}?charset=utf8mb4'
).format(user=_env('DB_USER'), pwd=_env('DB_PASSWORD'),
host=_env('DB_HOST', '127.0.0.1'), name=_env('DB_NAME', 'personal_site'))
SQLALCHEMY_ENGINE_OPTIONS = {
'pool_pre_ping': True, # 取连接前先探活,避免 MySQL 8h 断连
'pool_recycle': 28800,
'pool_size': 5, 'max_overflow': 10,
}
CORS_ORIGINS = ['http://127.0.0.1:5174', 'http://localhost:5174']
class ProductionConfig(BaseConfig):
DEBUG = False
CORS_ORIGINS = '*' if os.getenv('CORS_ORIGINS') == '*' else [
o.strip() for o in _env('CORS_ORIGINS', '').split(',') if o.strip()
]
两个值得一提的细节:
pool_pre_ping=True:MySQL 默认会关闭 8 小时空闲连接,不加这个,周一早上第一个请求大概率拿到一条死连接然后 500;- 生产同源默认零 CORS 配置:线上前后端同域名,浏览器视为同源,开发期的跨域只在本地两个端口之间发生。
.env、venv/、uploads/ 一律在 .gitignore 里,仓库只提供 .env.example。服务器 git pull 永远不会冲掉线上密钥——这是上一篇讲过的部署纪律在代码层的落点。
八、这一篇踩过的坑
- 密码哈希必须显式指定
pbkdf2:sha256。服务器 Python 是源码编译的 3.9.18,绑定的 OpenSSL 较老;Werkzeug 3.x 把默认算法换成了 scrypt,老环境直接报module 'hashlib' has no attribute 'scrypt',登录注册全挂。模型里写死方法就再没出过问题:
python
generate_password_hash(raw_password, method='pbkdf2:sha256')
- 取对象用
db.session.get(Model, id),别用query.get()。Flask-SQLAlchemy 3.x / SQLAlchemy 2.x 里Query.get已属遗留写法,新写法语义更清晰,也避免某些 session 状态下的告警。 MAX_CONTENT_LENGTH超限抛的是 413,要记得配错误处理,否则前端收到一个没有信封结构的裸 HTML 错误页,拦截器解析直接懵。- 蓝图不要重名。
public_bp和admin_bp的第一个 name 参数必须不同('posts_public'/'posts_admin'),Flask 靠它做端点名,重名时注册直接报错——这也是两个蓝图都显式命名的原因。 - 首次初始化用独立的
seed.py,不把示例数据塞进create_app()。启动建表、脚本灌数职责分离,且 seed 开头先判断"库里已有用户就跳过",重复执行幂等。
九、小结
这五条规矩合起来,就是这个小后端的全部"框架感":
- 工厂组装,四步固定,顺序明确;
- 一个信封,全站接口同构,前后端各只需一层拦截;
- 双轨蓝图,权限成为 URL 前缀的显式约定;
- 模型自带序列化,列表/详情粒度可控,业务查询单点复用;
- 配置走环境、建表走启动,把部署差异和结构演进都压到最简单的形态。
Flask 没有替你做这些决定,但这恰恰是它适合单人项目的原因——每一条规矩都是按自己的痛点定的,因此每一条你都讲得出为什么。
下一篇进入安全领域,看后台登录这套"看似简单实则处处是坑"的系统:JWT 怎么发怎么废、图形验证码怎么防刷、失败限流在 Nginx 反代后怎么拿到真实 IP——第 3 篇:后台登录与安全,一个人的系统也要按被攻击来设计。
系列导航:① 开篇与总体架构 → ② Flask 后端骨架(本文)→ ③ 登录与安全 → ④ 博客内容模块 → ⑤ Markdown 编辑器 → ⑥ OSS 直传全攻略 → ⑦ SPA 的 SEO 与微信分享 → ⑧ 打卡热力图与小游戏 → ⑨ AI 网页宠物与收官


