14 KiB
Minimal C++ RPC PoC with Auto-Code Generation
Простой Proof-of-Concept реализации RPC для C++ с автоматической генерацией прокси и скелета по аннотациям в исходниках. Включает C-биндинги для клиентской стороны, позволяющие использовать RPC из C-приложений.
Проект демонстрирует:
- Парсинг исходников C++ с помощью
libclangдля поиска аннотированных классов и методов. - Автоматическую генерацию файлов:
*.proxy.h/cpp— клиентский прокси для вызова удалённых методов (C++).*.skeleton.h/cpp— серверный скелет для приёма запросов и вызова реальных методов.*Client_c.h/cpp— C-биндинги для клиентской стороны (автоматически генерируются для каждогоRPC_EXPORTкласса).
- Минимальный протокол передачи данных через именованные каналы (FIFO).
- Поддержка только типов
intдля аргументов и возвращаемого значения (PoC).
Структура проекта
project/
├── CMakeLists.txt
├── README.md
├── include/
│ ├── ipc/
│ │ ├── BaseIpcMessage.h # шаблонный класс сообщения
│ │ ├── IpcChannel.h
│ │ ├── IpcCodec.h
│ │ ├── IpcConfig.h # type alias: using IpcMessage = BaseIpcMessage<TextIpcSerializer>
│ │ ├── IpcDispatcher.h
│ │ ├── IpcPipeChannel.h
│ │ ├── IpcMarshaller.h
│ │ └── IpcSerializer.h # сериализаторы (TextIpcSerializer)
│ └── rpc/
│ ├── rpc_export.h
│ ├── RpcRegistry.h # реестр RPC-объектов (скелетонов)
│ ├── RpcInvoker.h # тонкий фасад над RpcRegistry
│ ├── RpcValue.h
│ ├── rpc_client_c.h # базовый C API для RPC клиента
│ └── rpc_client_c_impl.h # внутренний заголовок для C++ (не для C)
├── src/
│ ├── client.cpp # C++ клиент
│ ├── client_c.c # C клиент
│ ├── MyService.cpp
│ ├── MyService.h
│ ├── server.cpp
│ └── rpc_client_c.cpp # реализация базовой C-обёртки
├── tools/
│ ├── generate_rpc.py
│ └── templates/
│ ├── proxy.cpp.j2
│ ├── proxy.h.j2
│ ├── skeleton.cpp.j2
│ ├── skeleton.h.j2
│ ├── client_c.h.j2 # шаблон для C заголовка
│ └── client_c.cpp.j2 # шаблон для C реализации
└── build/ # создаётся при сборке
Архитектура (кратко)
-
Уровень IPC-сообщений:
IpcMessage(type alias дляBaseIpcMessage<TextIpcSerializer>)- Построение (PoC):
msg.add<int>(objectId); msg.add<std::string>("MyService.add"); msg.add<int>(7); msg.add<int>(8); - Разбор:
auto id = msg.get<int>(); auto name = msg.get<std::string>(); auto a = msg.get<int>(); auto b = msg.get<int>(); - Сериализация вынесена в отдельные сериализаторы (
TextIpcSerializerи т.д.) - Тип сырых данных параметризован через сериализатор (по умолчанию
std::string, можно использоватьstd::vector<std::byte>для бинарных форматов) - Выбор сериализатора делается один раз в
IpcConfig.hчерез type alias
- Построение (PoC):
-
Уровень канала:
IpcChannel+IpcPipeChannelIpcChannel— абстракция транспорта:send(const IpcMessage&),receive() -> IpcMessage.IpcPipeChannel— реализация поверх двух FIFO (/tmp/fifo_to_server,/tmp/fifo_to_client), которая внутри работает со строками, но наружу — только сIpcMessage.
-
Уровень RPC-ядра:
IpcMarshaller— собираетIpcMessageиз идентификатора объекта (ObjectId), имени метода и аргументов, отправляет черезIpcChannelи читает ответ.RpcRegistry— владеет RPC-объектами (IRpcObject), каждому выдаётся целочисленныйObjectId(PoC:int, совместимый сBaseIpcMessage).IRpcObject— базовый интерфейс для серверных RPC-объектов (обычно скелетоны), реализующий виртуальныйinvoke(method, args).RpcInvoker— тонкий фасад: поObjectIdберёт объект изRpcRegistryи зовётobj->invoke(method, args).
-
Сгенерированные обёртки:
*.proxy.*— шаблонные классы (C++), зависящие только от абстрактногоimplс методомimpl.callTyped<Ret>(method, args...)и не знающие про конкретный транспорт. В PoC рольimplвыполняетIpcMarshaller, которому при создании передаётсяObjectId. Для разных удалённых объектов создаются разные экземпляры маршаллера (с разнымиObjectId).*.skeleton.*— реализуютIRpcObject:- внутри держат ссылку/указатель на реальный объект (
MyService); - в
invoke()по имени метода ищут соответствующий хендлер в статическойstd::unordered_map<std::string, Handler>; - хендлеры (
call_<method>) распаковываютRpcArgs, вызывают реальный метод наMyServiceи упаковывают результат вRpcValue.
- внутри держат ссылку/указатель на реальный объект (
*Client_c.*— C-биндинги для клиентской стороны:- автоматически генерируются для каждого класса с аннотацией
RPC_EXPORT; - предоставляют чистый C API с функциями вида
{service}_client_create(),{service}_{method}(); - все исключения преобразуются в код ошибки
-1; - используют базовую C-обёртку
rpc_client_c.hдля работы с IPC.
- автоматически генерируются для каждого класса с аннотацией
Так достигается поддержка нескольких объектов одного и того же сервиса на сервере:
каждому объекту соответствует свой skeleton, зарегистрированный в RpcRegistry под уникальным ObjectId,
а клиентский код адресует конкретный объект через IpcMarshaller, «прошитый» этим ObjectId.
Зависимости
-
CMake ≥ 3.10
-
GCC или Clang с поддержкой C++17 и C11
-
Python 3.8+ с пакетами:
pip3 install jinja2 clang -
libclang (для Python) Если GCC используется только для сборки — libclang нужен только для генератора.
Важно: Сам libclang и пакет clang для Python должны быть одной версии.
Аннотации для экспорта
Управление тем, какие атрибуты каких классов следует экспортировать происходит с помощью аннотаций в исходном коде.
См. файл MyService.h:
class RPC_EXPORT MyService {
public:
RPC_EXPORT
int add(int a, int b);
int minus(int a, int b);
};
- Экспортируем класс
MyService:- Экспортируем метод
MyService::add; - Но не экспортируем метод
MyService::minus.
- Экспортируем метод
Сборка проекта
- Конфигурируем CMake:
cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -B build
Директива CMAKE_EXPORT_COMPILE_COMMANDS=ON нужна для корректного парсинга в libclang. Впрочем, в CMakeLists.txt эта директива уже должна быть включена.
- Собираем проект:
cmake --build build
В результате получаем бинарники:
./build/server # сервер
./build/client # C++ клиент
./build/client_c # C клиент
./build/librpc_client_c.a # статическая библиотека C-биндингов
Генерация кода
Автоматическая генерация прокси и скелета происходит при сборке через Python-скрипт tools/generate_rpc.py.
Пример ручного запуска генератора:
python3 tools/generate_rpc.py \
--out-dir build/generated \
--compile-commands build/compile_commands.json \
--templates tools/templates \
--header src/MyService.h \
--source src/MyService.cpp \
--out-base MyService
Сгенерированные файлы попадут в:
build/generated/
├─ MyService.proxy.h
├─ MyService.proxy.cpp
├─ MyService.skeleton.h
├─ MyService.skeleton.cpp
├─ MyServiceClient_c.h # C заголовок
└─ MyServiceClient_c.cpp # C реализация
Примечание: C-биндинги (*Client_c.*) автоматически генерируются для каждого класса с аннотацией RPC_EXPORT. При добавлении нового сервиса достаточно пометить его RPC_EXPORT — C-биндинги будут созданы автоматически при сборке.
Запуск
- В одном терминале запускаем сервер:
./build/server
Сервер создаёт (при необходимости) именованные пайпы /tmp/fifo_to_server и /tmp/fifo_to_client,
читает запросы из fifo_to_server и пишет ответы в fifo_to_client через IpcPipeChannel.
На стороне сервера RpcRegistry регистрирует два объекта MyService, обёрнутых в MyServiceSkeleton,
под ObjectId = 0 и ObjectId = 1.
- В другом терминале — клиент (C++ или C):
C++ клиент:
./build/client
C клиент:
./build/client_c
Оба клиента открывают те же FIFO, но логически пишут в fifo_to_server и читают из fifo_to_client
(через тот же IpcPipeChannel).
C++ клиент использует шаблонные прокси MyServiceProxy, привязанные к разным ObjectId.
C клиент использует C API:
// Создание клиента и маршаллеров
RpcClient* client = rpc_client_create("/tmp/fifo_to_client", "/tmp/fifo_to_server");
IpcMarshallerHandle* m1 = rpc_client_create_marshaller(client, 0);
IpcMarshallerHandle* m2 = rpc_client_create_marshaller(client, 1);
// Создание сервисных клиентов
MyServiceClient* obj1 = myservice_client_create(m1);
MyServiceClient* obj2 = myservice_client_create(m2);
// Вызовы методов (возвращают -1 при ошибке)
myservice_add(obj1, 7, 9);
int counter = myservice_get(obj1);
myservice_add(obj2, 0, counter);
Ожидаемый вывод (одинаковый для обоих клиентов):
$ ./server &
$ ./client
OBJ1: 16 OBJ2: 16
$ ./client
OBJ1: 32 OBJ2: 48
$ ./client_c
OBJ1: 48 OBJ2: 96
$ ./client_c
OBJ1: 64 OBJ2: 160
где:
- первый объект (
ObjectId = 0) увеличивает свой счётчик черезadd(7, 9)→ счётчик = 16; - второй объект (
ObjectId = 1) увеличивает свой счётчик наobj1.get(), то есть также до 16.
Обработка ошибок в C API: все функции возвращают -1 при ошибке (исключения преобразуются автоматически).
Использование C API
Базовая обёртка (rpc_client_c.h)
// Создание RPC клиента (обёртка над IpcPipeChannel)
RpcClient* rpc_client_create(const char* read_pipe, const char* write_pipe);
// Создание маршаллера для конкретного объекта
IpcMarshallerHandle* rpc_client_create_marshaller(RpcClient* client, int object_id);
// Освобождение ресурсов
void rpc_marshaller_destroy(IpcMarshallerHandle* marshaller);
void rpc_client_destroy(RpcClient* client);
Генерируемые функции для сервисов
Для каждого класса с RPC_EXPORT автоматически генерируются функции:
// Создание клиента сервиса
MyServiceClient* myservice_client_create(IpcMarshallerHandle* marshaller);
void myservice_client_destroy(MyServiceClient* client);
// Вызовы методов (возвращают -1 при ошибке)
int myservice_add(MyServiceClient* client, int a, int b);
int myservice_get(MyServiceClient* client);
Именование функций: имя класса преобразуется в нижний регистр (MyService → myservice), методы сохраняют оригинальное имя.
Автоматическая генерация: при добавлении нового RPC_EXPORT класса C-биндинги генерируются автоматически при сборке, без необходимости изменять код генератора.