当 RESTful 路径设计遇到不可控的第三方统计

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
API 设计RESTful可观测性监控

我以前设计单个资源接口时,很自然地会把 ID 放进路径:

GET    /api/networks/abc123
PATCH  /api/networks/abc123
DELETE /api/networks/abc123

这套写法没有什么问题。路径表达资源层级,HTTP 方法表达读取、修改和删除,很多公开 API 都采用这种结构。Google 的 API 设计指南也建议资源名称由集合和资源 ID 交替组成,例如 publishers/123/books/les-miserables

真正让我改主意的不是客户端调用是否好看,而是一套无法控制的第三方统计工具。

它不认识服务端的路由模板,只按实际 pathname 归类请求。于是同一个接口会变成这样:

/api/networks/abc123
/api/networks/def456
/api/networks/ghi789

平台把它们当成三个接口。资源越多,统计页面里的接口就越多。访问次数被拆散,平均耗时和失败率也失去了参考意义。我想知道“读取网络详情”这个操作最近慢不慢,最后却只能看到一长串各出现一两次的地址。

理想的监控不会这样统计

在可以控制的监控系统里,实际 URL 和指标中的路由应该是两回事。

请求可以是:

/api/networks/abc123

指标使用的路由模板应该是:

/api/networks/:networkId

OpenTelemetry 的 HTTP 语义约定专门定义了 http.route。它要求这个属性保持低基数,还明确指出:框架不提供路由信息时,不能拿真实 URI path 冒充路由模板。

Prometheus 对标签也有相同的限制。用户 ID、邮箱和其他不断增长的值不适合放进指标标签,因为每一种标签组合都会产生新的时间序列。把资源 ID 当作接口名称,本质上也在制造同类问题。

如果应用自己采集指标,可以在路由匹配后记录模板:

method=GET
route=/api/networks/:networkId
status=200
duration=12ms

这时没有必要为了监控修改 URL。保留资源型路径,同时让指标按模板聚合即可。

麻烦在于监控系统不一定归我控制

实际项目经常没有这么理想。请求统计可能来自小程序平台、API 网关、CDN 控制台或某个封闭的分析服务。它们有时只显示真实 pathname,既不读取应用框架匹配到的路由,也不允许配置正则归一化。

在这种环境里,反复解释“正确做法是记录 http.route”没有用。第三方平台拿不到这个字段,我也改不了它。

这改变了 API 设计的前提。

原来的路径在 HTTP 语义上更整齐,但它会让现有监控数据无法使用。监控不是设计完成后的装饰。线上接口出了问题,我需要知道哪个操作访问最多、哪个操作最慢,以及失败从什么时候开始增加。如果平台是唯一能长期保存这些数据的地方,它的统计方式就是运行约束。

固定 pathname,把变量移出去

在这个约束下,我更愿意让公开 pathname 保持固定:

GET  /api/network/list
GET  /api/network/detail?networkId=abc123
POST /api/network/create
POST /api/network/update
POST /api/network/remove

修改和删除所需的 ID 放进 JSON 请求体:

{
  "networkId": "abc123",
  "version": 3
}

第三方平台现在只会看到几个稳定的接口名称:

/api/network/list
/api/network/detail
/api/network/create
/api/network/update
/api/network/remove

每个业务操作都有独立的访问量、耗时和失败率。资源数量增加不会污染接口列表。

这里还有一个容易漏掉的情况:第三方平台可能连 HTTP 方法也不区分。如果读取、修改和删除都使用 /api/network,只是分别发送 GETPATCHDELETE,平台仍可能把它们合并。此时使用固定的操作路径不是多余,它让统计维度与实际需要观察的业务操作一致。

这种设计更接近 RPC,不是教科书式的 RESTful API。我接受这个变化,因为接口主要服务于受控客户端,而第三方统计又是无法替换的基础设施。换一个能正确识别路由模板的环境,我未必还会这样设计。

查询参数并不等于可选筛选条件

常见经验是:路径参数定位资源,查询参数负责筛选、排序和分页。这条经验很好用,但它不是 URI 语法的强制规定。

RFC 3986 将 path 描述为层级结构,将 query 描述为非层级数据;两者共同参与资源识别。也就是说:

/api/network/detail?networkId=abc123

在 URI 层面没有问题。它只是放弃了一部分资源层级表达,换来一个稳定的 pathname。

当然,参数换了位置,语义成本并不会消失。接口文档需要明确 networkId 是必填的唯一标识,服务端仍要验证它。缓存、签名、网关规则和日志脱敏也要重新检查,不能假定所有基础设施都用同一种方式处理查询字符串。

还有一点需要说清楚:把 ID 移到查询参数或请求体不是安全措施。查询参数仍可能出现在访问日志、代理日志和错误报告里。令牌、密码等秘密不应该因为“不在路径中”就被放心传递。

我现在怎样决定参数放在哪里

我不会看到单个资源就立即写成 /:id,也不会因为某个平台统计不方便,就把所有 API 都改成动作路径。设计前先确认统计链路实际拿到了什么:

  • 它按完整 URL、pathname 还是路由模板聚合?
  • 它能否区分 HTTP 方法?
  • 我能否增加 http.route,或者配置路径归一化?
  • 哪些业务操作必须单独观察访问量、耗时和错误率?
  • 这套 API 是面向开放生态,还是只服务于几个可同步升级的客户端?

如果监控系统支持路由模板,资源 ID 留在路径里通常更清楚。应用只需保证指标使用低基数模板,不要记录真实 ID。

如果唯一可用的平台只能按 pathname 统计,又无法扩展,那么固定路径更实际。读取操作把标识放进查询参数,写操作放进请求体;平台不区分 HTTP 方法时,再用固定的操作名称拆开接口。

RESTful 设计提供了一套好用的默认语言,但默认语言不应该盖住运行环境。一个在图上很漂亮、上线后却无法统计的接口,对我来说并不比稍微偏向 RPC 的固定路径更好维护。

路径形态确定以后,请求校验、客户端类型和接口文档仍要避免各写一份。相关的契约组织方式可以继续阅读Hono、Zod、RPC 与 OpenAPI 如何保持同一份契约

参考资料