码上问学问代码之理,学编程之道

第 5 章 · 蓝图与项目工程化

前四章我们都在一个 app.py 里写路由,几十行还能看懂,上百行就成一团乱麻。这一章教你用蓝图(Blueprint)把路由按业务拆开、用配置类区分开发与生产环境、用应用工厂 create_app 把整个项目「装配」起来。学完,你会得到一个真正像样的后端工程目录——这也是你后面所有章节的落脚点。

为什么单个 app.py 不够用

面条代码是怎么长出来的
面条代码 → 蓝图:把路由拆进模块 app.py(全挤一起) @app.route('/user/...') @app.route('/article/...') @app.route('/comment/...') @app.route('/login') ... 改一处,整文件抖三抖 Blueprint user.py 用户相关路由 article.py 作品相关路由 comment.py 书评相关路由 app.py 只负责注册

当路由越来越多,全写在一个 app.py 里就成了「面条代码」。蓝图(Blueprint)把一组相关路由打包成 user.py / article.py 等模块,主程序只负责把它们注册进来。

先讲为什么你想找一个东西,是愿意翻一个「所有杂物都塞在一起的抽屉」,还是愿意打开贴着「工具」「药品」「文具」标签的三层柜子?代码也一样。「古典文学网」最终有 18 个接口:用户、作品、书评、收藏各管一摊。如果全写在一个 app.py 里,你会得到一个几百行、谁都不敢碰的文件——这就是俗称的面条代码(spaghetti code,像面条一样缠在一起分不清头尾)。
本章要解决的,就是「怎么把一团面条拆成几个清晰的文件」。
动手做先看看「面条版」长什么样,回忆一下你是不是也这么写过。
app.py(面条版,反面教材)python
from flask import Flask, jsonify, request

app = Flask(__name__)

@app.route("/api/user/login", methods=["POST"])
def login():
    ...
    return jsonify({"code": 200, "msg": "登录成功", "data": {}})

@app.route("/api/user/profile", methods=["GET"])
def profile():
    ...

@app.route("/api/article/list", methods=["GET"])
def article_list():
    ...

@app.route("/api/article/detail/<int:id>", methods=["GET"])
def article_detail(id):
    ...

@app.route("/api/comment/list", methods=["GET"])
def comment_list():
    ...

# …… 还有 13 个接口,全部挤在这一个文件里
运行输出Python 运行结果
运行结果: # 文件行数 $ wc -l app.py 486 app.py # 全部 18 个接口、登录校验、数据库操作、配置,全在一个文件里
避坑最容易踩的坑,是在文件顶部写一句全局的 app = Flask(__name__),然后到处 from app import app。这个「全局单例」在只有一个环境、手工点点看的时候没事,一旦要写自动化测试(每个用例都要一个全新的、干净的 app),或者想同时跑「开发/测试」两套配置,就彻底卡死了。后面第 5 节的应用工厂,正是为了干掉这个单例。

Blueprint 是什么

把一组相关路由打包成模块
Blueprint:把一组相关路由打包成「抽屉」 user_bp /user/register /user/login /user/me article_bp /article/list /article/<id> /article/<id>/like comment_bp /comment/list /comment/add /comment/<id> app.register_blueprint(...) 蓝图本身不能跑,注册进 app 才生效

蓝图(Blueprint)把一组相关路由打包成一个模块(像抽屉),但它自己不能独立运行,必须 register_blueprint 注册进 app 才生效。

先讲为什么公司会把员工按「研发部、市场部、财务部」分组,但所有部门仍在同一栋楼里办公。蓝图(Blueprint)就是这个「部门」:它是一组路由的集合,你可以先把「用户相关的路由」全部写进一个蓝图,最后在应用上统一登记注册。
关键在于:蓝图本身不是一个能直接跑的应用,它更像一份「路由清单」——先写好,再挂到 app 上。
动手做把用户模块的路由单独放进 app/api/user.py。用 Blueprint(...) 创建蓝图,用 url_prefix 一次性给这个模块的所有路由加上公共前缀。
app/api/user.pypython
from flask import Blueprint, jsonify, request

