Learning Platform
Глоссарий Troubleshooting
Урок 11.07 · 23 мин
Продвинутый
extensionscpp-apiextension-templatedevelopment

Разработка собственных расширений

Шесть прошлых уроков были про готовые расширения. Этот — про то, как написать своё. Вы не станете писать расширение каждый день, но понимать, как они устроены изнутри, важно по двум причинам. Во-первых, это закрывает картину: вы видели, что расширение «добавляет функции», теперь увидите, как именно. Во-вторых, наступит задача, которую core- и community-расширения не покрывают — нестандартный источник данных, доменная функция, специфичный формат, — и тогда написать расширение окажется правильным решением. Урок даёт карту: что такое extension template, как устроен C++ API, каков жизненный цикл расширения.

Что физически представляет собой расширение

Расширение DuckDB — это файл .duckdb_extension. По сути это динамически линкуемая библиотека (на разных ОС — .so, .dylib, .dll), переименованная и снабжённая метаданными. Когда вы делаете LOAD, DuckDB подгружает этот файл в адресное пространство своего процесса и вызывает в нём специальную точку входа — init-функцию.

Init-функция получает ссылку на работающий экземпляр DuckDB и регистрирует в нём новые объекты: скалярные функции, table functions, типы, file systems, оптимизационные правила, replacement scan callbacks. Регистрация — это запись в каталог и системные таблицы экземпляра. После того как init-функция отработала, зарегистрированные объекты неотличимы от встроенных: парсер, binder, оптимизатор и исполнитель работают с ними одинаково.

Жизненный цикл загрузки расширения
Файл .duckdb_extensionДинамическая библиотека с кодом расширения и метаданными — версия DuckDB, платформа, подпись
LOAD
DuckDB вызывает init-функциюТочка входа расширения; получает ссылку на работающий экземпляр DuckDB
регистрация объектов
Функции и типы в каталогеЗарегистрированные объекты записаны в каталог и неотличимы от встроенных

Extension template: точка старта

Писать расширение с нуля не нужно — у DuckDB есть официальный extension template (репозиторий-шаблон на GitHub). Это готовый каркас проекта: структура папок, система сборки на CMake, привязка к исходникам DuckDB как подмодулю, заготовка CI, который собирает расширение под все поддерживаемые платформы, и пример простой функции внутри.

Работа начинается так: создаёте репозиторий из шаблона, и у вас сразу есть собирающееся расширение с демонстрационной функцией. Дальше вы заменяете демо-функцию на свою логику. Шаблон берёт на себя всю обвязку — сборку, линковку с DuckDB, упаковку в .duckdb_extension, кросс-платформенный CI, — оставляя вам только содержательную часть.

TIP

Расширения собираются под конкретную пару «версия DuckDB + платформа». Расширение, собранное под DuckDB 1.4 и linux_amd64, не загрузится в DuckDB 1.5 или на osx_arm64. Поэтому в extension template и встроен матричный CI: он собирает ваше расширение сразу под весь набор платформ. При самостоятельной сборке всегда сверяйте версию DuckDB, под которую линкуетесь, с версией, в которой собираетесь расширение загружать.

C++ API: основной язык расширений

Ядро DuckDB написано на C++, и основной язык разработки расширений — тоже C++. Расширение линкуется с исходниками DuckDB и использует их внутренние классы напрямую: DataChunk, Vector, LogicalType, ClientContext и прочие — те самые сущности, которые мы разбирали в модулях про векторизованный движок и систему типов.

Посмотрим на минимальную скалярную функцию — функцию, которая для каждой строки возвращает одно значение. Её суть — обработать входной DataChunk (колоночный батч на ~2048 значений) и заполнить выходной Vector:

// Скалярная функция, удваивающая целое число.
// Работает не построчно, а сразу над вектором значений.
static void DoubleFunction(DataChunk &args, ExpressionState &state, Vector &result) {
    // args.data[0] — входной вектор; result — выходной вектор
    UnaryExecutor::Execute<int64_t, int64_t>(
        args.data[0], result, args.size(),
        [](int64_t input) { return input * 2; }  // логика для одного значения
    );
}

