从零写一个 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 的解码与编码。
问: 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);
}
}
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 里的一处:
| mini | hyper |
|---|---|
第 1 步:parse_head 失败就 read_more | Conn::poll_read_head 调 Buffered::parse,头不全就 poll_read_from_io,见读写缓冲 |
第 2 步:凑够 content_length 再 split_to | Decoder 的 Length 分支,读到多少交多少,见 body 的解码与编码 |
第 3 步:同步调 handle,write_all | Server::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,正常退出。
最后一个测试用同一个 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 就回 501 | Decoder 的 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 Continue | Conn 的读写状态机 |
| 协议升级 | Upgrade 和 CONNECT 当普通请求处理,之后继续按 HTTP 解析 | 认出升级请求,写完 101 后交出 IO 和多读的字节 | 协议升级 |
| 读头超时 | 没有任何计时器,发半个头就能让任务和连接一直挂着 | poll_read_head 里的 header_read_timeout,默认 30 秒,前提是给了 timer | Executor 与 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,攒成一次写 | 读写缓冲 |
| 一直有活时让出 | 自己不做:缓冲区里有几个请求就连着处理几个,什么时候让出全看 tokio | poll_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 客户端读完这个响应就不再复用连接,Connectionfuture 正常结束。 - 这也说明 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 背压、让出这些吞吐与公平性问题。