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

Beginner

This tutorial is from open-source community. Access the source code

はじめに

シングルスレッドの Web サーバーの構築へようこそ。この実験は、Rust Bookの一部です。LabEx で Rust のスキルを練習することができます。

この実験では、HTTP と TCP プロトコルを使用してクライアントの要求を処理し、応答を提供するシングルスレッドの Web サーバーを構築します。

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

まずは、シングルスレッドの Web サーバーを動作させることから始めましょう。始める前に、Web サーバーの構築に関与するプロトコルの概要を見てみましょう。これらのプロトコルの詳細は本書の範囲外ですが、概要を見ることで必要な情報が得られます。

Web サーバーに関与する 2 つの主なプロトコルは、「ハイパーテキスト転送プロトコル(Hypertext Transfer Protocol:HTTP)」と「伝送制御プロトコル(Transmission Control Protocol:TCP)」です。両方のプロトコルは「要求応答(request-response)」プロトコルであり、「クライアント」が要求を開始し、「サーバー」が要求を受け取り、クライアントに応答を提供します。これらの要求と応答の内容はプロトコルによって定義されます。

TCP は、情報が 1 つのサーバーから別のサーバーにどのように届くかの詳細を記述する下位レベルのプロトコルですが、その情報が何であるかを指定しません。HTTP は、要求と応答の内容を定義することで TCP の上に構築されています。技術的には、HTTP を他のプロトコルと併用することも可能ですが、ほとんどの場合、HTTP は TCP を介してデータを送信します。私たちは TCP と HTTP の要求と応答の生バイトを扱います。

TCP 接続を待ち受ける

私たちの Web サーバーは TCP 接続を待ち受ける必要がありますので、それが最初に取り組む部分です。標準ライブラリにはこれを行うためのstd::netモジュールが用意されています。いつも通り新しいプロジェクトを作成しましょう:

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

次に、src/main.rsにリスト 20-1 のコードを入力して始めましょう。このコードは、ローカルアドレス127.0.0.1:7878で着信する TCP ストリームを待ち受けます。着信ストリームを受け取ると、接続確立!と表示します。

ファイル名:src/main.rs

use std::net::TcpListener;

fn main() {
  1 let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

  2 for stream in listener.incoming() {
      3 let stream = stream.unwrap();

      4 println!("Connection established!");
    }
}

リスト 20-1:着信ストリームを待ち受け、ストリームを受け取ったときにメッセージを表示する

TcpListenerを使用することで、アドレス127.0.0.1:7878で TCP 接続を待ち受けることができます[1]。アドレスのコロンの前の部分は、あなたのコンピュータを表す IP アドレスです(すべてのコンピュータで同じで、特に著者のコンピュータを表すものではありません)。そして7878はポートです。このポートを選んだ理由は 2 つあります。HTTP は通常このポートでは受け付けられないため、私たちのサーバーはあなたのマシン上で実行している他の Web サーバーと競合する可能性が低く、また 7878 は電話で「rust」と入力すると同じです。

このシナリオでのbind関数は、新しいTcpListenerインスタンスを返す点でnew関数と同じように機能します。この関数がbindと呼ばれるのは、ネットワーキングにおいて、ポートに接続して待ち受けることを「ポートにバインドする」と呼ぶからです。

bind関数はResult<T, E>を返します。これはバインドに失敗する可能性があることを示しています。たとえば、ポート 80 に接続するには管理者特権が必要です(管理者以外は 1023 より高いポートのみを待ち受けることができます)。したがって、管理者でない状態でポート 80 に接続しようとすると、バインドは機能しません。また、たとえば私たちのプログラムの 2 つのインスタンスを実行して同じポートを待ち受けている場合も、バインドは機能しません。学習目的で基本的なサーバーを書いているので、このようなエラーの処理は心配しません。代わりに、エラーが発生した場合にプログラムを停止するためにunwrapを使用します。

TcpListenerincomingメソッドは、ストリームのシーケンスを与えるイテレータを返します[2](より正確には、TcpStream型のストリームです)。単一の「ストリーム」は、クライアントとサーバーの間の開いた接続を表します。「接続」とは、クライアントがサーバーに接続し、サーバーが応答を生成し、サーバーが接続を閉じる、完全な要求と応答のプロセスのことです。したがって、私たちはTcpStreamから読み取ってクライアントが送信した内容を確認し、その後、応答をストリームに書き込んでクライアントにデータを送信します。全体的に、このforループは各接続を順番に処理し、私たちが処理するための一連のストリームを生成します。

