HTTP/1.1 报文结构与手写解析器

本节目标

报文长什么样(注意行尾是 \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}' }

名词解释

课后练习

  1. 为什么解析器要按"状态机"而不是一次性 split('\r\n\r\n')?
    • 答案:TCP 是流式、会分包/粘包,数据可能分多次到达,一次 split 会失败或丢数据;状态机可边收边解析,缓冲未完整的部分。
  2. Transfer-Encoding: chunked 的 body 怎样判断结束?
    • 答案:每个 chunk 形如 十六进制长度\r\n数据\r\n,长度为 0 的 chunk(0\r\n\r\n)表示结束;需循环读取直到遇到零长块。

总结

手写 HTTP 解析器是理解"文本协议如何落地"的绝佳入口。它逼你想清楚一个常被忽略的真相:HTTP 跑在 TCP 之上,而 TCP 只保证字节流有序到达,不保证"一次发、一次收"。所以任何基于 TCP 的协议都必须自己定义边界——HTTP 用"空行分隔头部 + Content-Length/chunked 界定 body"。状态机正是解决"数据可能分片到达"的通用范式,你今天写的这个解析器,和 Nginx、Node 内部解析请求的思路一脉相承。我特别想强调:Content-Length 错一个字节,整条连接就会错位——这就是为什么工程里"长度字段"比"分隔符"可靠。把这一课吃透,你看 Koa/Express 的 req/res 就不再是个黑盒,而是一串你已经亲手实现过的字节处理。