Была у меня мечта - писать backend на C++. А вот разбираться в unix socket'ах, TCP, многопоточной/асинхронной обработке запросов и во многом другом совсем не хотелось. Не верил я, что до сих пор нет каких-то минималистичных фреймворков. И сегодня я вам расскажу, как можно просто сделать HTTP API микросервис на C++ с помощью фреймворка Drogon.

Логотип фреймворка Drogon из его GitHub-репозитория
Логотип фреймворка Drogon из его GitHub-репозитория

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)


  1. RekGRpth
    16.12.2021 13:39
    +1

    есть ещё подобный фреймворк на плюсах https://www.webtoolkit.eu/wt


    1. hello_my_name_is_dany Автор
      16.12.2021 13:45
      +2

      Wt всё же больше full-stack фреймворк. А так за последние лет 5 появилось много фреймворков для web на C++, что не может не радовать


      1. LaG1924
        17.12.2021 02:09

        К сожалению, большинство этих фреймворков требуют boost.asio, из-за чего элегантно встроить http-бэкэнд в уже имеющееся приложение мне не удалось.


        1. bormental
          17.12.2021 15:14
          +1

          А чем asio мешает элегантности?


  1. NeoCode
    16.12.2021 14:27

    А на Qt есть хорошие фреймворки?


    1. hello_my_name_is_dany Автор
      16.12.2021 14:42

      Из живых я сейчас нашёл только Cutelyst


  1. Biga
    16.12.2021 16:03

    Тема корутин не раскрыта. Какую библиотеку используете? Или stackless из C++?


    1. hello_my_name_is_dany Автор
      16.12.2021 16:06
      +1

      Фреймворк поддерживает встроенные корутины из C++20: -std=c++20 -fcoroutines


      1. Biga
        16.12.2021 16:16

        А они вообще юзабельные?
        Например, если нужно сделать запрос к базе данных, в wiki используется future.get() - блокирующая операция. Она весь сервер подвесит, или там под капотом какая-то магия есть, чтобы остальные запросы продолжили обрабатываться? Плохо представляю, как такое сделать на stackless корутинах.


        1. 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: насчёт использования - вроде работает. Как я писал, фреймворк в продакшн я не видел, а с экспериментальными корутинами тем более


  1. bkstan
    17.12.2021 11:08

    OK, а POCO из pocoproject.org подходит под критерии поиска фремворка в статье? Я вот на нём делаю...


    1. hello_my_name_is_dany Автор
      17.12.2021 11:19
      +1

      Подходит. Цель статьи была всё же показать, что и на C++ можно легко писать бэкенд. Как я писал выше, сейчас действительно много разных фреймворков есть. Я просто показал один из них


  1. Sovietmade
    17.12.2021 14:24
    +1

    Была у меня мечта - писать backend на C++. А вот разбираться в unix socket'ах, TCP, многопоточной/асинхронной обработке запросов и во многом другом совсем не хотелось.

    Простите, а в чем прикол мечты писать бэк на плюсах и не разбираться в сокетах, сетях и прочем?


    1. hello_my_name_is_dany Автор
      17.12.2021 14:54
      +1

      В том, чтобы не писать очередной велосипед. Я, конечно, не отрицаю, что знание сокетов, протоколов и тд нужны, но делать реализацию стоит только в том случае, когда есть в этом необходимость, например, разного рода оптимизаций, написание своего протокола поверх TCP и тп. А когда стоит вопрос, что нужно просто написать бэкенд, а разработчики в команде знают только плюсы, то почему бы не взять фреймворк, который скрывает всю реализацию, оставляя бизнес-логику на разработчиках


      1. Sovietmade
        17.12.2021 15:22
        +1

        Разбираться =\= реализовывать. Удачи с протекающими абстракциями в дальнейшем


  1. KislyFan
    17.12.2021 18:56

    А где применяется бекэнд на C++ ? Мне думается, что это должен быть не самый тривиальный сценарий.

    Или просто just4fun ?


    1. Siemargl
      17.12.2021 20:10

      Любая сколь нибудь популярная игра


    1. hello_my_name_is_dany Автор
      17.12.2021 20:16

      Например, онлайн игры. А вообще в крупных компаниях бывают пишут бэкенд на плюсах. А ответ на вопрос - зачем? Не смогу ответить, наверное, или что-то кастомное (протокол, например), или увеличение перформанса


  1. mikets
    18.12.2021 18:56

    Очень простой и легковесный фреймворк https://github.com/yhirose/cpp-httplib/. Header only.


    1. hello_my_name_is_dany Автор
      18.12.2021 18:57

      Но он всё же с block I/O. Drogon предоставляет Thread Pull Manager и в каждом потоке Event Loop для non-block I/O


      1. mikets
        18.12.2021 20:19
        +1

        Зависит от потребностей. Конечно, не выдержит тысячи параллельных запросов, но зато очень просто, например для встроенного REST сервера. И не нужно никаких дополнительных библиотек и головной боли при сборке