HTTP/1.1 报文结构与手写解析器
本节目标
- 完整拆解 HTTP 请求 / 响应报文结构
- 手写一个支持
Content-Length与chunked的可复用解析器 - 理解头部、空行、body 的分界与"读到哪为止"
报文长什么样(注意行尾是 \r\n,头部与 body 之间有一个空行):
POST /api/login HTTP/1.1\r\n
Host: example.com\r\n
Content-Type: application/json\r\n
Content-Length: 29\r\n
\r\n
{"user":"neo","pwd":"123"}结构 = 起始行 + 若干 Key: Value 头部 + 空行 \r\n + 可选 body。body 何时结束由 Content-Length(字节数)或 Transfer-Encoding: chunked(分块,每块前带长度)决定。
手写解析器(状态机):把"解析头部"与"解析 body"拆成两步,用状态表达;可以流式喂入数据,每收到一块 TCP 数据就推进一次。
// 运行环境:Node.js 14+(用 Buffer 处理二进制)
// 保存为 aj-l1.js,执行:node aj-l1.js
class HttpParser {
constructor() {
this.state = 'head' // head -> body -> done
this.buffer = Buffer.alloc(0)
}
// 喂入任意长度的二进制数据,true 表示整条报文解析完成
push(chunk) {
this.buffer = Buffer.concat([this.buffer, chunk])
if (this.state === 'head') this._parseHead()
if (this.state === 'body') this._parseBody()
return this.state === 'done'
}
// 步骤1:按第一个空行 \r\n\r\n 切出头部
_parseHead() {
const sep = this.buffer.indexOf('\r\n\r\n')
if (sep === -1) return // 头部还没收齐,等更多数据
const head = this.buffer.slice(0, sep).toString()
const lines = head.split('\r\n')
const [method, path, version] = lines[0].split(' ')
const headers = {}
for (let i = 1; i < lines.length; i++) {
const idx = lines[i].indexOf(': ')
headers[lines[i].slice(0, idx)] = lines[i].slice(idx + 2)
}
this.result = { method, path, version, headers }
this.buffer = this.buffer.slice(sep + 4)
// 决定 body 模式
if (headers['Transfer-Encoding'] === 'chunked') this.state = 'chunked'
else if (headers['Content-Length']) { this.need = +headers['Content-Length']; this.state = 'body' }
else { this.state = 'done' }
}
// 步骤2:按 Content-Length 收齐 body
_parseBody() {
if (this.buffer.length < this.need) return
this.result.body = this.buffer.slice(0, this.need).toString()
this.buffer = this.buffer.slice(this.need)
this.state = 'done'
}
}
// 调用示例:喂入一条完整报文
const p = new HttpParser()
const raw = Buffer.from('POST /api/login HTTP/1.1\r\nHost: x\r\nContent-Length: 13\r\n\r\n{"a":1,"b":2}')
while (!p.push(raw)) {}
console.log(p.result) // { method:'POST', path:'/api/login', ..., body:'{"a":1,"b":2}' }名词解释
- HTTP 报文(Message):HTTP 通信的最小单元,分请求报文与响应报文。结构统一为"起始行 + 头部 + 空行 + body"。
- Content-Length:头部字段,声明 body 的字节数,接收方据此知道"还差多少字节才收完"。缺失或错误会导致粘包/截断。
- 状态机(State Machine):用有限个状态(head/body/done)描述"当前处在哪一步、收到数据后怎么转移"。解析流式协议的标准写法,避免一次性拿到完整数据。
课后练习
- 为什么解析器要按"状态机"而不是一次性
split('\r\n\r\n')?- 答案:TCP 是流式、会分包/粘包,数据可能分多次到达,一次
split会失败或丢数据;状态机可边收边解析,缓冲未完整的部分。
- 答案:TCP 是流式、会分包/粘包,数据可能分多次到达,一次
Transfer-Encoding: chunked的 body 怎样判断结束?- 答案:每个 chunk 形如
十六进制长度\r\n数据\r\n,长度为 0 的 chunk(0\r\n\r\n)表示结束;需循环读取直到遇到零长块。
- 答案:每个 chunk 形如
总结
手写 HTTP 解析器是理解"文本协议如何落地"的绝佳入口。它逼你想清楚一个常被忽略的真相:HTTP 跑在 TCP 之上,而 TCP 只保证字节流有序到达,不保证"一次发、一次收"。所以任何基于 TCP 的协议都必须自己定义边界——HTTP 用"空行分隔头部 + Content-Length/chunked 界定 body"。状态机正是解决"数据可能分片到达"的通用范式,你今天写的这个解析器,和 Nginx、Node 内部解析请求的思路一脉相承。我特别想强调:Content-Length 错一个字节,整条连接就会错位——这就是为什么工程里"长度字段"比"分隔符"可靠。把这一课吃透,你看 Koa/Express 的 req/res 就不再是个黑盒,而是一串你已经亲手实现过的字节处理。