Estamos construindo o quarto andar de modelos C ++ no RESTinio. Por que e como?

O RESTinio é um projeto relativamente pequeno, que é um servidor HTTP assíncrono incorporado aos aplicativos C ++. Seu recurso característico é o amplo uso de modelos C ++. Tanto na implementação quanto na API pública.


Os modelos C ++ no RESTinio são usados ​​tão ativamente que o primeiro artigo que falou sobre o RESTinio no Habr foi chamado de " modelos C ++ de três andares na implementação de um servidor HTTP assíncrono incorporado com um rosto humano ".


Modelos de três andares. E isso, em geral, não era uma figura de linguagem.


Recentemente, atualizamos novamente o RESTinio e, para adicionar novas funcionalidades à versão 0.5.1, tivemos que aumentar ainda mais o "número de andares" dos modelos. Portanto, em alguns locais os modelos C ++ no RESTinio já são de quatro andares.



E se alguém está se perguntando por que precisamos disso e como usamos os modelos, fique conosco, haverá alguns detalhes sob o corte. É improvável que os gurus C ++ inveterados encontrem algo novo para eles mesmos, mas apelidos menos avançados em C ++ poderão ver como os modelos são usados ​​para inserir / remover partes da funcionalidade. Quase em estado selvagem.


Ouvinte do status da conexão


O principal recurso para o qual a versão 0.5.1 foi criada é a capacidade de informar ao usuário que o status da conexão com o servidor HTTP foi alterado. Por exemplo, o cliente "caiu" e isso tornou desnecessário processar solicitações desse cliente que ainda estão aguardando na fila.


Algumas vezes nos perguntaram sobre esse recurso e agora nossas mãos chegaram à sua implementação. Mas desde nem todos perguntaram sobre esse recurso, pensou-se que ele deveria ser opcional: se algum usuário precisar dele, inclua-o explicitamente e todo o resto não pagará nada pela sua existência no RESTinio.


E como as principais características do servidor HTTP no RESTinio são definidas através de "características", foi decidido ativar / desativar a escuta no status das conexões através das propriedades do servidor.


Como um usuário define seu próprio ouvinte para o status da conexão?


Para definir seu ouvinte para o status das conexões, o usuário deve executar três etapas.


Etapa 1: defina sua própria classe, que deve ter um método state_changed não estático, do seguinte formato:


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

Por exemplo, poderia ser algo como:


 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_}; .... } ... }; 

Etapa 2: dentro das propriedades do servidor, você precisa definir um typedef chamado connection_state_listener_t , que deve se referir ao nome do tipo criado na etapa 1:


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

Portanto, essas propriedades devem ser usadas ao iniciar o servidor HTTP:


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

Etapa 3: o usuário deve criar uma instância de seu ouvinte e passar esse ponteiro pelo shared_ptr nos parâmetros do servidor:


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

Se o usuário não fizer uma chamada para o método connection_state_listener , uma exceção será lançada ao iniciar o servidor HTTP: o norte não funcionará se o usuário desejar usar o ouvinte de estado, mas não especificar esse ouvinte.


E se você não definir o connection_state_listener_t?


Se o usuário definir o nome connection_state_listener_t nas propriedades do servidor, ele deverá chamar o método connection_state_listener para definir os parâmetros do servidor. Mas se o usuário não especificar connection_state_listener_t ?


Nesse caso, o nome connection_state_listener_t ainda estará presente nas propriedades do servidor, mas esse nome apontará para o tipo especial restinio::connection_state::noop_listener_t .


De fato, acontece o seguinte: no RESTinio, ao definir características regulares, o valor connection_state_listener_t é definido. Algo como:


 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 */ 

E quando o usuário herda de restinio::default_traits_t , a definição padrão de connection_state_listener_t também é herdada. Mas se o novo nome connection_state_listener_t definido na classe sucessora:


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

o novo nome oculta a definição herdada para connection_state_listener_t . E se não houver nova definição, a antiga definição permanecerá visível.


