シングルスレッドのWebサーバーを構築する
まずは、シングルスレッドのWebサーバーを動かすところから始めましょう。始める前に、 Webサーバーを構築する際に関係するプロトコルの概要を簡単に見ておきます。これらの プロトコルの詳細はこの本の範囲を超えていますが、概要を短く確認するだけでも必要な 情報は得られます。
Webサーバーに関係する主要なプロトコルは、ハイパーテキスト転送プロトコル (HTTP) と 伝送制御プロトコル (TCP) の2つです。どちらのプロトコルも リクエストレスポンス 型のプロトコルであり、つまり クライアント がリクエストを 開始し、サーバー がそのリクエストを待ち受けてクライアントにレスポンスを返します。 それらのリクエストとレスポンスの内容は、これらのプロトコルによって定義されています。
TCP は、情報があるサーバーから別のサーバーへどのように届くかの詳細を記述する、 より低レベルのプロトコルですが、その情報が何であるかは規定しません。HTTP は TCP の 上に構築され、リクエストとレスポンスの内容を定義します。技術的には HTTP をほかの プロトコルと組み合わせて使うことも可能ですが、大多数のケースでは HTTP は TCP 上で データを送信します。ここでは TCP と HTTP のリクエストおよびレスポンスの生の バイト列を扱います。
TCP接続を待ち受ける
Webサーバーは TCP 接続を待ち受ける必要があるため、まずはそこから取り組みます。
標準ライブラリには、これを行うための std::net モジュールがあります。いつもの
やり方で新しいプロジェクトを作成しましょう。
$ cargo new hello
Created binary (application) `hello` project
$ cd hello
では、まずはリスト 21-1 のコードを src/main.rs に入力してください。このコードは、
ローカルアドレス 127.0.0.1:7878 で到着する TCP ストリームを待ち受けます。到着した
ストリームを受け取ると、Connection established! と表示します。
use std::net::TcpListener;
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
println!("Connection established!");
}
}
TcpListener を使うと、アドレス 127.0.0.1:7878 で TCP 接続を待ち受けることが
できます。このアドレスでは、コロンの前の部分はあなたのコンピュータを表す IP
アドレスです(これはどのコンピュータでも同じであり、著者のコンピュータを特に
表しているわけではありません)。そして 7878 がポートです。このポートを選んだ
理由は2つあります。HTTP は通常このポートでは受け付けられないため、あなたの
マシン上で動いているほかの Web サーバーと競合する可能性が低いこと、そして
7878 は電話のキーで rust を入力したものになっていることです。
この場合の bind 関数は new 関数と同じように動作し、新しい TcpListener
インスタンスを返します。この関数が bind と呼ばれているのは、ネットワークの
世界では、待ち受けるためにポートに接続することを「ポートにバインドする」と
呼ぶからです。
bind 関数は Result<T, E> を返します。これは、バインドに失敗する可能性がある
ことを示しています。たとえば、同じプログラムを2つ起動してしまうと、2つの
プログラムが同じポートを待ち受けようとするため失敗します。今回は学習用の
基本的なサーバーを書いているだけなので、この種のエラー処理は気にしません。
代わりに、エラーが起きたらプログラムを停止するために unwrap を使います。
TcpListener の incoming メソッドはイテレータを返し、そこからストリームの列
(より正確には TcpStream 型のストリーム)を取得できます。1つの ストリーム は、
クライアントとサーバーの間の開いている接続を表します。接続 とは、クライアントが
サーバーに接続し、サーバーがレスポンスを生成し、サーバーが接続を閉じるまでの、
リクエストとレスポンスの一連の完全な処理全体を指す名前です。そのため、クライアントが
送ってきた内容を確認するには TcpStream から読み取り、その後クライアントへデータを
送り返すためにレスポンスをストリームへ書き込みます。全体として、この for ループは
各接続を順番に処理し、扱うべきストリームの列を生成します。
今のところ、ストリームの処理では、そのストリームにエラーがあれば unwrap を
呼び出してプログラムを終了させるだけです。エラーがなければ、プログラムは
メッセージを表示します。成功する場合の処理については、次のリストで機能を追加します。
クライアントがサーバーに接続したときに incoming メソッドからエラーを受け取ることが
ある理由は、実際には接続そのものを反復しているのではなく、接続試行 を反復して
いるからです。接続が成功しない理由はいくつもあり、その多くはオペレーティング
システム固有です。たとえば、多くのオペレーティングシステムには同時に開ける接続数の
上限があります。その数を超える新しい接続試行は、既存の開いている接続の一部が
閉じられるまでエラーになります。
このコードを実行してみましょう。ターミナルで cargo run を実行し、その後 Web
ブラウザで 127.0.0.1:7878 を開いてください。サーバーは現在まだ何のデータも返して
いないため、ブラウザには「Connection reset」のようなエラーメッセージが表示される
はずです。しかし、ターミナルを見ると、ブラウザがサーバーに接続したときに表示された
いくつかのメッセージが見えるはずです。
Running `target/debug/hello`
Connection established!
Connection established!
Connection established!
1回のブラウザのリクエストに対して複数のメッセージが表示されることがあります。その 理由としては、ブラウザがページ本体のリクエストに加えて、ブラウザのタブに表示される favicon.ico アイコンのような別のリソースも要求している可能性があります。
また、サーバーが何のデータも返していないために、ブラウザがサーバーへの接続を複数回
試みている可能性もあります。stream がスコープを抜け、ループの終わりで drop
されると、drop 実装の一部として接続は閉じられます。問題が一時的なものである
可能性があるため、ブラウザは閉じられた接続に対して再試行することがあります。
ブラウザはまた、後で 実際に リクエストを送ることになったときにそれらをより速く 処理できるよう、まだ何のリクエストも送らずにサーバーへの接続を複数開くことも あります。これが起こると、その接続上にリクエストがあるかどうかに関係なく、サーバーは それぞれの接続を認識します。たとえば、Chrome ベースのブラウザの多くのバージョンは このような動作をします。この最適化は、プライベートブラウジングモードを使うか、 別のブラウザを使うことで無効にできます。
重要なのは、TCP 接続へのハンドルを正常に取得できたことです!
あるバージョンのコードの実行が終わったら、ctrl-C を押して
プログラムを停止するのを忘れないでください。その後、コードを変更するたびに
cargo run コマンドを実行してプログラムを再起動し、常に最新のコードを実行して
いることを確認してください。
リクエストを読み取る
ブラウザからのリクエストを読み取る機能を実装しましょう! まず接続を取得することと、
その接続に対して何らかの処理を行うことの関心を分離するために、接続を処理する新しい
関数を作成します。この新しい handle_connection 関数では、TCP ストリームから
データを読み取り、それを表示して、ブラウザから送られてくるデータを確認できるように
します。コードをリスト 21-2 のように変更してください。
```rust,no_run
use std::{
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
println!("Request: {http_request:#?}");
}
std::io::BufReader と std::io::prelude をスコープに導入し、ストリームの読み書きを可能にするトレイトと型にアクセスできるようにします。main 関数の for ループでは、接続を確立したことを示すメッセージを出力する代わりに、新しい handle_connection 関数を呼び出し、stream をそれに渡します。
handle_connection 関数では、stream への参照をラップする新しい BufReader インスタンスを作成します。BufReader は、std::io::Read トレイトのメソッド呼び出しを私たちの代わりに管理することで、バッファリングを追加します。
http_request という名前の変数を作成して、ブラウザがサーバーに送信するリクエストの各行を集めます。Vec<_> 型注釈を追加することで、これらの行をベクタに収集したいことを示しています。
BufReader は std::io::BufRead トレイトを実装しており、このトレイトは lines メソッドを提供します。lines メソッドは、データのストリーム内で改行バイトを見つけるたびに分割し、Result<String, std::io::Error> のイテレータを返します。各 String を取得するために、各 Result に対して map と unwrap を行います。データが有効な UTF-8 でない場合や、ストリームからの読み取り中に問題が発生した場合、Result はエラーになる可能性があります。繰り返しになりますが、本番用プログラムではこれらのエラーをもっと適切に処理すべきですが、ここでは単純化のため、エラーの場合はプログラムを停止することにしています。
ブラウザは HTTP リクエストの終わりを、改行文字を 2 つ連続で送ることで示します。そのため、ストリームから 1 つのリクエストを取得するには、空文字列である行に達するまで行を取り出します。行をベクタに収集したら、見やすいデバッグフォーマットでそれらを出力し、Web ブラウザがサーバーに送っている命令を確認できるようにします。
このコードを試してみましょう! プログラムを起動し、再び Web ブラウザからリクエストを送ってください。ブラウザには引き続きエラーページが表示されますが、ターミナルでのプログラムの出力は次のようになります。
$ cargo run
Compiling hello v0.1.0 (file:///projects/hello)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
Running `target/debug/hello`
Request: [
"GET / HTTP/1.1",
"Host: 127.0.0.1:7878",
"User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language: en-US,en;q=0.5",
"Accept-Encoding: gzip, deflate, br",
"DNT: 1",
"Connection: keep-alive",
"Upgrade-Insecure-Requests: 1",
"Sec-Fetch-Dest: document",
"Sec-Fetch-Mode: navigate",
"Sec-Fetch-Site: none",
"Sec-Fetch-User: ?1",
"Cache-Control: max-age=0",
]
使用するブラウザによっては、少し異なる出力になるかもしれません。リクエストデータを出力するようになったので、リクエストの最初の行で GET の後にあるパスを見ることで、1 回のブラウザリクエストから複数の接続が発生する理由がわかります。繰り返される接続がすべて / を要求しているなら、ブラウザはプログラムからレスポンスを受け取れていないため、繰り返し / を取得しようとしているのだとわかります。
このリクエストデータを分解して、ブラウザがプログラムに何を要求しているのかを理解しましょう。
HTTP リクエストをさらに詳しく見る
HTTP はテキストベースのプロトコルであり、リクエストは次の形式を取ります。
Method Request-URI HTTP-Version CRLF
headers CRLF
message-body
最初の行は request line で、クライアントが何を要求しているかに関する情報を保持しています。リクエスト行の最初の部分は、GET や POST など、使用されているメソッドを示し、これはクライアントがどのようにこのリクエストを行っているかを表します。私たちのクライアントは GET リクエストを使用しており、これは情報を要求していることを意味します。
リクエスト行の次の部分は / で、これはクライアントが要求している Uniform Resource Identifier (URI) を示しています。URI は Uniform Resource Locator (URL) とほとんど同じですが、完全には同じではありません。URI と URL の違いはこの章の目的には重要ではありませんが、HTTP 仕様では URI という用語が使われているので、ここでは頭の中で URI を URL に置き換えて考えてかまいません。
最後の部分はクライアントが使用している HTTP バージョンで、その後リクエスト行は CRLF シーケンスで終わります。(CRLF は carriage return と line feed の略で、これらはタイプライター時代の用語です!)CRLF シーケンスは \r\n と書くこともでき、ここで \r はキャリッジリターン、\n はラインフィードです。CRLF sequence は、リクエスト行と残りのリクエストデータを区切ります。CRLF が出力されるとき、\r\n として表示されるのではなく、新しい行が始まることに注目してください。
ここまでにプログラムを実行して受け取ったリクエスト行のデータを見ると、GET がメソッド、/ がリクエスト URI、そして HTTP/1.1 がバージョンであることがわかります。
リクエスト行の後で、Host: から始まる残りの行はヘッダーです。GET リクエストにはボディがありません。
別のブラウザからリクエストを送ったり、127.0.0.1:7878/test のような別のアドレスを要求したりして、リクエストデータがどのように変化するかを見てみてください。
ブラウザが何を求めているのかわかったので、今度は何らかのデータを送り返しましょう!
レスポンスを書き込む
クライアントのリクエストに応じてデータを送信する処理を実装します。レスポンスは次の形式を持ちます。
HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
最初の行は status line で、レスポンスで使用される HTTP バージョン、リクエストの結果を要約する数値のステータスコード、そしてそのステータスコードのテキストによる説明を提供する reason phrase を含みます。CRLF シーケンスの後には、任意のヘッダー、さらに別の CRLF シーケンス、そしてレスポンスのボディが続きます。
以下は HTTP バージョン 1.1 を使用し、ステータスコード 200、OK という reason phrase、ヘッダーなし、ボディなしのレスポンスの例です。
HTTP/1.1 200 OK\r\n\r\n
ステータスコード 200 は標準的な成功レスポンスです。このテキストは、ごく小さな成功 HTTP レスポンスです。これを、成功したリクエストへのレスポンスとしてストリームに書き込んでみましょう! handle_connection 関数から、リクエストデータを出力していた println! を削除し、Listing 21-3 のコードに置き換えてください。
use std::{
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
let response = "HTTP/1.1 200 OK\r\n\r\n";
stream.write_all(response.as_bytes()).unwrap();
}
これらの変更を加えたら、コードを実行してリクエストを送ってみましょう。 もうターミナルにデータを出力していないため、Cargo からの出力以外は何も 表示されません。Web ブラウザで 127.0.0.1:7878 を開くと、エラーではなく 空白のページが表示されるはずです。これで、HTTP リクエストを受け取り、 レスポンスを送信する処理を手作業で実装したことになります。
実際の HTML を返す
空白のページ以上のものを返す機能を実装しましょう。プロジェクト ディレクトリのルートに、新しいファイル hello.html を作成してください。 hello.html は src ディレクトリの中ではありません。HTML の内容は好きに 入力してかまいません。リスト 21-4 にその一例を示します。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Hello!</h1>
<p>Hi from Rust</p>
</body>
</html>
これは、見出しといくつかのテキストを含む最小限の HTML5 ドキュメントです。
リクエストを受け取ったときにサーバーからこれを返すために、リスト 21-5 に
示すように handle_connection を修正して HTML ファイルを読み込み、
それを本文としてレスポンスに追加して送信します。
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
// --snip--
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
標準ライブラリのファイルシステムモジュールをスコープに取り込むために、
use 文に fs を追加しました。ファイルの内容を文字列として読み込む
コードは見覚えがあるはずです。リスト 12-4 の I/O プロジェクトで
ファイルの内容を読んだときに使いました。
次に、format! を使ってファイルの内容を成功レスポンスの本文として追加
します。有効な HTTP レスポンスにするために、Content-Length ヘッダーを
追加します。これはレスポンス本文のサイズ、この場合は hello.html の
サイズに設定されます。
このコードを cargo run で実行し、ブラウザで 127.0.0.1:7878 を
開いてください。HTML がレンダリングされるはずです。
現在は、http_request 内のリクエストデータを無視して、HTML ファイルの
内容を無条件に送り返しています。つまり、ブラウザで
127.0.0.1:7878/something-else をリクエストしても、同じ HTML
レスポンスが返ってきます。現時点では、私たちのサーバーは非常に限定的で、
ほとんどの Web サーバーが行うことをしていません。リクエストに応じて
レスポンスをカスタマイズし、/ への正しい形式のリクエストに対してのみ
HTML ファイルを返すようにしたいところです。
リクエストを検証して条件に応じて応答する
現時点では、クライアントが何を要求しても、この Web サーバーはファイル内の
HTML を返します。HTML ファイルを返す前に、ブラウザが / を要求している
ことを確認し、それ以外を要求した場合にはエラーを返す機能を追加しましょう。
そのためには、リスト 21-6 に示すように handle_connection を修正する
必要があります。この新しいコードは、受信したリクエストの内容を、/ への
リクエストがどのようなものかという既知の内容と照合し、リクエストを
異なる方法で扱うための if ブロックと else ブロックを追加します。
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
// --snip--
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
if request_line == "GET / HTTP/1.1" {
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
} else {
// some other request
}
}
HTTP リクエストの最初の行だけを見るので、リクエスト全体をベクターに
読み込む代わりに、イテレーターから最初の要素を取得するために next を
呼び出しています。最初の unwrap は Option を処理し、イテレーターに
要素がない場合はプログラムを停止します。2 つ目の unwrap は Result
を処理し、リスト 21-2 で追加した map 内にあった unwrap と同じ効果を
持ちます。
次に、request_line が / パスへの GET リクエストのリクエスト行と等しい
かどうかを確認します。等しければ、if ブロックが HTML ファイルの内容を
返します。
request_line が / パスへの GET リクエストと一致しない場合は、ほかの
何らかのリクエストを受信したことを意味します。すべてのほかのリクエストに
応答するためのコードは、このあと else ブロックに追加します。
このコードを今実行して、127.0.0.1:7878 をリクエストしてください。 hello.html の HTML が返されるはずです。127.0.0.1:7878/something-else のような別のリクエストを行うと、リスト 21-1 とリスト 21-2 のコードを 実行したときに見たような接続エラーが発生します。
では次に、リスト 21-7 のコードを else ブロックに追加して、リクエスト
されたコンテンツが見つからなかったことを示す 404 のステータスコードを
持つレスポンスを返しましょう。また、エンドユーザーに対するレスポンス内容を
ブラウザに表示するための HTML も返します。
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
if request_line == "GET / HTTP/1.1" {
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
// --snip--
} else {
let status_line = "HTTP/1.1 404 NOT FOUND";
let contents = fs::read_to_string("404.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
}
}
ここで、レスポンスにはステータスコード 404 と理由句 NOT FOUND を含む
ステータス行があります。レスポンスの本文は、ファイル 404.html にある
HTML になります。次に、エラーページ用として hello.html の隣に
404.html ファイルを作成する必要があります。ここでも、HTML の内容は
好きにしてかまいませんし、リスト 21-8 の HTML の例を使ってもかまいません。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Oops!</h1>
<p>Sorry, I don't know what you're asking for.</p>
</body>
</html>
これらの変更を加えたら、サーバーを再度実行してください。 127.0.0.1:7878 をリクエストすると hello.html の内容が返され、 127.0.0.1:7878/foo のようなそれ以外のリクエストでは 404.html の エラー HTML が返されるはずです。
リファクタリング
現時点では、if ブロックと else ブロックには多くの重複があります。
どちらもファイルを読み取り、そのファイルの内容をストリームに書き込んで
います。違うのは、ステータス行とファイル名だけです。これらの違いを、
ステータス行とファイル名の値を変数に代入する別々の if 行と else 行に
切り出すことで、コードをより簡潔にしましょう。そうすれば、その後の
コードでは無条件にそれらの変数を使ってファイルを読み込み、レスポンスを
書き込めます。リスト 21-9 は、大きな if ブロックと else ブロックを
置き換えた後のコードを示しています。
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
// --snip--
fn handle_connection(mut stream: TcpStream) {
// --snip--
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
("HTTP/1.1 200 OK", "hello.html")
} else {
("HTTP/1.1 404 NOT FOUND", "404.html")
};
let contents = fs::read_to_string(filename).unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
いまや if と else のブロックは、ステータス行とファイル名に対応する適切な値をタプルとして返すだけになりました。その後、第19章で説明したように、let 文のパターンを使って分解し、この2つの値を status_line と filename に代入します。
以前は重複していたコードは、いまでは if と else のブロックの外にあり、status_line と filename 変数を使っています。これにより、2つのケースの違いが見やすくなり、ファイルの読み込みやレスポンスの書き込みの動作を変更したい場合にも、コードを更新する場所は1か所だけになります。リスト21-9のコードの振る舞いは、リスト21-7のものと同じです。
すばらしい! これで、約40行のRustコードで、ある1つのリクエストにはコンテンツのページを返し、それ以外のすべてのリクエストには404レスポンスを返す、シンプルなWebサーバーができました。
現在、私たちのサーバーは単一スレッドで動作しているため、一度に処理できるリクエストは1つだけです。いくつかの遅いリクエストをシミュレートして、これがどのように問題になりうるのかを見てみましょう。その後、サーバーが複数のリクエストを同時に処理できるように修正します。