资讯详情

资讯详情

MVC与MVT分层实践:控制器边界与业务逻辑落点

接手一个写了两年的后台系统最怕看到的不是报错而是打开一个视图文件前两百行在拼 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( userrequest.user, pagepage, keywordkeyword, ) # 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_iduser.id) if keyword: qs qs.filter(device_name_likekeyword) return qs.paginate(pagepage, size20)这段代码里有三个刻意为之的设计值得单独说明。第一控制器里没有一行 SQL它连模型都没直接碰通过 service 层间接访问这样后面加缓存、加审计日志都只需要改 service 一处。第二权限规则放在 service 而不是控制器因为权限属于业务规则导出功能、移动端接口、定时任务都要用同一套规则放控制器就会被复制三份。第三模板拿到的只有items和pager一个干净的、已经算好分页的数据结构模板里不需要再算任何东西。2.4 MVC 走形的四种常见现场模式本身没错错的是落地时的变形。我把见过最多的四种列出来你可以对照自己的代码。第一种胖控制器。控制器方法超过一百行里面能看到的操作包括参数解析、权限判断、SQL 拼接、数据格式化、异常处理。判断标准很简单——如果这个方法里出现了if 用户等级 3这种业务判断就说明业务规则漏到了控制器。第二种贫血模型。模型文件里只有字段定义和关联关系一个方法都没有。所有计算逻辑堆在 service 里service 变成上帝类。这种情况在用了重型框架的项目里特别常见因为框架把持久化封装得太舒服开发者就忘了模型还能放行为。第三种视图里查数据。模板中直接调用模型查询比如循环里对每一条记录再查一次关联表。这不仅破坏了分层还会引发经典的 N1 查询问题——列表页二十条数据打出二十一次数据库请求页面慢得莫名其妙。视图只应该接收已经准备好的数据。第四种模型反向依赖请求。模型里出现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模板模板 TemplateMVT 把展示层彻底独立全局横切逻辑手写中间件或过滤器框架中间件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、modelsMVT 版本是按业务模块分的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, page1, keyword): qs BorrowRecord.base_query() # 权限规则唯一落点 if not user.is_admin: qs qs.filter(owner_iduser.id) if keyword: qs qs.filter(device_name_likef%{keyword}%) total qs.count() items qs.order_by(-borrowed_at).offset((page - 1) * 20).limit(20).all() return PageResult(itemsitems, totaltotal, pagepage, size20) def create(self, user, form_data): # 校验放在业务层接口和命令行都能复用 if not form_data.get(device_name): raise ValidationError(设备名称不能为空) if BorrowRecord.base_query().filter( owner_iduser.id, returned_atNone ).count() 5: raise BusinessError(未归还设备已达上限) return BorrowRecord.create( device_nameform_data[device_name], owner_iduser.id, borrowed_atnow(), )控制器薄到几乎没有逻辑# controllers/borrow_controller.py def list_view(request): page max(int(request.get(page, 1)), 1) result borrow_service.query( userrequest.user, pagepage, keywordrequest.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)}, status400)这个版本的好处非常明确BorrowService不依赖任何 Web 对象user是一个普通对象form_data是一个字典。写单元测试的时候直接构造一个假用户传进去就行不需要启动服务、不需要模拟请求上下文。这就是分层的实际回报。4.3 MVT 版本的关键实现同样的需求MVT 版本会更紧凑因为路由分发、请求对象、响应封装都由框架代劳# borrow/models.py class BorrowRecord(models.Model): device_name models.CharField(max_length64) owner models.ForeignKey(User, on_deletemodels.CASCADE) borrowed_at models.DateTimeField(auto_now_addTrue) returned_at models.DateTimeField(nullTrue, blankTrue) 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(ownerrequest.user) keyword request.GET.get(q, ).strip() if keyword: qs qs.filter(device_name__icontainskeyword) 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 methodget input nameq value{{ keyword }} placeholder搜索设备名称 button typesubmit搜索/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 %} trtd colspan3暂无记录/td/tr {% endfor %} /table {% include pager.html with page_objpage_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 反而增加心智负担。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →