为什么我不倾向于使用内容协商

Carson Gross

我已经写了很多关于超媒体 API 与数据(JSON)API 的文章,包括两者之间的区别REST “真正”的含义以及为什么HATEOAS 并不像你的 API 与超媒体客户端交互时那么糟糕。

当我与来自“REST 就是 HTTP 上的 JSON”世界(也就是普通世界)的人们进行讨论时,我常常不得不处理许多语言和概念问题:

最后一点常常让习惯于单一、通用 JSON API 的人觉得愚蠢:当你 可以拥有一个能满足任意类型客户端需求的单一 API 时,为什么要拥有两个 API?我试图在上面的文章中尽可能好地回答这个问题,但这无疑是一个合理的问题。

与拥有一个通用 API 相比,这似乎在某种程度上是额外的工作。

在谈话进行到这里时,某个大体上同意我对 REST、超媒体驱动应用程序等看法的人通常会跳出来说类似这样的话:

“哦,这很简单,你只需使用内容协商,它是 HTTP 内置的!”

既然不满足于只疏远通用 JSON API 爱好者,那就让我现在也疏远一下 我那些曾经的超媒体爱好者盟友吧:

我认为对于大多数应用程序,内容协商通常不是 返回 JSON 和 HTML 的正确方法。

什么是内容协商?

首先,什么是“内容协商”?

内容协商是 HTTP 的一个特性, 允许客户端协商服务器响应的内容类型。HTTP 中实现的完整处理 超出了本文的范围,但让我们考虑 HTTP 中最著名的内容协商机制,即 Accept 请求头

Accept 请求头允许客户端(例如浏览器)指示它愿意从服务器接收的响应的 MIME 类型。

该标头的一个示例值是:

Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8

这个 Accept 标头告诉服务器客户端愿意接受哪些格式。偏好通过 q 权重因子表示。通配符用星号 * 表示。

在这个例子中,客户端说的是:

我最希望接收 text/html、application/xhtml+xml 或 image/webp。其次我偏好 application/xml。最后,我会接受你提供的任何内容。

然后服务器可以获取这些信息并确定向客户端提供的最佳内容类型。

这就是“内容协商”的行为,它无疑是 HTTP 的一个有趣特性。

在 API 中使用内容协商

据我所知,是 Ruby On Rails 社区首先大规模地 使用内容协商从同一个 URL 提供 HTML 和 JSON(以及其他)格式。

在 Rails 中,这是通过控制器中可用的 respond_to 辅助方法完成的。

抛开 Rails 的细节不谈,你可能有一个像 HTTP GET/contacts 的请求,最终会调用 ContactsController 类中的一个函数,看起来像这样:

def index
  @contacts = Contacts.all

  respond_to do |format|
    format.html # 默认渲染逻辑
    format.json { render json: @contacts }
  end
end

通过使用 respond_to 辅助方法,如果客户端使用上面的 Accept 标头发出请求,控制器 将使用 Rails 模板系统渲染 HTML 响应。

然而,如果客户端的 Accept 标头的值是 application/json,Rails 会将联系人 渲染为 JSON 数组给客户端。

一个相当巧妙的技巧:你可以保持所有控制器逻辑(如查找联系人)不变,只需使用一点 ruby/Rails 魔法来通过内容协商渲染两种不同的响应类型。在正常的模型/视图/控制器逻辑之上几乎不需要任何额外的工作。

你可以理解人们为什么喜欢这个想法!

那么问题出在哪里?

那么为什么我不认为这是拆分 JSON 和 HTML API 的好方法呢?

这归结于我前面提到的JSON API 和超媒体(HTML)API 之间的差异。具体来说:

虽然所有这些差异都很重要,并且会影响你的控制器代码,将其拉向两个不同的方向, 但真正让我经常选择不在应用程序中使用内容协商的是第一项和最后一项。

你的 JSON API 需要是一组稳定的端点,客户端代码可以依赖它。

另一方面,你的超媒体 API 可以根据你的应用程序的用户界面需求发生巨大变化。

这两者混合得不好。

给你一个具体的例子,考虑一个渲染联系人详情的端点,比如在 /contacts/:id (其中 :id 是一个包含要渲染的联系人 ID 的参数)。假设该页面有一个“相关联系人” UI 部分,并且由于某种原因计算这些相关联系人的开销很大。

在这种情况下,你可能会选择使用 延迟加载 模式来延迟 加载相关联系人,直到初始联系人详情屏幕渲染完成之后。这提高了用户对页面性能的感知。

如果你这样做,你可能会将延迟加载的内容放在端点 /contacts/:id/related

现在,稍后,也许你能够优化相关联系人的计算。此时你可能会选择 移除 /contacts/:id/related 端点,并直接在初始页面渲染中渲染相关联系人信息。

所有这一切对你的超媒体 API 来说都很好:超媒体通过统一接口和 HATEOAS设计来处理这类变化的。

然而,你的 JSON API……就不一样了。

你的 JSON API 应该保持稳定。你不能随意添加和删除端点。 是的,你可以让一些端点响应 JSON 或 HTML,而其他端点只响应 HTML,但这会变得混乱。如果你不小心在某个地方复制粘贴了错误的代码怎么办。

考虑到所有这些,以及速率限制等因素,我认为你可以有力地论证 在 JSON API 和超媒体 API 之间应该有一个 关注点分离

(是的,我意识到提出 SoC 论点的人就是那个创造了 行为局部性 术语的人。)

那么替代方案是什么?

替代方案是,正如我在拆分你的 API 中所倡导的,嗯,拆分你的 API。这意味着为你的 JSON API 和超媒体(HTML)API 提供不同的路径(或子域,或其他任何形式)。

回到我们的联系人 API,我们可能有以下内容:

这种布局意味着两个不同的控制器,而我敢说,这是一件好事:JSON API 控制器可以实现 JSON API 的要求:速率限制、稳定性,也许还有一个像 GraphQL 这样富有表现力的查询机制。

同时,你的 超媒体 API(实际上只是你的超媒体驱动应用程序的端点)可以根据你的用户界面 需求发生巨大变化,具有高度调优的数据库查询、支持特殊 UI 需求的端点等等。

通过分离这两个关注点,你的 JSON API 可以是稳定、规范且低维护的,而你的超媒体 API 可以是 混乱、专门化且灵活的。每个都在自己的控制器环境中茁壮成长,不会相互冲突。

这就是为什么我更喜欢将 JSON 和超媒体 API 拆分成单独的控制器,而不是尝试使用 HTTP 内容 协商来重用控制器处理两者。

</>