Веб-сервер на C++ и сокетах

38 комментариев

Создадим HTTP-сервер, который обрабатывает запросы браузера и возвращает ответ в виде HTML-страницы.

Введение в HTTP

Для начала разберемся, что из себя представляет HTTP. Это текстовый протокол для обмена данными между браузером и веб-сервером.

Пример HTTP-запроса:

GET /page.html HTTP/1.1
Host: site.com

Первая строка передает метод запроса, идентификатор ресурса (URI) и версию HTTP-протокола. Затем перечисляются заголовки запроса, в которых браузер передает имя хоста, поддерживаемые кодировки, cookie и другие служебные параметры. После каждого заголовка ставится символ переноса строки \r\n.

У некоторых запросов есть тело. Когда отправляется форма методом POST, в теле запроса передаются значения полей этой формы.

POST /submit HTTP/1.1
Host site.com
Content-Type: application/x-www-form-urlencoded

name=Sergey&last_name=Ivanov&birthday=1990-10-05

Тело запроса отделяется от заголовков одной пустой строкой. Заголовок «Content-Type» говорит серверу, в каком формате закодировано тело запроса. По умолчанию, в HTML-форме данные кодируются методом «application/x-www-form-urlencoded».

Иногда необходимо передать данные в другом формате. Например, при загрузке файлов на сервер, бинарные данные кодируются методом «multipart/form-data».

Сервер обрабатывает запрос клиента и возвращает ответ.

Пример ответа сервера:

HTTP/1.1 200 OK
Host: site.com
Content-Type: text/html; charset=UTF-8
Connection: close
Content-Length: 21

<h1>Test page...</h1>

В первой строке ответа передается версия протокола и статус ответа. Для успешных запросов обычно используется статус «200 OK». Если ресурс не найден на сервере, возвращается «404 Not Found».

Тело ответа так же, как и у запроса, отделяется от заголовков одной пустой строкой.

Полная спецификации протокола HTTP описывается в стандарте rfc-2068. По понятным причинам, мы не будем реализовывать все возможности протокола в рамках этого материала. Достаточно реализовать поддержку работы с заголовками запроса и ответа, получение метода запроса, версии протокола и URL-адреса.

Что будет делать сервер?

Сервер будет принимать запросы клиентов, парсить заголовки и тело запроса, и возвращать тестовую HTML-страничку, на которой отображены данные запроса клиента (запрошенный URL, метод запроса, cookie и другие заголовки).

Пример ответа от сервера

О сокетах

Для работы с сетью на низком уровне традиционно используют сокеты. Сокет — это абстракция, которая позволяет работать с сетевыми ресурсами, как с файлами. Мы можем писать и читать данные из сокета почти так же, как из обычного файла.

В этом материале мы будем работать с виндовой реализацией сокетов, которая находится в заголовочном файле <WinSock2.h>. В Unix-подобных ОС принцип работы с сокетами такой же, только отличается API. Вы можете подробнее почитать о сокетах Беркли, которые используются в GNU/Linux.

Создание сокета

Создадим сокет с помощью функции socket, которая находится в заголовочном файле <WinSock2.h>. Для работы с IP-адресами нам понадобится заголовочный файл <WS2tcpip.h>.

#include <iostream>
#include <sstream>
#include <string>

// Для корректной работы freeaddrinfo в MinGW
// Подробнее: http://stackoverflow.com/a/20306451
#define _WIN32_WINNT 0x501

#include <WinSock2.h>
#include <WS2tcpip.h>

// Необходимо, чтобы линковка происходила с DLL-библиотекой 
// Для работы с сокетам
#pragma comment(lib, "Ws2_32.lib")

using std::cerr;

