ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MVC架构真正含义:用Flask记账Demo讲清模型视图控制器职责边界

MVC架构真正含义:用Flask记账Demo讲清模型视图控制器职责边界

简介:这是一套演示MVC三层架构的Java Web应用案例代码,面向正在学习Java Web分层设计、需要理解Controller/Servlet与Model/MyBatis配合方式的开发者;项目以具体业务功能为主线,涵盖从前端表单到数据库访问的完整请求链路,适合作为课程设计或毕业设计的前期参考。资源共121个文件,压缩包约6.86MB,其中32个java源文件对应各层核心逻辑,29个xml文件用于MyBatis映射与Maven工程管理,9个jsp和3个html页面构成视图层,另有properties、css等配置文件与样式表;源码和编译后的class文件一并提供,便于对照阅读和排错。项目围绕brand-demo模块展开,用户通过HTML表单和JavaScript发起交互,Servlet作为控制器接收请求并调用业务处理,再由MyBatis执行MySQL数据操作,最终把结果交给JSP渲染输出,完整呈现了从页面到数据库再回到页面的MVC处理闭环。目前已有1703人学习下载,可用它搭建本地运行环境、观察各层代码依赖关系,快速建立对MVC与Java Web技术栈的整体认识。

1. MVC三层架构被误读最多的地方:Model不是数据表,三层不是三个包

很多刚开始写业务系统的同学看到“MVC三层架构”这个提法,会以为它就是把代码放进 models、views、controllers 三个文件夹里;真动手的时候又容易把所有 SQL 都塞进路由回调里,跑通一遍就觉得自己已经会了。这套理解不是 MVC,只是目录长得像。MVC 三层架构真正说的是:模型 Model 只负责数据和业务规则,视图 View 只负责把数据渲染出来,控制器 Controller 只负责接请求、调模型、选视图,三者之间单向依赖,谁也别越界。这篇我用一份能直接跑起来的最小记账 Demo,把典型 MVC 案例拆开讲清楚——代码很短,但职责边界、参数校验、常见踩坑全在里面。适合刚开始接触后端分层的开发者,也适合已经写过几天业务代码、但总觉得 Controller 越写越乱的人。

2. 先立住概念:三个角色各自管什么,请求路径怎么走

2.1 一次HTTP请求在MVC里的完整流转路径

一个 MVC 请求的标准路径是固定的。用户在前端页面发起操作,浏览器把 HTTP 请求发给服务器;服务器里的路由表先匹配 URL,命中后把请求转给对应的控制器方法。控制器先做参数预检,再调用模型的方法;模型去数据库读取或写入数据,然后把结果原样返回给控制器;控制器拿到结果后决定“渲染哪个视图”或者“重定向到哪个 URL”;视图只做模板填充,把控制器传过来的数据变成 HTML 返回给浏览器。

拿我们马上要写的记账 Demo 举例:用户提交一笔支出,POST 请求到达/add。控制器的add_expense方法接住表单参数,调用ExpenseModel.add()让它把记录插入 SQLite 数据库;插入成功后控制器返回一个重定向到首页;浏览器收到重定向后再次发起 GET/请求;控制器调用list_all()和total()拿到记录列表和总金额,传给模板;模板循环渲染出页面。

这个过程中每个角色只有一件事可做。控制器不写 SQL,不做 HTML 拼接,不算总额;模型只操作数据库,不关心页面长什么样;视图只做展示,不在模板里做统计。任何一个请求,你都能沿着“路由 → 控制器 → 模型 → 控制器 → 视图”这条链把它讲完,这就是 MVC 最容易理解、也最不容易跑偏的检查方式。

2.2 MVC与三层架构的本质区别:一个管职责,一个管整体切分

“MVC 三层架构”这个说法经常被混用,实际它是两套维度叠在一起。MVC 关注的是一个请求处理过程中,代码职责怎么划分;三层架构关注的是整个系统按纵向怎么切成表现层、业务层、数据层。MVC 里的 Controller 和 View 都属于表现层,Model 则覆盖了业务层和数据层的大部分内容。

对比维度MVC三层架构
关注点单个请求的职责分配整个系统的纵向层级
最小单元模型 / 视图 / 控制器表现层 / 业务层 / 数据层
常见落地一个模块内部的代码组织整个工程从上到下的结构

