Привет Хабр!

Наконец нашёл немного времени для написания статьи. Это моя первая статья на хабре, так что, извиняйте если что...

Цель статьи — показать проблемы при разработке SDK и найти попробовать архитектуру, которая минимизирует проблемы управления памятью, упрощает биндинги для разных языков и сохраняет производительность.

В этой статье мы порассуждаем о поиске некоего «универсального» SDK (Software Development Kit), который бы всем нравился, как новогодняя ёлка.

Так же в статье опущены вопросы безопасности. Мы будем предполагать, что используем проверенные исходники зависимостей для сборки нашего sdk.

И так, поехали «кушать кактус по частям...»

Для начала нужно определиться с требованиями. Чего вообще мы тут будем делать?

Требования SDK:
 — должен использоваться разработчиками на разных языках программирования (java,.net, swift, kotlin, c++, python).
 — должен быть кросс‑платформенный (Linux, Window, MacOS, iOS, Android).
 — не содержит GUI элементов.
 — должен иметь не большой размер.
 — должен быть максимальной простой и понятный в использовании.
 — не использовать проприетарные зависимости без исходников
 — ограничимся 64-битными платформами.

Исходя из таких требований попробуем определить стек нужных нам технологий. Понятно, что это должна быть библиотека, которая собирается под все нужные платформы и у неё должен быть интерфейс, который позволит подключить её в любом языке программирования. И тут к нам приходит С. СИ интерфейс и простые типы дают нам возможность интегрироваться со всеми языками. А вот внутренности мы будем писать на С++. Очень хочется С++20, к сожалению, не на всех платформах пока он поддерживается полностью. Но мы же хотим его!
И в наших требованиях нет указаний на минимальные версии библиотек. Пока будем ориентироваться на 20 стандарт и постараемся избегать возможностей, которые ещё не дошли, например до clang на macos\ios. В связи с тем, что часто бывают проблемы со сборкой зависимостей или поиском готовых бинарей для разных платформ мы возьмём vcpkg, в качестве менеджера пакетов и будем собирать зависимости из исходников и, если нужно, патчить.


Технический стек

С — интерфейс. С++, Cmake, vcpkg, gcc\сlang\msvc, Android NDK/ SDK,Linux host,MacOS host,Windows host (без этого можно обойтись, но он очень полезен)

Пока будем наивно думать, что этого нам хватит и подумаем над ограничениями реализации.

Ограничения

Нам нужно будет ограничить версию рантаймов или таскать их с собой, что делать не хотелось бы.

Ну что... с тем, что нужно вроде определились. Давайте теперь думать над внешним интерфейсом нашего SDK.

Варианты интерфейса

Конечно же это СИ интерфейс! А как мы его организуем? Кто будет аллоцировать и освобождать память? А как сделать так, чтобы разработчики на высокоуровневых языках не сошли с ума при написании очередного враппера для СИ?

Вариант 1:

А давайте просто сделаем методы где память аллоцирует и освобождает SDK!

int getData(const char* key, uint8_t data, uint64_t size);
freeData(data);

И получим на каждый возвращаемый параметр метод free. А там ещё структуры с кучей вложенных параметров.... А если разработчик, который привыкли к GC (garbage collector) не вызовет free? А потом разработчики SDK будут разбирать претензии с падениями и утечкой памяти. Неее... давай думать ещё.


Вариант 2:

Ну ок, тогда пускай сами память аллоцируют, а мы будем заполнять данным. Ну как в winapi? Напишем об этом в комментариях.

int getData(const char key, uint8_t data, uint64_t *size);

Чтобы узнать размер выходного буфера придётся сначала вызвать getData с пустым буфером, который вернёт размер. Аллоцировать буфер и потом уже вызвать ещё раз getData для получения данных. И после этого у разработчиков на языках с GC начинается боль с трем, чтобы переменные умирали, когда надо и не вызывали sigfault. А если нужно сохранять контекст и передавать его в разные методы? А ещё нужно читать комментарии, чтобы понять, что нужно 2 раза вызвать функцию.

Вариант 3:

А что если сделать хитро, через дескрипторы? Выделять память в SDK и сделать функции free,но не всё подряд возвращаемых структур и параметров, а на дескрипторы? И доступ к полям структур тоже через отдельные методы дескриптора. Дескриптором будет void* в внутри это структура с std::shared_ptr<>. И мы будем её туда сюда кастить. А чтобы нам не передали мусор добавим magic_field и будем его проверять. А ещё нужно возвращать не только код ошибки, а строку!

/// sdk.h
typedef struct Error
{
uint32_t code;
char description[1024];
}

int CreateContext(sdk_descriptor* d, Error* e);
int getData(sdk_descriptor d, const char* key, data_descriptor* out, Error* e);
freeSDKaDescriptor(sdk_descriptor d);
freeDataDescriptor(data_descriptor d);

/// data_descriptor.h
int GetSizeData(uint64_t* size, Error* e);
int GetData(uint64_t* buff, uint64_t size, Error* e);
и тд...

В итоге мы уменьшили проблемы с аллокацией памяти (структуры содержащие массивы байт и массивы других структур теперь передаются через дескрипторы), получили строгую последовательность вызова функций. Контроль ошибок. Высокую гибкость. Легко добавлять новые параметры. Для простых Си методов легче писать врапперы на других языках. А ещё можно использовать SWIG, чтобы генерить биндинги для простых вызовов.

НО! Ещё мы получили огромную кучу методов и различных хедеров. Отдельный метод на каждый параметр структуры. По 2 метода, чтобы получить строку. И ещё кучу вопросов от пользователей SDK, где искать нужный метод. И документацию на десятки страниц.
Формально оно всё работает и соответствует требованиям, но мы вроде не хотели такое?

Вариант 4:

Неее столько методов это всё очень не удобно, особенно в серверной версии с многопоточкой.
А давайте сделаем 2 метода и будем туда передавать JSON.

int get(const char* json_in, uint64_t size_in, const char** json_out, uint64_t* size_out, Error* e);
free(const char* json_out);

Всего 2 метода и они никогда не поменяются! Просто будем описывать новые параметры в json. И враперы писать удобно! Конечно мы в производительности сильно проиграем на парсинге и валидации и копировании этого json, а ещё надо документацию на все эти параметры и ещё надо её держать актуальной. А ещё бинарные буферы преобразовывать в base64, а ещё с кодировкой и utf8 что‑то надо подумать. Простота интерфейса — это хорошо, но у нас же С++ и нужно чтобы всё было быстро.

Вариант 5:

Так.... 2 метода это выглядит круто, нужно только избавить от json... Точно! Есть же thrift, protobuf может их? У них другое предназначение (RPC), но идею хорошая.
Давай возьмём FlatBuffer! Он Идеально подходит! Всё описание будет в формате FlatBuffer. Он бинарный, и с минимумом копирований (zero‑copy). Он умеет генерить код для всех нужных языков программирования и поддерживает все платформы. Всё описание будет структурировано и распространяется вместе с SDК. Мы получаем обратную совместимость и логирование из коробки. Кажется мы нашли то, что искали!

// in.fbs
table GetData {
key:string;
}

// out.fbs
table Data {
data:string;
value:int32;
....
}

/// sdk.h
int get(const char* data_in, uint64_t size_in, const char** data_out, uint64_t* size_out, Error* e);
free(const char* data_out);

А можно вообще без free? А что если мы будем возвращать указатель на память, которую будем переиспользовать постоянно для запросов и удалим только при выгрузке библиотеки?
Тогда не нужно делать free? FlatBuffer позволяет без копирований получить итоговый буфер, то есть будем хранить вот такое:
static thread local DetachBuffer
и он сам уничтожится в конце. Вообще замечательно 1 метод!

Только появляется проблема того, что мы это всё будем держать в памяти каждого потока, а если это «горячие» потоки и они живут до конца программы? Память, то не резиновая...
А если кто‑то сохранит указатель и вызовет метод get ещё раз? Или передаст в лямбду std::async? В общем и тут могут быть проблемы.

Получается метод free нужно вызывать после каждого вызова get. Если пользователь не вызовет free, память не будет потеряна безвозвратно, но будут вышеописанные проблемы. Внутри free мы не будем делать delete указателя, а будем делать swap текущего DetachBuffer с пустым.

Тут могут быть разные мнения на, то можно ли так делать в многопоточных приложения. Надежнее возвращать копию буфера и потом его очищать, но задача была максимально сократить копирование, поэтому оставим так, но укажем в документации на необходимость вызова free. И рекомендации использовать RAII.


В итоге нам удалось сделать максимально простой и легко расширяемый интерфейс, который легко встраивается в другие языки программирования. Мы максимально избежали копирования и проблем с аллокацией памяти. Получили готовое решение для сериализации/десериализации данных, удобный механизм отладки и анализа данных через преобразование FlatBuffers в текстовое представление (FlatBufferToString), кодогенерацию, избежали проблем с кодировками при передаче данных. FlatBuffers предоставляет механизмы для эволюции схемы и позволяет относительно безопасно добавлять новые поля без нарушения совместимости. Метод free не освобождает память операционной системе. Он лишь очищает внутренний буфер и позволяет переиспользовать его при следующих вызовах.


Что ещё можно улучшить?

А если добавить AI? Зачем? А кто будет делать примеры на кучи разных языков и документацию? Doxygen — прошлый век. Берём AI агента и просим всё это сделать. Идеально!

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

// in.fbs
table GetData { key:string;}

// out.fbs
table Data { data:string; value:int32; ....}

/// sdk.h
int get(const char* data_in, uint64_t size_in, const char** data_out, uint64_t* size_out, Error* e); free(const char* data_out);

Попробуем свести в табличку все методы.

Решение

Zero Copy

Кодогенерация

Удобство

Размер

Чистый C

Возможно

Нет

Нет

Малый

JSON

Нет

Нет

Частично

Большой

Protobuf (RPC)

Частично

Да

Да

Средний

FlatBuffers

Да

Да

Да

Малый

Для поставленной задачи вариант с FlatBuffers показался наиболее удобным и перспективным. Вероятно, вместо FlatBuffers можно использовать другой похожий фреймворк. Бэнчмарки скорости не проводились. И этот вариант не претендует на идеальный.

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

Давайте теперь актуализируем наш стек.

Технический стек
С — интерфейс,
С++,
Cmake,
vcpkg,
FlatBuffer,
gcc\сlang\msvc,
Android NDK/ SDK,
Linux host,
MacOS host,
Windows host
+ то, что зависит от функционала SDK (boost, nlohman, soci, curl и тд)
Можно использовать кросс‑компиляцию, но для проверки лучше иметь «живые» целевые ОС.

В результате мы пришли к выводу, что самая сложная часть SDK — это не алгоритмы и не бизнес‑логика, а границы между языками, управление памятью и совместимость между платформами. FlatBuffers не решает все проблемы, но позволяет существенно сократить объём инфраструктурного кода и сделать интерфейс SDK более предсказуемым для разработчиков на разных языках.

Если вы добрались до этого места, то теперь вы осознаёте боль разработчиков SDK.
Тут ещё не учтены огромное количество проблем при сборки под все эти платформы, разные разрядности x86 \ x64, различия версий рантаймов, проблемы видимости символов при встраивании SDK. Ну, а если в SDK есть GUI (например Qt), то это уже другая не универсальная история...

Надеюсь, Вам было интересно и не очень скучно!