Dev Study
← Rust で小さなブラウザを作る コース

8. レスポンスの分解 — status-line、field-line、そして本文の長さをどう決めるか

レスポンスもテキストで、1 行目が (HTTP-version status-code SP reason-phrase)、の行、空行、です。分解の難所は 1 つだけで、「本文はどこで終わるか」です。

9112 §6.3 は本文の長さの決め方を順番つきで定めています。この実装が使うのは 3 つ。 があれば chunk を解いて長さを知る( より優先)。Content-Length があればそのバイト数。どちらも無ければ接続が閉じるまでが本文。前のレッスンで を付けたので、3 つ目の「閉じるまで」が常に使えますが、本物のサーバーは chunked で返してくることが多いので(example.com がそうです)、chunk の復号も入れてあります。

行末は が正式ですが、RFC 9112 §2.2 は「受信側は 単独を行末として認識してよい(先行する CR は無視してよい)」としています。参照実装は、ヘッダー部と本文の境目を CRLF CRLF と LF LF の早い方で見つけ、ヘッダーの行は で読みます(lines() は \r\n も \n も行末として扱います)。本文は手を加えずに切り出します。chunk の長さはバイト数なので、本文の中の CRLF を勝手に LF に直すと長さが狂います。

ヘッダー名は大文字小文字を区別しません(RFC 9110 §5.1)。だから header("content-type") でも Content-Type が引けるように、eq_ignore_ascii_case で比べます。status-code は 3 桁の数字で、それ以外なら InvalidStatusLine、":" の無いヘッダー行なら InvalidHeader、gzip のような知らない なら UnsupportedBody を返します。

本物のブラウザでは: ではヘッダーの分解を HttpStreamParser が行い、結果は HttpResponseHeaders に入ります。chunked の復元は http/http_chunked_decoder.cc で、ファイル先頭に「Derived from: mozilla/netwerk/protocol/http/src/nsHttpChunkedDecoder.h」とあり、Firefox の nsHttpChunkedDecoder.cpp と同じ祖先です。この回の decode_chunked() は、その 2 つと同じ RFC 9112 の を読んで書いたものです。本文はからへ のデータパイプで 64KB ずつ渡されます。1 回の () で全部読むこの実装との差は、届いた分から順にへ流せるかどうかです。

開発のどこに活かすか: 「本文の長さは 3 通りの決め方があり、優先順位がある」ことを知らないと、自作のクライアントやプロキシが chunked の応答で固まります。逆に、API サーバーを書く側では Content-Length を正しく出すか chunked にするかで、クライアントの読み終わり方が変わります。ヘッダー名の大文字小文字を区別しない比較も、自前でヘッダーを触るコードで毎回出てくる約束です。

点線の付いた語はタップすると説明が出ます。用語集を開く →

サンプルコード(フレームワーク環境が必要なため表示のみ)

// src/http.rs の分解の部分(HttpResponse::parse と decode_chunked)
pub fn parse(raw: &str) -> Result<HttpResponse, HttpError> {
    if raw.is_empty() {
        return Err(HttpError::EmptyResponse);
    }

    // ヘッダー部と本文の境目 = 最初の空行(CRLF CRLF、または LF LF)
    let (head, body) = split_head_body(raw);

    let mut lines = head.lines(); // lines() は "\r\n" も "\n" も行末として扱う
    let status_line = lines.next().unwrap_or("");

    // status-line = HTTP-version SP status-code SP [ reason-phrase ]
    let mut parts = status_line.splitn(3, ' ');
    let version = parts.next().unwrap_or("");
    let code = parts.next().unwrap_or("");
    let reason_phrase = parts.next().unwrap_or("").trim_end().to_string();
    if !version.starts_with("HTTP/") || code.len() != 3 {
        return Err(HttpError::InvalidStatusLine(status_line.to_string()));
    }
    let status_code = code
        .parse::<u16>()
        .map_err(|_| HttpError::InvalidStatusLine(status_line.to_string()))?;

    // field-line = field-name ":" OWS field-value OWS
    let mut headers = Vec::new();
    for line in lines {
        if line.trim().is_empty() {
            continue;
        }
        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 response = HttpResponse {
        version: version.to_string(),
        status_code,
        reason_phrase,
        headers,
        body: String::new(),
    };

    // 本文の長さの決め方(RFC 9112 §6.3 の順番): Transfer-Encoding が Content-Length より優先
    response.body = match response.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 response.header("Content-Length").and_then(|v| v.trim().parse::<usize>().ok()) {
            // Content-Length があればそのバイト数まで(文字の途中で切れるなら全部)
            Some(len) => body.get(..len).unwrap_or(body).to_string(),
            // どちらも無ければ「接続が閉じるまで」= 受け取った全部
            None => body.to_string(),
        },
    };
    Ok(response)
}

fn decode_chunked(body: &str) -> Result<String, HttpError> {
    let mut out = String::new();
    let mut rest = body;
    loop {
        let (size_line, after) = rest
            .split_once('\n')
            .ok_or_else(|| HttpError::UnsupportedBody("chunk-size の行がありません".into()))?;
        let size_hex = size_line.split(';').next().unwrap_or("").trim();
        let size = usize::from_str_radix(size_hex, 16)
            .map_err(|_| HttpError::UnsupportedBody(format!("chunk-size が 16 進でない: {size_hex:?}")))?;
        if size == 0 {
            break; // last-chunk。trailer は読まない
        }
        let data = after
            .get(..size)
            .ok_or_else(|| HttpError::UnsupportedBody("chunk-data が足りません".into()))?;
        out.push_str(data);
        // chunk-data の後ろの CRLF(または LF)を飛ばす
        rest = &after[size..];
        rest = rest.strip_prefix("\r\n").or_else(|| rest.strip_prefix('\n')).unwrap_or(rest);
    }
    Ok(out)
}

手元で動かす(配布物)

図やコードに出てくる用語(タップで説明)

サンプルコードで身につける(3本) →

学んだ概念を、素朴な例から実務寄りの例まで3つの具体例で確認します。

公式ドキュメントで詳しく ↗