📜 ⬆️ ⬇️

RPC is a reason to try something new in C ++ 14/17

A few years ago, C ++ developers got the long-awaited C ++ 11 standard, which brought a lot of new things. And I had an interest to move quickly to its use in everyday tasks. Go to C ++ 14 and 17 was not like this. It seemed that there is no feature set that would interest. In the spring, I decided to look at the innovations of the language and try something. To experiment with innovations, you had to invent a task for yourself. I did not have to think long. It was decided to write my own RPC with user data structures as parameters and without using macros and code generation - all in C ++. This was possible thanks to the new features of the language.

The idea, the implementation, feedback with Reddit, improvements - everything appeared in the spring, early summer. By the end it was possible to add a post on Habr.

Are you thinking about your own RPC? Perhaps, the material of the post will help you to decide on the purpose, methods, means and decide for the benefit of the finished one or to realize something yourself ...

Introduction


RPC (remote procedure call) is not a new topic. There are many implementations in different programming languages. Implementations use different data formats and modes of transport. All this can be reflected in several points:
')

Implementation is determined by the desired goal. For example, you can set a goal to provide a high speed call to a remote method and sacrifice usability, or vice versa, to ensure maximum comfort in writing code, perhaps a little lost in performance. The goals and tools are different ... I wanted comfort and acceptable performance.

Implementation


Below are a few steps of implementing RPC in C ++ 14/17, and emphasis has been placed on some of the language innovations that have caused this material to appear.

The material is designed for those who for some reason are interested in their RPC, and, perhaps for now, need additional information. In the comments it would be interesting to see a description of the experience of other developers who are faced with similar tasks.

Serialization


Before you start writing code, form the task:


Below is the simplified string serializer code.

string_serializer
namespace rpc::type { using buffer = std::vector<char>; } // namespace rpc::type namespace rpc::packer { class string_serializer final { public: 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)}; } 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 

And the main function code, which demonstrates the work of the serializer.

Main function
 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); // For test { auto pack = serializer.save(params); std::cout << "Deserialized pack: " << std::string{begin(pack), end(pack)} << std::endl; } } catch (std::exception const &e) { std::cerr << "Error: " << e.what() << std::endl; return EXIT_FAILURE; } return EXIT_SUCCESS; } 

Placement of promised accents

The first step is to determine the buffer with which all data exchange will be performed:

 namespace rpc::type { using buffer = std::vector<char>; } // namespace rpc::type 

The serializer has methods for saving a tuple to the buffer (save) and loading it from the buffer (load)

The save method takes a tuple and returns a 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)}; } 

A tuple is a template with a variable number of parameters. Such templates appeared in C ++ 11 and have proven themselves well. Here you need to somehow go through all the elements of such a template. There may be several options. I will use one of the features of C ++ 14 - a sequence of integers (indices). The standard library has the type make_index_sequence, which allows to get the following sequence:

 template< class T, T... Ints > class integer_sequence; template<class T, T N> using make_integer_sequence = std::integer_sequence<T, /* a sequence 0, 1, 2, ..., N-1 */ >; template<std::size_t N> using make_index_sequence = make_integer_sequence<std::size_t, N>; 

Similar can be implemented in C ++ 11, and then carried along from project to project.

Such a sequence of indices makes it possible to “pass” through a tuple:

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

The to_string method uses several features of the latest C ++ standards.

Placement of promised accents

In C ++ 14, it became possible to use auto as parameters for lambda functions. This was often lacking, for example, when working with standard library algorithms.

In C ++ 17, “convolution” appeared , which allows you to write code such as:

 (put_item(std::get<I>(tuple)), ... ); 

In the given fragment, the lambda put_item function is called for each of the elements of the passed tuple. At the same time, a sequence independent of the platform and the compiler is guaranteed. Something similar could be written in C ++ 11.

 template <typename … T> void unused(T && … ) {} // ... unused(put_item(std::get<I>(tuple)) ... ); 

But in what order the elements would be stored would depend on the compiler.

In the standard library C ++ 17, many aliases appeared, for example, decay_t, which reduced the entries like:

 typename decay<T>::type 

The desire to write shorter constructions takes place. The template construction, where in one line there is a couple of typename and template, separated by colons and angle brackets, looks scary. What can scare some of their colleagues. In the future they promise to reduce the number of places where you need to write a template, typename.

The desire for conciseness gave another interesting construction of the language “if constexpr”, allows you to avoid writing a lot of private specializations of templates.

There is an interesting point. Many have learned that switch and similar constructions are not very good in terms of code scalability. It is preferable to use compile time / compile time polymorphism and overload with arguments in favor of the “right choice”. And then “if constexpr” ... The possibility of compactness does not leave everyone indifferent to it. The possibility of language does not mean the need to use it

