走进网络编程,有两种"协议观"。一种是我们前面学过的:应用层协议是我们程序员自己定的,收发双方约定好一套格式,彼此按这个格式解析。另一种是我们要认识的现实:其实已经有非常多的"大佬"替我们定义好了现成又极其好用的应用层协议,直接拿来参考、拿来用就行。**HTTP(HyperText Transfer Protocol,超文本传输协议)**就是其中最典型、也最重要的一个。

在整个互联网世界里,HTTP 的地位怎么形容都不为过——它是浏览器和企业服务器之间通信的基石。你去访问一个网站、在网页上搜索、提交一个表单、点一个链接,背后走的几乎都是 HTTP。所以这一篇,我们会掰开揉碎地把 HTTP 讲清楚:HTTP 到底是什么、长度和格式怎么规定、URL 长什么样、请求和响应各是什么结构、有哪些方法和状态码、底层又是怎么在 TCP 上跑的、不同版本之间有什么区别,最后亲手用 C/C++ 写一个最简单的 HTTP 服务器,让浏览器能真正访问到它返回的网页。

跟着这一篇走下来,你不仅能"看懂"抓包里的 HTTP 报文,还能"写出"一个能上网的服务器。我们开始。

HTTP 是什么

先给 HTTP 一个准确的定义:**HTTP 是客户端与服务器之间通信的基础协议,它规定了"客户端怎么把请求发出去"以及"服务器怎么把响应送回来"。**客户端(最常见的例子就是浏览器)通过 HTTP 协议向服务器发送请求,服务器收到请求后处理,并按照同样的协议把响应返回给客户端。

这里有两个经常被放在一起说的形容词,第一次见到它们一定要弄明白——HTTP 是一个"无连接、无状态"的协议。

  • 无连接:严格说,这种说法源于早期版本。指的是 HTTP 本身不维护连接,它依赖更底层的 TCP 来建立、传输、断开。在 HTTP/1.0 里,每发一个请求就要建立一个新的 TCP 连接、请求完就立刻断开,所以看起来"每次请求都需要建立新的连接"。到了 HTTP/1.1 引入了持久连接(也叫长连接),同一个 TCP 连接上可以连续发多个请求,关于这点我们后面专门有一节讲它。
  • 无状态:HTTP 服务器不会保存客户端的状态信息。它不会记得"这个用户上次访问过没有、上次登录过没有、上次在购物车里加过什么"。每一次请求对服务器来说都是全新的、独立的,它不认识你。这一点既是 HTTP 简单的根源,也是很多功能(比如登录态、购物车、个性化推荐)需要额外手段(比如 Cookie、Session、Token)来"补出来"的原因。

一句话先把心智模型立起来:HTTP 是跑在 TCP 之上的一套"文本格式约定",客户端按格式发出请求,服务器按格式返回响应,整个过程无状态。

那么问题来了:既然我们已经会用 TCP 传数据了,为什么还要再套一层 HTTP?因为 TCP 只解决"可靠地把一串字节从 A 传到 B",但它不关心这些字节"是什么含义"。HTTP 补上的正是这一层语义:字节该怎么划分,叫作请求行、报头、正文;某个数字代表什么,叫作状态码;某段内容怎么标记长度,等等。有了这层约定,浏览器和服务器才能各自用一套标准准确地解析对方发的数据。

认识 URL

平时我们俗称的"网址",其实说的就是 URL(Uniform Resource Locator,统一资源定位符)。这个名字的三个词拆开看,含义就全了:Uniform(统一)——用一种统一格式表达;Resource(资源)——它定位的是一份"资源",可以是网页、图片、视频、接口等任何东西;Locator(定位符)——它负责告诉你这份资源在哪里。

所以 URL 的本质是:互联网上某个资源"唯一的门牌地址"。我给一个典型的 URL 拆开看:

http://www.example.com:8080/path/to/page.html?name=alice&age=18#section2
└┬─┘   └──────┬───────┘└┬─┘         └──────┬────────┘└───────┬───────┘└──┬───┘
scheme      host       port            path            query      fragment
  • scheme(协议方案):最前面的 http://,告诉网络栈"用 HTTP 协议去访问"。常见还有 https://(加密版 HTTP)、ftp:// 等。
  • host(主机):要么是域名(www.example.com),要么是 IP。它定位到"这台机器"。
  • port(端口):8080,定位到"这台机器的哪个进程"。这里有个极其重要的缺省规则,正是它解释了为什么你在地址栏敲 www.baidu.com 却能直接打开网页——见下方思考题。
  • path(路径):/path/to/page.html,定位到"这台机器上的哪个资源"。
  • query(查询字符串):? 后面的 name=alice&age=18,一系列"键=值",用 & 连接,通常用来向服务器传递参数,GET 请求的参数一般挂在 URL 里就靠它。
  • fragment(片段/锚点):#section2,定位到页面内部的某个位置。注意它只对浏览器生效,不会发送给服务器。

思考题:为什么地址栏敲网址不用写端口,也能访问到网页?

答案:因为 URL 对端口有缺省值。HTTP 的固定惯例端口是 80,HTTPS 的惯例端口是 443。当你没有显式写端口时,浏览器会默认采用协议对应的惯例端口——HTTP 就自动补成 80。也就是说,你在地址栏敲 http://www.example.com,实际会被浏览器解析成 http://www.example.com:80。服务器监听在 80 端口上,二者自然就打通了。这正好也呼应了范本里那句话——"HTTP 服务器一般使用 80 端口"只是一个习惯、是缺省约定,并不是说它不能用别的端口。你用 9090 起一个 HTTP 服务器完全可以,只是访问时浏览器不能省端口,必须写完整 http://127.0.0.1:9090。

urlencode 与 urldecode