int main()
{
    // служебная структура для хранение информации
    // о реализации Windows Sockets
    WSADATA wsaData;

    // старт использования библиотеки сокетов процессом
    // (подгружается Ws2_32.dll)
    int result = WSAStartup(MAKEWORD(2, 2), &wsaData);

    // Если произошла ошибка подгрузки библиотеки
    if (result != 0) {
        cerr << "WSAStartup failed: " << result << "\n";
        return result;
    }

    struct addrinfo* addr = NULL; // структура, хранящая информацию
    // об IP-адресе  слущающего сокета

    // Шаблон для инициализации структуры адреса
    struct addrinfo hints;
    ZeroMemory(&hints, sizeof(hints));

    // AF_INET определяет, что используется сеть для работы с сокетом
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM; // Задаем потоковый тип сокета
    hints.ai_protocol = IPPROTO_TCP; // Используем протокол TCP
    // Сокет биндится на адрес, чтобы принимать входящие соединения
    hints.ai_flags = AI_PASSIVE;

    // Инициализируем структуру, хранящую адрес сокета - addr.
    // HTTP-сервер будет висеть на 8000-м порту локалхоста
    result = getaddrinfo("127.0.0.1", "8000", &hints, &addr);

    // Если инициализация структуры адреса завершилась с ошибкой,
    // выведем сообщением об этом и завершим выполнение программы 
    if (result != 0) {
        cerr << "getaddrinfo failed: " << result << "\n";
        WSACleanup(); // выгрузка библиотеки Ws2_32.dll
        return 1;
    }

    // Создание сокета
    int listen_socket = socket(addr->ai_family, addr->ai_socktype,
        addr->ai_protocol);
    // Если создание сокета завершилось с ошибкой, выводим сообщение,
    // освобождаем память, выделенную под структуру addr,
    // выгружаем dll-библиотеку и закрываем программу
    if (listen_socket == INVALID_SOCKET) {
        cerr << "Error at socket: " << WSAGetLastError() << "\n";
        freeaddrinfo(addr);
        WSACleanup();
        return 1;
    }

    // ...

Мы подготовили все данные, которые необходимо для создания сокета и создали сам сокет. Функция socket возвращает целочисленное значение файлового дескриптора, который выделен операционной системой под сокет.

Привязка сокета к адресу (bind)

Следующим шагом, нам необходимо привязать IP-адрес к сокету, чтобы он мог принимать входящие соединения. Для привязки конкретного адреса к сокету используется фукнция bind. Она принимает целочисленный идентификатор файлового дескриптора сокета, адрес (поле ai_addr из структуры addrinfo) и размер адреса в байтах (используется для поддержки IPv6).

// Привязываем сокет к IP-адресу
result = bind(listen_socket, addr->ai_addr, (int)addr->ai_addrlen);

// Если привязать адрес к сокету не удалось, то выводим сообщение
// об ошибке, освобождаем память, выделенную под структуру addr.
// и закрываем открытый сокет.
// Выгружаем DLL-библиотеку из памяти и закрываем программу.
if (result == SOCKET_ERROR) {
    cerr << "bind failed with error: " << WSAGetLastError() << "\n";
    freeaddrinfo(addr);
    closesocket(listen_socket);
    WSACleanup();
    return 1;
}

Подготовка сокета к принятию входящих соединений (listen)

Подготовим сокет к принятию входящих соединений от клиентов. Это делается с помощью функции listen. Она принимает дескриптор слушающего сокета и максимальное количество одновременных соединений.

В случае ошибки, функция listen возращает значение константы SOCKET_ERROR. При успешном выполнении она вернет 0.

// Инициализируем слушающий сокет
if (listen(listen_socket, SOMAXCONN) == SOCKET_ERROR) {
    cerr << "listen failed with error: " << WSAGetLastError() << "\n";
    closesocket(listen_socket);
    WSACleanup();
    return 1;
}

В константе SOMAXCONN хранится максимально возможное число одновременных TCP-соединений. Это ограничение работает на уровне ядра ОС.

Ожидание входящего соединения (accept)

Функция accept ожидает запрос на установку TCP-соединения от удаленного хоста. В качестве аргумента ей передается дескриптор слушающего сокета.

При успешной установке TCP-соединения, для него создается новый сокет. Функция accept возвращает дескриптор этого сокета. Если произошла ошибка соединения, то возвращается значение INVALID_SOCKET.

// Принимаем входящие соединения
int client_socket = accept(listen_socket, NULL, NULL);
if (client_socket == INVALID_SOCKET) {
    cerr << "accept failed: " << WSAGetLastError() << "\n";
    closesocket(listen_socket);
    WSACleanup();
    return 1;
}

Получение запроса и отправка ответа

После установки соединение с сервером, браузер отправляет HTTP-запрос. Мы получаем содержимое запроса через функцию recv. Она принимает дескриптор TCP-соединения (в нашем случае это client_socket), указатель на буфер для сохранения полученных данных, размер буфера в байтах и дополнительные флаги (которые сейчас нас не интересуют).

При успешном выполнении функция recv вернет размер полученных данных. В случае ошибки возвращается значение SOCKET_ERROR. Если соединение было закрыто клиентом, то возвращается 0.

Мы создадим буфер размером 1024 байта для сохранения HTTP-запроса.

const int max_client_buffer_size = 1024;
char buf[max_client_buffer_size];

result = recv(client_socket, buf, max_client_buffer_size, 0);

std::stringstream response; // сюда будет записываться ответ клиенту
std::stringstream response_body; // тело ответа

if (result == SOCKET_ERROR) {
    // ошибка получения данных
    cerr << "recv failed: " << result << "\n";
    closesocket(client_socket);
} else if (result == 0) {
    // соединение закрыто клиентом
    cerr << "connection closed...\n";
} else if (result > 0) {
    // Мы знаем фактический размер полученных данных, поэтому ставим метку конца строки
    // В буфере запроса.
    buf[result] = '\0';

    // Данные успешно получены
    // формируем тело ответа (HTML)
    response_body << "<title>Test C++ HTTP Server</title>\n"
        << "<h1>Test page</h1>\n"
        << "<p>This is body of the test page...</p>\n"
        << "<h2>Request headers</h2>\n"
        << "<pre>" << buf << "</pre>\n"
        << "<em><small>Test C++ Http Server</small></em>\n";

    // Формируем весь ответ вместе с заголовками
    response << "HTTP/1.1 200 OK\r\n"
        << "Version: HTTP/1.1\r\n"
        << "Content-Type: text/html; charset=utf-8\r\n"
        << "Content-Length: " << response_body.str().length()
        << "\r\n\r\n"
        << response_body.str();

    // Отправляем ответ клиенту с помощью функции send
    result = send(client_socket, response.str().c_str(),
        response.str().length(), 0);

    if (result == SOCKET_ERROR) {
        // произошла ошибка при отправле данных
        cerr << "send failed: " << WSAGetLastError() << "\n";
    }
    // Закрываем соединение к клиентом
    closesocket(client_socket);
}

После получения запроса мы сразу же отправили ответ клиенту с помощью функции send. Она принимает дескриптор сокета, строку с данными ответа и размер ответа в байтах.

В случае ошибки, функция возвращает значение SOCKET_ERROR. В случае успеха — количество переданных байт.

Попробуем скомпилировать программу, не забыв предварительно завершить функцию main.

    // Убираем за собой
    closesocket(listen_socket);
    freeaddrinfo(addr);
    WSACleanup();
    return 0;
}

