Была у меня мечта - писать backend на C++. А вот разбираться в unix socket'ах, TCP, многопоточной/асинхронной обработке запросов и во многом другом совсем не хотелось. Не верил я, что до сих пор нет каких-то минималистичных фреймворков. И сегодня я вам расскажу, как можно просто сделать HTTP API микросервис на C++ с помощью фреймворка Drogon.
Drogon Framework
Drogon - HTTP-фреймворк для создания серверных приложений на C++14/17/20. Назван в честь дракона из сериала «Игра Престолов». Поддерживает неблокирующий ввод/вывод, корутины, асинхронную работу с БД (MySQL, PostgreSQL), ORM, WebSocket и много чего ещё. Полный список возможностей можно узнать на сайте документации или в wiki на GitHub.
Есть множество вариантов установки этого фреймворка начиная от компиляции исходников и установки в систему до скачивания docker-образа. Выберите подходящий вам способ и поехали!
Конфигурация
Для конфигурации Drogon есть два способа. Первый и самый простой - при создании приложения до запуска указывать параметры настроек в аспектно-ориентированном стиле:
#include <сstdlib>
#include <drogon/drogon.h>
using namespace drogon;
int main() {
app()
// Слушаем адрес 0.0.0.0 с портом 3000
.addListener("0.0.0.0", 3000)
// Выставляем кол-во I/O-потоков
.setThreadNum(8)
// Отключаем HTTP заголовок с названием сервера
.enableServerHeader(false)
// Запускаем приложение
.run();
return EXIT_SUCCESS;
}
Но есть вариант и более эстетичный и удобный - конфигурация через JSON-файл. Для этого создаём JSON-файл рядом с исполняемым файлом, а в исходном коде указываем, что берём конфигурацию из этого файла.
{
"listeners": [
{
"address": "0.0.0.0",
"port": 3000,
"https": false
}
],
"app": {
"number_of_threads": 8,
"server_header_field": ""
}
}
#include <сstdlib>
#include <drogon/drogon.h>
using namespace drogon;
int main() {
app()
.loadConfigFile("./config.json")
.run();
return EXIT_SUCCESS;
}
Стоит уточнить, что, конечно же, конфигурация читается один раз перед запуском и на лету её изменять без перезапуска приложения не получиться.
Регистрация обработчиков
Фреймворк предлагает два способа регистрации обработчиков HTTP-запросов: AOP обработчики (вдохновлено express.js) и контроллеры (из MVC шаблона). Так как я показываю вам простой пример микросервиса, то будем использовать первый вариант.
Делается это очень просто. Для приложения регистрируем обработчик, передав path, функцию обработки и ограничения в виде HTTP-методов:
#include <сstdlib>
#include <drogon/drogon.h>
using namespace drogon;
typedef std::function<void(const HttpResponsePtr &)> Callback;
void indexHandler(const HttpRequestPtr &request, Callback &&callback) {
// Код обработчика
// Вызов обратной функции для передачи управления фреймворку
callback();
}
int main() {
app()
// Регистрируем обработчик indexHandler
// для запроса
// GET /
.registerHandler("/", &indexHandler, {Get})
.loadConfigFile("./config.json")
.run();
return EXIT_SUCCESS;
}
Создание обработчика
Давайте сделаем так, чтобы indexHandler
возвращал клиенту JSON-объект:
{
"message": "Hello, world!"
}
Для этого создаём JSON-объект в функции indexHandler
и присваиваем по ключу message
значение Hello, world!
:
Json::Value jsonBody;
jsonBody["message"] = "Hello, world!";
Далее, нам нужно сформировать HTTP-ответ с нужным статус кодом и заголовками, для этого есть метод newHttpJsonResponse
у класса HttpResponse
:
auto response = HttpResponse::newHttpJsonResponse(jsonBody);
Он формирует ответ вида:
HTTP/1.0 200 OK
Content-Type: application/json; charset=UTF-8
Content-Length: 28
{"message":"Hello, world!"}
И осталось только отдать сформированный HTTP-ответ клиенту. передав response
в callback
:
callback(response);
В итоге, у нас получается такой код:
#include <cstdlib>
#include <drogon/drogon.h>
using namespace drogon;
typedef std::function<void(const HttpResponsePtr &)> Callback;
void indexHandler(const HttpRequestPtr &request, Callback &&callback) {
// Формируем JSON-объект
Json::Value jsonBody;
jsonBody["message"] = "Hello, world";
// Формируем и отправляем ответ с JSON-объектом
auto response = HttpResponse::newHttpJsonResponse(jsonBody);
callback(response);
}
int main() {
app()
.loadConfigFile("./config.json")
.registerHandler("/", &indexHandler, {Get})
.run();
return EXIT_SUCCESS;
}
А что насчёт получения данных из запроса?
И тут тоже всё максимально просто. Как вы заметили у функций обработчиков есть аргумент HttpRequestPtr &request
, с помощью которого можно получить данные запроса. Например, есть метод getJsonObject
, который преобразует тело запроса в экземпляр типа Json::Value
, которым мы, кстати, пользовались для создания JSON-объекта.
Предположим, мы на запрос POST /name
и телом с {"name": "some name"}
хотим получить ответ в виде JSON с полем message
, содержащий строку с приветствием по имени, которое пришло в запросе. Для этого создаём обработчик и проверяем в нём, отправили ли нам в теле запроса JSON-объект, проверяем, есть ли в нём параметр name
, и возвращаем сообщение:
void nameHandler(const HttpRequestPtr &request, Callback &&callback) {
Json::Value jsonBody;
// Получаем JSON из тела запроса
auto requestBody = request->getJsonObject();
// Если нет тела запроса или не смогли десериализовать,
// то возвращаем ошибку 400 Bad Request
if (requestBody == nullptr) {
jsonBody["status"] = "error";
jsonBody["message"] = "body is required";
auto response = HttpResponse::newHttpJsonResponse(jsonBody);
response->setStatusCode(HttpStatusCode::k400BadRequest);
callback(response);
return;
}
// Если в теле запроса JSON нет поля name,
// то возвращаем ошибку 400 Bad Request
if (!requestBody->isMember("name")) {
jsonBody["status"] = "error";
jsonBody["message"] = "field `name` is required";
auto response = HttpResponse::newHttpJsonResponse(jsonBody);
response->setStatusCode(HttpStatusCode::k400BadRequest);
callback(response);
return;
}
// Получаем name из тела запроса
auto name = requestBody->get("name", "guest").asString();
// Формируем ответ
jsonBody["message"] = "Hello, " + name + "!";
auto response = HttpResponse::newHttpJsonResponse(jsonBody);
// Отдаём ответ
callback(response);
}
Так как фреймворк довольно простой, то бойлерплэйт код есть и, например, формирование ответа с ошибками можно вынести в отдельную функцию.
Осталось только зарегистрировать обработчик в приложении и получаем такой код:
#include <cstdlib>
#include <drogon/drogon.h>
using namespace drogon;
typedef std::function<void(const HttpResponsePtr &)> Callback;
void nameHandler(const HttpRequestPtr &request, Callback &&callback) {
Json::Value jsonBody;
auto requestBody = request->getJsonObject();
if (requestBody == nullptr) {
jsonBody["status"] = "error";
jsonBody["message"] = "body is required";
auto response = HttpResponse::newHttpJsonResponse(jsonBody);
response->setStatusCode(HttpStatusCode::k400BadRequest);
callback(response);
return;
}
if (!requestBody->isMember("name")) {
jsonBody["status"] = "error";
jsonBody["message"] = "field `name` is required";
auto response = HttpResponse::newHttpJsonResponse(jsonBody);
response->setStatusCode(HttpStatusCode::k400BadRequest);
callback(response);
return;
}
auto name = requestBody->get("name", "guest").asString();
jsonBody["message"] = "Hello, " + name + "!";
auto response = HttpResponse::newHttpJsonResponse(jsonBody);
callback(response);
}
int main() {
app()
.loadConfigFile("./config.json")
// Регистрируем обработчик nameHandler
// для запроса
// POST /name
.registerHandler("/name", &nameHandler, {Post})
.run();
return EXIT_SUCCESS;
}
Итоги
Как видите, с помощью фреймворка Drogon довольно просто создавать простые микросервисы. Если вам нужны какие-то более сложные вещи, то этот фреймворк предоставляет такие возможности, как контроллеры, маппинг роутов по регулярным выражениям, драйвера для баз данных (ORM в том числе) и т.д. К тому же, вы можете использовать огромное кол-во библиотек, которые написаны для C/C++. Фреймворк себя хорошо показывает в бенчмарках TechEmpower, что говорит о минимальном оверхеде, составляемым для обработки запросов.
Но информации по использованию в production-системах я не нашёл, поэтому всё же пока не советую его использовать, хотя релизы стабильно выходят и пулл реквесты сливаются в мастер довольно часто.
Комментарии (21)
Biga
16.12.2021 16:03Тема корутин не раскрыта. Какую библиотеку используете? Или stackless из C++?
hello_my_name_is_dany Автор
16.12.2021 16:06+1Фреймворк поддерживает встроенные корутины из C++20:
-std=c++20 -fcoroutines
Biga
16.12.2021 16:16А они вообще юзабельные?
Например, если нужно сделать запрос к базе данных, в wiki используется future.get() - блокирующая операция. Она весь сервер подвесит, или там под капотом какая-то магия есть, чтобы остальные запросы продолжили обрабатываться? Плохо представляю, как такое сделать на stackless корутинах.hello_my_name_is_dany Автор
16.12.2021 16:58+1Если очень простой пример привести:
Task<HttpResponsePtr> getUsersAsync(const HttpRequestPtr &request) { auto sql = app().getDbClient(); auto result = co_await sql->execSqlCoro( "SELECT COUNT(*) FROM users;" ); Json::Value jsonBody; jsonBody["users_count"] = result[0][0].as<size_t>(); auto response = HttpResponse::newHttpJsonResponse(jsonBody); co_return response; }
Под капотом там EventLoop с менеджером пулла потоков. Во внутреннем обработчике запроса используется co_await, но при этом этот обработчик вызывается без каких-либо блокировок
UPD: насчёт использования - вроде работает. Как я писал, фреймворк в продакшн я не видел, а с экспериментальными корутинами тем более
bkstan
17.12.2021 11:08OK, а POCO из pocoproject.org подходит под критерии поиска фремворка в статье? Я вот на нём делаю...
hello_my_name_is_dany Автор
17.12.2021 11:19+1Подходит. Цель статьи была всё же показать, что и на C++ можно легко писать бэкенд. Как я писал выше, сейчас действительно много разных фреймворков есть. Я просто показал один из них
Sovietmade
17.12.2021 14:24+1Была у меня мечта - писать backend на C++. А вот разбираться в unix socket'ах, TCP, многопоточной/асинхронной обработке запросов и во многом другом совсем не хотелось.
Простите, а в чем прикол мечты писать бэк на плюсах и не разбираться в сокетах, сетях и прочем?
hello_my_name_is_dany Автор
17.12.2021 14:54+1В том, чтобы не писать очередной велосипед. Я, конечно, не отрицаю, что знание сокетов, протоколов и тд нужны, но делать реализацию стоит только в том случае, когда есть в этом необходимость, например, разного рода оптимизаций, написание своего протокола поверх TCP и тп. А когда стоит вопрос, что нужно просто написать бэкенд, а разработчики в команде знают только плюсы, то почему бы не взять фреймворк, который скрывает всю реализацию, оставляя бизнес-логику на разработчиках
Sovietmade
17.12.2021 15:22+1Разбираться =\= реализовывать. Удачи с протекающими абстракциями в дальнейшем
KislyFan
17.12.2021 18:56А где применяется бекэнд на C++ ? Мне думается, что это должен быть не самый тривиальный сценарий.
Или просто just4fun ?
hello_my_name_is_dany Автор
17.12.2021 20:16Например, онлайн игры. А вообще в крупных компаниях бывают пишут бэкенд на плюсах. А ответ на вопрос - зачем? Не смогу ответить, наверное, или что-то кастомное (протокол, например), или увеличение перформанса
mikets
18.12.2021 18:56Очень простой и легковесный фреймворк https://github.com/yhirose/cpp-httplib/. Header only.
hello_my_name_is_dany Автор
18.12.2021 18:57Но он всё же с block I/O. Drogon предоставляет Thread Pull Manager и в каждом потоке Event Loop для non-block I/O
mikets
18.12.2021 20:19+1Зависит от потребностей. Конечно, не выдержит тысячи параллельных запросов, но зато очень просто, например для встроенного REST сервера. И не нужно никаких дополнительных библиотек и головной боли при сборке
RekGRpth
есть ещё подобный фреймворк на плюсах https://www.webtoolkit.eu/wt
hello_my_name_is_dany Автор
Wt всё же больше full-stack фреймворк. А так за последние лет 5 появилось много фреймворков для web на C++, что не может не радовать
LaG1924
К сожалению, большинство этих фреймворков требуют boost.asio, из-за чего элегантно встроить http-бэкэнд в уже имеющееся приложение мне не удалось.
bormental
А чем asio мешает элегантности?