我经常看到人们对使用 htmx 和超媒体的反对意见,大致是以下内容:
从服务器返回 HTML(而不是 JSON)的问题在于,你可能还想为移动应用程序提供服务,并且不想重复你的 API。
我已经在 另一篇论文 中概述了,我认为你应该将你的 JSON API 和你的超媒体 API 分成单独的组件。
在那篇论文中,我明确建议(在一定程度上)“重复”你的 API,以便将返回 HTML 的“快速变化” Web 应用程序 API 端点与稳定、常规且表达性强的 JSON 数据 API 分离开来。
在回顾我与人们就这个想法进行的对话时,我认为我一直在假设人们熟悉一种模式,而这种模式并没有像我一样被许多人所熟悉:模型-视图-控制器 (MVC) 模式。
我有点震惊地发现,在最近的一次播客中,许多年轻的 Web 开发人员几乎没有 MVC 方面的经验。这可能是由于单页面应用程序成为主流时发生的 前端/后端 分裂。
MVC 是一种简单的模式,早于 Web,并且可以与几乎任何向用户提供图形界面的程序一起使用。
大致思路如下:
“模型”层包含你的 “领域模型”。此层包含特定于应用程序的领域逻辑。因此,例如,联系人管理应用程序将在此层中包含与联系人相关的逻辑。它不会包含对可视元素的引用,并且应该相对“纯净”。
“视图”层包含呈现给用户的“视图”或可视元素。此层通常(尽管并非总是)与模型值一起使用,以向用户呈现可视信息。
最后,“控制器”层协调这两个层:例如,它可能会接收来自用户的更新,更新模型,然后将更新后的模型传递给视图,以便向用户显示更新的用户界面。
有很多变化,但这就是想法。
在 Web 开发的早期,许多服务器端框架明确采用 MVC 模式。我最熟悉的实现是 Ruby On Rails,它对这些主题都有文档:模型 持久化到数据库,视图 用于生成 HTML 视图,以及 控制器 用于协调两者之间的关系。
在 Rails 中,大致思路是:
Rails 具有一个相当标准(尽管有些“肤浅”和简化)的 MVC 模式实现,它建立在底层的 HTML、HTTP 请求/响应生命周期之上。
在 Rails 社区中,经常出现的一个概念是 “胖模型,瘦控制器”。这里的想法是你的控制器应该相对简单,可能只在模型上调用一两个方法,然后立即将结果传递给视图。
另一方面,模型可以更“厚实”,包含大量特定于领域的逻辑。(有人反对说这会导致 上帝对象,但现在让我们先把这个放到一边。)
当我们逐步了解 MVC 模式的简单示例及其有用之处时,让我们牢记胖模型/瘦控制器的这个概念。
对于我们的示例,让我们看一下我最喜欢的示例之一:在线联系人应用程序。下面是该应用程序的控制器方法,它通过生成 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)
这里我使用的是 Python 和 Flask,因为我在我的 Hypermedia Systems 书中使用了它们。
在这里,你可以看到控制器非常“瘦”:它只是通过 Contact
模型对象查找联系人,从请求中传递 page
参数。
这是非常典型的:控制器的任务是将 HTTP 请求映射到某些领域逻辑,从 HTTP 特定的信息中提取出来并将其转换为模型可以理解的数据,例如页码。
然后,控制器将分页的联系人集合传递给 index.html
模板,以便将其呈现为 HTML 页面,并将其发送回用户。
现在,另一方面,Contact
模型在内部可能相对“胖”:all()
方法内部可能包含很多领域逻辑,这些逻辑会进行数据库查找、对数据进行分页、可能应用一些转换或业务规则等等。这没什么问题,该逻辑封装在 Contact 模型中,控制器不需要处理它。
因此,如果我们有这个相对完善的 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 添加速率限制,以防止 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。而且,因为我们已经将两者分开了,所以我们可以实现这一点。
让我们考虑另一个更改:我们想向我们基于 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。)
许多较旧的 Web 框架,如 Spring、ASP.NET、Rails 都有非常强大的 MVC 概念,这些概念使你能够以这种方式非常有效地将逻辑拆分出来。
Django 对这个想法有一个变体,称为 MVT。
对 MVC 的强大支持是这些框架与 htmx 配合得非常好的原因之一,这些社区对它感到兴奋。
而且,虽然上面的示例显然偏向于 面向对象 编程,但同样的想法也可以应用于函数式环境。
我希望,如果你以前没有接触过,这会让你对 MVC 的概念有一个很好的了解,并展示了通过在你的 Web 应用程序中采用这种组织原则,你如何能够有效地解耦你的 API,同时避免大量的代码重复。