今のところ、私たちのストリームの処理は、ストリームにエラーがある場合にunwrapを呼び出してプログラムを終了することで構成されています[3]。エラーがない場合、プログラムはメッセージを表示します[4]。次のリストで成功した場合の機能を追加します。クライアントがサーバーに接続したときにincomingメソッドからエラーを受け取る理由は、実際には接続を反復しているわけではなく、「接続試行」を反復しているからです。接続が成功しない理由はいくつかあり、その多くはオペレーティングシステムに固有のものです。たとえば、多くのオペレーティングシステムは同時にサポートできる開いた接続の数に制限があります。その数を超える新しい接続試行は、いくつかの開いた接続が閉じられるまでエラーを生成します。

このコードを実行してみましょう!ターミナルで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

1 use std::{
    io::{prelude::*, BufReader},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

      2 handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
  3 let buf_reader = BufReader::new(&mut stream);
  4 let http_request: Vec<_> = buf_reader
      5.lines()
      6.map(|result| result.unwrap())
      7.take_while(|line|!line.is_empty())
       .collect();

  8 println!("Request: {:#?}", http_request);
}

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

std::io::preludestd::io::BufReaderをスコープに入れて、ストリームから読み書きするためのトレイトと型にアクセスできるようにします[1]。main関数のforループでは、接続を確立したというメッセージを表示する代わりに、新しいhandle_connection関数を呼び出し、streamを渡します[2]。

handle_connection関数では、streamへの可変参照をラップする新しいBufReaderインスタンスを作成します[3]。BufReaderは、std::io::Readトレイトメソッドへの呼び出しを管理することでバッファリングを追加します。

ブラウザがサーバーに送信する要求の行を収集するために、http_requestという名前の変数を作成します。Vec<_>型注釈を追加することで、これらの行をベクトルに収集することを示します[4]。

BufReaderstd::io::BufReadトレイトを実装しており、これがlinesメソッドを提供します[5]。linesメソッドは、データのストリームを改行バイトを見つけるたびに分割することで、Result<String, std::io::Error>のイテレータを返します。各Stringを取得するには、各Resultに対してマップとunwrapを行います[6]。データが有効な UTF-8 でない場合や、ストリームからの読み取りに問題がある場合、Resultはエラーになる可能性があります。再び、本番用のプログラムはこれらのエラーをもっとスマートに処理する必要がありますが、簡単のためにエラーの場合にプログラムを停止することにします。

ブラウザは、HTTP 要求の終了を 2 つの改行文字を連続して送信することで示します。したがって、ストリームから 1 つの要求を取得するには、空文字列になる行まで行を取得します[7]。行をベクトルに収集したら、それらをきれいなデバッグ形式で表示します[8]。これで、Web ブラウザがサーバーに送信している指示を見ることができます。

