Old-School Networking / 2026 ・ No.10

古法网络入门

第10讲 HTTP ── 把申请按钮的内容一行一行地读

发布

Wireshark 的界面。列出了电脑与 203.0.113.20 的 3000 号之间的 10 个数据包,选中第 4 个 POST /api/expenses HTTP/1.1。展开详情的 Hypertext Transfer Protocol,POST /api/expenses HTTP/1.1、Host、User-Agent、Accept、Content-Type: application/json、Content-Length: 54 每行末尾都有 \r\n,空行的 \r\n 之后是 File Data: 54 bytes。第 6 个是 HTTP/1.1 201 Created
把从第 1 讲一直发送的申请,用不加密的 HTTP 发送并记录下来。第 4 个是申请,第 6 个是“已受理(201 Created)”。详情里可以看到每行都以 \r\n 结尾,空的 \r\n 之后是 54 字节的正文(在笔者于云端搭建的练习用网络中,于 2026 年 10 月 25 日记录,用 Wireshark 4.2.2 打开后截图。界面保持英文。窗口上方的标题栏和小标题是为了说明而后加的)

从第 1 讲一直发送的 POST /api/expenses。查了名字(第 9 讲),连上了(第 6 讲),上了锁(第 8 讲),终于要读它的内容了。
HTTP 是由一行行文字组成的“请求”和“回复”。结构、方法、状态码、缓存(ETag 与 304)、Cookie、CORS、压缩,以及 HTTP/1.1、2、3 的区别,都在练习用的服务器上实测确认。

一

读申请的那一层

新井从第 1 讲花了 9 讲,终于轮到申请的内容了。好漫长的旅程。

古井信封上的地址、邮路、挂号的锁,这些是下面的层。今天拆开信纸。

新井信纸上写的是什么?

古井普通得让人泄气的文字。“POST /api/expenses,内容是 JSON,54 字节”。人能读懂。

全 12 讲路线图(当前位置:第 10 讲)

1 全景图 2 物理层 3 数据链路层 4 IP 地址 5 路由器 6 TCP 7 UDP・套接字 8 TLS 9 DNS 10 HTTP 11 开发者的网络 12 故障排查 当前位置 全景图 第1层 第2层 第3层 第4层 第5・6层 第7层 实战
  • 结构 ── 第 1 行、头部、空行、正文。申请是头部 149 字节+正文 54 字节(实测)
  • 方法与状态 ── 要做什么,以及结果如何。本讲让服务器返回了 10 种状态码(实测)
  • 加速的办法 ── 304 让正文 0 字节,gzip 把 2 MB 变成 6 KB,HTTP/2 在一条连接上同时进行(实测)
  • 开发者的陷阱 ── Cookie 的三个标记,CORS 错误该由谁来修

按 TCP/IP 的分层,HTTP 属于第 7 层,是应用层最上面的约定。练习网络和第 9 讲相同:电脑,以及单程 20 毫秒的捣乱线对面的申请服务器(Node.js)。服务器在 HTTP(3000 号)、HTTPS(3443 号,同时支持 HTTP/1.1 和 HTTP/2)、不加密的 HTTP/2(3080 号,观察用)上等待。

二

请求和响应的结构

为了看内容,先用不上锁的 HTTP 发送申请,用 curl -v 和 Wireshark 记录下来(Wireshark 的界面见封面照片)。

