第 6 章 URL:世界上最成功也最拧巴的一次命名

URL 是人类历史上最成功的一次”命名”:它用一行字符串,同时完成了”这是什么、它在哪、怎么拿到”三件事。但它的成功,恰恰掩盖了它的拧巴。


6.0 引子:改了个 URL,全崩了

小美:老哥,我们把接口路径从 /api/orders 改成了 /api/v2/orders,为什么 SEO 全没了?用户收藏夹里的旧链接也全 404 了?

你:那肯定啊,路径变了,老链接当然失效。

小美:可是功能没变啊,只是加了个版本号……

老张:而且 CDN 缓存全失效了,回源暴涨,带宽又打满了。

墨叔:来了,两个问题,背后是同一个被低估的事实:

URL 不只是”地址”,它是”资源身份”的一部分。你改 URL,等于给资源换了个身份,全世界所有指向旧身份的东西都会失效。

这一章,我们把 URL 彻底拆开,搞清楚它到底是什么、为什么这么设计、以及你为什么不能随便改它。


6.1 先把问题问对:URI、URL、URN 到底什么关系

墨叔:先解决一个经典的糊涂账。URI、URL、URN 这三个词,面试常问,但 90% 的人说不清。

你:我大概知道 URL 是网址,URI 是更宽泛的标识,URN 是……名字?

墨叔:对,用一张图说清关系:

URI(统一资源标识符)
├── URL(统一资源定位符):通过"位置"来标识资源
│     http://example.com/a.jpg    (告诉你去哪找)
└── URN(统一资源名称):通过"名字"来标识资源,与位置无关
      urn:isbn:9787115428028     (告诉你它叫什么,不告诉你它在哪)

关键区别:

  • URL 回答”它在哪、怎么拿到”——所以 URL 里带协议、带主机、带路径。
  • URN 回答”它是什么”——只是一个稳定的名字,跟它在哪台服务器、什么路径无关。
  • URI 是两者的总称。

你:那 URN 的意义是什么?我都没怎么见过。

墨叔:URN 想解决的是 URL 的一个致命缺陷——URL 绑定了”位置”,位置一变,标识就失效(就像小美改路径导致旧链接全崩)。

URN 的愿景是:给每个资源一个”永不改变的身份证号”,不管它搬到哪台服务器、哪个路径,这个名字永远有效。

但它失败了,原因很现实:

URN 需要一个”从名字到位置”的解析机制(类似 DNS 之于域名),而这个机制需要全世界的解析基础设施——这个基础设施从来没建起来。

所以今天的世界是:URL 赢了,URN 基本停留在纸面上。你只要知道”URI ⊇ URL”这个关系,以及”URN 是个没实现的理想”就够了。


6.2 URL 的解剖:五个部分,各管一件事

墨叔:把一个 URL 拆到底:

https://user:[email protected]:8443/v1/orders?page=2&size=20#summary
└─┬──┘ └───┬───┘ └────┬─────────┘└─┘└───────┬──────────┘└──┬──┘
scheme  userinfo  host         port      path          query    fragment
部分作用例子
scheme用什么协议/机制去拿https、http、mailto、ws、file
userinfo认证信息(极少用,且危险)user:pass@
host哪台机器api.example.com
port哪个端口8443
path机器上的哪个”资源”/v1/orders
query对这个资源的”参数化”?page=2&size=20
fragment资源内部的”锚点”#summary

你:看起来挺清楚。但我知道它肯定有坑。

墨叔:坑多着呢。我们挑最关键的几个,尤其是那些”你以为懂,其实会翻车”的。


6.3 fragment 为什么服务器收不到?

