Flask MSV 架构(借鉴 MVC 分层思想,在 Flask 中落地为 Model / Service / View 三层,内部简称 MSV)

术语说明:本文的 V 指路由与控制层(Flask 的 views,对应 MVC 的 C);Jinja2 模板层对应 MVC 的 V,不参与 MSV 缩写。

版本基线:Flask 3.x、Flask-SQLAlchemy 3.x、Flask-Migrate 4.x、Python 3.11+。

注:下文目录为规范约定的目标结构;扩展(extensions)、异常(errors)等模块在实际仓库中可归入 config/、views/ 等目录,以具体实现为准。

一、工程目录

flask-tutorial/                    # 项目根目录
├── .venv/                         # 虚拟环境(不纳入 Git)
├── app/                           # 业务应用主包
│   ├── __init__.py                # 应用工厂 create_app 入口
│   ├── extensions.py              # 第三方扩展实例化:db、jwt、limiter、cors
│   ├── errors.py                  # 异常基类、业务码枚举、错误处理器
│   ├── hooks.py                   # 请求钩子、请求 ID、日志初始化
│   ├── config/
│   │   ├── __init__.py            # 导出 Config 类
│   │   ├── config.py              # 应用配置、常量、版本、业务开关
│   │   └── security.py            # CORS/CSRF/CSP、密钥与加密参数
│   ├── models/
│   │   ├── __init__.py            # 导出全部 ORM 模型,并再导出 db
│   │   └── ...                    # 按业务域拆分的模型文件
│   ├── services/
│   │   ├── __init__.py
│   │   └── ...                    # 按业务域拆分的服务文件
│   ├── views/
│   │   ├── __init__.py            # 唯一允许聚合导入子蓝图的位置
│   │   ├── schemas/               # webargs + marshmallow 校验 Schema
│   │   └── ...                    # 按业务模块拆分的子蓝图
│   ├── templates/
│   │   ├── ...                    # 页面模板
│   │   └── errors/                # 404.html、500.html、business.html
│   ├── static/                    # CSS / JS / 图片
│   └── utils/
│       ├── pure/                  # 纯工具:无 Flask 依赖
│       └── flask_tool/            # 依赖应用上下文的工具
├── scripts/                       # 离线 CLI、初始化、批量任务
├── migrations/                    # Flask-Migrate(Alembic) 迁移脚本
├── tests/                         # 单元测试与集成测试
├── logs/                          # 文件日志(按天轮转,保留 30 天)
├── data/                          # 运行时临时目录;不打包、不挂持久卷
├── storage/                       # 上传与导出文件;禁止脚本解析与执行
├── deploy/
│   ├── nginx/
│   │   ├── nginx.conf             # Nginx 主配置
│   │   └── ssl/                   # 证书目录;私钥由部署机注入,不入库
│   ├── docker/
│   │   ├── gunicorn.conf.py       # Gunicorn 进程配置
│   │   ├── entrypoint.sh          # 容器启动脚本
│   │   ├── Dockerfile             # 镜像构建文件
│   │   └── docker-compose.yml     # 容器编排
│   └── envs/
│       ├── .env.example           # 环境变量模板,纳入 Git
│       ├── .env.dev               # 开发环境变量,不提交
│       └── .env.prod              # 生产环境变量,不提交
├── .gitignore                     # 忽略 .venv、.env、logs、data、证书私钥
├── run.py                         # 本地开发入口
├── wsgi.py                        # Gunicorn 挂载入口
├── README.md                      # 项目说明
├── MANIFEST.in                    # 打包资源清单(仅分发时需要)
└── pyproject.toml                 # 依赖与项目配置

二、运行时业务调用链路(分层请求流转)

请求 → Nginx → Gunicorn → before_request → View(V) → Service(S) → Model(M) → DB
响应 ← Nginx ← Gunicorn ← after_request ← View(V) ← Service(S) ← Model(M) ← DB
异常:Model / Service 抛出 → 全局异常处理器 → HTML 错误页 / JSON 响应
  • View(V):路由匹配、参数校验、调用 Service、组装响应或渲染模板。
  • Service(S):业务编排与事务边界,负责多模型联动与第三方调用。
  • Model(M):表定义与基础单表 CRUD,不做业务编排。
  • 异常:Model / Service 只抛不吞,由全局异常处理器统一记录并返回响应。
  • 事务:只在 Service 层开启与提交,View 层不提交事务。

