嘿,朋友!你有没有想过,当你在那小小的浏览器地址栏里敲下 https://www.baidu.com 然后按下回车的那一刻,背后究竟发生了什么惊天动地的大工程?
这就像是你点了一份外卖。你下单(输入网址),骑手接单(DNS解析),去商家取货(TCP连接),把餐送到你家(HTTP请求),你把饭吃进嘴里(HTML渲染),最后满意地打个饱嗝(页面显示)。
但这个过程比点外卖复杂一万倍,因为它涉及了网络世界的“七大层次”、无数台服务器、还有我们今天要聊的主角——HTTP协议。
今天,我们就把这个过程掰开揉碎了讲清楚,顺便用 Python 和 Java 写两个小demo,让你亲手“造”一个网络请求。准备好咖啡了吗?我们要开始了。
第一阶段:DNS 解析——从域名到 IP 的“电话簿查询”
首先,你得知道,计算机世界其实并不认识 www.baidu.com 这种花花绿绿的域名。它们只认识冷冰冰的数字 IP 地址,比如 110.242.68.4。
所以,第一步,浏览器得像查电话簿一样,把域名翻译成 IP 地址。这个过程叫 DNS 解析。
这个过程有多曲折?
想象一下,你忘了朋友的电话号码,你该怎么办?
- 浏览器缓存:你先摸摸自己的口袋,看看有没有存过这个号码。其实,浏览器里真的有一张缓存表,最近访问过的域名 IP 都记着呢。如果找到了,嘿,直接打电话,不用麻烦别人。
- 系统缓存(Hosts 文件):浏览器没记住?那问问操作系统。Windows 有个叫
hosts的文件,Linux/Mac 也有。你可以在里面手动指定www.baidu.com对应哪个 IP。这招通常被黑客或者网络管理员用来劫持流量。 - 路由器缓存:系统也没记住?问问你的路由器。很多路由器也会缓存 DNS 记录,毕竟大家用的都是一个网。
- ISP DNS 服务器(本地 DNS):上面全没了?那就只能问你的网络服务提供商(ISP)了,比如中国电信、联通。它们有一台专门的服务器叫 本地 DNS 服务器,它手里有一本很大的“全球电话号码簿”。如果本地 DNS 也没有,它就去问根域名服务器。
- 根域名服务器 -> 顶级域名服务器 -> 权威域名服务器:
- 本地 DNS 先去问根域名服务器:“嘿,.com 归谁管?” 根服务器说:“归
.com的顶级域名服务器管,地址是 xxx。” - 本地 DNS 再去问 .com 顶级域名服务器:“嘿,baidu.com 归谁管?” 顶级服务器说:“归百度的权威域名服务器管,地址是 yyy。”
- 本地 DNS 最后去问 权威域名服务器:“嘿,www.baidu.com 的 IP 是多少?” 权威服务器说:“是
110.242.68.4。”
- 本地 DNS 先去问根域名服务器:“嘿,.com 归谁管?” 根服务器说:“归
- 返回结果:本地 DNS 把 IP 地址存进自己的缓存,然后发给你的电脑。你的电脑再存进系统缓存,发给浏览器。
整个过程在几十毫秒内完成,快到你感觉不到。
第二阶段:TCP 连接——建立一条“可靠的高速公路”
拿到了 IP 地址,但浏览器还不能直接发数据。因为互联网是不稳定的,数据包可能会丢、会乱序。为了确数据能稳稳当当送到,我们需要先建立一条可靠的、面向连接的通道。这就是 TCP 三次握手。
为什么是“三次”握手?
想象一下,A 想给 B 打电话,但中间有个很大的山谷,声音传过去会有回音,分不清谁是谁。
- 第一次握手(SYN):A 对 B 说:“喂,B 在吗?我想跟你聊天。”(A 发送 SYN 包,序列号 x)
- 第二次握手(SYN + ACK):B 听到了,说:“我在!我听到你了,你也听到了对吧?”(B 发送 SYN+ACK 包,序列号 y,确认号 x+1)
- 第三次握手(ACK):A 说:“好的,我也听到了,那我们开始吧。”(A 发送 ACK 包,序列号 x+1,确认号 y+1)
关键点:如果只有两次握手,B 说“我在”,A 就不说话了。那 B 会一直等下去,资源浪费。而且,如果 A 的请求在网络里延迟了很久才到 B,B 以为 A 要连接,就建立连接。但其实 A 早就忘了。第三次握手让 A 确认一下“这真的是我要连的吗?”,从而避免历史重复连接的问题。
代码时间:Python 看三次握手
我们可以用 Python 的 scapy 库来捕获这个握手过程,或者简单地用 socket 模拟。为了让你看清本质,我们用标准的 socket 连接一个网站,并观察底层行为(虽然 socket 封装了太多,但我们还是能感受到它的存在)。
import socket
# 目标:百度
target_host = "www.baidu.com"
target_port = 80 # HTTP 默认端口
# 创建 TCP Socket
# AF_INET 表示 IPv4,SOCK_STREAM 表示 TCP
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
# 连接服务器,这背后就是三次握手
print(f"正在连接 {target_host}:{target_port} ...")
sock.connect((target_host, target_port))
print("TCP 三次握手成功!连接已建立。")
# 接下来就可以发送 HTTP 请求了
# ... (后面会讲)
except Exception as e:
print(f"连接失败: {e}")
finally:
sock.close()
在 Java 中,逻辑是一模一样的:
import java.io.IOException;
import java.net.Socket;
public class TcpHandshakeDemo {
public static void main(String[] args) {
String targetHost = "www.baidu.com";
int targetPort = 80;
try (Socket sock = new Socket(targetHost, targetPort)) {
System.out.println("TCP 三次握手成功!连接已建立。");
// 接下来发送 HTTP 请求...
} catch (IOException e) {
System.err.println("连接失败: " + e.getMessage());
}
}
}
注意:现代浏览器默认用 HTTPS(端口 443),HTTPS 在 TCP 之上还有一层 TLS/SSL 握手,比 TCP 握手更复杂,涉及密钥交换、证书验证等。为了讲清楚 HTTP,我们先聚焦在 HTTP 本身,假设是 HTTP 协议。
第三阶段:发送 HTTP 请求——写一封信
TCP 路通了,现在可以发货了。浏览器要构造一个 HTTP 请求报文,发给服务器。
HTTP 请求长什么样?
它就像一封信,分为四部分:
- 请求行:告诉服务器我要干什么。
- 方法:GET(我要读)、POST(我要写)、PUT(我要更新)、DELETE(我要删)…
- URL:我要去哪个资源?比如
/index.html - 版本:HTTP/1.1, HTTP/2, HTTP/3
- 请求头(Headers):附加信息。
Host: 目标主机(必须)User-Agent: 你是谁?(Chrome, Firefox, Python-requests…)Accept: 我想要什么格式?(HTML, JSON, Image…)Cookie: 你的身份信息- … 等等几十个字段
- 空行:表示请求头结束。
- 请求体(Body):POST 请求才有数据,比如你登录时填的账号密码。GET 请求没有请求体。
举个例子
假设你用 Chrome 访问 http://example.com/login,并且提交账号 user 密码 pass。
POST /login HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Content-Type: application/x-www-form-urlencoded
Content-Length: 22
username=user&password=pass
代码时间:Python 发送 HTTP 请求
用 requests 库最简单,但为了让你看清本质,我们用底层 socket 手动拼一个请求。
import socket
def send_http_request(host, port, path, method="GET", body=None):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
# 构造请求头
headers = f"{method} {path} HTTP/1.1\r\n"
headers += f"Host: {host}\r\n"
headers += "Connection: close\r\n" # 请求完后关闭连接(HTTP/1.1 默认 Keep-Alive,这里简单演示)
headers += "User-Agent: Custom-Python-Client\r\n"
# 如果有请求体(比如 POST)
if body:
headers += f"Content-Type: application/x-www-form-urlencoded\r\n"
headers += f"Content-Length: {len(body)}\r\n"
headers += "\r\n" # 空行,结束请求头
# 组装完整请求
request = headers + (body if body else "")
# 发送请求
sock.sendall(request.encode('utf-8'))
print("发送请求:\n" + request)
# 接收响应
response = b""
while True:
chunk = sock.recv(4096)
if not chunk:
break
response += chunk
if b"\r\n\r\n" in response: # 简单的判断响应头结束
# 这里为了演示,只打印前1000字节
print("接收到响应 (前1000字节):\n" + response[:1000].decode('utf-8', errors='ignore'))
break
sock.close()
# 模拟一个 GET 请求
send_http_request("www.baidu.com", 80, "/")
# 模拟一个 POST 请求
send_http_request("httpbin.org", 80, "/post", method="POST", body="name=Agnes&age=20")
Java 版本类似,利用 Socket 发送字节流:
import java.io.*;
import java.net.Socket;
public class HttpGetExample {
public static void main(String[] args) {
String host = "www.baidu.com";
int port = 80;
String path = "/";
try (Socket socket = new Socket(host, port);
OutputStream out = socket.getOutputStream();
InputStream in = socket.getInputStream()) {
// 构造 HTTP 请求
String request = "GET " + path + " HTTP/1.1\r\n"
+ "Host: " + host + "\r\n"
+ "Connection: close\r\n"
+ "\r\n";
// 发送请求
out.write(request.getBytes());
System.out.println("发送请求:\n" + request);
// 读取响应
byte[] buffer = new byte[4096];
int bytesRead;
StringBuilder response = new StringBuilder();
while ((bytesRead = in.read(buffer)) != -1) {
response.append(new String(buffer, 0, bytesRead));
if (response.toString().contains("\r\n\r\n")) {
// 简单起见,只打印响应头部分
String[] parts = response.toString().split("\r\n\r\n", 2);
System.out.println("接收到响应头:\n" + parts[0]);
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
第四阶段:服务器处理请求——房东的反应
服务器(比如 Nginx、Apache、Tomcat)收到请求后,不会直接返回 HTML 文件。它会经过一系列处理:
- 解析请求:看看是 GET 还是 POST,URL 是什么,头信息有没有问题。
- 路由匹配:根据 URL,找到对应的处理逻辑。比如
/api/user可能指向某个 Python Flask 或 Java Spring Boot 的接口。 - 业务逻辑处理:
- 可能需要查数据库。比如你访问首页,服务器要去数据库里拉取最新新闻。
- 可能需要调用其他微服务。
- 可能需要验证权限(看 Cookie 里的 Token 对不对)。
- 生成响应:把处理结果包装成 HTTP 响应报文。
第五阶段:HTTP 响应——服务器的回信
服务器给浏览器回一封“信”,这就是 HTTP 响应。
HTTP 响应长什么样?
- 状态行:
HTTP/1.1 200 OK:成功!HTTP/1.1 301 Moved Permanently:网址变了,去新的地方。HTTP/1.1 404 Not Found:哎呀,你要的东西没找到。HTTP/1.1 500 Internal Server Error:服务器自己出 bug 了。
- 响应头(Headers):
Content-Type: 我发给你的是什么?text/html(网页),application/json(数据),image/jpeg(图片)。Set-Cookie: 送你一个 Cookie,下次来记得带上。Content-Length: 响应体有多长。Cache-Control: 浏览器要不要缓存?
- 空行
- 响应体(Body):真正的内容。对于网页来说,就是 HTML 代码;对于 API 来说,就是 JSON 数据。
一个典型的 200 OK 响应
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 12345
Set-Cookie: session_id=abc123; Path=/
Cache-Control: max-age=3600
<!DOCTYPE html>
<html>
<head><title>Baidu</title></head>
<body>
<h1>Hello, World!</h1>
<img src="logo.png">
</body>
</html>
第六阶段:浏览器渲染页面——把文字变成图画
浏览器收到 HTML 响应体后,并不会马上把画图画出来。它要先“读”懂这些代码。这个过程分三步:
1. 解析 HTML,构建 DOM 树
浏览器有一个 HTML 解析器。它从左到右扫描 HTML 字符串,把标签变成节点。
<html>是根节点<head>和<body>是它的子节点<h1>Hello</h1>又是一个子节点
这就形成了一棵倒着的树,叫 DOM 树 (Document Object Model)。每个 HTML 标签都是树上的一个节点。
2. 解析 CSS,构建 CSSOM 树
CSS 决定长什么样。浏览器同时解析 CSS 文件(内嵌的或外链的),构建 CSSOM 树 (CSS Object Model)。
h1 { color: red; }这个规则,会被解析成 CSSOM 的一个节点。
3. 合并成渲染树 (Render Tree)
DOM 树 + CSSOM 树 = 渲染树。
- 注意:
<div style="display:none">这样的元素,在渲染树里会被剔除,因为用户看不到它。 - 渲染树里的每个节点,都包含了“内容”和“样式”。
4. 布局 (Layout)
浏览器计算每个节点在屏幕上的确切位置和大小。
- 这个
h1应该在左边 100px,顶部 50px,宽度 800px… - 这个过程叫 重排 (Reflow)。
5. 绘制 (Paint)
浏览器把像素画到屏幕上。
- 把文字涂成红色,把图片贴上去,把边框画出来。
- 这个过程叫 重绘 (Repaint)。
如果页面里有 JavaScript,JS 可能会修改 DOM 或 CSS,导致重新布局、重新绘制。这个过程非常耗时。
第七阶段:JavaScript 执行——让页面“活”起来
很多网站不只是一堆死文字,还有交互。比如点击按钮弹出对话框,或者轮播图自动播放。这些都靠 JavaScript。
浏览器在解析 HTML 时,如果遇到 <script> 标签,会暂停解析,先去执行 JS 代码(除非有 defer 或 async 属性)。
JS 执行完后,可能会:
- 修改 DOM(比如动态添加一个新
div) - 发起新的 HTTP 请求(Ajax/Fetch,比如加载评论数据)
- 修改 CSS 样式
这些操作会触发新一轮的布局(Reflow)和绘制(Repaint)。
总结:从头到尾走一遍
让我们把整个流程串起来,用一张图(文字版)表示:
”` 用户输入: https://www.example.com
DNS 解析 浏览器 -> 系统缓存 -> 路由器 -> ISP DNS -> 根DNS -> .com DNS -> 权威DNS 得到 IP: 93.184.216.34
TCP 三次握手 浏览器 (SYN) -> 服务器 服务器 (SYN+ACK) -> 浏览器 浏览器 (ACK) -> 服务器 连接建立
3.
