サンプルコードで身につける: レスポンスの分解 — status-line、field-line、そして本文の長さをどう決めるか
解説で学んだ概念を、3つの具体例で確認します。素朴な例から実務寄りの例へと進みます。
1status-line と field-line を手で分ける
レスポンスは最初の空行で部とに分かれます。1 行目の は splitn(3, ' ') で 3 つまで(reason-phrase には空白が入る)、残りの field-line は最初の ':' だけで分けます(Date の値にも ':' が入る)。実行すると version= code=200 reason="OK"、ヘッダー 3 行、本文 "<p>hello</p>" が 12 バイトと出ます。
fn main() {
let raw = "HTTP/1.1 200 OK\r\n\
Content-Type: text/html; charset=utf-8\r\n\
Date: Mon, 13 Oct 2026 09:00:00 GMT\r\n\
Content-Length: 12\r\n\
\r\n\
<p>hello</p>";
// 1. 最初の空行でヘッダー部と本文に分ける
let (head, body) = raw.split_once("\r\n\r\n").expect("空行が無い");
let mut lines = head.lines();
// 2. 1 行目は status-line = HTTP-version SP status-code SP reason-phrase。
// reason-phrase には空白が入る("Not Found")ので、分けるのは 3 つまで
let status_line = lines.next().unwrap();
let mut parts = status_line.splitn(3, ' ');
let version = parts.next().unwrap();
let code: u16 = parts.next().unwrap().parse().expect("status-code が数字でない");
let reason = parts.next().unwrap_or("");
println!("version={version} code={code} reason={reason:?}");
// 3. 残りは field-line = name ":" value。値にも ":" が入る(Date の時刻)ので、分けるのは最初の ":" だけ
for line in lines {
let (name, value) = line.split_once(':').expect("':' が無い");
println!("header {:<14} = {:?}", name.trim(), value.trim());
}
println!("body {body:?} ({} バイト)", body.len());
}
2HttpResponse::parse: 本文の長さと LF 許容
HttpResponse::parse の縮小版で、本文の長さは 9112 6.3 の順で ()を より先に見て、どちらも無ければ受け取った全部とします。行末は が正式ですが 2.2 に従って だけの行も受け付け、header() は名前の大文字小文字を区別しません(RFC 9110 5.1)。実行すると 7 本のうち 3 本が ok(Content-Length 5 で "hello world" が "hello" に切られる、LF だけの 404、chunked が "hello, world" に復元される)、4 本が gzip・壊れたヘッダー・ステータス行無し・空の理由で NG になります。
struct Header { name: String, value: String }
struct HttpResponse {
status_code: u16,
reason_phrase: String,
headers: Vec<Header>,
body: String,
}
enum HttpError {
EmptyResponse,
InvalidStatusLine(String),
InvalidHeader(String),
UnsupportedBody(String),
}
impl HttpResponse {
fn parse(raw: &str) -> Result<HttpResponse, HttpError> {
if raw.is_empty() { return Err(HttpError::EmptyResponse); }
// 行末は CRLF が正式だが、RFC 9112 2.2 は「受信側は LF 単独も行末と認めてよい」とする
let (head, body) = raw.split_once("\r\n\r\n").or_else(|| raw.split_once("\n\n")).unwrap_or((raw, ""));
let mut lines = head.lines(); // lines() は "\r\n" も "\n" も行末として扱う
let status_line = lines.next().unwrap_or("");
let mut parts = status_line.splitn(3, ' ');
let version = parts.next().unwrap_or("");
let code = parts.next().unwrap_or("");
if !version.starts_with("HTTP/") || code.len() != 3 {
return Err(HttpError::InvalidStatusLine(status_line.to_string()));
}
let status_code = code.parse().map_err(|_| HttpError::InvalidStatusLine(status_line.to_string()))?;
let reason_phrase = parts.next().unwrap_or("").to_string();
let mut headers = Vec::new();
for line in lines {
let (name, value) = line.split_once(':').ok_or_else(|| HttpError::InvalidHeader(line.to_string()))?;
headers.push(Header { name: name.trim().to_string(), value: value.trim().to_string() });
}
let mut r = HttpResponse { status_code, reason_phrase, headers, body: String::new() };
// 本文の長さの決め方(RFC 9112 6.3): Transfer-Encoding が Content-Length より優先
r.body = match r.header("Transfer-Encoding") {
Some(te) if te.eq_ignore_ascii_case("chunked") => decode_chunked(body)?,
Some(te) => return Err(HttpError::UnsupportedBody(format!("Transfer-Encoding: {te}"))),
None => match r.header("Content-Length").and_then(|v| v.parse::<usize>().ok()) {
Some(len) => body.get(..len).unwrap_or(body).to_string(), // 宣言されたバイト数まで
None => body.to_string(), // どちらも無ければ「接続が閉じるまで」= 受け取った全部
},
};
Ok(r)
}
/// field-name は大文字小文字を区別しない(RFC 9110 5.1)
fn header(&self, name: &str) -> Option<&str> {
self.headers.iter().find(|h| h.name.eq_ignore_ascii_case(name)).map(|h| h.value.as_str())
}
}
/// chunk = chunk-size(16 進) CRLF chunk-data CRLF の繰り返し。chunk-size が 0 で終わり
fn decode_chunked(mut rest: &str) -> Result<String, HttpError> {
let mut out = String::new();
loop {
let (size_line, after) = rest.split_once('\n').ok_or_else(|| HttpError::UnsupportedBody("chunk-size の行が無い".to_string()))?;
let size = usize::from_str_radix(size_line.trim(), 16)
.map_err(|_| HttpError::UnsupportedBody(format!("chunk-size が 16 進でない: {size_line:?}")))?;
if size == 0 { return Ok(out); }
let data = after.get(..size).ok_or_else(|| HttpError::UnsupportedBody("chunk-data が足りない".to_string()))?;
out.push_str(data);
rest = after[size..].trim_start_matches(['\r', '\n']);
}
}
fn main() {
let samples = [
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello world",
"HTTP/1.1 404 Not Found\nContent-Length: 9\n\nnot found",
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n7\r\n, world\r\n0\r\n\r\n",
"HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip\r\n\r\nx",
"HTTP/1.1 200 OK\r\nbroken header\r\n\r\n",
"<html></html>",
"",
];
for raw in samples.iter() {
match HttpResponse::parse(raw) {
Ok(r) => println!("ok {} {:<9} type={:?} body={:?}", r.status_code, r.reason_phrase, r.header("content-type"), r.body),
Err(HttpError::EmptyResponse) => println!("NG レスポンスが空"),
Err(HttpError::InvalidStatusLine(l)) => println!("NG ステータス行が不正: {l:?}"),
Err(HttpError::InvalidHeader(l)) => println!("NG ヘッダーが不正: {l:?}"),
Err(HttpError::UnsupportedBody(m)) => println!("NG 本文を読めない: {m}"),
}
}
}
3実務: レスポンスのダンプで宣言と実際の長さを比べる
curl -i やプロキシのログに残ったレスポンスを読み、Content-Length の値と本文の実際のバイト数を比べる検査です。Content-Length はバイト数で文字数ではないので「こんにちは」は 15 バイト(5 文字)で一致し、1024 バイト宣言で 22 バイトしか無いダンプは「1002 バイト足りない(途中で切れた可能性)」、5 バイト宣言に 34 バイトあるダンプは「29 バイト余っている(次のレスポンスが続いている)」、chunked は「長さの宣言が無い」、空行の無いものは区切り無しと判定されます。切れたレスポンスの調査で最初に見る数字です。
/// ヘッダー部から Content-Length を探す(名前は大文字小文字を区別しない)
fn content_length(head: &str) -> Option<usize> {
head.lines()
.skip(1)
.filter_map(|l| l.split_once(':'))
.find(|(name, _)| name.trim().eq_ignore_ascii_case("content-length"))
.and_then(|(_, v)| v.trim().parse().ok())
}
/// curl -i やプロキシのログに残ったレスポンスのダンプを 1 件検査する
fn inspect(label: &str, raw: &str) {
let (head, body) = match raw.split_once("\r\n\r\n") {
Some(x) => x,
None => {
println!("[{label}] ヘッダーと本文の区切り(空行)が無い");
return;
}
};
let status = head.lines().next().unwrap_or("");
let declared = content_length(head);
let actual = body.len(); // バイト数。文字数(chars().count())ではない
print!("[{label}] {status} / 宣言 {declared:?} / 実際 {actual} バイト({} 文字) -> ", body.chars().count());
match declared {
None => println!("長さの宣言が無い。chunked か、接続が閉じるまでが本文"),
Some(n) if n == actual => println!("一致"),
Some(n) if n > actual => println!("{} バイト足りない。途中で接続が切れた可能性", n - actual),
Some(n) => println!("{} バイト余っている。次のレスポンスが続いている(Keep-Alive)か、宣言が間違い", actual - n),
}
}
fn main() {
inspect(
"日本語の本文",
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: 15\r\n\r\nこんにちは",
);
inspect(
"途中で切れた",
"HTTP/1.1 200 OK\r\nContent-Length: 1024\r\n\r\n<html><body><p>Loading",
);
inspect(
"2 つ目がくっついた",
"HTTP/1.1 200 OK\r\ncontent-length: 5\r\n\r\nhelloHTTP/1.1 304 Not Modified\r\n\r\n",
);
inspect(
"chunked",
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n",
);
inspect("区切りが無い", "HTTP/1.1 200 OK\nContent-Length: 0\n");
}