# ① 创建蓝图:名称、所在模块、URL 前缀
user_bp = Blueprint("user", __name__, url_prefix="/api/user")

# ② 用 user_bp 代替 app 来注册路由,路径只写前缀后面的部分
@user_bp.route("/login", methods=["POST"])
def login():
    data = request.get_json()
    # …… 校验用户名密码(第 8 章讲 JWT)
    return jsonify({"code": 200, "msg": "登录成功", "data": {}})

@user_bp.route("/profile", methods=["GET"])
def get_profile():
    return jsonify({"code": 200, "msg": "success", "data": {"id": 3, "nickname": "张三"}})
运行输出Python 运行结果
运行结果: # 注意 route 里写的是 /login,不是 /api/user/login # 前缀 url_prefix 会自动拼在前面,最终地址为: POST /api/user/login GET /api/user/profile
讲透
  • 三个参数各管什么:"user" 是蓝图名(全局唯一,调试用);__name__ 告诉 Flask 这个蓝图定义在哪个模块,用于定位模板/静态文件;url_prefix="/api/user" 是给这个蓝图所有路由加的公共前缀。
  • 蓝图是「延迟注册」的:定义蓝图时并没有真正生效,必须等 app.register_blueprint(user_bp) 执行后才挂到应用上。所以同一个蓝图可以用不同前缀注册到两个 app 上(测试时很常用)。
  • 端点的名字变了:注册后这个视图函数的完整端点是 user.get_profile(蓝图名.函数名),不再是裸的 get_profile——用 url_for 时要注意。
  • 前缀是「拼」出来的:url_prefix + route 路径 = 最终地址。这就是为什么 route 里不该再写一遍 /api/user。

用蓝图重构路由

主程序只负责「注册」
先讲为什么把路由按模块拆成多个蓝图后,主程序就只剩一件事:把这些蓝图一个个登记到 app 上。它从此不用关心「登录接口里具体写了什么」,只管「用户模块、作品模块、书评模块,都给我挂上去」。
这叫关注点分离——每个文件只干一件事,读代码的人一眼就能找到想去的地方。
动手做照葫芦画瓢再建三个蓝图(作品、书评、收藏),然后在主程序里统一注册。
app/api/article.py / comment.py / favorite.pypython
# app/api/article.py
from flask import Blueprint, jsonify
article_bp = Blueprint("article", __name__, url_prefix="/api/article")

@article_bp.route("/list", methods=["GET"])
def article_list():
    return jsonify({"code": 200, "msg": "success", "data": {"list": [], "total": 0}})

# app/api/comment.py
from flask import Blueprint, jsonify
comment_bp = Blueprint("comment", __name__, url_prefix="/api/comment")

@comment_bp.route("/list", methods=["GET"])
def comment_list():
    return jsonify({"code": 200, "msg": "success", "data": {"list": [], "total": 0}})

# app/api/favorite.py
from flask import Blueprint, jsonify
favorite_bp = Blueprint("favorite", __name__, url_prefix="/api/favorite")

@favorite_bp.route("/toggle", methods=["POST"])
def toggle_favorite():
    return jsonify({"code": 200, "msg": "操作成功", "data": {"favorited": True}})
运行输出Python 运行结果
运行结果: # 三个模块各自独立成文件,互不干扰 app/api/article.py → article_bp app/api/comment.py → comment_bp app/api/favorite.py → favorite_bp
app.py(主程序,只做注册)python
from flask import Flask

from app.api.user import user_bp
from app.api.article import article_bp
from app.api.comment import comment_bp
from app.api.favorite import favorite_bp

app = Flask(__name__)

# 把每个蓝图挂到应用上
app.register_blueprint(user_bp)
app.register_blueprint(article_bp)
app.register_blueprint(comment_bp)
app.register_blueprint(favorite_bp)

if __name__ == "__main__":
    app.run(debug=True)
