RPC — повод попробовать новое в C++ 14 / 17

    Несколько лет назад разработчики на C++ получили долгожданный стандарт C++ 11, принесший много нового. И у меня был интерес быстрее перейти к его использованию в повседневно решаемых задачах. Перейти к C++ 14 и 17 такого не было. Казалось, нет того набора фич, который бы заинтересовал. Весной я все же решил посмотреть на новшества языка и что-нибудь попробовать. Чтобы поэкспериментировать с новшествами нужно было придумать себе задачу. Долго думать не пришлось. Решено написать свое RPC с пользовательскими структурами данных в качестве параметров и без использования макросов и кодогенерации — все на C++. Это удалось благодаря новым возможностям языка.

    Идея, реализация, фидбэк с Reddit, доработки — все появилось весной, начале лета. К концу же удалось дописать пост на Хабр.

    Вы задумались о собственном RPC? Возможно, материал поста Вам поможет определиться с целью, методами, средствами и принять решение в пользу готового или что-то реализовывать самостоятельно…

    Введение


    RPC (remote procedure call) — тема не новая. Существует множество реализаций на разных языках программирования. В реализациях используются различные форматы данных и виды транспорта. Все это можно отразить несколькими пунктами:

    • Сериализация / десериализация
    • Транспорт
    • Выполнение удаленного метода
    • Возврат результата

    Реализация определяется желаемой целью. Например, можно задаться целью обеспечить высокую скорость вызова удаленного метода и пожертвовать удобством использования или наоборот обеспечить максимальный комфорт написания кода, возможно, немного потеряв в производительности. Цели и инструменты разные… Мне хотелось комфорта и приемлемой производительности.

    Реализация


    Ниже приведено несколько шагов реализации RPC на C++ 14 / 17, и сделаны акценты на некоторые новшества языка, ставшие причиной появления этого материала.

    Материал рассчитан на тех, кто по каким-то причинам заинтересован в своем RPC, и, возможно пока, нуждается в дополнительной информации. В комментариях было бы интересно увидеть описание опыта других разработчиков, столкнувшихся с подобными задачами.

    Сериализация


    Перед тем, как начать писать код сформирую задачу:

    • Все параметры методов и возвращаемый результат передаются через кортеж.
    • Сами вызываемые методы не обязаны принимать и возвращать кортежи.
    • Результатом упаковки кортежа дожен быть буфер, формат которого не фиксирован

    Ниже приведен код упрощенного строкового сериализатора.

    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
    

    И код функции main, демонстрирующий работу сериализатора.

    Функция main
    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;
    }
    

    Расстановка обещанных акцентов

    Первым делом нужно определить буфер, с помощью которого будет производиться весь обмен данными:

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

    Сериализатор имеет методы сохранения кортежа в буфер (save) и загрузки его из буфера (load)

    Метод save принимает кортеж и возвращает буфер.

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

    Кортеж — шаблон с переменным количеством параметров. Такие шаблоны появились в C++11 и хорошо себя зарекомендовали. Здесь нужно как-то пройти по всем элементам такого шаблона. Вариантов может быть несколько. Воспользуюсь одной из возможностей C++ 14 — последовательностью целых чисел (индексов). В стандартной библиотеке появился тип make_index_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>;
    

    Аналогичное можно реализовать и на C++11, а после носить за собой из проекта в проект.

    Такая последовательность индексов дает возможность «пройти» по кортежу:

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

    Метод to_string использует несколько возможностей последних стандартов C++.

    Расстановка обещанных акцентов

    В C++ 14 появилась возможность использовать auto в качестве параметров для лямбда-функций. Этого часто не хватало, например, при работе с алгоритмами стандартной библиотеки.

    В C++ 17 появилась «свертка», которая позволяет писать такой код, как:

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

    В приведенном фрагменте вызывается лямбда-функция put_item для каждого из элеметов переданного кортежа. При этом гарантирована последовательность не зависящая от платформы и компилятора. Что-то подобное можно было написать и на C++ 11.

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

    Но в каком порядке были бы сохранены элементы зависело бы от компилятора.

    В стандартной библиотеке C++ 17 появилось много алиасов, например, decay_t, сократившие записи вида:

    typename decay<T>::type
    

    Желание писать более короткие конструкции имеет место быть. Шаблонная конструкция, где в одной строке встречается пара-тройка typename и template, разделенные двоеточиями и угловыми скобками, выглядит жутковато. Чем можно напугать некоторых своих коллег. В будущем обещают уменьшить количество мест, где необходимо писать template, typename.

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

    Есть интересный момент. Многих учили, что switch и аналогичные конструкции — это не очень хорошо с точки зрения масштабируемости кода. Предпочтительно использовать полиморфизм времени выполнения / времени компиляции и перегрузку с доводами в пользу «правильного выбора». А тут «if constexpr»… Возможность компактности не всех оставляет равнодушными к ней. Возможность языка не означает необходимость ее использования.

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

    С описанием сериализации пока можно остановиться. Десериализация (load) реализована аналогично.

    Транспорт


    Транспорт прост. Это функция, принимающая и возвращающая буфер.

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

    Формируя подобный объект «исполнитель» с помощью std::bind, лямбда-функций и т. д. можно использовать любую свою реализацию транспорта. Детали реализации транспорта в рамках этого поста рассматриваться не будут. Можно взглянуть на завершенную реализацию RPC, ссылка на которую будет дана в конце.

    Клиент


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

    Клиент
    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
    

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

    В текущей реализации конструктор класса принимает объект-исполнитель. Исполнитель скрывает под собой реализацию транспорта, и дает возможность в этом месте кода не задумываться о методах обмена данными между процессами. В тестовом примере реализация транспорта выводит запросы на консоль.

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

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

    Метод клиента call:

    • с помощью сериализатора упаковывает имя вызываемого метода и его параметры
    • с помощью объекта-исполнителя отправляет запрос на сервер и принимает ответ
    • передает полученный ответ в класс, извлекающий полученный результат

    Базовая реализация клиента готова. Что-то еще осталось. Об этом позже.

    Сервер


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

    Для простоты демонстрации все в одном процессе. Реализация транспорта — лямбда-функция, передающая буфер между клиентом и сервером.

    Клиент-серверное взаимодействие. Тестовый пример
    #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;
    }
    

    В приведенной реализации класса сервер самое интересное — это его конструктор и метод execute.

    Конструктор класса 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)), ... );
    }
    

    Конструктор класса является шаблонным. На вход принимает список пар. Каждая пара — имя метода и обработчик. А так как конструктор является шаблоном с переменным количеством параметров, то при создании объекта server сразу регистрируются все доступные на сервере обработчики. Что даст возможность не делать дополнительных методов регистрации вызываемых на сервере обработчиков. И в свою очередь освобождает от размышлений о том, будет ли объект класса server использоваться в многопоточной среде и нужна ли синхронизация.

    Фрагмент конструктора класса server

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

    Помещает множество переданных разнотипных обработчиков в карту однотипно вызываемых функций. Для этого так же используется свертка, позволяющая легко поместить в std::map все множество переданных обработчиков одной строкой без циклов и алгоритмов

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

    Лямбда-функции, позволяющие использовать auto в качестве параметров дали возможность легко реализовать однотипные обертки над обработчиками. Однотипные обертки регистрируются в карте доступных на сервере методов (std::map). При обработке запросов производится поиск по такой карте, и однотипный вызов найденного обработчика вне зависимости от принимаемых параметров и возвращаемого результата. Появившаяся в стандартной библиотеке функция std::apply вызывает переданную ей функцию с параметрами, переданными в виде кортежа. Функцию std::apply можно реализовать и на C++11. Теперь же она доступна «из коробки» и не надо ее переносить из проекта в проект.

    Метод execute

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

    Извлекает имя вызываемой функции, производит поиск метода в карте зарегистрированных обработчиков, вызывает обработчик и возвращает результат. Все интересное в обертках подготовленных в конструкторе класса server. Кто-то возможно заметил исключение, и, возможно, возник вопрос: «А исключения как-то обрабатываются?». Да, в полной реализации, которая будет дана ссылкой в конце, маршалинг исключений предусмотрен. Тут же для упрощения материала исключения не передаются между клиентом и сервером.

    Взгляните еще раз на функцию

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

    В ней реализовано полноценное клиент-серверное взаимодействия. Чтобы не усложнять материал клиент и сервер работают в один процесс. Заменив реализацию executor, можно использовать нужный транспорт.

    В стандарте C++ 17 появилась возможность иногда не указывать параметры шаблонов при инстанцировании. В приведенной выше функции main это используется при регистрации обработчиков сервера (std::pair без параметров шаблона) и делает код проще.

    Базовая реализация RPC готова. Осталось добавить обещанную возможность передавать пользовательские структуры данных в качестве параметров и возвращаемых результатов.

    Пользовательские структуры данных


    Чтобы передать данные через границу процесса их нужно во что-нибудь сериализовать. Например, можно все выводить в стандартный поток. Многое будет поддерживаться «из коробки». Для пользовательских структур данных придется реализовать самостоятельно операторы вывода. Каждой структуре нужен свой оператор вывода. Иногда хочется этого не делать. Чтобы перебрать все поля структуры и вывести каждое поле в поток, нужен какой-то обобщенный метод. В этом могла бы хорошо помочь рефлексия. Ее пока нет в C++. Можно прибегнуть к кодогенерации и использованию смеси из макросов и шаблонов. Но идея была в том, чтобы сделать интерфейс библиотеки на чистом C++.

    Полноценной рефлексии в C++ пока нет. Поэтому приведенное ниже решение может использоваться с некоторыми ограничениями.

    Решение построено на использовании новой возможности C++ 17 «structured bindings». Часто в диалогах можно встретить много жаргонизмов, поэтому я отказался от каких-либо вариантов названия этой возможности на русском.

    Ниже приведено решение, позволяющее перенести в кортеж поля переданной структуры данных.

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

    В Интернете можно найти немало аналогичных решений.

    О многом, что здесь использовано было сказано выше, кроме structured bindings. Функция to_tuple принимает пользовательский тип, определяет количество полей, и с помощью structured bindings «перекладывает» поля структуры в кортеж. А «if constexpr» позволяет выбрать нужную ветвь реализации. Так как в C++ рефлексии нет, то полноценное, учитывающее все аспекты типа, решение построить нельзя. Есть ограничения на используемые типы. Одно из них — тип должен быть без пользовательских конструкторов.

    В to_tuple используется is_braces_constructible_v. Этот тип позволяет определить возможность инициализировать переданную структуру с помощью фигурных скобок и определить количество полей.

    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;
    

    Приведенная выше функция to_tuple может преобразовывать в кортежи пользовательские структуры данных, содержащие не более трех полей. Чтобы увеличить возможное количество «перекладываемых» полей структуры можно или копировать ветки «if constexpr» с небольшим включением разума, или прибегнуть к использованию не самой простой библиотеки boost.preprocessor. В случае выбора второго варианта код станет трудночитаемым и даст возможность использовать структуры с большим количеством полей.

    Реализация to_tuple с помощью 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
    }
    

    Если Вы когда-либо пробовали сделать что-то подобное boost.bind для C++ 03, где нужно было сделать множество реализаций с разным количеством параметров, то реализация to_tuple с использованием boost.preprocessor не покажется странной или сложной.

    А если добавить в сериализатор поддержку кортежей, то функция to_tuple даст возможность сериализовать пользовательские структуры данных. И появляется возможность предавать их в качестве параметров и возвращаемых результатов в своем RPC.

    Кроме пользовательских структур данных в C++ есть другие встроенные типы, для которых вывод в стандартный поток не реализован. Желание уменьшить количество перегруженных операторов вывода в поток приводит к обобщенному коду, позволяющему одним методом обрабатывать большую часть контейнеров C++ таких, как std::list, std::vector, std::map. Не забыв про SFINAE и std::enable_if_t можно продолжить расширять сериализатор. При этом нужно будет как-то косвенно определять свойства типов, подобно тому, как сделано в реализации is_braces_constructible_v.

    Заключение


    За рамками поста остался маршалинг исключение, транспорт, сериализация stl-контейнеров и многое другое. Дабы сильно не усложнять пост были приведены только общие принципы, на которых мне удалось построить свою RPC библиотеку и решить изначально поставленную для себя же задачу — попробовать новые возможности C++ 14 / 17. А полученная реализация позволяет вызывать удаленные методы по широкораспространенным протоколам HTTP / HTTPS и содержит достаточно подробные примеры использования.

    Код библиотеки NanoRPC на GitHub .

    Спасибо за внимание!
    • +23
    • 10,1k
    • 3
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 3

      +4
      Но в каком порядке были бы сохранены элементы зависело бы от компилятора.

      В таком виде — да. Но можно использовать то, что инициализация аргументов для initializer_list'а выполняется в строго определённом порядке, и получается такой вот трюк:


      template<typename... Args>
      void foo(Args... args)
      {
          std::initializer_list<int> unused { (static_cast<void>(f(args)), 0)... };
          (void) unused;
      }

      Первое приведение к void здесь нужно на (наркоманский и не встречающийся в реальности, но всё же) случай, когда f(args) возвращает что-то с переопределённым оператором запятая.

        0
        Для полноты картины следовало бы сравнить как RPC выглядит на других языках.
        Например RPC для андроида на lua
          0
          Как-то давно мечтал об идеальной функции send, суть которой была в том, что она могла принять любой аргумент и в разном количестве аргументов отправить его серверу, а тот обработать.

          Получилось так:
          // Код сервера
          #include <socket.hpp>
          
          void Foo(const char *txt, int i)
          {
          	printf("%s %d\n", txt, i);
          }
          
          int main()
          {
          	snl::server server;
          	server["Foo"] = Foo;
          
          	server.create("127.0.0.1", 9999, snl::arch_server_t::tcp_thread);
          	server.run(snl::type_blocked_t::non_block);
          
          
          	system("pause");
                  return 0;
          }
          


          // Код клиента
          #include <socket.hpp>
          
          int main()
          {
          	snl::client client;
          	client.connect("127.0.0.1", 9999, snl::arch_server_t::tcp_thread);
          	client.run(snl::type_blocked_t::non_block);
          
          	for (int i = 0;; i++)
          		client.send("Foo", "Test", i);
          
                  return 0;
          }
          


          Как видно:
           client.send("Foo", "Test", i); 

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

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое