返回博客
Reference 2026-04-27

URL vs URI vs URN: 区别是什么

一次性厘清这三个标识符规范之间的关系。

开发者在交流中混用「URL」和「URI」,日常场合这没问题。但两者的区别是真实存在的,写在RFC 3986里;而对其底层——通用语法及其编码规则——的误解,才是真正导致生产事故的根源,从损坏的查询字符串到服务端请求伪造(SSRF)。

一段话讲清层次

URI(统一资源标识符)是标识资源的任何字符串的总称。URL(统一资源定位符)是同时告诉你如何到达资源的URI——它带有定位机制,比如scheme和主机。URN(统一资源名称)是以位置无关、持久的方式为资源命名的URI。每个URL都是URI;每个URN也都是URI;但URN不是URL。

URI ─┬─ URL: https://example.com/spec.html

└─ URN: urn:ietf:rfc:3986

通用语法 (RFC 3986)

每个URI可分解为五个组件:

  https://[email protected]:8443/v2/items?sort=asc#top

\___/ \__________________________/\_______/ \______/ \_/

scheme authority path query fragment

  • scheme — 不区分大小写,规范形式为小写;定义其余部分如何解释。
  • authority — 可选的userinfo、主机(注册名、IPv4或带方括号的IPv6如 [::1])和可选端口。user:password@ 形式作为凭据用途已被废弃,如今主要存活在钓鱼和SSRF载荷中。
  • path — 层级结构;... 段在规范化时被解析。
  • query — 约定俗成是用与号连接的key=value对,但RFC 3986根本不强制任何结构。
  • fragment — 永远不会发送到服务器。仅存在于客户端,这就是SPA中 # 之后的路由不出现在访问日志里的原因。

百分号编码是按组件进行的

被误解最深的事实:不存在一个统一的「必须编码的字符」集合。每个组件有自己的保留集。字面的 ? 在路径中是分隔符,但在查询串内部是完全合法的数据;& 在路径段中是数据字符,在查询中却是分隔符。这就是为什么对完整URL整体执行一次 encodeURIComponent 会毁掉它,而正确做法是在组装时对每个动态部分分别编码:

const url = new URL('https://api.example.com/search');

url.searchParams.set('q', 'a&b=c?');

url.toString(); // https://api.example.com/search?q=a%26b%3Dc%3F

还要记住空格的两种写法:%20 是RFC 3986的编码,而 + 只在 application/x-www-form-urlencoded 数据中表示空格。在路径中把 + 解码为空格是一个bug。

URN: 没有地址的名字

URN(RFC 8141)存在于注册的命名空间中,承诺的是持久性,而非可解析性:

urn:isbn:9780134685991

urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6

urn:ietf:rfc:3986

ISBN永久地命名那本书;找到一本实体书是另外的解析步骤。实践中,URN出现在XML命名空间、UUID序列化和标准文档中。值得内化的模式:当你需要一个即使托管迁移也绝不能失效的标识符时,URN风格的名字(或任何位置无关的ID)胜过URL。

两份规范,一个Web

这是多数文章跳过的部分:现代浏览器和JavaScript实现的不是RFC 3986,而是WHATWG URL Standard——一份为匹配浏览器实际行为而编写的活标准。会咬人的差异:

  • WHATWG解析更宽容:在特殊scheme中把反斜杠当作斜杠(https:\\example.com 能解析)、剥离制表符和换行符、把主机转为小写。
  • new URL(relative) 在没有base参数时会抛出异常——WHATWG解析器没有独立相对引用的概念,而RFC 3986为此专门写了一整节。
  • 校验器、代理和较老的服务端框架倾向于遵循RFC 3986,因此同一个URL在每一跳可能被判定得不一样。

这种分歧是可利用的。URL解析器混淆是反复出现的SSRF根因:使用一种解析器的校验器放行了 https://trusted.com\@evil.com/,而使用另一种解析器的HTTP客户端把请求发给了evil.com。防御要从架构层面做:只解析一次,校验解析后的组件(scheme白名单、主机白名单),并把解析后的对象——而不是原始字符串——传给发起请求的代码。

实用规则

  • 无论构建还是解析,都使用 URLURLSearchParams(或所用语言的等价物),而不是字符串拼接。
  • 比较前先规范化:scheme和主机转小写、解析点号段、百分号编码的十六进制转大写、去掉默认端口。
  • /blog/blog/ 视为不同资源;选定一个规范形式,另一个做301重定向。
  • 永远不要把完整URL一次性解码后再拆分;先拆分,再对每个组件解码。

国际化标识符

RFC 3986只允许ASCII,因此非ASCII标识符是叠加在其上的一层。IRI(RFC 3987)直接允许Unicode;把IRI转换为URI时,路径和查询会被百分号编码,主机则通过IDNA进行Punycode编码——bücher.example 变成 xn--bcher-kva.example。这带来两个运维后果。第一,比较必须在规范化之后进行,因为同一逻辑地址有多种编码。第二,同形异义攻击:西里尔字母 а 与拉丁字母 a 视觉上完全相同,让 аpple.com 可以冒充 apple.com。浏览器通过把混合文字的主机名渲染为Punycode来缓解,任何展示用户提交链接的系统都应采用同样的策略,而不是相信字形。

在 sdk.is/url-encoder 的URL编码工具中试验那些棘手的组件编码吧。