运行输出Python 运行结果
运行结果: $ flask routes Endpoint Methods Rule ---------------------- --------- --------------------------------- article.article_list GET /api/article/list comment.comment_list GET /api/comment/list favorite.toggle_favorite POST /api/favorite/toggle user.get_profile GET /api/user/profile user.login POST /api/user/login static GET /static/<path:filename>
讲透
  • register_blueprint 还能临时改前缀:app.register_blueprint(user_bp, url_prefix="/api/v1/user") 会把前缀整个替换掉,做 API 版本升级时很好用。
  • 路由表用 flask routes 看:这个命令会列出所有端点、方法和地址,排查「接口 404」时第一件事就是跑它。
  • 蓝图可以嵌套:一个蓝图能注册到另一个蓝图下(子蓝图),适合「后台管理系统」这种带多层前缀的场景,初学阶段用不到。
  • 蓝图里也能定义 before_request / errorhandler:只对进入这个蓝图前缀的请求生效——比如给「用户中心」整个模块加一道登录校验。
避坑蓝图最常见的错误是前缀写两遍:已经 url_prefix="/api/user",路由里又写 @user_bp.route("/api/user/profile"),结果地址变成 /api/user/api/user/profile,前端怎么调都是 404。记牢一句话:蓝图里只写「前缀之后」的路径。另外 url_prefix 不能以 / 结尾、route 路径必须以 / 开头,否则也会拼出双斜杠。

配置管理:开发与生产分离

config.py + from_object
配置管理:开发与生产分离 Config(基类) 公共配置:SECRET_KEY 等 SQLALCHEMY_TRACK_MODIFICATIONS DevConfig(开发) DEBUG = True 数据库:本地 hiwen ProdConfig(生产) DEBUG = False 数据库:服务器上的库

配置按「基类 + 子类」分层:公共配置放 Config 基类,DevConfig / ProdConfig 各自继承并覆盖(DEBUG、数据库地址等)。用 app.config.from_object() 按环境加载。

先讲为什么你的开发机上数据库是 127.0.0.1、开着 debug、密钥随便填;线上服务器数据库是另一台机器、必须关掉 debug、密钥绝不能泄露。如果这些值硬编码在代码里,你每次上线都得手动改一遍再改回来——迟早出错。
正确做法:把配置抽成独立的配置类,用不同类代表不同环境,启动时按需选用。密码密钥这类敏感值,一律走环境变量。
动手做在项目根目录写一个 config.py:一个 Config 放公共配置,DevConfig / ProdConfig 各自继承它并覆盖差异。
config.pypython
import os

class Config:
    """所有环境共用的基础配置"""
    SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key")
    JSON_AS_ASCII = False            # 响应里的中文不转成 \uXXXX
    SQLALCHEMY_TRACK_MODIFICATIONS = False

class DevConfig(Config):
    """开发环境:本地数据库、开调试"""
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = "mysql+pymysql://root:123456@127.0.0.1:3306/hiwen?charset=utf8mb4"

class ProdConfig(Config):
    """生产环境:密码从环境变量读,绝不明文写死"""
    DEBUG = False
    SECRET_KEY = os.environ["SECRET_KEY"]          # 没有就启动报错,早暴露早修
    SQLALCHEMY_DATABASE_URI = os.environ.get(
        "DATABASE_URL",
        "mysql+pymysql://user:pass@127.0.0.1:3306/hiwen?charset=utf8mb4",
    )

# 环境名 → 配置类 的映射表,供 create_app 按名取用
config_map = {
    "development": DevConfig,
    "production": ProdConfig,
    "default": DevConfig,
}
运行输出Python 运行结果
运行结果: $ python -c "from config import DevConfig; print(DevConfig.SQLALCHEMY_DATABASE_URI)" mysql+pymysql://root:123456@127.0.0.1:3306/hiwen?charset=utf8mb4 # 三套配置互不干扰,切换环境只改一个名字
动手做用 app.config.from_object() 把配置类加载进应用。它会把类里的大写属性逐条写进 app.config。
加载配置.pypython
from flask import Flask
from config import config_map

def create_app(env: str = "development") -> Flask:
    app = Flask(__name__)
    # 一行搞定:把配置类的大写属性批量灌进 app.config
    app.config.from_object(config_map[env])
    print("当前数据库:", app.config["SQLALCHEMY_DATABASE_URI"])
    print("调试模式:", app.config["DEBUG"])
    return app