このコードを試してみましょう!プログラムを起動し、Web ブラウザで要求を行います。ブラウザではまだエラーページが表示されますが、ターミナルでのプログラムの出力は次のようになります。

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished dev [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 はテキストベースのプロトコルであり、要求は次の形式をとります。

メソッド 要求URI HTTPバージョン CRLF
ヘッダー CRLF
メッセージボディ

最初の行は、クライアントが要求している内容に関する情報を保持する「要求行」です。要求行の最初の部分は、クライアントがこの要求を行っている方法を表す「メソッド」を示します。たとえば、GETPOSTなどです。私たちのクライアントはGET要求を使用しており、これは情報を要求していることを意味します。

要求行の次の部分は/_で、クライアントが要求している「統一資源識別子(Uniform Resource Identifier:URI)」を示します。URI は、「統一資源ロケータ(Uniform Resource Locator:URL)」とほぼ同じですが、少し異なります。本章の目的にとって、URI と URL の違いは重要ではありませんが、HTTP 仕様書では「URI」という用語が使われています。したがって、ここでは「URL」の代わりに「URI」を頭の中で置き換えることができます。

最後の部分は、クライアントが使用する HTTP バージョンで、その後、要求行は CRLF シーケンスで終わります。(CRLF は「復帰キャリッジ」と「改行」を表します。これはタイプライターの時代の用語です!)CRLF シーケンスは、\r\nと書くこともできます。ここで\rは復帰キャリッジ、\nは改行です。「CRLF シーケンス」は、要求行を要求データの残りの部分から区切ります。CRLF が表示されるとき、改行が始まることがわかりますが、\r\nではなくなります。

これまでのプログラムの実行から受け取った要求行データを見ると、GETがメソッド、/_が要求 URI、HTTP/1.1がバージョンであることがわかります。

要求行の後、Host:以降の残りの行はヘッダーです。GET要求にはボディがありません。

別のブラウザから要求を行ったり、異なるアドレス(たとえば、_127.0.0.1:7878/test_)を要求して、要求データがどのように変化するかを確認してみましょう。

これで、ブラウザが何を要求しているかがわかったので、いくつかのデータを返しましょう!

応答を書き込む

クライアント要求に応じてデータを送信する機能を実装します。応答は次の形式をとります。

HTTPバージョン ステータスコード 理由フレーズ CRLF
ヘッダー CRLF
メッセージボディ

最初の行は「ステータス行」で、応答で使用される HTTP バージョン、要求の結果を要約する数値のステータスコード、およびステータスコードのテキスト説明を提供する理由フレーズが含まれています。CRLF シーケンスの後には、任意のヘッダー、もう 1 つの 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

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&mut stream);
    let http_request: Vec<_> = buf_reader
     .lines()
     .map(|result| result.unwrap())
     .take_while(|line|!line.is_empty())
     .collect();

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

  2 stream.write_all(response.3 as_bytes()).unwrap();
}

リスト 20-3:小さな成功した HTTP 応答をストリームに書き込む

最初の新しい行は、成功メッセージのデータを保持するresponse変数を定義します[1]。次に、responseに対してas_bytesを呼び出して、文字列をバイトに変換します[3]。streamwrite_allメソッドは&[u8]を取り、それらのバイトを直接接続を通じて送信します[2]。write_all操作は失敗する可能性があるため、以前と同じようにエラー結果に対してunwrapを使用します。再び、本番用のアプリケーションではここにエラー処理を追加する必要があります。

これらの変更を加えて、コードを実行して要求を行いましょう。ターミナルにはもはやデータを表示していないので、Cargo の出力以外には何も出力されません。Web ブラウザで127.0.0.1:7878を読み込むと、エラーの代わりに空白のページが表示されるはずです。あなたは、HTTP 要求を受け取り、応答を送信するための手動コーディングをしたばかりです!

本物の HTML を返す

空白のページだけでなく、より多くの内容を返す機能を実装しましょう。プロジェクトディレクトリのルートに、srcディレクトリではなく新しいファイルhello.htmlを作成します。好きな HTML を入力できます。リスト 20-4 に 1 つの例を示します。

ファイル名:hello.html

<!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>

リスト 20-4:応答で返すサンプル HTML ファイル

これは、見出しといくつかのテキストがある最小限の HTML5 ドキュメントです。要求があったときにこれをサーバーから返すには、handle_connectionをリスト 20-5 のように変更して、HTML ファイルを読み取り、応答の本文として追加して送信します。

ファイル名:src/main.rs

use std::{
  1 fs,
    io::{prelude::*, BufReader},
    net::{TcpListener, TcpStream},
};
--snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&mut 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();

  2 let response = format!(
        "{status_line}\r\n\
         Content-Length: {length}\r\n\r\n\
         {contents}"
    );

    stream.write_all(response.as_bytes()).unwrap();
}

リスト 20-5:hello.htmlの内容を応答の本文として送信する

use文にfsを追加して、標準ライブラリのファイルシステムモジュールをスコープに入れます[1]。ファイルの内容を文字列に読み取るコードはおそらくおなじみでしょう。リスト 12-4 の I/O プロジェクトでファイルの内容を読み取るときに使用しました。