墨叔:第一个坑,也是最能揭示 URL 设计哲学的:fragment(# 后面的部分)根本不会发给服务器。

GET /v1/orders?page=2&size=20 HTTP/1.1    <- 你看,请求行里没有 #summary
Host: api.example.com

你:为什么?我明明写在 URL 里了。

墨叔:因为 fragment 的语义是”资源内部的一个位置”,而不是”另一个资源”。

它的设计意图是:一个 URL 指向一个完整的资源(比如一整个 HTML 文档),fragment 指向这个文档内部的某个小节(比如”第 3 章”)。

  • 客户端请求的是整个资源(整个文档)。
  • 拿到之后,客户端自己在本地定位到 fragment 对应的位置(浏览器滚动到 #summary 那里)。

你:所以 fragment 是”客户端侧的”东西?

墨叔:对。它解决的是”一个资源太大,我想指向其中的一部分,但这一部分的划分是’内容层’的,服务器不需要知道”。

这个设计非常精妙——它把”定位”这件事分层了:

URL 的 path + query 负责”找到哪个资源”(服务器关心),fragment 负责”找到资源的哪个部分”(只有客户端关心)。

你:那现代 SPA(单页应用)里,#/user/123 这种把”路由”塞进 fragment 的做法,是不是就”滥用”了 fragment?

墨叔:好眼力。SPA 早期用 hash 路由(#/user/123),因为——改 fragment 不会触发页面刷新,也不会发请求,正好适合前端自己管路由。但这是”借用”了 fragment 的”不发给服务器”这个特性,把本属于”资源定位”的路由信息藏进了**本属于”资源内定位”的 fragment 里。

代价是什么?你想想。

你:……搜索引擎看不到 fragment,所以这些”页面”对 SEO 不友好?

墨叔:对。而且服务器永远不知道用户在看 /user/123 还是 /user/456,因为 fragment 根本没发过去。所以后来 SPA 纷纷改用了 History API(/user/123 变成真正的 path),代价是服务器要做”fallback 路由”(把所有路径都重定向回 index.html)。

这个故事很有启发:fragment 的设计是自洽的,但它的”不发给服务器”特性,让它成为一个诱人的”私货通道”——大家总想往里面塞本该发给服务器的东西,结果都要为这个”聪明”付出代价。


6.4 百分号编码:为什么 URL 里不能有空格

墨叔:下一个坑。为什么 URL 里出现空格、中文、& 这些字符时,要变成 %20、%E4%B8%AD、%26 这种形式?

你:因为 URL 有”保留字符”,这些字符有特殊含义?

墨叔:对。URL 里有一套保留字符,它们在语法里有特殊作用:

: / ? # [ ] @   —— 分隔符,有语法作用
! $ & ' ( ) * + , ; =   —— 子分隔符

如果你的数据里恰好含有这些字符(比如用户名是 A&B,或者搜索关键词里有 ?),你就必须转义,否则解析器会把它当成语法分隔符,URL 就乱了。

转义方式就是百分号编码:% + 两个十六进制数,表示一个字节。比如空格(ASCII 32)= %20,&(ASCII 38)= %26。

你:那中文呢?中文不是 ASCII。

墨叔:这正是历史上最拧巴的地方。百分号编码编码的是字节,而一个中文字符在不同编码下是不同的字节:

  • UTF-8 下,“中”是 E4 B8 AD 三个字节 → %E4%B8%AD。
  • GBK 下,“中”是 D6 D0 两个字节 → %D6%D0。

URL 标准本身没规定”原始字符用什么字符集”(标准只规定”URL 是 ASCII 的,非 ASCII 先编码成字节”)。于是历史上客户端用 UTF-8,服务器用 GBK,就出现了中文乱码——这是 2000 年代中文网站最经典的 bug 之一。

现在的共识是:统一用 UTF-8。但遗留系统的坑还在,你接手老项目时,URL 中文乱码八成是这个原因。

你:所以”百分号编码”本质是把”任意字节”塞进”ASCII-only 的 URL”里的一种方式。

墨叔:精确。记住这个本质,你就能理解为什么它又叫”URL 编码”(URL encoding)了。


6.5 path vs query:定位 vs 参数化

墨叔:下一个高频的设计决策:什么放 path,什么放 query?

GET /v1/orders/123          <- path 里的 123
GET /v1/orders?id=123       <- query 里的 123

你:这俩有啥区别?不都能拿到订单 123 吗?

墨叔:语义区别很大,而且影响深远:

pathquery
语义资源的身份(这是哪个资源)资源的参数化(怎么筛选/分页/排序这个资源)
例子/orders/123 = “订单 123 这个资源”?status=paid = “订单集合里,已支付的那些”
对缓存每个 path 是独立缓存键query 变了,通常也是新的缓存键

判断标准:问自己”这是在标识一个特定的东西,还是在筛选一堆东西?”

  • “标识特定东西” → path(/orders/123)。
  • “筛选一堆东西” → query(/orders?status=paid&page=2)。

你:那 ?id=123 这种算什么?

墨叔:它其实是在”筛选”——语义上是”从所有订单里,筛选出 id=123 的那一个”。能用,但不够精确:它把”身份”降格成了”筛选条件”,会丢失一些东西(比如语义上它不像一个”资源”了)。

更深一层:这个区分决定了你的资源模型是否清晰。REST 的思想(第 6 章后面会展开,其实我在第 1 章埋过伏笔)核心就是”一切皆资源,资源有身份”。path 就是”身份”,query 是”视图”。

一个实际的坑:如果你把分页参数放 path(/orders/page/2),那”第 2 页”就变成了一个”资源身份”,而不是”订单集合的一个视图”。这会导致缓存、路由都变别扭。分页、排序、筛选,永远放 query。


6.6 URL 长度限制:谁规定的?其实没人规定

墨叔:一个流传很广的”伪知识”:“GET 请求 URL 最长 2048 个字符”。

你:难道不是吗?我一直这么记的。

墨叔:协议从来没有规定 URL 的长度上限。

你听到的”2048""8192""2KB”这些数字,全是具体实现自己定的:

  • IE 时代的老限制:2083 字符。
  • 老 Nginx:默认 8KB。
  • 现代浏览器:普遍支持几十 KB。

你:那为什么大家都说 GET 有长度限制?

墨叔:因为 GET 的参数在 URL 里,而 URL 又出现在请求行里,请求行有一个”所有实现都有限制”的隐性约束。而且URL 太长的真实代价是:

  1. 会被中间设备(代理、WAF、日志系统)截断或拒绝。
  2. 会泄露数据——URL 会出现在访问日志、浏览器历史、referer、分享链接里。
  3. 会污染缓存。

所以结论:

“GET 有长度限制”不是协议规定,是”现实世界对长 URL 不友好”的民间总结。 正确的做法不是”担心 URL 多长会爆”,而是”URL 里的数据本该就短——长的、敏感的数据放 body”。


6.7 路径规范化与安全:/../ 的陷阱

墨叔:下一个坑,安全相关的,而且坑过无数人。

URL 路径可以被”规范化”。比如 /a/b/../c 可以化简成 /a/c(.. 表示上一级)。问题在于:服务器做路径解析时,是先规范化还是先鉴权?顺序错了,就出漏洞。

经典攻击:路径穿越(Path Traversal)。

想象一个文件下载接口:

GET /download?file=../../etc/passwd

如果服务器天真地把 file 参数直接拼到文件路径里:

// 危险!没做规范化/校验
data, _ := os.ReadFile("/var/www/files/" + r.URL.Query().Get("file"))
// 用户传 ../../etc/passwd,就读到了系统的密码文件

你:所以要先规范化、再校验、再拼接。

墨叔:对,而且顺序极其重要。正确的防御是:

  1. 先规范化路径(把 ..、.、重复斜杠都解析掉)。
  2. 再校验规范化后的路径是否还在允许的目录内(/var/www/files/ 前缀检查)。
  3. 最后才拼接到真实文件系统。

这个”顺序”就是安全漏洞的核心——任何”先信任、后校验”的代码,都等于没有校验。 规范化、解码、校验的先后顺序,是 URL 安全的第一课。

你:还有一个坑,百分号编码的”双重解码”?

墨叔:对,进阶版。攻击者可以发 %2e%2e%2f(这是 ../ 的编码形式)。如果服务器先做安全校验(没解码),再做解码去拼路径,那 %2e%2e%2f 在校验时是”无害的字符串”,解码后却变成了 ../——绕过校验。这就是”解码和校验的顺序”问题,和上面是同一个坑的不同变体。


6.8 设计者视角:到底什么是”资源”?

墨叔:这一章的最后,讲一个真正”深刻”的问题,它会把 URL、REST、以及第 15 章的”不变量”都串起来:

到底什么是”资源”?

你:资源就是……服务器上的东西?文件、数据、记录?

墨叔:这是最常见的误解,也是 REST 被”CRUD 化”误解的根源。让我引 Roy Fielding(REST 的提出者)的原意:

资源(resource)是”任何可以被命名、被标识的东西”——它可以是文档、图片、一个时间段的服务、一个概念、甚至”今天北京的天气”。

注意关键词:资源 ≠ 文件,资源 ≠ 数据库记录,资源是”一个可以被 URI 标识的抽象概念”。

你:抽象概念?那”今天北京的天气”算资源?

墨叔:算。你可以给它一个 URI:https://api.weather.com/beijing/today。这个 URI 指向的不是某个文件,而是”今天北京天气”这个概念,每次请求,服务器返回的是这个概念当前的表示(representation)。

这就是 REST 最关键、也最被误解的一层:

资源(resource)和表示(representation)是两回事。 资源是抽象的、可能随时变的(“北京天气”每时每刻在变);表示是具体的、某个时刻的快照(“2026-08-30 06:00 的天气是 26°C”)。

URL 标识的是”资源”,不是”表示”。 同一个 URL,你今天访问和明天访问,拿到的是不同的表示——因为资源变了,但资源本身还是”北京今天的天气”这个身份。

你:……我好像第一次真正理解 REST 在说什么。

墨叔:这就对了。所以业界把 REST 简化成”GET/POST/PUT/DELETE 对应增删改查”,其实丢掉了它最精华的部分——即”把世界建模成资源,用 URI 命名它们,用表示来传递状态”。

这引出一个对你有直接价值的结论:

设计接口时,第一个要想清楚的问题不是”用什么方法”,而是”我这里的’资源’到底是什么?”

如果你的接口是”动作导向”的(/doSomething、/getUserById),你还没想清楚资源是什么;如果你的接口是”资源导向”的(/users/123),你才开始了 REST 式的思考。


6.9 Go 落地:url 包里的三个坑

墨叔:Go 里解析 URL,有三个坑必须知道:

u, _ := url.Parse("https://example.com/search?q=hello%20world&lang=zh")
 
// 坑一:Query() 会帮你解码,但重复的 key 只取第一个
fmt.Println(u.Query().Get("q"))    // "hello world"(已解码)
fmt.Println(u.RawQuery)            // "q=hello%20world&lang=zh"(原始未解码)
 
// 坑二:Path 和 RawPath 的区别
// Path 是解码后的,RawPath 是原始的。编码过的路径要用 EscapedPath()
fmt.Println(u.Path)         // 解码后的路径
fmt.Println(u.EscapedPath()) // 编码后的路径(转发时要用这个!)
 
// 坑三:直接拼字符串容易错,应该用 net/url 构造
v := url.Values{}
v.Set("q", "hello world")
v.Set("lang", "zh")
fmt.Println(v.Encode())  // "lang=zh&q=hello+world"(注意空格变成了 +)

你:第三个,空格变成 + 了?这跟 %20 什么关系?

墨叔:问得好,这是个经典坑。在 query 里,空格可以编码成 %20,也可以编码成 +(这是历史遗留的两种约定,application/x-www-form-urlencoded 用 + 表示空格)。所以:

  • 在 path 里,空格必须是 %20。
  • 在 query 里,空格可能是 %20 或 +(看编码方式)。

Go 的 url.Values.Encode() 默认用 +,而你如果手动解码时只处理 %20 不处理 +,就会把 + 当成真的加号。这个 %20 vs + 的差异,坑过无数人。


6.10 排查清单:URL 相关问题的定位

第 1 步:URL 的哪个部分出了错?
        → scheme / host / port / path / query / fragment,先定位

第 2 步:是不是编码问题?
        → 中文乱码 = 字符集不一致(UTF-8 vs GBK)
        → 空格变 + = query 编码方式混用

第 3 步:是不是路径规范化/解码顺序问题?
        → 路径穿越、双重解码

第 4 步:改 URL 之前,评估了"身份变更"的后果吗?
        → SEO、收藏夹、缓存、第三方回调

第 5 步:URL 里的敏感数据泄露了吗?
        → 访问日志、浏览器历史、referer

思考题

第 1 题(概念)

请说明 URI、URL、URN 三者的关系,并回答:为什么”URN 的愿景”(给资源一个永不改变的、与位置无关的名字)在现实中基本失败了?

第 2 题(概念)

fragment(# 后面的部分)不会发送给服务器。请回答:

  1. 这个设计是为了解决什么问题?
  2. 现代 SPA 的 hash 路由利用了 fragment 的什么特性?
  3. 利用这个特性付出了什么代价?

第 3 题(设计)

你要设计一个”商品列表”接口,支持:按分类筛选、按价格排序、分页、按关键词搜索。

请设计这个接口的 URL 结构,并说明:

  1. 哪些信息放 path,哪些放 query,为什么?
  2. 如果要支持”收藏某个商品”,URL 应该长什么样?(提示:思考”资源”是什么)

第 4 题(生活类比)

墨叔:一个公司的工牌,和 URL,有什么相似之处?

提示:工牌上有”公司名(哪家公司)”+“部门(哪个部门)”+“工号(哪个人)“。而 URL 有 “host(哪台机器)” + “path(哪个资源)“。想想:

  1. 员工换部门了,工牌上的”部门”变了,但”工号”没变——这对应 URL 的什么问题?
  2. 一个员工跳槽到另一家公司,工号可能不变,但”公司名+部门”全变了——这又对应什么?

第 5 题(更深一层的发问)

“资源”和”表示”的区分(6.8 节)是 REST 的核心。现在把这个区分推向极致:

  1. “今天的天气”是一个资源,它每时每刻在变。那么,一个永远不变的资源存在吗?(比如”π 的值”)
  2. 如果资源永远不变,那”资源”和”表示”还有区别吗?
  3. 这引出一个更深的问题:URL 标识的到底是”一个东西”,还是”一个持续变化的、但身份稳定的容器”? 这跟”接口版本管理”有什么关系?(提示:想一下 https://api.weather.com/beijing/today 和 https://api.weather.com/beijing/2026-08-30 的区别)

参考答案

第 1 题答案

关系:URI 是总称,URL 和 URN 是它的两种。URL 通过”位置”标识资源(告诉你去哪拿),URN 通过”名字”标识资源(与位置无关,只告诉你它叫什么)。

URN 为什么失败:

URN 的本质是”名字和位置解耦”——资源可以随便搬家,名字不变。但解耦的代价是:你需要一个”名字 → 位置”的解析服务(类似 DNS:你给域名,它给你 IP)。

这个解析服务要成立,需要:

  1. 全球统一的、权威的解析基础设施(谁能决定”这个 URN 指向哪台机器”?谁有权威?)。
  2. 所有人愿意用这个基础设施(而不是各自用 URL)。

而这两点都没有发生。原因:

  • 权威问题:URL 的权威很自然——“这个域名属于我,我域名下的路径我随意定义”。但 URN 的权威归属模糊(urn:isbn:xxx 归 ISBN 中心管?那 urn:mycompany:xxx 归谁管?)。
  • 激励问题:URL “开箱即用”——你拿到一个 URL,直接用 HTTP 就能访问,不需要任何第三方解析。而 URN 多了一层”查表”,每多一层,就多一个失败点、多一个需要协调的环节。

结论:

URN 的失败告诉我们:一个”更优雅、更解耦”的设计,如果需要一个”尚未存在的全球协调机制”,它就不会赢。 而 URL 的”耦合”(位置即身份)恰恰是它赢的原因——它不需要任何协调,开箱即用。

这和第 1 章”协议的成功取决于允许多少人犯错还活下来”是同一个道理:简单、零协调、开箱即用,永远比”优雅但需要协调”更可能在现实世界胜出。

第 2 题答案

1. fragment 解决什么问题: 解决”资源内部定位”。一个资源(如一个长文档)可能很大,我想指向其中的某个小节。fragment 让 URL 能表达”这个资源里的这个位置”,而服务器不需要理解资源内部的结构(那是客户端的职责)。

2. SPA hash 路由利用了 fragment 的什么特性: 利用了”修改 fragment 不会发送新请求、不会触发页面刷新”这个特性。所以前端可以自由地改 fragment 来实现”路由跳转”,而不会导致整页重载。

3. 付出了什么代价:

  • 服务器永远看不到路由:/app#/user/123 和 /app#/user/456 对服务器是同一个请求(都是 /app)。服务器无法做基于路由的日志、权限、SEO。
  • SEO 失效:搜索引擎的爬虫不解析 fragment,所以这些”页面”对搜索引擎不可见(或需要额外的”hash 转 path”方案)。
  • 语义错位:fragment 本意是”资源内定位”,现在被用来承载”资源定位”(哪个页面),是用错了语义层——所以后来 SPA 都迁移到了 History API(真 path 路由),代价是服务器要加 fallback 路由。

升华:这题和 6.3 节呼应——fragment 的”不发给服务器”是个特性,但它太诱人了,诱人到大家总想往里塞”本该发给服务器”的东西,然后为这个”聪明”付出代价。 协议设计里,“诱人的捷径”往往是有隐形成本的。

第 3 题答案

1. 哪些放 path,哪些放 query:

# 商品列表(集合)本身是一个资源
GET /products

# 筛选、排序、分页、搜索 —— 都是对"集合"的参数化,放 query
GET /products?category=electronics&sort=price_desc&page=2&size=20&q=iphone

判断依据(6.5 节):

  • category、sort、page、q 都是”如何筛选/排列这个集合”,是视图参数,不是资源身份,所以放 query。
  • 如果把它们放 path(如 /products/electronics/page/2),就把”视图”错当成了”资源身份”,会导致缓存、路由、语义都混乱。

2. 收藏某个商品:

关键在于”收藏”这件事,资源是什么。有两种建模:

方案 A:收藏是一个”子资源”。

PUT /products/123/favorite      # 收藏商品 123
DELETE /products/123/favorite   # 取消收藏
GET  /products/123/favorite     # 查看是否已收藏

这里把”收藏”建模成了”商品 123 的一个子资源”,用 PUT(设置状态)和 DELETE(移除状态)来操作——这正是第 3 章说的”把 toggle 拆成两个幂等操作”。

方案 B:收藏是一个独立的”收藏资源”。

POST /favorites        # body: {"product_id": 123},创建一条收藏
DELETE /favorites/xxx  # 删除某条收藏

这里把”收藏”本身建模成资源(它有自己的身份、创建时间等属性)。

两个方案都对,取决于”收藏”有没有自己的属性:

  • 如果收藏只是个开关(收藏/取消),方案 A 更简洁。
  • 如果收藏有属性(收藏时间、备注、分组),方案 B 更合适(它是”一等公民”)。

核心要点:设计 URL 的本质,是设计”资源模型”——想清楚这里有哪些资源、它们是什么关系(包含/并列/子资源)。 方法(GET/PUT/POST)是其次的,资源模型才是骨架。

第 4 题答案

1. “换部门,工号不变” 对应 URL 的什么问题:

对应**“位置变了,身份失效”**——这是 URL 的核心缺陷。

员工的”工号”是稳定的身份,但”部门”是位置。工牌上如果只有”公司+部门+工号”作为身份(而没有独立的工号体系),那么一换部门,工牌就作废了——别人手里存的”你的旧工牌信息”就失效了。

这跟 URL 的问题一模一样:http://a.com/dept1/zhangsan 里,zhangsan(工号)藏在 dept1(部门)下面。一旦 dept1 改成 dept2,这个 URL 就失效了,所有指向旧 URL 的地方(收藏夹、链接、缓存)全部作废。

这就是小美在引子里”改路径导致 SEO 全没、旧链接全 404”的根本原因:URL 把”身份”和”位置”焊死了,位置一动,身份就崩。

2. “跳槽,公司名+部门全变,工号可能不变” 对应什么:

对应**“同一个资源,可以有多个 URL 指向它”**(或者反过来,“一个 URL 的身份,完全取决于它的位置”)。

员工跳槽,他的”本质”(这个人)没变,但”公司名+部门”全变了。如果身份完全绑定位置,那”这个人”就变成了”另一个身份”。

这对应的 URL 问题是:一个资源搬了家(从 a.com 搬到 b.com),它的 URL 全变了,看起来像”新资源”,但它其实是同一个资源。这就是为什么有”重定向”(301/302,第 4 章)——它是”旧位置”到”新位置”的搬家声明,用来给”身份随位置变”这个缺陷打补丁。

升华:

工牌和 URL 的共同困境,是”身份”和”位置”的耦合。 位置是易变的,身份应该稳定。把身份绑死在位置上(URL、工牌),就会面临”一变位置就断身份”的持续痛苦。URN 想解耦但失败了;重定向是”解耦失败”后的补丁;而现代系统里,大家用”资源的业务 ID”(如订单号)在应用层重建了稳定身份,URL 只是”入口”——这是对”身份-位置耦合”的又一种绕行。

第 5 题答案

1. 永远不变的资源存在吗?

在严格意义上,几乎不存在。因为:

  • 即使”π 的值”这种数学常量,它的”表示”也可能变——今天服务器返回 100 位小数,明天返回 200 位(精度变了)。
  • 更常见的是:资源是”一个持续更新的东西”(天气、股价、用户信息、新闻)。

所以”资源会变”是常态,“资源不变”是特例。

2. 如果资源永远不变,“资源”和”表示”还有区别吗?

没有区别了——或者说,区别变得无关紧要。

因为”资源 vs 表示”这个区分之所以有意义,恰恰是因为资源在变:

  • 资源在变 → 同一资源的”表示”会随时间不同 → 所以需要区分”抽象的、持续的资源”和”具体的、某时刻的表示”。
  • 资源不变 → 任何时候拿到的表示都一样 → “资源”和”表示”塌缩成一个东西。

这反过来揭示了”资源/表示”区分的本质:

“资源”和”表示”的区分,是用来处理”变化”的。 它让”一个会变的东西”也能有一个稳定的身份(URL),而每次访问拿到的”当前快照”就是表示。

3. URL 标识的到底是”东西”还是”持续变化的容器”?

是”持续变化的、但身份稳定的容器”。

  • https://api.weather.com/beijing/today 标识的是”北京今天”这个容器——它的内容(温度)时刻在变,但身份(“北京今天”)稳定。
  • https://api.weather.com/beijing/2026-08-30 标识的是”北京 2026-08-30 这一天”——这是一个固定的、不可变的快照(过了这天,它的内容就永远定了)。

这跟接口版本管理的深刻关系:

这是本题的高潮。它揭示了两种完全不同的接口版本策略:

  • .../beijing/today = “滚动的最新版”:URL 不变,内容随时变。客户端永远拿到最新的,但也永远不知道什么时候会拿到一个”变了的东西”。这对应”不做版本管理,直接改接口”——老客户端可能随时被”最新”坑到。
  • .../beijing/2026-08-30 = “不可变的快照版”:URL 绑定到某个固定时点,内容永不改变。客户端拿到的是稳定的、可预期的。这对应”URL 版本化”(/v1、/v2)——每个版本是一个”不可变的快照”。

结论:

接口版本管理的本质,就是把”会变的资源”冻结成”不可变的快照”。 /v1/orders 和 /v2/orders 不是两个”不同的资源”,而是”订单接口”这个持续变化的容器的两个冻结快照。冻结了,老客户端才能依赖它;不冻结,老客户端永远活在”接口可能随时变”的恐惧里。

所以,当你设计接口时,问自己一句:“我要给调用方的是一个’会变的 today’,还是一个’冻结的 2026-08-30’?” 这个问题的答案,决定了你的版本策略。


一页纸小结

URI ⊇ URL/URN:URL 用位置标识,URN 用名字标识(但 URN 因缺少全球解析基础设施而失败)。

URL 七部分:scheme / userinfo / host / port / path / query / fragment。

fragment 不发给服务器:它是”资源内定位”,是客户端的事。SPA hash 路由借用了它,付出了 SEO 失效的代价。

百分号编码:把任意字节塞进 ASCII-only URL。中文乱码 = 字符集不一致(UTF-8 vs GBK)。

path vs query:path 是身份(这是哪个资源),query 是视图(怎么筛选)。分页/排序/筛选放 query。

URL 长度:协议没规定,是实现的限制 + “长 URL 不友好”的民间总结。

路径穿越:规范化/解码/校验的顺序,是 URL 安全的核心。

资源 vs 表示:资源是抽象的、会变的;表示是具体的快照。URL 标识资源,不是表示。这是 REST 最精华、最被误解的一层。

Go 坑:Path vs EscapedPath、空格在 query 里可能是 +。


下一章:URL 和报文都讲完了,现在轮到那个让你”本地飞快、线上拉胯”的问题——连接。我要问你一个能让你重新理解”性能”的问题:为什么你的接口,第一个请求总是特别慢,后面的就快了? 以及那个困扰了 HTTP 整整 20 年的恶魔——队头阻塞。