if __name__ == "__main__":
    create_app("development")
运行输出Python 运行结果
运行结果: $ python 加载配置.py 当前数据库: mysql+pymysql://root:123456@127.0.0.1:3306/hiwen?charset=utf8mb4 调试模式: True
讲透
  • from_object 只看大写属性:类里写成 SECRET_KEY 会被加载,写成 secret_key 会被忽略。这是 Flask 的约定,别问为什么。
  • 敏感配置必须走环境变量:os.environ.get("SECRET_KEY") 从操作系统读,不进代码仓。本地开发可以放一个 .env 文件,用 python-dotenv 自动加载;但 .env 一定要写进 .gitignore。
  • 继承就是复用:DevConfig(Config) 自动拥有父类所有配置,只写「不一样的那几行」,清爽且不易漏。
  • 配置里常见的还有 UPLOAD_FOLDER(上传目录)、JWT_EXPIRES(token 有效期)等,统一在这里管理,后面几章随用随加。
避坑两件事千万别做:① 把数据库密码、SECRET_KEY 直接写死在提交到 Git 的代码里——公共仓库里的密钥等于公开的;② 线上开着 DEBUG=True——出异常时页面会把整个源代码和变量暴露给任何访问者,是严重安全事故。上线前的检查清单里,这两条排最前。

应用工厂 create_app

消灭全局单例
应用工厂:调用时才「造」出 app create_app(config) 工厂函数 ① 加载配置 dev / prod 分离 ② 初始化 db / migrate / jwt ③ 注册蓝图 user/article/comment app(造好了,可跑) 不再有全局单例,测试 / 多配置都方便

用「应用工厂」create_app() 代替全局 app 单例:调用它才真正创建并组装好 app(加载配置 → 初始化扩展 → 注册蓝图)。这样同一份代码既能用开发配置、也能用测试配置反复造出不同的 app。

先讲为什么回忆第 1 节那个坑:全局唯一的 app 让测试和多环境变得难受。应用工厂(Application Factory)的思路是:不预先造好 app,而是写一个「造 app 的函数」,谁需要谁调用。
就像买车:与其全公司共用一辆车(谁用都别扭),不如提供一个「下单造车」的流程,要几辆造几辆。测试时造一辆「测试配置」的,线上造一辆「生产配置」的,互不影响。
动手做先解决一个隐患:如果 models.py 里 import app、app/__init__.py 里又 import models,就循环导入了。破法是把扩展对象单独放一个文件。
app/extensions.pypython
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_cors import CORS

# 只创建「扩展对象」,不绑定任何 app —— 这是避免循环导入的关键
db = SQLAlchemy()
migrate = Migrate()
cors = CORS()
运行输出Python 运行结果
运行结果: # 这些对象此刻还没连上任何应用 # 它们先「裸奔」着,等 create_app 里再绑到具体的 app 上
动手做在 app/__init__.py 里定义工厂函数:读配置 → 初始化扩展 → 注册蓝图,三步走。
app/__init__.pypython
from flask import Flask
from config import config_map
from .extensions import db, migrate, cors

def create_app(env: str = "development") -> Flask:
    app = Flask(__name__)

    # 1. 加载配置
    app.config.from_object(config_map.get(env, config_map["default"]))

    # 2. 初始化扩展:把「裸奔」的扩展对象绑到这个 app 上
    db.init_app(app)
    migrate.init_app(app, db)
    cors.init_app(app)

    # 3. 注册蓝图(注意:在函数内部 import,避免循环导入)
    from .api.user import user_bp
    from .api.article import article_bp
    from .api.comment import comment_bp
    from .api.favorite import favorite_bp

    app.register_blueprint(user_bp)
    app.register_blueprint(article_bp)
    app.register_blueprint(comment_bp)
    app.register_blueprint(favorite_bp)

    return app
