‹ 返回博客

个人网站(二):后端骨架——把 Flask 写成一个"小框架"

2026-09-20 · 约 11 分钟读完 · #编程

一个人做个人网站(二):后端骨架——把 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__)?两个实际好处:

  1. 测试和脚本可以各自创建独立 app。比如 seed.py 灌示例数据时自己 create_app(),不必借用一个全局实例;
  2. 创建时机可控。配置、扩展、蓝图的装配顺序显式可见,不会出现"导入某个模块时 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…),datanull。配套三个工具函数放在 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=-3per_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')

权限策略因此变成了 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

几个刻意的约定:

  1. 序列化方法带"粒度开关"to_dict() 给列表用(轻量),to_dict(with_content=True) 给详情用。列表接口绝不小心把几千字正文全吐出去;
  2. 时间在出模型时就格式化成字符串,前端拿到的永远是 2026-09-20,不需要自己处理时区;
  3. 关系为空时给 None 而不是炸掉self.category.to_dict() if self.category else None);
  4. 关联导航函数和模型放一起。上一篇/下一篇、相关文章这种"带业务规则的查询"(要和列表排序严格一致)放在 models/content.py 里,API 层和爬虫 SSR 层共用同一份,不会两边写出不同的排序逻辑。

六、create_all 免迁移:单人项目的表结构演进策略

我没有用 Flask-Migrate/Alembic。启动时一句 db.create_all(),新表自动创建。这是个有意为之的取舍,必须讲清楚它的边界,否则就是埋雷。

它能优雅处理的

它处理不了、需要手动 SQL 的

所以配套规矩是:线上需要改老表时,我在 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()
    ]

两个值得一提的细节:

.envvenv/uploads/ 一律在 .gitignore 里,仓库只提供 .env.example。服务器 git pull 永远不会冲掉线上密钥——这是上一篇讲过的部署纪律在代码层的落点。

八、这一篇踩过的坑

  1. 密码哈希必须显式指定 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')

  1. 取对象用 db.session.get(Model, id),别用 query.get()。Flask-SQLAlchemy 3.x / SQLAlchemy 2.x 里 Query.get 已属遗留写法,新写法语义更清晰,也避免某些 session 状态下的告警。
  2. MAX_CONTENT_LENGTH 超限抛的是 413,要记得配错误处理,否则前端收到一个没有信封结构的裸 HTML 错误页,拦截器解析直接懵。
  3. 蓝图不要重名public_bpadmin_bp 的第一个 name 参数必须不同('posts_public' / 'posts_admin'),Flask 靠它做端点名,重名时注册直接报错——这也是两个蓝图都显式命名的原因。
  4. 首次初始化用独立的 seed.py,不把示例数据塞进 create_app()。启动建表、脚本灌数职责分离,且 seed 开头先判断"库里已有用户就跳过",重复执行幂等。

九、小结

这五条规矩合起来,就是这个小后端的全部"框架感":

Flask 没有替你做这些决定,但这恰恰是它适合单人项目的原因——每一条规矩都是按自己的痛点定的,因此每一条你都讲得出为什么。

下一篇进入安全领域,看后台登录这套"看似简单实则处处是坑"的系统:JWT 怎么发怎么废、图形验证码怎么防刷、失败限流在 Nginx 反代后怎么拿到真实 IP——第 3 篇:后台登录与安全,一个人的系统也要按被攻击来设计

系列导航:① 开篇与总体架构 → ② Flask 后端骨架(本文)→ ③ 登录与安全 → ④ 博客内容模块 → ⑤ Markdown 编辑器 → ⑥ OSS 直传全攻略 → ⑦ SPA 的 SEO 与微信分享 → ⑧ 打卡热力图与小游戏 → ⑨ AI 网页宠物与收官