Ключевой момент здесь — векторизация. Функция получает не одну строку, а целый DataChunk. Хелпер UnaryExecutor::Execute применяет вашу поэлементную лямбду ко всему вектору сразу, корректно обрабатывая validity mask (NULL-значения) и физический тип вектора. Так расширение автоматически встраивается в векторизованную модель исполнения DuckDB — ваша функция исполняется так же эффективно, как встроенные.

Регистрация функции происходит в init-функции расширения:

// В init-функции расширения: регистрируем функцию в каталоге
ScalarFunction double_fn("double_it",            // имя в SQL
                         {LogicalType::BIGINT},  // типы аргументов
                         LogicalType::BIGINT,    // тип результата
                         DoubleFunction);        // реализация
ExtensionUtil::RegisterFunction(instance, double_fn);

После загрузки такого расширения в SQL появляется функция double_it:

LOAD my_extension;
SELECT double_it(21) AS answer;
-- answer = 42
Скалярная функция работает над вектором, а не строкой
Входной DataChunkКолоночный батч примерно на 2048 значений — функция получает весь батч сразу
UnaryExecutor
Поэлементная лямбдаХелпер применяет вашу логику ко всему вектору, сам обрабатывая NULL и физический тип
Выходной VectorРезультат — вектор той же кардинальности; функция встроена в векторизованное исполнение

Что ещё можно регистрировать

Скалярная функция — простейший случай. C++ API позволяет добавлять и более сложные вещи:

  • Table functions — функции, возвращающие таблицу. Именно так устроены read_parquet, read_csv и сканеры баз: table function реализует интерфейс «выдавай поток DataChunk-ов». Через table function пишут собственные сканеры новых форматов и источников.
  • Aggregate functions — агрегатные функции со своим состоянием, шагами накопления и слияния (последнее нужно для параллельной агрегации).
  • Новые типы — пользовательские LogicalType со своим физическим представлением и кастами.
  • File systems — реализации файловой системы для новых протоколов; так устроен httpfs.
  • Replacement scan callbacks — те самые callbacks из первого модуля, которые превращают имя в источник данных.
  • Optimizer rules — правила, которые оптимизатор применяет к плану запроса.

То есть расширение может дотянуться почти до любого слоя DuckDB. Это и делает архитектуру «маленькое ядро плюс расширения» настолько гибкой: внешний код подключается на тех же интерфейсах, что использует само ядро.

Подпись и распространение

Готовое расширение надо распространять. DuckDB проверяет подпись расширения при загрузке — это защита от подмены и запуска чужого кода. Расширения из официального и community-репозиториев подписаны через инфраструктуру DuckDB.

Своё, локально собранное расширение не подписано. Чтобы DuckDB его загрузил, нужен флаг allow_unsigned_extensions при старте. Это нормально для разработки и внутреннего использования: собрали, запустили DuckDB с этим флагом, загрузили расширение с локального пути.

-- DuckDB запущен с allow_unsigned_extensions
-- Загрузка расширения по прямому пути к файлу
LOAD '/path/to/my_extension.duckdb_extension';

Для широкого распространения путь — подать расширение в community-репозиторий. Тогда оно собирается инфраструктурой DuckDB под все платформы, подписывается, и любой пользователь сможет поставить его через INSTALL my_extension FROM community — без флага unsigned.

WARNING

allow_unsigned_extensions отключает проверку подписи для ВСЕХ расширений в этой сессии, а не только для вашего. Загрузка неподписанного расширения означает исполнение произвольного нативного кода прямо в процессе DuckDB — с полным доступом к памяти, файлам и сети. На своей машине при разработке это приемлемо. На общем или продакшен-сервере включать этот флаг и загружать расширения из непроверенных источников нельзя — это прямой вектор атаки.

Когда писать расширение, а когда нет

