DuckDB 插件开发实战:Mac 版 Everything-从 0 到 1 构建 apfs ——完整实现与官方对比
从 0 到 1 构建 apfs 插件——完整实现与官方插件对比
—— 如果从零开始,怎么一步步构建出当前的样子
本文是《DuckDB 插件开发实战:Mac 版 Everything》系列的第 2 篇。上一篇体验了 apfs 扩展的效果和项目结构,这篇以”如果从零开始”为主线,完整走一遍 apfs 插件的构建决策过程,并横向对比 DuckDB 官方核心插件。
版本基线:本文所有代码、行数、API 均基于
duckdb-apfs仓库duckdb/submodule 锁定的 DuckDB v1.5.2、Apple clang 16.0.0 编译;文中提到的所有行数均为作者环境实测,读者在不同提交、不同平台下会有细微差异。仅基本功能实现,实际运行体验仍与Windows版本有不小差距,源码见参考资料。
从 extension-template 到 apfs——搭骨架
本文讲解的
duckdb-apfs仓库就是从duckdb/extension-templateclone 下来、按下文 Step 2-5 演进而成的。阅读本文时可对照duckdb-apfs源码。
Step 1:clone 模板,改名
git clone --recurse-submodules https://github.com/duckdb/extension-template.git duckdb-apfscd duckdb-apfs
模板默认叫 “quack”,自带一个 quack('world') → 'Quack world 🐥' 的标量函数。
Step 2:配置 extension_config.cmake
# This file is included by DuckDB's build system. It specifies which extension to loadduckdb_extension_load(apfs SOURCE_DIR ${CMAKE_CURRENT_LIST_DIR} LOAD_TESTS)
把 quack 改成 apfs——extension_config.cmake 完整文件 7 行(含 header 注释 + 空行),核心声明 5 行。这告诉 DuckDB 构建系统:”我有个叫 apfs 的扩展,源码在当前目录,测试也要加载。”
Step 3:配置 CMakeLists.txt
apfs 扩展比模板多了一步——链接 macOS 框架。下面是 CMakeLists.txt(完整 38 行)中最关键的片段,省略了固定模板生成的 cmake_minimum_required / project / include_directories / install 等样板:
set(EXTENSION_SOURCES src/apfs_extension.cpp src/apfs_fts_scanner.cpp src/apfs_spotlight_scanner.cpp src/apfs_has_changes.cpp src/apfs_utils.cpp)build_static_extension(${TARGET_NAME}${EXTENSION_SOURCES})build_loadable_extension(${TARGET_NAME}${PARAMETERS}${EXTENSION_SOURCES})# 这是 apfs 特有的——Spotlight API 在这两个框架里if(APPLE)find_library(CORE_FOUNDATION_LIB CoreFoundation)find_library(CORE_SERVICES_LIB CoreServices)if(CORE_FOUNDATION_LIB AND CORE_SERVICES_LIB)target_link_libraries(${EXTENSION_NAME}${CORE_FOUNDATION_LIB}${CORE_SERVICES_LIB})target_link_libraries(${LOADABLE_EXTENSION_NAME}${CORE_FOUNDATION_LIB}${CORE_SERVICES_LIB})endif()endif()
对比 json 扩展:json 扩展的 CMakeLists.txt 不需要链接系统框架,但它引入了 yyjson 第三方库。不同扩展的外部依赖不同,但 build_static_extension / build_loadable_extension 这两个宏是绝大多数 C++ 扩展都会用到的(delta 这类带 vcpkg 依赖的扩展有额外步骤)。
Step 4:定义 Extension 类
// src/include/apfs_extension.hpp(22 行)classApfsExtension : public Extension {public:voidLoad(ExtensionLoader &loader)override;std::string Name()override;std::string Version()constoverride;};
三个方法,每个扩展都一样。Load() 是唯一需要写逻辑的——注册你的函数。
Step 5:写 LoadInternal——总入口
LoadInternal 函数本身是 27 行(src/apfs_extension.cpp),剩余 153 行是 include、Helper 函数(DefineApfsColumns / ApfsScanBindInternal / ApfsScanBindOneArg / ApfsScanBindTwoArgs / ApfsScanInit / ApfsScanFunction 等)和 C 入口宏。完整 LoadInternal:
staticvoidLoadInternal(ExtensionLoader &loader){// 1. 注册 apfs_scan:TableFunctionSet 含两个重载TableFunctionSet scan_set("apfs_scan");// 重载 1: apfs_scan(path) → 默认 fts 模式TableFunction scan_one_arg("apfs_scan", {LogicalType::VARCHAR}, ApfsScanFunction, ApfsScanBindOneArg, ApfsScanInit); scan_one_arg.named_parameters["since"] = LogicalType::TIMESTAMP; scan_set.AddFunction(scan_one_arg);// 重载 2: apfs_scan(path, mode) → 显式指定模式TableFunction scan_two_args("apfs_scan", {LogicalType::VARCHAR, LogicalType::VARCHAR}, ApfsScanFunction, ApfsScanBindTwoArgs, ApfsScanInit); scan_two_args.named_parameters["since"] = LogicalType::TIMESTAMP; scan_set.AddFunction(scan_two_args); loader.RegisterFunction(scan_set);// 2. 注册 apfs_search(单个 TableFunction)TableFunction search_func("apfs_search", {LogicalType::VARCHAR}, ApfsSearchFunction, ApfsSearchBindWrapper, ApfsSearchInit); loader.RegisterFunction(search_func);// 3. 注册 apfs_has_changes(ScalarFunction) loader.RegisterFunction(CreateApfsHasChangesFunction());}
named_parameters["since"] 的注册至关重要——省略后写 apfs_scan('/', 'spotlight', since=TIMESTAMP '2026-04-01') 会报 Named parameter 'since' not found。位置参数和命名参数的混合设计让 path 必传、mode 通过重载实现可选(单参时默认 fts)、since 作为可选命名参数。
对比 json 扩展的 LoadInternal(duckdb/extension/json/json_extension.cpp,103 行):
staticvoidLoadInternal(ExtensionLoader &loader){// 1. 注册 JSON 类型 loader.RegisterType(LogicalType::JSON_TYPE_NAME, LogicalType::JSON());// 2. 注册类型转换(cast) JSONFunctions::RegisterSimpleCastFunctions(loader); JSONFunctions::RegisterJSONCreateCastFunctions(loader);// 3. 注册标量函数(json_extract, json_type, ...)for (auto &fun : JSONFunctions::GetScalarFunctions()) { loader.RegisterFunction(fun); }// 4. 注册表函数(read_json, read_json_auto, ...)for (auto &fun : JSONFunctions::GetTableFunctions()) { loader.RegisterFunction(fun); }// 5. 注册 pragma 函数// 6. 注册替换扫描(replacement scan)// 7. 注册 copy 函数(COPY TO ... FORMAT JSON)// 8. 注册宏(json_group_array, json_group_object, ...)}
json 扩展注册了类型 + cast + 标量函数 + 表函数 + pragma + copy + macro——几乎用到了 DuckDB 所有的注册 API。apfs 只用了其中两种(表函数 + 标量函数),是最常见的组合。
对比 tpch 扩展的 LoadInternal(duckdb/extension/tpch/tpch_extension.cpp,227 行):
staticvoidLoadInternal(ExtensionLoader &loader){// 1. 注册 dbgen 表函数(生成 TPC-H 测试数据)TableFunction dbgen_func("dbgen", {}, DbgenFunction, DbgenBind); dbgen_func.named_parameters["sf"] = LogicalType::DOUBLE; dbgen_func.named_parameters["overwrite"] = LogicalType::BOOLEAN;// ... 更多命名参数 loader.RegisterFunction(dbgen_func);// 2. 注册 tpch pragma(运行 TPC-H 查询)auto tpch_func = PragmaFunction::PragmaCall("tpch", PragmaTpchQuery, {LogicalType::BIGINT}); loader.RegisterFunction(tpch_func);// 3. 注册 tpch_queries 表函数(返回 22 条查询)// 4. 注册 tpch_answers 表函数(返回查询答案)}
tpch 扩展的结构最清晰——表函数 + pragma,没有复杂的类型系统。apfs 和它最像。
设计 Schema——11 列是怎么定下来的
staticvoidDefineApfsColumns(vector<LogicalType> &return_types, vector<string> &names){ names.emplace_back("path"); return_types.emplace_back(LogicalType::VARCHAR); names.emplace_back("name"); return_types.emplace_back(LogicalType::VARCHAR); names.emplace_back("extension"); return_types.emplace_back(LogicalType::VARCHAR); names.emplace_back("size"); return_types.emplace_back(LogicalType::BIGINT); names.emplace_back("file_type"); return_types.emplace_back(LogicalType::VARCHAR); names.emplace_back("modified_at"); return_types.emplace_back(LogicalType::TIMESTAMP); names.emplace_back("created_at"); return_types.emplace_back(LogicalType::TIMESTAMP); names.emplace_back("accessed_at"); return_types.emplace_back(LogicalType::TIMESTAMP); names.emplace_back("permissions"); return_types.emplace_back(LogicalType::VARCHAR); names.emplace_back("owner"); return_types.emplace_back(LogicalType::VARCHAR); names.emplace_back("depth"); return_types.emplace_back(LogicalType::INTEGER);}
为什么是这 11 列?来自两个数据源的交集:
|
|
|
|
|
|
entry->fts_path |
kMDItemPath |
|
|
entry->fts_name |
kMDItemFSName |
|
|
|
|
|
|
st->st_size |
kMDItemFSSize |
|
|
entry->fts_info |
kMDItemContentType |
|
|
st->st_mtimespec |
kMDItemContentModificationDate |
|
|
st->st_birthtimespec |
kMDItemContentCreationDate |
|
|
st->st_atimespec |
kMDItemLastUsedDate |
|
|
st->st_mode |
lstat() 补充 |
|
|
getpwuid(st->st_uid) |
lstat() 补充 |
|
|
entry->fts_level |
|
设计原则:两种模式返回相同的 schema,用户不需要关心底层用的哪个引擎。permissions 和 owner 在 Spotlight 模式下需要额外的 lstat() 调用——Spotlight 索引不存储这两个字段。
对比 tpch 的 schema:tpch_queries 只有 2 列(query_nr INTEGER, query VARCHAR),因为它返回的是固定的 22 条 SQL 查询文本。schema 设计完全取决于你的数据源。
实现三个函数——从简单到复杂
函数 1:apfs_has_changes——标量函数,最简单
// src/apfs_has_changes.cpp(133 行)ScalarFunction CreateApfsHasChangesFunction(){ScalarFunction func("apfs_has_changes", {LogicalType::VARCHAR, LogicalType::TIMESTAMP}, LogicalType::BOOLEAN, ApfsHasChangesFunction); func.stability = FunctionStability::VOLATILE;return func;}
标量函数不需要 Bind/Init/Execute 三板斧,只要一个执行函数。输入 (path, since_timestamp),输出 BOOLEAN。
对比 demo_capi 的 add_numbers_together(duckdb/extension/demo_capi/add_numbers.cpp,64 行):
// 纯 C API 写法——不用 C++ 类,直接操作底层指针staticvoidAddNumbersTogether(duckdb_function_info info, duckdb_data_chunk input, duckdb_vector output) {idx_t input_size = duckdb_data_chunk_get_size(input); duckdb_vector a = duckdb_data_chunk_get_vector(input, 0); duckdb_vector b = duckdb_data_chunk_get_vector(input, 1);auto a_data = (int64_t *)duckdb_vector_get_data(a);auto b_data = (int64_t *)duckdb_vector_get_data(b);auto result_data = (int64_t *)duckdb_vector_get_data(output);for (idx_t row = 0; row < input_size; row++) { result_data[row] = a_data[row] + b_data[row]; }}
demo_capi 用的是 DuckDB 的 C 稳定 API(duckdb_extension.h),直接操作底层数据指针,性能更高但代码更底层。apfs 用的是 C++ 内部 API(ScalarFunction + Value),更简洁但与 DuckDB 版本强绑定。
函数 2:apfs_scan——表函数,双模式分发
apfs_scan 是最复杂的函数——用 TableFunctionSet 注册两个重载:
// 重载 1: apfs_scan(path) → 默认 fts 模式TableFunction scan_one_arg("apfs_scan", {LogicalType::VARCHAR}, ApfsScanFunction, ApfsScanBindOneArg, ApfsScanInit);// 重载 2: apfs_scan(path, mode) → 显式指定模式TableFunction scan_two_args("apfs_scan", {LogicalType::VARCHAR, LogicalType::VARCHAR}, ApfsScanFunction, ApfsScanBindTwoArgs, ApfsScanInit);
DuckDB 通过参数个数自动选择重载。Bind 层根据 mode 参数创建不同类型的 BindData,Init/Execute 层通过 dynamic_cast 分发到对应引擎。
对比 tpch 的 dbgen 表函数:tpch 没有重载,全部用命名参数:
TableFunction dbgen_func("dbgen", {}, DbgenFunction, DbgenBind);dbgen_func.named_parameters["sf"] = LogicalType::DOUBLE;dbgen_func.named_parameters["overwrite"] = LogicalType::BOOLEAN;dbgen_func.named_parameters["catalog"] = LogicalType::VARCHAR;
dbgen 的参数列表是空的 {}——所有参数都是命名参数。apfs_scan 选择了位置参数 + 命名参数的混合方式:path 和 mode 是位置参数(必须传),since 是命名参数(可选)。
函数 3:apfs_search——表函数,Spotlight 专用
TableFunction search_func("apfs_search", {LogicalType::VARCHAR}, ApfsSearchFunction, ApfsSearchBindWrapper, ApfsSearchInit);
apfs_search 只走 Spotlight,不支持 fts 模式。它的 Bind 函数复用了 DefineApfsColumns 定义相同的 11 列 schema,但 Init 和 Execute 是独立的实现。
5 个源文件的职责划分
|
|
|
|
apfs_extension.cpp |
|
|
apfs_fts_scanner.cpp |
|
|
apfs_spotlight_scanner.cpp |
|
|
apfs_has_changes.cpp |
|
|
apfs_utils.cpp |
|
|
Spotlight 引擎比 fts 引擎大一倍——因为要处理 Core Foundation 的内存管理和类型转换(每个 MDItemCopyAttribute 返回值都要 CFRelease)。
对比 json 扩展的文件组织:json 扩展有 14+ 源文件,按功能域拆分(json_scan.cpp、json_reader.cpp、json_functions.cpp 等)。apfs 的 5 个文件按”引擎”拆分——入口、fts 引擎、spotlight 引擎、标量函数、工具函数。
对比 parquet 扩展的文件组织:parquet 扩展有 20+ 源文件,按 reader/writer/decoder 分层。这是因为 Parquet 格式本身就很复杂(列式存储、嵌套类型、压缩编码)。apfs 的数据模型简单(11 列扁平结构),不需要这么多层次。
DuckDB 官方核心插件巡览
DuckDB v1.5.2 源码 extension/ 目录下共 10 个内置扩展目录(外加一个非扩展的 loader/ 辅助目录,不计入)。以下是它们的对比:
|
|
|
|
|
| core_functions |
|
|
|
| parquet |
|
|
|
| json |
|
|
|
| icu |
|
|
|
| tpch |
|
|
|
| tpcds |
|
|
|
| demo_capi |
|
|
唯一的 C API 示例 |
| delta |
|
|
delta-kernel-rs 库 |
| autocomplete |
|
|
|
| jemalloc |
|
|
|
DuckDB 的 extension/extension_config.cmake 默认至少启用 2 个扩展,另有 1 个按平台条件启用:
# 每次构建都会包含duckdb_extension_load(core_functions)duckdb_extension_load(parquet)# 仅 Linux x86_64 默认启用(Apple Silicon / Windows 等不启用)if(CMAKE_SIZEOF_VOID_P EQUAL8AND ... AND OS_NAME STREQUAL"linux" ...) duckdb_extension_load(jemalloc)endif()
其他扩展(json、icu、tpch、autocomplete 等)需要通过 DUCKDB_EXTENSIONS 变量或配置文件显式启用。
三种扩展 API 风格
|
|
|
|
|
|
|
|
DUCKDB_CPP_EXTENSION_ENTRY |
|
|
|
|
|
DUCKDB_EXTENSION_ENTRYPOINT |
|
|
|
|
|
|
|
|
|
apfs 选择了 C++ 内部 API——因为它是和特定版本的 DuckDB 一起编译分发的(通过 git submodule 锁定版本),不需要跨版本兼容。
C++ 内部 API「版本绑定」的真实代价:DuckDB 的 C++ 内部 API 会在跨版本演进时重命名/移除符号。本文作者在 v1.5.2 编译时就曾遇到:某些历史教程写作 FlatVector::GetDataMutable<T>(result) 以获取非 const 可写指针,但 v1.5.2 已将这个模板方法整合回 FlatVector::GetData<T>(Vector &) 的非 const 重载(src/include/duckdb/common/types/vector.hpp:370)。使用旧写法会直接编译失败:
error: no member named 'GetDataMutable' in 'duckdb::FlatVector'
这就是 C++ 内部 API 的脆弱性——社区扩展只能通过 duckdb/ submodule 锁死版本解决。参见官方的 Extension Versioning。如果你的扩展想要跨多个 DuckDB 版本分发,只能选 C 稳定 API。
apfs 插件的设计决策复盘
为什么选双引擎(fts + spotlight)?
不是”二选一”而是”互补”:
|
|
|
|
/tmp |
|
|
.git/ 内容 |
|
|
|
|
|
|
|
|
|
|
为什么用 TableFunctionSet 重载而不是全用命名参数?
apfs_scan('/tmp') 比 apfs_scan(path='/tmp') 更自然。path 是必须的,mode 有默认值(fts),since 是可选的——位置参数 + 命名参数的混合方式最符合使用习惯。
为什么 apfs_has_changes 是标量函数而不是表函数?
因为它只返回一个 boolean 值——”有没有变更”。不需要返回变更的文件列表。标量函数可以在 WHERE 子句中使用:
-- 只在有变更时才重新扫描SELECT*FROM apfs_scan('/tmp', 'fts')WHERE apfs_has_changes('/tmp', TIMESTAMP'2026-04-01');
和向量扩展组合——做本地 RAG
apfs 扩展本身只做「文件元数据扫描」,但 DuckDB 生态里还有 vss(向量相似搜索)、fts(全文搜索)等扩展。把 apfs 作为数据采集层,三者组合就能在本地做一个轻量 RAG 管道:
-- 1. 用 apfs 扫描:拿到路径 + 修改时间 + 大小CREATE TABLE files ASSELECT*FROM apfs_scan('/Users/me/docs');-- 2. 只读文本类文件(交给外部脚本/扩展做内容抽取)-- 3. 在 files 表上建 FTS(用 DuckDB 的 fts 扩展)或 vss 向量索引PRAGMA create_fts_index('files', 'path', 'name');-- 4. 增量更新:只重新 embed 变更过的文件SELECT path FROM filesWHERE apfs_has_changes(path, last_embed_time);
11 列 schema 的 AI 用途:
|
|
|
|
path
name / extension |
|
|
size |
|
|
modified_at
created_at |
|
|
file_type |
|
public.pdf 一类 |
owner
permissions |
|
|
这也是 apfs 和 apfs_has_changes 为什么要标量化的理由之一——可以在 SQL 的 WHERE 里做增量 Embedding 更新,这是 RAG 管道的高频需求。
总结
从 0 到 1 构建一个 DuckDB 插件,核心步骤:
-
1. 从 extension-template 开始——改名 + 配置 CMake -
2. 设计 Schema——根据数据源能提供什么来定义输出列 -
3. 实现函数——标量函数最简单,表函数需要 Bind/Init/Execute 三板斧 -
4. 写 LoadInternal——注册所有函数的总入口 -
5. 文件组织——按引擎/功能域拆分,入口文件只做注册和分发
apfs 扩展的代码量(~1200 行 C++)在 DuckDB 扩展生态中属于中等偏小。json 扩展有数千行,parquet 扩展更多。但核心模式是一样的——LoadInternal 注册函数,Bind/Init/Execute 处理数据。
下一篇拆解 DuckDB 表函数的 Bind / Init / Execute 三板斧——apfs 插件的每个函数是怎么注册和执行的。
参考资料
-
• mac-everything -
• duckdb-apfs -
• DuckDB Extension Template -
• DuckDB 官方文档 – Extensions -
• DuckDB 源码: extension/README.md(扩展类型和构建方式说明) -
• DuckDB 源码: extension/json/json_extension.cpp(103 行,json 扩展入口) -
• DuckDB 源码: extension/tpch/tpch_extension.cpp(227 行,tpch 扩展入口) -
• DuckDB 源码: extension/demo_capi/add_numbers.cpp(64 行,C API 标量函数示例) -
• DuckDB 源码: extension/demo_capi/capi_demo.cpp(23 行,C API 扩展入口) -
• DuckDB 源码: extension/extension_config.cmake(默认启用 core_functions + parquet) -
• duckdb-apfs 源码: src/apfs_extension.cpp(180 行,完整入口代码)
夜雨聆风