Portanto, se o usuário não definir um valor próprio para connection_state_listener_t , o RESTinio usará o valor padrão noop_listener_t , que é tratado pelo RESTinio de uma maneira especial. Por exemplo:


  • O RESTinio não armazena o shared_ptr neste caso para connection_state_listener_t . E, consequentemente, uma chamada para o método connection_state_listener é proibida (essa chamada levará a um erro em tempo de compilação);
  • O RESTinio não faz chamadas adicionais relacionadas à alteração do estado da conexão.

E exatamente como tudo isso é alcançado e será discutido abaixo.


Como isso é implementado no RESTinio?


Portanto, no código RESTinio, você precisa verificar qual valor a definição de connection_state_listener_t nas propriedades do servidor e, dependendo deste valor:


  • armazenar ou não a instância shared_ptr para um objeto do tipo connecton_state_listener_t ;
  • permitir ou proibir chamadas aos métodos connection_state_listener para definir parâmetros do servidor HTTP;
  • verifique ou não a presença de um ponteiro atual para um objeto do tipo connection_state_listener_t antes de iniciar a operação do servidor HTTP;
  • faça ou não faça chamadas para o método state_changed quando o estado da conexão com o cliente for alterado.

Também é adicionado às condições de contorno que o RESTinio ainda está desenvolvendo como uma biblioteca para C ++ 14, portanto, você não pode usar os recursos do C ++ 17 na implementação (o mesmo se constexpr).


Tudo isso é implementado através de truques simples: classes de modelo e suas especializações para o tipo restinio::connection_state::noop_listener_t . Por exemplo, veja como o armazenamento shared_ptr é feito para um objeto do tipo connection_state_listener_t nos parâmetros do servidor. Parte Um:


 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. } }; 

Aqui é definida uma estrutura de modelo que possui ou não um conteúdo útil. Apenas para o tipo noop_listener_t , ele não possui conteúdo útil.


E parte dois:


 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 > { ... }; 

A classe que contém os parâmetros para o servidor HTTP é herdada de connection_state_listener_holder_t . Portanto, os parâmetros do servidor mostram shared_ptr para um objeto do tipo connection_state_listener_t , ou não.


Devo dizer que armazenar ou não o shared_ptr nos parâmetros são flores. Mas os basic_server_settings_t ao tentar disponibilizar os métodos destinados ao trabalho com o ouvinte de estado em basic_server_settings_t apenas se connection_state_listener_t for diferente de noop_listener_t .


Idealmente, eu queria que o compilador "não os visse". Mas fui torturado para escrever condições para std::enable_if para ocultar esses métodos. Portanto, limitou-se simplesmente a adicionar 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(); } 

Houve apenas outro momento em que me arrependi disso em C ++ se constexpr não é o mesmo que estático se em D. E, em geral, em C ++ 14, não há nada semelhante :(


Aqui você também pode ver a disponibilidade do método ensure_valid_connection_state_listener . Este método é chamado no construtor http_server_t para verificar se os parâmetros do servidor contêm todos os valores necessários:


 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(); ... 

Ao mesmo tempo, dentro do ensure_valid_connection_state_listener método ensure_valid_connection_state_listener herdado de connection_state_listener_holder_t é check_valid_connection_state_listener_pointer , que, devido à especialização connection_state_listener_holder_t , faz uma verificação real ou não faz nada.


Truques semelhantes foram usados ​​para chamar o state_changed atual se o usuário quisesse usar o ouvinte de estado ou não chamar nada de outra maneira.


Primeiro, precisamos de outra opção 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 */ 

Ao contrário de connection_state_listener_holder_t , que foi mostrado anteriormente e usado para armazenar o ouvinte de status de conexão nos parâmetros de todo o servidor (ou seja, em objetos do tipo basic_server_settings_t ), esse state_listener_holder_t será usado para fins semelhantes, mas não nos parâmetros de todo o servidor, mas de maneira separada. conexão:


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

Existem dois recursos aqui.


Primeiro, inicializando state_listener_holder_t . É necessário ou não. Mas apenas state_listener_holder_t sabe disso. Portanto, o construtor connection_settings_t simplesmente "puxa" o construtor state_listener_holder_t , como se costuma dizer, apenas no caso de:


 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() } 

