Appearance
URI 设计规范
本章导读:URI 是 REST 契约中最"长寿"的部分——它会出现在书签、日志、第三方系统、爬虫与用户分享里,几乎不可回收。本章从 RFC 3986 的语法底线讲起,给出集合命名、层级、字符选择、查询参数等全套规则,最后附正反例对照表。
1. 先守住语法底线(RFC 3986)
一个 URI 的通用语法:
scheme ":" ["//" authority] path ["?" query] ["#" fragment]
https://api.example.com:443/v1/articles/42?fields=title#section-2
└─scheme─┘ └─────authority─────┘ └──path──┘ └─query─┘ └fragment┘路径段(path segment)之间以 / 分隔;? 之后是查询串(k=v&k2=v2);# fragment 不会发送到服务器(纯客户端定位),API 设计不要用。
必须遵守的字符规则
| 类别 | 字符 | 规则 |
|---|---|---|
| 非保留字符 | A-Z a-z 0-9 - . _ ~ | URI 设计只应该用这些 |
| 保留字符(gen-delims) | : / ? # [ ] @ | 有结构含义,出现即改变解析 |
| 子分隔符 | ! $ & ' ( ) * + , ; = | 路径段内可用但有歧义,慎用 |
| 其它一切 | 中文、空格、%… | 必须百分号编码(UTF-8 后编码),如 空格→%20 |
推论:
- 路径参数值可能含特殊字符时,必须编码后放入(
/files/2026%2F09%2Freport.pdf),或者改用查询参数承载。 - 不要在路径里出现
%2F(编码后的斜杠)——很多服务器/网关会先行解码导致路由错乱,含斜杠的 ID 建议走 query 或 base64url。 +在查询串中历史上有"空格"歧义(表单编码),路径中无语义——统一用%20表示空格最安全。
2. 风格约定(社区共识)
2.1 层级结构
https://{host}/{base-path}/{version}/{resources}/{resource-id}/{sub-resources}/{sub-id}
│ │ │ │ │
│ │ │ │ └ 定位单个资源
│ │ └────────┴ 名词、复数、kebab-case
│ └ 可选:多服务共享域名时的前缀 /api
└ 版本(见版本一章)- 路径表达"名词的从属",深度 ≤ 2 层资源(见资源与资源层次)。
- 一切过滤/排序/分页/字段选择放查询参数,不进路径:理由:路径标识资源,查询参数是"对该资源表示的定制"——这是 RFC 3986 中 path 与 query 的官方分工。
❌ /articles/author/7/published/true/page/2 ✅ /articles?authorId=7&status=published&page=2
2.2 大小写
推荐全小写 + 连字符(kebab-case):
✅ /user-profiles ❌ /UserProfiles
✅ /orders/123/items ❌ /orders/123/getItems原因:URI 的路径大小写敏感性取决于部署(Windows IIS 默认不敏感、Linux nginx 敏感),混用即事故;连字符对 SEO/可读性最优(Google 指南推荐),下划线在渲染时易被链接下划线淹没。
2.3 复数与单数
- 集合资源用复数:
/articles、/articles/42/comments。 - 例外:单例资源(每用户唯一的资源)用单数合理:
/users/7/settings、/sessions/current、/me。
2.4 尾斜杠、扩展名、文件式片段
❌ /articles/42/ ← 尾斜杠:统一不使用(团队一致即可,混用会分裂缓存键)
❌ /articles/42.json ← 内容协商走 Accept 头,不用扩展名
✅ /articles/42 ← 表示格式由媒体类型决定(Google AIP-127/128 明确禁止 .json 式扩展名;需要多格式时用 Accept 或专门的表示子资源 /articles/42.pdf 这种"资源本身即文件"的例外。)
2.5 查询参数命名
统一 camelCase(与 JSON 字段一致,见响应结构规范):
GET /articles?authorId=7&status=published&sort=-createdAt&page=2&pageSize=20- 布尔参数用"值"表达:
?published=true(参数名是字段名),不推荐?published裸值或?is_published混乱。 - 列表值:重复参数(
?tag=a&tag=b)或逗号分隔(?tag=a,b)皆可,团队二选一并在 OpenAPI 中声明explode。 - 参数名用完整单词,缩写只留白名单:
id、uuid、url、uri、max、page。
3. 特殊操作的 URI 模式
| 场景 | 模式 | 例 |
|---|---|---|
| 动作无法名词化 | POST /{resource}/{id}/{action} | POST /orders/7/cancel |
| 单例子资源 | GET/PUT /{resource}/{id}/{singleton} | GET /users/7/settings |
| "当前"语义 | 保留字 current 或 /me | GET /users/me、GET /sessions/current |
| 自定义方法(Google AIP-136) | POST /{resource}/{id}:action | POST /articles/42:publish |
| 批量 | POST /{resource}:batchCreate 等 | 见批量操作设计 |
| 作业/异步进度 | GET /{resource}/{id}/jobs/{jobId} 或独立 /jobs/{id} | 见实战篇 |
:是保留字符但允许出现在路径段中(AIP-136 依赖此点);若团队不想引入非常规字符,退回/{id}/publish形式。
4. 域名与路径布局
对外公开 API: https://api.example.com/v1/... ← 独立子域,便于单独限流/证书/版本发布
多产品线: https://api.example.com/blog/v1/... ← 二级路径区分服务
单体应用内: https://www.example.com/api/v1/... ← 与页面同域,减少 CORS- 永远 HTTPS:HTTP 明文下 Token 与隐私数据等同公开;HSTS 建议开启(详见安全章)。
- 环境用域名/子域区分(
staging.api.example.com),不在 URI 里塞/test/。
5. 正反例对照速查
| 反例 | 正例 | 违反的点 |
|---|---|---|
/getArticleList | GET /articles | 动词、单复数 |
/api/v1/article/getById?id=1 | /api/v1/articles/1 | RPC 式路径 |
/Articles?Sort_By=CREATED_AT | /articles?sort=-createdAt | 大小写混排 |
/v1/articles/42/comments/7/replies/3 | /replies/3(≤2 层规则) | 层级过深 |
/articles?page=2&limit=20&offset=0&from=10&size=400 | 固定一套分页参数 | 同义参数并存 |
/user/7/ | /users/7 | 单数 + 尾斜杠 |
GET /articles/deleteAll | (不存在——删除用 DELETE,批量删除显式设计) | 动词滥用、用安全方法做删除 |
/download?file=/etc/passwd | 资源 ID 化 + 输入校验 | 路径注入(安全反例) |
6. URI 的"不可变性契约"
上线的 URI 会被缓存与外部系统集成,把它当 API 的"主键"对待:
- 路由变更用
301 Moved Permanently(永久)或308(保留方法)重定向至少一个弃用周期——见 RFC 9110 §15.4。 301可能把 POST 变成 GET(历史客户端行为),需要保持方法不变的重定向用308。- 资源永久消失返回
410 Gone,而不是含糊的 404——帮助合法客户端清理失效链接。 - 版本升级不改旧 URI(见下一章)。
7. 本章小结
- 语法底线来自 RFC 3986:非保留字符 + 正确百分号编码;路径标识资源、查询定制表示。
- 风格三件套:小写 kebab-case、资源复数、无扩展名无尾斜杠——核心是团队一致。
- URI 是持久契约:重定向、弃用、410 是它的新陈代谢机制。
8. 下一步
版本信息放哪里?URI、查询、还是头部? → 版本设计