Весь исходный код примера.

Если скомпилировать и запустить программу, то окно консоли «подвиснет» в ожидании запроса на установление TCP-соединения. Откройте в браузере адрес http://127.0.0.1:8000/. Сервер вернет ответ, как на рисунке ниже и завершит работу.

Пример ответа от сервера

Последовательная обработка запросов

Чтобы сервер не завершал работу после обработки первого запроса, а продолжал обрабатывать новые соединения, нужно зациклить ту часть кода, которая принимает запрос на установку соединения и возвращает ответ.

const int max_client_buffer_size = 1024;
char buf[max_client_buffer_size];
int client_socket = INVALID_SOCKET;

for (;;) {
    // Принимаем входящие соединения
    client_socket = accept(listen_socket, NULL, NULL);
    if (client_socket == INVALID_SOCKET) {
        cerr << "accept failed: " << WSAGetLastError() << "\n";
        closesocket(listen_socket);
        WSACleanup();
        return 1;
    }

    result = recv(client_socket, buf, max_client_buffer_size, 0);

    std::stringstream response; // сюда будет записываться ответ клиенту
    std::stringstream response_body; // тело ответа

    if (result == SOCKET_ERROR) {
        // ошибка получения данных
        cerr << "recv failed: " << result << "\n";
        closesocket(client_socket);
    } else if (result == 0) {
        // соединение закрыто клиентом
        cerr << "connection closed...\n";
    } else if (result > 0) {
        // Мы знаем размер полученных данных, поэтому ставим метку конца строки
        // В буфере запроса.
        buf[result] = '\0';

        // Данные успешно получены
        // формируем тело ответа (HTML)
        response_body << "<title>Test C++ HTTP Server</title>\n"
            << "<h1>Test page</h1>\n"
            << "<p>This is body of the test page...</p>\n"
            << "<h2>Request headers</h2>\n"
            << "<pre>" << buf << "</pre>\n"
            << "<em><small>Test C++ Http Server</small></em>\n";

        // Формируем весь ответ вместе с заголовками
        response << "HTTP/1.1 200 OK\r\n"
            << "Version: HTTP/1.1\r\n"
            << "Content-Type: text/html; charset=utf-8\r\n"
            << "Content-Length: " << response_body.str().length()
            << "\r\n\r\n"
            << response_body.str();

        // Отправляем ответ клиенту с помощью функции send
        result = send(client_socket, response.str().c_str(),
            response.str().length(), 0);

        if (result == SOCKET_ERROR) {
            // произошла ошибка при отправле данных
            cerr << "send failed: " << WSAGetLastError() << "\n";
        }
        // Закрываем соединение к клиентом
        closesocket(client_socket);
    }
}

Когда сервер закончит обработку запроса одного клиента, он закроет соединение с ним и будет ожидать нового запроса.

Исходный код окончательной версии сервера.

Во второй части этой статьи мы напишем парсер HTTP-заголовков и создадим нормальное API для управления HTTP-запросами и ответами.

Примечание: если вы используете MinGW в Windows, то библиотеку Ws2_32.lib нужно вручную прописать в настройках линковщика.

Комментарии к статье: 38

Подождите, загружаются комментарии...

Возможность комментировать эту статью отключена автором. Возможно, во всем виновата её провокационная тематика или большое обилие флейма от предыдущих комментаторов.

Если у вас есть вопросы по содержанию статьи, рекомендуем вам обратиться за помощью на наш форум.