E o state_listener_holder_t construtor state_listener_holder_t executa as ações necessárias ou não faz nada (no último caso, o compilador mais ou menos sensível não gerará nenhum código para inicializar state_listener_holder_t ).


Em segundo lugar, é o state_listner_holder_t::call_state_listener , que faz a chamada state_changed para o listener de estado. Ou não, se não houver ouvinte de estado. Esse call_state_listener em locais onde o RESTinio diagnostica uma alteração no estado da conexão. Por exemplo, quando é detectado que a conexão foi fechada:


 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 }; } ); } 

Um call_state_listener é passado para call_state_listener , a partir do qual um objeto notice_t com informações de status da conexão é retornado. Se houver um ouvinte real, esse lambda será realmente chamado e o valor retornado por ele será passado para state_changed .


No entanto, se não houver ouvinte, o call_state_listener ficará vazio e, portanto, nenhum lambda será chamado. De fato, o compilador normal simplesmente lança todas as chamadas para um call_state_listener vazio. E, nesse caso, no código gerado, não haverá nada relacionado ao status da conexão acessada pelo ouvinte.


Também bloqueador de IP


No RESTinio-0.5.1, além do ouvinte do status da conexão, um bloqueador de IP foi adicionado. I.e. o usuário pode especificar um objeto que o RESTinio irá "puxar" para cada nova conexão de entrada. Se o bloqueador de IP disser que você pode trabalhar com a conexão, o RESTinio iniciará a manutenção usual da nova conexão (ele lê e analisa a solicitação, chama o manipulador de solicitações, controla os tempos limite, etc.). Mas se o bloqueador de IP proíbe trabalhar com a conexão, o RESTinio fecha estupidamente essa conexão e não faz mais nada com ela.


Como o ouvinte de estado, o bloqueador de IP é um recurso opcional. Para usar o bloqueador de IP, você deve ativá-lo explicitamente. Através das propriedades do servidor HTTP. Assim como com o ouvinte do status da conexão. E a implementação do suporte ao bloqueador de IP no RESTinio usa as mesmas técnicas que já foram descritas acima. Portanto, não falaremos sobre como o bloqueador de IP é usado dentro do RESTinio. Em vez disso, considere um exemplo em que o bloqueador de IP e o ouvinte de estado são o mesmo objeto.


Análise do exemplo padrão ip_blocker


Na versão 0.5.1, outro exemplo está incluído nos exemplos padrão do RESTinio: ip_blocker . Este exemplo demonstra como você pode limitar o número de conexões simultâneas ao servidor a partir de um único endereço IP.


Isso exigirá não apenas um bloqueador de IP, que permitirá ou proíbe a aceitação de conexões. Mas também um ouvinte para o status da conexão. É necessário um ouvinte para rastrear os momentos de criação e fechamento de conexões.


Ao mesmo tempo, o bloqueador de IP e o ouvinte precisarão do mesmo conjunto de dados. Portanto, a solução mais simples é tornar o bloqueador de IP e o ouvinte o mesmo objeto.


Não tem problema, podemos fazer isso facilmente:


 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 {...} }; 

Aqui não temos herança de nenhuma interface ou substituição de métodos virtuais herdados. O único requisito para o ouvinte é a presença do método state_changed . Este requisito é satisfeito.


Da mesma forma, com o único requisito para um bloqueador de IP: existe um método de inspect com a assinatura necessária? Existe! Então está tudo bem.


Resta determinar as propriedades corretas para o servidor 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; }; 

Depois disso, resta apenas criar uma instância de blocker_t e passá-la nos parâmetros para o servidor 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) ); } ) ); 

Conclusão


Sobre modelos C ++


Na minha opinião, os modelos C ++ são chamados de armas muito grandes. I.e. recurso tão poderoso que você involuntariamente precisa pensar em como e como seu uso é justificado. Portanto, a moderna comunidade C ++ é como se estivesse dividida em vários campos de batalha.


Os representantes de um deles preferem ficar longe dos modelos. Como os modelos são complexos, eles geram comprimentos ilegíveis de folhas de mensagens ilegíveis, aumentando significativamente o tempo de compilação. Sem mencionar lendas urbanas sobre inchaço do código e redução de desempenho.


Representantes de outro campo (como eu) acreditam que os modelos são um dos aspectos mais poderosos do C ++. É até possível que os modelos sejam uma das poucas vantagens competitivas mais sérias do C ++ no mundo moderno. Portanto, na minha opinião, o futuro do C ++ é precisamente os modelos. E alguns dos inconvenientes atuais associados ao uso generalizado de modelos (como compilação demorada e com muitos recursos ou mensagens de erro não informativas) serão eliminados de uma maneira ou de outra ao longo do tempo.


Portanto, parece-me pessoalmente que a abordagem escolhida durante a implementação do RESTinio, ou seja, o uso generalizado de modelos e a configuração das características de um servidor HTTP por meio de propriedades, ainda compensa. Graças a isso, conseguimos uma boa personalização para necessidades específicas. E, ao mesmo tempo, no sentido literal, não pagamos pelo que não usamos.


No entanto, por outro lado, parece que a programação em modelos C ++ ainda é excessivamente complicada. Você sente isso especialmente quando precisa programar não constantemente, mas ao alternar entre atividades diferentes. Você ficará distraído por algumas semanas da codificação, depois retornará e começará a ser estúpido de maneira aberta e específica, se necessário, ocultar algum método usando SFINAE ou verificar a existência de um método com uma certa assinatura no objeto.


Portanto, é bom que haja modelos em C ++. Seria ainda melhor se eles fossem levados a um estado em que até iniciantes como eu pudessem usar facilmente modelos C ++ sem ter que estudar o cppreference e o stackoverflow a cada 10 a 15 minutos.


Sobre o estado atual do RESTinio e a funcionalidade futura do RESTinio. E não apenas o RESTinio


No momento, o RESTinio está desenvolvendo o princípio de "quando houver tempo e houver uma lista de desejos". Por exemplo, no outono de 2018 e no inverno de 2019, não tivemos muito tempo para o desenvolvimento do RESTinio. Eles responderam às perguntas dos usuários, fizeram pequenas alterações, mas, para algo mais, nossos recursos não foram suficientes.


Mas no final da primavera de 2019, houve tempo para o RESTinio, e fizemos o RESTinio 0.5.0 e, em seguida, 0.5.1 . Ao mesmo tempo, o suprimento de nossa e de outras mercadorias estava esgotado. I.e. o que nós mesmos queríamos ver no RESTinio e o que os usuários nos contaram já está no RESTinio.


Obviamente, o RESTinio pode ser preenchido com muito mais. Mas o que exatamente?


E aqui a resposta é muito simples: apenas o que nos é pedido para entrar no RESTinio. Portanto, se você deseja ver algo que precisa no RESTinio, reserve um tempo para nos falar sobre isso (por exemplo, através de problemas no GitHub ou BitBucket , através do grupo do Google ou diretamente nos comentários aqui no Habré) . Você não dirá nada - você não receberá nada;)


Na verdade, a mesma situação ocorre com nossos outros projetos, em particular com o SObjectizer . Suas novas versões serão lançadas após o recebimento da lista de desejos inteligível.


Bem, e finalmente, gostaria de oferecer a todos que ainda não experimentaram o RESTinio: experimente de graça não machuca. De repente, gosto disso. E se você não gostar, compartilhe o que exatamente. Isso nos ajudará a tornar o RESTinio ainda mais conveniente e funcional.

Source: https://habr.com/ru/post/pt456632/


All Articles