はじめに

本シリーズでは、POSIX API を使用して C++ で HTTP サーバーをゼロから構築する方法を解説し、ブラウザに対して CRUD 操作が可能な Web ページを提供できるサーバーを作成します。また、同時に複数のクライアントを処理できるようにします!

注: C++ 17 を使用しています。このプロジェクトは複数回に分けて解説します(現時点では 4〜5 回のブログを予定しています)。

このプロジェクトで学べること

まず、TCP ソケットがどのように動作し、サーバーを構築するためにどのように使用するかを学びます。また、HTTP の仕組みとその解析方法、Web ページの作成方法とブラウザへの提供方法、そして複数のクライアントに数百万リクエストを処理しようとするとアーキテクチャがどれほど複雑になるかを理解できるようになります。なお、数百万リクエストを処理するサーバーは作成しませんが、このブログ記事を提供している実際のサーバーの仕組みを理解していただければと思います。

早速、シリーズの最初の部分である TCP ソケットから始めましょう!

準備

以下のインクルードを使用します:

#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <string>

Enter fullscreen mode Exit fullscreen mode

ソケットの作成

サーバーがリクエストを受け付け、レスポンスを返すためには、サーバーとクライアントが接続(相互に通信)できる媒体が必要です。そのためにソケットを使用します。ソケットは、ネットワーク上で動作する 2 つのプログラム間の通信の終端点と考えることができます。

int server_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (server_fd < 0)
{
    std::cerr << "error creating the socket" << std::endl;
    return -1;
}

Enter fullscreen mode Exit fullscreen mode

上記のコードはソケットを作成します。AF_INETIPv4 を使用することを意味します。SOCK_STREAMTCP を使用することを意味します。IPPROTO_TCPTCP プロトコルを使用することを意味します。代わりに最後の引数として 0 を渡すこともでき、その場合、カーネルはソケットの種類に基づいてプロトコルを判断します(この場合は SOCK_STREAM を使用しているため TCP になります)。

socket 関数は整数型のファイル記述子を返します。この記述子を使用してソケットを参照できます。ファイル記述子は、このソケットの一意な識別子と考えることができます。ファイル記述子が -1 の場合、ソケットの作成に失敗したことを意味します。

ソケット作成後に以下のコードを追加することをお勧めします:

int opt{1};
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

Enter fullscreen mode Exit fullscreen mode

コードをテストする際、サーバーを複数回起動する必要があるでしょう(私もそうしました)。しかし、上記のステップを実行しない場合、アドレスがすでに使用中であることを示すエラーが発生します。これは、サーバーが閉じられた後、ソケットが TIME_WAIT 状態のままになるために発生します。上記のコードはこれを防ぎ、サーバーが閉じられた直後に同じアドレスを再利用できるようにします。

ソケットをアドレスにバインドする

ソケットは作成できましたが、まだどのアドレスにも接続されていません。したがって、アドレスにバインドする必要があります。アドレスは IP アドレスとポート番号の組み合わせです。

struct sockaddr_in server_addr{};
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);

if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
{
    std::cerr << "error binding to the socket" << std::endl;
    return -1;
}

Enter fullscreen mode Exit fullscreen mode

ここで server_addr はサーバーのアドレスを保持する構造体です。sin_addr.s_addr = INADDR_ANY は、サーバーが利用可能なすべてのインターフェースでリッスンすることを意味します。sin_family = AF_INETIPv4 を使用することを意味します。sin_port = htons(8080) はポート 8080 を使用することを意味します。htons() はポート番号をネットワークバイトオーダーに変換するために使用されます。

bind() 関数を使用してサーバーをバインドします。この関数は、ソケットのファイル記述子、サーバーのアドレス、アドレスのサイズを引数として取ります。戻り値が -1 の場合、ソケットのバインドに失敗したことを意味します。

着信接続のリッスン

サーバーは特定のアドレスにバインドされました。つまり、サーバーには「家」があります。しかし、まだ閉鎖されており、訪問者を受け付けていません。したがって、着信接続をリッスンするようにする必要があります。

if (listen(server_fd, 10) < 0){
    std::cerr << "error listening on the socket" << std::endl;
    return -1;
}

