资源标识 — 给资源起名字¶
这一章讲什么: 一个 REST API 对外的全部「脸面」是一串 URL。这一章讲两件决定这张脸能用多久的事: 怎么起名(四条命名约定,每条都对应一种具体的歧义)和怎么改版(四种版本策略,核心是「让旧客户端继续活」)。 读完你会明白为什么
/users比/user好、为什么CreateBook是个坏名字、为什么书里说不选版本策略的后果是「你不再创新,开始谈判」。
1. 顶层全景:一个 URI 的三层结构¶
api.example.com /v1 /users /123?active=true
└────── 域名 ──────┘ 版本 命名空间 资源
│ │ │
│ │ └─ 单个资源(123 号用户)
│ └────────── 集合(所有用户)+ 层级路径
└───────────────── 版本策略(这一章的第 4 节)
图说:本章管的就是中间这两段怎么写,以及最左那段版本号放不放、放哪。
(本章示例里的 URL 省略了协议头,只为排版清爽;真实请求一律带协议。)
先解释本章反复出现的三个词。
URI(Uniform Resource Identifier,统一资源标识符)就是你在浏览器地址栏看到的那种地址;它是资源在 Web 上的「街道地址」——楼里住户换了一茬又一茬,门牌号不变,URI 也应当不随资源内容变化而变化1。
资源是 API 暴露给客户端的核心实体:一个用户、一本书、一张订单。后端的实现、数据库的表结构都可能翻天覆地,但资源这个名字要稳——名字就是契约。
可寻址性(addressability)是本章的理论基石:在 URI 之前,数据的位置通常是「一套指令」——比如一段只能在这台机器上跑的 SQL(向数据库要数据的标准语言)查询,或者只能在公司内网 Windows 机器上访问的网络共享路径。URI 用一套全球统一的地址格式替换了这些本地约定,不同平台、不同系统第一次可以用同一个地址指向同一个东西2。
本章的主线例子:一家图书馆 API 的表面¶
后面每一节都用同一个具体输入走完:我们要给一座图书馆设计 API,核心资源是书、作者、用户。
起点(坏例子,先感受错法):
GET example.org/CreateBook ← 动词进了名字,GET 它到底是在干嘛?
GET example.org/getBook?id=42 ← 同上,动作靠 URL 说
GET api.example.org/user ← 单数:是一个用户还是所有用户?
GET api.example.org/UserProfiles ← 驼峰,词边界糊了
(某天要加版本…… URL 里没地方放,客户端全断)
终点(本章讲完后的样子):
GET libraryapi.com/books 所有书(集合)
GET libraryapi.com/books/fiction/jk-rowling/harry-potter
按流派/作者/书名层级定位
POST libraryapi.com/v2/books 创建(v2 版接口)
GET libraryapi.com/books/42?fields=id,title 只回要的字段
图说:每一节拆掉起点里的一个错。这五步就是本章的目录。
(上面 libraryapi.com 的具体路径取自原书的图书分类例子;字段过滤参数是第 7 章的内容,这里先记住「URL 上能挂参数」这回事。)
2. 约定一:名字用名词,不用动词¶
解决什么问题¶
API 的端点名字,本质是借句子结构做类比。一个句子有主语(谁)和谓语(对它干什么);一个端点也该如此——资源是主语,HTTP 方法是谓语。如果把动作塞进资源名,这个句子就病了:客户端拿着一个端点,根本分不清你要它干嘛3。
怎么做:同一个创建操作,两种名字¶
原书的例子,我们为图书馆改写成书:
坏: GET example.org/CreateBook
→ 这是在 GET(取)一本叫 CreateBook 的书,还是在创建一本书?
→ 原书的原话:像在说「我要去图书馆 get a book and write it」
好: POST example.org/books ← 创建:POST 作用于 books 集合
GET example.org/books/{id} ← 读取:GET 作用于具体一本
图说:名字只回答「这是什么」,动作一律由方法回答。PUT/PATCH 管改、DELETE 管删,同理。
注意好例子里 POST 指向的是集合(/books),GET 指向的是个体(/books/{id})——这条规则下一节展开。原书还给了对称的另两个例子:更新用 PUT /customers/{id} 和 PATCH /customers/{id},删除用 DELETE /products/{id},名字里都没有动词4。
为什么有效,边界在哪¶
有效是因为消歧:看到 CreateBook,你要查文档才知道它是干什么的;看到 POST /books,任何一个懂 HTTP 的人不用查就懂——API 变得自文档化。边界有两条:一是现实中确有一些「动作」很难名词化(比如「重发验证邮件」),第 03 章讲方法时会回来处理这类情况(把动作建模成资源,例如 POST /emails/verification:resend 这类写法不在原书范围,我们的建议见 03 章 §5);二是这一约定管的是公开的表面,内部代码怎么命名不受它管。
3. 约定二:集合用复数 + 约定三:复合词用连字符¶
复数:消灭「是一个还是所有」的歧义¶
/users 一眼是一组用户;/user 是某一个?最新的一个?还是所有用户的笔误?复数把这个歧义从根上消掉,并且天然区分了集合级操作和个体级操作两个不同的端点5。
原书用三个真实 API 作证,我们把它们排成一张对照表(三个例子的措辞与原书一致):
| 公司的 API | 好的写法(复数) | 坏的写法(单数)会引来什么误会 |
|---|---|---|
| GitHub:仓库活动 | GET /repos/{owner}/{repo}/events(所有事件) |
/event 是全部?最近一条?随便一条? |
| PagerDuty:事故告警 | GET /incidents(所有事故)/ GET /incidents/{id}(单个) |
/incident 让人不敢确定返回的是列表还是单项 |
| Stripe:支付争议 | GET /v1/disputes(所有争议)/ GET /v1/disputes/{id} |
/dispute 会被读成「取最近一条争议」 |
连字符:把词边界还给读者(和搜索引擎)¶
复合资源名怎么拼?原书给的选择是连字符:/user-profiles、/order-items、/alert-notifications,而不是 camelCase(userProfiles)或下划线(user_profiles)。理由比「好看」实际:搜索引擎和爬虫抓到一个连字符分开的词,会把前后两段各自当成一个独立的词收录进目录——「user」和「profiles」各算一个词;而 camelCase 里的大写边界、下划线在不同工具里的待遇都不稳定,词边界容易糊掉。对自动化系统(搜索、读懂意思的程序)更友好,这一行是这个约定常被忽略的论据6。
边界¶
这两个约定都是一致性 > 形式:如果你的团队历史上就是下划线,硬改连字符的收益抵不上迁移成本——原书没有讨论这个,但「保持一致」是它反复强调的底层要求(见 §6 的结论:「选一个版本策略,坚持下去」的同一逻辑)。
4. 层级:用路径表达「谁属于谁」¶
解决什么问题¶
后端的数据模型常常是树状的:书按流派、作者、书名组织;用户按地区、部门组织;产品按品牌、品类组织。把树的形状写进 URI,客户端就拿到了一张能背的地图,不用查文档就能猜出任何资源的地址——原书称这种层级路径为命名空间(namespace,一层层的路径前缀),并把它归到「统一接口」约束之下:API 表面要反映后端数据的逻辑组织7。
怎么做:三个层级模板,走一遍实例化¶
原书的三个模板,我们逐个展开成实例:
① 图书: 模板 /books/{genre}/{author}/{book-title}
实例 /books/fiction/jk-rowling/harry-potter
树: /books → fiction → jk-rowling → harry-potter
② 员工: 模板 /users/{region}/{department}/{user-name}
实例 /users/us/marketing/john-doe
/users/canada/marketing/tim-fox
树: /users → us / canada → marketing → 具体的人
(注意:树跨了网络区域 A 和 B,但叶子节点的寻址方式完全一样——
这正是可寻址性的卖点:地址的形式与资源存在哪台机器上无关)
③ 商品: 模板 /products/{brand}/{category}/{product}
实例 /products/nike/sneakers/air-max
树: /products → nike / adidas → sneakers → air-max / samba
图说:每加一层 {},树的深度就反映到地址里。客户端从任意一层都能往下走。
值得停下来看 ② 的一个细节:这个命名空间横跨两个网络区域(美国区和加拿大区的服务器),但客户端看到的寻址方式完全一致。这呼应第 01 章「分层系统」约束——地址的表面结构把物理上的分散部署藏了起来8。
为什么有效,边界在哪¶
有效是因为认知负担:层级化的 URI 成了用户脑子里的心理地图,不用每次「重建」资源之间的关系;API 变复杂时,命名空间也给新增资源留好了挂点9。边界(书中未明写,我们补上):层级别嵌太深——每深一层,客户端要记住的背景就多一截,实践中两到三层之后就该考虑把深层资源提到顶层(如 /orders/{id}/items 与 /items?order={id} 之争);以及层级表达的是「包含/从属」,表达不了的关联(比如「用户收藏的书」这种多对多)别硬塞进路径。
5. 版本策略:API 一定会变,问题是别弄断别人¶
解决什么问题¶
只要 API 活得够久,必然遇到破坏性改动:字段改名、结构调整、行为变更。「破坏」的准确定义是:客户端按旧契约写的代码跑不通了。版本策略的全部目的,是让「用 v1 的老客户端」和「用 v2 的新客户端」同时活着,直到老客户端自然死光10。原书给了四种办法。
四种策略,逐个走一遍同一个场景¶
场景:电商平台要给商品列表加字段。v1 只回 product_name 和 price;v2 要加 description、category、availability。
策略一:URI 版本。 版本号写进路径:/v1/products 与 /v2/products 是两个地址。v1 客户端继续打 v1,拿到老格式;新客户打 v2,拿到新格式11。
GET api.ecommerce.com/v1/products
→ [{"product_name":"Wireless Mouse","price":25.99}, …]
GET api.ecommerce.com/v2/products
→ [{"product_name":"Wireless Mouse","price":25.99,
"description":"Ergonomic wireless mouse","category":"Electronics",
"availability":"In Stock"}, …]
图说:地址变了,但变化被隔离在 v2 路径下;v1 的响应一个字没动。
优点是简单、显眼——看 URL 就知道版本;缺点(书中未展开,我们补):URL 是永久的公共资产,/v1 会跟着 API 一辈子,而且违背「URI 指资源」的纯粹性(同一资源有了两个地址)。
策略二:头版本。 路径不动,版本放进自定义头:GET /tasks 加一行 X-API-Version: 3。客户端不带这个头时,可以由网关决定默认走最新版还是稳定版12。
GET /tasks
X-API-Version: 3
→ {"version":"3","tasks":[{id:101,title:"Complete quarterly report",…}]}
图说:URL 干净了,但版本藏进了请求头——看服务器日志时要多看一栏才查得到版本。
策略三:媒体类型版本。 版本放进 Accept 头,变成内容协商的一部分:Accept: application/vnd.ecommerceapi.v1+json。同一端点,客户端要什么版本的数据格式,服务器回什么——这是四种里最「REST 纯粹」的(版本是表述格式的一部分,URI 始终指向同一个资源),也是四种里实现最重的(要动内容协商机制)13。
GET /products + Accept: application/vnd.ecommerceapi.v1+json → v1 格式(手机客户端:轻)
GET /products + Accept: application/vnd.ecommerceapi.v2+json → v2 格式(Web 客户端:全)
策略四:查询参数版本。 GET /users/12345/profile?version=2——版本挂在查询串上。原书说它的适用面是「版本只针对个别资源」的场景:单端点多版本共存,客户端改个参数就切换14。
作者的判断:选哪种是权衡,不选是灾难¶
原书没有对四种策略排出优劣——它给的判断在结论章:「选一个,坚持下去。没有计划,一次改动会弄断每一个客户端。你停止创新,你开始谈判。」15这句话值得拆开:「停止创新」的意思是——没有版本策略时,任何改动都有打破客户端的风险,于是每次发版都变成和所有调用方的一轮谈判,改动的成本大到团队不敢改,创新就此停摆。四种策略的差异(书里散在各处,我们收拢成一张表):
| 比较项 | URI | 头 | 媒体类型 | 查询参数 |
|---|---|---|---|---|
| 看 URL 知道版本 | ✓ | ✗ | ✗ | ✓ |
| 服务器日志里好查 | ✓ | 一般 | 一般 | ✓ |
| 实现成本 | 最低 | 低 | 高(内容协商) | 低 |
| 「一个资源一个地址」的纯粹性 | ✗ | ✓ | ✓ | ✓ |
6. 作者的判断与证据¶
- 有真实世界证据的部分:复数命名的价值用 GitHub/PagerDuty/Stripe 三家现役 API 作证;层级命名空间给了三个完整模板与树图。这是本章的证据主力16。
- 作者的判断:「名词 > 动词」「连字符 > 驼峰」是从句子结构和可读性推出的规范主张,不是测量结论——原书用句子类比(主语/谓语)论证,没有用户实验。这是行业主流约定,但「动作型端点一律消灭」在复杂业务里做不到,我们的边界一节已说明。
- 版本策略的选择:原书保持中立,只强调「必须有」。这个中立是诚实也是留白——实际行业里 URI 版本(Stripe 就是
/v1/...)与头/媒体类型版本各有大阵营,选择应主要跟随「你的客户端是谁」:公开的第三方集成多的 API,URI 版本最不容易出错。
7. 边界与局限¶
- 命名约定管不了深层建模问题:资源间的关系怎么进 URI(层级 vs 扁平)、动作型端点怎么表达(「取消订单」是
POST /orders/123/cancel还是PATCH /orders/123 {status:cancelled}?),本章只给了约定没给判据——第 03、05 章分别接住这两问。 - 版本策略没讲「什么时候该升版本」:破坏性 vs 非破坏性改动的界线(加字段算不算破坏?书里默认算「非破坏」但没写判据)、旧版本何时退出服务(先公告、再下线,所谓日落计划)都没展开。第 05 章的版本号三段约定(MAJOR.MINOR.PATCH)补了判据的一半。
- 媒体类型版本有真实成本:原书把它讲得很轻,实际上
vnd.*媒体类型要让网关、缓存、客户端框架全链路配合,小团队常被这个成本劝退。 - 命名空间的深度问题(§4 边界)原书未提,是我们就原书例子补的实务注。
8. 可带走的¶
本章主线例子一行走完: 图书馆 API 从 GET /CreateBook、/user、UserProfiles 三个坏名字出发,改成名词 + 复数 + 连字符(/books、/user-profiles),用路径层级表达流派/作者/书名的树,最后选定 URI 版本挂上 /v2,老客户端继续走无版本的旧路径——每一步都先指出一种具体的歧义,再给消掉它的写法。
- 端点是句子,资源是主语,方法是谓语——动词进名字,句子就病了。
- 集合永远复数:
/users与/users/{id}的区分,比单复数之争更值钱的是它区分了两种操作级别。 - 复合词用连字符:理由不是好看,是搜索引擎能把两个词各自索引。
- 层级写进路径,客户端就拿到了能背的地图;但层级控制在两三层的实务线内。
- 版本策略四选一(URI / 头 / 媒体类型 / 查询参数),选择标准是团队和客户端生态,不是正确性——四个都是正经答案。
- 不选版本策略的代价是创新停摆:每次改动都变成与所有客户端的谈判。
- URI 是街道地址:名字要稳过实现;后端随便重构,门牌号不动。
- 一致性 > 形式:四条约定里任何一条,长期坚持都比选对一条更重要。
9. 原文地图¶
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| URI 的可寻址性与来历 | Resource Identification | text/09-fm-introduction.txt:35(搜「addressability」) · :38(搜「a set of instructions」) |
| 五条命名约定总览 | Resource Identification | text/09-fm-introduction.txt:67(搜「5 key naming conventions」) |
| 名词 vs 动词与三个例子 | Resource Identification | text/09-fm-introduction.txt:73(搜「I kicked the ball」) · :79(搜「name a resource CreateBook」) · :103(搜「UpdateCustomer」) · :118(搜「DeleteProduct」) |
| 复数命名与 GitHub 例 | Resource Identification | text/09-fm-introduction.txt:130(搜「plural nouns」) · :148(搜「GitHub REST API」) |
| 复数命名与 PagerDuty/Stripe 例 | Resource Identification | text/09-fm-introduction.txt:175(搜「PagerDuty」) · :202(搜「Stripe」) |
| 连字符与搜索引擎 | Resource Identification | text/09-fm-introduction.txt:229(搜「Hyphens」) · :259(搜「token」) |
| 命名空间与图书/员工/商品三模板 | Resource Identification | text/09-fm-introduction.txt:265(搜「tree-like structure」) · :277(搜「{genre}/{author}/{book-title}」) · :305(搜「{region}/{department}」) · :339(搜「{brand}/{category}/{product}」) |
| 命名空间跨网络区域 | Resource Identification | text/09-fm-introduction.txt:320(搜「network regions」) · :348(搜「regions A and B」) |
| 版本存在的理由 | Resource Identification | text/09-fm-introduction.txt:367(搜「break the contract」) |
| URI 版本与电商例子 | Resource Identification | text/09-fm-introduction.txt:376(搜「/v1/resource」) · :388(搜「v1/products」) |
| 头版本与任务例子 | Resource Identification | text/09-fm-introduction.txt:487(搜「X-API-Version」) · :499(搜「GET /tasks」) |
| 媒体类型版本与电商例子 | Resource Identification | text/09-fm-introduction.txt:571(搜「Content-Type」) · :586(搜「vnd.ecommerceapi.v1」) |
| 查询参数版本与社交资料例子 | Resource Identification | text/09-fm-introduction.txt:781(搜「?version=1」) · :793(搜「profile?version=1」) |
| 结论:选定并坚持 | Resource Identification | text/09-fm-introduction.txt:901(搜「Choose a versioning tactic」) |
-
出处:「CHAPTER 2 Resource Identification」第 35 段(
text/09-fm-introduction.txt:35,搜「addressability」)与第 1 章「CHAPTER 1」第 886 段(text/08-fm-introduction.txt:886,搜「street address」)。街道地址的比喻出自第 1 章:「The address remains the same even if the contents of the building change over time, just as a URI should stay fixed as the resource evolves.」 ↩ -
出处:「CHAPTER 2 Resource Identification」第 38 段(
text/09-fm-introduction.txt:38,搜「a set of instructions」)。原文:URI 之前,数据地址通常是「一套指令,像一段耦合了数据库表结构的查询,只在特定机器上有效」;Windows 网络共享路径只在授权的企业网 Windows 机器上可用;「For the web to scale, a uniform framework for locating resources was needed.」 ↩ -
出处:「CHAPTER 2 Resource Identification」第 73 段(
text/09-fm-introduction.txt:73,搜「I kicked the ball」)。原文用两句英语语法做类比:「I kicked the ball」以动作(kick)为中心,「The ball was kicked」以对象(the ball)为中心;「an API endpoint centers on a resource modified by an HTTP method」。 ↩ -
出处:「CHAPTER 2 Resource Identification」第 85 段(
text/09-fm-introduction.txt:85,搜「I am going to the library to get a book and write it」)、第 103 段(text/09-fm-introduction.txt:103,搜「in whole or in part」)、第 118 段(text/09-fm-introduction.txt:118,搜「DELETEProduct」)。CreateBook 例的消解歧义句「We do not know if we are GETing a book or CREATing one」见第 85 段。 ↩ -
出处:「CHAPTER 2 Resource Identification」第 130 段(
text/09-fm-introduction.txt:130,搜「unmistakable that an endpoint returns multiple items」)。原文:「Pluralization also separates collection-level operations... From item-specific operations」。 ↩ -
出处:「CHAPTER 2 Resource Identification」第 229 段(
text/09-fm-introduction.txt:229,搜「Hyphens hold the name together」)与第 259 段(text/09-fm-introduction.txt:259,搜「each word before and after a hyphen becomes a token」)。原文明确对比了 camelCase 与 under_scores:「Unlike camelCase or under_scores, which can blur word boundaries...」 ↩ -
出处:「CHAPTER 2 Resource Identification」第 265 段(
text/09-fm-introduction.txt:265,搜「tree-like structure」)。原文:「URIs can communicate containment and hierarchy... By embedding relationships into a tree-path structure, URIs reflect the branching, containment, or subordination in a data model.」并把它归到「a basic RESTful principle, uniform interface」之下。 ↩ -
出处:「CHAPTER 2 Resource Identification」第 320 段(
text/09-fm-introduction.txt:320,搜「Even though the namespace crosses multiple network regions」)。原文:「it addresses the leaf nodes of the tree (user profiles) in the same, uniform way」——寻址方式不随资源所在网络区域改变。 ↩ -
出处:「CHAPTER 2 Resource Identification」第 358 段(
text/09-fm-introduction.txt:358,搜「cognitive load」)。原文:hierarchical URIs「may become a mental map, thereby reducing cognitive load on API users by eliminating the need to reconstruct relationships」;第 361 段讲命名空间为新增资源留好挂点、支撑增长。 ↩ -
出处:「CHAPTER 2 Resource Identification」第 367 段(
text/09-fm-introduction.txt:367,搜「without breaking their clients」)。原文:「Resource versions allow APIs to update without breaking their clients.」并说明破坏性改动来自资源格式修改或端点行为调整。 ↩ -
出处:「CHAPTER 2 Resource Identification」第 376 段(
text/09-fm-introduction.txt:376,搜「/v1/resource」)与第 481 段(text/09-fm-introduction.txt:481,搜「backward compatible」)。原文:v1 只回商品名和价格,v2 增加描述、类目、库存;「the platform supports clients using version 1 in a backward compatible way」。 ↩ -
出处:「CHAPTER 2 Resource Identification」第 487 段(
text/09-fm-introduction.txt:487,搜「X-API-Version: 1」)与第 565 段(text/09-fm-introduction.txt:565,搜「API gateway could assume」)。原文:自定义头把版本藏进头里,URL 不出现在服务器日志的版本位;客户端不带头时,网关可默认路由到最新版或稳定版。 ↩ -
出处:「CHAPTER 2 Resource Identification」第 571 段(
text/09-fm-introduction.txt:571,搜「Content-Type」)与第 586 段(text/09-fm-introduction.txt:586,搜「application/vnd.ecommerceapi.v1+json」)。原文:同一端点按 Accept 头回不同详略——Web 客户端要规格、评论、图片,移动客户端只要名称和价格。 ↩ -
出处:「CHAPTER 2 Resource Identification」第 781 段(
text/09-fm-introduction.txt:781,搜「?version=1」)与第 787 段(text/09-fm-introduction.txt:787,搜「basic profile」)。原文:适用面是「versioning applies to individual resources」;改一个查询参数即可现场切换版本。 ↩ -
出处:「CHAPTER 2 Resource Identification」结论第 901 段(
text/09-fm-introduction.txt:901,搜「You stop innovating. You start negotiating.」)。原话上下文:「Without a plan, a single change breaks every client. You stop innovating. You start negotiating.」 ↩ -
出处:三家真实 API 的例子见「CHAPTER 2 Resource Identification」第 148 段(
text/09-fm-introduction.txt:148,搜「GitHub」)、第 175 段(text/09-fm-introduction.txt:175,搜「PagerDuty」)、第 202 段(text/09-fm-introduction.txt:202,搜「Stripe」)。 ↩