Разработка 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
знает о текущем окружении и вызовет ожидаемый им механизм обработки ошибок.