Rust in Everything
基于 hyper 1.12.0

从零写一个 HTTP/1.1 服务端

主线位置:把服务端主线自己走一遍。字节 → 请求:读进缓冲、httparse 解析请求头、按 Content-Length 切出 body;请求 → 响应:调一个处理函数;响应 → 字节:拼好整个响应一次写出;然后回到缓冲区,开始同一条连接上的下一个请求。

flowchart TB
  acc["accept:每条连接一个任务"] --> parse["parse_head:解析缓冲区"]
  parse -->|Partial| rd["read_buf:再读一些"]
  rd -->|读到 0| eof[对端关闭,结束]
  rd --> parse
  parse -->|"Err(status)"| rej["回 4xx/5xx,关连接"]
  parse -->|"Complete(n)"| body["凑够 Content-Length"]
  body --> handle[调处理函数]
  handle --> wr["write_all 整个响应"]
  wr --> ka{keep-alive?}
  ka -->|是| parse
  ka -->|否| closenode[关连接]

本页只讲思路和关键代码。服务端部分大约 170 行,只依赖 tokio、httparse 和 bytes:

httparse = "=1.10.1"
bytes = "1"
tokio = { version = "=1.54.1", features = ["net", "io-util", "rt-multi-thread", "macros", "time"] }

验证时用到的 hyper 客户端另外需要:

hyper = { version = "=1.12.0", features = ["client", "http1"] }
hyper-util = { version = "=0.1.21", features = ["tokio"] }
http-body-util = "0.1"
futures = "0.3"

完整实现(服务端、测试程序)、chunked 解码器、HTTP/1 客户端和连接池的从零实现,以及配套作业,放在付费课程里。

解析请求头:parse_head

const MAX_HEAD: usize = 8 * 1024;
const MAX_BODY: usize = 1024 * 1024;
const MAX_HEADERS: usize = 32;

struct Head {
  method: String,
  path: String,
  minor: u8,
  content_length: usize,
  keep_alive: bool,
  chunked: bool,
}

/// Ok(None):头还没收全。Err(status):用这个状态码拒绝并关连接。
fn parse_head(buf: &[u8]) -> Result<Option<(Head, usize)>, u16> {
  let mut headers = [httparse::EMPTY_HEADER; MAX_HEADERS];
  let mut req = httparse::Request::new(&mut headers);
  let head_len = match req.parse(buf) {
    Ok(httparse::Status::Complete(n)) => n,
    Ok(httparse::Status::Partial) => return Ok(None),
    Err(httparse::Error::TooManyHeaders) => return Err(431),
    Err(_) => return Err(400),
  };
  // Complete 保证 method、path、version 都是 Some
  let minor = req.version.unwrap_or(1);
  let mut head = Head {
    method: req.method.unwrap_or_default().to_owned(),
    path: req.path.unwrap_or_default().to_owned(),
    minor,
    content_length: 0,
    keep_alive: minor == 1, // 1.1 默认 keep-alive,1.0 默认关闭
    chunked: false,
  };
  let mut seen_length = false;
  let (mut saw_close, mut saw_keep_alive) = (false, false);
  for h in req.headers.iter() {
    if h.name.eq_ignore_ascii_case("content-length") {
      let n = std::str::from_utf8(h.value)
        .ok()
        .and_then(|s| s.trim().parse::<usize>().ok())
        .ok_or(400u16)?;
      // 两个不同的长度:请求走私的入口
      if seen_length && n != head.content_length {
        return Err(400);
      }
      seen_length = true;
      head.content_length = n;
    } else if h.name.eq_ignore_ascii_case("transfer-encoding") {
      head.chunked = true;
    } else if h.name.eq_ignore_ascii_case("connection") {
      for token in h.value.split(|&b| b == b',') {
        let token = token.trim_ascii();
        saw_close |= token.eq_ignore_ascii_case(b"close");
        saw_keep_alive |= token.eq_ignore_ascii_case(b"keep-alive");
      }
    }
  }
  // 所有 Connection 行都看完再定:任何一处 close 都算数
  if saw_close {
    head.keep_alive = false;
  } else if saw_keep_alive {
    head.keep_alive = true;
  }
  Ok(Some((head, head_len)))
}

