Предисловие
В этой статье я расскажу про свою C++ библиотеку KASWeb (Kandelaber's Async & Safe Web requests), ставящей во главу своих принципов удобство и безопасность асинхронного кода для выполнения web запросов и реакций на их результаты.
Предыстория и мотивация
Как-то раз мне захотелось написать лаунчер для Minecraft сервера, который должен был скачивать игровой клиент, сборку модификаций и выполнять прочие запросы к REST API. Для этой задачи я выбрал libCurl ввиду его легковесности, но использование его в "голом" виде усложнило бы поддержку кода. К тому же мне захотелось создать для себя инструмент, который бы облегчил разработку последующих программ и на котором мне самому было бы приятно писать код. Так и зародилась идея о создании обёртки над libCurl, переросшей в универсальное решение, предоставляющей удобный синтаксис и гарантию безопасности асинхронного кода.
Основные идеи
Одним из условий удобства библиотеки я посчитал самодостаточность каждого запроса. Это значит, что он не нуждается в централизованной системе, которая бы настраивала и исполняла его. Достаточно лишь создать объект запроса и, если нужно, прямо в него установить header, requestBody и прочие параметры, как, например, timeout. Тем не менее минимальный сценарий использования состоит всего из двух строк:
//GET-запрос
AsyncRequestGET request;
request.get("https://example.com");
Как следует из названия класса, запрос выполняется асинхронно. Но ведь его результат надо как-то отслеживать! Вся информация об ответе сервера записывается в служебный класс WebResponseState, сокрытый от пользователя библиотеки. Вместо этого ему предлагается WebAsyncResponse, предоставляющий интерфейс чтения WebResponseState и удерживающий его в "живом" состоянии благодаря std::shared_ptr.
//Структура WebAsyncResponse
class WebAsyncResponse
{
//friend связь обсуловлена необходимостью предоставить WebAsyncRequest исключительное право на создание WebAsyncResponse
friend class Details::WebAsyncRequest;
private:
std::shared_ptr<Details::WebResponseState> state;
//Запрещаем создание вне WebAsyncRequest
WebAsyncResponse() = default;
public:
int getHttpStatus() const;
bool isReady() const;
//Копируем строку во избежание висячей ссылки
std::string getResponse() const;
bool isFailed() const;
//Копируем строку во избежание висячей ссылки
std::string getErrorMessage() const;
//Вовзращает прогресс загрузки 0.f - 1.f (currentDataSize/expectedDataSize)
float getProgress() const;
size_t getCurrentDataSize() const;
size_t getExpectedDataSize() const;
};
/*
Пока существует хотя бы один отслеживатель состояния ответа сервера, WebResponseState не умрёт. При отсутствии хотя бы одного WebAsyncResponse этот объект продолжает жить до конца выполнения запроса, так как лямбда в исполняющем его потоке также удерживает std::shared_ptr на WebResponseState.
*/
Почему же в методах getResponse() и getErrorMessage() возвращается копия std::string, а не ссылка? Если бы они возвращали const std::string&, то после завершения выполнения запроса могли бы возникнуть висячие ссылки, что привело бы к неопределённому поведению программы.
Хорошо, мы обзавелись доступом к чтению WebResponseState. Теперь наш код выглядит так:
//Инициализация библиотеки
KASWeb::initialize();
//GET-запрос
AsyncRequestGET request;
WebAsyncResponse response = request.get("https://example.com");
//WebAsyncResponse без проблем копируется для получения безопасного доступа к WebResponseState из разных частей программы
//Имитация блокирующего ожидания
while(!response.isReady())
std::this_thread::sleep_for(std::chrono::milliseconds(200));
//Обработка ответа
if(response.isFailed())
std::cerr<<"Error: "<<response.getErrorMessage()<<std::endl;
else
{
std::cout<<"Http status: "<<response.getHttpStatus()<<std::endl;
std::cout<<"Response: "<<response.getResponse()<<std::endl;
}
Но такой вариант блокирует поток. Значит, нам необходимо реализовать асинхронный обработчик. Рассмотрим два варианта обработки ответов:
Простой сценарий — функция или лямбда без захвата
Пример использования №1
using namespace KASWeb;
//Создание объекта запроса
AsyncRequestGET request;
//Привязка лямбды (захват в лямбду блокируется)
request.bindReaction([](WebAsyncResponse response)
{
if(response.isFailed())
std::cerr<<response.getErrorMessage()<<std::endl;
else
{
std::cout<<"HTTP code: "<<response.getHttpStatus()<<std::endl;
std::cout<<"Response:\n"<<response.getResponse()<<std::endl;
}
});
//Исполнение запроса
request.get("https://example.com");
Все объекты запросов (AsyncRequestGET, AsyncRequestPOST и т.п.) наследуются от класса WebAsyncRequest, удерживающий std::shared_ptr на ReactionHandler
//Служебный класс, представляющий с��бой каркас запроса. Сокрыт от пользователя в пространстве KASWeb::Details
class WebAsyncRequest
{
protected:
//Указатель на абстрактный ReactionHandler
std::shared_ptr<Details::ReactionHandler> bridge;
//Указатель на исполнитель запроса. Позволяет подменить стандартную реализацию http методов через libCurl
std::shared_ptr<INetworkProvider> provider;
WebAsyncResponse buildResponse(std::shared_ptr<Details::WebResponseState> state);
//Метод для запуска асинхронных задач
template<typename Callable>
void enqueueWebTask(Callable&& task);
public:
//Конструктор запроса
explicit WebAsyncRequest(std::unique_ptr<INetworkProviderFactory> factory);
//Привязка реакции с механизмом lifetoken (для сложных сценариев использования библиотеки)
void bindReaction(ResponseToken& token, std::unique_ptr<Details::IResponseReaction> reaction);
//Привязка функций и лямбд без захвата (для простых сценариев использования бибилотеки)
void bindReaction(void (*callback)(WebAsyncResponse response) );
//Установка заголовков запроса
void setHeaders(std::vector<std::string> headers);
//Добавление заголовка запроса
void addHeader(const std::string& header);
//Установка timeout
void setTimeout(size_t milliseconds);
};
Механизм с lifetoken рассмотрен далее
ReactionHandler — абстрактный класс, необходимый для создания универсального указателя на привязанные реакции для простого и сложного сценариев.
Сложный сценарий — когда лямбд без захвата недостаточно
Предыдущий вариант асинхронных реакций не позволял менять состояние других объектов, так как в WebAsyncRequest::bindReaction невозможно передать лямбду с захватом. Это сделано для предотвращения попытки обращения к удалённым объектам.
Данная проблема решена следующим образом:
"Захваченные" данные обёрнуты в объект, который при собственном разрушении сообщает исполняющему потоку, что данные больше не валидны и обращаться к ним нельзя.
Пример использования №2
class WeatherDisplay : public KASWeb::AsyncResponseReactor<WeatherDisplay>
{
private:
//Пользовательские поля и методы
void updateUI();
void drawOnScreen();
float temperature;
float windSpeed;
//Автоматически вызывается после выполнения запроса
void responseReaction(KASWeb::WebAsyncResponse response)
{
//Псевдокод обработки json
JSON json = JSON::parse(response.getResponse());
float temp = json["temperature"];
float windSpeed = json["windSpeed"];
updateUI(temp, wind);
}
public:
WeatherDisplay() : KASWeb::AsyncResponseReactor<WeatherDisplay>(*this){}
void fetchWeather()
{
//Создание объекта запроса
KASWeb::AsyncRequestGET request;
//Метод, предоставленный классом-родителем KASWeb::AsyncResponseReactor
//Привязывает метод класса в качестве колбэка
bindReaction(&request, &WeatherDisplay::responseReaction);
//Запуск запроса
const std::string URI = "https://...";
request.get(URI);
}
}
В данном примере при разрушении объекта WeatherDisplay уничтожается также AsyncResponseReactor, а вслед за ним хранящийся внутри lifetimeToken, который при удалении оповещает все привязанные к нему запросы об удалении WeatherDisplay.
Сам AsyncResponseReactor имеет единственный метод bindReaction, который принимает указатель на объект запроса и на метод объекта-носителя
Остальные возможности библиотеки
Общие методы
//Установка заголовков
.setHeaders(std::vector<std::string> headers);
//Добавление заголовка
.addHeader(const std::string& header);
//Установка timeout
.setTimeout(size_t milliseconds);
//Привязка реакции
.bindReaction(IResponseReactor* reaction);
Конструкторы запросов
По умолчанию методы запросов используют libCurl, но библиотека предоставляет возможность заменить решение "из коробки" на собственное путём передачи его в конструктор запроса:
//GET-запрос, использующий libCurl
AsyncRequestGET defaultGET;
//Создание фабрики, возвращающей std::shared_ptr<INetworkProvider>
std::unique_ptr<INetworkProviderFactory> myProviderFactory = /*...*/;
//GET-запрос, испольщующий пользовательскую реализацию методов
AsyncRequestGET customGET(myProviderFactory);
GET-запрос
AsyncRequestGET request;
//Выполнение запроса и получение объекта response для отслеживания результата
WebAsyncResponse response = request.get("https://example.com");
POST-запрос
AsyncRequestPOST request;
//Установка requestBody (1 вариант)
std::string requestBody = /*...*/;
request.setRequestBody(requestBody);
//Установка requestBody (2 вариант)
std::vector<uint8_t> requestBody = /*...*/;
request.setRequestBody(requestBody);
//Выполнение запроса и получение объекта response для отслеживания результата
WebAsyncResponse response = request.post("https://example.com");
PUT-запрос
AsyncRequestPUT request;
//Код настройки аналогичен AsyncRequestPOST
//Выполнение запроса и получение объекта response для отслеживания результата
WebAsyncResponse response = request.put("https://example.com");
DELETE-запрос
AsyncRequestDELETE del;
//Выполнение запроса и получение объекта response для отслеживания результата
WebAsyncResponse response = request.del("https://example.com");
Запрос для скачивания файлов и страниц
AsyncRequestDownload request;
std::string saveAs = "downloadedFile.dat";
std::string URI = /*...*/;
//Скачивание и запись в файл downloadedFile.dat
WebAsyncResponse response = request.downloadAsFile(URI, saveAs);
AsyncRequestDownload request;
std::shared_ptr<std::vector<uint8_t>> outputBuffer;
std::string URI = /*...*/;
//Скачивание и запись в ОЗУ
WebAsyncResponse response = request.downloadInMemory(URI, outputBuffer);
//P.S оборачивание outputBuffer в std::shared_ptr необходимо для гарантии безопасности выполнения асинхронного запроса
Отслеживание состояния ответа
Как уже было ск��зано раннее, из любой точки программы возможно безопасно отслеживать состояние запроса и ответ сервера с помощью WebAsyncResponse.
//--- Методы объекта WebAsyncResponse ---//
//Получение ответа сервера
int getHttpStatus();
std::string getResponse();
//Проверяет, завершилось ли выполнение запроса (корректно или с ошибкой)
bool isReady();
//Методы для обработки ошибок
bool isFailed();
std::string getErrorMessage();
//Возвращает прогресс в диапазоне от 0.f до 1.f (currentDataSize/expectedDataSize)
float getProgress();
//Возвращает ожидаемый размер файла или страницы
size_t getCurrentDataSize();
//Возвращает размер полученных данных на текущий момент времени
size_t getExpectedDataSize();
Так как WebAsyncResponse является лишь держателем указателя на реальный объект состояния WebResponseState, его можно безопасно копировать и перемещать.
Заключение
KASWeb находится на ранней стадии разработки и не стремится заменить собой устоявшиеся решения. Она была создана в качестве любительского проекта под строго определённые цели. Принципы, заложенные в философию библиотеки, соблюдены, и поставленные перед ней задачи выполнены. В будущем также планируется поддержка WebSocket и добавление future-promise механизма.
Благодарю за проявленное внимание ;-)