三、模块导入依赖关系(箭头代表「左侧导入右侧」)

app/__init__.py       → 导入 →  config, extensions, errors, hooks, views(注册蓝图)
config                → 仅标准库与第三方库,读取环境变量;不导入项目内其他模块
extensions            → 仅导入第三方扩展库并实例化;不导入项目内模块
models                → 导入 →  extensions.db(并在 models/__init__.py 再导出 db)
services              → 导入 →  models(含 db)、utils.pure、utils.flask_tool(仅配置读取)
views                 → 导入 →  services、utils.flask_tool、views/schemas
utils.pure            → 仅标准库与通用第三方库,无框架、无业务依赖
utils.flask_tool      → 顶层仅 import current_app 代理;函数内使用上下文
tests                 → 可导入 app.create_app、models、services、utils

初始化顺序(create_app 内):

  1. 加载 config(Config 类 + 环境变量)
  2. 配置日志(dictConfig)
  3. 初始化 extensions(app):db.init_app、migrate.init_app、jwt.init_app、limiter.init_app、cors.init_app
  4. 注册 views(蓝图)
  5. 注册 errors(业务异常、404、500 处理器)
  6. 注册 hooks(before_request / after_request / teardown_appcontext)
  7. 注册 Jinja2 全局变量与 CLI 命令

四、统一开发规范

4.1 通用基础规则

  1. 自定义中间件、请求钩子、信号、全局异常处理器分别在 app/hooks.py、app/errors.py 实现,由 create_app 统一调用 register_hooks(app)、register_error_handlers(app) 完成注册;禁止在业务模块中注册。
  2. app/views/__init__.py 统一聚合导出全部业务蓝图,是唯一允许导入子蓝图的位置;app/models/__init__.py 统一导出全部 ORM 模型,并再导出 db。
  3. 蓝图、模型优先从包顶层导入;模型数量 ≥15 时允许按需 from app.models.xxx import Xxx。Alembic autogenerate 依赖 models/__init__.py 的全量导入,按需导入不得删除顶层汇总导入。
  4. 视图按业务模块拆分子蓝图文件;蓝图间页面跳转仅使用 url_for,禁止互相导入蓝图对象。
  5. 离线脚本统一放在 scripts/,仅调用 create_app(),使用 with app.app_context(),禁止顶层导入 app 实例。
  6. 所有 .env 环境变量文件加入 .gitignore,仓库只保留 .env.example;密钥、证书私钥、敏感配置禁止提交。
  7. tests 可导入 app.create_app 构建测试应用,可导入 models、services、utils;禁止导入模块级 app 实例,禁止依赖视图内部实现细节。
  8. 目录职责固定:上传与导出文件只写 storage/,缓存与临时文件只写 data/,业务代码不得在项目根目录创建运行时文件。

4.2 静态导入约束(仅允许从左向右导入,禁止反向回流)

  1. app/__init__.py:导入 config、extensions、errors、hooks、views。
  2. config:仅标准库与第三方库,读取环境变量;不导入 extensions、models、services、views、utils。
  3. extensions:仅导入第三方扩展库并实例化;不导入 config、models、services、views。扩展配置在 init_app 阶段从 app.config 读取。
  4. models:仅导入 extensions.db,并再导出 db;允许标准库与第三方基础库(如 datetime、werkzeug.security);不导入 services、views、utils、app。
  5. services:导入 models(含 db)、utils.pure、utils.flask_tool(仅用于读取配置);禁止导入 views、app 实例、config 模块。
  6. views:导入 services、utils.flask_tool、views/schemas;不导入其他子蓝图(views/__init__.py 聚合入口除外),不导入 app 实例。
  7. utils:pure 无上层依赖,全场景可调用;flask_tool 顶层仅导入 current_app 代理,不在模块级访问 g、request 或 App 实例。

全局禁止行为:

  1. views / services / models 导入 app 实例或 config 模块;extensions 仅允许 models 导入 db。
  2. 任意文件顶层编写 from app import app;应用实例只能由 create_app() 创建。
  3. utils 在模块级直接引用 App 实例或 g / request 等上下文对象;current_app 代理本身允许导入。
  4. views 直接提交事务或直接操作模型、数据库。

