Предисловие

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

Благодарю за проявленное внимание ;-)

Ссылка на репозиторий