我一般写小型业务系统时,直接让 Model 承担数据访问和业务规则两件事,Controller 承担表现层的调度,View 只做渲染。等项目里 Model 超过 200 行、出现多个模块共享同一套业务逻辑时,再把 Model 里偏业务规则的部分拆成独立的 Service 层,这时候就变成了“三层架构里面的 MVC”。如果你一上来就把 Controller、Service、DAO 全部分好,小项目很容易被抽象拖垮,这个坑第 5 章会重点讲。

2.3 项目到多大规模才值得上MVC

有同学问是不是所有项目都该套 MVC,我的判断标准是三条。第一条:同一份数据要同时供给网页和接口时,如果没有 Model 层,两个路由各写一份 SQL,改一次表结构要改两处地方,漏改一处线上就翻车。第二条:多个页面共用同一类数据操作,把 SQL 抽成 Model 方法后,一次修改全局生效。第三条:Controller 开始出现超过三五十行的趋势,说明视图调度和业务逻辑已经混在一起了,不拆迟早要乱。

反过来,项目只有三个页面、几个接口、全部代码不到 500 行,硬套 MVC 反而会增加负担。最典型的现象是改一个字段要打开五个文件:先改数据库、再改 Model、再改 Controller、再改模板、再改测试。这种情况下我建议先不建目录,保持线性代码,等真的出现重复逻辑再抽。MVC 是为了应对“复杂度增长”的,不是为了装饰工程结构。

3. 最小可运行案例:用Flask写一个MVC记账Demo

3.1 为什么用Flask而不是Spring Boot来演示MVC

选 Flask 不是因为 Python 比 Java 好,而是因为 Flask 的 MVC 边界最直白:路由函数天然就是 Controller,Jinja2 模板就是 View,普通 Python 类就是 Model。整个过程没有任何注解、依赖注入、容器管理的干扰,你能一眼看清数据在哪进去、从哪出来。Java 背景的读者做一次映射就能对齐:这里的ExpenseModel对应你熟悉的 DAO,app.py里的路由方法对应 Servlet 或 SpringMVC 的 Controller,list.html对应 JSP 或 Thymeleaf 模板。

完整项目只需要三个文件加一个数据库文件,结构如下:

demo/ ├── app.py # 入口,同时是 Controller 层 ├── models.py # Model 层,封装全部数据库操作 ├── views/ │ └── list.html # View 层,只做渲染 └── db.sqlite3 # SQLite 数据库,首次运行自动生成

提示:目录名字叫 views 只是工程习惯,和三层架构里的“表现层”不是一个概念,别混着记。

3.2 Model层:四个方法封装全部SQL,对外只暴露数据操作

Model 层的原则是:所有 SQL 都收在模型内部,外部调用者只知道“我要添加一笔记录”“我要拿全部记录”“我要算总额”,不关心数据库是 SQLite 还是 MySQL。这样将来换数据库,或者改表结构,只改这一个文件就够了。

# models.py import sqlite3 class ExpenseModel: """Model 层:只负责数据读写,不包含 HTTP 相关逻辑""" def __init__(self, db_path="db.sqlite3"): self.db_path = db_path self._init_table() def _init_table(self): # 建表操作幂等,重复执行不会报错 with sqlite3.connect(self.db_path) as conn: conn.execute( "CREATE TABLE IF NOT EXISTS expense (" "id INTEGER PRIMARY KEY AUTOINCREMENT," "amount REAL NOT NULL," "category TEXT NOT NULL," "note TEXT DEFAULT ''," "created_at TEXT DEFAULT CURRENT_TIMESTAMP)" ) def add(self, amount, category, note): # 业务规则统一在这里把关,谁调用都绕不过 if not amount or amount <= 0: raise ValueError("金额必须大于 0") if not category: raise ValueError("分类不能为空") with sqlite3.connect(self.db_path) as conn: cur = conn.execute( "INSERT INTO expense (amount, category, note) " "VALUES (?, ?, ?)", (amount, category, note), ) return cur.lastrowid def list_all(self): # 按时间倒序返回全部记录 with sqlite3.connect(self.db_path) as conn: rows = conn.execute( "SELECT id, amount, category, note, created_at " "FROM expense ORDER BY id DESC" ).fetchall() return rows def total(self): # 空表时 SUM 返回 NULL,统一转成 0 with sqlite3.connect(self.db_path) as conn: row = conn.execute( "SELECT SUM(amount) FROM expense" ).fetchone() return row[0] if row[0] else 0