It was necessary to write a separate serialization for the string type. For convenient work with strings, for example, std :: quoted. Has appeared in the stream while reading and reading from it. It allows you to escape lines and allows saving to the stream and loading dates from it, without thinking about the delimiter.

With a description of serialization, you can stop. Deserialization (load) is implemented similarly.

Transport


Transportation is simple. This is the function that receives and returns the buffer.

 namespace rpc::type { // ... using executor = std::function<buffer (buffer)>; } // namespace rpc::type 

Forming a similar “executor” object using std :: bind, lambda functions, etc., you can use any of your transport implementations. Details of the implementation of transport in this post will not be considered. You can take a look at the completed RPC implementation, which will be referenced at the end.

Customer


Below is the client test code. The client creates requests and sends them to the server based on the selected transport. In the following test code, all client requests are displayed on the console. And in the next step of the implementation, the client will communicate directly with the server.

Customer
 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_; }; }; } // namespace rpc 

The client is implemented as a template class. The template parameter is a serializer. If necessary, the class can be converted into a non-template and the implementation of the serializer is passed to the constructor.

In the current implementation, the class constructor accepts the executable object. The executor hides the transport implementation underneath, and in this place of the code makes it possible not to think about the methods of data exchange between processes. In the test case, the implementation of the transport displays requests to the console.

 auto executor = [] (rpc::type::buffer buffer) { // Print request data std::cout << "Request pack: " << std::string{begin(buffer), end(buffer)} << std::endl; return buffer; }; 

The user code is not trying to use the result of the client’s work yet, as it’s not from where to get it.

Client call method:


The basic client implementation is ready. Something else remains. More on that later.

Server


Before proceeding to the consideration of the details of the implementation of the server part, I propose a quick, diagonal look at the completed example of client-server interaction.

For ease of demonstration all in one process. Transport implementation is a lambda function that transfers a buffer between the client and the server.

Client-server interaction. Test case
 #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; } 

In the above implementation of the class, the most interesting thing about the server is its constructor and the execute method.

Constructor class server

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

The class constructor is template. At the entrance takes a list of pairs. Each pair is a method name and a handler. And since the constructor is a template with a variable number of parameters, when a server object is created, all the handlers available on the server are immediately registered. This will make it possible not to make additional registration methods for handlers called on the server. And in turn, it frees you from thinking about whether the object of the class server will be used in a multi-threaded environment and whether synchronization is necessary.

Fragment of the server class constructor

 template <typename ... THandler> server(std::pair<char const *, THandler> const & ... handlers) { // … (handlers_.emplace(handlers.first, make_executor(handlers.second)), ... ); } 

Places a lot of passed handlers of different types in the map of functions of the same type. For this, a convolution is also used, which makes it easy to put into std :: map the entire set of passed handlers in one line without cycles and algorithms.

 (handlers_.emplace(handlers.first, make_executor(handlers.second)), ... ); 

Lambda functions that allow the use of auto as parameters have made it possible to easily implement wraps of the same type over handlers. Wrappers of the same type are registered in the methods available on the server (std :: map). When processing requests, a search is performed on such a map, and a similar call to the found handler, regardless of the parameters received and the returned result. The std :: apply function that appeared in the standard library calls the function passed to it with the parameters passed as a tuple. The std :: apply function can also be implemented in C ++ 11. Now it is available “out of the box” and it is not necessary to transfer it from project to project.

Execute method

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

Retrieves the name of the function being called, searches the method in the map of the registered handlers, calls the handler and returns the result. Everything is interesting in wrappers prepared in the constructor of the server class. Someone may have noticed an exception, and perhaps the question arose: “Are exceptions somehow processed?”. Yes, in full implementation, which will be given by reference at the end, marshalling of exceptions is provided. Immediately to simplify the material, exceptions are not passed between the client and the server.

Take another look at the function.

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

It implements a full-fledged client-server interaction. In order not to complicate the material, the client and server work in one process. Replacing the implementation executor, you can use the desired transport.

In the standard C ++ 17, it is sometimes possible not to specify template parameters when instantiating. In the main function above, this is used when registering server handlers (std :: pair without template parameters) and makes the code easier.

The basic RPC implementation is ready. It remains to add the promised ability to pass custom data structures as parameters and returned results.

Custom data structures


To transfer data across the process boundary, they need to be serialized into something. For example, everything can be output to the standard stream. Much will be supported out of the box. For user data structures, you will have to implement output operators yourself. Each structure needs its own output operator. Sometimes you want not to do it. To iterate through all the fields of a structure and output each field to a stream, we need some generalized method. This could well help reflexion. It is not yet in C ++. You can resort to code generation and use a mixture of macros and templates. But the idea was to make the library interface in pure C ++.

There is no complete reflection in C ++ yet. Therefore, the solution below can be used with some limitations.

The solution is based on the use of the new C ++ 17 “structured bindings” feature. Often in conversations you can find a lot of jargon, so I refused any options for naming this feature in Russian.

Below is a solution that allows you to transfer the fields of the transferred data structure to a tuple.

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

On the Internet, you can find many similar solutions.

Much of what was used here was mentioned above, except for the structured bindings. The to_tuple function accepts a user-defined type, determines the number of fields, and with the help of structured bindings “shifts” the structure fields into a tuple. And “if constexpr” allows you to select the desired implementation branch. Since there is no reflection in C ++, it is impossible to build a complete solution that takes into account all aspects of the type. There are restrictions on the types used. One of them is the type must be without custom constructors.

The to_tuple uses is_braces_constructible_v. This type allows you to determine the ability to initialize the passed structure using curly brackets and determine the number of fields.

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; 

The to_tuple function above can convert custom data structures containing up to three fields to tuples. To increase the possible number of “shifted” structure fields, you can either copy the “if constexpr” branches with a small turn on of the mind, or resort to using the not so simple library boost.preprocessor. If the second option is chosen, the code will become difficult to read and will provide an opportunity to use structures with a large number of fields.

Implementing to_tuple with boost.preprocessor
 template <typename T> auto to_tuple(T &&value) { using type = std::decay_t<T>; #define NANORPC_TO_TUPLE_LIMIT_FIELDS 64 // you can try to use BOOST_PP_LIMIT_REPEAT #define NANORPC_TO_TUPLE_DUMMY_TYPE_N(_, n, data) \ BOOST_PP_COMMA_IF(n) data #define NANORPC_TO_TUPLE_PARAM_N(_, n, data) \ BOOST_PP_COMMA_IF(n) data ## n #define NANORPC_TO_TUPLE_ITEM_N(_, n, __) \ if constexpr (is_braces_constructible_v<type, \ BOOST_PP_REPEAT_FROM_TO(0, BOOST_PP_SUB(NANORPC_TO_TUPLE_LIMIT_FIELDS, n), NANORPC_TO_TUPLE_DUMMY_TYPE_N, dummy_type) \ >) { auto &&[ \ BOOST_PP_REPEAT_FROM_TO(0, BOOST_PP_SUB(NANORPC_TO_TUPLE_LIMIT_FIELDS, n), NANORPC_TO_TUPLE_PARAM_N, f) \ ] = value; return std::make_tuple( \ BOOST_PP_REPEAT_FROM_TO(0, BOOST_PP_SUB(NANORPC_TO_TUPLE_LIMIT_FIELDS, n), NANORPC_TO_TUPLE_PARAM_N, f) \ ); } else #define NANORPC_TO_TUPLE_ITEMS(n) \ BOOST_PP_REPEAT_FROM_TO(0, n, NANORPC_TO_TUPLE_ITEM_N, nil) NANORPC_TO_TUPLE_ITEMS(NANORPC_TO_TUPLE_LIMIT_FIELDS) { return std::make_tuple(); } #undef NANORPC_TO_TUPLE_ITEMS #undef NANORPC_TO_TUPLE_ITEM_N #undef NANORPC_TO_TUPLE_PARAM_N #undef NANORPC_TO_TUPLE_DUMMY_TYPE_N #undef NANORPC_TO_TUPLE_LIMIT_FIELDS } 

If you have ever tried to do something like boost.bind for C ++ 03, where you had to do a lot of implementations with a different number of parameters, then implementing to_tuple using boost.preprocessor does not seem strange or difficult.

And if you add support for tuples to the serializer, then the function to_tuple will give the opportunity to serialize user data structures. And it becomes possible to betray them as parameters and return results in your RPC.

In addition to user data structures, C ++ has other built-in types for which output to the standard stream is not implemented. The desire to reduce the number of overloaded output statements to a stream leads to generalized code, which allows one method to handle most of the C ++ containers such as std :: list, std :: vector, std :: map. Not forgetting SFINAE and std :: enable_if_t, you can continue to expand the serializer. In this case, it will be necessary to somehow indirectly determine the properties of types, just as is done in the implementation of is_braces_constructible_v.

Conclusion


Outside the post was marshaling the exception, transport, serialization of stl-containers and much more.In order not to complicate the post, only the general principles were presented, on which I managed to build my RPC library and solve the task originally posed for myself - try new C ++ 14/17 features. contains fairly detailed usage examples. NanoRPC

library code on github .

Thanks for attention!

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


All Articles