シングルスレッドのWebサーバを構築する

シングルスレッドのWebサーバを動かすところから始めます。始める前に、Webサーバ構築に関係するプロトコルをさっと一覧しましょう。 これらのプロトコルの詳細は、この本の範疇を超えていますが、さっと眺めることで必要な情報が得られるでしょう。

主に2つのプロトコルがWebサーバに関係し、Hypertext Transfer Protocol (HTTP)(注釈: ハイパーテキスト転送プロトコル)と、 Transmission Control Protocol (TCP)(注釈: 伝送制御プロトコル)です。 両者のプロトコルは、リクエスト・レスポンスプロトコルであり、つまり、クライアントがリクエストを初期化し、 サーバはリクエストをリッスンし、クライアントにレスポンスを提供するということです。 それらのリクエストとレスポンスの中身は、プロトコルで規定されています。

TCPは、情報がとあるサーバから別のサーバへどう到達するかの詳細を記述するものの、その情報がなんなのかは指定しない、 より低レベルのプロトコルです。HTTPはリクエストとレスポンスの中身を定義することでTCPの上に成り立っています。 技術的にはHTTPを他のプロトコルとともに使用することができますが、過半数の場合、HTTPはTCPの上にデータを送信します。 TCPとHTTPのリクエストとレスポンスの生のバイトを取り扱います。

TCP接続をリッスンする

WebサーバはTCP接続をリッスンするので、そこが最初に取り掛かる部分になります。標準ライブラリは、 std::netというこれを行うモジュールを用意しています。通常通り、新しいプロジェクトを作りましょう:

$ cargo new hello --bin
     Created binary (application) `hello` project
$ cd hello

さて、リスト20-1のコードをsrc/main.rsに入力して始めてください。このコードは、 やってくるTCPストリームを求めて127.0.0.1:7878というアドレスをリッスンします。 入力ストリームを得ると、Connection established!と出力します。

ファイル名: src/main.rs

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!");
    }
}

リスト20-1: 入力ストリームをリッスンし、ストリームを受け付けた時にメッセージを出力する

TcpListenerにより、アドレス127.0.0.1:7878でTCP接続をリッスンできます。アドレス内で、 コロンの前の区域は、自分のコンピュータを表すIPアドレスで(これはどんなコンピュータでも同じで、 特に著者のコンピュータを表すわけではありません)、7878はポートです。このポートを選択した理由は2つあります: HTTPは通常このポートで受け付けられることと、7878は電話で“rust”と入力されるからです。

この筋書きでのbind関数は、新しいTcpListenerインスタンスを返すという点でnew関数のような働きをします。 この関数がbindと呼ばれている理由は、ネットワークにおいて、リッスンすべきポートに接続することは、 「ポートに束縛する」(binding to a port)こととして知られているからです。

bind関数はResult<T, E>を返し、束縛が失敗することもあることを示しています。例えば、 ポート80に接続するには管理者権限が必要なので(管理者以外はポート1024以上しかリッスンできません)管理者にならずにポート80に接続を試みたら、 束縛はうまくいかないでしょう。また、別の例として自分のプログラムを2つ同時に立ち上げて2つのプログラムが同じポートをリッスンしたら、 束縛は機能しないでしょう。学習目的のためだけに基本的なサーバを記述しているので、この種のエラーを扱う心配はしません; その代わり、unwrapを使用してエラーが発生したら、プログラムを停止します。

TcpListenerincomingメソッドは、一連のストリームを与えるイテレータを返します(具体的には、型TcpStreamのストリーム)。 単独のストリームがクライアント・サーバ間の開かれた接続を表します。接続(connection)は、 クライアントがサーバに接続し、サーバがレスポンスを生成し、サーバが接続を閉じるというリクエストとレスポンス全体の過程の名前です。 そのため、TcpStreamは自身を読み取って、クライアントが送信したことを確認し、それからレスポンスをストリームに記述させてくれます。 総括すると、このforループは各接続を順番に処理し、我々が扱えるように一連のストリームを生成します。

とりあえず、ストリームの扱いは、unwrapを呼び出してストリームにエラーがあった場合にプログラムを停止することから構成されています; エラーがなければ、プログラムはメッセージを出力します。次のリストで成功した時にさらに多くの機能を追加します。 クライアントがサーバに接続する際にincomingメソッドからエラーを受け取る可能性がある理由は、 実際には接続を走査していないからです。代わりに接続の試行を走査しています。接続は多くの理由で失敗する可能性があり、 そのうちの多くは、OS特有です。例を挙げれば、多くのOSには、サポートできる同時に開いた接続数に上限があります; 開かれた接続の一部が閉じられるまでその数字を超えた接続の試行はエラーになります。