运行输出Python 运行结果
运行结果: $ flask --app app routes Endpoint Methods Rule ------------------------ --------- --------------------------------- article.article_list GET /api/article/list comment.comment_list GET /api/comment/list favorite.toggle_favorite POST /api/favorite/toggle user.get_profile GET /api/user/profile user.login POST /api/user/login
动手做最后写一个两三行的 run.py 作为启动入口,开发时直接 python run.py。
run.pypython
from app import create_app

# 从环境变量读环境名,默认开发环境
import os
app = create_app(os.environ.get("FLASK_ENV", "development"))

if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5000, debug=True)
运行输出Python 运行结果
运行结果: $ python run.py * Serving Flask app 'app' * Debug mode: on * Running on http://127.0.0.1:5000 Press CTRL+C to quit # 按 Ctrl+C 退出
讲透
  • 工厂函数的返回值就是一个普通 app:测试时你可以 create_app("testing") 造一个全新的干净实例,跑完丢掉,用例之间互不污染。
  • db.init_app(app) 干了什么:把全局唯一的 db 对象与某个具体的 app 关联起来——配置存在 app 上,db 通过 app 去取。同一个 db 可以 init_app 到多个 app。
  • 为什么蓝图的 import 写在函数里:app/api/user.py 里会 from ..extensions import db,如果 app/__init__.py 在文件顶部就 import 蓝图,模块加载顺序会打架。放进函数、等调用时再 import,就绕开了。
  • 工厂模式不只是「多实例」:它还让「创建 app」这件事变成了可控的一步——测试可以只注册部分蓝图、只开某些扩展,这在写单元测试时非常方便(第 12 章会用到)。
避坑用了工厂之后,别再在 models.py 里 from app import create_app 或者到处 from app import app——这时候根本没有模块级的 app 可导,会直接 ImportError 或循环导入。模型文件只应该 from .extensions import db。记住这条依赖方向:业务代码 → extensions → (无),永远不反向导入 app。

hiflask 目录树与文件职责

