Day 6 - 裸写 HTTP 服务器:客人到底是怎么跟你说话的?
🚪 接上回:后厨调教好了,前门还没看过
回头看这五天,我们一直在餐厅里面忙:
| Day | 解决的问题 | 餐厅里的位置 |
|---|---|---|
| Day 1 | 一个人怎么同时招呼很多客人 | 服务员的工作方式 |
| Day 2 | 他按什么路线巡场 | 服务员的走位 |
| Day 3 | 2GB 的菜怎么端 | 传菜的水管 |
| Day 4 | 一个人不够怎么办 | 开分店 |
| Day 5 | 能不能在店里加个操作台 | 后厨扩容 |
但有一件最基本的事,我们一直没看过:客人走到门口,到底是怎么跟你说话的?
客人不会走进来跟你聊天。他会递一张纸条。
纸条的格式是全世界统一的:第一行写「我要什么」,接下来几行写「我是谁、我能吃什么、我带了多少东西」,空一行,然后是附件(如果有的话)。
你也回一张纸条:第一行写「成了 / 没成 / 你走错门了」,接下来几行写「这是什么菜、有多重」,空一行,然后是菜本身。
这张纸条的格式,就叫 HTTP。
今天我们不用 Express,不用 NestJS。直接看这张纸条长什么样,然后亲手写一张回去。
你以后 99% 的时间都会用框架。但框架只是把纸条读好、帮你填好而已。当出现下面这些情况,你必须能看穿框架:
- 下载大文件时浏览器一直转圈 →
Content-Length写错了 - 前端说跨域失败 → 那是响应 header 少了一行(Week 18 会细讲)
- 接口返回 200 但前端解析报错 →
Content-Type不对 - 上传大文件内存爆了 → 你把 body 当字符串一次读完了(Day 3 的背压问题)
这些都不是框架 bug,是 HTTP 没搞懂。
📇 概念卡 #1:HTTP 报文就是一段有格式的纯文本
通用定义(不绑定任何语言):HTTP 请求是一段按固定格式写的纯文本,通过 TCP 连接发送。它由四部分组成,顺序不能变:
- 请求行:
方法 路径 协议版本,一行 - 请求头:
名字: 值,一行一个,可以有很多行 - 一个空行:告诉对方「头写完了」
- 请求体:可有可无。GET 通常没有,POST 通常有
浏览器访问 http://localhost:3100/users?page=2 时,实际发出去的字节长这样:
GET /users?page=2 HTTP/1.1
Host: localhost:3100
User-Agent: Mozilla/5.0 ...
Accept: text/html,application/json
Accept-Language: de-DE,de;q=0.9,en;q=0.8
Connection: keep-alive
← 这个空行很关键
就这么简单。没有魔法,没有二进制,就是几行字。你用记事本手打这几行发过去,服务器一样认。
| 报文这一行 | 客人在说什么 |
|---|---|
GET /users?page=2 HTTP/1.1 | 「我要看(GET)客人名单,第 2 页。我说的是 HTTP 1.1 版的规矩」 |
Host: localhost:3100 | 「我是来这家店的」(一台服务器可能同时开好几家店,靠这行区分) |
User-Agent: Mozilla/5.0 | 「我是用 Chrome 浏览器来的」 |
Accept: text/html,application/json | 「我能吃 HTML,也能吃 JSON,你看着给」 |
Accept-Language: de-DE... | 「我优先要德语,其次英语」← 我们网站的语言跳转就靠这行 |
Connection: keep-alive | 「吃完别赶我走,我可能还要点」 |
方法(method):你想干什么
| 方法 | 意思 | 幂等吗 | 有 body 吗 |
|---|---|---|---|
GET | 我要看 | ✅ 是 | 通常没有 |
POST | 我要新建 / 提交 | ❌ 否 | 有 |
PUT | 我要整个替换 | ✅ 是 | 有 |
PATCH | 我要改其中几个字段 | ⚠️ 看实现 | 有 |
DELETE | 我要删 | ✅ 是 | 通常没有 |
幂等 = 同样的请求做一次和做十次,服务器最终的状态一样。
「把用户 5 的名字改成张三」(PUT)做十次,结果都是张三 → 幂等。
「新建一个订单」(POST)做十次,你会有十个订单 → 不幂等。
为什么这件事很重要?因为网络会抽风。客户端发了请求,没收到响应——它不知道是「服务器没收到」还是「服务器做了但回复丢了」。如果这个请求是幂等的,重试就是安全的;不幂等的,重试可能扣两次钱。
这就是为什么支付接口必须带一个「幂等键」(idempotency key)。Week 5 会正式做这件事。
👀 亲眼看一次:用 TCP 把浏览器的原话打印出来
光看文字不够。今天习题 1 就是把浏览器发来的原始字节打印出来。方法是绕过 node:http,直接用更底层的 node:net。
import net from "node:net";
const server = net.createServer((socket) => {
socket.on("data", (chunk) => {
console.log("===== 浏览器的原话 =====");
console.log(chunk.toString()); // ← 这里就是那张纸条
// 手写一张纸条回去。注意 \r\n,不是 \n
socket.end(
"HTTP/1.1 200 OK\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"Content-Length: 12\r\n" +
"\r\n" + // ← 空行,分隔头和体
"hello world!"
);
});
});
server.listen(3100);
HTTP 规定行结束符是 \r\n(回车 + 换行),不是 \n。写错了浏览器可能不认。
这是个历史遗留:HTTP 从打字机时代的电报协议继承来的。你平时用框架看不到,是因为框架替你写对了。
这一步做完你会有一个顿悟:所谓「Web 服务器」,本质就是一个 TCP 程序,按约定的格式读字符串、写字符串。Express、NestJS、Spring Boot、ASP.NET Core,底下全是这个。
net 是「门口有人递纸条给你,你自己拆开看」。
http 是「门口有个接待员,已经帮你把纸条拆好、分好类,递给你一个填好的表格」。
Express 是「接待员还顺便按客人要求把他领到对应的桌子」(路由)。
📇 概念卡 #2:响应报文与状态码的五个家族
结构和请求几乎一样,只有第一行不同:
- 状态行:
协议版本 状态码 状态文本,例如HTTP/1.1 404 Not Found - 响应头:一行一个
- 一个空行
- 响应体:HTML、JSON、图片字节,都行
状态码:只要记住第一位数字
| 家族 | 一句话 | 常见成员 |
|---|---|---|
| 1xx | 「等一下,还没完」 | 101 Switching Protocols(升级成 WebSocket 用的,Week 26 见) |
| 2xx | 「成了」 | 200 OK · 201 Created(新建成功)· 204 No Content(成了但没东西给你) |
| 3xx | 「不在这儿,去别处」 | 301 永久搬走 · 302 临时去那边 · 304 你手上那份还是新的(缓存) |
| 4xx | 「你错了」 | 400 你话说得不对 · 401 你没登录 · 403 登录了但没资格 · 404 没这东西 · 409 冲突 · 422 格式对但内容不合理 · 429 你点太快了 |
| 5xx | 「我错了」 | 500 我崩了 · 502 我后面那台崩了 · 503 我忙不过来 · 504 我后面那台太慢了 |
- 把 4xx 当 5xx 用。用户填错表单你返回 500,监控就会天天报警,而其实是用户的问题。4xx 是「别怪我」,5xx 是「我的锅」——这决定了要不要半夜爬起来修。
- 什么都返回 200,把错误写在 body 里(
{"success": false})。这样做浏览器缓存、CDN、监控、重试逻辑全部失效,因为它们只看状态码。
401 vs 403:面试高频
401 Unauthorized = 「你是谁?先出示证件」——其实该叫 Unauthenticated,是历史命名错误。
403 Forbidden = 「证件看过了,是你本人。但这是员工通道,你不能进」。
一句话:401 是不知道你是谁,403 是知道你是谁但你没资格。
校验失败该返回什么?(你的水平测试 A2 题)
用户提交表单,邮箱格式不对。返回什么?
状态码:400 Bad Request 或 422 Unprocessable Content。两个都对,团队内统一就行。(400 = 「话都说不明白」,422 = 「话说明白了但内容不合理」)
响应体:不要只回一句 "error"。有一个国际标准专门规定了错误格式,叫 RFC 9457 problem+json:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://example.com/errors/validation",
"title": "Validation failed",
"status": 422,
"detail": "邮箱格式不正确",
"instance": "/api/users",
"errors": [
{ "field": "email", "message": "must be a valid email" }
]
}
为什么要用标准而不是自己编?因为前端、日志系统、监控、其他团队的服务,都能用同一套代码解析。自己编的格式,每接一个新前端就要重写一遍。Week 5 做 REST API 设计时会正式落地它。
🔌 node:http 帮你做了什么 —— 以及 req/res 其实是 Day 3 的流
看过原始报文之后,再看 node:http 就一目了然了:
import http from "node:http";
const server = http.createServer((req, res) => {
// req:接待员已经把请求那张纸条拆好了
req.method // "GET" ← 请求行第 1 段
req.url // "/users?page=2" ← 请求行第 2 段
req.headers["accept-language"] // "de-DE,de;q=0.9" ← 请求头(名字自动转小写)
// res:你往里写的东西,会被组装成响应那张纸条
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ ok: true }));
});
server.listen(3100);
| 它替你做的事 | 不做的话你要自己干什么 |
|---|---|
| 解析请求行和请求头 | 自己切字符串、处理 \r\n |
自动写 Content-Length 或改用 chunked | 自己数字节 |
| 管理 keep-alive 连接复用 | 自己判断什么时候关 socket |
| 把 body 变成一个可读流 | 自己攒 TCP 分片 |
req 是一个 Readable stream。res 是一个 Writable stream。
这不是比喻,是字面意思。所以 Day 3 学的所有东西在这里全部适用:
// 上传大文件:直接把请求流接到文件流,内存恒定
import { pipeline } from "node:stream/promises";
await pipeline(req, fs.createWriteStream("upload.bin"));
// 下载大文件:直接把文件流接到响应流,不占内存
fs.createReadStream("big.zip").pipe(res);
反过来说,如果你写成这样,就是 Day 3 讲的那个内存炸弹:
// ❌ 500MB 的上传会把内存吃光
let body = "";
req.on("data", (chunk) => { body += chunk; }); // 全攒在内存里
req.on("end", () => { /* ... */ });
顺便把前五天串起来
一个 HTTP 请求进来,前面五天学的东西全在起作用:
- TCP 连接到达 → libuv 通知 event loop → 在 poll 阶段被取出(Day 2)
- 你的 handler 被调用。它在主线程上跑(Day 1)
- handler 里
await db.query()→ 主线程立刻去接待下一个请求(Day 1) - handler 里如果做了 2 秒的 CPU 计算 → 所有请求一起卡住,该丢给 worker(Day 5)
- 响应体很大 → 用流写出去,别一次性拼字符串(Day 3)
- 想吃满 4 核 → cluster 起 4 个进程共享同一个端口(Day 4)
这就是 Week 1 的全貌。明天(Day 7)复盘日会把这张图完整画一遍。
📋 三个必须搞懂的 header
1. Content-Type:这是什么东西
告诉对方 body 该怎么解析。写错了对方就解析失败。
| 值 | 用在哪 |
|---|---|
application/json | API 返回 JSON。最常用 |
text/html; charset=utf-8 | 返回网页。charset 不写中文会乱码 |
application/x-www-form-urlencoded | 普通表单提交 |
multipart/form-data | 带文件的表单 |
application/problem+json | RFC 9457 错误响应 |
2. Content-Length vs Transfer-Encoding: chunked:我有多少东西给你
Content-Length = 「我这份外卖一共 3 盒,数好了再给你」。客人知道总数,能显示进度条。
chunked = 「我边做边给你,做好一盒递一盒,最后说一声『没了』」。客人不知道总数,进度条只能转圈。
什么时候必须用 chunked?当你开始发送时还不知道总大小——比如流式读数据库、AI 逐字返回答案。Node 的 res 在你不设 Content-Length 时会自动切到 chunked。
Content-Length 写的数字和实际发的字节数不一致 → 浏览器要么一直转圈等剩下的,要么把内容截断。
特别注意中文:"你好".length 是 2,但 UTF-8 编码后是 6 个字节。Content-Length 要的是字节数,不是字符数。用 Buffer.byteLength(str) 算——这就是 Day 3 概念卡「字节 ≠ 字符」的实战。
3. Connection: keep-alive:别急着挂电话
HTTP/1.0 时代:每个请求开一个 TCP 连接,用完就关。一个网页 50 个资源 = 建 50 次连接,每次都要三次握手,慢得要命。
HTTP/1.1 默认 keep-alive:一条连接连续发多个请求。这是巨大的性能提升,也是你今天写 net 版服务器时会踩的坑——如果你不关连接也不说清楚,浏览器会一直等。
🌍 跨语言对照:框架不同,报文一模一样
HTTP 是协议,不是某个语言的特性。你今天理解的报文格式、状态码、header,在 C#、Java、Python、Go 里一个字都不用改。
换语言时你要重学的只是「这个框架把 req 叫什么」,而不是「HTTP 是怎么回事」。
| 通用概念 | Node.js | C# / .NET | Java | Python | Go |
|---|---|---|---|---|---|
| 裸 TCP | net.createServer | TcpListener | ServerSocket | socket | net.Listen |
| HTTP 服务器 | http.createServer | WebApplication(Kestrel) | HttpServer / Tomcat | http.server / ASGI | http.ListenAndServe |
| 请求对象 | req(Readable stream) | HttpRequest | HttpServletRequest | Request | *http.Request |
| 响应对象 | res(Writable stream) | HttpResponse | HttpServletResponse | Response | http.ResponseWriter |
| 读 body | 监听 data 事件 / 流 | Request.Body(Stream) | getInputStream() | await request.body() | r.Body(io.Reader) |
| 写状态码 | res.writeHead(404) | Results.NotFound() | resp.setStatus(404) | Response(status_code=404) | w.WriteHeader(404) |
注意最后两行的共同点:每种语言里,body 都是「流」,不是「字符串」。这不是巧合——因为 body 可能有 2GB,没有任何语言敢默认把它读进内存。Day 3 的道理在这里再次成立。
🛠️ 今日习题(约 100 min)
代码全部写在 src/06-http-server/。今天不许用任何框架。
习题 1(约 25 min):看见浏览器的原话
文件:ex1-raw-tcp.ts
做什么:用 node:net 起一个 TCP 服务器,把浏览器发来的原始字节原样打印,然后手写一个 HTTP 响应回去。
跑起来后用浏览器访问 http://localhost:3100/hello?name=david,再用 curl -X POST -d "a=1" http://localhost:3100/submit 试一次。
要回答:
- Q1:把浏览器发来的报文完整抄进
notes.md,标出哪一行是请求行、哪些是请求头、空行在哪。 - Q2:浏览器和 curl 发来的报文,有哪三处明显不同?
- Q3:把响应里的
Content-Length故意改成5(实际内容更长),浏览器显示什么?解释为什么。 - Q4:把响应里的
\r\n全部换成\n,还能正常显示吗?
习题 2(约 45 min):不用框架写一个真 API
文件:ex2-http-api.ts
做什么:用 node:http 实现一个任务清单 API,骨架已经搭好,你要补的是路由分发逻辑(文件里有 TODO):
| 请求 | 行为 | 状态码 |
|---|---|---|
GET /tasks | 返回全部任务,支持 ?done=true 过滤 | 200 |
GET /tasks/:id | 返回一个任务 | 200 / 404 |
POST /tasks | 新建。title 为空要报错 | 201 / 422 |
DELETE /tasks/:id | 删除 | 204 / 404 |
| 其他 | 找不到路由 | 404 |
硬性要求:错误响应必须用 application/problem+json 格式(见概念卡 #2)。
要回答:
- Q1:为什么新建成功返回 201 而不是 200?201 还应该带一个什么 header?
- Q2:为什么 DELETE 成功返回 204 而不是 200?204 的响应体必须是什么?
- Q3:你写路由分发时,最麻烦的是哪一部分?这就是 Express 帮你做的事。
习题 3(约 30 min):Content-Length、中文字节、流式响应
文件:ex3-headers-and-streams.ts
做什么:三个小实验,每个都用 curl -v 看响应头:
/fixed—— 返回中文,自己算 Content-Length。先用str.length算一次(错的),再用Buffer.byteLength算一次(对的),对比 curl 的表现。/chunked—— 每 300ms 写一行,共 5 行,不设 Content-Length。观察响应头里出现了什么。/download—— 用createReadStream(...).pipe(res)发一个 100MB 文件,同时用process.memoryUsage()打印内存。
要回答:
- Q1:用
str.length当 Content-Length,curl 报了什么错?为什么中文会出问题? - Q2:
/chunked的响应头里 Node 自动加了哪一行? - Q3:发 100MB 文件时进程内存涨了多少?如果改成
res.end(fs.readFileSync(...))会涨多少?(先猜再试)
默写题(约 10 min,写在 notes.md)
- 不看讲义,默写一个完整的 HTTP 请求报文和一个响应报文,标出四个组成部分。
- 五类状态码各写一句话,并各举两个例子。特别说清 401 和 403 的区别。
- 用三句话解释「幂等」,并说明它和「客户端重试」的关系。
- 回答:
req和res分别是什么类型的流?这给你带来了哪两个实际好处?
✅ 总结与自测
今天带走的 5 句话
- HTTP 报文就是一段有格式的纯文本:请求行/状态行 + 头 + 一个空行 + 体。所有 Web 框架底下都是在读写这段文本。
- 状态码只要记第一位:2xx 成了、3xx 去别处、4xx 你错了、5xx 我错了。把用户输入错误返回 500,会让你半夜被叫醒修一个不存在的 bug。
- 幂等 = 做一次和做十次结果一样。网络会抽风,客户端会重试;幂等的请求重试安全,不幂等的要靠幂等键保护。
req是 Readable,res是 Writable。Day 3 学的流和背压在这里字面适用——上传下载都该用流,拼字符串就是内存炸弹。- Content-Length 要的是字节数不是字符数。中文一个字 3 字节,用
Buffer.byteLength,别用.length。
自测清单(能打勾才算今天过关)
- 能默写出 HTTP 请求报文的四个组成部分,并说出那个空行的作用
- 能说出五类状态码各代表什么,并解释 401 和 403 的区别
- 能解释幂等,并说出 POST 和 PUT 哪个幂等、为什么这对重试很重要
- 能说出表单校验失败该返回什么状态码和什么格式的 body
- 能说清
Content-Length和chunked的区别,以及什么时候必须用 chunked - 能解释为什么
req/res是流,以及这对上传下载意味着什么 - 能用一句话说出
net、http、Express 三层各自负责什么 - 能画出「一个 HTTP 请求进来,前五天的知识分别在哪一步起作用」