httparse 1.10.1 的 Request::parse 给三种结果,正好对应服务端要做的三件事:

结果含义mini 怎么办
Ok(Status::Partial)到目前为止的字节合法,但头还没结束返回 Ok(None),调用方再读
Ok(Status::Complete(n))头完整,占前 n 个字节取出要用的字段,返回 n
Err(TooManyHeaders) / 其他 Err头超过 32 个 / 格式错431 / 400,关连接

body 怎么分界只看两个头。Content-Length 出现多次且值不同就拒绝,这和 hyper 在 Server::parse 里按 RFC 7230 第 3.3.3 节做的一样:两个长度对不上,前后两台服务器可能各信一个,请求的边界就被人操纵了。Transfer-Encoding 只记一个标志,mini 没有 chunked 解码器,见到它就回 501。hyper 怎么解 chunked,见 body 的解码与编码。

📝NOTE
没有蠢问题

问: method 和 path 为什么要 to_owned() 拷成 String?httparse 给的是 &str,直接留着不行吗?

答: 不行。req 借用着 buf,而 parse_head 一返回,调用方就要 buf.advance(head_len)、再往 buf 里读 body,这些都要 &mut buf。借用检查器不允许两者同时存在,所以先拷出来,放开借用。hyper 不拷:它让 httparse 只记下每个头名和值在缓冲区里的起止下标,然后把头那一段 split_to(len).freeze() 成一个 Bytes,URI 和每个头值都是这个 Bytes 的切片,只增加引用计数(见解析与编码)。

Connection 头可以有多行,一行里也可以有多个逗号分隔的值。mini 先把所有值看完,最后才定 keep-alive:只要出现过 close 就关,否则出现过 keep-alive 就保持,都没有就按版本的默认值。hyper 的 Server::parse 是边看边定,用一个 saw_connection_close 标志让 close 一旦出现就不再被推翻:

                header::CONNECTION => {
                    // A message may carry more than one `Connection` line. Once
                    // any of them asks to close, a later `keep-alive` must not
                    // undo it.
                    if saw_connection_close || headers::connection_close(&value) {
                        saw_connection_close = true;
                        keep_alive = false;
                    } else if !keep_alive {
                        // HTTP/1.0, which defaults to close
                        keep_alive = headers::connection_keep_alive(&value);
                    }
                }
📝NOTE
冷知识:close 后面跟一行 keep-alive,hyper 1.12.0 之前会保持连接

这个标志是 #4201 加的,首次发布于 v1.12.0。之前的代码按 keep_alive 的当前值选分支:是 true 就当 HTTP/1.1 处理(看有没有 close),是 false 就当 HTTP/1.0 处理(看有没有 keep-alive)。提交说明写道:「a line that set keep_alive to false made the next line take the HTTP/1.0 arm and set it back to true. Connection: close followed by Connection: keep-alive therefore parsed as keep-alive, while the reverse order parsed correctly.」修法是「Track whether any Connection line carried a close token and let that win for the rest of the loop, in both Server::parse and Client::parse」。mini 把「看」和「定」分成两步,就不会出这个顺序问题;下面的实验里有这一条。

连接循环:serve

每条连接 spawn 一个任务跑 serve。缓冲区用 BytesMut,一条连接从头用到尾:

