滑坡
Source: Dev.to
Source:
介绍
最近在 LinkedIn 上的一篇帖子讨论了使用查询参数来请求资源的替代表示。其思路是仅投影所需的字段,例如:
GET /users/1
响应
HTTP/1.1 200 OK
Content-Type: application/json
{"username": "Name", "displayName": "Display Name"}
如果客户端只需要 displayName,请求可以写成:
GET /users/1?fields=displayName
响应
HTTP/1.1 200 OK
Content-Type: application/json
{"displayName": "Display Name"}
乍一看这似乎是合理的,但它引发了关于查询组件正确使用方式的疑问。
RFC 3986 概述
RFC 3986 定义了 URI 的通用语法:
scheme ":" hier-part [ "?" query ] [ "#" fragment ]
- Scheme – 协议标识符(例如
http、ftp)。不区分大小写。 - Authority – 主机、可选端口和可选用户信息(例如
//www.example.com:80)。 - Path – 用于标识资源的层级段序列。
- Query – 非层级数据,通常是
key=value键值对,由?引入。 - Fragment – 次级资源标识符(例如页面锚点),由
#引入;由用户代理处理,而非服务器。
规范将查询字符串视为一组不透明的键‑值对。它不为诸如 fields、sort 或 filter 等参数规定任何语义。
查询参数作为迷你 DSL
使用查询参数作为指令(例如 fields、sort、filter)实际上在有限的 HTTP 动词之上创建了一种领域特定语言(DSL)。示例:
GET /users/1?fields=displayName
GET /users/?sort=
GET /users/?filter=
GET /users?id=1&fields=displayName
这里 id 是搜索参数,而 fields 是投影指令。随着时间推移,这类约定可能会成为事实标准,迫使 API 设计者维护额外的文档并将这些关键字视为保留字。
为什么这会成为问题
- URI 的不透明性 – RFC 3986 将
?之后的所有内容视为不透明;将其解释为 DSL 会引入隐藏的契约。 - 动词与指令的耦合 – 有限的 HTTP 方法集合被额外语义所负载。
- 维护负担 – 每新增一个指令(例如
fields、sort)都需要文档说明并让客户端知晓。
内容协商作为替代方案
投影本质上是一个表示(representation)的问题。HTTP 已经提供了一种机制,让客户端请求特定的表示形式:内容协商。
GET /users/1
Accept: application/json; fields=displayName
Accept 头部可以携带媒体类型参数,例如:
GET /users/1
Accept: application/json; q=0.9; charset=UTF-8
这些参数是开放式的,服务器可以根据需要进行解释。
RFC 6906 – Profile 参数
RFC 6906 引入了一种更结构化的方式来请求表示的变体:
GET /users/1
Accept: application/json; profile="http://www.example.com/profiles/user-summary"
或者,也可以定义自定义媒体类型:
GET /users/1
Accept: application/vnd.user.displayname+json
实际考虑
-
链接 – 在创建资源时,使用已经包含所需投影的 URL 更为简单:
Location: /users/1?fields=displayName使用额外的头部来传达投影指令会使响应变得复杂。
-
缓存 – 缓存本身并不了解自定义查询参数的语义。正确的缓存需要显式的
Vary:头部,不同实现的行为可能会有所差异,导致缓存未命中或数据陈旧。
结论
查询参数快捷方式的普遍使用并不自动使其成为架构最佳实践。虽然它们很实用,但会引入隐藏的契约,可能削弱 REST 所追求的清晰性和互操作性。请记住:
“架构的衰退不是因为错误的决定,而是因为被遗忘的理由。”
明确地将此类快捷方式记录为 约定 而非标准,有助于保留其初衷,并防止偏离既定 HTTP 语义的滑坡。