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 内):
- 加载 config(Config 类 + 环境变量)
- 配置日志(
dictConfig) - 初始化 extensions(app):
db.init_app、migrate.init_app、jwt.init_app、limiter.init_app、cors.init_app - 注册 views(蓝图)
- 注册 errors(业务异常、404、500 处理器)
- 注册 hooks(
before_request/after_request/teardown_appcontext) - 注册 Jinja2 全局变量与 CLI 命令
四、统一开发规范
4.1 通用基础规则
- 自定义中间件、请求钩子、信号、全局异常处理器分别在
app/hooks.py、app/errors.py实现,由create_app统一调用register_hooks(app)、register_error_handlers(app)完成注册;禁止在业务模块中注册。 app/views/__init__.py统一聚合导出全部业务蓝图,是唯一允许导入子蓝图的位置;app/models/__init__.py统一导出全部 ORM 模型,并再导出db。- 蓝图、模型优先从包顶层导入;模型数量 ≥15 时允许按需
from app.models.xxx import Xxx。Alembic autogenerate 依赖models/__init__.py的全量导入,按需导入不得删除顶层汇总导入。 - 视图按业务模块拆分子蓝图文件;蓝图间页面跳转仅使用
url_for,禁止互相导入蓝图对象。 - 离线脚本统一放在
scripts/,仅调用create_app(),使用with app.app_context(),禁止顶层导入app实例。 - 所有
.env环境变量文件加入.gitignore,仓库只保留.env.example;密钥、证书私钥、敏感配置禁止提交。 tests可导入app.create_app构建测试应用,可导入models、services、utils;禁止导入模块级app实例,禁止依赖视图内部实现细节。- 目录职责固定:上传与导出文件只写
storage/,缓存与临时文件只写data/,业务代码不得在项目根目录创建运行时文件。
4.2 静态导入约束(仅允许从左向右导入,禁止反向回流)
app/__init__.py:导入config、extensions、errors、hooks、views。config:仅标准库与第三方库,读取环境变量;不导入extensions、models、services、views、utils。extensions:仅导入第三方扩展库并实例化;不导入config、models、services、views。扩展配置在init_app阶段从app.config读取。models:仅导入extensions.db,并再导出db;允许标准库与第三方基础库(如datetime、werkzeug.security);不导入services、views、utils、app。services:导入models(含db)、utils.pure、utils.flask_tool(仅用于读取配置);禁止导入views、app实例、config模块。views:导入services、utils.flask_tool、views/schemas;不导入其他子蓝图(views/__init__.py聚合入口除外),不导入app实例。utils:pure无上层依赖,全场景可调用;flask_tool顶层仅导入current_app代理,不在模块级访问g、request或 App 实例。
全局禁止行为:
views/services/models导入app实例或config模块;extensions仅允许models导入db。- 任意文件顶层编写
from app import app;应用实例只能由create_app()创建。 utils在模块级直接引用 App 实例或g/request等上下文对象;current_app代理本身允许导入。views直接提交事务或直接操作模型、数据库。
4.3 运行时分层职责约束
- 视图 (V):仅参数校验、调用服务、组装返回响应或渲染模板。
- 校验工具:
webargs+marshmallow,Schema 文件放在views/schemas/下。 - ❌ 禁止直接操作模型 / 数据库、编写复杂业务逻辑、提交事务。
- 校验工具:
- 服务 (S):处理业务流程、事务、多模型联动与第三方调用。
- 需要的配置通过
utils.flask_tool的配置读取函数获取,或由 View 显式传参。 - ❌ 禁止操作
request/url_for/g、直接返回 HTTP 响应。
- 需要的配置通过
- 模型 (M):仅数据表定义、基础单表 CRUD 封装。
- ❌ 禁止复杂业务、事务、跨模型业务调用。
- 业务异常必须向上抛出,由全局异常处理器统一拦截响应。
- ❌ 禁止在 models、services 内部捕获并吞掉 BusinessError 或未知系统异常。
- 数据仅自下而上逐层透传,下层禁止反向调用上层。
4.4 异常与错误码规范
- 异常体系在
app/errors.py中定义,HTTP 状态与业务码分离:BusinessError携带http_status与biz_code,ParamError、NotFoundError、ForbiddenError继承并给出默认值。 - 业务码统一使用
IntEnum,编码规则为「HTTP 状态码 3 位 + 业务序号 2 位」;同一 HTTP 状态下00为通用码,01起为具体业务码,新增业务域需在枚举注释中登记占用段。SUCCESS = 0仅用于响应成功标识,不参与异常抛出。 - Service 层遇到业务异常直接
raise BusinessError;具体业务码通过biz_code=覆盖,例如raise NotFoundError("用户不存在", biz_code=BizCode.USER_NOT_FOUND)。 - View 层不写
try/except BusinessError,统一由create_app内注册的全局异常处理器拦截。自定义NotFoundError与 Werkzeug 的 404HTTPException是两套异常,需分别注册处理器;401、405、429 按需补充注册,JWT 校验失败由JWTManager统一返回 401。 - API 统一响应格式为
{"code": 0, "data": {}, "message": "ok"};异常响应为{"code": 40401, "data": null, "message": "用户不存在"}。HTTP 状态码:成功返回 200;业务异常返回对应 4xx;500 仅用于未捕获的系统异常。 - Jinja2 页面与 API 共用异常处理器时,按内容协商决定响应类型:API 蓝图固定 JSON;其余请求按显式
Accept判断,浏览器导航返回 HTML,fetch等Accept: */*请求返回 JSON。 - 业务码只增不改:已发布的业务码不得复用或改变语义;废弃码保留占位并在注释中标记,避免客户端判断错乱。
参考实现(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 日志规范
- 统一使用 Python
logging,通过dictConfig(disable_existing_loggers=False)配置;不使用logging.basicConfig,并明确由dictConfig接管app.logger,避免 Gunicorn 预加载时配置失效。 - 日志级别:开发环境 DEBUG,生产环境 INFO。
- 日志统一格式:
时间 | 级别 | 模块 | 请求ID | 消息,对应%(asctime)s | %(levelname)s | %(name)s | %(request_id)s | %(message)s。 -
请求 ID 由
create_app内的before_request钩子生成:优先沿用上游合法的X-Request-ID,否则使用uuid4().hex;存入flask.g.request_id,并在after_request回写响应头。日志 Filter 通过has_app_context()判断,非请求上下文(如 cron)统一填-;过滤器实现见本节末尾。 -
各层记录职责唯一,禁止重复记录:
- View 层:只记录业务语义节点(INFO),如「导出任务已创建」;访问信息由全局切面采集,业务层禁止手动记录。
- Service 层:业务关键节点 DEBUG;可恢复分支 WARN;异常不捕获、不打日志,交由全局处理器。
- Models 层:禁止主动打印业务日志,数据库异常直接向上抛出。
- 全局异常处理器:
BusinessError记 WARNING(含业务码、路径、请求 ID);未捕获异常记 ERROR 并调用logger.exception()。 - 若业务需要临时捕获以补充上下文,仅允许修改异常 message 后重新抛出,禁止在捕获处重复打印日志。
-
logs/目录按天轮转,日志文件保留 30 天,使用TimedRotatingFileHandler(when="midnight", backupCount=30)。 -
日志文件按业务类型分文件写入,写入位置固定,具体分工见本节末尾的「日志文件分工表」。
- 慢查询采用事件监听,避免
SQLALCHEMY_RECORD_QUERIES的版本差异与请求上下文限制;监听器在before_cursor_execute记录起始时间,在after_cursor_execute计算耗时,超过阈值写入slow_query.log。 logs/api.log与logs/security.log必须脱敏:禁止记录明文密码、Token、完整 Cookie、身份证号与完整手机号(手机号保留后四位)。-
业务审计日志需持久化数据库,与文件日志区分:
- 用户操作 / 权限审计日志:
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 层第三方调用统一封装 | 用于对账与超时排查 |