このコードを試しに実行してみましょう!端末でcargo runを呼び出し、それからWebブラウザで127.0.0.1:7878をロードしてください。 ブラウザは、「接続がリセットされました」などのエラーメッセージを表示するはずです。サーバが現状、何もデータを返してこないからです。 ですが、端末に目を向ければ、ブラウザがサーバに接続した際にいくつかメッセージが出力されるのを目の当たりにするはずです。

     Running `target/debug/hello`
Connection established!
Connection established!
Connection established!

時々、1回のブラウザリクエストで複数のメッセージが出力されるのを目の当たりにするでしょう; その理由は、ブラウザがページだけでなく、 ブラウザのタブに出現するfavicon.icoアイコンなどの他のリソースにもリクエストを行なっているということかもしれません。

サーバが何もデータを送り返してこないので、ブラウザがサーバに何度も接続を試みているということである可能性もあるでしょう。 streamがスコープを抜け、ループの最後でドロップされると、接続はdrop実装の一部として閉じられます。 ブラウザは、再試行することで閉じられた接続を扱うことがあります。問題が一時的なものである可能性があるからです。 重要な要素は、TCP接続へのハンドルを得ることに成功したということです!

特定のバージョンのコードを走らせ終わった時にctrl-cを押して、 プログラムを止めることを忘れないでください。そして、一連のコード変更を行った後にcargo runを再起動し、 最新のコードを実行していることを確かめてください。

リクエストを読み取る

ブラウザからリクエストを読み取る機能を実装しましょう!まず接続を得、それから接続に対して何らかの行動を行う責任を分離するために、 接続を処理する新しい関数を開始します。この新しいhandle_connection関数において、TCPストリームからデータを読み取り、 ブラウザからデータが送られていることを確認できるように端末に出力します。コードをリスト20-2のように変更してください。

ファイル名: src/main.rs

use std::io::prelude::*;
use std::net::TcpStream;
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();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let mut buffer = [0; 512];

    stream.read(&mut buffer).unwrap();

    println!("Request: {}", String::from_utf8_lossy(&buffer[..]));
}

リスト20-2: TcpStreamから読み取り、データを出力する

std::io::preludeをスコープに導入して、ストリームから読み書きさせてくれる特定のトレイトにアクセスできるようにしています。 main関数内のforループで、接続を確立したというメッセージを出力する代わりに、今では、 新しいhandle_connection関数を呼び出し、streamを渡しています。

handle_connection関数において、stream引数を可変にしました。理由は、 TcpStreamインスタンスが内部で返すデータを追いかけているからです。要求した以上のデータを読み取り、 次回データを要求した時のためにそのデータを保存する可能性があります。故に、内部の状態が変化する可能性があるので、 mutにする必要があるのです; 普通、「読み取り」に可変化は必要ないと考えてしまいますが、この場合、mutキーワードが必要です。

次に、実際にストリームから読み取る必要があります。これを2つの手順で行います: まず、 スタックに読み取ったデータを保持するbufferを宣言します。バッファーのサイズは512バイトにしました。 これは、基本的なリクエストには十分な大きさでこの章の目的には必要十分です。任意のサイズのリクエストを扱いたければ、 バッファーの管理はもっと複雑にする必要があります; 今は、単純に保っておきます。このバッファーをstream.readに渡し、 これがTcpStreamからバイトを読み取ってバッファーに置きます。

2番目にバッファーのバイトを文字列に変換し、その文字列を出力します。String::from_utf8_lossy関数は、 &[u8]を取り、Stringを生成します。名前の“lossy”の箇所は、無効なUTF-8シーケンスを目の当たりにした際のこの関数の振る舞いを示唆しています: 無効なシーケンスをU+FFFD REPLACEMENT CHARACTERで置き換えます。 置き換え文字をリクエストデータによって埋められたバッファーの文字の箇所に目撃する可能性があります。

このコードを試しましょう!プログラムを開始してWebブラウザで再度リクエストを送ってください。ブラウザではそれでも、 エラーページが得られるでしょうが、端末のプログラムの出力はこんな感じになっていることに注目してください:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42 secs
     Running `target/debug/hello`