这段代码的逻辑说明:每个方法内部自己开连接、自己关连接,with sqlite3.connect()保证了连接关闭和事务提交;add()方法里对金额和分类做了业务校验,任何入口调用它都会被这道检查拦住,这就是 4.3 要展开讲的“校验归属”;list_all()返回的是元组列表,元组顺序和 SELECT 语句里的列顺序一致,View 层通过下标取值。参数说明:db_path默认指向db.sqlite3,单元测试时可以改成":memory:"使用内存数据库;amount在 Controller 层已经转成 float,所以 Model 里直接做大小判断,不再关心字符串解析。

3.3 View层:模板只做渲染,不碰统计和查询

View 层的模板里不应该出现sum、count、字符串拼接等逻辑计算。所有数据都应该是 Controller 传过来的现成变量,模板只负责把它放进 HTML 对应位置。

<!-- views/list.html --> <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>记账 Demo</title> </head> <body> <h2>总支出:{{ total }} 元</h2> <form method="POST" action="/add"> 金额:<input type="text" name="amount"> 分类:<input type="text" name="category"> 备注:<input type="text" name="note"> <button type="submit">提交</button> </form> <ul> {% for r in records %} <li>{{ r[2] }}:{{ r[1] }} 元,备注 {{ r[3] }}</li> {% endfor %} </ul> </body> </html>

这里{{ total }}和{{ r[1] }}是 Jinja2 模板的取值语法,用来输出 Controller 传进来的数据。{% for %}循环只做展示,没有做求和,没有调用数据库,也没有导入模块。如果你发现模板里开始写{% set total = total + r[1] %},或者出现了过滤器里做复杂格式化,说明逻辑漏到 View 层了——这种代码最难测试,改需求时也最容易漏改。

3.4 Controller层:校验参数、调Model、决定返回哪个View

Controller 是三层里唯一的调度入口,它做五件事:接参、类型预检、调 Model、把异常转成错误提示、选 View。业务规则不写在 Controller 里,SQL 更不写,页面拼接也不写。

# app.py from flask import Flask, request, render_template, redirect, url_for from models import ExpenseModel app = Flask(__name__) model = ExpenseModel() @app.route("/") def list_expenses(): records = model.list_all() total = model.total() # Controller 只负责组合数据,然后把数据交给模板 return render_template("list.html", records=records, total=total) @app.route("/add", methods=["POST"]) def add_expense(): # 类型和空值检查放在 Controller amount = request.form.get("amount", type=float) category = request.form.get("category", "").strip() note = request.form.get("note", "").strip() if amount is None: return "金额必须是数字", 400 try: model.add(amount, category, note) except ValueError as e: # Model 抛出的业务规则异常,转成 HTTP 错误提示 return str(e), 400 # 提交后重定向,避免用户刷新页面时重复提交 return redirect(url_for("list_expenses")) if __name__ == "__main__": app.run(debug=True, port=5000)

参数说明:request.form.get("amount", type=float)是 Flask 提供的类型转换写法,用户输入非数字时返回None,所以 Controller 里只判断None,不判断<= 0——大小判断属于业务规则,已经下沉到model.add()里了。redirect(url_for("list_expenses"))是 Post/Redirect/Get 模式的经典写法,不让浏览器在刷新时重复提交表单。debug=True会在代码出错时显示调试页,生产环境必须改成debug=False。

3.5 跑起来的最小命令与验证路径

依赖只有 Flask 一个库,安装后直接启动。

pip install flask python app.py

浏览器访问http://127.0.0.1:5000/,按照下面三步验证 MVC 是否各司其职。第一次打开时页面显示“总支出:0 元”,没有记录,这证明 View 正常渲染、Model 正常建表;表单里填金额 12.5、分类“餐饮”、备注“午饭”,点提交,页面跳转回首页;首页出现一条记录“餐饮:12.5 元,备注午饭”,总支出变成 12.5,这证明控制器接到了参数、模型成功入库、模板正确展示了数据。如果 5000 端口被占用,把app.run(port=5000)改成其他端口,页面访问地址跟着改即可。

注意:SQLite 文件db.sqlite3是首次启动自动生成的,删除它可以清空数据重新开始,不影响代码逻辑。

4. 前后端分离场景下的MVC变体:JSON视图与API控制器

4.1 传统MVC到API场景要调整的三件事

现在很多项目不再由服务器渲染 HTML,而是后端只出 JSON,前端用框架自行渲染。这种场景下 MVC 的骨架还在,但三个角色要调整一下:View 从“服务器模板”降级为“JSON 数据结构”,Controller 从返回 HTML 变成返回 JSON 加状态码;校验变成两层,前端负责体验层的即时提示,后端负责数据底线。

