
Há vários anos, os desenvolvedores de C ++ receberam o tão esperado padrão C ++ 11, que trouxe muitas coisas novas. E eu tinha interesse em mudar rapidamente para o seu uso nas tarefas diárias. Vá para C ++ 14 e 17, isso não foi. Parecia que não havia um conjunto de recursos que seriam de interesse. Na primavera, decidi olhar para as inovações da linguagem e tentar algo. Para experimentar inovações, você teve que criar uma tarefa para si mesmo. Não precisei pensar muito. Foi decidido escrever seu RPC com estruturas de dados personalizadas como parâmetros e sem o uso de macros e geração de código - tudo em C ++. Isso foi possível graças aos novos recursos do idioma.
A idéia, implementação, feedback com o Reddit, melhorias - tudo apareceu na primavera, início do verão. No final, eles conseguiram terminar o post no Habr.
Você já pensou em seu próprio RPC? Talvez o material do post o ajude a determinar o objetivo, os métodos, os meios e a decidir em favor do finalizado ou a implementar algo você mesmo ...
1. Introdução
RPC (chamada de procedimento remoto) não é um novo tópico. Existem muitas implementações em diferentes linguagens de programação. As implementações usam vários formatos de dados e modos de transporte. Tudo isso pode ser refletido em alguns pontos:
- Serialização / desserialização
- Transporte
- Execução remota de método
- Resultado de retorno
A implementação é determinada pelo objetivo desejado. Por exemplo, você pode definir o objetivo de garantir uma alta velocidade de chamar um método remoto e sacrificar a usabilidade, ou vice-versa, para fornecer o máximo conforto para escrever código, possivelmente perdendo um pouco de desempenho. Os objetivos e as ferramentas são diferentes ... Eu queria conforto e desempenho aceitável.
Implementação
Abaixo estão algumas etapas para implementar o RPC no C ++ 14/17, e enfatizamos algumas das inovações de linguagem que fizeram com que esse material aparecesse.
O material é destinado a quem, por algum motivo, está interessado em sua RPC e, talvez, até o momento, precise de informações adicionais. Nos comentários, seria interessante ver uma descrição da experiência de outros desenvolvedores diante de tarefas semelhantes.
Serialização
Antes de começar a escrever o código, formarei uma tarefa:
- Todos os parâmetros do método e o resultado retornado são passados pela tupla.
- Os métodos chamados em si não são obrigados a aceitar e retornar tuplas.
- O resultado da compactação de uma tupla deve ser um buffer cujo formato não é fixo
A seguir, é apresentado um código serializador de cadeia simplificado.
string_serializernamespace rpc::type { using buffer = std::vector<char>; }
E o código de função principal que demonstra a operação do serializador.
Função principal int main() { try { std::tuple args{10, std::string{"Test string !!!"}, 3.14}; rpc::packer::string_serializer serializer; auto pack = serializer.save(args); std::cout << "Pack data: " << std::string{begin(pack), end(pack)} << std::endl; decltype(args) params; serializer.load(pack, params);
Acentos credenciadosPrimeiro de tudo, você precisa determinar o buffer com o qual toda a troca de dados será realizada:
namespace rpc::type { using buffer = std::vector<char>; }
O serializador possui métodos para salvar uma tupla no buffer (salvar) e carregá-la no buffer (carregar)
O método save utiliza uma tupla e retorna um buffer.
template <typename ... T> type::buffer save(std::tuple<T ... > const &tuple) const { auto str = to_string(tuple, std::make_index_sequence<sizeof ... (T)>{}); return {begin(str), end(str)}; }
Uma tupla é um modelo com um número variável de parâmetros. Esses padrões apareceram no C ++ 11 e funcionaram bem. Aqui você precisa, de alguma forma, passar por todos os elementos desse modelo. Pode haver várias opções. Vou usar um dos recursos do C ++ 14 - uma sequência de números inteiros (índices). O tipo make_index_sequence apareceu na biblioteca padrão, o que permite obter a seguinte sequência:
template< class T, T... Ints > class integer_sequence; template<class T, T N> using make_integer_sequence = std::integer_sequence<T, >; template<std::size_t N> using make_index_sequence = make_integer_sequence<std::size_t, N>;
Um similar pode ser implementado no C ++ 11 e depois transportá-lo de projeto para projeto.
Essa sequência de índices torna possível "passar" pela tupla:
template <typename T, std::size_t ... I> std::string to_string(T const &tuple, std::index_sequence<I ... >) const { std::stringstream stream; auto put_item = [&stream] (auto const &i) { if constexpr (std::is_same_v<std::decay_t<decltype(i)>, std::string>) stream << std::quoted(i) << ' '; else stream << i << ' '; }; (put_item(std::get<I>(tuple)), ... ); return std::move(stream.str()); }
O método to_string usa vários recursos dos mais recentes padrões C ++.
Acentos credenciadosNo C ++ 14, tornou-se possível usar auto como parâmetros para funções lambda. Isso geralmente não era suficiente, por exemplo, ao trabalhar com os algoritmos da biblioteca padrão.
Uma
convolução apareceu no C ++ 17, que permite escrever códigos como:
(put_item(std::get<I>(tuple)), ... );
No fragmento fornecido, a função lambda put_item é chamada para cada um dos elementos da tupla transferida. Isso garante uma sequência independente da plataforma e do compilador. Algo semelhante pode ser escrito em C ++ 11.
template <typename … T> void unused(T && … ) {}
Mas em que ordem os elementos seriam armazenados dependeria do compilador.
Muitos aliases apareceram na biblioteca padrão do C ++ 17, por exemplo, decay_t, o que reduziu os registros do formulário:
typename decay<T>::type
O desejo de escrever construções mais curtas tem um lugar para estar. Um design de modelo, onde um par de typename e modelo são encontrados em uma linha, separados por dois pontos e colchetes angulares, parece assustador. Como você pode assustar alguns de seus colegas. No futuro, eles prometem reduzir o número de lugares em que você precisa escrever um modelo, tipo de nome.
O desejo de concisão deu outra construção interessante da linguagem "se constexpr", evita escrever muitas especializações particulares de modelos.
Há um ponto interessante. Muitos foram ensinados que switch e construções similares não são muito boas em termos de escalabilidade de código. É preferível usar o polimorfismo em tempo de execução / tempo de compilação e sobrecarregar com argumentos a favor da “escolha certa”. E então "se consexpr" ... A possibilidade de compactação não deixa todos indiferentes a ela. A possibilidade da linguagem não significa a necessidade de usá-la.
Era necessário escrever uma serialização separada para o tipo de string. Para um trabalho conveniente com seqüências de caracteres, por exemplo, ao salvar em um fluxo e ler a partir dele, a função std :: quoted apareceu. Ele permite que você rastreie as strings e possibilita salvar em um fluxo e carregar datas dele sem pensar no delimitador.
Você pode parar com a descrição da serialização por enquanto. A desserialização (carga) é implementada da mesma forma.
Transporte
O transporte é simples. Esta é uma função que recebe e retorna um buffer.
namespace rpc::type {
Formando esse objeto “executor” usando as funções std :: bind, lambda, etc., você pode usar qualquer uma das suas implementações de transporte. Os detalhes da implementação do transporte neste post não serão considerados. Você pode dar uma olhada na implementação RPC concluída, um link ao qual será fornecido no final.
Cliente
Abaixo está um código de cliente de teste. O cliente gera solicitações e as envia para o servidor, levando em consideração o transporte selecionado. No código de teste abaixo, todas as solicitações do cliente são exibidas no console. E na próxima etapa da implementação, o cliente já se comunicará diretamente com o servidor.
Cliente namespace rpc { template <typename TPacker> class client final { private: class result; public: client(type::executor executor) : executor_{executor} { } template <typename ... TArgs> result call(std::string const &func_name, TArgs && ... args) { auto request = std::make_tuple(func_name, std::forward<TArgs>(args) ... ); auto pack = packer_.save(request); auto responce = executor_(std::move(pack)); return {responce}; } private: using packer_type = TPacker; packer_type packer_; type::executor executor_; class result final { public: result(type::buffer buffer) : buffer_{std::move(buffer)} { } template <typename T> auto as() const { std::tuple<std::decay_t<T>> tuple; packer_.load(buffer_, tuple); return std::move(std::get<0>(tuple)); } private: packer_type packer_; type::buffer buffer_; }; }; }
O cliente é implementado como uma classe de modelo. O parâmetro do modelo é um serializador. Se necessário, a classe pode ser refeita, não no modelo um, e passada ao construtor um objeto que implementa o serializador.
Na implementação atual, o construtor da classe aceita um objeto em execução. O contratante oculta a implementação do transporte em si e torna possível, neste ponto do código, não pensar em métodos para a troca de dados entre processos. No caso de teste, a implementação de transporte exibe solicitações para o console.
auto executor = [] (rpc::type::buffer buffer) {
O código personalizado ainda não tentou tirar proveito do resultado do trabalho do cliente, pois não há onde obtê-lo.
Método de chamada do cliente:- usar o serializador embala o nome do método chamado e seus parâmetros
- o uso do objeto em execução envia uma solicitação ao servidor e recebe uma resposta
- passa a resposta recebida para a classe que recupera o resultado
A implementação básica do cliente está pronta. Algo mais resta. Mais sobre isso mais tarde.
Servidor
Antes de começar a considerar os detalhes de implementação do lado do servidor, sugiro uma rápida análise diagonal do exemplo completo de interação cliente-servidor.
Para simplificar, as demonstrações são todas em um processo. A implementação de transporte é uma função lambda que passa um buffer entre o cliente e o servidor.
Interação cliente-servidor. Caso de teste #include <cstdint> #include <cstdlib> #include <functional> #include <iomanip> #include <iostream> #include <map> #include <sstream> #include <string> #include <tuple> #include <vector> #include <utility> namespace rpc::type { using buffer = std::vector<char>; using executor = std::function<buffer (buffer)>; } // namespace rpc::type namespace rpc::detail { template <typename> struct function_meta; template <typename TRes, typename ... TArgs> struct function_meta<std::function<TRes (TArgs ... )>> { using result_type = std::decay_t<TRes>; using args_type = std::tuple<std::decay_t<TArgs> ... >; using request_type = std::tuple<std::string, std::decay_t<TArgs> ... >; }; } // namespace rpc::detail namespace rpc::packer { class string_serializer final { public: template <typename ... T> type::buffer save(std::tuple<T ... > const const &tuple) const { auto str = to_string(tuple, std::make_index_sequence<sizeof ... (T)>{}); return {begin(str), end(str)}; } template <typename ... T> void load(type::buffer const &buffer, std::tuple<T ... > &tuple) const { std::string str{begin(buffer), end(buffer)}; from_string(std::move(str), tuple, std::make_index_sequence<sizeof ... (T)>{}); } private: template <typename T, std::size_t ... I> std::string to_string(T const &tuple, std::index_sequence<I ... >) const { std::stringstream stream; auto put_item = [&stream] (auto const &i) { if constexpr (std::is_same_v<std::decay_t<decltype(i)>, std::string>) stream << std::quoted(i) << ' '; else stream << i << ' '; }; (put_item(std::get<I>(tuple)), ... ); return std::move(stream.str()); } template <typename T, std::size_t ... I> void from_string(std::string str, T &tuple, std::index_sequence<I ... >) const { std::istringstream stream{std::move(str)}; auto get_item = [&stream] (auto &i) { if constexpr (std::is_same_v<std::decay_t<decltype(i)>, std::string>) stream >> std::quoted(i); else stream >> i; }; (get_item(std::get<I>(tuple)), ... ); } }; } // namespace rpc::packer namespace rpc { template <typename TPacker> class client final { private: class result; public: client(type::executor executor) : executor_{executor} { } template <typename ... TArgs> result call(std::string const &func_name, TArgs && ... args) { auto request = std::make_tuple(func_name, std::forward<TArgs>(args) ... ); auto pack = packer_.save(request); auto responce = executor_(std::move(pack)); return {responce}; } private: using packer_type = TPacker; packer_type packer_; type::executor executor_; class result final { public: result(type::buffer buffer) : buffer_{std::move(buffer)} { } template <typename T> auto as() const { std::tuple<std::decay_t<T>> tuple; packer_.load(buffer_, tuple); return std::move(std::get<0>(tuple)); } private: packer_type packer_; type::buffer buffer_; }; }; template <typename TPacker> class server final { public: template <typename ... THandler> server(std::pair<char const *, THandler> const & ... handlers) { auto make_executor = [&packer = packer_] (auto const &handler) { auto executor = [&packer, function = std::function{handler}] (type::buffer buffer) { using meta = detail::function_meta<std::decay_t<decltype(function)>>; typename meta::request_type request; packer.load(buffer, request); auto response = std::apply([&function] (std::string const &, auto && ... args) { return function(std::forward<decltype(args)>(args) ... ); }, std::move(request) ); return packer.save(std::make_tuple(std::move(response))); }; return executor; }; (handlers_.emplace(handlers.first, make_executor(handlers.second)), ... ); } type::buffer execute(type::buffer buffer) { std::tuple<std::string> pack; packer_.load(buffer, pack); auto func_name = std::move(std::get<0>(pack)); auto const iter = handlers_.find(func_name); if (iter == end(handlers_)) throw std::runtime_error{"Function \"" + func_name + "\" not found."}; return iter->second(std::move(buffer)); } private: using packer_type = TPacker; packer_type packer_; using handlers_type = std::map<std::string, type::executor>; handlers_type handlers_; }; } // namespace rpc int main() { try { using packer_type = rpc::packer::string_serializer; rpc::server<packer_type> server{ std::pair{"hello", [] (std::string const &s) { std::cout << "Func: \"hello\". Inpur string: " << s << std::endl; return "Hello " + s + "!"; }}, std::pair{"to_int", [] (std::string const &s) { std::cout << "Func: \"to_int\". Inpur string: " << s << std::endl; return std::stoi(s); }} }; auto executor = [&server] (rpc::type::buffer buffer) { return server.execute(std::move(buffer)); }; rpc::client<packer_type> client{std::move(executor)}; std::cout << client.call("hello", std::string{"world"}).as<std::string>() << std::endl; std::cout << "Convert to int: " << client.call("to_int", std::string{"100500"}).as<int>() << std::endl; } catch (std::exception const &e) { std::cerr << "Error: " << e.what() << std::endl; return EXIT_FAILURE; } return EXIT_SUCCESS; }
Na implementação acima da classe de servidor, o mais interessante é seu construtor e o método execute.
Construtor da classe de servidor template <typename ... THandler> server(std::pair<char const *, THandler> const & ... handlers) { auto make_executor = [&packer = packer_] (auto const &handler) { auto executor = [&packer, function = std::function{handler}] (type::buffer buffer) { using meta = detail::function_meta<std::decay_t<decltype(function)>>; typename meta::request_type request; packer.load(buffer, request); auto response = std::apply([&function] (std::string const &, auto && ... args) { return function(std::forward<decltype(args)>(args) ... ); }, std::move(request) ); return packer.save(std::make_tuple(std::move(response))); }; return executor; }; (handlers_.emplace(handlers.first, make_executor(handlers.second)), ... ); }
O construtor da classe é clichê. Aceita uma lista de pares como entrada Cada par é um nome de método e um manipulador. E como o construtor é um modelo com um número variável de parâmetros, ao criar o objeto do servidor, todos os manipuladores disponíveis no servidor são registrados imediatamente. Isso tornará possível não criar métodos de registro adicionais chamados nos manipuladores de servidor. E, por sua vez, nos livra de pensar se o objeto de classe de servidor será usado em um ambiente multithread e se a sincronização é necessária.
Um fragmento do construtor da classe de servidor template <typename ... THandler> server(std::pair<char const *, THandler> const & ... handlers) {
Coloca muitos manipuladores heterogêneos passados no mapa de funções do mesmo tipo. Para isso, a convolução também é usada, o que facilita a inserção no std :: map de todo o conjunto de manipuladores passados em uma linha, sem loops e algoritmos
(handlers_.emplace(handlers.first, make_executor(handlers.second)), ... );
As funções Lambda que permitem o uso de auto como parâmetros facilitam a implementação do mesmo tipo de wrapper sobre os manipuladores. Wraps do mesmo tipo são registrados no mapa de métodos disponíveis no servidor (std :: map). Ao processar solicitações, uma pesquisa é realizada em um cartão e o mesmo manipulador chama o manipulador encontrado, independentemente dos parâmetros recebidos e do resultado retornado. A função std :: apply que apareceu na biblioteca padrão chama a função passada para ela com os parâmetros passados como uma tupla. A função std :: apply também pode ser implementada no C ++ 11. Agora ele está disponível “pronto para uso” e não há necessidade de transferi-lo de projeto para projeto.
Método de execução type::buffer execute(type::buffer buffer) { std::tuple<std::string> pack; packer_.load(buffer, pack); auto func_name = std::move(std::get<0>(pack)); auto const iter = handlers_.find(func_name); if (iter == end(handlers_)) throw std::runtime_error{"Function \"" + func_name + "\" not found."}; return iter->second(std::move(buffer)); }
Recupera o nome da função chamada, procura o método no mapa de manipuladores registrados, chama o manipulador e retorna o resultado. Tudo interessante nos wrappers preparados no construtor da classe de servidor. Alguém pode ter notado a exceção, e talvez tenha surgido a pergunta: "As exceções são tratadas de alguma forma?" Sim, na implementação completa, que será fornecida por referência no final, o empacotamento de exceção é fornecido. Ali mesmo, para simplificar o material, as exceções não são passadas entre o cliente e o servidor.
Dê uma outra olhada na função
principal int main() { try { using packer_type = rpc::packer::string_serializer; rpc::server<packer_type> server{ std::pair{"hello", [] (std::string const &s) { std::cout << "Func: \"hello\". Inpur string: " << s << std::endl; return "Hello " + s + "!"; }}, std::pair{"to_int", [] (std::string const &s) { std::cout << "Func: \"to_int\". Inpur string: " << s << std::endl; return std::stoi(s); }} }; auto executor = [&server] (rpc::type::buffer buffer) { return server.execute(std::move(buffer)); }; rpc::client<packer_type> client{std::move(executor)}; std::cout << client.call("hello", std::string{"world"}).as<std::string>() << std::endl; std::cout << "Convert to int: " << client.call("to_int", std::string{"100500"}).as<int>() << std::endl; } catch (std::exception const &e) { std::cerr << "Error: " << e.what() << std::endl; return EXIT_FAILURE; } return EXIT_SUCCESS; }
Ele implementa uma interação cliente-servidor completa. Para não complicar o material, o cliente e o servidor trabalham em um processo. Substituindo a implementação do executor, você pode usar o transporte necessário.
No padrão C ++ 17, às vezes é possível não especificar parâmetros de modelo na instanciação. Na função principal acima, isso é usado ao registrar manipuladores de servidor (std :: pair sem parâmetros de modelo) e simplifica o código.
A implementação básica de RPC está pronta. Resta acrescentar a capacidade prometida de passar estruturas de dados personalizadas como parâmetros e retornar resultados.
Estruturas de dados personalizadas
Para transferir dados através do limite do processo, eles precisam ser serializados em algo. Por exemplo, você pode enviar tudo para um fluxo padrão. Muito será suportado imediatamente. Para estruturas de dados personalizadas, você precisará implementar os operadores de saída. Cada estrutura precisa de seu próprio operador de saída. Às vezes você quer não fazer isso. Para classificar todos os campos da estrutura e gerar cada campo no fluxo, você precisa de algum método generalizado. A reflexão poderia ajudar muito nisso. Ainda não está em C ++. Você pode recorrer à geração de código e ao uso de uma mistura de macros e modelos. Mas a idéia era fazer a interface da biblioteca em C ++ puro.
Ainda não há reflexão completa em C ++. Portanto, a solução abaixo pode ser usada com algumas limitações.
A solução é baseada no uso do novo recurso "ligações estruturadas" do C ++ 17. Muitas vezes, nas caixas de diálogo, você encontra muitos jargões, por isso recusei qualquer opção para o nome desse recurso em russo.
Abaixo está uma solução que permite transferir os campos da estrutura de dados transferidos para a tupla.
template <typename T> auto to_tuple(T &&value) { using type = std::decay_t<T>; if constexpr (is_braces_constructible_v<type, dummy_type, dummy_type, dummy_type>) { auto &&[f1, f2, f3] = value; return std::make_tuple(f1, f2, f3); } else if constexpr (is_braces_constructible_v<type, dummy_type, dummy_type>) { auto &&[f1, f2] = value; return std::make_tuple(f1, f2); } else if constexpr (is_braces_constructible_v<type, dummy_type>) { auto &&[f1] = value; return std::make_tuple(f1); } else { return std::make_tuple(); } }
Na Internet, você pode encontrar muitas soluções semelhantes.
Muito do que foi usado aqui foi dito acima, exceto as ligações estruturadas. A função to_tuple aceita um tipo personalizado, determina o número de campos e, com a ajuda de ligações estruturadas, "transfere" os campos da estrutura para uma tupla. E “if constexpr” permite selecionar o ramo de implementação desejado. Como não há reflexo no C ++, não é possível criar uma solução completa que leve em consideração todos os aspectos do tipo. Existem restrições nos tipos usados. Um deles - o tipo deve ser sem construtores personalizados.
To_tuple usa is_braces_constructible_v. Esse tipo permite determinar a capacidade de inicializar a estrutura transferida usando chaves e determinar o número de campos.
is_braces_constructible_v struct dummy_type final { template <typename T> constexpr operator T () noexcept { return *static_cast<T const *>(nullptr); } }; template <typename T, typename ... TArgs> constexpr decltype(void(T{std::declval<TArgs>() ... }), std::declval<std::true_type>()) is_braces_constructible(std::size_t) noexcept; template <typename, typename ... > constexpr std::false_type is_braces_constructible(...) noexcept; template <typename T, typename ... TArgs> constexpr bool is_braces_constructible_v = std::decay_t<decltype(is_braces_constructible<T, TArgs ... >(0))>::value;
A função to_tuple acima pode transformar estruturas de dados do usuário contendo no máximo três campos em tuplas. Para aumentar o número possível de campos “deslocados” da estrutura, você pode copiar os ramos “if constexpr” com uma pequena inclusão da mente ou recorrer ao uso da biblioteca boost.preprocessor não mais simples. Se você selecionar a segunda opção, o código ficará difícil de ler e possibilitará o uso de estruturas com um grande número de campos.
Implementando to_tuple com boost.preprocessor template <typename T> auto to_tuple(T &&value) { using type = std::decay_t<T>; #define NANORPC_TO_TUPLE_LIMIT_FIELDS 64
Se você já tentou fazer algo como boost.bind para C ++ 03, onde teve que fazer muitas implementações com um número diferente de parâmetros, a implementação de to_tuple usando o boost.preprocessor não parece estranha ou complicada.
E se o suporte à tupla for adicionado ao serializador, a função to_tuple permitirá a serialização de estruturas de dados do usuário. E torna-se possível traí-los como parâmetros e retornar resultados em seu RPC.
Além das estruturas de dados definidas pelo usuário, o C ++ possui outros tipos internos para os quais a saída para o fluxo padrão não é implementada. O desejo de reduzir o número de operadores de saída sobrecarregados no fluxo leva a um código generalizado que permite que um método processe a maioria dos contêineres C ++, como std :: list, std :: vector, std :: map. Sem esquecer o SFINAE e o std :: enable_if_t, você pode continuar estendendo o serializador. Nesse caso, será necessário determinar de alguma maneira indiretamente as propriedades dos tipos, semelhante ao que é feito na implementação de is_braces_constructible_v.
Conclusão
, , stl- . , RPC — C++ 14 / 17. HTTP / HTTPS .
NanoRPC GitHub .
Obrigado pela atenção!