Request: GET / HTTP/1.1
Host: 127.0.0.1:7878
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:52.0) Gecko/20100101
Firefox/52.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Upgrade-Insecure-Requests: 1
������������������������������������

ブラウザによって、少し異なる出力になる可能性があります。今やリクエストデータを出力しているので、 Request: GETの後のパスを見ることで1回のブラウザリクエストから複数の接続が得られる理由が確認できます。 繰り返される接続が全て / を要求しているなら、ブラウザは、我々のプログラムからレスポンスが得られないので、 繰り返し / をフェッチしようとしていることがわかります。

このリクエストデータを噛み砕いて、ブラウザが我々のプログラムに何を要求しているかを理解しましょう。

HTTPリクエストを詳しく見る

HTTPはテキストベースのプロトコルで、1つの要求はこのようなフォーマットに則っています:

Method Request-URI HTTP-Version CRLF
headers CRLF
message-body

1行目は、クライアントが要求しているものがなんなのかについての情報を保持するリクエスト行です。 リクエスト行の最初の部分は使用されているGETPOSTなどのメソッドを示し、これは、どのようにクライアントがこの要求を行なっているかを記述します。 クライアントはGETリクエストを使用しました。

リクエスト行の次の部分は / で、これはクライアントが要求しているUniform Resource Identifier (URI)(注釈: 統一資源識別子)を示します: URIはほぼ、ですが完全ではなく、Uniform Resource Locator (URL)(注釈: 統一資源位置指定子)と同じです。 URIとURLの違いは、この章の目的には重要ではありませんが、HTTPの規格はURIという用語を使用しているので、 ここでは脳内でURIをURLと読み替えられます。

最後の部分は、クライアントが使用しているHTTPのバージョンで、それからリクエスト行はCRLFで終了します。 (CRLFはcarriage returnline feed(無理に日本語でいえば、キャリッジ(紙を固定するシリンダー)が戻ることと行を(コンピュータに)与えること)を表していて、 これはタイプライター時代からの用語です!)CRLFは\r\nとも表記され、\rがキャリッジ・リターンで\nがライン・フィードです。 CRLFにより、リクエスト行がリクエストデータの残りと区別されています。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

1行目は、ステータス行で、レスポンスで使用されるHTTPバージョン、リクエストの結果を総括する数値のステータスコード、 ステータスコードをテキストで表現する理由フレーズを含みます。CRLFの後には、あらゆるヘッダ、別のCRLF、 レスポンスの本体があります。

こちらがHTTPバージョン1.1を使用し、ステータスコードが200で、OKフレーズ、ヘッダと本体なしの例のレスポンスです:

HTTP/1.1 200 OK\r\n\r\n

ステータスコード200は、一般的な成功のレスポンスです。テキストは、矮小(わいしょう)な成功のHTTPレスポンスです。 これを成功したリクエストへの返答としてストリームに書き込みましょう!handle_connection関数から、 リクエストデータを出力していたprintln!を除去し、リスト20-3のコードと置き換えてください。

ファイル名: src/main.rs


# #![allow(unused_variables)]
#fn main() {
# use std::io::prelude::*;
# use std::net::TcpStream;
fn handle_connection(mut stream: TcpStream) {
    let mut buffer = [0; 512];

    stream.read(&mut buffer).unwrap();

    let response = "HTTP/1.1 200 OK\r\n\r\n";

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}
#}

リスト20-3: ストリームに矮小な成功のHTTPレスポンスを書き込む

新しい最初の行に成功したメッセージのデータを保持するresponse変数を定義しています。そして、 responseに対してas_bytesを呼び出し、文字列データをバイトに変換します。streamwriteメソッドは、 &[u8]を取り、接続に直接そのバイトを送信します。

write処理は失敗することもあるので、以前のようにエラーの結果にはunwrapを使用します。 今回も、実際のアプリでは、エラー処理をここに追加するでしょう。最後にflushは待機し、 バイトが全て接続に書き込まれるまでプログラムが継続するのを防ぎます; TcpStreamは内部のバッファーを保持して、 元となるOSへの呼び出しを最小化します。

これらの変更とともに、コードを実行し、リクエストをしましょう。最早、端末にどんなデータも出力していないので、 Cargoからの出力以外には何も出力はありません。Webブラウザで127.0.0.1:7878をロードすると、 エラーではなく空のページが得られるはずです。HTTPリクエストとレスポンスを手で実装したばかりなのです!

本物のHTMLを返す