async fn serve(mut stream: TcpStream, id: usize) -> io::Result<()> {
  let mut buf = BytesMut::with_capacity(4096);
  loop {
    // 1. 读,直到缓冲区里有一个完整的头
    let (head, head_len) = loop {
      match parse_head(&buf) {
        Ok(Some(parsed)) => break parsed,
        Ok(None) if buf.len() >= MAX_HEAD => return reject(&mut stream, 431, id).await,
        Ok(None) => {}
        Err(status) => return reject(&mut stream, status, id).await,
      }
      if read_more(&mut stream, &mut buf, id).await? == 0 {
        // 两个请求之间读到 EOF 是正常关闭;头读到一半就不是
        println!("[srv c{id}] eof, {} bytes of partial head", buf.len());
        return Ok(());
      }
    };
    if head.chunked {
      // 打出客户端发来的字节,然后放弃:这里没有 chunked 解码器
      println!("[srv c{id}] buffer: {}", escape(&buf));
      return reject(&mut stream, 501, id).await;
    }
    buf.advance(head_len);
    if head.content_length > MAX_BODY {
      return reject(&mut stream, 413, id).await;
    }

    // 2. body:正好 Content-Length 个字节,后面的是下一个请求
    while buf.len() < head.content_length {
      if read_more(&mut stream, &mut buf, id).await? == 0 {
        return Err(io::ErrorKind::UnexpectedEof.into());
      }
    }
    let body = buf.split_to(head.content_length);

    // 3. 调「服务」,拼好整个响应一次写出
    let resp = handle(&head, &body);
    let mut out = format!("HTTP/1.1 200 OK\r\ncontent-length: {}\r\n", resp.len());
    if !head.keep_alive {
      out.push_str("connection: close\r\n");
    } else if head.minor == 0 {
      out.push_str("connection: keep-alive\r\n");
    }
    out.push_str("\r\n");
    out.push_str(&resp);
    stream.write_all(out.as_bytes()).await?;
    // ...(打印一行日志)

    // 4. keep-alive:再转一圈,缓冲区里可能已经躺着下一个请求
    if !head.keep_alive {
      return Ok(());
    }
  }
}

read_more 先 buf.reserve(4096) 再 stream.read_buf(&mut buf),打印读到几个字节。reject 写一个 content-length: 0、connection: close 的响应。handle 把方法、路径、版本和 body 拼成一行文本当响应 body。这三个函数从略。

四步各对应 hyper 里的一处:

minihyper
第 1 步:parse_head 失败就 read_moreConn::poll_read_head 调 Buffered::parse,头不全就 poll_read_from_io,见读写缓冲
第 2 步:凑够 content_length 再 split_toDecoder 的 Length 分支,读到多少交多少,见 body 的解码与编码
第 3 步:同步调 handle,write_allServer::recv_msg 调服务、存 future;poll_write 等响应、编码头、按帧写 body,见 Dispatcher
第 4 步:keep_alive 为真就回到循环开头State::try_keep_alive:读写两端都到 KeepAlive 才回 Init,见 Conn 的读写状态机

mini 的循环本身就是串行的:上一个响应写完之前,不会去解析下一个请求。hyper 也一样,只是它靠 Conn 的读写状态来保证,见 Dispatcher 的「同一条连接上的请求严格串行」。

用 hyper 客户端验证

服务端绑在 127.0.0.1:0,测试程序先用 hyper 的 client::conn::http1::handshake 连上去,在同一条连接上发两个请求:

type Body = BoxBody<Bytes, Infallible>;

async fn hyper_client(addr: SocketAddr, reqs: Vec<Request<Body>>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
  let stream = TcpStream::connect(addr).await?;
  let (mut sender, conn) = hyper::client::conn::http1::handshake::<_, Body>(TokioIo::new(stream)).await?;
  let conn_task = tokio::spawn(conn);
  for req in reqs {
    let line = format!("{} {}", req.method(), req.uri());
    if let Err(e) = sender.ready().await {
      println!("[cli] {line}: not sent, {e}");
      break;
    }
    match sender.send_request(req).await {
      // Ok:打印状态码、响应头和收齐的 body;Err:打印错误
      // ...
    }
  }
  drop(sender);
  let res = conn_task.await?;
  tokio::time::sleep(Duration::from_millis(20)).await; // 等服务端那一行日志先打出来
  println!("[cli] connection task: {res:?}");
  Ok(())
}

// main 里:
let reqs = vec![
  Request::get("/hello").header("host", "mini").body(Full::new(Bytes::new()).boxed())?,
  Request::post("/echo").header("host", "mini").body(Full::new(Bytes::from("ping")).boxed())?,
];
hyper_client(addr, reqs).await?;

下面几段输出都出自同一次运行(M2 Max、rustc 1.99.0、--release),按顺序截成几段。连跑 3 次,输出逐字相同。这一段是 hyper 客户端:

== hyper client (client::conn::http1), two requests on one connection
[srv c1] read 35 bytes
[srv c1] GET /hello -> 200, keep-alive, 0 bytes left in buffer
[cli] GET /hello -> 200 OK [content-length: 36] b"GET /hello HTTP/1.1, body 0 bytes: \n"
[srv c1] read 58 bytes
[srv c1] POST /echo -> 200, keep-alive, 0 bytes left in buffer
[cli] POST /echo -> 200 OK [content-length: 40] b"POST /echo HTTP/1.1, body 4 bytes: ping\n"
[srv c1] read 0 bytes
[srv c1] eof, 0 bytes of partial head
[cli] connection task: Ok(())
  • 两个请求走的是同一条连接(c1),keep-alive 生效。
  • 35 字节是 GET /hello HTTP/1.1\r\nhost: mini\r\n\r\n;58 字节是 POST 的头加 4 字节 body。Full 的长度已知,hyper 客户端自己加了 content-length: 4,头和 body 一次写出,服务端一次读全。
  • drop(sender) 后客户端的 Connection 结束并关掉 socket,服务端在两个请求之间读到 0,正常退出。
❗IMPORTANT
动脑筋:body 长度未知时会怎样?

最后一个测试用同一个 hyper_client 发一个请求,body 换成只有一帧的 StreamBody:

let streamed = StreamBody::new(futures::stream::iter([Ok::<_, Infallible>(Frame::data(Bytes::from("streamed")))]));
let reqs = vec![Request::post("/stream").header("host", "mini").body(streamed.boxed())?];
hyper_client(addr, reqs).await?;

hyper 客户端会发出什么样的请求头?[cli] 那一行打印的是一个响应还是一个错误?连接任务的结果是什么?答案在本页末尾。

用手写字节验证

raw 连上服务端,把几段字节依次写出去(两段之间睡 50ms),然后一直读到 EOF,把响应按行打印(\r、\n 转义显示):

async fn raw(addr: SocketAddr, label: &str, writes: &[&[u8]]) -> io::Result<()> {
  println!("== raw: {label}");
  let mut stream = TcpStream::connect(addr).await?;
  for (i, chunk) in writes.iter().enumerate() {
    if i > 0 {
      tokio::time::sleep(Duration::from_millis(50)).await;
    }
    stream.write_all(chunk).await?;
  }
  let mut out = Vec::new();
  stream.read_to_end(&mut out).await?;
  // ...(逐行打印)
  Ok(())
}

pipelining:三个请求一次写出

raw(
  addr,
  "three pipelined requests in one write",
  &[b"GET /a HTTP/1.1\r\nHost: x\r\n\r\nPOST /b HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhelloGET /c HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"],
)
.await?;
== raw: three pipelined requests in one write
[srv c2] read 128 bytes
[srv c2] GET /a -> 200, keep-alive, 100 bytes left in buffer
[srv c2] POST /b -> 200, keep-alive, 47 bytes left in buffer
[srv c2] GET /c -> 200, close, 0 bytes left in buffer
[raw] HTTP/1.1 200 OK\r\n
[raw] content-length: 32\r\n
[raw] \r\n
[raw] GET /a HTTP/1.1, body 0 bytes: \n
[raw] HTTP/1.1 200 OK\r\n
[raw] content-length: 38\r\n
[raw] \r\n
[raw] POST /b HTTP/1.1, body 5 bytes: hello\n
[raw] HTTP/1.1 200 OK\r\n
[raw] content-length: 32\r\n
[raw] connection: close\r\n
[raw] \r\n
[raw] GET /c HTTP/1.1, body 0 bytes: \n
[raw] eof after 238 bytes

一次读进 128 字节,之后再没读过。/a 处理完,缓冲区剩 100 字节,第 4 步回到循环开头,parse_head 直接在剩下的字节上成功,不用读。/b 的 body hello 紧挨着 /c 的请求行,split_to(5) 正好在边界上切开,剩 47 字节就是 /c。/c 带 Connection: close,响应带上同样的头,写完关连接,客户端读到 EOF。三个响应各 write_all 一次;hyper 默认也是每个响应 flush 一次,打开 pipeline_flush(true) 才会把它们攒成一次写,见读写缓冲。

