Трехэтажные C++ные шаблоны в реализации встраиваемого асинхронного HTTP-сервера с человеческим лицом
Наша команда специализируется на C++ проектах. И нам время от времени приходилось создавать HTTP-точки входа в C++ компоненты. Для чего использовались разные инструменты. Тут были и старые-добрые CGI, и различные встраиваемые библиотеки, как сторонние, так и самописные. Все это работало, но всегда оставалось ощущение, что следовало бы делать такие вещи и проще, и быстрее, и производительнее.
В итоге мы решили, что пора прекращать смотреть по сторонам и нужно попробовать сделать что-то свое, с преферансом и куртизанками кроссплатформенностью, асинхронностью, производительностью и человеческим отношением к конечному пользователю. В результате у нас получилась небольшая C++14 библиотека RESTinio, которая позволяет запустить HTTP-сервер внутри C++ приложения всего несколькими строчками кода. Вот, например, простейший сервер, который на все запросы отвечает «Hello, World»:
#include
int main()
{
restinio::run(
restinio::on_this_thread()
.port(8080)
.address("localhost")
.request_handler([](auto req) {
return req->create_response().set_body("Hello, World!").done();
}));
return 0;
}
В реализации RESTinio активно используются C++ные шаблоны и об этом хотелось бы сегодня немного поговорить.
Буквально пара общих слов о RESTinio
RESTinio — это небольшой OpenSource проект, который распространяется под BSD-3-CLAUSE лицензией. RESTinio активно развивается с весны 2017-го года. За это время мы сделали несколько публичных релизов, постепенно наполняя RESTinio функциональностью. Самый свежий релиз состоялся сегодня. Это релиз версии 0.4, в которой мы, пожалуй, таки реализовали тот минимум функциональности, который мы хотели иметь.
RESTinio использует несколько сторонних компонентов. Для работы с сетью мы используем Asio (standalone версию Asio), для парсинга HTTP-протокола у нас используется http-parser из Node.js. Также внутри используется fmtlib, а для тестирования — библиотека Catch2.
Не смотря на то, что RESTinio пока еще не достиг версии 1.0, мы очень тщательно относимся к качеству и стабильности работы RESTinio. Например, наш коллега участвовал в Mail.ru-шном конкурсе HighloadCup с решением на базе RESTinio. Это решение вышло в финал с 45-го места и заняло в финале 44-е место. Могу ошибаться, но среди финалистов было всего два или три решения, которые строились на базе универсальных HTTP-фреймворков. Вот одним из них как раз и оказалось решение на базе RESTinio.
Вообще, если говорить о производительности, то скорость работы RESTinio не была приоритетом №1 при разработке. И хотя производительности мы уделяли внимание, тем не менее более важным для нас было получение решения, которым удобно пользоваться. При этом RESTinio не так уж плохо выглядит в синтетических бенчмарках.
Однако, в данной статье хотелось бы поговорить не столько о самой библиотеке RESTinio и ее возможностях (подробнее с этой информацией можно ознакомиться здесь). Сколько о том, как в ее реализации используется такая важная фича языка C++, как шаблоны.
Почему шаблоны?
Код RESTinio построен на шаблонах. Так, в показанном выше примере шаблонов не видно, хотя они там повсюду:
- функция restinio: run () шаблонная;
- функция restinio: on_this_thread () шаблонная;
- метод request_handler () так же шаблонный;
- и даже метод create_response () шаблонный.
Почему же RESTinio так активно использует шаблоны? Наверное, самыми серьезными были две следующих причины:
Во-первых, мы хотели, чтобы RESTinio могла кастомизироваться в широких пределах. Но чтобы кастомизация имела минимальную стоимость в run-time. Как нам кажется, шаблоны здесь просто вне конкуренции.
Во-вторых, кое-кого из нас, видимо, сильно покусал Александреску. И это до сих пор сказывается, хотя времени с тех пор прошло уже немало.
Ну и еще нам понравилось следствие из того, что изрядная часть RESTinio представляет из себя шаблонный код: библиотека получилась header-only. Так уж складывается, что в нынешнем C++ подключить header-only библиотеку к своему (или к чужому) проекту гораздо проще, чем ту, которую нужно компилировать. Таки зоопарк систем сборки и систем управления зависимостями в C++ доставляет. И header-only библиотеки в этих зоопарках чувствуют себя гораздо лучше. Пусть даже за это приходится платить увеличением времени компиляции, но это уже тема для совершенно другого разговора…
Кастомизация на шаблонах в простых примерах
Выше мы сказали, что шаблоны позволяют кастомизировать RESTinio. Давайте покажем, что под этим подразумевается на паре-тройке простых примеров.
Отдаем ответ в режиме chunked encoding
Выше уже было сказано, что метод create_response () является шаблонным. Этот метод параметризуется способом формирования HTTP-ответа. По умолчанию используется restinio_controlled_output_t. Этот метод самостоятельно вычисляет значение HTTP-заголовка Content-Length и инициирует запись ответа в сокет после того, как программист полностью создаст весь ответ и вызовет метод done ().
Но RESTinio поддерживает еще несколько методов: user_controlled_output_t и chunked_output_t. Например, использование режима chunked_output_t будет выглядеть как-то так:
auto handler = [&](auto req) {
auto resp = req->create_response();
resp
.append_header(restinio::http_field::server, "MyApp Embedded Server")
.append_header_date_field()
.append_header(restinio::http_field::content_type, "test/plain; charset=utf-8");
resp.flush(); // Запись подготовленных заголовков.
for(const auto & part : fragments) {
resp.append_chunk(make_chunk_from(part));
resp.flush(); // Запись очередной части ответа.
}
return resp.done(); // Завершение обработки.
};
Примечательно то, что create_response () возвращает объект response_builder_t
Включаем логирование
В самом начале статьи мы показали простейший однопоточный HTTP-сервер. Который работает как «черный ящик», без каких-либо отладочных печатей или диагностического логирования. Давайте сделаем так, чтобы запускаемый HTTP-сервер логировал все происходящие с ним действия на стандартный поток вывода. Для этого нам потребуется небольшой трюк с шаблонами:
#include
int main()
{
struct my_traits : public restinio::default_single_thread_traits_t {
using logger_t = restinio::single_threaded_ostream_logger_t;
};
restinio::run(
restinio::on_this_thread()
.port(8080)
.address("localhost")
.request_handler([](auto req) {
return req->create_response().set_body("Hello, World!").done();
}));
return 0;
}
Что мы здесь сделали?
Мы определили собственный класс свойств (traits) для HTTP-сервера, в котором задали нужный нам тип логгера. Потом заставили RESTinio использовать этот класс свойств при конструировании HTTP-сервера внутри restinio: run (). В итоге внутри restino: run () создается HTTP-сервер, который логирует все события посредством логгера, который реализуется типом single_threaded_ostream_logger_t.
Если мы запустим модифицированный пример и выдадим простейший запрос к нашему серверу (вроде wget localhost:8080), то мы увидим что-то такое:
[2017-12-24 12:04:29.612] TRACE: starting server on 127.0.0.1:8080
[2017-12-24 12:04:29.612] INFO: init accept #0
[2017-12-24 12:04:29.612] INFO: server started on 127.0.0.1:8080
[2017-12-24 12:05:00.423] TRACE: accept connection from 127.0.0.1:45930 on socket #0
[2017-12-24 12:05:00.423] TRACE: [connection:1] start connection with 127.0.0.1:45930
[2017-12-24 12:05:00.423] TRACE: [connection:1] start waiting for request
[2017-12-24 12:05:00.423] TRACE: [connection:1] continue reading request
[2017-12-24 12:05:00.423] TRACE: [connection:1] received 141 bytes
[2017-12-24 12:05:00.423] TRACE: [connection:1] request received (#0): GET /
[2017-12-24 12:05:00.423] TRACE: [connection:1] append response (#0), flags: { final_parts, connection_keepalive }, bufs count: 2
[2017-12-24 12:05:00.423] TRACE: [connection:1] sending resp data, buf count: 2
[2017-12-24 12:05:00.423] TRACE: [connection:1] start waiting for request
[2017-12-24 12:05:00.423] TRACE: [connection:1] continue reading request
[2017-12-24 12:05:00.423] TRACE: [connection:1] outgoing data was sent: 76 bytes
[2017-12-24 12:05:00.423] TRACE: [connection:1] should keep alive
[2017-12-24 12:05:00.423] TRACE: [connection:1] start waiting for request
[2017-12-24 12:05:00.423] TRACE: [connection:1] continue reading request
[2017-12-24 12:05:00.424] TRACE: [connection:1] EOF and no request, close connection
[2017-12-24 12:05:00.424] TRACE: [connection:1] close
[2017-12-24 12:05:00.424] TRACE: [connection:1] destructor called
[2017-12-24 12:05:16.402] TRACE: closing server on 127.0.0.1:8080
[2017-12-24 12:05:16.402] INFO: server closed on 127.0.0.1:8080
Что мы сделали? По сути мы поправили один параметр в свойствах HTTP-сервера и получили дополнительную функциональность. Которой вообще не было в первом случае, когда мы использовали дефолтные свойства для HTTP-сервера. Причем под «вообще» мы понимаем именно «вообще». Поясним на примере.
В коде RESTinio разбросано логирование выполняемых сервером операций. Вот, скажем:
void close_impl()
{
const auto ep = m_acceptor.local_endpoint();
m_logger.trace( [&]{
return fmt::format( "closing server on {}", ep );
} );
m_acceptor.close();
m_logger.info( [&]{
return fmt::format( "server closed on {}", ep );
} );
}
Идет обращение к логгеру с передачей лямбда-функции, отвечающей за формирование сообщения для лога. Но если в качестве логгера используется restinio: null_logger_t (а это и происходит по умолчанию), то в null_logger_t методы trace (), info () и им подобные просто ничего не делают:
class null_logger_t
{
public:
template< typename Message_Builder >
constexpr void trace( Message_Builder && ) const {}
template< typename Message_Builder >
constexpr void info( Message_Builder && ) const {}
template< typename Message_Builder >
constexpr void warn( Message_Builder && ) const {}
...
Поэтому нормальный компилятор просто выбрасывает все обращения к логгеру и не генерирует никакого кода для логирования. «Не используешь — не платишь» в чистом виде.
Выбираем regex-engine для express-роутера
Еще один пример кастомизации за счет шаблонов продемонстрируем с использованием express-роутера, который есть в RESTinio. Express-роутер сделан в RESTinio по мотивам JavaScript-фреймворка Express. Использование express-роутера существенно упрощает работу с URL для выбора подходящего обработчика. Особенно, когда внутри URL «зашиты» нужные обработчику параметры.
Вот небольшой пример, который показывает, как посредством express-роутера вешать обработчики на GET-запросы вида /measure/: id и /measures/: year/: month/: day:
#include
using my_router_t = restinio::router::express_router_t<>;
auto make_request_handler()
{
auto router = std::make_unique();
router->http_get(R"(/measure/:id(\d+))",
[](auto req, auto params) {
return req->create_response()
.set_body(
fmt::format("Measure with id={} requested",
restinio::cast_to(params["id"])))
.done();
});
router->http_get(R"(/measures/:year(\d{4})/:month(\d{2})/:day(\d{2}))",
[](auto req, auto params) {
return req->create_response()
.set_body(
fmt::format("Request measures for a date: {}.{}.{}",
restinio::cast_to(params["year"]),
restinio::cast_to(params["month"]),
restinio::cast_to(params["day"])))
.done();
});
router->non_matched_request_handler([](auto req) {
return req->create_response(404, "Unknown request")
.connection_close()
.done();
});
return router;
}
int main()
{
struct my_traits : public restinio::default_single_thread_traits_t {
using request_handler_t = my_router_t;
};
restinio::run(
restinio::on_this_thread()
.port(8080)
.address("localhost")
.request_handler(make_request_handler()));
return 0;
}
Для того, чтобы разбирать URL-ы из запросов, express-роутеру нужна какая-то реализация регулярных выражений. По умолчанию используется std: regex, но std: regex, на данный момент, к сожалению, не может похвастаться отличной производительностью. Например, PCRE/PCRE2 гораздо быстрее std: regex.
Поэтому в RESTinio можно задать другую реализацию регулярных выражений для express_router_t. Задать как? Правильно: через параметр шаблона. Например, для того, чтобы использовать PCRE2 вместо std: regex:
#include
#include
using my_router_t = restinio::router::express_router_t<
restinio::router::pcre2_regex_engine_t<>>;
Причем внимательный читатель может заметить, что pcre2_regex_engine_t так же является шаблоном. В этот раз pcre2_regex_engine_t довольствуется дефолтными параметрами. Но мы можем легко это исправить…
pcre2_regex_engine_t параметризуется собственным классом свойств, специфических для PCRE2. В настоящий момент в свойствах для pcre2_regex_engine_t можно задать такие параметры как опции для компиляции регулярного выражения, опции для pcre2_match, а также такой важный параметр, как max_capture_groups. Этот параметр определяет максимальное количество извлекаемых из строки фрагментов. По умолчанию max_capture_groups равен 20, что означает, что pcre2_regex_engine_t сразу выделит место под 20 фрагментов. В нашем случае это слишком много, т.к. максимальное количество элементов в строках с URL для нашего короткого примера — три. Давайте сделаем настройки, специфические для нашего конкретного случая:
#include
#include
struct my_pcre2_traits : public restinio::router::pcre2_traits_t<> {
static constexpr int max_capture_groups = 4; // +1 для всей строки с URL.
};
using my_router_t = restinio::router::express_router_t<
restinio::router::pcre2_regex_engine_t>;
И еще про Traits
Выше уже были показаны примеры использования классов свойств (т.е. traits) для управления поведения тех или иных сущностей. Но вообще именно Traits определяют все поведение HTTP-сервера в RESTinio. Ибо под капотом у показанных выше функций restinio: run () скрывается создание экземпляра шаблонного класса restinio: http_server_t. И шаблонный параметр Traits как раз определяет параметры работы HTTP-сервера.
Если смотреть по большому сверху, то в Traits должны быть определены следующие имена типов:
timer_manager_t. Определяет тип, который будет использоваться HTTP-сервером для отсчета таймаутов, связанных с подключениями к серверу. В RESTinio по умолчанию используется asio_timer_manager_t, использующий штатный механизм таймеров Asio. Так же есть so_timer_manager_t, который использует механизм таймеров SObjectizer-а. Есть еще null_timer_manager_t, который вообще ничего не делает и который оказывается полезным для проведения бенчмарков.
logger_t. Определяет механизм логирования внутренней активности HTTP-сервера. По умолчанию используется null_logger_t, т.е. по умолчанию HTTP-сервер ничего не логирует. Есть штатная реализация очень простого логгера ostream_logger_t, полезная для отладки.
request_handler_t. Определяет тип обработчика HTTP-запросов. По умолчанию используется default_request_handler_t, что есть всего лишь std: function
strand_t. Определяет тип т.н. strand-а для защиты Asio-шных потрохов при работе в многопоточном режиме. По умолчанию это asio: strand
restinio::run(
restinio::on_thread_pool(std::thread::hardware_concurrency())
.port(8080)
.address("localhost")
.request_handler(make_request_handler()));
Если же HTTP-сервер работает в однопоточном режиме, то можно избежать дополнительных накладных расходов определив Traits: strand_t как restinio: noop_strand_t (что и делается в restinio: default_single_thread_traits_t).
stream_socket_t. Определяет тип сокета, с которым предстоит работать RESTinio. По умолчанию это asio: ip: tcp: socket. Но для работы с HTTPS этот параметр должен быть задан как restinio: tls_socket_t.
В общем, даже в своем ядре — центральном классе http_server_t — в RESTinio применяется policy based design на С++ных шаблонах. Поэтому неудивительно, что отголоски этого подхода обнаруживаются и во многих других частях RESTinio.
Ну и какая же трехэтажность без CRTP?
В заголовке статьи упомянуты трехэтажные шаблоны, но до сих пор речь шла лишь о том, как широко шаблоны используются в RESTinio. Примеров же самой трехэтажности пока не было. Нужно устранить это упущение;)
Есть в C++ такая хитрая штука, как CRTP (что расшифровывается как Curiously recurring template pattern). Вот с помощью этой штуки в RESTinio реализована работа с параметрами сервера.
Перед тем, как запустить HTTP-сервер, ему нужно задать несколько обязательных параметров (+ еще можно задать несколько необязательных). Например, в этом примере задается порт и адрес, которые должен слушать HTTP-сервер, обработчик для запросов, а так же тайм-ауты для различных операций:
restinio::run(
restinio::on_this_thread()
.port(8080)
.address("localhost")
.request_handler(server_handler())
.read_next_http_message_timelimit(10s)
.write_http_response_timelimit(1s)
.handle_request_timeout(1s));
На самом деле здесь нет ничего особо сложного: функция on_this_thread конструирует и возвращает объект server_settings, который далее уже модифицируется посредством вызова методов-setter-ов.
Однако, говоря «нет ничего особо сложного» мы немного лукавим, поскольку on_this_thread возвращает экземпляр вот такого типа:
template
class run_on_this_thread_settings_t final
: public basic_server_settings_t, Traits>
{
using base_type_t = basic_server_settings_t<
run_on_this_thread_settings_t, Traits>;
public:
using base_type_t::base_type_t;
};
Т.е. мы уже видим уши CRTP. Но еще интереснее заглянуть в определение basic_server_settings_t:
template
class basic_server_settings_t
: public socket_type_dependent_settings_t
{
...
};
Тут можно увидеть еще один шаблон, который используется в качестве базового типа. Сам по себе он ничего интересного не представляет:
template
class socket_type_dependent_settings_t
{
protected :
~socket_type_dependent_settings_t() = default;
};
Но зато его можно специализировать для различных сочетаний Settings и Socket. Например, для поддержки TLS:
template
class socket_type_dependent_settings_t
{
protected:
~socket_type_dependent_settings_t() = default;
public:
socket_type_dependent_settings_t() = default;
socket_type_dependent_settings_t(socket_type_dependent_settings_t && ) = default;
Settings & tls_context(asio::ssl::context context ) & {...}
Settings && tls_context(asio::ssl::context context ) && {...}
asio::ssl::context tls_context() {...}
...
};
И вот если все это сложить вместе, например, вот в такой ситуации:
struct my_pcre2_traits : public restinio::router::pcre2_traits_t<> {
static constexpr int max_capture_groups = 4;
};
using my_router_t = restinio::router::express_router_t<
restinio::router::pcre2_regex_engine_t>;
using my_traits_t = restinio::single_thread_tls_traits_t<
restinio::asio_timer_manager_t,
restinio::single_threaded_ostream_logger_t,
my_router_t>;
...
restinio::run(
restinio::on_this_thread()
.address("localhost")
.request_handler(server_handler())
.read_next_http_message_timelimit(10s)
.write_http_response_timelimit(1s)
.handle_request_timeout(1s)
.tls_context(std::move(tls_context)));
То тут уж точно шаблон сидит на шаблоне и шаблоном погоняет. Что особенно хорошо становится заметно в сообщениях об ошибках компилятора, если где-то случайно опечатаешься…
Заключение
Вряд ли мы ошибемся, если скажем, что отношение к C++ным шаблонам среди практикующих C++программистов очень разное: кто-то использует шаблоны повсеместно, кто-то время от времени, кто-то категорически против. Еще более неоднозначное отношение к С++ым шаблонам у завсегдатаев профильных форумов/ресурсов, особенно среди тех, кто профессионально разработкой на C++ не занимается, но мнение имеет. Поэтому наверняка у многих прочитавших статью возникнет вопрос: «А оно того стоило?»
По нашему мнению — да. Хотя нас, например, не сильно смущает время компиляции C++ного кода. Кстати говоря, у компиляции RESTinio+Asio вполне себе нормальная скорость. Это когда к этому добавляется еще и Catch2, вот тогда да, время компиляции увеличивается в разы. Да и сообщений об ошибках от C++ компилятора мы не боимся, тем более, что от года к году эти самые сообщения становятся все более и более вменяемыми.
В любом случае, на C++ программируют очень по-разному. И каждый может использовать тот стиль, который ему наиболее подходит. Начиная от оберток над чисто сишными библиотеками (вроде mongoose или civetweb) или C++ных библиотек, написанных в Java-подобном «Си с классами» (как это происходит, скажем, в POCO). И заканчивая активно использующими C++ные шаблоны CROW, Boost.Beast и RESTinio.
Мы вообще придерживаемся того мнения, что в современном мире, при наличии таких конкурентов, как Rust, Go, D и, не говоря уже про C# и Java, у С++ не так уж много серьезных и объективных достоинств. И C++ные шаблоны, пожалуй, одно из немногих конкурентных преимуществ C++, способное оправдать применение C++ в конкретной прикладной задаче. А раз так, то какой смысл отказываться от C++ных шаблонов или ограничивать себя в их использовании? Мы такого смысла не видим, поэтому и задействуем шаблоны в реализации RESTinio настолько активно, насколько это нам позволяет здравый смысл (ну или его отсутствие, тут уж с какой стороны посмотреть).