设计模式修炼之路 ㉖ MVC 模式——改界面不动业务,改业务不动界面
比 GoF 早十五年、依赖方向定生死、业务逻辑永远住 Model、换视图零成本、MVC→MVP→MVVM 进化树
目录
J2EE 模式第一站。
GoF 二十三式集齐,龙珠在手。可回头看看真实的企业应用:请求从浏览器涌进来,一个页面背后是数据存取、业务规则、界面渲染、请求分发……二十三个模式各管一段,谁来给它们排兵布阵?从 ㉖ 起,本系列进入最后一片大陆:J2EE 企业应用模式——八种模式,讲的就是"分层协作"这件事。第一站,也是最响亮的一站:MVC。
先看痛点。最早的 JSP 页面(人称 Model 1 时代)长这样:页面就是应用,应用就是页面——一个方法里,参数解析、查数据库、业务规则、拼 HTML 四件事糊成一团。想改个按钮配色?通读两百行业务逻辑。想改条促销规则?在同一坨代码里翻 HTML。四件事有四个变化方向,糊在一起,每次变化都是全量阅读。
MVC 开出的药方只有一句话:按"谁变化"切分,而不是按"做什么"堆砌。
一、MVC 模式是什么
MVC 不是 GoF 的孩子——它比 GoF 早出生十五年。1979 年,挪威工程师 Trygve Reenskaug 在 Xerox PARC 访学期间,为 Smalltalk-79 提出了这套结构(最初叫 Thing-Model-View-Editor,后简化)。GoF 管一个类怎么设计,MVC 管整个应用怎么分层——它是架构模式,粒量更大,但药理相通。
三个角色:
| 角色 | 职责 | 认识谁 | 本文对应 |
|---|---|---|---|
| Model(模型) | 数据 + 业务规则,应用的灵魂 | **谁都不认识** | Catalog + Product |
| View(视图) | 把模型数据渲染成用户能看的样子 | 只认识模型的数据 | ConsoleView / HtmlView |
| Controller(控制器) | 接收请求、翻译、调模型、选视图 | 认识另外两位 | ProductController |
职责边界一句话说清:Model 不知道自己被谁看着,View 不关心数据对不对,Controller 两头都不懂行——只管翻译转发。
注意那张表中间一列——依赖方向才是 MVC 的灵魂,比"三个角色"本身重要得多:Model 不依赖任何人(所以能独立测试、独立演化);View 只依赖 Model 的数据(所以换个皮不伤筋骨);Controller 依赖两位(所以它是粘合剂,也是唯一知道全局的)。所有"这段代码该放哪"的争论,答案都藏在依赖方向里。
二、优点与缺点
优点:
- 变化隔离:界面天天改、规则偶尔改、参数解析很少改——三个变化速度不同的东西装进三个房间,各自折腾互不惊扰。
- 多视图复用:同一份 Model,控制台、HTML、JSON 接口、APP 页面各挂各的 View——本文后半段当场演示。
- Model 可独立测试:业务规则住进 Model 后,测试它不需要启动任何界面、不需要 mock 任何渲染器——直接调方法、断言异常。
- 并行开发:接口约定好,一个人写 View、一个人写 Model,Controller 最后联调——前端后端分工的鼻祖。
缺点:
- 类数量膨胀:一个页面拆成三个(组)类,demo 级应用写 MVC 是自虐。
- Controller 易发福:路由、参数解析、视图选择全在它身上,一不留神就长成"上帝控制器";更常见的伴生病是贫血模型——Model 只剩 getter/setter,业务逻辑全被 Controller 吸走,MVC 名存实亡。
- 职责划分靠自觉:
if (delta > price * 0.2)写在 Controller 里编译器不报错——MVC 的边界是纪律问题,没有语言机制强制。 - 简单场景过度设计:只有一个静态页的应用、纯计算无界面的程序,没有 V 可言。
三、什么时候用
判断标准:有界面(或多种输出格式),且业务逻辑值得独立于界面演化。
- 典型 Web 应用:页面样式随产品经理的心情天天变,业务规则随法务的意见偶尔变——变化速度不同,就该分房住
- 多渠道输出:同一数据要出 Web 页、APP 接口、小程序、开放 API——一份 Model 挂一排 View
- 桌面应用:MVC 的出生场景——数据变了,多个打开的窗口同时刷新
反面对照:纯后端服务——没有 View,分层交给 ㉙ DAO 和服务层模式;一次性脚本、demo——别自虐;Model 和 View 天生耦合的应用(比如游戏渲染引擎),强行 MVC 只会制造搬运代码。
四、实现方式:商品目录
4.1 反面教材:一个类包揽一切
JSP Model 1 时代的画风:
public class BadPage {
private static final Map<String, Product> DB = new LinkedHashMap<>();
// static 初始化块灌三条商品数据,略
// "页面":一个方法从请求干到 HTML
public static String raisePrice(String id, String deltaText) {
int delta;
try {
delta = Integer.parseInt(deltaText); // 本该是控制器的活:参数解析
} catch (NumberFormatException e) {
return "<p style='color:red'>参数必须是数字</p>"; // 本该是视图的活:错误展示
}
Product product = DB.get(id); // 本该是模型的活:数据存取
if (product == null) {
return "<p style='color:red'>商品不存在:" + id + "</p>";
}
if (delta > product.getPrice() * 0.2) { // 本该是模型的活:业务规则
return "<p style='color:red'>单次调价不能超过原价的 20%</p>";
}
product.setPrice(product.getPrice() + delta); // 本该是模型的活:状态变更
StringBuilder html = new StringBuilder("<h1>商品列表</h1><ul>"); // 本该是视图的活:渲染
for (Product p : DB.values()) {
html.append("<li>").append(p.getName())
.append(" ").append(p.getPrice()).append("元</li>");
}
return html.append("</ul>").toString();
}
}
跑起来是能跑的:
[请求] 调价 P101 +50(50 > 199×20%,被业务规则拒绝)→
<p style='color:red'>单次调价不能超过原价的 20%</p>
[请求] 调价 P103 +200(200 ≤ 1899×20%,通过)→
<h1>商品列表</h1><ul><li>机械键盘 199元</li><li>无线鼠标 89元</li><li>4K 显示器 2099元</li></ul>
但它是一道四合一的乱炖:改样式要通读业务规则,改规则要在 HTML 里找,想加个控制台输出?整个方法复制一遍。最痛的是测试——想验证"20% 上限"这条规则,断言里得写 assertTrue(result.contains("color:red"))——业务规则的测试竟然要解析 HTML。
4.2 正解:Model、View、Controller 三分
Model:数据 + 业务规则,谁都不认识。
public class Catalog {
private final Map<String, Product> products = new LinkedHashMap<>();
public Catalog() { /* 灌三条商品数据,略 */ }
public List<Product> findAll() {
return List.copyOf(products.values());
}
public Product find(String id) {
Product product = products.get(id);
if (product == null) {
throw new IllegalArgumentException("商品不存在:" + id);
}
return product;
}
/** 业务规则住在模型家:单次调价不能超过原价的 20% */
public void raisePrice(String id, int delta) {
Product product = find(id);
if (delta <= 0) {
throw new IllegalArgumentException("调价幅度必须为正数");
}
if (delta > product.getPrice() * 0.2) {
throw new IllegalArgumentException("单次调价不能超过原价的 20%");
}
product.setPrice(product.getPrice() + delta);
}
}
View:只管"模型数据长什么样"。
public interface ProductView {
String render(List<Product> products);
String renderError(String message);
}
public class ConsoleView implements ProductView {
@Override
public String render(List<Product> products) {
StringBuilder sb = new StringBuilder("------ 商品列表(控制台版)------");
for (Product p : products) {
sb.append(String.format("%n %s %-8s %5d 元", p.getId(), p.getName(), p.getPrice()));
}
return sb.toString();
}
@Override
public String renderError(String message) {
return " × " + message;
}
}
HtmlView 同款接口,输出换成 <h1> + <ul> 的拼装——同一个 Model 的另一张皮。
Controller:请求的接待员,只做翻译转发。
public class ProductController {
private final Catalog catalog; // Model
private final ProductView view; // View
public ProductController(Catalog catalog, ProductView view) {
this.catalog = catalog;
this.view = view;
}
/** 模拟 HTTP 请求入口:"list" / "raise P101 30" */
public String dispatch(String request) {
String[] parts = request.trim().split("\\s+");
switch (parts[0]) {
case "list" -> { return view.render(catalog.findAll()); }
case "raise" -> {
try {
catalog.raisePrice(parts[1], Integer.parseInt(parts[2]));
return view.render(catalog.findAll());
} catch (IllegalArgumentException | ArrayIndexOutOfBoundsException e) {
return view.renderError(e.getMessage());
}
}
default -> { return view.renderError("未知请求:" + request); }
}
}
}
收钱。 先看 Model 独立测试——这是反面教材永远给不了的待遇:
Catalog catalog = new Catalog();
try {
catalog.raisePrice("P101", 999); // 999 > 199×20%
} catch (IllegalArgumentException e) {
System.out.println("[Model 独立测试] " + e.getMessage() + "(零 UI 参与,直接断言异常)");
}
再组装上线,跑完整流程:
ProductController controller = new ProductController(catalog, new ConsoleView());
System.out.println(controller.dispatch("list"));
System.out.println(controller.dispatch("raise P101 30")); // 通过
System.out.println(controller.dispatch("raise P102 999")); // 拒绝
// 换视图:同一个 Model、同一个 Controller,只把 View 换成 HtmlView
ProductController htmlController = new ProductController(catalog, new HtmlView());
System.out.println(htmlController.dispatch("list"));
运行输出:
========== 正解:MVC 三层各司其职 ==========
[Model 独立测试] 单次调价不能超过原价的 20%(零 UI 参与,直接断言异常)
[请求] "list"
------ 商品列表(控制台版)------
P101 机械键盘 199 元
P102 无线鼠标 89 元
P103 4K 显示器 1899 元
[请求] "raise P101 30"(30 ≤ 199×20%,通过)
------ 商品列表(控制台版)------
P101 机械键盘 229 元
P102 无线鼠标 89 元
P103 4K 显示器 1899 元
[请求] "raise P102 999"(999 > 89×20%,拒绝)
× 单次调价不能超过原价的 20%
—— 换视图:同一个 Model、同一个 Controller,只把 View 换成 HtmlView ——
[请求] "list"(HTML 版)
<h1>商品列表</h1><ul><li>机械键盘 229元</li><li>无线鼠标 89元</li><li>4K 显示器 1899元</li></ul>
最后一组对照是 MVC 的含金量时刻:ConsoleView 换 HtmlView,Catalog 一行没改、ProductController 一行没改——只换了一个构造器参数。反过来,"20% 上限"改成"10%",只动 Catalog 一个方法,两个视图连知道都不需要知道。
4.3 三处细节
细节一:View 认识的是模型的数据,不是模型本身。 本文 ProductView 的接口签名是 render(List<Product>)——View 依赖 List 和 Product,不依赖 Catalog 这个类。这不是巧合是刻意:View 需要"渲染什么",不需要"谁来提供"。将来 Catalog 换成 RemoteCatalog、ElasticSearchCatalog,只要吐出来的还是 List<Product>,所有视图无动于衷。依赖越窄,变化越贵不了。
细节二:View 是被注入的策略。 Controller 构造器接收 ProductView,换视图等于换策略——⑮ 策略模式的形制。Spring MVC 把这一步做成了流水线:Controller 返回逻辑视图名 + Model 数据(ModelAndView),ViewResolver 按名字解析出真视图(JSP、Thymeleaf、Freemarker、JSON 序列化器)——同一个 Controller 方法,浏览器请求返回 HTML 页、Ajax 请求返回 JSON,内容协商全自动。本文手动的 new HtmlView(...),在 Spring 里叫 ViewResolver 链。
细节三:Controller 里出现业务判断就该报警。 if (delta > product.getPrice() * 0.2) 这行代码放进 Controller,功能上一模一样,编译器毫无意见——但它意味着这条规则只有走这个 Controller 的请求才能享受,第二个入口(APP 接口、定时任务)进来就得复制一遍。判断标准很简单:这段逻辑换个入口还成立吗?成立就住 Model;只有这个入口成立才是 Controller 的事。贫血模型的病因,十有八九是这条判断没做。
五、在框架里找它
-
Spring MVC:J2EE 模式目录的集大成者。@Controller 对应 Controller,@Service + @Repository 对应 Model(业务与数据分层),Thymeleaf/JSP 对应 View;DispatcherServlet 统一接客(它是 ㉚ 前端控制器模式的主角,此处先按下不表)。你每天写的三层注解,就是 MVC 的实体化。
-
Struts:J2EE 时代的 MVC 旗手。Action 类是 Controller,ActionForm/JavaBean 是 Model,JSP 是 View——它的兴衰说明 MVC 结构本身不挑实现技术,挑的是实现得好不好用。
-
iOS Cocoa MVC:苹果官方文档直接教 MVC——UIViewController + UIView + Model。移动端与桌面端才是 MVC 的出生地:数据变了,所有开着的窗口同时刷新,靠的就是 Model 广播事件。
-
前端框架:React 组件的
state → render()单向数据流,是"Model 变 → View 跟着变"的函数式转译;Vue、Angular 则走得更远——第六节的 MVVM。
六、五十年进化树:MVC → MVP → MVVM
MVC 从 1979 年活到今天,没死,但换了四次形态。四个代际的差别只有一个问题:View 怎么知道该更新了?
| 代际 | View 怎么更新 | 代表 | 适合 |
|---|---|---|---|
| 经典 MVC(1979) | View 订阅 Model,Model 变了主动广播 | Smalltalk | 常驻内存的桌面应用 |
| Web MVC / Model 2(1998) | View 无法常驻;Controller 把 Model 数据喂给 View,渲染完就销毁 | JSP Model 2、Struts、Spring MVC | HTTP 请求-响应 |
| MVP | Presenter 持有 View 接口,一步一指挥;View 完全被动 | WinForms、早期 Android | 要单测 View 逻辑 |
| MVVM | 数据绑定:View 声明式绑定 ViewModel 的属性,双向同步 | WPF、Vue、Angular | 声明式 UI |
看出主旋律了吗?View 越来越被动,越来越声明式。 经典 MVC 里 View 还要自己订阅、自己监听;Web MVC 里 View 变成一性性的渲染模板;MVP 里 View 退化成一个接口实现;MVVM 里 View 干脆只声明"我绑定谁"——人写的命令式代码步步后退,框架的声明式机制步步前进。这和 ⑭ 模板方法到 ⑮ 策略的演进一脉相承:好莱坞原则——别打电话给我们,我们会打给你(Don't call us, we'll call you)。
顺带一提,前端面试爱考的"双向绑定",本质是 MVVM 在 View 与 ViewModel 之间装了两条单向数据流:用户输入 → 更新 ViewModel(表单监听),ViewModel 变化 → 刷新 View(订阅通知)。Vue 的 v-model、Angular 的 ngModel,拆开看还是 ⑯ 观察者那套老手艺。
写在最后
- MVC 是架构模式,1979 年出生,比 GoF 早十五年——管的是整个应用的分层,不是一个类的设计
- 三分职责:Model 管数据和规则,View 管呈现,Controller 管翻译转发
- 依赖方向定生死:Model 谁都不认识——业务逻辑永远住 Model,Controller 里出现业务判断就该报警
- 换视图零成本、Model 独立测试,是 MVC 兑现的两张支票
- 五十年进化只有一个主旋律:View 越来越被动,声明式步步进逼
㉖ 的 Controller 直接持有 Catalog、直接调用,丝滑顺畅——因为它们在同一个进程里。可 EJB 时代的真实画风是:业务层住在另一台服务器上。表示层每次调用业务,都要 JNDI 查找、建网络连接、翻译远程异常、失败重试……Controller 迟早被这些"远程破事"淹没。谁来替表示层把这些脏活挡在门外?——业务代表模式,下一篇见。