URL、URI、URN 到底有什么区别?从概念到实战彻底讲透
1. 从一个被问烂了的问题说起“URL 和 URI 到底有什么区别”——这问题我在带新人的时候几乎每个月都会被问一次。有意思的是问的人往往已经写了半年多的接口调用、配过路由、拼过查询参数但真让他用一句话说清楚两者的关系十有八九会卡壳。更常见的情况是面试时背过“URI 是统一资源标识符URL 是统一资源定位符”背完就没了再追问一句“那 URN 呢”“相对路径算不算 URL”场面立刻安静。这篇东西就是想把这个问题彻底讲透。它不是一篇词典式的名词解释而是从实际写代码、调接口、做爬虫、设计 API 的角度把 URL、URI、URN 这三个概念的关系、边界、以及日常踩坑点全部摊开。看完之后你应该能做到看到任何一个字符串能判断它属于哪一类设计接口时知道该用哪个词遇到编码、解析、拼接的坑能自己定位。适合谁看后端开发、前端开发、测试、做数据采集的、写文档的以及正在准备技术面试的人。哪怕你完全没接触过网络编程只要用过浏览器这篇也能让你把地址栏里那串东西彻底搞明白。先说结论方便你带着框架往下读URI 是一个大集合URL 是 URI 的一个子集URN 也是 URI 的另一个子集。所有的 URL 都是 URI但反过来不成立。这句话是整篇文章的骨架后面所有的细节都是往这个骨架上挂肉。2. 三个概念的本质标识、定位、命名2.1 URI 到底“统一”了什么URI 全称 Uniform Resource Identifier中文叫统一资源标识符。注意关键词是“标识”Identifier不是“地址”也不是“链接”。标识的意思是在一个约定的体系里我能用这个字符串唯一地指代某个东西。至于这个东西在哪、怎么拿到它URI 本身不保证。打个生活化的比方。假设一个小区里有几百户人家每户都有一个唯一的门牌号比如“3 栋 502”。这个门牌号就是一个标识符——它能唯一指代那户人家。但如果你只知道门牌号你未必知道怎么走过去可能小区有两个门也未必知道那户人家姓什么。门牌号解决的是“唯一指代”的问题不解决“怎么到达”和“叫什么名字”的问题。URI 就是这个门牌号体系。它规定了语法结构让全世界的资源都能被一个字符串唯一指代。这个语法结构大致是这样的scheme:[//authority]path[?query][#fragment]拆开看scheme是协议方案比如 http、https、ftp、mailto、fileauthority通常是主机名加端口path是路径query是查询参数fragment是片段标识。这套结构是 RFC 3986 定义的是所有 URI 的通用骨架。关键点在于URI 只要求“唯一标识”不要求“可定位”。一个 URI 可以指向一个网页也可以指向一个邮箱地址mailto:someoneexample.com甚至可以只是一个名字URN 的情况。它是个抽象概念是语法层面的约定。2.2 URL 多了“定位”这个能力URL 全称 Uniform Resource Locator统一资源定位符。它比 URI 多出来的核心能力就是“定位”——不仅告诉你这个东西叫什么还告诉你它在哪、怎么取。还是用门牌号的例子。如果“3 栋 502”后面还附上了“从南门进直走 200 米左转第二栋”那它就从一个纯标识变成了一个定位方式。URL 就是这种带“怎么到达”信息的标识符。一个典型的 URLhttps://www.example.com:443/articles/detail?id42langzh#section-3这里面每一段都有明确含义https是协议www.example.com是主机443是端口/articles/detail是路径id42langzh是查询参数section-3是片段。浏览器拿到这个字符串就知道该用什么协议、连哪台服务器、请求哪个路径、带什么参数、跳到页面哪个位置。这就是“定位”的完整含义。所以 URL 是 URI 的子集它满足 URI 的所有语法要求同时额外提供了定位能力。你在代码里写https://...这种字符串它既是 URI 也是 URL但叫它 URL 更精确因为它确实在定位一个资源。2.3 URN 是另一个子集但日常很少见URN 全称 Uniform Resource Name统一资源名称。它也是 URI 的子集但走的是另一条路只负责“命名”不负责“定位”。最经典的例子是 ISBN。urn:isbn:9787115428028这个字符串唯一标识了一本书但你没法用它直接下载到这本书——它只告诉你“这是哪本书”不告诉你“去哪拿”。类似的还有urn:uuid:...这种用 UUID 做标识的写法。URN 在实际开发里出现频率很低低到很多人干了几年都没主动用过。但理解它很重要因为它是“URI 不等于 URL”的最有力证据。如果 URI 就等于 URL那 URN 就没地方放了。正是因为 URI 是个更大的集合才能同时容纳 URL 和 URN 这两个方向不同的子集。2.4 三者关系一张表说清概念全称核心能力是否 URI 子集日常出现频率URIUniform Resource Identifier唯一标识自身高但常被误用URLUniform Resource Locator标识 定位是极高URNUniform Resource Name标识 命名是极低这张表建议记牢。面试被问到先甩这张表的关系再展开讲基本就稳了。3. 为什么这个区别值得较真3.1 规范用词影响接口设计的清晰度有人会说日常开发里大家把https://...都叫 URL也没出过问题较真这个有意义吗有意义而且比你想的更有意义。举个真实场景。设计 RESTful API 的时候文档里经常要描述“资源路径”。如果你把路径部分叫 URL那/users/42这种相对路径算不算 URL严格说单独的/users/42不是完整 URL因为它缺 scheme 和 authority。但它是 URI 吗在 RFC 3986 里这种叫“相对引用”relative reference它需要配合一个 base URI 才能解析成绝对 URI。所以准确的说法是/users/42是一个相对 URI 引用不是 URL。这个区别在写接口文档、做参数校验、处理重定向的时候会直接影响实现。比如你写一个校验函数判断用户传入的是不是合法 URL如果按“必须以 http/https 开头”来校验那mailto:开头的就被误杀了如果按 URI 语法校验范围又太宽。搞清楚概念边界才知道校验规则该怎么定。3.2 解析库的行为差异源于概念差异不同语言的标准库对这两个词的处理也不一样。比如 Java 里有java.net.URI和java.net.URL两个类它们的行为差异就体现了概念差异URI类更偏向语法解析能处理相对路径、能处理各种 schemeURL类则偏向“可连接”它带有打开连接的方法但对某些 scheme 支持有限。再比如 Python 的urllib.parse里面的urlparse函数其实解析的是通用 URI 结构名字叫 url 但干的是 uri 的活。这种命名和实际行为的错位如果不理解概念看文档时很容易懵。3.3 编码和转义规则统一在 URI 层面URI 的语法里定义了一套百分号编码percent-encoding规则用来处理那些在 URI 里有特殊含义的字符。比如空格要编码成%20中文要按 UTF-8 编码后再百分号转义。这套规则是定义在 URI 层面的URL 和 URN 都继承它。理解这一点你就能明白为什么查询参数里的和需要转义为什么路径里的/不能随便编码为什么 fragment 里的内容不发给服务器。这些细节全部来自 URI 的语法定义而不是 URL 独有的。把根扎在 URI 上这些规则就串成了一条线不用死记。4. 拆开一个 URI每个部分的实战含义4.1 scheme决定“用什么规则玩”scheme 是 URI 最前面的部分冒号之前的那段。它决定了后面内容的解析规则和访问方式。常见的 scheme 有http/https网页访问最熟悉的一类ftp文件传输mailto邮件地址后面直接跟邮箱没有//file本地文件data直接把数据内嵌在 URI 里比如data:text/plain;base64,SGVsbG8urn命名空间scheme 的写法有讲究大小写不敏感但规范建议小写。HTTP://和http://等价但没人会大写。另外scheme 后面必须跟冒号冒号后面如果是//说明有 authority 部分如果没有//比如mailto:和urn:说明没有 authority。这个细节在写解析器的时候很关键。很多手写的正则只认://遇到mailto:就歇菜。正确的做法是先按第一个冒号切出 scheme再根据 scheme 决定后续怎么解析。4.2 authority主机、端口、用户信息authority 部分以//开头到下一个/、?或#结束。它的完整结构是[userinfo]host[:port]userinfo是用户信息格式是用户名:密码现在基本没人用了因为把密码写在 URI 里极不安全。host是主机名或 IP 地址。port是端口号省略时用 scheme 的默认端口http 默认 80https 默认 443。这里有个容易踩的坑默认端口省略时解析出来的 port 是空还是默认值不同库行为不一样。有的返回None有的返回-1有的自动填 80/443。写代码时不能想当然要实测。比如做 URL 去重的时候http://example.com和http://example.com:80应该视为同一个但如果库不自动归一化端口你就得自己处理。还有一个坑是主机名大小写。DNS 解析不区分大小写所以EXAMPLE.COM和example.com指向同一台机器。但路径部分是区分大小写的/Users和/users可能是两个不同资源。做缓存 key 或者去重时主机名要转小写路径不能动。4.3 path资源在服务器上的“门牌”path 是 authority 之后、query 之前的部分以/开头绝对路径的情况。它标识资源在服务器上的位置。path 的解析规则里.和..有特殊含义表示当前目录和上级目录解析时要做归一化。比如/a/b/../c归一化后是/a/c。这个规则在浏览器和服务器里都会执行但执行时机和次数可能不同这是路径穿越漏洞的根源之一。做文件服务的时候必须对 path 做严格归一化防止../../etc/passwd这种攻击。path 里的字符也有讲究。/是分隔符;在某些场景下用于分隔路径参数比如/users;id42?和#是保留字符出现在 path 里必须编码。中文、空格这些非 ASCII 字符也要编码。编码规则是先按 UTF-8 转成字节再对每个字节做百分号编码。4.4 query参数传递的主战场query 以?开头到#结束。格式是keyvalue用分隔。它是 URI 里最灵活也最容易出问题的部分。几个实战要点第一query 里的和是分隔符如果 value 里本身包含这两个字符必须编码成%26和%3D。我见过太多拼接查询参数的代码直接字符串相加用户输入里带个就把参数结构破坏了。第二query 的顺序理论上不影响语义但实际中有些服务端实现依赖顺序做签名的时候顺序更是必须固定。所以做参数拼接时要么保持原顺序要么统一排序不能随机。第三query 里可以出现重复的 key比如?tagatagb。解析时是取第一个、最后一个还是全部不同库不一样。设计接口时要明确约定别让调用方猜。第四query 的长度有限制虽然 RFC 没规定上限但浏览器和服务器都有各自限制通常几千字符。超长参数应该放 body 里别硬塞 query。4.5 fragment只给客户端看的部分fragment 以#开头是 URI 的最后一部分。它的特殊之处在于fragment 不会发送给服务器。浏览器拿到完整 URI 后会把 fragment 之前的部分发给服务器fragment 自己留着用比如跳转到页面锚点。这个特性有几个实际影响。做前端路由的时候hash 模式就是利用 fragment 不触发服务器请求的特点实现无刷新跳转。做数据采集的时候如果目标内容靠 fragment 控制那直接请求 URI 是拿不到对应内容的必须用能执行脚本的工具。还有一个坑fragment 里如果再有#后面的部分也算 fragment 的一部分因为 fragment 是 URI 的结尾没有更细的层级了。但 fragment 里可以有?它只是普通字符。5. 相对引用与基准 URI解析的隐藏逻辑5.1 相对引用不是“残缺的 URL”/users/42、../images/logo.png、?page2、#top这些写法都是相对引用。它们单独拿出来不是完整 URI但配合一个基准 URIbase URI就能解析成绝对 URI。RFC 3986 定义了一套解析算法叫“引用解析”reference resolution。核心步骤是拿基准 URI 的 scheme、authority、path和相对引用的各部分做合并。合并规则有点绕但记住几个典型情况就够了相对引用以//开头继承 scheme替换 authority 及之后以/开头继承 scheme 和 authority替换 path 及之后以?开头继承 scheme、authority、path替换 query以#开头继承除 fragment 外的全部替换 fragment其他情况把相对引用的 path 拼到基准 path 的最后一个/之后再做归一化这套算法是浏览器解析地址栏、爬虫处理页面链接、HTTP 客户端处理重定向的基础。理解它你就能明白为什么有些重定向会跳到意想不到的地方为什么爬虫抓到的链接有时候是错的。5.2 基准 URI 从哪来基准 URI 的来源有好几个优先级不同。在 HTML 页面里base标签可以指定基准没有的话用当前页面的 URI。在 HTTP 响应里Content-Location头可以作为基准。在代码里调用解析函数时通常要显式传入基准。这里有个经典坑如果页面里有base href...那页面里所有相对链接都基于它解析而不是当前页面地址。做爬虫的时候如果不处理base抓到的链接可能全错。这个坑我踩过排查了半天才发现是 base 标签在作怪。5.3 归一化什么时候该做什么时候不该做URI 归一化是指把等价的 URI 转成统一形式比如 scheme 和 host 转小写、默认端口去掉、路径里的.和..解析掉、百分号编码统一大写等。归一化的用途主要是去重和缓存。但要注意归一化不能过度。比如路径里的百分号编码%7E和~在某些场景下不等价不能随便统一。再比如 query 参数的顺序归一化时如果排序了可能破坏签名。所以归一化要按场景定制没有万能方案。6. 编码与转义最容易翻车的地方6.1 百分号编码的本质百分号编码的规则很简单把字符按指定字符集通常是 UTF-8转成字节每个字节写成%XXXX 是两位十六进制。比如空格是%20中文“中”是%E4%B8%AD。但简单规则背后有几个容易搞错的点第一哪些字符需要编码RFC 3986 把字符分成保留字符reserved和非保留字符unreserved。非保留字符是字母、数字、-、_、.、~这些不用编码。保留字符是:/?#[]!$()*,;这些在特定位置有特殊含义出现在其他位置时要编码。其他字符空格、中文等一律编码。第二编码的字符集是什么RFC 3986 建议用 UTF-8但没强制。实际中 UTF-8 是事实标准。如果服务端用 GBK 解码客户端用 UTF-8 编码中文就会乱码。这是跨系统对接时的经典问题。第三号的处理。在 query 里有时被解释为空格这是application/x-www-form-urlencoded的规则有时被解释为字面的加号。这个不一致导致无数 bug。安全做法是空格统一编码成%20加号统一编码成%2B不依赖的特殊含义。6.2 不同部分的编码规则不同这是最容易被忽略的点URI 的不同部分需要编码的字符集不一样。path 里/是分隔符不能编码但?和#必须编码。query 里和是分隔符不能编码但#必须编码。fragment 里几乎没有保留字符大部分字符都可以直接出现。所以不能拿一个通用的编码函数到处用。比如把整个 path 用encodeURIComponent编码会把/也编成%2F路径结构就毁了。正确的做法是用encodeURI处理整个 URI或者分段用encodeURIComponent处理各段的值。6.3 双重编码的坑双重编码是指对一个已经编码的字符串再编码一次。比如%20再编码变成%2520因为%被编码成了%25。双重编码经常在多层系统里出现前端编码一次网关又编码一次到了后端解码一次得到%20以为是空格其实还需要再解一次。这种问题排查起来很痛苦因为每一层看起来都“做对了”。避免双重编码的办法是明确约定哪一层负责编码其他层透传。通常的做法是最靠近用户输入的那一层做编码后续层不再动。如果必须多层处理每层都要记录是否已编码。7. 常见问题与排查速查7.1 高频问题对照表现象可能原因排查方向中文参数乱码编码字符集不一致检查两端是否都用 UTF-8参数被截断value 里有未编码的检查拼接逻辑value 要编码路径 404相对引用解析基准错误检查 base URI 和base标签重定向到错误地址相对 Location 头解析问题检查基准 URI 和解析算法签名校验失败query 顺序或编码不一致固定排序规则和编码方式路径穿越告警path 未归一化对 path 做严格归一化和白名单校验fragment 内容拿不到fragment 不发给服务器改用 query 或客户端渲染7.2 排查思路从字符串到结构遇到 URI 相关问题第一步永远是把字符串解析成结构看清楚每一部分到底是什么。不要盯着原始字符串猜。用对应语言的解析库打印出 scheme、host、path、query、fragment很多问题一眼就能看出来。第二步是检查编码。把解析出来的各部分再编码回去看和原始字符串是否一致。不一致的地方就是问题所在。第三步是检查基准。如果是相对引用确认基准 URI 是什么解析算法有没有用对。这三步走下来九成的 URI 问题都能定位。7.3 几个独家避坑技巧技巧一永远不要用字符串拼接构造 URI。用专门的构造库比如 Python 的urllib.parse.urlencode、JavaScript 的URLSearchParams。手写拼接迟早出事。技巧二解析 URI 时先判断有没有 scheme。没有 scheme 的字符串按相对引用处理别硬套绝对 URI 的解析逻辑。技巧三处理重定向时Location 头可能是相对引用。必须用当前请求的 URI 作为基准去解析不能直接当绝对 URI 用。技巧四做 URI 比较时先归一化再比。但归一化规则要固定且要意识到归一化可能改变语义涉及签名的场景要谨慎。技巧五日志里打印 URI 时注意脱敏。query 里可能有 token、密码等敏感信息直接打日志会泄露。打印前要把敏感参数替换掉。8. 落到代码几个典型场景的实现8.1 安全地拼接查询参数以 Python 为例正确做法是用urlencodefrom urllib.parse import urlencode, urlunparse params {keyword: 中 文特殊字符, page: 2} query urlencode(params) # 结果: keyword%E4%B8%AD%E6%96%87%26%E7%89%B9%E6%AE%8A%3D%E5%AD%97%E7%AC%A6page2注意urlencode默认把空格编成如果要编成%20传quote_viaquote。这个细节在对接严格要求%20的服务时很关键。8.2 解析相对引用JavaScript 里可以用URL构造函数const base https://example.com/a/b/c; const resolved new URL(../d?x1, base); console.log(resolved.href); // https://example.com/a/d?x1这个构造函数内部实现了 RFC 3986 的解析算法比自己写正则靠谱得多。注意它要求 base 必须是绝对 URI否则会抛错。8.3 判断一个字符串是不是绝对 URI不能简单看有没有://因为mailto:和urn:没有。正确做法是按 RFC 3986 的语法判断scheme 必须是字母开头后跟字母、数字、、-、.然后跟冒号。import re def is_absolute_uri(s): return bool(re.match(r^[a-zA-Z][a-zA-Z0-9\-.]*:, s))这个判断比://靠谱能覆盖所有 scheme。9. 面试怎么答才不掉坑被问到 URL 和 URI 的区别别只背定义。按这个顺序答第一先给关系URI 是父集URL 和 URN 是子集URL 是 URI 里最常见的一类。第二给判据URL 的核心是“定位”能告诉你资源在哪、怎么取URI 只要求“标识”能唯一指代就行。第三给例子https://...是 URL 也是 URIurn:isbn:...是 URI 不是 URL/users/42是相对 URI 引用不是 URL。第四给实战影响接口设计、参数校验、重定向处理里用词准确能避免歧义编码规则统一在 URI 层面理解这点能解释很多转义问题。这样答下来既显示了概念清晰又显示了有实战经验比干背定义强得多。10. 我个人在实际操作中的体会干了这么多年我最大的体会是URI 这套东西难点不在概念在细节的一致性。概念半小时就能讲明白但真正让人翻车的是编码字符集不统一、相对引用基准搞错、query 拼接没转义这些细节。这些问题不会在写 demo 的时候暴露只会在跨系统对接、处理用户输入、做数据采集的时候冒出来。我的建议是把 URI 当成一个有严格语法的数据结构来对待而不是一串可以随便拼的字符。解析用库构造用库比较前先归一化编码规则按部分区分。做到这几点URI 相关的 bug 能少一大半。另外遇到 URI 问题别慌按“解析成结构、检查编码、确认基准”这三步走基本都能定位。这个排查套路我用了很多年屡试不爽。