空のページ以上のものを返す機能を実装しましょう。新しいファイルhello.htmlsrcディレクトリではなく、 プロジェクトのルートディレクトリに作成してください。お好きなようにHTMLを書いてください; リスト20-4は、一つの可能性を示しています。

ファイル名: hello.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <!-- やあ! -->
    <h1>Hello!</h1>
    <!-- Rustからやあ -->
    <p>Hi from Rust</p>
  </body>
</html>

リスト20-4: レスポンスで返すサンプルのHTMLファイル

これは、ヘッドとテキストのある最低限のHTML5ドキュメントです。リクエストを受け付けた際にこれをサーバから返すには、 リスト20-5のようにhandle_connectionを変更してHTMLファイルを読み込み、本体としてレスポンスに追加して送ります。

ファイル名: src/main.rs


# #![allow(unused_variables)]
#fn main() {
# use std::io::prelude::*;
# use std::net::TcpStream;
use std::fs::File;
// --snip--

fn handle_connection(mut stream: TcpStream) {
    let mut buffer = [0; 512];
    stream.read(&mut buffer).unwrap();

    let mut file = File::open("hello.html").unwrap();

    let mut contents = String::new();
    file.read_to_string(&mut contents).unwrap();

    let response = format!("HTTP/1.1 200 OK\r\n\r\n{}", contents);

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}
#}

リスト20-5: レスポンスの本体としてhello.htmlの中身を送る

先頭に行を追加して標準ライブラリのFileをスコープに導入しました。ファイルを開き、中身を読み込むコードは、 馴染みがあるはずです; リスト12-4でI/Oプロジェクト用にファイルの中身を読み込んだ時に第12章で使用しましたね。

次にformat!でファイルの中身を成功したレスポンスの本体として追記しています。

このコードをcargo runで走らせ、127.0.0.1:7878をブラウザでロードしてください; HTMLが描画されるのが確認できるはずです!

現時点では、buffer内のリクエストデータは無視し、無条件でHTMLファイルの中身を送り返しているだけです。 これはつまり、ブラウザで127.0.0.1:7878/something-elseをリクエストしても、 この同じHTMLレスポンスが得られるということです。我々のサーバはかなり限定的で、多くのWebサーバとは異なっています。 リクエストに基づいてレスポンスをカスタマイズし、/ への合法なリクエストに対してのみHTMLファイルを送り返したいです。

リクエストにバリデーションをかけ、選択的にレスポンスを返す

現状、このWebサーバはクライアントが何を要求しても、ファイルのHTMLを返します。HTMLファイルを返却する前にブラウザが / をリクエストしているか確認し、 ブラウザが他のものを要求していたらエラーを返す機能を追加しましょう。このために、 handle_connectionをリスト20-6のように変更する必要があります。この新しいコードは、 / への要求がどんな見た目になるのか知っていることに対して受け取ったリクエストの中身を精査し、ifelseブロックを追加して、 要求を異なる形で扱います。

ファイル名: src/main.rs


# #![allow(unused_variables)]
#fn main() {
# use std::io::prelude::*;
# use std::net::TcpStream;
# use std::fs::File;
// --snip--

fn handle_connection(mut stream: TcpStream) {
    let mut buffer = [0; 512];
    stream.read(&mut buffer).unwrap();

    let get = b"GET / HTTP/1.1\r\n";

    if buffer.starts_with(get) {
        let mut file = File::open("hello.html").unwrap();

        let mut contents = String::new();
        file.read_to_string(&mut contents).unwrap();

        let response = format!("HTTP/1.1 200 OK\r\n\r\n{}", contents);

        stream.write(response.as_bytes()).unwrap();
        stream.flush().unwrap();
    } else {
        // 何か他の要求
        // some other request
    }
}
#}

リスト20-6: リクエストとマッチさせ、/ へのリクエストを他のリクエストとは異なる形で扱う

まず、/ リクエストに対応するデータをget変数にハードコードしています。生のバイトをバッファーに読み込んでいるので、 b""バイト文字列記法を中身のデータの先頭に追記することで、getをバイト文字列に変換しています。 そして、buffergetのバイトから始まっているか確認します。もしそうなら、/ への合法なリクエストを受け取ったことを意味し、 これが、HTMLファイルの中身を返すifブロックで扱う成功した場合になります。

buffergetのバイトで始まらないのなら、何か他のリクエストを受け取ったことになります。 この後すぐ、elseブロックに他のリクエストに対応するコードを追加します。