在 URL 里,有些字符(比如 /、?、:、#、&、=、%、空格等)已经被 URL 语法赋予了特殊意义。比如 / 用来分隔路径、? 用来引出查询串、# 用来引出片段。因此这些字符不能随便出现在 URL 里——如果你希望它们作为"普通数据"的一部分(比如某个参数的值本身就是一个字符串,里面带了 /),就必须先把它们"转义"。

这个转义的过程就叫 urlencode(URL 编码)。规则如下:

  • 将需要转码的字符按字节转成十六进制;
  • 从右到左,若不足 4 位则直接处理,每 2 位凑一组,写成百分号加两位十六进制,最终编码成 %XY 这种格式。

一个最经典的例子:"+" 被转义成了 %2B。因为 + 在 URL 的查询串里本身常被解释成"空格",所以要表示一个真正的加号,就得把它编码成 %2B。再看几个:空格在 URL 里常被编码成 %20,/ 编码成 %2F,? 编码成 %3F。

与它相对的是 urldecode(URL 解码),它是 urlencode 的逆过程:把 %XY 这种形式还原成原来的字符。浏览器在提交表单时,会在请求发出去之前自动做 urlencode;服务器接收到之后再做一次 urldecode,就能拿到用户的真实输入。

(这块我们只需要"了解就够了",实际工程里大多用现成的库函数,比如 C 里可以自己写一个简单的编解码,或者用各种网络库自带的 query 解析工具。知道"有编码、有逆过程、%XX 是十六进制"这个事实,足够你在抓包时看懂 URL 是怎么被编码的了。)

HTTP 请求的格式

无论请求还是响应,HTTP 报文的整体结构都可以抽象成三大部分:首行(start line)、Header(报头)、Body(正文)。我们先从**请求(Request)**看起,它由三部分组成:

  1. 请求行(首行):格式是 [方法] + [空格] + [URL] + [空格] + [HTTP版本]。例如 GET /index.html HTTP/1.1。这一行交代了三件事:我想干什么(方法)、我要哪个资源(URL)、我用什么协议版本(版本)。
  2. 请求报头(Header):一系列描述这次请求属性的"键:值"对,冒号把键和值分开。每组属性之间用 \r\n(回车换行)分隔,遇到一个空行,就表示 Header 部分结束。
  3. 请求正文(Body):空行后面的所有内容都是 Body。Body 允许为空字符串;如果 Body 存在,那么在 Header 里就一定会有一个 Content-Length 属性,用来标识 Body 的字节长度。

把上面的结构拼成一段真实的请求报文,长这样(每一行末尾其实都有 \r\n,为了可读性我在这里用肉眼可见的分行展示):

GET /index.html HTTP/1.1\r\n
Host: www.example.com\r\n
User-Agent: Mozilla/5.0 ...\r\n
Connection: keep-alive\r\n
Content-Length: 0\r\n
\r\n
(这里是空行,表示 Header 结束;如果 Body 为空,到此报文结束)

注意最后那个空行——它是 Header 和 Body 之间的分界线。这一个看起来不起眼的空行,是整个 HTTP 报文格式里最容易踩的坑,我们单独用一节讲清楚它为什么必不可少。

为什么 Header 之后必须有一个空行?

可能有人会想:报文不是一行一行发的吗?服务器读的时候,读到"没有下一行 Header"不就知道 Header 结束了吗?问题是——服务器根本不知道你这个消息该读到哪为止。 它是用 read() 从 TCP 流里一点一点把字节读上来的,TCP 是"流",没有天然的"一条消息"边界。所以必须靠协议自己约定一个明确的"终止标记"。

那个空行的本质,就是 Header 的结束标记。解析规则是这样的:请求行之后,逐行读 Header;一旦读到一行长度为 0 的行(即只有 \r\n 的空行),就说明 Header 到此为止,下一行开始就是 Body 了。如果没有这个空行,服务器就没法知道"报头读完了没、接下来是 Body 还是又是 Header",解析就乱套了。所以说,"遇到空行表示 Header 结束"不是格式上的巧合,而是协议必须这么做——它是让"流式"字节能还原成"结构化"消息的那把钥匙。

顺带一提,分行解析依赖的换行符也是约定好的:HTTP/1.1 明确规定行结束用 \r\n(回车+换行),而不是 C 里常见的 \n。自己在手写客户端或服务器时,如果只发 \n,很多严格的服务器会不认,这是新手最容易踩的第二个坑。

思考题:既然 Header 用空行结尾,那"空行本身"算不算 Body 的一部分?

不算。空行只是分隔符,它本身不属于 Body。 空行后面的内容才是 Body。如果报文里空行后面什么也没有,那 Body 就是空字符串(长度为 0)。服务器是怎么判断 Body 有多长的?靠 Header 里的 Content-Length 字段:先按 \r\n 把 Header 解析到空行为止,然后从空行后开始,再读 Content-Length 指出的那么多字节作为 Body。这一段逻辑,正是后面我们手写 HTTP 服务器时要亲自实现的。

HTTP 响应的格式

有请求就有响应(Response)。响应的整体结构跟请求几乎一模一样,也是"首行 + Header + 空行 + Body"三件套,区别只在首行:

  1. 状态行(首行):格式是 [HTTP版本] + [空格] + [状态码] + [空格] + [状态码解释]。例如 HTTP/1.1 200 OK。它交代了服务器用的是哪个版本、这次处理的结果是一个什么码、这个码大致是什么意思。
  2. 响应报头(Header):同样用 \r\n 分隔的键值对,空行表示 Header 结束。
  3. 响应正文(Body):空行后面是 Body。如果服务器返回的是一个 HTML 页面,那么页面内容就放在 Body 里。 对应于请求侧,响应里也用 Content-Type 说明正文是什么类型,用 Content-Length 说明正文有多长。

一个典型的响应报文(来自访问百度的真实抓包,看着眼熟吧):

HTTP/1.1 200 OK\r\n
Content-Type: text/html\r\n
Content-Length: 2381\r\n
Connection: keep-alive\r\n
Server: bfe/1.0.8.18\r\n
Date: Sun, 16 Jun 2024 08:38:04 GMT\r\n
\r\n
<!DOCTYPE html>
<html>
...
</html>

<html>...</html> 那一段就是 Body,也就是浏览器最终要渲染出来的网页源码。

这里补充一个概念,帮你理解 URL 到网页的完整旅程——也就是题目里要覆盖的 浏览器与服务器是怎么交互的。整个过程大致是这样:

  1. 你在地址栏输入 http://www.example.com/index.html,浏览器解析出 host(www.example.com)和端口(缺省 80)。
  2. 浏览器向该主机 80 端口发起 TCP 连接(三次握手)。
  3. TCP 建立后,浏览器把拼好的请求报文(首行 + Header + 空行 + 空 Body)通过这个 TCP 连接发出去。
  4. 服务器在 80 端口收到请求,解析请求行(GET /index.html HTTP/1.1)和 Header,然后去磁盘上找到 index.html 这个文件。
  5. 服务器把响应报文(状态行 + Header + 空行 + 文件内容作为 Body)通过同一个 TCP 连接写回浏览器。
  6. 浏览器收到响应,解析状态码(200),按照 Content-Type: text/html 把 Body 里的 HTML 渲染成你看到的网页。
  7. 如果连接是 Connection: close,双方关闭 TCP;如果是 keep-alive,则保持连接以复用——详见后面的"连接关闭时机"一节。

你发现没有,浏览器只是负责"拼报文、发报文、渲染报文",服务器只是负责"解析请求、拼响应、回响应",中间传输全靠 TCP。这一条链路能通,核心就是双方都严格遵循了 HTTP 的格式约定。

HTTP 的常用方法

HTTP 里那个"我想干什么",由每种方法(Method)来表达。方法很多,我们挑最常用的讲透,不常用的了解即可。填表格之前,先给两个最重要的重点对象打上高亮:GET 和 POST,它们占据了日常 Web 流量的绝大部分。

方法用途示例特性与说明
GET(重点)请求 URL 指定的资源GET /index.html HTTP/1.1只"读取",不提交数据/不改变服务器状态。参数通常挂在 URL 的查询串后面。浏览器输入网址、点普通链接,走的就是 GET。
POST(重点)传输实体的主体,常用于提交表单数据POST /submit.cgi HTTP/1.1可以发送大量数据给服务器,数据放在请求体(Body)里,而不是塞在 URL 里。用户注册、登录、发表评论,走的是 POST。
PUT(不常用)传输文件,把请求正文保存到 URL 指定的位置PUT /example.html HTTP/1.1不太常用,但在部分 RESTful 接口里用来"更新资源"。
HEAD与 GET 类似,但不返回正文,只返回响应头HEAD /index.html HTTP/1.1用于确认 URL 是否有效、资源是否有更新(看 Last-Modified、Content-Length 就够了,不用白传数据)。
DELETE(不常用)删除文件,是 PUT 的相反操作DELETE /example.html HTTP/1.1按请求 URL 删除指定的资源。
OPTIONS查询针对某个 URL 支持哪些方法OPTIONS * HTTP/1.1返回"允许的方法",如 GET、POST 等,跨域请求的预检测(CORS 预检)常用到它。

关于 GET 和 POST 的区别,一句最经典的话值得记住:GET 把数据放在 URL 里(可见、会被浏览器写进历史/被日志记录、有长度限制),POST 把数据放在 Body 里(相对隐蔽、可以很大)。所以敏感数据、大数据量、会改变服务器状态的操作,一律用 POST。

下面我们来"亲眼"看两种方法对应的真实报文。

先看 GET。用 curl 访问百度:

# -i 表示连响应头一起显示出来
$ curl -i www.baidu.com
HTTP/1.1 200 OK
Accept-Ranges: bytes
Cache-Control: private, no-cache, no-store, proxy-revalidate, no-transform
Connection: keep-alive
Content-Length: 2381
Content-Type: text/html
Date: Sun, 16 Jun 2024 08:38:04 GMT
Etag: "588604dc-94d"
Last-Modified: Mon, 23 Jan 2017 13:27:56 GMT
Pragma: no-cache
Server: bfe/1.0.8.18
Set-Cookie: BDORZ=27315; max-age=86400; domain=.baidu.com; path=/
<!DOCTYPE html>
...

再看 HEAD——和前面对比,你会发现它省略了 HTML 正文,只返回响应头:

# curl --head 相当于发一个 HEAD 请求,只拿响应头
$ curl --head www.baidu.com
HTTP/1.1 200 OK
Accept-Ranges: bytes
Cache-Control: private, no-cache, no-store, proxy-revalidate, no-transform
Connection: keep-alive
Content-Length: 277
Content-Type: text/html
Date: Sun, 16 Jun 2024 08:43:38 GMT
Etag: "575e1f71-115"
Last-Modified: Mon, 13 Jun 2016 02:50:25 GMT
Pragma: no-cache
Server: bfe/1.0.8.18

两个响应里都出现了 Content-Length 但一个 2381、一个却只有 277?别被吓到——HEAD 请求要求服务器返回"如果它是 GET,正文会长多少",但实际不发送正文。所以 Content-Length: 277 这里表示的是"这个资源 GET 时会返回 277 字节",而 HEAD 本身不返回这段正文。

再看 OPTIONS。实际玩一次最直观:搭一个 nginx,然后对根路径发 OPTIONS。我在 Ubuntu 上这样操作:

# 1. 安装 nginx
sudo apt install nginx
 
# 2. 启动
sudo nginx
 
# 3. 验证进程起来了没有(能看到 master + worker)
ps ajx | grep nginx
 
# 4. 停止服务(我用来演示"停了会怎样")
sudo nginx -s stop

先看"服务器不支持"的效果。如果我让 nginx 默认只允许部分方法,对根路径发 OPTIONS,会得到:

# -X(大写X)用来显式指定这次请求的方法
$ curl -X OPTIONS -i http://127.0.0.1/
HTTP/1.1 405 Not Allowed
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 16 Jun 2024 08:48:22 GMT
Content-Type: text/html
Content-Length: 166
Connection: keep-alive
<html>
<head><title>405 Not Allowed</title></head>
<body>
<center><h1>405 Not Allowed</h1></center>
<hr><center>nginx/1.18.0 (Ubuntu)</center>
</body>
</html>

这里出现了 **405 Method Not Allowed(方法不被允许)**这个状态码——意思是"你要的方法,这个 URL 不支持"。而如果我把 nginx 配置成允许该 URL 处理 OPTIONS,它就会在响应头里老老实实列出支持的方法:

HTTP/1.1 200 OK
Allow: GET, HEAD, POST, OPTIONS
Content-Type: text/plain
Content-Length: 0
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 16 Jun 2024 09:04:44 GMT
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization

注意两点:第一,响应头里的 Allow: GET, HEAD, POST, OPTIONS 就是 OPTIONS 方法要拿的东西——告诉客户端"这个 URL 允许哪些方法";第二,这里没有响应体,因为 Content-Length: 0。同样的报文结构,再次印证了"Body 可以为空,空就靠 Content-Length: 0 来表达"。

思考题:GET 和 POST 都能传参数,为什么表单里的密码要用 POST?

因为两者安全性差别很大。GET 的参数放在 URL 查询串里:它会出现在浏览器地址栏、会被浏览器保存到历史记录里、会被代理服务器和服务器日志记录下来,而且 URL 长度还受限(服务器和浏览器都有上限)。如果你的密码走 GET,等于把自己的密码明文摆在了一堆地方。POST 把参数放在请求体 Body 里,不外露在地址栏,也不进历史记录,还支持发送更大的数据。所以凡涉及密码、隐私、大量数据、以及会改变服务器状态的提交(注册、下单、删除),都应该用 POST。需要补一句严谨的话:POST 的数据在传输层面"字面上"并没有加密(除非用 HTTPS),它只是"不外露在 URL",真正的加密要靠 HTTPS。

HTTP 的状态码

服务器处理完请求后,用一个**三位数的状态码(Status Code)**告诉客户端结果。它的作用是一眼看出"这次请求到底成没成、成到哪一步、需要客户端再做什么"。按首位数字,状态码分五大类,规律极好记:

  • 1xx:信息性响应,请求已收到,还在继续处理。例如 100。
  • 2xx:成功,服务器成功处理了请求。例如 200、201、204。
  • 3xx:重定向,客户端需要进一步动作(比如去别的地方访问)才能完成请求。例如 301、302、304。
  • 4xx:客户端错误,问题出在请求本身(地址不存在、没权限、格式错等)。例如 400、401、403、404。
  • 5xx:服务器错误,请求没问题,但服务器自己处理失败了。例如 500、502、503、504。

把这套记忆法记牢,你抓包看到 4xx 就知道"是客户端的问题,回去改请求",看到 5xx 就知道"是服务端的问题,回去查服务器"——排错效率高一个档次。

下面把最常用的一批状态码列成表格:

状态码含义应用样例
100Continue上传大文件时,服务器告诉客户端可以继续上传
200OK访问网站首页,服务器返回网页内容
201Created发布新文章,服务器返回"创建成功"的信息
204No Content删除文章后,服务器返回"无内容"表示操作成功
301Moved Permanently网站换域名,自动跳到新域名;搜索引擎更新网站链接时使用
302Found(或 See Other)用户登录成功后,重定向到用户首页
304Not Modified浏览器缓存机制,对未修改的资源返回 304,可直接用本地缓存
400Bad Request填写表单格式不对导致提交失败
401Unauthorized访问需要登录的页面时,未登录或认证失败
403Forbidden尝试访问你没有权限查看的页面
404Not Found访问不存在的网页链接,最常见
500Internal Server Error服务器崩溃或数据库错误导致页面无法加载
502Bad Gateway使用代理服务器时,代理无法从上游服务器拿到有效响应
503Service Unavailable服务器维护或过载,暂时无法处理请求
504Bad Gateway网关/代理超时,等不到上游服务器的响应

关于 301 和 302 这类重定向,它们都依赖一个叫 Location 的响应头来告诉浏览器"接下来去哪儿"。区别在于语义:

  • 301 vs 302:
    • 301 Moved Permanently(永久重定向):资源已经被永久移动到新位置。浏览器会缓存这个重定向,以后直接访问新地址;搜索引擎也会把权重转移给新地址。用于"网站换域名后,永久把老地址指到新地址"。
    • 302 Found / See Other(临时重定向):资源只是临时移动。浏览器不会缓存,以后访问还会再来问服务器。
  • 301 vs 308、302 vs 307:307/308 是比 302/301 更"严格"的近亲,区别在于前者会保持原来的请求方法(如果原来是 POST,重定向后仍是 POST),而 301/302 在历史上有的浏览器会把 POST 改成 GET。实际工作中 301/302 最常见,307/308 较少见。

看两个真实的重定向响应长什么样:

HTTP/1.1 301 Moved Permanently\r\n
Location: https://www.new-url.com\r\n

HTTP/1.1 302 Found\r\n
Location: https://www.new-url.com\r\n

浏览器收到后,会读取 Location 里的新 URL,自动再发起一次请求跳过去。

思考题:如何用状态码一眼判断"是客户端错还是服务器错"?

看百位(首位)数字即可。4xx 都是客户端错误——请求本身有问题(比如 404 地址不存在、400 格式错、401 没登录、403 没权限),通常修好请求重试就行;5xx 都是服务器错误——请求是好的,但服务器在处理时出了问题(比如 500 内部异常、502 上游挂了、503 过载、504 超时),需要等服务器修好或过会儿再试。这是最实用的一条排错经验。

思考题:301 和 302 有什么区别?什么时候用 302?

301 是永久重定向,浏览器会缓存结果,搜索引擎会把权重转移给新地址,适用于"网站换域名""老地址今后永远不用了"这种场景;302 是临时重定向,浏览器不缓存,每次访问都会再问服务器,适用于"登录成功后跳转到首页""活动页临时指向另一个页面"这种"只是暂时换一下"的场景。选哪个的关键是问自己:这个跳转是"永远的"还是"临时的"。永远 → 301;临时 → 302。

HTTP 常见 Header

**Header(报头)**是键值对,格式固定为 名字: 值。它的作用是携带请求或响应的"元信息"。我们把最常用的一批列成表,方便对号入座:

字段名含义样例
Accept客户端可接受的响应内容类型Accept: text/html,application/xml;q=0.9,*/*;q=0.8
Accept-Encoding客户端支持的压缩格式Accept-Encoding: gzip, deflate, br
Accept-Language客户端可接受的语言Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Host请求的主机名和端口号Host: www.example.com:8080
User-Agent客户端的软件环境信息(操作系统、浏览器版本)User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...
Cookie客户端发送给服务器的 cookie 信息Cookie: session_id=abcdefg12345; user_id=123
Referer请求的来源 URL(从哪个页面跳转过来的)Referer: http://www.example.com/previous_page.html(注:是 Referer,HTTP 里少写一位的"拼写错误"已被历史固化)
Content-Type实体正文的媒体类型Content-Type: application/x-www-form-urlencoded(表单)或 application/json(JSON)
Content-Length实体正文的字节大小Content-Length: 150
Authorization认证信息,如用户名密码(Base64 编码)Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
Cache-Control缓存控制指令请求:Cache-Control: no-cache;响应:Cache-Control: public, max-age=3600
Connection请求完是关闭还是保持连接Connection: keep-alive 或 Connection: close
Date请求或响应的日期时间Date: Wed, 21 Oct 2023 07:28:00 GMT
Location重定向的目标 URL(搭配 3xx)Location: http://www.example.com/new_location.html
Server服务器类型Server: Apache/2.4.41 (Unix)
Last-Modified资源的最后修改时间Last-Modified: Wed, 21 Oct 2023 07:20:00 GMT
ETag资源的唯一标识符,用于缓存ETag: "3f80f-1b6-5f4e2512a4100"
Expires响应过期的日期时间Expires: Wed, 21 Oct 2023 08:28:00 GMT

在这么多字段里,我要单独拎出几个,因为它们和全书的核心概念强相关,值得额外展开一次。

Content-Type(内容类型):告诉接收方"Body 里的数据是什么类型"。它的合法取值遵循 MIME(Multipurpose Internet Mail Extensions,多用途互联网邮件扩展)类型这套标准,常见有 text/html(HTML 网页)、text/plain(纯文本)、application/json(JSON 数据)、image/png(图片)、application/x-www-form-urlencoded(表单提交的默认编码)等。服务器返回 text/html,浏览器才知道把这串字节当网页渲染;返回 application/json,前端才知道该用 JSON.parse 去解析。说错 Content-Type,浏览器就会"看不懂"你的数据。 这在我们手写服务器返回网页时是必须正确填写的头。

Content-Length(内容长度):Body 的字节大小。它承担一个关键职责:告诉接收方 Body 到底多长。正是它和"空行"配合,才让 TCP 流能准确一刀切出"一条完整的 HTTP 消息"。我们手写服务器返回网页时,必须精确填上这个值;如果填错(比如只写了实际长度的一部分),浏览器就只能渲染到一半卡住,甚至报错。

Host(主机):请求是发给哪个主机的哪个端口。HTTP/1.0 时代没有它,到 HTTP/1.1 才引入。它带来一个能力:在一台服务器(一个 IP)上可以同时部署多个网站——服务器靠请求头里的 Host 字段区分"用户想要的是哪个网站"。这就是虚拟主机的基础,也是 HTTP/1.1 的一个重要改进。

User-Agent(用户代理):声明用户的操作系统和浏览器版本。它后面往往跟着一串历史包袱——Mozilla/5.0 ... 这个"看似不相关的前缀"是一个著名的历史故事:早期浏览器 NCSA Mosaic 的用户代理叫 Mozilla/1.0(它是第一个流行浏览器的代号),后来的浏览器为了"不被服务器拒绝"(服务器当年只认 Mozilla 开头的 UA),纷纷在自己的 UA 前加上 Mozilla/5.0,一直沿用到今天。所以你现在看见的 Mozilla/5.0 (Windows NT 10.0; ...) AppleWebKit/537.36 ...,就是一层层历史叠加出来的结果——每一个片段都代表一段浏览器内核的继承关系。

Content-Type: application/x-www-form-urlencoded:这是 POST 表单最默认的编码方式。当你在网页里提交 <form method="post">,浏览器默认会把表单字段按"键=值"用 & 连接、并对特殊字符做 urlencode(还记得前面讲过的 %2B 吗),再放进请求体,同时用这个 Content-Type 声明它。这正好把我们讲过的 urlencode、POST、Body 三个概念串成了一条线。

Connection 报头与连接关闭时机

前文反复提到"持久连接""keep-alive",这一节我们把它彻底讲透,这也是"连接到底什么时候关"这个问题的答案所在。

HTTP 报文头里的 Connection 字段,主要用来控制和表达客户端与服务器之间的连接状态。它有两种取值,语义相反:

  • Connection: keep-alive:表示希望保持连接。请求/响应完成后先不关闭 TCP 连接,以便在同一个连接上接着发下一个请求、收下一个响应。这就是持久连接(长连接)。
  • Connection: close:表示这次请求/响应完成后就关闭 TCP 连接。

到底默认用哪个,取决于协议版本:

  • HTTP/1.0:默认连接是非持久的——每发一个请求,TCP 连接用一次就断。如果希望在 HTTP/1.0 上实现持久连接,必须在请求头里显式写 Connection: keep-alive。
  • HTTP/1.1:默认就是持久连接(keep-alive)。只要客户端和服务器都不明确说 Connection: close,连接就保持打开,后续请求/响应可以复用同一条 TCP 连接。

那"连接什么时候关"?总结成一句话:收到 Connection: close,或者任一方主动关闭(比如服务器处理完要退出、客户端关闭浏览器/标签页),连接就结束;否则默认保持,用来复用。 在 HTTP/1.1 下,你要"用完即断",就主动发 Connection: close;要用长连接省去反复建连的开销,就什么都不写(用默认的 keep-alive)。

思考题:既然 keep-alive 能省去反复三次握手,为什么不是永远通着不断?

有几个现实原因。第一,连接是资源:每一条保持不关的 TCP 连接都要占用服务器一端套接字、内核缓冲内存,客户端要保持连接也要占本机资源。一个高并发服务器如果所有连接都永远不断,很快内存和文件描述符就耗尽了(这就是著名的"C10K 问题"的其中一个侧面)。第二,连接可能已经失效:网络中间某台路由/网关可能因为长时间空闲而静默地回收了这条连接,此时你再往这个"还开着"的 socket 上写数据,会写进一个已断的管道。所以即便用长连接,服务器通常也会设置超时时间(比如 nginx 默认 65 秒)来"清理"空闲连接,达到上限后主动 Connection: close。权衡的点在于:对频繁请求的站点,复用连接省的是建连开销,值得;对极小频率的访问,保持一堆空闲连接反而是浪费。这正是"连接关闭时机"需要被协议和服务器共同管理的原因。

思考题:我们手写的服务器(见后文)里,什么时候关闭连接比较合适?

如果要追求功能和正确性,最省事的初次实现就是 "来一个请求、处理完、关闭连接"(对应发 Connection: close)。它命中 HTTP/1.0 的默认模型,逻辑最简单,也最容易看懂。代价是每个请求都要重新建立 TCP 连接,少一点性能、多一点握手开销。等理解了整个流程之后,再升级成 keep-alive 的长连接版本——需要在循环里反复 read/write 同一条连接,并正确处理"一条连接上连续多个请求"如何切分,这就又回到了 Content-Length 和空行分界的核心逻辑上。一笔一笔从短连接到长连接,是最顺的进阶路线。

HTTP 的底层与版本演进

现在我们把 HTTP 放到它所属的网络栈里定位一下,再看它一路走来的版本。

HTTP 的底层:HTTP 是一个应用层协议,它不自己造"传输的轮子",而是跑在 **TCP(传输层的传输控制协议)**之上。你可以这样记链路:HTTP(应用层)→ TCP(传输层)→ IP(网络层)。每一次 HTTP 通信,底层都要先经过一次 TCP 的三次握手来建立可靠通道;正是 TCP 提供的"可靠字节流",让 HTTP 报文能被准确、按序、不丢失地送达。前面讲"无连接""连接关闭时机",说的其实都是这条 TCP 连接的事——HTTP 本身不管理连接,连接是 TCP 的,HTTP 只是"借用"它来传文档。理解了这一点,你就明白了为什么"HTTP 和 TCP 的关系"是理解网络编程怎么串起来的钥匙。

最后附上我做的一张"HTTP 版本演进"总表,把各版本的核心技术和时代背景放到一起,你会看到一条清晰的性能进化线:

版本代表年份核心技术时代背景
HTTP/0.91991仅支持 GET;仅传纯文本 HTML;无请求/响应头互联网起步期,内容简单以文本为主
HTTP/1.01996引入 POST/HEAD;加入请求头和响应头;支持多数据格式(MIME);支持缓存;引入状态码、多字符集网页内容丰富起来,需要更多功能和灵活性;但每次 TCP 只能发一个请求,性能受限
HTTP/1.11997 初版、1999 定型(RFC 2616)引入持久连接、管道化;允在一个 TCP 连接上发多个请求/响应;引入分块传输编码 chunked;支持 Host 头(一 IP 多站点)网页外部资源(图片/CSS/JS)越来越多,1.0 每请求一连接的短板愈发突出
HTTP/2.02015多路复用(一个 TCP 连接多个 HTTP 请求);二进制分帧;头部压缩;服务器推送移动互联网、云计算兴起,对性能要求更高;大幅提升传输效率,并支持加密 HTTPS
HTTP/3.02022用 QUIC 替代 TCP,基于 UDP 构建多路复用;减少握手时间;解决 TCP"队头阻塞"5G、物联网对实时性可靠性要求高,进一步提速并天然支持加密

这几个版本翻译成一句话体会一下:0.9 只会"取一个纯文本页面";1.0 学会了"带各种信息、换多种格式";1.1 学会了"一条连接多用几次";2.0 学会了"一条连接里并行塞多个请求";3.0 干脆把老底层的 TCP 换成更快更抗阻塞的 QUIC。 每一次升级,都是朝着"更快、更多、更省"的方向走。

思考题:为什么说 HTTP/1.1 的"持久连接 + 管道化"能提升性能,但又有"队头阻塞"?

持久连接省的是"每条请求都要重建 TCP 连接、都要三次握手"的重复开销,所以快。管道化(pipelining)允许客户端不等上一个响应就连续发多个请求,省的是请求之间"纹丝不动地干等"的浪费。但问题来了:服务器端仍然必须按收到请求的顺序一个一个返回响应,而且 HTTP/1.1 一个 TCP 连接上同时只处理一个请求。一旦第一个响应处理得很慢(比如它背后连着一个慢的数据库),后面所有响应都得排队等着它——这个"前面堵住、后面全堵"的现象,就叫队头阻塞。这就是 HTTP/2.0 引入"多路复用 + 二进制分帧"要解决的核心痛点:把请求/响应切成一个个独立的"帧",在一条连接上交错传输,互不排队,从根上绕过队头阻塞。

手写最简单的 HTTP 服务器

前面的知识都在"读"和"理解",现在到动手的时候了。我们要做的是:只要严格按 HTTP 协议的要求去构造数据,服务器就很容易写出来。

先做一个在网页上只输出一句 hello world 的最简版本。别看它小,它把 HTTP 响应的骨架(状态行 + 头部 + 空行 + Body)完整地走了一遍。

// http_server.c —— 用 C 语言写一个最简单的 HTTP 服务器
// 它在浏览器里只输出一个 "hello world"
// 编译:gcc http_server.c -o http_server
// 运行:./http_server 0.0.0.0 9090
#include <sys/socket.h>        // socket / bind / listen / accept
#include <netinet/in.h>        // struct sockaddr_in
#include <arpa/inet.h>         // inet_addr / htons
#include <unistd.h>            // read / write / close
#include <stdio.h>             // printf / snprintf / perror
#include <string.h>            // strlen
#include <stdlib.h>            // atoi
 
// 用法说明
void Usage()
{
    printf("usage: ./http_server [ip] [port]\n");
}
 
int main(int argc, char* argv[])
{
    if (argc != 3)                       // 必须给 IP 和端口两个参数
    {
        Usage();                         // 参数不对就打印用法
        return 1;                        // 非 0 退出表示出错
    }
 
    int fd = socket(AF_INET, SOCK_STREAM, 0);   // 创建 TCP 流式套接字
    if (fd < 0)
    {
        perror("socket");                // 创建失败,打印原因
        return 1;
    }
 
    struct sockaddr_in addr;             // 服务器地址结构体
    addr.sin_family = AF_INET;           // 地址族:IPv4
    addr.sin_addr.s_addr = inet_addr(argv[1]); // 把点分十进制 IP 转成二进制
    addr.sin_port = htons(atoi(argv[2]));      // 端口,主机字节序转网络字节序
 
    int ret = bind(fd, (struct sockaddr*)&addr, sizeof(addr)); // 绑定 IP+端口
    if (ret < 0)
    {
        perror("bind");                  // 绑定失败
        return 1;
    }
 
    ret = listen(fd, 10);                // 监听,队列最多积压 10 个待处理连接
    if (ret < 0)
    {
        perror("listen");                // 监听失败
        return 1;
    }
 
    for (;;)                             // 服务器主循环,永远运行
    {
        struct sockaddr_in client_addr;  // 用来接收"对方是谁"
        socklen_t len = sizeof(client_addr);
        int client_fd = accept(fd, (struct sockaddr*)&client_addr, &len);
        if (client_fd < 0)
        {
            perror("accept");            // 接受失败就跳过,继续等下一个
            continue;
        }
 
        char input_buf[1024 * 10] = {0}; // 用足够大的缓冲区把请求直接读完
        ssize_t read_size = read(client_fd, input_buf, sizeof(input_buf) - 1);
        if (read_size < 0)               // 读请求失败就关连接继续
        {
            close(client_fd);
            continue;
        }
        printf("[Request]\n%s\n", input_buf); // 把收到的请求打到终端,方便观察
 
        char buf[1024] = {0};            // 用来拼响应报文的缓冲区
        const char* hello = "<h1>hello world</h1>"; // 要返回给浏览器的正文
        snprintf(buf, sizeof(buf),
                 "HTTP/1.0 200 OK\r\nContent-Length:%lu\r\n\r\n%s",
                 strlen(hello), hello);  // 状态行 + 头部 + 空行 + 正文
        write(client_fd, buf, strlen(buf)); // 把响应发回去
 
        close(client_fd);                // 处理完这个请求,关闭连接
    }
    return 0;
}

编译并启动它:

$ gcc http_server.c -o http_server        # 编译
$ ./http_server 0.0.0.0 9090              # 启动,监听所有网卡的 9090 端口
[Request]                                 # 浏览器访问后会打印收到的请求
GET / HTTP/1.1
Host: 127.0.0.1:9090
...

然后在浏览器地址栏输入 http://127.0.0.1:9090,你就能看到页面显示出 hello world 了。第一次在浏览器里看到自己的服务器吐出来的内容,这个成就感值得记一下。

打开浏览器开发者工具(F12)的 Network 面板,点击刚才那个请求,你会看到响应头正是 HTTP/1.0 200 OK、Content-Length: 19(<h1>hello world</h1> 正好 19 个字节)、还有那个空行分隔出的 Body。你亲手构造的报文,和真实的 HTTP 响应逐字段一致——这就是"按协议构造数据"的含义。

有几个细节值得单独挖一挖。

第一,关于端口 9090 而不是 80。 就像我们前面讲的,HTTP 服务器一般用 80 端口只是一个通用习惯,并不是规定。80 端口是特权端口(需要 root 权限才能监听),开发时用 9090 这类高位端口更省事,访问时把端口写全即可。

第二,为什么浏览器除了 GET /,还会请求 GET /favicon.ico。 终端里你多半能看到第二行日志:GET /favicon.ico HTTP/1.1。这是因为浏览器的固定行为——它在你访问任意页面时,都会去请求 /favicon.ico 这个文件,用来显示在地址栏标签页上的小图标(favorite icon 的缩写,中文叫"网站图标")。favicon.ico 是浏览器约定俗成的固定路径,如果你不想每个页面都去报错找它,就在 web 根目录放一个 favicon.ico 文件;否则它每换一页就会 404 一次(这在真实网站上很常见,也是为什么很多站点都会专门准备这个文件)。

第三,关于状态码的"实验"。 你可以自己改代码,把 HTTP/1.0 200 OK 改成 404 Not Found、403 Forbidden、500 Internal Server Error 等,再去浏览器刷新 —— 你会看到浏览器分别显示"404 Not Found""403 Forbidden""500 Internal Server Error"对应的错误页。观察浏览器对不同状态码的差异化处理(404 会显示专门的错误页、200 会渲染正文),能帮你把"状态码 + 浏览器行为"这对关系刻进脑子里。

思考题:这个服务器为什么没有解析请求,也能把页面返回回去?

因为它不需要解析请求内容——它忽略客户端发来的一切,无论你请求 / 还是 /abc,都一概返回同一个 hello world。HTTP 服务器"要不要解析请求"取决于业务需求:这个 demo 只是想演示"构造一段合法响应 + 用 TCP 送回",所以读懂请求对它是多余的。但真正的服务器(包括我们下一节要写的)必须解析请求行:要取出 GET /index.html HTTP/1.1 里的路径 /index.html,才能知道去读哪个文件来返回。解析的关键就是"按 \r\n 切行、首行取方法/路径/版本、空行后按 Content-Length 取 Body",这就是前面"空行分界"知识的具体落地。

如何返回一个真正的网页

hello world 只是字符串,真实的网站要返回磁盘上一个 HTML 文件。这就引出了两个新概念:web 根目录(web root) 和文件读取。

  • web 根目录:服务器只允许从某个固定目录里取文件来返回,这个目录就叫 web 根目录,比如约定为 ./wwwroot。它既是一个组织网页的约定(所有页面都放在这下面),也是一道安全边界(避免用户请求 ../../etc/passwd 这种路径穿越去读服务器系统文件——真实服务器一定会做路径校验,这里为了教学我们仅做到"拼路径读取",实际生产必须校验,这是重要的安全提醒)。
  • 文件读取:从请求行里拿到客户想要的路径后,把它拼到 web 根目录后面,得到真实的磁盘路径,然后用文件读取函数把整个文件内容读进内存,作为响应的 Body 返回。

先写一个"按路径读取文件内容"的辅助函数。这里用 C++ 的 ifstream,因为它天然按字节处理、还能直接拿到文件大小:

#include <cstdio>
#include <fstream>
#include <string>
 
// 读取一个文件的全部内容(二进制方式),返回字符串
// 用法:GetFileContentHelper("/path/to/index.html")
std::string GetFileContentHelper(const std::string& path)
{
    std::ifstream in(path, std::ios::binary); // 以二进制方式打开文件
    if (!in.is_open())                          // 打开失败(文件不存在/无权限)
        return std::string();                   // 返回空串,由调用方决定怎么处理(通常返回 404)
 
    in.seekg(0, in.end);   // 移动读指针到文件末尾
    int filesize = in.tellg(); // 记录指针位置,即文件大小(字节)
    in.seekg(0, in.beg);   // 再把读指针移回文件开头
 
    std::string content(filesize, '\0');        // 预设一个能装下整个文件的字符串
    if (filesize > 0)
        in.read(const_cast<char*>(content.data()), filesize); // 一次性整块读入
    in.close();                                  // 关闭文件
    return content;                              // 返回文件内容
}

有了它,主函数的大致流程就变成:接收请求 → 解析出路径 → 拼上 web 根目录 → 读文件 → 文件读到了就返回 200 + 文件内容,读不到就返回 404。我把一个完整的"返回网页的服务器"写出来(它包含了"上一个 demo 的框架 + 解析请求行 + 读文件返回",逻辑一次到位):

// http_server_file.cpp —— 返回网页文件的简易 HTTP 服务器
// 编译:g++ -std=c++17 http_server_file.cpp -o http_server_file
// 运行:./http_server_file 0.0.0.0 9090
// 需要先在服务器目录下建一个 web 根目录并放入网页:
//   mkdir wwwroot
//   把 index.html 放进 wwwroot/index.html
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <string>
#include <fstream>
 
const std::string kWebRoot = "./wwwroot"; // web 根目录,网页都放这里
 
// 读取文件的全部内容(见前面 GetFileContentHelper)
std::string GetFileContentHelper(const std::string& path)
{
    std::ifstream in(path, std::ios::binary);
    if (!in.is_open())
        return std::string();
    in.seekg(0, in.end);
    int filesize = in.tellg();
    in.seekg(0, in.beg);
    std::string content(filesize, '\0');
    if (filesize > 0)
        in.read(const_cast<char*>(content.data()), filesize);
    in.close();
    return content;
}
 
// 从请求报文里提取出"客户端想要的路径"
// 例如请求行 "GET /index.html HTTP/1.1" -> 返回 "/index.html"
std::string GetPathFromRequest(const std::string& req)
{
    // 把请求按行切分,取第一行(请求行)放到 first_line 里
    size_t pos = req.find("\r\n");        // 找到第一行的结尾
    std::string first_line = (pos == std::string::npos) ? req : req.substr(0, pos);
 
    // 请求行格式:method + 空格 + path + 空格 + version
    // 取第二个空格之前的内容作为路径
    size_t start = first_line.find(' ');              // 第一个空格(method 结束)
    if (start == std::string::npos)
        return "/";                                    // 找不到就当请求根路径
    size_t end = first_line.find(' ', start + 1);     // 第二个空格(path 结束)
    std::string path = first_line.substr(start + 1, end - start - 1);
 
    // 把查询字符串(? 后面的部分)去掉,路径里不该带它
    size_t q = path.find('?');
    if (q != std::string::npos)
        path = path.substr(0, q);
 
    if (path.empty() || path == "/")       // 请求根路径,默认返回首页 index.html
        path = "/index.html";
    return path;
}
 
// 构造一条完整 HTTP 响应(状态行 + 头部 + 空行 + Body)
std::string MakeResponse(int status, const std::string& body, const char* type)
{
    std::string resp;
    char head[256];
    // 根据状态码选对应的状态行。body 为空说明没有正文。
    if (status == 200)
        snprintf(head, sizeof(head), "HTTP/1.1 200 OK\r\n");
    else
        snprintf(head, sizeof(head), "HTTP/1.1 404 Not Found\r\n");
    resp += head;
    // 头里带上内容类型和正文长度(Content-Length 必须精确)
    resp += std::string("Content-Type: ") + type + "\r\n";
    char len[64];
    snprintf(len, sizeof(len), "Content-Length: %zu\r\n", body.size());
    resp += len;
    resp += "Connection: close\r\n";   // 处理完就关连接,最简实现
    resp += "\r\n";                    // 空行:Header 结束的标记
    resp += body;                      // 空行之后是 Body
    return resp;
}
 
int main(int argc, char* argv[])
{
    if (argc != 3)                     // 需要 IP 和端口两个参数
    {
        printf("usage: ./http_server_file [ip] [port]\n");
        return 1;
    }
 
    int fd = socket(AF_INET, SOCK_STREAM, 0); // 创建 TCP 套接字
    if (fd < 0) { perror("socket"); return 1; }
 
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = inet_addr(argv[1]);
    addr.sin_port = htons(atoi(argv[2]));
 
    if (bind(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; }
    if (listen(fd, 10) < 0) { perror("listen"); return 1; }
 
    for (;;)                           // 服务器主循环
    {
        struct sockaddr_in client_addr;
        socklen_t len = sizeof(client_addr);
        int client_fd = accept(fd, (struct sockaddr*)&client_addr, &len);
        if (client_fd < 0) { perror("accept"); continue; }
 
        char input_buf[1024 * 10] = {0};
        ssize_t n = read(client_fd, input_buf, sizeof(input_buf) - 1);
        if (n < 0) { close(client_fd); continue; }
 
        std::string req(input_buf, n);            // 收到的原始请求
        std::string path = GetPathFromRequest(req); // 解析出客户端要的路径
        printf("[Request] %s\n", path.c_str());   // 打印请求了哪个路径
 
        // 把解析出的路径拼上 web 根目录,得到真实磁盘路径
        std::string full = kWebRoot + path;
        std::string body = GetFileContentHelper(full); // 读该文件内容
        std::string resp;
        if (body.empty())
            resp = MakeResponse(404, "<html><body><h1>404 Not Found</h1></body></html>", "text/html");
        else
            resp = MakeResponse(200, body, "text/html; charset=utf-8");
 
        write(client_fd, resp.c_str(), resp.size()); // 把响应发回浏览器
        close(client_fd);                            // 处理完关闭连接
    }
    return 0;
}

准备网页并运行:

$ mkdir wwwroot                                 # 建 web 根目录
$ cat > wwwroot/index.html <<'EOF'              # 写一个首页
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>我的首页</title></head>
<body><h1>你好,HTTP</h1><p>这是我自己写的服务器返回的网页。</p></body>
</html>
EOF
$ g++ -std=c++17 http_server_file.cpp -o http_server_file   # 编译
$ ./http_server_file 0.0.0.0 9090              # 启动
# 浏览器访问 http://127.0.0.1:9090 即可看到 index.html
# 访问一个不存在的路径,比如 http://127.0.0.1:9090/xxx,会看到 404 页面

浏览器访问 http://127.0.0.1:9090 时,因为请求的是根路径 /,代码里把它映射成了 /index.html,于是打开 wwwroot/index.html,作为 200 响应的 Body 返回,浏览器把这串 HTML 渲染成了你看到的页面。如果你访问一个不存在的路径(比如 /nope),读文件会失败返回空串,于是 body.empty() 成立,服务器返回 404 页面——你看,一个老师刚刚讲过的状态码 404,此刻正由你自己亲手实现出来。

思考题:为什么要设"web 根目录",而不是直接按请求路径去读任意文件?

因为安全。请求路径直接由客户端提供,如果服务器"请求什么路径就去读什么路径",恶意用户可以构造 GET /../../etc/passwd HTTP/1.1 这类路径穿越攻击,把服务器上的敏感系统文件读走。通过约定"只能在这个根目录下取文件",把客户端可以触碰的文件范围死死限制在 web 根目录以内。真实的 Web 服务器还会做更严格的归一化(处理 ..、.、重复斜杠等),禁止任何"越出根目录"的路径。这是应用层服务器必须做的一类安全边界,哪怕我们的教学版也要心里有这根弦。(本教学代码为求核心逻辑清晰,未做路径穿越的完整防御,真实项目绝不能照搬。)

思考题:返回文件时为什么必须精确填写 Content-Length?填少了/填多了会怎样?

因为 Content-Length 是接收方判断"Body 有多少字节"的唯一依据之一。填少了:浏览器会认为正文还没收完,一直等更多数据,页面卡住或只渲染出一部分;填多了:浏览器会把后面不属于这个响应的字节(比如下一条响应头)也当成正文,导致渲染出错、解析错乱。所以必须用 body.size()(文件的实际大小,单位字节)来填。这正是我们前面反复强调的 Content-Length 责任的落地——它和空行一道,是让"TCP 字节流"能被准确切分成"一条条完整 HTTP 消息"的关键。


到这里,我们从头到尾把 HTTP 过了一遍:知道了 HTTP 是跑在 TCP 之上的应用层协议、是客户端与服务器通信的基础,也记住了它"无状态"的根本性格;认识了 URL 这位"资源门牌",弄懂了端口缺省 80、urlencode/urldecode 怎么转义特殊字符;彻底拆解了请求和响应那"首行 + 报头 + 空行 + 正文"的四段结构,并亲手证明了那个空行为什么是整个格式的分界命脉;掌握了 GET/POST 这对主角和我们常用的状态码(200、404、403、302、504……);认识了 Content-Type、Content-Length、Host、User-Agent 这批常见报头,看懂了 Connection 报头决定连接何时关闭;顺着版本演进,明白了 HTTP 从 0.9 到 3.0 是怎么一步步"更快、更多、更省"的。最后,我们亲手写了一个最简单的 HTTP 服务器,又从 hello world 升级成了能从 web 根目录读取文件、返回真正网页、还能优雅返回 404 的服务器。

如果你跟着上面的代码一个个敲下来、在浏览器里亲眼看着自己拼出来的报文起作用、再把状态码改一改看看浏览器的反应,这一章才算真正拿下。HTTP 这块砖铺得越实,后面那些"基于 HTTP 的框架是怎么帮你封装好这一切的""为什么高并发服务器要在连接和解析上做那么多优化"你理解起来就越省力。

而我们现在做的这个"按协议手工收发报文"的服务器,正是理解所有 Web 技术的一把钥匙——学会自己造轮子,你才能看透别人造的轮子是怎么转起来的。下一篇,我们把目光从"能收发 HTTP 报文"向前推一步,去揭开 TCP 之上更底层的传输细节,看看数据在网络里到底是怎么被可靠送达的。准备好了吗?