Простой backend на C++: это возможно?
Была у меня мечта — писать backend на C++. А вот разбираться в unix socket’ах, TCP, многопоточной/асинхронной обработке запросов и во многом другом совсем не хотелось. Не верил я, что до сих пор нет каких-то минималистичных фреймворков. И сегодня я вам расскажу, как можно просто сделать HTTP API микросервис на C++ с помощью фреймворка Drogon.
Логотип фреймворка Drogon из его GitHub-репозитория
Drogon Framework
Drogon — HTTP-фреймворк для создания серверных приложений на C++14/17/20. Назван в честь дракона из сериала «Игра Престолов». Поддерживает неблокирующий ввод/вывод, корутины, асинхронную работу с БД (MySQL, PostgreSQL), ORM, WebSocket и много чего ещё. Полный список возможностей можно узнать на сайте документации или в wiki на GitHub.
Есть множество вариантов установки этого фреймворка начиная от компиляции исходников и установки в систему до скачивания docker-образа. Выберите подходящий вам способ и поехали!
Конфигурация
Для конфигурации Drogon есть два способа. Первый и самый простой — при создании приложения до запуска указывать параметры настроек в аспектно-ориентированном стиле:
#include
#include
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
#include
using namespace drogon;
int main() {
app()
.loadConfigFile("./config.json")
.run();
return EXIT_SUCCESS;
}
Стоит уточнить, что, конечно же, конфигурация читается один раз перед запуском и на лету её изменять без перезапуска приложения не получиться.
Регистрация обработчиков
Фреймворк предлагает два способа регистрации обработчиков HTTP-запросов: AOP обработчики (вдохновлено express.js) и контроллеры (из MVC шаблона). Так как я показываю вам простой пример микросервиса, то будем использовать первый вариант.
Делается это очень просто. Для приложения регистрируем обработчик, передав path, функцию обработки и ограничения в виде HTTP-методов:
#include
#include
using namespace drogon;
typedef std::function 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
#include
using namespace drogon;
typedef std::function 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
#include
using namespace drogon;
typedef std::function 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-системах я не нашёл, поэтому всё же пока не советую его использовать, хотя релизы стабильно выходят и пулл реквесты сливаются в мастер довольно часто.