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

卡森·格罗斯

我已经写了很多关于超媒体 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 # default rendering logic
    format.json { render json: @contacts }
  end
end

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

但是,如果客户端的Accept 标头的值为application/json,Rails 将把联系人渲染成一个 JSON 数组,供客户端使用。

一个很酷的技巧:你可以保持所有控制器逻辑(例如查找联系人)相同,只需使用一点 ruby/Rails 魔法,就可以使用内容协商渲染两种不同的响应类型。在正常的 Model/View/Controller 逻辑之上几乎不需要额外的工作。

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

#那么问题出在哪里呢?

那么,为什么我认为这不是将 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 内容协商来尝试重用控制器来处理两者。

</>