AI → Full-Stack

🌐 Diese Lektion ist derzeit nur auf Chinesisch verfügbar. Englische und deutsche Übersetzungen sind für Woche 46 geplant.

365 天全栈成长 · PHASE 1 · WEEK 1

Day 6 - 裸写 HTTP 服务器:客人到底是怎么跟你说话的?

今日时长:3 小时(学习 50 min + 动手 100 min + 整理 30 min)· 主题:HTTP 协议与服务器原理——前五天都在调教后厨,今天走到前门看客人怎么点单 · 练习语言:Node.js(不用任何框架。Express、NestJS、ASP.NET Core 都是在这层之上包了一层)

🚪 接上回:后厨调教好了,前门还没看过

回头看这五天,我们一直在餐厅里面忙:

Day解决的问题餐厅里的位置
Day 1一个人怎么同时招呼很多客人服务员的工作方式
Day 2他按什么路线巡场服务员的走位
Day 32GB 的菜怎么端传菜的水管
Day 4一个人不够怎么办开分店
Day 5能不能在店里加个操作台后厨扩容

但有一件最基本的事,我们一直没看过:客人走到门口,到底是怎么跟你说话的?

🍜 餐厅版

客人不会走进来跟你聊天。他会递一张纸条。

纸条的格式是全世界统一的:第一行写「我要什么」,接下来几行写「我是谁、我能吃什么、我带了多少东西」,空一行,然后是附件(如果有的话)。

你也回一张纸条:第一行写「成了 / 没成 / 你走错门了」,接下来几行写「这是什么菜、有多重」,空一行,然后是菜本身。

这张纸条的格式,就叫 HTTP。

今天我们不用 Express,不用 NestJS。直接看这张纸条长什么样,然后亲手写一张回去。

为什么要学这个(不是为了以后手写服务器)

你以后 99% 的时间都会用框架。但框架只是把纸条读好、帮你填好而已。当出现下面这些情况,你必须能看穿框架:

  • 下载大文件时浏览器一直转圈 → Content-Length 写错了
  • 前端说跨域失败 → 那是响应 header 少了一行(Week 18 会细讲)
  • 接口返回 200 但前端解析报错 → Content-Type 不对
  • 上传大文件内存爆了 → 你把 body 当字符串一次读完了(Day 3 的背压问题)

这些都不是框架 bug,是 HTTP 没搞懂。

📇 概念卡 #1:HTTP 报文就是一段有格式的纯文本

概念卡:HTTP 请求报文(Request Message)

通用定义(不绑定任何语言):HTTP 请求是一段按固定格式写的纯文本,通过 TCP 连接发送。它由四部分组成,顺序不能变:

  1. 请求行:方法 路径 协议版本,一行
  2. 请求头:名字: 值,一行一个,可以有很多行
  3. 一个空行:告诉对方「头写完了」
  4. 请求体:可有可无。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我要删✅ 是通常没有
⚠️ 幂等(idempotent)——你的水平测试 A1 题

幂等 = 同样的请求做一次和做十次,服务器最终的状态一样。

「把用户 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);
⚠️ 换行必须是 \r\n

HTTP 规定行结束符是 \r\n(回车 + 换行),不是 \n。写错了浏览器可能不认。

这是个历史遗留:HTTP 从打字机时代的电报协议继承来的。你平时用框架看不到,是因为框架替你写对了。

这一步做完你会有一个顿悟:所谓「Web 服务器」,本质就是一个 TCP 程序,按约定的格式读字符串、写字符串。Express、NestJS、Spring Boot、ASP.NET Core,底下全是这个。

🍜 餐厅版

net 是「门口有人递纸条给你,你自己拆开看」。
http 是「门口有个接待员,已经帮你把纸条拆好、分好类,递给你一个填好的表格」。
Express 是「接待员还顺便按客人要求把他领到对应的桌子」(路由)。

📇 概念卡 #2:响应报文与状态码的五个家族

概念卡:HTTP 响应报文(Response Message)

