我已经写了很多关于超媒体 API 与数据(JSON)API 的内容,包括两者之间的区别、 REST “真正”含义是什么,以及为什么 HATEOAS 并不那么糟糕,只要你的 API 与 Hypermedia Client 交互。
当我与来自“REST 就是 HTTP 上的 JSON”世界(即正常世界)的人进行讨论时,我经常需要处理很多语言和概念问题:
最后一点常常让习惯于单一通用 JSON API 的人觉得愚蠢:为什么要有两个 API,当你可以有一个单一的 API 来满足各种类型的客户端时?我在上面的文章中尽力回答了这个问题,但这是一个合理的疑问。
相比于拥有一个通用 API,这在某些方面似乎是(而且确实是)额外的工作。
在对话进行到这里时,那些大致同意我对 REST、Hypermedia-Driven Applications 等的看法的人常常会插话,说类似这样的话:
“哦,这很简单,你只需使用_内容协商_,它是 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 的一个有趣功能。
据我所知,是 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 部分,而且,进一步说,计算这些相关联系人由于某种原因很昂贵。
在这种情况下,你可能选择使用 Lazy Loading 模式来推迟加载相关联系人,直到初始联系人详情屏幕渲染之后。这会改善页面给用户带来的感知性能。
如果你这样做,你可能会将懒加载的内容放在端点 /contacts/:id/related。
现在,后来,也许你能够优化相关联系人的计算。这时你可能会选择移除 /contacts/:id/related 端点,只在初始页面渲染中渲染相关联系人信息。
所有这些对于你的超媒体 API 来说都没问题:超媒体通过统一接口和 HATEOAS 设计 来处理这类变化。
然而,你的 JSON API……就不是这样了。
你的 JSON API 应该保持稳定。你不能随意添加和移除端点。是的,你可以让_某些_端点以 JSON 或 HTML 响应,而其他端点只以 HTML 响应,但这会变得混乱。例如,如果你不小心复制粘贴了错误的代码怎么办。
考虑到所有这些,以及速率限制等因素,我认为你可以为 JSON API 和超媒体 API 之间进行关注点分离提出强有力的论据。
(是的,我知道提出 Locality of Behaviour 一词的人在做关注点分离论据的讽刺。)
替代方案,正如我在 Splitting Your APIs 中提倡的,呃,好吧,将你的 API 分离。这意味着为你的 JSON API 和超媒体(HTML)API 提供不同的路径(或子域名,或其他)。
回到我们的联系人 API,我们可能会有以下设置:
/api/v1/contacts/contacts这种布局意味着两个不同的控制器,我说这是好事:JSON API 控制器可以实现 JSON API 的要求:速率限制、稳定性,也许像 GraphQL 这样的表达性查询机制。
同时,你的 超媒体 API(实际上,只是你的超媒体驱动应用程序端点)可以随着用户界面需求的变化而剧烈变化,具有高度优化的数据库查询、支持特殊 UI 需求的端点等。
通过分离这两个关注点,你的 JSON API 可以稳定、规律且低维护,而你的超媒体 API 可以混乱、专用且灵活。每个都有自己的控制器环境来茁壮成长,而不会相互冲突。
这就是为什么我更喜欢将 JSON 和超媒体 API 分离到独立的控制器中,而不是使用 HTTP 内容协商来尝试为两者重用控制器。