照着搭一遍
动手做把上面所有拼图合起来,就是「古典文学网」后端 hiflask 的真实目录结构。先看全景,再逐条对职责。
项目结构bash
tree hiflask -L 3 -I "__pycache__"
终端输出命令行执行结果
运行结果: hiflask/ ├── app/ │ ├── __init__.py # 应用工厂 create_app() │ ├── extensions.py # 扩展对象:db / migrate / cors │ ├── models.py # ORM 模型(第 6 章) │ ├── utils.py # 统一响应 success()/fail()(第 7 章) │ ├── api/ │ │ ├── __init__.py │ │ ├── user.py # user_bp 用户模块 │ │ ├── article.py # article_bp 作品模块 │ │ ├── comment.py # comment_bp 书评模块 │ │ └── favorite.py # favorite_bp 收藏模块 │ └── static/ │ └── upload/ # 上传的头像等文件 ├── migrations/ # Flask-Migrate 生成的迁移脚本 ├── config.py # 配置类 Config / DevConfig / ProdConfig ├── run.py # 启动入口 ├── requirements.txt # 依赖清单 └── .env # 敏感配置(必须写进 .gitignore)
讲透:每个文件到底负责什么
  • run.py:唯一入口,只做「造 app + 跑起来」,不写任何业务。
  • config.py:所有配置的唯一出处,开发/生产在此分家。
  • app/__init__.py:工厂 create_app(),负责把配置、扩展、蓝图组装成完整应用。
  • app/extensions.py:全局扩展对象的家,切断循环导入。
  • app/api/*.py:一个业务模块一个蓝图,接口都写在这里。
  • app/models.py:数据库模型(下一章开始写)。
避坑目录搭好后,务必先建 app/api/__init__.py 这个空文件。Python 靠它把 app/api/ 认成一个「包」,漏了它,from .api.user import user_bp 会报 ModuleNotFoundError——新手排查半天,往往就栽在这个零字节的空文件上。

综合案例:给古典文学网划蓝图

把 18 个接口归位

打开配套的《古典文学网 · 后端 API 接口文档》,18 个接口其实天然分成四类。按业务划蓝图,项目结构立刻清晰。

蓝图划分规划python
# 四个蓝图,四份前缀,各管一摊接口
user_bp     = Blueprint("user",     __name__, url_prefix="/api/user")
article_bp  = Blueprint("article",  __name__, url_prefix="/api/article")
comment_bp  = Blueprint("comment",  __name__, url_prefix="/api/comment")
favorite_bp = Blueprint("favorite", __name__, url_prefix="/api/favorite")

# user_bp     → /register  /login  /profile  /avatar  /logout
# article_bp  → /list  /recommend  /detail/<id>  /like
# comment_bp  → /list  /add  /like  /<id>(DELETE)
# favorite_bp → /toggle  /list
运行输出Python 运行结果
运行结果: # 划分依据:同一份 url_prefix + 同一类业务 = 一个蓝图 /api/user/* 用户:注册、登录、资料、头像、退出 /api/article/* 作品:列表、推荐、详情、点赞 /api/comment/* 书评:列表、发表、点赞、删除 /api/favorite/* 收藏:收藏切换、我的收藏 # 首页的 /api/banner/list、/api/category/list 可另起一个 common_bp # 共 18 个接口,全部归位,一个不漏
讲透:从「一条路由」到「一个工程」的完整心法先按业务分蓝图(谁跟谁是一家人)→ 再按环境抽配置(开发和生产差在哪)→ 最后用工厂把两者装配起来(谁需要谁调用)。这三步,就是任何一个 Flask 项目从「能跑」走向「能维护」的必经之路。你现在搭好的这套骨架,会一直用到第 13 章的项目实战。
动手练习
1为「古典文学网」新建一个 banner_bp,前缀 /api/banner,实现一个 GET /list 接口,返回统一格式 {code,msg,data}(data 先返回空列表即可)。
2写一个 TestingConfig 配置类(继承 Config,DEBUG=True、TESTING=True、数据库指向 SQLite 内存库),并说明为什么测试环境要用内存数据库。
3迁移练习:把本章的做法搬到「红影赏析」(电影主题网站)。请写出:① 四个蓝图的名称与前缀(影片/影评/收藏/用户);② create_app() 里注册它们的代码;③ 一句话说明「蓝图划分」和「电影网站」本身有没有关系。
参考答案
练习答案.pypython
# 第 1 题:app/api/banner.py
from flask import Blueprint, jsonify

banner_bp = Blueprint("banner", __name__, url_prefix="/api/banner")

@banner_bp.route("/list", methods=["GET"])
def banner_list():
    return jsonify({"code": 200, "msg": "success", "data": []})

# 在主程序里注册
# app.register_blueprint(banner_bp)

# 第 2 题:config.py 里追加
class TestingConfig(Config):
    DEBUG = True
    TESTING = True
    SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"   # 内存数据库

# 第 3 题:红影赏析(电影主题,做法完全一致)
# ① 四个蓝图
movie_bp    = Blueprint("movie",    __name__, url_prefix="/api/movie")     # 影片
review_bp   = Blueprint("review",   __name__, url_prefix="/api/review")    # 影评
fav_bp      = Blueprint("favorite", __name__, url_prefix="/api/favorite")  # 收藏
user_bp     = Blueprint("user",     __name__, url_prefix="/api/user")      # 用户

# ② 在 create_app() 里注册
# app.register_blueprint(movie_bp)
# app.register_blueprint(review_bp)
# app.register_blueprint(fav_bp)
# app.register_blueprint(user_bp)
运行输出Python 运行结果
运行结果: # 第 1 题:GET /api/banner/list {"code": 200, "msg": "success", "data": []} # 第 2 题:测试用 SQLite 内存库的原因 # 1) sqlite:///:memory: 只存在内存里,快、无残留 # 2) 每个测试用例都能拿到一个全新的空库,互不污染 # 3) 不用在开发/生产数据库上跑测试,避免误删真实数据

第 3 题的关键:蓝图划分的依据是「业务归属」,不是「网站主题」。古典文学网分成「作品/书评/收藏/用户」,红影赏析分成「影片/影评/收藏/用户」——换的是模块名,不变的是「同一份前缀 + 同一类业务 = 一个蓝图」这条原则。

本章小结