Расширение — не первый инструмент, к которому стоит тянуться. Прежде чем писать C++, проверьте более лёгкие пути:

  • Нужна доменная функция — возможно, хватит обычной SQL-функции (CREATE MACRO) или Python UDF (об этом — в модуле про Python-экосистему). Это пишется за минуты и не требует сборки.
  • Нужен нестандартный источник — возможно, его уже покрывает существующее community-расширение.

Писать собственное расширение оправдано, когда: нужна высокая производительность нативного кода (Python UDF медленнее); нужен доступ к внутренним слоям движка (свой сканер формата, своя file system, optimizer rule); функциональность должна распространяться как переиспользуемый модуль для многих пользователей. В этих случаях extension template и C++ API — правильный путь, и теперь вы знаете, из чего он состоит.

Попробуй сам

Это задание на чтение и эксперимент — компилятор C++ не обязателен.

  1. Откройте на GitHub официальный extension template DuckDB. Изучите структуру: где лежит код расширения, где CMake-файлы, где находится init-функция, как DuckDB подключён подмодулем.
  2. Найдите в шаблоне демонстрационную скалярную функцию. Разберите её: что она получает на вход, как использует executor-хелпер, как регистрируется в init-функции.
  3. В работающем DuckDB выполните SELECT function_name, function_type FROM duckdb_functions() LIMIT 20. Обратите внимание на колонку с типом функции (scalar, aggregate, table) — это те самые категории, которые регистрирует C++ API.
  4. Сформулируйте для себя: какую конкретную задачу из вашей практики решило бы собственное расширение, и можно ли её закрыть более лёгким средством — CREATE MACRO или Python UDF — без написания C++.
DataFusion: пользовательские функции на Rust
Проверка знанийKnowledge check
Что физически представляет собой расширение DuckDB, что происходит при его загрузке, и почему скалярная функция расширения пишется как операция над вектором, а не над одной строкой?
ОтветAnswer
Расширение DuckDB физически представляет собой файл .duckdb_extension — по сути динамически линкуемую библиотеку (.so, .dylib или .dll в зависимости от ОС), снабжённую метаданными о версии DuckDB, платформе и подписи. При загрузке командой LOAD DuckDB подгружает этот файл в адресное пространство своего процесса и вызывает в нём специальную точку входа — init-функцию. Init-функция получает ссылку на работающий экземпляр DuckDB и регистрирует в нём новые объекты: скалярные функции, table functions, агрегатные функции, типы, файловые системы, replacement scan callbacks, правила оптимизатора. Регистрация — это запись в каталог и системные таблицы экземпляра; после того как init-функция отработала, зарегистрированные объекты неотличимы от встроенных, и парсер, binder, оптимизатор и исполнитель работают с ними одинаково. Скалярная функция расширения пишется как операция над вектором, а не над одной строкой, потому что DuckDB — векторизованный движок: исполнение идёт колоночными батчами DataChunk примерно по 2048 значений. Функция получает на вход не одну строку, а целый DataChunk, и должна заполнить выходной Vector. На практике используется хелпер вроде UnaryExecutor::Execute, которому передаётся поэлементная лямбда: хелпер применяет эту лямбду ко всему вектору сразу, корректно обрабатывая validity mask (NULL-значения) и физический тип вектора. Благодаря этому функция расширения автоматически встраивается в векторизованную модель исполнения и работает так же эффективно, как встроенные функции — если бы она обрабатывала строки по одной, она потеряла бы все преимущества векторизации и стала бы узким местом. Основной язык разработки расширений — C++, потому что ядро DuckDB написано на C++ и расширение использует его внутренние классы напрямую; стартовать удобно с официального extension template, который даёт готовый каркас со сборкой и кросс-платформенным CI. Своё локально собранное расширение не подписано и требует флага allow_unsigned_extensions, а для широкого распространения его подают в community-репозиторий.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что физически представляет собой расширение DuckDB и что происходит при выполнении команды LOAD?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7