我看到的关于使用 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 在底层的 HTML、HTTP 请求/响应生命周期之上,有一个相当标准(尽管有些“浅显”和简化)的 MVC 模式实现。
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,因为我在 超媒体系统 一书中使用它们。
你可以看到控制器非常“瘦”:它仅通过 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 的同时避免大量代码重复。