作って学ぶブラウザの仕組み 第3章
HTTP とは
いろいろ書いてあるが、みなさんわかると思うのでスキップ。。。
HTTPの構成
HTTPメッセージは大きく3つの要素に分けることができる。
- スタートライン
- ヘッダ
- ボディ
スタートラインはリクエストの場合はリクエストライン、レスポンスの場合はステータスラインと呼ばれる。
リクエストラインはよくみられる
GET /index.html HTTP/1.1
レスポンスラインは
HTTP/1.1 200 OK
ヘッダはリクエストとレスポンスの2行目以降、改行だけの行までの部分。
HTTP/1.1からは1つのIPアドレスとポート番号で複数のWebサイトを運用しているときのためにHostヘッダを含める必要がある。(e.g.) example.com, example.orgが同じIPアドレスで運用されているときなど
Host: example.com
ボディはヘッダの改行以降の部分。
GETリクエストは空であることが多く、POSTとPUTはリクエストの更新内容が含まれる。レスポンスにはHTMLなどのリソース内容が含まれる。
HTTP Clientの実装
環境
- 2019 Intel Macbook
- RustRover 使ってみました
サブプロジェクトの作成
net/wasabiおサブプロジェクトのCargo.tomlを変更する。
net/wasabi/Cargo.toml
[package]
aname = "net_wasabi_mitani"
version = "0.1.0"
edition = "2021"
[dependencies]
saba_core = { path = "../../saba_core" }
noli = { git = "https://github.com/hikalium/wasabi.git", branch = "for_saba" }
次にルートディレクトのCargo.tomlを変更する。
workspace = { members = ["saba_core", "net/wasabi"] }
...
[features]
default = ["wasabi"]
wasabi = ["dep:net_wasabi", "dep:noli"]
[[bin]]
name = "saba"
path = "src/main.rs"
required-features = ["wasabi"]
CargoのFeatures機能は条件付きコンパイルや依存関係の切り替えを行うメカニズム。net_wasabi, noliはデフォルトであるwasabiのときに使用されるようになっている。
自分はgradleを使うのですが、gradleだとシステムプロパティを設定してそれでコンパイルするかの条件分岐をビルドファイルに書くことになるらしい。
[[bin]]セクションでバイナリターゲットを設定することができる。sabaというバイナリファイルをsrc/main.rsから作成することになっている。パスを指定すればmitani.rsにファイル名を変えることも可能。
[[bin]]
name = "mitani"
path = "src/mitani.rs"
リクエストの構築
以下をhttp.rsに追加することによって、httpモジュールをほかのプロジェクトから使用できるようになる。
pub mod http;
クライアントの作成
HttpClientにgetメソッドを定義し、GETリクエストを送信する。getメソッドはホスト名、ポート番号、そしてパス名を引数にとり、HttpResponseを返す。
まずは noliライブラリのlookup_hostメソッドを使ってホストからIPアドレスを取得する(これは正引きというそう)。一つのドメインに対して複数のIPアドレスが存在することがあるので、返り値はVectorになっている。ロードバランサーや複数のサーバが同じドメイン名を持っている場合などにありえる。
impl HttpClient {
pub fn new() -> Self {
Self {}
}
pub fn get(&self, host: String, port: u16, path: String) -> Result<HttpResponse, Error> {
let ips = match lookup_host(&host) {
Ok(ips) => ips,
Err(e) => {
return Err(Error::Network(format!(
"Failed to find IP addresses: {:#?}",
e
)))
}
};
if ips.len() < 1 {
return Err(Error::Network("Failed to find IP addresses".to_string()));
}
ちなみに lookup_hostの内部実装は以下のような感じ。Api::nslookupの内部を探したが見つからなかった(IDEからも飛べなかった)。
pub fn lookup_host(host: &str) -> Result<Vec<IpV4Addr>> {
let mut result = [RawIpV4Addr::default(); 4];
match Api::nslookup(host, &mut result) {
n if n >= 0 => Ok(result
.iter()
.take(n as usize)
.map(|e| IpV4Addr::new(*e))
.collect()),
-1 => Err(Error::Failed("RESOLUTION_FAILED")),
-2 => Err(Error::Failed("NXDOMAIN")),
_ => Err(Error::Failed("UNDEFINED")),
}
}
...
pub type RawIpV4Addr = [u8; 4];
ソケットアドレスとストリームの構築
ソケットアドレスを構築する。ソケットアドレスはIPアドレス+ポート(80番)で構成されるアドレス。そしてそのアドレスを利用してTCPのストリームを構築する。データは小さなパケットに分割され、受信側では連続したデータとして復元される。noliライブラリがまたAPIを提供してくれている。
let socket_addr: SocketAddr = (ips[0], port).into();
let mut stream = match TcpStream::connect(socket_addr) {
Ok(stream) => stream,
Err(_) => {
return Err(Error::Network(
"Failed to connect to TCP stream".to_string(),
))
}
};
into トレイトは型変換のために使える関数。
noliのTcpStream::connect
pub fn connect(sa: SocketAddr) -> Result<Self> {
let handle = Api::open_tcp_socket(sa.addr.0, sa.port);
if handle >= 0 {
Ok(Self {
sock_addr: sa,
handle,
})
} else {
Err(Error::Failed("Failed to open TCP socket"))
}
}
リクエストラインとヘッダの構築
ストリームに送信するデータを構築していく。最初の3行はリクエストラインの作成。これらはWhitespaceでつなげる。ヘッダはHost、Accept、Connectionヘッダを追加する。Hostは省略できず、Accept、Conncetionは省略可能。Acceptは text/htmlを指定している。ConnectionはKeep aliveを使用せず、コネクションを毎回切りたいのでcloseを指定する。
let mut request = String::from("GET /");
request.push_str(&path);
request.push_str(" HTTP/1.1\n");
// ヘッダの追加
request.push_str("Host: "); // 省略できない
request.push_str(&host);
request.push('\n');
request.push_str("Accept: text/html\n");
request.push_str("Connection: close\n");
request.push('\n');
リクエストの送信
writeメソッドでリクエストを送信する。返されるバイト数は使わないので、_で始まる変数名にしておくことでコンパイラWarningを避ける。
let _bytes_written = match stream.write(request.as_bytes()) {
Ok(bytes) => bytes,
Err(_) => {
return Err(Error::Network(
"Failed to send a request to TCP stream".to_string(),
))
}
};
レスポンスの処理
readメソッドを使ってレスポンスを読み込む。ストリームのデータがなくなるまでループを回し、Rustによって提供されているextend_from_sliceメソッドを使ってデータを繋ぎあわせる。
let mut received = Vec::new();
loop {
let mut buf = [0u8; 4096];
let bytes_read = match stream.read(&mut buf) {
Ok(bytes) => bytes,
Err(_) => {
return Err(Error::Network(
"Failed to receive a request from TCP stream".to_string(),
))
}
};
if bytes_read == 0 {
break;
}
received.extend_from_slice(&buf[..bytes_read]);
}
HTTPレスポンスの構築
HTTPレスポンスはUTF8のバイト列なので、from_utf8関数を使用してstr型の文字列に変換する。
match core::str::from_utf8(&received) {
Ok(response) => HttpResponse::new(response.to_string()),
Err(e) => Err(Error::Network(format!("Invalid received response: {}", e))),
}
HttpResponseはどのプラットフォームでも使えるため、saba_coreディレクトリ下に配置する。
lib.rsに pub mod http
を追加して外部からも使えるようにする。
レスポンス構造体にバージョン番号、ステータスコード、ステータスコードの理由のフレーズ、ヘッダのベクタそしてボディを入れる。
#[derive(Debug, Clone)]
pub struct HttpResponse {
version: String,
status_code: u32,
reason: String,
headers: Vec<Header>,
body: String,
}
Header構造体はヘッダの名前とそれに対応するValueを持つ
#[derive(Debug, Clone)]
pub struct Header {
name: String,
value: String,
}
ここで他の場所でも使えるエラー構造体を定義しておく
lib.rsにerrorモジュールを追加し、Error列挙型を以下のように定義する。
use alloc::string::String;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
Network(String),
UnexpectedInput(String),
InvalidUI(String),
Other(String),
}
文字列の前処理を行う。
キャリッジリターンと改行シーケンス(\r\n)を単一の改行に変換し、一貫した行末を保証する。
そして、ステータスラインの分割を行い、status_line、remainingにそれぞれステータスラインとヘッダ・ボディを格納する。
let preprocessed_response = raw_response.trim_start().replace("\r\n", "\n");
let (status_line, remaining) = match preprocessed_response.split_once('\n') {
Some((s, r)) => (s, r),
None => {
return Err(Error::Network(format!(
"invalid http response: {}",
preprocessed_response
)))
}
};
残りのremainingからヘッダとボディを2つの連続した改行で分割し、分割が成功した場合は前半部分と後半部分を処理して、それぞれheader, bodyに割り当てる。
let (headers, body) = match remaining.split_once("\n\n") {
Some((h, b)) => {
let mut headers = Vec::new();
for header in h.split('\n') {
let splitted_header: Vec<&str> = header.splitn(2, ':').collect();
headers.push(Header::new(
String::from(splitted_header[0].trim()),
String::from(splitted_header[1].trim()),
));
}
(headers, b)
}
None => (Vec::new(), remaining),
};
そしてステータスラインをWhitespaceで分割し、HttpResponse構造体にセットする。
let statuses: Vec<&str> = status_line.split(' ').collect();
Ok(Self {
version: statuses[0].to_string(),
status_code: statuses[1].parse().unwrap_or(404),
reason: statuses[2].to_string(),
headers,
body: body.to_string(),
})
HttpResponse構造体のフィールドはすべてプライベートなので、ゲッタメソッドを追加する。
pub fn version(&self) -> String {
self.version.clone()
}
pub fn status_code(&self) -> u32 {
self.status_code
}
pub fn reason(&self) -> String {
self.reason.clone()
}
pub fn headers(&self) -> Vec<Header> {
self.headers.clone()
}
pub fn body(&self) -> String {
self.body.clone()
}
pub fn header_value(&self, name: &str) -> Result<String, String> {
for h in &self.headers {
if h.name == name {
return Ok(h.value.clone());
}
}
Err(format!("failed to find {} in headers", name))
}
ユニットテスト
HttpResponseが正しく構築されているように確認するユニットテスト。
以下の4つの場合をテストする:
1.ステータスラインのみ
2. ヘッダのみ
3. ヘッダが2つ存在する時
4. ボディを含むとき
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_status_line_only() {
let raw = "HTTP/1.1 200 OK\n\n".to_string();
let res = HttpResponse::new(raw).expect("failed to parse http response");
assert_eq!(res.version(), "HTTP/1.1");
assert_eq!(res.status_code(), 200);
assert_eq!(res.reason(), "OK");
}
#[test]
fn test_one_header() {
let raw = "HTTP/1.1 200 OK\nDate:xx xx xx\n\n".to_string();
let res = HttpResponse::new(raw).expect("failed to parse http response");
assert_eq!(res.version(), "HTTP/1.1");
assert_eq!(res.status_code(), 200);
assert_eq!(res.reason(), "OK");
assert_eq!(res.header_value("Date"), Ok("xx xx xx".to_string()));
}
#[test]
fn test_two_headers_with_white_space() {
let raw = "HTTP/1.1 200 OK\nDate: xx xx xx\nContent-Length: 42\n\n".to_string();
let res = HttpResponse::new(raw).expect("failed to parse http response");
assert_eq!(res.version(), "HTTP/1.1");
assert_eq!(res.status_code(), 200);
assert_eq!(res.reason(), "OK");
assert_eq!(res.header_value("Date"), Ok("xx xx xx".to_string()));
assert_eq!(res.header_value("Content-Length"), Ok("42".to_string()));
}
#[test]
fn test_body() {
let raw = "HTTP/1.1 200 OK\nDate: xx xx xx\n\nbody message".to_string();
let res = HttpResponse::new(raw).expect("failed to parse http response");
assert_eq!(res.version(), "HTTP/1.1");
assert_eq!(res.status_code(), 200);
assert_eq!(res.reason(), "OK");
assert_eq!(res.header_value("Date"), Ok("xx xx xx".to_string()));
assert_eq!(res.body(), "body message".to_string());
}
}
ステータスラインだけで改行文字がない場合は不正と扱うこともテストする。
#[test]
fn test_invalid() {
let raw = "HTTP/1.1 200 OK".to_string();
assert!(HttpResponse::new(raw).is_err());
}
5つのテストはすべて通りました。
動かしてみる
./run_on_wasabi.sh
以下のような結果がQEMUにてでました
Log
[INFO] os/src/net/manager.rs:262: DHCPACK
[INFO] os/src/net/manager.rs:276: netmask: 255.255.255.0
[INFO] os/src/net/manager.rs:282: router: 10.0.2.2
[INFO] os/src/net/manager.rs:291: dns: 10.0.2.3
[INFO] os/src/cmd.rs:58 : Executing cmd: ["ip"]
netmask: Some(255.255.255.0)
router: Some(10.0.2.2)
dns: Some(8.8.8.8)
[INFO] os/src/cmd.rs:58 : Executing cmd: ["wait_until_dns_ready"]
[INFO] os/src/cmd.rs:94 : DNS server address is set up! ip = 8.8.8.8
[INFO] os/src/executor.rs:147: Task completed: Task(os/src/main.rs:211): Ok(())
pythonコマンドでサーバーを立てます。
python -m http.server 8000
sabaと打ってみました。
sabaとうってみたときのログ
> saba
[INFO] os/src/cmd.rs:58 : Executing cmd: ["saba"]
[INFO] os/src/net/manager.rs:221: socket created: TcpSocket{ state: SynSent }
[INFO] os/src/net/manager.rs:169: dynamic TCP port 49152 is picked
[INFO] os/src/net/tcp.rs:470: Trying to open a socket with 10.0.2.2:8000
[INFO] os/src/net/tcp.rs:285: net: tcp: send: TCP :49152 -> :8000, seq = 1234, ack = 0, flags = 0b0000000000000010 SYN
[INFO] os/src/syscall.rs:171: tx data enqueued. waiting...
[INFO] os/src/net/tcp.rs:306: net: tcp: recv: TCP :8000 -> :49152, seq = 832001, ack = 1235, flags = 0b0000000000010010 SYN ACK
[INFO] os/src/net/tcp.rs:349: net: tcp: recv: TCP connection established
[INFO] os/src/net/tcp.rs:285: net: tcp: send: TCP :49152 -> :8000, seq = 1235, ack = 832002, flags = 0b0000000000010000 ACK
[INFO] os/src/net/tcp.rs:429: Trying to send data [71, 69, 84, 32, 47, 116, 101, 115, 116, 46, 104, 116, 109, 108, 32, 72, 84, 84, 80, 47, 49, 46, 49, 10, 72, 111, 115, 116, 58, 32, 104, 111, 115, 116, 46, 116, 101, 115, 116, 10, 65, 99, 99, 101, 112, 116, 58, 32, 116, 101, 120, 116, 47, 104, 116, 109, 108, 10, 67, 111, 110, 110, 101, 99, 116, 105, 111, 110, 58, 32, 99, 108, 111, 115, 101, 10, 10] to 10.0.2.2:8000
[INFO] os/src/net/tcp.rs:285: net: tcp: send: TCP :49152 -> :8000, seq = 1235, ack = 832002, flags = 0b0000000000010000 ACK
[INFO] os/src/syscall.rs:177: write done
[INFO] os/src/net/tcp.rs:306: net: tcp: recv: TCP :8000 -> :49152, seq = 832002, ack = 1312, flags = 0b0000000000010000 ACK
[INFO] os/src/net/tcp.rs:285: net: tcp: send: TCP :49152 -> :8000, seq = 1312, ack = 832002, flags = 0b0000000000010000 ACK
[INFO] os/src/net/tcp.rs:306: net: tcp: recv: TCP :8000 -> :49152, seq = 832002, ack = 1312, flags = 0b0000000000010000 ACK
[INFO] os/src/net/tcp.rs:285: net: tcp: send: TCP :49152 -> :8000, seq = 1312, ack = 832259, flags = 0b0000000000010000 ACK
[INFO] os/src/net/tcp.rs:306: net: tcp: recv: TCP :8000 -> :49152, seq = 832259, ack = 1312, flags = 0b0000000000010001 FIN ACK
[INFO] os/src/net/tcp.rs:285: net: tcp: send: TCP :49152 -> :8000, seq = 1312, ack = 832260, flags = 0b0000000000010001 FIN ACK
response:
HttpResponse {
version: "HTTP/1.0",
status_code: 200,
reason: "OK",
headers: [
Header {
name: "Server",
value: "SimpleHTTP/0.6 Python/3.7.9",
},
Header {
name: "Date",
value: "Fri, 21 Mar 2025 14:27:05 GMT",
},
Header {
name: "Content-type",
value: "text/html",
},
Header {
name: "Content-Length",
value: "73",
},
Header {
name: "Last-Modified",
value: "Fri, 21 Mar 2025 14:16:08 GMT",
},
],
body: "<html>\n<body>\n <h1>Test Page</h1>\n <p>Hello World!</p>\n</body>\n</html>\n",
}[INFO] os/src/cmd.rs:154: Ok(0)
> [INFO] os/src/net/tcp.rs:306: net: tcp: recv: TCP :8000 -> :49152, seq = 832260, ack = 1313, flags = 0b0000000000010000 ACK
[INFO] os/src/net/tcp.rs:379: net: tcp: recv: TCP connection closed
Discussion