一句话概括
URL 编码,官方名称为百分号编码(percent-encoding),是将那些在 URL 中具有特殊含义或无效的字符,转换成一种安全、通用的格式,以便它们在传输过程中不会引起混淆。
它解决了什么问题
在早期互联网的“原始汤”里,一切都很简单。URL——或者更宽泛地说,URI(统一资源标识符)——被设计成一种清晰、可预测的资源定位方式。包括 Tim Berners-Lee 爵士在内的架构师们,将这个系统建立在一个有限的字符集基础上:ASCII。
只要你需要的仅仅是指向 http://example.com/reports/April.html 这样的地址,这套系统就工作得很好。但当事情变得复杂时,会发生什么呢?
想想一个 URL 的解剖结构。它有几个部分:协议方案(http:)、主机(example.com)、路径(/search),可能还有查询字符串(?q=dogs&cats)。在这场戏里,某些字符扮演着结构性指导的角色。冒号(:)分隔协议方案。斜杠(/)分隔路径段。问号(?)开启查询参数。与号(&)分隔各个参数。
麻烦就从这里开始了。如果你想搜索一个字面量字符串 "C++ & C#" 怎么办?如果你直接把它塞进 URL,你会得到 .../search?q=C++ & C#。Web 服务器看到这个会彻底懵圈。它会认为查询的是 "C++ ",然后它看到了一个与号,期望后面是另一个键值对,结果只得到了一个孤零零的 " C#"。场面一度十分混乱。原本的意图就这样丢失了。
此外,有些字符是根本不被允许的。空格就是个典型的捣蛋鬼。什么时候的空格是文件名的一部分,什么时候它只是浏览器应该忽略的打字错误?还有那些基本英文字母表之外的字符呢?Web 是全球化的!你要怎么把 Résumé.pdf 或者 你好.html 放进一个为 ASCII 设计的 URL 里?
百分号编码解决了所有这一类问题。它提供了一个“应急通道”,一种方式来告诉 Web 服务器:“嘿,服务器老兄,接下来的字符不是结构性的。别去解析它。它们就是纯粹的数据。” 它就像一个通用翻译器,确保一个 URL 无论是在巴西的浏览器里,还是在柏林的服务器上,意思都完全一样。
底层工作原理
百分号编码背后的“魔法”其实简单得惊人。与其说是魔法,不如说是一种所有人都同意使用的简单替换密码。
角色阵容:保留字符 vs. 非保留字符
首先,你需要知道哪些字符是“乖孩子”,哪些是“问题少年”。它们可以分为几组。
| 字符类型 | 字符 | 何时编码 |
|---|---|---|
| 非保留字符 | A-Z a-z 0-9 - _ . ~ |
从不。 它们是 URL 世界里的 VIP,永远都是安全的。 |
| 保留字符 | : / ? # [ ] @ ! $ & ' ( ) * + , ; = |
有时。 它们有特殊的结构含义。如果你想使用它们的本义(比如路径中的 /),就不用编码。如果你想把它们用作字面量数据(比如搜索查询中的 &),就必须编码。 |
| 其他(不安全) | (空格), `< > " % { } \ |
^` 以及所有非 ASCII 字符 |
关键在于上下文。字符 ? 如果是那个用来分隔路径和查询字符串的唯一 ?,那它就没问题。但如果你需要在查询参数的值里包含一个字面量的问号,你就必须对它进行编码。
魔法揭秘:百分号 + 十六进制
编码过程是一个简单的三步舞:
- 选一个你需要编码的字符。我们用与号
&来举例。 - 使用一个标准字符集找到它的字节值。对于 Web 来说,这个标准就是 UTF-8。在 UTF-8(以及它的前身 ASCII)中,
&字符由十进制数38表示。 - 将该数字转换为两位十六进制数,并在前面加上一个百分号(
%)。十进制的38就是十六进制的26。
所以,& 就变成了 %26。
我们再来试试几个:
- 空格是十进制
32,也就是十六进制20。编码后是:%20。 - 问号(
?)是十进制63,也就是十六进制3F。编码后是:%3F。 - 百分号(
%)本身是十进制37,十六进制25。所以要编码一个字面量的%,你需要写成%25。
这个系统非常巧妙,因为百分号本身不是一个非保留字符,所以解析器知道每当它看到一个 %,后面就应该跟着两个十六进制数字。
那非英文字符呢?
这就是 UTF-8 变得至关重要的地方。像 A 这样的简单 ASCII 字符是一个字节。但像法语 é 或中文 好 这样的字符,在 UTF-8 中由多个字节表示。编码过程是一样的,只是对每个字节重复进行。
我们以 é 为例:
- 在 UTF-8 中,
é由两个字节表示:C3和A9(十六进制)。 - 分别对每个字节进行编码:
C3变成%C3。A9变成%A9。
- 将它们组合起来:
é就变成了%C3%A9。
解码过程则完全相反。浏览器或服务器看到 %C3%A9,抓取 C3 和 A9 这两个字节,通过 UTF-8 解码器运行一遍,就得到了那个漂亮的 é 字符。
真实世界的案例
理论很美好,但让我们来看看它在真实世界中的应用。
搜索查询离奇失踪案
初级开发者 Maya 正在为一个技术文档网站构建搜索功能。用户可以搜索像 "C++"、"promises & async/await" 这样的内容。她通过简单的字符串拼接来构建搜索 URL:site.com/search?q= + userInput。
结果事情搞砸了。搜索 promises & async/await 生成了 URL .../search?q=promises & async/await。然而,服务器报告的搜索词只有 "promises "。& 被解释为新参数的分隔符,而 async/await 因为没有键而被丢弃了。她的搜索结果完全错误。
教训: Maya 学到了 Web 开发的一条黄金法则:任何放入 URL 组件的动态数据,都必须进行百分号编码。 在她开始对用户输入进行编码后,URL 正确地变成了 .../search?q=promises%20%26%20async%2Fawait。现在服务器收到了完整、正确的字符串,搜索功能完美运行。
“国际化”事件
一家在线商店决定主推一款来自德国合作伙伴的新产品:“Fußball”。市场团队为它创建了一个友好的 URL:store.com/products/Fußball。在他们办公室的现代浏览器上,一切看起来都很好。
但上线日却一团糟。客服工单蜂拥而至。一些用户收到“404 Not Found”错误。另一些用户在浏览器地址栏里看到一个像 .../products/Fu%C3%9Fball 的 URL,而还有些人看到的是 .../products/FuÃball。这个系统是新旧组件的混合体,它们在处理非 ASCII 字符 ß (Eszett) 时不一致。有些部分没有编码它,有些部分假设是 UTF-8 进行了编码,还有些遗留系统用不同的字符集解码,导致了乱码(mojibake)。
教训: 指望浏览器和服务器“自动处理”URL 中的非 ASCII 字符,是导致不一致性的温床。主动并一致地使用 UTF-8 标准对所有非保留字符进行百分号编码,可以确保你的 URL 健壮,并且在整个 Web 生态系统(无论新旧)中都能可预测地工作。
双重编码大灾难
一个团队正在构建一个单点登录(SSO)系统。流程是这样的:service-a.com 会将用户重定向到 sso.com/login,并将自己的 URL 作为参数传递,以便用户登录后可以被送回。重定向 URL 看起来像这样:sso.com/login?redirect_uri=https://service-a.com/dashboard?param=1。
service-a.com 的开发者很聪明,对 redirect_uri 的值进行了编码,生成了:sso.com/login?redirect_uri=https%3A%2F%2Fservice-a.com%2Fdashboard%3Fparam%3D1。
然而,他们使用的 Web 框架有一个中间件层,会“为了安全”自动对所有出站的查询参数进行 URL 编码。它看到了那个已经编码过的字符串,然后把它又编码了一次。%3A 中的 % 被转换成了 %25,所以 %3A 就变成了 %253A。最终的 URL 成了一堆乱七八糟的双重编码。当用户到达 sso.com 时,它解码了一次 URL,得到了单次编码的字符串,这个字符串它无法用作重定向,导致登录流程彻底中断。
教训: 要了解你的整个工具链。在数据创建时进行编码,并确保后续没有其他系统会再次编码它。 双重编码是一个常见的、令人抓狂的 bug,它能把一个有效的 URL 变成无用的垃圾。
常见错误和陷阱
- 对整个 URL 进行编码。 绝对不要这样做。如果你对
https://example.com进行百分号编码,你会得到类似https%3A%2F%2Fexample.com的东西。这不再是一个有效的 URL;协议方案和主机部分现在只是一堆无意义的字符。你必须只对需要编码的单个组件进行编码(比如查询参数值或特定的路径段)。 - 完全不编码。 这是最常见的“原罪”。直接把原始用户输入或带有特殊字符的数据塞进 URL 字符串,简直是在为安全漏洞(如跨站脚本攻击 Cross-Site Scripting)和功能损坏埋雷。
- 忘记上下文。
&字符在 URL 的路径部分是没问题的,但在查询字符串中它是一个保留的分隔符。/也是同理。当保留字符被用于其特殊目的时,你不需要编码它们。 - 混淆
+和%20。 在application/x-www-form-urlencoded内容类型中(HTML 表单使用),空格在查询字符串中通常被编码为+号。虽然许多服务器能理解这一点,但对于空格,官方的百分号编码是%20。使用%20是无歧义的,并且在 URL 的所有部分都正确工作,而不仅仅是查询字符串。拿不准的时候,就用%20。 - 使用过时的字符集。 Web 运行在 UTF-8 之上。如果你用不同的字符集(比如 ISO-8859-1)来编码数据,那么期望 UTF-8 的服务器将会错误地解释这些字节,并搞乱你的数据。请始终指定并使用 UTF-8。
为什么你应该关注它
如果你写的代码会接触到 URL,你就需要理解百分号编码。这不是可选项。无论何时,只要你正在:
- 从变量或用户输入构建 URL。
- 发起一个 URL 中带有参数的 API 请求。
- 处理文件名、用户资料或可能出现在 URL 中的内容里的国际字符。
- 在服务器端解析 URL 以提取数据。
- 编写重定向或将 URL 作为参数传递给其他服务。
简而言之,百分号编码是 Web 管道系统里一个基础组件。忽略它会导致 bug 频出、不安全且不可靠的软件。了解它的工作原理,是专业 Web 开发者的标志。
深入探索
- RFC 3986: 统一资源标识符 (URI) 的权威规范。第 2 节定义了字符集和百分号编码规则。这是最终的真理之源。
- MDN Web Docs: encodeURIComponent(): 面向 JavaScript 开发者的实用指南,解释了该使用哪个函数以及为什么。其“另见”部分链接到其他相关的编码函数。
- Wikipedia: Percent-encoding: 对该概念、其历史及其各种细微差别的全面且非常易读的概述。
- W3C: Character encodings: 一篇高层次的介绍,解释了为什么字符编码在 Web 上很重要,并将 UTF-8 视为故事的主角。