Разработка UDF на C++
Введение
На данный момент в YQL поддерживаются пользовательские функции (UDF) на C++, Python. Ниже речь пойдет только о C++, про Python есть отдельная статья.
На сервисе YQL уже установлен набор базовых функций — рекомендуем ознакомиться со списком.
Терминология
- Вызываемое значение callable — сущность с фиксированной, строго типизированной сигнатурой, которая при вызове способна вернуть результат по своим аргументам.
- Пользовательская функция (function, user defined function, UDF) — фабрика по созданию вызываемых значений.
- Модуль (module) — набор тесно связанных функций с общим префиксом; все функции модуля распространяются вместе в одной разделяемой библиотеке (
.so). Если модуль специфичен для какого-либо проекта, то крайне желательно отразить это в его имени в виде префикса. - Регистратор (registrator) — интерфейс для внесения модулей и функций в общий реестр.
- Группа модулей (group) — группа модулей, принадлежащих структурному подразделению или крупному проекту; служит лишь для логического разделения исходного кода (см. следующий раздел), из YQL/SQL не видна.
- Конфигурационные параметры — опциональный механизм для настройки пользовательской функции (фабрики вызываемых значений). Позволяют изменить поведение вызываемых значений без перекомпиляции:
Type Config,User Type— по ним функция должна определить и объявить сигнатуру вызываемого значения; используется при построении графа вычислений. Должны быть известны в момент компиляции, поэтомуType Configобязательно должен быть атомом, аUser Type— некоторым типом, например полученным с помощью операции TypeOf.Run Config— по нему функция должна вернуть вызываемое значение с объявленной ранее сигнатурой. Тип значения Run Config указывается в коде UDF при объявлении сигнатуры вызываемого значения. Отличается от обычного аргумента тем, что он передается при инициализации, а не при каждом вызове.
Примеры
Примеры готовых модулей можно посмотреть здесь: https://github.com/ytsaurus/ytsaurus/tree/main/yql/essentials/udfs.
Совсем простые примеры см. в поддиректории examples, приближенные к реальности — в остальных, например common.
Перед изучением следующего раздела рекомендуем открыть один или несколько примеров, так как в разделе даны только пояснения к коду, но полные листинги не приводятся.
Интерфейсы (С++)
TUnboxedValue (udf_value.h)
Класс-обертка вокруг типизированных данных. Предоставляет набор конструкторов для создания сложных типов данных из простых типов данных и набор хелперов для работы со сложными типами данных.
Числа, интервалы, даты, включая даты с временной зоной и короткие строки до 14 байт хранятся по значению, метод IsEmbedded() для них вернет true. Строки длиннее 14 байт хранятся по указателю.
TUnboxedValuePod(udf_value.h)
Является базовым классом для TUnboxedValue. Не
производит автоматический подсчет ссылок на свое значение, в отличие от своего наследника, который осуществляет это в конструкторе и деструкторе - ключевым моментом здесь является отсутсвие конструкторов и деструкторов у класса TUnboxedValuePod. Реализует всю логику работы с
типизированными данными.
IValueBuilder (udf_value_builder.h)
Метод Run должен возвращать TUnboxedValue. Для числовых типов можно воспользоваться конструктором типа, например return TUnboxedValue<ui32>(123);. Для создания TUnboxedValue с более сложным содержимым в метод Run передается IValueBuilder. На нем можно вызвать один из методов New*** для получения интерфейса I***ValueBuilder, где *** — одно из String/Struct/Dict/пр. С его помощью можно добавить специфичных для каждого типа элементов и в конце вызвать Build для получения заполненного TUnboxedValue.
Есть вариант создать Struct/Tuple с преаллокацией для хранения содержимого, для этого надо вызвать соответствующий метод New***(const TType* type, TUnboxedValue*& items) и заполнить элементы items на месте.
См. yql/essentials/udfs/examples/structs для примера со структурами.
TBoxedValue (udf_value.h)
Класс типичного UDF наследуется от TBoxedValue и помимо конструктора переопределяет метод Run, в котором должна размещаться основная логика по обработке одного конкретного вызова. В аргументы Run передается интерфейс IValueBuilder, о котором ниже, а также сами переданные аргументы в виде массива TUnboxedValue.
В конструкторе можно сохранить какое-то состояние в приватные переменные класса, таким образом конкретный объект нашего класса-наследника TBoxedValue превращается в вызываемое значение с потенициально уникальным поведением.
Для простых UDF (без использования YQL-структур, C++ шаблонов, наследования и т. п.), можно воспользоваться макросом SIMPLE_UDF(udfName, signature) (находится в udf_helpers.h). signature задается в виде returnType(arg1, arg2, ..., argN).
Пример использования:
SIMPLE_UDF(TAbs, ui64(i64)) {
i64 input = args[0].Get<i64>();
ui64 result = static_cast<ui64>(input >= 0 ? input : -input);
return TUnboxedValue(result);
}
Функция будет зарегистрирована под именем Abs, аргумент типа i64, результат — ui64.
Пример, когда успех не гарантируется:
SIMPLE_UDF(TParseDuration, TOptional<ui64>(char*)) {
TDuration result;
auto input = args[0].AsStringRef();
bool success = TDuration::TryParse(input, result);
return success ? TUnboxedValue::Optional(result.Seconds()) : TUnboxedValue();
}
Под Optional (type) (тип, допускающий значение NULL) подразумевается возможность отсутствия значения указанного типа, например в случае какой-то ошибки. Из SQL такого типа наличие в результате можно проверить с помощью конструкции IS [NOT] NULL.
IUdfModule (udf_registrator.h)
Для типовых модулей можно воспользоваться макросом SIMPLE_MODULE(moduleName, udfs...) (находится в udf_helpers.h):
- Первым аргументом — имя класса-модуля, при регистрации префикс
Tи суффиксModuleбудут отброшены. - Далее — список из N входящих в него UDF, при регистрации которых будет отброшен только префикс
T.
Таким образом, в примере SIMPLE_MODULE(TFooModule, TBar, TBaz) после регистрации (о которой ниже) из YQL будет доступен модуль Foo с двумя UDF — Bar и Baz.
Реализация модуля вручную
Класс типичного модуля наследуется от IUdfModule и должен реализовать два метода:
GetAllFunctionNames— в переданномIFunctionNamesSinkнужно вызвать методAdd(name)для каждой из UDF, входящей в модуль.BuildFunctionTypeInfo— по переданному имени UDF и Type Config нужно на переданномIFunctionTypeInfoBuilderвызвать:Args()->Add<ui32>()[->Add(foo)->Add...].Done():- для указания аргументов и их типов, для чисел и строк тип аргумента можно указать шаблоном;
- сложный тип вроде Struct/Dict/etc можно описать с помощью одноименного метода
IFunctionTypeInfoBuilder, затем передав его в аргументAdd, пример:
ui32 a = 0;
ui32 b = 0;
auto foo = builder.Struct()->AddField<char*>("A", &a).AddField<ui32>("B", &b).Build();
builder.Args()->Add(foo);
- При описании типов наподобие
builder.List()->...следует максимально внимательно копировать чужие примеры, так как часто вызов цепочки методов билдера в неправильной последовательности или с неправильными аргументами нормально компилируется, но в рантайме падает в корку с нетривиальным бектрейсом. Пример такой цепочки:
// udf returns list of lists of char*
auto retType = buider.List()->Item(builder.List()->Item<char*>().Build()).Build(); // Correct
auto retType = buider.List()->Item<char*>().Build(); // Wrong type, coredump with backtrace in casting to string deep in validating
auto retType = buider.List()->Build(); // No type at all, coredump with attempt to dereference nullptr deep in type machinery
builder.Returns(retType);
- Также в аргументах передается битовая маска
ui32 flags, в которой на момент написания может присутствовать только(cpp)TFlags::TypesOnly, при наличии которого не обязательно вызватьbuilder.Implementation(new TMyUdf);для UDF, соответствующей переданному имени.
IRegistrator (udf_registrator.h)
Для упрощенной регистрации модуля в системе рекомендуется использовать макрос:
REGISTER_MODULES(
TFooModule,
TBarModule<true>,
TBarModule<false>
)
Имя модуля в этом случае берется из метода Name(), который должен вернуть TStringRef по значению, макрос SIMPLE_MODULE создает его автоматически.
Альтернативный вариант для продвинутых пользователей
Нужно объявить функцию Register следующего вида и в переданном интерфейсе зарегистрировать модуль(-и), а также экспортировать версию ABI:
extern "C" YQL_UDF_API void Register(NKikimr::NUdf::IRegistrator& registrator, ui32 flags)
{
registrator->AddModule("Foo", new NDetail::TFooModule(flags));
}
extern "C" YQL_UDF_API ui32 AbiVersion()
{
return CurrentAbiVersion();
}
Список флагов перечислен в IUdfModule::TFlags. На момент написания реализован только TypesOnly, который используется как указание на то, что реальных вычислений пока производиться не будет, так как ещё идет построение графа вычислений, и на данном этапе нужны только сигнатуры функций.
Обработка исключений/ошибок
Чтобы возвращать пользователю фатальные исключения, нужно использовать следующую конструкцию:
try {
...
} catch (const std::exception& e) {
UdfTerminate(e.what());
}
Вместо e.what() можно написать человекочитаемое описание ошибки, если это возможно.
Внимание
Завершать выполнение программы целиком с помощью abort, exit, Y_ABORT_UNLESS, Y_ABORT и подобных механизмов из кода UDF крайне не рекомендуется, так как это ограничит область её применения только теми окружениями, где под каждый расчет создается отдельный процесс. Реализация UdfTerminate знает о текущем окружении и вызовет ожидаемый им механизм обработки ошибок.