シングルスレッドの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!"); } }
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
を使用してエラーが発生したら、プログラムを停止します。
TcpListener
のincoming
メソッドは、一連のストリームを与えるイテレータを返します(具体的には、型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; 1024]; stream.read(&mut buffer).unwrap(); println!("Request: {}", String::from_utf8_lossy(&buffer[..])); }
std::io::prelude
をスコープに導入して、ストリームから読み書きさせてくれる特定のトレイトにアクセスできるようにしています。
main
関数内のfor
ループで、接続を確立したというメッセージを出力する代わりに、今では、
新しいhandle_connection
関数を呼び出し、stream
を渡しています。
handle_connection
関数において、stream
引数を可変にしました。理由は、
TcpStream
インスタンスが内部で返すデータを追いかけているからです。要求した以上のデータを読み取り、
次回データを要求した時のためにそのデータを保存する可能性があります。故に、内部の状態が変化する可能性があるので、
mut
にする必要があるのです; 普通、「読み取り」に可変化は必要ないと考えてしまいますが、この場合、mut
キーワードが必要です。
次に、実際にストリームから読み取る必要があります。これを2つの手順で行います: まず、
スタックに読み取ったデータを保持するbuffer
を宣言します。バッファーのサイズは1024バイトにしました。
これは、基本的なリクエストには十分な大きさでこの章の目的には必要十分です。任意のサイズのリクエストを扱いたければ、
バッファーの管理はもっと複雑にする必要があります; 今は、単純に保っておきます。このバッファーをstream.read
に渡し、
これがTcpStream
からバイトを読み取ってバッファーに置きます。
2番目にバッファーのバイトを文字列に変換し、その文字列を出力します。String::from_utf8_lossy
関数は、
&[u8]
を取り、String
を生成します。名前の“lossy”の箇所は、無効なUTF-8シーケンスを目の当たりにした際のこの関数の振る舞いを示唆しています:
無効なシーケンスを�
、U+FFFD REPLACEMENT CHARACTER
で置き換えます。
リクエストデータによって埋められなかったバッファーの部分(訳注
バッファーとして1024バイトの領域を用意しているが、リクエストデータは1024バイト存在しないことがほとんどなので変数 buffer
の後ろ部分が埋められないまま放置されることを意図していると思われる) は、置換文字が表示される場合があります。
このコードを試しましょう!プログラムを開始して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行目は、クライアントが要求しているものがなんなのかについての情報を保持するリクエスト行です。
リクエスト行の最初の部分は使用されている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により、リクエスト行がリクエストデータの残りと区別されています。CRLFを出力すると、
\r\n
ではなく、新しい行が開始されることに注意してください。
ここまでプログラムを実行して受け取ったリクエスト行のデータをみると、GET
がメソッド、/ が要求URI、
HTTP/1.1
がバージョンであることが確認できます。
リクエスト行の後に、Host:
以下から始まる残りの行は、ヘッダです。GET
リクエストには、本体(訳注
:message-body
のこと)がありません。
試しに他のブラウザからリクエストを送ったり、127.0.0.1:7878/testなどの異なるアドレスを要求してみて、どうリクエストデータが変わるか確認してください。
さて、ブラウザが要求しているものがわかったので、何かデータを返しましょう!
レスポンスを記述する
さて、クライアントのリクエストに対する返答としてデータの送信を実装します。レスポンスは、以下のようなフォーマットです:
HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
最初の行は、レスポンスで使用される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)] fn main() { use std::io::prelude::*; use std::net::TcpStream; fn handle_connection(mut stream: TcpStream) { let mut buffer = [0; 1024]; 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(); } }
新しい最初の行に成功したメッセージのデータを保持するresponse
変数を定義しています。そして、
response
に対してas_bytes
を呼び出し、文字列データをバイトに変換します。stream
のwrite
メソッドは、
&[u8]
を取り、接続に直接そのバイトを送信します。
write
処理は失敗することもあるので、以前のようにエラーの結果にはunwrap
を使用します。
今回も、実際のアプリでは、エラー処理をここに追加するでしょう。最後にflush
は待機し、
バイトが全て接続に書き込まれるまでプログラムが継続するのを防ぎます; TcpStream
は内部にバッファーを保持して、
元となるOSへの呼び出しを最小化します。
これらの変更とともに、コードを実行し、リクエストをしましょう。最早、端末にどんなデータも出力していないので、 Cargoからの出力以外には何も出力はありません。Webブラウザで127.0.0.1:7878をロードすると、 エラーではなく空のページが得られるはずです。HTTPリクエストとレスポンスを手で実装したばかりなのです!
本物のHTMLを返す
空のページ以上のものを返す機能を実装しましょう。新しいファイルhello.htmlをsrcディレクトリではなく、 プロジェクトのルートディレクトリに作成してください。お好きなように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>
これは、ヘッドとテキストのある最低限のHTML5ドキュメントです。リクエストを受け付けた際にこれをサーバから返すには、
リスト20-5のようにhandle_connection
を変更してHTMLファイルを読み込み、本体としてレスポンスに追加して送ります。
ファイル名: src/main.rs
#![allow(unused)] 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; 1024]; 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(); } }
先頭に行を追加して標準ライブラリの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のように変更する必要があります。この新しいコードは、
/ への要求がどんな見た目になるのか我々が知っていることに対して受け取ったリクエストの中身を検査し、if
とelse
ブロックを追加して、
リクエストを異なる形で扱います。
ファイル名: src/main.rs
#![allow(unused)] 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; 1024]; 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 } } }
まず、/ リクエストに対応するデータをget
変数にハードコードしています。生のバイトをバッファーに読み込んでいるので、
b""
バイト文字列記法を中身のデータの先頭に追記することで、get
をバイト文字列に変換しています。
そして、buffer
がget
のバイトから始まっているか確認します。もしそうなら、/ への合法なリクエストを受け取ったことを意味し、
これが、HTMLファイルの中身を返すif
ブロックで扱う成功した場合になります。
buffer
がget
のバイトで始まらないのなら、何か他のリクエストを受け取ったことになります。
この後すぐ、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)] 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(); } } }
ここでは、レスポンスにはステータスコード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>
これらの変更とともに、もう一度サーバを実行してください。127.0.0.1:7878を要求すると、 hello.htmlの中身が返り、127.0.0.1:7878/fooなどの他のリクエストには404.htmlからのエラーHTMLが返るはずです。
リファクタリングの触り
現在、if
とelse
ブロックには多くの繰り返しがあります: どちらもファイルを読み、ファイルの中身をストリームに書き込んでいます。
唯一の違いは、ステータス行とファイル名だけです。それらの差異を、ステータス行とファイル名の値を変数に代入する個別のif
とelse
行に引っ張り出して、
コードをより簡潔にしましょう; そうしたら、それらの変数を無条件にコードで使用し、ファイルを読んでレスポンスを書き込めます。
リスト20-9は、大きなif
とelse
ブロックを置き換えた後の結果のコードを示しています。
ファイル名: src/main.rs
#![allow(unused)] 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; 1024]; 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(); } }
これで、if
とelse
ブロックは、タプルにステータス行とファイル名の適切な値を返すだけになりました;
それから、分配を使用してこれら2つの値を第18章で議論したように、let
文のパターンでstatus_line
とfilename
に代入しています。
前は重複していたコードは、今ではif
とelse
ブロックの外に出て、status_line
とfilename
変数を使用しています。
これにより、2つの場合の違いがわかりやすくなり、ファイル読み取りとレスポンス記述の動作法を変更したくなった際に、
1箇所だけコードを更新すればいいようになったことを意味します。リスト20-9のコードの振る舞いは、
リスト20-8と同じです。
素晴らしい!もう、およそ40行のRustコードで、あるリクエストには中身のあるページで応答し、 他のあらゆるリクエストには404レスポンスで応答する単純なWebサーバができました。
現状、このサーバは、シングルスレッドで実行されます。つまり、1回に1つのリクエストしか捌けないということです。 何か遅いリクエストをシミュレーションすることで、それが問題になる可能性を調査しましょう。 それから1度にサーバが複数のリクエストを扱えるように修正します。