Предисловие
В этой статье я расскажу про свою 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 механизма.
Благодарю за проявленное внимание ;-)