Enter fullscreen mode Exit fullscreen mode

listen() 関数は、カーネルにソケットがパッシブソケットであることを伝え、着信接続を受け付けるために使用すべきであることを通知します。第 2 引数の 10 は、キューに入れられる保留中の接続の最大数です。キューが満杯の場合、着信接続は拒否されます。戻り値が -1 の場合、ソケットのリッスンに失敗したことを意味します。

すべてをまとめる

サーバーの準備ができたので、接続の受け付けを開始できます。その前に、上記のコードをすべて startServer() という関数にまとめます:

int startServer()
{
    int server_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (server_fd < 0)
    {
        std::cerr << "error creating the socket" << std::endl;
        return -1;
    }

    int opt{1};
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in server_addr{};
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8080);

    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
    {
        std::cerr << "error binding to the socket" << std::endl;
        return -1;
    }

    if (listen(server_fd, 10) < 0)
    {
        std::cerr << "error listening on the socket" << std::endl;
        return -1;
    }

    return server_fd;
}

Enter fullscreen mode Exit fullscreen mode

着信接続の受け付け

この関数を getClient() と呼びます。

int getClient(int server_fd)
{
    struct sockaddr_in client_addr{};
    socklen_t client_len = sizeof(client_addr);
    int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
    if (client_fd < 0)
    {
        std::cerr << "error accepting the client" << std::endl;
        return -1;
    }

    std::cout << "client connected to socket successfully! \n";
    return client_fd;
}

Enter fullscreen mode Exit fullscreen mode

上記のコードは、サーバーのファイル記述子に対して accept() を呼び出して接続を受け付け、クライアントのアドレスを client_addr 構造体に格納します。成功した場合はクライアントのファイル記述子を返し、失敗した場合は -1 を返します。client_fd は、クライアントのソケットの一意な識別子と理解できます。

クライアントとサーバー間の通信

サーバーが実行され、クライアントを受け付けているので、次はクライアントのリクエストを受け付け、適切なレスポンスを提供する必要があります。そのためには、クライアントからデータを受信し、クライアントにデータを送信する必要があります。

クライアントからデータを受信する

char buffer[8193]{};
int bytes = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
buffer[bytes] = '\0';

Enter fullscreen mode Exit fullscreen mode

recv() 関数を使用してクライアントからデータを受信します。この関数には、どのクライアントと通信しているかを識別するためのクライアントのファイル記述子、データを格納するためのバッファ、バッファのサイズ、そして最後にデフォルト動作のためのフラグ 0 が必要です:データを受信するまでブロックし、内部ソケットバッファからデータを読み取り、それをバッファにコピーし、重複受信を避けるためにソケットバッファから削除します。受信したバイト数を返し、失敗した場合は -1、クライアントが接続を閉じた場合は 0 を返します。

クライアントにデータを送信する

// res は std::string 型で、送信するデータを含みます。
if (send(client_fd, res.c_str(), res.size(), 0) < 0)
{
    std::cerr << "Error sending reply: " << res << "\n";
}

Enter fullscreen mode Exit fullscreen mode

send() 関数を使用してクライアントにデータを送信します。この関数には、どのクライアントと通信しているかを識別するためのクライアントのファイル記述子、送信するデータ(C スタイルの文字列 char * として)、および送信するデータのサイズが必要です。送信したバイト数を返し、失敗した場合は -1 を返します。

リクエストの継続的な処理

クライアントは 1 回のリクエストだけを行うわけではありません。複数のリクエストを行うことができます。したがって、継続的に処理する必要があります。while ループを使用することでこれを実現できます。handleClient() という関数を作成し、ループ内に recvsend 関数をまとめます。