4.3 运行时分层职责约束

  1. 视图 (V):仅参数校验、调用服务、组装返回响应或渲染模板。
    • 校验工具:webargs + marshmallow,Schema 文件放在 views/schemas/ 下。
    • ❌ 禁止直接操作模型 / 数据库、编写复杂业务逻辑、提交事务。
  2. 服务 (S):处理业务流程、事务、多模型联动与第三方调用。
    • 需要的配置通过 utils.flask_tool 的配置读取函数获取,或由 View 显式传参。
    • ❌ 禁止操作 request / url_for / g、直接返回 HTTP 响应。
  3. 模型 (M):仅数据表定义、基础单表 CRUD 封装。
    • ❌ 禁止复杂业务、事务、跨模型业务调用。
  4. 业务异常必须向上抛出,由全局异常处理器统一拦截响应。
    • ❌ 禁止在 models、services 内部捕获并吞掉 BusinessError 或未知系统异常。
  5. 数据仅自下而上逐层透传,下层禁止反向调用上层。

4.4 异常与错误码规范

  1. 异常体系在 app/errors.py 中定义,HTTP 状态与业务码分离:BusinessError 携带 http_status 与 biz_code,ParamError、NotFoundError、ForbiddenError 继承并给出默认值。
  2. 业务码统一使用 IntEnum,编码规则为「HTTP 状态码 3 位 + 业务序号 2 位」;同一 HTTP 状态下 00 为通用码,01 起为具体业务码,新增业务域需在枚举注释中登记占用段。SUCCESS = 0 仅用于响应成功标识,不参与异常抛出。
  3. Service 层遇到业务异常直接 raise BusinessError;具体业务码通过 biz_code= 覆盖,例如 raise NotFoundError("用户不存在", biz_code=BizCode.USER_NOT_FOUND)。
  4. View 层不写 try/except BusinessError,统一由 create_app 内注册的全局异常处理器拦截。自定义 NotFoundError 与 Werkzeug 的 404 HTTPException 是两套异常,需分别注册处理器;401、405、429 按需补充注册,JWT 校验失败由 JWTManager 统一返回 401。
  5. API 统一响应格式为 {"code": 0, "data": {}, "message": "ok"};异常响应为 {"code": 40401, "data": null, "message": "用户不存在"}。HTTP 状态码:成功返回 200;业务异常返回对应 4xx;500 仅用于未捕获的系统异常。
  6. Jinja2 页面与 API 共用异常处理器时,按内容协商决定响应类型:API 蓝图固定 JSON;其余请求按显式 Accept 判断,浏览器导航返回 HTML,fetch 等 Accept: */* 请求返回 JSON。
  7. 业务码只增不改:已发布的业务码不得复用或改变语义;废弃码保留占位并在注释中标记,避免客户端判断错乱。

参考实现(app/errors.py):

# 业务码:HTTP 状态码 3 位 + 业务序号 2 位
from enum import IntEnum


class BizCode(IntEnum):
    SUCCESS = 0          # 仅用于响应成功标识,不参与异常抛出
    PARAM_INVALID = 40001
    UNAUTHORIZED = 40100
    FORBIDDEN = 40300
    NOT_FOUND = 40400
    USER_NOT_FOUND = 40401
    CONFLICT = 40900
    RATE_LIMITED = 42900
    INTERNAL_ERROR = 50000


class BusinessError(Exception):
    http_status = 400
    biz_code = BizCode.PARAM_INVALID

    def __init__(self, message: str, *, biz_code: BizCode | None = None,
                 http_status: int | None = None) -> None:
        super().__init__(message)
        self.message = message
        self.biz_code = biz_code or self.biz_code
        self.http_status = http_status or self.http_status


class ParamError(BusinessError):
    http_status = 400
    biz_code = BizCode.PARAM_INVALID


class NotFoundError(BusinessError):
    http_status = 404
    biz_code = BizCode.NOT_FOUND


class ForbiddenError(BusinessError):
    http_status = 403
    biz_code = BizCode.FORBIDDEN
# 内容协商与业务异常响应
def wants_html() -> bool:
    if request.blueprint in {"api"}:
        return False
    return request.accept_mimetypes.best_match(
        ["application/json", "text/html"]
    ) == "text/html"


def handle_business_error(err: BusinessError):
    payload = {
        "code": int(err.biz_code),
        "data": None,
        "message": err.message,
    }
    if wants_html():
        return (
            render_template("errors/business.html", error=err),
            err.http_status,
        )
    return jsonify(payload), err.http_status
# create_app 内注册
app.register_error_handler(BusinessError, handle_business_error)
app.register_error_handler(404, handle_http_error)
app.register_error_handler(500, handle_http_error)

4.5 日志规范

  1. 统一使用 Python logging,通过 dictConfig(disable_existing_loggers=False) 配置;不使用 logging.basicConfig,并明确由 dictConfig 接管 app.logger,避免 Gunicorn 预加载时配置失效。
  2. 日志级别:开发环境 DEBUG,生产环境 INFO。
  3. 日志统一格式:时间 | 级别 | 模块 | 请求ID | 消息,对应 %(asctime)s | %(levelname)s | %(name)s | %(request_id)s | %(message)s。
  4. 请求 ID 由 create_app 内的 before_request 钩子生成:优先沿用上游合法的 X-Request-ID,否则使用 uuid4().hex;存入 flask.g.request_id,并在 after_request 回写响应头。日志 Filter 通过 has_app_context() 判断,非请求上下文(如 cron)统一填 -;过滤器实现见本节末尾。

  5. 各层记录职责唯一,禁止重复记录:

    • View 层:只记录业务语义节点(INFO),如「导出任务已创建」;访问信息由全局切面采集,业务层禁止手动记录。
    • Service 层:业务关键节点 DEBUG;可恢复分支 WARN;异常不捕获、不打日志,交由全局处理器。
    • Models 层:禁止主动打印业务日志,数据库异常直接向上抛出。
    • 全局异常处理器:BusinessError 记 WARNING(含业务码、路径、请求 ID);未捕获异常记 ERROR 并调用 logger.exception()。
    • 若业务需要临时捕获以补充上下文,仅允许修改异常 message 后重新抛出,禁止在捕获处重复打印日志。
  6. logs/ 目录按天轮转,日志文件保留 30 天,使用 TimedRotatingFileHandler(when="midnight", backupCount=30)。

  7. 日志文件按业务类型分文件写入,写入位置固定,具体分工见本节末尾的「日志文件分工表」。

  8. 慢查询采用事件监听,避免 SQLALCHEMY_RECORD_QUERIES 的版本差异与请求上下文限制;监听器在 before_cursor_execute 记录起始时间,在 after_cursor_execute 计算耗时,超过阈值写入 slow_query.log。
  9. logs/api.log 与 logs/security.log 必须脱敏:禁止记录明文密码、Token、完整 Cookie、身份证号与完整手机号(手机号保留后四位)。
  10. 业务审计日志需持久化数据库,与文件日志区分:

    • 用户操作 / 权限审计日志:models/ 定义独立审计 ORM 模型,services/ 封装入库逻辑,视图仅调用服务,不直接操作模型。
    • 前端行为埋点日志:前端自主采集,通过专用上报接口接收参数;视图仅做参数校验,交由 Service 统一入库存储。
    • 第三方接口关键流水信息(订单号、交易号、业务状态):Service 层持久化至数据库,用于长期对账与业务追溯。

请求 ID 过滤器参考实现:

class RequestIdFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        record.request_id = (
            getattr(g, "request_id", "-") if has_app_context() else "-"
        )
        return True

日志文件分工表:

文件 内容 唯一写入位置 补充说明
logs/access.log 请求方法、路径、客户端 IP、响应状态码、请求耗时 before_request / after_request 钩子 全局切面统一采集,禁止业务层手动记录
logs/error.log 未捕获异常完整堆栈、500 服务错误 全局异常处理器 logger.exception() 其他层不得重复记录
logs/slow_query.log 执行耗时超过 500ms 的 SQL SQLAlchemy before_cursor_execute / after_cursor_execute 事件 不使用 SQLALCHEMY_RECORD_QUERIES 作为长期方案
logs/security.log 登录失败、JWT 校验失败、限流触发、可疑访问 IP 安全中间件 / 认证信号内 logger.warning() 用于安全审计与风险溯源
logs/cron.log 离线脚本执行起止、脚本运行异常 scripts/ 内部 logging.getLogger(__name__) 所有批量任务、初始化脚本统一输出
logs/api.log 第三方外部接口请求 / 响应摘要 Service 层第三方调用统一封装 用于对账与超时排查