接手一个写了两年的后台系统,最怕看到的不是报错,而是打开一个视图文件,前两百行在拼 SQL,中间一百行在做权限判断,最后八十行在输出 HTML。这种代码不是没人管,是当初没人管得住——MVC、MVT 这类设计模式被讲了三十年,真正难的不是背出那几个字母分别代表什么,而是在项目跑到第三个月、需求开始互相打架的时候,你还能不能守住那条分界线。MVC 和 MVT 本质上都在回答同一个问题:一次用户请求进来,数据和呈现之间该隔几层墙、墙砌在哪里。MVC 把墙砌在控制器和视图之间,MVT 干脆把控制器这一层收进框架内部,只留给你模型、视图函数和模板。这两个模式不新鲜,但每年仍有大量项目在同一个地方翻车,说明它们真正的价值不在概念本身,而在落地时那些说不清道不明的取舍。这篇内容面向三类人:刚接触 Web 开发、被 MVC/MVT 术语绕晕的新手;写过一两年代码、感觉分层"有名无实"的中级开发者;以及需要给团队定规范、却苦于找不到判断标准的负责人。我会从模式想解决的问题讲起,一路拆到一次请求的完整调用链、同一份需求的两套实现、以及我在实际项目里踩过的坑,尽量让每一段都能直接对照你手上的代码。
1. MVC/MVT 到底在防什么:从一次重构说起
1.1 一个很典型的事故现场
我见过一个运营后台,早期的功能只有"查列表、改状态"两个动作,开发者为了快,直接在视图函数里写了三条 SQL、拼了一段 HTML、顺手把权限判断也塞进去了。第一个月没问题,第三个月需求来了:列表要加导出、状态要加审批流、同一个页面要给移动端复用数据。这时候问题全爆出来了——导出需要复用查询逻辑,但查询逻辑和 HTML 输出黏在同一个函数里;移动端只要 JSON,但函数体最后一定是render;审批流要求状态变更前做校验,而校验代码散在六个地方,改一处漏五处。改一个字段名要在七个文件里搜索替换,这个成本已经远远超过了当初省下来的那点时间。
这就是没有分层时的真实代价:耦合不是让你写不出来,而是让你改不动。代码能跑,但它失去了被修改的能力。MVC/MVT 这套东西,说到底就是一套"提前付费"的契约——你在前期多花一点时间划边界,换取后期修改时不必推倒重来。
1.2 关注点分离:收益是真的,成本也是真的
分离关注点(Separation of Concerns)这个词听起来很空,落到实际就是一句话:一段代码应该只有一个理由被修改。展示逻辑变,只改模板;业务规则变,只改模型或服务层;路由规则变,只改分发配置。三个方向的变更互不干扰,这就是收益。
但我想说清楚成本,因为很多教程只讲好处。第一,文件数量会明显变多,一个简单功能可能涉及五个文件,新人在目录里找不到东西;第二,调用链路变长,出问题时你得从路由一路跳到模板才能定位;第三,也是最容易被忽略的——过度分层会催生贫血模型,模型退化成只有字段定义的"数据袋子",所有业务逻辑堆到一个叫 service 的巨型类里,最后你只是把"胖视图"换成了"胖服务",问题原地搬家。我在实际项目里见过 service 文件超过三千行的,那已经失去了分层的意义。
所以判断分层是否有效,不能看目录结构整不整齐,要看变更的传播范围。改一个展示文案,需要动几个文件?改一条业务规则,需要动几个文件?这个数字才是分层的真实体检指标。
1.3 三个角色的边界怎么划
MVC 的三个角色,教科书定义人人会背,但边界到底卡在哪,很多人是模糊的。我给一个更实操的判断标准。
模型(Model)承载两样东西:数据结构,以及围绕这些数据的规则。这里的争议最大——模型该不该包含持久化代码?两种流派都存在:Active Record 让模型自己管存取,写起来快、直观;Data Mapper 把持久化拆出去,模型更纯粹、更好测。选哪个取决于你的领域复杂度,字段十几个、逻辑简单的项目,Active Record 完全够用;业务规则复杂、需要跨存储迁移的项目,拆出去更稳。
视图(View)只做呈现,不做判断。这条线最容易被突破,因为模板语言往往支持条件判断和循环,开发者一顺手就在模板里写if 用户等级 > 3 and 订单金额 < 100。这是典型的越界——业务规则跑到了展示层,测试覆盖不到,改起来还得开浏览器验证。
控制器(Controller)是最容易变胖的角色。它该做的事只有三件:接收并校验输入、调用模型完成业务、选择视图返回结果。除此之外的一切,都不该往这里塞。
一个特别实用的自检方法:如果这段代码脱离了 HTTP 请求也能跑(比如定时任务、命令行脚本、单元测试),那它就不该待在控制器里。控制器是"入口适配器",不是"业务容器"。
1.4 判断你的分层有没有真正生效的四个信号
不用看架构图,问自己四个问题就够了。
第一个:换一套页面样式,需要动业务代码吗?如果答案是"要",说明展示和业务还没分开。第二个:换一种存储方式(比如从关系库换到文档库),需要改模板吗?要改就说明数据访问泄漏到了展示层。第三个:能不能不启动 Web 服务,直接对业务逻辑写单元测试?如果每次测试都要起服务、连数据库、模拟请求对象,那分层只是形式。第四个:一个新人只看目录名,能不能猜到"我要改的这个功能"在哪个文件?猜不到,说明你的目录是按技术分层画的,但命名没有语义。
这四个信号比任何架构评审都管用,因为它们直接对应日常开发中最频繁的三类变更:改样式、换存储、加测试。任何一个环节卡壳,分层就该重新审视了。
2. MVC 的几种真实形态:桌面、Web 与前端分流
2.1 经典 MVC 里的数据流向
MVC 最早来自桌面图形界面,那个场景下它的数据流是"双向"的:用户操作触发控制器,控制器修改模型,模型主动通知视图更新。这里有个关键细节常被忽略——经典 MVC 里模型和视图之间是观察者关系,模型不知道视图长什么样,但它知道"我变了要通知谁"。一个模型可以挂多个视图,比如同一份数据同时显示成表格和图表,改一处两边同步刷新。
这个设计在桌面程序里非常自然,因为程序是常驻内存的,模型对象活得比界面久。但搬到 Web 上就出问题了:HTTP 是无状态的,一次请求进来、处理完、响应出去,对象生命周期就结束了。模型没法"主动通知"一个已经消失的视图。所以 Web 上的 MVC 必然是被动版本。
2.2 Web 请求-响应模型下的被动 MVC
被动 MVC 的流程变成单向:请求进来 → 控制器解析参数 → 调用模型获取数据 → 控制器把数据交给视图 → 视图渲染成字符串 → 返回响应。模型不再通知任何人,视图也不再监听,两者通过控制器这个"中间人"完成一次性交接。
这个变化带来一个副作用:控制器变成了整个流程的调度中心,职责天然变重。这也是为什么在 Web 项目里,"胖控制器"是最普遍的病症——它既要解析请求、又要调用业务、还要准备视图数据,三件事写在一个方法里,很容易越写越长。后来前端演化出的各种 MV* 变体(MVVM、Flux、单向数据流),本质上都是在解决"控制器太重"这个历史遗留问题。
2.3 一次请求在 MVC 里的完整链路
光说概念太空,我把链路写具体一点。假设一个查询设备借用记录的功能,目录结构大致是这样:
app/ ├── routes.py # 路由表:URL -> 控制器方法 ├── controllers/ │ └── borrow_controller.py # 接收请求、组装响应 ├── services/ │ └── borrow_service.py # 业务规则:权限过滤、分页、排序 ├── models/ │ └── borrow.py # 数据结构 + 持久化映射 └── templates/ └── borrow_list.html # 只负责渲染对应的调用链:
# routes.py ROUTES = { "/borrow/list": ("borrow_controller", "list_view"), } # controllers/borrow_controller.py def list_view(request): # 1. 只做输入解析和基础校验 page = int(request.get("page", 1)) keyword = request.get("q", "").strip() # 2. 业务全部委托出去,控制器不写规则 result = borrow_service.query( user=request.user, page=page, keyword=keyword, ) # 3. 只负责选择视图并注入数据 return render("borrow_list.html", { "items": result.items, "pager": result.pager, }) # services/borrow_service.py def query(user, page, keyword): # 权限规则在这里,不在控制器也不在模板 qs = borrow_model.base_query() if not user.is_admin: qs = qs.filter(owner_id=user.id) if keyword: qs = qs.filter(device_name_like=keyword) return qs.paginate(page=page, size=20)这段代码里有三个刻意为之的设计,值得单独说明。第一,控制器里没有一行 SQL,它连模型都没直接碰,通过 service 层间接访问,这样后面加缓存、加审计日志都只需要改 service 一处。第二,权限规则放在 service 而不是控制器,因为权限属于业务规则,导出功能、移动端接口、定时任务都要用同一套规则,放控制器就会被复制三份。第三,模板拿到的只有items和pager,一个干净的、已经算好分页的数据结构,模板里不需要再算任何东西。
2.4 MVC 走形的四种常见现场
模式本身没错,错的是落地时的变形。我把见过最多的四种列出来,你可以对照自己的代码。
第一种:胖控制器。控制器方法超过一百行,里面能看到的操作包括参数解析、权限判断、SQL 拼接、数据格式化、异常处理。判断标准很简单——如果这个方法里出现了if 用户等级 == 3这种业务判断,就说明业务规则漏到了控制器。
第二种:贫血模型。模型文件里只有字段定义和关联关系,一个方法都没有。所有计算逻辑堆在 service 里,service 变成上帝类。这种情况在用了重型框架的项目里特别常见,因为框架把持久化封装得太舒服,开发者就忘了模型还能放行为。
第三种:视图里查数据。模板中直接调用模型查询,比如循环里对每一条记录再查一次关联表。这不仅破坏了分层,还会引发经典的 N+1 查询问题——列表页二十条数据打出二十一次数据库请求,页面慢得莫名其妙。视图只应该接收已经准备好的数据。
第四种:模型反向依赖请求。模型里出现request.user、session、current_url这类东西,模型就绑死在 HTTP 层了。后果是这个模型没法在命令行脚本、消息队列消费端复用。数据从哪来,应该是控制器传进去,而不是模型自己去摸。
3. MVT:控制器被框架收编之后
3.1 职责重排与命名由来
MVT 是 MVC 的一种变体,主要出现在 Python 生态里那类"开箱即用"的全栈框架中。它把三个角色重新命名为模型(Model)、视图(View)、模板(Template)。很多人第一次看到会懵:为什么叫视图的东西干的其实是控制器的活?这背后是一次视角切换。
在 MVC 里,你是从"应用"的视角命名:控制器负责控制流程,视图负责展示。而在 MVT 里,命名是从"框架"的视角出发的——框架认为真正在控制流程的是它自己,URL 分发器、中间件、请求响应对象的封装,这些才是控制器的职责,只不过它们已经被框架写好了,不需要你碰。那么留给开发者写的那一层,框架就管它叫"视图",意思是"你写的这段代码,是给某个 URL 提供的一个视图"。至于纯粹的展示部分,被单独抽出来叫"模板"。
所以准确的映射关系是:MVT 的视图 ≈ MVC 的控制器,MVT 的模板 ≈ MVC 的视图,MVT 的模型 ≈ MVC 的模型,而 MVC 的控制器在 MVT 里被拆成了"框架的分发机制 + 开发者写的视图函数"两部分。
3.2 一次请求的完整生命周期
把链路走一遍,你就能感受到框架到底替你做了什么。请求从客户端发出后,依次经过这些环节:
第一步,中间件前置处理。请求还没到业务代码,先过一遍安全校验、会话解析、请求ID注入之类的通用逻辑。这一步相当于框架自带的"全局控制器"。
第二步,URL 匹配。框架拿着请求路径去路由表里查,找到对应的视图函数,同时把路径里的参数(比如/borrow/42/里的 42)解析成函数参数。这一步在 MVC 里通常是开发者自己写在路由文件里的,在 MVT 里配置仍然要写,但匹配和执行是由框架驱动的。
第三步,执行视图函数。这是你唯一需要亲手写的控制逻辑:取参数、调模型、做校验、决定返回什么。
第四步,模型访问。视图通过 ORM 操作数据,ORM 负责把对象操作翻译成具体语句,并处理连接、事务、懒加载。
第五步,模板渲染。视图把数据上下文交给模板引擎,模板做字符串替换和简单循环,产出一段 HTML 文本。
第六步,响应封装与中间件后置处理。框架把渲染结果包装成标准响应对象,附上状态码和响应头,再次穿过中间件,最后返回客户端。
这条链路里,第一、二、六步是框架的活,第三、四、五步是你的活。对比 MVC,你少写了"请求对象构造"和"响应对象构造"这两块样板代码,代价是流程的控制权部分让渡给了框架——比如中间件的执行顺序、URL 匹配的优先级,这些规则你得按框架的方式来。
3.3 MVC 与 MVT 的逐层映射对照
| 职责 | MVC 中的角色 | MVT 中的角色 | 说明 |
|---|---|---|---|
| 数据结构与业务规则 | 模型 Model | 模型 Model | 基本一致,MVT 通常内建 ORM |
| 接收请求、编排流程 | 控制器 Controller | 视图 View(函数/类) | MVT 里这层叫视图,容易混淆 |
| 请求分发与匹配 | 控制器或独立路由 | 框架 URL 分发器 | MVT 里由框架负责驱动 |
| 纯展示 | 视图 View(模板) | 模板 Template | MVT 把展示层彻底独立 |
| 全局横切逻辑 | 手写中间件或过滤器 | 框架中间件 | MVT 内建更完整 |
| 响应对象构造 | 开发者手写 | 框架封装 | MVT 减少样板代码 |
这张表最值得琢磨的是最后三行。MVC 给你的是"自由",每一层怎么接、中间件怎么写、响应怎么拼,都由你决定,灵活但重复劳动多。MVT 给你的是"约定",框架把分发、中间件、响应封装都做好,你只要填空,效率高但自由度受限。
3.4 模板层为什么值得单独存在
有人会问:模板不就是拼 HTML 吗,为什么不直接在视图函数里返回字符串?这个问题在纯接口项目里确实成立,但对需要服务端渲染的项目,模板独立有三层价值。
第一层是协作分离。模板文件是独立的,样式和结构改动不需要碰 Python 代码,写前端的人可以直接改,不用理解业务逻辑。这个价值在小团队里不明显,一旦前后端分工明确,独立模板能省掉大量沟通成本。第二层是复用机制。成熟模板引擎都有继承和包含语法,一个基础骨架加若干子模板,公共头部、侧边栏、页脚只写一次。如果用字符串拼接,这套复用要自己实现,很容易写成意大利面条。第三层是安全兜底。模板引擎默认对变量做转义,输出到 HTML 的特殊字符会被处理掉,这是防注入的第一道防线。手写字符串拼接的话,转义全靠自觉,漏一处就是漏洞。
不过模板也不是万能的。当页面交互复杂度上升到需要频繁局部刷新时,服务端模板就会力不从心,这时候更合适的做法是让视图函数返回结构化数据,前端自己渲染。同一套 MVT 结构完全可以支撑这两种形态——视图函数按请求头或路径前缀返回不同格式即可,模型和业务逻辑一行都不用改。这正是分层的价值所在。
4. 实操:同一份需求,两套模式各写一遍
4.1 需求拆解与目录结构规划
为了对比公平,我用同一份需求做两遍。需求是"设备借用登记":列表页支持关键词搜索和分页;详情页展示借用人和时间;新增和编辑走表单;普通用户只能看到自己的记录,管理员能看全部。这个需求规模适中,既有查询也有写入,还带权限规则,足够暴露分层设计的差异。
MVC 版本的目录(手动分层,自由度更高):
mvc_app/ ├── routes.py ├── controllers/ │ └── borrow_controller.py ├── services/ │ └── borrow_service.py ├── models/ │ └── borrow.py ├── templates/ │ ├── base.html │ └── borrow_list.html └── tests/ └── test_borrow_service.pyMVT 版本的目录(按框架约定组织,按业务模块切分):
mvt_app/ ├── config/ │ └── urls.py ├── borrow/ │ ├── models.py │ ├── views.py │ ├── urls.py │ ├── forms.py │ └── templates/borrow/ │ ├── list.html │ └── form.html └── tests/ └── test_views.py注意一个细节:MVC 版本我是按技术角色分的目录(controllers、services、models),MVT 版本是按业务模块分的(borrow 一个包,里面自带模型、视图、路由、模板)。这个差异不是随意的——技术分层适合角色边界清晰、团队按职能分工的项目;业务分模块适合功能内聚、希望"删掉一个功能就删掉一个目录"的项目。规模大了以后,纯技术分层会让一次需求变更横跨五个目录,维护成本很高。
4.2 MVC 版本的关键实现
模型层只放数据结构和最基础的查询构造,业务规则上移:
# models/borrow.py class BorrowRecord: id: int device_name: str owner_id: int borrowed_at: datetime returned_at: datetime | None @staticmethod def base_query(): return Query(BorrowRecord) # services/borrow_service.py class BorrowService: def query(self, user, page=1, keyword=""): qs = BorrowRecord.base_query() # 权限规则:唯一落点 if not user.is_admin: qs = qs.filter(owner_id=user.id) if keyword: qs = qs.filter(device_name_like=f"%{keyword}%") total = qs.count() items = qs.order_by("-borrowed_at").offset((page - 1) * 20).limit(20).all() return PageResult(items=items, total=total, page=page, size=20) def create(self, user, form_data): # 校验放在业务层,接口和命令行都能复用 if not form_data.get("device_name"): raise ValidationError("设备名称不能为空") if BorrowRecord.base_query().filter( owner_id=user.id, returned_at=None ).count() >= 5: raise BusinessError("未归还设备已达上限") return BorrowRecord.create( device_name=form_data["device_name"], owner_id=user.id, borrowed_at=now(), )控制器薄到几乎没有逻辑:
# controllers/borrow_controller.py def list_view(request): page = max(int(request.get("page", 1)), 1) result = borrow_service.query( user=request.user, page=page, keyword=request.get("q", "").strip(), ) return render("borrow_list.html", {"result": result}) def create_view(request): if request.method == "GET": return render("borrow_form.html", {}) try: record = borrow_service.create(request.user, request.form) return redirect(f"/borrow/{record.id}") except (ValidationError, BusinessError) as e: return render("borrow_form.html", {"error": str(e)}, status=400)这个版本的好处非常明确:BorrowService不依赖任何 Web 对象,user是一个普通对象,form_data是一个字典。写单元测试的时候,直接构造一个假用户传进去就行,不需要启动服务、不需要模拟请求上下文。这就是分层的实际回报。
4.3 MVT 版本的关键实现
同样的需求,MVT 版本会更紧凑,因为路由分发、请求对象、响应封装都由框架代劳:
# borrow/models.py class BorrowRecord(models.Model): device_name = models.CharField(max_length=64) owner = models.ForeignKey(User, on_delete=models.CASCADE) borrowed_at = models.DateTimeField(auto_now_add=True) returned_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["-borrowed_at"] # borrow/views.py def borrow_list(request): qs = BorrowRecord.objects.all() if not request.user.is_admin: qs = qs.filter(owner=request.user) keyword = request.GET.get("q", "").strip() if keyword: qs = qs.filter(device_name__icontains=keyword) paginator = Paginator(qs, 20) page_obj = paginator.get_page(request.GET.get("page", 1)) return render(request, "borrow/list.html", { "page_obj": page_obj, "keyword": keyword, })能明显看出差别:MVT 版本把"取数据"和"编排流程"写在一起了,没有独立的 service 层。这在小需求上是优势——一个功能一个函数,读起来顺。但当业务规则变复杂(比如归还流程要走审批、借用要校验额度、要发通知),这个视图函数就会迅速膨胀。
所以我在 MVT 项目里的实际做法是按需分层:简单 CRUD 直接写视图;一旦出现需要跨入口复用的规则(导出、定时任务、开放接口),立刻抽出一个独立的业务模块,视图函数退化成调用方。这个渐进策略比一开始就上重分层更务实,因为大部分功能一辈子都不会复杂到需要 service 层,提前抽反而是负担。
模板部分用继承解决复用:
<!-- borrow/templates/borrow/list.html --> {% extends "base.html" %} {% block content %} <form method="get"> <input name="q" value="{{ keyword }}" placeholder="搜索设备名称"> <button type="submit">搜索</button> </form> <table> {% for item in page_obj %} <tr> <td>{{ item.device_name }}</td> <td>{{ item.borrowed_at }}</td> <td>{% if item.returned_at %}已归还{% else %}借用中{% endif %}</td> </tr> {% empty %} <tr><td colspan="3">暂无记录</td></tr> {% endfor %} </table> {% include "pager.html" with page_obj=page_obj %} {% endblock %}模板里只做展示和简单判断,"借用中/已归还"这种状态文案可以放模板,但如果状态背后有审批规则,规则必须在模型或业务层,模板只读取最终结果。这条线要守住。
4.4 三个维度的对比复盘
| 对比维度 | MVC 版本 | MVT 版本 |
|---|---|---|
| 实现同一功能所需文件数 | 5 个(路由、控制器、服务、模型、模板) | 4 个(路由、视图、模型、模板) |
| 业务逻辑落点 | 集中在 service,边界清晰 | 默认在视图,需主动抽取 |
| 单元测试成本 | 低,业务层不依赖 Web 对象 | 中,视图测试通常要走测试客户端 |
| 换模板引擎成本 | 低,只改渲染函数 | 低,模板目录独立 |
| 换存储成本 | 中,改模型和查询构造 | 中,改 ORM 映射,视图可能要跟着调 |
| 新人上手速度 | 慢,要理解三层跳跃 | 快,一个功能看一个文件 |
| 长期演进友好度 | 高,规则变更影响面小 | 中,需要自律地拆包 |
这张表里最值得说的两行是"业务逻辑落点"和"新人上手速度"。它们是直接对立的——MVC 用更多的文件换取更清晰的边界,MVT 用更少的文件换取更快的上手速度。没有哪个绝对更好,关键看你项目处在什么阶段。项目初期功能少、人少、需求变化快,MVT 的紧凑更划算;项目进入长期维护期、多人协作、规则复杂,MVC 的清晰边界会持续回报。
还有一条隐含差异:MVC 版本的分层是显式的,它写在目录结构里,谁都能看见,破坏分层会被 code review 拦下。MVT 版本的分层是隐式的,框架只保证模型和模板分开,视图函数里放多少东西完全靠自觉。这也是为什么大量 MVT 项目最终会退化成"视图函数巨型化"——不是框架的问题,是缺少约束。
5. 选型决策与踩坑排查实录
5.1 什么场景该用哪个
先说需要谨慎选择 MVC 的场景。一类是多端复用需求明确的项目,同一套业务逻辑要服务网页、移动端接口、开放平台、内部脚本。这种时候业务逻辑必须彻底脱离 Web 层,MVC 的显式分层几乎是必需的。另一类是领域规则复杂、生命周期长的系统,比如涉及审批流、结算规则、状态机的东西,规则会持续变化,变更影响面必须可控。还有一类是团队按角色分工的,有人专写业务、有人专写前端,显式的服务层能让两边各干各的。
再说 MVT 更省事的场景。典型的是表单驱动的内部管理系统,核心就是增删改查加列表筛选,业务规则轻,用 MVT 能把开发周期压缩得很短。还有需要快速验证的产品原型,先跑起来看反馈,等需求稳定了再重构分层,这比一开始就设计完美架构要务实得多。以及小团队单兵作战的项目,一个人从数据库写到页面,中间加一层 service 反而增加心智负担。