void handleClient(int client_fd)
{
    while (true)
    {
        char buffer[8193]{};
        int bytes = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
        if (bytes < 0)
        {
            std::cerr << "Error receiving message \n";
            break;
        }
        if (bytes == 0)
        {
            std::cerr << "Client Disconnected \n";
            break;
        }

        buffer[bytes] = '\0';

        /**
        リクエストの解析
        (今は無視してください。次のブログ記事で説明します)
        **/
        HttpRequest req = parse_request(buffer);
        auto error = isValidRequest(req);
        HttpResponse res{};
        if (error)
        {
            res = error.value();
        }
        else
        {
            res = route(req);
        }

        /**
        適切な HTTP レスポンスの構築
        (今は無視してください。次のブログ記事で説明します)
        **/
        std::string http_res = build_response(res);

        std::cout << "Received:\n" << buffer << "\n";
        std::cout << "Sending:\n" << http_res << "\n";

        if (send(client_fd, http_res.c_str(), http_res.size(), 0) < 0)
        {
            std::cerr << "Error sending reply: " << http_res << "\n";
        }
    }
    close(client_fd);
    std::cout << "Client: " << client_fd << " Has Disconnected\n";
}

Enter fullscreen mode Exit fullscreen mode

上記のコードは、データの受信と送信のためのコードスニペットに比べてかなり多いです。ここで実際に何が起こっているのでしょうか? while ループを使用して、クライアントが接続を閉じずに継続的にリクエストを送信できるようにしています。recv はブロッキングコールであることを忘れないでください。つまり、クライアントからデータを受信するか、クライアントが接続を閉じるまで停止します。データを受信してブロックが解除された後、受信に失敗した場合(bytes < 0)やクライアントが切断した場合(bytes == 0)に while ループを中断します。それ以外の場合は、C 文字列を解析する関数が安全に読み取れるようにバッファを明示的にヌル終端します(buffer[bytes] = '\0')。最後に、ループが中断されたときに close(client_fd) を呼び出して接続を閉じます。

データの受信に成功したら、parse_request(buffer) 関数を呼び出してリクエストを解析し、HttpRequest 構造体に格納します。この関数については次のブログ記事で説明します。次に、isValidRequest(req) を使用してリクエストが有効かどうかを確認します。リクエストが無効な場合は、send_error_response() を使用してエラーレスポンスを送信します。それ以外の場合は、route(req) を使用してリクエストを適切なハンドラーにルーティングします。最後に、build_response(res) を使用して HTTP レスポンスを構築し、send(client_fd, res.c_str(), res.size(), 0) を使用してクライアントに送信します。これで完了です。

受信 -> 解析 -> 検証 -> レスポンスの構築 -> 送信

すべてを接続する

必要な関数がすべて揃ったので、main() 関数にすべてをまとめます。

int main()
{
    int server_fd = startServer();
    if (server_fd < 0)
    {
        return 1;
    }
    std::cout << "Server started on port 8080" << std::endl;
    while (true)
    {
        int client_fd = getClient(server_fd);
        if (client_fd < 0)
        {
            continue;
        }
        handleClient(client_fd);
    }
    // 以下のコードは while ループが無限に実行されるため、決して実行されません。
    close(server_fd);
    return 0;
}

Enter fullscreen mode Exit fullscreen mode

サーバーが無期限に実行され、クライアントを待機するように while ループを使用します。(注: この初期段階では、handleClient() は単一スレッドで順次接続を処理します。このシリーズの今後の部分では、マルチスレッドを使用してサーバーが複数のクライアント接続を同時に処理できるようにします)

サーバーをテストしたい場合

"今は無視してください" とコメントされている部分のコードを削除し、send() の引数を http_res.c_str()http_res.size() ではなく、それぞれ bufferbytes に変更します。

ファイルを server.cpp という名前で保存します。

次に、以下のコマンドを使用してバイナリファイルを生成し、1 つのターミナルで実行します:

g++ -std=c++17 -Wall server.cpp -o server

Enter fullscreen mode Exit fullscreen mode

./server

Enter fullscreen mode Exit fullscreen mode

次に、別のターミナルで nc localhost 8080 と入力し、そのターミナルで Hello! のような任意のテキストを入力して Enter キーを押します。同じテキストがターミナルに表示されるはずです。入力後、ターミナルが「スタック」したり「待機」しているように見えますが、これは接続がまだ開いており、サーバーがさらなるリクエストを待機しているため正常です。接続を閉じるには、Ctrl+C を押します。

次回予告

次のブログ記事では、HTTP リクエストの解析HTTP レスポンスの構築 について説明します。