Pull to refresh

Трехэтажные C++ные шаблоны в реализации встраиваемого асинхронного HTTP-сервера с человеческим лицом

Reading time 14 min
Views 16K
Наша команда специализируется на C++ проектах. И нам время от времени приходилось создавать HTTP-точки входа в C++ компоненты. Для чего использовались разные инструменты. Тут были и старые-добрые CGI, и различные встраиваемые библиотеки, как сторонние, так и самописные. Все это работало, но всегда оставалось ощущение, что следовало бы делать такие вещи и проще, и быстрее, и производительнее.

В итоге мы решили, что пора прекращать смотреть по сторонам и нужно попробовать сделать что-то свое, с преферансом и куртизанками кроссплатформенностью, асинхронностью, производительностью и человеческим отношением к конечному пользователю. В результате у нас получилась небольшая C++14 библиотека RESTinio, которая позволяет запустить HTTP-сервер внутри C++ приложения всего несколькими строчками кода. Вот, например, простейший сервер, который на все запросы отвечает «Hello, World»:

#include <restinio/all.hpp>

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<restinio::chunked_output_t>();
   resp
      .append_header(restinio::http_field::server, "MyApp Embedded Server")
      .append_header_date_field()
      .append_header(restinio::http_field::content_type, "text/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<Output_Type>, публичный API которого зависит от Output_Type. Так, у response_builder_t<restinio_controlled_output_t> нет публичного метода flush(), а публичный метод set_content_length() есть только у response_builder_t<user_controlled_output_t>.

Включаем логирование


В самом начале статьи мы показали простейший однопоточный HTTP-сервер. Который работает как «черный ящик», без каких-либо отладочных печатей или диагностического логирования. Давайте сделаем так, чтобы запускаемый HTTP-сервер логировал все происходящие с ним действия на стандартный поток вывода. Для этого нам потребуется небольшой трюк с шаблонами:

#include <restinio/all.hpp>

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<my_traits>()
         .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 <restinio/all.hpp>

using my_router_t = restinio::router::express_router_t<>;

auto make_request_handler()
{
   auto router = std::make_unique<my_router_t>();

   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<unsigned long>(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<int>(params["year"]),
                     restinio::cast_to<short>(params["month"]),
                     restinio::cast_to<short>(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<my_traits>()
         .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 <restinio/all.hpp>
#include <restinio/router/pcre2_regex_engine.hpp>

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 <restinio/all.hpp>
#include <restinio/router/pcre2_regex_engine.hpp>

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<my_pcre2_traits>>;


И еще про 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<request_handling_status_t(request_handle_t)>. Но пользователь может задать и другой тип, если этот тип предоставляет operator() с нужной сигнатурой. Например, express-роутер, о котором речь шла выше, определяет свой тип обработчика запросов, который нужно задать в качестве request_handler_t в Traits HTTP-сервера.

strand_t. Определяет тип т.н. strand-а для защиты Asio-шных потрохов при работе в многопоточном режиме. По умолчанию это asio::strand<asio::executor>, что позволяет безопасно запускать HTTP-сервер сразу на нескольких рабочих нитях. Например:

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<typename Traits>
class run_on_this_thread_settings_t final
   : public basic_server_settings_t<run_on_this_thread_settings_t<Traits>, Traits>
{
   using base_type_t = basic_server_settings_t<
         run_on_this_thread_settings_t<Traits>, Traits>;
public:
      using base_type_t::base_type_t;
};

Т.е. мы уже видим уши CRTP. Но еще интереснее заглянуть в определение basic_server_settings_t:

template<typename Derived, typename Traits>
class basic_server_settings_t
   : public socket_type_dependent_settings_t<Derived, typename Traits::stream_socket_t>
{
...
};

Тут можно увидеть еще один шаблон, который используется в качестве базового типа. Сам по себе он ничего интересного не представляет:

template <typename Settings, typename Socket>
class socket_type_dependent_settings_t
{
protected :
   ~socket_type_dependent_settings_t() = default;
};

Но зато его можно специализировать для различных сочетаний Settings и Socket. Например, для поддержки TLS:

template<typename Settings>
class socket_type_dependent_settings_t<Settings, tls_socket_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<my_pcre2_traits>>;
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<my_traits_t>()
      .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 настолько активно, насколько это нам позволяет здравый смысл (ну или его отсутствие, тут уж с какой стороны посмотреть).
Tags:
Hubs:
+40
Comments 19
Comments Comments 19

Articles