HTTP 深度解析:从 URL 到 Cookie 的完整实践
一、为什么还需要一篇 HTTP 文章?
前面三篇文章我们已经构建了完整的网络知识体系:从物理层到应用层的分层模型,从 TCP 可靠传输到 IP 路由寻址,再到 HTTPS 的加密握手。但对于 HTTP 协议本身,还有一大块"日常开发高频使用、面试中高频出现"的内容没有展开。
比如:你每天都在用 URL,但你真的理解它每一部分的含义吗?你知道为什么 URL 中有些字符必须转义吗?你知道 GET 和 POST 的本质区别不是"参数放哪"而是"幂等性"吗?你知道 Cookie 到底是怎么让无状态的 HTTP 变成"有状态"的吗?
这些就是本文要讲的内容。
二、URL:定位互联网资源的六把钥匙
2.1 一个 URL 到底由什么组成?
我们先看一个完整的 URL:
https://user:pass@v.bitedu.vip:8080/personInf/student?userId=10000&classId=100#section
把它拆成六个部分:
协议方案 | 用户信息 | 主机名 | 端口 | 路径 | 查询字符串 | 片段标识
https:// | user:pass | v.bitedu.vip | :8080 | /personInf/student | ?userId=10000&classId=100 | #section
协议方案 告诉客户端"用什么协议通信"。最常见的当然是 http 和 https,但 URL 的协议方案不局限于此------ftp:// 是文件传输,mailto: 是发邮件,jdbc:mysql:// 是数据库连接字符串。协议方案后面必须跟双斜杠 //,这是早期 URL 规范定义的,现在已经成了标准。
用户信息 的格式是 用户名:密码@。这个字段在早期互联网中用于认证,但现在已经几乎没人用了。原因很直接:密码写在 URL 里太危险了。URL 会被记录在浏览器历史、服务器日志、代理服务器日志里,明文密码随时可能泄露。现代网站都把认证信息放到 Header(如 Authorization)或 Cookie 里,URL 里的用户信息字段基本废了。
主机名 就是域名,通过 DNS 解析成 IP 地址。你可以用 ping 命令验证:ping v.bitedu.vip 会返回真实 IP(比如 118.24.113.28)。URL 中也可以直接写 IP 地址(比如 http://118.24.113.28/),只是不好记而已。
端口号 指定 TCP 端口。如果省略,浏览器会根据协议自动选择默认值:http 用 80,https 用 443,ftp 用 21。这就是为什么你在浏览器输入 http://example.com 时不需要写 :80。
路径 表示资源在服务器上的层次化位置。但要注意:路径不一定对应实际的物理文件。在 Spring MVC 中:
java
@GetMapping("/personInf/student")
public Student getStudent(@RequestParam Long userId) { ... }
这个路径只是路由规则的一个键,背后可能对应数据库查询、业务逻辑计算等等。它和文件系统没有直接关系。
查询字符串 是问号后面的键值对,用 & 分隔多个键值对,用 = 分隔键和值。你可以把它理解为函数调用的参数列表:
javascript
// 函数调用
getUserById(userId=10000, classId=100)
// 等价的 URL query string
?userId=10000&classId=100
键和值的命名完全由后端程序员自定义,没有标准约束。
片段标识 是 # 后面的部分,主要用于页面内跳转。比如 https://cn.vuejs.org/v2/guide/#起步 会让浏览器滚动到 id 为 起步 的元素。
这里有一个非常重要的细节:片段标识不会发送给服务器。当你访问上面的 URL 时,服务器收到的请求行是:
GET /v2/guide/ HTTP/1.1
服务器完全不知道你是因为 #起步 才跳转过来的。这是前端用来做单页应用(SPA)路由的基础------Vue Router 的 hash 模式、React Router 的 hash 模式,都是利用这个特性实现的。
2.2 URL 中哪些部分可以省略?
| 部分 | 可省略吗 | 省略后的含义 |
|---|---|---|
| 协议名 | 可以 | 默认为 http:// |
| 用户信息 | 可以 | 几乎总是省略 |
| 端口号 | 可以 | http→80, https→443 |
| 路径 | 可以 | 省略后相当于 / |
| 查询字符串 | 可以 | 没有任何参数 |
| 片段标识 | 可以 | 页面内无跳转 |
HTML 标签中还有一套特殊的省略规则。当 src 或 href 属性只写路径时,会自动使用当前页面的域名和协议:
html
<!-- 假设当前页面是 https://example.com/a/page.html -->
<img src="image.png"> <!-- 实际访问 https://example.com/a/image.png -->
<a href="/other/index.html"> <!-- 实际访问 https://example.com/other/index.html -->
前者是相对路径(相对于当前目录),后者是绝对路径(从域名根目录开始)。
2.3 URL Encode:为什么特殊字符必须转义?
URL 中有许多字符被赋予了特殊含义:/ 是路径分隔符,? 是查询字符串开始,& 是键值对分隔符,= 是键值分隔符,# 是片段标识开始,+ 在某些场景下表示空格,% 是转义符本身。
如果一个参数值中包含这些字符怎么办?比如你想搜索 a+b=c,直接拼接到 URL 中会变成 ?q=a+b=c。服务器收到后无法判断这是一个参数 q="a+b=c" 还是两个参数 q="a" + b="c"。
所以需要对特殊字符进行转义编码。规则是:将字符转为十六进制,前面加 %,格式为 %XY。
| 原始字符 | 编码后 |
|---|---|
+ |
%2B |
= |
%3D |
| 空格 | %20 或 + |
| 中文"测试"(UTF-8) | %E6%B5%8B%E8%AF%95 |
浏览器会自动处理这个问题。如果你在地址栏输入 ?q=a+b=c,浏览器实际发送的是 ?q=a%2Bb%3Dc。服务器收到后会自动做 URL Decode 还原成原始字符。
常见误区:"GET 只能传文本,POST 能传二进制"
这个说法不准确。GET 的 query string 虽然不能直接放二进制数据,但可以先 Base64 编码再 URL Encode,就能传任意数据了。只是不方便阅读、长度受限罢了。
三、HTTP Method:不只是 GET 和 POST
3.1 GET:获取资源的默认操作
GET 是最常用的 HTTP 方法,几乎所有浏览器地址栏的请求都是 GET。它的特点是:
- 首行第一部分是
GET - query string 可以为空或不为空
- header 部分有若干键值对
- body 部分为空
触发 GET 请求的场景包括:浏览器地址栏输入 URL、HTML 中的 link/img/script/a 标签加载资源、JavaScript 的 fetch()/axios.get() 等。
关于 GET 的 URL 长度问题
网上有些资料说"GET 请求最多 1024KB",这是错误的。HTTP/1.1 标准(RFC 2616)原文明确指出:
"Hypertext Transfer Protocol -- HTTP/1.1" does not specify any requirement for URL length.
标准中没有规定 URL 的长度限制!实际限制来自浏览器和服务器的实现:Chrome 约 2MB,Firefox 约 65KB~2MB,Nginx 默认约 8KB(可配置),Apache 默认约 8KB。
这不是协议的限制,而是软件实现的限制。就像 TCP 本身不限制数据包大小,但以太网 MTU 限制为 1500 字节一样。
3.2 POST:提交数据的标准方式
POST 的特点是:
- 首行第一部分是
POST - query string 一般为空(也可以不为空)
- header 部分有若干键值对
- body 部分一般不为空,数据格式由
Content-Type指定
POST 多用于提交用户输入的数据给服务器,比如登录页面、下单页面、文件上传等。
3.3 GET 和 POST 的本质区别
这是面试中被问到烂的问题,但绝大多数人只答了一半。完整的回答应该从三个层面展开:
语义层面:GET 的语义是"获取资源",它是幂等且安全的------多次执行效果相同,不修改服务器状态。POST 的语义是"提交数据",它是非幂等的------每次执行可能产生不同的副作用。
实现层面:
| 特性 | GET | POST |
|---|---|---|
| 参数位置 | URL 的 query string | 请求体(Body) |
| 长度限制 | 受浏览器/服务器实现限制 | 理论上无限制 |
| 浏览器缓存 | 可以被缓存 | 不会被缓存 |
| 浏览器书签 | 可以收藏 | 不可以收藏 |
| 浏览器回退 | 不会重新提交 | 会提示"确认重新提交" |
常见误解纠正:
有些资料说"POST 比 GET 安全",这是不科学的。是否安全取决于前端在传输密码等敏感信息时是否进行加密,和 GET/POST 无关。HTTPS 下 GET 参数同样是加密传输的。
有些资料说"GET 传输数据量小,POST 传输数据量大",这也是不科学的。标准没有规定 GET 的 URL 长度,也没有规定 POST 的 body 长度。传输数据量完全取决于浏览器和服务器的实现。
有些资料说"GET 只能传文本,POST 能传二进制",同样不准确。GET 的 query string 虽然无法直接传输二进制,但可以对二进制数据进行 URL Encode 后传输。
3.4 其他 Method 的详细语义
| Method | 语义 | 幂等性 | 典型用途 |
|---|---|---|---|
| PUT | 整体替换资源 | 是 | 更新整个用户资料 |
| DELETE | 删除指定资源 | 是 | 删除评论 |
| HEAD | 同 GET 但不返回 body | 是 | 检查资源是否存在 |
| OPTIONS | 返回服务器支持的方法 | 是 | CORS 预检请求 |
幂等性的定义:多次执行相同请求,结果与一次执行相同。
为什么 PUT 和 DELETE 是幂等的?PUT /users/123 {name:"张三"} 第一次把用户改成"张三",第二次还是同样的结果。DELETE /users/123 第一次删除了用户,第二次再删会返回 404,但不会"多删"一次。
为什么 POST 通常不是幂等的?POST /orders 创建订单,每次执行都会产生一个新的订单记录。两次执行就是两条记录。
RESTful API 的设计规范 就是基于这个原则:GET /users/123 获取,PUT /users/123 整体替换,PATCH /users/123 部分更新,DELETE /users/123 删除。Method 的语义决定了 API 的行为。
四、Header:键值对构成的元数据仓库
4.1 Host:服务器的地址和端口
即使你把 IP 写在 URL 里,HTTP 请求的 header 中仍然会有 Host 字段:
GET /index.html HTTP/1.1
Host: www.example.com:80
为什么需要 Host?因为一个 IP 地址可能对应多个网站(虚拟主机)。比如腾讯云的一台服务器可能同时托管着 site-a.com、site-b.com、site-c.com。如果没有 Host header,服务器收到请求后不知道要把数据返回给哪个站点。HTTP/1.1 标准强制要求必须有 Host header。
4.2 Content-Length:Body 的确切长度
Content-Length: 105
表示 Body 部分的字节数是 105。为什么需要它?因为 TCP 是面向字节流的,不像 UDP 有固定的报文边界。如果没有 Content-Length,接收方可能永远不知道 Body 何时结束。
这和前面讲过的 TCP 粘包问题是同一个根源:TCP 不保留应用层的消息边界,所以必须在应用层自己定义"消息结束"的标记。Content-Length 就是 HTTP 定义的标记方式之一。
4.3 Content-Type:Body 的数据格式
这是最关键的 header 之一,决定了服务器如何解析 Body。HTTP 协议定义了三种主流的 Body 格式:
application/x-www-form-urlencoded
传统的表单提交格式:
title=test&content=hello&tags=java,backend
就像 URL 的 query string 一样,是 key=value&key2=value2 的形式。适合简单的表单提交,但无法上传文件。
multipart/form-data
用于文件上传,格式复杂但功能强大:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryrGKCBY7qhFd3Trw
------WebKitFormBoundaryrGKCBY7qhFd3TrwA
Content-Disposition: form-data; name="text"
title
------WebKitFormBoundaryrGKCBY7qhFd3TrwA
Content-Disposition: form-data; name="file"; filename="chrome.png"
Content-Type: image/png
PNG 二进制数据...
------WebKitFormBoundaryrGKCBY7qhFd3TrwA--
boundary 定义了各个部分的边界,每部分有自己的 metadata(文件名、类型等)。这种格式可以传任意类型的数据(二进制、文本混合),适合文件上传。
application/json
现代 API 最常用的格式:
json
{"username":"zhangsan","password":"123456"}
服务器通过 Content-Type: application/json 知道要用 JSON 解析器来解析 Body。JSON 结构清晰、嵌套方便、支持复杂数据类型(数组、对象、布尔值等),而且 JavaScript 原生支持(JSON.parse()、JSON.stringify()),相比 XML 更简洁、占用更少带宽。
如何选择? 简单表单提交用 application/x-www-form-urlencoded,文件上传用 multipart/form-data,现代 API 通信用 application/json。
4.4 User-Agent:浏览器的自我陈述
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36
这个字符串包含了操作系统(Windows 10)、浏览器引擎(WebKit/Chrome)、浏览器版本(Chrome 91.0.4472.77)等信息。
为什么这么长?这是历史遗留问题。早期 Netscape 浏览器引入 UA 时只是简单声明"Mozilla N.0",后来为了兼容性各大厂商都延续了这种方式,把自己的信息塞进去。
User-Agent 的实际用途包括:服务器根据 UA 返回不同版本的页面(移动版/桌面版)、爬虫检测(很多网站会拦截非正常 UA 的请求)、统计分析(各浏览器市场份额)。很多爬虫会用最新的 Chrome UA 伪装身份,但实际上它们的代码根本没有实现渲染能力。
4.5 Referer:来源追踪
Referer: https://gitee.com/login
表示"我是从哪个页面跳转到当前页面的"。注意:它是 Referer 而不是 Referrer,这是个拼写错误但沿用至今(HTTP/1.0 规范 RFC 1945 中的拼写错误,HTTP/1.1 决定保留以确保兼容性)。
如果直接从地址栏输入 URL、从书签或邮件中的链接点击,就没有 Referer。Referer 的用途包括:防盗链(检查请求是否来自合法站点)、流量分析(看用户从哪来)、安全(防止 CSRF 攻击的一部分手段)。
4.6 Cookie:会话身份的载体
Cookie: username=zhangsan; gitee-session-n=M1Rhbk1QUUxQdWk1VEZVQ1BvZXYybG13ZUJFNGR1V0pSYTZyTllE
Cookie 是"键值对的集合",不同的键值对之间用 ; 分隔。每个域名下的 Cookie 是隔离的:gitee.com 的 Cookie 不会发给 baidu.com,login.gitee.com 的 Cookie 也不会发给 www.gitee.com。
Cookie 可以通过 Expires 或 Max-Age 设置过期时间,也可以在响应中设为 session cookie(浏览器关闭就删除)。
五、登录流程:Cookie/Token 如何实现"有状态的会话管理"
5.1 从抓包看登录全过程
以 Gitee 为例,完整观察一遍登录:
第 1 步:清除 Cookie
打开开发者工具 → Network → Cookies,手动删除所有 gitee.com 相关的 Cookie。
第 2 步:发起登录请求
POST https://gitee.com/login HTTP/1.1
Host: gitee.com
Content-Type: application/x-www-form-urlencoded
encrypt_key=password&utf8=%E2%9C%93&authenticity_token=36ZqO9tglSN6EB6pF6f2Gt%2B...
注意这里传的是密码,但没有看到任何 session token------此时我们确实还没有身份标识。
第 3 步:服务器返回 302 重定向 + Set-Cookie
HTTP/1.1 302 Found
Location: https://gitee.com/HGtz2222
Set-Cookie: oschina_new_user=false; path=/
Set-Cookie: gitee_user=true; path=/
Set-Cookie: gitee-session-n=M1Rhbk1QUUxQdWk1VEZVQ1BvZXYybG13ZUJFNGR1V0pSYTZyTllE; path=/
这里有三个 Set-Cookie,最关键的是第三个 gitee-session-n。它的值是一串加密的长字符串,这就是会话令牌(Session Token)。
第 4 步:后续请求自动携带 Cookie
现在访问个人主页:
GET https://gitee.com/HGtz2222 HTTP/1.1
Cookie: oschina_new_user=false; user_locale=zh-CN; yp_riddler_id=1ce4a551-a160-4; gitee-session-n=M1Rhbk1QUUxQdWk1VEZVQ1BvZXYybG13ZUJFNGR1V0pSYTZyTllE
看到了吗?浏览器自动把上一步服务器设置的 Cookie 带上去了。这就是"会话保持"的秘密。
第 5 步:服务器验证 Cookie
服务器收到请求后,从 Cookie 中提取 gitee-session-n,用它去 Session 存储(Redis、内存等)查找对应的用户信息。找到就放行,找不到就返回 401/302 跳到登录页。
医院看病的比喻:
- 挂号:提供身份证 → 拿到就诊卡(Session Token)
- 看病:每次出示就诊卡 → 医生识别身份 → 开药
- 结束:注销就诊卡 → Session 失效
- 再来看病:办一张新的就诊卡 → 得到新的 Token
5.2 Cookie vs Token:两种身份验证模式
前面讲的是 Cookie-based Session 模式。还有一种流行方式是 Token-based,以 JWT 为例:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
两者对比:
| 特性 | Cookie-based Session | Token (JWT) |
|---|---|---|
| 状态存储 | 服务器端(Session Store) | 客户端(JWT Self-contained) |
| 携带方式 | 浏览器自动携带 | 前端手动放入 Header |
| 跨域 | 受同源策略限制 | 不受限 |
| CSRF 防护 | 需要额外机制 | 天然免疫 |
| XSS 风险 | HttpOnly 可防 | localStorage 易被窃取 |
| 扩展性 | 需共享 Session(Redis) | 无状态,天然水平扩展 |
| 主动失效 | 服务端删除即失效 | 过期前无法强制失效 |
Token 的工作方式:服务器生成一张带签名的卡片(JWT)给你,每次出示这张卡片时服务器验证签名即可,无需查数据库。卡片过期自然失效。
实际项目中的混合用法:很多系统会结合两种方式------用 Cookie 存一个 refresh token(HttpOnly 防 XSS),用 Token 存 access token(短有效期),Token 过期时用 Refresh Token 换取新 Token。这样既安全又灵活。
六、深入思考题
Q1:如果 GET 不允许有 Body,为什么实际中很多人会往 GET 请求的 Body 里放数据?
RFC 规范说 GET 不应该有 Body,但并没有禁止。实际中 axios、Postman 都允许 GET 有 Body。但这会导致问题:不符合规范可能被某些服务器拒绝,缓存行为不一致(有些缓存系统忽略 Body),语义混乱(GET 应该只是获取,不应该有 payload)。
最佳实践:严格遵守规范,GET 不放 Body,参数全部走 query string。
Q2:302 重定向和 301 有什么区别?307/308 呢?
| 状态码 | 名称 | 浏览器行为 |
|---|---|---|
| 301 | Moved Permanently | 永久重定向,浏览器会缓存,下次直接访问新地址 |
| 302 | Found | 临时重定向,浏览器不会缓存 |
| 307 | Temporary Redirect | 同 302,但严格要求保持原方法和 Body |
| 308 | Permanent Redirect | 同 301,但严格要求保持原方法和 Body |
为什么需要 307/308?因为历史上很多浏览器对 301/302 的处理不规范:当你用 POST 请求一个返回 302 的 URL 时,浏览器可能会把后续请求变成 GET(丢失 Body)。307/308 明确要求浏览器必须保持原方法和 Body。
Q3:为什么 referer 拼写成"referer"而不是"referrer"?
这是 HTTP/1.0 规范(RFC 1945)中的拼写错误。到了 HTTP/1.1(RFC 2068),标准决定保留这个错误以确保兼容性。所以正确的拼写应该是"referrer"(源指涉者),但 Header 字段名永远是 Referer。
七、经典面试题补充
1. URL 中哪些部分是可选的?省略后会默认什么值?
协议名(默认为 http://)、用户信息(省略)、端口号(http→80, https→443)、路径(→/)、查询字符串(→无参数)、片段标识(→无跳转)。HTML 标签中还可以省略域名,自动使用当前页面域名。
2. application/x-www-form-urlencoded 和 multipart/form-data 的区别?
前者是简单的 key=value&key2=value2 格式,只能传文本;后者用 boundary 分隔,支持任意类型数据(如文件上传),但格式复杂。
3. Cookie 和 Token 的核心区别?
Cookie 存储在服务器端(Session),浏览器仅持有 ID;Token(如 JWT)包含完整用户信息,存在客户端。Cookie 有 CSRF 风险但可通过 HttpOnly 缓解,Token 无状态易于扩展但难以主动失效。
4. PUT 和 POST 的区别?
PUT 是幂等的,用于整体替换资源;POST 是非幂等的,用于创建新资源。PUT 要求客户端知道资源标识,POST 则由服务器分配 ID。
5. Content-Type 有哪些常见值?分别对应什么格式?
application/x-www-form-urlencoded(传统表单)、multipart/form-data(文件上传)、application/json(现代 API)、text/html(HTML 页面)、image/png(图片)等。
八、总结
本文深入讲解了 HTTP 协议的工程细节:URL 的六个组成部分、可省略规则、URL Encode 的必要性;HTTP Method 的语义与幂等性、GET/POST 的本质区别;Header 的实际用途、三种 Body 格式的适用场景;以及 Cookie/Session 如何实现"有状态的会话管理"。
这些内容补充了前三篇文章没有展开的应用层细节,构成了完整的 HTTP 知识体系。