Pull to refresh

Достраиваем в RESTinio четвертый этаж из C++ных шаблонов. Зачем и как?

Reading time13 min
Views3.1K

RESTinio — это относительно небольшой проект, представляющий из себя встраиваемый в C++приложения асинхронный HTTP-сервер. Его характерной особенностью является широкое, можно сказать, повсеместное применение C++ных шаблонов. Как в реализации, так и в публичном API.


C++ные шаблоны в RESTinio используются настолько активно, что первая статья, которая рассказывала о RESTinio на Хабре, называлась "Трехэтажные C++ные шаблоны в реализации встраиваемого асинхронного HTTP-сервера с человеческим лицом".


Трехэтажные шаблоны. И это, в общем-то, была не фигура речи.


А недавно мы в очередной раз обновили RESTinio и для добавления новой функциональности в версию 0.5.1 пришлось сделать "этажность" шаблонов еще выше. Так что местами C++ные шаблоны в RESTinio уже четырехэтажные.



И если кому-то интересно зачем нам такое потребовалось и как мы шаблоны использовали, то оставайтесь с нами, под катом будет немного подробностей. Матерые C++ гуру вряд ли найдут для себя что-нибудь новое, а вот менее продвинутые C++ники смогут посмотреть на то, как шаблоны применяются для вставки/изъятия кусков функциональности. Почти что в "дикой природе".


Слушатель состояния подключений


Основная фича, ради которой создавалась версия 0.5.1, — это возможность информирования пользователя о том, что состояние подключения к HTTP-серверу изменилось. Например, клиент "отвалился" и это сделало ненужным обработку запросов от данного клиента, которые еще ждут своей очереди.


Об этой фиче нас иногда спрашивали и вот у нас руки дошли до ее реализации. Но т.к. спрашивали об этой фиче далеко не все, то подумалось, что она должна быть опциональной: если какому-то пользователю она нужна, то пусть он ее явным образом включает, а все остальные ничего не должны платить за ее существование в RESTinio.


А поскольку основные характеристики HTTP-сервера в RESTinio задаются через "свойства" (traits), то и включение/выключение прослушивания состояния подключений было решено сделать через свойства сервера.


Как пользователь задает собственного слушателя состояния подключений?


Для того, чтобы задать свой слушатель состояния подключений пользователь должен выполнить три шага.


Шаг №1: определение собственного класса, у которого должен быть нестатический метод state_changed следующего вида:


void state_changed(
   const restinio::connection_state::notice_t & notice) noexcept;

Например, это может быть что-то вроде:


class my_state_listener {
   std::mutex lock_;
   ...
public:
   void state_changed(const restinio::connection_state::notice_t & notice) noexcept {
      std::lock_guard<std::mutex> l{lock_};
      ....
   }
   ...
};

Шаг №2: внутри свойств сервера нужно определить typedef с именем connection_state_listener_t, который должен ссылаться на имя созданного на шаге №1 типа:


struct my_traits : public restinio::default_traits_t {
   using connection_state_listener_t = my_state_listener;
};

Соответственно, именно эти свойства должны использоваться при запуске HTTP-сервера:


restinio::run(restinio::on_thread_pool<my_traits>(8)...);

Шаг №3: пользователь должен создать экземпляр своего слушателя и передать этот указатель через shared_ptr в параметрах сервера:


restinio::run(
   restinio::on_thread_pool<my_traits>(8)
      .port(8080)
      .address("localhost")
      .request_handler(...)
      .connection_state_listener(std::make_shared<my_state_listener>(...))
   )
);

Если пользователь не сделает вызов метода connection_state_listener, то при запуске HTTP-сервера будет брошено исключение: север не может работать, если пользователь захотел использовать слушатель состояний, но не задал этот самый слушатель.


А если не задавать connection_state_listener_t?


Если пользователь задает в свойствах сервера имя connection_state_listener_t, то он обязан вызвать метод connection_state_listener при задании параметров сервера. А вот если пользователь не задает connection_state_listener_t?


В этом случае в свойствах сервера все равно будет присутствовать имя connection_state_listener_t, но это имя будет указывать на специальный тип restinio::connection_state::noop_listener_t.


По сути, происходит следующее: в RESTinio при определении штатных trait-ов задается значение connection_state_listener_t. Что-то вроде:


namespace restinio {

struct default_traits_t {
   using time_manager_t = asio_time_manager_t;
   using logger_t = null_logger_t;
   ...
   using connection_state_listener_t = connection_state::noop_listener_t;
};

} /* namespace restinio */

И когда пользователь наследуется от restinio::default_traits_t, то наследуется и штатное определение connection_state_listener_t. Но если в классе-наследнике определяется новое имя connection_state_listener_t:


struct my_traits : public restinio::default_traits_t {
   using connection_state_listener_t = my_state_listener;
   ...
};

то новое имя скрывает унаследованное определение для connection_state_listener_t. А если нового определения нет, то остается видимым старое определение.


Так что, если пользователь не определяет собственное значение для connection_state_listener_t, то RESTinio будет использовать значение по умолчанию, noop_listener_t, которое обрабатывается RESTinio специальным образом. Например:


  • RESTinio вообще не хранит в этом случае shared_ptr для connection_state_listener_t. И, соответственно, обращение к методу connection_state_listener запрещено (таковое обращение будет приводить к ошибке времени компиляции);
  • RESTinio вообще не делает никаких дополнительных вызовов, связанных с изменением состояния соединения.

И как раз о том, каким образом все это достигается речь и пойдет ниже.


Как это реализовано в RESTinio?


Итак, в коде RESTinio нужно проверять, какое значение имеет определение connection_state_listener_t в свойствах сервера и, в зависимости от этого значения:


  • хранить или не хранить экземпляр shared_ptr для объекта типа connecton_state_listener_t;
  • разрешать или запрещать вызовы методов connection_state_listener для задания параметров HTTP-сервера;
  • проверять или не проверять наличие актуального указателя на объект типа connection_state_listener_t перед началом работы HTTP-сервера;
  • выполнять или не выполнять вызовы метода state_changed при изменении состояния соединения с клиентом.

В граничные условия добавляется еще и то, что RESTinio пока развивается как библиотека для C++14, поэтому в реализации нельзя использовать возможности C++17 (тот же if constexpr).


Реализовано все это посредством нехитрых приемов: шаблонных классов и их специализаций для типа restinio::connection_state::noop_listener_t. Например, вот как сделано хранение shared_ptr для объекта типа connection_state_listener_t в параметрах сервера. Часть первая:


template< typename Listener >
struct connection_state_listener_holder_t
{
    ... // Некоторые compile-time проверки.

    std::shared_ptr< Listener > m_connection_state_listener;

    static constexpr bool has_actual_connection_state_listener = true;

    void
    check_valid_connection_state_listener_pointer() const
    {
        if( !m_connection_state_listener )
            throw exception_t{ "connection state listener is not specified" };
    }
};

template<>
struct connection_state_listener_holder_t< connection_state::noop_listener_t >
{
    static constexpr bool has_actual_connection_state_listener = false;

    void
    check_valid_connection_state_listener_pointer() const
    {
        // Nothing to do.
    }
};

Здесь определяется шаблонная структура, которая либо имеет полезное наполнение, либо нет. Как раз для типа noop_listener_t у нее полезного наполнения нет.


И часть вторая:


template<typename Derived, typename Traits>
class basic_server_settings_t
    :   public socket_type_dependent_settings_t<
            Derived, typename Traits::stream_socket_t >
    ,   protected connection_state_listener_holder_t<
            typename Traits::connection_state_listener_t >
    ,   protected ip_blocker_holder_t< typename Traits::ip_blocker_t >
{
...
};

Класс, который содержит параметры для HTTP-сервера, наследуется от connection_state_listener_holder_t. Таким образом в параметрах сервера либо оказывается shared_ptr для объекта типа connection_state_listener_t, либо не оказывается.


Нужно сказать, что хранение или не хранение shared_ptr в параметрах — это цветочки. А ягодки пошли при попытке сделать так, чтобы предназначенные для работы со слушателем состояний методы в basic_server_settings_t были доступны только если connection_state_listener_t отличен от noop_listener_t.


В идеале хотелось сделать так, чтобы компилятор их вообще "не видел". Но я замучался выписывать условия для std::enable_if дабы скрывать эти методы. Посему просто ограничился добавлением static_asser-а:


Derived &
connection_state_listener(
    std::shared_ptr< typename Traits::connection_state_listener_t > listener ) &
{
    static_assert(
            has_actual_connection_state_listener,
            "connection_state_listener(listener) can't be used "
            "for the default connection_state::noop_listener_t" );

    this->m_connection_state_listener = std::move(listener);
    return reference_to_derived();
}

