模型/视图/控制器 (MVC)

Carson Gross

我经常看到对使用 htmx 和超媒体的常见反对意见,大致如下:

服务器返回 HTML(而不是 JSON)的问题在于,你可能还想服务移动应用,并且不想重复你的 API

我已经在另一篇文章中概述了,我认为你应该将 JSON API 和超媒体 API 分离成独立的组件。

在那篇文章中,我明确推荐“重复”(在一定程度上)你的 API,以便将返回 HTML 的“易变” Web 应用 API 端点与稳定、常规且富有表现力的 JSON 数据 API 分离。

回顾我与他人围绕这个想法的对话,我认为我假设了很多人并不像我那样熟悉的一种模式:模型/视图/控制器 (MVC) 模式。

MVC 简介

我最近在一期播客中发现,许多年轻的 Web 开发者对 MVC 并没有太多经验,这让我有点震惊。这或许是因为当单页应用成为常态时,前端/后端分离发生了。

MVC 是一种简单的模式,早于 Web 的出现,几乎适用于任何提供图形用户界面的程序。

大致想法如下:

有许多变体,但这就是核心想法。

在 Web 开发的早期,许多服务器端框架明确采用了 MVC 模式。我最熟悉的实现是Ruby On Rails,它有关于这些主题的文档:Models 用于持久化到数据库的模型,Views 用于生成 HTML 视图,以及Controllers 用于协调两者。

在 Rails 中的大致想法是:

Rails 有相当标准的(尽管有些“浅显”和简化的)MVC 模式实现,建立在底层的 HTML、HTTP 请求/响应生命周期之上。

胖模型/瘦控制器

Rails 社区中经常出现的一个概念是“胖模型,瘦控制器”。这里的想法是,你的控制器应该相对简单,只需在模型上调用一两个方法,然后立即将结果交给视图。

另一方面,模型可能“更胖”,包含大量特定于领域的逻辑。(有人反对这会导致上帝对象,但我们暂且搁置。)

让我们记住胖模型/瘦控制器的这个想法,当我们通过 MVC 模式的简单示例并解释其有用性时。

MVC 风格的 Web 应用

作为示例,让我们看一个我最喜欢的:在线联系人应用。这里是一个控制器方法,用于通过生成 HTML 页面显示给定页面的联系人:

@app.route("/contacts")
def contacts():
    contacts = Contact.all(page=request.args.get('page', default=0, type=int))
    return render_template("index.html", contacts=contacts)

这里我使用PythonFlask,因为我在我的Hypermedia Systems书中使用了它们。

这里你可以看出控制器非常“瘦”:它只是通过 Contact 模型对象查找联系人,从请求中传递一个 page 参数。

这是非常典型的:控制器的任务是将 HTTP 请求映射到某些领域逻辑,将 HTTP 特定信息提取出来并转换为模型可以理解的数据,例如页码。

然后,控制器将分页的联系人集合交给 index.html 模板,以渲染成 HTML 页面发送回用户。

现在,Contact 模型内部可能相对“胖”:那个 all() 方法可能内部包含大量领域逻辑,进行数据库查找、分页数据、应用某些转换或业务规则等。而且这没问题,这些逻辑封装在 Contact 模型中,控制器不必处理它。

创建 JSON 数据 API 控制器

因此,如果我们有一个相对发达的 Contact 模型封装了我们的领域,你可以轻松创建一个不同的 API 端点/控制器,它执行类似操作,但返回 JSON 文档而不是 HTML 文档:

@app.route("/api/v1/contacts")
def contacts():
    contacts = Contact.all(page=request.args.get('page', default=0, type=int))
    return jsonify(contacts=contacts)

但你在重复代码!

此时,看着这两个控制器函数,你可能会想“这很愚蠢,这些方法几乎相同”。

你是对的,目前它们几乎相同。

但让我们考虑两个潜在的系统添加。

为 JSON API 添加速率限制

首先,让我们为 JSON API 添加速率限制,以防止 DDOS 或编写不当的自动化客户端淹没我们的系统。我们将添加 Flask-Limiter 库:

@app.route("/api/v1/contacts")
@limiter.limit("1 per second")
def contacts():
    contacts = Contact.all(page=request.args.get('page', default=0, type=int))
    return jsonify(contacts=contacts)

很简单。

但注意:我们不希望这个限制应用于我们的 Web 应用,我们只希望它适用于 JSON 数据 API。而且,因为我们将两者分离,我们可以实现这一点。

为 Web 应用添加图表

让我们考虑另一个变化:我们想在 HTML 基础 Web 应用的 index.html 模板中添加每天添加联系人数量的图表。事实证明,这个图表的计算很昂贵。

我们不想让图表生成阻塞 index.html 模板的渲染,因此我们将使用懒加载模式。为此,我们需要创建一个新端点 /graph,它返回该懒加载内容的 HTML:

@app.route("/graph")
def graph():
    graphInfo = Contact.computeGraphInfo(page=request.args.get('page', default=0, type=int))
    return render_template("graph.html", info=graphInfo)

注意,这里我们的控制器仍然“瘦”:它只是委托给模型,然后将结果交给视图。

容易忽略的是,我们为 Web 应用 HTML API 添加了一个新端点,但_我们没有将其添加到 JSON 数据 API 中_。因此,我们向其他非 Web 客户端承诺这个(专用的)端点——它完全由我们的 UI 需求驱动——将永远存在。

由于我们不向所有客户端承诺这个数据将永远在 /graph 可用,并且由于我们在基于 HTML 的 Web 应用中使用超媒体作为应用状态的引擎,我们可以自由地稍后移除或重构这个 URL。

或许某些数据库优化突然使图表计算变快,我们可以将其内联包含在对 /contacts 的响应中:我们可以移除这个端点,因为我们没有将其暴露给其他客户端,它只是为了支持我们的 Web 应用。

因此,我们为超媒体 API 获得了我们想要的灵活性,并为 JSON 数据 API 获得了我们想要的功能

在 MVC 方面,最重要的是要注意,因为我们的领域逻辑已收集在模型中,我们可以灵活地变化这两个 API,同时实现大量代码重用。是的,JSON 和 HTML 控制器最初有很多相似性,但随着时间推移它们分化了。

同时,我们没有重复模型逻辑:两个控制器都保持相对“瘦”,委托给我们的模型对象来完成大部分工作。

我们的两个 API 是解耦的,而我们的领域逻辑保持集中。

(注意,这也解释了为什么我倾向于不使用内容协商,并从同一端点返回 HTML 和 JSON。)

MVC 框架

许多较旧的 Web 框架,如 SpringASP.NET、Rails 都有非常强的 MVC 概念,允许你以这种方式极有效地分离你的逻辑。

Django 有一种名为 MVT 的变体。

这种对 MVC 的强大支持是这些框架与 htmx 搭配得非常好的一个原因,那些社区对此很兴奋。

而且,虽然上面的示例显然偏向面向对象编程,但相同的想法也可以应用于函数式上下文中。

结论

我希望,如果你对它不熟悉,这能给你一个好的 MVC 概念感觉,并展示通过在 Web 应用中采用这种组织原则,你可以有效地解耦你的 API,同时避免大量的代码重复。

</>