# 请求:第 1 行(方法、路径、版本)、头部、空行、正文
    POST /api/expenses HTTP/1.1
    Host: expenses.example:3000
    User-Agent: curl/8.5.0
    Accept: */*
    Content-Type: application/json
    Content-Length: 54
    
    {"date":"2026-10-25","item":"交通費","amount":1280}
# 响应:第 1 行(版本、状态码)、头部、空行、正文
    HTTP/1.1 201 Created
    Location: /api/expenses/EXP-2026-0012
    Content-Type: application/json
    Content-Length: 43
    Date: Sun, 25 Oct 2026 00:28:31 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    
    {"id":"EXP-2026-0012","status":"submitted"}

请求和响应都由第 1 行、头部、空行、正文四部分组成

请求(电脑 → 服务器) POST /api/expenses HTTP/1.1 第 1 行 Host: expenses.example:3000 Content-Type: application/json Content-Length: 54 头部 空行 {"date":…,"amount":1280} 正文 头部 149 字节+正文 54 字节 响应(服务器 → 电脑) HTTP/1.1 201 Created 第 1 行 Location: /api/expenses/EXP-… Content-Type: application/json Content-Length: 43 头部 空行 {"id":"EXP-2026-0012",…} 正文 头部+正文 43 字节 只是一行行文字。用换行(\r\n)分隔,用空行表示头部结束 正文长度写在 Content-Length 里(第 6 讲“分界”的答案)

请求的第 1 行是“做什么(POST)、对哪里(/api/expenses)、用哪个版本(HTTP/1.1)”。接下来的头部是“名字: 值”的行,Host 是目标的名字(一台服务器要负责好几个名字,所以必须写),Content-Type 是正文的种类,Content-Length 是正文的长度。空行表示头部结束,后面是 54 字节的 JSON。

第 6 讲说过,TCP 是没有分界的“流”。到哪里为止算一个申请,只能由上层决定。答案就在这里:到空行为止是头部,从那里起 Content-Length 字节是正文。服务器按这个规则切分数据流。

响应的第 1 行是“版本、状态码、说明”。201 Created 表示“已创建”,Location 里写着新申请的位置。最后的 Connection: keep-alive 和 Keep-Alive: timeout=5 的意思是“这条连接会保持 5 秒,下一个请求也可以用”。第 6 讲和第 8 讲看到的、省掉 TCP 和 TLS 寒暄的“连接复用”,就是在这里约定的。

POINT

Wireshark 的第 4 个数据包是 269 字节。以太网 14、IP 20、TCP 32(带时间戳)、HTTP 头部 149、正文 54,加起来正好 269。从第 2 讲开始的各层,全都装在一个数据包里。

三

方法:要做什么

第 1 行的第一个词是方法。同样是 /api/expenses,GET 是“给我看列表”,POST 是“新提交一个申请”。

方法含义只读(安全)做几次都一样(幂等)在报销应用里
GET获取○○看列表
POST发送并请对方处理××提交申请
PUT整个替换×○(这台练习服务器返回 405)
PATCH改写一部分××只改金额
DELETE删除×○撤回申请

重要的是右数第 2 列,幂等。意思是“同一个请求发多少次,结果都和发一次一样”。HTTP 规范(RFC 9110)把 GET、HEAD、OPTIONS 等安全方法以及 PUT、DELETE 定为幂等。幂等的话,即使没等到回复就超时了,也可以放心重发。

新井POST 不是幂等的……那回复太慢,我把申请按钮按了两次呢?

古井交通费 1,280 日元变成两条。你得向财务道歉两次。所以按钮按下后要变灰,服务器也要能认出重复的申请。两样都要。

四

状态码:结果如何

故意用各种方式向练习服务器发请求,把回复的第 1 行排在一起。

# 向同一个 API 发送各种请求时的回复(第 1 行和相关头部)
    GET /api/expenses                  → 200 OK
    POST /api/expenses(JSON)         → 201 Created Location: /api/expenses/EXP-2026-0013
    POST(JSON 损坏)                  → 400 Bad Request
    POST(Content-Type: text/plain)   → 415 Unsupported Media Type
    POST(amount 是字符串)            → 422 Unprocessable Entity
    PUT /api/expenses                  → 405 Method Not Allowed Allow: GET, POST
    GET /expenses(旧 URL)            → 301 Moved Permanently Location: /api/expenses
    GET /api/expenses/EXP-2026-9999    → 404 Not Found
    DELETE(没有钥匙)                 → 401 Unauthorized WWW-Authenticate: Bearer
    DELETE(带钥匙)                   → 204 No Content

按第一个数字分成 5 组(方块是本讲实测返回的状态码)

1xx 1xx 中间过程 后面还有 2xx 2xx 成功 200 OK、201 已创建、204 无内容 200 201 204 3xx 3xx 去别处 301 已搬家、304 沿用即可 301 4xx 4xx 请求方的问题 该改的是发送方 400 401 404 405 415 422 5xx 5xx 服务器的问题 发送方没有错 (本讲没有出现) 方块是本讲实测返回的状态码。只看第一个数字,就知道该由谁来修

三位数的第一个数字表示类别。2xx 是成功,3xx 是“去别处”,4xx 是“请求方的问题”,5xx 是“服务器的问题”。4xx 由发送方(前端)来改,5xx 由服务器一侧来改。出故障时首先看的,就是这一位数。

4xx 里也有区分。400 是 JSON 坏了读不了,415 是“只收 JSON,却来了 text/plain”,422 是“读得懂,但金额是字符串,内容不对”。401 是“不知道你是谁(请出示钥匙)”,用 WWW-Authenticate 附上钥匙的种类。知道是谁、但“不允许你”的是 403。

POINT

该看的是数字,不是后面的说明文字。规范 RFC 9110 把 422 的名字改成了“Unprocessable Content”,而本讲的 Node.js 返回的还是旧的“Unprocessable Entity”。这完全没有影响。HTTP/2 甚至连说明文字都没有,第六章的记录里也只有 HTTP/2 200 这个数字。

五

缓存:ETag 与 304

报销列表每次打开,返回的内容几乎都一样。每次都全部运送很浪费。于是服务器在返回列表的同时,附上内容的标记 ETag。

# 第 1 次:列表和内容的标记(ETag)一起返回
pc$ curl -D - http://expenses.example:3000/api/expenses
    HTTP/1.1 200 OK
    ETag: "41dc1e7dc5a9"
    Cache-Control: no-cache
    (正文 153 bytes)
# 第 2 次:附上“这个标记的我已经有了”再问
pc$ curl -D - -H 'If-None-Match: "41dc1e7dc5a9"' http://expenses.example:3000/api/expenses
    HTTP/1.1 304 Not Modified
    ETag: "41dc1e7dc5a9"
    (正文 0 bytes)

没变的话只回答“304,沿用之前的就行”

电脑 服务器 GET /api/expenses 200 OK + ETag "41dc…" (153 B) If-None-Match: "41dc…" 304 Not Modified (0 B) 保存在手边 没变就不发正文。往返次数不减,但运送的量减少
Wireshark 的界面。用显示过滤器 http 筛选出 4 个。第 4 个 GET /api/expenses,第 6 个 HTTP/1.1 200 OK(422 字节),第 14 个 GET /api/expenses(193 字节),第 16 个 HTTP/1.1 304 Not Modified(226 字节)。第 16 个的详情里有 ETag: 41dc1e7dc5a9 和 Cache-Control: no-cache,没有正文
获取两次列表。第 2 次(第 14 个)附上了“这个标记的我已经有了”,所以回答(第 16 个)是 304 Not Modified。ETag 标记和第 1 次相同,没有正文(在笔者于云端搭建的练习用网络中,于 2026 年 10 月 25 日记录,用 Wireshark 4.2.2 打开后截图。界面保持英文。窗口上方的标题栏和小标题是为了说明而后加的)

第 2 次用 If-None-Match 附上“这个标记的我已经有了”再问,服务器因为内容没变,用 304 Not Modified、正文 0 字节来回答。往返次数一样,但列表越大越有效。浏览器不用吩咐,就会自动这样做。

Cache-Control: no-cache 和名字相反,不是“别存”,而是“可以存,但每次使用前都要向服务器确认”(RFC 9111)。真的不想被存时用 no-store。max-age=600 是“10 分钟内不用问就可以用”,这期间连往返都没有了。

POINT

“改好了画面却不变”,多半是缓存。开发时在浏览器开发者工具 Network 标签里勾上“Disable cache”,可以少很多烦恼。

六

健忘的 HTTP 与 Cookie

HTTP 在一个请求结束后就把一切忘掉(无状态)。那“已登录”是怎么记住的呢?从这里开始用 HTTPS(3443 号)来试。

# 登录前问“我是谁?”
pc$ curl https://expenses.example:3443/api/me
    {"error":"login required"}
# 登录后,服务器发给 Cookie
pc$ curl -c jar.txt -X POST … https://expenses.example:3443/login
    HTTP/2 200 
    set-cookie: sid=480df7…; Path=/; HttpOnly; Secure; SameSite=Lax
# 下一个请求带上 Cookie 再问
pc$ curl -b jar.txt https://expenses.example:3443/api/me
    {"user":"arai"}

发一张号码牌,之后每次都请对方出示

电脑 服务器 GET /api/me 401:你是谁? POST /login Set-Cookie: sid=480df7… GET /api/me Cookie: sid=480df7… {"user":"arai"} 服务器的表 sid → arai HTTP 每次都会忘记。让它“假装”记得的就是 Cookie(第 5 层的会话管理) HttpOnly=JavaScript 读不到,Secure=只在 HTTPS 时发送 SameSite=从别的网站过来时不发送

登录后,服务器用 Set-Cookie 发一张号码牌(sid)。电脑把它收好,之后每次向同一台服务器发请求时都作为 Cookie 带上。服务器根据号码查出“是新井”。HTTP 本身依旧健忘,让它“假装”记得的就是 Cookie。第 8 讲看到的 OSI 第 5 层“会话管理”的职责,实际上就是在这里完成的。

号码牌后面的三个标记,是开发者最在意的地方。HttpOnly 让 JavaScript 读不到,Secure 只在 HTTPS 时发送,SameSite=Lax 在从别的网站跳过来时,大多数请求都不带上。号码牌就是钥匙本身,所以要让它不容易被偷、不容易被冒用。

POINT

带 Secure 的 Cookie 不会发给 http:// 的页面。开发中遇到“登录了却马上变回未登录”,首先怀疑这一点。另外,浏览器常常对 localhost 特殊对待,这个留到第 11 讲。

七

CORS:浏览器的守门人

假设页面放在 https://app.expenses.example,API 放在 https://expenses.example:3443。名字不同,所以两者是不同的 Origin(http/https、名字、端口的组合)。浏览器通常不允许某个页面的 JavaScript 读取另一个 Origin 的回复。由 API 一方回答允许与否的约定,就是 CORS。

POST JSON 时,浏览器会在正式请求之前先用 OPTIONS 做一次“预检”(preflight)。下面用 curl 重现了这个预检。

# 预检(preflight):能否从 https://app.expenses.example 的页面发 POST
    OPTIONS /api/expenses
    Origin: https://app.expenses.example
    Access-Control-Request-Method: POST
    Access-Control-Request-Headers: content-type
  →
    HTTP/2 204 
    access-control-allow-origin: https://app.expenses.example
    access-control-allow-methods: GET, POST, DELETE
    access-control-allow-headers: Content-Type, Authorization
    access-control-max-age: 600
# 来自陌生 Origin(https://other.example)时,不附带许可的头部
  →
    HTTP/2 204 

浏览器先预检,被许可才发送正式请求

浏览器 页面 app.expenses.example 浏览器的守门人 预检后再发送 API expenses.example OPTIONS(预检) Access-Control-Allow-Origin 被许可才发正式的 POST 向不同 Origin(名字、端口、http/https 的组合)fetch 时,由浏览器把关 把关的是浏览器。curl 和服务器之间的通信不管 CORS CORS 错误要在服务器一侧返回许可的头部来解决

对许可的 Origin 会带上 Access-Control-Allow-Origin 等头部,对不认识的 Origin 什么都不带。两者都是 204,请注意服务器并没有说“拒绝”。拦下来的是浏览器。没有许可的头部,浏览器就不发送正式请求,并在开发者工具的 Console 里显示 CORS 错误。

新井那个 CORS 错误,我怎么改前端代码都消不掉。

古井当然。该改的是服务器的回复。curl 不报这个错也是同样的道理,curl 没有守门人。

八

压缩

在限速到 10 Mbit/s 的线上,导出报销 CSV(2 MB)。

# 2 MB 的 CSV,原样/用 gzip(10 Mbit/s、单程 20 ms 的线)
pc$ curl https://expenses.example:3443/api/export.csv
    2,097,152 bytes   1.934 s
pc$ curl --compressed …   (Accept-Encoding: deflate, gzip, br, zstd → Content-Encoding: gzip)
        6,179 bytes   0.142 s

电脑用 Accept-Encoding 告诉对方“这些压缩方式我能读”,服务器用 Content-Encoding: gzip 压缩后返回。2,097,152 字节变成 6,179 字节,1.934 秒变成 0.142 秒。浏览器也会自动这样做。

不过这个 CSV 是同一行的重复,所以压得极小。另做一个编号、日期、金额各不相同的 2 MB CSV,同样用 gzip 压缩,是 428,597 字节,约五分之一。文字数据(HTML、JSON、CSV)压缩效果好,本来就压缩过的 JPEG 和视频几乎压不动。

POINT

压缩过的回复要带上 Vary: Accept-Encoding。意思是“这个回复随 Accept-Encoding 而变”,防止途中的缓存把压缩过的回复交给读不了 gzip 的一方。

九

HTTP/1.1、2、3

一个页面通常由几十个请求组成。用 HTTPS 获取 6 个各需 100 毫秒的报告,做了比较。

# 6 个各需 100 ms 的报告(HTTPS、单程 20 ms)
    HTTP/1.1 依次          0.953 s   1条连接
    HTTP/1.1 同时          0.246 s   6条连接
    HTTP/2 同时            0.245 s   1条连接

获取 6 个报告所用的时间(实测)

HTTP/1.1 依次 连接 1 条 0.953 s HTTP/1.1 同时 连接 6 条 0.246 s HTTP/2 同时 连接 1 条 0.245 s 0.0 s 0.5 s 1.0 s HTTP/1.1 一条连接一次只能处理一个。要加快就得建好几条连接(浏览器最多约 6 条) HTTP/2 在一条连接上同时推进许多请求(多路复用)

HTTP/1.1 一条连接一次只能处理一个请求。依次进行的话,即使复用连接也要 0.953 秒。要加快只能多建几条连接,6 条是 0.246 秒。浏览器出于同样的原因,对同一个对象会建 6 条左右的连接。相应地,TCP 和 TLS 的寒暄也要 6 次。

HTTP/2 在一条连接上给请求编号(流),同时推进(多路复用)。仍是一条连接,0.245 秒。内容从文字行变成了二进制的帧,但方法、状态码、头部的含义都一样。

Wireshark 的数据包列表。协议全部是 HTTP2。第 5、6、7 个以 HEADERS[1]、HEADERS[3]、HEADERS[5] 在 0.041 秒连续发出 3 个 GET /api/report。第 18 个把 HEADERS[1]: 200 OK、HEADERS[3]: 200 OK、DATA[1]、DATA[3] 装在一个数据包里返回,第 20 个返回 HEADERS[5],第 23 个返回 DATA[5]
用 HTTP/2 同时请求 3 个各需 100 毫秒的报告。[ ] 里的数字是流(请求)的编号,3 个请求在一条连接上同时进行,0.182 秒时回答交错着返回。为了看到内容,只有这一张是用不加密的 HTTP/2(h2c)记录的(在笔者于云端搭建的练习用网络中,于 2026 年 10 月 25 日记录,用 Wireshark 4.2.2 打开后截图。界面保持英文。窗口上方的标题栏和小标题是为了说明而后加的)

在 HTTPS 上,用哪个版本说话是在 TLS 握手时顺便决定的(ALPN。在第 8 讲看到的 Client Hello 里写上“我也会说 h2”发过去)。浏览器只在 HTTPS 时使用 HTTP/2,照片里不加密的 HTTP/2 是为了观察而做的特殊设置。

HTTP 的版本及其下层

HTTP/1.1 TLS TCP IP 1997 年(RFC 2068),文字行 HTTP/2 TLS TCP IP 2015 年,二进制帧与多路复用 HTTP/3 QUIC(TLS 1.3) UDP IP 2022 年,搬到 UDP 上 为了避开 TCP 的队头阻塞(第 7 讲),HTTP/3 搬到了 UDP 之上 内容的含义(方法、状态码、头部)在每个版本都一样

HTTP/2 仍留有弱点。下面的 TCP 是一条流,丢一个数据包,无关的流也要等重发(第 7 讲的“队头阻塞”)。于是 HTTP/3 搬到了 UDP 之上的 QUIC。QUIC 按每条流保证顺序,也内置了 TLS 1.3。浏览器通过回复里的 Alt-Svc 头部或 DNS 的 HTTPS 记录(第 9 讲)得知“这个对象也会说 HTTP/3”,之后就切换过去。

十

在自己的电脑上确认

想查的事浏览器(开发者工具)curl
一行行看请求和回复在 Network 标签选中一行,看 Headerscurl -v https://example.com/
只看状态码Network 标签的 Status 列curl -s -o /dev/null -w '%{http_code}\n' URL
使用的版本右键列标题,显示 Protocol(h2、h3)curl -sI --http2 URL
缓存Size 列的“(disk cache)”、304 的行curl -D - -H 'If-None-Match: "…"' URL
压缩Size 列(运送的量和解压后的大小)curl --compressed -w '%{size_download}\n' URL
CORS 预检Type 为 preflight 的行、Console 的错误curl -X OPTIONS -H 'Origin: …' -i URL

先打开平时常用的网站,看看 Network 标签吧。一个页面里有多少请求、用哪个版本、压缩了多少,一目了然。

结 — 一页总结与下一讲

  1. HTTP 是文字行。第 1 行、头部、空行、正文。正文长度看 Content-Length(TCP 数据流的切分方法)。
  2. 方法表示要做什么。GET、PUT、DELETE 是幂等的,可以重发。POST 要小心重复申请。
  3. 状态码看第一个数字。4xx 由发送方改,5xx 由服务器改。说明文字只是装饰。
  4. 用 ETag 与 304,没变就正文 0 字节。no-cache 是“每次都确认”。
  5. HTTP 很健忘。用 Cookie 的号码牌“假装”记得。HttpOnly、Secure、SameSite。
  6. 拦下 CORS 的是浏览器,要改的是服务器的回复。
  7. gzip 把 2 MB 变成 6 KB(普通 CSV 约五分之一)。HTTP/2 在一条连接上同时进行,HTTP/3 跑在 QUIC(UDP)之上。

新井这样申请按钮的旅程就全部走完了!可是,我电脑上的 localhost:3000 在这张地图的哪里呢?

古井好问题。下一讲是开发者的网络。localhost、Docker 的里和外、代理。每天都在用,却最容易迷路的地方。

参考

记录来自笔者在云端 Linux(Ubuntu 24.04,内核 6.18)上用网络命名空间搭建的练习网络,于 2026 年 10 月 25 日抓取。服务器是用 Node.js 22.23 的 http、http2 模块写的练习用程序,电脑一侧是 curl 8.5.0,名字和地址都是为说明而保留的或为练习而制作的。电脑和服务器之间是第 6 讲的捣乱线(单程 20 毫秒,仅压缩一章限速 10 Mbit/s)。表中的数字都是从这些记录和当时命令的输出读出的实际数值。各不相同的 CSV 的压缩,是把笔者制作的 2 MB CSV 用相同强度(6)的 gzip 压缩所得的值。登场人物均为虚构。

古法网络入门 第10讲 — HTTP / 2026.10

← 返回文章列表