さあ、このコードを走らせて127.0.0.1:7878を要求してください; hello.htmlのHTMLが得られるはずです。 127.0.0.1:7878/something-elseなどの他のリクエストを行うと、リスト20-1や20-2のコードを走らせた時に見かけた接続エラーになるでしょう。

では、elseブロックにリスト20-7のコードを追記して、ステータスコード404のレスポンスを返しましょう。 これは、リクエストの中身が見つからなかったことを通知します。エンドユーザにレスポンスを示唆するページをブラウザに描画するよう、 何かHTMLも返します。

ファイル名: src/main.rs


# #![allow(unused_variables)]
#fn main() {
# use std::io::prelude::*;
# use std::net::TcpStream;
# use std::fs::File;
# fn handle_connection(mut stream: TcpStream) {
# if true {
// --snip--

} else {
    let status_line = "HTTP/1.1 404 NOT FOUND\r\n\r\n";
    let mut file = File::open("404.html").unwrap();
    let mut contents = String::new();

    file.read_to_string(&mut contents).unwrap();

    let response = format!("{}{}", status_line, contents);

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}
# }
#}

リスト20-7: / 以外の何かが要求されたら、ステータスコード404とエラーページで応答する

ここでは、レスポンスにはステータスコード404と理由フレーズNOT FOUNDのステータス行があります。 それでもヘッダは返さず、レスポンスの本体は、ファイル404.htmlのHTMLになります。エラーページのために、 hello.htmlの隣に404.htmlファイルを作成する必要があります; 今回も、ご自由にお好きなHTMLにしたり、 リスト20-8の例のHTMLを使用したりしてください。

ファイル名: 404.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>

リスト20-8: あらゆる404レスポンスでページが送り返す中身のサンプル

これらの変更とともに、もう一度サーバを実行してください。127.0.0.1:7878を要求すると、 hello.htmlの中身が返り、127.0.0.1:7878/fooなどの他のリクエストには404.htmlからのエラーHTMLが返るはずです。

リファクタリングの触り

現在、ifelseブロックには多くの繰り返しがあります: どちらもファイルを読み、ファイルの中身をストリームに書き込んでいます。 唯一の違いは、ステータス行とファイル名だけです。それらの差異を、ステータス行とファイル名の値を変数に代入する個別のifelse行に引っ張り出して、 コードをより簡潔にしましょう; そうしたら、それらの変数を無条件にコードで使用し、ファイルを読んでレスポンスを書き込めます。 リスト20-9は、大きなifelseブロックを置き換えた後の結果のコードを示しています。

ファイル名: src/main.rs


# #![allow(unused_variables)]
#fn main() {
# use std::io::prelude::*;
# use std::net::TcpStream;
# use std::fs::File;
// --snip--

fn handle_connection(mut stream: TcpStream) {
#     let mut buffer = [0; 512];
#     stream.read(&mut buffer).unwrap();
#
#     let get = b"GET / HTTP/1.1\r\n";
    // --snip--

    let (status_line, filename) = if buffer.starts_with(get) {
        ("HTTP/1.1 200 OK\r\n\r\n", "hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND\r\n\r\n", "404.html")
    };

    let mut file = File::open(filename).unwrap();
    let mut contents = String::new();

    file.read_to_string(&mut contents).unwrap();

    let response = format!("{}{}", status_line, contents);

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}
#}

リスト20-9: 2つの場合で異なるコードだけを含むように、ifelseブロックをリファクタリングする

これで、ifelseブロックは、タプルにステータス行とファイル名の適切な値を返すだけになりました; それから、分配を使用してこれら2つの値を第18章で議論したように、let文のパターンでstatus_linefilenameに代入しています。

前は重複していたコードは、今ではifelseブロックの外に出て、status_linefilename変数を使用しています。 これにより、2つの場合の違いがわかりやすくなり、ファイル読み取りとレスポンス記述の動作法を変更したくなった際に、 1箇所だけコードを更新すればいいようになったことを意味します。リスト20-9のコードの振る舞いは、 リスト20-8と同じです。

素晴らしい!もう、およそ40行のRustコードで、あるリクエストには中身のあるページで応答し、 他のあらゆるリクエストには404レスポンスで応答する単純なWebサーバができました。

現状、このサーバは、シングルスレッドで実行されます。つまり、1回に1つのリクエストしか捌けないということです。 何か遅いリクエストをシミュレーションすることで、それが問題になる可能性を調査しましょう。 それから1度にサーバが複数のリクエストを扱えるように修正します。