结构和请求几乎一样,只有第一行不同:

  1. 状态行:协议版本 状态码 状态文本,例如 HTTP/1.1 404 Not Found
  2. 响应头:一行一个
  3. 一个空行
  4. 响应体: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 和 res 就是 Day 3 学的流

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 请求进来,前面五天学的东西全在起作用:

  1. TCP 连接到达 → libuv 通知 event loop → 在 poll 阶段被取出(Day 2)
  2. 你的 handler 被调用。它在主线程上跑(Day 1)
  3. handler 里 await db.query() → 主线程立刻去接待下一个请求(Day 1)
  4. handler 里如果做了 2 秒的 CPU 计算 → 所有请求一起卡住,该丢给 worker(Day 5)
  5. 响应体很大 → 用流写出去,别一次性拼字符串(Day 3)
  6. 想吃满 4 核 → cluster 起 4 个进程共享同一个端口(Day 4)

这就是 Week 1 的全貌。明天(Day 7)复盘日会把这张图完整画一遍。

📋 三个必须搞懂的 header

1. Content-Type:这是什么东西

告诉对方 body 该怎么解析。写错了对方就解析失败。

值用在哪
application/jsonAPI 返回 JSON。最常用
text/html; charset=utf-8返回网页。charset 不写中文会乱码
application/x-www-form-urlencoded普通表单提交
multipart/form-data带文件的表单
application/problem+jsonRFC 9457 错误响应

2. Content-Length vs Transfer-Encoding: chunked:我有多少东西给你

🍜 餐厅版

Content-Length = 「我这份外卖一共 3 盒,数好了再给你」。客人知道总数,能显示进度条。
chunked = 「我边做边给你,做好一盒递一盒,最后说一声『没了』」。客人不知道总数,进度条只能转圈。

什么时候必须用 chunked?当你开始发送时还不知道总大小——比如流式读数据库、AI 逐字返回答案。Node 的 res 在你不设 Content-Length 时会自动切到 chunked。

⚠️ 经典 bug

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.jsC# / .NETJavaPythonGo
裸 TCPnet.createServerTcpListenerServerSocketsocketnet.Listen
HTTP 服务器http.createServerWebApplication(Kestrel)HttpServer / Tomcathttp.server / ASGIhttp.ListenAndServe
请求对象req(Readable stream)HttpRequestHttpServletRequestRequest*http.Request
响应对象res(Writable stream)HttpResponseHttpServletResponseResponsehttp.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 看响应头:

  1. /fixed —— 返回中文,自己算 Content-Length。先用 str.length 算一次(错的),再用 Buffer.byteLength 算一次(对的),对比 curl 的表现。
  2. /chunked —— 每 300ms 写一行,共 5 行,不设 Content-Length。观察响应头里出现了什么。
  3. /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)

  1. 不看讲义,默写一个完整的 HTTP 请求报文和一个响应报文,标出四个组成部分。
  2. 五类状态码各写一句话,并各举两个例子。特别说清 401 和 403 的区别。
  3. 用三句话解释「幂等」,并说明它和「客户端重试」的关系。
  4. 回答:req 和 res 分别是什么类型的流?这给你带来了哪两个实际好处?

✅ 总结与自测

今天带走的 5 句话

  1. HTTP 报文就是一段有格式的纯文本:请求行/状态行 + 头 + 一个空行 + 体。所有 Web 框架底下都是在读写这段文本。
  2. 状态码只要记第一位:2xx 成了、3xx 去别处、4xx 你错了、5xx 我错了。把用户输入错误返回 500,会让你半夜被叫醒修一个不存在的 bug。
  3. 幂等 = 做一次和做十次结果一样。网络会抽风,客户端会重试;幂等的请求重试安全,不幂等的要靠幂等键保护。
  4. req 是 Readable,res 是 Writable。Day 3 学的流和背压在这里字面适用——上传下载都该用流,拼字符串就是内存炸弹。
  5. Content-Length 要的是字节数不是字符数。中文一个字 3 字节,用 Buffer.byteLength,别用 .length。

自测清单(能打勾才算今天过关)

  • 能默写出 HTTP 请求报文的四个组成部分,并说出那个空行的作用
  • 能说出五类状态码各代表什么,并解释 401 和 403 的区别
  • 能解释幂等,并说出 POST 和 PUT 哪个幂等、为什么这对重试很重要
  • 能说出表单校验失败该返回什么状态码和什么格式的 body
  • 能说清 Content-Length 和 chunked 的区别,以及什么时候必须用 chunked
  • 能解释为什么 req/res 是流,以及这对上传下载意味着什么
  • 能用一句话说出 net、http、Express 三层各自负责什么
  • 能画出「一个 HTTP 请求进来,前五天的知识分别在哪一步起作用」