次に、format!を使用して、成功応答の本文としてファイルの内容を追加します[2]。有効な 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 ファイルを返す前に、ブラウザが*/を要求しているかどうかを確認する機能を追加し、ブラウザがそれ以外の何かを要求した場合はエラーを返すようにしましょう。このために、handle_connectionをリスト 20-6 のように変更する必要があります。この新しいコードは、受け取った要求の内容を、/*の要求がどのように見えるかを知っているものと照合し、要求を異なる方法で処理するためのifelseブロックを追加します。

ファイル名:src/main.rs

--snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&mut stream);
  1 let request_line = buf_reader
      .lines()
      .next()
      .unwrap()
      .unwrap();

  2 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\n\
             Content-Length: {length}\r\n\r\n\
             {contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
  3 } else {
        // some other request
    }
}

リスト 20-6:*/*への要求とその他の要求を異なる方法で処理する

HTTP 要求の最初の行だけを見ることにしているので、要求全体をベクトルに読み込む代わりに、nextを呼び出してイテレータから最初の項目を取得します[1]。最初のunwrapOptionを処理し、イテレータに項目がない場合にプログラムを停止します。2 番目のunwrapResultを処理し、リスト 20-2 で追加したmap内のunwrapと同じ効果を持ちます。

次に、request_lineをチェックして、*/*パスへの GET 要求の要求行と一致するかどうかを確認します[2]。一致する場合は、ifブロックが HTML ファイルの内容を返します。

request_lineが*/*パスへの GET 要求と一致しない場合、それは他の要求を受け取ったことを意味します。elseブロック[3]に、他のすべての要求に応答するコードを追加します。

このコードを実行して、127.0.0.1:7878を要求してください。hello.html内の HTML が表示されるはずです。127.0.0.1:7878/something-elseのような他の要求を行うと、リスト 20-1 とリスト 20-2 のコードを実行したときに見たような接続エラーが表示されます。

次に、リスト 20-7 のコードをelseブロックに追加して、ステータスコード 404 を返す応答を返します。これは、要求のコンテンツが見つからないことを示します。また、エンドユーザーに対する応答を示すために、ブラウザでレンダリングするためのページ用の HTML も返します。

ファイル名:src/main.rs

--snip--
} else {
  1 let status_line = "HTTP/1.1 404 NOT FOUND";
  2 let contents = fs::read_to_string("404.html").unwrap();
    let length = contents.len();

    let response = format!(
        "{status_line}\r\n\
         Content-Length: {length}\r\n\r\n
         {contents}"
    );

    stream.write_all(response.as_bytes()).unwrap();
}

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

ここでは、応答にはステータスコード 404 と理由フレーズNOT FOUNDが含まれるステータス行があります[1]。応答の本文は、ファイル404.html内の HTML になります[1]。エラーページ用に、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

--snip--

fn handle_connection(mut stream: TcpStream) {
    --snip--

    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\n\
         Content-Length: {length}\r\n\r\n\
         {contents}"
    );

    stream.write_all(response.as_bytes()).unwrap();
}

リスト 20-9:ifelseブロックをリファクタリングして、両方のケースで異なるコードのみを含むようにする

今では、ifelseブロックは、タプルでステータス行とファイル名に適切な値を返すだけです。その後、第 18 章で説明したlet文のパターンを使って、これらの 2 つの値をstatus_linefilenameに代入するために構文分解を使用します。

以前は重複していたコードは、今ではifelseブロックの外にあり、status_linefilename変数を使用しています。これにより、2 つのケースの違いを把握しやすくなり、ファイル読み取りと応答書き込みの仕組みを変更したい場合には、コードを更新する箇所が 1 か所になります。リスト 20-9 のコードの動作は、リスト 20-8 のものと同じです。

素晴らしい!これで、約 40 行の Rust コードでシンプルな Web サーバーができました。この Web サーバーは、1 つの要求に対してコンテンツのページを返し、その他のすべての要求に対して 404 応答を返します。

現在、私たちのサーバーは単一スレッドで実行されており、一度に 1 つの要求のみを処理できます。いくつかの低速な要求をシミュレートすることで、それがどのような問題になるかを調べましょう。その後、サーバーが一度に複数の要求を処理できるように修正します。

まとめ

おめでとうございます!シングルスレッドの Web サーバーを構築する実験を完了しました。技術力を向上させるために、LabEx でさらに実験を行って練習してください。