URL 的每个组成部分以及它们允许包含什么
URL 是一个字符串,它说明要讲哪种协议、要对哪台机器讲,以及要请求什么。正确地读懂一个 URL,主要就是知道每个部分在哪里结束,因为分隔符都是单个字符,而且第一个出现的那个说了算。
https://alex:s3cret@www.example.co.uk:8443/docs/intro?lang=en&page=2#notes
│ │ │ │ │ │ └─ fragment
│ │ │ │ │ └──────────────── query
│ │ │ │ └──────────────────────────── path
│ │ │ └──────────────────────────────── port
│ │ └────────────────────────────────────────────────── host
│ └────────────────────────────────────────────────────────────── userinfo
└────────────────────────────────────────────────────────────────────── scheme
各个部分
| 部分 | 由什么界定 | 用途 | 是否到达服务器 |
|---|---|---|---|
| 协议(scheme) | 到第一个 : 为止 | 使用哪种协议,以及其余部分适用哪套规则 | 决定如何建立连接 |
| 用户信息(userinfo) | 在 // 之后,到 @ 为止 | 凭据,对 http 和 https 早已废弃 | 只有客户端选择发送时才会 |
| 主机(host) | 到 :、/、? 或 # 为止 | 要解析并连接的域名或 IP 地址 | 会,放在 Host 头里 |
| 端口(port) | 在 : 之后,到 /、? 或 # 为止 | TCP 端口,http 默认 80,https 默认 443 | 用于建立连接 |
| 路径(path) | 从 / 开始,到 ? 或 # 为止 | 该主机上的哪个资源 | 会,放在请求行里 |
| 查询(query) | 从 ? 开始,到 # 为止 | 该资源的参数 | 会,放在请求行里 |
| 片段(fragment) | 从 # 开始,一直到末尾 | 资源内部的某个位置 | 不会 |
主机和端口合起来就是 authority。片段是个例外:它在构造请求之前就被剥掉了,所以服务器永远看不到它。浏览器用它做锚点和客户端路由,某些认证流程还刻意把令牌放在片段里返回,好让这些令牌不出现在服务器日志中。但它仍然会进入浏览器历史记录,所以它不是私密的,只是没被发送出去而已。
保留字符与非保留字符
百分号编码把一个字节写成 % 加两位十六进制数字,用的是字符的 UTF-8 字节。é 变成 %C3%A9,两个字节,两个转义。
除了字母和数字之外,还有四个字符属于非保留字符,在任何位置都不需要编码:- . _ ~。其余的要么是保留字符,意思是它在 URL 的某个部分承担结构性职责,要么就必须编码。保留字符集是 : / ? # [ ] @ 以及 ! $ & ' ( ) * + , ; =。
“保留”这个词不等于“永远禁止”。它的意思是这个字符在某些部分里是分隔符,只有在会被当成分隔符读的那些部分里才必须编码。
| 字符 | 在路径段中 | 在查询值中 |
|---|---|---|
| 空格 | 必须是 %20 | %20 或 + |
/ | 必须是 %2F,否则会切开这个路径段 | 可以原样使用 |
? | 必须是 %3F,否则路径到此为止 | 可以原样使用 |
# | 必须是 %23 | 必须是 %23 |
& 和 = | 可以原样使用 | 必须是 %26 和 %3D,否则键值对会被切开 |
+ | 合法,表示一个加号 | 必须是 %2B,否则可能被解码成空格 |
实用规则就是这条:把会终结当前所处部分的那个字符编码掉。路径里的空格必须写成 %20,因为在多数解析器看来一个裸空格就把 URL 结束了;而查询值里的空格既可以是 %20,也可以是更老的 +。用 URL 编码工具做这件事最可靠,因为编码整个 URL 和编码单个组成部分是两种不同的操作,而人们想要的几乎总是后者。
查询字符串只是一种约定
规范只说查询是 ? 和 # 之间的全部内容,字符取自允许的集合。key=value&key=value 这种形态来自 HTML 表单编码,而不是 URI 标准。没有什么能阻止服务器按自己的想法去解析 ?a:1;b:2。
正因为这种形态只是约定,重复的键并没有定义好的含义,于是每个技术栈都自己选了一个答案:
对 ?id=1&id=2 的行为 | 出现在哪里 |
|---|---|
| 保留两个值,作为列表 | Python 的 parse_qs、Node 的 querystring、Express |
| 只取第一个值 | Go 的 Query().Get、Java 的 getParameter |
| 只取最后一个值 | PHP、Rails |
拼接成 1,2 | ASP.NET 的请求集合 |
数组也继承了同样的问题。ids=1&ids=2、ids[]=1&ids[]=2、ids[0]=1&ids[1]=2 和 ids=1,2 都在被广泛使用,而只有服务器才决定它能听懂哪一种。严格来说方括号写法必须编码才算合法(ids%5B%5D=1),尽管多数服务器也接受裸方括号。选接收方文档里写明的那种形式,然后保持一致。
加号值得单独警告一次。在 application/x-www-form-urlencoded 数据(也就是表单编码的请求体)里,+ 表示空格。在 URL 路径里它表示一个字面的加号。在查询字符串里则取决于解析器,而多数 Web 框架会在那里套用表单解码,于是 + 会悄悄变成空格。任何有可能合法地包含加号的值,比如标准 Base64 的输出或电话号码,都必须以 %2B 的形式发送。
相对引用
相对引用是通过替换基准 URL 中最后一个 / 之后的所有内容来解析的。规则全在那个最后的斜杠上,这也正是结尾斜杠为什么重要。
| 基准 URL | 引用 | 结果 |
|---|---|---|
https://ex.com/docs/intro | guide | https://ex.com/docs/guide |
https://ex.com/docs/intro/ | guide | https://ex.com/docs/intro/guide |
https://ex.com/docs/intro | /guide | https://ex.com/guide |
https://ex.com/docs/intro/ | ../guide | https://ex.com/docs/guide |
https://ex.com/docs/intro?a=1 | ?b=2 | https://ex.com/docs/intro?b=2 |
https://ex.com/docs/intro?a=1 | #top | https://ex.com/docs/intro?a=1#top |
https://ex.com/docs/intro | //cdn.ex.com/x.js | https://cdn.ex.com/x.js |
最后一行是协议相对引用:开头的两个斜杠保留基准 URL 的协议,并替换掉 authority。以 ? 开头的引用保留路径并丢弃原有的查询,以 # 开头的引用则两者都保留。
什么时候两个 URL 指向同一个资源
协议和主机不区分大小写,所以 HTTPS://Example.COM 和 https://example.com 是同一个地址。就标准而言,主机之后的一切都区分大小写,哪怕某台服务器自己选择忽略大小写。
下面这些成对的写法是等价的:
https://example.com:443/a和https://example.com/a,因为那是默认端口https://example.com和https://example.com/,因为空路径就表示根路径/a/%7Euser和/a/~user,因为~是非保留字符,对它做百分号编码什么也没改变
下面这些则不等价:
/docs和/docs/,它们是不同的资源,尽管多数服务器会把其中一个重定向到另一个/p?a=1&b=2和/p?b=2&a=1,因为参数顺序也是字符串的一部分/index.html和/,除非服务器另有规定
缓存和 CDN 是按精确的字节来做键的,所以多加一个跟踪参数、或者把查询参数换个顺序,就会把一个缓存对象拆成两个,命中率直接减半。搜索引擎也会把这些变体当成重复页面,除非有 canonical 链接指向选定的那一种形式。解决办法是选定单一形态,用 301 把其余形态重定向过去,并去掉那些不改变响应内容的参数。
长度、日志与隐私
规范里没有长度限制,但其他地方到处都是限制。常见的服务器默认把整条请求行限制在 8 KB 左右,一些代理和网络设备是 4 KB,较老的客户端大约是 2 KB。任何需要经受住邮件客户端、二维码和短链接服务的 URL,控制在 2000 个字符以内最稳妥。
把 URL 保持简短的更有力的理由是:查询字符串并不私密。它会被写进访问日志,通过 Referer 头传给第三方,留在浏览器历史里,随书签一起保存,并且在有人分享链接时被一并复制。因此会话令牌、密码重置码、API 密钥和个人数据都不该出现在那里。体积大或者敏感的数据应该放进请求体,请求体没有实际的大小上限,默认也不会被记入日志。
点击链接之前先读懂它
authority 从 :// 之后开始,到最靠前的那个 /、? 或 # 结束。先读出这一段,再看它最后的两三个标签。那才是真正的主机,它左边的任何东西都改变不了这一点。
https://www.paypal.com@198.51.100.7/secure/login
^^^^^^^^^^^^^^ ^^^^^^^^^^^^
userinfo real host
一些需要认得出来的常见伪装:
@之前的文本是用户信息,绝不是主机,而且它可以是任何一个品牌名。paypal.com.secure-login.example是secure-login.example的一个子域名。标签要从右往左读。https://short.example/https://www.bank.com/login把一整个看着可信的 URL 塞进了路径里。- 以
xn--开头的主机是 Punycode,也就是 IDNA 产生的国际化域名的 ASCII 形式。münchen.de在链路上是xn--mnchen-3ya.de,这本身是正当的,但同一套机制也让同形字攻击成为可能:西里尔字母а(U+0430)看起来和拉丁字母a完全一样,用它构成的域名会编码成类似xn--pple-43d.com的东西。当一个标签混用了多种文字时,浏览器会显示 Punycode 形式,不过各家规则不一,并不能作为保证。 - 主机里出现
%2F或%40这类被编码的分隔符,是在蓄意迷惑解析器。
把链接粘进 URL 解析器一步就能判定,因为真正的解析器会套用浏览器同样的“第一个分隔符说了算”规则,并把主机单独显示出来。要一次核对大量链接时,正则测试工具是确认它们当中哪些真的具备你预期的 authority 的快捷办法。