头被拆成两次写,body 又拆到第三次

raw(
  addr,
  "head split across two writes, body across the next",
  &[b"POST /split HTTP/1.1\r\nHo", b"st: x\r\nContent-Length: 10\r\nConnection: close\r\n\r\n01234", b"56789"],
)
.await?;
== raw: head split across two writes, body across the next
[srv c3] read 24 bytes
[srv c3] read 53 bytes
[srv c3] read 5 bytes
[srv c3] POST /split -> 200, close, 0 bytes left in buffer
[raw] HTTP/1.1 200 OK\r\n
[raw] content-length: 48\r\n
[raw] connection: close\r\n
[raw] \r\n
[raw] POST /split HTTP/1.1, body 10 bytes: 0123456789\n
[raw] eof after 106 bytes

第一次读到 24 字节,停在头名 Ho 的中间,httparse 返回 Partial,再读。第二次的 53 字节补全了头,还带来 body 的前 5 字节;Content-Length 是 10,第 2 步的 while 再读一次,凑够才调 handle。头在哪里被切开都没关系:每次读完都从缓冲区开头重新解析一遍。

HTTP/1.0、两行 Connection、坏的请求头

raw(addr, "HTTP/1.0, no keep-alive", &[b"GET /old HTTP/1.0\r\n\r\n"]).await?;
raw(
  addr,
  "Connection: close, then Connection: keep-alive on a second line",
  &[b"GET /two-lines HTTP/1.1\r\nHost: x\r\nConnection: close\r\nConnection: keep-alive\r\n\r\n"],
)
.await?;
raw(addr, "malformed header", &[b"GET / HTTP/1.1\r\nHost x\r\n\r\n"]).await?;
== raw: HTTP/1.0, no keep-alive
[srv c4] read 21 bytes
[srv c4] GET /old -> 200, close, 0 bytes left in buffer
[raw] HTTP/1.1 200 OK\r\n
[raw] content-length: 34\r\n
[raw] connection: close\r\n
[raw] \r\n
[raw] GET /old HTTP/1.0, body 0 bytes: \n
[raw] eof after 92 bytes
== raw: Connection: close, then Connection: keep-alive on a second line
[srv c5] read 79 bytes
[srv c5] GET /two-lines -> 200, close, 0 bytes left in buffer
[raw] HTTP/1.1 200 OK\r\n
[raw] content-length: 40\r\n
[raw] connection: close\r\n
[raw] \r\n
[raw] GET /two-lines HTTP/1.1, body 0 bytes: \n
[raw] eof after 98 bytes
== raw: malformed header
[srv c6] read 26 bytes
[srv c6] reject 400, close
[raw] HTTP/1.1 400 Bad Request\r\n
[raw] content-length: 0\r\n
[raw] connection: close\r\n
[raw] \r\n
[raw] eof after 66 bytes
  • HTTP/1.0 没带 Connection: keep-alive,默认关闭,响应写完就关。
  • 两行 Connection,先 close 后 keep-alive:按 close 处理。这就是上面冷知识里 hyper 1.12.0 修掉的那种输入。
  • Host x 少了冒号,httparse 返回错误,回 400 并关连接。hyper 对解析错误回什么,见解析与编码。

和 hyper 比:少了什么

能跑通上面这些,离 hyper 还差得远。下表每一行是 mini 缺的一样东西,最后一列是本卷讲它的那一页:

缺的mini 现在怎样hyper 怎么做见
chunked 请求体见到 Transfer-Encoding 就回 501Decoder 的 Chunked 分支,逐字节的 ChunkedState 状态机,扩展和 trailers 都有上限body 的解码与编码
流式响应体响应是一个 String,必须先算好长度Encoder 按 body 的 size_hint 选 Content-Length 或 chunked,一帧一帧写body 的解码与编码
Expect: 100-continue不认识,直接等 body;客户端若要等到 100 才发 body,两边就互相等着读端进 Reading::Continue,服务第一次读 body 时才写 HTTP/1.1 100 ContinueConn 的读写状态机
协议升级Upgrade 和 CONNECT 当普通请求处理,之后继续按 HTTP 解析认出升级请求,写完 101 后交出 IO 和多读的字节协议升级
读头超时没有任何计时器,发半个头就能让任务和连接一直挂着poll_read_head 里的 header_read_timeout,默认 30 秒,前提是给了 timerExecutor 与 Timer、常见坑
自适应读缓冲每次固定 reserve(4096)ReadStrategy::Adaptive:从 8 KiB 起按 2 的幂涨落读写缓冲
大小上限头 8 KiB、32 个头、body 1 MiB,写死读缓冲上限 max_buf_size(约 408 KiB)、max_headers(默认 100)、max_header_size;body 不设上限,交给用户(如 Limited)读写缓冲、http-body-util、常见坑
头没收全时的重复解析每读一次都从头完整解析一遍,头被拆得越碎越费有过不完整的读之后,先用 is_complete_fast 只找 \r\n\r\n,找到才完整解析解析与编码
pipelining 的 flush每个响应 write_all 一次默认也是每个响应 flush 一次;pipeline_flush(true) 时读缓冲里还有请求就先不 flush,攒成一次写读写缓冲
一直有活时让出自己不做:缓冲区里有几个请求就连着处理几个,什么时候让出全看 tokiopoll_loop 最多转 16 圈就唤醒自己、让出Dispatcher
body 的背压整个 body 读进内存才调处理函数读完头就调服务,body 经过只有一个槽的通道交给 Incoming,槽满了就不再从 socket 读Incoming 与 body 通道
头的零拷贝方法和路径拷成 String头值和 URI 都是同一个 Bytes 的切片解析与编码
优雅停机没有graceful_shutdown:做完手上的请求再关服务端 Builder 与 Connection、优雅停机

前三行决定 mini 能不能和真实客户端互通,动脑筋那个实验就是一例。中间几行是防御:没有读头超时和大小上限,一个慢的或恶意的客户端就能占住连接和内存。最后几行关系到吞吐和公平性。hyper 的 proto/h1 去掉测试约 5500 行(服务端和客户端共用),多出来的大部分花在这张表上。

动脑筋答案

同一次运行的最后一段:

== hyper client, body without a known length
[srv c7] read 83 bytes
[srv c7] buffer: POST /stream HTTP/1.1\r\nhost: mini\r\ntransfer-encoding: chunked\r\n\r\n8\r\nstreamed\r\n0\r\n\r\n
[srv c7] reject 501, close
[cli] POST /stream -> 501 Not Implemented [content-length: 0, connection: close] b""
[cli] connection task: Ok(())
  • 请求头:StreamBody 不知道总长度,hyper 客户端发 transfer-encoding: chunked,body 编码成 8\r\nstreamed\r\n 加结尾的 0\r\n\r\n。头和整个 body 一次到达服务端,共 83 字节。
  • [cli] 那一行是一个响应,状态码 501,不是错误。对 hyper 客户端来说,服务端拒绝也是一个格式正确的 HTTP 响应,send_request 照常返回 Ok。
  • 连接任务是 Ok(()):响应带 connection: close,hyper 客户端读完这个响应就不再复用连接,Connection future 正常结束。
  • 这也说明 mini 的局限会在哪里撞上:任何不知道长度的请求体,hyper 客户端都会用 chunked 发。

小结

  • 一个能和 hyper 客户端互通的 HTTP/1.1 服务端,核心是一个循环:读进缓冲 → httparse 解析,Partial 就再读 → 按 Content-Length 切 body → 写响应 → keep-alive 就回到缓冲区开头。
  • 缓冲区跨请求保留,pipelining 和拆开的头都自然处理:前者不用再读,后者多读几次。
  • keep-alive 的默认值看版本,Connection 要看完所有行再定,close 优先;hyper 在 1.12.0 才修好这个顺序问题(#4201)。
  • Content-Length 多个值不同就拒绝,Transfer-Encoding 没有解码器就别装作支持。
  • 和 hyper 相比,缺的是 chunked、Expect、升级这些互通问题,读头超时和大小上限这些防御,以及自适应缓冲、body 背压、让出这些吞吐与公平性问题。

💬 关联讨论

发起讨论
加载中...