调整后的对应关系是:Controller 仍然接收 HTTP 请求、解析参数、调 Model、返回响应,只是响应体换成了jsonify的结果;Model 完全不变,它本来就不关心数据给到谁;View 层被拆成两部分,返回给前端的 JSON 由前端负责渲染,服务端只保证结构正确。这个变体在现在的工作里反而比传统模板渲染更常见。

4.2 改造成Ajax版:Controller返回JSON替代HTML

在原有代码基础上新增两个路由,不动已有的 HTML 页面,这就是 MVC 的可扩展性——同一个 Model 同时服务传统页面和 API,数据操作没有任何重复。

# app.py 中新增的 API 路由 from flask import jsonify @app.route("/api/expenses") def api_list(): records = model.list_all() # 元组转字典,前端拿 JSON 语义更清楚 return jsonify([ { "id": r[0], "amount": r[1], "category": r[2], "note": r[3], "created_at": r[4], } for r in records ]) @app.route("/api/expenses", methods=["POST"]) def api_add(): # 前端以 JSON 格式提交,而不是表单格式 data = request.get_json(force=True) amount = float(data.get("amount", 0)) category = data.get("category", "").strip() note = data.get("note", "").strip() try: model.add(amount, category, note) except ValueError as e: return jsonify({"error": str(e)}), 400 return jsonify({"ok": True}), 201

对应前端请求代码:

fetch("/api/expenses", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ amount: 12.5, category: "餐饮", note: "午饭" }) }) .then(res => res.json()) .then(data => { if (data.ok) { loadExpenses(); // 重新拉取列表 } else { alert(data.error); } });

jsonify会自动设置Content-Type: application/json,并把 Python 的字典和列表转成 JSON 字符串。状态码 201 表示资源创建成功,比一律返回 200 更规范,前端也能据此区分“成功”和“新建成功”的场景。force=True表示即使请求头没带Content-Type: application/json也强行按 JSON 解析,本地调试方便,但生产环境建议不写这个参数,让非法请求直接返回 415 错误,反而更容易暴露问题。

4.3 数据校验归属问题:Model还是Controller说了算

这是 MVC 里最容易产生分歧的地方。我的划分标准是:参数能不能转成数字、字段是不是 None,这类“类型和空值检查”放 Controller;金额必须大于 0、分类必须存在于合法列表里,这类“业务规则”放 Model。理由很简单,Controller 是入口,但入口不止一个——以后可能加命令行脚本、加定时任务、加另一个内部接口,它们都可能绕过这个 Controller,却绕不过 Model 的add()方法。

如果把“金额大于 0”写在 Controller,将来新增一个 API 或者一个导入 Excel 的脚本时,很容易漏掉这道检查,脏数据就进库了。把规则放在 Model 的add()方法里,所有入口都必须经过同一个关卡。你也可以理解为:Controller 保证“请求能进来”,Model 保证“数据进门之前是干净的”。

5. MVC落地避坑:5条真实踩坑记录与排查路径

5.1 Controller越写越肥:SQL和业务逻辑混进路由

现象:Controller 里先拆参数,然后直接拼 SQL 字符串,查完库再手动拼 HTML 返回给前端,整个路由函数七八十行,看着像一团乱线。

原因:项目刚起步时图省事,所有逻辑都往路由回调里堆,因为它是请求入口,需求改到哪里就加到哪里;等页面多起来,每个路由都复制一份同样的 SQL,改表结构时漏改一处,问题才暴露。

解决:把所有 SQL 收进 Model 层的方法里,把所有 HTML 拼接收进 View 模板里。Controller 只保留五件事:接参、类型预检、调 Model、异常转状态码、选 View。判断标准是,任何一个 Controller 方法超过 30 行,就该检查是不是有职责越界了。

5.2 View模板里做统计:改一次需求翻三层文件

现象:模板里出现{% set total = total + r[1] %},或者循环里调用带逻辑的过滤器,页面显示倒是正常,但数据稍微复杂一点就开始乱。

原因:写模板时嫌 Controller 里多加一个变量麻烦,想着“这里顺手就算出来了”。短期看省事,长期看,模板里的逻辑没法做单元测试,也没法复用,换一个页面要重新算一遍。

解决:统一规则是模板只接收“已经算好”的数据。总额在 Model 的total()方法里计算,Controller 把它传进模板,模板里只做循环和取值。我现在的习惯是,模板文件里搜索set和带括号的表达式,搜到就说明逻辑漏下去了,立即上提。

5.3 Model反向依赖View:循环依赖让改动处处连环爆

现象:Model 方法里直接返回render_template("list.html", records=...)的结果,甚至直接调用了redirect()。改一次前端样式,Model 跟着改;改一次查询逻辑,模板也跟着动,两边互相牵连。

原因:写的时候觉得“反正这个数据只给这个页面用”,就把页面渲染也塞进了 Model。这与 MVC 的单向依赖完全相反——Model 本该不认识 View,现在它反而决定了 View 的内容。

解决:Model 层禁止导入任何 Flask 渲染相关的东西,只返回 list、dict、int 这类纯数据。Controller 拿到数据后,再由它决定用模板还是 JSON 包装。检查方法是在models.py顶部搜索flask,搜到就是犯界了。

5.4 过度分层:小项目套大厂架构,一个字段改动牵连五处

现象:一个总共五六个页面的项目,目录套了 Controller、Service、DAO、Repository、Entity 五层,改一个字段要从数据库映射一路改到 Controller,中间任何一个文件漏改,接口就挂。

原因:把“分层”当成工程完善度的标志,以为层数越多项目越正规,没考虑抽象成本。这也是 MVC 被误解最深的一个方向——MVC 本身就是为了控制复杂度,而不是制造复杂度。

解决:小项目让 Model 同时承担数据访问和业务规则两个职责,等真的出现“两个模块共用同一套业务逻辑”时,再把公共部分抽成 Service 层。抽象的正确时机是重复出现两次以上,第一次出现叫冗余,第二次出现才叫共性。

5.5 校验规则两头写:漏改一处线上才暴露

现象:表单提交入口校验了金额大于 0,API 入口没有校验,结果一批负数金额的数据通过接口进库,报表对不上了。

原因:校验规则没有唯一归属,每个入口各写各的,新人接手时不知道哪份才是最新的规则。

解决:业务规则全部收进 Model 层的方法入口。Controller 只做类型转换和空值检查,不碰业务规则;Model 统一抛ValueError,Controller 负责把它转成 400 响应。这样即使新增十个入口,校验逻辑也只有一份。

报错症状优先排查层确认方法
SQL 报错、字段对不上Model搜索.execute(是否全在 models.py
页面数据错乱、样式缺失View打开模板看是否出现逻辑计算
参数为 None、类型转换报错Controller在路由入口打印request.form结果
改动一处引发连环报错依赖方向检查 models.py 是否导入了 flask 相关模块

6. 让MVC案例真正可维护:三个自查技巧与排查顺序

6.1 接口契约化:给每层定义“什么进什么出”

Model 层的方法应该像接口一样稳定:入参是什么类型、返回值是什么类型,在方法顶部用注释写死。比如add()的契约是“入参amount: float,category: str,note: str;出参id: int;规则违反时抛ValueError”。Controller 调用它时不需要关心内部 SQL,只管拿到结果往后走;将来内部从 SQLite 换到 MySQL,只要契约不变,调用方一行都不用改。这个习惯能让分层在项目变大时依然可控。

6.2 目录即文档:让命名把边界说出来

目录结构和命名本身就是文档。Model 层文件名用models.py,路由函数名和 URL 保持直接对应,比如/api/expenses对应的函数就叫api_expenses。新成员接手时,看到一个 URL 就能顺着函数名找到 Controller、顺着 Controller 里的model.前缀找到 Model 方法、顺着返回的模板名找到 View。如果某个命名对不上了,通常说明边界已经歪了,需要重构。

6.3 报错定位法:异常按层判断

MVC 项目报错后,先看堆栈里第一个项目内文件是哪一层。models.py里的异常绝大多数是 SQL 写错或字段名对不上;app.py里的异常多半是参数解析失败或调用顺序有问题;模板抛出来的异常则说明 Controller 没有把数据传全。处理顺序建议从 Model 层开始往上游排查,因为数据层错了,Controller 和 View 拿到的都是脏数据,往上查只会浪费时间。

我最早写 MVC 时也翻过车,把 SQL 直接写在路由里,一次改需求连带改三个文件,改了半个月才意识到分层不是为了好看,是为了让改动只落在一处。后来我习惯在动手前先问自己一句:这个改动应该改哪一层?如果答案说不清楚,多半是前面代码已经把边界写糊了。希望这篇能帮你少走一圈弯路,把 MVC 这套骨架真正搭稳。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进