设计模式预计阅读 21 分钟8085 字2596 次浏览

设计模式修炼之路 ㉖ 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 依赖两位(所以它是粘合剂,也是唯一知道全局的)。所有"这段代码该放哪"的争论,答案都藏在依赖方向里。

二、优点与缺点

优点:

  1. 变化隔离:界面天天改、规则偶尔改、参数解析很少改——三个变化速度不同的东西装进三个房间,各自折腾互不惊扰。
  2. 多视图复用:同一份 Model,控制台、HTML、JSON 接口、APP 页面各挂各的 View——本文后半段当场演示。
  3. Model 可独立测试:业务规则住进 Model 后,测试它不需要启动任何界面、不需要 mock 任何渲染器——直接调方法、断言异常。
  4. 并行开发:接口约定好,一个人写 View、一个人写 Model,Controller 最后联调——前端后端分工的鼻祖。

缺点:

  1. 类数量膨胀:一个页面拆成三个(组)类,demo 级应用写 MVC 是自虐。
  2. Controller 易发福:路由、参数解析、视图选择全在它身上,一不留神就长成"上帝控制器";更常见的伴生病是贫血模型——Model 只剩 getter/setter,业务逻辑全被 Controller 吸走,MVC 名存实亡。
  3. 职责划分靠自觉:if (delta > price * 0.2) 写在 Controller 里编译器不报错——MVC 的边界是纪律问题,没有语言机制强制。
  4. 简单场景过度设计:只有一个静态页的应用、纯计算无界面的程序,没有 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 的事。贫血模型的病因,十有八九是这条判断没做。

五、在框架里找它

  1. Spring MVC:J2EE 模式目录的集大成者。@Controller 对应 Controller,@Service + @Repository 对应 Model(业务与数据分层),Thymeleaf/JSP 对应 View;DispatcherServlet 统一接客(它是 ㉚ 前端控制器模式的主角,此处先按下不表)。你每天写的三层注解,就是 MVC 的实体化。

  2. Struts:J2EE 时代的 MVC 旗手。Action 类是 Controller,ActionForm/JavaBean 是 Model,JSP 是 View——它的兴衰说明 MVC 结构本身不挑实现技术,挑的是实现得好不好用。

  3. iOS Cocoa MVC:苹果官方文档直接教 MVC——UIViewController + UIView + Model。移动端与桌面端才是 MVC 的出生地:数据变了,所有开着的窗口同时刷新,靠的就是 Model 广播事件。

  4. 前端框架: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 迟早被这些"远程破事"淹没。谁来替表示层把这些脏活挡在门外?——业务代表模式,下一篇见。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论