Derived &&
connection_state_listener(
    std::shared_ptr< typename Traits::connection_state_listener_t > listener ) &&
{
    return std::move(this->connection_state_listener(std::move(listener)));
}

const std::shared_ptr< typename Traits::connection_state_listener_t > &
connection_state_listener() const noexcept
{
    static_assert(
            has_actual_connection_state_listener,
            "connection_state_listener() can't be used "
            "for the default connection_state::noop_listener_t" );

    return this->m_connection_state_listener;
}

void
ensure_valid_connection_state_listener()
{
    this->check_valid_connection_state_listener_pointer();
}

Тут как раз был очередной момент, когда довелось пожалеть, что в C++ if constexpr не такой, как static if в языке D. Да и вообще в C++14 нет ничего похожего :(


Здесь же можно увидеть наличие метода ensure_valid_connection_state_listener. Этот метод вызывается в конструкторе http_server_t для проверки того, что параметры сервера содержат все необходимые значения:


template<typename D>
http_server_t(
    io_context_holder_t io_context,
    basic_server_settings_t< D, Traits > && settings )
    :   m_io_context{ io_context.giveaway_context() }
    ,   m_cleanup_functor{ settings.giveaway_cleanup_func() }
{
    // Since v.0.5.1 the presence of custom connection state
    // listener should be checked before the start of HTTP server.
    settings.ensure_valid_connection_state_listener();
...

При этом внутри ensure_valid_connection_state_listener вызывается унаследованный из connection_state_listener_holder_t метод check_valid_connection_state_listener_pointer, который благодаря специализации connection_state_listener_holder_t либо делает актуальную проверку, либо не делает ничего.


Похожие приемы использованы и для того, чтобы либо вызывать актуальный state_changed, если пользователь пожелал использовать слушателя состояний, либо ничего не вызывать в противном случае.


Сперва нам потребуется еще один вариант state_listener_holder_t:


namespace connection_settings_details
{

template< typename Listener >
struct state_listener_holder_t
{
    std::shared_ptr< Listener > m_connection_state_listener;

    template< typename Settings >
    state_listener_holder_t(
        const Settings & settings )
        :   m_connection_state_listener{ settings.connection_state_listener() }
    {}

    template< typename Lambda >
    void
    call_state_listener( Lambda && lambda ) const noexcept
    {
        m_connection_state_listener->state_changed( lambda() );
    }
};

template<>
struct state_listener_holder_t< connection_state::noop_listener_t >
{
    template< typename Settings >
    state_listener_holder_t( const Settings & ) { /* nothing to do */ }

    template< typename Lambda >
    void
    call_state_listener( Lambda && /*lambda*/ ) const noexcept
    {
        /* nothing to do */
    }
};

} /* namespace connection_settings_details */

В отличии от connection_state_listener_holder_t, который был показан ранее и который использовался для хранения слушателя состояния подключений в параметрах всего сервера (т.е. в объектах типа basic_server_settings_t), этот state_listener_holder_t будет использоваться для аналогичных целей, но уже в параметрах не всего сервера, а отдельного подключения:


template < typename Traits >
struct connection_settings_t final
    :   public std::enable_shared_from_this< connection_settings_t< Traits > >
    ,   public connection_settings_details::state_listener_holder_t<
                typename Traits::connection_state_listener_t >
{
    using connection_state_listener_holder_t =
            connection_settings_details::state_listener_holder_t<
                    typename Traits::connection_state_listener_t >;
...

Здесь есть две особенности.


Во-первых, инициализация state_listener_holder_t. Она либо нужна, либо нет. Но знает об этом лишь сам state_listener_holder_t. Поэтому конструктор connection_settings_t просто "дергает" конструктор state_listener_holder_t, что называется, на всякий случай:


template < typename Settings >
connection_settings_t(
    Settings && settings,
    http_parser_settings parser_settings,
    timer_manager_handle_t timer_manager )
    :   connection_state_listener_holder_t{ settings }
    ,   m_request_handler{ settings.request_handler() }

А уже сам конструктор state_listener_holder_t либо выполняет нужные действия, либо вообще ничего не делает (в последнем случае более-менее толковый компилятор вообще не сгенерирует никакого кода для инициализации state_listener_holder_t).


Во-вторых, это метод state_listner_holder_t::call_state_listener, который и делает вызов state_changed у слушателя состояний. Либо не делает, если слушателя состояний нет. Вызывается этот call_state_listener в местах, в которых RESTinio диагностирует изменение состояния подключения. Например, когда обнаруживается, что подключение было закрыто:


void
close()
{
    m_logger.trace( [&]{
        return fmt::format( "[connection:{}] close", connection_id() );
    } );
...
    // Inform state listener if it used.
    m_settings->call_state_listener( [this]() noexcept {
            return connection_state::notice_t{
                    this->connection_id(),
                    this->m_remote_endpoint,
                    connection_state::cause_t::closed };
        } );
}

В call_state_listener передается лямбда, из которой возвращается объект notice_t с информацией о состоянии подключения. Если актуальный слушатель есть, то эта лямбда действительно будет вызвана, а возвращенное ею значение будет передано в state_changed.


Однако, если слушателя нет, то call_state_listener будет пустым и, соответственно, никакая лямбда вызываться не будет. По сути, нормальный компилятор просто повыбрасывает все обращения к пустому call_state_listener. И в этом случае в сгенерированном коде вообще не будет ничего, связанного с обращением к слушателю состояния подключений.


Еще и IP-blocker


В RESTinio-0.5.1 кроме слушателя состояния подключений была добавлена еще и такая штука, как IP-blocker. Т.е. пользователь может задать объект, который RESTinio будет "дергать" для каждого нового входящего подключения. Если IP-blocker говорит, что с подключением можно работать, то RESTinio начинает обычное обслуживание нового соединения (вычитывает и парсит запрос, вызывает request-handler, контролирует тайм-ауты и т.д.). Но вот если IP-blocker запрещает работу с подключением, то RESTinio тупо закрывает это соединение и ничего больше с ним не делает.


Как и слушатель состояний IP-blocker является опциальнальной функциональностью. Чтобы использовать IP-blocker его нужно явно включить. Через свойства HTTP-сервера. В точности как и со слушателем состояния подключений. И реализация поддержки IP-blocker-а в RESTinio использует те же самые приемы, которые были уже описаны выше. Поэтому мы не будем останавливаться на том, как IP-blocker используется внутри RESTinio. Вместо этого рассмотрим пример, в котором и IP-blocker, и слушатель состояний являются одним и тем же объектом.


Разбор штатного примера ip_blocker


В версии 0.5.1 в состав штатных примеров RESTinio включен еще один пример: ip_blocker. Этот пример демонстрирует, как можно ограничить количество параллельных подключений к серверу с одного IP-адреса.


Для этого потребуется не только IP-blocker, который будет разрешать или запрещать прием подключений. Но и слушатель состояния подключений. Слушатель нужен для того, чтобы отслеживать моменты создания и закрытия подключений.


При этом и IP-blocker-у, и слушателю потребуется один и тот же набор данных. Поэтому самое простое решение — это сделать так, чтобы IP-blocker и слушатель были одним и тем же объектом.


Никаких проблем, мы запросто можем это сделать:


class blocker_t
{
    std::mutex m_lock;

    using connections_t = std::map<
            restinio::asio_ns::ip::address,
            std::vector< restinio::connection_id_t > >;

    connections_t m_connections;

public:
    // Это метод IP-blocker-а.
    restinio::ip_blocker::inspection_result_t
    inspect(
        const restinio::ip_blocker::incoming_info_t & info ) noexcept
    {...}

    // Это метод слушателя состояния подключений.
    void state_changed(
        const restinio::connection_state::notice_t & notice ) noexcept
    {...}
};

Тут у нас нет никакого наследования от каких-либо интерфейсов и переопределения унаследованных виртуальных методов. Единственное требование к слушателю — наличие метода state_changed. Это требование удовлетворено.


Точно так же и с единственным требованием к IP-blocker-у: метод inspect с требуемой сигнатурой есть? Есть! Значит все нормально.


Далее остается определить правильные свойства для HTTP-сервера:


struct my_traits_t : public restinio::default_traits_t
{
    using logger_t = restinio::shared_ostream_logger_t;

    // Используется один и тот же тип.
    using connection_state_listener_t = blocker_t;
    using ip_blocker_t = blocker_t;
};

После чего остается только создать экземпляр blocker_t и передать его в параметрах HTTP-серверу:


auto blocker = std::make_shared<blocker_t>();

restinio::run(
    ioctx,
    restinio::on_thread_pool<my_traits_t>( std::thread::hardware_concurrency() )
        .port( 8080 )
        .address( "localhost" )
        .connection_state_listener( blocker )
        .ip_blocker( blocker )
        .max_pipelined_requests( 4 )
        .handle_request_timeout( std::chrono::seconds{20} )
        .request_handler( [&ioctx](auto req) {
                return handler( ioctx, std::move(req) );
            } )
);

Заключение


О C++ных шаблонах


На мой взгляд, C++ные шаблоны — это то, что называют "too big gun". Т.е. настолько мощная фича, что невольно приходится задумываться о том, а насколько ее применение оправдано. Поэтому современный C++ community как бы разделен на несколько враждующих лагерей.


Представители одного из них предпочитают держаться от шаблонов подальше. Поскольку шаблоны сложны, генерируют невообразимой длины нечитаемые простыни сообщений об ошибках, значительно увеличивают время компиляции. Не говоря уже о городских легендах про раздувание кода и снижение производительности.


Представители другого лагеря (вроде меня), считают, что именно шаблоны являются одной из самых мощных сторон C++. Возможно даже, что шаблоны — это одно из немногих серьезнейших конкурентных преимуществ C++ в современном мире. Поэтому, на мой взгляд, будущее C++ — это именно шаблоны. А некоторые текущие неудобства, связанные с широким использованием шаблонов (вроде длительной и ресурсоемкой компиляции или малоинформативных сообщениях об ошибках), будут тем или иным образом устранены с течением времени.


Поэтому лично мне представляется, что подход, выбранный при реализации RESTinio, а именно повсеместное использование шаблонов и задание характеристик HTTP-сервера через свойства, все еще оправдывает себя. Благодаря этому мы получаем хорошую настраиваемость под конкретные нужды. И, при этом, в буквальном смысле не платим за то, что не используем.


Однако, с другой стороны, складывается ощущение, что программирование на C++ных шаблонах все еще неоправданно сложно. Особенно сильно это ощущаешь когда программировать приходится не постоянно, а переключаясь между различными видами деятельности. Отвлечешься на пару-тройку недель от кодинга, потом вернешься и начинаешь откровенно и конкретно тупить при необходимости спрятать какой-то метод посредством SFINAE или проверить наличие метода с определенной сигнатурой у объекта.


Так что хорошо, что шаблоны в C++ есть. Еще лучше было бы, если бы их довели до такого состояния, чтобы даже выходящие в тираж старперы, вроде меня, могли бы безболезненно использовать C++ные шаблоны, без необходимости штудировать cppreference и stackoverflow каждые 10-15 минут.


О текущем состоянии RESTinio и будущей функциональности RESTinio. И не только RESTinio


На данный момент RESTinio развивается по принципу "когда есть время и есть хотелки". Скажем, осенью 2018 и зимой 2019-го времени на развитие RESTinio у нас особо не было. На вопросы пользователей отвечали, мелкие правки вносили, но на что-то большее наших ресурсов не хватало.


А вот в конце весны 2019-го время для RESTinio нашлось и мы сперва сделали RESTinio 0.5.0, а следом и 0.5.1. При этом запас наших и чужих хотелок был исчерпан. Т.е. то что мы сами хотели видеть в RESTinio и то, о чем нам говорили пользователи, в RESTinio уже есть.


Очевидно, что RESTinio можно наполнить еще много чем. Но вот чем именно?


И здесь ответ очень простой: в RESTinio будет попадать лишь то, о чем нас попросят. Поэтому если вы хотите увидеть в RESTinio что-то нужное именно вам, то найдите время сообщить нам об этом (например, через issues на GitHub-е или BitBucket-е, либо через Google-группу, либо прямо в комментариях здесь, на Хабре). Ничего не скажете — ничего и не получите ;)


Собственно, такая же ситуация и с другими нашими проектами, в частности с SObjectizer-ом. Их новые версии будут выходить по мере поступления внятных хотелок.


Ну а совсем уж в завершение хочется предложить всем, кто еще не пробовал RESTinio: попробуйте, это бесплатно не больно. Вдруг понравится. А не понравится, то поделитесь чем именно. Нам это поможет сделать RESTinio еще удобнее и функциональнее.

Tags:
Hubs:
Total votes 13: ↑13 and ↓0+13
Comments19

Articles